diff --git a/README.md b/README.md index 94594e5..04b72b3 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,29 @@ We recommend using those tags: [%tags] 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`: + +```markdown +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}} +``` + The syntax markers are replaced in reading view and live preview. Source mode keeps -the syntax as plain text. If a key is missing, the marker is left unchanged. -Inline code and code blocks are ignored in preview. If a key exists but has no -value, the marker renders empty. +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 +preview. If a key exists but has no value, the marker renders empty. Markdown in +remote property values is rendered relative to the target note, so links stored +in that property keep the same meaning. Built-in keys (when enabled): - `filename` (full file name with extension) @@ -79,7 +98,10 @@ normal rendering or autocomplete. ## Autocomplete Type the configured opener (`[%` or `{{`) to see a dropdown of frontmatter keys -from the current file. Results are sorted alphabetically and update as you type. +from the current file. For remote references, use Obsidian's normal file +autocomplete inside `[[...]]`, then type `#` after the completed target to see +that note's property keys. Results are sorted alphabetically and update as you +type. ## Disclaimer AI was used during the development of this project. diff --git a/manifest.json b/manifest.json index 14131f1..755c66f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "embed-metadata", "name": "Embed Metadata", - "version": "0.6.0", + "version": "0.7.1", "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 8d7bc53..fc27151 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "embed-metadata", - "version": "0.6.0", + "version": "0.7.1", "description": "Render frontmatter metadata inside Obsidian notes with lightweight inline syntax.", "private": true, "main": "main.js", diff --git a/src/editor-metadata.ts b/src/editor-metadata.ts index edc9c9c..5ff092d 100644 --- a/src/editor-metadata.ts +++ b/src/editor-metadata.ts @@ -2,20 +2,23 @@ import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType} from "@codemirror/view"; import {RangeSetBuilder, StateEffect, Text} from "@codemirror/state"; import {editorInfoField, editorLivePreviewField, TFile} from "obsidian"; -import {createFrontmatterResolver, getSyntaxOpen, getSyntaxRegex} from "./metadata-utils"; +import { + createMetadataDependencies, + createMetadataResolver, + findMetadataMarkers, + getSyntaxOpen, + metadataDependenciesInclude, + type MetadataDependencies, + type MetadataMarker, + type SyntaxStyle, +} from "./metadata-utils"; import {renderInlineMarkdown} from "./markdown-render"; import {applyValueStyles, getStyleKey} from "./metadata-style"; import {EmbedMetadataPlugin} from "./settings"; -type LineMarker = { - from: number; - to: number; - key: string; -}; - type LineMarkers = { text: string; - markers: LineMarker[]; + markers: MetadataMarker[]; }; type MarkdownStyle = { @@ -34,10 +37,10 @@ type FenceState = { const livePreviewRefreshEffect = StateEffect.define(); const livePreviewInstances = new Set(); -// Force a Live Preview refresh for a given file after metadata changes. -export function refreshLivePreviewForFile(file: TFile): void { +// Refresh only the Live Preview views whose rendered values depend on `file`. +export function refreshLivePreviewForDependents(file: TFile): void { for (const instance of livePreviewInstances) { - if (instance.matchesFile(file)) { + if (instance.dependsOn(file)) { instance.requestRefresh(); } } @@ -64,13 +67,16 @@ class MetadataViewPlugin { private cursorMarkerKey: string; private lineCache: Map; private syntaxStyle: string; + private dependencies: MetadataDependencies; constructor(plugin: EmbedMetadataPlugin, view: EditorView) { this.plugin = plugin; this.view = view; this.lineCache = new Map(); this.syntaxStyle = plugin.settings.syntaxStyle; - this.decorations = buildDecorations(view, plugin, this.lineCache); + const build = buildDecorations(view, plugin, this.lineCache); + this.decorations = build.decorations; + this.dependencies = build.dependencies; this.cursorMarkerKey = getCursorMarkerKey(view, plugin); livePreviewInstances.add(this); } @@ -111,7 +117,9 @@ class MetadataViewPlugin { } if (needsRebuild) { - this.decorations = buildDecorations(update.view, this.plugin, this.lineCache, forceFullScan); + const build = buildDecorations(update.view, this.plugin, this.lineCache, forceFullScan); + this.decorations = build.decorations; + this.dependencies = build.dependencies; } } @@ -119,9 +127,10 @@ class MetadataViewPlugin { livePreviewInstances.delete(this); } - matchesFile(file: TFile): boolean { - const info = this.view.state.field(editorInfoField); - return info?.file?.path === file.path; + // Dependencies cover the last built ranges; markers scrolled into view are + // re-resolved by the viewport rebuild, so visible coverage is sufficient. + dependsOn(file: TFile): boolean { + return metadataDependenciesInclude(this.dependencies, file); } requestRefresh(): void { @@ -129,38 +138,37 @@ class MetadataViewPlugin { } } +type DecorationBuild = { + decorations: DecorationSet; + dependencies: MetadataDependencies; +}; + // Scan visible ranges and replace syntax markers with widgets (skipping active edits). function buildDecorations( view: EditorView, plugin: EmbedMetadataPlugin, lineCache: Map, forceFullScan = false -): DecorationSet { +): DecorationBuild { if (!view.state.field(editorLivePreviewField)) { - return Decoration.none; + return {decorations: Decoration.none, dependencies: createMetadataDependencies()}; } const info = view.state.field(editorInfoField); const file = info?.file; if (!file || !(file instanceof TFile)) { - return Decoration.none; - } - - const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? null; - if (!frontmatter && !plugin.settings.builtInKeysEnabled) { - return Decoration.none; + return {decorations: Decoration.none, dependencies: createMetadataDependencies()}; } const builder = new RangeSetBuilder(); const selectionRanges = view.state.selection.ranges; const styleKey = getStyleKey(plugin.settings); const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); - const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); const seenLines = new Set(); - const resolveValue = createFrontmatterResolver( - frontmatter ?? {}, - plugin.settings.caseInsensitiveKeys, + const resolver = createMetadataResolver( + plugin.app, file, + plugin.settings.caseInsensitiveKeys, plugin.settings.builtInKeysEnabled ); @@ -181,7 +189,7 @@ function buildDecorations( } seenLines.add(lineNumber); - const markers = getLineMarkers(lineNumber, line, lineCache, syntaxRegex, syntaxOpen); + const markers = getLineMarkers(lineNumber, line, lineCache, plugin.settings.syntaxStyle, syntaxOpen); if (markers.length === 0) { continue; } @@ -211,8 +219,8 @@ function buildDecorations( continue; } - const value = resolveValue(marker.key); - if (value === null) { + const result = resolver.resolve(marker); + if (!result.resolved) { continue; } @@ -226,7 +234,13 @@ function buildDecorations( start, end, Decoration.replace({ - widget: new MetadataWidget(value, file.path, plugin, styleKey, markdownStyle), + widget: new MetadataWidget( + result.value, + result.targetFile.path, + plugin, + styleKey, + markdownStyle + ), inclusive: false, }) ); @@ -234,42 +248,31 @@ function buildDecorations( } } - return builder.finish(); + return {decorations: builder.finish(), dependencies: resolver.dependencies}; } function getLineMarkers( lineNumber: number, line: {from: number; text: string}, lineCache: Map, - syntaxRegex: RegExp, + syntaxStyle: SyntaxStyle, syntaxOpen: string -): LineMarker[] { +): MetadataMarker[] { const cached = lineCache.get(lineNumber); if (cached && cached.text === line.text) { return cached.markers; } - const markers: LineMarker[] = []; + const markers: MetadataMarker[] = []; if (line.text.includes(syntaxOpen)) { - syntaxRegex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = syntaxRegex.exec(line.text)) !== null) { - const key = (match[1] ?? "").trim(); - if (!key) { - continue; - } - - const start = match.index; - const end = start + match[0].length; - markers.push({from: start, to: end, key}); - } + markers.push(...findMetadataMarkers(line.text, syntaxStyle)); } lineCache.set(lineNumber, {text: line.text, markers}); return markers; } -function maskMarkerText(text: string, markers: LineMarker[]): string { +function maskMarkerText(text: string, markers: MetadataMarker[]): string { if (markers.length === 0) { return text; } @@ -357,7 +360,7 @@ function getInlineCodeRanges(text: string): InlineCodeRange[] { return ranges; } -function isMarkerInInlineCode(marker: LineMarker, ranges: InlineCodeRange[]): boolean { +function isMarkerInInlineCode(marker: MetadataMarker, ranges: InlineCodeRange[]): boolean { for (const range of ranges) { if (rangesOverlap(marker.from, marker.to, range.from, range.to)) { return true; @@ -455,7 +458,7 @@ function findInlineRangeAt(pos: number, ranges: InlineCodeRange[]): InlineCodeRa } function getMarkdownStyleForMarker( - marker: LineMarker, + marker: MetadataMarker, emphasisRanges: EmphasisRange[], strikeRanges: DelimitedRange[], highlightRanges: DelimitedRange[] @@ -499,7 +502,6 @@ function pruneLineCache(lineCache: Map, maxLine: number): v function shouldRebuildForChanges(update: ViewUpdate, plugin: EmbedMetadataPlugin): boolean { const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); - const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); const nextDoc = update.state.doc; const prevDoc = update.startState.doc; const prevFrontmatter = getFrontmatterRange(prevDoc); @@ -521,12 +523,12 @@ function shouldRebuildForChanges(update: ViewUpdate, plugin: EmbedMetadataPlugin return; } - if (changeTouchesMarker(prevDoc, fromA, toA, syntaxRegex, syntaxOpen)) { + if (changeTouchesMarker(prevDoc, fromA, toA, plugin.settings.syntaxStyle, syntaxOpen)) { needsRebuild = true; return; } - if (changeTouchesMarker(nextDoc, fromB, toB, syntaxRegex, syntaxOpen)) { + if (changeTouchesMarker(nextDoc, fromB, toB, plugin.settings.syntaxStyle, syntaxOpen)) { needsRebuild = true; } }); @@ -538,7 +540,7 @@ function changeTouchesMarker( doc: Text, from: number, to: number, - syntaxRegex: RegExp, + syntaxStyle: SyntaxStyle, syntaxOpen: string ): boolean { const safeTo = Math.max(to - 1, from); @@ -551,11 +553,9 @@ function changeTouchesMarker( continue; } - syntaxRegex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = syntaxRegex.exec(line.text)) !== null) { - const start = line.from + match.index; - const end = start + match[0].length; + for (const marker of findMetadataMarkers(line.text, syntaxStyle)) { + const start = line.from + marker.from; + const end = line.from + marker.to; if (rangesOverlap(from, to, start, end)) { return true; } @@ -594,7 +594,6 @@ function getFrontmatterRange(doc: Text): {from: number; to: number} | null { function getCursorMarkerKey(view: EditorView, plugin: EmbedMetadataPlugin): string { const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); - const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); const markerKeys: string[] = []; for (const range of view.state.selection.ranges) { @@ -608,11 +607,9 @@ function getCursorMarkerKey(view: EditorView, plugin: EmbedMetadataPlugin): stri continue; } - syntaxRegex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = syntaxRegex.exec(line.text)) !== null) { - const start = line.from + match.index; - const end = start + match[0].length; + for (const marker of findMetadataMarkers(line.text, plugin.settings.syntaxStyle)) { + const start = line.from + marker.from; + const end = line.from + marker.to; if (pos >= start && pos <= end) { markerKeys.push(`${start}:${end}`); break; diff --git a/src/main.ts b/src/main.ts index 71a772c..5f82a1d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import {Plugin} from "obsidian"; -import {createEditorExtension, refreshAllLivePreview, refreshLivePreviewForFile} from "./editor-metadata"; +import {createEditorExtension, refreshAllLivePreview, refreshLivePreviewForDependents} from "./editor-metadata"; import {registerMetadataRenderer} from "./metadata-renderer"; import {MetadataSuggest} from "./metadata-suggest"; import {registerOutlineRenderer} from "./outline-renderer"; @@ -14,9 +14,12 @@ export default class EmbedMetadata extends Plugin { async onload() { await this.loadSettings(); - this.markdownRefresher = registerMarkdownRefresh(this, refreshLivePreviewForFile); + const refreshReadingView = registerMetadataRenderer(this); + this.markdownRefresher = registerMarkdownRefresh(this, (file) => { + refreshLivePreviewForDependents(file); + refreshReadingView(file); + }); this.registerEditorExtension(createEditorExtension(this)); - registerMetadataRenderer(this); this.refreshOutlineViews = registerOutlineRenderer(this); this.registerEditorSuggest(new MetadataSuggest(this)); this.addSettingTab(new EmbedMetadataSettingTab(this.app, this)); diff --git a/src/metadata-renderer.ts b/src/metadata-renderer.ts index 0759e05..6d02093 100644 --- a/src/metadata-renderer.ts +++ b/src/metadata-renderer.ts @@ -1,20 +1,32 @@ // Reading view renderer that replaces syntax markers in the preview DOM. import {MarkdownView, TFile} from "obsidian"; import { - createFrontmatterResolver, + createMetadataResolver, + findMetadataMarkers, getSyntaxClose, getSyntaxOpen, - getSyntaxRegex, + metadataTargetCouldResolveTo, + parseMetadataReference, + type MetadataReference, + type MetadataResolution, + type MetadataResolver, } from "./metadata-utils"; import {renderInlineMarkdown} from "./markdown-render"; import {EmbedMetadataPlugin} from "./settings"; +const VALUE_CLASS = "embed-metadata-value"; +const UNRESOLVED_CLASS = "embed-metadata-unresolved"; + +// What each span last rendered, so refreshes can skip unchanged values. +const lastRendered = new WeakMap(); + // Render syntax markers in Reading view by post-processing the preview DOM. -export function registerMetadataRenderer(plugin: EmbedMetadataPlugin) { +// Returns a targeted refresh that updates only the spans affected by a change. +export function registerMetadataRenderer(plugin: EmbedMetadataPlugin): (changedFile: TFile) => void { plugin.registerMarkdownPostProcessor((el, ctx) => { - if (el.closest(".markdown-source-view.mod-cm6") || el.closest(".cm-editor")) { - return; - } + // Runs for Reading view and for Live Preview rendered blocks (callouts, + // embeds). Raw editable text is handled by the CodeMirror plugin and is + // excluded below via the `.cm-line` filter, so the two never overlap. if (!ctx.sourcePath) { return; } @@ -24,20 +36,14 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin) { return; } - const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? null; - if (!frontmatter && !plugin.settings.builtInKeysEnabled) { - return; - } - - const resolveValue = createFrontmatterResolver( - frontmatter ?? {}, - plugin.settings.caseInsensitiveKeys, + const resolver = createMetadataResolver( + plugin.app, file, + plugin.settings.caseInsensitiveKeys, plugin.settings.builtInKeysEnabled ); const doc = el.ownerDocument; const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); - const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.includes(syntaxOpen)) { @@ -45,7 +51,7 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin) { } const parent = node.parentElement; - if (!parent || parent.closest("code, pre, .cm-inline-code, .cm-hmd-internal-code, .embed-metadata-value")) { + if (!parent || parent.closest(`.cm-line, code, pre, .cm-inline-code, .cm-hmd-internal-code, .${VALUE_CLASS}, .${UNRESOLVED_CLASS}`)) { return NodeFilter.FILTER_REJECT; } @@ -61,29 +67,22 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin) { } for (const textNode of textNodes) { - replaceSyntaxInTextNode( - textNode, - (key) => resolveValue(key), - doc, - syntaxRegex, - syntaxOpen, - ctx.sourcePath ?? "", - plugin - ); + replaceSyntaxInTextNode(textNode, resolver, doc, syntaxOpen, file.path, plugin); } }); - plugin.registerEvent(plugin.app.metadataCache.on("changed", (file) => { - refreshRenderedValuesForFile(plugin, file); - })); + return (changedFile: TFile) => { + refreshRenderedValues(plugin, changedFile); + }; } // Replace inline syntax markers in a single text node with rendered spans. +// Unresolved markers become placeholder spans too, so they can resolve in +// place later without a full preview rerender. function replaceSyntaxInTextNode( textNode: Text, - resolveValue: (key: string) => string | null, + resolver: MetadataResolver, doc: Document, - syntaxRegex: RegExp, syntaxOpen: string, sourcePath: string, plugin: EmbedMetadataPlugin @@ -93,32 +92,29 @@ function replaceSyntaxInTextNode( return; } - syntaxRegex.lastIndex = 0; - let match: RegExpExecArray | null; + const markers = findMetadataMarkers(text, plugin.settings.syntaxStyle); + if (markers.length === 0) { + return; + } + let lastIndex = 0; const fragment = doc.createDocumentFragment(); - let didReplace = false; - while ((match = syntaxRegex.exec(text)) !== null) { - const before = text.slice(lastIndex, match.index); + for (const marker of markers) { + const before = text.slice(lastIndex, marker.from); if (before) { fragment.append(before); } - const key = (match[1] ?? "").trim(); - const value = resolveValue(key); - if (value === null) { - fragment.append(match[0]); - } else { - const span = doc.createElement("span"); - span.className = "embed-metadata-value"; - span.dataset.embedMetadataKey = key; - renderInlineMarkdown(plugin.app, sourcePath, span, value, plugin); - fragment.append(span); - didReplace = true; - } + 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); - lastIndex = match.index + match[0].length; + lastIndex = marker.to; } const after = text.slice(lastIndex); @@ -126,23 +122,43 @@ function replaceSyntaxInTextNode( fragment.append(after); } - if (didReplace) { - textNode.replaceWith(fragment); - } + textNode.replaceWith(fragment); } -function refreshRenderedValuesForFile(plugin: EmbedMetadataPlugin, file: TFile): void { - const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? null; - if (!frontmatter && !plugin.settings.builtInKeysEnabled) { +// Render a resolution into a span, skipping the DOM write when nothing changed. +function applyResolution( + span: HTMLElement, + resolution: MetadataResolution, + markerText: string, + plugin: EmbedMetadataPlugin +): void { + if (!resolution.resolved) { + span.className = UNRESOLVED_CLASS; + if (resolution.targetFile) { + span.dataset.embedMetadataTargetPath = resolution.targetFile.path; + } else { + delete span.dataset.embedMetadataTargetPath; + } + if (lastRendered.get(span) !== markerText) { + lastRendered.set(span, markerText); + span.textContent = markerText; + } return; } - const resolveValue = createFrontmatterResolver( - frontmatter ?? {}, - plugin.settings.caseInsensitiveKeys, - file, - plugin.settings.builtInKeysEnabled - ); + span.className = VALUE_CLASS; + span.dataset.embedMetadataTargetPath = resolution.targetFile.path; + // The newline keeps render keys distinct from single-line marker text. + const renderKey = `${resolution.targetFile.path}\n${resolution.value}`; + if (lastRendered.get(span) === renderKey) { + return; + } + lastRendered.set(span, renderKey); + renderInlineMarkdown(plugin.app, resolution.targetFile.path, span, resolution.value, plugin); +} + +// Re-resolve only the spans whose value can be affected by the changed file. +function refreshRenderedValues(plugin: EmbedMetadataPlugin, changedFile: TFile): void { const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); const syntaxClose = getSyntaxClose(plugin.settings.syntaxStyle); const leaves = plugin.app.workspace.getLeavesOfType("markdown"); @@ -152,22 +168,49 @@ function refreshRenderedValuesForFile(plugin: EmbedMetadataPlugin, file: TFile): if (!(view instanceof MarkdownView)) { continue; } - if (view.file?.path !== file.path) { + const viewFile = view.file; + if (!(viewFile instanceof TFile)) { continue; } - const rendered = view.containerEl.querySelectorAll(".embed-metadata-value[data-embed-metadata-key]"); - for (const el of Array.from(rendered)) { - const key = el.dataset.embedMetadataKey; - if (!key) { + // One resolver per source path: embedded notes resolve relative to themselves. + const resolvers = new Map(); + const spans = view.containerEl.querySelectorAll("[data-embed-metadata-reference]"); + for (const span of Array.from(spans)) { + const raw = span.dataset.embedMetadataReference; + if (raw === undefined) { continue; } - const value = resolveValue(key); - if (value === null) { - el.textContent = `${syntaxOpen}${key}${syntaxClose}`; + const reference = parseMetadataReference(raw); + if (!reference || !spanAffectedBy(span, reference, changedFile)) { continue; } - renderInlineMarkdown(plugin.app, file.path, el, value, plugin); + + const sourcePath = span.dataset.embedMetadataSourcePath ?? viewFile.path; + let resolver = resolvers.get(sourcePath); + if (!resolver) { + const sourceFile = plugin.app.vault.getAbstractFileByPath(sourcePath); + resolver = createMetadataResolver( + plugin.app, + sourceFile instanceof TFile ? sourceFile : viewFile, + plugin.settings.caseInsensitiveKeys, + plugin.settings.builtInKeysEnabled + ); + resolvers.set(sourcePath, resolver); + } + + const markerText = span.dataset.embedMetadataMarker ?? `${syntaxOpen}${raw}${syntaxClose}`; + applyResolution(span, resolver.resolve(reference), markerText, plugin); } } } + +// A span is affected when the change hits its resolved target, or when the +// changed file could satisfy a target that previously failed to resolve. +function spanAffectedBy(span: HTMLElement, reference: MetadataReference, file: TFile): boolean { + const targetPath = span.dataset.embedMetadataTargetPath; + if (targetPath !== undefined) { + return targetPath === file.path; + } + return reference.target !== null && metadataTargetCouldResolveTo(reference.target, file); +} diff --git a/src/metadata-style.ts b/src/metadata-style.ts index 4ebbd9f..3eb6006 100644 --- a/src/metadata-style.ts +++ b/src/metadata-style.ts @@ -7,8 +7,6 @@ export function getStyleKey(settings: EmbedMetadataSettings): string { settings.bold ? "1" : "0", settings.italic ? "1" : "0", settings.underline ? "1" : "0", - settings.underlineColorEnabled ? "1" : "0", - settings.underlineColor, settings.highlight ? "1" : "0", settings.highlightColorEnabled ? "1" : "0", settings.highlightColor, @@ -30,9 +28,6 @@ export function applyValueStyles(el: HTMLElement, settings: EmbedMetadataSetting if (settings.underline) { el.classList.add("embed-metadata-underline"); - if (settings.underlineColorEnabled) { - el.style.setProperty("--embed-metadata-underline-color", settings.underlineColor); - } } if (settings.highlight) { diff --git a/src/metadata-suggest.ts b/src/metadata-suggest.ts index 4e75849..61d3b82 100644 --- a/src/metadata-suggest.ts +++ b/src/metadata-suggest.ts @@ -2,14 +2,25 @@ import {Editor, EditorPosition, EditorSuggest, EditorSuggestContext, EditorSuggestTriggerInfo, TFile} from "obsidian"; import { collectFrontmatterKeys, + findMetadataMarkers, getBuiltInKeys, getSyntaxClose, + getSyntaxOpen, getSyntaxTriggerRegex, + parseMetadataReference, + resolveMetadataTargetFile, + type MetadataTarget, + type SyntaxStyle, } from "./metadata-utils"; import {EmbedMetadataPlugin} from "./settings"; +type SuggestMode = + | {type: "local"} + | {type: "remote"; targetFile: TFile}; + export class MetadataSuggest extends EditorSuggest { private plugin: EmbedMetadataPlugin; + private suggestMode: SuggestMode | null = null; constructor(plugin: EmbedMetadataPlugin) { super(plugin.app); @@ -18,12 +29,27 @@ export class MetadataSuggest extends EditorSuggest { // Start suggesting once the syntax opener is detected on the current line. onTrigger(cursor: EditorPosition, editor: Editor, file: TFile | null): EditorSuggestTriggerInfo | null { + this.suggestMode = null; if (!file) { return null; } const line = editor.getLine(cursor.line); const prefix = line.slice(0, cursor.ch); + const remoteTrigger = getRemotePropertyTrigger(prefix, this.plugin.settings.syntaxStyle); + if (remoteTrigger) { + const targetFile = resolveMetadataTargetFile(this.plugin.app, file.path, remoteTrigger.target); + if (!targetFile) { + return null; + } + this.suggestMode = {type: "remote", targetFile}; + return { + start: {line: cursor.line, ch: cursor.ch - remoteTrigger.query.length}, + end: cursor, + query: remoteTrigger.query, + }; + } + const triggerRegex = getSyntaxTriggerRegex(this.plugin.settings.syntaxStyle); const match = prefix.match(triggerRegex); if (!match) { @@ -31,6 +57,7 @@ export class MetadataSuggest extends EditorSuggest { } const query = match[1] ?? ""; + this.suggestMode = {type: "local"}; return { start: {line: cursor.line, ch: cursor.ch - query.length}, end: cursor, @@ -40,7 +67,10 @@ export class MetadataSuggest extends EditorSuggest { // Return sorted frontmatter keys that match the current query getSuggestions(context: EditorSuggestContext): string[] { - const frontmatter = this.plugin.app.metadataCache.getFileCache(context.file)?.frontmatter ?? null; + const file = this.suggestMode?.type === "remote" + ? this.suggestMode.targetFile + : context.file; + const frontmatter = this.plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? null; const keys = frontmatter ? collectFrontmatterKeys(frontmatter) : []; if (this.plugin.settings.builtInKeysEnabled) { keys.push(...getBuiltInKeys()); @@ -80,3 +110,30 @@ export class MetadataSuggest extends EditorSuggest { editor.setCursor(this.context.start.line, cursorCh); } } + +function getRemotePropertyTrigger( + prefix: string, + style: SyntaxStyle +): {query: string; target: MetadataTarget} | null { + const open = getSyntaxOpen(style); + const markerStart = prefix.lastIndexOf(open); + if (markerStart === -1 || hasClosedMarkerBeforeCursor(prefix, markerStart, style)) { + return null; + } + + const content = prefix.slice(markerStart + open.length); + const reference = parseMetadataReference(content, true); + if (!reference?.target || !/^[A-Za-z0-9_.-]*$/.test(reference.key)) { + return null; + } + + return { + query: reference.key, + target: reference.target, + }; +} + +function hasClosedMarkerBeforeCursor(prefix: string, markerStart: number, style: SyntaxStyle): boolean { + const markerText = prefix.slice(markerStart); + return findMetadataMarkers(markerText, style).some((found) => found.from === 0); +} diff --git a/src/metadata-utils.ts b/src/metadata-utils.ts index 337223d..b4fb7a2 100644 --- a/src/metadata-utils.ts +++ b/src/metadata-utils.ts @@ -1,9 +1,35 @@ // Utils for all other modules -import {MarkdownView, Plugin, TFile} from "obsidian"; +import {App, MarkdownView, normalizePath, Plugin, TFile} from "obsidian"; // Syntax parsing and frontmatter resolution utilities. export type SyntaxStyle = "brackets" | "doubleBraces"; export type FrontmatterResolver = (keyPath: string) => string | null; +export type MetadataResolver = { + resolve: (reference: MetadataReference) => MetadataResolution; + dependencies: MetadataDependencies; +}; +export type MetadataResolution = + | {resolved: true; value: string; targetFile: TFile} + | {resolved: false; targetFile: TFile | null}; +// Files that resolved values depend on, so views can refresh only when affected. +// Unresolved linkpaths are kept so a view can refresh once a matching note appears. +export type MetadataDependencies = { + paths: Set; + unresolvedLinkpaths: Set; +}; +export type MetadataTarget = + | {type: "wiki"; linktext: string} + | {type: "markdown"; destination: string}; +export type MetadataReference = { + raw: string; + key: string; + target: MetadataTarget | null; +}; +export type MetadataMarker = MetadataReference & { + from: number; + to: number; + marker: string; +}; export type BuiltInKey = | "filename" | "basename" @@ -35,13 +61,6 @@ export function getSyntaxClose(style: SyntaxStyle): string { return style === "doubleBraces" ? "}}" : "]"; } -// Regex for matching full syntax markers, including the key. -export function getSyntaxRegex(style: SyntaxStyle): RegExp { - return style === "doubleBraces" - ? /\{\{([^{}]+)\}\}/g - : /\[%([^[\]%]+)\]/g; -} - // Regex for triggering autocomplete from the current cursor prefix. export function getSyntaxTriggerRegex(style: SyntaxStyle): RegExp { return style === "doubleBraces" @@ -49,35 +68,230 @@ export function getSyntaxTriggerRegex(style: SyntaxStyle): RegExp { : /\[%([A-Za-z0-9_.-]*)$/; } +export function findMetadataMarkers(text: string, style: SyntaxStyle): MetadataMarker[] { + const markers: MetadataMarker[] = []; + const open = getSyntaxOpen(style); + const close = getSyntaxClose(style); + let offset = 0; + + while (offset < text.length) { + const start = text.indexOf(open, offset); + if (start === -1) { + break; + } + + const end = findMarkerEnd(text, start, style); + if (end === -1) { + offset = start + open.length; + continue; + } + + // Keep the untrimmed inner text as `raw` so migrations round-trip spacing. + const inner = text.slice(start + open.length, end - close.length); + const reference = parseMetadataReference(inner); + if (reference) { + markers.push({ + key: reference.key, + target: reference.target, + raw: inner, + from: start, + to: end, + marker: text.slice(start, end), + }); + } + + offset = end; + } + + return markers; +} + +export function parseMetadataReference(raw: string, allowEmptyKey = false): MetadataReference | null { + const reference = raw.trim(); + if (!reference) { + return null; + } + + const remote = parseRemoteReference(reference, allowEmptyKey); + if (remote) { + return remote; + } + + return { + raw: reference, + key: reference, + target: null, + }; +} + +export function createMetadataResolver( + app: App, + sourceFile: TFile, + caseInsensitive: boolean, + builtInKeysEnabled: boolean +): MetadataResolver { + const resolverCache = new Map(); + const dependencies = createMetadataDependencies(); + + const resolve = (reference: MetadataReference): MetadataResolution => { + const targetFile = reference.target + ? resolveMetadataTargetFile(app, sourceFile.path, reference.target) + : sourceFile; + if (!targetFile) { + if (reference.target) { + dependencies.unresolvedLinkpaths.add(getTargetLinkpath(reference.target)); + } + return {resolved: false, targetFile: null}; + } + + // The dependency holds even when the key is missing: adding the key to + // the target's frontmatter must refresh this reference. + dependencies.paths.add(targetFile.path); + + let resolveValue = resolverCache.get(targetFile.path); + if (!resolveValue) { + const frontmatter = app.metadataCache.getFileCache(targetFile)?.frontmatter ?? {}; + resolveValue = createFrontmatterResolver( + frontmatter, + caseInsensitive, + targetFile, + builtInKeysEnabled + ); + resolverCache.set(targetFile.path, resolveValue); + } + + const value = resolveValue(reference.key); + if (value === null) { + return {resolved: false, targetFile}; + } + + return {resolved: true, value, targetFile}; + }; + + return {resolve, dependencies}; +} + +export function createMetadataDependencies(): MetadataDependencies { + return {paths: new Set(), unresolvedLinkpaths: new Set()}; +} + +// Check whether a metadata change in `file` can affect values resolved with these dependencies. +export function metadataDependenciesInclude(dependencies: MetadataDependencies, file: TFile): boolean { + if (dependencies.paths.has(file.path)) { + return true; + } + + for (const linkpath of dependencies.unresolvedLinkpaths) { + if (linkpathCouldResolveTo(linkpath, file)) { + return true; + } + } + + return false; +} + +// Check whether `file` could be the destination of an unresolved target. +export function metadataTargetCouldResolveTo(target: MetadataTarget, file: TFile): boolean { + return linkpathCouldResolveTo(getTargetLinkpath(target), file); +} + +// Heuristic counterpart to getFirstLinkpathDest: a linkpath can resolve to a +// file when it matches the full path, a path suffix, or the bare basename. +function linkpathCouldResolveTo(linkpath: string, file: TFile): boolean { + const normalized = normalizePath(linkpath).toLowerCase(); + if (!normalized || normalized === "/") { + return false; + } + + const withExtension = normalized.endsWith(".md") ? normalized : `${normalized}.md`; + const path = file.path.toLowerCase(); + return path === withExtension || path.endsWith(`/${withExtension}`); +} + +export function resolveMetadataTargetFile( + app: App, + sourcePath: string, + target: MetadataTarget +): TFile | null { + const linkpath = getTargetLinkpath(target); + if (!linkpath || isExternalLinkpath(linkpath)) { + return null; + } + + for (const candidate of getLinkpathCandidates(linkpath)) { + const linkedFile = app.metadataCache.getFirstLinkpathDest(candidate, sourcePath); + if (linkedFile) { + return linkedFile; + } + } + + for (const candidate of getLinkpathCandidates(linkpath)) { + const abstractFile = app.vault.getAbstractFileByPath(normalizePath(candidate)); + if (abstractFile instanceof TFile) { + return abstractFile; + } + } + + return null; +} + // Refresh markdown views after metadata changes. export type MarkdownRefresher = { refreshAll: () => void; - refreshForFile: (file: TFile) => void; }; +// Quiet period before dispatching batched metadata changes. Keeps bursts +// (typing, sync, bulk edits) from refreshing views once per event. +const METADATA_CHANGE_DEBOUNCE_MS = 150; + +// Batch metadata change events and notify once per changed file. Targeted +// in-place refreshes (live preview decorations, reading view spans) happen in +// the callback; the returned refreshAll is the heavyweight full rerender for +// settings changes. export function registerMarkdownRefresh( plugin: Plugin, - onLivePreviewRefresh?: (file: TFile) => void + onMetadataChanged: (file: TFile) => void ): MarkdownRefresher { - const refreshForFile = (file: TFile) => { - const leaves = plugin.app.workspace.getLeavesOfType("markdown"); - for (const leaf of leaves) { - const view = leaf.view; - if (!(view instanceof MarkdownView)) { - continue; - } - if (view.file?.path !== file.path) { - continue; - } + const pendingFiles = new Map(); + let timerId: number | null = null; + + const flush = () => { + timerId = null; + const files = Array.from(pendingFiles.values()); + pendingFiles.clear(); + for (const file of files) { + onMetadataChanged(file); + } + }; - if (view.getMode() === "preview") { - const scroll = view.previewMode?.getScroll?.() ?? 0; - view.previewMode?.rerender(true); - view.previewMode?.applyScroll?.(scroll); - } + const schedule = (file: TFile) => { + pendingFiles.set(file.path, file); + if (timerId !== null) { + window.clearTimeout(timerId); } + timerId = window.setTimeout(flush, METADATA_CHANGE_DEBOUNCE_MS); }; + plugin.registerEvent(plugin.app.metadataCache.on("changed", schedule)); + // Deletes and renames don't fire "changed", but remote references that + // point at the affected note must revert or resolve. + plugin.registerEvent(plugin.app.vault.on("delete", (file) => { + if (file instanceof TFile) { + schedule(file); + } + })); + plugin.registerEvent(plugin.app.vault.on("rename", (file) => { + if (file instanceof TFile) { + schedule(file); + } + })); + plugin.register(() => { + if (timerId !== null) { + window.clearTimeout(timerId); + timerId = null; + } + }); + const refreshAll = () => { const leaves = plugin.app.workspace.getLeavesOfType("markdown"); for (const leaf of leaves) { @@ -94,12 +308,202 @@ export function registerMarkdownRefresh( } }; - plugin.registerEvent(plugin.app.metadataCache.on("changed", (file) => { - refreshForFile(file); - onLivePreviewRefresh?.(file); - })); + return {refreshAll}; +} + +function findMarkerEnd(text: string, start: number, style: SyntaxStyle): number { + if (style === "doubleBraces") { + const end = text.indexOf(getSyntaxClose(style), start + getSyntaxOpen(style).length); + return end === -1 ? -1 : end + getSyntaxClose(style).length; + } + + let pos = start + getSyntaxOpen(style).length; + while (pos < text.length) { + if (text.startsWith("[[", pos)) { + const wikiEnd = text.indexOf("]]", pos + 2); + if (wikiEnd === -1) { + return -1; + } + pos = wikiEnd + 2; + continue; + } + + const markdownLinkEnd = findMarkdownLinkEnd(text, pos); + if (markdownLinkEnd !== null) { + pos = markdownLinkEnd; + continue; + } + + if (text[pos] === "]") { + return pos + 1; + } + + pos += 1; + } + + return -1; +} + +function parseRemoteReference(raw: string, allowEmptyKey: boolean): MetadataReference | null { + if (raw.startsWith("[[")) { + const wikiEnd = raw.indexOf("]]", 2); + if (wikiEnd === -1) { + return null; + } + + const key = parseRemoteKey(raw.slice(wikiEnd + 2), allowEmptyKey); + if (key === null) { + return null; + } + + return { + raw, + key, + target: { + type: "wiki", + linktext: raw.slice(2, wikiEnd).trim(), + }, + }; + } + + const markdownLinkEnd = findMarkdownLinkEnd(raw, 0); + if (markdownLinkEnd === null) { + return null; + } + + const destination = parseMarkdownDestination(raw, 0); + const key = parseRemoteKey(raw.slice(markdownLinkEnd), allowEmptyKey); + if (destination === null || key === null) { + return null; + } + + return { + raw, + key, + target: { + type: "markdown", + destination, + }, + }; +} + +function parseRemoteKey(raw: string, allowEmptyKey: boolean): string | null { + const trimmed = raw.trim(); + if (!trimmed.startsWith("#")) { + return null; + } + + const key = trimmed.slice(1).trim(); + if (!allowEmptyKey && !key) { + return null; + } + return key; +} + +function findMarkdownLinkEnd(text: string, start: number): number | null { + if (text[start] !== "[" || text.startsWith("[[", start)) { + return null; + } + + const labelEnd = findClosingBracket(text, start + 1); + if (labelEnd === -1 || text[labelEnd + 1] !== "(") { + return null; + } + + const destinationEnd = findClosingParen(text, labelEnd + 2); + return destinationEnd === -1 ? null : destinationEnd + 1; +} + +function parseMarkdownDestination(text: string, start: number): string | null { + const labelEnd = findClosingBracket(text, start + 1); + if (labelEnd === -1 || text[labelEnd + 1] !== "(") { + return null; + } + + const destinationEnd = findClosingParen(text, labelEnd + 2); + if (destinationEnd === -1) { + return null; + } + + return text.slice(labelEnd + 2, destinationEnd).trim(); +} + +function findClosingBracket(text: string, start: number): number { + for (let pos = start; pos < text.length; pos += 1) { + if (text[pos] === "\\") { + pos += 1; + continue; + } + if (text[pos] === "]") { + return pos; + } + } + return -1; +} + +function findClosingParen(text: string, start: number): number { + let depth = 0; + for (let pos = start; pos < text.length; pos += 1) { + if (text[pos] === "\\") { + pos += 1; + continue; + } + if (text[pos] === "(") { + depth += 1; + continue; + } + if (text[pos] === ")") { + if (depth === 0) { + return pos; + } + depth -= 1; + } + } + return -1; +} + +function getTargetLinkpath(target: MetadataTarget): string { + if (target.type === "wiki") { + const withoutAlias = target.linktext.split("|")[0]?.trim() ?? ""; + return stripSubpath(withoutAlias); + } + + return stripSubpath(cleanMarkdownDestination(target.destination)); +} + +function getLinkpathCandidates(linkpath: string): string[] { + const candidates = new Set(); + const normalized = normalizePath(linkpath); + candidates.add(normalized); + + if (normalized.toLowerCase().endsWith(".md")) { + candidates.add(normalized.slice(0, -3)); + } else { + candidates.add(`${normalized}.md`); + } + + return Array.from(candidates); +} + +function stripSubpath(linkpath: string): string { + return linkpath.split("#")[0]?.trim() ?? ""; +} + +function cleanMarkdownDestination(destination: string): string { + const withoutTitle = destination.replace(/\s+["'][^"']*["']\s*$/, "").trim(); + const unwrapped = withoutTitle.startsWith("<") && withoutTitle.endsWith(">") + ? withoutTitle.slice(1, -1) + : withoutTitle; + + try { + return decodeURI(unwrapped); + } catch { + return unwrapped; + } +} - return {refreshAll, refreshForFile}; +function isExternalLinkpath(linkpath: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(linkpath) || linkpath.startsWith("//") || linkpath.startsWith("#"); } // Resolve a dot-path in frontmatter and stringify the result. diff --git a/src/migration-modal.ts b/src/migration-modal.ts index 19df71a..d46155e 100644 --- a/src/migration-modal.ts +++ b/src/migration-modal.ts @@ -1,6 +1,6 @@ // UI to review and run syntax migrations across the vault import {App, Modal, Notice, TFile} from "obsidian"; -import {getSyntaxClose, getSyntaxOpen, getSyntaxRegex, SyntaxStyle} from "./metadata-utils"; +import {findMetadataMarkers, getSyntaxClose, getSyntaxOpen, SyntaxStyle} from "./metadata-utils"; import {EmbedMetadataPlugin} from "./settings"; type MigrationMode = "dataview" | "otherSyntax"; @@ -118,7 +118,7 @@ export class MigrationModal extends Modal { if (style === current) { continue; } - total += countRegexMatches(getSyntaxRegex(style), content); + total += findMetadataMarkers(content, style).length; } return total; } @@ -191,11 +191,33 @@ function replaceOtherSyntax( if (style === currentStyle) { continue; } - updated = updated.replace(getSyntaxRegex(style), (_, key: string) => `${open}${key}${close}`); + updated = replaceSyntaxMarkers(updated, style, open, close); } return updated; } +function replaceSyntaxMarkers( + content: string, + style: SyntaxStyle, + open: string, + close: string +): string { + const markers = findMetadataMarkers(content, style); + if (markers.length === 0) { + return content; + } + + let lastIndex = 0; + const parts: string[] = []; + for (const marker of markers) { + parts.push(content.slice(lastIndex, marker.from)); + parts.push(`${open}${marker.raw}${close}`); + lastIndex = marker.to; + } + parts.push(content.slice(lastIndex)); + return parts.join(""); +} + function countRegexMatches(regex: RegExp, content: string): number { let count = 0; regex.lastIndex = 0; diff --git a/src/outline-renderer.ts b/src/outline-renderer.ts index 7e883b7..5227a24 100644 --- a/src/outline-renderer.ts +++ b/src/outline-renderer.ts @@ -1,5 +1,5 @@ import {TFile, WorkspaceLeaf} from "obsidian"; -import {createFrontmatterResolver, getSyntaxOpen, getSyntaxRegex} from "./metadata-utils"; +import {createMetadataResolver, findMetadataMarkers, getSyntaxOpen} from "./metadata-utils"; import {renderInlineMarkdownText} from "./markdown-render"; import {EmbedMetadataPlugin} from "./settings"; @@ -71,20 +71,13 @@ function updateOutlineView(plugin: EmbedMetadataPlugin, leaf: WorkspaceLeaf): vo return; } - const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter ?? null; - if (!frontmatter && !plugin.settings.builtInKeysEnabled) { - resetOutlineView(leaf); - return; - } - - const resolveValue = createFrontmatterResolver( - frontmatter ?? {}, - plugin.settings.caseInsensitiveKeys, + const resolver = createMetadataResolver( + plugin.app, file, + plugin.settings.caseInsensitiveKeys, plugin.settings.builtInKeysEnabled ); const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); - const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); const items = Array.from(container.querySelectorAll(OUTLINE_ITEM_SELECTOR)); for (const item of items) { @@ -101,15 +94,19 @@ function updateOutlineView(plugin: EmbedMetadataPlugin, leaf: WorkspaceLeaf): vo let next = raw; if (raw.includes(syntaxOpen)) { - syntaxRegex.lastIndex = 0; - next = raw.replace(syntaxRegex, (fullMatch: string, key: string) => { - const trimmed = (key ?? "").trim(); - if (!trimmed) { - return fullMatch; + const markers = findMetadataMarkers(raw, plugin.settings.syntaxStyle); + if (markers.length > 0) { + let lastIndex = 0; + const parts: string[] = []; + for (const marker of markers) { + parts.push(raw.slice(lastIndex, marker.from)); + const result = resolver.resolve(marker); + parts.push(result.resolved ? result.value : marker.marker); + lastIndex = marker.to; + } + parts.push(raw.slice(lastIndex)); + next = parts.join(""); } - const value = resolveValue(trimmed); - return value === null ? fullMatch : value; - }); } const previous = item.dataset.embedMetadataRendered ?? ""; diff --git a/src/settings.ts b/src/settings.ts index 8a3897e..124995f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -10,8 +10,6 @@ export interface EmbedMetadataSettings { bold: boolean; italic: boolean; underline: boolean; - underlineColorEnabled: boolean; - underlineColor: string; highlight: boolean; highlightColorEnabled: boolean; highlightColor: string; @@ -27,8 +25,6 @@ export const DEFAULT_SETTINGS: EmbedMetadataSettings = { bold: false, italic: false, underline: true, - underlineColorEnabled: false, - underlineColor: "#000000", highlight: false, highlightColorEnabled: false, highlightColor: "#fff59d", @@ -149,26 +145,6 @@ export class EmbedMetadataSettingTab extends PluginSettingTab { }); }); - new Setting(containerEl) - .setName("Underline color") - .setDesc("Override underline color (otherwise uses text color).") - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.underlineColorEnabled) - .onChange(async (value) => { - this.plugin.settings.underlineColorEnabled = value; - await this.plugin.saveSettings(); - }); - }) - .addColorPicker((picker) => { - picker - .setValue(this.plugin.settings.underlineColor) - .onChange(async (value) => { - this.plugin.settings.underlineColor = value; - await this.plugin.saveSettings(); - }); - }); - new Setting(containerEl) .setName("Highlight") .setDesc("Highlight rendered values.") diff --git a/styles.css b/styles.css index 8d2ea0d..ac153d9 100644 --- a/styles.css +++ b/styles.css @@ -14,17 +14,18 @@ .embed-metadata-underline { text-decoration: underline; - text-decoration-color: var(--embed-metadata-underline-color, currentColor); } :where(.embed-metadata-strike) { text-decoration: line-through; - text-decoration-color: currentColor; } +/* Underline and strike can't share text-decoration without the partially + supported multi-keyword shorthand, so render the underline as a border + when both apply. */ .embed-metadata-underline.embed-metadata-strike { - text-decoration: underline line-through; - text-decoration-color: var(--embed-metadata-underline-color, currentColor); + text-decoration: line-through; + border-bottom: 1px solid currentColor; } :where(.embed-metadata-highlight) { diff --git a/versions.json b/versions.json index 57f4b73..8733030 100644 --- a/versions.json +++ b/versions.json @@ -8,5 +8,7 @@ "0.5.0": "0.15.0", "0.5.1": "0.15.0", "0.5.2": "0.15.0", - "0.6.0": "1.0.0" + "0.6.0": "1.0.0", + "0.7.0": "1.0.0", + "0.7.1": "1.0.0" }