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 = "" } } 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("") } 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("") } b.WriteString("") } b.WriteString("
    " + r.inline(c) + "
    " + r.inline(c) + "
    ") 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, ":") }