diff --git a/src/core/markdown.ts b/src/core/markdown.ts index 9ee2eca86..fa6c24e2b 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -338,17 +338,31 @@ function collectValidationErrors( // already-serialized markdown re-wrapped in fresh frontmatter). // gray-matter parses only the first block and silently leaves the second // in the body. Heuristic: first non-empty line after the close is `---`, - // a later `---` closes it, and at least one line between looks like a - // YAML `key:` — a lone `---` stays a markdown horizontal rule. + // a later `---` closes it, EVERY line between is frontmatter-shaped + // (YAML `key:`, `- ` list item, `#` comment, indented continuation, or + // blank — the issue's "stop at the first non-frontmatter character" + // spec), and at least one is a `key:` line. A lone `---` stays a + // markdown horizontal rule, and an hrule followed by prose — even + // colon-prefixed prose like `Note: …` mixed with plain lines — is body + // content, not a stacked block. let afterClose = closeLine + 1; while (afterClose < lines.length && lines[afterClose].trim().length === 0) afterClose++; if (afterClose < lines.length && lines[afterClose].trim() === '---') { let secondClose = -1; for (let i = afterClose + 1; i < lines.length; i++) { - if (lines[i].trim() === '---') { + const trimmed = lines[i].trim(); + if (trimmed === '---') { secondClose = i; break; } + const yamlShaped = + trimmed.length === 0 || + /^[A-Za-z_][\w-]*\s*:/.test(trimmed) || + trimmed.startsWith('- ') || + trimmed === '-' || + trimmed.startsWith('#') || + /^\s/.test(lines[i]); + if (!yamlShaped) break; // first non-frontmatter line → body prose, not a stacked block } if ( secondClose > afterClose + 1 && diff --git a/test/markdown-validation.test.ts b/test/markdown-validation.test.ts index 1a1171322..7fec6f80d 100644 --- a/test/markdown-validation.test.ts +++ b/test/markdown-validation.test.ts @@ -286,6 +286,24 @@ body`; const parsed = parseMarkdown(md, undefined, { validate: true }); expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER'); }); + + test('body hrule + colon-prefixed prose (`Note: …`) mixed with plain lines is NOT flagged', () => { + const md = `${fence}\ntitle: ok\ntype: concept\n${fence}\n\n${fence}\n\nNote: remember to follow up\n\nlots of plain prose here\n\n${fence}\n\nmore prose`; + const parsed = parseMarkdown(md, undefined, { validate: true }); + expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER'); + }); + + test('fence pairing stops at the first non-frontmatter line (no far-fence pairing across prose)', () => { + const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\n${'plain prose line\n'.repeat(40)}TODO: fix the widget\n${'more prose\n'.repeat(40)}${fence}\nend`; + const parsed = parseMarkdown(md, undefined, { validate: true }); + expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER'); + }); + + test('stacked block with list-valued keys is still flagged', () => { + const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\ntags:\n - a\n - b\n${fence}\n\nbody`; + const parsed = parseMarkdown(md, undefined, { validate: true }); + expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER'); + }); }); test('error.line is set for line-bearing errors', () => {