From 1f777eebb1b6113c16c6301ab22a42c8678bc298 Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 30 Aug 2026 04:50:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Markdown=20=E5=B7=A5=E5=85=B7=E6=A0=8F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E4=B8=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共用 md_editor 于发帖/改帖/评论,扩展 ComposeBodyToHTML,并提供 /compose/preview。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 4 +- docs/rebuild-spec/06-pages-ux.md | 30 +-- docs/rebuild-spec/09-ssr-progress.md | 8 +- modules/webrender/render.go | 14 ++ public/assets/site.css | 49 ++++ public/assets/site.js | 288 +++++++++++++++++------ routers/web/compose.go | 23 ++ routers/web/home.go | 1 + routers/web/post.go | 15 +- services/compose_body.go | 338 +++++++++++++++++++++++---- services/compose_body_test.go | 72 ++++++ templates/compose.tmpl | 29 +-- templates/post/body.tmpl | 14 +- templates/post/comment_edit.tmpl | 14 +- templates/shared/md_editor.tmpl | 52 +++++ web_src/css/site.css | 49 ++++ web_src/js/site.js | 288 +++++++++++++++++------ 17 files changed, 1056 insertions(+), 232 deletions(-) create mode 100644 services/compose_body_test.go create mode 100644 templates/shared/md_editor.tmpl diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 686f57a..35c7f50 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -55,8 +55,8 @@ - [x] 发帖:选板块、标题、标签、正文 — SSR `/compose`(normal) - [x] 正文图片上传 — `/compose/upload` + Markdown 插入 -- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— 本分支改用 Markdown textarea 渐进增强 -- [ ] Markdown 编辑模式(与富文本互转/双模) +- [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— **本分支不做 TipTap**;改用 Markdown 工具栏渐进增强 +- [x] Markdown 编辑(工具栏 + `/compose/preview`;发帖/改帖/评论共用 `shared/md_editor`)— 非 TipTap 双模 - [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit` - [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`) - [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 4fab168..e8d2dff 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -28,7 +28,7 @@ | `/board/:id` | 板块 Feed | 已迁 | | `/boards` | 板块索引 | 已迁 | | `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 | -| `/compose` | 发帖(normal / question / poll / bounty / lottery;textarea + 图片 + 门控插入) | 已迁 | +| `/compose` | 发帖(类型字段 + Markdown 工具栏/预览/图片/门控) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/user/:id` | 公开用户页 | 已迁 | @@ -123,22 +123,24 @@ | 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 | | 抽奖 | 中奖人数 1–20 — SSR compose | -### 3.2 编辑器能力(应对齐) +### 3.2 编辑器能力(本分支:Markdown 渐进增强) -源:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx) 与 `editor/` 扩展 +源对照:[`ArticleEditor.tsx`]((仅 main)frontend/src/components/ArticleEditor.tsx)(TipTap,**不迁**) -- 标题 h2–h6(无 h1,避免与帖标题冲突) -- 粗体/斜体/删除线等基础标记 -- 链接对话框 -- 代码块(语言、选项对话框) -- 表格插入/编辑 -- 图片上传 + 图片组布局 + 浮动/清除浮动 -- 表情 / 贴纸选择器(多套:bilibili/douyin/tieba/weibo 等静态资源) -- **登录可见** / **回复可见** / **积分可见**(价格 1–9999)节点 -- 富文本 ↔ Markdown 双模(门控块有 markdown 约定,见 [`utils/markdownContent.ts`]((仅 main)frontend/src/utils/markdownContent.ts)) -- Tab 缩进 +| 能力 | SSR 状态 | +|------|----------| +| 标题 h2–h6(`#` 映射为 h2) | Markdown 工具栏 + `ComposeBodyToHTML` | +| 粗体/斜体/删除线/行内代码 | 已迁 | +| 链接 | prompt 插入 | +| 围栏代码块 | 已迁(语言 class) | +| 列表 / 引用 | 已迁 | +| 图片上传 | `/compose/upload` | +| 登录/回复/积分可见门控 | compose 工具栏 | +| 预览 | `POST /compose/preview`(同消毒管线) | +| Tab 缩进 / 未保存离开 | `beforeunload` | +| 表格 / 图片组 / 表情贴纸 / TipTap 双模 | **未做**(后置) | -未保存离开:`UnsavedChangesDialog`。 +共用片段:`templates/shared/md_editor.tmpl`(发帖、改帖、评论、改评)。 --- diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index 46a3475..de7fa03 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -10,15 +10,15 @@ | 项 | 值 | |----|-----| -| **上一刀** | 嵌套评论树(全局楼层 + 缩进) | -| **下一刀** | 需点名(OIDC / S3 / 编辑器 / Gitea / 主题…) | +| **上一刀** | Markdown 编辑器(工具栏 + 预览,共用 md_editor) | +| **下一刀** | 需点名(OIDC / S3 / Gitea / 主题 / 表格表情…) | | **工作区** | 应干净 | --- ## 一句话状态 -核心论坛、Admin、Limits/伪静态、**嵌套评论**已齐。余量多为后置与体验打磨。 +核心论坛、Admin、Limits/伪静态、嵌套评论、**Markdown 编辑器**已齐。TipTap / 表格 / 表情贴纸仍后置。 --- @@ -29,12 +29,14 @@ | 五种帖 + Admin P1 + §H | 已迁 | | Limits / 伪静态 / 品牌图 | 已迁 | | 评论嵌套树 UI | 已迁 | +| Markdown 工具栏 + preview | 已迁 | --- ## 未完成 - P3:主题、TipTip、游客评论、修订 diff +- 编辑器后置:TipTap、表格、图片组、表情贴纸 - 后置:OIDC、S3 热切换、Gitea `/projects` --- diff --git a/modules/webrender/render.go b/modules/webrender/render.go index f4b3385..57629ff 100644 --- a/modules/webrender/render.go +++ b/modules/webrender/render.go @@ -20,6 +20,20 @@ var ( func funcMap() template.FuncMap { return template.FuncMap{ "safeHTML": func(s string) template.HTML { return template.HTML(s) }, + "dict": func(values ...any) (map[string]any, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("dict 需要偶数个参数") + } + m := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + k, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict 键必须是字符串") + } + m[k] = values[i+1] + } + return m, nil + }, "sortURL": func(boardID uint, sort string) string { q := url.Values{} if sort != "" && sort != "latest" { diff --git a/public/assets/site.css b/public/assets/site.css index 6e27034..5945bc4 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -524,6 +524,55 @@ body.j13-body { } .j13-filebtn { display: inline-block; margin: 0; cursor: pointer; } .j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; } + +.j13-md-editor { margin: 0.35rem 0 0.75rem; } +.j13-md-editor__toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; + margin-bottom: 0.45rem; +} +.j13-md-editor__btn { + appearance: none; + border: 1px solid var(--j13-border); + background: var(--j13-surface, #fff); + color: inherit; + border-radius: 4px; + padding: 0.25rem 0.5rem; + font: inherit; + font-size: 0.8rem; + line-height: 1.3; + cursor: pointer; +} +.j13-md-editor__btn:hover { border-color: var(--j13-accent, #3366cc); } +.j13-md-editor__btn.is-active { + border-color: var(--j13-accent, #3366cc); + color: var(--j13-accent, #3366cc); +} +.j13-md-editor__filebtn { margin: 0; cursor: pointer; } +.j13-md-editor__sep { + width: 1px; + height: 1.1rem; + background: var(--j13-border); + margin: 0 0.15rem; +} +.j13-md-editor__input { + width: 100%; + min-height: 8rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9rem; + line-height: 1.5; +} +.j13-md-editor__preview { + min-height: 8rem; + padding: 0.75rem 0.85rem; + border: 1px solid var(--j13-border); + border-radius: 4px; + background: var(--j13-surface, #fff); +} +.j13-md-editor__status { min-height: 1.2em; margin: 0.35rem 0 0; } + .j13-poll-fields { margin: 0.75rem 0 1rem; padding: 0.75rem 1rem; diff --git a/public/assets/site.js b/public/assets/site.js index 16e5206..89acb95 100644 --- a/public/assets/site.js +++ b/public/assets/site.js @@ -1,17 +1,8 @@ // 姜十三论坛 SSR 渐进增强 document.documentElement.dataset.j13Ssr = "1"; -(function composeUploadAndGates() { - const form = document.getElementById("compose-form"); - const textarea = document.getElementById("compose-content"); - if (!form || !textarea) return; - - const csrf = form.getAttribute("data-csrf") || ""; - const uploadURL = form.getAttribute("data-upload") || "/compose/upload"; - const fileInput = document.getElementById("compose-image"); - const statusEl = document.getElementById("compose-upload-status"); - - function insertAtCursor(text) { +(function mdEditors() { + function insertAtCursor(textarea, text) { const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || start; textarea.value = @@ -21,14 +12,44 @@ document.documentElement.dataset.j13Ssr = "1"; textarea.setSelectionRange(pos, pos); } - function wrapSelection(openTag, closeTag, fallbackInner) { + function wrapSelection(textarea, before, after, fallback) { const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || start; let selected = textarea.value.slice(start, end); - if (!selected.trim()) { - selected = fallbackInner || "在此填写隐藏内容"; + if (!selected) selected = fallback || ""; + const block = before + selected + after; + textarea.value = + textarea.value.slice(0, start) + block + textarea.value.slice(end); + textarea.focus(); + if (fallback && !textarea.value.slice(start, end)) { + textarea.setSelectionRange(start + before.length, start + before.length + selected.length); + } else { + textarea.setSelectionRange(start + block.length, start + block.length); } - // 若选区尚非段落,包一层 p 便于消毒后结构稳定 + } + + function prefixLines(textarea, prefix) { + const start = textarea.selectionStart || 0; + const end = textarea.selectionEnd || start; + const value = textarea.value; + let lineStart = value.lastIndexOf("\n", start - 1) + 1; + let lineEnd = value.indexOf("\n", end); + if (lineEnd < 0) lineEnd = value.length; + const chunk = value.slice(lineStart, lineEnd); + const next = chunk + .split("\n") + .map((ln) => (ln.trim() ? prefix + ln.replace(/^\s+/, "") : prefix.trimEnd())) + .join("\n"); + textarea.value = value.slice(0, lineStart) + next + value.slice(lineEnd); + textarea.focus(); + textarea.setSelectionRange(lineStart, lineStart + next.length); + } + + function wrapGate(textarea, openTag, closeTag, statusEl) { + const start = textarea.selectionStart || 0; + const end = textarea.selectionEnd || start; + let selected = textarea.value.slice(start, end); + if (!selected.trim()) selected = "在此填写隐藏内容"; let inner = selected; if (!/^\s*" + selected.replace(/\n/g, "
\n") + "

"; @@ -37,65 +58,196 @@ document.documentElement.dataset.j13Ssr = "1"; textarea.value = textarea.value.slice(0, start) + block + textarea.value.slice(end); textarea.focus(); - const pos = start + block.length; - textarea.setSelectionRange(pos, pos); + textarea.setSelectionRange(start + block.length, start + block.length); if (statusEl) statusEl.textContent = "已插入门控块"; } - if (fileInput) { - fileInput.addEventListener("change", async () => { - const file = fileInput.files && fileInput.files[0]; - fileInput.value = ""; - if (!file) return; - if (statusEl) statusEl.textContent = "上传中…"; - const fd = new FormData(); - fd.append("image", file); - fd.append("_csrf", csrf); - try { - const res = await fetch(uploadURL, { - method: "POST", - headers: { "X-CSRF-Token": csrf }, - body: fd, - credentials: "same-origin", - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || "上传失败"); - const url = data.url; - if (!url) throw new Error("未返回图片地址"); - insertAtCursor(`\n\n![](${url})\n\n`); - if (statusEl) statusEl.textContent = "已插入图片"; - } catch (e) { - if (statusEl) statusEl.textContent = e.message || "上传失败"; - } + function initEditor(root) { + const textarea = root.querySelector("[data-md-input]"); + if (!textarea) return; + const statusEl = root.querySelector("[data-md-status]"); + const previewEl = root.querySelector("[data-md-preview]"); + const csrf = root.getAttribute("data-csrf") || ""; + const uploadURL = root.getAttribute("data-upload") || "/compose/upload"; + const previewURL = root.getAttribute("data-preview") || "/compose/preview"; + const unsaved = root.getAttribute("data-unsaved") === "1"; + let dirty = false; + let previewOn = false; + + function setStatus(msg) { + if (statusEl) statusEl.textContent = msg || ""; + } + + root.querySelectorAll("[data-md-cmd]").forEach((btn) => { + btn.addEventListener("click", async () => { + const cmd = btn.getAttribute("data-md-cmd"); + if (cmd === "preview") { + previewOn = !previewOn; + if (previewOn) { + setStatus("预览中…"); + try { + const fd = new FormData(); + fd.append("content", textarea.value); + fd.append("_csrf", csrf); + const res = await fetch(previewURL, { + method: "POST", + headers: { "X-CSRF-Token": csrf }, + body: fd, + credentials: "same-origin", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "预览失败"); + previewEl.innerHTML = data.html || "

(空)

"; + previewEl.hidden = false; + textarea.hidden = true; + btn.classList.add("is-active"); + setStatus("预览模式(再点返回编辑)"); + } catch (e) { + previewOn = false; + setStatus(e.message || "预览失败"); + } + } else { + previewEl.hidden = true; + previewEl.innerHTML = ""; + textarea.hidden = false; + btn.classList.remove("is-active"); + textarea.focus(); + setStatus(""); + } + return; + } + if (previewOn) return; + switch (cmd) { + case "bold": + wrapSelection(textarea, "**", "**", "粗体"); + break; + case "italic": + wrapSelection(textarea, "*", "*", "斜体"); + break; + case "strike": + wrapSelection(textarea, "~~", "~~", "删除线"); + break; + case "code": + wrapSelection(textarea, "`", "`", "code"); + break; + case "link": { + const url = window.prompt("链接地址", "https://"); + if (!url) return; + wrapSelection(textarea, "[", "](" + url + ")", "链接文字"); + break; + } + case "h2": + prefixLines(textarea, "## "); + break; + case "h3": + prefixLines(textarea, "### "); + break; + case "ul": + prefixLines(textarea, "- "); + break; + case "ol": + prefixLines(textarea, "1. "); + break; + case "quote": + prefixLines(textarea, "> "); + break; + case "fence": + wrapSelection(textarea, "```\n", "\n```", "code"); + break; + case "gate-login": + wrapGate(textarea, '', "", statusEl); + break; + case "gate-reply": + wrapGate(textarea, '', "", statusEl); + break; + case "gate-points": { + let cost = window.prompt("积分价格(1–9999)", "10"); + if (cost == null) return; + const n = parseInt(String(cost).trim(), 10); + if (!n || n < 1 || n > 9999) { + setStatus("价格须为 1–9999 的整数"); + return; + } + wrapGate( + textarea, + '', + "", + statusEl + ); + break; + } + } + dirty = true; + }); }); + + const fileInput = root.querySelector("[data-md-upload]"); + if (fileInput) { + fileInput.addEventListener("change", async () => { + const file = fileInput.files && fileInput.files[0]; + fileInput.value = ""; + if (!file || previewOn) return; + setStatus("上传中…"); + const fd = new FormData(); + fd.append("image", file); + fd.append("_csrf", csrf); + try { + const res = await fetch(uploadURL, { + method: "POST", + headers: { "X-CSRF-Token": csrf }, + body: fd, + credentials: "same-origin", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "上传失败"); + if (!data.url) throw new Error("未返回图片地址"); + insertAtCursor(textarea, "\n\n![](" + data.url + ")\n\n"); + dirty = true; + setStatus("已插入图片"); + } catch (e) { + setStatus(e.message || "上传失败"); + } + }); + } + + textarea.addEventListener("keydown", (e) => { + if (e.key !== "Tab" || previewOn) return; + e.preventDefault(); + const start = textarea.selectionStart || 0; + const end = textarea.selectionEnd || start; + if (e.shiftKey) { + const value = textarea.value; + const lineStart = value.lastIndexOf("\n", start - 1) + 1; + const line = value.slice(lineStart, end); + const next = line.replace(/^( {1,2}|\t)/gm, ""); + textarea.value = value.slice(0, lineStart) + next + value.slice(end); + textarea.setSelectionRange(lineStart, lineStart + next.length); + } else { + insertAtCursor(textarea, " "); + } + dirty = true; + }); + + textarea.addEventListener("input", () => { + dirty = true; + }); + + if (unsaved) { + const form = root.closest("form"); + window.addEventListener("beforeunload", (e) => { + if (!dirty) return; + e.preventDefault(); + e.returnValue = ""; + }); + if (form) { + form.addEventListener("submit", () => { + dirty = false; + }); + } + } } - document.getElementById("compose-gate-login")?.addEventListener("click", () => { - wrapSelection( - '', - "" - ); - }); - document.getElementById("compose-gate-reply")?.addEventListener("click", () => { - wrapSelection( - '', - "" - ); - }); - document.getElementById("compose-gate-points")?.addEventListener("click", () => { - let cost = window.prompt("积分价格(1–9999)", "10"); - if (cost == null) return; - cost = String(cost).trim(); - const n = parseInt(cost, 10); - if (!n || n < 1 || n > 9999) { - if (statusEl) statusEl.textContent = "价格须为 1–9999 的整数"; - return; - } - wrapSelection( - ``, - "" - ); - }); + document.querySelectorAll("[data-j13-md-editor]").forEach(initEditor); })(); (function postContentGates() { diff --git a/routers/web/compose.go b/routers/web/compose.go index 1af0709..4cf0ae9 100644 --- a/routers/web/compose.go +++ b/routers/web/compose.go @@ -187,6 +187,29 @@ func (d Deps) ComposeUpload(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": url}) } +// ComposePreview 正文预览(与发帖同一套 ComposeBodyToHTML + 消毒) +func (d Deps) ComposePreview(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.IsSigned() { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + if !ctx.CheckCSRF() { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"}) + return + } + content := c.PostForm("content") + if content == "" { + var body struct { + Content string `json:"content"` + } + _ = c.ShouldBindJSON(&body) + content = body.Content + } + htmlBody := services.SanitizePostHTML(services.ComposeBodyToHTML(content)) + c.JSON(http.StatusOK, gin.H{"html": htmlBody}) +} + type composeForm struct { BoardID uint Title string diff --git a/routers/web/home.go b/routers/web/home.go index 8f3b12b..d36bc90 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -58,6 +58,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet) g.POST("/compose", authMW.RequireAuth(), deps.ComposePost) g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload) + g.POST("/compose/preview", authMW.RequireAuth(), deps.ComposePreview) g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") }) admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin()) diff --git a/routers/web/post.go b/routers/web/post.go index 05bba26..a0d7893 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -2,7 +2,6 @@ package web import ( "fmt" - "html" "net/http" "net/url" "strconv" @@ -510,7 +509,7 @@ func (d Deps) PostComment(c *gin.Context) { ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) return } - safe := "

" + html.EscapeString(content) + "

" + safe := services.ComposeBodyToHTML(content) cm, err := d.Comment.Create(services.CommentCreateInput{ PostID: id, UserID: ctx.UserID(), Content: safe, ReplyTo: replyTo, IsPrivate: isPrivate, @@ -815,7 +814,7 @@ func (d Deps) CommentEditGet(c *gin.Context) { PostID: postID, CommentID: cm.ID, Floor: cm.Floor, - Content: commentHTMLToPlain(cm.Content), + Content: services.HTMLToComposePlain(cm.Content), }) } @@ -841,7 +840,7 @@ func (d Deps) CommentEditPost(c *gin.Context) { d.renderCommentEdit(ctx, "评论不能为空", postID, cm, plain) return } - safe := "

" + html.EscapeString(plain) + "

" + safe := services.ComposeBodyToHTML(plain) _, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe) if err != nil { d.renderCommentEdit(ctx, err.Error(), postID, cm, plain) @@ -923,14 +922,6 @@ func (d Deps) renderCommentEdit(ctx *webctx.Context, errMsg string, postID uint, }) } -// commentHTMLToPlain 发评存的是转义后的

,编辑时还原为纯文本 -func commentHTMLToPlain(s string) string { - s = strings.TrimSpace(s) - s = strings.TrimPrefix(s, "

") - s = strings.TrimSuffix(s, "

") - return html.UnescapeString(s) -} - type postRevisionRow struct { ID uint EditorName string diff --git a/services/compose_body.go b/services/compose_body.go index 2d899f9..793f354 100644 --- a/services/compose_body.go +++ b/services/compose_body.go @@ -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, "` + alt + `

` - }) - 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 `` + text + `` - }) + } + 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("
")
+			b.WriteString(html.EscapeString(strings.Join(code, "\n")))
+			b.WriteString("
\n") continue } - // 已是独立 img 段 - if strings.HasPrefix(p, "

") + b.WriteString(mdInlineToHTML(title)) + 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("

") + b.WriteString(mdInlineToHTML(strings.Join(quote, "
\n"))) + b.WriteString("

\n") + continue + } + + // 无序列表 + if isMDUL(trim) { + b.WriteString("
    \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("
  • ") + b.WriteString(mdInlineToHTML(item)) + b.WriteString("
  • \n") + i++ + } + b.WriteString("
\n") + continue + } + + // 有序列表 + if isMDOL(trim) { + b.WriteString("
    \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("
  1. ") + b.WriteString(mdInlineToHTML(item)) + b.WriteString("
  2. \n") + i++ + } + b.WriteString("
\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(`

`)
+			b.WriteString(m[1])
+			b.WriteString(`

` + "\n") continue } - p = strings.ReplaceAll(p, "\n", "
\n") b.WriteString("

") - b.WriteString(p) + b.WriteString(mdInlineToHTML(strings.ReplaceAll(joined, "\n", "
\n"))) b.WriteString("

\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: "" + html.EscapeString(sub[1]) + ""}) + 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 `` + sub[1] + `` + }) + s = mdLinkRe.ReplaceAllStringFunc(s, func(m string) string { + sub := mdLinkRe.FindStringSubmatch(m) + if len(sub) != 3 || !safeComposeURL(sub[2]) { + return m + } + return `` + sub[1] + `` + }) + s = mdBoldRe.ReplaceAllString(s, `$1`) + s = mdStrikeRe.ReplaceAllString(s, `$1`) + s = mdItalicRe.ReplaceAllString(s, `$1`) + + 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, "]+src="([^"]+)"[^>]*>`) - s = imgRe.ReplaceAllString(s, "![]($1)") + + // 代码块 + s = regexp.MustCompile(`(?is)]*>\s*]*>([\s\S]*?)\s*`).ReplaceAllStringFunc(s, func(m string) string { + sub := regexp.MustCompile(`(?is)]*>\s*]*>([\s\S]*?)\s*`).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)]*>([\s\S]*?)`).ReplaceAllStringFunc(s, func(m string) string { + sub := regexp.MustCompile(`(?i)]*>([\s\S]*?)`).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)]*>([\s\S]*?)`).ReplaceAllString(s, "- $1\n") + s = regexp.MustCompile(`(?i)]*>`).ReplaceAllString(s, "\n") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllStringFunc(s, func(m string) string { + sub := regexp.MustCompile(`(?i)]*>([\s\S]*?)`).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)]*>([\s\S]*?)`).ReplaceAllString(s, "**$1**") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "**$1**") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "*$1*") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "*$1*") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "~~$1~~") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "~~$1~~") + s = regexp.MustCompile(`(?i)]*>([\s\S]*?)`).ReplaceAllString(s, "`$1`") + s = regexp.MustCompile(`(?i)]+src="([^"]+)"[^>]*>`).ReplaceAllString(s, "![]($1)") + s = regexp.MustCompile(`(?i)]+href="([^"]+)"[^>]*>([\s\S]*?)`).ReplaceAllString(s, "[$2]($1)") s = regexp.MustCompile(`(?i)

\s*

`).ReplaceAllString(s, "\n\n") s = regexp.MustCompile(`(?i)`).ReplaceAllString(s, "\n") s = regexp.MustCompile(`(?i)]*>`).ReplaceAllString(s, "") s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "") return strings.TrimSpace(html.UnescapeString(s)) } + +func stripTagsKeepText(s string) string { + s = regexp.MustCompile(`(?i)`).ReplaceAllString(s, "\n") + s = regexp.MustCompile(`(?i)<[^>]+>`).ReplaceAllString(s, "") + return strings.TrimSpace(html.UnescapeString(s)) +} diff --git a/services/compose_body_test.go b/services/compose_body_test.go new file mode 100644 index 0000000..7844a5c --- /dev/null +++ b/services/compose_body_test.go @@ -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{ + "

", "标题", "

", + "粗体", + "斜体", + "删除", + "code", + "
    ", "
  • ", + "
      ", + "
      ", + `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, "

      ") { + t.Fatal("must not emit h1") + } +} + +func TestComposeBodyToHTML_PassThroughGates(t *testing.T) { + in := `

      ` + 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, "

      ") || strings.Contains(out, "

      ") { + 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) + } +} diff --git a/templates/compose.tmpl b/templates/compose.tmpl index 0755dff..9c907fa 100644 --- a/templates/compose.tmpl +++ b/templates/compose.tmpl @@ -63,20 +63,21 @@ - -
      - - - - - -
      -

      门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。

      + + {{template "shared/md_editor" (dict + "Name" "content" + "ID" "compose-content" + "Content" .Content + "Rows" 16 + "MaxLength" .ContentMax + "Placeholder" "支持 Markdown:标题/粗斜体/列表/代码块/链接;可用工具栏插入图片与门控块" + "CSRF" .CSRF + "ShowGates" 1 + "ShowUpload" 1 + "Unsaved" 1 + "Required" 1 + )}} +

      门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。Tab 缩进,可切换预览。

      diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index a26d479..ccaa7be 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -218,7 +218,19 @@ - + {{template "shared/md_editor" (dict + "Name" "content" + "ID" "comment-content" + "Content" "" + "Rows" 5 + "MaxLength" 8000 + "Placeholder" "支持 Markdown;可回复楼上" + "CSRF" .CSRF + "ShowGates" 0 + "ShowUpload" 1 + "Unsaved" 0 + "Required" 1 + )}}