Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/computeInlineInsertIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
28 changes: 28 additions & 0 deletions src/metaController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
3 changes: 2 additions & 1 deletion src/metaController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
39 changes: 39 additions & 0 deletions src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 49 additions & 19 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,33 +53,61 @@ const INLINE_FIELD_WRAPPERS: Readonly<Record<string, string>> = 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 {
Expand Down Expand Up @@ -310,7 +339,7 @@ export default class MetaEditParser {
private *walkInlineContentLines(content: string): IterableIterator<InlineContentLine> {
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];
Expand All @@ -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),
};
}
}
Expand Down Expand Up @@ -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.
Expand All @@ -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)) {
Expand Down
78 changes: 78 additions & 0 deletions tests/e2e/metaedit-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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();

Expand Down