feat: Markdown 工具栏编辑器与服务端预览
共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
// 标题:#–###### → h2–h6(禁止 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, "")
|
||||
|
||||
// 代码块
|
||||
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, "")
|
||||
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))
|
||||
}
|
||||
|
||||
72
services/compose_body_test.go
Normal file
72
services/compose_body_test.go
Normal 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)
|
||||

|
||||
`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user