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

Answer architecture round 2: portableSequence walks a stack in linear time, the * escape and the alternation are worded and named for what they do, item 51 names no count, and item 94 files the link-definition join

This commit is contained in:
2026-10-09 15:05:04 +02:00
committed by Lillem4n
parent 49ec61709c
commit c2b4bea2dc
5 changed files with 46 additions and 31 deletions
+8 -6
View File
@@ -68,8 +68,9 @@ same portable markdown.
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`!adf:textBreak{}`: a leaf building no node keeps both nodes and asks nothing of the text around
it. A code span holds no directive, so the spans close and reopen around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
`!adf:textBreak{}`: a leaf building no node keeps both nodes and asks nothing of the text around it.
A code span holds no directive, so the spans close and reopen around the leaf. The grammar:
`spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
## A list beside one of its kind takes the other marker
@@ -702,10 +703,11 @@ per level above it otherwise.
The parse and the portable reduction keep each node's readable spelling in a memo, so the
`commonMarkSpelling` ask stops spelling a node once per level above it. The spelled texts and
`spelling` carry no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry
rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and
an ordered list past the marker cap gives way, spending two emitter levels where the parser spent
one. Only what succeeded is kept, so no path minted at another position is ever read.
`spelling` carry no depth and `headroom` is affine in it, so a read at or above the depth that
filled the entry rebases; a read below re-spells, because a hit skips the depth guards the walk it
replaces runs and an ordered list past the marker cap gives way, spending two emitter levels where
the parser spent one. Only what succeeded is kept, so no path minted at another position is ever
read.
## Cost fixes are measured, never timed
+5 -4
View File
@@ -21,8 +21,9 @@ normalizes to it through the round-trip.
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's
`order` attribute. A bullet or task list right after a bullet or task list takes `* `, and an
ordered list right after an ordered one `1)`, since CommonMark starts a new list where the
bullet character or the delimiter changes; the list after that takes `- ` or `1.` again, and a
first line of `*` alone after `* ` opens with a backslash, `* \**`. Continuation lines align with the first character
bullet character or the delimiter changes; the list after that takes `- ` or `1.` again. An item
text of asterisks and spaces that would read as a rule after `* ` opens with a backslash,
`* \**`. Continuation lines align with the first character
after the marker (two spaces for `- `, three for `1. `); blank lines inside an item are empty
lines, none between a nested list and a CommonMark block above it — one wherever the nested
list's own marker cannot interrupt a paragraph (an ordered list whose first number is not 1, or
@@ -84,8 +85,8 @@ escaped, and erroring input gaining meaning later is MINOR, never a reparse (`do
or an inline node written as a block directive, a block node written inline — is a different error,
naming the spelling it takes. Three reserved names read back to no node: `carry` for the inline
opaque carry, `doc` for a document holding no `content` key (Canonical form), and `textBreak` for
the leaf that parts two text nodes (Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque
carry).
the leaf that parts two text nodes (Inline nodes). Every fence info string opening `adf:` is
reserved for the block carry (The opaque carry).
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
closes a container, and a name picks by what follows it in turn — a space or the line's end a block
+9 -8
View File
@@ -20,13 +20,13 @@ import { tryPipeTable } from './pipe-table.ts'
type BlockContainer = 'directive' | 'document' | 'list-item'
// The usual delimiter, then the one a list beside another of its kind takes.
type Delimiters = readonly [string, string]
// CommonMark starts a new list where the marker changes, so a list beside one of its kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
// headroom: the nesting levels the deepest node below may still spend before `largestNesting` refuses it.
type EmittedBlock = SpelledBlock & { headroom: number }
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
type PlacedBlock = SpelledBlock & { node: AdfNode }
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so a list beside one of its kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
@@ -34,9 +34,9 @@ export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined
const bulletDelimiters: Delimiters = ['-', '*']
export const largestListMarker = 999999999
const orderedDelimiters: Delimiters = ['.', ')']
// Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
const orderedDelimiters: Delimiters = ['.', ')']
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
return writeMarkdown(document, 'lossless')
@@ -76,14 +76,15 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
for (const [index, block] of blocks.entries()) {
const previous = blocks[index - 1]
if (previous !== undefined) text += separationBetween(previous, block, container)
alternated = !alternated && previous?.spelling === 'list' && block.spelling === 'list' && isOrdered(previous.node) === isOrdered(block.node)
alternated = !alternated && previous !== undefined && sharesMarker(previous, block)
text += alternated && block.spelling === 'list' ? block.alternateText : block.text
}
return text
}
function isOrdered(node: AdfNode): boolean {
return node.type === 'orderedList'
// A task list is spelled as a bullet list.
function sharesMarker(previous: PlacedBlock, next: PlacedBlock): boolean {
return previous.spelling === 'list' && next.spelling === 'list' && (previous.node.type === 'orderedList') === (next.node.type === 'orderedList')
}
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
@@ -208,7 +209,7 @@ function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writin
const walk = walkBlocks(nodeContent(task), path, depth, writing)
if (!walk.ok) return walk
const [first, ...rest] = walk.value.blocks
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
const blocks = first?.node.type === 'paragraph' && first.spelling !== 'list' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
return success({ blocks, headroom: walk.value.headroom })
}
@@ -366,7 +367,7 @@ function tryListItemLines(inner: string, marker: string): string | undefined {
const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
const first = lines[0] ?? ''
if (!isThematicBreak(first)) return lines.join('\n')
// A first line of `*` alone beside the `* ` marker is text, since a rule is spelled `---`: a backslash keeps it from reading as one.
// Only text opens an item with `*`, a rule being spelled `---`; where `* ` before it reads as a rule, a backslash keeps it text.
return inner.startsWith('*') ? [`${marker}\\${first.slice(marker.length)}`, ...lines.slice(1)].join('\n') : undefined
}
@@ -116,16 +116,18 @@ function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]>
// A list still taking the directive form gives way to its items' blocks.
function portableSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0)
for (let index = 0; index < sequence.length; index += 1) {
const listed = sequence[index]
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
const block = numberedPastMarkers(listed)
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'portable', memo: reduction.memo })?.ok === true
if (spelled) continue
sequence = [...sequence.slice(0, index), ...(block === listed ? nodeContent(block).flatMap(nodeContent) : [block]), ...sequence.slice(index + 1)]
// The replacement's first block takes this index, and may itself take the directive form.
index -= 1
const pending = blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0).reverse()
const sequence: AdfNode[] = []
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const block = numberedPastMarkers(next)
const listed = block.type === 'bulletList' || block.type === 'orderedList'
if (!listed || (block === next && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'portable', memo: reduction.memo })?.ok === true)) {
sequence.push(block)
continue
}
// Read next, since a replacement may take the directive form itself.
const replacement = block === next ? nodeContent(block).flatMap(nodeContent) : [block]
for (const node of replacement.reverse()) pending.push(node)
}
return success(sequence)
}
+12 -3
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 94`
`Next ID = 95`
| Goal | W |
|---|---|
@@ -26,6 +26,7 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
| 94 | 0.2.0 | defect | **Read two lists a link reference definition parts as two lists, in every reader.** | 3 | 3 | 4 | 7 | 4, 5 | 13.6 |
| 93 | 0.2.0 | | **Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.** | 2 | 3 | 3 | 5 | 1 | 13.2 |
| 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 |
| 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
@@ -76,6 +77,14 @@ only the property tests catch drift. It caps the panel's Locality score. The rep
time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run
of one character from each of its characters.
### 94. Read two lists a link reference definition parts as two lists, in every reader.
`openedList` in `src/markdown/parse/blocks.ts` joins a fresh list to the list just before it when
no block stands between, so `- a\n\n[r]: /u\n\n- b` reads as one list; CommonMark reads two,
the definition closing the first. The join exists for an empty item a blank line ends, which
continues its list. `markdown-to-adf.test.ts` asserts the one list. Breaking: `MIGRATION.md`'s
Readings table gains its row.
### 93. Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.
`bulletBlocks` fixes a block's path before `splitTaskList` moves it beside or after its task, so
@@ -266,8 +275,8 @@ A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
example 540); lowercasing and then uppercasing folds it. Breaking: `MIGRATION.md`'s Readings table
gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Seven
CommonMark spellings" bullet, which counts one fewer.
gains its row. Its `pending` exceptions go, and its spelling leaves the README's bullet counting
the CommonMark spellings, which counts one fewer.
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.