feat: render Markdown, mathematics and Mermaid diagrams server-side
This commit is contained in:
@@ -0,0 +1,817 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import "bytes"
|
||||
|
||||
const (
|
||||
tabStop = 4
|
||||
codeIndent = 4
|
||||
)
|
||||
|
||||
// Parse parses Markdown source into a tree of block nodes. The source is
|
||||
// trusted input: no sanitisation is applied, because whether the output may
|
||||
// reach an audience is the consumer's policy.
|
||||
func Parse(source []byte) *Node {
|
||||
p := &parser{doc: &Node{kind: kindDocument, refs: map[string]reference{}}}
|
||||
p.tip = p.doc
|
||||
for _, line := range splitLines(normalise(source)) {
|
||||
p.processLine(line)
|
||||
}
|
||||
p.closeUnmatched(p.doc)
|
||||
p.finalise(p.doc)
|
||||
return p.doc
|
||||
}
|
||||
|
||||
// parser holds the block parsing state. The offset, column, indent and
|
||||
// blank fields describe the current line from the current offset onward,
|
||||
// which moves as container prefixes are consumed; a tab may end up
|
||||
// partially consumed, in which case offset rests on the tab and column
|
||||
// counts only the consumed part of it.
|
||||
type parser struct {
|
||||
doc *Node
|
||||
tip *Node
|
||||
|
||||
line []byte
|
||||
lineNo int
|
||||
|
||||
offset int
|
||||
column int
|
||||
|
||||
firstNonspace int
|
||||
firstNonspaceColumn int
|
||||
indent int
|
||||
blank bool
|
||||
|
||||
partiallyConsumedTab bool
|
||||
|
||||
// suppressBlankMark keeps the blank-line bookkeeping away from a line
|
||||
// the parser consumed entirely, such as a closing code fence.
|
||||
suppressBlankMark bool
|
||||
}
|
||||
|
||||
func (p *parser) processLine(line []byte) {
|
||||
p.line = line
|
||||
p.lineNo++
|
||||
p.offset = 0
|
||||
p.column = 0
|
||||
p.partiallyConsumedTab = false
|
||||
p.findFirstNonspace()
|
||||
|
||||
lastMatched := p.checkOpenBlocks()
|
||||
container, opened := p.openNewBlocks(lastMatched)
|
||||
p.addText(container, opened)
|
||||
|
||||
switch p.tip.kind {
|
||||
case kindHeading, kindThematicBreak:
|
||||
p.finalise(p.tip)
|
||||
}
|
||||
}
|
||||
|
||||
// checkOpenBlocks matches the line against the open block chain, from the
|
||||
// document down to the tip, consuming the prefix of every block that
|
||||
// continues. It returns the deepest matched block; the blocks below it stay
|
||||
// open until addText closes or lazily continues them.
|
||||
func (p *parser) checkOpenBlocks() *Node {
|
||||
var chain []*Node
|
||||
for n := p.tip; n != nil; n = n.parent {
|
||||
chain = append(chain, n)
|
||||
}
|
||||
for i := len(chain) - 2; i >= 0; i-- {
|
||||
p.findFirstNonspace()
|
||||
if !p.continueBlock(chain[i]) {
|
||||
return chain[i+1]
|
||||
}
|
||||
}
|
||||
return chain[0]
|
||||
}
|
||||
|
||||
// continueBlock reports whether the open block continues on the current
|
||||
// line, consuming its prefix when it does.
|
||||
func (p *parser) continueBlock(n *Node) bool {
|
||||
switch n.kind {
|
||||
case kindBlockquote:
|
||||
if p.blank || p.indent > 3 {
|
||||
return false
|
||||
}
|
||||
if p.line[p.firstNonspace] != '>' {
|
||||
return false
|
||||
}
|
||||
p.advanceOffset(p.firstNonspace+1-p.offset, false)
|
||||
if p.offset < len(p.line) && isSpaceTab(p.line[p.offset]) {
|
||||
p.advanceOffset(1, true)
|
||||
}
|
||||
return true
|
||||
case kindListItem:
|
||||
if p.blank {
|
||||
// A blank line ends an item that never took content.
|
||||
if len(n.children) == 0 {
|
||||
return false
|
||||
}
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
return true
|
||||
}
|
||||
if p.indent >= n.markerOffset+n.padding {
|
||||
p.advanceOffset(n.markerOffset+n.padding, true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case kindCodeBlock:
|
||||
if !n.fenced {
|
||||
switch {
|
||||
case p.indent >= codeIndent:
|
||||
p.advanceOffset(codeIndent, true)
|
||||
return true
|
||||
case p.blank:
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !p.blank && p.indent <= 3 && p.line[p.firstNonspace] == n.fenceChar {
|
||||
length := fenceRun(p.line[p.firstNonspace:], n.fenceChar)
|
||||
if length >= n.fenceLength && allSpaceTab(p.line[p.firstNonspace+length:]) {
|
||||
// A closing fence ends the block; the rest of the line is
|
||||
// nothing but whitespace.
|
||||
p.advanceOffset(len(p.line)-p.offset, false)
|
||||
p.finalise(n)
|
||||
p.suppressBlankMark = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
// A content line gives up to the opening fence's indentation.
|
||||
for i := n.fenceOffset; i > 0 && p.offset < len(p.line) && isSpaceTab(p.line[p.offset]); i-- {
|
||||
p.advanceOffset(1, true)
|
||||
}
|
||||
return true
|
||||
case kindHTMLBlock:
|
||||
// The tag-based kinds end at a blank line; the raw kinds run to
|
||||
// their closing condition.
|
||||
if n.htmlType == 6 || n.htmlType == 7 {
|
||||
return !p.blank
|
||||
}
|
||||
return true
|
||||
case kindParagraph:
|
||||
return !p.blank
|
||||
case kindTable:
|
||||
return !p.blank
|
||||
case kindFootnoteDef:
|
||||
if p.blank {
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
return true
|
||||
}
|
||||
if p.indent >= codeIndent {
|
||||
p.advanceOffset(codeIndent, true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case kindDefItem:
|
||||
if p.blank {
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
return true
|
||||
}
|
||||
if p.indent >= n.markerOffset+n.padding {
|
||||
p.advanceOffset(n.markerOffset+n.padding, true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// openNewBlocks starts new blocks on the line, beginning at the last
|
||||
// matched container and opening containers until a leaf takes over. It
|
||||
// returns the container the remaining text belongs to and whether anything
|
||||
// was opened or changed on the line.
|
||||
func (p *parser) openNewBlocks(container *Node) (*Node, bool) {
|
||||
opened := false
|
||||
for {
|
||||
switch container.kind {
|
||||
case kindCodeBlock, kindHTMLBlock:
|
||||
return container, opened
|
||||
}
|
||||
p.findFirstNonspace()
|
||||
if p.blank {
|
||||
return container, opened
|
||||
}
|
||||
indented := p.indent >= codeIndent
|
||||
|
||||
if !indented && p.line[p.firstNonspace] == '>' {
|
||||
p.advanceOffset(p.firstNonspace+1-p.offset, false)
|
||||
if p.offset < len(p.line) && isSpaceTab(p.line[p.offset]) {
|
||||
p.advanceOffset(1, true)
|
||||
}
|
||||
container = p.addChild(container, kindBlockquote)
|
||||
opened = true
|
||||
continue
|
||||
}
|
||||
if !indented {
|
||||
if level, ok := scanATX(p.line[p.firstNonspace:]); ok {
|
||||
h := p.addChild(container, kindHeading)
|
||||
h.level = level
|
||||
p.advanceOffset(p.firstNonspace+level-p.offset, false)
|
||||
return h, true
|
||||
}
|
||||
}
|
||||
if !indented {
|
||||
if char, length, ok := scanOpenFence(p.line[p.firstNonspace:]); ok {
|
||||
code := p.addChild(container, kindCodeBlock)
|
||||
code.fenced = true
|
||||
code.fenceChar = char
|
||||
code.fenceLength = length
|
||||
code.fenceOffset = p.indent
|
||||
p.advanceOffset(p.firstNonspace+length-p.offset, false)
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
if !indented {
|
||||
if t := scanHTMLBlockStart(p.line[p.firstNonspace:], container.kind == kindParagraph); t > 0 {
|
||||
h := p.addChild(container, kindHTMLBlock)
|
||||
h.htmlType = t
|
||||
return h, true
|
||||
}
|
||||
}
|
||||
if !indented && container.kind == kindParagraph {
|
||||
if aligns, ok := scanTableDelimiter(p.line[p.firstNonspace:]); ok {
|
||||
if table := p.tryOpenTable(container, aligns); table != nil {
|
||||
p.advanceOffset(len(p.line)-p.offset, false)
|
||||
return table, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !indented {
|
||||
if label, markerLen, ok := scanFootnoteDefStart(p.line[p.firstNonspace:]); ok {
|
||||
def := p.addChild(container, kindFootnoteDef)
|
||||
def.label = normaliseLabel(label)
|
||||
def.padding = codeIndent
|
||||
p.advanceOffset(p.firstNonspace+markerLen-p.offset, false)
|
||||
container = def
|
||||
opened = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !indented {
|
||||
if container.kind == kindParagraph || container.kind == kindDefList {
|
||||
if scanDefMarker(p.line[p.firstNonspace:]) {
|
||||
if item := p.tryOpenDefItem(container); item != nil {
|
||||
container = item
|
||||
opened = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !indented && container.kind == kindParagraph {
|
||||
if level, ok := scanSetext(p.line[p.firstNonspace:]); ok {
|
||||
// Reference definitions leave the paragraph first; the
|
||||
// heading forms only over what remains of it, and a
|
||||
// paragraph the definitions emptied turns the underline
|
||||
// back into plain text.
|
||||
p.extractReferences(container)
|
||||
if len(container.content) == 0 {
|
||||
parent := container.parent
|
||||
parent.children = parent.children[:len(parent.children)-1]
|
||||
p.tip = parent
|
||||
return parent, true
|
||||
}
|
||||
container.kind = kindHeading
|
||||
container.level = level
|
||||
p.advanceOffset(len(p.line)-p.offset, false)
|
||||
return container, true
|
||||
}
|
||||
}
|
||||
if !indented && isThematicBreak(p.line[p.firstNonspace:]) {
|
||||
p.addChild(container, kindThematicBreak)
|
||||
p.advanceOffset(len(p.line)-p.offset, false)
|
||||
return p.tip, true
|
||||
}
|
||||
if !indented {
|
||||
if data, markerLen, ok := p.parseListMarker(container.kind == kindParagraph); ok {
|
||||
if container.kind != kindList || !listsMatch(container, data) {
|
||||
l := p.addChild(container, kindList)
|
||||
l.listKind = data.listKind
|
||||
l.bulletChar = data.bulletChar
|
||||
l.delimiter = data.delimiter
|
||||
l.start = data.start
|
||||
container = l
|
||||
}
|
||||
item := p.addChild(container, kindListItem)
|
||||
item.markerOffset = p.indent
|
||||
p.advanceOffset(p.firstNonspace+markerLen-p.offset, false)
|
||||
saveOffset, saveColumn, saveTab := p.offset, p.column, p.partiallyConsumedTab
|
||||
for p.offset < len(p.line) && isSpaceTab(p.line[p.offset]) {
|
||||
p.advanceOffset(1, true)
|
||||
}
|
||||
cols := p.column - saveColumn
|
||||
blankItem := p.offset >= len(p.line)
|
||||
padding := markerLen + cols
|
||||
if blankItem || cols >= 5 || cols < 1 {
|
||||
padding = markerLen + 1
|
||||
}
|
||||
p.offset, p.column, p.partiallyConsumedTab = saveOffset, saveColumn, saveTab
|
||||
item.padding = padding
|
||||
p.advanceOffset(padding-markerLen, true)
|
||||
container = item
|
||||
opened = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
return container, opened
|
||||
}
|
||||
}
|
||||
|
||||
// listData carries what a list marker says about the list it belongs to.
|
||||
type listData struct {
|
||||
listKind listKind
|
||||
bulletChar byte
|
||||
delimiter byte
|
||||
start int
|
||||
}
|
||||
|
||||
// listsMatch reports whether a new marker continues the given list.
|
||||
func listsMatch(l *Node, d listData) bool {
|
||||
if l.listKind != d.listKind {
|
||||
return false
|
||||
}
|
||||
if d.listKind == bulletList {
|
||||
return l.bulletChar == d.bulletChar
|
||||
}
|
||||
return l.delimiter == d.delimiter
|
||||
}
|
||||
|
||||
// parseListMarker recognises a list marker at the first non-space
|
||||
// character. A marker interrupting a paragraph must carry content, and an
|
||||
// ordered one must number 1.
|
||||
func (p *parser) parseListMarker(interrupts bool) (listData, int, bool) {
|
||||
s := p.line[p.firstNonspace:]
|
||||
if len(s) == 0 {
|
||||
return listData{}, 0, false
|
||||
}
|
||||
var d listData
|
||||
i := 0
|
||||
switch c := s[0]; {
|
||||
case c == '-' || c == '+' || c == '*':
|
||||
d.listKind = bulletList
|
||||
d.bulletChar = c
|
||||
i = 1
|
||||
case c >= '0' && c <= '9':
|
||||
start := 0
|
||||
for i < len(s) && i < 9 && s[i] >= '0' && s[i] <= '9' {
|
||||
start = start*10 + int(s[i]-'0')
|
||||
i++
|
||||
}
|
||||
if i >= len(s) || (s[i] != '.' && s[i] != ')') {
|
||||
return listData{}, 0, false
|
||||
}
|
||||
if interrupts && start != 1 {
|
||||
return listData{}, 0, false
|
||||
}
|
||||
d.listKind = orderedList
|
||||
d.delimiter = s[i]
|
||||
d.start = start
|
||||
i++
|
||||
default:
|
||||
return listData{}, 0, false
|
||||
}
|
||||
if i < len(s) && !isSpaceTab(s[i]) {
|
||||
return listData{}, 0, false
|
||||
}
|
||||
if interrupts {
|
||||
j := i
|
||||
for j < len(s) && isSpaceTab(s[j]) {
|
||||
j++
|
||||
}
|
||||
if j >= len(s) {
|
||||
return listData{}, 0, false
|
||||
}
|
||||
}
|
||||
return d, i, true
|
||||
}
|
||||
|
||||
// tryOpenTable turns the last line of an open paragraph into the header of
|
||||
// a table whose delimiter row is on the current line. The paragraph keeps
|
||||
// its earlier lines, or disappears when the header was all of it. The
|
||||
// table opens only when the header and the delimiter row agree on the
|
||||
// number of columns.
|
||||
func (p *parser) tryOpenTable(para *Node, aligns []uint8) *Node {
|
||||
content := para.content
|
||||
if len(content) == 0 || content[len(content)-1] != '\n' {
|
||||
return nil
|
||||
}
|
||||
lastNewline := bytes.LastIndexByte(content[:len(content)-1], '\n') + 1
|
||||
headerLine := content[lastNewline : len(content)-1]
|
||||
cells := splitTableRow(headerLine)
|
||||
if len(cells) != len(aligns) {
|
||||
return nil
|
||||
}
|
||||
parent := para.parent
|
||||
if lastNewline > 0 {
|
||||
para.content = content[:lastNewline]
|
||||
p.finalise(para)
|
||||
} else {
|
||||
parent.children = parent.children[:len(parent.children)-1]
|
||||
p.tip = parent
|
||||
}
|
||||
table := p.addChild(parent, kindTable)
|
||||
table.align = aligns
|
||||
table.header = cells
|
||||
return table
|
||||
}
|
||||
|
||||
// tryOpenDefItem turns the line's definition marker into a definition
|
||||
// inside a definition list. When the container is a paragraph, the
|
||||
// paragraph's lines become the terms of a new entry; when it is a
|
||||
// definition list, the marker adds another definition to the entry. The
|
||||
// returned item is the container for the definition's content, with the
|
||||
// position placed at the content.
|
||||
func (p *parser) tryOpenDefItem(container *Node) *Node {
|
||||
var dl *Node
|
||||
if container.kind == kindParagraph {
|
||||
dl = p.defListFromTerms(container)
|
||||
if dl == nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
dl = container
|
||||
}
|
||||
item := p.addChild(dl, kindDefItem)
|
||||
item.markerOffset = p.indent
|
||||
p.advanceOffset(p.firstNonspace+1-p.offset, false)
|
||||
saveOffset, saveColumn, saveTab := p.offset, p.column, p.partiallyConsumedTab
|
||||
for p.offset < len(p.line) && isSpaceTab(p.line[p.offset]) {
|
||||
p.advanceOffset(1, true)
|
||||
}
|
||||
cols := p.column - saveColumn
|
||||
blankItem := p.offset >= len(p.line)
|
||||
padding := 1 + cols
|
||||
if blankItem || cols >= 5 || cols < 1 {
|
||||
padding = 2
|
||||
}
|
||||
p.offset, p.column, p.partiallyConsumedTab = saveOffset, saveColumn, saveTab
|
||||
item.padding = padding
|
||||
p.advanceOffset(padding-1, true)
|
||||
return item
|
||||
}
|
||||
|
||||
// defListFromTerms turns a paragraph of terms into definition terms of a
|
||||
// definition list, continuing the list when one is already open beside the
|
||||
// paragraph.
|
||||
func (p *parser) defListFromTerms(para *Node) *Node {
|
||||
if len(para.content) == 0 || para.content[len(para.content)-1] != '\n' {
|
||||
return nil
|
||||
}
|
||||
lines := splitLines(para.content)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
parent := para.parent
|
||||
parent.children = parent.children[:len(parent.children)-1]
|
||||
p.tip = parent
|
||||
var dl *Node
|
||||
switch {
|
||||
case parent.kind == kindDefList:
|
||||
dl = parent
|
||||
case len(parent.children) > 0 && parent.children[len(parent.children)-1].kind == kindDefList:
|
||||
dl = parent.children[len(parent.children)-1]
|
||||
default:
|
||||
dl = p.addChild(parent, kindDefList)
|
||||
}
|
||||
for _, line := range lines {
|
||||
dt := p.addChild(dl, kindDefTerm)
|
||||
dt.content = line
|
||||
}
|
||||
return dl
|
||||
}
|
||||
|
||||
// addText places the remaining text of the line: into the open paragraph
|
||||
// when it continues, lazily or not, or into the container that accepts
|
||||
// lines, or into a new block otherwise. It also records which blocks the
|
||||
// blank line terminates, which decides list tightness.
|
||||
func (p *parser) addText(container *Node, opened bool) {
|
||||
p.findFirstNonspace()
|
||||
|
||||
if p.suppressBlankMark {
|
||||
p.suppressBlankMark = false
|
||||
return
|
||||
}
|
||||
|
||||
if p.blank && len(container.children) > 0 {
|
||||
container.children[len(container.children)-1].lastLineBlank = true
|
||||
}
|
||||
lastBlank := p.blank &&
|
||||
container.kind != kindBlockquote &&
|
||||
container.kind != kindHeading &&
|
||||
container.kind != kindThematicBreak &&
|
||||
!(container.kind == kindCodeBlock && container.fenced) &&
|
||||
!(container.kind == kindListItem && len(container.children) == 0 && container.startLine == p.lineNo)
|
||||
container.lastLineBlank = lastBlank
|
||||
for n := container.parent; n != nil; n = n.parent {
|
||||
n.lastLineBlank = false
|
||||
}
|
||||
|
||||
maybeLazy := p.tip.kind == kindParagraph
|
||||
if maybeLazy && !opened && !p.blank {
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
p.appendLine(p.tip, true)
|
||||
return
|
||||
}
|
||||
|
||||
p.closeUnmatched(container)
|
||||
|
||||
switch {
|
||||
case container.kind == kindCodeBlock:
|
||||
p.appendLine(container, true)
|
||||
case container.kind == kindHTMLBlock:
|
||||
p.appendLine(container, true)
|
||||
if htmlBlockEnds(container.htmlType, p.line[p.offset:]) {
|
||||
p.finalise(container)
|
||||
}
|
||||
case p.blank:
|
||||
// A blank line adds no content.
|
||||
case container.kind == kindTable:
|
||||
row := splitTableRow(p.line[p.offset:])
|
||||
for len(row) < len(container.header) {
|
||||
row = append(row, nil)
|
||||
}
|
||||
container.rows = append(container.rows, row[:len(container.header)])
|
||||
case container.kind == kindParagraph:
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
p.appendLine(container, true)
|
||||
case container.kind == kindHeading:
|
||||
container.content = append(container.content, chopClosingHashes(p.line[p.firstNonspace:])...)
|
||||
default:
|
||||
if p.indent >= codeIndent && !maybeLazy {
|
||||
code := p.addChild(container, kindCodeBlock)
|
||||
p.advanceOffset(codeIndent, true)
|
||||
p.appendLine(code, true)
|
||||
} else {
|
||||
if container.kind == kindListItem && len(container.children) == 0 && container.startLine == p.lineNo {
|
||||
if checked, n, ok := scanTaskMarker(p.line[p.firstNonspace:]); ok {
|
||||
p.advanceOffset(p.firstNonspace+n-p.offset, false)
|
||||
container.task = true
|
||||
container.taskDone = checked
|
||||
p.findFirstNonspace()
|
||||
}
|
||||
}
|
||||
para := p.addChild(container, kindParagraph)
|
||||
p.advanceOffset(p.firstNonspace-p.offset, false)
|
||||
p.appendLine(para, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addChild attaches a new block below parent, closing open blocks that
|
||||
// cannot contain it, and makes it the tip.
|
||||
func (p *parser) addChild(parent *Node, kind nodeKind) *Node {
|
||||
for !canContain(parent.kind, kind) {
|
||||
p.finalise(parent)
|
||||
parent = parent.parent
|
||||
}
|
||||
n := &Node{kind: kind, parent: parent, startLine: p.lineNo}
|
||||
parent.children = append(parent.children, n)
|
||||
p.tip = n
|
||||
return n
|
||||
}
|
||||
|
||||
// closeUnmatched closes every open block below the given container.
|
||||
func (p *parser) closeUnmatched(container *Node) {
|
||||
for p.tip != container {
|
||||
p.finalise(p.tip)
|
||||
}
|
||||
}
|
||||
|
||||
// finalise closes a block and its children, trimming content and computing
|
||||
// derived data such as list tightness.
|
||||
func (p *parser) finalise(n *Node) {
|
||||
if n.finalised {
|
||||
return
|
||||
}
|
||||
n.finalised = true
|
||||
for _, c := range n.children {
|
||||
p.finalise(c)
|
||||
}
|
||||
switch n.kind {
|
||||
case kindParagraph:
|
||||
p.extractReferences(n)
|
||||
if len(n.content) == 0 && n.parent != nil {
|
||||
n.parent.children = n.parent.children[:len(n.parent.children)-1]
|
||||
}
|
||||
case kindHeading:
|
||||
if bytes.HasSuffix(n.content, []byte("\n")) {
|
||||
n.content = n.content[:len(n.content)-1]
|
||||
}
|
||||
case kindCodeBlock:
|
||||
if n.fenced {
|
||||
if i := bytes.IndexByte(n.content, '\n'); i >= 0 {
|
||||
n.info = unescapeText(string(bytes.TrimSpace(n.content[:i])))
|
||||
n.content = n.content[i+1:]
|
||||
} else {
|
||||
n.info = unescapeText(string(bytes.TrimSpace(n.content)))
|
||||
n.content = nil
|
||||
}
|
||||
} else {
|
||||
n.content = trimTrailingBlankLines(n.content)
|
||||
}
|
||||
case kindList, kindDefList:
|
||||
n.tight = !blocksAreLoose(n.children)
|
||||
case kindDocument:
|
||||
p.gatherFootnotes(n)
|
||||
}
|
||||
if p.tip == n {
|
||||
p.tip = n.parent
|
||||
}
|
||||
}
|
||||
|
||||
// trimTrailingBlankLines removes the trailing blank lines of an indented
|
||||
// code block. Every content line carries its newline, so the result of a
|
||||
// non-empty block ends with exactly one.
|
||||
func trimTrailingBlankLines(c []byte) []byte {
|
||||
for len(c) > 0 {
|
||||
end := len(c) - 1 // the final newline
|
||||
start := bytes.LastIndexByte(c[:end], '\n') + 1
|
||||
if !allSpaceTab(c[start:end]) {
|
||||
return c
|
||||
}
|
||||
c = c[:start]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// blocksAreLoose reports whether any two sibling blocks, or any two blocks
|
||||
// of one container-like block, are separated by a blank line.
|
||||
func blocksAreLoose(nodes []*Node) bool {
|
||||
for i, n := range nodes {
|
||||
if endsWithBlank(n) && i+1 < len(nodes) {
|
||||
return true
|
||||
}
|
||||
switch n.kind {
|
||||
case kindListItem, kindDefItem:
|
||||
for j, child := range n.children {
|
||||
lastItem := i+1 == len(nodes)
|
||||
lastChild := j+1 == len(n.children)
|
||||
if endsWithBlank(child) && (!lastItem || !lastChild) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// endsWithBlank reports whether the block, or the last block inside a
|
||||
// container chain, was followed by a blank line.
|
||||
func endsWithBlank(n *Node) bool {
|
||||
if n.lastLineChecked {
|
||||
return n.lastLineBlank
|
||||
}
|
||||
n.lastLineChecked = true
|
||||
switch n.kind {
|
||||
case kindList, kindListItem, kindDefList, kindDefItem, kindFootnoteDef:
|
||||
if len(n.children) > 0 {
|
||||
return endsWithBlank(n.children[len(n.children)-1])
|
||||
}
|
||||
}
|
||||
return n.lastLineBlank
|
||||
}
|
||||
|
||||
// gatherFootnotes lifts every footnote definition out of the tree, keeping
|
||||
// the first definition of a label.
|
||||
func (p *parser) gatherFootnotes(doc *Node) {
|
||||
seen := map[string]bool{}
|
||||
var defs []*Node
|
||||
var walk func(n *Node)
|
||||
walk = func(n *Node) {
|
||||
keep := n.children[:0]
|
||||
for _, c := range n.children {
|
||||
if c.kind == kindFootnoteDef {
|
||||
if !seen[c.label] {
|
||||
seen[c.label] = true
|
||||
defs = append(defs, c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
walk(c)
|
||||
keep = append(keep, c)
|
||||
}
|
||||
n.children = keep
|
||||
}
|
||||
walk(doc)
|
||||
doc.footnotes = defs
|
||||
}
|
||||
|
||||
// appendLine appends the remaining line to the block's content. A tab
|
||||
// partially consumed while skipping indentation becomes the spaces it
|
||||
// stood for.
|
||||
func (p *parser) appendLine(n *Node, newline bool) {
|
||||
if p.partiallyConsumedTab {
|
||||
p.offset++
|
||||
for i := tabStop - p.column%tabStop; i > 0; i-- {
|
||||
n.content = append(n.content, ' ')
|
||||
}
|
||||
}
|
||||
n.content = append(n.content, p.line[p.offset:]...)
|
||||
if newline {
|
||||
n.content = append(n.content, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
// advanceOffset moves into the line by count bytes, or columns when
|
||||
// columns is set, expanding tabs to tab stops and leaving a tab partially
|
||||
// consumed when the count stops inside it.
|
||||
func (p *parser) advanceOffset(count int, columns bool) {
|
||||
for count > 0 && p.offset < len(p.line) {
|
||||
c := p.line[p.offset]
|
||||
if c != '\t' {
|
||||
p.partiallyConsumedTab = false
|
||||
p.offset++
|
||||
p.column++
|
||||
count--
|
||||
continue
|
||||
}
|
||||
toTab := tabStop - p.column%tabStop
|
||||
if columns {
|
||||
p.partiallyConsumedTab = toTab > count
|
||||
steps := min(count, toTab)
|
||||
p.column += steps
|
||||
if !p.partiallyConsumedTab {
|
||||
p.offset++
|
||||
}
|
||||
count -= steps
|
||||
} else {
|
||||
p.partiallyConsumedTab = false
|
||||
p.offset++
|
||||
p.column += toTab
|
||||
count--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findFirstNonspace locates the first non-space character from the offset
|
||||
// onward, computing the column of that character and the indentation of
|
||||
// the line relative to the offset. The line is blank when the first
|
||||
// non-space character does not exist.
|
||||
func (p *parser) findFirstNonspace() {
|
||||
toTab := tabStop - p.column%tabStop
|
||||
p.firstNonspace = p.offset
|
||||
p.firstNonspaceColumn = p.column
|
||||
for p.firstNonspace < len(p.line) {
|
||||
c := p.line[p.firstNonspace]
|
||||
switch c {
|
||||
case ' ':
|
||||
p.firstNonspace++
|
||||
p.firstNonspaceColumn++
|
||||
toTab--
|
||||
if toTab == 0 {
|
||||
toTab = tabStop
|
||||
}
|
||||
case '\t':
|
||||
p.firstNonspace++
|
||||
p.firstNonspaceColumn += toTab
|
||||
toTab = tabStop
|
||||
default:
|
||||
p.indent = p.firstNonspaceColumn - p.column
|
||||
p.blank = false
|
||||
return
|
||||
}
|
||||
}
|
||||
p.indent = p.firstNonspaceColumn - p.column
|
||||
p.blank = true
|
||||
}
|
||||
|
||||
// normalise prepares source for parsing: line endings become newlines and
|
||||
// a null byte becomes the replacement character.
|
||||
func normalise(source []byte) []byte {
|
||||
out := make([]byte, 0, len(source))
|
||||
for i := 0; i < len(source); i++ {
|
||||
switch c := source[i]; c {
|
||||
case '\r':
|
||||
if i+1 < len(source) && source[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
out = append(out, '\n')
|
||||
case 0:
|
||||
out = append(out, '\xef', '\xbf', '\xbd')
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitLines splits normalised source into lines without their newlines.
|
||||
// A trailing newline produces no empty final line.
|
||||
func splitLines(source []byte) [][]byte {
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i, c := range source {
|
||||
if c == '\n' {
|
||||
lines = append(lines, source[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(source) {
|
||||
lines = append(lines, source[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
Reference in New Issue
Block a user