3j: the carry and the combinations read back #42
@@ -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": {
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(`\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'
|
||||
@@ -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' }] })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user