From aa96902664d3d582c65390f1905029a7dc809795 Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 26 Dec 2025 17:07:06 -0500 Subject: [PATCH] Update README and main plugin logic for improved menu processing - Enhanced README.md with build, development, and testing instructions. - Refactored main.js to separate menu block processing into a dedicated method. - Improved error handling during plugin loading. - Streamlined link processing for internal, external, and file links. - Added support for multi-line dataview queries. - Updated manifest.json version to 1.4. --- README.md | 65 +++++++ main.js | 471 ++++++++++++++++++++++---------------------- main.ts | 527 +++++++++++++++++++++++++------------------------- manifest.json | 2 +- 4 files changed, 574 insertions(+), 491 deletions(-) diff --git a/README.md b/README.md index ce241ea..c23601a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,71 @@ You can: CSS stays clean and predictable. +## Build & Development + +### Prerequisites +- Node.js 16+ and npm +- Obsidian installed for testing + +### Setup + +1. Clone or navigate to the plugin directory: +```bash +cd obsidian-menus +``` + +2. Install dependencies: +```bash +npm install +``` + +### Building + +Build the plugin: +```bash +npm run dev +``` + +This runs esbuild in watch mode, automatically rebuilding whenever you make changes. + +For a production build: +```bash +npm run build +``` + +### Testing Locally + +1. Copy the built plugin to your Obsidian test vault: +```bash +# On Windows: +copy main.js %APPDATA%\Obsidian\.obsidian\plugins\obsidian-menus\ +copy manifest.json %APPDATA%\Obsidian\.obsidian\plugins\obsidian-menus\ +copy styles.css %APPDATA%\Obsidian\.obsidian\plugins\obsidian-menus\ + +# On macOS: +cp main.js ~/Library/Application\ Support/Obsidian/.obsidian/plugins/obsidian-menus/ +cp manifest.json ~/Library/Application\ Support/Obsidian/.obsidian/plugins/obsidian-menus/ +cp styles.css ~/Library/Application\ Support/Obsidian/.obsidian/plugins/obsidian-menus/ + +# On Linux: +cp main.js ~/.config/Obsidian/.obsidian/plugins/obsidian-menus/ +cp manifest.json ~/.config/Obsidian/.obsidian/plugins/obsidian-menus/ +cp styles.css ~/.config/Obsidian/.obsidian/plugins/obsidian-menus/ +``` + +2. Reload the plugin in Obsidian: Settings → Community Plugins → Reload (or restart Obsidian). + +### Publishing (via BRAT or Community) + +**For BRAT testing:** +1. Push your changes to GitHub +2. Update `manifest.json` with the new version +3. Create a release on GitHub with the version tag +4. Users can add your repo URL to BRAT: `https://github.com/YOUR_USERNAME/obsidian-menus` + +**For Community Plugin:** +1. Submit a PR to the [Obsidian Sample Plugin](https://github.com/obsidianmd/obsidian-sample-plugin) repository with your manifest. + ## Basic Usage Create a menu using a `menu` code block with one of the built-in templates: diff --git a/main.js b/main.js index 9978063..9ed08fd 100644 --- a/main.js +++ b/main.js @@ -33,237 +33,16 @@ var DEFAULT_SETTINGS = { }; var MenuPlugin = class extends import_obsidian.Plugin { async onload() { - await this.loadSettings(); - this.addSettingTab(new MenuPluginSettingTab(this.app, this)); - this.registerMarkdownCodeBlockProcessor("menu", (source, el, ctx) => { - const lines = source.trim().split("\n"); - let layoutOrClass = ""; - let colors = {}; - const links = []; - let dataviewQuery = ""; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("layout:") || trimmed.startsWith("class:")) { - const colonIndex = trimmed.indexOf(":"); - layoutOrClass = trimmed.substring(colonIndex + 1).trim(); - } else if (trimmed.startsWith("dataview:") || trimmed.startsWith("dv:")) { - const colonIndex = trimmed.indexOf(":"); - dataviewQuery = trimmed.substring(colonIndex + 1).trim(); - } else if (trimmed.includes(":") && !trimmed.startsWith("[") && !trimmed.startsWith("[[") && !dataviewQuery) { - const [key, ...valueParts] = trimmed.split(":"); - const value = valueParts.join(":").trim(); - if (key && value && !key.includes("//") && !key.includes("http")) { - colors[key.trim()] = value; - } - } else if (trimmed && !trimmed.includes(":") && !dataviewQuery) { - links.push(trimmed); - } else if (trimmed.startsWith("[") && !dataviewQuery) { - links.push(trimmed); - } else if (dataviewQuery) { - dataviewQuery += "\n" + line; - } - } - layoutOrClass = ""; - colors = {}; - links.length = 0; - dataviewQuery = ""; - let isDataviewBlock = false; - for (const line of lines) { - const trimmed = line.trim(); - if (isDataviewBlock) { - dataviewQuery += "\n" + line; - continue; - } - if (trimmed.startsWith("layout:") || trimmed.startsWith("class:")) { - const colonIndex = trimmed.indexOf(":"); - layoutOrClass = trimmed.substring(colonIndex + 1).trim(); - } else if (trimmed.startsWith("dataview:") || trimmed.startsWith("dv:")) { - const colonIndex = trimmed.indexOf(":"); - dataviewQuery = trimmed.substring(colonIndex + 1).trim(); - isDataviewBlock = true; - } else if (trimmed.includes(":") && !trimmed.startsWith("[") && !trimmed.startsWith("[[")) { - const [key, ...valueParts] = trimmed.split(":"); - const value = valueParts.join(":").trim(); - if (key && value && !key.includes("//") && !key.includes("http")) { - colors[key.trim()] = value; - } - } else if (trimmed) { - links.push(trimmed); - } - } - const builtInLayouts = /* @__PURE__ */ new Set(["default", "minimal", "slate", "horizon", "aether"]); - const container = el.createEl("div", { cls: "menu-container" }); - let selectedLayout = ""; - let extraClasses = []; - if (layoutOrClass) { - const tokens = layoutOrClass.split(/\s+/).filter(Boolean); - if (tokens.length) { - const builtInIndex = tokens.findIndex((t) => builtInLayouts.has(t)); - if (builtInIndex !== -1) { - selectedLayout = tokens[builtInIndex]; - extraClasses = tokens.filter((_, i) => i !== builtInIndex); - } else { - extraClasses = tokens; - } - } - } else { - selectedLayout = "default"; - } - if (selectedLayout) { - container.setAttr("data-layout", selectedLayout); - } - for (const cls of extraClasses) { - container.addClass(cls); - } - if (Object.keys(colors).length > 0) { - const baseKeys = /* @__PURE__ */ new Set([ - "bg", - "text", - "border", - "font", - "hover-text", - "hover-bg", - "hover-border", - "hover-font" - ]); - const normalizeKey = (raw) => { - let s = raw.trim().toLowerCase(); - s = s.replace(/\btext-hover\b/g, "hover-text").replace(/\bbg-hover\b/g, "hover-bg").replace(/\bborder-hover\b/g, "hover-border").replace(/\binternal-text-hover\b/g, "internal-hover-text").replace(/\binternal-bg-hover\b/g, "internal-hover-bg").replace(/\binternal-border-hover\b/g, "internal-hover-border").replace(/\bexternal-text-hover\b/g, "external-hover-text").replace(/\bexternal-bg-hover\b/g, "external-hover-bg").replace(/\bexternal-border-hover\b/g, "external-hover-border").replace(/\bfile-text-hover\b/g, "file-hover-text").replace(/\bfile-bg-hover\b/g, "file-hover-bg").replace(/\bfile-border-hover\b/g, "file-hover-border").replace(/\baccent\b/g, "hover-text").replace(/\binternal-accent\b/g, "internal-hover-text").replace(/\bexternal-accent\b/g, "external-hover-text").replace(/\bfile-accent\b/g, "file-hover-text").replace(/\bbackground\b/g, "bg"); - return s; - }; - const isAllowed = (key) => { - if (baseKeys.has(key)) - return true; - const m = key.match(/^(internal|external|file)-(.*)$/); - return !!(m && baseKeys.has(m[2])); - }; - for (const [rawKey, value] of Object.entries(colors)) { - const key = normalizeKey(rawKey); - if (!isAllowed(key)) - continue; - container.style.setProperty(`--${key}`, value); - } - } - const applyInlineBaseStyles = (a, variant) => { - const prefix = variant === "generic" ? "" : `${variant}-`; - const get = (k) => { - var _a; - return (_a = colors[`${prefix}${k}`]) != null ? _a : colors[k]; - }; - const bgVal = get("bg"); - if (bgVal) - a.style.background = bgVal; - const textVal = get("text"); - if (textVal) - a.style.color = textVal; - const borderVal = get("border"); - if (borderVal) - a.style.borderColor = borderVal; - const fontVal = get("font"); - if (fontVal) - a.style.fontFamily = fontVal; - const hoverKeys = ["hover-bg", "hover-text", "hover-border", "hover-font"]; - for (const hk of hoverKeys) { - const v = get(hk); - if (v) - a.style.setProperty(`--${hk}`, v); - } - }; - for (const link of links) { - if (link.startsWith("[[") && link.endsWith("]]")) { - const linkContent = link.slice(2, -2); - let href = linkContent; - let text = linkContent; - if (linkContent.includes("|")) { - [href, text] = linkContent.split("|"); - } - const a = container.createEl("a", { - text, - attr: { "data-href": href } - }); - a.addClass("menu-internal-link"); - if (!selectedLayout) - applyInlineBaseStyles(a, "internal"); - a.style.cursor = "pointer"; - a.addEventListener("click", (e) => { - e.preventDefault(); - this.app.workspace.openLinkText(href, ctx.sourcePath, false); - }); - } else if (link.match(/^\[.*\]\(.*\)$/)) { - const match = link.match(/^\[(.*)\]\((.*)\)$/); - if (match) { - const text = match[1]; - const url = match[2]; - const a = container.createEl("a", { - text, - attr: url.startsWith("file://") ? {} : { href: url, target: "_blank", rel: "noopener noreferrer" } - }); - a.style.cursor = "pointer"; - if (url.startsWith("file://")) { - a.addClass("menu-file-link"); - if (!selectedLayout) - applyInlineBaseStyles(a, "file"); - } else { - a.addClass("menu-external-link"); - if (!selectedLayout) - applyInlineBaseStyles(a, "external"); - } - a.addEventListener("click", (e) => { - e.preventDefault(); - if (url.startsWith("file://")) { - if (import_obsidian.Platform.isDesktop) { - try { - const { shell } = require("electron"); - let filePath = decodeURIComponent(url.substring(7)); - if (filePath.startsWith("/") && filePath.charAt(2) === ":") { - filePath = filePath.substring(1); - } - shell.openPath(filePath); - } catch (error) { - console.error("Failed to open file:", error); - } - } else { - console.warn("File links are not supported on mobile."); - } - } else { - window.open(url, "_blank", "noopener,noreferrer"); - } - }); - } - } - } - if (dataviewQuery) { - const dvContainer = container.createDiv({ cls: "menu-dataview-container" }); - import_obsidian.MarkdownRenderer.render( - this.app, - `\`\`\`dataview -${dataviewQuery} -\`\`\``, - dvContainer, - ctx.sourcePath, - this - ); - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.type === "childList") { - const links2 = dvContainer.querySelectorAll("a"); - links2.forEach((link) => { - if (link.hasClass("internal-link")) { - link.addClass("menu-internal-link"); - if (!selectedLayout) - applyInlineBaseStyles(link, "internal"); - } else { - link.addClass("menu-external-link"); - if (!selectedLayout) - applyInlineBaseStyles(link, "external"); - } - }); - } - } - }); - observer.observe(dvContainer, { childList: true, subtree: true }); - } - }); + try { + await this.loadSettings(); + this.addSettingTab(new MenuPluginSettingTab(this.app, this)); + this.registerMarkdownCodeBlockProcessor("menu", (source, el, ctx) => { + this.processMenuBlock(source, el, ctx); + }); + } catch (error) { + console.error("[obsidian-menus] Failed to load plugin:", error); + throw error; + } } onunload() { } @@ -273,6 +52,236 @@ ${dataviewQuery} async saveSettings() { await this.saveData(this.settings); } + processMenuBlock(source, el, ctx) { + const lines = source.trim().split("\n"); + let layoutOrClass = ""; + let colors = {}; + const links = []; + let dataviewQuery = ""; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("layout:") || trimmed.startsWith("class:")) { + const colonIndex = trimmed.indexOf(":"); + layoutOrClass = trimmed.substring(colonIndex + 1).trim(); + } else if (trimmed.startsWith("dataview:") || trimmed.startsWith("dv:")) { + const colonIndex = trimmed.indexOf(":"); + dataviewQuery = trimmed.substring(colonIndex + 1).trim(); + } else if (trimmed.includes(":") && !trimmed.startsWith("[") && !trimmed.startsWith("[[") && !dataviewQuery) { + const [key, ...valueParts] = trimmed.split(":"); + const value = valueParts.join(":").trim(); + if (key && value && !key.includes("//") && !key.includes("http")) { + colors[key.trim()] = value; + } + } else if (trimmed && !trimmed.includes(":") && !dataviewQuery) { + links.push(trimmed); + } else if (trimmed.startsWith("[") && !dataviewQuery) { + links.push(trimmed); + } else if (dataviewQuery) { + dataviewQuery += "\n" + line; + } + } + layoutOrClass = ""; + colors = {}; + links.length = 0; + dataviewQuery = ""; + let isDataviewBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (isDataviewBlock) { + dataviewQuery += "\n" + line; + continue; + } + if (trimmed.startsWith("layout:") || trimmed.startsWith("class:")) { + const colonIndex = trimmed.indexOf(":"); + layoutOrClass = trimmed.substring(colonIndex + 1).trim(); + } else if (trimmed.startsWith("dataview:") || trimmed.startsWith("dv:")) { + const colonIndex = trimmed.indexOf(":"); + dataviewQuery = trimmed.substring(colonIndex + 1).trim(); + isDataviewBlock = true; + } else if (trimmed.includes(":") && !trimmed.startsWith("[") && !trimmed.startsWith("[[")) { + const [key, ...valueParts] = trimmed.split(":"); + const value = valueParts.join(":").trim(); + if (key && value && !key.includes("//") && !key.includes("http")) { + colors[key.trim()] = value; + } + } else if (trimmed) { + links.push(trimmed); + } + } + const builtInLayouts = /* @__PURE__ */ new Set(["default", "minimal", "slate", "horizon", "aether"]); + const container = el.createEl("div", { cls: "menu-container" }); + let selectedLayout = ""; + let extraClasses = []; + if (layoutOrClass) { + const tokens = layoutOrClass.split(/\s+/).filter(Boolean); + if (tokens.length) { + const builtInIndex = tokens.findIndex((t) => builtInLayouts.has(t)); + if (builtInIndex !== -1) { + selectedLayout = tokens[builtInIndex]; + extraClasses = tokens.filter((_, i) => i !== builtInIndex); + } else { + extraClasses = tokens; + } + } + } else { + selectedLayout = "default"; + } + if (selectedLayout) { + container.setAttr("data-layout", selectedLayout); + } + for (const cls of extraClasses) { + container.addClass(cls); + } + if (Object.keys(colors).length > 0) { + const baseKeys = /* @__PURE__ */ new Set([ + "bg", + "text", + "border", + "font", + "hover-text", + "hover-bg", + "hover-border", + "hover-font" + ]); + const normalizeKey = (raw) => { + let s = raw.trim().toLowerCase(); + s = s.replace(/\btext-hover\b/g, "hover-text").replace(/\bbg-hover\b/g, "hover-bg").replace(/\bborder-hover\b/g, "hover-border").replace(/\binternal-text-hover\b/g, "internal-hover-text").replace(/\binternal-bg-hover\b/g, "internal-hover-bg").replace(/\binternal-border-hover\b/g, "internal-hover-border").replace(/\bexternal-text-hover\b/g, "external-hover-text").replace(/\bexternal-bg-hover\b/g, "external-hover-bg").replace(/\bexternal-border-hover\b/g, "external-hover-border").replace(/\bfile-text-hover\b/g, "file-hover-text").replace(/\bfile-bg-hover\b/g, "file-hover-bg").replace(/\bfile-border-hover\b/g, "file-hover-border").replace(/\baccent\b/g, "hover-text").replace(/\binternal-accent\b/g, "internal-hover-text").replace(/\bexternal-accent\b/g, "external-hover-text").replace(/\bfile-accent\b/g, "file-hover-text").replace(/\bbackground\b/g, "bg"); + return s; + }; + const isAllowed = (key) => { + if (baseKeys.has(key)) + return true; + const m = key.match(/^(internal|external|file)-(.*)$/); + return !!(m && baseKeys.has(m[2])); + }; + for (const [rawKey, value] of Object.entries(colors)) { + const key = normalizeKey(rawKey); + if (!isAllowed(key)) + continue; + container.style.setProperty(`--${key}`, value); + } + } + const applyInlineBaseStyles = (a, variant) => { + const prefix = variant === "generic" ? "" : `${variant}-`; + const get = (k) => { + var _a; + return (_a = colors[`${prefix}${k}`]) != null ? _a : colors[k]; + }; + const bgVal = get("bg"); + if (bgVal) + a.style.background = bgVal; + const textVal = get("text"); + if (textVal) + a.style.color = textVal; + const borderVal = get("border"); + if (borderVal) + a.style.borderColor = borderVal; + const fontVal = get("font"); + if (fontVal) + a.style.fontFamily = fontVal; + const hoverKeys = ["hover-bg", "hover-text", "hover-border", "hover-font"]; + for (const hk of hoverKeys) { + const v = get(hk); + if (v) + a.style.setProperty(`--${hk}`, v); + } + }; + for (const link of links) { + if (link.startsWith("[[") && link.endsWith("]]")) { + const linkContent = link.slice(2, -2); + let href = linkContent; + let text = linkContent; + if (linkContent.includes("|")) { + [href, text] = linkContent.split("|"); + } + const a = container.createEl("a", { + text, + attr: { "data-href": href } + }); + a.addClass("menu-internal-link"); + if (!selectedLayout) + applyInlineBaseStyles(a, "internal"); + a.style.cursor = "pointer"; + a.addEventListener("click", (e) => { + e.preventDefault(); + this.app.workspace.openLinkText(href, ctx.sourcePath, false); + }); + } else if (link.match(/^\[.*\]\(.*\)$/)) { + const match = link.match(/^\[(.*)\]\((.*)\)$/); + if (match) { + const text = match[1]; + const url = match[2]; + const a = container.createEl("a", { + text, + attr: url.startsWith("file://") ? {} : { href: url, target: "_blank", rel: "noopener noreferrer" } + }); + a.style.cursor = "pointer"; + if (url.startsWith("file://")) { + a.addClass("menu-file-link"); + if (!selectedLayout) + applyInlineBaseStyles(a, "file"); + } else { + a.addClass("menu-external-link"); + if (!selectedLayout) + applyInlineBaseStyles(a, "external"); + } + a.addEventListener("click", (e) => { + e.preventDefault(); + if (url.startsWith("file://")) { + if (import_obsidian.Platform.isDesktop) { + try { + const electronPath = "electron"; + const { shell } = require(electronPath); + let filePath = decodeURIComponent(url.substring(7)); + if (filePath.startsWith("/") && filePath.charAt(2) === ":") { + filePath = filePath.substring(1); + } + shell.openPath(filePath); + } catch (error) { + console.error("Failed to open file:", error); + } + } else { + console.warn("File links are not supported on mobile."); + } + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } + }); + } + } + } + if (dataviewQuery) { + const dvContainer = container.createDiv({ cls: "menu-dataview-container" }); + import_obsidian.MarkdownRenderer.render( + this.app, + `\`\`\`dataview +${dataviewQuery} +\`\`\``, + dvContainer, + ctx.sourcePath, + this + ); + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "childList") { + const links2 = dvContainer.querySelectorAll("a"); + links2.forEach((link) => { + if (link.hasClass("internal-link")) { + link.addClass("menu-internal-link"); + if (!selectedLayout) + applyInlineBaseStyles(link, "internal"); + } else { + link.addClass("menu-external-link"); + if (!selectedLayout) + applyInlineBaseStyles(link, "external"); + } + }); + } + } + }); + observer.observe(dvContainer, { childList: true, subtree: true }); + } + } }; var MenuPluginSettingTab = class extends import_obsidian.PluginSettingTab { constructor(app, plugin) { diff --git a/main.ts b/main.ts index cca8d53..b34016b 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,4 @@ -import { App, Plugin, PluginSettingTab, Setting, MarkdownRenderer, Platform } from 'obsidian'; +import { App, Plugin, PluginSettingTab, Setting, MarkdownRenderer, Platform, MarkdownPostProcessorContext } from 'obsidian'; interface MenuPluginSettings { mySetting: string; @@ -12,266 +12,19 @@ export default class MenuPlugin extends Plugin { settings: MenuPluginSettings; async onload() { - await this.loadSettings(); + try { + await this.loadSettings(); - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new MenuPluginSettingTab(this.app, this)); + // This adds a settings tab so the user can configure various aspects of the plugin + this.addSettingTab(new MenuPluginSettingTab(this.app, this)); - this.registerMarkdownCodeBlockProcessor('menu', (source, el, ctx) => { - const lines = source.trim().split('\n'); - let layoutOrClass = ''; - let colors: Record = {}; - const links: string[] = []; - let dataviewQuery = ''; - - // Parse YAML-like properties and links - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith('layout:') || trimmed.startsWith('class:')) { - const colonIndex = trimmed.indexOf(':'); - layoutOrClass = trimmed.substring(colonIndex + 1).trim(); - } else if (trimmed.startsWith('dataview:') || trimmed.startsWith('dv:')) { - const colonIndex = trimmed.indexOf(':'); - dataviewQuery = trimmed.substring(colonIndex + 1).trim(); - } else if (trimmed.includes(':') && !trimmed.startsWith('[') && !trimmed.startsWith('[[') && !dataviewQuery) { - // Parse color properties - const [key, ...valueParts] = trimmed.split(':'); - const value = valueParts.join(':').trim(); - if (key && value && !key.includes('//') && !key.includes('http')) { - colors[key.trim()] = value; - } - } else if (trimmed && !trimmed.includes(':') && !dataviewQuery) { - links.push(trimmed); - } else if (trimmed.startsWith('[') && !dataviewQuery) { - links.push(trimmed); - } else if (dataviewQuery) { - // If we already found a dataview start, append subsequent lines to it - // This handles multi-line queries if the user didn't put it all on one line - // But actually, the simple parsing above assumes one line per property. - // For complex queries, we might need a better parser. - // For now, let's assume the query might be multi-line if it started with dv: - // But the loop iterates lines. - // Let's adjust: if we are in "dataview mode", just add to query. - dataviewQuery += '\n' + line; - } - } - - // Re-parsing to handle multi-line dataview queries correctly - // The previous loop was a bit naive for multi-line. Let's do a cleaner pass. - layoutOrClass = ''; - colors = {}; - links.length = 0; - dataviewQuery = ''; - let isDataviewBlock = false; - - for (const line of lines) { - const trimmed = line.trim(); - if (isDataviewBlock) { - dataviewQuery += '\n' + line; - continue; - } - - if (trimmed.startsWith('layout:') || trimmed.startsWith('class:')) { - const colonIndex = trimmed.indexOf(':'); - layoutOrClass = trimmed.substring(colonIndex + 1).trim(); - } else if (trimmed.startsWith('dataview:') || trimmed.startsWith('dv:')) { - const colonIndex = trimmed.indexOf(':'); - dataviewQuery = trimmed.substring(colonIndex + 1).trim(); - isDataviewBlock = true; // Assume rest of block is the query - } else if (trimmed.includes(':') && !trimmed.startsWith('[') && !trimmed.startsWith('[[')) { - const [key, ...valueParts] = trimmed.split(':'); - const value = valueParts.join(':').trim(); - if (key && value && !key.includes('//') && !key.includes('http')) { - colors[key.trim()] = value; - } - } else if (trimmed) { - links.push(trimmed); - } - } - - // Determine layout and classes - const builtInLayouts = new Set(['default', 'minimal', 'slate', 'horizon', 'aether']); - const container = el.createEl('div', { cls: 'menu-container' }); - - let selectedLayout = ''; - let extraClasses: string[] = []; - - if (layoutOrClass) { - const tokens = layoutOrClass.split(/\s+/).filter(Boolean); - if (tokens.length) { - const builtInIndex = tokens.findIndex(t => builtInLayouts.has(t)); - if (builtInIndex !== -1) { - selectedLayout = tokens[builtInIndex]; - extraClasses = tokens.filter((_, i) => i !== builtInIndex); - } else { - extraClasses = tokens; - } - } - } else { - selectedLayout = 'default'; - } - - if (selectedLayout) { - container.setAttr('data-layout', selectedLayout); - } - - for (const cls of extraClasses) { - container.addClass(cls); - } - - // Apply custom properties - if (Object.keys(colors).length > 0) { - const baseKeys = new Set([ - 'bg', 'text', 'border', 'font', - 'hover-text', 'hover-bg', 'hover-border', 'hover-font', - ]); - const normalizeKey = (raw: string) => { - let s = raw.trim().toLowerCase(); - s = s - .replace(/\btext-hover\b/g, 'hover-text') - .replace(/\bbg-hover\b/g, 'hover-bg') - .replace(/\bborder-hover\b/g, 'hover-border') - .replace(/\binternal-text-hover\b/g, 'internal-hover-text') - .replace(/\binternal-bg-hover\b/g, 'internal-hover-bg') - .replace(/\binternal-border-hover\b/g, 'internal-hover-border') - .replace(/\bexternal-text-hover\b/g, 'external-hover-text') - .replace(/\bexternal-bg-hover\b/g, 'external-hover-bg') - .replace(/\bexternal-border-hover\b/g, 'external-hover-border') - .replace(/\bfile-text-hover\b/g, 'file-hover-text') - .replace(/\bfile-bg-hover\b/g, 'file-hover-bg') - .replace(/\bfile-border-hover\b/g, 'file-hover-border') - .replace(/\baccent\b/g, 'hover-text') - .replace(/\binternal-accent\b/g, 'internal-hover-text') - .replace(/\bexternal-accent\b/g, 'external-hover-text') - .replace(/\bfile-accent\b/g, 'file-hover-text') - .replace(/\bbackground\b/g, 'bg'); - return s; - }; - const isAllowed = (key: string) => { - if (baseKeys.has(key)) return true; - const m = key.match(/^(internal|external|file)-(.*)$/); - return !!(m && baseKeys.has(m[2])); - }; - for (const [rawKey, value] of Object.entries(colors)) { - const key = normalizeKey(rawKey); - if (!isAllowed(key)) continue; - container.style.setProperty(`--${key}`, value); - } - } - - const applyInlineBaseStyles = (a: HTMLElement, variant: 'internal' | 'external' | 'file' | 'generic') => { - const prefix = variant === 'generic' ? '' : `${variant}-`; - const get = (k: string) => (colors[`${prefix}${k}`] ?? colors[k]); - const bgVal = get('bg'); if (bgVal) a.style.background = bgVal as string; - const textVal = get('text'); if (textVal) a.style.color = textVal as string; - const borderVal = get('border'); if (borderVal) a.style.borderColor = borderVal as string; - const fontVal = get('font'); if (fontVal) a.style.fontFamily = fontVal as string; - const hoverKeys = ['hover-bg', 'hover-text', 'hover-border', 'hover-font']; - for (const hk of hoverKeys) { - const v = get(hk); - if (v) a.style.setProperty(`--${hk}`, v as string); - } - }; - - // Process regular links - for (const link of links) { - if (link.startsWith('[[') && link.endsWith(']]')) { - const linkContent = link.slice(2, -2); - let href = linkContent; - let text = linkContent; - if (linkContent.includes('|')) { - [href, text] = linkContent.split('|'); - } - const a = container.createEl('a', { - text: text, - attr: { 'data-href': href } - }); - a.addClass('menu-internal-link'); - if (!selectedLayout) applyInlineBaseStyles(a, 'internal'); - a.style.cursor = 'pointer'; - a.addEventListener('click', (e) => { - e.preventDefault(); - this.app.workspace.openLinkText(href, ctx.sourcePath, false); - }); - } else if (link.match(/^\[.*\]\(.*\)$/)) { - const match = link.match(/^\[(.*)\]\((.*)\)$/); - if (match) { - const text = match[1]; - const url = match[2]; - const a = container.createEl('a', { - text: text, - attr: url.startsWith('file://') ? {} : { href: url, target: '_blank', rel: 'noopener noreferrer' } - }); - a.style.cursor = 'pointer'; - - if (url.startsWith('file://')) { - a.addClass('menu-file-link'); - if (!selectedLayout) applyInlineBaseStyles(a, 'file'); - } else { - a.addClass('menu-external-link'); - if (!selectedLayout) applyInlineBaseStyles(a, 'external'); - } - a.addEventListener('click', (e) => { - e.preventDefault(); - if (url.startsWith('file://')) { - if (Platform.isDesktop) { - try { - const { shell } = require('electron'); - let filePath = decodeURIComponent(url.substring(7)); - if (filePath.startsWith('/') && filePath.charAt(2) === ':') { - filePath = filePath.substring(1); - } - shell.openPath(filePath); - } catch (error) { - console.error('Failed to open file:', error); - } - } else { - console.warn('File links are not supported on mobile.'); - // Optionally show a notice to the user - // new Notice('File links are not supported on this device.'); - } - } else { - window.open(url, '_blank', 'noopener,noreferrer'); - } - }); - } - } - } - - // Process Dataview Query - if (dataviewQuery) { - const dvContainer = container.createDiv({ cls: 'menu-dataview-container' }); - // Render the dataview query - // We wrap it in ```dataview ... ``` so Obsidian's renderer handles it - MarkdownRenderer.render( - this.app, - `\`\`\`dataview\n${dataviewQuery}\n\`\`\``, - dvContainer, - ctx.sourcePath, - this - ); - - // MutationObserver to style links once they render - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.type === 'childList') { - const links = dvContainer.querySelectorAll('a'); - links.forEach((link: HTMLElement) => { - if (link.hasClass('internal-link')) { - link.addClass('menu-internal-link'); - if (!selectedLayout) applyInlineBaseStyles(link, 'internal'); - } else { - link.addClass('menu-external-link'); - if (!selectedLayout) applyInlineBaseStyles(link, 'external'); - } - }); - } - } - }); - - observer.observe(dvContainer, { childList: true, subtree: true }); - } - }); + this.registerMarkdownCodeBlockProcessor('menu', (source, el, ctx) => { + this.processMenuBlock(source, el, ctx); + }); + } catch (error) { + console.error('[obsidian-menus] Failed to load plugin:', error); + throw error; + } } onunload() { @@ -285,6 +38,262 @@ export default class MenuPlugin extends Plugin { async saveSettings() { await this.saveData(this.settings); } + + private processMenuBlock(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) { + const lines = source.trim().split('\n'); + let layoutOrClass = ''; + let colors: Record = {}; + const links: string[] = []; + let dataviewQuery = ''; + + // Parse YAML-like properties and links + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('layout:') || trimmed.startsWith('class:')) { + const colonIndex = trimmed.indexOf(':'); + layoutOrClass = trimmed.substring(colonIndex + 1).trim(); + } else if (trimmed.startsWith('dataview:') || trimmed.startsWith('dv:')) { + const colonIndex = trimmed.indexOf(':'); + dataviewQuery = trimmed.substring(colonIndex + 1).trim(); + } else if (trimmed.includes(':') && !trimmed.startsWith('[') && !trimmed.startsWith('[[') && !dataviewQuery) { + // Parse color properties + const [key, ...valueParts] = trimmed.split(':'); + const value = valueParts.join(':').trim(); + if (key && value && !key.includes('//') && !key.includes('http')) { + colors[key.trim()] = value; + } + } else if (trimmed && !trimmed.includes(':') && !dataviewQuery) { + links.push(trimmed); + } else if (trimmed.startsWith('[') && !dataviewQuery) { + links.push(trimmed); + } else if (dataviewQuery) { + // If we already found a dataview start, append subsequent lines to it + // This handles multi-line queries if the user didn't put it all on one line + // But actually, the simple parsing above assumes one line per property. + // For complex queries, we might need a better parser. + // For now, let's assume the query might be multi-line if it started with dv: + // But the loop iterates lines. + // Let's adjust: if we are in "dataview mode", just add to query. + dataviewQuery += '\n' + line; + } + } + + // Re-parsing to handle multi-line dataview queries correctly + // The previous loop was a bit naive for multi-line. Let's do a cleaner pass. + layoutOrClass = ''; + colors = {}; + links.length = 0; + dataviewQuery = ''; + let isDataviewBlock = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (isDataviewBlock) { + dataviewQuery += '\n' + line; + continue; + } + + if (trimmed.startsWith('layout:') || trimmed.startsWith('class:')) { + const colonIndex = trimmed.indexOf(':'); + layoutOrClass = trimmed.substring(colonIndex + 1).trim(); + } else if (trimmed.startsWith('dataview:') || trimmed.startsWith('dv:')) { + const colonIndex = trimmed.indexOf(':'); + dataviewQuery = trimmed.substring(colonIndex + 1).trim(); + isDataviewBlock = true; // Assume rest of block is the query + } else if (trimmed.includes(':') && !trimmed.startsWith('[') && !trimmed.startsWith('[[')) { + const [key, ...valueParts] = trimmed.split(':'); + const value = valueParts.join(':').trim(); + if (key && value && !key.includes('//') && !key.includes('http')) { + colors[key.trim()] = value; + } + } else if (trimmed) { + links.push(trimmed); + } + } + + // Determine layout and classes + const builtInLayouts = new Set(['default', 'minimal', 'slate', 'horizon', 'aether']); + const container = el.createEl('div', { cls: 'menu-container' }); + + let selectedLayout = ''; + let extraClasses: string[] = []; + + if (layoutOrClass) { + const tokens = layoutOrClass.split(/\s+/).filter(Boolean); + if (tokens.length) { + const builtInIndex = tokens.findIndex(t => builtInLayouts.has(t)); + if (builtInIndex !== -1) { + selectedLayout = tokens[builtInIndex]; + extraClasses = tokens.filter((_, i) => i !== builtInIndex); + } else { + extraClasses = tokens; + } + } + } else { + selectedLayout = 'default'; + } + + if (selectedLayout) { + container.setAttr('data-layout', selectedLayout); + } + + for (const cls of extraClasses) { + container.addClass(cls); + } + + // Apply custom properties + if (Object.keys(colors).length > 0) { + const baseKeys = new Set([ + 'bg', 'text', 'border', 'font', + 'hover-text', 'hover-bg', 'hover-border', 'hover-font', + ]); + const normalizeKey = (raw: string) => { + let s = raw.trim().toLowerCase(); + s = s + .replace(/\btext-hover\b/g, 'hover-text') + .replace(/\bbg-hover\b/g, 'hover-bg') + .replace(/\bborder-hover\b/g, 'hover-border') + .replace(/\binternal-text-hover\b/g, 'internal-hover-text') + .replace(/\binternal-bg-hover\b/g, 'internal-hover-bg') + .replace(/\binternal-border-hover\b/g, 'internal-hover-border') + .replace(/\bexternal-text-hover\b/g, 'external-hover-text') + .replace(/\bexternal-bg-hover\b/g, 'external-hover-bg') + .replace(/\bexternal-border-hover\b/g, 'external-hover-border') + .replace(/\bfile-text-hover\b/g, 'file-hover-text') + .replace(/\bfile-bg-hover\b/g, 'file-hover-bg') + .replace(/\bfile-border-hover\b/g, 'file-hover-border') + .replace(/\baccent\b/g, 'hover-text') + .replace(/\binternal-accent\b/g, 'internal-hover-text') + .replace(/\bexternal-accent\b/g, 'external-hover-text') + .replace(/\bfile-accent\b/g, 'file-hover-text') + .replace(/\bbackground\b/g, 'bg'); + return s; + }; + const isAllowed = (key: string) => { + if (baseKeys.has(key)) return true; + const m = key.match(/^(internal|external|file)-(.*)$/); + return !!(m && baseKeys.has(m[2])); + }; + for (const [rawKey, value] of Object.entries(colors)) { + const key = normalizeKey(rawKey); + if (!isAllowed(key)) continue; + container.style.setProperty(`--${key}`, value); + } + } + + const applyInlineBaseStyles = (a: HTMLElement, variant: 'internal' | 'external' | 'file' | 'generic') => { + const prefix = variant === 'generic' ? '' : `${variant}-`; + const get = (k: string) => (colors[`${prefix}${k}`] ?? colors[k]); + const bgVal = get('bg'); if (bgVal) a.style.background = bgVal as string; + const textVal = get('text'); if (textVal) a.style.color = textVal as string; + const borderVal = get('border'); if (borderVal) a.style.borderColor = borderVal as string; + const fontVal = get('font'); if (fontVal) a.style.fontFamily = fontVal as string; + const hoverKeys = ['hover-bg', 'hover-text', 'hover-border', 'hover-font']; + for (const hk of hoverKeys) { + const v = get(hk); + if (v) a.style.setProperty(`--${hk}`, v as string); + } + }; + + // Process regular links + for (const link of links) { + if (link.startsWith('[[') && link.endsWith(']]')) { + const linkContent = link.slice(2, -2); + let href = linkContent; + let text = linkContent; + if (linkContent.includes('|')) { + [href, text] = linkContent.split('|'); + } + const a = container.createEl('a', { + text: text, + attr: { 'data-href': href } + }); + a.addClass('menu-internal-link'); + if (!selectedLayout) applyInlineBaseStyles(a, 'internal'); + a.style.cursor = 'pointer'; + a.addEventListener('click', (e) => { + e.preventDefault(); + this.app.workspace.openLinkText(href, ctx.sourcePath, false); + }); + } else if (link.match(/^\[.*\]\(.*\)$/)) { + const match = link.match(/^\[(.*)\]\((.*)\)$/); + if (match) { + const text = match[1]; + const url = match[2]; + const a = container.createEl('a', { + text: text, + attr: url.startsWith('file://') ? {} : { href: url, target: '_blank', rel: 'noopener noreferrer' } + }); + a.style.cursor = 'pointer'; + + if (url.startsWith('file://')) { + a.addClass('menu-file-link'); + if (!selectedLayout) applyInlineBaseStyles(a, 'file'); + } else { + a.addClass('menu-external-link'); + if (!selectedLayout) applyInlineBaseStyles(a, 'external'); + } + a.addEventListener('click', (e) => { + e.preventDefault(); + if (url.startsWith('file://')) { + if (Platform.isDesktop) { + try { + // Use dynamic import with proper error handling + const electronPath = 'electron'; + const { shell } = require(electronPath); + let filePath = decodeURIComponent(url.substring(7)); + if (filePath.startsWith('/') && filePath.charAt(2) === ':') { + filePath = filePath.substring(1); + } + shell.openPath(filePath); + } catch (error) { + console.error('Failed to open file:', error); + } + } else { + console.warn('File links are not supported on mobile.'); + } + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }); + } + } + } + + // Process Dataview Query + if (dataviewQuery) { + const dvContainer = container.createDiv({ cls: 'menu-dataview-container' }); + // Render the dataview query + // We wrap it in ```dataview ... ``` so Obsidian's renderer handles it + MarkdownRenderer.render( + this.app, + `\`\`\`dataview\n${dataviewQuery}\n\`\`\``, + dvContainer, + ctx.sourcePath, + this + ); + + // MutationObserver to style links once they render + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + const links = dvContainer.querySelectorAll('a'); + links.forEach((link: HTMLElement) => { + if (link.hasClass('internal-link')) { + link.addClass('menu-internal-link'); + if (!selectedLayout) applyInlineBaseStyles(link, 'internal'); + } else { + link.addClass('menu-external-link'); + if (!selectedLayout) applyInlineBaseStyles(link, 'external'); + } + }); + } + } + }); + + observer.observe(dvContainer, { childList: true, subtree: true }); + } + } } class MenuPluginSettingTab extends PluginSettingTab { diff --git a/manifest.json b/manifest.json index 782d61c..9988dcd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "menu-plugin", "name": "Obsidian Menus", - "version": "1.3", + "version": "1.4", "minAppVersion": "0.15.0", "description": "Create custom menus using code blocks with links and CSS styling.", "author": "Olivier Legendre",