// Package render turns Markdown into sanitized HTML for the public site. package render import ( "bytes" "regexp" "strings" "unicode" "unicode/utf8" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" ) var md = goldmark.New( goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithParserOptions(parser.WithAutoHeadingID()), goldmark.WithRendererOptions(html.WithHardWraps()), ) var unsafeScheme = regexp.MustCompile(`(?i)(href|src)\s*=\s*"(javascript|data|vbscript):[^"]*"`) // Markdown renders Markdown to HTML. Raw HTML stays escaped (goldmark default) // and dangerous URL schemes are stripped. func Markdown(src string) string { var buf bytes.Buffer if err := md.Convert([]byte(src), &buf); err != nil { return "

" + escapeHTML(src) + "

" } out := buf.String() out = unsafeScheme.ReplaceAllString(out, `$1="#"`) return out } func escapeHTML(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) return r.Replace(s) } // ReadingMinutes estimates reading time: ~400 CJK chars or ~220 latin words // per minute, whichever dominates. func ReadingMinutes(markdown string) int { if strings.TrimSpace(markdown) == "" { return 1 } cjk := 0 latinWords := 0 inWord := false for _, r := range markdown { switch { case r >= 0x4E00 && r <= 0x9FFF, r >= 0x3400 && r <= 0x4DBF, r >= 0x3000 && r <= 0x303F, r >= 0xFF00 && r <= 0xFFEF: cjk++ inWord = false case unicode.IsSpace(r): inWord = false default: if !inWord { latinWords++ inWord = true } } } minutes := cjk/400 + latinWords/220 if minutes < 1 { return 1 } return minutes } // Excerpt builds a plain-text summary from Markdown when the author left the // summary field empty. func Excerpt(markdown string, limit int) string { var b strings.Builder inFence := false for _, line := range strings.Split(markdown, "\n") { t := strings.TrimSpace(line) if strings.HasPrefix(t, "```") { inFence = !inFence continue } if inFence || t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, ">") { continue } t = strings.TrimLeft(t, "-*+0123456789. ") b.WriteString(t) b.WriteString(" ") } s := strings.TrimSpace(b.String()) if limit <= 0 { limit = 140 } runes := []rune(s) if len(runes) <= limit { return string(runes) } return string(runes[:limit]) + "…" } // TitleFromMarkdown derives a fallback title for short posts. func TitleFromMarkdown(markdown string) string { for _, line := range strings.Split(markdown, "\n") { t := strings.TrimSpace(strings.TrimLeft(line, "# ")) if t != "" { runes := []rune(t) if len(runes) > 24 { return string(runes[:24]) + "…" } return t } } return "无题" } func RuneLen(s string) int { return utf8.RuneCountInString(s) }