diff --git a/CHANGELOG.md b/CHANGELOG.md index e62abf0..c651ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,8 +57,9 @@ - Every reader takes time linear in the inline markdown when it holds many `](` that open no link, as in `[a](b` repeated: 16000 of them took 25 seconds, now 80 ms. - `adfToLosslessMarkdown` and `adfToPortableMarkdown` take time linear in a line's marks when many - of its runs need a carry or lose their mark, as in `un**-r**` repeated: 1000 of them took 10 - seconds, now 150 ms. + of its marked texts or inline nodes need a carry or lose their mark, as in text `un` beside strong + `-r`, repeated: 1000 of them took 10 seconds, now 150 ms. Such a line whose texts each hold two + or more of em, strong and strike still takes time quadratic in its length. - `adfToLosslessMarkdown` and `adfToPortableMarkdown` take time linear in the markdown when lists and quotes nest deeply, and so does `losslessMarkdownToAdf`, which spells what it reads: a list nested 480 levels around 64 KB of paragraphs took 2 seconds to write, now 100 ms. diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 5bc2bcf..07808cb 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -206,10 +206,13 @@ const positions = fc.letrec((tie) => { }) // Short texts under a few marks beside nodes only the carry spells: the lines where one misread or carried run sits beside another. +const emphasisStack = fc.uniqueArray(fc.constantFrom({ type: 'em' }, { type: 'strike' }, { type: 'strong' }), { maxLength: 3, minLength: 2, selector: (held) => held.type }) const lineMark = fc.constantFrom({ type: 'backgroundColor' }, { type: 'code' }, { type: 'em' }, { attrs: { href: 'u' }, type: 'link' }, { type: 'link' }, { type: 'odd' }, { type: 'strike' }, { type: 'strong' }) const lineMarks = fc.uniqueArray(lineMark, { maxLength: 3, selector: (held) => held.type }) +const lineText = fc.string({ maxLength: 3, minLength: 1, unit: fc.constantFrom(' ', '(', '*', '-', '.', '0', '=', '[', '_', '`', 'a', 'un') }) const lineLeaf = fc.oneof( - { arbitrary: fc.record({ marks: lineMarks, text: fc.string({ maxLength: 3, minLength: 1, unit: fc.constantFrom(' ', '*', '-', '.', '0', '=', '[', '_', '`', 'a', 'un') }) }).map((held): AdfNode => ({ ...held, type: 'text' })), weight: 8 }, + { arbitrary: fc.record({ marks: emphasisStack, text: lineText }).map((held): AdfNode => ({ ...held, type: 'text' })), weight: 4 }, + { arbitrary: fc.record({ marks: lineMarks, text: lineText }).map((held): AdfNode => ({ ...held, type: 'text' })), weight: 8 }, { arbitrary: fc.constant({ type: 'hardBreak' }), weight: 1 }, { arbitrary: fc.record({ attrs: fc.constantFrom({ id: 'a' }, { id: 'a', odd: 'b' }), marks: lineMarks }).map((held): AdfNode => ({ ...held, type: 'mention' })), weight: 1 }, ) diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 0a98b81..b2aa248 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -40,6 +40,15 @@ test('names the node a refusal came from, and no source the emitter never read', assert.equal(position(adfToLosslessMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined) }) +test('refuses a node whose carry nests too deep ahead of a later node in its line', () => { + let id: JsonValue = 'a' + for (let depth = 0; depth < largestNesting; depth += 1) id = { id } + const twiceStrong: AdfNode = { marks: [{ type: 'strong' }, { type: 'strong' }], text: 'x', type: 'text' } + const refused = adfToLosslessMarkdown(document(paragraph({ attrs: { id }, type: 'mention' }, twiceStrong))) + assert.equal(code(refused), 'unsupported-nesting-depth') + assert.deepEqual(path(refused), ['content', 0, 'content', 0]) +}) + test('refuses a value that is not an ADF document, naming the check it failed', () => { assert.equal(markdown(adfToLosslessMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN') }) diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts index 7bd1696..b1038c5 100644 --- a/src/markdown/emit/block-text.ts +++ b/src/markdown/emit/block-text.ts @@ -83,11 +83,10 @@ export function writtenText(text: BlockText): string { // Innermost first: whether a line is empty when an outer prefix meets it depends on the prefixes inside. function prefixedLine(line: string, open: readonly Prefix[], innermostQuote: number | undefined, started: number): string { - // Started prefixes inside the innermost quote leave an empty line empty, so its walk begins at that quote. + // An empty line takes no indent, so where every prefix has started its walk begins at the innermost quote. const walked = line === '' && started === open.length ? open.slice(0, (innermostQuote ?? -1) + 1) : open return walked.reduceRight((written, prefix, depth) => { if (prefix.kind === 'quoted') return written === '' ? '>' : `> ${written}` - if (depth >= started) return `${prefix.head}${written}` - return written === '' ? '' : `${prefix.indent}${written}` + return depth >= started ? `${prefix.head}${written}` : `${prefix.indent}${written}` }, line) } diff --git a/src/markdown/emit/inline-line.test.ts b/src/markdown/emit/inline-line.test.ts index be9add5..1997c01 100644 --- a/src/markdown/emit/inline-line.test.ts +++ b/src/markdown/emit/inline-line.test.ts @@ -10,6 +10,14 @@ test('spells a line alike taking every fallback a pass finds or the first alone' fc.property(markedLine, lineContainer, (nodes, container) => { assert.deepEqual(emitInlineLine(nodes, container, [], 'lossless'), emitInlineLine(nodes, container, [], 'lossless', 'first')) }), - propertyRuns(3000), + // Lines where carrying one run lets a neighbour read as written, which the seed's runs rarely reach. + { + ...propertyRuns(3000), + examples: [ + [[{ marks: [{ type: 'em' }], text: '-', type: 'text' }, { text: '0', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: 'un', type: 'text' }, { text: '0', type: 'text' }], 'paragraph'], + [[{ marks: [{ type: 'em' }, { type: 'strike' }], text: 'a', type: 'text' }, { text: 'a', type: 'text' }, { marks: [{ type: 'strong' }, { type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'strong' }], text: 'a', type: 'text' }], 'paragraph'], + [[{ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strike' }, { type: 'strong' }], text: 'a', type: 'text' }, { text: 'a', type: 'text' }], 'paragraph'], + ], + }, ) }) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 48edcc7..93ee3ce 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -278,8 +278,19 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result< if (model === undefined) return success(emitText(node, context, index)) const held = heldContentOrText(node) if (held !== undefined) return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, nodePath(context, index)) - if (node.type === 'hardBreak') return success(emitHardBreak(node, model, context, index)) - return success(emitInlineDirective(node, model, index)) + const emission = node.type === 'hardBreak' ? emitHardBreak(node, model, context, index) : emitInlineDirective(node, model, index) + return checkedCarry(emission, [node], context) +} + +// Builds a carry's JSON where the walk finds it, so its refusal comes ahead of any later node's. +function checkedCarry(emission: Emission, nodes: readonly AdfNode[], context: InlineContext): Result { + const range = emission.carries?.[0] + if (range === undefined) return success(emission) + for (const [offset, node] of nodes.entries()) { + const carried = carriedInline(node, nodePath(context, range.first + offset)) + if (!carried.ok) return carried + } + return success(emission) } function emitHardBreak(node: AdfNode, model: InlineNodeModel, context: InlineContext, index: number): Emission { @@ -311,10 +322,9 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, const range: NodeRange = { first: index, last: index + nodes.length - 1 } if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, range, context) const spelling = markSpelling(mark.type) - if (spelling === undefined) return success({ carries: [range] }) - const attributes = spellMarkAttributes(mark, spelling) - if (attributes === undefined) return success({ carries: [range] }) - if (spelling.kind === 'code') return success(emitCodeSpan(nodes, depth, range)) + const attributes = spelling === undefined ? undefined : spellMarkAttributes(mark, spelling) + if (spelling === undefined || attributes === undefined) return checkedCarry({ carries: [range] }, nodes, context) + if (spelling.kind === 'code') return checkedCarry(emitCodeSpan(nodes, depth, range), nodes, context) if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined if (link !== undefined) return link @@ -366,7 +376,7 @@ function needsPadding(text: string): boolean { function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result | undefined { const href = linkHref(nodeAttrs(mark)) - if (href === undefined) return success({ carries: [range] }) + if (href === undefined) return checkedCarry({ carries: [range] }, nodes, context) const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed, titleEscapes(context.flavour, context.container)) if (commonMark === undefined) return undefined diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 54ba29c..f370049 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -146,6 +146,8 @@ test('spells a task list as a bullet list whose items lead with their state', () const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')) const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' }) assert.equal(portable(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n```\nx\n```\n') + const brokenTask = node('blockTaskItem', { state: 'TODO' }, paragraph(text('a'), { type: 'hardBreak' }, text('b')), said('c')) + assert.equal(portable(node('taskList', {}, brokenTask)), '- [ ] a\\\n b\n\n c\n') const parent = node('blockTaskItem', { state: 'TODO' }, said('q'), said('q2')) assert.equal(portable(node('taskList', {}, parent, node('taskList', {}, task('DONE', 'n')), task('DONE', 'B'))), '- [ ] q\n\n q2\n - [x] n\n- [x] B\n') const holding = node('blockTaskItem', { state: 'TODO' }, said('A'), node('taskList', {}, task('TODO', 'c'))) @@ -177,6 +179,9 @@ test('escapes text a renderer would take as a flavour marker, and only there', ( assert.equal(portable({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n') assert.equal(portable({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n') assert.equal(portable(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- \\[ ]\n') + const broken = (value: string): AdfNode => paragraph(text(value), { type: 'hardBreak' }, text('a')) + assert.equal(portable(bulletList(item(broken('[x]')), item(said('[x]\\')))), '- \\[x]\\\n a\n- [x]\\\n') + assert.equal(portable({ content: [broken('[!NOTE]')], type: 'blockquote' }), '> \\[!NOTE]\\\n> a\n') assert.equal(portable(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- \\[x] a\n- b\n\n1. \\[x] c\n') const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value)) assert.equal(portable(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n') @@ -403,6 +408,11 @@ test('gives up every highlight run whose own delimiter cannot flank in one fallb assert.deepEqual(portableLineFallback(line, 'paragraph', []), { ok: true, value: { kind: 'unspellable-run', runs } }) }) +test('refuses a portable line holding a node only the carry spells, at the line', () => { + const refused = portableLineFallback([text('x', { type: 'odd' })], 'paragraph', ['content', 0]) + assert.deepEqual(refused.ok ? refused : [refused.error.code, refused.error.path], ['unsupported-node-shape', ['content', 0]]) +}) + test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => { assert.equal(portable(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n') assert.equal(portable(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n') diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts index 0ffac25..679157e 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -10,7 +10,13 @@ test('reduces a line alike dropping every run a pass may or the first alone', () fc.property(markedLine, lineContainer, (nodes, container) => { assert.deepEqual(reduceInline(nodes, container, [], 0), reduceInline(nodes, container, [], 0, 'first')) }), - // A line holding a later run that must not drop with the first, which the seed's runs rarely reach. - { ...propertyRuns(3000), examples: [[[{ attrs: { id: 'a' }, type: 'mention' }, { marks: [{ type: 'strike' }, { type: 'strong' }], text: 'un*', type: 'text' }, { marks: [{ type: 'strong' }], text: '[', type: 'text' }, { text: 'a ', type: 'text' }, { text: ' ', type: 'text' }], 'heading']] }, + // Lines holding a later run that must not drop with the first, which the seed's runs rarely reach. + { + ...propertyRuns(3000), + examples: [ + [[{ attrs: { id: 'a' }, type: 'mention' }, { marks: [{ type: 'strike' }, { type: 'strong' }], text: 'un*', type: 'text' }, { marks: [{ type: 'strong' }], text: '[', type: 'text' }, { text: 'a ', type: 'text' }, { text: ' ', type: 'text' }], 'heading'], + [[{ marks: [{ type: 'strong' }], text: '-', type: 'text' }, { text: '0', type: 'text' }, { marks: [{ type: 'em' }, { type: 'strong' }], text: 'un ', type: 'text' }, { text: ' ', type: 'text' }], 'heading'], + ], + }, ) }) diff --git a/todo.md b/todo.md index c1d22c5..1efe40b 100644 --- a/todo.md +++ b/todo.md @@ -6,19 +6,20 @@ `Bar = 0.1` -`Next ID = 158` +`Next ID = 160` ## Items | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| +| 158 | 0.3.0 | | defect | **Emit a line in time linear in its mark runs when its texts each hold two or more of em, strong and strike, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 | | 150 | 0.3.0 | | decision | **Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.** | 2 | 2 | 2 | 4 | 4 | 6 by 5 | 5.0 | | 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 | | 91 | 0.3.0 | | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 | | 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 | | 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | -| 73 | 0.3.0 | 91, 115, 132, 133, 150 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | -| 72 | 0.3.0 | 91, 115, 132, 133, 150 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | +| 73 | 0.3.0 | 91, 115, 132, 133, 150, 158 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 72 | 0.3.0 | 91, 115, 132, 133, 150, 158 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | | 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | | 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | @@ -28,6 +29,7 @@ | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | | 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 | | 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 | +| 159 | 0.4.0 | | | **Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 | | 144 | 0.4.0 | | | **Give every container `docker-runner.sh` and `publish.sh` start a memory limit and an equal swap limit, sized from the largest a gate leg measurably needs.** | 1 | 1 | 1 | 1 | 2 | code health by 1 | 8.7 | | 143 | 0.4.0 | | defect | **Keep, in `adfToPortableMarkdown`, the link around a note and the target of a link marking a card, so a linked stored image or card writes every target.** | 2 | 1 | 3 | 4 | 3 | 7 by 1 | 6.5 | | 112 | 0.4.0 | | | **Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.** | 2 | 1 | 2 | 3 | 3 | 4 by 4 | 6.2 | @@ -67,6 +69,21 @@ ## Details +### 158. Emit a line in time linear in its mark runs when its texts each hold two or more of em, strong and strike, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`. + +Item 33 batched a line's fallbacks, but two guards keep nested emphasis at one run per pass. A +paragraph repeating text `un` beside `-r` marked em and strong takes 13 seconds at 1000 repeats in +either writer. `(` marked em, strike and strong beside a mention takes 27 seconds in the portable +one. `mayFlank`'s same-character guard (`line-escaping.ts`) is needed: without it +`[{em,'-'},{'0'},{em+strong,'un'},{'0'}]` writes other markdown. `dropsAlone`'s innermost-mark +requirement (`inline-reduction.ts`) has no known counterexample, but relaxing it alone leaves +lossless, the three-mark line and the chain below quadratic. A chain of `z` then strike `(b` beside +em `(b`, repeated, needs one pass per run, since each run's drop decides whether the next run's +delimiter flanks: linear there needs a reading that settles runs left to right in one pass. The +every-against-first properties in `inline-line.test.ts` and `inline-reduction.test.ts` pin every +other guard. The seats find `emitLine` and `unspellableRuns` hard: make both easier to read. +Found by the stability review of item 33, 2026-10-29. + ### 150. Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back. `htmlToAdf` refuses all three today. A browser's `innerHTML` writes text's `"` raw and a no-break @@ -212,6 +229,13 @@ Three tests read source text: Item 81 changes `result.test.ts` too. Found by the technical-principles audit of item 60, 2026-10-22. +### 159. Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it. + +`taskBlocks` (`portable/block-spelling.ts`) writes the marker as a paragraph of its own where a +block task item's content opens with another block. Mutation testing finds that branch unreached: +a block task item is left only when it holds two or more paragraphs. Found by the stability review +of item 33, 2026-10-29. + ### 144. Give every container `docker-runner.sh` and `publish.sh` start a memory limit and an equal swap limit, sized from the largest a gate leg measurably needs. `in_image`, `with_firefox` and `publish.sh` run `docker run` with neither `--memory` nor