MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)

This commit is contained in:
Sakurasan
2026-09-20 20:56:03 +08:00
parent edee708802
commit 4d8f2de3a4
96 changed files with 6005 additions and 2602 deletions
+117
View File
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
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) }
+49
View File
@@ -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)
}
}