Answer the architecture review: the claim rides a block, and the path comes from the node layer
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 23:39:06 +02:00
parent 5fc4272155
commit 05f53a2024
8 changed files with 95 additions and 83 deletions
+23 -28
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
@@ -36,6 +36,21 @@ function names(root: string, extension: string): string[] {
.sort()
}
// One kind's fixture pairs, its two tests declared with them.
function pairedNames(root: string, first: string, second: string): string[] {
const kind = basename(root)
test(`${kind} pairs every ${first} with a ${second}`, () => {
assert.deepEqual(names(root, first), names(root, second))
})
test(`${kind} holds fixtures`, () => {
assert.ok(names(root, first).length > 0)
})
return names(root, first)
}
function corpusJsonPaths(): string[] {
return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
.filter((name) => name.endsWith('.json'))
@@ -43,6 +58,10 @@ function corpusJsonPaths(): string[] {
.sort()
}
test('every corpus directory is a kind the runner reads', () => {
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip', 'unspellable'])
})
test('every round-trip directory emits', () => {
assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort())
})
@@ -147,15 +166,7 @@ for (const directory of emittingDirectories) {
}
}
test('unspellable pairs every .json with an .error', () => {
assert.deepEqual(names(unspellableRoot, '.json'), names(unspellableRoot, '.error'))
})
test('unspellable holds fixtures', () => {
assert.ok(names(unspellableRoot, '.json').length > 0)
})
for (const name of names(unspellableRoot, '.json')) {
for (const name of pairedNames(unspellableRoot, '.json', '.error')) {
test(`unspellable/${name} is refused with the error it names`, () => {
const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
@@ -165,15 +176,7 @@ for (const name of names(unspellableRoot, '.json')) {
})
}
test('normalization pairs every .md with a .json', () => {
assert.deepEqual(names(normalizationRoot, '.md'), names(normalizationRoot, '.json'))
})
test('normalization holds fixtures', () => {
assert.ok(names(normalizationRoot, '.md').length > 0)
})
for (const name of names(normalizationRoot, '.md')) {
for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
test(`normalization/${name} parses to the document beside it`, () => {
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
@@ -183,15 +186,7 @@ for (const name of names(normalizationRoot, '.md')) {
})
}
test('errors pairs every .md with an .error', () => {
assert.deepEqual(names(errorsRoot, '.md'), names(errorsRoot, '.error'))
})
test('errors holds fixtures', () => {
assert.ok(names(errorsRoot, '.md').length > 0)
})
for (const name of names(errorsRoot, '.md')) {
for (const name of pairedNames(errorsRoot, '.md', '.error')) {
test(`errors/${name} is refused with the error it names`, () => {
const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8'))
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
+1
View File
@@ -29,6 +29,7 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli
const delimiters = ['*', '_', '`', '~']
// The `:` keeps a `[label]: url` line escaped: unescaped, the parser swallows it as a link reference definition.
const followsLinkText = /[([:]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
+7 -9
View File
@@ -2,21 +2,14 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import type { LinkDefinition } from './link-reference-definitions.ts'
import type { ParsedBlocks } from './blocks.ts'
import { parseBlocks } from './blocks.ts'
function walk(markdown: string): ParsedBlocks {
const result = parseBlocks(markdown)
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
return result.value
}
function definitions(markdown: string): [string, LinkDefinition][] {
return [...walk(markdown).definitions]
return [...parseBlocks(markdown).definitions]
}
function kinds(markdown: string): string[] {
return walk(markdown).blocks.map((block) => block.kind)
return parseBlocks(markdown).blocks.map((block) => block.kind)
}
test('keeps the link reference definitions a paragraph gives up, the first of a label winning', () => {
@@ -51,3 +44,8 @@ test('swallows an HTML block to the end condition its start sets', () => {
assert.deepEqual(kinds('<div>\nx\n'), ['html'])
assert.deepEqual(kinds('Part.\n<div>\n'), ['paragraph', 'html'])
})
test('carries a claimed line as the block it opens, the refusal the node layer builds', () => {
assert.deepEqual(kinds(':::\nPart.\n'), ['claim', 'paragraph'])
assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'claim'])
})
+36 -28
View File
@@ -1,14 +1,16 @@
import type { LinkDefinition } from './link-reference-definitions.ts'
import type { OpenHtmlBlock } from './html-blocks.ts'
import { atxHeading, claimsDirectiveLine, claimsPipeLine, closingCodeFence, isThematicBreak, openingCodeFence, setextHeadingLevel } from '../commonmark-grammar.ts'
import { failure, success, type Result } from '../../result.ts'
import { openingHtmlBlock } from './html-blocks.ts'
import { readLinkDefinitions } from './link-reference-definitions.ts'
export type ClaimedConstruct = 'directive' | 'pipe-table'
export type LeafBlock =
| { construct: ClaimedConstruct; kind: 'claim' }
| { construct: string; kind: 'html' }
| { kind: 'code'; language: string; text: string }
| { kind: 'heading'; level: number; text: string }
| { kind: 'html'; name: string }
| { kind: 'paragraph'; text: string }
| { kind: 'rule' }
@@ -21,41 +23,51 @@ const indentedCodeColumns = 4
const largestOpenerIndentation = 3
const tabStop = 4
export function parseBlocks(markdown: string): Result<ParsedBlocks> {
export function parseBlocks(markdown: string): ParsedBlocks {
const lines = normalizeInput(markdown).split('\n')
const walk: Walk = { blocks: [], definitions: new Map(), paragraph: [] }
let index = 0
while (index < lines.length) {
const line = lines[index] ?? ''
index += 1
if (blankLine.test(line)) {
closeParagraph(walk)
index += 1
continue
}
if (leadingColumns(line) >= indentedCodeColumns && walk.paragraph.length === 0) {
index = readIndentedCode(walk, lines, index - 1)
index = readIndentedCode(walk, lines, index)
continue
}
const opener = removeColumns(line, largestOpenerIndentation)
const claim = claimedLine(walk, opener)
if (claim !== undefined) return claim
if (readLineBlock(walk, opener)) continue
const fence = openingCodeFence(opener)
if (fence !== undefined) {
closeParagraph(walk)
index = readFencedCode(walk, lines, index, fence, leadingColumns(line))
continue
}
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
if (html !== undefined) {
closeParagraph(walk)
index = readHtmlBlock(walk, lines, index - 1, html)
const opened = openBlock(walk, lines, index, line)
if (opened !== undefined) {
index = opened
continue
}
walk.paragraph.push(line.replace(/^[ \t]+/, ''))
index += 1
}
closeParagraph(walk)
return success({ blocks: walk.blocks, definitions: walk.definitions })
return { blocks: walk.blocks, definitions: walk.definitions }
}
function openBlock(walk: Walk, lines: readonly string[], index: number, line: string): number | undefined {
const opener = removeColumns(line, largestOpenerIndentation)
const claimed = claimedConstruct(opener)
if (claimed !== undefined) {
closeParagraph(walk)
walk.blocks.push({ construct: claimed, kind: 'claim' })
return index + 1
}
if (readLineBlock(walk, opener)) return index + 1
const fence = openingCodeFence(opener)
if (fence !== undefined) {
closeParagraph(walk)
return readFencedCode(walk, lines, index + 1, fence, leadingColumns(line))
}
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
if (html === undefined) return undefined
closeParagraph(walk)
return readHtmlBlock(walk, lines, index, html)
}
// The document's last line ending closes its line rather than opening an empty one.
@@ -66,13 +78,9 @@ function normalizeInput(markdown: string): string {
.replace(/\n$/, '')
}
function claimedLine(walk: Walk, opener: string): Result<ParsedBlocks> | undefined {
const directive = claimsDirectiveLine(opener)
if (!directive && !claimsPipeLine(opener)) return undefined
closeParagraph(walk)
const path = ['content', walk.blocks.length]
if (directive) return failure('malformed-directive', 'the line claims a directive and parses as none', path)
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
function claimedConstruct(opener: string): ClaimedConstruct | undefined {
if (claimsDirectiveLine(opener)) return 'directive'
return claimsPipeLine(opener) ? 'pipe-table' : undefined
}
// A setext underline over a paragraph the definitions emptied is no heading: it opens the next block.
@@ -139,7 +147,7 @@ function readHtmlBlock(walk: Walk, lines: readonly string[], start: number, html
index += 1
if (html.closer !== undefined && html.closer.test(line)) break
}
walk.blocks.push({ kind: 'html', name: html.name })
walk.blocks.push({ construct: html.construct, kind: 'html' })
return index
}
+10 -10
View File
@@ -1,6 +1,6 @@
export type OpenHtmlBlock = { closer: RegExp | undefined; name: string }
export type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
type HtmlBlockCondition = { closer: RegExp | undefined; interrupts: boolean; name: string | undefined; start: RegExp }
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
// CommonMark 0.31.2, HTML blocks: the tag names start condition 6 lists.
const blockTagNames =
@@ -10,19 +10,19 @@ const completeTag = new RegExp(`^(?:<[A-Za-z][A-Za-z0-9-]*${attributeSource}*[ \
const tagName = /^<\/?([A-Za-z][A-Za-z0-9-]*).*$/
const conditions: HtmlBlockCondition[] = [
{ closer: /<\/(?:pre|script|style|textarea)>/i, interrupts: true, name: undefined, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
{ closer: /-->/, interrupts: true, name: 'an HTML comment', start: /^<!--/ },
{ closer: /\?>/, interrupts: true, name: 'an HTML processing instruction', start: /^<\?/ },
{ closer: />/, interrupts: true, name: 'an HTML declaration', start: /^<![A-Za-z]/ },
{ closer: /\]\]>/, interrupts: true, name: 'a CDATA section', start: /^<!\[CDATA\[/ },
{ closer: undefined, interrupts: true, name: undefined, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
{ closer: undefined, interrupts: false, name: undefined, start: completeTag },
{ closer: /<\/(?:pre|script|style|textarea)>/i, construct: undefined, interrupts: true, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
{ closer: /-->/, construct: 'an HTML comment', interrupts: true, start: /^<!--/ },
{ closer: /\?>/, construct: 'an HTML processing instruction', interrupts: true, start: /^<\?/ },
{ closer: />/, construct: 'an HTML declaration', interrupts: true, start: /^<![A-Za-z]/ },
{ closer: /\]\]>/, construct: 'a CDATA section', interrupts: true, start: /^<!\[CDATA\[/ },
{ closer: undefined, construct: undefined, interrupts: true, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
{ closer: undefined, construct: undefined, interrupts: false, start: completeTag },
]
export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlBlock | undefined {
for (const condition of conditions) {
if ((interrupting && !condition.interrupts) || !condition.start.test(line)) continue
return { closer: condition.closer, name: condition.name ?? line.replace(tagName, '<$1>') }
return { closer: condition.closer, construct: condition.construct ?? line.replace(tagName, '<$1>') }
}
return undefined
}
@@ -107,6 +107,7 @@ test('refuses the raw HTML no element mapping carries', () => {
assert.equal(code(markdownToAdf('<pre>\nx\n</pre>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<span foo="bar">\n')), 'unmappable-html')
assert.deepEqual(path(markdownToAdf('Part.\n\n<div>\n')), ['content', 1])
assert.equal(code(markdownToAdf('<div>\nx\n\n:::\n')), 'unmappable-html')
})
test('swallows an HTML block ahead of the claim a line inside it would make', () => {
+9 -5
View File
@@ -1,13 +1,11 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { LeafBlock } from './blocks.ts'
import type { ClaimedConstruct, LeafBlock } from './blocks.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { parseBlocks } from './blocks.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> {
const parsed = parseBlocks(markdown)
if (!parsed.ok) return parsed
const content: AdfNode[] = []
for (const [index, block] of parsed.value.blocks.entries()) {
for (const [index, block] of parseBlocks(markdown).blocks.entries()) {
const node = blockNode(block, ['content', index])
if (!node.ok) return node
content.push(node.value)
@@ -16,13 +14,19 @@ export function markdownToAdf(markdown: string): Result<AdfDocument> {
}
function blockNode(block: LeafBlock, path: ConvertErrorPath): Result<AdfNode> {
if (block.kind === 'claim') return claimFailure(block.construct, path)
if (block.kind === 'code') return success(codeBlockNode(block.language, block.text))
if (block.kind === 'heading') return success(withContent({ attrs: { level: block.level }, type: 'heading' }, block.text))
if (block.kind === 'html') return failure('unmappable-html', `no element mapping carries ${block.name}`, path)
if (block.kind === 'html') return failure('unmappable-html', `no ADF node carries ${block.construct}`, path)
if (block.kind === 'paragraph') return success(withContent({ type: 'paragraph' }, block.text))
return success({ type: 'rule' })
}
function claimFailure(construct: ClaimedConstruct, path: ConvertErrorPath): Result<AdfNode> {
if (construct === 'directive') return failure('malformed-directive', 'the line claims a directive and parses as none', path)
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
}
function codeBlockNode(language: string, text: string): AdfNode {
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] }