From 44eed97fe033d5f6a6558540a4f215d8ad377aff Mon Sep 17 00:00:00 2001 From: freefire Date: Tue, 1 Sep 2026 07:07:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=20Go=20SSR=20?= =?UTF-8?q?=E4=B8=8E=20React=20hydrate=20=E5=90=8C=E6=9E=84=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E5=A3=B3=E5=B1=82=E4=B8=8E=E5=B8=96=E8=A1=8C=E9=97=AA?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。 Co-authored-by: Cursor --- embed_static/spa_meta.go | 85 +- frontend/index.html | 18 +- frontend/scripts/extract-lucide-paths.mjs | 60 + frontend/scripts/gen-ssr-icons.mjs | 168 +++ frontend/src/components/AsideCheckInStrip.tsx | 3 +- frontend/src/components/FeedSortBar.tsx | 7 +- frontend/src/components/RightPanel.tsx | 15 +- frontend/src/components/SiteFooter.tsx | 71 +- frontend/src/components/StaticFeedList.tsx | 227 ++++ frontend/src/hooks/useAuth.tsx | 14 +- frontend/src/hooks/useForumLimits.ts | 7 + frontend/src/hooks/useSitePages.ts | 6 + frontend/src/layouts/MainLayout.tsx | 21 +- frontend/src/main.tsx | 85 +- frontend/src/pages/HomePage.tsx | 93 +- frontend/src/styles/global.css | 105 +- frontend/src/utils/authBoot.ts | 43 + frontend/src/utils/feedCache.ts | 5 +- frontend/src/utils/homeBoot.ts | 110 ++ frontend/src/utils/homeHydrate.ts | 41 + frontend/src/utils/softRefresh.ts | 13 +- frontend/vite.config.ts | 31 +- handler/seo.go | 13 +- handler/ssr_home.go | 1090 +++++++++++++++++ handler/ssr_icons.go | 353 ++++++ router/router.go | 5 +- 26 files changed, 2530 insertions(+), 159 deletions(-) create mode 100644 frontend/scripts/extract-lucide-paths.mjs create mode 100644 frontend/scripts/gen-ssr-icons.mjs create mode 100644 frontend/src/components/StaticFeedList.tsx create mode 100644 frontend/src/utils/authBoot.ts create mode 100644 frontend/src/utils/homeBoot.ts create mode 100644 frontend/src/utils/homeHydrate.ts create mode 100644 handler/ssr_home.go create mode 100644 handler/ssr_icons.go diff --git a/embed_static/spa_meta.go b/embed_static/spa_meta.go index a682aa3..eec8cb1 100644 --- a/embed_static/spa_meta.go +++ b/embed_static/spa_meta.go @@ -5,12 +5,16 @@ import ( "encoding/json" "html" "net/http" + "regexp" "strings" "github.com/gin-gonic/gin" ) -// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 ,不写 #root,避免刷新闪屏) +var spaRootEmptyRe = regexp.MustCompile(`(?s)
\s*
`) + +// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。 +// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。 type SPAPageMeta struct { Title string // 完整 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+"")) } - var head strings.Builder - writeMeta(&head, "description", meta.Description) - writeMeta(&head, "keywords", meta.Keywords) + // —— 静态 SEO HTML(meta / OG / JSON-LD),紧跟 ,不经 JS —— + var seo strings.Builder + writeMeta(&seo, "description", meta.Description) + writeMeta(&seo, "keywords", meta.Keywords) if canonical := strings.TrimSpace(meta.Canonical); canonical != "" { - head.WriteString(``) + seo.WriteString(``) } 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(``) + // 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入 + seo.WriteString(``) } - // 同步注入品牌配置,避免 React 首屏用默认名闪一下 - if boot := spaBrandingBootScript(); boot != "" { - head.WriteString(boot) + if seo.Len() > 0 { + data = bytes.Replace(data, []byte(""), []byte("\n"+seo.String()), 1) } - if head.Len() > 0 { - data = bytes.Replace(data, []byte(""), []byte(head.String()+""), 1) + // —— 可执行 boot 脚本仍放在 前 —— + 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(""), []byte(boot.String()+""), 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 "" +} + +// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白) +func injectSPARootHTML(data []byte, rootHTML string) []byte { + return spaRootEmptyRe.ReplaceAll(data, []byte(`
`+rootHTML+`
`)) +} + // 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 字符串中的 提前闭合标签 safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`)) return "" } diff --git a/frontend/index.html b/frontend/index.html index 90420c5..03fe577 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,23 +5,7 @@ 姜十三论坛 - 拾三一隅,自在交流 - +