From 16c66493aa3f9f48ebe9bcc2966022cf82b70437 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:04:52 -0700 Subject: [PATCH 1/2] Lengthen a code fence when the snippet contains one When a snippet contained a bare fence line such as ```, sync wrote it inside the existing ``` fence. That line closed the block early, so the rest of the snippet landed in the document as Markdown (or raw HTML), check reported the block out of sync forever, and every sync appended another copy. renderBlock now picks a fence one longer than the longest bare run of the fence character in the new body (CommonMark's rule) and applies it to the opening and closing fence. Fences are never shortened, runs of the other fence character and runs followed by an info string don't count, and unclosed blocks get a longer opening fence only. --- src/markdown-document.ts | 37 ++++++++++++-- test/cli.test.ts | 39 ++++++++++++++ test/markdown-document.test.ts | 92 ++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/markdown-document.ts b/src/markdown-document.ts index d16324b..9ebd8cc 100644 --- a/src/markdown-document.ts +++ b/src/markdown-document.ts @@ -43,6 +43,27 @@ interface BlockRecord { const OPENING_FENCE_PREFIX = /^[ \t]*(?:`{3,}|~{3,})[ \t]*\S*/; const FENCE_MARKER = /^[ \t]*(`{3,}|~{3,})/; const CLOSING_FENCE = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/; +const BARE_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; +const FENCE_RUN = /`{3,}|~{3,}/; + +function fenceLengthFor( + content: string, + fenceChar: string, + currentLength: number, +): number { + let longest = 0; + for (const line of content.split(/\r?\n/)) { + const run = line.match(BARE_FENCE_LINE)?.[1]; + if (run?.[0] === fenceChar) { + longest = Math.max(longest, run.length); + } + } + return longest >= currentLength ? longest + 1 : currentLength; +} + +function withFenceLength(line: string, fenceChar: string, length: number) { + return line.replace(FENCE_RUN, fenceChar.repeat(length)); +} function closesFence(openingLine: string, candidate: string): boolean { const opening = openingLine.match(FENCE_MARKER)?.[1]; @@ -149,12 +170,20 @@ export function renderBlock( const lineEnding = getLineEnding(blockText); const lastLine = blockText.slice(blockText.lastIndexOf('\n') + 1); - const closing = closesFence(openingLine, lastLine) - ? `${lineEnding}${lastLine}` - : ''; + const hasClosingFence = closesFence(openingLine, lastLine); const body = indentBody(edit.content, column, lineEnding); - return `${opening}\n${body}${closing}`; + const fence = openingLine.match(FENCE_MARKER)?.[1] ?? '```'; + const fenceChar = fence[0]!; + const fenceLength = fenceLengthFor(edit.content, fenceChar, fence.length); + + const fit = (line: string) => + fenceLength > fence.length + ? withFenceLength(line, fenceChar, fenceLength) + : line; + + const closing = hasClosingFence ? `${lineEnding}${fit(lastLine)}` : ''; + return `${fit(opening)}\n${body}${closing}`; } export class MarkdownDocument { diff --git a/test/cli.test.ts b/test/cli.test.ts index ec204d5..2ed2dbb 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -843,6 +843,45 @@ malicious content }); describe('complex scenarios', () => { + it('keeps a snippet containing a code fence inside its block', async () => { + await project.write({ + 'a.md': + 'Usage:\n\n```js\nrun();\n```\n\n\n', + 'README.md': '# Doc\n\n```md snippet=a.md\nold\n```\n\nafter\n', + }); + + const firstSync = await runBin('sync'); + const synced = readFileSync( + path.join(project.baseDir, 'README.md'), + 'utf-8', + ); + const secondSync = await runBin('sync'); + const check = await runBin('check'); + + expect(firstSync.exitCode).toEqual(0); + expect(synced).toMatchInlineSnapshot(` + "# Doc + + \`\`\`\`md snippet=a.md + Usage: + + \`\`\`js + run(); + \`\`\` + + + \`\`\`\` + + after + " + `); + expect(secondSync.stdout).toContain('All files are already in sync.'); + expect( + readFileSync(path.join(project.baseDir, 'README.md'), 'utf-8'), + ).toBe(synced); + expect(check.exitCode).toEqual(0); + }); + it('handles multiple files with mixed results', async () => { const scenario = loadScenario('multi-file-mixed'); diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index dd8f675..31deafc 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -260,6 +260,98 @@ old expect(document.toString()).toBe('~~~~ts snippet=a.ts\nnew'); }); + describe('when the body contains a fence line', () => { + it('lengthens the fence so the body cannot close the block', () => { + const document = parse( + '# Doc\n\n```js snippet=a.js\nold\n```\n\nafter\n', + ); + const body = + 'const a = 1;\n```\n\n\n\n```js'; + + document.setBody(document.codeBlocks[0]!, body); + const output = document.toString(); + + expect(output).toMatchInlineSnapshot(` + "# Doc + + \`\`\`\`js snippet=a.js + const a = 1; + \`\`\` + + + + \`\`\`js + \`\`\`\` + + after + " + `); + expect(parse(output).codeBlocks.map((b) => b.content)).toEqual([body]); + }); + + it('goes one longer than the longest fence run in the body', () => { + const document = parse('````md snippet=a.md\nold\n````'); + + document.setBody(document.codeBlocks[0]!, '`````\n```\n `````` '); + + expect(document.toString()).toBe( + '```````md snippet=a.md\n`````\n```\n `````` \n```````', + ); + }); + + it('only counts runs of the fence character', () => { + const document = parse( + '~~~md snippet=a.md\nold\n~~~\n\n```md snippet=b.md\nold\n```', + ); + const [tilde, backtick] = document.codeBlocks; + + document.setBody(tilde!, '```\n~~~~'); + document.setBody(backtick!, '~~~\n```'); + + expect(document.toString()).toBe( + '~~~~~md snippet=a.md\n```\n~~~~\n~~~~~\n\n````md snippet=b.md\n~~~\n```\n````', + ); + }); + + it('ignores fence runs followed by an info string', () => { + const document = parse('```md snippet=a.md\nold\n```'); + + document.setBody(document.codeBlocks[0]!, '```js\ncode'); + + expect(document.toString()).toBe( + '```md snippet=a.md\n```js\ncode\n```', + ); + }); + + it('keeps a longer existing fence and its closing length', () => { + const document = parse('`````md snippet=a.md\nold\n``````'); + + document.setBody(document.codeBlocks[0]!, '```'); + + expect(document.toString()).toBe('`````md snippet=a.md\n```\n``````'); + }); + + it('lengthens a fence nested in a list', () => { + const document = parse( + '- item\n\n ```md snippet=a.md\n old\n ```\n', + ); + + document.setBody(document.codeBlocks[0]!, '```'); + const output = document.toString(); + + expect(output).toBe('- item\n\n ````md snippet=a.md\n ```\n ````\n'); + expect(parse(output).codeBlocks[0]!.content).toBe('```'); + }); + + it('lengthens the opening fence of an unclosed block', () => { + const document = parse('```md snippet=a.md\nold'); + + document.setBody(document.codeBlocks[0]!, '```'); + + expect(document.toString()).toBe('````md snippet=a.md\n```'); + }); + }); + it('does not invent a closing fence for an unclosed block', () => { const document = parse('```ts snippet=a.ts\nold'); From e1c507a648b2f581aca81fab2bac0e3df41ab1a8 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sat, 10 Oct 2026 13:13:41 -0700 Subject: [PATCH 2/2] Address second-pass review feedback Split body lines on lone CRs too, keep a closing fence that is already long enough, rename helpers, and note fence lengthening in the README. --- README.md | 5 ++++- src/markdown-document.ts | 24 +++++++++++++++--------- test/markdown-document.test.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 50feb1a..a1af7b8 100644 --- a/README.md +++ b/README.md @@ -173,13 +173,16 @@ inside JSX components are found, JSX indentation never produces phantom code blocks, and `{...}` inside YAML frontmatter parses cleanly. Snippet directives coexist with other fence metadata, such as `title="app.ts"` and `snippet=app.ts`. -Two guarantees worth knowing: +Three guarantees worth knowing: - **Non-destructive extraction**: `extract` only inserts ` snippet=` into opening fence lines (offset-addressed, never regex-matched) — stripping the annotations reproduces your original file byte-for-byte. - **Fail-safe parsing**: a file that isn't valid MDX (e.g. HTML `` comments, which MDX forbids) is reported and left untouched. +- **Snippets stay inside their block**: if a snippet contains a fence line + such as ` ``` `, `sync` lengthens that block's fence (to ` ```` ` and so on) + so the snippet can't close the block early. Fences are never shortened. Note: framework-specific MDX extensions (like Docusaurus `{#heading-id}` anchors) are not part of the MDX grammar and will be reported as parse errors; diff --git a/src/markdown-document.ts b/src/markdown-document.ts index 9ebd8cc..90645ca 100644 --- a/src/markdown-document.ts +++ b/src/markdown-document.ts @@ -43,7 +43,7 @@ interface BlockRecord { const OPENING_FENCE_PREFIX = /^[ \t]*(?:`{3,}|~{3,})[ \t]*\S*/; const FENCE_MARKER = /^[ \t]*(`{3,}|~{3,})/; const CLOSING_FENCE = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/; -const BARE_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; +const CLOSING_FENCE_IN_BODY = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; const FENCE_RUN = /`{3,}|~{3,}/; function fenceLengthFor( @@ -52,8 +52,8 @@ function fenceLengthFor( currentLength: number, ): number { let longest = 0; - for (const line of content.split(/\r?\n/)) { - const run = line.match(BARE_FENCE_LINE)?.[1]; + for (const line of content.split(/\r\n|\r|\n/)) { + const run = line.match(CLOSING_FENCE_IN_BODY)?.[1]; if (run?.[0] === fenceChar) { longest = Math.max(longest, run.length); } @@ -61,8 +61,14 @@ function fenceLengthFor( return longest >= currentLength ? longest + 1 : currentLength; } -function withFenceLength(line: string, fenceChar: string, length: number) { - return line.replace(FENCE_RUN, fenceChar.repeat(length)); +function lengthenFence( + line: string, + fenceChar: string, + length: number, +): string { + return line.replace(FENCE_RUN, (run) => + fenceChar.repeat(Math.max(run.length, length)), + ); } function closesFence(openingLine: string, candidate: string): boolean { @@ -177,13 +183,13 @@ export function renderBlock( const fenceChar = fence[0]!; const fenceLength = fenceLengthFor(edit.content, fenceChar, fence.length); - const fit = (line: string) => + const lengthen = (line: string) => fenceLength > fence.length - ? withFenceLength(line, fenceChar, fenceLength) + ? lengthenFence(line, fenceChar, fenceLength) : line; - const closing = hasClosingFence ? `${lineEnding}${fit(lastLine)}` : ''; - return `${fit(opening)}\n${body}${closing}`; + const closing = hasClosingFence ? `${lineEnding}${lengthen(lastLine)}` : ''; + return `${lengthen(opening)}\n${body}${closing}`; } export class MarkdownDocument { diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index 31deafc..2fb20ae 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -323,6 +323,24 @@ old ); }); + it('keeps a closing fence that is already long enough', () => { + const document = parse('```md snippet=a.md\nold\n``````'); + + document.setBody(document.codeBlocks[0]!, '```'); + + expect(document.toString()).toBe('````md snippet=a.md\n```\n``````'); + }); + + it('detects fence lines separated by a lone carriage return', () => { + const document = parse('```md snippet=a.md\nold\n```\nafter\n'); + + document.setBody(document.codeBlocks[0]!, 'a\r```\rb'); + const output = document.toString(); + + expect(output).toBe('````md snippet=a.md\na\n```\nb\n````\nafter\n'); + expect(parse(output).codeBlocks).toHaveLength(1); + }); + it('keeps a longer existing fence and its closing length', () => { const document = parse('`````md snippet=a.md\nold\n``````');