3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 04:08:29 +01:00

Answer architecture round 1: closeLeaf records the block definitions end beside the blocks it pushes, openedList's comment names the exception, the README drops its counts, the changelog puts other changes last, and item 98 files the blank-line re-scan

This commit is contained in:
2026-10-10 11:11:06 +02:00
parent caf64604fc
commit 6182ba6b63
4 changed files with 35 additions and 25 deletions
+6 -6
View File
@@ -6,8 +6,6 @@
CommonMark reads it: `- a` then `* b`, `1. a` then `1) b`. `adfToLosslessMarkdown` gives a list CommonMark reads it: `- a` then `* b`, `1. a` then `1) b`. `adfToLosslessMarkdown` gives a list
right after one of its kind the other marker, `* ` or `1)`, in place of the `::listBreak` right after one of its kind the other marker, `* ` or `1)`, in place of the `::listBreak`
separator. Convert stored markdown per `MIGRATION.md`. separator. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** two lists a link reference definition parts read as two lists, as CommonMark
reads them, where they read as one; see `MIGRATION.md`.
- **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf` - **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf`
are now `adfToLosslessMarkdown` and `losslessMarkdownToAdf`. A bot's or a model's markdown reads are now `adfToLosslessMarkdown` and `losslessMarkdownToAdf`. A bot's or a model's markdown reads
in `portableMarkdownToAdf`, and markdown known to be strict CommonMark in the new in `portableMarkdownToAdf`, and markdown known to be strict CommonMark in the new
@@ -58,6 +56,8 @@
`losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as `losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as
`unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks as `unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks as
`!adf:carry{json="…"}`. See `MIGRATION.md`. `!adf:carry{json="…"}`. See `MIGRATION.md`.
- **Breaking:** two lists a link reference definition parts read as two lists, as CommonMark
reads them, where `0.1.0` read one; see `MIGRATION.md`.
- `adfToLosslessMarkdown` and `adfToPortableMarkdown` write text holding a long run of `*`, `_`, `~` - `adfToLosslessMarkdown` and `adfToPortableMarkdown` write text holding a long run of `*`, `_`, `~`
or backticks, or a heading ending in a long run of `#`, in time linear in the run: a run of 20000 or backticks, or a heading ending in a long run of `#`, in time linear in the run: a run of 20000
`*` took seconds before, and now takes milliseconds. `*` took seconds before, and now takes milliseconds.
@@ -79,16 +79,16 @@
- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and - Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and
`adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, `adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`,
where all three hung. where all three hung.
- The README opens with what the package does and for whom, and `package.json` carries search
keywords.
- The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where
issues and pull requests go.
- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from - Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item, pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item,
such as an image or a code block, stands after the task list. such as an image or a code block, stands after the task list.
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's - Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
- The README opens with what the package does and for whom, and `package.json` carries search
keywords.
- The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where
issues and pull requests go.
## 0.1.0 ## 0.1.0
+8 -7
View File
@@ -239,8 +239,8 @@ Serves Goals 1, 4 and 5.
without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`). without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`).
- A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's - A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's
canonical spelling, which round-trips byte-identically. canonical spelling, which round-trips byte-identically.
- Six CommonMark spellings parse without an error and build a document the reference - These CommonMark spellings parse without an error and build a document the reference
implementation renders differently. Four because ADF holds no node for what it renders: implementation renders differently, where ADF holds no node for what it renders:
- An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()` - An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()`
as nothing. A link text holding only hard breaks reads the same, followed by its hard breaks. as nothing. A link text holding only hard breaks reads the same, followed by its hard breaks.
- An image reads as an image only alone in its paragraph. Anywhere else — beside other text, in - An image reads as an image only alone in its paragraph. Anywhere else — beside other text, in
@@ -253,11 +253,12 @@ Serves Goals 1, 4 and 5.
- An image's title becomes its caption when the image stays an image, and its link's title when - An image's title becomes its caption when the image stays an image, and its link's title when
it becomes linked alt text. Inside another link, the title is dropped. it becomes linked alt text. Inside another link, the title is dropped.
And two more: a shortcut reference matching its definition only under Unicode case folding And these: a shortcut reference matching its definition only under Unicode case folding stays
stays unresolved, and a link whose text holds an autolink keeps the inner link and leaves the unresolved, and a link whose text holds an autolink keeps the inner link and leaves the outer
outer brackets literal text, which the spec requires and the reference itself breaks, nesting one brackets literal text, which the spec requires and the reference itself breaks, nesting one `<a>`
`<a>` in the other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model` in the other. `corpus/commonmark-spec/exceptions.json` pins the spellings ADF holds no node for
where the suite reaches them and the fifth `pending`; the suite holds no example of the last. `node-model` where the suite reaches them, and the case-folded reference `pending`; the suite
holds no example of a link holding an autolink.
- Not every document converts back: `adfToLosslessMarkdown` refuses a document nesting past 500 - Not every document converts back: `adfToLosslessMarkdown` refuses a document nesting past 500
levels, and the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the levels, and the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the
document read-only; saving markdown you could not produce is the loss the round-trip exists to document read-only; saving markdown you could not produce is the loss the round-trip exists to
+8 -10
View File
@@ -75,8 +75,8 @@ type ContainerStack = {
type Walk = ParsedBlocks & { type Walk = ParsedBlocks & {
claims: Claims claims: Claims
// The block a paragraph of link reference definitions alone last closed after, which ends a list as any block does. // The last block before a paragraph of link reference definitions alone, which ends a list as any block does.
definedAfter: Block | undefined endedByDefinitions: Block | undefined
leaf: OpenLeaf | undefined leaf: OpenLeaf | undefined
leafOpeners: Map<Block[], Map<string, LeafOpener>> leafOpeners: Map<Block[], Map<string, LeafOpener>>
position: SourcePosition position: SourcePosition
@@ -91,8 +91,8 @@ export function parseBlocks(markdown: string, claims: Claims): ParsedBlocks {
const walk: Walk = { const walk: Walk = {
blocks: [], blocks: [],
claims, claims,
definedAfter: undefined,
definitions: new Map(), definitions: new Map(),
endedByDefinitions: undefined,
leaf: undefined, leaf: undefined,
leafOpeners: new Map(), leafOpeners: new Map(),
position: { line: 1, offset: 0 }, position: { line: 1, offset: 0 },
@@ -249,12 +249,12 @@ function openContainer(walk: Walk, start: ContainerStart): void {
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list }) walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
} }
// An item opening after its list's last item closed, as an empty item a blank line ends does, continues that list. // An item opening after its list's last item closed, as an empty item a blank line ends does, continues that list, unless definitions ended it.
function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>): ListBlock { function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>): ListBlock {
if (!start.fresh) return start.list if (!start.fresh) return start.list
const blocks = currentBlocks(walk) const blocks = currentBlocks(walk)
const previous = blocks.at(-1) const previous = blocks.at(-1)
if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous !== walk.definedAfter && continuesList(previous, start.list)) return previous if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous !== walk.endedByDefinitions && continuesList(previous, start.list)) return previous
blocks.push(start.list) blocks.push(start.list)
return start.list return start.list
} }
@@ -466,7 +466,8 @@ function closeLeaf(walk: Walk): void {
if (leaf === undefined) return if (leaf === undefined) return
if (leaf.kind === 'paragraph') { if (leaf.kind === 'paragraph') {
const paragraph = takeParagraph(walk) const paragraph = takeParagraph(walk)
if (paragraph !== undefined) currentBlocks(walk).push(bareTableFault(paragraph, walk.claims) ?? paragraph) if (paragraph === undefined) walk.endedByDefinitions = currentBlocks(walk).at(-1)
else currentBlocks(walk).push(bareTableFault(paragraph, walk.claims) ?? paragraph)
return return
} }
walk.leaf = undefined walk.leaf = undefined
@@ -517,10 +518,7 @@ function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | unde
if (leaf?.kind !== 'paragraph') return undefined if (leaf?.kind !== 'paragraph') return undefined
walk.leaf = undefined walk.leaf = undefined
const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n')) const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n'))
if (text === '') { if (text === '') return undefined
walk.definedAfter = currentBlocks(walk).at(-1)
return undefined
}
const kept = leaf.positions[leaf.lines.length - text.split('\n').length] const kept = leaf.positions[leaf.lines.length - text.split('\n').length]
return { kind: 'paragraph', position: kept ?? leaf.position, text } return { kind: 'paragraph', position: kept ?? leaf.position, text }
} }
+13 -2
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 98` `Next ID = 99`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -35,6 +35,7 @@
| 96 | 0.2.1 | defect | **Read and write a text holding many unclosed backtick runs, or many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, in time linear in the text.** | 2 | 3 | 5 | 9 | 9 | 9.2 | | 96 | 0.2.1 | defect | **Read and write a text holding many unclosed backtick runs, or many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, in time linear in the text.** | 2 | 3 | 5 | 9 | 9 | 9.2 |
| 92 | 0.2.1 | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 4 | 2 | 4 | 1 | 9.0 | | 92 | 0.2.1 | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 4 | 2 | 4 | 1 | 9.0 |
| 33 | 0.2.1 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 | | 33 | 0.2.1 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
| 98 | 0.2.1 | defect | **Read blank lines inside deeply nested list items in time linear in the markdown.** | 3 | 3 | 5 | 9 | 9 | 8.2 |
| 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 | | 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| 6 | 0.3.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 | | 6 | 0.3.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 |
@@ -143,6 +144,15 @@ the portable reduction's `spellableLine` drops one mark per re-emit the same way
whose attributes no spelling writes re-emits the line the same way before riding the carry. Make all whose attributes no spelling writes re-emits the line the same way before riding the carry. Make all
three linear. three linear.
### 98. Read blank lines inside deeply nested list items in time linear in the markdown.
`matchContainers` in `src/markdown/parse/blocks.ts` continues every open item holding a block on
each blank line, and `parseBlocks` holds no depth guard: `readBlocks` refuses past 500 levels only
after the walk. `'- '.repeat(n) + 'a\n' + '\n'.repeat(n)` took 675 ms at n = 5000, 2.7 s at 10000
and 10.7 s at 20000 (60 kB) before refusing as `unsupported-nesting-depth`. `docs/decisions.md`
§Cost fixes are measured, never timed calls the re-scan bounded by that guard; correct it with the
fix.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.
Lands after items 6, 59, 60 and 62. Which flavours get a composed call is this item's to settle; Lands after items 6, 59, 60 and 62. Which flavours get a composed call is this item's to settle;
@@ -344,7 +354,8 @@ quadratic in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces,
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes. ### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.
Technical principle "One owner per value": the `Walk` record passes through twelve functions that Technical principle "One owner per value": the `Walk` record passes through twelve functions that
mutate it and return `void`; `walk.leaf` alone is written in seven places. `ContainerStack` in the mutate it and return `void`; `walk.leaf` alone is written in seven places, and `openedList` reads
the `endedByDefinitions` that `closeLeaf` leaves. `ContainerStack` in the
same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen
functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return
`void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in `void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in