Repository navigation
Read and write fence directives through one CommonMark info-string codec - #111
Conversation
Directives were read from remark's decoded meta but edited on the raw fence line, so escapes and character references made them disagree: entity whitespace dropped neighbouring metadata, " quotes left fragments behind, and written paths containing & or a backtick read back differently or broke the fence. Add info-string.ts, which decodes with micromark-util-decode-string while mapping decoded ranges to source ranges, and encodes only what would otherwise decode. readDirective and withDirective both use it. Drop the directive grammar's own backslash-quote rule, which applied to already-decoded text, and replace the character-loop tokenizer with token patterns.
Review fixes: - CommonMark separates the language from the metadata only at a space or tab, so js<NBSP>snippet=a.ts has no directive. splitLanguage used \S and read one. - Remove a directive with the separator after it, or before it when it is last, so a separator written as a character reference can no longer join the words around it. - Escape a trailing backslash, which could otherwise escape the source text that follows the directive. - Let the opening-line pattern match U+2028 and U+2029; a fence whose info contained one made parseFencedBlockAt throw. - Segment the info string with one matchAll pattern and an explicit decoded flag, expose sourceOffset(index), and split removal from replacement. - Make the offset test compare decoded prefixes, so it can fail.
ReviewThere were two reviews, plus two adversarial fuzz rounds. The fuzzing compared our directive reads with remark's
Standards: approved. I applied its changes:
The reviewer judged copying micromark's private pattern acceptable, since the exhaustive "decodes like micromark" test catches drift. Adversarial round 1 (about 130k cases) found three bugs:
It also found that Fixed in f8f880e:
Round 2 covered 154,100 sources, 924,600 sets and 426,096 add-then-remove pairs, with 0 failures in reads, sets and restores. Removal works for single directives. A fence with two Pre-existing, not changed here:
CI is green on Ubuntu, macOS and Windows × Node 22 and 24. 550 tests pass locally. |
Problem
Markdown decodes backslash escapes and character references in a fence's info string. We read directives from remark's decoded
meta, butsetDirectivesearched and wrote the raw fence line. Where the two differ, reading and writing disagree:setDirectivesnippet=a.ts title=xa.tsa.ts title=x, droppingtitle=xsnippet="my docs/a.ts"my docs/a.tssnippet="my, leavingdocs/a.ts"a&b.tsa&b.tsa`b.tsThe last two show up through the CLI.
extractinbacktick.mdwrites a directive that breaks the fence, andejectthen can't remove it.extractinodd&name.mdwrites a directive whose snippetcheck` reports as missing.The directive grammar also had its own rule that
\"doesn't end a quoted value. That rule applied to text CommonMark had already decoded, so a quoted path ending in a backslash (snippet="C:\my docs\") couldn't be written and read back.Fix
Escaping is now handled in one place: CommonMark's info-string rules. Reading and writing a directive both go through the same decoded text.
src/info-string.ts(new, about 70 lines):decodeInfoString(source)returns the decoded value andsourceOffset(index), which maps a decoded offset back to the source. It splits the source into literal text and escape/reference parts with onematchAllpattern, and decodes each part withmicromark-util-decode-string. That's the decoder remark uses, and it's already in the dependency tree; it is now a direct dependency.encodeInfoString(value)escapes only what would otherwise decode, plus a trailing backslash so it can't combine with the text after the directive. Backticks are written as`.src/fence-info.ts:readDirective(info)decodes the metadata and parses the directive.MarkdownDocumentnow uses it instead ofnode.meta. As in CommonMark, the language is split from the metadata only at a space or tab.withDirective(info, directive)finds the token in the decoded text and maps it back to the source. Replacing writes the encoded directive over that range. Removing also removes the separator after the directive, or the one before it if the directive is last, so the surrounding words can't join even when the separator is written as .src/snippet-directive.ts: the character-by-character tokenizer (about 50 lines of index arithmetic) is replaced by a token regex plus quoted/unquoted value patterns. A quoted value now ends at the next matching quote. A differential test against the old tokenizer found the same result on all 37,449 strings of up to 5 fragments, except inputs with a backslash before a quote.Directives without escapes or references read and write exactly as before. No existing test or snapshot changed.
parseFencedBlockAt(from #110) now accepts U+2028 and U+2029 in the info string. Before this fix, a fence containing one madeMarkdownDocument.parsethrow.Tests
info-string.test.ts: exhaustive over all 11,111 strings of up to 4 characters from\ & # 9 ;_ x ␠ "`, plus named, numeric, astral and invalid references. It checks that:decodeString;meta;decode(encode(v)) === vand the encoded form has no backticks.fence-info.test.ts:readDirectivewith escapes, references and"quotes; source-range replacement and removal; encoding on write.markdown-document.test.ts:docs\_draft.ts,a&b.ts,a`b.ts,C:\my docs\, and others) each round-trip throughsetDirective, read back unchanged, and removing the directive restores the source;snippet-directive.test.ts: a quoted value ending in a backslash; quoted values that don't end their token.extract→check→ejectonbacktick.mdandodd&name.md. Both resolve, and eject restores both byte for byte. Onmain, the backtick file kept its directive and&` was reported missing.README: the Snippet Syntax section now covers quoting and escapes.
npm run lintandnpm run format:check.npm run buildto verify the project builds.npm testto ensure all tests pass. (550 passed, plusnpm run test:package)