From dba94dd8fdda8b203eb812fc03337f16f93af2e6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 16:39:13 +0200 Subject: [PATCH] Escape a line against the inline reader's own tokens, in time linear in a run --- AGENTS.md | 2 +- docs/decisions.md | 4 +- src/markdown/commonmark/grammar.ts | 8 -- src/markdown/directive-syntax.test.ts | 4 +- src/markdown/directive-syntax.ts | 10 +- src/markdown/emit/line-escaping.ts | 171 ++++++++++------------- src/markdown/inline-tokens.ts | 77 +++++++++++ src/markdown/parse/inline-content.ts | 191 +++++++------------------- todo.md | 13 +- 9 files changed, 212 insertions(+), 268 deletions(-) create mode 100644 src/markdown/inline-tokens.ts diff --git a/AGENTS.md b/AGENTS.md index 9522e2c..cf7d219 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ In `docs/decisions.md`: - Firefox reads the build - The coverage floors - The size ratchet -- The project ships under the comprehension floor until items 59, 60, 61 and 62 land +- The project ships under the comprehension floor until items 59, 60 and 62 land - Properties on a fixed seed - The CommonMark suite checks three ways - The flavour spec is read as a source diff --git a/docs/decisions.md b/docs/decisions.md index 93ddd89..da52430 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -570,10 +570,10 @@ config gone missing fails the leg instead of falling back to oxlint's own defaul `--deny-warnings`, since a rule from a category this config never names arrives as a warning it exits 0 on. -## The project ships under the comprehension floor until items 59, 60, 61 and 62 land +## The project ships under the comprehension floor until items 59, 60 and 62 land 2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid -while `todo.md` items 59, 60, 61 and 62 are open. +while `todo.md` items 59, 60 and 62 are open. A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of 2026-10-08 is the baseline. Since 2026-10-04, a chunk whose panel scores any diff --git a/src/markdown/commonmark/grammar.ts b/src/markdown/commonmark/grammar.ts index 2aab6e3..ec96a41 100644 --- a/src/markdown/commonmark/grammar.ts +++ b/src/markdown/commonmark/grammar.ts @@ -235,14 +235,6 @@ export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlB return undefined } -export function opensBracketedAutolink(text: string, index: number): boolean { - return readBracketedAutolink(text, index) !== undefined -} - -export function opensEmailAutolink(text: string, index: number): boolean { - return readEmailAutolink(text, index) !== undefined -} - export function readBracketedAutolink(text: string, index: number): number | undefined { bracketedAutolink.lastIndex = index return bracketedAutolink.exec(text)?.[0].length diff --git a/src/markdown/directive-syntax.test.ts b/src/markdown/directive-syntax.test.ts index 6639093..b25b9d0 100644 --- a/src/markdown/directive-syntax.test.ts +++ b/src/markdown/directive-syntax.test.ts @@ -3,7 +3,7 @@ import test from 'node:test' import type { DirectiveAttributes, DirectiveLine } from './directive-syntax.ts' import { largestNesting } from '../nesting.ts' -import { readDirectiveLine, readInlineDirective } from './directive-syntax.ts' +import { claimsDirectivePrefix, readDirectiveLine, readInlineDirective } from './directive-syntax.ts' // A pair the input spells bare decodes to itself; a quoted one names its spelling beside the decoding. type Pair = [string, string, string?] @@ -22,8 +22,8 @@ function fault(line: string): string { } function inline(text: string): unknown { + if (!claimsDirectivePrefix(text, 0)) return 'unclaimed' const read = readInlineDirective(text, 0) - if (read === undefined) return 'unclaimed' if (read.fault !== undefined) return read.fault.message return { attributes: read.value.attributes, content: read.value.content, length: read.value.length, name: read.value.name } } diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 3b75314..f5ef4b3 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -88,8 +88,9 @@ export function readDirectiveLine(line: string): Read | undefined return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line) } -export function readInlineDirective(text: string, index: number): Read | undefined { - return readNestedDirective(text, index, 1) +// Reads the directive an `!adf:` at `index` claims. +export function readInlineDirective(text: string, index: number): Read { + return readClaimedDirective(text, index, 1) } export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read { @@ -211,7 +212,10 @@ function readOpenerAttributes(rest: string, index: number): Read { } function readNestedDirective(text: string, index: number, depth: number): Read | undefined { - if (!claimsDirectivePrefix(text, index)) return undefined + return claimsDirectivePrefix(text, index) ? readClaimedDirective(text, index, depth) : undefined +} + +function readClaimedDirective(text: string, index: number, depth: number): Read { const name = inlineDirectiveName(text, index) if (name === undefined) return { fault: malformedDirective(prefixFault) } if (depth > largestNesting) { diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 535d542..273a60e 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,13 +1,12 @@ import type { Claims, WrittenFlavour } from '../portable/conventions.ts' +import type { InlineToken } from '../inline-tokens.ts' import type { LineContainer } from '../line-container.ts' -import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts' -import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' -import { claimsDirectivePrefix } from '../directive-syntax.ts' import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' -import { flavourClaims, highlightDelimiter, highlightFlanking } from '../portable/conventions.ts' +import { delimiterRunToken, readInlineToken } from '../inline-tokens.ts' +import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts' +import { flavourClaims, highlightFlanking } from '../portable/conventions.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts' import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts' -import { readEntityReference } from '../commonmark/entity-references.ts' export type DelimiterRole = 'close' | 'open' @@ -33,7 +32,7 @@ type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number } -const delimiters = ['*', '_', '`', '~'] +const runCharacters = '*_`~' const followsLinkText = /[([]/ @@ -92,36 +91,68 @@ 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 let line = scanLine(scan, 0) - let afterEscape = false - // Whether the `=` before opens a `==` the reader takes whole, so this one starts nothing. - let pairsEquals = false - for (let index = 0; index < scan.length; index += 1) { - if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1) + 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) + continue + } const escaping = escapings[index] - const escapable = escaping === 'backslash' || escaping === 'bracketed' - const opensEquals: boolean = claims.highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index) - const opensDirective = claims.directives && claimsDirectivePrefix(scan, index) - const claimed: boolean = - (escapable && - ((opensEquals && claimsHighlight(scan, index)) || - claimsLineStart(line, index, container) || - mergesWithSyntax(scan, escapings, index) || - opensDirective || - opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) || - (escaping === 'bracketed-link-target' && - ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || opensDirective)) + const token = readInlineToken(scan, index, claims) + const claimed = isEscapable(escaping) + ? index === headingCloser || claimsLineStart(line, index, container) || claimsCharacter(scan, escapings, linkClose, index, token, container) + : escaping === 'bracketed-link-target' && token.kind === 'directive' if (claimed) escaped.add(index) - afterEscape = claimed - pairsEquals = opensEquals && !claimed + index += !claimed && token.kind === 'highlight' ? token.width : 1 } return escaped } -// Like an emphasis run, a `==` in text escapes where the reader can open or close with it. -function claimsHighlight(scan: string, index: number): boolean { - const flanking = highlightFlanking(scan, index) - return flanking.opens || flanking.closes +// 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 { + const end = start + runLength(scan, start) + const run = escapings.slice(start, end) + if (run.some(isSyntax) && run.some(isEscapable)) { + for (let index = start; index < end; index += 1) if (isEscapable(escapings[index])) escaped.add(index) + return end + } + 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 + escaped.add(index) + } + return end +} + +function claimsRunRest(scan: string, escapings: readonly InlineEscaping[], index: number, end: number, line: ScanLine, container: LineContainer, claims: Claims): boolean { + const escaping = escapings[index] + if (escaping === 'bracketed-link-target') return readInlineToken(scan, index, claims).kind === 'code-span' + if (!isEscapable(escaping)) return false + if (claimsLineStart(line, index, container)) return true + const token = scan.charAt(index) === '`' ? readInlineToken(scan, index, claims) : delimiterRunToken(scan, index, end - index, claims) + return token.kind !== 'text' +} + +function claimsCharacter(scan: string, escapings: readonly InlineEscaping[], linkClose: number, index: number, token: InlineToken, container: LineContainer): boolean { + const character = scan.charAt(index) + const inBrackets = escapings[index] === 'bracketed' + switch (token.kind) { + case 'bracket': + if (token.image) return isSyntax(escapings[index + 1]) + return inBrackets || index < linkClose + case 'bracket-close': + return inBrackets + case 'highlight': + return token.opens || token.closes + case 'text': + if (character === '|') return container === 'table-cell' + return character === '{' && scan.charAt(index - 1) === ']' && isSyntax(escapings[index - 1]) + default: + return true + } } // CommonMark reads no escape inside a code span, so a backtick string left beside an escape still closes a span that an earlier bare run of its length opens. @@ -246,37 +277,14 @@ function emittedRuns(segments: readonly InlineSegment[], placements: readonly nu return { nodes, runs } } -function mergesWithSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean { - const character = scan.charAt(index) - if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(escapings[index + 1]) - if (character === '{') return scan.charAt(index - 1) === ']' && isSyntax(escapings[index - 1]) - if (!delimiters.includes(character)) return false - return touchesSyntax(scan, escapings, index, -1) || touchesSyntax(scan, escapings, index, 1) -} - -function touchesSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number, step: number): boolean { - const character = scan.charAt(index) - let cursor = index + step - while (scan.charAt(cursor) === character && !isSyntax(escapings[cursor])) cursor += step - return scan.charAt(cursor) === character && isSyntax(escapings[cursor]) +function isEscapable(escaping: InlineEscaping | undefined): boolean { + return escaping === 'backslash' || escaping === 'bracketed' } export function isSyntax(escaping: InlineEscaping | undefined): boolean { return escaping === 'none' || escaping === 'bracketed-link-target' } -function opensConstruct( - scan: string, - linkClose: number, - index: number, - inBrackets: boolean, - container: LineContainer, - afterEscape: boolean, -): boolean { - if (container === 'heading' && closesHeading(scan, index)) return true - return claimsCharacter(scan, linkClose, index, inBrackets, container, afterEscape) -} - // A hard break is the one spelling that puts a delimiter row under a row of its own, so only a later line claims. function claimsLineStart(line: ScanLine, index: number, container: LineContainer): boolean { if (container !== 'paragraph') return false @@ -284,36 +292,19 @@ function claimsLineStart(line: ScanLine, index: number, container: LineContainer return escapesLineClaim(line.text, index - line.start, line.position) } +// Where the closing sequence an ATX heading would read in the text starts, `undefined` where it reads none. +function closingHashes(scan: string): number | undefined { + let start = scan.length + while (scan.charAt(start - 1) === '#') start -= 1 + if (start === scan.length) return undefined + return start === 0 || /[ \t]/.test(scan.charAt(start - 1)) ? start : undefined +} + function scanLine(scan: string, start: number): ScanLine { const end = scan.indexOf('\n', start) return { position: start === 0 ? 'first' : 'later', start, text: scan.slice(start, end === -1 ? undefined : end) } } -function closesHeading(scan: string, index: number): boolean { - if (scan.charAt(index) !== '#' || !/^#+$/.test(scan.slice(index))) return false - return index === 0 || /[ \t]/.test(scan.charAt(index - 1)) -} - -function claimsCharacter( - scan: string, - linkClose: number, - index: number, - inBrackets: boolean, - container: LineContainer, - afterEscape: boolean, -): boolean { - const character = scan.charAt(index) - if (inBrackets && (character === '[' || character === ']')) return true - if (character === '|') return container === 'table-cell' - if (character === '\\') return backslashEscape(scan, index) !== undefined - if (character === '&') return readEntityReference(scan, index) !== undefined - if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined - if (character === '[') return index < linkClose - if (character === '`') return opensCodeSpan(scan, index, afterEscape) - if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, afterEscape) - return false -} - // A `]` the emitter spelled sits inside a construct that binds before link text does. function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undefined)[]): number { for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) { @@ -323,28 +314,6 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef return -1 } -function opensCodeSpan(scan: string, index: number, afterEscape: boolean): boolean { - // A run escapes whole: a rest left bare would be a raw run of another length for a closer. - if (afterEscape && scan.charAt(index - 1) === '`') return true - if (!startsRun(scan, index, afterEscape)) return false - const opener = backtickRun(scan, index) - return closingBacktickRun(scan, index + opener, opener) !== undefined -} - -function claimsEmphasis(scan: string, index: number, afterEscape: boolean): boolean { - if (!startsRun(scan, index, afterEscape)) return false - const character = scan.charAt(index) - const length = runLength(scan, index) - if (character === '~' && length !== 2) return false - const flags = delimiterFlags(character, index === 0 ? '' : scan.charAt(index - 1), scan.charAt(index + length)) - return flags.canClose || flags.canOpen -} - -function startsRun(scan: string, index: number, afterEscape: boolean): boolean { - if (index === 0 || afterEscape) return true - return scan.charAt(index - 1) !== scan.charAt(index) -} - function charAt(text: string, index: number): string { return index < 0 ? '' : text.charAt(index) } diff --git a/src/markdown/inline-tokens.ts b/src/markdown/inline-tokens.ts new file mode 100644 index 0000000..db51ac8 --- /dev/null +++ b/src/markdown/inline-tokens.ts @@ -0,0 +1,77 @@ +import type { Claims } from './portable/conventions.ts' +import { backslashEscape, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from './commonmark/grammar.ts' +import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts' +import { claimsDirectivePrefix } from './directive-syntax.ts' +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. +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 } + +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 } + return source.charAt(index + 1) === '[' ? { image: true, kind: 'bracket', width: 2 } : text(1) + case '[': + return { image: false, kind: 'bracket', width: 1 } + case ']': + return { kind: 'bracket-close', width: 1 } + case '\\': + if (source.charAt(index + 1) === '\n') return { kind: 'hard-break', width: 2 } + return backslashEscape(source, index) === undefined ? text(1) : { kind: 'escape', width: 2 } + case '\n': + return { kind: 'line-ending', width: 1 } + case '&': { + const reference = readEntityReference(source, index) + return reference === undefined ? text(1) : { kind: 'entity', width: reference.length } + } + case '`': + return backtickToken(source, index) + case '*': + case '_': + case '~': + return delimiterRunToken(source, index, runLength(source, index), claims) + case '=': + return claims.highlights && source.startsWith(highlightDelimiter, index) ? { ...highlightFlanking(source, index), kind: 'highlight', width: highlightDelimiter.length } : text(1) + default: + return text(1) + } +} + +// The run of `length` delimiters from `index`, which a caller knowing the run's end reads without rescanning it. +export function delimiterRunToken(source: string, index: number, length: number, claims: Claims): InlineToken { + const character = source.charAt(index) + if (character === '~' && (!claims.strikethrough || length !== 2)) return text(length) + const flags = delimiterFlags(character, source.charAt(index - 1), source.charAt(index + length)) + return flags.canOpen || flags.canClose ? { ...flags, kind: 'delimiter-run', width: length } : text(length) +} + +function angleToken(source: string, index: number): InlineToken { + const bracketed = readBracketedAutolink(source, index) + if (bracketed !== undefined) return { email: false, kind: 'autolink', width: bracketed } + 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 } +} + +function backtickToken(source: string, index: number): InlineToken { + const opener = backtickRun(source, index) + const closer = closingBacktickRun(source, index + opener, opener) + return closer === undefined ? text(opener) : { kind: 'code-span', opener, width: closer + opener - index } +} + +function text(width: number): InlineToken { + return { kind: 'text', width } +} diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 9c02fc5..a94ecfe 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -2,15 +2,15 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { Claims } from '../portable/conventions.ts' import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts' import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts' +import type { InlineToken } from '../inline-tokens.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts' -import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts' -import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' +import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts' import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkLink, linkHref } from '../mark-spellings.ts' -import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' +import { matchEmphasis } from '../commonmark/emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { highlightDelimiter, highlightFlanking } from '../portable/conventions.ts' +import { highlightDelimiter } from '../portable/conventions.ts' import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts' @@ -20,6 +20,7 @@ import { openingLinkTakesDirective } from '../emit/inline-line.ts' import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from './directive-marks.ts' import { readInlineDirectiveNode } from './directive-nodes.ts' +import { readInlineToken } from '../inline-tokens.ts' import { readTextDirective } from '../text-directive.ts' // Only a `paragraph` block takes `image`, the `mediaSingle` a lone image builds; every other container takes `nodes`, where the image is its linked alt text. @@ -107,75 +108,60 @@ function freshScan(source: string, shared: SharedScan, own: Pick { - const { source } = scan let index = 0 - while (index < source.length) { - switch (source.charAt(index)) { - case '<': { - const angle = readAngle(scan, index) - if (!angle.ok) return angle - index = angle.value - break - } - case '!': { - const directive = scan.claims.directives ? readDirective(scan, index) : undefined - if (directive === undefined) { - index = openBracket(scan, index) - break - } - if (!directive.ok) return directive - index = directive.value - break - } - case '[': - index = openBracket(scan, index) - break - case ']': { - const closed = closeBracket(scan, index) - if (!closed.ok) return closed - index = closed.value - break - } - default: - index = readCharacter(scan, index) - } + while (index < scan.source.length) { + const read = readToken(scan, index, readInlineToken(scan.source, index, scan.claims)) + if (!read.ok) return read + index = read.value } flush(scan, scan.container !== undefined) return assemble(scan) } -function readCharacter(scan: Scan, index: number): number { - switch (scan.source.charAt(index)) { - case '\\': - return readBackslash(scan, index) - case '\n': - return readLineEnding(scan, index) - case '`': - return readBackticks(scan, index) - case '*': - case '_': - case '~': - return readDelimiterRun(scan, index) - case '=': - return readEquals(scan, index) - default: - scan.pending += scan.source.charAt(index) - return index + 1 +function readToken(scan: Scan, index: number, token: InlineToken): Result { + const end = index + token.width + switch (token.kind) { + case 'autolink': + 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) + case 'bracket-close': + return closeBracket(scan, index) + case 'code-span': + 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) + case 'directive': + return readDirective(scan, index) + 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) + case 'highlight': + flush(scan, false) + scan.pieces.push({ closes: token.closes, kind: 'highlight', opens: token.opens }) + return success(end) + 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) } } -function readBackslash(scan: Scan, index: number): number { - if (scan.source.charAt(index + 1) === '\n') { - // 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 index + 2 - } - const width = backslashEscape(scan.source, index) === undefined ? 1 : 2 - scan.pending += scan.source.slice(index, index + width) - return index + width -} - function readLineEnding(scan: Scan, index: number): number { const hard = scan.pending.endsWith(' ') flush(scan, true) @@ -184,48 +170,10 @@ function readLineEnding(scan: Scan, index: number): number { return index + 1 } -function readBackticks(scan: Scan, index: number): number { - const span = readCodeSpan(scan.source, index) - if (span === undefined) { - const run = backtickRun(scan.source, index) - scan.pending += scan.source.slice(index, index + run) - return index + run - } - flush(scan, false) - pushNode(scan, { marks: [{ type: 'code' }], text: span.text, type: 'text' }) - return span.end -} - -function readAngle(scan: Scan, index: number): Result { - const autolink = readAutolink(scan.source, index) - if (autolink !== undefined) { - flush(scan, false) - pushNode(scan, autolink.node) - return success(index + autolink.length) - } - const construct = inlineHtmlConstruct(scan.source, index) - if (construct !== undefined) return failure('unmappable-html', `no raw HTML converts at this version: ${construct}`, scan.path) - scan.pending += '<' - return success(index + 1) -} - -function openBracket(scan: Scan, index: number): number { - const image = scan.source.charAt(index) === '!' - if (image && scan.source.charAt(index + 1) !== '[') { - scan.pending += '!' - return index + 1 - } - const width = image ? 2 : 1 - flush(scan, false) - scan.pieces.push({ active: true, image, kind: 'open', start: index + width }) - return index + width -} - -function readDirective(scan: Scan, index: number): Result | undefined { +function readDirective(scan: Scan, index: number): Result { const held = scan.spans.get(index) if (held !== undefined) return pushDirective(scan, held, index) const directive = readInlineDirective(scan.source, index) - if (directive === undefined) return undefined if (directive.fault !== undefined) return faulted(directive.fault, scan.path) return pushDirective(scan, directive.value, index) } @@ -382,36 +330,6 @@ function marksLink(nodes: readonly Inline[]): boolean { return adfNodes(nodes).some((node) => nodeMarks(node).some((mark) => mark.type === 'link')) } -function readDelimiterRun(scan: Scan, index: number): number { - const character = scan.source.charAt(index) - const length = runLength(scan.source, index) - const flags = delimiterFlags(character, scan.source.charAt(index - 1), scan.source.charAt(index + length)) - if ((character === '~' && (!scan.claims.strikethrough || length !== 2)) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length) - else { - flush(scan, false) - scan.pieces.push({ canClose: flags.canClose, canOpen: flags.canOpen, character, kind: 'run', length }) - } - return index + length -} - -function readEquals(scan: Scan, index: number): number { - if (!scan.claims.highlights || !scan.source.startsWith(highlightDelimiter, index)) { - scan.pending += '=' - return index + 1 - } - flush(scan, false) - scan.pieces.push({ ...highlightFlanking(scan.source, index), kind: 'highlight' }) - return index + highlightDelimiter.length -} - -function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined { - const bracketed = readBracketedAutolink(source, index) - if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') } - const email = readEmailAutolink(source, index) - if (email === undefined) return undefined - return { length: email, node: linkedText(source.slice(index + 1, index + email - 1), 'mailto:') } -} - function linkedText(text: string, scheme: string): AdfNode { return { marks: [{ attrs: { href: `${scheme}${text}` }, type: 'link' }], text, type: 'text' } } @@ -685,13 +603,6 @@ function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] { }) } -function readCodeSpan(source: string, index: number): { end: number; text: string } | undefined { - const opener = backtickRun(source, index) - const closer = closingBacktickRun(source, index + opener, opener) - if (closer === undefined) return undefined - return { end: closer + opener, text: codeSpanText(source.slice(index + opener, closer)) } -} - function codeSpanText(content: string): string { const text = content.replaceAll('\n', ' ') const padded = text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) diff --git a/todo.md b/todo.md index 137d382..ffb2574 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 | | 94 | 0.2.0 | defect | **Read two lists a link reference definition parts as two lists, in every reader.** | 3 | 3 | 4 | 7 | 4, 5 | 13.6 | | 93 | 0.2.0 | | **Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.** | 2 | 3 | 3 | 5 | 1 | 13.2 | | 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 | @@ -69,14 +68,6 @@ ## Details -### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it. - -The comprehension panel's worst place: `escapeClaims` and `escapeClosedRuns` re-implement the -reader's view — flanking, code-span closers, link-definition openings, highlight flanking — and -only the property tests catch drift. It caps the panel's Locality score. The replacement runs in -time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run -of one character from each of its characters. - ### 94. Read two lists a link reference definition parts as two lists, in every reader. `openedList` in `src/markdown/parse/blocks.ts` joins a fresh list to the list just before it when @@ -153,7 +144,7 @@ three linear. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour. -Lands after items 6, 59, 60, 61 and 62. Which flavours get a composed call is this item's to settle; +Lands after items 6, 59, 60 and 62. Which flavours get a composed call is this item's to settle; the CommonMark spec suite also runs against the one reading CommonMark. The README documents HTML as it documents markdown, and its tagline and `package.json`'s description and keywords name HTML again. `htmlToAdf` refuses a value that is not a string as `not-a-string` @@ -286,7 +277,7 @@ gains its row. ### 91. Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers. -Lands after item 61. No reader marks a hard break, so `*One\` then `Two*` writes back as `_One_\` then +No reader marks a hard break, so `*One\` then `Two*` writes back as `_One_\` then `_Two_`, and a link typed across a line as two links. Joining the run across the break in `inline-line.ts` ruled worse with two comprehension panels (PR #22): a joined run that cannot be spelled needs its own fallback, first as a set of parted breaks, then as a flag the first fallback