Compare commits

...

2 Commits

Author SHA1 Message Date
lilleman ca3c929ba9 4c - the guard's spread was a class: the code block's held lines and a mark run's segments too
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-18 19:52:00 +02:00
lilleman 05c2db34a1 One release for everything known: 0.2.1 and 0.3.0 fold into 0.2.0 2026-09-18 19:49:45 +02:00
10 changed files with 74 additions and 47 deletions
+10 -3
View File
@@ -147,7 +147,7 @@ not take — is
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code `unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
across both directions for good, since the call site knows which direction it called and parting across both directions for good, since the call site knows which direction it called and parting
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
converts no raw HTML, so at `0.3.0` the mapped elements stop erroring and the code stays for what converts no raw HTML, so at `0.2.0` the mapped elements stop erroring and the code stays for what
no ADF node carries. A refusal found before its path is known — the block walk's, a directive no ADF node carries. A refusal found before its path is known — the block walk's, a directive
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
descends, so a document reports its first error in document order. `not-an-adf-document` carries descends, so a document reports its first error in document order. `not-an-adf-document` carries
@@ -176,7 +176,7 @@ A parse names a position for every refusal it returns, so the type says so rathe
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns `Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
`Result<T, ParseError>` — `ConvertError` with `position` required. An optional field a direction `Result<T, ParseError>` — `ConvertError` with `position` required. An optional field a direction
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway. always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
`htmlToAdf` inherits this at `0.3.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the `htmlToAdf` inherits this at `0.2.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
wide `Result<T>`, since half their refusals come from an emit stage that read no source. wide `Result<T>`, since half their refusals come from an emit stage that read no source.
## 9. Release automation ## 9. Release automation
@@ -297,10 +297,17 @@ someone spells it or pins it.
has — for the fallback to refuse at zero rather than walk again; counting every list twice has — for the fallback to refuse at zero rather than walk again; counting every list twice
halved the list limit, counting the directive form once doubled the parser's frames per level halved the list limit, counting the directive form once doubled the parser's frames per level
(the maintainer, 2026-09-18). (the maintainer, 2026-09-18).
- Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result`
is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and
is fine (4c).
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the - 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 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 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. nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond. A
scan may keep what it read for a later walk of the same text, and the fallback where it kept
nothing must be the same reader over the same text at the same index, so the two cannot disagree
— which is what makes the kept value a memo rather than a second spelling (4c).
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - 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 fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable. unrepresentable.
+10 -10
View File
@@ -4,7 +4,7 @@ Lossless conversion between **Atlassian Document Format** (ADF), an extended mar
an HTML dialect. an HTML dialect.
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`0.3.0`.** `0.2.0`.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md). [`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md). Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
@@ -44,10 +44,10 @@ adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument, ParseError> markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
isAdfDocument(v: unknown): v is AdfDocument isAdfDocument(v: unknown): v is AdfDocument
adfToHtml(doc: AdfDocument): Result<string> // 0.3.0 adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.3.0 htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
markdownToHtml(markdown: string): Result<string> // 0.3.0, via ADF markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF
htmlToMarkdown(html: string): Result<string> // 0.3.0, via ADF htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF
``` ```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
@@ -75,17 +75,17 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of
or before the refusal — currently the start of the line the enclosing block begins on; a later or before the refusal — currently the start of the line the enclosing block begins on; a later
minor may narrow that, never widen it. minor may narrow that, never widen it.
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.3.0`: Parsing — `markdownToAdf`, and `htmlToAdf` at `0.2.0`:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | | `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.3.0` | | `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.2.0` |
| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title | | `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title |
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.3.0`: Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
@@ -121,7 +121,7 @@ emit refuses:
reference matching its definition only under Unicode case folding stays unresolved. Each is reference matching its definition only under Unicode case folding stays unresolved. Each is
pinned `pending` in `corpus/commonmark-spec/exceptions.json`. pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
fence. Show the refusal and keep the document read-only; saving markdown you could not produce fence. Show the refusal and keep the document read-only; saving markdown you could not produce
@@ -134,7 +134,7 @@ emit refuses:
is the `taskList` directive. is the `taskList` directive.
- A document nested deeper than 500 levels is an error result, not a stack overflow. - A document nested deeper than 500 levels is an error result, not a stack overflow.
- The emitted formats are semver surface (AGENTS.md §8). - The emitted formats are semver surface (AGENTS.md §8).
- **`0.3.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
`data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an `data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an
error, and well-formed HTML only — no tag-soup recovery. error, and well-formed HTML only — no tag-soup recovery.
+2 -1
View File
@@ -174,7 +174,6 @@ export function isThematicBreak(line: string): boolean {
return holdsThematicBreak(thematicBreakTail(line), 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 { export function thematicBreakTail(text: string): ThematicBreakTail | undefined {
let marker: string | undefined let marker: string | undefined
let markers = 0 let markers = 0
@@ -195,6 +194,8 @@ export function thematicBreakTail(text: string): ThematicBreakTail | undefined {
return { longest: text.length - first, marker, shortest: text.length - third } return { longest: text.length - first, marker, shortest: text.length - third }
} }
// `text` is a suffix of what `tail` was read from, or opens with a space: a length alone names a
// suffix, and the marker check is what refuses one starting mid-run — `--- ---` holds ` ---`.
export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: string): boolean { export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: string): boolean {
if (tail === undefined) return false if (tail === undefined) return false
return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker
+4 -11
View File
@@ -17,7 +17,7 @@ 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 }
// spans: the directives the content holds, at their offset in it, so reading the slot back never scans them again. // spans keys index content, so the two travel together: separate them and every offset is wrong.
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string; spans: NestedSpans } export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string; spans: NestedSpans }
export type NestedSpans = ReadonlyMap<number, DirectiveSpan> export type NestedSpans = ReadonlyMap<number, DirectiveSpan>
@@ -254,10 +254,11 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
cursor = span cursor = span
continue continue
} }
const nested = keepNestedSpan(text, cursor, depth, start, spans) const nested = readNestedDirective(text, cursor, depth + 1)
if (nested?.fault !== undefined) return { fault: nested.fault } if (nested?.fault !== undefined) return { fault: nested.fault }
if (nested !== undefined) { if (nested !== undefined) {
cursor = nested.value spans.set(cursor - start, nested.value)
cursor += nested.value.length
continue continue
} }
if (character === ']' && brackets === 0) return { value: { end: cursor, spans } } if (character === ']' && brackets === 0) return { value: { end: cursor, spans } }
@@ -268,14 +269,6 @@ 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)
@@ -736,3 +736,8 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
// CommonMark strips spaces and tabs alone, so the whitespace beside them is plain text. // CommonMark strips spaces and tabs alone, so the whitespace beside them is plain text.
assert.equal(emitted({ text: '\va\f', type: 'text' }), '\va\f\n') assert.equal(emitted({ text: '\va\f', type: 'text' }), '\va\f\n')
}) })
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`)
})
+1 -1
View File
@@ -166,7 +166,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
if (emitted.value.carry !== undefined) return emitted if (emitted.value.carry !== undefined) return emitted
segments.push(...emitted.value.segments) for (const segment of emitted.value.segments) segments.push(segment)
} }
return success({ segments }) return success({ segments })
} }
+4
View File
@@ -118,3 +118,7 @@ test('closes the containers a closer names past as unclosed, and crosses no list
assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph']) assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph'])
assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault']) assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault'])
}) })
test("releases an indented code block's held blank lines as a walk rather than as one call's arguments", () => {
assert.deepEqual(kinds(` a\n${'\n'.repeat(200000)} b\n`), ['code'])
})
+2 -1
View File
@@ -365,7 +365,8 @@ function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>
return true return true
} }
if (leadingColumns(line) < indentedCodeColumns) return false if (leadingColumns(line) < indentedCodeColumns) return false
leaf.lines.push(...leaf.held, removeColumns(line, indentedCodeColumns).text) for (const held of leaf.held) leaf.lines.push(held)
leaf.lines.push(removeColumns(line, indentedCodeColumns).text)
leaf.held.length = 0 leaf.held.length = 0
return true return true
} }
+5
View File
@@ -534,6 +534,11 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
38 ms where the tail scan alone would have left it quadratic. 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, The sixth site the sweep found went to 18 rather than landing here (the maintainer,
2026-09-18). 2026-09-18).
**Widened** (the systems-architect, 2026-09-18): the guard's spread was a class rather than a
site, and two more threw out of the public API — `readIndentedCodeLine` releasing the blank
lines an indented code block held (200k of them at 200 kB), and `emitRun` joining a mark
run's segments (200k nodes under one mark). Both fixed here with the same loop and a test
each, and §11 gained the rule so the spelling cannot walk back in.
- [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.
+31 -20
View File
@@ -5,11 +5,18 @@ 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,
4d, 5f, 18 → `0.2.1`; 6, 7 → `0.3.0`; 9, 17 → TBD; 5e last. 4c, 14, 15, 16, 18, 4d, 17, 10, 6, 7, 5f, 5g → `0.2.0`; 8, 9 → 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. Everything known and
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4 shaped ships in one release rather than a string of them: nothing waits on a version, and no
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change. consumer is served by the churn (the maintainer, 2026-09-18). So `0.2.0` completes §1's three
formats, and `0.2.1` and `0.3.0` are gone. `8` and `9` stay out as the two goals nothing has shaped
yet. `0.2.0`'s order is settled (the maintainer, 2026-09-13, extended 2026-09-18): 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; then 14 moves the files 15, 16 and 10 edit and HTML
is written against that layout, 4d marks the gate legs before 17 adds one, 17 puts the complexity
guardrail under the largest body of new code, and 5f and 5g read last because 7 is what changes the
bundle size and the tagline.
- [x] **0 — Scaffold.** - [x] **0 — Scaffold.**
- [x] **1a — The directive grammar.** - [x] **1a — The directive grammar.**
@@ -50,7 +57,7 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
- [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`).**
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** - [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
- [ ] **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.0`).** `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
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
@@ -77,7 +84,7 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
and is the trade to weigh rather than discover on a red release run. and is the trade to weigh rather than discover on a red release run.
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed **Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
there knowing the cutoff may land before `0.2.0` ships. there knowing the cutoff may land before `0.2.0` ships.
- [ ] **5f — Publish the bundle size (`0.2.1`).** Measure the shipped artifact and put the number in the - [ ] **5f — Publish the bundle size (`0.2.0`).** Measure the shipped artifact and put the number in the
README, kept honest by the release pipeline rather than by a human re-reading it. The README, kept honest by the release pipeline rather than by a human re-reading it. The
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
@@ -95,10 +102,10 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
"why" note left. The top follows the package-README order: an npm version badge and the Gitea "why" note left. The top follows the package-README order: an npm version badge and the Gitea
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
one-line table of contents, then install and the shortest runnable example; a table of one-line table of contents, then install and the shortest runnable example; a table of
everything exported sits near the bottom. The HTML directions are one aside line under the API everything exported sits near the bottom. The HTML directions were to stay an aside until a
until `0.3.0` ships them, the `// 0.3.0` signatures and the `0.3.0` guarantee going until then. later release shipped them; 7 now ships in this one and reads ahead of this item, so the
The tagline and `description` read "Lossless conversion between Atlassian Document Format and README documents HTML as it documents markdown, the tagline and `description` naming both
extended markdown" until 7 restores HTML. (the maintainer, 2026-09-13, revised 2026-09-18).
- [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5a — Rename to `@larvit/adf-codec`.**
- [x] **5b — The consumer's error surface.** - [x] **5b — The consumer's error surface.**
- [x] **5b1 — The error's source position.** - [x] **5b1 — The error's source position.**
@@ -107,11 +114,11 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
- [x] **5b4 — The README's consumer surface.** - [x] **5b4 — The README's consumer surface.**
- [x] **5c — The build and the release pipeline.** - [x] **5c — The build and the release pipeline.**
- [x] **5d — The browser leg.** - [x] **5d — The browser leg.**
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **6 — The HTML dialect spec (`0.2.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, the third format (`0.2.0`).** `adfToHtml`, `htmlToAdf`, the composed
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). The `markdownToHtml` / `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from
README's tagline and `package.json`'s `description` regain HTML (5g). here (§10). The README's tagline and `package.json`'s `description` regain HTML (5g).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists. - [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build. - [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF, - [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
@@ -206,11 +213,15 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
brackets stay literal text, CommonMark's rule that no link holds another — rather than brackets stay literal text, CommonMark's rule that no link holds another — rather than
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization
fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17). fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
- [ ] **17 — A machine-enforced size guardrail.** Add a per-function complexity check to the gate — - [ ] **17 — A machine-enforced size guardrail (`0.2.0`).** Add a per-function complexity check to
branch count or size — so the fits-in-your-head guardrail fails the build rather than the gate — branch count or size — so the fits-in-your-head guardrail fails the build rather
waiting for a review to catch it (the systems-architect, 2026-09-16); placed after `0.3.0` than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of
(the maintainer, 2026-09-17). 6, 7 and 10 so the largest body of new code is written under it, which is also what decides
- [ ] **18 — The subtree the directive spelling asks about (`0.2.1`).** The parser asks the threshold: today's worst is `readDirectiveContent`, 27 lines and about 12 decision points
over four concerns in one loop — escape, code span, nested directive, bracket balance — which
4c left half-split and this item either passes or forces apart (the systems-architect and the
maintainer, 2026-09-18).
- [ ] **18 — The subtree the directive spelling asks about (`0.2.0`).** The parser asks
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree `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 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 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