// Copyright (c) 2026 Petr BalvĂ­n (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 }