3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

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

This commit is contained in:
2026-10-09 16:48:34 +02:00
parent 6e22c19909
commit 2378782315
5 changed files with 67 additions and 36 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ export function readDirectiveLine(line: string): Read<DirectiveLine> | 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<DirectiveSpan> {
return readClaimedDirective(text, index, 1)
}
+31 -12
View File
@@ -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<number>
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<number> {
const escaped = new Set<number>()
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>): 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) {
+9 -8
View File
@@ -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 {
+16 -13
View File
@@ -119,46 +119,49 @@ function scanInline(scan: Scan): Result<Scanned> {
}
function readToken(scan: Scan, index: number, token: InlineToken): Result<number> {
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)
}
}
+10 -2
View File
@@ -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 `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator in time linear in the text.** | 2 | 3 | 5 | 9 | 9 | 9.2 |
| 33 | 0.2.1 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
| 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
@@ -134,6 +135,12 @@ them 68188. Read most over the last five, opens and searches: `README.md` 32, `d
may reject the document. Replace `InlineNodeModel.takesMarks` (`src/adf/inline-nodes.ts`) with the
mark types a node takes, and have the emitter carry a node holding another.
### 96. Read and write a text holding many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator in time linear in the text.
`inlineHtmlConstruct` (`src/markdown/commonmark/grammar.ts`) searches the rest of the text for the
opener's terminator at every opener, so `<!--` repeated with no `-->` 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.