Files
adf-codec/src/markdown/commonmark-grammar.ts
T
2026-09-01 20:11:41 +02:00

241 lines
10 KiB
TypeScript

import { readEntityReference } from './entity-references.ts'
export type LinePosition = 'first' | 'later'
type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
const htmlConstructNames = {
cdata: 'a CDATA section',
comment: 'an HTML comment',
declaration: 'an HTML declaration',
processingInstruction: 'an HTML processing instruction',
}
const controlCharacterRange = '\\u0000-\\u001f\\u007f'
const autolinkSource = `[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\\s<>${controlCharacterRange}]*`
const nullCharacterSource = '\\u0000'
const tagNameSource = '[A-Za-z][A-Za-z0-9-]*'
const htmlSpaceSource = '[ \\t\\n]'
const attributeSource = `(?:${htmlSpaceSource}+[A-Za-z_:][A-Za-z0-9_.:-]*(?:${htmlSpaceSource}*=${htmlSpaceSource}*(?:[^ \\t\\n"'=<>\`]+|'[^']*'|"[^"]*"))?)`
const htmlTagSource = `(?:<${tagNameSource}${attributeSource}*${htmlSpaceSource}*/?>|</${tagNameSource}${htmlSpaceSource}*>)`
const autolink = new RegExp(`^(?:${autolinkSource})$`)
const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y')
const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const htmlTag = new RegExp(htmlTagSource, 'y')
const nullCharacter = new RegExp(nullCharacterSource)
const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`)
// The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`).
const inlineHtmlConstructs = [
{ name: htmlConstructNames.cdata, opener: /<!\[CDATA\[/y, terminator: ']]>' },
{ name: htmlConstructNames.comment, opener: /<!(?:--->|-->|--)/y, terminator: '-->' },
{ name: htmlConstructNames.declaration, opener: /<![A-Za-z]/y, terminator: '>' },
{ name: htmlConstructNames.processingInstruction, opener: /<\?/y, terminator: '?>' },
]
// CommonMark 0.31.2, HTML blocks: the tag names start condition 6 lists.
const blockTagNames =
'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul'
const completeTag = new RegExp(`^${htmlTagSource}[ \\t]*$`)
const htmlBlockConditions: HtmlBlockCondition[] = [
{ closer: /<\/(?:pre|script|style|textarea)>/i, construct: undefined, interrupts: true, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
{ closer: /-->/, construct: htmlConstructNames.comment, interrupts: true, start: /^<!--/ },
{ closer: /\?>/, construct: htmlConstructNames.processingInstruction, interrupts: true, start: /^<\?/ },
{ closer: />/, construct: htmlConstructNames.declaration, interrupts: true, start: /^<![A-Za-z]/ },
{ closer: /\]\]>/, construct: htmlConstructNames.cdata, interrupts: true, start: /^<!\[CDATA\[/ },
{ closer: undefined, construct: undefined, interrupts: true, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
{ closer: undefined, construct: undefined, interrupts: false, start: completeTag },
]
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
const codeFenceOpener = /^(`{3,}|~{3,})/
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
const pipeClaim = /^\|/
const bulletListOpener = /^[*+-](?:[ \t]|$)/
// A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip.
const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFenceOpener, /^:{2,}/, pipeClaim]
// CommonMark 0.31.2, Autolinks: the email production, whose label may not open or close with a hyphen.
const emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+"
const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/
const setextUnderline = /^(=+|-+)[ \t]*$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
export function atxHeading(line: string): { level: number; text: string } | undefined {
const hashes = atxHeadingOpener.exec(line)?.[1]
if (hashes === undefined) return undefined
const text = trimSpace(line.slice(hashes.length))
return { level: hashes.length, text: trimSpace(text.replace(/(?:^|(?<=[ \t]))#+$/, '')) }
}
// The character a backslash escapes at `index`, `undefined` where the backslash is literal text.
export function backslashEscape(text: string, index: number): string | undefined {
if (text.charAt(index) !== '\\') return undefined
const escaped = text.charAt(index + 1)
return isAsciiPunctuation(escaped) ? escaped : undefined
}
export function claimsDirectiveLine(line: string): boolean {
return directiveClaim.test(line)
}
export function claimsLine(line: string, position: LinePosition): boolean {
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
}
export function claimsPipeLine(line: string): boolean {
return pipeClaim.test(line)
}
export function closingCodeFence(line: string, marker: string): boolean {
const closing = codeFenceOpener.exec(line)?.[1]
if (closing === undefined || closing.charAt(0) !== marker.charAt(0) || closing.length < marker.length) return false
return /^[ \t]*$/.test(line.slice(closing.length))
}
export function decodeTextEscapes(text: string): string {
let decoded = ''
let index = 0
while (index < text.length) {
const escaped = backslashEscape(text, index)
if (escaped !== undefined) {
decoded += escaped
index += 2
continue
}
const reference = readEntityReference(text, index)
if (reference !== undefined) {
decoded += reference.text
index += reference.length
continue
}
decoded += text.charAt(index)
index += 1
}
return decoded
}
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
if (offset === 0) {
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true
if (openingHtmlBlock(line, position === 'later') !== undefined) return true
return position === 'later' && setextUnderline.test(line)
}
const digits = orderedListOpener.exec(line)?.[1]
return digits !== undefined && offset === digits.length
}
export function holdsControlCharacter(text: string): boolean {
return controlCharacter.test(text)
}
export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text)
}
function htmlTagName(text: string): string {
return text.replace(tagName, '<$1>')
}
export function inlineHtmlConstruct(text: string, index: number): string | undefined {
for (const construct of inlineHtmlConstructs) {
construct.opener.lastIndex = index
const opened = construct.opener.exec(text)?.[0]
if (opened === undefined) continue
if (opened.endsWith(construct.terminator)) return construct.name
return text.includes(construct.terminator, index + opened.length) ? construct.name : undefined
}
htmlTag.lastIndex = index
const tag = htmlTag.exec(text)?.[0]
return tag === undefined ? undefined : htmlTagName(tag)
}
export function isAsciiPunctuation(character: string): boolean {
return asciiPunctuation.test(character)
}
export function isAutolink(text: string): boolean {
return autolink.test(text)
}
export function isThematicBreak(line: string): boolean {
return thematicBreak.test(line)
}
export function isUnicodeWhitespace(character: string): boolean {
return unicodeWhitespace.test(character)
}
// `start` is the list's first number, `undefined` for a bullet.
export function listMarker(line: string): { start: number | undefined; width: number } | undefined {
const ordered = orderedListOpener.exec(line)
if (ordered !== null) {
const digits = ordered[1] ?? ''
return { start: Number(digits), width: digits.length + 1 }
}
return bulletListOpener.test(line) ? { start: undefined, width: 1 } : undefined
}
export function markerInterruptsParagraph(start: number | undefined, empty: boolean): boolean {
return !empty && (start === undefined || start === 1)
}
export function openingCodeFence(line: string): { info: string; marker: string } | undefined {
const marker = codeFenceOpener.exec(line)?.[1]
if (marker === undefined) return undefined
const info = trimSpace(line.slice(marker.length))
return marker.startsWith('`') && info.includes('`') ? undefined : { info, marker }
}
export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlBlock | undefined {
for (const condition of htmlBlockConditions) {
if ((interrupting && !condition.interrupts) || !condition.start.test(line)) continue
return { closer: condition.closer, construct: condition.construct ?? htmlTagName(line) }
}
return undefined
}
export function opensBracketedAutolink(text: string, index: number): boolean {
return readBracketedAutolink(text, index) !== undefined
}
export function opensEmailAutolink(text: string, index: number): boolean {
return readEmailAutolink(text, index) !== undefined
}
export function readBracketedAutolink(text: string, index: number): number | undefined {
bracketedAutolink.lastIndex = index
return bracketedAutolink.exec(text)?.[0].length
}
export function readEmailAutolink(text: string, index: number): number | undefined {
emailAutolink.lastIndex = index
return emailAutolink.exec(text)?.[0].length
}
export function setextHeadingLevel(line: string): number | undefined {
const underline = setextUnderline.exec(line)?.[1]
if (underline === undefined) return undefined
return underline.startsWith('=') ? 1 : 2
}
function spaceOrTab(character: string): boolean {
return character === ' ' || character === '\t'
}
export function trimSpace(text: string): string {
let start = 0
while (start < text.length && spaceOrTab(text.charAt(start))) start += 1
return trimTrailingSpace(text.slice(start))
}
export function trimTrailingSpace(text: string): string {
let end = text.length
while (end > 0 && spaceOrTab(text.charAt(end - 1))) end -= 1
return text.slice(0, end)
}