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 d16324b..90645ca 100644 --- a/src/markdown-document.ts +++ b/src/markdown-document.ts @@ -43,6 +43,33 @@ 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 CLOSING_FENCE_IN_BODY = /^[ \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|\r|\n/)) { + const run = line.match(CLOSING_FENCE_IN_BODY)?.[1]; + if (run?.[0] === fenceChar) { + longest = Math.max(longest, run.length); + } + } + return longest >= currentLength ? longest + 1 : currentLength; +} + +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 { const opening = openingLine.match(FENCE_MARKER)?.[1]; @@ -149,12 +176,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 lengthen = (line: string) => + fenceLength > fence.length + ? lengthenFence(line, fenceChar, fenceLength) + : line; + + const closing = hasClosingFence ? `${lineEnding}${lengthen(lastLine)}` : ''; + return `${lengthen(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..2fb20ae 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -260,6 +260,116 @@ 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 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``````'); + + 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');