From 23787823156ae3c2ddaabe3d3e45725d4ebca506 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 16:48:34 +0200 Subject: [PATCH] Answer architecture round 1: a token's width is true for every kind that carries one, the escape walk threads one record, the == step and the directive precondition say why, and item 96 files the inline HTML scan --- src/markdown/directive-syntax.ts | 2 +- src/markdown/emit/line-escaping.ts | 43 ++++++++++++++++++++-------- src/markdown/inline-tokens.ts | 17 +++++------ src/markdown/parse/inline-content.ts | 29 ++++++++++--------- todo.md | 12 ++++++-- 5 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index f5ef4b3..cc00b88 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -88,7 +88,7 @@ export function readDirectiveLine(line: string): Read | undefined return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line) } -// Reads the directive an `!adf:` at `index` claims. +// The caller has checked `claimsDirectivePrefix` at `index`. export function readInlineDirective(text: string, index: number): Read { return readClaimedDirective(text, index, 1) } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 273a60e..976ade2 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -32,10 +32,21 @@ type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number } -const runCharacters = '*_`~' +// What stays fixed while one line's escapes are decided; `escaped` collects them. +type EscapeWalk = { + claims: Claims + container: LineContainer + escaped: Set + escapings: readonly InlineEscaping[] + headingCloser: number | undefined + linkClose: number + scan: string +} const followsLinkText = /[([]/ +const runCharacters = '*_`~' + export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): AssembledLine { return escape(resolveEmphasis(segments), container, flavourClaims[flavour]) } @@ -89,30 +100,38 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, cl } function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, claims: Claims): ReadonlySet { - const escaped = new Set() - const linkClose = lastLinkClose(scan, escapings) - const headingCloser = container === 'heading' ? closingHashes(scan) : undefined + const walk: EscapeWalk = { + claims, + container, + escaped: new Set(), + escapings, + headingCloser: container === 'heading' ? closingHashes(scan) : undefined, + linkClose: lastLinkClose(scan, escapings), + scan, + } let line = scanLine(scan, 0) let index = 0 while (index < scan.length) { while (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1) if (runCharacters.includes(scan.charAt(index))) { - index = escapeRun(scan, escapings, index, line, container, claims, escaped) + index = escapeRun(walk, index, line) continue } const escaping = escapings[index] const token = readInlineToken(scan, index, claims) const claimed = isEscapable(escaping) - ? index === headingCloser || claimsLineStart(line, index, container) || claimsCharacter(scan, escapings, linkClose, index, token, container) + ? index === walk.headingCloser || claimsLineStart(line, index, container) || claimsCharacter(walk, index, token) : escaping === 'bracketed-link-target' && token.kind === 'directive' - if (claimed) escaped.add(index) + if (claimed) walk.escaped.add(index) + // The reader takes a `==` whole, so its second `=` starts nothing unless the first is escaped. index += !claimed && token.kind === 'highlight' ? token.width : 1 } - return escaped + return walk.escaped } // A run joining the emitter's own delimiter escapes its text; a run of text escapes from its start while the reader claims what the escapes leave of it. -function escapeRun(scan: string, escapings: readonly InlineEscaping[], start: number, line: ScanLine, container: LineContainer, claims: Claims, escaped: Set): number { +function escapeRun(walk: EscapeWalk, start: number, line: ScanLine): number { + const { escaped, escapings, scan } = walk const end = start + runLength(scan, start) const run = escapings.slice(start, end) if (run.some(isSyntax) && run.some(isEscapable)) { @@ -121,13 +140,13 @@ function escapeRun(scan: string, escapings: readonly InlineEscaping[], start: nu } for (let index = start; index < end; index += 1) { // A backtick run escapes whole: a rest left bare would be a raw run of another length for a closer. - if (!(index > start && scan.charAt(index) === '`') && !claimsRunRest(scan, escapings, index, end, line, container, claims)) break + if (!(index > start && scan.charAt(index) === '`') && !claimsRunRest(walk, index, end, line)) break escaped.add(index) } return end } -function claimsRunRest(scan: string, escapings: readonly InlineEscaping[], index: number, end: number, line: ScanLine, container: LineContainer, claims: Claims): boolean { +function claimsRunRest({ claims, container, escapings, scan }: EscapeWalk, index: number, end: number, line: ScanLine): boolean { const escaping = escapings[index] if (escaping === 'bracketed-link-target') return readInlineToken(scan, index, claims).kind === 'code-span' if (!isEscapable(escaping)) return false @@ -136,7 +155,7 @@ function claimsRunRest(scan: string, escapings: readonly InlineEscaping[], index return token.kind !== 'text' } -function claimsCharacter(scan: string, escapings: readonly InlineEscaping[], linkClose: number, index: number, token: InlineToken, container: LineContainer): boolean { +function claimsCharacter({ container, escapings, linkClose, scan }: EscapeWalk, index: number, token: InlineToken): boolean { const character = scan.charAt(index) const inBrackets = escapings[index] === 'bracketed' switch (token.kind) { diff --git a/src/markdown/inline-tokens.ts b/src/markdown/inline-tokens.ts index db51ac8..067a856 100644 --- a/src/markdown/inline-tokens.ts +++ b/src/markdown/inline-tokens.ts @@ -6,22 +6,23 @@ import { delimiterFlags, runLength } from './commonmark/emphasis-matching.ts' import { highlightDelimiter, highlightFlanking } from './portable/conventions.ts' import { readEntityReference } from './commonmark/entity-references.ts' -// What the inline reader reads from a token start, `width` characters on; `text` reads literally. +// What the inline reader reads from a token start, `width` characters on; `text` reads literally, a directive reads by its own grammar, and HTML is refused. export type InlineToken = - | { kind: 'code-span'; opener: number; width: number } - | { closes: boolean; kind: 'highlight'; opens: boolean; width: number } - | { canClose: boolean; canOpen: boolean; kind: 'delimiter-run'; width: number } - | { construct: string; kind: 'html'; width: 1 } | { email: boolean; kind: 'autolink'; width: number } | { image: boolean; kind: 'bracket'; width: number } - | { kind: 'bracket-close' | 'directive' | 'escape' | 'hard-break' | 'line-ending' | 'entity' | 'text'; width: number } + | { kind: 'bracket-close' | 'entity' | 'escape' | 'hard-break' | 'line-ending' | 'text'; width: number } + | { kind: 'code-span'; opener: number; width: number } + | { canClose: boolean; canOpen: boolean; kind: 'delimiter-run'; width: number } + | { kind: 'directive' } + | { closes: boolean; kind: 'highlight'; opens: boolean; width: number } + | { construct: string; kind: 'html' } export function readInlineToken(source: string, index: number, claims: Claims): InlineToken { switch (source.charAt(index)) { case '<': return angleToken(source, index) case '!': - if (claims.directives && claimsDirectivePrefix(source, index)) return { kind: 'directive', width: 1 } + if (claims.directives && claimsDirectivePrefix(source, index)) return { kind: 'directive' } return source.charAt(index + 1) === '[' ? { image: true, kind: 'bracket', width: 2 } : text(1) case '[': return { image: false, kind: 'bracket', width: 1 } @@ -63,7 +64,7 @@ function angleToken(source: string, index: number): InlineToken { const email = readEmailAutolink(source, index) if (email !== undefined) return { email: true, kind: 'autolink', width: email } const construct = inlineHtmlConstruct(source, index) - return construct === undefined ? text(1) : { construct, kind: 'html', width: 1 } + return construct === undefined ? text(1) : { construct, kind: 'html' } } function backtickToken(source: string, index: number): InlineToken { diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index a94ecfe..27fdcbe 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -119,46 +119,49 @@ function scanInline(scan: Scan): Result { } function readToken(scan: Scan, index: number, token: InlineToken): Result { - const end = index + token.width switch (token.kind) { - case 'autolink': + case 'autolink': { + const end = index + token.width flush(scan, false) pushNode(scan, linkedText(scan.source.slice(index + 1, end - 1), token.email ? 'mailto:' : '')) return success(end) + } case 'bracket': flush(scan, false) - scan.pieces.push({ active: true, image: token.image, kind: 'open', start: end }) - return success(end) + scan.pieces.push({ active: true, image: token.image, kind: 'open', start: index + token.width }) + return success(index + token.width) case 'bracket-close': return closeBracket(scan, index) - case 'code-span': + case 'code-span': { + const end = index + token.width flush(scan, false) pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' }) return success(end) + } case 'delimiter-run': flush(scan, false) scan.pieces.push({ canClose: token.canClose, canOpen: token.canOpen, character: scan.source.charAt(index), kind: 'run', length: token.width }) - return success(end) + return success(index + token.width) case 'directive': return readDirective(scan, index) + case 'entity': + case 'escape': + case 'text': + scan.pending += scan.source.slice(index, index + token.width) + return success(index + token.width) case 'hard-break': // CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash. flush(scan, false) pushNode(scan, { type: 'hardBreak' }) - return success(end) + return success(index + token.width) case 'highlight': flush(scan, false) scan.pieces.push({ closes: token.closes, kind: 'highlight', opens: token.opens }) - return success(end) + return success(index + token.width) case 'html': return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.path) case 'line-ending': return success(readLineEnding(scan, index)) - case 'entity': - case 'escape': - case 'text': - scan.pending += scan.source.slice(index, end) - return success(end) } } diff --git a/todo.md b/todo.md index 394979a..c1cee9d 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 96` +`Next ID = 97` | Goal | W | |---|---| @@ -34,6 +34,7 @@ | 62 | 0.2.0 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 | | 92 | 0.2.0 | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 4 | 2 | 4 | 1 | 9.0 | | 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 | +| 96 | 0.2.1 | defect | **Read and write a text holding many `` is quadratic in both +directions: the reader and the emitter's escape walk both ask it through `readInlineToken`. + ### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`. `adfToLosslessMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its @@ -279,7 +286,8 @@ gains its row. `src/markdown/emit/line-escaping.ts` asks the reader's tokens where text would read as syntax, but `escapeClosedRuns` still works out which backtick strings an escape leaves, and `unspellableRuns` -rebuilds the reader's emphasis and highlight pairing to pick the run to carry. Reading the line +rebuilds the reader's emphasis and highlight pairing to pick the run to carry, and `lastLinkClose` +guesses which text `[` opens link text. Reading the line back costs a parse per line attempt, and the run it picks may differ, changing output. ### 91. Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers.