Read the block nodes back, and stop a marker change splitting a list #40

Merged
lilleman merged 6 commits from block-nodes-read-back into main 2026-09-01 20:23:29 +02:00
12 changed files with 163 additions and 35 deletions
Showing only changes of commit aae1ed4baf - Show all commits
+92
View File
@@ -0,0 +1,92 @@
{
"content": [
{
"content": [
{
"content": [
{
"content": [
{
"text": "Bolt M8",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Nut M8",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Washer M8",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"attrs": {
"order": 1
},
"content": [
{
"content": [
{
"content": [
{
"text": "Loosen the clamp",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Lift the cover",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
}
],
"type": "doc",
"version": 1
}
+8
View File
@@ -0,0 +1,8 @@
- Bolt M8
* Nut M8
-
+ Washer M8
1. Loosen the clamp
1) Lift the cover
+1 -1
View File
@@ -1,6 +1,6 @@
* Bolt M8 * Bolt M8
+ Nut M8 * Nut M8
Then: Then:
+6 -1
View File
@@ -187,12 +187,17 @@ for (const directory of emittingDirectories) {
} }
for (const name of pairedNames(normalizationRoot, '.md', '.json')) { for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
test(`normalization/${name} parses to the document beside it`, () => { test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => {
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')) const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected) assert.deepEqual(result.value, expected)
const emitted = adfToMarkdown(result.value)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
assert.deepEqual(again.value, expected)
}) })
} }
+8 -6
View File
@@ -3,10 +3,12 @@ import { carryName } from './opaque-carry.ts'
import { holdsControlCharacter } from './commonmark-grammar.ts' import { holdsControlCharacter } from './commonmark-grammar.ts'
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './entity-references.ts'
// spec/flavour.md, The CommonMark blocks: the info string the language rides, `undefined` where the attribute carries it. export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
export function fenceInfo(language: JsonValue | undefined): string | undefined {
if (language === undefined) return '' // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides, both directions.
if (typeof language !== 'string' || language === '' || language === carryName) return undefined export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return undefined if (language === undefined) return { kind: 'none' }
return language if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' }
} }
+3 -3
View File
@@ -171,13 +171,13 @@ export function isUnicodeWhitespace(character: string): boolean {
} }
// `start` is the list's first number, `undefined` for a bullet. // `start` is the list's first number, `undefined` for a bullet.
export function listMarker(line: string): { delimiter: string; start: number | undefined; width: number } | undefined { export function listMarker(line: string): { start: number | undefined; width: number } | undefined {
const ordered = orderedListOpener.exec(line) const ordered = orderedListOpener.exec(line)
if (ordered !== null) { if (ordered !== null) {
const digits = ordered[1] ?? '' const digits = ordered[1] ?? ''
return { delimiter: ordered[2] ?? '', start: Number(digits), width: digits.length + 1 } return { start: Number(digits), width: digits.length + 1 }
} }
return bulletListOpener.test(line) ? { delimiter: line.charAt(0), start: undefined, width: 1 } : undefined return bulletListOpener.test(line) ? { start: undefined, width: 1 } : undefined
} }
export function markerInterruptsParagraph(start: number | undefined, empty: boolean): boolean { export function markerInterruptsParagraph(start: number | undefined, empty: boolean): boolean {
+8 -7
View File
@@ -6,7 +6,7 @@ import { carriesOnly, isAdfDocument } from '../../adf/document.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../backtick-runs.ts' import { fencedCodeBlock } from '../backtick-runs.ts'
import { fenceInfo } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { spellDirectiveHeader } from './block-directive-spelling.ts' import { spellDirectiveHeader } from './block-directive-spelling.ts'
@@ -147,20 +147,21 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return undefined if (!carriesOnly(node, ['language'])) return undefined
const info = fenceInfo(node.attrs?.['language']) const slot = languageSlot(node.attrs?.['language'])
if (info === undefined) return undefined if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text if (!text.ok) return text
return success(commonMarkText(fencedCodeBlock(info, text.value))) return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
} }
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> { function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> {
const info = fenceInfo(node.attrs?.['language']) const slot = languageSlot(node.attrs?.['language'])
const header = spellDirectiveHeader(node, directive, info === undefined ? [] : ['language']) const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path)) if (header === undefined) return commonMarkLine(carriedBlock(node, path))
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text if (!text.ok) return text
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info ?? '', text.value)}\n:::` }) const info = slot.kind === 'fence' ? slot.info : ''
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info, text.value)}\n:::` })
} }
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> { function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
+13 -3
View File
@@ -176,9 +176,19 @@ function openContainer(walk: Walk, start: ContainerStart): void {
walk.stack.push(blockquote) walk.stack.push(blockquote)
return return
} }
if (start.fresh) currentBlocks(walk).push(start.list) const list = openedList(walk, start)
start.list.items.push(blocks) list.items.push(blocks)
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list: start.list }) walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
}
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>): ListBlock {
if (!start.fresh) return start.list
const blocks = currentBlocks(walk)
const previous = blocks.at(-1)
if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous.kind === start.list.kind) return previous
blocks.push(start.list)
return start.list
} }
function closeContainers(walk: Walk, depth: number): void { function closeContainers(walk: Walk, depth: number): void {
@@ -390,6 +390,8 @@ test('reads a bullet list, the marker width setting the continuation', () => {
assert.deepEqual(content(markdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))]) assert.deepEqual(content(markdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))])
assert.deepEqual(content(markdownToAdf('- a\n* b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))]) assert.deepEqual(content(markdownToAdf('- a\n* b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('- a\n\n+ b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))]) assert.deepEqual(content(markdownToAdf('- a\n\n+ b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('- a\n-\n\n- c\n')), [bulletList(item(paragraph('a')), item(), item(paragraph('c')))])
assert.deepEqual(content(markdownToAdf('- a\n1. b\n')), [bulletList(item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
assert.deepEqual(content(markdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')]) assert.deepEqual(content(markdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')])
}) })
+3 -3
View File
@@ -5,7 +5,7 @@ import type { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts' import { carryName } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fenceInfo } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
@@ -85,8 +85,8 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one fenced code block as its body`, path) if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one fenced code block as its body`, path)
const attribute = node.attrs?.['language'] const attribute = node.attrs?.['language']
const fromFence = only.language !== '' const fromFence = only.language !== ''
const info = fenceInfo(fromFence ? only.language : attribute) const slot = languageSlot(fromFence ? only.language : attribute)
if ((info !== undefined && info !== '') !== fromFence || (fromFence && attribute !== undefined)) { if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path) return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
} }
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node
+11 -7
View File
@@ -295,13 +295,17 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so The emitter's other refusals survive on causes no fixture in that directory covers, so
3k's one-list pass is where they get fixtures or the directory goes. 3k's one-list pass is where they get fixtures or the directory goes.
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the two shapes **Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
input accepted and emit refused, not the last. The other surfaced here — CommonMark opens a accepted and emit refused, not the last. Two adjacent lists of a kind are what
second list on a marker change, so `- a` over `* b` built two adjacent `bulletList` nodes `adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
`adfToMarkdown` refuses — and the parser continues the list instead, the way it already two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
drops the blank lines between items. With both closed the parse-then-emit fixpoint the whose blank line pops the container the list's identity hung from. The parser opens no list
README promises holds for every construct the parser reads, rather than only for what the beside one of its own kind instead, the way it already drops the blank lines between items;
emitter wrote. 3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
`normalization/` arm emits each document and reads it back from here, so the population that
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
narrow.
- [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first - [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
directory to. A parsed directive becomes its node: the name to the type and an unknown one directory to. A parsed directive becomes its node: the name to the type and an unknown one
to a named error, the arg to the attribute it names, each value to the type its section to a named error, the arg to the attribute it names, each value to the type its section
+8 -4
View File
@@ -61,9 +61,10 @@ numbering is the order the work was planned in, not the order it ships.
parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts` parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts`
that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart
from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline
directive position want a claim code — `:em[x]` is an error forever, so directive position take `unsupported-node-shape` rather than a code of their own — §8
`unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal, already answers a well-formed directive the node tables refuse, and the message names the
as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens. spelling to use (`*x*`), while `unknown-directive-name`'s "a later MINOR may give the name
meaning" stays the wrong signal, as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
The marks a spelling wraps answer the same question 3g settled for a block's form: only the The marks a spelling wraps answer the same question 3g settled for a block's form: only the
nesting the emitter writes parses back. nesting the emitter writes parses back.
- [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back: - [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
@@ -123,7 +124,10 @@ numbering is the order the work was planned in, not the order it ships.
landed the last decision `corpus/unspellable/` held and the directory went with it, so what landed the last decision `corpus/unspellable/` held and the directory went with it, so what
the code list holds from here is permanent. The parser's own code the code list holds from here is permanent. The parser's own code
additions are read here as one list before that freeze — nine sessions mint them additions are read here as one list before that freeze — nine sessions mint them
independently, and one cause wearing two codes is breaking to undo after `0.1.0`. `0.1.0` independently, and one cause wearing two codes is breaking to undo after `0.1.0`. That read
gets a test rather than an eye — every `ConvertErrorCode` member named at a production call
site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation`
outlived its cause until 3h went looking. `0.1.0`
is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build
lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own
`allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands `allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands