From 60792bceacd41632bd44d75b0a798bb9b186042f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 30 Aug 2026 22:57:05 +0200 Subject: [PATCH] Answer the stability review: readers take an index, and the dead branch and field go --- AGENTS.md | 7 +++- src/markdown/commonmark-grammar.ts | 45 ++++++++++++---------- src/markdown/directive-attributes.ts | 5 ++- src/markdown/emit/adf-to-markdown.test.ts | 2 + src/markdown/emit/line-escaping.ts | 36 ++++++++++------- src/markdown/entity-references.test.ts | 17 ++++++++ src/markdown/entity-references.ts | 11 +++--- src/markdown/parse/blocks.ts | 2 +- src/markdown/parse/inline-content.ts | 20 ++++------ src/markdown/parse/markdown-to-adf.test.ts | 4 ++ 10 files changed, 92 insertions(+), 57 deletions(-) create mode 100644 src/markdown/entity-references.test.ts diff --git a/AGENTS.md b/AGENTS.md index c2fdf95..804a722 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,9 @@ 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. - 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. @@ -177,8 +180,8 @@ 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 — real documents are kilobytes. A CLI is a later goal -(`todo.md`), not a non-goal. +streaming APIs, no performance budget past §11's linear scan — 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/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 564142f..edeb4e7 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -1,14 +1,12 @@ import { readEntityReference } from './entity-references.ts' -export type HtmlConstruct = { length: number; name: string } - export type LinePosition = 'first' | 'later' -export 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 } -export const htmlConstructNames = { +const htmlConstructNames = { cdata: 'a CDATA section', comment: 'an HTML comment', declaration: 'an HTML declaration', @@ -22,19 +20,20 @@ const tagNameSource = '[A-Za-z][A-Za-z0-9-]*' const htmlSpaceSource = '[ \\t\\n]' const attributeSource = `(?:${htmlSpaceSource}+[A-Za-z_:][A-Za-z0-9_.:-]*(?:${htmlSpaceSource}*=${htmlSpaceSource}*(?:[^ \\t\\n"'=<>\`]+|'[^']*'|"[^"]*"))?)` -export const htmlTagSource = `(?:<${tagNameSource}${attributeSource}*${htmlSpaceSource}*/?>|)` +const htmlTagSource = `(?:<${tagNameSource}${attributeSource}*${htmlSpaceSource}*/?>|)` const autolink = new RegExp(`^(?:${autolinkSource})$`) -const bracketedAutolink = new RegExp(`^<(?:${autolinkSource})>`) +const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y') const controlCharacter = new RegExp(`[${controlCharacterRange}]`) -const htmlTag = new RegExp(`^${htmlTagSource}`) +const htmlTag = new RegExp(htmlTagSource, 'y') const nullCharacter = new RegExp(nullCharacterSource) const tagName = new RegExp(`^`). const inlineHtmlConstructs = [ - { name: htmlConstructNames.cdata, pattern: /^/ }, - { name: htmlConstructNames.comment, pattern: /^(?:||)/ }, - { name: htmlConstructNames.declaration, pattern: /^]*>/ }, - { name: htmlConstructNames.processingInstruction, pattern: /^<\?[\s\S]*?\?>/ }, + { name: htmlConstructNames.cdata, opener: /' }, + { name: htmlConstructNames.comment, opener: /|-->|--)/y, terminator: '-->' }, + { name: htmlConstructNames.declaration, opener: /' }, + { name: htmlConstructNames.processingInstruction, opener: /<\?/y, terminator: '?>' }, ] // CommonMark 0.31.2, HTML blocks: the tag names start condition 6 lists. const blockTagNames = @@ -57,7 +56,7 @@ const pipeClaim = /^\|/ const bulletListOpener = /^[*+-](?:[ \t]|$)/ // A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip. const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFenceOpener, /^:{2,}/, pipeClaim] -const emailAutolink = /^<[^\s<>@]+@[^\s<>@]+>/ +const emailAutolink = /<[^\s<>@]+@[^\s<>@]+>/y const orderedListOpener = /^(\d{1,9})([.)])(?:[ \t]|$)/ const setextUnderline = /^(=+|-+)[ \t]*$/ const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ @@ -105,7 +104,7 @@ export function decodeTextEscapes(text: string): string { index += 2 continue } - const reference = text.charAt(index) === '&' ? readEntityReference(text.slice(index)) : undefined + const reference = readEntityReference(text, index) if (reference !== undefined) { decoded += reference.text index += reference.length @@ -135,17 +134,21 @@ export function holdsNullCharacter(text: string): boolean { return nullCharacter.test(text) } -export function htmlTagName(text: string): string { +function htmlTagName(text: string): string { return text.replace(tagName, '<$1>') } -export function inlineHtmlConstruct(text: string): HtmlConstruct | undefined { +export function inlineHtmlConstruct(text: string, index: number): string | undefined { for (const construct of inlineHtmlConstructs) { - const matched = construct.pattern.exec(text)?.[0] - if (matched !== undefined) return { length: matched.length, name: construct.name } + construct.opener.lastIndex = index + const opened = construct.opener.exec(text)?.[0] + if (opened === undefined) continue + if (opened.endsWith(construct.terminator)) return construct.name + return text.includes(construct.terminator, index + opened.length) ? construct.name : undefined } + htmlTag.lastIndex = index const tag = htmlTag.exec(text)?.[0] - return tag === undefined ? undefined : { length: tag.length, name: htmlTagName(tag) } + return tag === undefined ? undefined : htmlTagName(tag) } export function isAsciiPunctuation(character: string): boolean { @@ -193,11 +196,13 @@ export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlB return undefined } -export function opensBracketedAutolink(text: string): boolean { +export function opensBracketedAutolink(text: string, index: number): boolean { + bracketedAutolink.lastIndex = index return bracketedAutolink.test(text) } -export function opensEmailAutolink(text: string): boolean { +export function opensEmailAutolink(text: string, index: number): boolean { + emailAutolink.lastIndex = index return emailAutolink.test(text) } diff --git a/src/markdown/directive-attributes.ts b/src/markdown/directive-attributes.ts index 246a216..405ba07 100644 --- a/src/markdown/directive-attributes.ts +++ b/src/markdown/directive-attributes.ts @@ -3,7 +3,7 @@ import type { VocabularyPair } from '../adf/attribute-vocabulary.ts' import { serializeCanonicalJson } from '../canonical-json.ts' const bareToken = /^[A-Za-z0-9_-]+$/ -const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/ +const inlineDirectiveOpener = /:[a-z][A-Za-z0-9]*[[{]/y // spec/flavour.md, Attributes. const quotedEscapes = /[&<`|]/g @@ -12,7 +12,8 @@ export function isBareToken(text: string): boolean { return bareToken.test(text) } -export function opensInlineDirective(text: string): boolean { +export function opensInlineDirective(text: string, index: number): boolean { + inlineDirectiveOpener.lastIndex = index return inlineDirectiveOpener.test(text) } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 9375bf4..7ab0676 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -216,6 +216,8 @@ test('escapes only text that would otherwise open a construct', () => { assert.equal(emitted('a e'), 'a \\ e\n') assert.equal(emitted('a c'), 'a \\ c\n') const later = paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, { text: ' \\ here.\n')), 'unmappable-html: no ADF node carries an HTML comment') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML comment') assert.equal(code(markdownToAdf('A b\n')), 'unmappable-html') assert.equal(code(markdownToAdf('Part.\n\n')), 'unmappable-html') assert.deepEqual(path(markdownToAdf('Part.\n\nA b.\n')), ['content', 1]) @@ -315,6 +318,7 @@ test('refuses the raw inline HTML no element mapping carries, naming it', () => test('leaves the angle bracket that opens no HTML construct to the text it sits in', () => { assert.deepEqual(content(markdownToAdf('3 < 4 and 5 d\n')), [paragraph('a d')]) + assert.deepEqual(content(markdownToAdf('a