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("

" + text + "

\n") } else { id := slug(g[2]) b.WriteString("` + text + "\n") } i++ case trim == "---" || trim == "***": b.WriteString("
\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("
\n") m.blocks(b, inner) b.WriteString("
\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("

" + m.inline(strings.Join(para, "\n")) + "

\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(`
" + html.EscapeString(strings.Join(code, "\n")) + "
\n") return i + 1 } func (m markdown) table(b *strings.Builder, lines []string, i int) int { head := splitRow(lines[i]) b.WriteString("
\n") for _, c := range head { b.WriteString("") } b.WriteString("\n\n") i += 2 for i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|") { b.WriteString("") for _, c := range splitRow(lines[i]) { b.WriteString("") } b.WriteString("\n") i++ } b.WriteString("
" + m.inline(c) + "
" + m.inline(c) + "
\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("
  • ") 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("
  • \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("\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("" + html.EscapeString(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 `" + g[1] + "" }) s = boldRe.ReplaceAllString(s, "$1") return strings.ReplaceAll(s, "\n", " ") } func slug(s string) string { s = strings.Trim(slugRe.ReplaceAllString(strings.ToLower(s), "-"), "-") if s == "" { return "section" } return s }