diff --git a/AGENTS.md b/AGENTS.md index 3527e67..853a8d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,10 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros `dependencies` is empty. A runtime dependency enters only through a decision entry here stating why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the -CommonMark and HTML parsers are written in this repo. `devDependencies`: few, each earning its -keep; they never reach a consumer. +CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than +a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own +module, so entity decoding is complete without one. `devDependencies`: few, each earning its keep; +they never reach a consumer. ## 6. The package contract @@ -136,6 +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 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. @@ -175,8 +181,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 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/corpus/errors/inline-html.error b/corpus/errors/inline-html.error new file mode 100644 index 0000000..847e0f9 --- /dev/null +++ b/corpus/errors/inline-html.error @@ -0,0 +1 @@ +unmappable-html diff --git a/corpus/errors/inline-html.md b/corpus/errors/inline-html.md new file mode 100644 index 0000000..12c3825 --- /dev/null +++ b/corpus/errors/inline-html.md @@ -0,0 +1 @@ +A paragraph carrying a raw tag in its text. diff --git a/corpus/normalization/entity-references.json b/corpus/normalization/entity-references.json new file mode 100644 index 0000000..0afd7b4 --- /dev/null +++ b/corpus/normalization/entity-references.json @@ -0,0 +1,37 @@ +{ + "content": [ + { + "content": [ + { + "text": "© 2026 — the № you asked for, & nothing else.", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "Escaped, the reference stays literal: © and ", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "©", + "type": "text" + }, + { + "text": " alike.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/entity-references.md b/corpus/normalization/entity-references.md new file mode 100644 index 0000000..2287481 --- /dev/null +++ b/corpus/normalization/entity-references.md @@ -0,0 +1,3 @@ +© 2026 — the № you asked for, & nothing else. + +Escaped, the reference stays literal: \© and `©` alike. diff --git a/corpus/normalization/soft-wraps.json b/corpus/normalization/soft-wraps.json new file mode 100644 index 0000000..8a6f25f --- /dev/null +++ b/corpus/normalization/soft-wraps.json @@ -0,0 +1,22 @@ +{ + "content": [ + { + "content": [ + { + "text": "A paragraph the author soft-wrapped across three lines, holding a hard break", + "type": "text" + }, + { + "type": "hardBreak" + }, + { + "text": "the backslash spells.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/soft-wraps.md b/corpus/normalization/soft-wraps.md new file mode 100644 index 0000000..b0a159f --- /dev/null +++ b/corpus/normalization/soft-wraps.md @@ -0,0 +1,3 @@ +A paragraph the author soft-wrapped +across three lines, holding a hard break\ +the backslash spells. diff --git a/corpus/round-trip/commonmark-subset/text-escaping.json b/corpus/round-trip/commonmark-subset/text-escaping.json index 5481877..9350b74 100644 --- a/corpus/round-trip/commonmark-subset/text-escaping.json +++ b/corpus/round-trip/commonmark-subset/text-escaping.json @@ -44,6 +44,24 @@ } ], "type": "paragraph" + }, + { + "content": [ + { + "text": "
${controlCharacterRange}]*` const nullCharacterSource = '\\u0000' -const entityReferenceSource = '&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});' +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"'=<>\`]+|'[^']*'|"[^"]*"))?)` + +const htmlTagSource = `(?:<${tagNameSource}${attributeSource}*${htmlSpaceSource}*/?>|)` -const anchoredEntityReference = new RegExp(`^(?:${entityReferenceSource})`) const autolink = new RegExp(`^(?:${autolinkSource})$`) -const bracketedAutolink = new RegExp(`^<(?:${autolinkSource})>`) +const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y') const controlCharacter = new RegExp(`[${controlCharacterRange}]`) -const entityReference = new RegExp(entityReferenceSource) +const htmlTag = new RegExp(htmlTagSource, 'y') const nullCharacter = new RegExp(nullCharacterSource) +const tagName = new RegExp(`^`). +const inlineHtmlConstructs = [ + { 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 = + 'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul' +const completeTag = new RegExp(`^${htmlTagSource}[ \\t]*$`) +const htmlBlockConditions: HtmlBlockCondition[] = [ + { closer: /<\/(?:pre|script|style|textarea)>/i, construct: undefined, interrupts: true, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i }, + { closer: /-->/, construct: htmlConstructNames.comment, interrupts: true, start: /^ c'), 'a \\ c\n') + const later = paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, { text: ' \\/, construct: 'an HTML comment', interrupts: true, start: /^ 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 processing instruction') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries an HTML declaration') + assert.equal(content(markdownToAdf('Part here.\n')), 'unmappable-html: no ADF node carries a CDATA section') + assert.equal(content(markdownToAdf('Part 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]) +}) + +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