diff --git a/AGENTS.md b/AGENTS.md index 804a722..853a8d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,9 +138,10 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that waits near 2000. -- A reader takes the text and an index — a sticky regex, `indexOf` — never a fresh slice per - character, and a per-character walk hoists what does not vary with the character. The pipeline - persona feeds documents nobody typed, so an ordinary megabyte stays milliseconds. +- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the + line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk + hoists the scan that does not vary with the character. The pipeline persona feeds documents + nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond. - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states unrepresentable. @@ -180,7 +181,7 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema validation or exported validator — a refusal that keeps the round-trip is not schema validation, so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no -streaming APIs, no performance budget past §11's linear scan — nothing here is tuned, and no +streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no figure is promised. A CLI is a later goal (`todo.md`), not a non-goal. ## 15. The working loop diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 3249a5d..000ef5a 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -69,6 +69,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A const escaped = new Set() const placements: number[] = [] let output = '' + const linkClose = lastLinkClose(scan, escapings) let line = scanLine(scan, 0) 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) @@ -78,7 +79,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A escapable && (claimsLineStart(line, index, container) || mergesWithSyntax(scan, escapings, index) || - opensConstruct(scan, escapings, index, escaping === 'bracketed', container, escaped)) + opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped)) ) { output += '\\' escaped.add(index) @@ -185,14 +186,14 @@ function isSyntax(escaping: InlineEscaping | undefined): boolean { function opensConstruct( scan: string, - escapings: readonly (InlineEscaping | undefined)[], + linkClose: number, index: number, inBrackets: boolean, container: LineContainer, escaped: ReadonlySet, ): boolean { if (container === 'heading' && closesHeading(scan, index)) return true - return claimsCharacter(scan, escapings, index, inBrackets, container, escaped) + return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped) } function claimsLineStart(line: ScanLine, index: number, container: LineContainer): boolean { @@ -211,7 +212,7 @@ function closesHeading(scan: string, index: number): boolean { function claimsCharacter( scan: string, - escapings: readonly (InlineEscaping | undefined)[], + linkClose: number, index: number, inBrackets: boolean, container: LineContainer, @@ -224,19 +225,20 @@ function claimsCharacter( if (character === '&') return readEntityReference(scan, index) !== undefined if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined if (character === ':') return opensInlineDirective(scan, index) - if (character === '[') return opensLink(scan, escapings, index) + if (character === '[') return index < linkClose if (character === '`') return opensCodeSpan(scan, index, escaped) if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped) return false } +// The last `]` link text could close on, so a `[` ahead of it opens one and a `[` past it cannot. // A `]` the emitter spelled sits inside a construct that binds before link text does. -function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean { - for (let cursor = index + 1; cursor < scan.length; cursor += 1) { +function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undefined)[]): number { + for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) { if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue - if (followsLinkText.test(scan.charAt(cursor + 1))) return true + if (followsLinkText.test(scan.charAt(cursor + 1))) return cursor } - return false + return -1 } function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet): boolean { diff --git a/src/markdown/entity-references.test.ts b/src/markdown/entity-references.test.ts index deb9d4a..43507bf 100644 --- a/src/markdown/entity-references.test.ts +++ b/src/markdown/entity-references.test.ts @@ -4,14 +4,18 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { readEntityReference } from './entity-references.ts' + const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'entity-references.ts'), 'utf8') -// The packing the module rests on, read off the rows rather than trusted: a value holding a space or a -// tilde mis-splits into a wrong name and a lost one, at load, with nothing failing. -test('every packed reference parts into one name and one value', () => { +// A value holding a space or a tilde mis-splits into a wrong name and a lost one, silently, at load. +test('every packed reference parts into one name and one value the table decodes', () => { const packed = /const packedReferences =\n {2}'([\s\S]*?)'\n/.exec(source)?.[1] assert.ok(packed !== undefined) const references = packed.split(/(?:\\\n)? +/) assert.equal(references.length, 2125) - for (const reference of references) assert.match(reference, /^[A-Za-z][A-Za-z0-9]*~[^ ~]+$/) + for (const reference of references) { + assert.match(reference, /^[A-Za-z][A-Za-z0-9]*~[^ ~]+$/) + assert.ok(readEntityReference(`&${reference.slice(0, reference.indexOf('~'))};`, 0) !== undefined) + } })