diff --git a/README.md b/README.md index 04b72b3..a82b43b 100644 --- a/README.md +++ b/README.md @@ -34,22 +34,27 @@ The Author called [%author_name] is really funny! ``` You can also reference properties from another note by putting an Obsidian link -target inside the marker, followed by `#property`: +target inside the marker, followed by `@property`: ```markdown -Current status: [%[[Project]]#status] -Project owner: [%[[Projects/Website|Website project]]#owner] -Review date: [%[Website project](Projects/Website.md)#review_date] +Current status: [%[[Project]]@status] +Project owner: [%[[Projects/Website|Website project]]@owner] +Review date: [%[Website project](Projects/Website.md)@review_date] ``` The same target syntax works with double braces when that syntax format is selected: ```markdown -Current status: {{[[Project]]#status}} -Review date: {{[Website project](Projects/Website.md)#review_date}} +Current status: {{[[Project]]@status}} +Review date: {{[Website project](Projects/Website.md)@review_date}} ``` +> **Deprecation:** earlier releases used `#property` as the separator (e.g. +> `[[Project]]#status`). That form still renders but is **deprecated and will be +> removed in a future release**, because Obsidian indexes the trailing `#status` +> as a tag. Switch existing references to `@` to avoid stray tags. + The syntax markers are replaced in reading view and live preview. Source mode keeps the syntax as plain text. If a local key, target note, or target property is missing, the marker is left unchanged. Inline code and code blocks are ignored in diff --git a/manifest.json b/manifest.json index 755c66f..b3b9729 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "embed-metadata", "name": "Embed Metadata", - "version": "0.7.1", + "version": "0.8.0", "minAppVersion": "1.0.0", "description": "Render frontmatter metadata (Properties) inside your notes with a lightweight inline syntax.", "author": "Schemen", diff --git a/package.json b/package.json index fc27151..fda8781 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "embed-metadata", - "version": "0.7.1", + "version": "0.8.0", "description": "Render frontmatter metadata inside Obsidian notes with lightweight inline syntax.", "private": true, "main": "main.js", diff --git a/src/markdown-render.ts b/src/markdown-render.ts index 800ff86..cfbbae2 100644 --- a/src/markdown-render.ts +++ b/src/markdown-render.ts @@ -1,7 +1,7 @@ // Inline markdown renderer for metadata values (links, embeds, etc.) import {App, Component, MarkdownRenderer} from "obsidian"; -const markdownHintRegex = /(\[\[|!\[\[|`|\*|_|~|\[[^\]]+\]\([^)]+\)|#|\n)/; +const markdownHintRegex = /(\[\[|!\[\[|`|\*|_|~|\[[^\]]+\]\([^)]+\)|#|https?:\/\/|\n)/; // Render a value as inline markdown export function renderInlineMarkdown( diff --git a/src/metadata-renderer.ts b/src/metadata-renderer.ts index 6d02093..6f7c71f 100644 --- a/src/metadata-renderer.ts +++ b/src/metadata-renderer.ts @@ -7,6 +7,7 @@ import { getSyntaxOpen, metadataTargetCouldResolveTo, parseMetadataReference, + type MetadataMarker, type MetadataReference, type MetadataResolution, type MetadataResolver, @@ -17,6 +18,11 @@ import {EmbedMetadataPlugin} from "./settings"; const VALUE_CLASS = "embed-metadata-value"; const UNRESOLVED_CLASS = "embed-metadata-unresolved"; +// Contexts we never rewrite: raw editable text (handled by the CodeMirror +// plugin), code, and spans we have already rendered. +const EXCLUDED_SELECTOR = + `.cm-line, code, pre, .cm-inline-code, .cm-hmd-internal-code, .${VALUE_CLASS}, .${UNRESOLVED_CLASS}`; + // What each span last rendered, so refreshes can skip unchanged values. const lastRendered = new WeakMap(); @@ -44,6 +50,12 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin): (changedF ); const doc = el.ownerDocument; const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); + + // First collapse markers Obsidian fragmented across inline elements + // (e.g. `{{[[Note]]@key}}` becomes text + link + text). The text-node + // pass below only matches markers that live inside a single text node. + replaceFragmentedMarkers(el, resolver, doc, syntaxOpen, file.path, plugin); + const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.includes(syntaxOpen)) { @@ -51,7 +63,7 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin): (changedF } const parent = node.parentElement; - if (!parent || parent.closest(`.cm-line, code, pre, .cm-inline-code, .cm-hmd-internal-code, .${VALUE_CLASS}, .${UNRESOLVED_CLASS}`)) { + if (!parent || parent.closest(EXCLUDED_SELECTOR)) { return NodeFilter.FILTER_REJECT; } @@ -106,13 +118,7 @@ function replaceSyntaxInTextNode( fragment.append(before); } - const span = doc.createElement("span"); - span.dataset.embedMetadataKey = marker.key; - span.dataset.embedMetadataReference = marker.raw; - span.dataset.embedMetadataMarker = marker.marker; - span.dataset.embedMetadataSourcePath = sourcePath; - applyResolution(span, resolver.resolve(marker), marker.marker, plugin); - fragment.append(span); + fragment.append(createMarkerSpan(marker, resolver, doc, sourcePath, plugin)); lastIndex = marker.to; } @@ -125,6 +131,151 @@ function replaceSyntaxInTextNode( textNode.replaceWith(fragment); } +// A child node of an inline container, with the source text it reconstructs to +// and its offset within the container's reconstructed source string. +type SourceSegment = { + node: ChildNode; + source: string; + start: number; + isText: boolean; +}; + +// Replace markers that Obsidian split across inline elements. Obsidian renders +// links and tags before our post-processor runs, so `{{[[Note]]@key}}` arrives +// as separate sibling nodes and never appears whole in any single text node. +function replaceFragmentedMarkers( + root: HTMLElement, + resolver: MetadataResolver, + doc: Document, + syntaxOpen: string, + sourcePath: string, + plugin: EmbedMetadataPlugin +): void { + const containers = new Set(); + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue || !node.nodeValue.includes(syntaxOpen)) { + return NodeFilter.FILTER_SKIP; + } + + const parent = node.parentElement; + if (!parent || parent.closest(EXCLUDED_SELECTOR)) { + return NodeFilter.FILTER_REJECT; + } + + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let node = walker.nextNode(); + while (node) { + const parent = (node as Text).parentElement; + if (parent) { + containers.add(parent); + } + node = walker.nextNode(); + } + + for (const container of containers) { + reassembleContainer(container, resolver, doc, syntaxOpen, sourcePath, plugin); + } +} + +// Rebuild the source for one inline container and replace any marker that spans +// more than one child node. Single-node markers are left to the text-node pass. +function reassembleContainer( + container: HTMLElement, + resolver: MetadataResolver, + doc: Document, + syntaxOpen: string, + sourcePath: string, + plugin: EmbedMetadataPlugin +): void { + const segments: SourceSegment[] = []; + let source = ""; + for (const child of Array.from(container.childNodes)) { + const piece = reconstructSource(child); + segments.push({node: child, source: piece, start: source.length, isText: child.nodeType === Node.TEXT_NODE}); + source += piece; + } + + if (!source.includes(syntaxOpen)) { + return; + } + + const markers = findMetadataMarkers(source, plugin.settings.syntaxStyle); + + // Replace from the end so earlier offsets and untouched nodes stay valid. + for (let i = markers.length - 1; i >= 0; i -= 1) { + const marker = markers[i]; + if (!marker) { + continue; + } + const startSeg = findSegmentAt(segments, marker.from); + const endSeg = findSegmentAt(segments, marker.to - 1); + if (!startSeg || !endSeg || startSeg === endSeg) { + continue; + } + + // Marker delimiters (`{{`/`}}`) always sit in text; a non-text boundary + // means an unexpected layout we leave untouched rather than mangle. + if (!startSeg.isText || !endSeg.isText) { + continue; + } + + const span = createMarkerSpan(marker, resolver, doc, sourcePath, plugin); + const range = doc.createRange(); + range.setStart(startSeg.node, marker.from - startSeg.start); + range.setEnd(endSeg.node, marker.to - endSeg.start); + range.deleteContents(); + range.insertNode(span); + } +} + +// Reconstruct the markdown source a rendered node came from, so a fragmented +// marker can be parsed back into its original reference. +function reconstructSource(node: ChildNode): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.nodeValue ?? ""; + } + if (node instanceof HTMLElement) { + if (node.matches("a.internal-link") && node.dataset.href) { + // `data-href` keeps the link target including any `#subpath`. + return `[[${node.dataset.href}]]`; + } + // Tags (legacy `#key` form) and other inline markup map back to their text. + return node.textContent ?? ""; + } + return node.textContent ?? ""; +} + +function findSegmentAt(segments: SourceSegment[], offset: number): SourceSegment | null { + for (const segment of segments) { + if (offset >= segment.start && offset < segment.start + segment.source.length) { + return segment; + } + } + return null; +} + +// Build a placeholder span for a marker, tagged so targeted refreshes can find +// and re-resolve it in place when a dependency changes. +function createMarkerSpan( + marker: MetadataMarker, + resolver: MetadataResolver, + doc: Document, + sourcePath: string, + plugin: EmbedMetadataPlugin +): HTMLElement { + const span = doc.createElement("span"); + span.dataset.embedMetadataKey = marker.key; + span.dataset.embedMetadataReference = marker.raw; + span.dataset.embedMetadataMarker = marker.marker; + span.dataset.embedMetadataSourcePath = sourcePath; + applyResolution(span, resolver.resolve(marker), marker.marker, plugin); + return span; +} + // Render a resolution into a span, skipping the DOM write when nothing changed. function applyResolution( span: HTMLElement, diff --git a/src/metadata-suggest.ts b/src/metadata-suggest.ts index 61d3b82..3ffafe5 100644 --- a/src/metadata-suggest.ts +++ b/src/metadata-suggest.ts @@ -127,6 +127,13 @@ function getRemotePropertyTrigger( return null; } + // Only the canonical `@` separator gets autocomplete. The deprecated `#` + // form still renders, but withholding the assist nudges notes toward `@`. + const beforeKey = content.slice(0, content.length - reference.key.length).trimEnd(); + if (!beforeKey.endsWith("@")) { + return null; + } + return { query: reference.key, target: reference.target, diff --git a/src/metadata-utils.ts b/src/metadata-utils.ts index b4fb7a2..fd3393a 100644 --- a/src/metadata-utils.ts +++ b/src/metadata-utils.ts @@ -387,9 +387,12 @@ function parseRemoteReference(raw: string, allowEmptyKey: boolean): MetadataRefe }; } +// `@` is the canonical key separator; `#` is deprecated and still parsed for +// back-compat, but will be removed in a future release. `@` avoids the key being +// indexed as an Obsidian tag the way a loose `#key` is. function parseRemoteKey(raw: string, allowEmptyKey: boolean): string | null { const trimmed = raw.trim(); - if (!trimmed.startsWith("#")) { + if (!trimmed.startsWith("@") && !trimmed.startsWith("#")) { return null; } diff --git a/src/settings.ts b/src/settings.ts index 124995f..b109a1e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -93,6 +93,15 @@ export class EmbedMetadataSettingTab extends PluginSettingTab { }); }); + new Setting(containerEl) + .setName("Remote property syntax") + .setDesc( + "Reference another note's property with [[Note]]@key; autocomplete is offered after @." + + " The older [[Note]]#key form still renders but is deprecated and will be removed in a" + + " future release, because Obsidian indexes the #key as a tag. Switch existing references" + + " to @ to avoid stray tags." + ); + new Setting(containerEl) .setName("Render in outline (experimental)") .setDesc("Render metadata markers in the outline view.") diff --git a/versions.json b/versions.json index 8733030..0b2e6de 100644 --- a/versions.json +++ b/versions.json @@ -10,5 +10,6 @@ "0.5.2": "0.15.0", "0.6.0": "1.0.0", "0.7.0": "1.0.0", - "0.7.1": "1.0.0" + "0.7.1": "1.0.0", + "0.8.0": "1.0.0" }