feat: Markdown 工具栏编辑器与服务端预览

共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:50:07 +08:00
parent 7f048bd9b5
commit 1f777eebb1
17 changed files with 1056 additions and 232 deletions

View File

@@ -7,73 +7,266 @@ import (
)
var (
mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
mdStrikeRe = regexp.MustCompile(`~~([^~]+)~~`)
mdItalicRe = regexp.MustCompile(`\*([^*\n]+)\*`)
mdInlineCodeRe = regexp.MustCompile("`([^`\n]+)`")
mdLangRe = regexp.MustCompile(`^[a-zA-Z0-9_+-]+$`)
)
// ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。
// 若已含块级 HTML / 门控标签则原样交 Sanitize否则按纯文本/轻量 Markdown 转段落
// ComposeBodyToHTML 将发帖/评论表单正文转为可消毒 HTML。
// 若已含块级 HTML / 门控标签则原样交 Sanitize否则按轻量 Markdown 转
func ComposeBodyToHTML(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lower := strings.ToLower(raw)
if strings.Contains(lower, "<p") || strings.Contains(lower, "<div") ||
strings.Contains(lower, "<h1") || strings.Contains(lower, "<ul") ||
strings.Contains(lower, "<ol") || strings.Contains(lower, "<pre") ||
strings.Contains(lower, "<blockquote") ||
strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") ||
strings.Contains(lower, "<points-only") {
if looksLikeRichHTML(raw) {
return raw
}
return mdDocumentToHTML(raw)
}
// 先转义,再恢复轻量 md 图片/链接
esc := html.EscapeString(raw)
esc = mdImageRe.ReplaceAllStringFunc(esc, func(m string) string {
sub := mdImageRe.FindStringSubmatch(m)
if len(sub) != 3 {
return m
func looksLikeRichHTML(raw string) bool {
lower := strings.ToLower(raw)
markers := []string{
"<p", "<div", "<h1", "<h2", "<h3", "<ul", "<ol", "<pre", "<blockquote",
"<table", "<members-only", "<reply-only", "<points-only",
}
for _, m := range markers {
if strings.Contains(lower, m) {
return true
}
alt, src := sub[1], sub[2]
// 仅允许站内 uploads 或 http(s)
if !safeComposeURL(src) {
return m
}
return `<p><img src="` + html.EscapeString(src) + `" alt="` + alt + `"></p>`
})
esc = mdLinkRe.ReplaceAllStringFunc(esc, func(m string) string {
sub := mdLinkRe.FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
text, href := sub[1], sub[2]
if !safeComposeURL(href) {
return m
}
return `<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer">` + text + `</a>`
})
}
return false
}
parts := strings.Split(esc, "\n\n")
func mdDocumentToHTML(raw string) string {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
var b strings.Builder
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
i := 0
for i < len(lines) {
line := lines[i]
trim := strings.TrimSpace(line)
// 围栏代码块
if strings.HasPrefix(trim, "```") {
lang := strings.TrimSpace(strings.TrimPrefix(trim, "```"))
lang = strings.TrimSpace(strings.Split(lang, " ")[0])
i++
var code []string
for i < len(lines) {
if strings.TrimSpace(lines[i]) == "```" {
i++
break
}
code = append(code, lines[i])
i++
}
b.WriteString("<pre><code")
if lang != "" && mdLangRe.MatchString(lang) {
b.WriteString(` class="language-`)
b.WriteString(html.EscapeString(lang))
b.WriteString(`"`)
}
b.WriteString(">")
b.WriteString(html.EscapeString(strings.Join(code, "\n")))
b.WriteString("</code></pre>\n")
continue
}
// 已是独立 img 段
if strings.HasPrefix(p, "<p><img ") {
b.WriteString(p)
// 空行跳过
if trim == "" {
i++
continue
}
// 标题:####### → h2h6禁止 h1
if level, title, ok := parseMDHeading(trim); ok {
b.WriteString("<h")
b.WriteString(string(rune('0' + level)))
b.WriteString(">")
b.WriteString(mdInlineToHTML(title))
b.WriteString("</h")
b.WriteString(string(rune('0' + level)))
b.WriteString(">\n")
i++
continue
}
// 引用
if strings.HasPrefix(trim, "> ") || trim == ">" {
var quote []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == ">" {
quote = append(quote, "")
i++
continue
}
if strings.HasPrefix(t, "> ") {
quote = append(quote, strings.TrimPrefix(t, "> "))
i++
continue
}
break
}
b.WriteString("<blockquote><p>")
b.WriteString(mdInlineToHTML(strings.Join(quote, "<br>\n")))
b.WriteString("</p></blockquote>\n")
continue
}
// 无序列表
if isMDUL(trim) {
b.WriteString("<ul>\n")
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !isMDUL(t) {
break
}
item := strings.TrimSpace(t[1:])
if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") {
item = strings.TrimSpace(t[2:])
}
b.WriteString("<li>")
b.WriteString(mdInlineToHTML(item))
b.WriteString("</li>\n")
i++
}
b.WriteString("</ul>\n")
continue
}
// 有序列表
if isMDOL(trim) {
b.WriteString("<ol>\n")
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !isMDOL(t) {
break
}
idx := strings.Index(t, ". ")
item := strings.TrimSpace(t[idx+2:])
b.WriteString("<li>")
b.WriteString(mdInlineToHTML(item))
b.WriteString("</li>\n")
i++
}
b.WriteString("</ol>\n")
continue
}
// 普通段落(连续非空、非特殊行)
var para []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == "" || strings.HasPrefix(t, "```") || isMDUL(t) || isMDOL(t) ||
strings.HasPrefix(t, "> ") || t == ">" {
break
}
if _, _, ok := parseMDHeading(t); ok {
break
}
para = append(para, lines[i])
i++
}
joined := strings.Join(para, "\n")
// 整段已是图片行则单独输出
if m := mdImageRe.FindStringSubmatch(strings.TrimSpace(joined)); len(m) == 3 && strings.TrimSpace(joined) == m[0] && safeComposeURL(m[2]) {
b.WriteString(`<p><img src="`)
b.WriteString(html.EscapeString(m[2]))
b.WriteString(`" alt="`)
b.WriteString(m[1])
b.WriteString(`"></p>` + "\n")
continue
}
p = strings.ReplaceAll(p, "\n", "<br>\n")
b.WriteString("<p>")
b.WriteString(p)
b.WriteString(mdInlineToHTML(strings.ReplaceAll(joined, "\n", "<br>\n")))
b.WriteString("</p>\n")
}
return b.String()
}
func parseMDHeading(trim string) (level int, title string, ok bool) {
n := 0
for n < len(trim) && trim[n] == '#' {
n++
}
if n < 1 || n > 6 {
return 0, "", false
}
if n >= len(trim) || trim[n] != ' ' {
return 0, "", false
}
title = strings.TrimSpace(trim[n+1:])
if title == "" {
return 0, "", false
}
level = n
if level == 1 {
level = 2 // 帖正文禁止 h1
}
return level, title, true
}
func isMDUL(trim string) bool {
return strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") || strings.HasPrefix(trim, "+ ")
}
var mdOLRe = regexp.MustCompile(`^\d+\. `)
func isMDOL(trim string) bool {
return mdOLRe.MatchString(trim)
}
func mdInlineToHTML(s string) string {
// 先抽出行内代码,避免其它规则干扰
type ph struct{ key, html string }
var phs []ph
s = mdInlineCodeRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdInlineCodeRe.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
key := "\x00CODE" + string(rune('A'+len(phs))) + "\x00"
phs = append(phs, ph{key: key, html: "<code>" + html.EscapeString(sub[1]) + "</code>"})
return key
})
s = html.EscapeString(s)
// 占位符被 escape 了,还原 key
for i := range phs {
escKey := html.EscapeString(phs[i].key)
s = strings.ReplaceAll(s, escKey, phs[i].key)
}
s = mdImageRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdImageRe.FindStringSubmatch(m)
if len(sub) != 3 || !safeComposeURL(sub[2]) {
return m
}
return `<img src="` + html.EscapeString(sub[2]) + `" alt="` + sub[1] + `">`
})
s = mdLinkRe.ReplaceAllStringFunc(s, func(m string) string {
sub := mdLinkRe.FindStringSubmatch(m)
if len(sub) != 3 || !safeComposeURL(sub[2]) {
return m
}
return `<a href="` + html.EscapeString(sub[2]) + `" rel="noopener noreferrer">` + sub[1] + `</a>`
})
s = mdBoldRe.ReplaceAllString(s, `<strong>$1</strong>`)
s = mdStrikeRe.ReplaceAllString(s, `<s>$1</s>`)
s = mdItalicRe.ReplaceAllString(s, `<em>$1</em>`)
for _, p := range phs {
s = strings.ReplaceAll(s, p.key, p.html)
}
return s
}
func safeComposeURL(u string) bool {
u = strings.TrimSpace(u)
if strings.HasPrefix(u, "/uploads/") || strings.HasPrefix(u, "/media/") {
@@ -82,24 +275,71 @@ func safeComposeURL(u string) bool {
return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
}
// HTMLToComposePlain 编辑页回显:去掉简单标签便于 textarea 编辑(尽力而为)
// HTMLToComposePlain 编辑页回显:尽力还原为 Markdown / 纯文本;含门控时保留 HTML。
func HTMLToComposePlain(htmlBody string) string {
s := strings.TrimSpace(htmlBody)
if s == "" {
return ""
}
lower := strings.ToLower(s)
// 含门控块时保留 HTML避免编辑丢失
if strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") ||
strings.Contains(lower, "<points-only") {
return s
}
// img → markdown
imgRe := regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`)
s = imgRe.ReplaceAllString(s, "![]($1)")
// 代码块
s = regexp.MustCompile(`(?is)<pre[^>]*>\s*<code(?:\s+class="language-([^"]*)")?[^>]*>([\s\S]*?)</code>\s*</pre>`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?is)<pre[^>]*>\s*<code(?:\s+class="language-([^"]*)")?[^>]*>([\s\S]*?)</code>\s*</pre>`).FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
lang, body := sub[1], html.UnescapeString(sub[2])
return "```" + lang + "\n" + body + "\n```\n\n"
})
s = regexp.MustCompile(`(?i)<h([2-6])[^>]*>([\s\S]*?)</h[2-6]>`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?i)<h([2-6])[^>]*>([\s\S]*?)</h[2-6]>`).FindStringSubmatch(m)
if len(sub) != 3 {
return m
}
level := sub[1][0] - '0'
inner := stripTagsKeepText(sub[2])
return strings.Repeat("#", int(level)) + " " + inner + "\n\n"
})
s = regexp.MustCompile(`(?i)<li[^>]*>([\s\S]*?)</li>`).ReplaceAllString(s, "- $1\n")
s = regexp.MustCompile(`(?i)</?[uo]l[^>]*>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)<blockquote[^>]*>([\s\S]*?)</blockquote>`).ReplaceAllStringFunc(s, func(m string) string {
sub := regexp.MustCompile(`(?i)<blockquote[^>]*>([\s\S]*?)</blockquote>`).FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
inner := stripTagsKeepText(sub[1])
lines := strings.Split(inner, "\n")
for i := range lines {
lines[i] = "> " + strings.TrimSpace(lines[i])
}
return strings.Join(lines, "\n") + "\n\n"
})
s = regexp.MustCompile(`(?i)<strong[^>]*>([\s\S]*?)</strong>`).ReplaceAllString(s, "**$1**")
s = regexp.MustCompile(`(?i)<b[^>]*>([\s\S]*?)</b>`).ReplaceAllString(s, "**$1**")
s = regexp.MustCompile(`(?i)<em[^>]*>([\s\S]*?)</em>`).ReplaceAllString(s, "*$1*")
s = regexp.MustCompile(`(?i)<i[^>]*>([\s\S]*?)</i>`).ReplaceAllString(s, "*$1*")
s = regexp.MustCompile(`(?i)<s[^>]*>([\s\S]*?)</s>`).ReplaceAllString(s, "~~$1~~")
s = regexp.MustCompile(`(?i)<del[^>]*>([\s\S]*?)</del>`).ReplaceAllString(s, "~~$1~~")
s = regexp.MustCompile(`(?i)<code[^>]*>([\s\S]*?)</code>`).ReplaceAllString(s, "`$1`")
s = regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`).ReplaceAllString(s, "![]($1)")
s = regexp.MustCompile(`(?i)<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)</a>`).ReplaceAllString(s, "[$2]($1)")
s = regexp.MustCompile(`(?i)</p>\s*<p>`).ReplaceAllString(s, "\n\n")
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)</?p[^>]*>`).ReplaceAllString(s, "")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func stripTagsKeepText(s string) string {
s = regexp.MustCompile(`(?i)<br\s*/?>`).ReplaceAllString(s, "\n")
s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}

View File

@@ -0,0 +1,72 @@
package services
import (
"strings"
"testing"
)
func TestComposeBodyToHTML_MarkdownBasics(t *testing.T) {
in := strings.TrimSpace(`
## 标题
段落 **粗体** 与 *斜体* 和 ~~删除~~ 与 ` + "`code`" + `
- 甲
- 乙
1. 一
2. 二
> 引用行
` + "```go\nfmt.Println(1)\n```" + `
[链接](https://example.com)
![图](/uploads/a.png)
`)
out := ComposeBodyToHTML(in)
checks := []string{
"<h2>", "标题", "</h2>",
"<strong>粗体</strong>",
"<em>斜体</em>",
"<s>删除</s>",
"<code>code</code>",
"<ul>", "<li>",
"<ol>",
"<blockquote>",
`class="language-go"`,
"fmt.Println(1)",
`href="https://example.com"`,
`src="/uploads/a.png"`,
}
for _, c := range checks {
if !strings.Contains(out, c) {
t.Fatalf("missing %q in:\n%s", c, out)
}
}
if strings.Contains(out, "<h1>") {
t.Fatal("must not emit h1")
}
}
func TestComposeBodyToHTML_PassThroughGates(t *testing.T) {
in := `<members-only data-gate="login"><p>隐</p></members-only>`
if got := ComposeBodyToHTML(in); got != in {
t.Fatalf("gate html should pass through, got %q", got)
}
}
func TestComposeBodyToHTML_H1MapsToH2(t *testing.T) {
out := ComposeBodyToHTML("# 顶标题")
if !strings.Contains(out, "<h2>") || strings.Contains(out, "<h1>") {
t.Fatalf("got %s", out)
}
}
func TestHTMLToComposePlain_RoundTripLite(t *testing.T) {
html := ComposeBodyToHTML("## Hi\n\n**x**")
plain := HTMLToComposePlain(html)
if !strings.Contains(plain, "## Hi") || !strings.Contains(plain, "**x**") {
t.Fatalf("plain=%q", plain)
}
}