feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动

补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 07:07:38 +08:00
parent 9e134916a4
commit 44eed97fe0
26 changed files with 2530 additions and 159 deletions

View File

@@ -5,12 +5,16 @@ import (
"encoding/json"
"html"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root避免刷新闪屏
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
// 默认不写 #root首页/板块文档 SSR 可填 RootHTML / BootJSON。
type SPAPageMeta struct {
Title string // 完整 <title>
Description string
@@ -23,6 +27,8 @@ type SPAPageMeta struct {
Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码0 视为 200
RootHTML string // 可选:写入 #root 的首屏 HTML首页文档 SSR
BootJSON []byte // 可选window.__J13_HOME_BOOT__ 合法 JSON
}
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
@@ -56,15 +62,16 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
}
var head strings.Builder
writeMeta(&head, "description", meta.Description)
writeMeta(&head, "keywords", meta.Keywords)
// —— 静态 SEO HTMLmeta / OG / JSON-LD紧跟 </title>,不经 JS ——
var seo strings.Builder
writeMeta(&seo, "description", meta.Description)
writeMeta(&seo, "keywords", meta.Keywords)
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
}
robots := strings.TrimSpace(meta.Robots)
if robots != "" {
writeMeta(&head, "robots", robots)
writeMeta(&seo, "robots", robots)
}
ogType := strings.TrimSpace(meta.OGType)
@@ -75,36 +82,63 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
if locale == "" {
locale = "zh_CN"
}
writeProp(&head, "og:type", ogType)
writeProp(&head, "og:site_name", meta.SiteName)
writeProp(&head, "og:locale", locale)
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&head, "og:description", meta.Description)
writeProp(&head, "og:url", meta.Canonical)
writeProp(&head, "og:image", meta.OGImage)
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&head, "twitter:description", meta.Description)
writeMetaName(&head, "twitter:image", meta.OGImage)
writeProp(&seo, "og:type", ogType)
writeProp(&seo, "og:site_name", meta.SiteName)
writeProp(&seo, "og:locale", locale)
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&seo, "og:description", meta.Description)
writeProp(&seo, "og:url", meta.Canonical)
writeProp(&seo, "og:image", meta.OGImage)
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&seo, "twitter:description", meta.Description)
writeMetaName(&seo, "twitter:image", meta.OGImage)
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
head.WriteString(`<script type="application/ld+json">`)
head.WriteString(jsonld)
head.WriteString(`</script>`)
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
seo.WriteString(`<script type="application/ld+json">`)
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
seo.WriteString(`</script>`)
}
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
if boot := spaBrandingBootScript(); boot != "" {
head.WriteString(boot)
if seo.Len() > 0 {
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
}
if head.Len() > 0 {
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
// —— 可执行 boot 脚本仍放在 </head> 前 ——
var boot strings.Builder
if s := spaBrandingBootScript(); s != "" {
boot.WriteString(s)
}
if s := spaHomeBootScript(meta.BootJSON); s != "" {
boot.WriteString(s)
}
if boot.Len() > 0 {
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
}
if root := strings.TrimSpace(meta.RootHTML); root != "" {
data = injectSPARootHTML(data, root)
}
return data
}
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO
func spaHomeBootScript(raw []byte) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
}
// injectSPARootHTML 将首屏 HTML 写入 #root允许空白
func injectSPARootHTML(data []byte, rootHTML string) []byte {
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
}
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
func spaBrandingBootScript() string {
if spaBrandJSONFn == nil {
@@ -114,7 +148,6 @@ func spaBrandingBootScript() string {
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
// 防止 JSON 字符串中的 </script> 提前闭合标签
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
}