feat: 站点单页、时间线导入与行级 BBCode 短代码

首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 19:52:06 +08:00
parent 2aacf99a49
commit a83276060f
52 changed files with 5073 additions and 648 deletions

View File

@@ -6,7 +6,7 @@ import (
)
func TestParseIgnoreCodeFence(t *testing.T) {
src := "公开\n\n```\n:::hide login\n假的\n:::\n```\n\n结尾\n"
src := "公开\n\n```\n[hide login]\n假的\n[/hide]\n```\n\n结尾\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -17,7 +17,7 @@ func TestParseIgnoreCodeFence(t *testing.T) {
}
func TestParseMixedAndDerive(t *testing.T) {
src := "公开段\n\n:::hide login\n登录内容\n:::\n\n:::hide points 10\n积分A\n:::\n\n:::hide points 5\n积分B\n:::\n"
src := "公开段\n\n[hide login]\n登录内容\n[/hide]\n\n[hide points=10]\n积分A\n[/hide]\n\n[hide points=5]\n积分B\n[/hide]\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -35,7 +35,7 @@ func TestParseMixedAndDerive(t *testing.T) {
}
func TestParseNestedRejected(t *testing.T) {
src := ":::hide login\n外\n:::hide reply\n内\n:::\n:::\n"
src := "[hide login]\n外\n[hide reply]\n内\n[/hide]\n[/hide]\n"
_, err := ParseHideBlocks(src)
if err != ErrHideNested {
t.Fatalf("want ErrHideNested, got %v", err)
@@ -43,7 +43,7 @@ func TestParseNestedRejected(t *testing.T) {
}
func TestSanitizeLocksBody(t *testing.T) {
src := "公开\n\n:::hide login\n秘密内容\n:::\n\n尾\n"
src := "公开\n\n[hide login]\n秘密内容\n[/hide]\n\n尾\n"
out, fully := SanitizeForViewer(src, ViewerCaps{})
if fully {
t.Fatal("should not be fully locked")
@@ -51,7 +51,7 @@ func TestSanitizeLocksBody(t *testing.T) {
if strings.Contains(out, "秘密内容") {
t.Fatalf("leaked: %q", out)
}
if !strings.Contains(out, ":::hide login locked") {
if !strings.Contains(out, "[hide login locked]") {
t.Fatalf("missing locked marker: %q", out)
}
if !strings.Contains(out, "公开") || !strings.Contains(out, "尾") {
@@ -60,7 +60,7 @@ func TestSanitizeLocksBody(t *testing.T) {
}
func TestSanitizeUnlockLogin(t *testing.T) {
src := ":::hide login\n秘密\n:::\n"
src := "[hide login]\n秘密\n[/hide]\n"
out, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true})
if fully {
t.Fatal("should see content")
@@ -74,7 +74,7 @@ func TestSanitizeUnlockLogin(t *testing.T) {
}
func TestSanitizeBypass(t *testing.T) {
src := ":::hide points 9\n付费\n:::\n"
src := "[hide points=9]\n付费\n[/hide]\n"
out, _ := SanitizeForViewer(src, ViewerCaps{Bypass: true})
if !strings.Contains(out, "付费") {
t.Fatalf("bypass failed: %q", out)
@@ -82,7 +82,7 @@ func TestSanitizeBypass(t *testing.T) {
}
func TestSanitizePointsPaid(t *testing.T) {
src := ":::hide points 3\n付费文\n:::\n"
src := "[hide points=3]\n付费文\n[/hide]\n"
out, _ := SanitizeForViewer(src, ViewerCaps{LoggedIn: true, PointsPaid: true})
if !strings.Contains(out, "付费文") {
t.Fatalf("paid unlock failed: %q", out)
@@ -90,7 +90,7 @@ func TestSanitizePointsPaid(t *testing.T) {
}
func TestFullyLocked(t *testing.T) {
src := ":::hide reply\n仅回复\n:::\n"
src := "[hide reply]\n仅回复\n[/hide]\n"
_, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true})
if !fully {
t.Fatal("expected fully locked")
@@ -98,7 +98,7 @@ func TestFullyLocked(t *testing.T) {
}
func TestCodeInsideHide(t *testing.T) {
src := ":::hide login\n```\n:::hide reply\n伪\n:::\n```\n真\n:::\n"
src := "[hide login]\n```\n[hide reply]\n伪\n[/hide]\n```\n真\n[/hide]\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -112,7 +112,7 @@ func TestCodeInsideHide(t *testing.T) {
}
func TestParsePasswordAndSanitize(t *testing.T) {
src := "公开\n\n:::hide password secret1\n密码内容\n:::\n"
src := "公开\n\n[hide password=secret1]\n密码内容\n[/hide]\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -131,7 +131,7 @@ func TestParsePasswordAndSanitize(t *testing.T) {
if strings.Contains(out, "secret1") || strings.Contains(out, "密码内容") {
t.Fatalf("leaked: %q", out)
}
if !strings.Contains(out, ":::hide password locked") {
if !strings.Contains(out, "[hide password locked]") {
t.Fatalf("%q", out)
}
out2, _ := SanitizeForViewer(src, ViewerCaps{PasswordUnlocked: map[int]bool{0: true}})
@@ -144,7 +144,7 @@ func TestParsePasswordAndSanitize(t *testing.T) {
}
func TestMatchPasswordBlocks(t *testing.T) {
src := ":::hide password aaa\nA\n:::\n\n:::hide password bbb\nB\n:::\n"
src := "[hide password=aaa]\nA\n[/hide]\n\n[hide password=bbb]\nB\n[/hide]\n"
hit, err := MatchPasswordBlocks(src, "bbb")
if err != nil {
t.Fatal(err)
@@ -155,7 +155,7 @@ func TestMatchPasswordBlocks(t *testing.T) {
}
func TestPasswordNeed(t *testing.T) {
_, err := ParseHideBlocks(":::hide password\n无密码\n:::\n")
_, err := ParseHideBlocks("[hide password]\n无密码\n[/hide]\n")
if err != ErrHidePasswordNeed {
t.Fatalf("got %v", err)
}
@@ -163,10 +163,34 @@ func TestPasswordNeed(t *testing.T) {
func TestWrapContentAsHide(t *testing.T) {
got := WrapContentAsHide("points", 20, "旧正文")
if !strings.HasPrefix(got, ":::hide points 20\n") {
if !strings.HasPrefix(got, "[hide points=20]\n") {
t.Fatalf("%q", got)
}
if !strings.Contains(got, "旧正文") {
t.Fatal(got)
}
}
func TestTimelineDoesNotBreakHideValidate(t *testing.T) {
src := "[timeline]\n## 2026-09-16 feat\n说明\n[/timeline]\n"
if err := ValidateHideContent(src); err != nil {
t.Fatalf("timeline-only should pass: %v", err)
}
}
func TestRewriteLegacyDirectives(t *testing.T) {
src := "公开\n\n:::hide login\n秘\n:::\n\n:::timeline\n## 2026-01-01 A\n:::\n"
got := RewriteLegacyDirectives(src)
if strings.Contains(got, ":::") {
t.Fatalf("legacy remains: %q", got)
}
if !strings.Contains(got, "[hide login]") || !strings.Contains(got, "[/hide]") {
t.Fatalf("hide missing: %q", got)
}
if !strings.Contains(got, "[timeline]") || !strings.Contains(got, "[/timeline]") {
t.Fatalf("timeline missing: %q", got)
}
if err := ValidateHideContent(got); err != nil {
t.Fatal(err)
}
}