Judge a delimiter by the run it sits in, not the segment it came from
CI / gate (push) Successful in 4s

This commit is contained in:
2026-08-25 09:55:36 +02:00
parent cccbfd3f2d
commit 91d5cd6c43
3 changed files with 38 additions and 11 deletions
+4
View File
@@ -190,6 +190,10 @@ test('refuses a mark spelling that cannot open or close where it sits', () => {
assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n') 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(marked('a.', strong)), '**a.**\n')
assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n') assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n')
const em: AdfMark = { type: 'em' }
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong))))), 'unspellable-mark')
assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n')
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
}) })
test('refuses marks and attributes nested deeper than the emitter carries', () => { test('refuses marks and attributes nested deeper than the emitter carries', () => {
+29 -8
View File
@@ -2,13 +2,16 @@ import { escapesLineClaim, isUnicodeWhitespace, opensBracketedAutolink, startsEn
export type InlineSegment = { export type InlineSegment = {
kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax' kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax'
mark?: string
text: string text: string
} }
export type AssembledLine = { line: string; unspellableDelimiter: string | undefined } export type AssembledLine = { line: string; unspellableMark: string | undefined }
export type LineContainer = 'heading' | 'paragraph' export type LineContainer = 'heading' | 'paragraph'
type DelimiterRun = { character: string; closes: boolean; end: number; mark: string; opens: boolean; start: number }
const delimiters = ['*', '_', '`', '~'] const delimiters = ['*', '_', '`', '~']
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
@@ -67,21 +70,39 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
placements.push(output.length) placements.push(output.length)
output += scan.charAt(index) output += scan.charAt(index)
} }
return { line: output, unspellableDelimiter: unspellableDelimiter(segments, output, placements) } return { line: output, unspellableMark: unspellableMark(segments, output, placements) }
} }
function unspellableDelimiter(segments: readonly InlineSegment[], output: string, placements: readonly number[]): string | undefined { function unspellableMark(segments: readonly InlineSegment[], output: string, placements: readonly number[]): string | undefined {
for (const run of delimiterRuns(segments, placements)) {
const before = charAt(output, run.start - 1)
const after = output.charAt(run.end)
if (run.opens && !isLeftFlanking(before, after)) return run.mark
if (run.closes && !isRightFlanking(before, after)) return run.mark
}
return undefined
}
function delimiterRuns(segments: readonly InlineSegment[], placements: readonly number[]): DelimiterRun[] {
const runs: DelimiterRun[] = []
let cursor = 0 let cursor = 0
for (const segment of segments) { for (const segment of segments) {
const start = placements[cursor] ?? 0 const start = placements[cursor] ?? 0
cursor += segment.text.length cursor += segment.text.length
if (segment.kind !== 'emphasis-close' && segment.kind !== 'emphasis-open') continue if (segment.kind !== 'emphasis-close' && segment.kind !== 'emphasis-open') continue
const before = charAt(output, start - 1) const closes = segment.kind === 'emphasis-close'
const after = output.charAt(start + segment.text.length) const end = start + segment.text.length
const spellable = segment.kind === 'emphasis-open' ? isLeftFlanking(before, after) : isRightFlanking(before, after) const mark = segment.mark ?? segment.text
if (!spellable) return segment.text const previous = runs[runs.length - 1]
if (previous !== undefined && previous.end === start && previous.character === segment.text.charAt(0)) {
previous.closes = previous.closes || closes
previous.end = end
previous.opens = previous.opens || !closes
continue
} }
return undefined runs.push({ character: segment.text.charAt(0), closes, end, mark, opens: !closes, start })
}
return runs
} }
function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean { function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean {
+5 -3
View File
@@ -21,8 +21,8 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
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 assembled = assembleInlineLine(segments.value, container) const assembled = assembleInlineLine(segments.value, container)
if (assembled.unspellableDelimiter !== undefined) { if (assembled.unspellableMark !== undefined) {
return failure('unspellable-mark', `the ${assembled.unspellableDelimiter} spelling cannot open or close where it sits`, path) return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path)
} }
const line = assembled.line const line = assembled.line
for (const [index, single] of line.split('\n').entries()) { for (const [index, single] of line.split('\n').entries()) {
@@ -80,6 +80,8 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
if (unspelled !== undefined) { if (unspelled !== undefined) {
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path) return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path)
} }
const types = (node.marks ?? []).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)
if (node.type === 'hardBreak') { if (node.type === 'hardBreak') {
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }]) if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
return success([{ kind: 'syntax', text: '\\\n' }]) return success([{ kind: 'syntax', text: '\\\n' }])
@@ -102,7 +104,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)
return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }]) return success([{ kind: 'emphasis-open', mark: mark.type, text: spelling }, ...inner.value, { kind: 'emphasis-close', mark: mark.type, text: spelling }])
} }
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> { function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {