Read the carries and the combinations back, and land markdownToAdf
CI / gate (push) Successful in 8s
CI / gate (push) Successful in 8s
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-node-shape
|
||||
@@ -0,0 +1 @@
|
||||
_a :adf{json="{\"type\":\"blockCard\"}"} b_
|
||||
@@ -0,0 +1 @@
|
||||
malformed-directive
|
||||
@@ -0,0 +1 @@
|
||||
:adf{json="{"}
|
||||
@@ -0,0 +1 @@
|
||||
malformed-directive
|
||||
@@ -0,0 +1,3 @@
|
||||
```adf
|
||||
{"type":
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-nesting-depth
|
||||
@@ -0,0 +1 @@
|
||||
:adf{json="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import type { ConvertFault } from '../result.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, spellAttributes, spellStringAttribute } from './directive-syntax.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { spellAttributes, spellStringAttribute } from './directive-syntax.ts'
|
||||
|
||||
export const carryName = 'adf'
|
||||
|
||||
const jsonAttribute = 'json'
|
||||
|
||||
export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
const json = carriedJson(node, 'two-space', path)
|
||||
if (!json.ok) return json
|
||||
@@ -18,7 +23,21 @@ export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<stri
|
||||
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
const json = carriedJson(node, 'compact', path)
|
||||
if (!json.ok) return json
|
||||
return success(`:${carryName}${spellAttributes([['json', spellStringAttribute(json.value)]])}`)
|
||||
return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`)
|
||||
}
|
||||
|
||||
export function readCarriedBlock(body: string): Read<AdfNode> {
|
||||
return readCarriedJson(body, 'two-space')
|
||||
}
|
||||
|
||||
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
|
||||
if (span.name !== carryName) return undefined
|
||||
if (span.content !== undefined) return { fault: unsupported(`${carryName} takes no content`) }
|
||||
const spelled = span.attributes.get(jsonAttribute)
|
||||
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${carryName} holds one ${jsonAttribute} attribute alone`) }
|
||||
const spelling = spellStringAttribute(spelled.decoded)
|
||||
if (spelling !== spelled.spelling) return { fault: unsupported(`${carryName} spells its ${jsonAttribute} attribute as ${jsonAttribute}=${spelling}`) }
|
||||
return readCarriedJson(spelled.decoded, 'compact')
|
||||
}
|
||||
|
||||
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
|
||||
@@ -27,3 +46,31 @@ function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPa
|
||||
}
|
||||
return success(serializeCanonicalJson(node, spelling))
|
||||
}
|
||||
|
||||
function readCarriedJson(raw: string, spelling: JsonSpelling): Read<AdfNode> {
|
||||
const parsed = parseJsonText(raw)
|
||||
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
|
||||
const { value } = parsed
|
||||
if (!isJsonValue(value)) {
|
||||
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries` } }
|
||||
}
|
||||
if (serializeCanonicalJson(value, spelling) !== raw) {
|
||||
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
|
||||
return { fault: unsupported(`the opaque carry spells its node's JSON canonically: ${shape}`) }
|
||||
}
|
||||
if (!isAdfNode(value)) return { fault: unsupported("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
|
||||
}
|
||||
}
|
||||
|
||||
function unsupported(message: string): ConvertFault {
|
||||
return { code: 'unsupported-node-shape', message }
|
||||
}
|
||||
|
||||
@@ -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' }])
|
||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
||||
const slot = slotNodes(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 slotNodes(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,11 +390,15 @@ 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 {
|
||||
|
||||
@@ -266,10 +266,78 @@ 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 the parser carries', () => {
|
||||
const deep = `:adf{json="${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}"}\n`
|
||||
assert.equal(content(markdownToAdf(deep)), `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries`)
|
||||
})
|
||||
|
||||
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(`\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(`\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' },
|
||||
|
||||
@@ -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'
|
||||
@@ -135,7 +135,11 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
|
||||
}
|
||||
|
||||
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)
|
||||
if (language === carryName) {
|
||||
const carried = readCarriedBlock(text)
|
||||
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' }] })
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,17 +51,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.
|
||||
|
||||
Reference in New Issue
Block a user