Skip to content

Keep rewritten code blocks inside blockquotes and tab-indented lists - #110

Merged
scalvert merged 4 commits into
mainfrom
fix/container-prefixes
Oct 11, 2026
Merged

scalvert merged 4 commits into
mainfrom
fix/container-prefixes

Conversation

@scalvert

@scalvert scalvert commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Problem

When sync rewrites a code block inside a blockquote or a tab-indented list, the snippet is written outside the block. The body lines got column - 1 spaces, which drops the > markers and turns a tab into a single space:

> ```js snippet=a.js
  one

  two
> ```

After that sync, the block is empty: the snippet text lands in a paragraph and the closing fence opens a new blockquote. A second sync still 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:

  • a bare > (>```js, >>```js);
  • > followed directly by a list marker (>- ```js);
  • tabs that a > partly consumes (>>\t```js).

Blank body lines get the prefix with trailing whitespace trimmed (>), so they don't end the blockquote.

Opening line Body prefix
> ```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 main as well:

  • Unclosed block ending in a line break. The break is now kept. Before, > ```js\n> x\n> became > y>.
  • Empty body. It is written as no lines instead of one blank line. Before, an empty body in an unclosed blockquote block grew on every render.
  • Trailing blank lines. An unclosed block drops trailing blank lines from its new body. CommonMark reads them differently depending on the container: at the end of a blockquote they aren't content. Keeping them made the block grow on every render. sync already trims snippets, so only direct setBody calls see the difference.
  • Closing fence. It only counts if it sits behind exactly the container's blockquote markers. Before, a `> ```` body line in an unclosed top-level block was read as the closing fence on the next render.

Structure

The fenced-block text handling moves out of MarkdownDocument into two small pure modules:

  • src/fenced-block.ts: parseFencedBlockAt(source, start, end) builds a model of the block, { fence, info, rawBody, end, bodyPrefix, lineEnding }. end is closing-fence, line-break or unterminated. 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.

MarkdownDocument now only locates code nodes, using a hasLanguage type guard, and splices rendered blocks back into the source. The deprecated replaceCodeBlock uses the same two functions. line-endings.ts gains splitLines, which normalizeLineEndings now uses as well.

Two more edge cases change behaviour:

  • setBody on a block with only an opening line, and no line break after it, now writes the body. Before, it was ignored.
  • Files whose lines end in a lone CR now get the body written, with LF line endings. 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 on main. An exact check needs the list item's content column, which only the parser knows, and sync never 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 sync on 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 lint and npm run format:check.

  • I have run npm run build to verify the project builds.

  • I have run npm test to ensure all tests pass. (507 passed, plus npm run test:package)

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).
@scalvert scalvert added the bug Something isn't working label Oct 11, 2026
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.
@scalvert

Copy link
Copy Markdown
Owner Author

Review

There were two reviews, plus three adversarial rounds using a generated harness that checked:

  • that parse(toString()) gives the body back;
  • that the mdast structure is unchanged once code values are blanked;
  • that three renders in a row give the same output;
  • that setDirective leaves everything except the info string byte-identical.

Standards: approved. I applied its suggestions:

  • a single parseFencedBlockAt(source, start, end) instead of an exported linePrefixAt;
  • a model without overlapping fields (rawBody + an explicit end);
  • a shared splitLines;
  • a hasLanguage type guard instead of node.lang!;
  • no ! assertions in the new modules;
  • named regex constants.

Adversarial round 1 (about 46k cases) found four gaps that main shared:

  • a bare > followed by a list marker or tab;
  • an unclosed block losing the line break it ends with;
  • an empty body growing on each render;
  • a `> ```` body line read as the closing fence.

Fixed:

  • Prefixes are now computed in columns: one > per blockquote level, then the indentation as spaces.
  • The block's end is modelled explicitly.
  • An empty body is written as no lines.
  • A closing fence must sit behind exactly the container's blockquote markers.

Rounds 2–3 (about 80k contexts × 33 bodies) found two problems with unclosed blocks whose new body ends in blank lines:

  • My first fix made such blocks grow by one line on re-render.
  • Inside a blockquote they grew on every render.

Fixed: unclosed blocks drop trailing blank lines from the new body. That is stable in every container. sync trims snippets already. The rounds also found that linePrefixAt ignored a lone \r; that's fixed too.

Across the final sweep of 3,888 closed blocks, nothing regressed relative to origin/main or the previous commit. setDirective stayed byte-identical everywhere.

Not fixed: at the end of an unclosed block, a fence-like line indented 4+ columns still counts as the closing fence. main behaves the same; the PR description covers this. Files whose lines end in a lone CR get LF line endings in rewritten blocks; main ignored edits to those files entirely.

CI is green on Ubuntu, macOS and Windows × Node 22 and 24. 507 tests pass locally.

@scalvert
scalvert merged commit 042bd08 into main Oct 11, 2026
7 checks passed
@scalvert
scalvert deleted the fix/container-prefixes branch October 11, 2026 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant