Give CommonMark's emphasis section one home, and split the collision property
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 12:53:15 +02:00
parent e4a18dec43
commit a8bea25b7c
6 changed files with 155 additions and 110 deletions
+59 -51
View File
@@ -1,5 +1,5 @@
import { escapesLineClaim, isUnicodeWhitespace, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
import { unmatchedPair, type EmphasisDelimiter, type EmphasisRun } from './emphasis-matching.ts'
import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis } from './emphasis-matching.ts'
export type EmphasisRole = 'close' | 'open'
@@ -15,7 +15,9 @@ export type AssembledLine = { line: string; unspellableRun: NodeRange | undefine
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
type DelimiterGroup = { character: string; delimiters: EmphasisDelimiter[]; end: number; start: number }
type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
const delimiters = ['*', '_', '`', '~']
@@ -23,7 +25,6 @@ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/
const followsLinkText = /[([:]/
const unicodePunctuation = /[\p{P}\p{S}]/u
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
return escape(resolveEmphasis(segments), container)
@@ -79,18 +80,43 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
}
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined {
const { nodes, runs } = emphasisRuns(segments, placements, output)
for (const run of runs) {
for (const delimiter of run.delimiters) {
if (!(delimiter.closes ? run.canClose : run.canOpen)) return nodes[delimiter.pair]
}
}
const unmatched = unmatchedPair(runs)
return unmatched === undefined ? undefined : nodes[unmatched]
const { nodes, runs } = emittedRuns(segments, placements, output)
const pair = misflanked(runs) ?? unpaired(runs)
return pair === undefined ? undefined : nodes[pair]
}
function emphasisRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: NodeRange[]; runs: EmphasisRun[] } {
const groups: DelimiterGroup[] = []
function misflanked(runs: readonly EmittedRun[]): number | undefined {
for (const run of runs) {
for (const delimiter of run.delimiters) {
if (!(delimiter.closes ? run.canClose : run.canOpen)) return delimiter.pair
}
}
return undefined
}
function unpaired(runs: readonly EmittedRun[]): number | undefined {
const matched = new Set<number>()
for (const pairing of matchEmphasis(runs)) {
const opened = delimiterAt(pairing.opener, false, pairing.openerOffset, pairing.used)
const closed = delimiterAt(pairing.closer, true, pairing.closerOffset, pairing.used)
if (opened !== undefined && closed !== undefined && opened.pair === closed.pair) matched.add(opened.pair)
}
// The last opener left unpaired is the innermost: the smallest carry that changes the line.
let innermost: number | undefined
for (const run of runs) {
for (const delimiter of run.delimiters) {
if (!delimiter.closes && !matched.has(delimiter.pair)) innermost = delimiter.pair
}
}
return innermost
}
function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: number): EmittedDelimiter | undefined {
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
}
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: NodeRange[]; runs: EmittedRun[] } {
const runs: EmittedRun[] = []
const nodes: NodeRange[] = []
const open: number[] = []
let cursor = 0
@@ -99,30 +125,33 @@ function emphasisRuns(segments: readonly InlineSegment[], placements: readonly n
cursor += segment.text.length
if (segment.emphasis === undefined) continue
const closes = segment.emphasis === 'close'
const end = start + segment.text.length
const pair = closes ? (open.pop() ?? nodes.length) : nodes.length
if (!closes) {
nodes.push(segment.nodes)
open.push(pair)
}
const delimiter = { closes, end, pair, start }
const previous = groups[groups.length - 1]
if (previous !== undefined && previous.end === start && previous.character === segment.text.charAt(0)) {
previous.delimiters.push(delimiter)
previous.end = end
const width = segment.text.length
const previous = runs[runs.length - 1]
if (previous !== undefined && previous.start + previous.length === start && previous.character === segment.text.charAt(0)) {
previous.delimiters.push({ closes, offset: start - previous.start, pair, width })
previous.length += width
continue
}
groups.push({ character: segment.text.charAt(0), delimiters: [delimiter], end, start })
runs.push({
canClose: false,
canOpen: false,
character: segment.text.charAt(0),
delimiters: [{ closes, offset: 0, pair, width }],
length: width,
start,
})
}
return { nodes, runs: groups.map((group) => ({ ...group, ...delimiterFlags(group.character, charAt(output, group.start - 1), output.charAt(group.end)) })) }
}
// spec/flavour.md, Canonical form: CommonMark's own can-open and can-close, which `~` follows too.
function delimiterFlags(character: string, before: string, after: string): { canClose: boolean; canOpen: boolean } {
const left = isLeftFlanking(before, after)
const right = isRightFlanking(before, after)
if (character !== '_') return { canClose: right, canOpen: left }
return { canClose: right && (!left || isPunctuation(after)), canOpen: left && (!right || isPunctuation(before)) }
for (const run of runs) {
const flags = delimiterFlags(run.character, charAt(output, run.start - 1), output.charAt(run.start + run.length))
run.canClose = flags.canClose
run.canOpen = flags.canOpen
}
return { nodes, runs }
}
function mergesWithSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
@@ -228,29 +257,8 @@ function runLength(scan: string, index: number): number {
return length
}
function isLeftFlanking(before: string, after: string): boolean {
if (isWhitespace(after)) return false
if (!isPunctuation(after)) return true
return isWhitespace(before) || isPunctuation(before)
}
function isRightFlanking(before: string, after: string): boolean {
if (isWhitespace(before)) return false
if (!isPunctuation(before)) return true
return isWhitespace(after) || isPunctuation(after)
}
function isPunctuation(character: string): boolean {
return character !== '' && unicodePunctuation.test(character)
}
function isWhitespace(character: string): boolean {
return character === '' || isUnicodeWhitespace(character)
}
function isWordCharacter(character: string): boolean {
return character !== '' && !isUnicodeWhitespace(character) && !unicodePunctuation.test(character)
}
function charAt(text: string, index: number): string {
return index < 0 ? '' : text.charAt(index)