feat: render Markdown, mathematics and Mermaid diagrams server-side
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user