diff --git a/corpus/round-trip/block-nodes/table-pipe.json b/corpus/round-trip/block-nodes/table-pipe.json index e0cb469..bb50b24 100644 --- a/corpus/round-trip/block-nodes/table-pipe.json +++ b/corpus/round-trip/block-nodes/table-pipe.json @@ -98,7 +98,7 @@ { "content": [ { - "text": "Sold as a pair", + "text": "\fSold as a pair\u000b", "type": "text" } ], diff --git a/corpus/round-trip/block-nodes/table-pipe.md b/corpus/round-trip/block-nodes/table-pipe.md index f69b148..ab857c7 100644 --- a/corpus/round-trip/block-nodes/table-pipe.md +++ b/corpus/round-trip/block-nodes/table-pipe.md @@ -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 | | diff --git a/package.json b/package.json index cacfab5..706336c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index c5aa55c..d1fb73c 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -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) } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index d09f333..37e4f10 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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 } } diff --git a/src/markdown/parse/directive-attributes.ts b/src/markdown/parse/directive-attributes.ts new file mode 100644 index 0000000..cfcbdc4 --- /dev/null +++ b/src/markdown/parse/directive-attributes.ts @@ -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 { + 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) +} diff --git a/src/markdown/parse/directive-marks.ts b/src/markdown/parse/directive-marks.ts index 83b67af..a27b991 100644 --- a/src/markdown/parse/directive-marks.ts +++ b/src/markdown/parse/directive-marks.ts @@ -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 | undefined { const spelling = markSpelling(name) diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 3c3a31d..9b2970d 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -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 { - 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 { const read = attributeValue(spelled.decoded, 'json') const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 1b0fa08..ebf844c 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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[
]{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[
]{timestamp=1}\n')), 'unmappable-html') + assert.equal(code(markdownToAdf(':widget[
]\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') }) diff --git a/src/markdown/text-directive.ts b/src/markdown/text-directive.ts index d168582..63ab681 100644 --- a/src/markdown/text-directive.ts +++ b/src/markdown/text-directive.ts @@ -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 | undefined { diff --git a/todo-history.md b/todo-history.md index 3534b8a..e9179e0 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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 diff --git a/todo.md b/todo.md index b811278..6198cc1 100644 --- a/todo.md +++ b/todo.md @@ -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