Spell every document the emitter refused for a carriage return, a null character, a code-span line start or an empty text node
CI / gate (push) Successful in 57s
CI / publish (push) Has been skipped
CI / gate (push) Successful in 57s
CI / publish (push) Has been skipped
This commit is contained in:
@@ -13,7 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus',
|
||||
const checks = ['count', 'fixpoint', 'text'] as const
|
||||
|
||||
type Check = (typeof checks)[number]
|
||||
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable'
|
||||
type ExceptionKind = 'mark-model' | 'pending'
|
||||
|
||||
type SpecExample = { example: number; html: string; markdown: string; section: string }
|
||||
|
||||
@@ -30,7 +30,7 @@ function isCheck(value: unknown): value is Check {
|
||||
}
|
||||
|
||||
function isKind(value: unknown): value is ExceptionKind {
|
||||
return value === 'mark-model' || value === 'pending' || value === 'unspellable'
|
||||
return value === 'mark-model' || value === 'pending'
|
||||
}
|
||||
|
||||
function isSpecExample(value: unknown): value is SpecExample {
|
||||
@@ -77,12 +77,12 @@ test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
|
||||
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
|
||||
})
|
||||
|
||||
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => {
|
||||
test('every exception is unique, names a parsing example, and files no fixpoint divergence', () => {
|
||||
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
|
||||
for (const entry of exceptions) {
|
||||
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
|
||||
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
|
||||
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`)
|
||||
assert.notEqual(entry.check, 'fixpoint', `exception ${entry.example} files a fixpoint divergence; the emitter spells every document the parser builds`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ function holdsDirectiveShape(document: AdfDocument): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
|
||||
test('generated markdown refuses, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
|
||||
const parameters = propertyRuns(gateRuns)
|
||||
let directiveShaped = 0
|
||||
let fixpoints = 0
|
||||
@@ -427,7 +427,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
|
||||
const parsed = markdownToAdf(input)
|
||||
if (!parsed.ok) return
|
||||
const emitted = adfToMarkdown(parsed.value)
|
||||
if (!emitted.ok) return
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
|
||||
fixpoints += 1
|
||||
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
||||
const read = markdownToAdf(emitted.value)
|
||||
|
||||
@@ -24,7 +24,7 @@ export const propertyTimeout = 600000
|
||||
const depthIdentifier = fc.createDepthIdentifier()
|
||||
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||
const flatCommonMarkShapeWeight = 4
|
||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n\r\0!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||
const nestingCommonMarkShapeWeight = 21
|
||||
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
||||
|
||||
@@ -106,7 +106,7 @@ const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
|
||||
))
|
||||
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||
|
||||
const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
||||
const textNode = occasionallyEmpty(fc.record({ marks, text: fc.oneof({ arbitrary: text, weight: 19 }, { arbitrary: fc.constant(''), weight: 1 }) }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
||||
|
||||
const backtickRunNode = fc
|
||||
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
|
||||
|
||||
@@ -117,5 +117,5 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin
|
||||
}
|
||||
|
||||
function spellTitle(title: string): string | undefined {
|
||||
return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
|
||||
return /[\n\r\0\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
|
||||
return { value: spelled.decoded }
|
||||
}
|
||||
|
||||
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
|
||||
// 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
|
||||
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
|
||||
|
||||
@@ -35,7 +35,7 @@ test('names the node a refusal came from, and no source the emitter never read',
|
||||
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
||||
})
|
||||
@@ -147,8 +147,17 @@ test('carries a mark the canonical spellings cannot nest', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses a line whose start block parsing would claim', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start')
|
||||
function readsBack(doc: AdfDocument): string {
|
||||
const spelled = markdown(adfToMarkdown(doc))
|
||||
assert.deepEqual(markdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
|
||||
return spelled
|
||||
}
|
||||
|
||||
test('carries a code span whose backticks would open its line as a fence', () => {
|
||||
const fence: AdfNode = { marks: [{ type: 'code' }], text: '```', type: 'text' }
|
||||
const carried = '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"code\\"}],\\"text\\":\\"\\u0060\\u0060\\u0060\\",\\"type\\":\\"text\\"}"}'
|
||||
assert.equal(readsBack(document(paragraph(fence))), `${carried}\n`)
|
||||
assert.equal(readsBack(document(paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, fence, fence))), `a\\\n${carried}\`\`\`\` \`\`\` \`\`\`\`\n`)
|
||||
})
|
||||
|
||||
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
|
||||
@@ -456,28 +465,26 @@ test('spells a list item whose marker completes a thematic break as a directive'
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses the characters CommonMark rewrites', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))),
|
||||
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one',
|
||||
)
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))),
|
||||
'unspellable-character: a text node holds a carriage return CommonMark rewrites',
|
||||
)
|
||||
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(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
|
||||
test('spells the characters CommonMark rewrites', () => {
|
||||
assert.equal(readsBack(document(paragraph({ text: 'a\r\r\nb', type: 'text' }))), 'a !adf:text{text="\\n"}b\n')
|
||||
assert.equal(readsBack(document(paragraph({ marks: [{ type: 'strong' }], text: '\u0000\u0000a\u0000', type: 'text' }))), '**!adf:text{text="\\u0000\\u0000"}a!adf:text{text="\\u0000"}**\n')
|
||||
assert.equal(readsBack(document(paragraph({ marks: [{ attrs: { href: 'u' }, type: 'link' }], text: 'a\rb\u0000', type: 'text' }))), '[a b!adf:text{text="\\u0000"}](u)\n')
|
||||
assert.equal(readsBack(document({ attrs: { level: 1 }, content: [{ text: '\r', type: 'text' }], type: 'heading' })), '# \n')
|
||||
assert.equal(readsBack(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' })), '```adf:codeBlock\n{\n "content": [\n {\n "text": "a\\rb",\n "type": "text"\n }\n ]\n}\n```\n')
|
||||
readsBack(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))
|
||||
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: 'a\u0000b', type: 'text' })))
|
||||
})
|
||||
|
||||
test('refuses a text node the spelling would empty out', () => {
|
||||
test('carries a text node holding no text, or holding content', () => {
|
||||
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
|
||||
const empty = 'unsupported-node-shape: a text node holds text: this one has none'
|
||||
const holding = 'unsupported-node-shape: a text node holds no content: this one holds some'
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding)
|
||||
assert.equal(readsBack(document(paragraph({ text: '', type: 'text' }))), '!adf:carry{json="{\\"text\\":\\"\\",\\"type\\":\\"text\\"}"}\n')
|
||||
assert.equal(readsBack(document(paragraph({ type: 'text' }, { text: 'a', type: 'text' }))), '!adf:carry{json="{\\"type\\":\\"text\\"}"}a\n')
|
||||
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))
|
||||
readsBack(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: '', type: 'text' })))
|
||||
readsBack(document(paragraph({ content: nested, text: 'x', type: 'text' })))
|
||||
readsBack(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))
|
||||
readsBack(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))
|
||||
readsBack(document({ content: [{ text: 'a', type: 'text' }, { type: 'text' }], type: 'codeBlock' }))
|
||||
})
|
||||
|
||||
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||
@@ -688,8 +695,12 @@ test('refuses the content and slot an inline directive has no room for', () => {
|
||||
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
|
||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
|
||||
assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text'))
|
||||
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace: the status content slot holds a newline no inline directive spans')
|
||||
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character: a status content slot holds a null character CommonMark replaces')
|
||||
})
|
||||
|
||||
test('carries an inline node whose content slot holds a line ending or a null character', () => {
|
||||
assert.equal(readsBack(document(paragraph({ attrs: { text: 'a\nb' }, type: 'status' }))), '!adf:carry{json="{\\"attrs\\":{\\"text\\":\\"a\\\\nb\\"},\\"type\\":\\"status\\"}"}\n')
|
||||
readsBack(document(paragraph({ attrs: { text: 'a\rb' }, type: 'emoji' })))
|
||||
readsBack(document(paragraph({ attrs: { id: 'x', text: 'a\u0000b' }, type: 'mention' })))
|
||||
})
|
||||
|
||||
test('spells the directive marks around the longest run they cover', () => {
|
||||
|
||||
@@ -128,9 +128,9 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
|
||||
if (plain !== undefined) return plain
|
||||
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
||||
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
|
||||
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
|
||||
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node))
|
||||
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
||||
if (node.type === 'rule') return readableText(tryRule(node))
|
||||
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
|
||||
@@ -251,43 +251,36 @@ function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
||||
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||
}
|
||||
|
||||
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
function tryCodeBlock(node: AdfNode): string | undefined {
|
||||
if (!holdsOnlyAttributes(node, ['language'])) return undefined
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
if (slot.kind === 'attribute') return undefined
|
||||
const texts = fencedTexts(node, path)
|
||||
if (!texts.ok) return texts
|
||||
const [only, ...others] = texts.value ?? []
|
||||
const [only, ...others] = fencedTexts(node) ?? []
|
||||
if (only === undefined || others.length > 0) return undefined
|
||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)))
|
||||
return fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
|
||||
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const texts = fencedTexts(node, path)
|
||||
if (!texts.ok) return texts
|
||||
if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
||||
const texts = fencedTexts(node)
|
||||
if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
||||
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
if (!opener.ok) return opener
|
||||
const info = slot.kind === 'fence' ? slot.info : ''
|
||||
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||
}
|
||||
|
||||
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
||||
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
||||
if (node.content === undefined) return success([''])
|
||||
if (node.content.some((child) => !isUnmarkedBareText(child))) return success(undefined)
|
||||
// The text each fence holds, or `undefined` where a child is no text a fence keeps, which the carry holds instead.
|
||||
function fencedTexts(node: AdfNode): string[] | undefined {
|
||||
if (node.content === undefined) return ['']
|
||||
const texts: string[] = []
|
||||
for (const [index, child] of node.content.entries()) {
|
||||
const childPath = [...path, 'content', index]
|
||||
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
|
||||
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
||||
for (const child of node.content) {
|
||||
if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
|
||||
texts.push(child.text)
|
||||
}
|
||||
return success(texts)
|
||||
return texts
|
||||
}
|
||||
|
||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { tryImageLine } from './inline-line.ts'
|
||||
@@ -7,10 +6,10 @@ import { tryImageLine } from './inline-line.ts'
|
||||
const centeredMediaSingle = '{"layout":"center"}'
|
||||
const imageAttributes = ['alt', 'type', 'url']
|
||||
|
||||
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
||||
export function tryImage(node: AdfNode): string | undefined {
|
||||
const image = imageShape(node)
|
||||
if (image === undefined) return undefined
|
||||
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
|
||||
return tryImageLine(image.alt, image.url)
|
||||
}
|
||||
|
||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark
|
||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { highlightDelimiter } from '../plain/conventions.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
|
||||
@@ -67,13 +67,12 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f
|
||||
return emitted.value.line
|
||||
}
|
||||
|
||||
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
||||
export function tryImageLine(alt: string | undefined, href: string): string | undefined {
|
||||
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||
const destination = spellDestination(href)
|
||||
if (destination === undefined) return undefined
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||
const attempt = attemptLine([syntax('`)], 'paragraph', path, 'lossless')
|
||||
return attempt.ok ? attempt.value.line : undefined
|
||||
return attemptLine([syntax('`)], 'paragraph', 'lossless').line
|
||||
}
|
||||
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||
@@ -87,12 +86,11 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
|
||||
if (!taken.ok) return taken
|
||||
continue
|
||||
}
|
||||
const attempt = attemptLine(emission.value.segments, container, path, flavour)
|
||||
if (!attempt.ok) return attempt
|
||||
if (attempt.value.line !== undefined) {
|
||||
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||
const attempt = attemptLine(emission.value.segments, container, flavour)
|
||||
if (attempt.line !== undefined) {
|
||||
return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||
}
|
||||
const taken = takeFallback(fallbacks, attempt.value.fallback, path)
|
||||
const taken = takeFallback(fallbacks, attempt.fallback, path)
|
||||
if (!taken.ok) return taken
|
||||
}
|
||||
}
|
||||
@@ -117,12 +115,23 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
|
||||
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
|
||||
}
|
||||
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
|
||||
const verdict = lineVerdict(segments, container, flavour)
|
||||
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
|
||||
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] })
|
||||
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
|
||||
return success({ line: verdict.text })
|
||||
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
|
||||
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
|
||||
if (verdict.kind === 'line') return { line: verdict.text }
|
||||
return { fallback: lineOpener(segments, verdict.line) }
|
||||
}
|
||||
|
||||
// A code span's backticks are what block parsing claims, and its segment names its node; a segment naming none leaves takeFallback to refuse the line.
|
||||
function lineOpener(segments: readonly InlineSegment[], line: number): NodeRange {
|
||||
let breaks = 0
|
||||
const opener = segments.find((segment) => {
|
||||
const opens = breaks === line && segment.text !== ''
|
||||
breaks += segment.text.split('\n').length - 1
|
||||
return opens
|
||||
})
|
||||
return opener?.nodes ?? { first: 0, last: -1 }
|
||||
}
|
||||
|
||||
// The fallbacks in the order a line takes them, or the line where it takes none.
|
||||
@@ -238,7 +247,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
||||
const types = nodeMarks(node).map((mark) => mark.type)
|
||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||
const model = inlineNodeModel(node.type)
|
||||
if (model === undefined) return emitText(node, context, index, path)
|
||||
if (model === undefined) return success(emitText(node, context, index))
|
||||
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
|
||||
return emitInlineDirective(node, model, index, path)
|
||||
}
|
||||
@@ -260,38 +269,27 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
|
||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
|
||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||
const spans = slotLineEndingFault(node.type, slot)
|
||||
if (spans !== undefined) return faulted(spans, path)
|
||||
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
|
||||
if (slotLineEndingFault(node.type, slot) !== undefined || holdsNullCharacter(slot)) return success({ carry: { first: index, last: index } })
|
||||
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
||||
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
||||
}
|
||||
|
||||
function textHoldingContent(node: AdfNode, path: ConvertErrorPath): Result<never> | undefined {
|
||||
return node.type === 'text' && nodeContent(node).length > 0 ? failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) : undefined
|
||||
}
|
||||
|
||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
const holding = textHoldingContent(node, path)
|
||||
if (holding !== undefined) return holding
|
||||
if (!isBareText(node)) return success({ carry: { first: index, last: index } })
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||
// spec/flavour.md, Inline nodes: CommonMark reads a carriage return as a line ending, and a null character as U+FFFD.
|
||||
function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
|
||||
if (!isBareText(node) || typeof node.text !== 'string' || node.text === '') return { carry: { first: index, last: index } }
|
||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||
return success({ segments: parts.map((part) => (part.startsWith('\n') ? textDirectiveSegment(part) : { escaping, text: part })) })
|
||||
const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => part !== '')
|
||||
return { segments: parts.map((part) => (part === '\r' ? syntax(' ') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) }
|
||||
}
|
||||
|
||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||
const path = nodePath(context, index)
|
||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
||||
const spelling = markSpelling(mark.type)
|
||||
if (spelling === undefined) return success({ carry: range })
|
||||
const attributes = spellMarkAttributes(mark, spelling)
|
||||
if (attributes === undefined) return success({ carry: range })
|
||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||
if (spelling.kind === 'code') return success(emitCodeSpan(nodes, depth, range))
|
||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
||||
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
|
||||
if (link !== undefined) return link
|
||||
@@ -325,20 +323,17 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
|
||||
}
|
||||
|
||||
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
|
||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||
const spans: string[] = []
|
||||
for (const node of nodes) {
|
||||
const holding = textHoldingContent(node, path)
|
||||
if (holding !== undefined) return holding
|
||||
if (!isBareText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange): Emission {
|
||||
const segments: InlineSegment[] = []
|
||||
for (const [offset, node] of nodes.entries()) {
|
||||
const { text } = node
|
||||
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
||||
if (!isBareText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return { carry: range }
|
||||
if (offset > 0) segments.push(syntax(textBreakSpelling))
|
||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
|
||||
const index = range.first + offset
|
||||
segments.push({ escaping: 'none', nodes: { first: index, last: index }, text: `${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}` })
|
||||
}
|
||||
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
|
||||
return { segments }
|
||||
}
|
||||
|
||||
function needsPadding(text: string): boolean {
|
||||
|
||||
@@ -304,10 +304,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
|
||||
assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
|
||||
})
|
||||
|
||||
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
|
||||
test('gives back the refusal the CommonMark spelling itself raises', () => {
|
||||
const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n'
|
||||
assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"')
|
||||
test('refuses the directive form of a blockquote opening with a code span the carry spells', () => {
|
||||
assert.equal(content(markdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')), 'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form')
|
||||
})
|
||||
|
||||
test('names the directive name no node reads back to', () => {
|
||||
@@ -1010,6 +1008,11 @@ test('names the content slot no lone plain text node reads back from', () => {
|
||||
assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
|
||||
})
|
||||
|
||||
test('reads the characters CommonMark rewrites from their spellings', () => {
|
||||
assert.deepEqual(content(markdownToAdf('a b!adf:text{text="\\u0000\\u0000"}\n')), [paragraph('a\rb\u0000\u0000')])
|
||||
assert.equal(content(markdownToAdf('!adf:text{text="\\u0000a"}\n')), 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them')
|
||||
})
|
||||
|
||||
test('reads the whitespace the reserved text directive carries', () => {
|
||||
assert.deepEqual(content(markdownToAdf('!adf:text{text=" "}a\n')), [paragraph(' a')])
|
||||
assert.deepEqual(content(markdownToAdf('a!adf:text{text="\\n"}b\n')), [paragraph('a\nb')])
|
||||
@@ -1018,7 +1021,7 @@ test('reads the whitespace the reserved text directive carries', () => {
|
||||
})
|
||||
|
||||
test('names the text directive spelling no whitespace run reads back from', () => {
|
||||
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, or one run of newlines: this one spells neither'
|
||||
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them'
|
||||
assert.equal(content(markdownToAdf('!adf:text{text=hi}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:text{text=" \\n"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:text{text=""}\n')), named)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
|
||||
const name = 'text'
|
||||
const whitespaceRun = /^(?:[ \t]+|\n+)$/
|
||||
const spelledRun = /^(?:[ \t]+|\n+|\0+)$/
|
||||
|
||||
export const textDirectiveName = name
|
||||
|
||||
@@ -14,6 +14,6 @@ export function readTextDirective(span: DirectiveSpan): Read<string> | undefined
|
||||
if (span.name !== name) return undefined
|
||||
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: this one spells neither`) }
|
||||
if (!spelledRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them`) }
|
||||
return spelled
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ export type ConvertErrorCode =
|
||||
| 'unknown-directive-name'
|
||||
| 'unmappable-html'
|
||||
| 'unmappable-image'
|
||||
| 'unspellable-character'
|
||||
| 'unspellable-line-start'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-nesting-depth'
|
||||
|
||||
Reference in New Issue
Block a user