Skip to content

Lengthen a code fence when the snippet contains one - #106

Merged
scalvert merged 2 commits into
mainfrom
fix/fence-breakout
Oct 10, 2026
Merged

scalvert merged 2 commits into
mainfrom
fix/fence-breakout

Conversation

@scalvert

@scalvert scalvert commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

A snippet containing a bare fence line (```) broke out of its code block. On main, syncing this a.js into a ```js snippet=a.js block:

const a = 1;
```

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

```js

writes the <img> into README.md as raw HTML, outside any code block. check then reports the block out of sync forever, and every sync appends another copy of the injected content. With remote snippets, whoever controls the URL controls that HTML.

Fix

renderBlock in markdown-document.ts uses CommonMark's rule: when the new body contains a bare run of the fence character at least as long as the fence, both the opening and closing fence become one longer than the longest such run.

  • Fences are never shortened. A longer existing fence is kept, and a closing fence keeps its length when it's already long enough.
  • Body lines split on \r\n, \r or \n, so a lone-CR body passed to the library API can't hide a fence line.
  • Only runs of the fence's own character count (~~~ inside a ``` block is fine), and runs followed by an info string (```js) don't count because they can't close a block.
  • Unclosed blocks get a longer opening fence only.

Documents already corrupted by this bug aren't repaired: the injected text now sits outside the block as ordinary content and needs a manual cleanup.

Tests

  • markdown-document.test.ts: the injection case round-trips through parse; one-longer-than-longest; other fence character ignored; info strings ignored; long-enough closing fence kept; lone \r line breaks; longer existing fence kept; nested in a list; unclosed block.
  • cli.test.ts: syncing a Markdown snippet that contains a code block gives a 4-backtick fence. A second sync changes nothing and check exits 0.
  • 8 of these 10 fail on main. The info-string and longer-existing-fence cases are regression guards that already pass there.
  • README: one line under "Two guarantees worth knowing" (now three) saying sync may lengthen a fence.

Not covered: fences inside blockquotes. Rewritten blockquote bodies already lose their > prefix (a known follow-up from #98), so a test there would lock in that bug.

  • I have run npm run lint and fixed any issues.
  • I have run npm run build to verify the project builds.
  • I have run npm test to ensure all tests pass. (317 passed)

When a snippet contained a bare fence line such as ```, sync wrote it
inside the existing ``` fence. That line closed the block early, so the
rest of the snippet landed in the document as Markdown (or raw HTML),
check reported the block out of sync forever, and every sync appended
another copy.

renderBlock now picks a fence one longer than the longest bare run of
the fence character in the new body (CommonMark's rule) and applies it
to the opening and closing fence. Fences are never shortened, runs of
the other fence character and runs followed by an info string don't
count, and unclosed blocks get a longer opening fence only.
@scalvert scalvert added the bug Something isn't working label Oct 10, 2026
Split body lines on lone CRs too, keep a closing fence that is already long enough, rename helpers, and note fence lengthening in the README.
@scalvert

Copy link
Copy Markdown
Owner Author

Second-pass review

Two passes: an adversarial pass trying to break out of the block, and a standards pass.

Adversarial: 47 hostile inputs through sync → sync → check, re-parsed with remark (and remark-mdx for .mdx):

  • fence lines with 0–3, 4+ space and tab indent, and trailing whitespace;
  • CRLF snippets and documents;
  • tilde and backtick fences mixed, and 5000-character runs;
  • fence lines last in the file, with and without a newline;
  • unclosed blocks at EOF, lists, MDX with JSX and frontmatter, and HTML after a closing fence.

Every CLI case gave exactly one code block whose content equals the snippet, a no-op second sync, and check exiting 0. All 9 test/fixtures scenarios plus 10 normal cases are byte-identical to main.

Fixed in e1c507a

  • Library-only escape: setBody / replaceCodeBlock with lone \r line breaks hid the fence line (indentBody turns \r into \n after detection). Lines now split on \r\n|\r|\n, with a test.
  • A closing fence already long enough was shortened when lengthening. Its length is now kept, with a test.
  • Naming: fit is now lengthen, withFenceLength is now lengthenFence with a return type, and BARE_FENCE_LINE is now CLOSING_FENCE_IN_BODY.
  • README: a third guarantee says sync may lengthen a fence. The PR description now has accurate test counts.

Not changed (pre-existing, same on main)

  • Blockquote bodies lose their > prefix, and tab-indented list items get 2-space bodies. This is the known follow-up from Deepen Markdown document module; deprecate offset-based parser helpers #98.
  • Unclosed block inside a list item loses its newline. The CLOSING_FENCE mismatch on a last content line of an unclosed block ( ```` /> ````) also predates this PR.
  • Lines indented 4+ spaces still trigger lengthening in .md. That's harmless, and MDX needs it.

CI green on Node 22 and 24; 317 tests pass locally.

@scalvert
scalvert merged commit b71dd71 into main Oct 10, 2026
2 checks passed
@scalvert
scalvert deleted the fix/fence-breakout branch October 10, 2026 20:14
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