Merge pull request '3j: the carry and the combinations read back' (#42) from carry-read-back into main
CI / gate (push) Successful in 9s

This commit was merged in pull request #42.
This commit is contained in:
2026-09-03 08:10:21 +02:00
25 changed files with 270 additions and 92 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect.
**Status: pre-release — `adfToMarkdown` only, and not yet every document.**
**Status: pre-release — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML not
yet.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](spec/flavour.md).
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
_a :adf{json="{\"type\":\"blockCard\"}"} b_
@@ -0,0 +1 @@
malformed-directive
@@ -0,0 +1 @@
:adf{json="{"}
+1
View File
@@ -0,0 +1 @@
malformed-directive
+3
View File
@@ -0,0 +1,3 @@
```adf
{"type":
```
+1
View File
@@ -0,0 +1 @@
unsupported-nesting-depth
+1
View File
@@ -0,0 +1 @@
:adf{json="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
+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.8 --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.9 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
},
"devDependencies": {
+4 -2
View File
@@ -135,8 +135,10 @@ literal-text fallback — a typo that reparses as prose is the silent loss §2 r
## The opaque carry (AGENTS.md §3)
A node no section spells where it stands — an unknown type, or a known one whose spelling belongs
to the other position — rides as its raw JSON and restores to a deep-equal node. Block and inline
positions canonicalize differently, each fitting where it sits:
to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold
a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it
canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting
where it sits:
- **Block position**: a fenced code block with info string `adf`, body = the node's JSON —
two-space indent, object keys sorted.
+4
View File
@@ -37,6 +37,10 @@ export function isAdfDocument(value: unknown): value is AdfDocument {
return !('content' in value) || isNodeArray(value['content'])
}
export function isAdfNode(value: unknown): value is AdfNode {
return isNodeArray([value])
}
export function isAdfMark(value: unknown): value is AdfMark {
if (!isRecord(value) || !holdsOnly(value, markKeys)) return false
if (typeof value['type'] !== 'string') return false
+7 -16
View File
@@ -15,9 +15,7 @@ const errorsRoot = join(corpusRoot, 'errors')
const normalizationRoot = join(corpusRoot, 'normalization')
const roundTripRoot = join(corpusRoot, 'round-trip')
const emittingDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
// A directory joins once every fixture in it reads back to its document.
const parsingDirectories = ['block-nodes', 'commonmark-subset', 'inline-nodes']
const roundTripDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
function directoryNames(root: string): string[] {
return readdirSync(root, { withFileTypes: true })
@@ -63,11 +61,11 @@ test('every corpus directory is a kind the runner reads', () => {
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip'])
})
test('every round-trip directory emits', () => {
assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort())
test('every round-trip directory is a kind the runner reads', () => {
assert.deepEqual(directoryNames(roundTripRoot), [...roundTripDirectories].sort())
})
for (const directory of emittingDirectories) {
for (const directory of roundTripDirectories) {
const names = [...new Set([...fixtureNames(directory, '.json'), ...fixtureNames(directory, '.md')])].sort()
test(`${directory} pairs every .json with a .md`, () => {
@@ -89,14 +87,7 @@ for (const directory of emittingDirectories) {
}
}
test('every parsing directory is one of the emitting directories', () => {
assert.deepEqual(
parsingDirectories.filter((directory) => emittingDirectories.includes(directory)),
parsingDirectories,
)
})
for (const directory of parsingDirectories) {
for (const directory of roundTripDirectories) {
for (const name of fixtureNames(directory, '.md')) {
test(`${directory}/${name} reads its markdown back to the document beside it`, () => {
const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
@@ -109,7 +100,7 @@ for (const directory of parsingDirectories) {
}
function roundTripFixtures(): { name: string; path: string }[] {
return emittingDirectories.flatMap((directory) =>
return roundTripDirectories.flatMap((directory) =>
fixtureNames(directory, '.json').map((name) => ({ name: `${directory}/${name}`, path: join(roundTripRoot, directory, `${name}.json`) })),
)
}
@@ -178,7 +169,7 @@ test('the fence nesting check catches a fence a container cannot hold', () => {
assert.equal(fenceNestingFault(':::tableCell\n```text\n:::::::panel warning\n:::\n```\n:::'), undefined)
})
for (const directory of emittingDirectories) {
for (const directory of roundTripDirectories) {
for (const name of fixtureNames(directory, '.md')) {
test(`${directory}/${name} fences every container longer than its body`, () => {
assert.equal(fenceNestingFault(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')), undefined)
+1
View File
@@ -3,3 +3,4 @@ export type { ConvertError, ConvertErrorCode, Result } from './result.ts'
export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
+2 -2
View File
@@ -2,13 +2,13 @@ import { largestNesting } from './nesting.ts'
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
export function isJsonValue(value: unknown): value is JsonValue {
export function isJsonValue(value: unknown, levels: number = largestNesting): value is JsonValue {
const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }]
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
const { depth, item } = entry
if (depth > largestNesting) return false
if (depth > levels) return false
if (item === null || typeof item === 'boolean' || typeof item === 'string') continue
if (typeof item === 'number') {
if (!Number.isFinite(item)) return false
+13
View File
@@ -77,6 +77,15 @@ export function readInlineDirective(text: string, index: number): Read<Directive
return readNestedDirective(text, index, 1)
}
export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<string> {
if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content`) }
const spelled = span.attributes.get(key)
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone`) }
const spelling = spellStringAttribute(spelled.decoded)
if (spelling !== spelled.spelling) return { fault: unsupportedNodeShape(`${span.name} spells its ${key} attribute as ${key}=${spelling}`) }
return { value: spelled.decoded }
}
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined {
if (!/[\n\r]/.test(text)) return undefined
@@ -116,6 +125,10 @@ export function unknownDirectiveFault(name: string): ConvertFault {
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` }
}
export function unsupportedNodeShape(message: string): ConvertFault {
return { code: 'unsupported-node-shape', message }
}
function keyOrder(left: string, right: string): number {
if (left < right) return -1
return left > right ? 1 : 0
+7 -1
View File
@@ -167,11 +167,17 @@ test('breaks a mark run at the node it carries', () => {
)
})
test('refuses a carried node nested deeper than the emitter carries', () => {
test('refuses a carried node nested deeper than the levels its position leaves', () => {
let node: AdfNode = { type: 'blockCard' }
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' }
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth')
assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth')
let shallow: AdfNode = { type: 'blockCard' }
for (let depth = 0; depth < 200; depth += 1) shallow = { content: [shallow], type: 'blockCard' }
assert.ok(adfToMarkdown(document(shallow)).ok)
let quoted: AdfNode = shallow
for (let depth = 0; depth < 150; depth += 1) quoted = { content: [quoted], type: 'blockquote' }
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
})
test('refuses a node whose content model the canonical form cannot emit', () => {
+5 -5
View File
@@ -73,7 +73,7 @@ function interruptsParagraph(node: AdfNode): boolean {
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const directive = blockDirective(node.type)
if (directive === undefined) return commonMarkLine(carriedBlock(node, path))
if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const readable = readableBlock(node, path, depth)
if (readable !== undefined) return readable
return emitDirectiveBlock(node, directive, path, depth)
@@ -114,9 +114,9 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
const content = node.content ?? []
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
const header = spellDirectiveHeader(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) {
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
}
@@ -154,10 +154,10 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
}
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> {
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(node.attrs?.['language'])
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const text = codeBlockText(node, path)
if (!text.ok) return text
const info = slot.kind === 'fence' ? slot.info : ''
+49 -8
View File
@@ -1,29 +1,70 @@
import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellAttributes, spellStringAttribute } from './directive-syntax.ts'
export const carryName = 'adf'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'two-space', path)
const jsonAttribute = 'json'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const json = carriedJson(node, 'two-space', path, largestNesting - depth)
if (!json.ok) return json
return success(fencedCodeBlock(carryName, json.value))
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path)
const json = carriedJson(node, 'compact', path, largestNesting)
if (!json.ok) return json
return success(`:${carryName}${spellAttributes([['json', spellStringAttribute(json.value)]])}`)
return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`)
}
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
if (!isJsonValue(node)) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path)
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
return readCarriedJson(body, 'two-space', largestNesting - depth)
}
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
if (span.name !== carryName) return undefined
const spelled = readSoleStringAttribute(span, jsonAttribute)
if (spelled.fault !== undefined) return spelled
return readCarriedJson(spelled.value, 'compact', largestNesting)
}
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<string> {
if (!isJsonValue(node, levels)) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
}
return success(serializeCanonicalJson(node, spelling))
}
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read<AdfNode> {
const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
const { value } = parsed
if (!isJsonValue(value, levels)) {
// Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8).
if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
}
if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) }
}
if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON") }
return { value }
}
function parseJsonText(raw: string): { value: unknown } | undefined {
try {
const value: unknown = JSON.parse(raw)
return { value }
} catch {
return undefined
}
}
+53 -23
View File
@@ -9,12 +9,13 @@ import { failure, faulted, success, type ConvertErrorPath, type Result } from '.
import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts'
import { readInlineDirective } from '../directive-syntax.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts'
import { readTextDirective } from '../text-directive.ts'
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
@@ -24,6 +25,7 @@ type Pairing = EmphasisPairing<Run>
type Piece =
| Bracket
| { kind: 'carry'; node: AdfNode }
| { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] }
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
@@ -32,6 +34,9 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const imageAlone = 'an image fits only as a paragraph of its own'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
@@ -152,38 +157,44 @@ function readDirective(scan: Scan, index: number): Result<number> {
return success(index + 1)
}
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
const nodes = directiveNodes(scan, directive.value)
if (!nodes.ok) return nodes
const piece = directivePiece(scan, directive.value)
if (!piece.ok) return piece
flush(scan, false)
scan.pieces.push({ kind: 'nodes', nodes: nodes.value })
scan.pieces.push(piece.value)
return success(index + directive.value.length)
}
function directiveNodes(scan: Scan, span: DirectiveSpan): Result<AdfNode[]> {
function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
const carried = readCarriedInline(span)
if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success({ kind: 'carry', node: carried.value })
}
const text = readTextDirective(span)
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text !== undefined) return success([{ text: text.value, type: 'text' }])
const slot = slotNodes(scan, span.content)
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
const slot = slotContent(scan, span.content)
if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) {
if (!mark.ok) return mark
if (slot.value === undefined || slot.value.length === 0) {
if (slot.value === undefined || slot.value.nodes.length === 0) {
return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks`, scan.path)
}
return success(applyMark(slot.value, mark.value))
if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) })
}
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
if (!node.ok) return node
return success([node.value])
return success({ kind: 'nodes', nodes: [node.value] })
}
function slotNodes(scan: Scan, content: string | undefined): Result<AdfNode[] | undefined> {
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
if (content === undefined) return success(undefined)
const parsed = parseInline(content, scan.definitions, scan.path, false)
if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value.nodes)
return success(parsed.value)
}
function flush(scan: Scan, strip: boolean): void {
@@ -200,7 +211,13 @@ function assemble(scan: Scan): Result<InlineContent> {
const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
return success({ nodes: resolveNodes(scan.pieces) })
const nodes = resolveNodes(scan.pieces, scan.path)
if (!nodes.ok) return nodes
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
}
function holdsCarry(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'carry')
}
function holdsImage(pieces: readonly Piece[]): boolean {
@@ -286,7 +303,10 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text.
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
const nodes = resolveNodes(inner)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path)
if (!resolved.ok) return resolved
const nodes = resolved.value
if (nodes.length === 0) return success(false)
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
scan.pieces.length = at
@@ -298,15 +318,19 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
const alt = imageAlt(inner)
const resolved = imageAlt(inner, scan.path)
if (!resolved.ok) return resolved
const alt = resolved.value
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
scan.pieces.length = at
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
return success(null)
}
function imageAlt(inner: readonly Piece[]): string {
return resolveNodes(inner).map(altText).join('')
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
const nodes = resolveNodes(inner, path)
if (!nodes.ok) return nodes
return success(nodes.value.map(altText).join(''))
}
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
@@ -317,18 +341,20 @@ function altText(node: AdfNode): string {
return typeof spelled === 'string' ? spelled : (node.text ?? '')
}
function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result<AdfNode[]> {
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings)
markPairings(nodes, pairings)
return mergeAdjacentText(nodes.flat())
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
return success(mergeAdjacentText(nodes.flat()))
}
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
function pieceNodes(piece: Piece): AdfNode[] {
switch (piece.kind) {
case 'carry':
return [piece.node]
case 'image':
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
case 'nodes':
@@ -364,12 +390,16 @@ function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: reado
}
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
function markPairings(nodes: AdfNode[][], pairings: readonly Pairing[]): void {
function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: readonly Pairing[]): boolean {
for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) nodes[index] = applyMark(nodes[index] ?? [], mark)
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
if (pieces[index]?.kind === 'carry') return false
nodes[index] = applyMark(nodes[index] ?? [], mark)
}
}
return true
}
function markType(character: string, used: number): string {
if (character === '~') return 'strike'
+82 -1
View File
@@ -266,10 +266,91 @@ test('names the reserved carry name a block directive spells', () => {
const reserved = 'malformed-directive: the name adf is reserved for the opaque carry, whose block form is the fence'
assert.equal(content(markdownToAdf('::adf\n')), reserved)
assert.equal(content(markdownToAdf(':::adf\nx\n:::\n')), reserved)
assert.equal(content(markdownToAdf('```adf\nx\n```\n')), 'malformed-directive: the info string adf is reserved for the opaque carry')
assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }])
})
const carried = ':adf{json="{\\"type\\":\\"placeholder\\"}"}'
test('reads the adf fence back to the node its JSON holds', () => {
assert.deepEqual(content(markdownToAdf('```adf\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
{ attrs: { url: 'https://example.com/x' }, type: 'blockCard' },
])
})
test('reads the inline carry back to the node its json attribute holds', () => {
assert.deepEqual(content(markdownToAdf(`a ${carried} b\n`)), [
{ content: [text('a '), { type: 'placeholder' }, text(' b')], type: 'paragraph' },
])
})
test('names the invalid JSON no opaque carry holds', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
assert.equal(content(markdownToAdf('```adf\n{"type":\n```\n')), invalid)
assert.equal(content(markdownToAdf('```adf\n```\n')), invalid)
assert.equal(content(markdownToAdf(':adf{json="{"}\n')), invalid)
assert.equal(content(markdownToAdf(':adf{json=abc}\n')), invalid)
})
test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
assert.equal(content(markdownToAdf('```adf\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`)
assert.equal(content(markdownToAdf(':adf{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf(':adf{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
})
test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON"
assert.equal(content(markdownToAdf('```adf\n[]\n```\n')), node)
assert.equal(content(markdownToAdf(':adf{json=null}\n')), node)
assert.equal(content(markdownToAdf(':adf{json="{\\"kind\\":\\"x\\"}"}\n')), node)
})
test('names the shape the inline carry reads alone', () => {
assert.equal(content(markdownToAdf(':adf[x]{json="{}"}\n')), 'unsupported-node-shape: adf takes no content')
assert.equal(content(markdownToAdf(':adf{}\n')), 'unsupported-node-shape: adf holds one json attribute alone')
assert.equal(content(markdownToAdf(':adf{json="{}" localId=x}\n')), 'unsupported-node-shape: adf holds one json attribute alone')
assert.equal(content(markdownToAdf(':adf{json="null"}\n')), 'unsupported-node-shape: adf spells its json attribute as json=null')
})
test('holds a carried JSON value to the nesting its position leaves', () => {
const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}`
const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
const deeper = (levels: number): string => `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${levels} levels its position leaves`
assert.equal(content(markdownToAdf(`:adf{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal(
content(markdownToAdf(fence('', largestNesting + 1))),
"unsupported-node-shape: the opaque carry spells its node's JSON canonically: two-space indent, keys sorted",
)
assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1))
})
test('names the number no JSON spelling carries in an opaque carry', () => {
const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell'
assert.equal(content(markdownToAdf(':adf{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named)
assert.equal(content(markdownToAdf('```adf\n1e999\n```\n')), named)
})
test('names the mark spelling no opaque carry sits inside', () => {
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named)
assert.equal(content(markdownToAdf(`:underline[${carried}]\n`)), named)
assert.equal(content(markdownToAdf(`:textColor[a ${carried}]{color="#ae2e24"}\n`)), named)
assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named)
})
test('keeps the carry a mark spelling does not wrap', () => {
assert.deepEqual(content(markdownToAdf(`[a ${carried} b]\n`)), [
{ content: [text('[a '), { type: 'placeholder' }, text(' b]')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf(`**a**${carried}**b**\n`)), [
{ content: [marked('a', strong), { type: 'placeholder' }, marked('b', strong)], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf(`![a ${carried} b](https://example.com/i)\n`)), [image('https://example.com/i', 'a b')])
})
test('reads each attribute value as the type its section assigns', () => {
assert.deepEqual(content(markdownToAdf('::media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [
{ attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' },
+8 -4
View File
@@ -2,7 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { languageSlot } from '../code-language.ts'
@@ -36,7 +36,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
case 'bulletList':
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
case 'code':
return codeBlockNode(block.language, block.text, path)
return codeBlockNode(block.language, block.text, path, depth)
case 'directive':
return directiveNode(block, definitions, path, depth)
case 'fault':
@@ -134,8 +134,12 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
return success({ ...node, content })
}
function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result<AdfNode> {
if (language === carryName) return failure('malformed-directive', `the info string ${carryName} is reserved for the opaque carry`, path)
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language === carryName) {
const carried = readCarriedBlock(text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path)
return success(carried.value)
}
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] })
}
+5 -13
View File
@@ -1,6 +1,5 @@
import type { ConvertFault } from '../result.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts'
import { spellAttributes, spellLeafDirective, spellStringAttribute } from './directive-syntax.ts'
import { readSoleStringAttribute, spellAttributes, spellLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
const name = 'text'
const whitespaceRun = /^(?:[ \t]+|\n+)$/
@@ -13,15 +12,8 @@ export function spellTextDirective(text: string): string {
export function readTextDirective(span: DirectiveSpan): Read<string> | undefined {
if (span.name !== name) return undefined
if (span.content !== undefined) return { fault: unsupported(`${name} takes no content`) }
const spelled = span.attributes.get(name)
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${name} holds one ${name} attribute alone`) }
const spelling = spellStringAttribute(spelled.decoded)
if (spelling !== spelled.spelling) return { fault: unsupported(`${name} spells its ${name} attribute as ${name}=${spelling}`) }
if (!whitespaceRun.test(spelled.decoded)) return { fault: unsupported(`${name} spells one run of spaces and tabs, or one run of newlines`) }
return { value: spelled.decoded }
}
function unsupported(message: string): ConvertFault {
return { code: 'unsupported-node-shape', message }
const spelled = readSoleStringAttribute(span, name)
if (spelled.fault !== undefined) return spelled
if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines`) }
return spelled
}
+11
View File
@@ -375,3 +375,14 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered
are carried before the line is assembled, so narrowing it to spaces and tabs left it no
cause and it goes with them.
- [x] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
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. 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.
+6 -15
View File
@@ -29,9 +29,8 @@ The numbering is the order the work was planned in, not the order it ships.
- [x] **2e5 — Combined documents and the collision property.**
- [x] **2f — The attributes CommonMark cannot hold.**
- [ ] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
the runner grows a parse half as they do: `parsingDirectories` beside `emittingDirectories`, a
round-trip directory joining it only once every fixture in it reads back to its document,
and readers for `corpus/normalization/` (setext, indented code, loose lists, `*`/`+`
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
indented code, loose lists, `*`/`+`
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
@@ -51,17 +50,7 @@ The numbering is the order the work was planned in, not the order it ships.
- [x] **3g — The node tables read backwards.**
- [x] **3h — The block nodes.**
- [x] **3i — The inline nodes and the marks.**
- [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
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. 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.
- [x] **3j — The carry and the combinations.**
- [ ] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
pinned to the version it ships — the one `html-blocks.ts` names for its start
conditions — `corpus/README.md` gaining the kind.
@@ -123,7 +112,9 @@ The numbering is the order the work was planned in, not the order it ships.
landed the last decision `corpus/unspellable/` held and the directory went with it, so what
the code list holds from here is permanent. The parser's own code
additions are read here as one list before that freeze — nine sessions mint them
independently, and one cause wearing two codes is breaking to undo after `0.1.0`. That read
independently, and one cause wearing two codes is breaking to undo after `0.1.0` — one is
known already: a json attribute value past 500 levels reads `unsupported-node-shape` on
parse but `unsupported-nesting-depth` through the carry on emit. That read
gets a test rather than an eye — every `ConvertErrorCode` member named at a production call
site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation`
outlived its cause until 3h went looking. `0.1.0`