118 lines
2.8 KiB
Go
118 lines
2.8 KiB
Go
// 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 "<p>" + escapeHTML(src) + "</p>"
|
|
}
|
|
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) }
|