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\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\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, " + `)
`
- })
- 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("- ")
+ b.WriteString(mdInlineToHTML(item))
+ b.WriteString("
\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(`](`)
+ b.WriteString(html.EscapeString(m[2]))
+ 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 `
`
+ })
+ 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, "")
+
+ // 代码块
+ 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)?[uo]l[^>]*>`).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, "")
+ 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)?p[^>]*>`).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)
+
+`)
+ 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
+ )}}