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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ref>` 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;
Expand Down
43 changes: 39 additions & 4 deletions src/markdown-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<img src=x onerror=alert(1)>\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();
\`\`\`

<img src=x onerror=alert(1)>
\`\`\`\`

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');

Expand Down
110 changes: 110 additions & 0 deletions test/markdown-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<img src=x onerror=alert(1)>\n\n```js';

document.setBody(document.codeBlocks[0]!, body);
const output = document.toString();

expect(output).toMatchInlineSnapshot(`
"# Doc

\`\`\`\`js snippet=a.js
const a = 1;
\`\`\`

<img src=x onerror=alert(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');

Expand Down
Loading