Emitter 2a: the corpus runner, the canonical serializer and the CommonMark subset #9
@@ -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.
|
`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
|
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
||||||
the spot.
|
the spot.
|
||||||
- Nothing recurses unbounded: the document guard walks iteratively and block emission refuses past
|
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values
|
||||||
500 nesting levels, so a deep document is a `Result` rather than the stack overflow that waits
|
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
|
||||||
near 3000.
|
waits near 2000.
|
||||||
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
|
- 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
|
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
|
||||||
unrepresentable.
|
unrepresentable.
|
||||||
|
|||||||
@@ -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\\b'))), 'unspellable-link-destination')
|
||||||
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&b=2'))), 'unspellable-link-destination')
|
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&b=2'))), 'unspellable-link-destination')
|
||||||
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination')
|
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination')
|
||||||
|
const entity = 'https://example.com/?a=1&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')
|
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: ' lead', type: 'text' })))), 'unspellable-whitespace')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'trail ', 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: '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')
|
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')
|
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', () => {
|
test('escapes a literal delimiter that would merge with an emitted one', () => {
|
||||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
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: 'em' })), '_\\_a_\n')
|
assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n')
|
||||||
assert.equal(emitted(marked('a*', { type: 'strong' })), '**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({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
|
||||||
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`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')
|
assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\\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({ 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(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: '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', () => {
|
test('refuses a text node carrying no text at all', () => {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { emitInlineLine } from './markdown-inline.ts'
|
|||||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
|
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
|
||||||
import { isAdfDocument } from './adf-document.ts'
|
import { isAdfDocument } from './adf-document.ts'
|
||||||
|
import { largestNesting } from './nesting.ts'
|
||||||
import { longestBacktickRun } from './backtick-runs.ts'
|
import { longestBacktickRun } from './backtick-runs.ts'
|
||||||
|
|
||||||
const largestListMarker = 999999999
|
const largestListMarker = 999999999
|
||||||
const largestNesting = 500
|
|
||||||
const listTypes = ['bulletList', 'orderedList']
|
const listTypes = ['bulletList', 'orderedList']
|
||||||
|
|
||||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||||
@@ -72,7 +72,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
|||||||
let text = ''
|
let text = ''
|
||||||
for (const [index, child] of (node.content ?? []).entries()) {
|
for (const [index, child] of (node.content ?? []).entries()) {
|
||||||
const childPath = [...path, 'content', index]
|
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)
|
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 (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath)
|
||||||
|
|||||||
+9
-4
@@ -1,17 +1,22 @@
|
|||||||
|
import { largestNesting } from './nesting.ts'
|
||||||
|
|
||||||
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
|
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
|
||||||
|
|
||||||
export function isJsonValue(value: unknown): value is 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) {
|
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 (item === null || typeof item === 'boolean' || typeof item === 'string') continue
|
||||||
if (typeof item === 'number') {
|
if (typeof item === 'number') {
|
||||||
if (!Number.isFinite(item)) return false
|
if (!Number.isFinite(item)) return false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them.
|
// A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them.
|
||||||
if (Array.isArray(item)) pending.push(...item)
|
if (Array.isArray(item)) for (const child of [...item]) pending.push({ depth: depth + 1, item: child })
|
||||||
else if (typeof item === 'object') pending.push(...Object.values(item))
|
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
|
||||||
else return false
|
else return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ export type InlineSegment = {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AssembledLine = { line: string; unspellableDelimiter: string | undefined }
|
||||||
|
|
||||||
export type LineContainer = 'heading' | 'paragraph'
|
export type LineContainer = 'heading' | 'paragraph'
|
||||||
|
|
||||||
const delimiters = ['`', '*', '_', '~']
|
const delimiters = ['*', '_', '`', '~']
|
||||||
|
|
||||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
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 linkOpener = /\](?=[([:])/
|
||||||
const unicodePunctuation = /[\p{P}\p{S}]/u
|
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)
|
return escape(resolveEmphasis(segments), container)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
|||||||
if (openerIndex === undefined || opener === undefined) continue
|
if (openerIndex === undefined || opener === undefined) continue
|
||||||
const openOffset = offsets[openerIndex] ?? 0
|
const openOffset = offsets[openerIndex] ?? 0
|
||||||
const closeOffset = offsets[index] ?? 0
|
const closeOffset = offsets[index] ?? 0
|
||||||
|
if (opener.text !== '_') continue
|
||||||
if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue
|
if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue
|
||||||
opener.text = '*'
|
opener.text = '*'
|
||||||
segment.text = '*'
|
segment.text = '*'
|
||||||
@@ -47,11 +50,12 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
|||||||
return resolved
|
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 scan = segments.map((segment) => segment.text).join('')
|
||||||
const kinds: InlineSegment['kind'][] = []
|
const kinds: InlineSegment['kind'][] = []
|
||||||
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) kinds.push(segment.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 escaped = new Set<number>()
|
||||||
|
const placements: number[] = []
|
||||||
let output = ''
|
let output = ''
|
||||||
for (let index = 0; index < scan.length; index += 1) {
|
for (let index = 0; index < scan.length; index += 1) {
|
||||||
const kind = kinds[index]
|
const kind = kinds[index]
|
||||||
@@ -60,9 +64,24 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): s
|
|||||||
output += '\\'
|
output += '\\'
|
||||||
escaped.add(index)
|
escaped.add(index)
|
||||||
}
|
}
|
||||||
|
placements.push(output.length)
|
||||||
output += scan.charAt(index)
|
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 {
|
function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean {
|
||||||
|
|||||||
+12
-7
@@ -1,5 +1,6 @@
|
|||||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||||
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.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 { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { longestBacktickRun } from './backtick-runs.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> {
|
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||||
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
|
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
|
||||||
if (!segments.ok) return segments
|
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()) {
|
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)
|
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
|
||||||
}
|
}
|
||||||
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
|
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[]> {
|
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 runs = inlineRuns(nodes, depth, firstIndex)
|
||||||
const segments: InlineSegment[] = []
|
const segments: InlineSegment[] = []
|
||||||
for (const [offset, run] of runs.entries()) {
|
for (const [offset, run] of runs.entries()) {
|
||||||
@@ -94,10 +102,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
|
|||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
const text = inner.value.map((segment) => segment.text).join('')
|
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 (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: '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[]> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
@@ -115,7 +120,7 @@ function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function holdsEdgeWhitespace(text: string): boolean {
|
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 {
|
function needsPadding(text: string): boolean {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const largestNesting = 500
|
||||||
Reference in New Issue
Block a user