clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

render a safe Markdown subset for READMEs and relay text

Dami committed 56c842a
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>
2 files changed, +116 -0
Ainternal/webui/markdown.go98++++++++++++++++++++++++++++++
Ainternal/webui/markdown_test.go18++++++
A internal/webui/markdown.go +98 -0
@@ -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+}
A internal/webui/markdown_test.go +18 -0