Settle one failure policy for the trial spellings, and lift the attribute vocabulary above the markdown layer
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -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.
|
||||
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
||||
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
|
||||
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
|
||||
waits near 2000.
|
||||
|
||||
@@ -4,6 +4,8 @@ export type AdfAttributes = { [key: string]: JsonValue }
|
||||
|
||||
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
|
||||
|
||||
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
|
||||
|
||||
export type AdfMark = {
|
||||
attrs?: AdfAttributes
|
||||
type: string
|
||||
|
||||
+11
-7
@@ -2,9 +2,9 @@ import type { AdfDocument, AdfNode } from './adf-document.ts'
|
||||
import type { BlockDirective } from './block-directives.ts'
|
||||
import type { JsonValue } from './json-value.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 { emitPipeTable } from './markdown-pipe-table.ts'
|
||||
import { tryPipeTable } from './markdown-pipe-table.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.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> {
|
||||
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> {
|
||||
@@ -131,15 +135,15 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
|
||||
}
|
||||
|
||||
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)
|
||||
return commonMarkLine(image)
|
||||
return success(commonMarkText(image))
|
||||
}
|
||||
|
||||
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)
|
||||
return commonMarkLine(pipe)
|
||||
return success(commonMarkText(pipe))
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||
import type { AttributeVocabulary } from './directive-attributes.ts'
|
||||
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from './result.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> {
|
||||
const argument = spellArgument(node, directive, path)
|
||||
if (!argument.ok) return argument
|
||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
|
||||
if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path)
|
||||
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
|
||||
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
|
||||
const marks = node.marks ?? []
|
||||
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
||||
const attributes = spellAttributes(pairs)
|
||||
if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
||||
const attributes = spellAttributes(spelled.pairs)
|
||||
return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { failure, type ConvertErrorPath, type Result } from './result.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
|
||||
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_-]+$/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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][] = []
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === slot) continue
|
||||
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)
|
||||
if (spelled === undefined) return { key, kind }
|
||||
if (spelled === undefined) return { fault: { key, kind } }
|
||||
pairs.push([key, spelled])
|
||||
}
|
||||
return pairs
|
||||
return { pairs }
|
||||
}
|
||||
|
||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||
|
||||
+13
-11
@@ -1,5 +1,5 @@
|
||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||
import type { AttributeVocabulary } from './directive-attributes.ts'
|
||||
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
||||
import type { AttributeFault } from './directive-attributes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from './result.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> {
|
||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
|
||||
if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path)
|
||||
return success(spellAttributes(pairs))
|
||||
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
|
||||
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
|
||||
return success(spellAttributes(spelled.pairs))
|
||||
}
|
||||
|
||||
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<string> {
|
||||
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
|
||||
if (!Array.isArray(pairs)) {
|
||||
if (pairs.kind === undefined) return failure('unspellable-mark', `the ${mark.type} spelling holds no ${pairs.key} attribute`, path)
|
||||
return failure('unspellable-mark', `the ${mark.type} attribute ${pairs.key} holds no ${pairs.kind}`, path)
|
||||
}
|
||||
return success(spellAttributes(pairs))
|
||||
const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
|
||||
if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path)
|
||||
return success(spellAttributes(spelled.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)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { AdfNode } from './adf-document.ts'
|
||||
import type { ConvertErrorPath, Result } from './result.ts'
|
||||
import { emitImageLine } from './markdown-inline.ts'
|
||||
import type { ConvertErrorPath } from './result.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
import { tryImageLine } from './markdown-inline.ts'
|
||||
|
||||
const centeredMediaSingle = '{"layout":"center"}'
|
||||
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)
|
||||
if (image === undefined) return undefined
|
||||
const line = emitImageLine(image.alt, image.url, [...path, 'content', 0])
|
||||
return line.ok ? line : undefined
|
||||
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
|
||||
}
|
||||
|
||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||
|
||||
+18
-16
@@ -7,7 +7,7 @@ import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullChara
|
||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||
import { longestBacktickRun } from './backtick-runs.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'
|
||||
|
||||
@@ -29,23 +29,21 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
|
||||
return finishLine(segments.value, container, path)
|
||||
}
|
||||
|
||||
// undefined where the cell holds a pipe no backslash reaches, leaving the table its directive form.
|
||||
export function emitPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> | undefined {
|
||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
||||
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
|
||||
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> {
|
||||
if (alt !== undefined && /^[ \t]|[ \t]$|[\n\r]/.test(alt)) {
|
||||
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)
|
||||
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
||||
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||
const destination = spellDestination(href, path)
|
||||
if (!destination.ok) return destination
|
||||
if (!destination.ok) return undefined
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||
return finishLine([syntax('`)], 'paragraph', path)
|
||||
const line = finishLine([syntax('`)], 'paragraph', path)
|
||||
return line.ok ? line.value : undefined
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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[] {
|
||||
const carried: InlineSegment[] = []
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
@@ -98,7 +96,11 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -164,7 +166,7 @@ function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertError
|
||||
const empty = refuseContentAndText(node, path)
|
||||
if (!empty.ok) return empty
|
||||
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[]> {
|
||||
@@ -173,7 +175,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: Co
|
||||
const attributes = spellInlineNodeAttributes(node, directive, path)
|
||||
if (!attributes.ok) return attributes
|
||||
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 (/[\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)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdfNode } from './adf-document.ts'
|
||||
import { emitPipeCell } from './markdown-inline.ts'
|
||||
import { success, type ConvertErrorPath, type Result } from './result.ts'
|
||||
import { tryPipeCell } from './markdown-inline.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)
|
||||
if (rows === undefined) return undefined
|
||||
const lines: string[] = []
|
||||
@@ -10,15 +10,14 @@ export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<str
|
||||
const cells: string[] = []
|
||||
for (const [cellIndex, paragraph] of row.entries()) {
|
||||
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.ok) return line
|
||||
cells.push(line.value)
|
||||
cells.push(line)
|
||||
}
|
||||
lines.push(`| ${cells.join(' | ')} |`)
|
||||
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
|
||||
}
|
||||
return success(lines.join('\n'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||
|
||||
@@ -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
|
||||
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/`.
|
||||
- [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment.kind` splits into
|
||||
its two axes here — escapability (`attribute` for `:text{text="…"}`, `backslash`, `none`)
|
||||
and the emphasis role — rather than gaining a third value that means one of each. A lone
|
||||
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break
|
||||
plain text still holds open — attribute values already escape it. The inline directives
|
||||
arriving here emit their attribute values as syntax, so `spellsPipeAsSyntax` grows with
|
||||
them alongside the `|` escaping `spec/flavour.md` already mandates in a pipe cell.
|
||||
- [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
|
||||
two axes — escapability (`attribute` for `:text{text="…"}`, `backslash`, `bracketed`,
|
||||
`none`) and the emphasis role. A lone surrogate in a text node emits verbatim and becomes
|
||||
U+FFFD on any UTF-8 encode, a §2 break plain text still holds open — attribute values
|
||||
already escape it. The pipe form's fallback reads the emitted segments rather than naming
|
||||
the nodes whose attribute values spell a pipe as syntax, so 2e's `\u007c` narrows it in
|
||||
one place.
|
||||
- [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into
|
||||
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `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`).
|
||||
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.
|
||||
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
|
||||
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
|
||||
@@ -108,11 +115,17 @@ detail is settled at its own milestone.
|
||||
assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin.
|
||||
`src/` gets its hierarchy at the same split — `adf/`,
|
||||
`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
|
||||
table is ADF knowledge milestones 6-7 need too and belongs in `adf/`, `spellDirectiveHeader`
|
||||
in `markdown/`. `AttributeKind` stays above both — it is the vocabulary a string-typed
|
||||
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,
|
||||
and a mistyped attribute name degrades into a false refusal no test catches.
|
||||
still mechanical. Three files do not move whole: `block-directives.ts` and
|
||||
`inline-directives.ts` each hold a node table milestones 6-7 need in `adf/` beside a
|
||||
markdown spelling that belongs in `markdown/`, and `directive-attributes.ts` fuses the
|
||||
format-neutral conformance walk (`vocabularyPairs`) with the markdown value spelling HTML
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user