ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/md.go

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 <h3> 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("<p>" + r.inline(strings.Join(para, " ")) + "</p>")
			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(`<pre class="mermaid">` + body + "</pre>")
			} else {
				b.WriteString("<pre><code>" + body + "</code></pre>")
			}
		case trimmed == "":
			endBlock()
		case reRule.MatchString(trimmed):
			endBlock()
			b.WriteString("<hr>")
		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("<h%d%s>%s</h%d>", 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("<li>" + r.inline(reBullet.FindStringSubmatch(line)[1]) + "</li>")
		case reNumbered.MatchString(line):
			flushPara()
			openList("ol")
			b.WriteString("<li>" + r.inline(reNumbered.FindStringSubmatch(line)[1]) + "</li>")
		case strings.HasPrefix(trimmed, ">"):
			endBlock()
			b.WriteString("<blockquote>" + r.inline(strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))) + "</blockquote>")
		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(`<div class="md-table"><table><thead><tr>`)
	for _, c := range splitRow(lines[start]) {
		b.WriteString("<th>" + r.inline(c) + "</th>")
	}
	b.WriteString("</tr></thead><tbody>")
	i := start + 2
	for ; i < len(lines); i++ {
		t := strings.TrimSpace(lines[i])
		if t == "" || !strings.Contains(t, "|") {
			break
		}
		b.WriteString("<tr>")
		for _, c := range splitRow(lines[i]) {
			b.WriteString("<td>" + r.inline(c) + "</td>")
		}
		b.WriteString("</tr>")
	}
	b.WriteString("</tbody></table></div>")
	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 := "<code>" + inner + "</code>"
		if r.opts.pathLink != nil {
			if href := r.opts.pathLink(html.UnescapeString(inner)); href != "" {
				out = `<a class="ref-link" href="` + html.EscapeString(href) + `" target="_blank" rel="noopener">` + out + `</a>`
			}
		}
		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 `<a href="` + html.EscapeString(href) + `">` + parts[1] + `</a>`
	})
	s = reBold.ReplaceAllString(s, "<strong>$1</strong>")
	s = reItalic.ReplaceAllString(s, "$1<em>$2</em>")
	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, ":")
}