Files

1248 lines
36 KiB
TypeScript

// CommonMark 0.31.2 inline parser (a port of commonmark.js's delimiter-stack and bracket-stack algorithm),
// extended with GFM strikethrough and extended autolinks, and OpenCode's `$…$`, `$$…$$`, and `\(…\)` math.
import { decodeEntity, ENTITY_PATTERN, ESCAPABLE, normalizeURI, unescapeString } from "./entities"
import type { InlineNode, MathDelimiter } from "./types"
export { healStreamingInline } from "./heal"
export interface ReferenceDef {
href: string
title?: string
/** Source text of the definition (container prefixes stripped), for renderers that re-parse a block on its own */
raw?: string
/** 1-based start line of the paragraph that defined this reference */
line?: number
}
export type RefMap = Map<string, ReferenceDef>
export interface InlineParseOptions {
gfm?: boolean
math?: boolean
defs?: ReadonlyMap<string, ReferenceDef>
unresolvedRefs?: Set<string>
usedRefs?: Set<string>
}
const C_NEWLINE = 10
const C_ASTERISK = 42
const C_UNDERSCORE = 95
const C_BACKTICK = 96
const C_OPEN_BRACKET = 91
const C_CLOSE_BRACKET = 93
const C_LESSTHAN = 60
const C_BANG = 33
const C_BACKSLASH = 92
const C_AMPERSAND = 38
const C_OPEN_PAREN = 40
const C_CLOSE_PAREN = 41
const C_COLON = 58
const C_TILDE = 126
const C_DOLLAR = 36
const ESCAPED_CHAR = "\\\\" + ESCAPABLE
const TAGNAME = "[A-Za-z][A-Za-z0-9-]*"
const ATTRIBUTENAME = "[a-zA-Z_:][a-zA-Z0-9:._-]*"
const UNQUOTEDVALUE = "[^\"'=<>`\\x00-\\x20]+"
const SINGLEQUOTEDVALUE = "'[^']*'"
const DOUBLEQUOTEDVALUE = '"[^"]*"'
const ATTRIBUTEVALUE = "(?:" + UNQUOTEDVALUE + "|" + SINGLEQUOTEDVALUE + "|" + DOUBLEQUOTEDVALUE + ")"
const ATTRIBUTEVALUESPEC = "(?:\\s*=\\s*" + ATTRIBUTEVALUE + ")"
const ATTRIBUTE = "(?:\\s+" + ATTRIBUTENAME + ATTRIBUTEVALUESPEC + "?)"
export const OPENTAG = "<" + TAGNAME + ATTRIBUTE + "*\\s*/?>"
export const CLOSETAG = "</" + TAGNAME + "\\s*[>]"
const HTMLCOMMENT = "<!-->|<!--->|<!--[\\s\\S]*?-->"
const PROCESSINGINSTRUCTION = "[<][?][\\s\\S]*?[?][>]"
const DECLARATION = "<![A-Za-z]+[^>]*>"
const CDATA = "<!\\[CDATA\\[[\\s\\S]*?\\]\\]>"
const HTMLTAG = "(?:" + OPENTAG + "|" + CLOSETAG + "|" + HTMLCOMMENT + "|" + PROCESSINGINSTRUCTION + "|" + DECLARATION + "|" + CDATA + ")"
const reHtmlTag = new RegExp(HTMLTAG, "y")
const rePunctuation = /^[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~\p{P}\p{S}]/u
const reLinkTitle = new RegExp(
'(?:"(' +
ESCAPED_CHAR +
'|\\\\[^\\\\]|[^\\\\"\\x00])*"' +
"|'(" +
ESCAPED_CHAR +
"|\\\\[^\\\\]|[^\\\\'\\x00])*'" +
"|\\((" +
ESCAPED_CHAR +
"|\\\\[^\\\\]|[^\\\\()\\x00])*\\))",
"y",
)
const reLinkDestinationBraces = /(?:<(?:[^<>\n\\\x00]|\\.)*>)/y
const reEscapable = new RegExp("^" + ESCAPABLE)
const reEntityHere = new RegExp(ENTITY_PATTERN, "iy")
const reTicks = /`+/g
const reTicksHere = /`+/y
const reEmailAutolink =
/<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/y
const reAutolink = /<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>/iy
const reSpnl = / *(?:\n *)?/y
const reWhitespaceChar = /^[ \t\n\x0b\x0c\x0d]/
const reUnicodeWhitespaceChar = /^\s/
const reFinalSpace = / *$/
const reInitialSpace = / */y
const reSpaceAtEndOfLine = / *(?:\n|$)/y
const reLinkLabel = /\[(?:[^\\[\]]|\\.){0,1000}\]/sy
const reMainCore = /[^\n`[\]\\!<&*_'"]+/y
const reMainGfm = /[^\n`[\]\\!<&*_'"~]+/y
const reMainMath = /[^\n`[\]\\!<&*_'"$]+/y
const reMainGfmMath = /[^\n`[\]\\!<&*_'"~$]+/y
// OpenCode's marked math extension (packages/ui/src/context/marked-parser.tsx), verbatim
const reInlineDollarMath =
/\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$|\$(?=[^\s$])((?:\\.|[^\\\n$])*?(?:\\\S|[^\s\\$]))\$(?!\w)/y
const reInlineParenMath = /\\\(((?:\\.|[^\\\n])*?)\\\)/y
type NodeType =
| "text"
| "softbreak"
| "linebreak"
| "code"
| "emph"
| "strong"
| "del"
| "link"
| "image"
| "html_inline"
| "math"
| "root"
class INode {
type: NodeType
literal = ""
escaped = false
destination = ""
title = ""
delimiter: MathDelimiter = "dollar"
display = false
parent: INode | null = null
first: INode | null = null
last: INode | null = null
next: INode | null = null
prev: INode | null = null
constructor(type: NodeType, literal = "") {
this.type = type
this.literal = literal
}
appendChild(child: INode) {
child.unlink()
child.parent = this
if (this.last) {
this.last.next = child
child.prev = this.last
this.last = child
} else {
this.first = child
this.last = child
}
}
insertAfter(sibling: INode) {
sibling.unlink()
sibling.next = this.next
if (sibling.next) sibling.next.prev = sibling
sibling.prev = this
this.next = sibling
sibling.parent = this.parent
if (sibling.parent && !sibling.next) sibling.parent.last = sibling
}
insertBefore(sibling: INode) {
sibling.unlink()
sibling.prev = this.prev
if (sibling.prev) sibling.prev.next = sibling
sibling.next = this
this.prev = sibling
sibling.parent = this.parent
if (sibling.parent && !sibling.prev) sibling.parent.first = sibling
}
unlink() {
if (this.prev) this.prev.next = this.next
else if (this.parent) this.parent.first = this.next
if (this.next) this.next.prev = this.prev
else if (this.parent) this.parent.last = this.prev
this.parent = null
this.next = null
this.prev = null
}
}
interface Delimiter {
cc: number
numdelims: number
origdelims: number
node: INode
previous: Delimiter | null
next: Delimiter | null
canOpen: boolean
canClose: boolean
}
interface Bracket {
node: INode
previous: Bracket | null
previousDelimiter: Delimiter | null
index: number
image: boolean
active: boolean
bracketAfter: boolean
}
const text = (s: string) => new INode("text", s)
/** Normalize a link label: strip brackets, trim, collapse whitespace, Unicode case fold */
export function normalizeReference(label: string): string {
return label
.slice(1, label.length - 1)
.trim()
.replace(/[ \t\r\n]+/g, " ")
.toLowerCase()
.toUpperCase()
}
function isSpecialFastPath(src: string, gfm: boolean, math: boolean): boolean {
for (let i = 0; i < src.length; i++) {
const c = src.charCodeAt(i)
if (
c === C_NEWLINE ||
c === C_BACKSLASH ||
c === C_BACKTICK ||
c === C_ASTERISK ||
c === C_UNDERSCORE ||
c === C_OPEN_BRACKET ||
c === C_CLOSE_BRACKET ||
c === C_BANG ||
c === C_LESSTHAN ||
c === C_AMPERSAND ||
(gfm && (c === C_TILDE || c === C_COLON || c === 0x40 /* @ */ || c === 0x2e /* . */)) ||
(math && c === C_DOLLAR)
) {
return true
}
}
return false
}
class InlineParser {
subject = ""
pos = 0
delimiters: Delimiter | null = null
brackets: Bracket | null = null
refmap: ReadonlyMap<string, ReferenceDef> = new Map()
gfm = false
math = false
reMain = reMainCore
tickRuns: Map<number, number[]> | null = null
unresolvedRefs?: Set<string>
usedRefs?: Set<string>
/** `re` must be sticky (anchored at `pos`) or global (searching forward from `pos`) */
match(re: RegExp): string | null {
re.lastIndex = this.pos
const m = re.exec(this.subject)
if (m === null) return null
this.pos = re.lastIndex
return m[0]
}
peek(): number {
return this.pos < this.subject.length ? this.subject.charCodeAt(this.pos) : -1
}
spnl(): true {
this.match(reSpnl)
return true
}
parseBackticks(block: INode): boolean {
const ticks = this.match(reTicksHere)
if (ticks === null) return false
const afterOpenTicks = this.pos
const len = ticks.length
if (this.tickRuns === null) {
const first = this.match(reTicks)
if (first === null) {
this.pos = afterOpenTicks
block.appendChild(text(ticks))
return true
}
if (first.length === len) {
const node = new INode("code")
const contents = this.subject.slice(afterOpenTicks, this.pos - len).replace(/\n/gm, " ")
if (contents.length > 0 && /[^ ]/.test(contents) && contents[0] === " " && contents[contents.length - 1] === " ") {
node.literal = contents.slice(1, contents.length - 1)
} else {
node.literal = contents
}
block.appendChild(node)
return true
}
const map = new Map<number, number[]>()
map.set(first.length, [this.pos])
let m: string | null
while ((m = this.match(reTicks)) !== null) {
let list = map.get(m.length)
if (!list) map.set(m.length, (list = []))
list.push(this.pos)
}
this.tickRuns = map
}
const candidates = this.tickRuns.get(len)
if (candidates) {
for (let i = 0; i < candidates.length; i++) {
const endPos = candidates[i]!
if (endPos - len >= afterOpenTicks) {
this.pos = endPos
const node = new INode("code")
const contents = this.subject.slice(afterOpenTicks, endPos - len).replace(/\n/gm, " ")
if (contents.length > 0 && /[^ ]/.test(contents) && contents[0] === " " && contents[contents.length - 1] === " ") {
node.literal = contents.slice(1, contents.length - 1)
} else {
node.literal = contents
}
block.appendChild(node)
return true
}
}
}
this.pos = afterOpenTicks
block.appendChild(text(ticks))
return true
}
parseBackslash(block: INode): boolean {
const subj = this.subject
if (this.math) {
reInlineParenMath.lastIndex = this.pos
const m = reInlineParenMath.exec(subj)
if (m) {
const node = new INode("math", m[1]!.trim())
node.delimiter = "paren"
node.display = false
block.appendChild(node)
this.pos += m[0].length
return true
}
}
this.pos += 1
if (this.peek() === C_NEWLINE) {
this.pos += 1
block.appendChild(new INode("linebreak"))
} else if (reEscapable.test(subj.charAt(this.pos))) {
const node = text(subj.charAt(this.pos))
node.escaped = true
block.appendChild(node)
this.pos += 1
} else {
block.appendChild(text("\\"))
}
return true
}
parseDollar(block: INode): boolean {
reInlineDollarMath.lastIndex = this.pos
const m = reInlineDollarMath.exec(this.subject)
if (!m) return false
const display = m[1] !== undefined
const node = new INode("math", (m[1] ?? m[2])!.trim())
node.delimiter = display ? "double-dollar" : "dollar"
node.display = display
block.appendChild(node)
this.pos += m[0].length
return true
}
parseAutolink(block: INode): boolean {
let m: string | null
if ((m = this.match(reEmailAutolink))) {
const dest = m.slice(1, m.length - 1)
const node = new INode("link")
node.destination = normalizeURI("mailto:" + dest)
node.appendChild(text(dest))
block.appendChild(node)
return true
}
if ((m = this.match(reAutolink))) {
const dest = m.slice(1, m.length - 1)
const node = new INode("link")
node.destination = normalizeURI(dest)
node.appendChild(text(dest))
block.appendChild(node)
return true
}
return false
}
parseHtmlTag(block: INode): boolean {
const m = this.match(reHtmlTag)
if (m === null) return false
block.appendChild(new INode("html_inline", m))
return true
}
scanDelims(cc: number): { numdelims: number; canOpen: boolean; canClose: boolean } | null {
let numdelims = 0
const startpos = this.pos
while (this.peek() === cc) {
numdelims++
this.pos++
}
if (numdelims === 0) return null
const charBefore = startpos === 0 ? "\n" : this.subject.charAt(startpos - 1)
const ccAfter = this.peek()
const charAfter = ccAfter === -1 ? "\n" : this.subject.charAt(this.pos)
const afterIsWhitespace = reUnicodeWhitespaceChar.test(charAfter)
const afterIsPunctuation = rePunctuation.test(charAfter)
const beforeIsWhitespace = reUnicodeWhitespaceChar.test(charBefore)
const beforeIsPunctuation = rePunctuation.test(charBefore)
const leftFlanking =
(!afterIsWhitespace && (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation)) ||
(this.gfm && cc !== C_TILDE && charAfter === "~")
const rightFlanking =
(!beforeIsWhitespace && (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation)) ||
(this.gfm && cc !== C_TILDE && charBefore === "~")
let canOpen: boolean
let canClose: boolean
if (cc === C_UNDERSCORE) {
canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation)
canClose = rightFlanking && (!leftFlanking || afterIsPunctuation)
} else {
canOpen = leftFlanking
canClose = rightFlanking
}
if (cc === C_TILDE && numdelims > 2) {
canOpen = false
canClose = false
}
this.pos = startpos
return { numdelims, canOpen, canClose }
}
handleDelim(cc: number, block: INode): boolean {
const res = this.scanDelims(cc)
if (!res) return false
const numdelims = res.numdelims
const startpos = this.pos
this.pos += numdelims
const node = text(this.subject.slice(startpos, this.pos))
block.appendChild(node)
if (res.canOpen || res.canClose) {
this.delimiters = {
cc,
numdelims,
origdelims: numdelims,
node,
previous: this.delimiters,
next: null,
canOpen: res.canOpen,
canClose: res.canClose,
}
if (this.delimiters.previous !== null) this.delimiters.previous.next = this.delimiters
}
return true
}
removeDelimiter(delim: Delimiter) {
if (delim.previous !== null) delim.previous.next = delim.next
if (delim.next === null) this.delimiters = delim.previous
else delim.next.previous = delim.previous
}
processEmphasis(stackBottom: Delimiter | null) {
if (this.delimiters === stackBottom) return
const openersBottom: (Delimiter | null)[] = []
for (let i = 0; i < 16; i++) openersBottom[i] = stackBottom
let closer = this.delimiters
while (closer !== null && closer.previous !== stackBottom) closer = closer.previous
while (closer !== null) {
const closercc = closer.cc
if (!closer.canClose) {
closer = closer.next
continue
}
let opener = closer.previous
let openerFound = false
const openersBottomIndex =
closercc === C_UNDERSCORE
? 2 + (closer.canOpen ? 3 : 0) + (closer.origdelims % 3)
: closercc === C_ASTERISK
? 8 + (closer.canOpen ? 3 : 0) + (closer.origdelims % 3)
: 14 + (closer.origdelims === 2 ? 1 : 0)
while (opener !== null && opener !== stackBottom && opener !== openersBottom[openersBottomIndex]) {
if (closercc === C_TILDE) {
if (opener.cc === C_TILDE && opener.canOpen && opener.origdelims === closer.origdelims) {
openerFound = true
break
}
} else {
const oddMatch =
(closer.canOpen || opener.canClose) &&
closer.origdelims % 3 !== 0 &&
(opener.origdelims + closer.origdelims) % 3 === 0
if (opener.cc === closer.cc && opener.canOpen && !oddMatch) {
openerFound = true
break
}
}
opener = opener.previous
}
const oldCloser = closer
if (!openerFound) {
closer = closer.next
} else {
const op = opener!
const useDelims = closercc === C_TILDE ? closer.numdelims : closer.numdelims >= 2 && op.numdelims >= 2 ? 2 : 1
const openerInl = op.node
const closerInl = closer.node
op.numdelims -= useDelims
closer.numdelims -= useDelims
openerInl.literal = openerInl.literal.slice(0, openerInl.literal.length - useDelims)
closerInl.literal = closerInl.literal.slice(0, closerInl.literal.length - useDelims)
const emph = new INode(closercc === C_TILDE ? "del" : useDelims === 1 ? "emph" : "strong")
let tmp = openerInl.next
while (tmp && tmp !== closerInl) {
const next = tmp.next
tmp.unlink()
emph.appendChild(tmp)
tmp = next
}
openerInl.insertAfter(emph)
// remove delimiters between opener and closer
if (op.next !== closer) {
op.next = closer
closer.previous = op
}
if (op.numdelims === 0) {
openerInl.unlink()
this.removeDelimiter(op)
}
if (closer.numdelims === 0) {
closerInl.unlink()
const tempstack = closer.next
this.removeDelimiter(closer)
closer = tempstack
}
}
if (!openerFound) {
openersBottom[openersBottomIndex] = oldCloser.previous
if (!oldCloser.canOpen) this.removeDelimiter(oldCloser)
}
}
while (this.delimiters !== null && this.delimiters !== stackBottom) this.removeDelimiter(this.delimiters)
}
parseLinkTitle(): string | null {
const title = this.match(reLinkTitle)
if (title === null) return null
return unescapeString(title.slice(1, -1))
}
parseLinkDestination(): string | null {
let res = this.match(reLinkDestinationBraces)
if (res !== null) return normalizeURI(unescapeString(res.slice(1, -1)))
if (this.peek() === C_LESSTHAN) return null
const savepos = this.pos
let openparens = 0
let c: number
while ((c = this.peek()) !== -1) {
if (c === C_BACKSLASH && reEscapable.test(this.subject.charAt(this.pos + 1))) {
this.pos += 1
if (this.peek() !== -1) this.pos += 1
} else if (c === C_OPEN_PAREN) {
this.pos += 1
openparens += 1
} else if (c === C_CLOSE_PAREN) {
if (openparens < 1) break
this.pos += 1
openparens -= 1
} else if (reWhitespaceChar.test(String.fromCharCode(c))) {
break
} else if (c < 0x20 || c === 0x7f) {
break
} else {
this.pos += 1
}
}
if (this.pos === savepos && c !== C_CLOSE_PAREN) return null
if (openparens !== 0) return null
res = this.subject.slice(savepos, this.pos)
return normalizeURI(unescapeString(res))
}
parseLinkLabel(): number {
const m = this.match(reLinkLabel)
if (m === null || m.length > 1001) return 0
return m.length
}
parseOpenBracket(block: INode): boolean {
const startpos = this.pos
this.pos += 1
const node = text("[")
block.appendChild(node)
this.addBracket(node, startpos, false)
return true
}
parseBang(block: INode): boolean {
const startpos = this.pos
this.pos += 1
if (this.peek() === C_OPEN_BRACKET) {
this.pos += 1
const node = text("![")
block.appendChild(node)
this.addBracket(node, startpos + 1, true)
} else {
block.appendChild(text("!"))
}
return true
}
lookupRef(label: string): ReferenceDef | undefined {
const norm = normalizeReference(label)
const def = this.refmap.get(norm)
if (def) this.usedRefs?.add(norm)
else if (norm) this.unresolvedRefs?.add(norm)
return def
}
parseCloseBracket(block: INode): boolean {
let dest: string | null = null
let title: string | null = null
let matched = false
this.pos += 1
const startpos = this.pos
let opener = this.brackets
if (opener === null) {
block.appendChild(text("]"))
return true
}
if (!opener.active) {
block.appendChild(text("]"))
this.removeBracket()
return true
}
const isImage = opener.image
const savepos = this.pos
if (this.peek() === C_OPEN_PAREN) {
this.pos++
if (
this.spnl() &&
(dest = this.parseLinkDestination()) !== null &&
this.spnl() &&
((reWhitespaceChar.test(this.subject.charAt(this.pos - 1)) && (title = this.parseLinkTitle())) || true) &&
this.spnl() &&
this.peek() === C_CLOSE_PAREN
) {
this.pos += 1
matched = true
} else {
this.pos = savepos
}
}
if (!matched) {
const beforelabel = this.pos
const n = this.parseLinkLabel()
let reflabel: string | undefined
if (n > 2) {
reflabel = this.subject.slice(beforelabel, beforelabel + n)
} else if (!opener.bracketAfter) {
reflabel = this.subject.slice(opener.index, startpos)
}
if (n === 0) this.pos = savepos
if (reflabel) {
const link = this.lookupRef(reflabel)
if (link) {
dest = link.href
title = link.title ?? null
matched = true
}
}
}
if (matched) {
const node = new INode(isImage ? "image" : "link")
node.destination = dest ?? ""
node.title = title || ""
let tmp = opener.node.next
while (tmp) {
const next = tmp.next
tmp.unlink()
node.appendChild(tmp)
tmp = next
}
block.appendChild(node)
this.processEmphasis(opener.previousDelimiter)
this.removeBracket()
opener.node.unlink()
if (!isImage) {
opener = this.brackets
while (opener !== null) {
if (!opener.image) {
if (!opener.active) break
opener.active = false
}
opener = opener.previous
}
}
return true
}
this.removeBracket()
this.pos = startpos
block.appendChild(text("]"))
return true
}
addBracket(node: INode, index: number, image: boolean) {
if (this.brackets !== null) this.brackets.bracketAfter = true
this.brackets = {
node,
previous: this.brackets,
previousDelimiter: this.delimiters,
index,
image,
active: true,
bracketAfter: false,
}
}
removeBracket() {
this.brackets = this.brackets!.previous
}
parseEntity(block: INode): boolean {
const m = this.match(reEntityHere)
if (!m) return false
const node = text(decodeEntity(m))
node.escaped = true
block.appendChild(node)
return true
}
parseString(block: INode): boolean {
const m = this.match(this.reMain)
if (!m) return false
block.appendChild(text(m))
return true
}
parseNewline(block: INode): boolean {
this.pos += 1
const lastc = block.last
if (lastc && lastc.type === "text" && lastc.literal[lastc.literal.length - 1] === " ") {
const hardbreak = lastc.literal[lastc.literal.length - 2] === " "
lastc.literal = lastc.literal.replace(reFinalSpace, "")
block.appendChild(new INode(hardbreak ? "linebreak" : "softbreak"))
} else {
block.appendChild(new INode("softbreak"))
}
this.match(reInitialSpace)
return true
}
/** Attempt to parse a link reference definition at the start of `s`, adding it to `refmap`. Returns chars consumed. */
parseReference(s: string, refmap: RefMap): number {
this.subject = s
this.pos = 0
const startpos = 0
let title: string | null = null
const matchChars = this.parseLinkLabel()
if (matchChars === 0) return 0
const rawlabel = this.subject.slice(0, matchChars)
if (this.peek() === C_COLON) {
this.pos++
} else {
this.pos = startpos
return 0
}
this.spnl()
const dest = this.parseLinkDestination()
if (dest === null) {
this.pos = startpos
return 0
}
const beforetitle = this.pos
this.spnl()
if (this.pos !== beforetitle) title = this.parseLinkTitle()
if (title === null) this.pos = beforetitle
let atLineEnd = true
if (this.match(reSpaceAtEndOfLine) === null) {
if (title === null) {
atLineEnd = false
} else {
title = null
this.pos = beforetitle
atLineEnd = this.match(reSpaceAtEndOfLine) !== null
}
}
if (!atLineEnd) {
this.pos = startpos
return 0
}
const normlabel = normalizeReference(rawlabel)
if (normlabel === "") {
this.pos = startpos
return 0
}
if (!refmap.has(normlabel)) {
refmap.set(normlabel, {
href: dest,
...(title ? { title } : {}),
raw: s.slice(0, this.pos).trim(),
})
}
return this.pos - startpos
}
parseInline(block: INode): boolean {
const c = this.peek()
if (c === -1) return false
let res = false
switch (c) {
case C_NEWLINE:
res = this.parseNewline(block)
break
case C_BACKSLASH:
res = this.parseBackslash(block)
break
case C_BACKTICK:
res = this.parseBackticks(block)
break
case C_ASTERISK:
case C_UNDERSCORE:
res = this.handleDelim(c, block)
break
case C_TILDE:
res = this.gfm && this.handleDelim(c, block)
break
case C_DOLLAR:
res = this.math && this.parseDollar(block)
break
case C_OPEN_BRACKET:
res = this.parseOpenBracket(block)
break
case C_BANG:
res = this.parseBang(block)
break
case C_CLOSE_BRACKET:
res = this.parseCloseBracket(block)
break
case C_LESSTHAN:
res = this.parseAutolink(block) || this.parseHtmlTag(block)
break
case C_AMPERSAND:
res = this.parseEntity(block)
break
default:
res = this.parseString(block)
break
}
if (!res) {
this.pos += 1
block.appendChild(text(String.fromCharCode(c)))
}
return true
}
parse(content: string): INode {
const root = new INode("root")
this.subject = content.trim()
this.pos = 0
this.delimiters = null
this.brackets = null
this.tickRuns = null
while (this.parseInline(root)) {}
this.processEmphasis(null)
return root
}
}
const sharedParser = new InlineParser()
const EMPTY_REFMAP: ReadonlyMap<string, ReferenceDef> = new Map()
/** Parse a link reference definition at the start of `s` into `refmap`; returns the number of characters consumed */
export function parseReference(s: string, refmap: RefMap): number {
return sharedParser.parseReference(s, refmap)
}
export function parseInlines(raw: string, options: InlineParseOptions = {}): InlineNode[] {
const gfm = options.gfm !== false
const math = options.math !== false
const src = raw.trim()
if (!src) return []
if (!isSpecialFastPath(src, gfm, math)) return [{ type: "text", text: src }]
const p = sharedParser
p.refmap = options.defs ?? EMPTY_REFMAP
p.gfm = gfm
p.math = math
p.reMain = gfm ? (math ? reMainGfmMath : reMainGfm) : math ? reMainMath : reMainCore
p.unresolvedRefs = options.unresolvedRefs
p.usedRefs = options.usedRefs
const root = p.parse(src)
return convertChildren(root, gfm, false)
}
function convertChildren(parent: INode, gfm: boolean, inLink: boolean): InlineNode[] {
const out: InlineNode[] = []
let pendingText = ""
let prevChar = ""
const pushText = (s: string) => {
if (!s) return
const prev = out[out.length - 1]
if (prev && prev.type === "text") prev.text += s
else out.push({ type: "text", text: s })
}
const flush = () => {
if (!pendingText) return
if (gfm && !inLink) {
for (const node of autolinkLiterals(pendingText, prevChar)) {
if (node.type === "text") pushText(node.text)
else out.push(node)
}
} else {
pushText(pendingText)
}
prevChar = pendingText[pendingText.length - 1]!
pendingText = ""
}
for (let n = parent.first; n; n = n.next) {
switch (n.type) {
case "text":
if (gfm && !inLink && n.escaped) {
flush()
pushText(n.literal)
prevChar = n.literal[n.literal.length - 1] ?? ""
} else {
pendingText += n.literal
}
break
case "softbreak":
pendingText += "\n"
break
case "linebreak":
flush()
out.push({ type: "br" })
prevChar = "\n"
break
case "code":
flush()
out.push({ type: "code", text: n.literal })
prevChar = "`"
break
case "emph":
flush()
out.push({ type: "em", children: convertChildren(n, gfm, inLink) })
prevChar = "*"
break
case "strong":
flush()
out.push({ type: "strong", children: convertChildren(n, gfm, inLink) })
prevChar = "*"
break
case "del":
flush()
out.push({ type: "del", children: convertChildren(n, gfm, inLink) })
prevChar = "~"
break
case "link":
flush()
out.push({
type: "link",
href: n.destination,
...(n.title ? { title: n.title } : {}),
children: convertChildren(n, gfm, true),
})
prevChar = ")"
break
case "image": {
flush()
const children = convertChildren(n, gfm, true)
out.push({
type: "image",
src: n.destination,
alt: inlinePlainText(children),
...(n.title ? { title: n.title } : {}),
children,
})
prevChar = ")"
break
}
case "html_inline":
flush()
out.push({ type: "html_inline", raw: n.literal })
prevChar = ">"
break
case "math":
flush()
out.push({ type: "math", source: n.literal, delimiter: n.delimiter, display: n.display })
prevChar = "$"
break
}
}
flush()
return out
}
function inlinePlainText(nodes: ReadonlyArray<InlineNode>): string {
let out = ""
for (const node of nodes) {
switch (node.type) {
case "text":
case "code":
out += node.text
break
case "strong":
case "em":
case "del":
case "link":
out += inlinePlainText(node.children)
break
case "image":
out += node.children ? inlinePlainText(node.children) : node.alt
break
case "math":
out += node.source
break
case "br":
out += "\n"
break
case "html_inline":
out += node.raw
break
}
}
return out
}
// GFM extended autolinks (www., http://, https://, ftp://, and bare emails), applied to merged text runs
// outside links, following cmark-gfm's autolink extension.
function isAlnum(c: number): boolean {
return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)
}
function isAutolinkWhitespace(c: number): boolean {
return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0b || c === 0x0c || c === 0x0d
}
function isValidPrecedingForWww(s: string, i: number, prevChar: string): boolean {
if (i === 0 && !prevChar) return true
const c = i === 0 ? prevChar.charCodeAt(0) : s.charCodeAt(i - 1)
return (
isAutolinkWhitespace(c) ||
c === C_ASTERISK ||
c === C_UNDERSCORE ||
c === C_TILDE ||
c === C_OPEN_PAREN ||
c === C_OPEN_BRACKET ||
c === C_CLOSE_BRACKET
)
}
/** Length of a valid domain at `i`, or 0. Mirrors cmark-gfm `check_domain`. */
function checkDomain(s: string, i: number, allowShort: boolean): number {
let np = 0
let uscore1 = 0
let uscore2 = 0
let j = i
for (; j < s.length; j++) {
const c = s.charCodeAt(j)
if (c === 0x5c /* \ */ && j + 1 < s.length) {
// a backslash is never part of a domain
break
}
if (c === C_UNDERSCORE) {
uscore2++
} else if (c === 0x2e /* . */) {
uscore1 = uscore2
uscore2 = 0
np++
} else if (!isAlnum(c) && c !== 0x2d /* - */ && c < 0x80) {
break
}
}
if ((uscore1 > 0 || uscore2 > 0) && np <= 10) return 0
if (allowShort || np > 0) return j - i
return 0
}
/** Trim trailing punctuation from a candidate link of length `len` starting at `start`. Mirrors cmark-gfm `autolink_delim`. */
function autolinkDelim(s: string, start: number, len: number): number {
for (let k = 0; k < len; k++) {
if (s.charCodeAt(start + k) === C_LESSTHAN) {
len = k
break
}
}
while (len > 0) {
const cclose = s.charCodeAt(start + len - 1)
if (cclose === C_CLOSE_PAREN) {
let opening = 0
let closing = 0
for (let k = 0; k < len; k++) {
const c = s.charCodeAt(start + k)
if (c === C_OPEN_PAREN) opening++
else if (c === C_CLOSE_PAREN) closing++
}
if (closing <= opening) break
len--
} else if (
cclose === 0x3f /* ? */ ||
cclose === 0x21 /* ! */ ||
cclose === 0x2e /* . */ ||
cclose === 0x2c /* , */ ||
cclose === 0x3a /* : */ ||
cclose === C_ASTERISK ||
cclose === C_UNDERSCORE ||
cclose === C_TILDE ||
cclose === 0x27 /* ' */ ||
cclose === 0x22 /* " */
) {
len--
} else if (cclose === 0x3b /* ; */) {
let newEnd = len - 2
while (newEnd > 0 && isAlnum(s.charCodeAt(start + newEnd))) newEnd--
if (newEnd < len - 2 && s.charCodeAt(start + newEnd) === C_AMPERSAND) len = newEnd
else len--
} else {
break
}
}
return len
}
function matchWww(s: string, i: number, prevChar: string): number {
if (!isValidPrecedingForWww(s, i, prevChar)) return 0
if (!s.startsWith("www.", i)) return 0
const domainLen = checkDomain(s, i, false)
if (domainLen === 0) return 0
let end = i + domainLen
while (end < s.length && !isAutolinkWhitespace(s.charCodeAt(end))) end++
return autolinkDelim(s, i, end - i)
}
function matchUrl(s: string, i: number, prevChar: string): { len: number; schemeLen: number } | null {
// `i` points at ':' of "://"
if (!s.startsWith("://", i)) return null
let start = i
while (start > 0 && /[A-Za-z]/.test(s[start - 1]!)) start--
if (start === 0 && prevChar && /[A-Za-z]/.test(prevChar)) return null
const scheme = s.slice(start, i).toLowerCase()
const matchedScheme =
scheme === "https" || scheme === "http" || scheme === "ftp" ? scheme : ""
if (!matchedScheme) return null
const schemeStart = start
const domainLen = checkDomain(s, i + 3, true)
if (domainLen === 0) return null
let end = i + 3 + domainLen
while (end < s.length && !isAutolinkWhitespace(s.charCodeAt(end))) end++
const len = autolinkDelim(s, schemeStart, end - schemeStart)
if (len <= i + 3 - schemeStart) return null
return { len, schemeLen: i - schemeStart }
}
function isEmailLocal(c: number): boolean {
return isAlnum(c) || c === 0x2e || c === 0x2b || c === 0x2d || c === C_UNDERSCORE
}
function matchEmail(s: string, at: number, minStart: number, prevChar: string): { start: number; end: number } | null {
let start = at
while (start > minStart && isEmailLocal(s.charCodeAt(start - 1))) start--
if (start === at) return null
if (start > 0) {
if (s.charCodeAt(start - 1) === 0x2f /* / */) return null
} else if (prevChar) {
const pc = prevChar.charCodeAt(0)
if (pc === 0x2f /* / */ || isEmailLocal(pc)) return null
}
let np = 0
let end = at + 1
for (; end < s.length; end++) {
const c = s.charCodeAt(end)
if (isAlnum(c)) continue
if (c === 0x40 /* @ */) return null
if (c === 0x2e && end + 1 < s.length && isAlnum(s.charCodeAt(end + 1))) {
np++
continue
}
if (c !== 0x2d && c !== C_UNDERSCORE) break
}
if (end - at < 2 || np === 0) return null
const last = s.charCodeAt(end - 1)
if (!((last >= 0x41 && last <= 0x5a) || (last >= 0x61 && last <= 0x7a))) return null
return { start, end }
}
const RE_AUTOLINK_CANDIDATE = /www\.|:\/\/|@/i
function autolinkLiterals(s: string, prevChar: string): InlineNode[] {
if (!RE_AUTOLINK_CANDIDATE.test(s)) return [{ type: "text", text: s }]
const out: InlineNode[] = []
let textStart = 0
let i = 0
const emit = (start: number, end: number, href: string) => {
if (start > textStart) out.push({ type: "text", text: s.slice(textStart, start) })
const label = s.slice(start, end)
out.push({ type: "link", href: normalizeURI(href), children: [{ type: "text", text: label }] })
textStart = end
i = end
}
while (i < s.length) {
const c = s.charCodeAt(i)
if ((c === 0x77 || c === 0x57) && s.startsWith("www.", i)) {
const len = matchWww(s, i, prevChar)
if (len > 0) {
emit(i, i + len, "http://" + s.slice(i, i + len))
continue
}
} else if (c === C_COLON) {
const m = matchUrl(s, i, prevChar)
if (m) {
const start = i - m.schemeLen
if (start >= textStart) {
emit(start, start + m.len, s.slice(start, start + m.len))
continue
}
}
} else if (c === 0x40 /* @ */) {
const m = matchEmail(s, i, textStart, prevChar)
if (m) {
emit(m.start, m.end, "mailto:" + s.slice(m.start, m.end))
continue
}
}
i++
}
if (textStart === 0) return [{ type: "text", text: s }]
if (textStart < s.length) out.push({ type: "text", text: s.slice(textStart) })
return out
}