package main
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"unicode"
)
// Markdown rendering for tracker text (reviews, checklists) and for the file
// viewer (docs/*.md). Supported: paragraphs, headings, ordered and unordered
// lists, pipe tables, fenced code (```mermaid becomes a diagram on the file
// viewer), block quotes, horizontal rules, **bold**, *italic*, `code` and
// [links](url).
//
// Everything is HTML-escaped before formatting, and links are only kept for
// http, https, mailto, relative and #fragment targets, so text from data files
// cannot inject markup or scripts.
type mdOptions struct {
headingShift int // add to heading levels: 2 turns "#" into
inside cards
headingIDs bool // add GitHub-style ids so doc#anchor links work
pathLink func(path string) string // link `code` spans that name a repo file (opens in a new tab)
}
// markdown renders tracker text: headings start at h3 and repo paths in
// `code` become links to the file viewer.
func markdown(src string) template.HTML {
return renderMarkdown(src, mdOptions{headingShift: 2, pathLink: refHref})
}
func renderMarkdown(src string, o mdOptions) template.HTML {
r := mdRenderer{opts: o, ids: map[string]int{}}
return template.HTML(r.render(src))
}
type mdRenderer struct {
opts mdOptions
ids map[string]int
}
var (
reHeading = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*#*$`)
reBullet = regexp.MustCompile(`^\s*[-*+]\s+(.+)$`)
reNumbered = regexp.MustCompile(`^\s*\d+[.)]\s+(.+)$`)
reRule = regexp.MustCompile(`^(?:(?:-\s*){3,}|(?:\*\s*){3,}|(?:_\s*){3,})$`)
reTableSep = regexp.MustCompile(`^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$`)
reCode = regexp.MustCompile("`([^`]+)`")
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
reBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
reItalic = regexp.MustCompile(`(^|[^*\w])\*([^*\s][^*]*)\*`)
)
func (r *mdRenderer) render(src string) string {
lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n")
var b strings.Builder
var para []string
list := "" // "ul" or "ol" while inside a list
flushPara := func() {
if len(para) > 0 {
b.WriteString("
" + r.inline(strings.Join(para, " ")) + "
")
para = nil
}
}
closeList := func() {
if list != "" {
b.WriteString("" + list + ">")
list = ""
}
}
openList := func(kind string) {
if list != kind {
closeList()
b.WriteString("<" + kind + ">")
list = kind
}
}
endBlock := func() { flushPara(); closeList() }
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "```"):
endBlock()
lang := strings.TrimSpace(strings.TrimPrefix(trimmed, "```"))
var code []string
for i++; i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```"); i++ {
code = append(code, lines[i])
}
body := html.EscapeString(strings.Join(code, "\n"))
if lang == "mermaid" {
b.WriteString(`` + body + "
")
} else {
b.WriteString("" + body + "
")
}
case trimmed == "":
endBlock()
case reRule.MatchString(trimmed):
endBlock()
b.WriteString("
")
case reHeading.MatchString(trimmed):
endBlock()
m := reHeading.FindStringSubmatch(trimmed)
level := len(m[1]) + r.opts.headingShift
if level > 6 {
level = 6
}
id := ""
if r.opts.headingIDs {
id = fmt.Sprintf(` id="%s"`, html.EscapeString(r.slug(m[2])))
}
b.WriteString(fmt.Sprintf("%s", level, id, r.inline(m[2]), level))
case strings.Contains(trimmed, "|") && i+1 < len(lines) && reTableSep.MatchString(lines[i+1]):
endBlock()
i = r.table(&b, lines, i)
case reBullet.MatchString(line):
flushPara()
openList("ul")
b.WriteString("" + r.inline(reBullet.FindStringSubmatch(line)[1]) + "")
case reNumbered.MatchString(line):
flushPara()
openList("ol")
b.WriteString("" + r.inline(reNumbered.FindStringSubmatch(line)[1]) + "")
case strings.HasPrefix(trimmed, ">"):
endBlock()
b.WriteString("" + r.inline(strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))) + "
")
default:
closeList()
para = append(para, trimmed)
}
}
endBlock()
return b.String()
}
// table renders a pipe table starting at lines[start] (the header row) and
// returns the index of its last row.
func (r *mdRenderer) table(b *strings.Builder, lines []string, start int) int {
b.WriteString(``)
for _, c := range splitRow(lines[start]) {
b.WriteString("| " + r.inline(c) + " | ")
}
b.WriteString("
")
i := start + 2
for ; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if t == "" || !strings.Contains(t, "|") {
break
}
b.WriteString("")
for _, c := range splitRow(lines[i]) {
b.WriteString("| " + r.inline(c) + " | ")
}
b.WriteString("
")
}
b.WriteString("
")
return i - 1
}
// splitRow splits "| a | b \| c |" into cells, honouring escaped pipes and
// pipes inside `code`.
func splitRow(line string) []string {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "|")
if strings.HasSuffix(line, "|") && !strings.HasSuffix(line, `\|`) {
line = line[:len(line)-1]
}
var cells []string
var cur strings.Builder
inCode := false
for i := 0; i < len(line); i++ {
ch := line[i]
switch {
case ch == '\\' && i+1 < len(line) && line[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()))
}
// slug makes a GitHub-style anchor: lower case, letters, marks and digits
// kept (Thai included), spaces to hyphens, other punctuation dropped.
func (r *mdRenderer) slug(text string) string {
text = reLink.ReplaceAllString(text, "$1")
text = strings.NewReplacer("`", "", "**", "", "*", "").Replace(text)
var b strings.Builder
for _, ch := range strings.ToLower(text) {
switch {
case unicode.IsLetter(ch) || unicode.IsMark(ch) || unicode.IsDigit(ch) || ch == '-' || ch == '_':
b.WriteRune(ch)
case ch == ' ':
b.WriteByte('-')
}
}
s := b.String()
if n := r.ids[s]; n > 0 {
r.ids[s]++
return fmt.Sprintf("%s-%d", s, n)
}
r.ids[s] = 1
return s
}
// inline formats one line. Code spans are cut out first so their contents
// are not formatted.
func (r *mdRenderer) inline(s string) string {
s = html.EscapeString(s)
var codes []string
s = reCode.ReplaceAllStringFunc(s, func(m string) string {
inner := reCode.FindStringSubmatch(m)[1]
out := "" + inner + ""
if r.opts.pathLink != nil {
if href := r.opts.pathLink(html.UnescapeString(inner)); href != "" {
out = `` + out + ``
}
}
codes = append(codes, out)
return fmt.Sprintf("\x00%d\x00", len(codes)-1)
})
s = reLink.ReplaceAllStringFunc(s, func(m string) string {
parts := reLink.FindStringSubmatch(m)
href := html.UnescapeString(parts[2])
if !safeHref(href) {
return parts[1]
}
return `` + parts[1] + ``
})
s = reBold.ReplaceAllString(s, "$1")
s = reItalic.ReplaceAllString(s, "$1$2")
for i, c := range codes {
s = strings.Replace(s, fmt.Sprintf("\x00%d\x00", i), c, 1)
}
return s
}
func safeHref(h string) bool {
l := strings.ToLower(h)
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "mailto:") ||
strings.HasPrefix(l, "/") || strings.HasPrefix(l, "#") || strings.HasPrefix(l, "./") || strings.HasPrefix(l, "../") {
return true
}
// Relative paths such as docs/agent/13-role-matrix.md (no scheme).
return !strings.Contains(l, ":")
}