Files
scriptorium/internal/markdown/inline.go
T

743 lines
18 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"unicode"
"unicode/utf8"
)
type inlineKind uint8
const (
inlText inlineKind = iota
inlCode
inlRawHTML
inlEmph
inlStrong
inlStrikethrough
inlLink
inlImage
inlBreak
inlFootnoteRef
)
// inline is one node of the inline tree. While parsing, the top level
// nodes form a doubly-linked chain; when emphasis or a link takes a range,
// the range becomes the children slice of the wrapping node.
type inline struct {
kind inlineKind
literal string // text, code content, raw HTML, soft break
dest string
title string
hasTitle bool
num int // footnote reference: ordinal
occurrence int // footnote reference: which reference to that ordinal
children []*inline
prev *inline
next *inline
}
// footnoteTracker numbers footnote references in document order: a
// definition receives its ordinal at its first reference, and later
// references to the same definition count their occurrences.
type footnoteTracker struct {
defs map[string]*Node
ordinals map[string]int
seen map[string]int
order []*Node
}
func newFootnoteTracker(defs []*Node) *footnoteTracker {
m := make(map[string]*Node, len(defs))
for _, d := range defs {
if _, ok := m[d.label]; !ok {
m[d.label] = d
}
}
return &footnoteTracker{defs: m, ordinals: map[string]int{}, seen: map[string]int{}}
}
// reference records one reference to the labelled footnote.
func (t *footnoteTracker) reference(label string) (ordinal, occurrence int, ok bool) {
if _, defined := t.defs[label]; !defined {
return 0, 0, false
}
t.seen[label]++
if t.seen[label] == 1 {
t.ordinals[label] = len(t.order) + 1
t.order = append(t.order, t.defs[label])
}
return t.ordinals[label], t.seen[label], true
}
// delimiter is one entry of the delimiter stack: a run of asterisks or
// underscores waiting to be matched, or an open link or image bracket.
type delimiter struct {
node *inline
char byte
isBracket bool
image bool
active bool
length int
origLen int
canOpen bool
canClose bool
srcPos int // bracket: index just after the opening literal
prev *delimiter
next *delimiter
}
// parseInlines parses the inline content of a paragraph or heading against
// the document's link reference definitions and footnotes.
func parseInlines(content []byte, refs map[string]reference, footnotes *footnoteTracker) []*inline {
p := &inlineParser{src: bytes.TrimRight(content, " \t"), refs: refs, footnotes: footnotes}
p.parse()
return p.chain()
}
type inlineParser struct {
src []byte
pos int
refs map[string]reference
footnotes *footnoteTracker
first *inline
last *inline
firstDelim *delimiter
lastDelim *delimiter
}
func (p *inlineParser) chain() []*inline {
var nodes []*inline
for n := p.first; n != nil; n = n.next {
nodes = append(nodes, n)
}
return nodes
}
func (p *inlineParser) push(n *inline) *inline {
n.prev = p.last
n.next = nil
if p.last != nil {
p.last.next = n
} else {
p.first = n
}
p.last = n
return n
}
func (p *inlineParser) removeNode(n *inline) {
if n.prev != nil {
n.prev.next = n.next
} else {
p.first = n.next
}
if n.next != nil {
n.next.prev = n.prev
} else {
p.last = n.prev
}
}
func (p *inlineParser) pushDelim(d *delimiter) {
d.prev = p.lastDelim
d.next = nil
if p.lastDelim != nil {
p.lastDelim.next = d
} else {
p.firstDelim = d
}
p.lastDelim = d
}
func (p *inlineParser) removeDelim(d *delimiter) {
if d.prev != nil {
d.prev.next = d.next
} else {
p.firstDelim = d.next
}
if d.next != nil {
d.next.prev = d.prev
} else {
p.lastDelim = d.prev
}
}
// lastBracket returns the most recent open bracket on the stack.
func (p *inlineParser) lastBracket() *delimiter {
for d := p.lastDelim; d != nil; d = d.prev {
if d.isBracket {
return d
}
}
return nil
}
func (p *inlineParser) parse() {
for p.pos < len(p.src) {
switch c := p.src[p.pos]; c {
case '\n':
p.handleNewline()
case '\\':
p.handleBackslash()
case '`':
p.handleBackticks()
case '<':
p.handleLessThan()
case '&':
p.handleAmpersand()
case '*', '_', '~':
p.handleDelimiterRun(c)
case '[':
if p.tryFootnoteRef() {
continue
}
p.pushBracket(false)
case '!':
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '[' {
p.pushBracket(true)
} else {
p.push(&inline{kind: inlText, literal: "!"})
p.pos++
}
case ']':
p.handleCloseBracket()
default:
p.textRun()
}
}
p.processEmphasis(nil)
}
// handleNewline ends the line: the whitespace before the line ending is
// stripped, and two or more spaces make the break hard.
func (p *inlineParser) handleNewline() {
spaces := 0
if p.last != nil && p.last.kind == inlText {
lit := p.last.literal
end := len(lit)
for end > 0 && isSpaceTab(lit[end-1]) {
if lit[end-1] == ' ' {
spaces++
}
end--
}
if end == 0 {
p.removeNode(p.last)
} else {
p.last.literal = lit[:end]
}
}
if spaces >= 2 {
p.push(&inline{kind: inlBreak})
} else {
p.push(&inline{kind: inlText, literal: "\n"})
}
p.pos++
}
func (p *inlineParser) handleBackslash() {
if p.pos+1 < len(p.src) {
next := p.src[p.pos+1]
switch {
case next == '\n':
p.push(&inline{kind: inlBreak})
p.pos += 2
return
case isASCIIPunct(next):
p.push(&inline{kind: inlText, literal: string(next)})
p.pos += 2
return
}
}
p.push(&inline{kind: inlText, literal: `\`})
p.pos++
}
// handleBackticks scans for a closing backtick string of equal length and
// emits the code span between them, or the literal opening run.
func (p *inlineParser) handleBackticks() {
openLen := fenceRun(p.src[p.pos:], '`')
i := p.pos + openLen
for i < len(p.src) {
if p.src[i] == '`' {
l := fenceRun(p.src[i:], '`')
if l == openLen {
p.push(&inline{kind: inlCode, literal: codeSpanContent(p.src[p.pos+openLen : i])})
p.pos = i + l
return
}
i += l
continue
}
i++
}
p.push(&inline{kind: inlText, literal: string(p.src[p.pos : p.pos+openLen])})
p.pos += openLen
}
// codeSpanContent converts line endings to spaces and strips the one-space
// margin a span carries at both ends when it is not all spaces.
func codeSpanContent(c []byte) string {
c = bytes.ReplaceAll(c, []byte("\n"), []byte(" "))
if len(c) >= 2 && c[0] == ' ' && c[len(c)-1] == ' ' {
allSpaces := true
for _, b := range c {
if b != ' ' {
allSpaces = false
break
}
}
if !allSpaces {
c = c[1 : len(c)-1]
}
}
return string(c)
}
func (p *inlineParser) handleLessThan() {
if text, dest, n, ok := scanAutolink(p.src[p.pos:]); ok {
p.push(&inline{kind: inlLink, dest: dest, children: []*inline{{kind: inlText, literal: text}}})
p.pos += n
return
}
if n := scanRawHTML(p.src[p.pos:]); n > 0 {
p.push(&inline{kind: inlRawHTML, literal: string(p.src[p.pos : p.pos+n])})
p.pos += n
return
}
p.push(&inline{kind: inlText, literal: "<"})
p.pos++
}
func (p *inlineParser) handleAmpersand() {
if s, n, ok := scanEntity(p.src, p.pos); ok {
p.push(&inline{kind: inlText, literal: s})
p.pos += n
return
}
p.push(&inline{kind: inlText, literal: "&"})
p.pos++
}
// handleDelimiterRun records a run of asterisks or underscores and whether
// it may open or close emphasis under the flanking rules.
func (p *inlineParser) handleDelimiterRun(char byte) {
start := p.pos
end := start + fenceRun(p.src[start:], char)
beforeWS, beforePunct := classifyRune(runeBefore(p.src, start))
afterWS, afterPunct := classifyRune(runeAfter(p.src, end))
left := !afterWS && (!afterPunct || beforeWS || beforePunct)
right := !beforeWS && (!beforePunct || afterWS || afterPunct)
d := &delimiter{char: char, length: end - start, origLen: end - start}
if char == '_' {
d.canOpen = left && (!right || beforePunct)
d.canClose = right && (!left || afterPunct)
} else {
// asterisks and tildes flank the same way
d.canOpen, d.canClose = left, right
}
d.node = p.push(&inline{kind: inlText, literal: string(p.src[start:end])})
p.pushDelim(d)
p.pos = end
}
// tryFootnoteRef consumes a reference to a defined footnote and emits its
// marker. A reference to an undefined footnote stays bracket text.
func (p *inlineParser) tryFootnoteRef() bool {
if p.footnotes == nil {
return false
}
label, n, ok := scanFootnoteLabel(p.src[p.pos:])
if !ok {
return false
}
num, occurrence, ok := p.footnotes.reference(normaliseLabel(label))
if !ok {
return false
}
p.push(&inline{kind: inlFootnoteRef, num: num, occurrence: occurrence})
p.pos += n
return true
}
func (p *inlineParser) pushBracket(image bool) {
lit := "["
if image {
lit = "!["
}
n := p.push(&inline{kind: inlText, literal: lit})
p.pushDelim(&delimiter{
node: n, char: '[', isBracket: true, image: image, active: true,
srcPos: p.pos + len(lit),
})
p.pos += len(lit)
}
// textRun consumes the run of ordinary characters up to the next special
// one, emitting extended autolinks and plain text in the order they come.
func (p *inlineParser) textRun() {
start := p.pos
for p.pos < len(p.src) {
c := p.src[p.pos]
if isInlineSpecial(c) {
break
}
var prev byte
if p.pos > 0 {
prev = p.src[p.pos-1]
}
if c == 'w' || c == 'h' || c == 'f' ||
(!isEmailPrevByte(prev) && isEmailLocalByte(c)) {
if n, text, dest, ok := scanExtendedAutolink(p.src, p.pos, prev); ok {
if p.pos > start {
p.push(&inline{kind: inlText, literal: string(p.src[start:p.pos])})
}
p.push(&inline{
kind: inlLink,
dest: dest,
children: []*inline{{kind: inlText, literal: text}},
})
p.pos += n
start = p.pos
continue
}
}
p.pos++
}
if p.pos > start {
p.push(&inline{kind: inlText, literal: string(p.src[start:p.pos])})
}
}
func isInlineSpecial(c byte) bool {
switch c {
case '\n', '\\', '`', '<', '&', '*', '_', '~', '[', ']', '!':
return true
}
return false
}
// processEmphasis matches delimiter runs between the stack bottom and the
// top into emphasis and strong nodes, following the reference algorithm:
// closers walk forward, openers are searched backwards, a matching pair
// may not both be intraword delimiters whose run lengths add up against
// the rule of three, and the matched run lengths shrink from the inner
// sides.
func (p *inlineParser) processEmphasis(bottom *delimiter) {
var closer *delimiter
if bottom == nil {
closer = p.firstDelim
} else {
closer = bottom.next
}
for closer != nil {
if closer.isBracket || !closer.canClose {
closer = closer.next
continue
}
opener, found := p.findOpener(closer, bottom)
if !found {
if !closer.canOpen {
p.removeDelim(closer)
}
closer = closer.next
continue
}
use := 1
kind := inlEmph
switch closer.char {
case '~':
if closer.length >= 2 && opener.length >= 2 {
use = 2
}
kind = inlStrikethrough
default:
if closer.length >= 2 && opener.length >= 2 {
use = 2
kind = inlStrong
}
}
var children []*inline
for n := opener.node.next; n != closer.node; n = n.next {
children = append(children, n)
}
node := &inline{kind: kind, children: children}
opener.node.next = node
node.prev = opener.node
node.next = closer.node
closer.node.prev = node
opener.node.literal = opener.node.literal[:len(opener.node.literal)-use]
closer.node.literal = closer.node.literal[use:]
opener.length -= use
closer.length -= use
for d := closer.prev; d != nil && d != opener; {
prev := d.prev
p.removeDelim(d)
d = prev
}
if opener.length == 0 {
p.removeNode(opener.node)
p.removeDelim(opener)
}
if closer.length == 0 {
next := closer.next
p.removeNode(closer.node)
p.removeDelim(closer)
closer = next
}
}
for p.lastDelim != nil && p.lastDelim != bottom {
p.removeDelim(p.lastDelim)
}
}
// findOpener searches backwards from the closer for a run of the same
// character that may open, honouring the rule of three.
func (p *inlineParser) findOpener(closer, bottom *delimiter) (*delimiter, bool) {
for opener := closer.prev; opener != nil && opener != bottom; opener = opener.prev {
if opener.isBracket || opener.char != closer.char || !opener.canOpen {
continue
}
oddMatch := closer.char != '~' &&
(closer.canOpen || opener.canClose) &&
(opener.origLen+closer.origLen)%3 == 0 &&
!(opener.origLen%3 == 0 && closer.origLen%3 == 0)
if !oddMatch {
return opener, true
}
}
return nil, false
}
// handleCloseBracket tries to close the most recent open bracket as an
// inline link or image, as a reference link with an explicit, collapsed or
// empty label, and leaves the bracket as literal text otherwise.
func (p *inlineParser) handleCloseBracket() {
opener := p.lastBracket()
if opener == nil {
p.push(&inline{kind: inlText, literal: "]"})
p.pos++
return
}
if !opener.active {
p.removeDelim(opener)
p.push(&inline{kind: inlText, literal: "]"})
p.pos++
return
}
closerIdx := p.pos
p.pos++
var dest, title string
var hasTitle bool
matched := false
if p.pos < len(p.src) && p.src[p.pos] == '(' {
save := p.pos
p.pos++
if d, t, ht, ok := p.scanInlineSpec(); ok {
dest, title, hasTitle, matched = d, t, ht, true
} else {
p.pos = save
}
}
if !matched {
save := p.pos
label, ok := p.referenceLabel(opener, closerIdx)
if ok && len(bytes.TrimSpace(label)) > 0 && validLabel(label) {
if ref, exists := p.refs[normaliseLabel(string(label))]; exists {
dest, title, hasTitle, matched = ref.destination, ref.title, ref.hasTitle, true
}
}
if !matched {
p.pos = save
}
}
if !matched {
p.removeDelim(opener)
p.push(&inline{kind: inlText, literal: "]"})
return
}
p.processEmphasis(opener)
var children []*inline
for n := opener.node.next; n != nil; n = n.next {
children = append(children, n)
}
kind := inlLink
if opener.image {
kind = inlImage
}
node := &inline{kind: kind, dest: dest, title: title, hasTitle: hasTitle, children: children}
if opener.node.prev != nil {
opener.node.prev.next = node
} else {
p.first = node
}
node.prev = opener.node.prev
node.next = nil
p.last = node
p.removeDelim(opener)
if !opener.image {
// Links may not nest in links; image brackets stay open.
for d := opener.prev; d != nil; d = d.prev {
if d.isBracket && !d.image {
d.active = false
}
}
}
}
// referenceLabel reads the label of a reference link after the closing
// bracket: an explicit label in brackets, the collapsed empty brackets, or
// the shortcut label taken from the link text itself.
func (p *inlineParser) referenceLabel(opener *delimiter, closerIdx int) ([]byte, bool) {
if p.pos < len(p.src) && p.src[p.pos] == '[' {
if p.pos+1 < len(p.src) && p.src[p.pos+1] == ']' {
p.pos += 2
return p.src[opener.srcPos:closerIdx], true
}
j := p.pos + 1
for j < len(p.src) {
if p.src[j] == '\\' && j+1 < len(p.src) {
j += 2
continue
}
if p.src[j] == ']' {
break
}
j++
}
if j < len(p.src) {
label := p.src[p.pos+1 : j]
p.pos = j + 1
return label, true
}
return nil, false
}
return p.src[opener.srcPos:closerIdx], true
}
// scanInlineSpec parses the destination and optional title of an inline
// link, starting after the opening parenthesis.
func (p *inlineParser) scanInlineSpec() (string, string, bool, bool) {
p.skipWhitespace()
if p.pos < len(p.src) && p.src[p.pos] == ')' {
p.pos++
return "", "", false, true
}
d, n, ok := scanDestination(p.src[p.pos:])
if !ok {
return "", "", false, false
}
dest := unescapeText(string(d))
p.pos += n
p.skipWhitespace()
if p.pos < len(p.src) {
switch c := p.src[p.pos]; c {
case '"', '\'', '(':
title, ok := p.scanInlineTitle(c)
if !ok {
return "", "", false, false
}
p.skipWhitespace()
if p.pos < len(p.src) && p.src[p.pos] == ')' {
p.pos++
return dest, unescapeText(title), true, true
}
return "", "", false, false
}
}
if p.pos < len(p.src) && p.src[p.pos] == ')' {
p.pos++
return dest, "", false, true
}
return "", "", false, false
}
// scanInlineTitle scans a title through its closing quote, line endings
// included, leaving the position past the closing quote.
func (p *inlineParser) scanInlineTitle(open byte) (string, bool) {
closer := open
if open == '(' {
closer = ')'
}
i := p.pos + 1
for i < len(p.src) {
c := p.src[i]
if c == '\\' && i+1 < len(p.src) {
i += 2
continue
}
if c == closer {
title := string(p.src[p.pos+1 : i])
p.pos = i + 1
return title, true
}
i++
}
return "", false
}
func (p *inlineParser) skipWhitespace() {
for p.pos < len(p.src) {
switch p.src[p.pos] {
case ' ', '\t', '\n':
p.pos++
default:
return
}
}
}
// runeBefore returns the rune that ends just before the position, or a
// null rune at the start, which counts as whitespace.
func runeBefore(src []byte, pos int) rune {
if pos == 0 {
return 0
}
r, _ := utf8.DecodeLastRune(src[:pos])
return r
}
// runeAfter returns the rune that starts at the position, or a null rune
// at the end, which counts as whitespace.
func runeAfter(src []byte, pos int) rune {
if pos >= len(src) {
return 0
}
r, _ := utf8.DecodeRune(src[pos:])
return r
}
// classifyRune reports whether the rune is whitespace and whether it is
// punctuation, under the CommonMark definitions: Unicode whitespace, and
// ASCII or Unicode punctuation or symbol characters.
func classifyRune(r rune) (space, punct bool) {
if r == 0 {
return true, false
}
if r < utf8.RuneSelf {
space = r == ' ' || r == '\t' || r == '\n' || r == '\v' || r == '\f' || r == '\r'
punct = isASCIIPunct(byte(r))
return space, punct
}
return unicode.IsSpace(r), unicode.IsPunct(r) || unicode.IsSymbol(r)
}
// isASCIIPunct reports whether c is an ASCII punctuation character.
func isASCIIPunct(c byte) bool {
switch c {
case '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-',
'.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^',
'_', '`', '{', '|', '}', '~':
return true
}
return false
}