MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
// 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) }
|
||||
@@ -0,0 +1,49 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownRendersStructure(t *testing.T) {
|
||||
out := Markdown("## 标题\n\n正文 **粗体**。\n\n- 一\n- 二\n\n> 引用\n")
|
||||
for _, want := range []string{"<h2", "<strong>粗体</strong>", "<li>一</li>", "<blockquote>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownEscapesRawHTML(t *testing.T) {
|
||||
out := Markdown("<script>alert(1)</script>")
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Errorf("raw HTML should be escaped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownBlocksUnsafeSchemes(t *testing.T) {
|
||||
out := Markdown(`[x](javascript:alert(1))`)
|
||||
if strings.Contains(out, "javascript:") {
|
||||
t.Errorf("unsafe scheme should be stripped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadingMinutes(t *testing.T) {
|
||||
if got := ReadingMinutes(""); got != 1 {
|
||||
t.Errorf("empty content should be 1 minute, got %d", got)
|
||||
}
|
||||
long := strings.Repeat("字", 1200)
|
||||
if got := ReadingMinutes(long); got != 3 {
|
||||
t.Errorf("1200 CJK chars should be 3 minutes, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcerpt(t *testing.T) {
|
||||
got := Excerpt("# 标题\n\n这是正文。\n\n```go\nfmt.Println()\n```", 140)
|
||||
if strings.Contains(got, "fmt.Println") {
|
||||
t.Errorf("code fences should be dropped, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "这是正文。") {
|
||||
t.Errorf("excerpt should keep body text, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user