270 lines
7.2 KiB
Go
270 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"html"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*?)\s*#*\s*$`)
|
|
listRe = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.*)$`)
|
|
sepRe = regexp.MustCompile(`^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$`)
|
|
linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
|
|
boldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
|
slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
|
)
|
|
|
|
// markdown converts the subset of Markdown used by the docs to HTML; link rewrites relative hrefs.
|
|
type markdown struct {
|
|
link func(href string) string
|
|
}
|
|
|
|
func (m markdown) render(src string) string {
|
|
lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n")
|
|
var b strings.Builder
|
|
m.blocks(&b, lines)
|
|
return b.String()
|
|
}
|
|
|
|
func (m markdown) blocks(b *strings.Builder, lines []string) {
|
|
for i := 0; i < len(lines); {
|
|
line := lines[i]
|
|
trim := strings.TrimSpace(line)
|
|
switch {
|
|
case trim == "":
|
|
i++
|
|
case strings.HasPrefix(trim, "```"):
|
|
i = m.fence(b, lines, i)
|
|
case headingRe.MatchString(trim):
|
|
g := headingRe.FindStringSubmatch(trim)
|
|
n := len(g[1])
|
|
text := m.inline(g[2])
|
|
if n == 1 {
|
|
b.WriteString("<h1>" + text + "</h1>\n")
|
|
} else {
|
|
id := slug(g[2])
|
|
b.WriteString("<h" + strconv.Itoa(n) + ` id="` + id + `">` + text + "</h" + strconv.Itoa(n) + ">\n")
|
|
}
|
|
i++
|
|
case trim == "---" || trim == "***":
|
|
b.WriteString("<hr>\n")
|
|
i++
|
|
case strings.HasPrefix(trim, "|") && i+1 < len(lines) && sepRe.MatchString(lines[i+1]):
|
|
i = m.table(b, lines, i)
|
|
case listRe.MatchString(line):
|
|
i = m.list(b, lines, i)
|
|
case strings.HasPrefix(trim, ">"):
|
|
var inner []string
|
|
for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), ">") {
|
|
inner = append(inner, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(lines[i]), ">"), " "))
|
|
i++
|
|
}
|
|
b.WriteString("<blockquote>\n")
|
|
m.blocks(b, inner)
|
|
b.WriteString("</blockquote>\n")
|
|
default:
|
|
var para []string
|
|
for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !startsBlock(lines, i) {
|
|
para = append(para, strings.TrimSpace(lines[i]))
|
|
i++
|
|
}
|
|
if len(para) == 0 {
|
|
para = append(para, trim)
|
|
i++
|
|
}
|
|
b.WriteString("<p>" + m.inline(strings.Join(para, "\n")) + "</p>\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func startsBlock(lines []string, i int) bool {
|
|
t := strings.TrimSpace(lines[i])
|
|
return strings.HasPrefix(t, "```") || headingRe.MatchString(t) || listRe.MatchString(lines[i]) ||
|
|
strings.HasPrefix(t, ">") || (strings.HasPrefix(t, "|") && i+1 < len(lines) && sepRe.MatchString(lines[i+1]))
|
|
}
|
|
|
|
func (m markdown) fence(b *strings.Builder, lines []string, i int) int {
|
|
open := strings.TrimSpace(lines[i])
|
|
lang := strings.TrimSpace(strings.TrimLeft(open, "`"))
|
|
var code []string
|
|
i++
|
|
for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") {
|
|
code = append(code, lines[i])
|
|
i++
|
|
}
|
|
class := ""
|
|
if lang != "" {
|
|
class = ` class="lang-` + html.EscapeString(lang) + `"`
|
|
}
|
|
b.WriteString(`<div class="code"><button type="button" class="copy" aria-label="复制代码">复制</button><pre><code` +
|
|
class + ">" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre></div>\n")
|
|
return i + 1
|
|
}
|
|
|
|
func (m markdown) table(b *strings.Builder, lines []string, i int) int {
|
|
head := splitRow(lines[i])
|
|
b.WriteString("<div class=\"table\"><table>\n<thead><tr>")
|
|
for _, c := range head {
|
|
b.WriteString("<th>" + m.inline(c) + "</th>")
|
|
}
|
|
b.WriteString("</tr></thead>\n<tbody>\n")
|
|
i += 2
|
|
for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") {
|
|
b.WriteString("<tr>")
|
|
for _, c := range splitRow(lines[i]) {
|
|
b.WriteString("<td>" + m.inline(c) + "</td>")
|
|
}
|
|
b.WriteString("</tr>\n")
|
|
i++
|
|
}
|
|
b.WriteString("</tbody></table></div>\n")
|
|
return i
|
|
}
|
|
|
|
// splitRow splits a table row on pipes outside code spans; `\|` is a literal pipe.
|
|
func splitRow(line string) []string {
|
|
s := strings.TrimSpace(line)
|
|
s = strings.TrimPrefix(s, "|")
|
|
if strings.HasSuffix(s, "|") && !strings.HasSuffix(s, `\|`) {
|
|
s = s[:len(s)-1]
|
|
}
|
|
var cells []string
|
|
var cur strings.Builder
|
|
inCode := false
|
|
for i := 0; i < len(s); i++ {
|
|
ch := s[i]
|
|
switch {
|
|
case ch == '\\' && i+1 < len(s) && s[i+1] == '|':
|
|
cur.WriteByte('|')
|
|
i++
|
|
case ch == '`':
|
|
inCode = !inCode
|
|
cur.WriteByte(ch)
|
|
case ch == '|' && !inCode:
|
|
cells = append(cells, strings.TrimSpace(cur.String()))
|
|
cur.Reset()
|
|
default:
|
|
cur.WriteByte(ch)
|
|
}
|
|
}
|
|
return append(cells, strings.TrimSpace(cur.String()))
|
|
}
|
|
|
|
func (m markdown) list(b *strings.Builder, lines []string, i int) int {
|
|
first := listRe.FindStringSubmatch(lines[i])
|
|
indent := len(first[1])
|
|
ordered := first[2][0] >= '0' && first[2][0] <= '9'
|
|
tag := "ul"
|
|
if ordered {
|
|
tag = "ol"
|
|
}
|
|
b.WriteString("<" + tag + ">\n")
|
|
for i < len(lines) {
|
|
g := listRe.FindStringSubmatch(lines[i])
|
|
if g == nil || len(g[1]) != indent {
|
|
break
|
|
}
|
|
body := []string{g[3]}
|
|
i++
|
|
for i < len(lines) {
|
|
l := lines[i]
|
|
if strings.TrimSpace(l) == "" {
|
|
if i+1 < len(lines) && leading(lines[i+1]) > indent {
|
|
body = append(body, "")
|
|
i++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if leading(l) <= indent {
|
|
break
|
|
}
|
|
body = append(body, strings.TrimLeft(l, " \t"))
|
|
i++
|
|
}
|
|
b.WriteString("<li>")
|
|
if len(body) == 1 || !hasBlock(body) {
|
|
b.WriteString(m.inline(strings.Join(body, "\n")))
|
|
} else {
|
|
var inner strings.Builder
|
|
m.blocks(&inner, body)
|
|
b.WriteString(inner.String())
|
|
}
|
|
b.WriteString("</li>\n")
|
|
if i < len(lines) && strings.TrimSpace(lines[i]) == "" && i+1 < len(lines) {
|
|
if g := listRe.FindStringSubmatch(lines[i+1]); g != nil && len(g[1]) == indent {
|
|
i++
|
|
}
|
|
}
|
|
}
|
|
b.WriteString("</" + tag + ">\n")
|
|
return i
|
|
}
|
|
|
|
func hasBlock(body []string) bool {
|
|
for i := range body {
|
|
if i > 0 && (strings.TrimSpace(body[i]) == "" || startsBlock(body, i)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func leading(s string) int {
|
|
return len(s) - len(strings.TrimLeft(s, " \t"))
|
|
}
|
|
|
|
// inline renders code spans, links and bold; everything else is escaped text.
|
|
func (m markdown) inline(s string) string {
|
|
var b strings.Builder
|
|
for {
|
|
start := strings.IndexByte(s, '`')
|
|
if start < 0 {
|
|
break
|
|
}
|
|
ticks := 1
|
|
for start+ticks < len(s) && s[start+ticks] == '`' {
|
|
ticks++
|
|
}
|
|
delim := strings.Repeat("`", ticks)
|
|
end := strings.Index(s[start+ticks:], delim)
|
|
if end < 0 {
|
|
break
|
|
}
|
|
b.WriteString(m.text(s[:start]))
|
|
code := strings.TrimSpace(s[start+ticks : start+ticks+end])
|
|
b.WriteString("<code>" + html.EscapeString(code) + "</code>")
|
|
s = s[start+ticks+end+ticks:]
|
|
}
|
|
b.WriteString(m.text(s))
|
|
return b.String()
|
|
}
|
|
|
|
func (m markdown) text(s string) string {
|
|
s = html.EscapeString(s)
|
|
s = linkRe.ReplaceAllStringFunc(s, func(x string) string {
|
|
g := linkRe.FindStringSubmatch(x)
|
|
href := html.UnescapeString(g[2])
|
|
if m.link != nil {
|
|
href = m.link(href)
|
|
}
|
|
attr := ""
|
|
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
|
attr = ` rel="noopener"`
|
|
}
|
|
return `<a href="` + html.EscapeString(href) + `"` + attr + ">" + g[1] + "</a>"
|
|
})
|
|
s = boldRe.ReplaceAllString(s, "<strong>$1</strong>")
|
|
return strings.ReplaceAll(s, "\n", " ")
|
|
}
|
|
|
|
func slug(s string) string {
|
|
s = strings.Trim(slugRe.ReplaceAllString(strings.ToLower(s), "-"), "-")
|
|
if s == "" {
|
|
return "section"
|
|
}
|
|
return s
|
|
}
|