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
+ Nut M8
* Nut M8
Then:
+6 -1
View File
@@ -187,12 +187,17 @@ for (const directory of emittingDirectories) {
}
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'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
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 { holdsEntityReference } from './entity-references.ts'
// spec/flavour.md, The CommonMark blocks: the info string the language rides, `undefined` where the attribute carries it.
export function fenceInfo(language: JsonValue | undefined): string | undefined {
if (language === undefined) return ''
if (typeof language !== 'string' || language === '' || language === carryName) return undefined
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return undefined
return language
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides, both directions.
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' }
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.
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)
if (ordered !== null) {
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 {
+8 -7
View File
@@ -6,7 +6,7 @@ import { carriesOnly, isAdfDocument } from '../../adf/document.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.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 { largestNesting } from '../../nesting.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 {
if (!carriesOnly(node, ['language'])) return undefined
const info = fenceInfo(node.attrs?.['language'])
if (info === undefined) return undefined
const slot = languageSlot(node.attrs?.['language'])
if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path)
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> {
const info = fenceInfo(node.attrs?.['language'])
const header = spellDirectiveHeader(node, directive, info === undefined ? [] : ['language'])
const slot = languageSlot(node.attrs?.['language'])
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
const text = codeBlockText(node, path)
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> {
+13 -3
View File
@@ -176,9 +176,19 @@ function openContainer(walk: Walk, start: ContainerStart): void {
walk.stack.push(blockquote)
return
}
if (start.fresh) currentBlocks(walk).push(start.list)
start.list.items.push(blocks)
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list: start.list })
const list = openedList(walk, start)
list.items.push(blocks)
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 {
@@ -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('- 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\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.')])
})
+3 -3
View File
@@ -5,7 +5,7 @@ import type { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.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 { parseBlocks } from './blocks.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)
const attribute = node.attrs?.['language']
const fromFence = only.language !== ''
const info = fenceInfo(fromFence ? only.language : attribute)
if ((info !== undefined && info !== '') !== fromFence || (fromFence && attribute !== undefined)) {
const slot = languageSlot(fromFence ? only.language : attribute)
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)
}
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.
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.
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the two shapes
input accepted and emit refused, not the last. The other surfaced here — CommonMark opens a
second list on a marker change, so `- a` over `* b` built two adjacent `bulletList` nodes
`adfToMarkdown` refuses — and the parser continues the list instead, the way it already
drops the blank lines between items. With both closed the parse-then-emit fixpoint the
README promises holds for every construct the parser reads, rather than only for what the
emitter wrote.
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
accepted and emit refused, not the last. Two adjacent lists of a kind are what
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
whose blank line pops the container the list's identity hung from. The parser opens no list
beside one of its own kind instead, the way it already drops the blank lines between items;
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
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
+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`
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
directive position want a claim code — `:em[x]` is an error forever, so
`unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal,
as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
directive position take `unsupported-node-shape` rather than a code of their own — §8
already answers a well-formed directive the node tables refuse, and the message names the
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
nesting the emitter writes parses 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
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
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
lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own
`allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands