This commit is contained in:
@@ -109,6 +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.
|
||||
- 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.
|
||||
|
||||
@@ -48,6 +48,7 @@ names, from the document root.
|
||||
spelling, which round-trips byte-identically.
|
||||
- Foreign HTML maps a documented element set; an unmappable element is an error, never a silent
|
||||
drop. Well-formed HTML only — no tag-soup recovery.
|
||||
- A document nested deeper than 500 levels is an error result, not a stack overflow.
|
||||
- The emitted formats are semver surface (AGENTS.md §8).
|
||||
|
||||
## Who it is for
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=92 --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=93 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+18
-8
@@ -29,7 +29,7 @@ export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||
if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false
|
||||
if (value['type'] !== 'doc') return false
|
||||
if (typeof value['version'] !== 'number' || !Number.isFinite(value['version'])) return false
|
||||
return !('content' in value) || isArrayOf(value['content'], isAdfNode)
|
||||
return !('content' in value) || isNodeArray(value['content'])
|
||||
}
|
||||
|
||||
function isAdfMark(value: unknown): value is AdfMark {
|
||||
@@ -38,13 +38,23 @@ function isAdfMark(value: unknown): value is AdfMark {
|
||||
return !('attrs' in value) || isAttributes(value['attrs'])
|
||||
}
|
||||
|
||||
function isAdfNode(value: unknown): value is AdfNode {
|
||||
if (!isRecord(value) || !holdsOnly(value, nodeKeys)) return false
|
||||
if (typeof value['type'] !== 'string') return false
|
||||
if ('attrs' in value && !isAttributes(value['attrs'])) return false
|
||||
if ('content' in value && !isArrayOf(value['content'], isAdfNode)) return false
|
||||
if ('marks' in value && !isArrayOf(value['marks'], isAdfMark)) return false
|
||||
return !('text' in value) || typeof value['text'] === 'string'
|
||||
function isNodeArray(value: unknown): value is AdfNode[] {
|
||||
if (!Array.isArray(value)) return false
|
||||
const pending: unknown[] = [...value]
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()
|
||||
if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false
|
||||
if (typeof node['type'] !== 'string') return false
|
||||
if ('attrs' in node && !isAttributes(node['attrs'])) return false
|
||||
if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false
|
||||
if ('text' in node && typeof node['text'] !== 'string') return false
|
||||
if ('content' in node) {
|
||||
const content = node['content']
|
||||
if (!Array.isArray(content)) return false
|
||||
pending.push(...content)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
|
||||
|
||||
@@ -177,6 +177,66 @@ 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('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({ 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' })), '\\\n')
|
||||
})
|
||||
|
||||
test('escapes a hyphen underline a hard break would expose', () => {
|
||||
const line = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text: 'foo', type: 'text' }, { type: 'hardBreak' }, { text, type: 'text' }))))
|
||||
assert.equal(line('--'), 'foo\\\n\\--\n')
|
||||
assert.equal(line('=='), 'foo\\\n\\==\n')
|
||||
})
|
||||
|
||||
test('refuses a list item whose marker completes a thematic break', () => {
|
||||
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
|
||||
assert.equal(code(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), 'unspellable-line-start')
|
||||
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), 'unspellable-line-start')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
test('refuses a text node carrying no text at all', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('refuses a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||
const em = { type: 'em' }
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))), 'unspellable-whitespace')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))), 'unspellable-whitespace')
|
||||
})
|
||||
|
||||
test('pads a code span whose edges CommonMark would strip', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
|
||||
})
|
||||
|
||||
test('spells one code span over a run of code-marked nodes', () => {
|
||||
const code_ = { type: 'code' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
||||
'`ab`\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses a document nested deeper than the emitter carries', () => {
|
||||
let node: AdfNode = paragraph({ text: 'x', type: 'text' })
|
||||
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockquote' }
|
||||
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('emits an empty list item without trailing whitespace', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n')
|
||||
})
|
||||
|
||||
+26
-20
@@ -2,21 +2,24 @@ import type { AdfDocument, AdfNode } from './adf-document.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
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 { longestBacktickRun } from './backtick-runs.ts'
|
||||
|
||||
const largestListMarker = 999999999
|
||||
const largestNesting = 500
|
||||
const listTypes = ['bulletList', 'orderedList']
|
||||
|
||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', [])
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
const blocks = emitBlocks(document.content ?? [], false, [])
|
||||
const blocks = emitBlocks(document.content ?? [], false, [], 0)
|
||||
if (!blocks.ok) return blocks
|
||||
return success(blocks.value === '' ? '' : `${blocks.value}\n`)
|
||||
}
|
||||
|
||||
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath): Result<string> {
|
||||
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
if (depth > largestNesting) return failure('unsupported-node-shape', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
let output = ''
|
||||
let previous: AdfNode | undefined
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
@@ -27,7 +30,7 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: Conver
|
||||
}
|
||||
output += inListItem && listTypes.includes(node.type) ? '\n' : '\n\n'
|
||||
}
|
||||
const block = emitBlock(node, nodePath)
|
||||
const block = emitBlock(node, nodePath, depth)
|
||||
if (!block.ok) return block
|
||||
output += block.value
|
||||
previous = node
|
||||
@@ -35,9 +38,9 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: Conver
|
||||
return success(output)
|
||||
}
|
||||
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
if (node.type === 'blockquote') return emitBlockquote(node, path)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path)
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
|
||||
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
|
||||
if (node.type === 'heading') return emitHeading(node, path)
|
||||
if (node.type === 'paragraph') return emitParagraph(node, path)
|
||||
@@ -48,10 +51,10 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path)
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
const validation = validateBlockNode(node, [], path)
|
||||
if (!validation.ok) return validation
|
||||
const inner = emitBlocks(node.content ?? [], false, path)
|
||||
const inner = emitBlocks(node.content ?? [], false, path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
return success(
|
||||
inner.value
|
||||
@@ -67,10 +70,13 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
const info = spellCodeFenceInfo(node.attrs?.['language'], path)
|
||||
if (!info.ok) return info
|
||||
let text = ''
|
||||
for (const child of node.content ?? []) {
|
||||
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) {
|
||||
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', path)
|
||||
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)
|
||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
||||
text += child.text
|
||||
}
|
||||
const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1))
|
||||
@@ -106,7 +112,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
return success(`${hashes} ${line.value}`)
|
||||
}
|
||||
|
||||
function emitList(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
const ordered = node.type === 'orderedList'
|
||||
const validation = validateBlockNode(node, ordered ? ['order'] : [], path)
|
||||
if (!validation.ok) return validation
|
||||
@@ -126,26 +132,26 @@ function emitList(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
for (const [offset, item] of items.entries()) {
|
||||
const itemPath = [...path, 'content', offset]
|
||||
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath)
|
||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath)
|
||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath, depth)
|
||||
if (!emitted.ok) return emitted
|
||||
lines.push(emitted.value)
|
||||
}
|
||||
return success(lines.join('\n'))
|
||||
}
|
||||
|
||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath): Result<string> {
|
||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<string> {
|
||||
const validation = validateBlockNode(item, [], path)
|
||||
if (!validation.ok) return validation
|
||||
const inner = emitBlocks(item.content ?? [], true, path)
|
||||
const inner = emitBlocks(item.content ?? [], true, path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value === '') return success(marker.trimEnd())
|
||||
const indent = ' '.repeat(marker.length)
|
||||
return success(
|
||||
inner.value
|
||||
.split('\n')
|
||||
.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
.join('\n'),
|
||||
)
|
||||
const lines = inner.value.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
const first = lines[0] ?? ''
|
||||
if (isThematicBreak(first)) {
|
||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path)
|
||||
}
|
||||
return success(lines.join('\n'))
|
||||
}
|
||||
|
||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
|
||||
@@ -2,6 +2,7 @@ export type LinePosition = 'first' | 'later'
|
||||
|
||||
const controlCharacterRange = '\\u0000-\\u001f\\u007f'
|
||||
const autolinkSource = `[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\\s<>${controlCharacterRange}]*`
|
||||
const nullCharacterSource = '\\u0000'
|
||||
const entityReferenceSource = '&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});'
|
||||
|
||||
const anchoredEntityReference = new RegExp(`^(?:${entityReferenceSource})`)
|
||||
@@ -9,10 +10,12 @@ const autolink = new RegExp(`^(?:${autolinkSource})$`)
|
||||
const bracketedAutolink = new RegExp(`^<(?:${autolinkSource})>`)
|
||||
const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
|
||||
const entityReference = new RegExp(entityReferenceSource)
|
||||
const nullCharacter = new RegExp(nullCharacterSource)
|
||||
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/]
|
||||
const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
|
||||
const setextUnderline = /^=+$/
|
||||
const setextUnderline = /^(?:=+|-+)$/
|
||||
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
|
||||
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
||||
|
||||
export function claimsLine(line: string, position: LinePosition): boolean {
|
||||
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
|
||||
@@ -35,10 +38,22 @@ export function holdsEntityReference(text: string): boolean {
|
||||
return entityReference.test(text)
|
||||
}
|
||||
|
||||
export function holdsNullCharacter(text: string): boolean {
|
||||
return nullCharacter.test(text)
|
||||
}
|
||||
|
||||
export function isAutolink(text: string): boolean {
|
||||
return autolink.test(text)
|
||||
}
|
||||
|
||||
export function isThematicBreak(line: string): boolean {
|
||||
return thematicBreak.test(line)
|
||||
}
|
||||
|
||||
export function isUnicodeWhitespace(character: string): boolean {
|
||||
return unicodeWhitespace.test(character)
|
||||
}
|
||||
|
||||
export function opensBracketedAutolink(text: string): boolean {
|
||||
return bracketedAutolink.test(text)
|
||||
}
|
||||
|
||||
+14
-6
@@ -1,10 +1,18 @@
|
||||
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null) return true
|
||||
if (typeof value === 'boolean' || typeof value === 'string') return true
|
||||
if (typeof value === 'number') return Number.isFinite(value)
|
||||
if (Array.isArray(value)) return [...value].every(isJsonValue)
|
||||
if (typeof value === 'object') return Object.values(value).every(isJsonValue)
|
||||
return false
|
||||
const pending: unknown[] = [value]
|
||||
while (pending.length > 0) {
|
||||
const item = pending.pop()
|
||||
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))
|
||||
else return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
|
||||
import { escapesLineClaim, isUnicodeWhitespace, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
|
||||
|
||||
export type InlineSegment = {
|
||||
kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax'
|
||||
@@ -7,12 +7,13 @@ export type InlineSegment = {
|
||||
|
||||
export type LineContainer = 'heading' | 'paragraph'
|
||||
|
||||
const delimiters = ['`', '*', '_', '~']
|
||||
|
||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
||||
const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
|
||||
const linkOpener = /\](?=[([:])/
|
||||
const unicodePunctuation = /[\p{P}\p{S}]/u
|
||||
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
||||
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): string {
|
||||
return escape(resolveEmphasis(segments), container)
|
||||
@@ -55,7 +56,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): s
|
||||
for (let index = 0; index < scan.length; index += 1) {
|
||||
const kind = kinds[index]
|
||||
const escapable = kind === 'literal' || kind === 'link-text'
|
||||
if (escapable && opensConstruct(scan, index, kind === 'link-text', container, escaped)) {
|
||||
if (escapable && (mergesWithSyntax(scan, kinds, index) || opensConstruct(scan, index, kind === 'link-text', container, escaped))) {
|
||||
output += '\\'
|
||||
escaped.add(index)
|
||||
}
|
||||
@@ -64,6 +65,24 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): s
|
||||
return output
|
||||
}
|
||||
|
||||
function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean {
|
||||
const character = scan.charAt(index)
|
||||
if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(kinds[index + 1])
|
||||
if (!delimiters.includes(character)) return false
|
||||
return touchesSyntax(scan, kinds, index, -1) || touchesSyntax(scan, kinds, index, 1)
|
||||
}
|
||||
|
||||
function touchesSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number, step: number): boolean {
|
||||
const character = scan.charAt(index)
|
||||
let cursor = index + step
|
||||
while (scan.charAt(cursor) === character && !isSyntax(kinds[cursor])) cursor += step
|
||||
return scan.charAt(cursor) === character && isSyntax(kinds[cursor])
|
||||
}
|
||||
|
||||
function isSyntax(kind: InlineSegment['kind'] | undefined): boolean {
|
||||
return kind === 'emphasis-close' || kind === 'emphasis-open' || kind === 'syntax'
|
||||
}
|
||||
|
||||
function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
||||
const claimsLine = container === 'heading' ? closesHeading(scan, index) : claimsLineStart(scan, index)
|
||||
return claimsLine || claimsCharacter(scan, index, inLinkText, escaped)
|
||||
@@ -143,11 +162,11 @@ function isPunctuation(character: string): boolean {
|
||||
}
|
||||
|
||||
function isWhitespace(character: string): boolean {
|
||||
return character === '' || unicodeWhitespace.test(character)
|
||||
return character === '' || isUnicodeWhitespace(character)
|
||||
}
|
||||
|
||||
function isWordCharacter(character: string): boolean {
|
||||
return character !== '' && !unicodeWhitespace.test(character) && !unicodePunctuation.test(character)
|
||||
return character !== '' && !isUnicodeWhitespace(character) && !unicodePunctuation.test(character)
|
||||
}
|
||||
|
||||
function charAt(text: string, index: number): string {
|
||||
|
||||
+19
-12
@@ -1,6 +1,6 @@
|
||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
|
||||
import { claimsLine, holdsControlCharacter, holdsEntityReference, isAutolink } from './commonmark-grammar.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'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
@@ -76,9 +76,10 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
||||
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
|
||||
return success([{ kind: 'syntax', text: '\\\n' }])
|
||||
}
|
||||
if (typeof node.text !== 'string') return failure('unsupported-node-shape', 'a text node carries no text', path)
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path)
|
||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path)
|
||||
if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell', path)
|
||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||
return success([{ kind: context.inLinkText ? 'link-text' : 'literal', text: node.text }])
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
|
||||
const inner = emitRun(nodes, depth + 1, index, context)
|
||||
if (!inner.ok) return inner
|
||||
const text = inner.value.map((segment) => segment.text).join('')
|
||||
if (/^[ \t]|[ \t]$/.test(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
|
||||
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 }])
|
||||
}
|
||||
@@ -100,20 +101,26 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
|
||||
}
|
||||
|
||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||
const node = nodes[0]
|
||||
if (nodes.length !== 1 || node === undefined || node.type !== 'text' || typeof node.text !== 'string') {
|
||||
return failure('unspellable-mark', 'a code span holds exactly one text node', path)
|
||||
let text = ''
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 'text' || typeof node.text !== 'string' || node.text === '') return failure('unspellable-mark', 'a code span holds text nodes only', path)
|
||||
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries', path)
|
||||
text += node.text
|
||||
}
|
||||
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries', path)
|
||||
if (/[\n\r]/.test(node.text)) return failure('unspellable-mark', 'a code span holds no newline', path)
|
||||
const fence = '`'.repeat(longestBacktickRun(node.text) + 1)
|
||||
const padded = needsPadding(node.text) ? ` ${node.text} ` : node.text
|
||||
if (/[\n\r]/.test(text)) return failure('unspellable-mark', 'a code span holds no newline', path)
|
||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||
const padded = needsPadding(text) ? ` ${text} ` : text
|
||||
return success([{ kind: 'syntax', text: `${fence}${padded}${fence}` }])
|
||||
}
|
||||
|
||||
function holdsEdgeWhitespace(text: string): boolean {
|
||||
return text !== '' && (isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1)))
|
||||
}
|
||||
|
||||
function needsPadding(text: string): boolean {
|
||||
if (text.startsWith('`') || text.endsWith('`')) return true
|
||||
return text.startsWith(' ') && text.endsWith(' ') && text.trim() !== ''
|
||||
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
||||
}
|
||||
|
||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
|
||||
@@ -126,7 +133,7 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index
|
||||
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path)
|
||||
const node = nodes[0]
|
||||
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
||||
if (bare && title === undefined && isAutolink(href)) return success([{ kind: 'syntax', text: `<${href}>` }])
|
||||
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success([{ kind: 'syntax', text: `<${href}>` }])
|
||||
const destination = spellDestination(href, path)
|
||||
if (!destination.ok) return destination
|
||||
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ConvertErrorCode =
|
||||
| 'not-an-adf-document'
|
||||
| 'reserved-adf-language'
|
||||
| 'unspellable-adjacent-lists'
|
||||
| 'unspellable-character'
|
||||
| 'unspellable-code-block-language'
|
||||
| 'unspellable-line-start'
|
||||
| 'unspellable-link-destination'
|
||||
|
||||
@@ -79,6 +79,9 @@ detail is settled at its own milestone.
|
||||
attribute canonicalization, a pipe cell's whitespace edges and `\u007c` for a `|` inside a
|
||||
quoted attribute value, documents combining nodes rather than isolating one, and a
|
||||
paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`).
|
||||
The gate gains the collision property here: no two corpus documents may emit the same
|
||||
bytes — one spelling for two documents is a round-trip break no parser can undo, and it is
|
||||
provable without one.
|
||||
- [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component; split it
|
||||
into sub-items before starting (§15). Fixtures land with the code that reads them:
|
||||
`corpus/normalization/` (setext, indented code, loose lists, `*`/`+` bullets, entity
|
||||
|
||||
Reference in New Issue
Block a user