diff --git a/src/fence-info.ts b/src/fence-info.ts new file mode 100644 index 0000000..8ddb622 --- /dev/null +++ b/src/fence-info.ts @@ -0,0 +1,45 @@ +import type { SnippetDirective } from './types.js'; +import { + findSnippetDirectiveToken, + formatSnippetDirective, +} 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] ?? ''; + 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 = WHITESPACE.test(meta.charAt(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..d67f22d --- /dev/null +++ b/src/fenced-block.ts @@ -0,0 +1,183 @@ +import type { SnippetDirective } from './types.js'; +import { withDirective } from './fence-info.js'; +import { getLineEnding, splitLines, type LineEnding } from './line-endings.js'; + +export interface BlockEdit { + content?: string | undefined; + 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 end: BlockEnd; + readonly bodyPrefix: string; + readonly lineEnding: LineEnding; +} + +const TAB_SIZE = 4; +const BLOCKQUOTE_MARKER = '>'; +const OPENING_LINE = /^(`{3,}|~{3,})(.*)$/; +const BODY_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/; +const FENCE_RUN = /`{3,}|~{3,}/; +const STARTS_WITH_WHITESPACE = /^[ \t]/; +const TRAILING_LINE_BREAKS = /(?:\r\n|\r|\n)+$/; + +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 { + 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 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]*$`); +} + +function blockEnd( + lines: ReadonlyArray, + fence: string, + quoteDepth: number, +): 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 { + const lineStart = + Math.max( + source.lastIndexOf('\n', offset - 1), + source.lastIndexOf('\r', offset - 1), + ) + 1; + return source.slice(lineStart, offset); +} + +export function parseFencedBlockAt( + source: string, + start: number, + end: number, +): FencedBlock { + 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 = '', info = ''] = match; + const linePrefix = linePrefixAt(source, start); + const quoteDepth = linePrefix.split(BLOCKQUOTE_MARKER).length - 1; + const blockEnding = blockEnd(lines, fence, quoteDepth); + + return { + fence, + info, + rawBody: text.slice( + opening.length, + text.length - endText(blockEnding).length, + ), + end: blockEnding, + bodyPrefix: bodyPrefixFor(linePrefix), + lineEnding: getLineEnding(text), + }; +} + +function longestFenceRun(content: string, fenceChar: string): number { + return splitLines(content).reduce((longest, line) => { + const run = BODY_FENCE_LINE.exec(line)?.[1]; + return run?.charAt(0) === fenceChar + ? Math.max(longest, run.length) + : longest; + }, 0); +} + +function fenceFor(content: string, fence: string): string { + const fenceChar = fence.charAt(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 { + if (content === '') { + return []; + } + const blankLinePrefix = prefix.trimEnd(); + return splitLines(content).map((line) => + line === '' ? blankLinePrefix : prefix + line, + ); +} + +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 { + 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 + ? block.info + : withDirective(block.info, edit.directive); + + if (edit.content === undefined) { + return block.fence + info + block.rawBody + endText(block.end); + } + + const content = bodyFor(edit.content, block.end); + const fence = fenceFor(content, block.fence); + + return [ + fence + info, + ...bodyLines(content, block.bodyPrefix), + ...endLines(block.end, fence), + ].join(block.lineEnding); +} 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 90645ca..b947f71 100644 --- a/src/markdown-document.ts +++ b/src/markdown-document.ts @@ -6,15 +6,16 @@ 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'; + parseFencedBlockAt, + renderFencedBlock, + type BlockEdit, + type FencedBlock, +} from './fenced-block.js'; +import type { LineEnding } from './line-endings.js'; export interface ParseOptions { mdx?: boolean; @@ -29,57 +30,19 @@ export interface DocumentCodeBlock { readonly lineEnding: LineEnding; } -export interface BlockEdit { - content?: string | undefined; - directive?: SnippetDirective | null | undefined; -} - interface BlockRecord { - block: DocumentCodeBlock; - start: number; - end: number; + readonly block: DocumentCodeBlock; + readonly start: number; + readonly end: number; + readonly source: FencedBlock; } -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)), - ); -} +type LanguageCode = Code & { lang: string }; -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 +61,39 @@ 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}`; + return [...replacements] + .sort((a, b) => b.start - a.start) + .reduce( + (output, { start, end, text }) => + output.slice(0, start) + text + output.slice(end), + source, + ); } -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); +function hasLanguage(node: Code): node is LanguageCode { + return Boolean(node.lang); } -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: LanguageCode): BlockRecord { + const start = node.position?.start.offset ?? 0; + const end = node.position?.end.offset ?? 0; + const source = parseFencedBlockAt(content, start, end); + 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 +113,9 @@ export class MarkdownDocument { const records: Array = []; visit(tree, 'code', (node: Code) => { - if (!node.lang) { - return; + if (hasLanguage(node)) { + 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 +137,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..41b83ba 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3,9 +3,9 @@ import type { MarkdownFile, CodeBlock } from './types.js'; import { MarkdownDocument, isMdxPath, - renderBlock, toLegacyCodeBlocks, } from './markdown-document.js'; +import { parseFencedBlockAt, renderFencedBlock } from './fenced-block.js'; export { parseSnippetDirective } from './snippet-directive.js'; export { @@ -83,9 +83,8 @@ export function replaceCodeBlock( newContent: string, ): string { const { start, end } = codeBlock.position; - const rendered = renderBlock( - markdownContent.slice(start, end), - codeBlock.columnNumber ?? 1, + const rendered = renderFencedBlock( + parseFencedBlockAt(markdownContent, start, end), { content: newContent }, ); return ( 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..44f108b --- /dev/null +++ b/test/fenced-block.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { parseFencedBlockAt, renderFencedBlock } from '../src/fenced-block.js'; + +function blockIn(source: string) { + return parseFencedBlockAt(source, source.search(/`{3}|~{3}/), source.length); +} + +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).toMatchInlineSnapshot(` + { + "bodyPrefix": "> ", + "end": { + "kind": "closing-fence", + "line": "> \`\`\`\`", + }, + "fence": "\`\`\`\`", + "info": "ts snippet=a.ts", + "lineEnding": " + ", + "rawBody": " + > x + ", + } + `); + }); + + it.each([ + ['', ''], + [' ', ' '], + ['\t', ' '], + ['- ', ' '], + ['-\t', ' '], + ['10) ', ' '], + ['> ', '> '], + ['>', '> '], + ['>>', '> > '], + ['>\t', '> '], + ['>>\t', '> > '], + ['>- ', '> '], + ['> 1. ', '> '], + [' > ', ' > '], + ])('continues the line prefix %j as %j', (linePrefix, bodyPrefix) => { + expect(blockIn(`${linePrefix}\`\`\`js\n\`\`\``).bodyPrefix).toBe( + bodyPrefix, + ); + }); + + it.each([ + [ + '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 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', () => { + expect(() => parseFencedBlockAt(' code', 0, 8)).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 = 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 block = blockIn('> ```js snippet=a.js\r\n> odd \n> ```'); + + expect(renderFencedBlock(block, { directive: null })).toBe( + '```js\r\n> odd \n> ```', + ); + }); + + it('keeps the line break that ends an unclosed block', () => { + expect(renderFencedBlock(blockIn('```js\nold\n'), { content: 'new' })).toBe( + '```js\nnew\n', + ); + }); + + 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```'); + }); + + 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', () => { + expect(renderFencedBlock(blockIn('```js'), { content: 'x' })).toBe( + '```js\nx', + ); + }); +}); diff --git a/test/markdown-document.test.ts b/test/markdown-document.test.ts index 2fb20ae..8e10607 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 }); } @@ -217,6 +233,136 @@ old `); }); + describe('when the fence is inside a container', () => { + 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\n\tafter\n', + ], + [ + 'a list item it opens', + '- ```js snippet=a.js\n old\n ```\n after\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> after\n', + ], + [ + 'a list right after a bare blockquote marker', + '>- ```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', + ], + [ + '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'], + [ + 'a blockquote followed by text', + '> ```js snippet=a.js\n> old\n\nafter\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'], + [ + 'the end of a file without a line break', + '```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'], + ]; + + 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]) => [ + container, + description, + source, + body, + ]), + ), + )('keeps the body inside %s when it has %s', (_, __, source, body) => { + 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', + ); + + 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');