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:
+8
-6
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 `[](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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user