Headings, paragraphs, fenced code, lists, inline code, emphasis and links with http, relative, fragment or mailto targets. Everything else stays escaped text.
- commit
- 56c842a74b9eb8f56ad038b1667aef7eeff135ba
- parent
- 7ab32ee7956634513df53eeac31647842a644b60
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 98 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 18 | ++++++ |
@@ -0,0 +1,98 @@
1+package webui2+3+import (4+ "html"5+ "html/template"6+ "regexp"7+ "strings"8+)9+10+// renderMarkdown turns a small, common subset of Markdown into HTML: ATX11+// headings, paragraphs, fenced code, unordered and ordered lists, inline12+// code, emphasis, and links. Every piece of text is escaped first and only13+// the tags this function emits reach the page, so untrusted README files and14+// relay descriptions cannot inject markup. Anything else stays plain text.15+func renderMarkdown(source string) template.HTML {16+ lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")17+ var out strings.Builder18+ var paragraph []string19+ flush := func() {20+ if len(paragraph) == 0 {21+ return22+ }23+ out.WriteString("<p>" + inlineMarkdown(strings.Join(paragraph, " ")) + "</p>\n")24+ paragraph = paragraph[:0]25+ }26+ for i := 0; i < len(lines); i++ {27+ line := lines[i]28+ switch {29+ case strings.HasPrefix(line, "```"):30+ flush()31+ var code []string32+ for i++; i < len(lines) && !strings.HasPrefix(lines[i], "```"); i++ {33+ code = append(code, lines[i])34+ }35+ out.WriteString("<pre><code>" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre>\n")36+ case strings.HasPrefix(line, "#"):37+ flush()38+ level := len(line) - len(strings.TrimLeft(line, "#"))39+ if level > 6 {40+ level = 641+ }42+ text := strings.TrimSpace(line[level:])43+ tag := "h" + string(rune('0'+level))44+ out.WriteString("<" + tag + ">" + inlineMarkdown(text) + "</" + tag + ">\n")45+ case isListItem(line):46+ flush()47+ ordered := orderedItem.MatchString(line)48+ tag := "ul"49+ if ordered {50+ tag = "ol"51+ }52+ out.WriteString("<" + tag + ">")53+ for ; i < len(lines) && isListItem(lines[i]); i++ {54+ out.WriteString("<li>" + inlineMarkdown(listText(lines[i])) + "</li>")55+ }56+ i--57+ out.WriteString("</" + tag + ">\n")58+ case strings.TrimSpace(line) == "":59+ flush()60+ default:61+ paragraph = append(paragraph, strings.TrimSpace(line))62+ }63+ }64+ flush()65+ return template.HTML(out.String())66+}67+68+var (69+ orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`)70+ bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`)71+ codeSpan = regexp.MustCompile("`([^`]+)`")72+ linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(((?:https?://|/|\./|#|mailto:)[^)\s]*)\)`)73+ strongSpan = regexp.MustCompile(`\*\*([^*]+)\*\*`)74+ emSpan = regexp.MustCompile(`(^|[^*\w])\*([^*]+)\*`)75+)76+77+func isListItem(line string) bool {78+ return bulletItem.MatchString(line) || orderedItem.MatchString(line)79+}80+81+func listText(line string) string {82+ if loc := bulletItem.FindStringIndex(line); loc != nil {83+ return line[loc[1]:]84+ }85+ return line[orderedItem.FindStringIndex(line)[1]:]86+}87+88+// inlineMarkdown escapes text, then restores the few inline forms it89+// recognises. Link targets are restricted to http, https, relative paths,90+// fragments and mailto so scripts and data URLs stay text.91+func inlineMarkdown(text string) string {92+ escaped := html.EscapeString(text)93+ escaped = codeSpan.ReplaceAllString(escaped, "<code>$1</code>")94+ escaped = linkSpan.ReplaceAllString(escaped, `<a href="$2">$1</a>`)95+ escaped = strongSpan.ReplaceAllString(escaped, "<strong>$1</strong>")96+ escaped = emSpan.ReplaceAllString(escaped, "$1<em>$2</em>")97+ return escaped98+}@@ -0,0 +1,18 @@
1+package webui2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) {9+ got := string(renderMarkdown("# Title\n\nA `code` span with **bold** and *em* and [a link](https://example.com).\n\n```sh\ngo build <x>\n```\n\n- one\n- two\n\n1. first\n2. second\n\n<script>alert(1)</script> and [bad](javascript:alert(1))"))10+ for _, want := range []string{"<h1>Title</h1>", "<code>code</code>", "<strong>bold</strong>", "<em>em</em>", `<a href="https://example.com">a link</a>`, "<pre><code>go build <x></code></pre>", "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "<script>alert(1)</script>"} {11+ if !strings.Contains(got, want) {12+ t.Errorf("missing %q in %s", want, got)13+ }14+ }15+ if strings.Contains(got, "<script>") || strings.Contains(got, `href="javascript:`) {16+ t.Fatalf("unsafe markup leaked: %s", got)17+ }18+}