From 5a41547222f4a2e09e0e15e47feceffeae8f62db Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 1 Sep 2026 20:11:41 +0200 Subject: [PATCH] Answer the stability nits: the runs a trailing-anchored regex re-walks --- corpus/README.md | 3 +- src/markdown/commonmark-grammar.ts | 16 +++++++-- src/markdown/parse/inline-content.ts | 8 ++--- todo-history.md | 52 ++++++++++++++-------------- 4 files changed, 45 insertions(+), 34 deletions(-) diff --git a/corpus/README.md b/corpus/README.md index d84d3aa..1100d66 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -6,7 +6,8 @@ One directory per contract kind, each landing with its milestone: document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped by what the fixture exercises. - `normalization/` — `.md` + `.json`: markdown input, and the document - `markdownToAdf` must build from it. One-way; the markdown is not canonical. + `markdownToAdf` must build from it, which must in turn emit and read back to itself. The + markdown is not canonical. - `errors/` — `.md`: markdown input that must not convert. A `.error` beside it pins which error. - `real-payloads/` — `.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index fd93d8d..7c4e074 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -223,6 +223,18 @@ export function setextHeadingLevel(line: string): number | undefined { return underline.startsWith('=') ? 1 : 2 } -export function trimSpace(text: string): string { - return text.replace(/^[ \t]+|[ \t]+$/g, '') +function spaceOrTab(character: string): boolean { + return character === ' ' || character === '\t' +} + +export function trimSpace(text: string): string { + let start = 0 + while (start < text.length && spaceOrTab(text.charAt(start))) start += 1 + return trimTrailingSpace(text.slice(start)) +} + +export function trimTrailingSpace(text: string): string { + let end = text.length + while (end > 0 && spaceOrTab(text.charAt(end - 1))) end -= 1 + return text.slice(0, end) } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 3a7b70b..a867fe1 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -1,7 +1,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { EmphasisPairing } from '../emphasis-matching.ts' import type { LinkDefinition } from '../link-syntax.ts' -import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts' +import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts' import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -28,9 +28,7 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } -const hardBreakSpaces = / {2,}$/ const imageAlone = 'an image fits only as a paragraph of its own' -const trailingSpace = /[ \t]+$/ export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { const scan: Scan = { definitions, path, pending: '', pieces: [], source } @@ -95,7 +93,7 @@ function readBackslash(scan: Scan, index: number): number { } function readLineEnding(scan: Scan, index: number): number { - const hard = hardBreakSpaces.test(scan.pending) + const hard = scan.pending.endsWith(' ') flush(scan, true) if (hard) pushNode(scan, { type: 'hardBreak' }) else scan.pending = ' ' @@ -154,7 +152,7 @@ function readDirective(scan: Scan, index: number): Result { } function flush(scan: Scan, strip: boolean): void { - const raw = strip ? scan.pending.replace(trailingSpace, '') : scan.pending + const raw = strip ? trimTrailingSpace(scan.pending) : scan.pending scan.pending = '' if (raw !== '') scan.pieces.push({ kind: 'nodes', nodes: [{ text: decodeTextEscapes(raw), type: 'text' }] }) } diff --git a/todo-history.md b/todo-history.md index 5e5de69..60c297d 100644 --- a/todo-history.md +++ b/todo-history.md @@ -281,32 +281,7 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: `spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost leaves the fence it did not close a named error, which §2 prefers to closing more than the author wrote. - - [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the - `language` attribute a bare fence leaves it; the media family's composition; and both - table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both - directions answer alike and moves to the `markdown/` root with the language attribute. - **Settled** (the maintainer, 2026-08-27): 1d's last pick, the one - `container-block-separation` holds — a CommonMark block and a directive block sit adjacent - in a container body with no blank line between them. That reduces the three cases to one - rule, separation only where its absence would merge the blocks: the `:::` fence is - separation already, and 3c's claim ends the lazy continuation that would otherwise swallow - it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair - that empties `corpus/unspellable/`: this chunk settles the directory's own guard in - `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. - The emitter's other refusals survive on causes no fixture in that directory covers, so - 3k's one-list pass is where they get fixtures or the directory goes. - **Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input - accepted and emit refused, not the last. Two adjacent lists of a kind are what - `adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them - two ways — a marker change, which CommonMark opens a second list on, and an empty last item, - whose blank line pops the container the list's identity hung from. The parser opens no list - beside one of its own kind instead, the way it already drops the blank lines between items; - 3k owes the CommonMark suite an exception where the reference HTML holds two `
    `. The - `normalization/` arm emits each document and reads it back from here, so the population that - class lives in is checked rather than read. The README's canonical-fixpoint sentence still - claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to - narrow. -- [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first + - [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift @@ -334,3 +309,28 @@ Under **3 — `markdownToAdf` (`0.1.0`)**: path and returns `Result`, so no second reader took it. The drift guard earned itself on the way in: the spec's `text` attribute was missing from three inline table entries, which the content slot spells and the vocabulary walk already passes over. + - [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the + `language` attribute a bare fence leaves it; the media family's composition; and both + table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both + directions answer alike and moves to the `markdown/` root with the language attribute. + **Settled** (the maintainer, 2026-08-27): 1d's last pick, the one + `container-block-separation` holds — a CommonMark block and a directive block sit adjacent + in a container body with no blank line between them. That reduces the three cases to one + rule, separation only where its absence would merge the blocks: the `:::` fence is + separation already, and 3c's claim ends the lazy continuation that would otherwise swallow + it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair + that empties `corpus/unspellable/`: this chunk settles the directory's own guard in + `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. + The emitter's other refusals survive on causes no fixture in that directory covers, so + 3k's one-list pass is where they get fixtures or the directory goes. + **Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input + accepted and emit refused, not the last. Two adjacent lists of a kind are what + `adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them + two ways — a marker change, which CommonMark opens a second list on, and an empty last item, + whose blank line pops the container the list's identity hung from. The parser opens no list + beside one of its own kind instead, the way it already drops the blank lines between items; + 3k owes the CommonMark suite an exception where the reference HTML holds two `
      `. The + `normalization/` arm emits each document and reads it back from here, so the population that + class lives in is checked rather than read. The README's canonical-fixpoint sentence still + claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to + narrow.