Answer the architecture pass: the seam attribute reading names, and the speller a third file copied
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-01 22:11:21 +02:00
parent 01faa71914
commit f4e4d77fc1
12 changed files with 59 additions and 41 deletions
@@ -98,7 +98,7 @@
{
"content": [
{
"text": "Sold as a pair",
"text": "\fSold as a pair\u000b",
"type": "text"
}
],
+1 -1
View File
@@ -1,6 +1,6 @@
| Part | Note |
| --- | --- |
| Bolt M8 | Grade `8.8` |
| Nut \| washer | Sold as a pair |
| Nut \| washer | Sold as a pair |
| Washer M8 | 100 pcs:hardBreak{}zinc-plated |
| Spare | |
+1 -1
View File
@@ -13,7 +13,7 @@
"node": ">=18"
},
"scripts": {
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.8 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
},
"devDependencies": {
+4
View File
@@ -87,6 +87,10 @@ export function spellJsonAttribute(value: JsonValue): string {
return quote(serializeCanonicalJson(value, 'compact'))
}
export function spellLeafDirective(name: string, attributes: string): string {
return `:${name}${attributes === '' ? '{}' : attributes}`
}
export function spellStringAttribute(text: string): string {
return isBareToken(text) ? text : quote(text)
}
+1 -4
View File
@@ -12,6 +12,7 @@ import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { spellDestination, spellTitle } from '../link-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellLeafDirective } from '../directive-syntax.ts'
import { spellTextDirective } from '../text-directive.ts'
type EmittedLine = { line: string; segments: InlineSegment[] }
@@ -121,10 +122,6 @@ function carriedText(text: string): InlineSegment {
return syntax(spellTextDirective(text))
}
function spellLeafDirective(name: string, attributes: string): string {
return `:${name}${attributes === '' ? '{}' : attributes}`
}
function syntax(text: string): InlineSegment {
return { escaping: 'none', text }
}
@@ -0,0 +1,31 @@
import type { AdfAttributes } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts'
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
export type Elsewhere = { key: string; slot: 'argument' | 'content' }
export function readVocabulary(
type: string,
attributes: DirectiveAttributes,
vocabulary: AttributeVocabulary,
elsewhere: Elsewhere | undefined,
path: ConvertErrorPath,
): Result<AdfAttributes> {
const attrs: AdfAttributes = {}
for (const [key, spelled] of attributes) {
if (key === elsewhere?.key) {
const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot'
return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}`, path)
}
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute`, path)
const read = attributeValue(spelled.decoded, kind)
if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
const spelling = spellAttributeValue(read)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
attrs[key] = read.value
}
return success(attrs)
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { DirectiveAttributes } from '../directive-syntax.ts'
import type { MarkSpelling } from '../mark-spellings.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { markSpelling } from '../mark-spellings.ts'
import { readVocabulary } from './directive-nodes.ts'
import { readVocabulary } from './directive-attributes.ts'
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
const spelling = markSpelling(name)
+2 -27
View File
@@ -1,7 +1,7 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import type { BlockDirective } from '../../adf/block-directives.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeValue, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts'
@@ -9,11 +9,10 @@ import { carryName } from '../opaque-carry.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
import { readVocabulary } from './directive-attributes.ts'
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
type Elsewhere = { key: string; slot: 'argument' | 'content' }
export function readBlockDirectiveNode(
name: string,
argument: string | undefined,
@@ -70,30 +69,6 @@ function slotText(content: readonly AdfNode[]): string | undefined {
return only.text
}
export function readVocabulary(
type: string,
attributes: DirectiveAttributes,
vocabulary: AttributeVocabulary,
elsewhere: Elsewhere | undefined,
path: ConvertErrorPath,
): Result<AdfAttributes> {
const attrs: AdfAttributes = {}
for (const [key, spelled] of attributes) {
if (key === elsewhere?.key) {
const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot'
return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}`, path)
}
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute`, path)
const read = attributeValue(spelled.decoded, kind)
if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
const spelling = spellAttributeValue(read)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
attrs[key] = read.value
}
return success(attrs)
}
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
const read = attributeValue(spelled.decoded, 'json')
const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value)
@@ -697,6 +697,9 @@ test('names the content slot no one unmarked text node reads back from', () => {
assert.equal(code(markdownToAdf(':status[:date{timestamp=1}]{color=yellow}\n')), 'unsupported-node-shape')
assert.equal(content(markdownToAdf(':status[![a](/u)]{color=yellow}\n')), 'unmappable-image: an image fits only as a paragraph of its own')
assert.equal(code(markdownToAdf(':status[<div>]{color=yellow}\n')), 'unmappable-html')
// The slot parses before the name's table is consulted, so a doubly-broken span reports its inner error.
assert.equal(code(markdownToAdf(':date[<div>]{timestamp=1}\n')), 'unmappable-html')
assert.equal(code(markdownToAdf(':widget[<div>]\n')), 'unmappable-html')
assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot')
})
+2 -2
View File
@@ -1,12 +1,12 @@
import type { ConvertFault } from '../result.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts'
import { spellAttributes, spellStringAttribute } from './directive-syntax.ts'
import { spellAttributes, spellLeafDirective, spellStringAttribute } from './directive-syntax.ts'
const name = 'text'
const whitespaceRun = /^(?:[ \t]+|\n+)$/
export function spellTextDirective(text: string): string {
return `:${name}${spellAttributes([[name, spellStringAttribute(text)]])}`
return spellLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]]))
}
export function readTextDirective(span: DirectiveSpan): Read<string> | undefined {
+2 -1
View File
@@ -357,7 +357,8 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
CommonMark carries plainly, are named errors, as 3g refuses the directive form of a node
CommonMark spells. The reader takes the slot's parsed nodes rather than its text, so the
rule refusing anything but one unmarked text node sits beside the node tables that own the
slot, and a node taking no content still names that first.
slot, and a node taking no content still names that first. `directive-content-slot` stays
with the fixtures, its cause now a marked slot rather than a slot at all.
The same read found the hole the other way: `attemptLine` refused a line edged with a
vertical tab or a form feed, where CommonMark strips spaces and tabs alone, so valid
CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered
+10 -3
View File
@@ -55,7 +55,10 @@ The numbering is the order the work was planned in, not the order it ships.
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
named error, a carry inside a mark spelling another, and the three carve-outs' escapes
reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a
`codeBlock` from it; the refusal goes when the carry reads it.
`codeBlock` from it; the refusal goes when the carry reads it. 3i left the slot parse
contextless, so the refusal a carry inside a mark spelling earns needs a channel — a reader
context in place of `parseInline`'s `strip` flag, or a return arm from the slot — and
`directiveNodes` takes its fourth reader beside it.
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
@@ -109,8 +112,12 @@ The numbering is the order the work was planned in, not the order it ships.
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
999-capped `readLabel` value, and two in `emit/inline-line.ts`. The fix is the one 3h used —
an index walk, `trimTrailingSpace` where the ends match. §11's scanning rule is the whole
argument; the pipeline persona feeds documents nobody typed.
an index walk, `trimTrailingSpace` where the ends match. A fourth of another shape joins
them: `readNestedDirective` restarts its depth counter per level, so each parse level
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
nobody typed.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: 3h
landed the last decision `corpus/unspellable/` held and the directory went with it, so what