Emitter 2a: the corpus runner, the canonical serializer and the CommonMark subset #9

Merged
lilleman merged 7 commits from emitter-commonmark-subset into main 2026-08-25 10:03:50 +02:00
7 changed files with 76 additions and 21 deletions
Showing only changes of commit cccbfd3f2d - Show all commits
+3 -3
View File
@@ -109,9 +109,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
`Result<T>``{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
the spot.
- Nothing recurses unbounded: the document guard walks iteratively and block emission refuses past
500 nesting levels, so a deep document is a `Result` rather than the stack overflow that waits
near 3000.
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
waits near 2000.
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable.
+26 -1
View File
@@ -73,6 +73,8 @@ test('refuses a link destination CommonMark cannot spell', () => {
assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link-destination')
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&amp;b=2'))), 'unspellable-link-destination')
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination')
const entity = 'https://example.com/?a=1&amp;b=2'
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link-destination')
assert.equal(markdown(adfToMarkdown(link('https://en.example.com/a_(b)'))), '[t](https://en.example.com/a_(b))\n')
})
@@ -100,6 +102,7 @@ test('refuses whitespace CommonMark cannot hold', () => {
assert.equal(code(adfToMarkdown(document(paragraph({ text: ' lead', type: 'text' })))), 'unspellable-whitespace')
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'trail ', type: 'text' })))), 'unspellable-whitespace')
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\nb', type: 'text' })))), 'unspellable-whitespace')
assert.equal(code(adfToMarkdown(document(paragraph({ text: '\fa', type: 'text' })))), 'unspellable-whitespace')
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'em' }], text: 'x ', type: 'text' }, { text: 'y', type: 'text' })))), 'unspellable-whitespace')
})
@@ -177,13 +180,34 @@ test('wraps adjacent nodes carrying one mark once, and a differing mark twice',
assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n')
})
test('refuses a mark spelling that cannot open or close where it sits', () => {
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
const strong: AdfMark = { type: 'strong' }
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' })))), 'unspellable-mark')
assert.equal(code(adfToMarkdown(document(paragraph(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' })))), 'unspellable-mark')
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('.a', strong))))), 'unspellable-mark')
assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n')
assert.equal(emitted(marked('a.', strong)), '**a.**\n')
assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n')
})
test('refuses marks and attributes nested deeper than the emitter carries', () => {
const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' }))
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-node-shape')
let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), 'not-an-adf-document')
})
test('escapes a literal delimiter that would merge with an emitted one', () => {
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
assert.equal(emitted(marked('a_', { type: 'em' })), '_a\\__\n')
assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n')
assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n')
assert.equal(emitted({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' })), 'x~~\\~a~~\n')
assert.equal(emitted(marked('~a', { type: 'strike' })), '~~\\~a~~\n')
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' }))))), 'unspellable-mark')
assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n')
assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\![x](https://example.com/)\n')
@@ -207,6 +231,7 @@ test('refuses the characters CommonMark rewrites', () => {
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), 'unspellable-whitespace')
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
assert.equal(code(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
})
test('refuses a text node carrying no text at all', () => {
+2 -2
View File
@@ -4,10 +4,10 @@ import { emitInlineLine } from './markdown-inline.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.ts'
import { largestNesting } from './nesting.ts'
import { longestBacktickRun } from './backtick-runs.ts'
const largestListMarker = 999999999
const largestNesting = 500
const listTypes = ['bulletList', 'orderedList']
export function adfToMarkdown(document: AdfDocument): Result<string> {
@@ -72,7 +72,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = ''
for (const [index, child] of (node.content ?? []).entries()) {
const childPath = [...path, 'content', index]
if (child.type !== 'text' || typeof child.text !== 'string' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) {
if (child.type !== 'text' || typeof child.text !== 'string' || child.text === '' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) {
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', childPath)
}
if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath)
+9 -4
View File
@@ -1,17 +1,22 @@
import { largestNesting } from './nesting.ts'
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
export function isJsonValue(value: unknown): value is JsonValue {
const pending: unknown[] = [value]
const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }]
while (pending.length > 0) {
const item = pending.pop()
const entry = pending.pop()
if (entry === undefined) continue
const { depth, item } = entry
if (depth > largestNesting) return false
if (item === null || typeof item === 'boolean' || typeof item === 'string') continue
if (typeof item === 'number') {
if (!Number.isFinite(item)) return false
continue
}
// A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them.
if (Array.isArray(item)) pending.push(...item)
else if (typeof item === 'object') pending.push(...Object.values(item))
if (Array.isArray(item)) for (const child of [...item]) pending.push({ depth: depth + 1, item: child })
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
else return false
}
return true
+23 -4
View File
@@ -5,9 +5,11 @@ export type InlineSegment = {
text: string
}
export type AssembledLine = { line: string; unspellableDelimiter: string | undefined }
export type LineContainer = 'heading' | 'paragraph'
const delimiters = ['`', '*', '_', '~']
const delimiters = ['*', '_', '`', '~']
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
@@ -15,7 +17,7 @@ const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
const linkOpener = /\](?=[([:])/
const unicodePunctuation = /[\p{P}\p{S}]/u
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): string {
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
return escape(resolveEmphasis(segments), container)
}
@@ -40,6 +42,7 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
if (openerIndex === undefined || opener === undefined) continue
const openOffset = offsets[openerIndex] ?? 0
const closeOffset = offsets[index] ?? 0
if (opener.text !== '_') continue
if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue
opener.text = '*'
segment.text = '*'
@@ -47,11 +50,12 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
return resolved
}
function escape(segments: readonly InlineSegment[], container: LineContainer): string {
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
const scan = segments.map((segment) => segment.text).join('')
const kinds: InlineSegment['kind'][] = []
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) kinds.push(segment.kind)
const escaped = new Set<number>()
const placements: number[] = []
let output = ''
for (let index = 0; index < scan.length; index += 1) {
const kind = kinds[index]
@@ -60,9 +64,24 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): s
output += '\\'
escaped.add(index)
}
placements.push(output.length)
output += scan.charAt(index)
}
return output
return { line: output, unspellableDelimiter: unspellableDelimiter(segments, output, placements) }
}
function unspellableDelimiter(segments: readonly InlineSegment[], output: string, placements: readonly number[]): string | undefined {
let cursor = 0
for (const segment of segments) {
const start = placements[cursor] ?? 0
cursor += segment.text.length
if (segment.kind !== 'emphasis-close' && segment.kind !== 'emphasis-open') continue
const before = charAt(output, start - 1)
const after = output.charAt(start + segment.text.length)
const spellable = segment.kind === 'emphasis-open' ? isLeftFlanking(before, after) : isRightFlanking(before, after)
if (!spellable) return segment.text
}
return undefined
}
function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean {
+11 -6
View File
@@ -1,5 +1,6 @@
import type { AdfMark, AdfNode } from './adf-document.ts'
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
import { largestNesting } from './nesting.ts'
import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { longestBacktickRun } from './backtick-runs.ts'
@@ -19,9 +20,13 @@ const linkAttributes = ['href', 'title']
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
if (!segments.ok) return segments
const line = assembleInlineLine(segments.value, container)
const assembled = assembleInlineLine(segments.value, container)
if (assembled.unspellableDelimiter !== undefined) {
return failure('unspellable-mark', `the ${assembled.unspellableDelimiter} spelling cannot open or close where it sits`, path)
}
const line = assembled.line
for (const [index, single] of line.split('\n').entries()) {
if (/^[ \t]|[ \t]$/.test(single)) {
if (/^[ \t\v\f]|[ \t\v\f]$/.test(single)) {
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
}
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
@@ -32,6 +37,9 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
}
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<InlineSegment[]> {
if (depth > largestNesting) {
return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
}
const runs = inlineRuns(nodes, depth, firstIndex)
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
@@ -94,11 +102,8 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (!inner.ok) return inner
const text = inner.value.map((segment) => segment.text).join('')
if (holdsEdgeWhitespace(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
if (mark.type === 'em') {
return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }])
}
return success([{ kind: 'syntax', text: spelling }, ...inner.value, { kind: 'syntax', text: spelling }])
}
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
let text = ''
@@ -115,7 +120,7 @@ function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErr
}
function holdsEdgeWhitespace(text: string): boolean {
return text !== '' && (isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1)))
return isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1))
}
function needsPadding(text: string): boolean {
+1
View File
@@ -0,0 +1 @@
export const largestNesting = 500