From 00f404a98494825e743ac1967a75658e920213fa Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 14 Mar 2026 20:20:16 +0100 Subject: [PATCH 1/3] fix(template): preserve wikilink lists in frontmatter --- ...oiceEngine.template-property-types.test.ts | 6 +- .../formatter-template-property-types.test.ts | 9 + src/formatters/formatter.ts | 10 +- src/utils/TemplatePropertyCollector.ts | 19 +- src/utils/yamlValues.test.ts | 36 +++- src/utils/yamlValues.ts | 26 +++ tests/e2e/template-property-links.test.ts | 188 ++++++++++++++++++ 7 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/template-property-links.test.ts diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index 39d5de65..02b9800d 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -147,7 +147,7 @@ describe("CaptureChoiceEngine template property types", () => { }; }); - it("post-processes capture frontmatter arrays into YAML lists", async () => { + it("writes a YAML-safe placeholder before post-processing capture frontmatter arrays", async () => { const targetPath = "Journal/Test.md"; const createdContent: Record = {}; let writtenContent = ""; @@ -270,7 +270,9 @@ describe("CaptureChoiceEngine template property types", () => { await engine.run(); - expect(writtenContent).toContain("tags: foo,bar"); + // The raw file write only needs to stay YAML-parseable; processFrontMatter + // applies the final structured array value afterward. + expect(writtenContent).toContain("tags: []"); expect(processFrontMatter).toHaveBeenCalledTimes(1); expect(appliedFrontmatter?.tags).toEqual(["foo", "bar"]); }); diff --git a/src/formatters/formatter-template-property-types.test.ts b/src/formatters/formatter-template-property-types.test.ts index 395098f7..066a24c0 100644 --- a/src/formatters/formatter-template-property-types.test.ts +++ b/src/formatters/formatter-template-property-types.test.ts @@ -129,6 +129,15 @@ describe('Formatter template property type inference', () => { expect(vars.get('projects')).toEqual(['project1', 'project2']); }); + it('uses a YAML-safe placeholder for collected arrays before post-processing', async () => { + (formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']); + const output = await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---'); + const vars = formatter.getAndClearTemplatePropertyVars(); + + expect(output).toBe('---\ntags: []\n---'); + expect(vars.get('tags')).toEqual(['[[John Doe]]', '[[Jane Doe]]']); + }); + it('ignores wiki links with commas to avoid incorrect splitting', async () => { (formatter as any).variables.set('source', '[[test, a]]'); await formatter.testFormat('---\nsource: {{VALUE:source}}\n---'); diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 40049548..790fc7c8 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -27,6 +27,7 @@ import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector"; import { settingsStore } from "../settingsStore"; import { normalizeDateInput } from "../utils/dateAliases"; import { transformCase } from "../utils/caseTransform"; +import { getYamlPlaceholder } from "../utils/yamlValues"; import { parseAnonymousValueOptions, parseValueToken, @@ -386,7 +387,7 @@ export abstract class Formatter { : rawValue; // Offer this variable to the property collector for YAML post-processing - this.propertyCollector.maybeCollect({ + const structuredYamlValue = this.propertyCollector.maybeCollect({ input: output, matchStart: match.index, matchEnd: match.index + match[0].length, @@ -395,8 +396,11 @@ export abstract class Formatter { featureEnabled: propertyTypesEnabled, }); - // Always use string replacement initially - const rawReplacement = this.getVariableValue(effectiveKey); + // Keep the interim frontmatter YAML-parseable until post-processing + // writes the real structured value back through Obsidian. + const rawReplacement = + getYamlPlaceholder(structuredYamlValue) ?? + this.getVariableValue(effectiveKey); const replacement = transformCase(rawReplacement, caseStyle); // Replace in output and adjust regex position diff --git a/src/utils/TemplatePropertyCollector.ts b/src/utils/TemplatePropertyCollector.ts index 4f9394f1..36874d6c 100644 --- a/src/utils/TemplatePropertyCollector.ts +++ b/src/utils/TemplatePropertyCollector.ts @@ -4,6 +4,7 @@ import { parseStructuredPropertyValueFromString, type ParseOptions, } from "./templatePropertyStringParser"; +import { isStructuredYamlValue } from "./yamlValues"; const PATH_SEPARATOR = "\u0000"; @@ -26,13 +27,13 @@ export class TemplatePropertyCollector { * Collects a variable for YAML post-processing when it is a complete value for a YAML key * and the raw value is a structured type (object/array/number/boolean/null). */ - public maybeCollect(args: CollectArgs): void { + public maybeCollect(args: CollectArgs): unknown | undefined { const { input, matchStart, matchEnd, rawValue, fallbackKey, featureEnabled } = args; - if (!featureEnabled) return; + if (!featureEnabled) return undefined; const yamlRange = findYamlFrontMatterRange(input); const context = getYamlContextForMatch(input, matchStart, matchEnd, yamlRange); - if (!context.isInYaml) return; + if (!context.isInYaml) return undefined; const lineContent = input.slice(context.lineStart, context.lineEnd); const trimmedLine = lineContent.trim(); @@ -49,7 +50,7 @@ export class TemplatePropertyCollector { propertyPath = this.findListParentPath(input, context.lineStart, context.baseIndent ?? ""); } - if (!propertyPath || propertyPath.length === 0) return; + if (!propertyPath || propertyPath.length === 0) return undefined; const effectiveKey = propertyPath[propertyPath.length - 1]; let structuredValue = rawValue; @@ -61,17 +62,11 @@ export class TemplatePropertyCollector { } } - const isStructured = - typeof structuredValue !== "string" && - (Array.isArray(structuredValue) || - (typeof structuredValue === "object" && structuredValue !== null) || - typeof structuredValue === "number" || - typeof structuredValue === "boolean" || - structuredValue === null); - if (!isStructured) return; + if (!isStructuredYamlValue(structuredValue)) return undefined; const mapKey = propertyPath.join(PATH_SEPARATOR); this.map.set(mapKey, structuredValue); + return structuredValue; } /** Returns a copy and clears the collector. */ diff --git a/src/utils/yamlValues.test.ts b/src/utils/yamlValues.test.ts index 0ccee9f8..73102833 100644 --- a/src/utils/yamlValues.test.ts +++ b/src/utils/yamlValues.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { coerceYamlValue } from "./yamlValues"; +import { + coerceYamlValue, + getYamlPlaceholder, + isStructuredYamlValue, +} from "./yamlValues"; describe("coerceYamlValue", () => { it("converts @date:ISO to Date", () => { @@ -23,3 +27,33 @@ describe("coerceYamlValue", () => { expect(coerceYamlValue(arr)).toBe(arr); }); }); + +describe("isStructuredYamlValue", () => { + it("accepts structured YAML property values", () => { + expect(isStructuredYamlValue(["a"])).toBe(true); + expect(isStructuredYamlValue({ a: 1 })).toBe(true); + expect(isStructuredYamlValue(42)).toBe(true); + expect(isStructuredYamlValue(false)).toBe(true); + expect(isStructuredYamlValue(null)).toBe(true); + }); + + it("rejects plain string placeholders", () => { + expect(isStructuredYamlValue("hello")).toBe(false); + expect(isStructuredYamlValue(undefined)).toBe(false); + }); +}); + +describe("getYamlPlaceholder", () => { + it("returns YAML-safe placeholders for structured values", () => { + expect(getYamlPlaceholder(["a"])).toBe("[]"); + expect(getYamlPlaceholder({ a: 1 })).toBe("{}"); + expect(getYamlPlaceholder(42)).toBe("42"); + expect(getYamlPlaceholder(true)).toBe("true"); + expect(getYamlPlaceholder(null)).toBe("null"); + }); + + it("returns undefined for non-structured values", () => { + expect(getYamlPlaceholder("hello")).toBeUndefined(); + expect(getYamlPlaceholder(undefined)).toBeUndefined(); + }); +}); diff --git a/src/utils/yamlValues.ts b/src/utils/yamlValues.ts index 5a6db573..d0e12931 100644 --- a/src/utils/yamlValues.ts +++ b/src/utils/yamlValues.ts @@ -37,3 +37,29 @@ export function coerceYamlValue(v: unknown): unknown { // Return original value for non-@date: strings and invalid dates return v; } + +/** + * Returns whether a value should be written back through Obsidian's YAML + * processor as a structured property type instead of plain string text. + */ +export function isStructuredYamlValue(v: unknown): boolean { + return typeof v !== "string" && ( + Array.isArray(v) || + (typeof v === "object" && v !== null) || + typeof v === "number" || + typeof v === "boolean" || + v === null + ); +} + +/** + * Produces a YAML-parseable placeholder for structured values so frontmatter + * stays valid until processFrontMatter rewrites the final value. + */ +export function getYamlPlaceholder(v: unknown): string | undefined { + if (!isStructuredYamlValue(v)) return undefined; + if (Array.isArray(v)) return "[]"; + if (v === null) return "null"; + if (typeof v === "object") return "{}"; + return String(v); +} diff --git a/tests/e2e/template-property-links.test.ts b/tests/e2e/template-property-links.test.ts new file mode 100644 index 00000000..412effdd --- /dev/null +++ b/tests/e2e/template-property-links.test.ts @@ -0,0 +1,188 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + acquireVaultRunLock, + captureFailureArtifacts, + clearVaultRunLockMarker, + createObsidianClient, + createSandboxApi, +} from "obsidian-e2e"; +import type { + ObsidianClient, + PluginHandle, + SandboxApi, + VaultRunLock, +} from "obsidian-e2e"; + +const VAULT = "dev"; +const PLUGIN_ID = "quickadd"; +const WAIT_OPTS = { timeoutMs: 10_000, intervalMs: 200 }; + +let obsidian: ObsidianClient; +let sandbox: SandboxApi; +let qa: PluginHandle; +let lock: VaultRunLock | undefined; + +type QuickAddData = { + choices: Record[]; + migrations: Record; + enableTemplatePropertyTypes?: boolean; +}; + +function templateChoice(id: string, templatePath: string, format: string) { + return { + id, + name: id, + type: "Template", + command: false, + templatePath, + fileNameFormat: { enabled: true, format }, + folder: { + enabled: false, + folders: [], + chooseWhenCreatingNote: false, + createInSameFolderAsActiveFile: false, + chooseFromSubfolders: false, + }, + appendLink: false, + openFile: false, + fileOpening: { + location: "tab", + direction: "vertical", + mode: "source", + focus: false, + }, + }; +} + +function clearTestChoices(data: QuickAddData) { + data.choices = data.choices.filter( + (choice) => !String(choice.id ?? "").startsWith("__qa-test-1140-"), + ); +} + +async function seedTemplate(path: string, content: string) { + await sandbox.write(path, content, { + waitForContent: true, + waitOptions: WAIT_OPTS, + }); +} + +async function runChoice(name: string, vars: Record) { + await obsidian.exec("quickadd:run", { + choice: name, + vars: JSON.stringify(vars), + }); +} + +async function runChoiceAndWaitForContent( + name: string, + vars: Record, + file: string, + expected: string, +) { + await runChoice(name, vars); + return sandbox.waitForContent( + file, + (content) => content.includes(expected), + WAIT_OPTS, + ); +} + +beforeAll(async () => { + obsidian = createObsidianClient({ vault: VAULT }); + await obsidian.verify(); + + lock = await acquireVaultRunLock({ + vaultName: VAULT, + vaultPath: await obsidian.vaultPath(), + }); + await lock.publishMarker(obsidian); + + qa = obsidian.plugin(PLUGIN_ID); + sandbox = await createSandboxApi({ + obsidian, + sandboxRoot: "__obsidian_e2e__", + testName: "template-property-links", + }); +}, 30_000); + +afterAll(async () => { + await qa.restoreData(); + await qa.reload(); + await sandbox.cleanup(); + await clearVaultRunLockMarker(obsidian).catch(() => {}); + await lock?.release(); +}, 15_000); + +beforeEach((ctx) => { + ctx.onTestFailed(async () => { + await captureFailureArtifacts( + { id: ctx.task.id, name: ctx.task.name }, + obsidian, + { plugin: qa, captureOnFailure: true }, + ); + }); +}); + +describe("issue 1140: list properties with links", () => { + beforeAll(async () => { + const root = sandbox.root; + const templatePath = sandbox.path("issue-1140-template.md"); + + await seedTemplate( + "issue-1140-template.md", + [ + "---", + "authors: {{VALUE:authors}}", + "---", + "", + ].join("\n"), + ); + + await qa.data().patch((data) => { + clearTestChoices(data); + data.enableTemplatePropertyTypes = true; + data.choices.push( + templateChoice( + "__qa-test-1140-single-link", + templatePath, + `${root}/qa-1140-single-link`, + ), + templateChoice( + "__qa-test-1140-multi-link", + templatePath, + `${root}/qa-1140-multi-link`, + ), + ); + }); + + await qa.reload({ waitUntilReady: true }); + }, 15_000); + + it("formats a single wikilink list item as a YAML list", async () => { + const content = await runChoiceAndWaitForContent( + "__qa-test-1140-single-link", + { authors: ["[[John Doe]]"] }, + "qa-1140-single-link.md", + ' - "[[John Doe]]"', + ); + + expect(content).toContain("authors:"); + expect(content).toContain(' - "[[John Doe]]"'); + expect(content).not.toContain("authors: [[John Doe]]"); + }); + + it("formats multiple wikilinks as separate YAML list items", async () => { + const content = await runChoiceAndWaitForContent( + "__qa-test-1140-multi-link", + { authors: ["[[John Doe]]", "[[Jane Doe]]"] }, + "qa-1140-multi-link.md", + ' - "[[Jane Doe]]"', + ); + + expect(content).toContain("authors:"); + expect(content).toContain(' - "[[John Doe]]"'); + expect(content).toContain(' - "[[Jane Doe]]"'); + expect(content).not.toContain("authors: [[John Doe]],[[Jane Doe]]"); + }); +}); From 54bb3d8498c7753e732153cc33dd82bb03dc5134 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 14 Mar 2026 20:37:56 +0100 Subject: [PATCH 2/3] fix(template): scope YAML placeholders to post-processing --- .../CaptureChoiceEngine.selection.test.ts | 3 + src/engine/CaptureChoiceEngine.ts | 35 ++++--- src/engine/SingleTemplateEngine.ts | 4 +- .../TemplateChoiceEngine.notice.test.ts | 3 + src/engine/TemplateEngine.ts | 8 +- src/engine/templateEngine-title.test.ts | 3 + .../formatter-template-property-types.test.ts | 52 ++++++++-- src/formatters/formatter.ts | 29 +++++- tests/e2e/template-property-links.test.ts | 96 ++++++++++++++----- 9 files changed, 181 insertions(+), 52 deletions(-) diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts index 69e31658..06f9b837 100644 --- a/src/engine/CaptureChoiceEngine.selection.test.ts +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -23,6 +23,9 @@ vi.mock("../formatters/captureChoiceFormatter", () => ({ } setDestinationFile() {} setDestinationSourcePath() {} + async withTemplatePropertyCollection(work: () => Promise) { + return await work(); + } async formatContentOnly(content: string) { return content; } diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 6b70a479..8fff3227 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -628,18 +628,22 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { this.formatter.setDestinationFile(file); // First format pass... - const formatted = await this.formatter.formatContentOnly(content); + const formatted = await this.formatter.withTemplatePropertyCollection( + () => this.formatter.formatContentOnly(content), + ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); const fileContent: string = await this.app.vault.read(file); // Second format pass, with the file content... User input (long running) should have been captured during first pass // So this pass is to insert the formatted capture value into the file content, depending on the user's settings const formattedFileContent: string = - await this.formatter.formatContentWithFile( - formatted, - this.choice, - fileContent, - file, + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatContentWithFile( + formatted, + this.choice, + fileContent, + file, + ), ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); @@ -685,7 +689,9 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // This mirrors the logic used when the target file already exists and prevents the timing issue // where templater would run before the {{value}} placeholder is substituted (Issue #809). const formattedCaptureContent: string = - await this.formatter.formatContentOnly(captureContent); + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatContentOnly(captureContent), + ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); let fileContent = ""; @@ -743,12 +749,15 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // after the initial Templater run on newly created files. const updatedFileContent: string = await this.app.vault.read(file); // Second formatting pass: embed the already-resolved capture content into the newly created file - const newFileContent: string = await this.formatter.formatContentWithFile( - formattedCaptureContent, - this.choice, - updatedFileContent, - file, - ); + const newFileContent: string = + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatContentWithFile( + formattedCaptureContent, + this.choice, + updatedFileContent, + file, + ), + ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); return { file, newFileContent, captureContent: formattedCaptureContent }; diff --git a/src/engine/SingleTemplateEngine.ts b/src/engine/SingleTemplateEngine.ts index c6a0e932..0f7b35b7 100644 --- a/src/engine/SingleTemplateEngine.ts +++ b/src/engine/SingleTemplateEngine.ts @@ -21,8 +21,8 @@ export class SingleTemplateEngine extends TemplateEngine { log.logError(`Template ${this.templatePath} not found.`); } - templateContent = await this.formatter.formatFileContent( - templateContent + templateContent = await this.formatter.withTemplatePropertyCollection( + () => this.formatter.formatFileContent(templateContent), ); return templateContent; diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 146b940f..19111314 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -66,6 +66,9 @@ vi.mock("../formatters/completeFormatter", () => { async formatFileContent(...args: unknown[]) { return await formatFileContentMock(...args); } + async withTemplatePropertyCollection(work: () => Promise) { + return await work(); + } getAndClearTemplatePropertyVars() { return new Map(); } diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index accc2d4e..7453487a 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -480,7 +480,9 @@ export abstract class TemplateEngine extends QuickAddEngine { this.formatter.setTitle(fileBasename); const formattedTemplateContent: string = - await this.formatter.formatFileContent(templateContent); + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatFileContent(templateContent), + ); // Get template variables before creating the file const templateVars = this.formatter.getAndClearTemplatePropertyVars(); @@ -537,7 +539,9 @@ export abstract class TemplateEngine extends QuickAddEngine { this.formatter.setTitle(fileBasename); const formattedTemplateContent: string = - await this.formatter.formatFileContent(templateContent); + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatFileContent(templateContent), + ); // Get template variables before modifying the file const templateVars = this.formatter.getAndClearTemplatePropertyVars(); diff --git a/src/engine/templateEngine-title.test.ts b/src/engine/templateEngine-title.test.ts index 473a3a04..a3dfe1a1 100644 --- a/src/engine/templateEngine-title.test.ts +++ b/src/engine/templateEngine-title.test.ts @@ -12,6 +12,9 @@ vi.mock('../formatters/completeFormatter', () => { return { setTitle: vi.fn((t: string) => { title = t; }), getTitle: () => title, + withTemplatePropertyCollection: vi.fn( + async (work: () => Promise) => await work(), + ), formatFileContent: vi.fn(async (content: string) => { // Simple mock that replaces {{title}} with the stored title return content.replace(/{{title}}/gi, title); diff --git a/src/formatters/formatter-template-property-types.test.ts b/src/formatters/formatter-template-property-types.test.ts index 066a24c0..742fca8e 100644 --- a/src/formatters/formatter-template-property-types.test.ts +++ b/src/formatters/formatter-template-property-types.test.ts @@ -24,8 +24,7 @@ class TemplatePropertyTypesTestFormatter extends Formatter { } protected getVariableValue(variableName: string): string { - const value = this.variables.get(variableName); - return typeof value === 'string' ? value : ''; + return (this.variables.get(variableName) as string) ?? ''; } protected suggestForValue( @@ -85,6 +84,14 @@ class TemplatePropertyTypesTestFormatter extends Formatter { public async testFormat(input: string): Promise { return await this.format(input); } + + public async testFormatWithTemplatePropertyCollection( + input: string, + ): Promise { + return await this.withTemplatePropertyCollection(() => + this.testFormat(input), + ); + } } describe('Formatter template property type inference', () => { @@ -110,37 +117,68 @@ describe('Formatter template property type inference', () => { it('collects comma-separated values as YAML arrays', async () => { (formatter as any).variables.set('tags', 'tag1, tag2, awesomeproject'); - await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\ntags: {{VALUE:tags}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.get('tags')).toEqual(['tag1', 'tag2', 'awesomeproject']); }); it('does not collect comma text for scalar properties', async () => { (formatter as any).variables.set('description', 'Hello, world'); - await formatter.testFormat('---\ndescription: {{VALUE:description}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\ndescription: {{VALUE:description}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.has('description')).toBe(false); }); it('collects bullet list values as YAML arrays', async () => { (formatter as any).variables.set('projects', '- project1\n- project2'); - await formatter.testFormat('---\nprojects: {{VALUE:projects}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\nprojects: {{VALUE:projects}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.get('projects')).toEqual(['project1', 'project2']); }); - it('uses a YAML-safe placeholder for collected arrays before post-processing', async () => { + it('preserves raw structured replacements outside template property collection', async () => { (formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']); const output = await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---'); const vars = formatter.getAndClearTemplatePropertyVars(); + expect(output).toBe('---\ntags: [[John Doe]],[[Jane Doe]]\n---'); + expect(vars.size).toBe(0); + }); + + it('uses a YAML-safe placeholder for collected arrays before post-processing', async () => { + (formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']); + const output = await formatter.testFormatWithTemplatePropertyCollection( + '---\ntags: {{VALUE:tags}}\n---', + ); + const vars = formatter.getAndClearTemplatePropertyVars(); + expect(output).toBe('---\ntags: []\n---'); expect(vars.get('tags')).toEqual(['[[John Doe]]', '[[Jane Doe]]']); }); + it('does not apply case transforms to YAML placeholders', async () => { + (formatter as any).variables.set('done', null); + const output = + await formatter.testFormatWithTemplatePropertyCollection( + '---\ndone: {{VALUE:done|case:upper}}\n---', + ); + const vars = formatter.getAndClearTemplatePropertyVars(); + + expect(output).toBe('---\ndone: null\n---'); + expect(vars.get('done')).toBeNull(); + }); + it('ignores wiki links with commas to avoid incorrect splitting', async () => { (formatter as any).variables.set('source', '[[test, a]]'); - await formatter.testFormat('---\nsource: {{VALUE:source}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\nsource: {{VALUE:source}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.has('source')).toBe(false); }); diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 790fc7c8..8686cfdb 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -59,6 +59,7 @@ export abstract class Formatter { // Tracks variables collected for YAML property post-processing private readonly propertyCollector: TemplatePropertyCollector; + private templatePropertyCollectionDepth = 0; protected constructor(protected readonly app?: App) { this.propertyCollector = new TemplatePropertyCollector(app); @@ -303,6 +304,23 @@ export abstract class Formatter { return this.propertyCollector.drain(); } + /** + * Runs a formatting operation in a scope where structured YAML values should + * be collected and replaced with temporary placeholders for later + * `processFrontMatter()` post-processing. + */ + public async withTemplatePropertyCollection( + work: () => Promise, + ): Promise { + this.templatePropertyCollectionDepth += 1; + + try { + return await work(); + } finally { + this.templatePropertyCollectionDepth -= 1; + } + } + protected abstract getCurrentFileLink(): string | null; protected abstract getCurrentFileName(): string | null; @@ -393,15 +411,16 @@ export abstract class Formatter { matchEnd: match.index + match[0].length, rawValue: rawValueForCollector, fallbackKey: variableName, - featureEnabled: propertyTypesEnabled, + featureEnabled: + propertyTypesEnabled && + this.templatePropertyCollectionDepth > 0, }); // Keep the interim frontmatter YAML-parseable until post-processing // writes the real structured value back through Obsidian. - const rawReplacement = - getYamlPlaceholder(structuredYamlValue) ?? - this.getVariableValue(effectiveKey); - const replacement = transformCase(rawReplacement, caseStyle); + const placeholder = getYamlPlaceholder(structuredYamlValue); + const replacement = placeholder ?? + transformCase(this.getVariableValue(effectiveKey), caseStyle); // Replace in output and adjust regex position output = output.slice(0, match.index) + replacement + output.slice(match.index + match[0].length); diff --git a/tests/e2e/template-property-links.test.ts b/tests/e2e/template-property-links.test.ts index 412effdd..46c68023 100644 --- a/tests/e2e/template-property-links.test.ts +++ b/tests/e2e/template-property-links.test.ts @@ -74,18 +74,51 @@ async function runChoice(name: string, vars: Record) { }); } -async function runChoiceAndWaitForContent( +async function runChoiceAndWaitForFile( name: string, vars: Record, file: string, - expected: string, ) { await runChoice(name, vars); - return sandbox.waitForContent( - file, - (content) => content.includes(expected), - WAIT_OPTS, - ); + await sandbox.waitForExists(file, WAIT_OPTS); +} + +async function waitForFrontmatter( + file: string, + predicate: (frontmatter: Record | null) => boolean, +) { + const filePath = sandbox.path(file); + return obsidian.waitFor(async () => { + const rawFrontmatter = await obsidian.dev.eval( + `(() => { + const file = app.vault.getFileByPath(${JSON.stringify(filePath)}); + if (!file) return null; + return app.metadataCache.getFileCache(file)?.frontmatter ?? null; + })()`, + ); + const frontmatter = + rawFrontmatter && typeof rawFrontmatter === "object" + ? (rawFrontmatter as Record) + : null; + + return predicate(frontmatter) ? frontmatter : undefined; + }, WAIT_OPTS); +} + +async function runTeardownStep( + label: string, + step: () => Promise | unknown, + errors: unknown[], +) { + try { + await step(); + } catch (error) { + errors.push(error); + console.warn( + `template-property-links teardown failed during ${label}`, + error, + ); + } } beforeAll(async () => { @@ -107,11 +140,21 @@ beforeAll(async () => { }, 30_000); afterAll(async () => { - await qa.restoreData(); - await qa.reload(); - await sandbox.cleanup(); - await clearVaultRunLockMarker(obsidian).catch(() => {}); - await lock?.release(); + const errors: unknown[] = []; + + await runTeardownStep("restoreData", () => qa?.restoreData?.(), errors); + await runTeardownStep("reload", () => qa?.reload?.(), errors); + await runTeardownStep("sandbox cleanup", () => sandbox?.cleanup?.(), errors); + await runTeardownStep( + "clear vault run lock marker", + () => (obsidian ? clearVaultRunLockMarker(obsidian) : undefined), + errors, + ); + await runTeardownStep("release vault lock", () => lock?.release(), errors); + + if (errors.length > 0) { + throw errors[0]; + } }, 15_000); beforeEach((ctx) => { @@ -160,29 +203,36 @@ describe("issue 1140: list properties with links", () => { }, 15_000); it("formats a single wikilink list item as a YAML list", async () => { - const content = await runChoiceAndWaitForContent( + await runChoiceAndWaitForFile( "__qa-test-1140-single-link", { authors: ["[[John Doe]]"] }, "qa-1140-single-link.md", - ' - "[[John Doe]]"', + ); + const frontmatter = await waitForFrontmatter( + "qa-1140-single-link.md", + (value) => + Array.isArray(value?.authors) && value.authors.length === 1, ); - expect(content).toContain("authors:"); - expect(content).toContain(' - "[[John Doe]]"'); - expect(content).not.toContain("authors: [[John Doe]]"); + expect(frontmatter).toMatchObject({ + authors: ["[[John Doe]]"], + }); }); it("formats multiple wikilinks as separate YAML list items", async () => { - const content = await runChoiceAndWaitForContent( + await runChoiceAndWaitForFile( "__qa-test-1140-multi-link", { authors: ["[[John Doe]]", "[[Jane Doe]]"] }, "qa-1140-multi-link.md", - ' - "[[Jane Doe]]"', + ); + const frontmatter = await waitForFrontmatter( + "qa-1140-multi-link.md", + (value) => + Array.isArray(value?.authors) && value.authors.length === 2, ); - expect(content).toContain("authors:"); - expect(content).toContain(' - "[[John Doe]]"'); - expect(content).toContain(' - "[[Jane Doe]]"'); - expect(content).not.toContain("authors: [[John Doe]],[[Jane Doe]]"); + expect(frontmatter).toMatchObject({ + authors: ["[[John Doe]]", "[[Jane Doe]]"], + }); }); }); From c06f869bf8968d716a961829cf48b8b7eb75dae8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 16 Mar 2026 20:54:19 +0100 Subject: [PATCH 3/3] chore: upgrade obsidian-e2e to 0.6.0 --- bun.lockb | Bin 528893 -> 528925 bytes package.json | 2 +- tests/e2e/template-property-links.test.ts | 29 ++++++++-------------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/bun.lockb b/bun.lockb index af74b972e045f2152154fa75b7dc6e141a91b1e8..ced4413d749bcdb99ca3603c3ab5996100626869 100755 GIT binary patch delta 11901 zcmeI2dz_7R9>>o)#vd@sN6@Ao|Cxt#NR zo^R(?oi(rO(9RV9*>%By&*$sr`Fv&JK&a(HSf2d5Xy=Ecti70oJ=yGVs=vp~50eWU zPBlk3BizJY{vMEEu*LswaZ&A#Kn>_zFO7`?}_@XMR!p zp`rO3nZFno9i!m^6>Mw`!YTjdRxrcjVhsM4W`|P)ZDoE@8`8$?ADB?I1$hK&lW?OF z+^|3(sc>L7VXDqhsP;FTeT&(*LoK4lH^Sm0%`d9mD6_+}WW^{KZk*b-l&Iseui~Uk;9f8t`bSxhtE`Z2sb?c3X&RL$+F8I5i;e6#}K)=>7hrUdr2Pd7|QbU}bm$ zYQU$U#&;HK+1CmY5odNevlF0};;8)cmS4g0 zMAfSZwHei-?MP##aNM<_4(W8LMbvg>m>o`a(A4~*3TK)vYC~E<<>f-v?P&2I*tKW} zUS$DM9dcD#q>bk!-x?|Cn z?X!gaP>ZNNanS5=s)LX4Ym1IpT#R+gKlCT1tDmnim3G>~qWou|`v1;s9bN%9y0jhO zo=n0~P}MIoI~HmQr{ZPtt8APx-txk!yz=;UMXL=}zmDaJ+RDpfe02*~8sSjEbf^O} z(-K-hHEb!{<;2=}^D(tK9iWceZVJO*P>ZNNPyki7zxj)!$`7=-sQf`@i*eW^%s(=Q z6H*=h9)~vU5erN-PJ&v(sew+l_~TF=PB%Vb`Jy_WVYaBeC(S<-YJ=v*aNKGDi_GyX z)DliLTxEVyg;$#`YC!9t@-|p}BUA_5pcYa6?10ta5%V7jTHvTT!l{mq;aB0$p+3oz zmM5y<8RI{p@^oht15oWFpgz(vP)(D}UlA%Vm~4&|V`ZpCREIUpU(;9%s$m+`7B+egj^3!=hic;d3gwY{}N$tYmJbWNw8oQThhNQXCa3nOiBD zTj4m+1*2qcrReK>$=r%A7==?PC37n!b1Q{&F8WzpGPhDPxAOlmw^C3xaP(yEjOU+z z^p*8%YHiQEe!xfZw;lUxW76*SX}gbStQcCVUaezGVpq*N*fVk6ruO@rUEOqGljQr- zznSpP!~NS{5`FkY#)j9gcxYl|1S%Dp80qz>mGicJ&(fV#zl=5WwcC7t^XXaC4)X=f zcQL-LuG(c@Le9CTP^Ad*S2(?#Z|PB%t~t!M=8HC;=D^b2ttz2!c{){NEyVL|-wOLd zaT%D1w-bk@7L;|&;86H9Mxp(cd%3(ByAI!G zH>j#GS&xNZ zK%3BYs2?go*CL(2Iw>0?opd_8R-#pCHF^$((0at{8bRMqR^C8wqPNg4v>UyH_Mmss zUUVg$cS4;}7o-W1g=i7dB+EQBAL(5vOhF*($2e{9p^Q`l0Jk0UCh%BfU|1 zEz)J6JL-vgq28f4T6%N+LA~|SlP<&7=Wq}y9DW)upQ}T8ojv@ zkMu@ID{^yDYt$Cyp?&1+N4hW^M2FBvNN2vz^pDXek-qA_?j+Y?Kxs%XcJ-m7zNi}O zRZ(S^*xIWR)Vp(|klqL#jBY?f(2ZyvT8&nrE$BtG9&JEt(R1i|vE$ofu2Cl z`P+DV-3M*F+-`TW^QyD0HPCBpt6qCaL><^CdT%F^^=NdI_0Q24=u7k!s!OfvQ1ixj zawbj1ejH76x3=|GSD8VxC(%qa3+XD^j?cF}n&w*Pc`3E-#&r*>P46|4Ubaa>F-R}} z=nbPw+?YJ?p+S00WG5{bqBK&gW9yxqN~jdlOGs-O^eXfq_IOkcWigz3s8UbhR1dMO&8zJyS@29Y7esdJz$2dr?UA6 zkZzhUpjz}-6X~w_K04qwwewOdy^HHD4F%nVhN0nTW#~jZ?}3Q)S4q{oW?N8g{G}-$ zg*S3QJ%Wnv)T z7y7Wkn^Zn#(a56kW;bf2S1&Ysq<6&cZyK6#mp68N#Zd*rhN|u5XJR+?n6|QSz~8rV zgUkkvL$UtI$zv~y)4hl#Zh~7dF0%c?FUCa%Vsa-GIY)#}kB?mZ9<}BUit4@WU)F$b AtpET3 delta 11800 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 diff --git a/package.json b/package.json index 4c7ca899..0cf95521 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "jsdom": "^26.1.0", "obsidian": "1.11.4", "obsidian-dataview": "^0.5.68", - "obsidian-e2e": "0.4.0", + "obsidian-e2e": "0.6.0", "semantic-release": "^24.2.6", "svelte": "^4.2.19", "svelte-check": "^3.8.6", diff --git a/tests/e2e/template-property-links.test.ts b/tests/e2e/template-property-links.test.ts index 46c68023..f57fe5d9 100644 --- a/tests/e2e/template-property-links.test.ts +++ b/tests/e2e/template-property-links.test.ts @@ -85,24 +85,15 @@ async function runChoiceAndWaitForFile( async function waitForFrontmatter( file: string, - predicate: (frontmatter: Record | null) => boolean, + predicate: (frontmatter: { authors: string[] }) => boolean, ) { - const filePath = sandbox.path(file); - return obsidian.waitFor(async () => { - const rawFrontmatter = await obsidian.dev.eval( - `(() => { - const file = app.vault.getFileByPath(${JSON.stringify(filePath)}); - if (!file) return null; - return app.metadataCache.getFileCache(file)?.frontmatter ?? null; - })()`, - ); - const frontmatter = - rawFrontmatter && typeof rawFrontmatter === "object" - ? (rawFrontmatter as Record) - : null; - - return predicate(frontmatter) ? frontmatter : undefined; - }, WAIT_OPTS); + return await obsidian.metadata.waitForFrontmatter<{ + authors: string[]; + }>( + sandbox.path(file), + predicate, + WAIT_OPTS, + ); } async function runTeardownStep( @@ -211,7 +202,7 @@ describe("issue 1140: list properties with links", () => { const frontmatter = await waitForFrontmatter( "qa-1140-single-link.md", (value) => - Array.isArray(value?.authors) && value.authors.length === 1, + Array.isArray(value.authors) && value.authors.length === 1, ); expect(frontmatter).toMatchObject({ @@ -228,7 +219,7 @@ describe("issue 1140: list properties with links", () => { const frontmatter = await waitForFrontmatter( "qa-1140-multi-link.md", (value) => - Array.isArray(value?.authors) && value.authors.length === 2, + Array.isArray(value.authors) && value.authors.length === 2, ); expect(frontmatter).toMatchObject({