文章排版四件套(参考 xLog):盘古之白 + 表格圆角斑马纹 + 代码块封顶与复制按钮 + 标题悬停锚点

- PanguHTML:CJK 与拉丁/数字间补空格,标签原样保留、code/pre 内文本不动;详情/时间线短文/RSS 描述三个出口生效(带单测)
- 表格改圆角容器 + tbody 斑马纹 + 纵向大留白(classic/vivid 双主题)
- pre max-height 30.5em 封顶内部滚动;enhanceProse 注入悬停复制按钮(App 全局委托),按下有 2px 物理感
- 带 id 的 h2-h4 悬停浮现 # 锚点,点击跳转
- 中文字体栈此前已配(PingFang SC 等),无需改动
This commit is contained in:
Sakurasan
2026-09-29 00:53:50 +08:00
parent 44a24736aa
commit 23771f0661
7 changed files with 257 additions and 14 deletions
+10 -1
View File
@@ -18,6 +18,7 @@ import (
"oneblog/internal/hub"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
@@ -128,6 +129,12 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
// 时间线上短文直接铺正文:出口处做盘古之白(库里存的是原始渲染结果)
for i := range page.Items {
if page.Items[i].Kind == model.KindShort {
page.Items[i].ContentHTML = render.PanguHTML(page.Items[i].ContentHTML)
}
}
httpx.OK(w, page)
}
@@ -156,6 +163,8 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
if err != nil {
nb = nil
}
// 详情正文出口做盘古之白(存库不动,改的是渲染层)
p.ContentHTML = render.PanguHTML(p.ContentHTML)
httpx.OK(w, struct {
model.Post
Neighbors []model.Post `json:"neighbors"`
@@ -255,7 +264,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
}
desc := p.Summary
if desc == "" {
desc = trimRunes(stripTags(p.ContentHTML), 160)
desc = trimRunes(stripTags(render.PanguHTML(p.ContentHTML)), 160)
}
item := rssItem{
Title: title,
+92
View File
@@ -0,0 +1,92 @@
// 盘古之白:在 CJK 与拉丁字母/数字之间补一个空格,中文混排的阅读观感
// 差别很大(「把MVP搬进仓库」→「把 MVP 搬进仓库」)。
// 在 HTML 层做:标签原样保留,<code>/<pre>/<script>/<style> 内的文本
// 一律不动(代码语义容不得我们加空格),其余文本段做补空格。
package render
import (
"regexp"
"strings"
"unicode"
)
var tagRe = regexp.MustCompile(`<[^>]*>`)
// code 上下文里的文本不处理
var codeOpenRe = regexp.MustCompile(`(?i)<(code|pre|script|style|kbd|samp)\b[^>]*>`)
var codeCloseRe = regexp.MustCompile(`(?i)</(code|pre|script|style|kbd|samp)>`)
// CJK 统一表意区 + 扩展A/兼容 + 日文假名已在 2E80-9FFF 覆盖,
// 再补全角标点区(FF00-FFEF)与 CJK 标点(3000-303F)
var cjkRe = regexp.MustCompile(`[\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}]`)
var latinTailRe = regexp.MustCompile(`([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])([A-Za-z0-9_$@])`)
var latinHeadRe = regexp.MustCompile(`([A-Za-z0-9+%,.;:!?%)\]])([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])`)
func isCJK(r rune) bool {
return cjkRe.MatchString(string(r))
}
// panguText 对纯文本做补空格。已经隔着空格的不动(正则天然要求相邻)。
func panguText(s string) string {
if !hasCJKAndLatin(s) {
return s
}
prev := ""
for prev != s {
prev = s
s = latinTailRe.ReplaceAllString(s, "$1 $2")
s = latinHeadRe.ReplaceAllString(s, "$1 $2")
}
return s
}
func hasCJKAndLatin(s string) bool {
var cjk, latin bool
for _, r := range s {
if isCJK(r) {
cjk = true
} else if unicode.IsLetter(r) || unicode.IsNumber(r) {
latin = true
}
if cjk && latin {
return true
}
}
return false
}
// PanguHTML 给渲染出的正文 HTML 做盘古之白。流式扫描:标签原样输出,
// 普通文本段补空格,代码上下文里的文本跳过。
func PanguHTML(html string) string {
if !strings.Contains(html, "<") {
return html
}
var b strings.Builder
inCode := false
for {
loc := tagRe.FindStringIndex(html)
if loc == nil {
rest := html
if !inCode {
rest = panguText(rest)
}
b.WriteString(rest)
return b.String()
}
head, tag := html[:loc[0]], html[loc[0]:loc[1]]
if !inCode {
head = panguText(head)
}
b.WriteString(head)
b.WriteString(tag)
if !inCode {
if m := codeOpenRe.FindStringSubmatch(tag); m != nil {
// 自闭合或行内 code 直接开着也按进/出配对处理(</code> 总会出现)
inCode = true
}
} else if codeCloseRe.MatchString(tag) {
inCode = false
}
html = html[loc[1]:]
}
}
+40
View File
@@ -0,0 +1,40 @@
package render
import "testing"
func TestPanguText(t *testing.T) {
cases := map[string]string{
"把MVP搬进仓库": "把 MVP 搬进仓库",
"今天写了3个小时代码": "今天写了 3 个小时代码",
"使用Go和Vue3开发": "使用 Go 和 Vue3 开发",
"纯中文没有混排": "纯中文没有混排",
"已经隔了空格的 Go 语言": "已经隔了空格的 Go 语言",
"数字100在前": "数字 100 在前",
"英文后接标点。followed": "英文后接标点。 followed",
}
for in, want := range cases {
if got := panguText(in); got != want {
t.Errorf("panguText(%q) = %q, want %q", in, got, want)
}
}
}
func TestPanguHTML(t *testing.T) {
cases := map[string]string{
// 普通文本:处理
"<p>把MVP搬进仓库</p>": "<p>把 MVP 搬进仓库</p>",
// 行内 code:不动
"<p>运行<code>go build ./...</code>即可</p>": "<p>运行<code>go build ./...</code>即可</p>",
// pre 代码块:整个内部不动
"<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>": "<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>",
// 属性里不是文本:不动(src 含字母数字混合不受影响)
`<img alt="测试图1" src="a1.png">`: `<img alt="测试图1" src="a1.png">`,
// 嵌套:code 结束后恢复处理
"<p>先<code>跑起来</code>再看结果100%</p>": "<p>先<code>跑起来</code>再看结果 100%</p>",
}
for in, want := range cases {
if got := PanguHTML(in); got != want {
t.Errorf("PanguHTML(%q) = %q, want %q", in, got, want)
}
}
}