3 Commits

10 changed files with 152 additions and 63 deletions
+6
View File
@@ -77,3 +77,9 @@ test('accepts the JSON values an attribute may hold', () => {
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false) assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
}) })
test("reads a node's siblings as a walk rather than as one call's arguments", () => {
const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 }
assert.equal(isAdfDocument(wide), true)
assert.equal(fault(wide), 'accepted')
})
+2 -2
View File
@@ -96,7 +96,7 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
if ('content' in node) { if ('content' in node) {
const content = node['content'] const content = node['content']
if (!Array.isArray(content)) return false if (!Array.isArray(content)) return false
pending.push(...content) for (const child of content) pending.push(child)
} }
} }
return true return true
@@ -109,7 +109,7 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
if (node === undefined) continue if (node === undefined) continue
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node)) const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
if (fault !== undefined) return fault if (fault !== undefined) return fault
pending.push(...nodeContent(node)) for (const child of nodeContent(node)) pending.push(child)
} }
return undefined return undefined
} }
+32 -3
View File
@@ -2,6 +2,9 @@ import { readEntityReference, replacementCharacter } from './entity-references.t
export type LinePosition = 'first' | 'later' export type LinePosition = 'first' | 'later'
// The suffix lengths of a text that spell a thematic break, and the marker each of them opens with.
export type ThematicBreakTail = { longest: number; marker: string; shortest: number }
type OpenHtmlBlock = { closer: RegExp | undefined; construct: string } type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp } type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
@@ -52,6 +55,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/ const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
const blankLine = /^[ \t]*$/ const blankLine = /^[ \t]*$/
const breakMarkers = '*-_'
const codeFenceOpener = /^(`{3,}|~{3,})/ const codeFenceOpener = /^(`{3,}|~{3,})/
const pipeClaim = /^\|/ const pipeClaim = /^\|/
const bulletListOpener = /^[*+-](?:[ \t]|$)/ const bulletListOpener = /^[*+-](?:[ \t]|$)/
@@ -63,7 +67,6 @@ const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y') const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/ const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/
const setextUnderline = /^(=+|-+)[ \t]*$/ const setextUnderline = /^(=+|-+)[ \t]*$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
export function atxHeading(line: string): { level: number; text: string } | undefined { export function atxHeading(line: string): { level: number; text: string } | undefined {
@@ -118,7 +121,7 @@ export function decodeTextEscapes(text: string): string {
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean { export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
if (offset === 0) { if (offset === 0) {
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true if (firstCharacterOpeners.some((opener) => opener.test(line)) || isThematicBreak(line)) return true
if (openingHtmlBlock(line, position === 'later') !== undefined) return true if (openingHtmlBlock(line, position === 'later') !== undefined) return true
return position === 'later' && setextUnderline.test(line) return position === 'later' && setextUnderline.test(line)
} }
@@ -168,7 +171,33 @@ export function isBlankLine(line: string): boolean {
} }
export function isThematicBreak(line: string): boolean { export function isThematicBreak(line: string): boolean {
return thematicBreak.test(line) return holdsThematicBreak(thematicBreakTail(line), line)
}
// A break runs to the line's end, so one scan of that run answers every level the list walk opens.
export function thematicBreakTail(text: string): ThematicBreakTail | undefined {
let marker: string | undefined
let markers = 0
let first = text.length
let third = text.length
let cursor = text.length
while (cursor > 0) {
const character = text.charAt(cursor - 1)
if (marker === undefined ? breakMarkers.includes(character) : character === marker) {
marker = character
markers += 1
first = cursor - 1
if (markers === 3) third = cursor - 1
} else if (!spaceOrTab(character)) break
cursor -= 1
}
if (marker === undefined || markers < 3) return undefined
return { longest: text.length - first, marker, shortest: text.length - third }
}
export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: string): boolean {
if (tail === undefined) return false
return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker
} }
export function isUnicodeWhitespace(character: string): boolean { export function isUnicodeWhitespace(character: string): boolean {
+26 -10
View File
@@ -17,7 +17,10 @@ export type DirectiveLine =
| { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string } | { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string }
| { kind: 'closer'; name: string } | { kind: 'closer'; name: string }
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string } // spans: the directives the content holds, at their offset in it, so reading the slot back never scans them again.
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string; spans: NestedSpans }
export type NestedSpans = ReadonlyMap<number, DirectiveSpan>
export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T }
@@ -25,7 +28,9 @@ type Attributes = { attributes: DirectiveAttributes; end: number }
type AttributePair = { end: number; key: string; value: DirectiveValue } type AttributePair = { end: number; key: string; value: DirectiveValue }
type Content = { content: string | undefined; end: number } type Content = { content: string | undefined; end: number; spans: NestedSpans }
type DirectiveContent = { end: number; spans: NestedSpans }
export const directivePrefix = '!adf:' export const directivePrefix = '!adf:'
@@ -41,6 +46,8 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
const rawReserved = new RegExp(reservedSource) const rawReserved = new RegExp(reservedSource)
const noAttributes: DirectiveAttributes = new Map() const noAttributes: DirectiveAttributes = new Map()
export const noSpans: NestedSpans = new Map()
export const directiveEscape = `\\${directivePrefix} keeps the prefix literal` export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}` const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}`
@@ -213,14 +220,14 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
if (slot.fault !== undefined) return { fault: slot.fault } if (slot.fault !== undefined) return { fault: slot.fault }
const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined) const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined)
if (attributes.fault !== undefined) return { fault: attributes.fault } if (attributes.fault !== undefined) return { fault: attributes.fault }
return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name } } return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name, spans: slot.value.spans } }
} }
function readContentSlot(text: string, index: number, depth: number): Read<Content> { function readContentSlot(text: string, index: number, depth: number): Read<Content> {
if (text.charAt(index) !== '[') return { value: { content: undefined, end: index } } if (text.charAt(index) !== '[') return { value: { content: undefined, end: index, spans: noSpans } }
const close = readDirectiveContent(text, index + 1, depth) const close = readDirectiveContent(text, index + 1, depth)
if (close.fault !== undefined) return { fault: close.fault } if (close.fault !== undefined) return { fault: close.fault }
return { value: { content: text.slice(index + 1, close.value), end: close.value + 1 } } return { value: { content: text.slice(index + 1, close.value.end), end: close.value.end + 1, spans: close.value.spans } }
} }
function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> { function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
@@ -231,7 +238,8 @@ function readAttributesAt(text: string, index: number, braceClaims: boolean): Re
} }
// A code span, an escape and a nested directive each bind before the content's own closing bracket. // A code span, an escape and a nested directive each bind before the content's own closing bracket.
function readDirectiveContent(text: string, start: number, depth: number): Read<number> { function readDirectiveContent(text: string, start: number, depth: number): Read<DirectiveContent> {
const spans = new Map<number, DirectiveSpan>()
let brackets = 0 let brackets = 0
let cursor = start let cursor = start
while (cursor < text.length && text.charAt(cursor) !== '\n') { while (cursor < text.length && text.charAt(cursor) !== '\n') {
@@ -246,13 +254,13 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
cursor = span cursor = span
continue continue
} }
const nested = readNestedDirective(text, cursor, depth + 1) const nested = keepNestedSpan(text, cursor, depth, start, spans)
if (nested?.fault !== undefined) return { fault: nested.fault }
if (nested !== undefined) { if (nested !== undefined) {
if (nested.fault !== undefined) return { fault: nested.fault } cursor = nested.value
cursor += nested.value.length
continue continue
} }
if (character === ']' && brackets === 0) return { value: cursor } if (character === ']' && brackets === 0) return { value: { end: cursor, spans } }
if (character === '[') brackets += 1 if (character === '[') brackets += 1
if (character === ']') brackets -= 1 if (character === ']') brackets -= 1
cursor += 1 cursor += 1
@@ -260,6 +268,14 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
return { fault: malformedDirective(`an inline directive [content] is unclosed; ${directiveEscape}`) } return { fault: malformedDirective(`an inline directive [content] is unclosed; ${directiveEscape}`) }
} }
function keepNestedSpan(text: string, cursor: number, depth: number, start: number, spans: Map<number, DirectiveSpan>): Read<number> | undefined {
const nested = readNestedDirective(text, cursor, depth + 1)
if (nested === undefined) return undefined
if (nested.fault !== undefined) return { fault: nested.fault }
spans.set(cursor - start, nested.value)
return { value: cursor + nested.value.length }
}
// `undefined` where the span crosses the line ending an inline directive may not cross. // `undefined` where the span crosses the line ending an inline directive may not cross.
function readCodeSpanEnd(text: string, index: number): number | undefined { function readCodeSpanEnd(text: string, index: number): number | undefined {
const opener = backtickRun(text, index) const opener = backtickRun(text, index)
+3 -3
View File
@@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts' import type { InlineDirective } from '../../adf/inline-directives.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts' import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts' import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark-grammar.ts'
import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts' import { escapeUnbalanced, spellDestination } from '../link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -129,8 +129,8 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean)
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment] if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : '' const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
const body = segment.text.slice(head.length) const body = segment.text.slice(head.length)
const tail = trailing ? (/[ \t]+$/.exec(body)?.[0] ?? '') : '' const middle = trailing ? trimTrailingSpace(body) : body
const middle = body.slice(0, body.length - tail.length) const tail = body.slice(middle.length)
const edges: InlineSegment[] = [] const edges: InlineSegment[] = []
if (head !== '') edges.push(carriedText(head)) if (head !== '') edges.push(carriedText(head))
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle }) if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
+4 -4
View File
@@ -17,10 +17,10 @@ export function readLabel(text: string, offset: number): LinkPart | undefined {
} }
export function normalizeLabel(raw: string): string { export function normalizeLabel(raw: string): string {
return raw const collapsed = raw.replace(/[ \t\n]+/g, ' ')
.replace(/^[ \t\n]+|[ \t\n]+$/g, '') const start = collapsed.startsWith(' ') ? 1 : 0
.replace(/[ \t\n]+/g, ' ') const end = collapsed.endsWith(' ') ? collapsed.length - 1 : collapsed.length
.toLowerCase() return collapsed.slice(start, end).toLowerCase()
} }
export function readDestination(text: string, offset: number): LinkPart | undefined { export function readDestination(text: string, offset: number): LinkPart | undefined {
+13 -3
View File
@@ -6,6 +6,7 @@ import {
claimsPipeLine, claimsPipeLine,
closingCodeFence, closingCodeFence,
decodeTextEscapes, decodeTextEscapes,
holdsThematicBreak,
isBlankLine, isBlankLine,
isThematicBreak, isThematicBreak,
listMarker, listMarker,
@@ -14,6 +15,8 @@ import {
openingHtmlBlock, openingHtmlBlock,
replaceNullCharacters, replaceNullCharacters,
setextHeadingLevel, setextHeadingLevel,
thematicBreakTail,
type ThematicBreakTail,
} from '../commonmark-grammar.ts' } from '../commonmark-grammar.ts'
import { blockDirectiveForm } from '../block-directive-forms.ts' import { blockDirectiveForm } from '../block-directive-forms.ts'
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts' import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
@@ -143,10 +146,11 @@ function blockquoteRest(opener: Line): Line | undefined {
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } { function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
const unmatched = walk.stack[depth] const unmatched = walk.stack[depth]
const tail = thematicBreakTail(line.text)
let opened = false let opened = false
let rest = line let rest = line
while (leadingColumns(rest) < indentedCodeColumns) { while (leadingColumns(rest) < indentedCodeColumns) {
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position) const start = containerStart(rest, tail, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
if (start === undefined) break if (start === undefined) break
if (!opened) closeContainers(walk, depth) if (!opened) closeContainers(walk, depth)
opened = true opened = true
@@ -156,11 +160,17 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
return { opened, rest } return { opened, rest }
} }
function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined { function containerStart(
line: Line,
tail: ThematicBreakTail | undefined,
paragraphOpen: boolean,
enclosing: OpenContainer | undefined,
position: SourcePosition,
): ContainerStart | undefined {
const opener = removeColumns(line, largestOpenerIndentation) const opener = removeColumns(line, largestOpenerIndentation)
const blockquote = blockquoteRest(opener) const blockquote = blockquoteRest(opener)
if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote } if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote }
if (isThematicBreak(opener.text)) return undefined if (holdsThematicBreak(tail, opener.text)) return undefined
return itemStart(line, opener, paragraphOpen, enclosing, position) return itemStart(line, opener, paragraphOpen, enclosing, position)
} }
+18 -11
View File
@@ -1,5 +1,5 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DirectiveSpan } from '../directive-syntax.ts' import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../emphasis-matching.ts' import type { EmphasisPairing } from '../emphasis-matching.ts'
import type { LineContainer } from '../emit/line-escaping.ts' import type { LineContainer } from '../emit/line-escaping.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts'
@@ -15,7 +15,7 @@ import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { openingLinkTakesDirective } from '../emit/inline-line.ts' import { openingLinkTakesDirective } from '../emit/inline-line.ts'
import { readCarriedInline } from '../opaque-carry.ts' import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts' import { readDirectiveMark } from './directive-marks.ts'
import { readInlineDirective } from '../directive-syntax.ts' import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts' import { readInlineDirectiveNode } from './directive-nodes.ts'
import { readTextDirective } from '../text-directive.ts' import { readTextDirective } from '../text-directive.ts'
@@ -45,6 +45,7 @@ type Scan = {
pending: string pending: string
pieces: Piece[] pieces: Piece[]
source: string source: string
spans: NestedSpans
} }
type SlotContent = { carry: boolean; nodes: AdfNode[] } type SlotContent = { carry: boolean; nodes: AdfNode[] }
@@ -54,11 +55,11 @@ const imageAlone = 'an image fits only as a paragraph of its own: this one sits
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>' const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
return parseInline(source, definitions, path, container) return parseInline(source, definitions, path, container, noSpans)
} }
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined): Result<InlineContent> { function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source } const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
switch (source.charAt(index)) { switch (source.charAt(index)) {
@@ -168,14 +169,20 @@ function openBracket(scan: Scan, index: number): number {
} }
function readDirective(scan: Scan, index: number): Result<number> | undefined { function readDirective(scan: Scan, index: number): Result<number> | undefined {
const held = scan.spans.get(index)
if (held !== undefined) return pushDirective(scan, held, index)
const directive = readInlineDirective(scan.source, index) const directive = readInlineDirective(scan.source, index)
if (directive === undefined) return undefined if (directive === undefined) return undefined
if (directive.fault !== undefined) return faulted(directive.fault, scan.path) if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
const piece = directivePiece(scan, directive.value, index) return pushDirective(scan, directive.value, index)
}
function pushDirective(scan: Scan, span: DirectiveSpan, index: number): Result<number> {
const piece = directivePiece(scan, span, index)
if (!piece.ok) return piece if (!piece.ok) return piece
flush(scan, false) flush(scan, false)
scan.pieces.push(piece.value) scan.pieces.push(piece.value)
return success(index + directive.value.length) return success(index + span.length)
} }
function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> { function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
@@ -187,7 +194,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const text = readTextDirective(span) const text = readTextDirective(span)
if (text?.fault !== undefined) return faulted(text.fault, scan.path) if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
const slot = slotContent(scan, span.content) const slot = slotContent(scan, span)
if (!slot.ok) return slot if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path) const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
@@ -216,9 +223,9 @@ function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
return undefined return undefined
} }
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> { function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (content === undefined) return success(undefined) if (span.content === undefined) return success(undefined)
const parsed = parseInline(content, scan.definitions, scan.path, undefined) const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans)
if (!parsed.ok) return parsed if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value) return success(parsed.value)
+30
View File
@@ -504,6 +504,36 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
counting the directive form once for doubling the parser's frames per level. counting the directive form once for doubling the parser's frames per level.
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms **Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
for the emit, so its two walks stay parted. for the emit, so its two walks stay parted.
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
its run from every start position, so an interior whitespace run costs quadratic time rather
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
replaced the regex. The sites the same sweep did not reach: `normalizeLabel` in
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
999-capped `readLabel` value, and `carryEdges` in `emit/inline-line.ts`. A third of another
shape joins them: `readNestedDirective` restarts its depth counter per level, so each parse
level re-scans the region below it and nested inline directives cost O(depth × content),
bounded by the 500-level guard. A fourth predates 12c: the list-item walk re-scans the rest
of a line once per item level, 30000 nested items taking 4.4s at 59 KB (the
stability-reviewer, 2026-09-16). §11's scanning rule is the whole argument; the pipeline
persona feeds documents nobody typed. A fifth is a throw rather than a cost:
`adfDocumentFault` pushes a node's content with a spread, so past about 125k sibling nodes
the guard throws a `RangeError` where §11 owes a `Result` (the stability-reviewer and the
maintainer, 2026-09-18).
**Settled** (the maintainer, 2026-09-18): the five sites land in one PR rather than split
into sub-items, and a behaviour-preserving cost fix is accepted on the suite staying green
with no fixture output changed, plus the measurement below — §14 promises no figure, so
nothing times the gate. The guard's spread is the one behavioural fix and carries a test.
**Corrected** (2026-09-18): the entry filed two sites in `emit/inline-line.ts` on 2026-09-01
and the file has changed since — `tryImageLine`'s alternation measures linear (3.4 / 1.9 /
5.3 ms over 10k / 20k / 40k spaces), leaving `carryEdges`' trailing trim the only one.
**Measured** (2026-09-18), each at the size its filing named: `normalizeLabel` 1026 ms → 5 ms
at 40k interior spaces, `carryEdges` 1024 ms → 7 ms (its heading path 978 ms → 5 ms),
`readNestedDirective` 434 ms → 10 ms at 397 kB and 200 levels, the list-item walk 4196 ms →
39 ms at 30000 items, and the document guard a `RangeError` → 42 ms at 200k siblings under
one node. The list-item walk's mixed-marker shape, which the fix had to answer too, reads
38 ms where the tail scan alone would have left it quadratic.
The sixth site the sweep found went to 18 rather than landing here (the maintainer,
2026-09-18).
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being - [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
the published identity: `package.json` `name` and `repository`, the Gitea repo and its the published identity: `package.json` `name` and `repository`, the Gitea repo and its
remote, the README title, §6's published-as line, the checkout directory. remote, the README title, §6's published-as line, the checkout directory.
+18 -27
View File
@@ -6,7 +6,7 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, 4c, 14, 15, 16, 10, 5g → `0.2.0`; Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, 4c, 14, 15, 16, 10, 5g → `0.2.0`;
4d, 5f → `0.2.1`; 6, 7 → `0.3.0`; 9, 17 → TBD; 5e last. 4d, 5f, 18 → `0.2.1`; 6, 7 → `0.3.0`; 9, 17 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. `0.2.0`'s order is settled The numbering is the order the work was planned in, not the order it ships. `0.2.0`'s order is settled
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4 (the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change. proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
@@ -49,32 +49,7 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
- [x] **4.3 — The markdown property.** - [x] **4.3 — The markdown property.**
- [x] **4.4 — The real payloads.** - [x] **4.4 — The real payloads.**
- [x] **4b — The block walk's retry (`0.2.0`).** - [x] **4b — The block walk's retry (`0.2.0`).**
- [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks - [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
its run from every start position, so an interior whitespace run costs quadratic time rather
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
999-capped `readLabel` value, and two in `emit/inline-line.ts`. The fix is the one 3h used —
an index walk, `trimTrailingSpace` where the ends match. A fourth of another shape joins
them: `readNestedDirective` restarts its depth counter per level, so each parse level
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. A fifth predates 12c: the list-item walk re-scans the rest of a line once per item
level — `isThematicBreak` in `containerStart` on an opener line, `isBlankLine` and
`leadingColumns` in `continuesContainer` on a continuation line, and a blank line
continues every open item without consuming input; 30000 nested items take 4.4s at 59 KB
(the stability-reviewer, 2026-09-16). §11's scanning rule is
the whole argument; the pipeline persona feeds documents nobody typed.
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
complexity (the maintainer, 2026-09-16). A sixth 4b leaves behind: the parser asks
`commonMarkSpelling` at every directive-spelled list it reads, and the answer spells the whole
subtree below, itself quadratic in the depth left, so nested directive lists cost about the
cube of their depth — 250 rule-first levels parse in 1.3 s at 16.5 kB, 1.5 MB of that shape
at 250 levels in 0.6 s — bounded by the depth guard like `readNestedDirective` (the
maintainer, 2026-09-18). A seventh is a throw rather than a cost: `adfDocumentFault` pushes a
node's content with a spread, so past about 125k sibling nodes the guard throws a
`RangeError` where §11 owes a `Result` — a loop over the content closes it (the
stability-reviewer and the maintainer, 2026-09-18).
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces - [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
@@ -235,6 +210,22 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
branch count or size — so the fits-in-your-head guardrail fails the build rather than branch count or size — so the fits-in-your-head guardrail fails the build rather than
waiting for a review to catch it (the systems-architect, 2026-09-16); placed after `0.3.0` waiting for a review to catch it (the systems-architect, 2026-09-16); placed after `0.3.0`
(the maintainer, 2026-09-17). (the maintainer, 2026-09-17).
- [ ] **18 — The subtree the directive spelling asks about (`0.2.1`).** The parser asks
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree
below, so a node at depth d is spelled d times: three nested rule-first directive lists cost
18 asks over 10 nodes, and 250 levels parse in 1.2 s at 16.4 kB, 4.9 s at 261 kB with a
kilobyte of content per level. The depth guard bounds the levels at about 250, never the
content, so this is the pipeline persona's hang on an input nobody typed (§11). Keeping each
child's emitted result for its parent's ask is not a straight handover: the same node object
is asked at different depths — 4, 3 and 2 for the innermost list of three — because the
parser counts a list and its item as two levels where the emitter's readable list counts one
(4b), and `headroom` is that guard's slack. The parts that survive the measurement: the paths
agree, `text` and `spelling` carry no depth, `headroom` is affine in it, and the parser asks
first at the deepest of them, so a kept result rebases by the difference. Either rebase and
record that argument in `AGENTS.md`, or give both directions one list accounting so a node
has one depth and nothing needs rebasing — which reopens 4b. A single post-build walk was
rejected: it reports the outer offender where the build reports the inner one (the
maintainer, 2026-09-18).
## The ADF inventory to cover ## The ADF inventory to cover