Emitter 2c: the inline node directives, the directive marks and the carried whitespace #13

Merged
lilleman merged 4 commits from inline-nodes into main 2026-08-26 09:49:03 +02:00
10 changed files with 93 additions and 70 deletions
Showing only changes of commit b196132dae - Show all commits
+3
View File
@@ -111,6 +111,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
`Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
the spot. the spot.
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell —
returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the
general form owns the refusal. Refusing there refuses a document the general form spells.
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
waits near 2000. waits near 2000.
+2
View File
@@ -4,6 +4,8 @@ export type AdfAttributes = { [key: string]: JsonValue }
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
export type AdfMark = { export type AdfMark = {
attrs?: AdfAttributes attrs?: AdfAttributes
type: string type: string
+11 -7
View File
@@ -2,9 +2,9 @@ import type { AdfDocument, AdfNode } from './adf-document.ts'
import type { BlockDirective } from './block-directives.ts' import type { BlockDirective } from './block-directives.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from './json-value.ts'
import { blockDirective, spellDirectiveHeader } from './block-directives.ts' import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
import { emitImage } from './markdown-image.ts' import { tryImage } from './markdown-image.ts'
import { emitInlineLine } from './markdown-inline.ts' import { emitInlineLine } from './markdown-inline.ts'
import { emitPipeTable } from './markdown-pipe-table.ts' import { tryPipeTable } from './markdown-pipe-table.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.ts' import { isAdfDocument } from './adf-document.ts'
@@ -98,7 +98,11 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
function commonMarkLine(text: Result<string>): Result<EmittedBlock> { function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
if (!text.ok) return text if (!text.ok) return text
return success({ fenceColons: 0, spelling: 'commonmark', text: text.value }) return success(commonMarkText(text.value))
}
function commonMarkText(text: string): EmittedBlock {
return { fenceColons: 0, spelling: 'commonmark', text }
} }
function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> { function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> {
@@ -131,15 +135,15 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
} }
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const image = emitImage(node, path) const image = tryImage(node, path)
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth) if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
return commonMarkLine(image) return success(commonMarkText(image))
} }
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const pipe = emitPipeTable(node, path) const pipe = tryPipeTable(node, path)
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth) if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
return commonMarkLine(pipe) return success(commonMarkText(pipe))
} }
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
+5 -6
View File
@@ -1,5 +1,4 @@
import type { AdfMark, AdfNode } from './adf-document.ts' import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
import type { AttributeVocabulary } from './directive-attributes.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from './json-value.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts' import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts'
@@ -84,11 +83,11 @@ export function blockDirective(type: string): BlockDirective | undefined {
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> { export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
const argument = spellArgument(node, directive, path) const argument = spellArgument(node, directive, path)
if (!argument.ok) return argument if (!argument.ok) return argument
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument) const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path) if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
const marks = node.marks ?? [] const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(pairs) const attributes = spellAttributes(spelled.pairs)
return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`) return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`)
} }
+6 -6
View File
@@ -1,11 +1,11 @@
import type { AdfAttributes, AttributeKind } from './adf-document.ts' import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from './json-value.ts'
import { failure, type ConvertErrorPath, type Result } from './result.ts' import { failure, type ConvertErrorPath, type Result } from './result.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
export type AttributeFault = { key: string; kind: AttributeKind | undefined } export type AttributeFault = { key: string; kind: AttributeKind | undefined }
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>> export type SpelledPairs = { fault: AttributeFault; pairs?: undefined } | { fault?: undefined; pairs: [string, string][] }
const bareToken = /^[A-Za-z0-9_-]+$/ const bareToken = /^[A-Za-z0-9_-]+$/
@@ -18,17 +18,17 @@ export function attributeFailure<T>(type: string, fault: AttributeFault, path: C
return failure('unsupported-node-shape', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path) return failure('unsupported-node-shape', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path)
} }
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): AttributeFault | [string, string][] { export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): SpelledPairs {
const pairs: [string, string][] = [] const pairs: [string, string][] = []
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs)) {
if (key === slot) continue if (key === slot) continue
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return { key, kind: undefined } if (kind === undefined) return { fault: { key, kind: undefined } }
const spelled = spellAttributeValue(value, kind) const spelled = spellAttributeValue(value, kind)
if (spelled === undefined) return { key, kind } if (spelled === undefined) return { fault: { key, kind } }
pairs.push([key, spelled]) pairs.push([key, spelled])
} }
return pairs return { pairs }
} }
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
+12 -10
View File
@@ -1,5 +1,5 @@
import type { AdfMark, AdfNode } from './adf-document.ts' import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
import type { AttributeVocabulary } from './directive-attributes.ts' import type { AttributeFault } from './directive-attributes.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { attributeFailure, spellAttributes, vocabularyPairs } from './directive-attributes.ts' import { attributeFailure, spellAttributes, vocabularyPairs } from './directive-attributes.ts'
@@ -45,16 +45,18 @@ export function markDirective(type: string): AttributeVocabulary | undefined {
} }
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<string> { export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<string> {
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot) const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path) if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
return success(spellAttributes(pairs)) return success(spellAttributes(spelled.pairs))
} }
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<string> { export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<string> {
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined) const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
if (!Array.isArray(pairs)) { if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path)
if (pairs.kind === undefined) return failure('unspellable-mark', `the ${mark.type} spelling holds no ${pairs.key} attribute`, path) return success(spellAttributes(spelled.pairs))
return failure('unspellable-mark', `the ${mark.type} attribute ${pairs.key} holds no ${pairs.kind}`, path)
} }
return success(spellAttributes(pairs))
function markFailure<T>(type: string, fault: AttributeFault, path: ConvertErrorPath): Result<T> {
if (fault.kind === undefined) return failure('unspellable-mark', `the ${type} spelling holds no ${fault.key} attribute`, path)
return failure('unspellable-mark', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path)
} }
+4 -5
View File
@@ -1,16 +1,15 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from './adf-document.ts'
import type { ConvertErrorPath, Result } from './result.ts' import type { ConvertErrorPath } from './result.ts'
import { emitImageLine } from './markdown-inline.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
import { tryImageLine } from './markdown-inline.ts'
const centeredMediaSingle = '{"layout":"center"}' const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url'] const imageAttributes = ['alt', 'type', 'url']
export function emitImage(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined { export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
const image = imageShape(node) const image = imageShape(node)
if (image === undefined) return undefined if (image === undefined) return undefined
const line = emitImageLine(image.alt, image.url, [...path, 'content', 0]) return tryImageLine(image.alt, image.url, [...path, 'content', 0])
return line.ok ? line : undefined
} }
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
+18 -16
View File
@@ -7,7 +7,7 @@ import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullChara
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { longestBacktickRun } from './backtick-runs.ts' import { longestBacktickRun } from './backtick-runs.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
import { spellStringAttribute } from './directive-attributes.ts' import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
type Brackets = 'directive' | 'link' | 'none' type Brackets = 'directive' | 'link' | 'none'
@@ -29,23 +29,21 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
return finishLine(segments.value, container, path) return finishLine(segments.value, container, path)
} }
// undefined where the cell holds a pipe no backslash reaches, leaving the table its directive form. export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
export function emitPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> | undefined {
const segments = lineSegments(nodes, 'table-cell', path) const segments = lineSegments(nodes, 'table-cell', path)
if (!segments.ok) return segments if (!segments.ok) return undefined
if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
return finishLine(segments.value, 'table-cell', path) const line = finishLine(segments.value, 'table-cell', path)
return line.ok ? line.value : undefined
} }
export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result<string> { export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
if (alt !== undefined && /^[ \t]|[ \t]$|[\n\r]/.test(alt)) { if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
return failure('unspellable-whitespace', 'a media alt holds whitespace no image description spells', path)
}
if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path)
const destination = spellDestination(href, path) const destination = spellDestination(href, path)
if (!destination.ok) return destination if (!destination.ok) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
return finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) const line = finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path)
return line.ok ? line.value : undefined
} }
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> { function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> {
@@ -71,7 +69,7 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer
return success(line) return success(line)
} }
// spec/flavour.md, Inline nodes: whitespace CommonMark strips at a line edge rides the reserved text directive. // spec/flavour.md, Inline nodes: whitespace CommonMark strips.
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] { function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const carried: InlineSegment[] = [] const carried: InlineSegment[] = []
for (const [index, segment] of segments.entries()) { for (const [index, segment] of segments.entries()) {
@@ -98,7 +96,11 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean)
} }
function carriedText(text: string): InlineSegment { function carriedText(text: string): InlineSegment {
return { escaping: 'attribute', text: `:text{text=${spellStringAttribute(text)}}` } return { escaping: 'attribute', text: spellLeafDirective('text', spellAttributes([['text', spellStringAttribute(text)]])) }
}
function spellLeafDirective(name: string, attributes: string): string {
return `:${name}${attributes === '' ? '{}' : attributes}`
} }
function syntax(text: string): InlineSegment { function syntax(text: string): InlineSegment {
@@ -164,7 +166,7 @@ function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertError
const empty = refuseContentAndText(node, path) const empty = refuseContentAndText(node, path)
if (!empty.ok) return empty if (!empty.ok) return empty
if (context.container === 'paragraph' && !context.atBlockEnd && context.brackets !== 'directive') return success([syntax('\\\n')]) if (context.container === 'paragraph' && !context.atBlockEnd && context.brackets !== 'directive') return success([syntax('\\\n')])
return success([syntax(':hardBreak{}')]) return success([syntax(spellLeafDirective('hardBreak', ''))])
} }
function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<InlineSegment[]> { function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<InlineSegment[]> {
@@ -173,7 +175,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: Co
const attributes = spellInlineNodeAttributes(node, directive, path) const attributes = spellInlineNodeAttributes(node, directive, path)
if (!attributes.ok) return attributes if (!attributes.ok) return attributes
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot] const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
if (slot === undefined) return success([syntax(`:${node.type}${attributes.value === '' ? '{}' : attributes.value}`)]) if (slot === undefined) return success([syntax(spellLeafDirective(node.type, attributes.value))])
if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, path) if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, path)
if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path) if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path) if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
+6 -7
View File
@@ -1,8 +1,8 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from './adf-document.ts'
import { emitPipeCell } from './markdown-inline.ts' import { tryPipeCell } from './markdown-inline.ts'
import { success, type ConvertErrorPath, type Result } from './result.ts' import type { ConvertErrorPath } from './result.ts'
export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined { export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
const rows = pipeRows(node) const rows = pipeRows(node)
if (rows === undefined) return undefined if (rows === undefined) return undefined
const lines: string[] = [] const lines: string[] = []
@@ -10,15 +10,14 @@ export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<str
const cells: string[] = [] const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) { for (const [cellIndex, paragraph] of row.entries()) {
const content = paragraph.content ?? [] const content = paragraph.content ?? []
const line = content.length === 0 ? success('') : emitPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
if (line === undefined) return undefined if (line === undefined) return undefined
if (!line.ok) return line cells.push(line)
cells.push(line.value)
} }
lines.push(`| ${cells.join(' | ')} |`) lines.push(`| ${cells.join(' | ')} |`)
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`) if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
} }
return success(lines.join('\n')) return lines.join('\n')
} }
function pipeRows(node: AdfNode): AdfNode[][] | undefined { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
+25 -12
View File
@@ -71,13 +71,13 @@ detail is settled at its own milestone.
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`, the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`. a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
- [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment.kind` splits into - [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
its two axes here — escapability (`attribute` for `:text{text="…"}`, `backslash`, `none`) two axes — escapability (`attribute` for `:text{text="…"}`, `backslash`, `bracketed`,
and the emphasis role — rather than gaining a third value that means one of each. A lone `none`) and the emphasis role. A lone surrogate in a text node emits verbatim and becomes
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break U+FFFD on any UTF-8 encode, a §2 break plain text still holds open — attribute values
plain text still holds open — attribute values already escape it. The inline directives already escape it. The pipe form's fallback reads the emitted segments rather than naming
arriving here emit their attribute values as syntax, so `spellsPipeAsSyntax` grows with the nodes whose attribute values spell a pipe as syntax, so 2e's `\u007c` narrows it in
them alongside the `|` escaping `spec/flavour.md` already mandates in a pipe cell. one place.
- [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into - [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf` `corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
info string, and the `codeBlock` whose language is `adf`. info string, and the `codeBlock` whose language is `adf`.
@@ -90,6 +90,13 @@ detail is settled at its own milestone.
paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`). paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`).
Guard `fenceNestingFault`'s bare-run pop here too — a run shorter than the open fence is a Guard `fenceNestingFault`'s bare-run pop here too — a run shorter than the open fence is a
fault, not a close — which today's emitter cannot reach. fault, not a close — which today's emitter cannot reach.
Two moves land before the attribute spelling changes. One mark vocabulary:
`emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
`inline-directives.ts`, which holds four of the nine marks while the rest are branch
literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named
error `spec/flavour.md` promises. And `escaping: 'attribute'` earns its keep at the
`\u007c` rule or collapses into `none`: nothing the escaper does tells the two apart
today, since a carried segment holds only spaces, tabs and newlines.
The gate gains the collision property here: no two corpus documents may emit the same The gate gains the collision property here: no two corpus documents may emit the same
bytes — one spelling for two documents is a round-trip break no parser can undo, and it is bytes — one spelling for two documents is a round-trip break no parser can undo, and it is
provable without one. It also settles the emitter's one known approximation: delimiter provable without one. It also settles the emitter's one known approximation: delimiter
@@ -108,11 +115,17 @@ detail is settled at its own milestone.
assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin. assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin.
`src/` gets its hierarchy at the same split — `adf/`, `src/` gets its hierarchy at the same split — `adf/`,
`markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is `markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is
still mechanical. `block-directives.ts` is the one file that does not move whole: the node still mechanical. Three files do not move whole: `block-directives.ts` and
table is ADF knowledge milestones 6-7 need too and belongs in `adf/`, `spellDirectiveHeader` `inline-directives.ts` each hold a node table milestones 6-7 need in `adf/` beside a
in `markdown/`. `AttributeKind` stays above both — it is the vocabulary a string-typed markdown spelling that belongs in `markdown/`, and `directive-attributes.ts` fuses the
attribute grammar needs, which is why HTML will want it too, not a markdown spelling. The table is a second copy of `spec/flavour.md`'s prose with no drift guard, format-neutral conformance walk (`vocabularyPairs`) with the markdown value spelling HTML
and a mistyped attribute name degrades into a false refusal no test catches. has no use for. `spellDestination`, `spellTitle` and `balanced` leave `markdown-inline.ts`
here too — CommonMark destination spelling `emitLink` and `tryImageLine` share, and the six
concerns that file carries are one fewer for it. `AttributeKind` and `AttributeVocabulary`
stay above all of it — the vocabulary a string-typed attribute grammar needs, which is why
HTML will want them too, not a markdown spelling. Both node tables are a second copy of
`spec/flavour.md`'s prose with no drift guard, and a mistyped attribute name degrades into a
false refusal no test catches.
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and - [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and
3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality 3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality
the round-trip asserts, which over normalized input is the canonical serializer's compact the round-trip asserts, which over normalized input is the canonical serializer's compact