Emitter 2a: the corpus runner, the canonical serializer and the CommonMark subset
CI / gate (push) Successful in 5s

This commit is contained in:
Mikael Göransson
2026-08-24 15:36:05 +02:00
parent 288ee1697c
commit 0728ea1cfb
16 changed files with 976 additions and 35 deletions
+160
View File
@@ -0,0 +1,160 @@
export type InlineSegment = {
kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax'
text: string
}
export type LineContainer = 'heading' | 'paragraph'
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const entityReference = /^&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});/
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*>/, /^<[^\s<>@]+@[^\s<>@]+>/]
const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
const linkOpener = /\](?=[([:])/
const orderedListMarker = /^\d{1,9}$/
const setextUnderline = /^=+$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
const unicodePunctuation = /[\p{P}\p{S}]/u
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
const escapableOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/]
const blockOpeners = [...escapableOpeners, /^\d{1,9}[.)](?:[ \t]|$)/]
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): string {
return escape(resolveEmphasis(segments), container)
}
export function lineOpensBlock(line: string): boolean {
return blockOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)
}
export function isWordCharacter(character: string): boolean {
return character !== '' && !unicodeWhitespace.test(character) && !unicodePunctuation.test(character)
}
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
const resolved = segments.map((segment) => ({ ...segment }))
const scan = resolved.map((segment) => segment.text).join('')
const offsets: number[] = []
let offset = 0
for (const segment of resolved) {
offsets.push(offset)
offset += segment.text.length
}
const open: number[] = []
for (let index = 0; index < resolved.length; index += 1) {
const segment = resolved[index]
if (segment === undefined) continue
if (segment.kind === 'emphasis-open') open.push(index)
if (segment.kind !== 'emphasis-close') continue
const openerIndex = open.pop()
const opener = openerIndex === undefined ? undefined : resolved[openerIndex]
if (openerIndex === undefined || opener === undefined) continue
const openOffset = offsets[openerIndex] ?? 0
const closeOffset = offsets[index] ?? 0
if (!isWordCharacter(charAt(scan, openOffset - 1)) && !isWordCharacter(charAt(scan, closeOffset + 1))) continue
opener.text = '*'
segment.text = '*'
}
return resolved
}
function escape(segments: readonly InlineSegment[], container: LineContainer): string {
const scan = segments.map((segment) => segment.text).join('')
const kinds: InlineSegment['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>()
let output = ''
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)) {
output += '\\'
escaped.add(index)
}
output += scan.charAt(index)
}
return output
}
function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
const character = scan.charAt(index)
const rest = scan.slice(index)
const line = rest.split('\n')[0] ?? ''
if (container === 'paragraph' && (index === 0 || scan.charAt(index - 1) === '\n')) {
if (escapableOpeners.some((opener) => opener.test(rest))) return true
if (thematicBreak.test(line)) return true
if (index > 0 && setextUnderline.test(line)) return true
}
if (container === 'paragraph' && (character === '.' || character === ')') && closesOrderedListMarker(scan, index)) return true
if (container === 'heading' && character === '#' && /^#+$/.test(rest) && (index === 0 || /[ \t]/.test(scan.charAt(index - 1)))) return true
if (inLinkText && (character === '[' || character === ']')) return true
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
if (character === '&') return entityReference.test(rest)
if (character === '<') return htmlConstructs.some((construct) => construct.test(rest))
if (character === ':') return inlineDirective.test(rest)
if (character === '[') return linkOpener.test(rest)
if (character === '`') return opensCodeSpan(scan, index, escaped)
if (character === '*' || character === '_' || character === '~') return opensEmphasis(scan, index, escaped)
return false
}
function closesOrderedListMarker(scan: string, index: number): boolean {
const lineStart = scan.lastIndexOf('\n', index - 1) + 1
if (!orderedListMarker.test(scan.slice(lineStart, index))) return false
const following = scan.charAt(index + 1)
return following === '' || following === ' ' || following === '\t' || following === '\n'
}
function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
if (!startsRun(scan, index, escaped)) return false
const length = runLength(scan, index)
return new RegExp('(?<!`)`{' + length + '}(?!`)').test(scan.slice(index + length))
}
function opensEmphasis(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
if (!startsRun(scan, index, escaped)) return false
const character = scan.charAt(index)
const length = runLength(scan, index)
const before = index === 0 ? '' : scan.charAt(index - 1)
const after = scan.charAt(index + length)
if (character === '~') return length === 2 && isLeftFlanking(before, after)
if (!isLeftFlanking(before, after)) return false
if (character === '*') return true
return !isRightFlanking(before, after) || isPunctuation(before)
}
function startsRun(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
if (index === 0 || escaped.has(index - 1)) return true
return scan.charAt(index - 1) !== scan.charAt(index)
}
function runLength(scan: string, index: number): number {
const character = scan.charAt(index)
let length = 0
while (scan.charAt(index + length) === character) length += 1
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 === '' || unicodeWhitespace.test(character)
}
function charAt(text: string, index: number): string {
return index < 0 ? '' : text.charAt(index)
}