306 lines
7.6 KiB
Go
306 lines
7.6 KiB
Go
package internal
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// BlockKind is the kind of a Markdown block.
|
|
type BlockKind uint8
|
|
|
|
const (
|
|
BlockParagraph BlockKind = iota
|
|
BlockHeading
|
|
BlockList
|
|
BlockQuote
|
|
BlockCode
|
|
BlockTable
|
|
BlockRule
|
|
)
|
|
|
|
// MdSpan is a run of inline text with one style.
|
|
type MdSpan struct {
|
|
Text string
|
|
// URL is set for a link.
|
|
URL string
|
|
Strong, Em, Code, Strike bool
|
|
}
|
|
|
|
// MdItem is one list item.
|
|
type MdItem struct {
|
|
// Number is the ordinal of an ordered item, 0 for a bullet.
|
|
Number int
|
|
Depth int
|
|
Spans []MdSpan
|
|
}
|
|
|
|
// TableAlign is a table column's alignment.
|
|
type TableAlign uint8
|
|
|
|
const (
|
|
AlignLeft TableAlign = iota
|
|
AlignCenter
|
|
AlignRight
|
|
)
|
|
|
|
// MdBlock is one parsed block.
|
|
type MdBlock struct {
|
|
Kind BlockKind
|
|
// Level is a heading's level, 1 to 6.
|
|
Level int
|
|
// Spans hold a paragraph's, heading's or quote's text.
|
|
Spans []MdSpan
|
|
Items []MdItem
|
|
// Lang and Code hold a fenced code block; Open is true until its fence closes.
|
|
Lang, Code string
|
|
Open bool
|
|
Header []string
|
|
Align []TableAlign
|
|
Rows [][]string
|
|
}
|
|
|
|
// Markdown is a document parsed incrementally as its source grows.
|
|
type Markdown struct {
|
|
src string
|
|
Blocks []MdBlock
|
|
starts []int
|
|
}
|
|
|
|
// Update parses src and returns the index of the first block parsed anew.
|
|
// When src extends the last source, blocks before the last stay as they are.
|
|
func (m *Markdown) Update(src string) int {
|
|
if src == m.src && m.starts != nil {
|
|
return len(m.Blocks)
|
|
}
|
|
from := 0
|
|
if len(m.Blocks) > 0 && strings.HasPrefix(src, m.src) {
|
|
from = len(m.Blocks) - 1
|
|
}
|
|
off := 0
|
|
if from > 0 {
|
|
off = m.starts[from]
|
|
}
|
|
m.Blocks, m.starts = m.Blocks[:from], m.starts[:from]
|
|
m.src = src
|
|
parseBlocks(m, src, off)
|
|
return from
|
|
}
|
|
|
|
// mdLine is one source line and its byte offset.
|
|
type mdLine struct {
|
|
text string
|
|
off int
|
|
}
|
|
|
|
func parseBlocks(m *Markdown, src string, off int) {
|
|
var lines []mdLine
|
|
for off < len(src) {
|
|
end := strings.IndexByte(src[off:], '\n')
|
|
if end < 0 {
|
|
lines = append(lines, mdLine{src[off:], off})
|
|
break
|
|
}
|
|
lines = append(lines, mdLine{strings.TrimRight(src[off:off+end], "\r"), off})
|
|
off += end + 1
|
|
}
|
|
for i := 0; i < len(lines); {
|
|
l := lines[i].text
|
|
if strings.TrimSpace(l) == "" {
|
|
i++
|
|
continue
|
|
}
|
|
start := lines[i].off
|
|
var b MdBlock
|
|
switch {
|
|
case mdFence(l) != "":
|
|
b, i = parseFenced(lines, i)
|
|
case mdHeadingLevel(l) > 0:
|
|
lv := mdHeadingLevel(l)
|
|
b = MdBlock{Kind: BlockHeading, Level: lv, Spans: ParseInline(strings.TrimSpace(strings.TrimRight(l[lv:], "# ")))}
|
|
i++
|
|
case mdIsRule(l):
|
|
b = MdBlock{Kind: BlockRule}
|
|
i++
|
|
case strings.HasPrefix(strings.TrimLeft(l, " "), ">"):
|
|
b, i = parseQuote(lines, i)
|
|
case mdListMarker(l) != nil:
|
|
b, i = parseList(lines, i)
|
|
case i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil:
|
|
b, i = parseTable(lines, i)
|
|
default:
|
|
b, i = parseParagraph(lines, i)
|
|
}
|
|
m.Blocks = append(m.Blocks, b)
|
|
m.starts = append(m.starts, start)
|
|
}
|
|
}
|
|
|
|
// mdStarts reports whether line l begins a block other than a paragraph.
|
|
func mdStarts(lines []mdLine, i int) bool {
|
|
l := lines[i].text
|
|
return strings.TrimSpace(l) == "" || mdFence(l) != "" || mdHeadingLevel(l) > 0 || mdIsRule(l) ||
|
|
strings.HasPrefix(strings.TrimLeft(l, " "), ">") || mdListMarker(l) != nil ||
|
|
i+1 < len(lines) && strings.Contains(l, "|") && mdDelimiter(lines[i+1].text) != nil
|
|
}
|
|
|
|
func parseParagraph(lines []mdLine, i int) (MdBlock, int) {
|
|
var parts []string
|
|
for ; i < len(lines) && (len(parts) == 0 || !mdStarts(lines, i)); i++ {
|
|
parts = append(parts, strings.TrimSpace(lines[i].text))
|
|
}
|
|
return MdBlock{Kind: BlockParagraph, Spans: ParseInline(strings.Join(parts, " "))}, i
|
|
}
|
|
|
|
// mdFence returns the fence a line opens, "```" or "~~~", or "".
|
|
func mdFence(l string) string {
|
|
t := strings.TrimLeft(l, " ")
|
|
for _, f := range []string{"```", "~~~"} {
|
|
if strings.HasPrefix(t, f) {
|
|
return f
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseFenced(lines []mdLine, i int) (MdBlock, int) {
|
|
fence := mdFence(lines[i].text)
|
|
b := MdBlock{Kind: BlockCode, Open: true, Lang: strings.ToLower(strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(lines[i].text), fence[:1])))}
|
|
var code []string
|
|
for i++; i < len(lines); i++ {
|
|
if strings.HasPrefix(strings.TrimSpace(lines[i].text), fence) && strings.Trim(strings.TrimSpace(lines[i].text), fence[:1]) == "" {
|
|
b.Open = false
|
|
i++
|
|
break
|
|
}
|
|
code = append(code, lines[i].text)
|
|
}
|
|
b.Code = strings.Join(code, "\n")
|
|
return b, i
|
|
}
|
|
|
|
func mdHeadingLevel(l string) int {
|
|
n := 0
|
|
for n < len(l) && l[n] == '#' {
|
|
n++
|
|
}
|
|
if n == 0 || n > 6 || n < len(l) && l[n] != ' ' {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func mdIsRule(l string) bool {
|
|
t := strings.ReplaceAll(strings.TrimSpace(l), " ", "")
|
|
if len(t) < 3 {
|
|
return false
|
|
}
|
|
return strings.Count(t, t[:1]) == len(t) && strings.ContainsAny(t[:1], "-*_")
|
|
}
|
|
|
|
func parseQuote(lines []mdLine, i int) (MdBlock, int) {
|
|
var parts []string
|
|
for ; i < len(lines); i++ {
|
|
t := strings.TrimLeft(lines[i].text, " ")
|
|
if !strings.HasPrefix(t, ">") {
|
|
break
|
|
}
|
|
parts = append(parts, strings.TrimSpace(t[1:]))
|
|
}
|
|
return MdBlock{Kind: BlockQuote, Spans: ParseInline(strings.Join(parts, " "))}, i
|
|
}
|
|
|
|
// mdMarker is a list item's marker.
|
|
type mdMarker struct {
|
|
number, depth, width int
|
|
}
|
|
|
|
func mdListMarker(l string) *mdMarker {
|
|
indent := len(l) - len(strings.TrimLeft(l, " "))
|
|
t := l[indent:]
|
|
if len(t) >= 2 && strings.ContainsRune("-*+", rune(t[0])) && t[1] == ' ' {
|
|
return &mdMarker{depth: indent / 2, width: indent + 2}
|
|
}
|
|
d := 0
|
|
for d < len(t) && t[d] >= '0' && t[d] <= '9' {
|
|
d++
|
|
}
|
|
if d == 0 || d > 9 || d+1 >= len(t) || !strings.ContainsRune(".)", rune(t[d])) || t[d+1] != ' ' {
|
|
return nil
|
|
}
|
|
n, _ := strconv.Atoi(t[:d])
|
|
return &mdMarker{number: max(n, 1), depth: indent / 2, width: indent + d + 2}
|
|
}
|
|
|
|
func parseList(lines []mdLine, i int) (MdBlock, int) {
|
|
b := MdBlock{Kind: BlockList}
|
|
var texts []string
|
|
for ; i < len(lines); i++ {
|
|
l := lines[i].text
|
|
if mk := mdListMarker(l); mk != nil {
|
|
b.Items = append(b.Items, MdItem{Number: mk.number, Depth: mk.depth})
|
|
texts = append(texts, strings.TrimSpace(l[mk.width:]))
|
|
continue
|
|
}
|
|
if mdStarts(lines, i) {
|
|
break
|
|
}
|
|
// A lazy line continues the item above.
|
|
texts[len(texts)-1] += " " + strings.TrimSpace(l)
|
|
}
|
|
for j := range b.Items {
|
|
b.Items[j].Spans = ParseInline(texts[j])
|
|
}
|
|
return b, i
|
|
}
|
|
|
|
// mdCells splits a table row into trimmed cells.
|
|
func mdCells(l string) []string {
|
|
t := strings.TrimSpace(l)
|
|
t = strings.TrimSuffix(strings.TrimPrefix(t, "|"), "|")
|
|
cells := strings.Split(t, "|")
|
|
for i := range cells {
|
|
cells[i] = strings.TrimSpace(cells[i])
|
|
}
|
|
return cells
|
|
}
|
|
|
|
// mdDelimiter parses a table's delimiter row, or returns nil.
|
|
func mdDelimiter(l string) []TableAlign {
|
|
if !strings.Contains(l, "-") {
|
|
return nil
|
|
}
|
|
var out []TableAlign
|
|
for _, c := range mdCells(l) {
|
|
left, right := strings.HasPrefix(c, ":"), strings.HasSuffix(c, ":")
|
|
if strings.Trim(c, ":") == "" || strings.Trim(c, ":-") != "" {
|
|
return nil
|
|
}
|
|
switch {
|
|
case left && right:
|
|
out = append(out, AlignCenter)
|
|
case right:
|
|
out = append(out, AlignRight)
|
|
default:
|
|
out = append(out, AlignLeft)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseTable(lines []mdLine, i int) (MdBlock, int) {
|
|
b := MdBlock{Kind: BlockTable, Header: mdCells(lines[i].text), Align: mdDelimiter(lines[i+1].text)}
|
|
n := len(b.Header)
|
|
for len(b.Align) < n {
|
|
b.Align = append(b.Align, AlignLeft)
|
|
}
|
|
b.Align = b.Align[:n]
|
|
for i += 2; i < len(lines) && strings.Contains(lines[i].text, "|") && strings.TrimSpace(lines[i].text) != ""; i++ {
|
|
row := mdCells(lines[i].text)
|
|
for len(row) < n {
|
|
row = append(row, "")
|
|
}
|
|
b.Rows = append(b.Rows, row[:n])
|
|
}
|
|
return b, i
|
|
}
|