Repository navigation
Keep rewritten code blocks inside blockquotes and tab-indented lists - #110
Conversation
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).
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.
…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.
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.
ReviewThere were two reviews, plus three adversarial rounds using a generated harness that checked:
Standards: approved. I applied its suggestions:
Adversarial round 1 (about 46k cases) found four gaps that
Fixed:
Rounds 2–3 (about 80k contexts × 33 bodies) found two problems with unclosed blocks whose new body ends in blank lines:
Fixed: unclosed blocks drop trailing blank lines from the new body. That is stable in every container. Across the final sweep of 3,888 closed blocks, nothing regressed relative to Not fixed: at the end of an unclosed block, a fence-like line indented 4+ columns still counts as the closing fence. CI is green on Ubuntu, macOS and Windows × Node 22 and 24. 507 tests pass locally. |
Problem
When
syncrewrites a code block inside a blockquote or a tab-indented list, the snippet is written outside the block. The body lines gotcolumn - 1spaces, which drops the>markers and turns a tab into a single space:After that sync, the block is empty: the snippet text lands in a paragraph and the closing fence opens a new blockquote. A second
syncstill reports "All files are already in sync."Fix
Body lines now get a prefix built from the containers on the opening fence's line. Each blockquote level becomes
>. The indentation after it is written as spaces: the columns the original line used, with tab stops of 4, less the optional space each>consumes. This gets these cases right:>(>```js,>>```js);>followed directly by a list marker (>- ```js);>partly consumes (>>\t```js).Blank body lines get the prefix with trailing whitespace trimmed (
>), so they don't end the blockquote.> ```js>>>```js> >>- ```js>> - ```js>1.↵\t```js-\t```js- x↵> ```js>Space-only prefixes come out exactly as before, and no existing snapshot changed. In tab-indented blocks the body lines now use spaces. The fence lines keep their tabs. On
main, those blocks broke.Three related edge cases are fixed too. They were broken on
mainas well:> ```js\n> x\n>became> y>.syncalready trims snippets, so only directsetBodycalls see the difference.Structure
The fenced-block text handling moves out of
MarkdownDocumentinto two small pure modules:src/fenced-block.ts:parseFencedBlockAt(source, start, end)builds a model of the block,{ fence, info, rawBody, end, bodyPrefix, lineEnding }.endisclosing-fence,line-breakorunterminated.renderFencedBlock(block, edit)renders it. Prefix columns, closing detection, fence lengthening and body lines are each small functions within this module.src/fence-info.ts:withDirective(info, directive)edits the text after the fence. The fix for escaped metadata will build on it.MarkdownDocumentnow only locates code nodes, using ahasLanguagetype guard, and splices rendered blocks back into the source. The deprecatedreplaceCodeBlockuses the same two functions.line-endings.tsgainssplitLines, whichnormalizeLineEndingsnow uses as well.Two more edge cases change behaviour:
setBodyon a block with only an opening line, and no line break after it, now writes the body. Before, it was ignored.Known limitation
At the end of an unclosed block, a fence-like line indented 4 or more columns past its container still counts as the closing fence (
js\nx\n). That's content in CommonMark. The same happens onmain. An exact check needs the list item's content column, which only the parser knows, andsyncnever produces this case.Tests
Round trip: 20 container contexts × 6 bodies (indentation + blank line + fence line, a leading tab, container markers, empty, trailing blank lines, only a blank line). Seven unclosed-block contexts (end of a blockquote or list item, a list inside a blockquote, end of file with and without a final line break) run against the same bodies. They expect trailing blank lines to be dropped. Each case checks three things:
parse(toString())gives the body back, the document's mdast structure is unchanged (following text stays inside its container), and a second render changes nothing.Unit tests:
fenced-block.test.ts: the prefix table, the closing-fence rules, and verbatim output when only the directive changes;fence-info.test.ts: setting and removing directives.End to end: I ran
md-code syncon blockquote, tab-list and blockquote-in-list documents. Each block reparses to the snippet, and a second sync changes nothing.I have run
npm run lintandnpm run format:check.I have run
npm run buildto verify the project builds.I have run
npm testto ensure all tests pass. (507 passed, plusnpm run test:package)