diff --git a/src/computeInlineInsertIndex.test.ts b/src/computeInlineInsertIndex.test.ts index b907079..5c8a7f2 100644 --- a/src/computeInlineInsertIndex.test.ts +++ b/src/computeInlineInsertIndex.test.ts @@ -120,6 +120,16 @@ describe("computeInlineInsertIndex - never inside fenced code blocks", () => { }); }); +describe("computeInlineInsertIndex - Admonition metadata containers", () => { + it("inserts after a matching field inside an ad-* fence (#188)", () => { + const content = ["```ad-note", "watch:: a", "tail", "```"].join("\n"); + + expect(append(content, "watch", "b")).toBe( + ["```ad-note", "watch:: a", "watch:: b", "tail", "```"].join("\n"), + ); + }); +}); + describe("computeInlineInsertIndex - location: end", () => { it("appends at end of body even when a matching field exists earlier", () => { const content = ["watch:: a", "body", "more"].join("\n"); diff --git a/src/metaController.test.ts b/src/metaController.test.ts index f23feee..509a119 100644 --- a/src/metaController.test.ts +++ b/src/metaController.test.ts @@ -149,6 +149,34 @@ describe("MetaController inline Dataview writes", () => { ); }); + it("updatePropertyInFile rewrites inline fields inside Admonition fences (#188)", async () => { + const {controller, file, store} = setup( + [ + "````ad-note", + "title: Metadata", + "status:: Backlog", + "```js", + "status:: fenced example", + "```", + "````", + ].join("\n"), + ); + + await controller.updatePropertyInFile({key: "status", content: "Backlog", type: MetaType.Dataview}, "Done", file); + + expect(store.content).toBe( + [ + "````ad-note", + "title: Metadata", + "status:: Done", + "```js", + "status:: fenced example", + "```", + "````", + ].join("\n"), + ); + }); + it("updateMultipleInFile rewrites multiple real inline fields but leaves fenced examples untouched", async () => { const {controller, file, store} = setup( [ diff --git a/src/metaController.ts b/src/metaController.ts index 27415bf..1cac051 100644 --- a/src/metaController.ts +++ b/src/metaController.ts @@ -95,7 +95,8 @@ export default class MetaController { * {@link updatePropertyInFile}, which replaces every existing instance. * * Placement is computed by {@link MetaEditParser.computeInlineInsertIndex} so the - * field is never inserted inside frontmatter or a fenced code block. The write goes + * field is never inserted inside frontmatter or an ordinary fenced code block. + * Admonition `ad-*` fences remain readable metadata containers. The write goes * through the per-file queue, so it serializes with other MetaEdit writes instead of * racing them (the previous implementation read and wrote outside the queue, and * silently no-opped when the chosen line index was 0). diff --git a/src/parser.test.ts b/src/parser.test.ts index 26d26f4..7a78f57 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -398,6 +398,45 @@ describe("MetaEditParser inline field parsing", () => { ]); }); + it("reads fields inside Admonition fences while keeping ordinary code fences hidden (#188)", () => { + const content = [ + "outside:: yes", + "```ad-note", + "title: Metadata", + "insideNote:: visible", + "```", + "~~~ad-warning", + "insideWarning:: visible", + "~~~", + "```js", + "insideJs:: hidden", + "```", + ].join("\n"); + + expect(parseInline(content)).toEqual([ + {key: "outside", content: "yes"}, + {key: "insideNote", content: "visible"}, + {key: "insideWarning", content: "visible"}, + ]); + }); + + it("keeps code fences nested inside an Admonition hidden (#188)", () => { + const content = [ + "````ad-note", + "before:: visible", + "```js", + "insideJs:: hidden", + "```", + "after:: visible", + "````", + ].join("\n"); + + expect(parseInline(content)).toEqual([ + {key: "before", content: "visible"}, + {key: "after", content: "visible"}, + ]); + }); + it("keeps a shorter inner fence from closing a longer outer fence", () => { const content = [ "real:: yes", diff --git a/src/parser.ts b/src/parser.ts index 9ade49a..eac4f88 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -19,7 +19,8 @@ export type Property = { // Where to insert a newly appended inline field instance. // - "afterLastMatch": right after the last existing `name::` line, else end of body. // - "end": always at the end of the body (after the last content, after a trailing -// closed code fence), never inside frontmatter or a fenced code block. +// closed code fence), never inside frontmatter or an ordinary code block. +// Admonition `ad-*` fences are readable metadata containers, not code examples. export type InlineFieldInsertLocation = "afterLastMatch" | "end"; // `start`/`end` are the field's span in the line; `sepEnd` is the offset just // after `::`; `valueEnd` is where the value content ends (the closing bracket @@ -52,33 +53,61 @@ const INLINE_FIELD_WRAPPERS: Readonly> = Object.freeze({" const FULL_LINE_PREFIX = /^\s*(?:>\s+)*(?:[-*+]\s+|\d+[.)]\s+)?/; // Opening fences for code blocks (``` or ~~~), optionally indented up to three -// spaces. Inline fields inside code blocks are examples, not metadata. +// spaces. Inline fields inside ordinary code blocks are examples, not metadata. +// Admonition uses `ad-*` fences as rendered Markdown containers, so their +// contents remain readable to preserve MetaEdit's established integration. const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/; -type OpenFence = {char: string, len: number}; +type OpenFence = {char: string, len: number, readable: boolean}; +type FenceLine = {char: string, len: number, bare: boolean, infoString: string}; // A potential fence line: the run's character, its length, and whether the run is // followed only by whitespace. A closing fence must be "bare" (no info string); // an opening fence may carry an info string (e.g. ```dataview). -function matchFenceLine(line: string): {char: string, len: number, bare: boolean} | null { +function matchFenceLine(line: string): FenceLine | null { const match = line.match(CODE_FENCE); if (!match) return null; const run = match[1]; - return {char: run[0], len: run.length, bare: line.slice(match[0].length).trim() === ""}; + const infoString = line.slice(match[0].length).trim(); + return {char: run[0], len: run.length, bare: infoString === "", infoString}; } // Advance fenced-code state by one line. A block closes only on a run of the SAME -// character at least as long as the opener, followed by whitespace - so a shorter -// inner fence (e.g. ``` shown inside a ```` block) does not close the outer block -// early and leak its example fields as metadata. -function nextFenceState(openFence: OpenFence | null, line: string): {open: OpenFence | null, boundary: "open" | "close" | null} { +// character at least as long as the opener, followed by whitespace. Readable +// Admonition containers may hold shorter or differently-delimited nested fences; +// those temporarily hide their code examples until their own closing marker. +function nextFenceState(openFences: readonly OpenFence[], line: string): {open: readonly OpenFence[], boundary: "open" | "close" | null} { const fence = matchFenceLine(line); - if (!fence) return {open: openFence, boundary: null}; - if (openFence === null) return {open: {char: fence.char, len: fence.len}, boundary: "open"}; + if (!fence) return {open: openFences, boundary: null}; + + const openFence = openFences[openFences.length - 1]; + if (!openFence) { + return { + open: [{char: fence.char, len: fence.len, readable: fence.infoString.startsWith("ad-")}], + boundary: "open", + }; + } if (fence.char === openFence.char && fence.len >= openFence.len && fence.bare) { - return {open: null, boundary: "close"}; + return {open: openFences.slice(0, -1), boundary: "close"}; + } + + // An ordinary code fence treats every non-closing fence marker as code. A + // readable Admonition instead parses its Markdown body, where a shorter + // same-character fence (or any differently-delimited fence) may nest. + const canOpenNestedFence = openFence.readable && + (fence.char !== openFence.char || fence.len < openFence.len); + if (canOpenNestedFence) { + return { + open: [...openFences, { + char: fence.char, + len: fence.len, + readable: fence.infoString.startsWith("ad-"), + }], + boundary: "open", + }; } - return {open: openFence, boundary: null}; + + return {open: openFences, boundary: null}; } export default class MetaEditParser { @@ -310,7 +339,7 @@ export default class MetaEditParser { private *walkInlineContentLines(content: string): IterableIterator { const lines = this.splitContentLines(content); const frontmatterInfo = this.getFrontmatterInfo(content); - let openFence: OpenFence | null = null; + let openFences: readonly OpenFence[] = []; for (let index = 0; index < lines.length; index++) { const line = lines[index]; @@ -321,15 +350,15 @@ export default class MetaEditParser { continue; } - const {open, boundary} = nextFenceState(openFence, line.text); - openFence = open; + const {open, boundary} = nextFenceState(openFences, line.text); + openFences = open; yield { ...line, index, inFrontmatter: false, fenceBoundary: boundary, - readable: boundary === null && openFence === null, + readable: boundary === null && openFences.every(fence => fence.readable), }; } } @@ -497,7 +526,8 @@ export default class MetaEditParser { * returned index is into `content.split(/\r?\n/)` (the same split * {@link splitContentLines} performs), so a caller can splice directly. * - * Guarantees: the index is never inside YAML frontmatter or a fenced code block. + * Guarantees: the index is never inside YAML frontmatter or an ordinary fenced + * code block. Admonition `ad-*` fences remain valid metadata regions. * - "afterLastMatch": just after the last body line that declares `name` (full-line * or bracketed), falling back to "end" when there is no such field. * - "end": just after the last body content line or trailing closing code fence. @@ -524,7 +554,7 @@ export default class MetaEditParser { lastAnchorIdx = line.index; continue; } - if (!line.readable) continue; // fence interior - never a target + if (!line.readable) continue; // ordinary fence interior - never a target if (line.text.trim() !== "") lastAnchorIdx = line.index; if (line.text.includes("::") && this.parseLineFields(line.text).some(field => field.key === name)) { diff --git a/tests/e2e/metaedit-runtime.test.ts b/tests/e2e/metaedit-runtime.test.ts index 17abbc9..76b6a53 100644 --- a/tests/e2e/metaedit-runtime.test.ts +++ b/tests/e2e/metaedit-runtime.test.ts @@ -129,6 +129,84 @@ describe("MetaEdit runtime", () => { expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); + test("reads and updates Dataview fields inside Admonition fences (#188)", async () => { + const { obsidian, sandbox } = getContext(); + + const notePath = sandbox.path("admonition-inline-fields.md"); + await writeLiveFile( + obsidian, + notePath, + [ + "```ad-note", + "title: Meta data", + "collapse: closed", + "", + "NoteDate:: 2024-03-07", + "Id:: PORT-18", + "Summary:: Some summary here", + "```", + "", + "````ad-warning", + "NestedVisible:: visible", + "```js", + "Id:: NESTED-CODE", + "```", + "````", + "", + "```js", + "InsideJs:: hidden", + "```", + ].join("\n"), + ); + + const before = await evalJsonAsync<{ + noteDate: string; + id: string; + summary: string; + nestedVisible: string; + insideJs: string | null; + keys: string[]; + }>( + obsidian, + ` + (async () => { + const api = app.plugins.plugins.${PLUGIN_ID}.api; + const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); + return { + noteDate: await api.getPropertyValue("NoteDate", file), + id: await api.getPropertyValue("Id", file), + summary: await api.getPropertyValue("Summary", file), + nestedVisible: await api.getPropertyValue("NestedVisible", file), + insideJs: (await api.getPropertyValue("InsideJs", file)) ?? null, + keys: (await api.getPropertiesInFile(file)).map((property) => property.key), + }; + })() + `, + ); + + expect(before).toEqual({ + noteDate: "2024-03-07", + id: "PORT-18", + summary: "Some summary here", + nestedVisible: "visible", + insideJs: null, + keys: ["NoteDate", "Id", "Summary", "NestedVisible"], + }); + + await callApi(obsidian, "update", ["Id", "PORT-19", notePath]); + + const content = await sandbox.waitForContent( + "admonition-inline-fields.md", + (value) => value.includes("Id:: PORT-19"), + WAIT_OPTS, + ); + expect(content).toContain("Id:: PORT-19"); + expect(content).toContain("Id:: NESTED-CODE"); + expect(content).toContain("InsideJs:: hidden"); + expect(await callApi(obsidian, "getPropertyValue", ["Id", notePath])).toBe("PORT-19"); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); + test("parses inline fields after an unmatched leading thematic break", async () => { const { obsidian, sandbox } = getContext();