From bfc4ea9a85af86fec82fa7d6e189ebf946a9f86b Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 11 Oct 2026 11:25:21 -0700 Subject: [PATCH 1/4] Keep rewritten code blocks inside blockquotes and tab-indented lists Body lines were indented with column - 1 spaces, which dropped blockquote markers and turned tabs into single spaces, so sync moved the snippet out of the block. Repeat the container prefix from the opening fence's line instead: keep > and whitespace, blank list markers, give bare > a space, and trim the prefix on blank lines. Move fenced-block text handling out of MarkdownDocument into fenced-block.ts (parse a block's source into a model and render it) and fence-info.ts (edit the info string). --- src/fence-info.ts | 44 +++++++ src/fenced-block.ts | 112 ++++++++++++++++ src/markdown-document.ts | 230 ++++++++------------------------- src/parser.ts | 12 +- test/fence-info.test.ts | 26 ++++ test/fenced-block.test.ts | 95 ++++++++++++++ test/markdown-document.test.ts | 56 ++++++++ 7 files changed, 396 insertions(+), 179 deletions(-) create mode 100644 src/fence-info.ts create mode 100644 src/fenced-block.ts create mode 100644 test/fence-info.test.ts create mode 100644 test/fenced-block.test.ts diff --git a/src/fence-info.ts b/src/fence-info.ts new file mode 100644 index 0000000..f89bd9f --- /dev/null +++ b/src/fence-info.ts @@ -0,0 +1,44 @@ +import type { SnippetDirective } from './types.js'; +import { + findSnippetDirectiveToken, + formatSnippetDirective, +} from './snippet-directive.js'; + +const LANGUAGE = /^[ \t]*\S*/; + +function splitLanguage(info: string): [language: string, meta: string] { + const language = LANGUAGE.exec(info)?.[0] ?? ''; + return [language, info.slice(language.length)]; +} + +function withMetaDirective( + meta: string, + directive: SnippetDirective | null, +): string { + const token = findSnippetDirectiveToken(meta); + + if (!token) { + return directive ? `${meta} ${formatSnippetDirective(directive)}` : meta; + } + + if (directive) { + return ( + meta.slice(0, token.start) + + formatSnippetDirective(directive) + + meta.slice(token.end) + ); + } + + const separated = /[ \t]/.test(meta[token.start - 1] ?? ''); + return ( + meta.slice(0, token.start - (separated ? 1 : 0)) + meta.slice(token.end) + ); +} + +export function withDirective( + info: string, + directive: SnippetDirective | null, +): string { + const [language, meta] = splitLanguage(info); + return language + withMetaDirective(meta, directive); +} diff --git a/src/fenced-block.ts b/src/fenced-block.ts new file mode 100644 index 0000000..77f6c34 --- /dev/null +++ b/src/fenced-block.ts @@ -0,0 +1,112 @@ +import type { SnippetDirective } from './types.js'; +import { withDirective } from './fence-info.js'; +import { getLineEnding, type LineEnding } from './line-endings.js'; + +export interface BlockEdit { + content?: string | undefined; + directive?: SnippetDirective | null | undefined; +} + +export interface FencedBlock { + readonly fence: string; + readonly info: string; + readonly afterOpening: string; + readonly closing: string | undefined; + readonly bodyPrefix: string; + readonly lineEnding: LineEnding; +} + +const LINE_BREAK = /\r\n|\r|\n/; +const OPENING_LINE = /^(`{3,}|~{3,})(.*)$/; +const CLOSING_LINE = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/; +const BODY_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; +const FENCE_RUN = /`{3,}|~{3,}/; +const LIST_MARKER_CHARACTER = /[^ \t>]/g; +const BARE_BLOCKQUOTE_MARKER = />(?![ \t])/g; + +export function linePrefixAt(source: string, offset: number): string { + return source.slice(source.lastIndexOf('\n', offset - 1) + 1, offset); +} + +function bodyPrefixFor(linePrefix: string): string { + return linePrefix + .replace(LIST_MARKER_CHARACTER, ' ') + .replace(BARE_BLOCKQUOTE_MARKER, '> '); +} + +function closes(fence: string, line: string): boolean { + const run = CLOSING_LINE.exec(line)?.[1]; + return run !== undefined && run[0] === fence[0] && run.length >= fence.length; +} + +export function parseFencedBlock( + text: string, + linePrefix: string, +): FencedBlock { + const lines = text.split(LINE_BREAK); + const opening = lines[0]!; + const match = OPENING_LINE.exec(opening); + if (!match) { + throw new Error(`Not the start of a fenced code block: ${opening}`); + } + + const fence = match[1]!; + const info = match[2]!; + const last = lines.length > 1 ? lines.at(-1) : undefined; + + return { + fence, + info, + afterOpening: text.slice(opening.length), + closing: last !== undefined && closes(fence, last) ? last : undefined, + bodyPrefix: bodyPrefixFor(linePrefix), + lineEnding: getLineEnding(text), + }; +} + +function longestFenceRun(content: string, fenceChar: string): number { + return content.split(LINE_BREAK).reduce((longest, line) => { + const run = BODY_FENCE_LINE.exec(line)?.[1]; + return run?.[0] === fenceChar ? Math.max(longest, run.length) : longest; + }, 0); +} + +function fenceFor(content: string, fence: string): string { + const fenceChar = fence[0]!; + const longest = longestFenceRun(content, fenceChar); + return longest >= fence.length ? fenceChar.repeat(longest + 1) : fence; +} + +function lengthenFence(line: string, fence: string): string { + return line.replace(FENCE_RUN, (run) => + run.length >= fence.length ? run : fence, + ); +} + +function bodyLines(content: string, prefix: string): Array { + const blankLinePrefix = prefix.trimEnd(); + return content + .split(LINE_BREAK) + .map((line) => (line === '' ? blankLinePrefix : prefix + line)); +} + +export function renderFencedBlock(block: FencedBlock, edit: BlockEdit): string { + const info = + edit.directive === undefined + ? block.info + : withDirective(block.info, edit.directive); + + if (edit.content === undefined) { + return block.fence + info + block.afterOpening; + } + + const fence = fenceFor(edit.content, block.fence); + const closing = + block.closing === undefined ? [] : [lengthenFence(block.closing, fence)]; + + return [ + fence + info, + ...bodyLines(edit.content, block.bodyPrefix), + ...closing, + ].join(block.lineEnding); +} diff --git a/src/markdown-document.ts b/src/markdown-document.ts index 90645ca..d29bd49 100644 --- a/src/markdown-document.ts +++ b/src/markdown-document.ts @@ -6,15 +6,17 @@ import { visit } from 'unist-util-visit'; import type { Code } from 'mdast'; import type { CodeBlock, SnippetDirective } from './types.js'; import { - findSnippetDirectiveToken, formatSnippetDirective, parseSnippetDirective, } from './snippet-directive.js'; import { - getLineEnding, - normalizeLineEndings, - type LineEnding, -} from './line-endings.js'; + linePrefixAt, + parseFencedBlock, + renderFencedBlock, + type BlockEdit, + type FencedBlock, +} from './fenced-block.js'; +import type { LineEnding } from './line-endings.js'; export interface ParseOptions { mdx?: boolean; @@ -29,57 +31,17 @@ export interface DocumentCodeBlock { readonly lineEnding: LineEnding; } -export interface BlockEdit { - content?: string | undefined; - directive?: SnippetDirective | null | undefined; -} - interface BlockRecord { - block: DocumentCodeBlock; - start: number; - end: number; -} - -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)), - ); + readonly block: DocumentCodeBlock; + readonly start: number; + readonly end: number; + readonly source: FencedBlock; } -function closesFence(openingLine: string, candidate: string): boolean { - const opening = openingLine.match(FENCE_MARKER)?.[1]; - const closing = candidate.match(CLOSING_FENCE)?.[1]; - return ( - opening !== undefined && - closing !== undefined && - closing[0] === opening[0] && - closing.length >= opening.length - ); +interface Replacement { + readonly start: number; + readonly end: number; + readonly text: string; } const legacyOffsets = new WeakMap< @@ -98,98 +60,38 @@ function createProcessor(options: ParseOptions) { return unified().use(remarkParse); } -function splitLineEnding(line: string): [string, string] { - return line.endsWith('\r') ? [line.slice(0, -1), '\r'] : [line, '']; -} - -function rewriteOpeningFence( - line: string, - directive: SnippetDirective | null, +function replaceRanges( + source: string, + replacements: ReadonlyArray, ): string { - const [text, lineEnding] = splitLineEnding(line); - const metaStart = text.match(OPENING_FENCE_PREFIX)?.[0].length ?? text.length; - const head = text.slice(0, metaStart); - const meta = text.slice(metaStart); - const existing = findSnippetDirectiveToken(meta); - - if (existing) { - const after = meta.slice(existing.end); - - if (directive) { - const before = meta.slice(0, existing.start); - return ( - head + before + formatSnippetDirective(directive) + after + lineEnding - ); - } - - const separatorStart = - existing.start > 0 && /[ \t]/.test(meta[existing.start - 1]!) - ? existing.start - 1 - : existing.start; - return head + meta.slice(0, separatorStart) + after + lineEnding; - } - - if (!directive) { - return line; - } - - return `${text} ${formatSnippetDirective(directive)}${lineEnding}`; -} - -function indentBody( - content: string, - column: number, - lineEnding: LineEnding, -): string { - const normalized = normalizeLineEndings(content, lineEnding); - const indent = ' '.repeat(Math.max(0, column - 1)); - if (indent === '') { - return normalized; - } - return normalized - .split(lineEnding) - .map((line) => (line === '' ? line : indent + line)) - .join(lineEnding); + return [...replacements] + .sort((a, b) => b.start - a.start) + .reduce( + (output, { start, end, text }) => + output.slice(0, start) + text + output.slice(end), + source, + ); } -export function renderBlock( - blockText: string, - column: number, - edit: BlockEdit, -): string { - const firstNewlineIndex = blockText.indexOf('\n'); - const openingLine = - firstNewlineIndex === -1 - ? blockText - : blockText.slice(0, firstNewlineIndex); - const rest = - firstNewlineIndex === -1 ? '' : blockText.slice(firstNewlineIndex); - - const opening = - edit.directive === undefined - ? openingLine - : rewriteOpeningFence(openingLine, edit.directive); - - if (edit.content === undefined || firstNewlineIndex === -1) { - return opening + rest; - } - - const lineEnding = getLineEnding(blockText); - const lastLine = blockText.slice(blockText.lastIndexOf('\n') + 1); - const hasClosingFence = closesFence(openingLine, lastLine); - const body = indentBody(edit.content, column, lineEnding); - - 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}`; +function toRecord(content: string, node: Code): BlockRecord { + const start = node.position?.start.offset ?? 0; + const end = node.position?.end.offset ?? 0; + const source = parseFencedBlock( + content.slice(start, end), + linePrefixAt(content, start), + ); + const directive = node.meta ? parseSnippetDirective(node.meta) : undefined; + const block: DocumentCodeBlock = { + language: node.lang!, + content: node.value, + ...(directive ? { directive } : {}), + line: node.position?.start.line ?? 1, + column: node.position?.start.column ?? 1, + lineEnding: source.lineEnding, + }; + + legacyOffsets.set(block, { start, end }); + return { block, start, end, source }; } export class MarkdownDocument { @@ -209,26 +111,9 @@ export class MarkdownDocument { const records: Array = []; visit(tree, 'code', (node: Code) => { - if (!node.lang) { - return; + if (node.lang) { + records.push(toRecord(content, node)); } - - const start = node.position?.start.offset ?? 0; - const end = node.position?.end.offset ?? 0; - const directive = node.meta - ? parseSnippetDirective(node.meta) - : undefined; - const block: DocumentCodeBlock = { - language: node.lang, - content: node.value, - ...(directive ? { directive } : {}), - line: node.position?.start.line ?? 1, - column: node.position?.start.column ?? 1, - lineEnding: getLineEnding(content.slice(start, end)), - }; - - legacyOffsets.set(block, { start, end }); - records.push({ block, start, end }); }); return new MarkdownDocument(content, records); @@ -250,20 +135,15 @@ export class MarkdownDocument { } toString(): string { - const edited = this.#records - .filter((record) => this.#edits.has(record.block)) - .sort((a, b) => b.start - a.start); - - let output = this.#source; - for (const { block, start, end } of edited) { - const rendered = renderBlock( - this.#source.slice(start, end), - block.column, - this.#edits.get(block)!, - ); - output = output.slice(0, start) + rendered + output.slice(end); - } - return output; + return replaceRanges( + this.#source, + this.#records.flatMap(({ block, start, end, source }) => { + const edit = this.#edits.get(block); + return edit + ? [{ start, end, text: renderFencedBlock(source, edit) }] + : []; + }), + ); } #edit(block: DocumentCodeBlock, edit: BlockEdit): void { diff --git a/src/parser.ts b/src/parser.ts index 70d13b8..d9e85f1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3,9 +3,13 @@ import type { MarkdownFile, CodeBlock } from './types.js'; import { MarkdownDocument, isMdxPath, - renderBlock, toLegacyCodeBlocks, } from './markdown-document.js'; +import { + linePrefixAt, + parseFencedBlock, + renderFencedBlock, +} from './fenced-block.js'; export { parseSnippetDirective } from './snippet-directive.js'; export { @@ -83,11 +87,11 @@ export function replaceCodeBlock( newContent: string, ): string { const { start, end } = codeBlock.position; - const rendered = renderBlock( + const block = parseFencedBlock( markdownContent.slice(start, end), - codeBlock.columnNumber ?? 1, - { content: newContent }, + linePrefixAt(markdownContent, start), ); + const rendered = renderFencedBlock(block, { content: newContent }); return ( markdownContent.slice(0, start) + rendered + markdownContent.slice(end) ); diff --git a/test/fence-info.test.ts b/test/fence-info.test.ts new file mode 100644 index 0000000..46ba98b --- /dev/null +++ b/test/fence-info.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { withDirective } from '../src/fence-info.js'; + +describe('withDirective', () => { + it.each([ + ['ts', 'ts snippet=a.ts'], + [' ts', ' ts snippet=a.ts'], + ['ts title="x"', 'ts title="x" snippet=a.ts'], + ['ts snippet=old.ts title="x"', 'ts snippet=a.ts title="x"'], + ])('sets the directive on %j', (info, expected) => { + expect(withDirective(info, { filePath: 'a.ts' })).toBe(expected); + }); + + it.each([ + ['ts snippet=a.ts', 'ts'], + ['ts title="x" snippet=a.ts', 'ts title="x"'], + ['ts snippet=a.ts title="x"', 'ts title="x"'], + ['ts title="snippet=fake.ts"', 'ts title="snippet=fake.ts"'], + ])('removes the directive from %j', (info, expected) => { + expect(withDirective(info, null)).toBe(expected); + }); + + it('does not read a directive in the language position', () => { + expect(withDirective('snippet=a.ts', null)).toBe('snippet=a.ts'); + }); +}); diff --git a/test/fenced-block.test.ts b/test/fenced-block.test.ts new file mode 100644 index 0000000..606e383 --- /dev/null +++ b/test/fenced-block.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { + linePrefixAt, + parseFencedBlock, + renderFencedBlock, +} from '../src/fenced-block.js'; + +describe('linePrefixAt', () => { + it.each([ + ['```js', 0, ''], + ['intro\n> - ```js', 10, '> - '], + ['a\r\n\t```js', 4, '\t'], + ])('returns the text before offset %#', (source, offset, expected) => { + expect(linePrefixAt(source, offset)).toBe(expected); + }); +}); + +describe('parseFencedBlock', () => { + it('splits the opening fence, info string and closing fence', () => { + const block = parseFencedBlock( + '````ts snippet=a.ts\r\n> x\r\n> ````', + '> - ', + ); + + expect(block.lineEnding).toBe('\r\n'); + expect(block.afterOpening).toBe('\r\n> x\r\n> ````'); + expect(block).toMatchInlineSnapshot(` + { + "afterOpening": " + > x + > \`\`\`\`", + "bodyPrefix": "> ", + "closing": "> \`\`\`\`", + "fence": "\`\`\`\`", + "info": "ts snippet=a.ts", + "lineEnding": " + ", + } + `); + }); + + it.each([ + ['> ', '> '], + ['>', '> '], + ['>>', '> > '], + ['>\t', '>\t'], + [' ', ' '], + ['\t', '\t'], + ['- ', ' '], + ['-\t', ' \t'], + ['10) ', ' '], + ['> 1. ', '> '], + [' > ', ' > '], + ])('continues the line prefix %j as %j', (linePrefix, bodyPrefix) => { + expect(parseFencedBlock('```js\n```', linePrefix).bodyPrefix).toBe( + bodyPrefix, + ); + }); + + it('has no closing fence when the last line does not close it', () => { + expect(parseFencedBlock('~~~~ts\nold\n~~~', '').closing).toBeUndefined(); + expect(parseFencedBlock('```ts', '').closing).toBeUndefined(); + }); + + it('rejects text that does not start with a fence', () => { + expect(() => parseFencedBlock(' code', '')).toThrow( + 'Not the start of a fenced code block', + ); + }); +}); + +describe('renderFencedBlock', () => { + it('prefixes every body line and gives blank lines the trimmed prefix', () => { + const block = parseFencedBlock('```js\n> old\n> ```', '> '); + + expect(renderFencedBlock(block, { content: 'a\n\n b' })).toBe( + '```js\n> a\n>\n> b\n> ```', + ); + }); + + it('keeps everything after the opening line when only the info changes', () => { + const text = '```js snippet=a.js\r\n> odd \n> ```'; + const block = parseFencedBlock(text, '> '); + + expect(renderFencedBlock(block, { directive: null })).toBe( + '```js\r\n> odd \n> ```', + ); + }); + + it('writes a body into a block that only has an opening line', () => { + const block = parseFencedBlock('```js', ''); + + expect(renderFencedBlock(block, { content: 'x' })).toBe('```js\nx'); + }); +}); diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index 2fb20ae..bf2c7ec 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -217,6 +217,62 @@ old `); }); + describe('when the fence is inside a container', () => { + const body = 'one\n\n two\n```'; + + it.each([ + ['a blockquote', '> ```js snippet=a.js\n> old\n> ```\n'], + ['a blockquote without a space', '>```js snippet=a.js\n>old\n>```\n'], + ['a nested blockquote', '> > ```js snippet=a.js\n> > old\n> > ```\n'], + [ + 'a blockquote followed by a tab', + '>\t```js snippet=a.js\n>\told\n>\t```\n', + ], + ['a compact nested blockquote', '>>```js snippet=a.js\n>>old\n>>```\n'], + [ + 'a tab-indented list item', + '1. Item\n\n\t```js snippet=a.js\n\told\n\t```\n', + ], + ['a list item it opens', '- ```js snippet=a.js\n old\n ```\n'], + [ + 'a list item whose marker is followed by a tab', + '-\t```js snippet=a.js\n \told\n \t```\n', + ], + ['an ordered list item', '10) ```js snippet=a.js\n old\n ```\n'], + [ + 'a list inside a blockquote', + '> - ```js snippet=a.js\n> old\n> ```\n', + ], + [ + 'a blockquote inside a list', + '- Nested\n > ```js snippet=a.js\n > old\n > ```\n', + ], + ])('keeps the body inside %s', (_, source) => { + const document = parse(source); + document.setBody(document.codeBlocks[0]!, body); + const output = document.toString(); + + const reparsed = parse(output); + expect(reparsed.codeBlocks.map((block) => block.content)).toEqual([ + body, + ]); + reparsed.setBody(reparsed.codeBlocks[0]!, body); + expect(reparsed.toString()).toBe(output); + }); + + it('repeats the blockquote marker on every body line', () => { + const document = parse( + '> Intro\n>\n> ```js snippet=a.js\n> old\n> ```\n> after\n', + ); + + document.setBody(document.codeBlocks[0]!, 'one\n\ntwo'); + + expect(document.toString()).toBe( + '> Intro\n>\n> ```js snippet=a.js\n> one\n>\n> two\n> ```\n> after\n', + ); + }); + }); + it('fills an empty block', () => { const document = parse('```ts snippet=a.ts\n```\n'); From e0e8654953c743fcb521c05a18448143e1448848 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 11 Oct 2026 11:40:48 -0700 Subject: [PATCH 2/4] Compute body prefixes in columns; keep unclosed block endings Character substitution got a bare > followed by a list marker or tab wrong. Build the prefix from the container structure instead: one '> ' per blockquote level, then the remaining indentation in columns with tab stops of 4, less the optional space each marker consumes. Keep the line break that ends an unclosed block, write an empty body as no lines, and only accept a closing fence behind exactly the container's blockquote markers. Parse blocks through parseFencedBlockAt(source, start, end), store the raw body separately from the closing line, and share splitLines from line-endings. --- src/fence-info.ts | 3 +- src/fenced-block.ts | 101 +++++++++++++++++++++++---------- src/line-endings.ts | 8 ++- src/markdown-document.ts | 20 ++++--- src/parser.ts | 13 ++--- test/fenced-block.test.ts | 94 +++++++++++++++++------------- test/markdown-document.test.ts | 88 +++++++++++++++++++++++----- 7 files changed, 223 insertions(+), 104 deletions(-) diff --git a/src/fence-info.ts b/src/fence-info.ts index f89bd9f..8ddb622 100644 --- a/src/fence-info.ts +++ b/src/fence-info.ts @@ -5,6 +5,7 @@ import { } from './snippet-directive.js'; const LANGUAGE = /^[ \t]*\S*/; +const WHITESPACE = /[ \t]/; function splitLanguage(info: string): [language: string, meta: string] { const language = LANGUAGE.exec(info)?.[0] ?? ''; @@ -29,7 +30,7 @@ function withMetaDirective( ); } - const separated = /[ \t]/.test(meta[token.start - 1] ?? ''); + const separated = WHITESPACE.test(meta.charAt(token.start - 1)); return ( meta.slice(0, token.start - (separated ? 1 : 0)) + meta.slice(token.end) ); diff --git a/src/fenced-block.ts b/src/fenced-block.ts index 77f6c34..21f8fa1 100644 --- a/src/fenced-block.ts +++ b/src/fenced-block.ts @@ -1,6 +1,6 @@ import type { SnippetDirective } from './types.js'; import { withDirective } from './fence-info.js'; -import { getLineEnding, type LineEnding } from './line-endings.js'; +import { getLineEnding, splitLines, type LineEnding } from './line-endings.js'; export interface BlockEdit { content?: string | undefined; @@ -10,69 +10,105 @@ export interface BlockEdit { export interface FencedBlock { readonly fence: string; readonly info: string; - readonly afterOpening: string; + readonly rawBody: string; readonly closing: string | undefined; readonly bodyPrefix: string; readonly lineEnding: LineEnding; } -const LINE_BREAK = /\r\n|\r|\n/; +const TAB_SIZE = 4; +const BLOCKQUOTE_MARKER = '>'; const OPENING_LINE = /^(`{3,}|~{3,})(.*)$/; -const CLOSING_LINE = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/; const BODY_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; const FENCE_RUN = /`{3,}|~{3,}/; -const LIST_MARKER_CHARACTER = /[^ \t>]/g; -const BARE_BLOCKQUOTE_MARKER = />(?![ \t])/g; +const STARTS_WITH_WHITESPACE = /^[ \t]/; -export function linePrefixAt(source: string, offset: number): string { - return source.slice(source.lastIndexOf('\n', offset - 1) + 1, offset); +function columnAfter(text: string, startColumn: number): number { + return [...text].reduce( + (column, character) => + character === '\t' ? column + TAB_SIZE - (column % TAB_SIZE) : column + 1, + startColumn, + ); } function bodyPrefixFor(linePrefix: string): string { - return linePrefix - .replace(LIST_MARKER_CHARACTER, ' ') - .replace(BARE_BLOCKQUOTE_MARKER, '> '); + const [outsideQuotes = '', ...quoted] = linePrefix.split(BLOCKQUOTE_MARKER); + const initial = columnAfter(outsideQuotes, 0); + + return quoted.reduce( + ({ column, prefix }, segment) => { + const start = column + BLOCKQUOTE_MARKER.length; + const end = columnAfter(segment, start); + const optionalSpace = STARTS_WITH_WHITESPACE.test(segment) ? 1 : 0; + const indent = ' '.repeat(end - start - optionalSpace); + return { column: end, prefix: `${prefix}${BLOCKQUOTE_MARKER} ${indent}` }; + }, + { column: initial, prefix: ' '.repeat(initial) }, + ).prefix; } -function closes(fence: string, line: string): boolean { - const run = CLOSING_LINE.exec(line)?.[1]; - return run !== undefined && run[0] === fence[0] && run.length >= fence.length; +function closingFencePattern(fence: string, quoteDepth: number): RegExp { + const blockquoteMarkers = `(?:[ \\t]*${BLOCKQUOTE_MARKER}){${quoteDepth}}`; + const fenceRun = `${fence.charAt(0)}{${fence.length},}`; + return new RegExp(`^${blockquoteMarkers}[ \\t]*${fenceRun}[ \\t]*$`); } -export function parseFencedBlock( - text: string, - linePrefix: string, +function closingLine( + lines: ReadonlyArray, + fence: string, + quoteDepth: number, +): string | undefined { + const last = lines.at(-1); + const closes = + lines.length > 1 && + last !== undefined && + (last === '' || closingFencePattern(fence, quoteDepth).test(last)); + return closes ? last : undefined; +} + +function linePrefixAt(source: string, offset: number): string { + return source.slice(source.lastIndexOf('\n', offset - 1) + 1, offset); +} + +export function parseFencedBlockAt( + source: string, + start: number, + end: number, ): FencedBlock { - const lines = text.split(LINE_BREAK); - const opening = lines[0]!; + const text = source.slice(start, end); + const lines = splitLines(text); + const [opening = ''] = lines; const match = OPENING_LINE.exec(opening); if (!match) { throw new Error(`Not the start of a fenced code block: ${opening}`); } - const fence = match[1]!; - const info = match[2]!; - const last = lines.length > 1 ? lines.at(-1) : undefined; + const [, fence = '', info = ''] = match; + const linePrefix = linePrefixAt(source, start); + const quoteDepth = linePrefix.split(BLOCKQUOTE_MARKER).length - 1; + const closing = closingLine(lines, fence, quoteDepth); return { fence, info, - afterOpening: text.slice(opening.length), - closing: last !== undefined && closes(fence, last) ? last : undefined, + rawBody: text.slice(opening.length, text.length - (closing?.length ?? 0)), + closing, bodyPrefix: bodyPrefixFor(linePrefix), lineEnding: getLineEnding(text), }; } function longestFenceRun(content: string, fenceChar: string): number { - return content.split(LINE_BREAK).reduce((longest, line) => { + return splitLines(content).reduce((longest, line) => { const run = BODY_FENCE_LINE.exec(line)?.[1]; - return run?.[0] === fenceChar ? Math.max(longest, run.length) : longest; + return run?.charAt(0) === fenceChar + ? Math.max(longest, run.length) + : longest; }, 0); } function fenceFor(content: string, fence: string): string { - const fenceChar = fence[0]!; + const fenceChar = fence.charAt(0); const longest = longestFenceRun(content, fenceChar); return longest >= fence.length ? fenceChar.repeat(longest + 1) : fence; } @@ -84,10 +120,13 @@ function lengthenFence(line: string, fence: string): string { } function bodyLines(content: string, prefix: string): Array { + if (content === '') { + return []; + } const blankLinePrefix = prefix.trimEnd(); - return content - .split(LINE_BREAK) - .map((line) => (line === '' ? blankLinePrefix : prefix + line)); + return splitLines(content).map((line) => + line === '' ? blankLinePrefix : prefix + line, + ); } export function renderFencedBlock(block: FencedBlock, edit: BlockEdit): string { @@ -97,7 +136,7 @@ export function renderFencedBlock(block: FencedBlock, edit: BlockEdit): string { : withDirective(block.info, edit.directive); if (edit.content === undefined) { - return block.fence + info + block.afterOpening; + return block.fence + info + block.rawBody + (block.closing ?? ''); } const fence = fenceFor(edit.content, block.fence); diff --git a/src/line-endings.ts b/src/line-endings.ts index e1ce4ef..0169c75 100644 --- a/src/line-endings.ts +++ b/src/line-endings.ts @@ -1,12 +1,18 @@ export type LineEnding = '\n' | '\r\n'; +const LINE_BREAK = /\r\n|\r|\n/; + export function getLineEnding(content: string): LineEnding { return content.match(/\r\n|\n/)?.[0] === '\r\n' ? '\r\n' : '\n'; } +export function splitLines(content: string): Array { + return content.split(LINE_BREAK); +} + export function normalizeLineEndings( content: string, lineEnding: LineEnding, ): string { - return content.replace(/\r\n|\r|\n/g, lineEnding); + return splitLines(content).join(lineEnding); } diff --git a/src/markdown-document.ts b/src/markdown-document.ts index d29bd49..b947f71 100644 --- a/src/markdown-document.ts +++ b/src/markdown-document.ts @@ -10,8 +10,7 @@ import { parseSnippetDirective, } from './snippet-directive.js'; import { - linePrefixAt, - parseFencedBlock, + parseFencedBlockAt, renderFencedBlock, type BlockEdit, type FencedBlock, @@ -38,6 +37,8 @@ interface BlockRecord { readonly source: FencedBlock; } +type LanguageCode = Code & { lang: string }; + interface Replacement { readonly start: number; readonly end: number; @@ -73,16 +74,17 @@ function replaceRanges( ); } -function toRecord(content: string, node: Code): BlockRecord { +function hasLanguage(node: Code): node is LanguageCode { + return Boolean(node.lang); +} + +function toRecord(content: string, node: LanguageCode): BlockRecord { const start = node.position?.start.offset ?? 0; const end = node.position?.end.offset ?? 0; - const source = parseFencedBlock( - content.slice(start, end), - linePrefixAt(content, start), - ); + const source = parseFencedBlockAt(content, start, end); const directive = node.meta ? parseSnippetDirective(node.meta) : undefined; const block: DocumentCodeBlock = { - language: node.lang!, + language: node.lang, content: node.value, ...(directive ? { directive } : {}), line: node.position?.start.line ?? 1, @@ -111,7 +113,7 @@ export class MarkdownDocument { const records: Array = []; visit(tree, 'code', (node: Code) => { - if (node.lang) { + if (hasLanguage(node)) { records.push(toRecord(content, node)); } }); diff --git a/src/parser.ts b/src/parser.ts index d9e85f1..41b83ba 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5,11 +5,7 @@ import { isMdxPath, toLegacyCodeBlocks, } from './markdown-document.js'; -import { - linePrefixAt, - parseFencedBlock, - renderFencedBlock, -} from './fenced-block.js'; +import { parseFencedBlockAt, renderFencedBlock } from './fenced-block.js'; export { parseSnippetDirective } from './snippet-directive.js'; export { @@ -87,11 +83,10 @@ export function replaceCodeBlock( newContent: string, ): string { const { start, end } = codeBlock.position; - const block = parseFencedBlock( - markdownContent.slice(start, end), - linePrefixAt(markdownContent, start), + const rendered = renderFencedBlock( + parseFencedBlockAt(markdownContent, start, end), + { content: newContent }, ); - const rendered = renderFencedBlock(block, { content: newContent }); return ( markdownContent.slice(0, start) + rendered + markdownContent.slice(end) ); diff --git a/test/fenced-block.test.ts b/test/fenced-block.test.ts index 606e383..ed1a71c 100644 --- a/test/fenced-block.test.ts +++ b/test/fenced-block.test.ts @@ -1,69 +1,72 @@ import { describe, it, expect } from 'vitest'; -import { - linePrefixAt, - parseFencedBlock, - renderFencedBlock, -} from '../src/fenced-block.js'; +import { parseFencedBlockAt, renderFencedBlock } from '../src/fenced-block.js'; -describe('linePrefixAt', () => { - it.each([ - ['```js', 0, ''], - ['intro\n> - ```js', 10, '> - '], - ['a\r\n\t```js', 4, '\t'], - ])('returns the text before offset %#', (source, offset, expected) => { - expect(linePrefixAt(source, offset)).toBe(expected); - }); -}); +function blockIn(source: string) { + return parseFencedBlockAt(source, source.search(/`{3}|~{3}/), source.length); +} -describe('parseFencedBlock', () => { - it('splits the opening fence, info string and closing fence', () => { - const block = parseFencedBlock( - '````ts snippet=a.ts\r\n> x\r\n> ````', - '> - ', +describe('parseFencedBlockAt', () => { + it('splits the opening fence, info string, body and closing fence', () => { + const block = blockIn( + 'intro\n> - ````ts snippet=a.ts\r\n> x\r\n> ````', ); expect(block.lineEnding).toBe('\r\n'); - expect(block.afterOpening).toBe('\r\n> x\r\n> ````'); expect(block).toMatchInlineSnapshot(` { - "afterOpening": " - > x - > \`\`\`\`", "bodyPrefix": "> ", "closing": "> \`\`\`\`", "fence": "\`\`\`\`", "info": "ts snippet=a.ts", "lineEnding": " + ", + "rawBody": " + > x ", } `); }); it.each([ - ['> ', '> '], - ['>', '> '], - ['>>', '> > '], - ['>\t', '>\t'], + ['', ''], [' ', ' '], - ['\t', '\t'], + ['\t', ' '], ['- ', ' '], - ['-\t', ' \t'], + ['-\t', ' '], ['10) ', ' '], + ['> ', '> '], + ['>', '> '], + ['>>', '> > '], + ['>\t', '> '], + ['>>\t', '> > '], + ['>- ', '> '], ['> 1. ', '> '], [' > ', ' > '], ])('continues the line prefix %j as %j', (linePrefix, bodyPrefix) => { - expect(parseFencedBlock('```js\n```', linePrefix).bodyPrefix).toBe( + expect(blockIn(`${linePrefix}\`\`\`js\n\`\`\``).bodyPrefix).toBe( bodyPrefix, ); }); - it('has no closing fence when the last line does not close it', () => { - expect(parseFencedBlock('~~~~ts\nold\n~~~', '').closing).toBeUndefined(); - expect(parseFencedBlock('```ts', '').closing).toBeUndefined(); + it.each([ + ['a matching fence', '```ts\nx\n```', '```'], + ['a longer fence', '```ts\nx\n`````', '`````'], + [ + 'a fence behind its blockquote marker', + '> ```ts\n> x\n> ```', + '> ```', + ], + ['a trailing line break', '```ts\nx\n', ''], + ['a shorter fence', '~~~~ts\nx\n~~~', undefined], + ['a fence of the other character', '```ts\nx\n~~~', undefined], + ['a fence behind an extra blockquote marker', '```ts\n> ```', undefined], + ['only an opening line', '```ts', undefined], + ])('ends a block at %s', (_, source, closing) => { + expect(blockIn(source).closing).toBe(closing); }); it('rejects text that does not start with a fence', () => { - expect(() => parseFencedBlock(' code', '')).toThrow( + expect(() => parseFencedBlockAt(' code', 0, 8)).toThrow( 'Not the start of a fenced code block', ); }); @@ -71,25 +74,36 @@ describe('parseFencedBlock', () => { describe('renderFencedBlock', () => { it('prefixes every body line and gives blank lines the trimmed prefix', () => { - const block = parseFencedBlock('```js\n> old\n> ```', '> '); + const block = blockIn('> ```js\n> old\n> ```'); expect(renderFencedBlock(block, { content: 'a\n\n b' })).toBe( '```js\n> a\n>\n> b\n> ```', ); }); + it('writes an empty body as no lines', () => { + expect(renderFencedBlock(blockIn('```js\nold\n```'), { content: '' })).toBe( + '```js\n```', + ); + }); + it('keeps everything after the opening line when only the info changes', () => { - const text = '```js snippet=a.js\r\n> odd \n> ```'; - const block = parseFencedBlock(text, '> '); + const block = blockIn('> ```js snippet=a.js\r\n> odd \n> ```'); expect(renderFencedBlock(block, { directive: null })).toBe( '```js\r\n> odd \n> ```', ); }); - it('writes a body into a block that only has an opening line', () => { - const block = parseFencedBlock('```js', ''); + it('keeps the line break that ends an unclosed block', () => { + expect(renderFencedBlock(blockIn('```js\nold\n'), { content: 'new' })).toBe( + '```js\nnew\n', + ); + }); - expect(renderFencedBlock(block, { content: 'x' })).toBe('```js\nx'); + it('writes a body into a block that only has an opening line', () => { + expect(renderFencedBlock(blockIn('```js'), { content: 'x' })).toBe( + '```js\nx', + ); }); }); diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index bf2c7ec..07b5583 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -1,6 +1,22 @@ import { describe, it, expect } from 'vitest'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import type { Node, Parent } from 'unist'; import { MarkdownDocument, isMdxPath } from '../src/markdown-document.js'; +interface Structure { + type: string; + children?: Array; +} + +function structure(markdown: string): Structure { + const outline = (node: Node): Structure => + 'children' in node + ? { type: node.type, children: (node as Parent).children.map(outline) } + : { type: node.type }; + return outline(unified().use(remarkParse).parse(markdown)); +} + function parse(content: string, mdx = false): MarkdownDocument { return MarkdownDocument.parse(content, { mdx }); } @@ -218,22 +234,33 @@ old }); describe('when the fence is inside a container', () => { - const body = 'one\n\n two\n```'; - - it.each([ - ['a blockquote', '> ```js snippet=a.js\n> old\n> ```\n'], - ['a blockquote without a space', '>```js snippet=a.js\n>old\n>```\n'], - ['a nested blockquote', '> > ```js snippet=a.js\n> > old\n> > ```\n'], + const containers = [ + ['a blockquote', '> ```js snippet=a.js\n> old\n> ```\n> after\n'], + [ + 'a blockquote without a space', + '>```js snippet=a.js\n>old\n>```\n>after\n', + ], [ 'a blockquote followed by a tab', '>\t```js snippet=a.js\n>\told\n>\t```\n', ], + [ + 'a nested blockquote', + '> > ```js snippet=a.js\n> > old\n> > ```\n> > after\n', + ], ['a compact nested blockquote', '>>```js snippet=a.js\n>>old\n>>```\n'], + [ + 'a compact nested blockquote followed by a tab', + '>>\t```js snippet=a.js\n>>\told\n>>\t```\n', + ], [ 'a tab-indented list item', - '1. Item\n\n\t```js snippet=a.js\n\told\n\t```\n', + '1. Item\n\n\t```js snippet=a.js\n\told\n\t```\n\n\tafter\n', + ], + [ + 'a list item it opens', + '- ```js snippet=a.js\n old\n ```\n after\n', ], - ['a list item it opens', '- ```js snippet=a.js\n old\n ```\n'], [ 'a list item whose marker is followed by a tab', '-\t```js snippet=a.js\n \told\n \t```\n', @@ -241,22 +268,57 @@ old ['an ordered list item', '10) ```js snippet=a.js\n old\n ```\n'], [ 'a list inside a blockquote', - '> - ```js snippet=a.js\n> old\n> ```\n', + '> - ```js snippet=a.js\n> old\n> ```\n> after\n', + ], + [ + 'a list right after a bare blockquote marker', + '>- ```js snippet=a.js\n> old\n> ```\n', + ], + [ + 'an ordered list right after a bare blockquote marker', + '>1. ```js snippet=a.js\n> old\n> ```\n', ], [ 'a blockquote inside a list', '- Nested\n > ```js snippet=a.js\n > old\n > ```\n', ], - ])('keeps the body inside %s', (_, source) => { - const document = parse(source); - document.setBody(document.codeBlocks[0]!, body); + [ + 'an unclosed block at the end of a blockquote', + '> ```js snippet=a.js\n> old\n>', + ], + [ + 'an unclosed block at the end of a list item', + '- ```js snippet=a.js\n old\n\nout\n', + ], + ['an unclosed top-level block', '```js snippet=a.js\nold\n'], + ]; + const bodies = [ + ['indentation, a blank line and a fence', 'one\n\n two\n```'], + ['a leading tab', '\tone'], + ['container markers', '> quoted\n- item\n> ```'], + ['nothing', ''], + ]; + + it.each( + containers.flatMap(([container, source]) => + bodies.map(([description, body]) => [ + container, + description, + source, + body, + ]), + ), + )('keeps the body inside %s when it has %s', (_, __, source, body) => { + const document = parse(source!); + document.setBody(document.codeBlocks[0]!, body!); const output = document.toString(); const reparsed = parse(output); expect(reparsed.codeBlocks.map((block) => block.content)).toEqual([ body, ]); - reparsed.setBody(reparsed.codeBlocks[0]!, body); + expect(structure(output)).toEqual(structure(source!)); + reparsed.setBody(reparsed.codeBlocks[0]!, body!); expect(reparsed.toString()).toBe(output); }); From 1af7100942ec1f95c5df33c30a8f8095c0a08c76 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 11 Oct 2026 12:18:26 -0700 Subject: [PATCH 3/4] Model how a fenced block ends; keep trailing blank lines in unclosed blocks An unterminated block cannot end in a blank body line: on reparse that line becomes the block's line break, so a body ending in a blank line grew by one line on the first re-render. Represent the end explicitly (closing fence, line break, or unterminated) and end an unterminated block at a line break when its new body ends in one, so the content round-trips exactly. --- src/fenced-block.ts | 58 ++++++++++++++++++++++++---------- test/fenced-block.test.ts | 50 ++++++++++++++++++++++------- test/markdown-document.test.ts | 10 ++++-- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/src/fenced-block.ts b/src/fenced-block.ts index 21f8fa1..96bf528 100644 --- a/src/fenced-block.ts +++ b/src/fenced-block.ts @@ -7,11 +7,16 @@ export interface BlockEdit { directive?: SnippetDirective | null | undefined; } +export type BlockEnd = + | { readonly kind: 'closing-fence'; readonly line: string } + | { readonly kind: 'line-break' } + | { readonly kind: 'unterminated' }; + export interface FencedBlock { readonly fence: string; readonly info: string; readonly rawBody: string; - readonly closing: string | undefined; + readonly end: BlockEnd; readonly bodyPrefix: string; readonly lineEnding: LineEnding; } @@ -22,6 +27,7 @@ const OPENING_LINE = /^(`{3,}|~{3,})(.*)$/; const BODY_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; const FENCE_RUN = /`{3,}|~{3,}/; const STARTS_WITH_WHITESPACE = /^[ \t]/; +const ENDS_WITH_LINE_BREAK = /(?:\r\n|\r|\n)$/; function columnAfter(text: string, startColumn: number): number { return [...text].reduce( @@ -53,17 +59,23 @@ function closingFencePattern(fence: string, quoteDepth: number): RegExp { return new RegExp(`^${blockquoteMarkers}[ \\t]*${fenceRun}[ \\t]*$`); } -function closingLine( +function blockEnd( lines: ReadonlyArray, fence: string, quoteDepth: number, -): string | undefined { - const last = lines.at(-1); - const closes = - lines.length > 1 && - last !== undefined && - (last === '' || closingFencePattern(fence, quoteDepth).test(last)); - return closes ? last : undefined; +): BlockEnd { + const last = lines.length > 1 ? lines.at(-1) : undefined; + if (last === '') { + return { kind: 'line-break' }; + } + if (last !== undefined && closingFencePattern(fence, quoteDepth).test(last)) { + return { kind: 'closing-fence', line: last }; + } + return { kind: 'unterminated' }; +} + +function endText(end: BlockEnd): string { + return end.kind === 'closing-fence' ? end.line : ''; } function linePrefixAt(source: string, offset: number): string { @@ -86,13 +98,16 @@ export function parseFencedBlockAt( const [, fence = '', info = ''] = match; const linePrefix = linePrefixAt(source, start); const quoteDepth = linePrefix.split(BLOCKQUOTE_MARKER).length - 1; - const closing = closingLine(lines, fence, quoteDepth); + const blockEnding = blockEnd(lines, fence, quoteDepth); return { fence, info, - rawBody: text.slice(opening.length, text.length - (closing?.length ?? 0)), - closing, + rawBody: text.slice( + opening.length, + text.length - endText(blockEnding).length, + ), + end: blockEnding, bodyPrefix: bodyPrefixFor(linePrefix), lineEnding: getLineEnding(text), }; @@ -129,6 +144,19 @@ function bodyLines(content: string, prefix: string): Array { ); } +function endFor(end: BlockEnd, content: string): BlockEnd { + return end.kind === 'unterminated' && ENDS_WITH_LINE_BREAK.test(content) + ? { kind: 'line-break' } + : end; +} + +function endLines(end: BlockEnd, fence: string): Array { + if (end.kind === 'closing-fence') { + return [lengthenFence(end.line, fence)]; + } + return end.kind === 'line-break' ? [''] : []; +} + export function renderFencedBlock(block: FencedBlock, edit: BlockEdit): string { const info = edit.directive === undefined @@ -136,16 +164,14 @@ export function renderFencedBlock(block: FencedBlock, edit: BlockEdit): string { : withDirective(block.info, edit.directive); if (edit.content === undefined) { - return block.fence + info + block.rawBody + (block.closing ?? ''); + return block.fence + info + block.rawBody + endText(block.end); } const fence = fenceFor(edit.content, block.fence); - const closing = - block.closing === undefined ? [] : [lengthenFence(block.closing, fence)]; return [ fence + info, ...bodyLines(edit.content, block.bodyPrefix), - ...closing, + ...endLines(endFor(block.end, edit.content), fence), ].join(block.lineEnding); } diff --git a/test/fenced-block.test.ts b/test/fenced-block.test.ts index ed1a71c..6e7be16 100644 --- a/test/fenced-block.test.ts +++ b/test/fenced-block.test.ts @@ -15,7 +15,10 @@ describe('parseFencedBlockAt', () => { expect(block).toMatchInlineSnapshot(` { "bodyPrefix": "> ", - "closing": "> \`\`\`\`", + "end": { + "kind": "closing-fence", + "line": "> \`\`\`\`", + }, "fence": "\`\`\`\`", "info": "ts snippet=a.ts", "lineEnding": " @@ -49,20 +52,36 @@ describe('parseFencedBlockAt', () => { }); it.each([ - ['a matching fence', '```ts\nx\n```', '```'], - ['a longer fence', '```ts\nx\n`````', '`````'], + [ + 'a matching fence', + '```ts\nx\n```', + { kind: 'closing-fence', line: '```' }, + ], + [ + 'a longer fence', + '```ts\nx\n`````', + { kind: 'closing-fence', line: '`````' }, + ], [ 'a fence behind its blockquote marker', '> ```ts\n> x\n> ```', - '> ```', + { kind: 'closing-fence', line: '> ```' }, + ], + ['a trailing line break', '```ts\nx\n', { kind: 'line-break' }], + ['a shorter fence', '~~~~ts\nx\n~~~', { kind: 'unterminated' }], + [ + 'a fence of the other character', + '```ts\nx\n~~~', + { kind: 'unterminated' }, ], - ['a trailing line break', '```ts\nx\n', ''], - ['a shorter fence', '~~~~ts\nx\n~~~', undefined], - ['a fence of the other character', '```ts\nx\n~~~', undefined], - ['a fence behind an extra blockquote marker', '```ts\n> ```', undefined], - ['only an opening line', '```ts', undefined], - ])('ends a block at %s', (_, source, closing) => { - expect(blockIn(source).closing).toBe(closing); + [ + 'a fence behind an extra blockquote marker', + '```ts\n> ```', + { kind: 'unterminated' }, + ], + ['only an opening line', '```ts', { kind: 'unterminated' }], + ])('ends a block at %s', (_, source, end) => { + expect(blockIn(source).end).toEqual(end); }); it('rejects text that does not start with a fence', () => { @@ -101,6 +120,15 @@ describe('renderFencedBlock', () => { ); }); + it('ends an unterminated block at a line break when the body ends in a blank line', () => { + const block = blockIn('```js\nold'); + + expect(renderFencedBlock(block, { content: 'new\n' })).toBe( + '```js\nnew\n\n', + ); + expect(renderFencedBlock(block, { content: 'new' })).toBe('```js\nnew'); + }); + it('writes a body into a block that only has an opening line', () => { expect(renderFencedBlock(blockIn('```js'), { content: 'x' })).toBe( '```js\nx', diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index 07b5583..187d2f0 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -272,11 +272,11 @@ old ], [ 'a list right after a bare blockquote marker', - '>- ```js snippet=a.js\n> old\n> ```\n', + '>- ```js snippet=a.js\n> old\n> ```\n> after\n', ], [ 'an ordered list right after a bare blockquote marker', - '>1. ```js snippet=a.js\n> old\n> ```\n', + '>1. ```js snippet=a.js\n> old\n> ```\n', ], [ 'a blockquote inside a list', @@ -291,12 +291,18 @@ old '- ```js snippet=a.js\n old\n\nout\n', ], ['an unclosed top-level block', '```js snippet=a.js\nold\n'], + [ + 'an unclosed block at the end of the file', + '```js snippet=a.js\nold\n\nold2', + ], ]; const bodies = [ ['indentation, a blank line and a fence', 'one\n\n two\n```'], ['a leading tab', '\tone'], ['container markers', '> quoted\n- item\n> ```'], ['nothing', ''], + ['trailing blank lines', 'one\n\n'], + ['only a blank line', '\n'], ]; it.each( From ae3370361985e9e3cb29e80dc258bbe3b67ab58e Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 11 Oct 2026 12:27:28 -0700 Subject: [PATCH 4/4] Drop trailing blank lines from the body of an unclosed block CommonMark reads trailing blank lines of an unclosed block differently by container (at the end of a blockquote they are not content), so keeping them made the block grow on each render. Drop them for unclosed blocks, which is stable everywhere, and find the line prefix after a lone carriage return. --- src/fenced-block.ts | 24 +++++++++----- test/fenced-block.test.ts | 19 +++++++---- test/markdown-document.test.ts | 60 +++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/fenced-block.ts b/src/fenced-block.ts index 96bf528..d67f22d 100644 --- a/src/fenced-block.ts +++ b/src/fenced-block.ts @@ -27,7 +27,7 @@ const OPENING_LINE = /^(`{3,}|~{3,})(.*)$/; const BODY_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; const FENCE_RUN = /`{3,}|~{3,}/; const STARTS_WITH_WHITESPACE = /^[ \t]/; -const ENDS_WITH_LINE_BREAK = /(?:\r\n|\r|\n)$/; +const TRAILING_LINE_BREAKS = /(?:\r\n|\r|\n)+$/; function columnAfter(text: string, startColumn: number): number { return [...text].reduce( @@ -79,7 +79,12 @@ function endText(end: BlockEnd): string { } function linePrefixAt(source: string, offset: number): string { - return source.slice(source.lastIndexOf('\n', offset - 1) + 1, offset); + const lineStart = + Math.max( + source.lastIndexOf('\n', offset - 1), + source.lastIndexOf('\r', offset - 1), + ) + 1; + return source.slice(lineStart, offset); } export function parseFencedBlockAt( @@ -144,10 +149,10 @@ function bodyLines(content: string, prefix: string): Array { ); } -function endFor(end: BlockEnd, content: string): BlockEnd { - return end.kind === 'unterminated' && ENDS_WITH_LINE_BREAK.test(content) - ? { kind: 'line-break' } - : end; +function bodyFor(content: string, end: BlockEnd): string { + return end.kind === 'closing-fence' + ? content + : content.replace(TRAILING_LINE_BREAKS, ''); } function endLines(end: BlockEnd, fence: string): Array { @@ -167,11 +172,12 @@ export function renderFencedBlock(block: FencedBlock, edit: BlockEdit): string { return block.fence + info + block.rawBody + endText(block.end); } - const fence = fenceFor(edit.content, block.fence); + const content = bodyFor(edit.content, block.end); + const fence = fenceFor(content, block.fence); return [ fence + info, - ...bodyLines(edit.content, block.bodyPrefix), - ...endLines(endFor(block.end, edit.content), fence), + ...bodyLines(content, block.bodyPrefix), + ...endLines(block.end, fence), ].join(block.lineEnding); } diff --git a/test/fenced-block.test.ts b/test/fenced-block.test.ts index 6e7be16..44f108b 100644 --- a/test/fenced-block.test.ts +++ b/test/fenced-block.test.ts @@ -120,13 +120,20 @@ describe('renderFencedBlock', () => { ); }); - it('ends an unterminated block at a line break when the body ends in a blank line', () => { - const block = blockIn('```js\nold'); + it('drops trailing blank lines from the body of an unclosed block', () => { + expect( + renderFencedBlock(blockIn('```js\nold'), { content: 'new\n\n' }), + ).toBe('```js\nnew'); + expect( + renderFencedBlock(blockIn('```js\nold\n'), { content: 'new\n' }), + ).toBe('```js\nnew\n'); + expect( + renderFencedBlock(blockIn('```js\nold\n```'), { content: 'new\n' }), + ).toBe('```js\nnew\n\n```'); + }); - expect(renderFencedBlock(block, { content: 'new\n' })).toBe( - '```js\nnew\n\n', - ); - expect(renderFencedBlock(block, { content: 'new' })).toBe('```js\nnew'); + it('reads the line prefix after a lone carriage return', () => { + expect(blockIn('p\r\r- ```js\r a\r ```\r').bodyPrefix).toBe(' '); }); it('writes a body into a block that only has an opening line', () => { diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index 187d2f0..8e10607 100644 --- a/test/markdown-document.test.ts +++ b/test/markdown-document.test.ts @@ -282,17 +282,19 @@ old 'a blockquote inside a list', '- Nested\n > ```js snippet=a.js\n > old\n > ```\n', ], + ]; + const unclosedContainers = [ + ['the end of a blockquote', '> ```js snippet=a.js\n> old\n>'], + ['the end of a file in a blockquote', '> ```js snippet=a.js\n> old\n'], [ - 'an unclosed block at the end of a blockquote', - '> ```js snippet=a.js\n> old\n>', - ], - [ - 'an unclosed block at the end of a list item', - '- ```js snippet=a.js\n old\n\nout\n', + 'a blockquote followed by text', + '> ```js snippet=a.js\n> old\n\nafter\n', ], - ['an unclosed top-level block', '```js snippet=a.js\nold\n'], + ['a list inside a blockquote', '> - ```js snippet=a.js\n> old\n'], + ['the end of a list item', '- ```js snippet=a.js\n old\n\nout\n'], + ['the end of the file', '```js snippet=a.js\nold\n'], [ - 'an unclosed block at the end of the file', + 'the end of a file without a line break', '```js snippet=a.js\nold\n\nold2', ], ]; @@ -305,6 +307,20 @@ old ['only a blank line', '\n'], ]; + function expectRoundTrip(source: string, body: string, content: string) { + const document = parse(source); + document.setBody(document.codeBlocks[0]!, body); + const output = document.toString(); + + const reparsed = parse(output); + expect(reparsed.codeBlocks.map((block) => block.content)).toEqual([ + content, + ]); + expect(structure(output)).toEqual(structure(source)); + reparsed.setBody(reparsed.codeBlocks[0]!, body); + expect(reparsed.toString()).toBe(output); + } + it.each( containers.flatMap(([container, source]) => bodies.map(([description, body]) => [ @@ -315,19 +331,25 @@ old ]), ), )('keeps the body inside %s when it has %s', (_, __, source, body) => { - const document = parse(source!); - document.setBody(document.codeBlocks[0]!, body!); - const output = document.toString(); - - const reparsed = parse(output); - expect(reparsed.codeBlocks.map((block) => block.content)).toEqual([ - body, - ]); - expect(structure(output)).toEqual(structure(source!)); - reparsed.setBody(reparsed.codeBlocks[0]!, body!); - expect(reparsed.toString()).toBe(output); + expectRoundTrip(source!, body!, body!); }); + it.each( + unclosedContainers.flatMap(([container, source]) => + bodies.map(([description, body]) => [ + container, + description, + source, + body, + ]), + ), + )( + 'keeps an unclosed block at %s stable when the body has %s', + (_, __, source, body) => { + expectRoundTrip(source!, body!, body!.replace(/\n+$/, '')); + }, + ); + it('repeats the blockquote marker on every body line', () => { const document = parse( '> Intro\n>\n> ```js snippet=a.js\n> old\n> ```\n> after\n',