diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b064314..593fce4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -34,6 +34,7 @@
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "compression": "^1.8.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
@@ -4152,6 +4153,60 @@
"node": ">= 6"
}
},
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/compression/node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -5731,6 +5786,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -6480,6 +6544,26 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1446d94..896e6b2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vike dev",
"build": "vike build",
- "preview": "vike preview"
+ "preview": "vike preview",
+ "start": "node server/index.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -35,6 +36,7 @@
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "compression": "^1.8.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
diff --git a/frontend/pages/+config.ts b/frontend/pages/+config.ts
index 82cacc8..062c740 100644
--- a/frontend/pages/+config.ts
+++ b/frontend/pages/+config.ts
@@ -1,6 +1,12 @@
import type { Config } from 'vike/types';
export default {
- // pageProps 透传给客户端(boot 数据经 +data 的 pageContext.data 自动序列化)
- passToClient: ['pageProps'],
+ // pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed
+ passToClient: ['pageProps', 'data'],
+ // 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false)
+ meta: {
+ ssr: {
+ env: { server: true },
+ },
+ },
} satisfies Config;
diff --git a/frontend/pages/_error/+Page.tsx b/frontend/pages/_error/+Page.tsx
new file mode 100644
index 0000000..b005bc8
--- /dev/null
+++ b/frontend/pages/_error/+Page.tsx
@@ -0,0 +1,10 @@
+import App from '../../src/App';
+import type { PageContext } from 'vike/types';
+
+export { Page };
+
+/** 错误页:复用 App 壳层与路由树 —— /post/:id 未找到渲染帖子自己的未找到态,未知路径渲染 catch-all NotFoundPage */
+function Page(pageContext: PageContext & { url?: string }) {
+ const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
+ return ;
+}
diff --git a/frontend/pages/_error/+data.ts b/frontend/pages/_error/+data.ts
new file mode 100644
index 0000000..35c9a65
--- /dev/null
+++ b/frontend/pages/_error/+data.ts
@@ -0,0 +1,24 @@
+import type { PageContextServer } from 'vike/types';
+import type { FeedData } from '../../src/ssr/feedData';
+
+export { data };
+
+type NotFoundAbortReason = {
+ head?: FeedData['head'];
+ boot?: FeedData['boot'];
+};
+
+/**
+ * 错误页取数:feed +data 未找到(帖子/板块)时 throw render(404, { head, boot }),
+ * 壳层数据经 abortReason 带过来,保证 404 页也渲染完整站点壳与 noindex head。
+ */
+async function data(pageContext: PageContextServer): Promise {
+ if (!import.meta.env.SSR) return { kind: 'notfound' };
+
+ const reason = pageContext.abortReason as NotFoundAbortReason | undefined;
+ return {
+ kind: 'notfound',
+ head: reason?.head,
+ boot: reason?.boot,
+ };
+}
diff --git a/frontend/pages/feed/+Page.tsx b/frontend/pages/feed/+Page.tsx
index d5c2722..fa3a3b4 100644
--- a/frontend/pages/feed/+Page.tsx
+++ b/frontend/pages/feed/+Page.tsx
@@ -4,7 +4,8 @@ import type { PageContext } from 'vike/types';
export { Page };
/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
-function Page(pageContext: PageContext) {
- const { pathname, searchOriginal } = pageContext.urlParsed;
- return ;
+function Page(pageContext: PageContext & { url?: string }) {
+ // onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
+ const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
+ return ;
}
diff --git a/frontend/pages/feed/+data.ts b/frontend/pages/feed/+data.ts
new file mode 100644
index 0000000..b54ac98
--- /dev/null
+++ b/frontend/pages/feed/+data.ts
@@ -0,0 +1,9 @@
+import type { PageContextServer } from 'vike/types';
+import { loadFeedData } from '../../src/ssr/feedData';
+
+export { data };
+
+// SSR 白名单页(/ 、/board/:id 、/post/:id)服务端取数 + head 构建
+async function data(pageContext: PageContextServer) {
+ return await loadFeedData(pageContext);
+}
diff --git a/frontend/renderer/+onRenderClient.tsx b/frontend/renderer/+onRenderClient.tsx
index 5155163..3733ee2 100644
--- a/frontend/renderer/+onRenderClient.tsx
+++ b/frontend/renderer/+onRenderClient.tsx
@@ -5,7 +5,7 @@ import type { PageContextClient } from 'vike/types';
import { applyTheme, getStoredTheme } from '../src/utils/theme';
import { beginHomeHydrate } from '../src/utils/homeHydrate';
import { reloadForStaleChunk } from '../src/utils/chunkLoad';
-import { consumeHomeBoot } from '../src/utils/homeBoot';
+import { seedFeedData, type FeedData } from '../src/ssr/feedData';
import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
export { onRenderClient };
@@ -40,7 +40,10 @@ function waitForStylesheets(): Promise {
async function onRenderClient(pageContext: PageContextClient) {
applyTheme(getStoredTheme());
- consumeHomeBoot();
+ // hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
+ if (pageContext.isHydration) {
+ seedFeedData((pageContext as { data?: FeedData }).data);
+ }
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
window.addEventListener('vite:preloadError', (event) => {
diff --git a/frontend/renderer/+onRenderHtml.tsx b/frontend/renderer/+onRenderHtml.tsx
index da932d4..83497dc 100644
--- a/frontend/renderer/+onRenderHtml.tsx
+++ b/frontend/renderer/+onRenderHtml.tsx
@@ -2,10 +2,15 @@ import type { ComponentType, ReactElement } from 'react';
import { renderToReadableStream } from 'react-dom/server';
import { escapeInject, dangerouslySkipEscape } from 'vike/server';
import type { PageContextServer } from 'vike/types';
-import App from '../src/App';
import { createHomeStore } from '../src/store/homeStore';
import { createRequestScope } from '../src/ssr/scopeTypes';
import { runWithScope } from '../src/ssr/scope';
+import { seedFeedData, type FeedData } from '../src/ssr/feedData';
+import { renderHeadTags } from '../src/ssr/head';
+import { installServerDom } from '../src/ssr/serverDom';
+
+// 服务端 DOM polyfill:内容处理依赖 DOMParser/document,须在渲染前安装
+installServerDom();
export { onRenderHtml };
@@ -32,6 +37,7 @@ async function renderPageToString(element: ReactElement): Promise {
async function onRenderHtml(pageContext: PageContextServer) {
const Page = pageContext.Page as PageComponent | undefined;
+ const data = (pageContext as { data?: FeedData }).data;
let pageHtml = '';
if (Page) {
@@ -41,20 +47,26 @@ async function onRenderHtml(pageContext: PageContextServer) {
const { pathname, searchOriginal } = pageContext.urlParsed;
const url = pathname + (searchOriginal ?? '');
try {
- pageHtml = await runWithScope(scope, () => renderPageToString());
+ pageHtml = await runWithScope(scope, () => {
+ // 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
+ seedFeedData(data);
+ return renderPageToString();
+ });
} catch (err) {
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
pageHtml = '';
}
}
+ const headTags = renderHeadTags(data?.head);
+
+ // 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
return escapeInject`
-
- 姜十三论坛 - 拾三一隅,自在交流
+ ${dangerouslySkipEscape(headTags)}
diff --git a/frontend/src/api/serverClient.ts b/frontend/src/api/serverClient.ts
index 45b07c8..88fc3ae 100644
--- a/frontend/src/api/serverClient.ts
+++ b/frontend/src/api/serverClient.ts
@@ -3,19 +3,26 @@
*
* - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001)
* - 转发浏览器 cookie 以透传登录态
+ * - 转发 host / X-Forwarded-* 让 Go 推导正确的站点 base URL(canonical/og:url 等)
* - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染
*/
const GO_API_URL = process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`;
-export async function serverApi(
- path: string,
- opts?: { cookie?: string; timeoutMs?: number },
-): Promise {
+export type ServerApiOptions = {
+ /** 浏览器请求的 cookie 原样转发 */
+ cookie?: string;
+ /** 额外转发给 Go 的请求头(host / x-forwarded-* 等) */
+ headers?: Record;
+ timeoutMs?: number;
+};
+
+export async function serverApi(path: string, opts?: ServerApiOptions): Promise {
try {
const res = await fetch(`${GO_API_URL}${path}`, {
headers: {
...(opts?.cookie ? { cookie: opts.cookie } : {}),
+ ...(opts?.headers ?? {}),
},
signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000),
cache: 'no-store',
diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx
index c984805..be26740 100644
--- a/frontend/src/components/ArticleEditor.tsx
+++ b/frontend/src/components/ArticleEditor.tsx
@@ -9,7 +9,7 @@ import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import { TableKit } from '@tiptap/extension-table';
-import DOMPurify from 'dompurify';
+import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
@@ -121,7 +121,7 @@ function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
- return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
+ return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
diff --git a/frontend/src/components/CommentEditor.tsx b/frontend/src/components/CommentEditor.tsx
index ae40117..672af4b 100644
--- a/frontend/src/components/CommentEditor.tsx
+++ b/frontend/src/components/CommentEditor.tsx
@@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
-import DOMPurify from 'dompurify';
+import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
@@ -36,7 +36,7 @@ interface Props {
}
function sanitizeHtml(html: string): string {
- return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
+ return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
}
function isEditorEmpty(editor: Editor): boolean {
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index e186b77..7f78901 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -92,9 +92,9 @@ export default function MainLayout() {
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false);
- /** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */
- const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
- const coldBootDone = useRef(isCompose || wasColdBootEnsured());
+ /** 冷启动门闩:main.tsx 已预热则可同步放行;SSR/hydrate 首帧数据已由 +data seed,直接放行保持同构 */
+ const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured() || isHomeHydrating());
+ const coldBootDone = useRef(isCompose || wasColdBootEnsured() || isHomeHydrating());
const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
diff --git a/frontend/src/ssr/feedData.ts b/frontend/src/ssr/feedData.ts
new file mode 100644
index 0000000..f0752f5
--- /dev/null
+++ b/frontend/src/ssr/feedData.ts
@@ -0,0 +1,143 @@
+/**
+ * Vike +data 服务端取数(SSR 白名单页:/ 、/board/:id 、/post/:id)。
+ *
+ * - feed(首页/板块):单次调用 Go /api/home-boot 聚合(对齐旧 homeBootPayload)
+ * - post:并行取 帖子详情 + 评论 + home-boot 壳层(侧栏/登录态 SSR 一致渲染)
+ * - 透传 cookie 登录态;转发 host / X-Forwarded-* 让 Go 推导站点 base URL
+ * - Go 不可达/取数失败:返回不含数据的 FeedData 降级为 SPA 壳,不阻断渲染
+ * - head:移植 Go spa_meta.go/seo.go,由 onRenderHtml 注入
+ */
+import { redirect, render } from 'vike/abort';
+import type { PageContextServer } from 'vike/types';
+import { serverApi } from '../api/serverClient';
+import type { Comment, PostDetailResponse } from '../api/types';
+import type { HomeBootPayload, PostDetailSnapshot } from '../utils/homeBoot';
+import { buildPostDetailSnapshot, postDetailCacheKey, seedHomeBoot } from '../utils/homeBoot';
+import { setSessionSnapshot } from '../utils/sessionPageCache';
+import { matchFeedSsrPath } from './feedPaths';
+import { buildFeedHead, buildNotFoundHead, buildPostHead, type PageHead } from './head';
+
+export type FeedData = {
+ kind: 'home' | 'board' | 'post' | 'notfound';
+ /** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
+ boot?: HomeBootPayload;
+ /** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
+ snapshot?: PostDetailSnapshot;
+ /** 元信息(服务端专用,onRenderHtml 消费) */
+ head?: PageHead;
+};
+
+/**
+ * 将 +data 结果灌入当前缓存层。
+ * 服务端在请求作用域内调用(onRenderHtml);客户端 hydrate 前调用(模块单例)——
+ * 两侧均经 seedHomeBoot/setSessionSnapshot 的 scope 判断,逻辑同一份。
+ */
+export function seedFeedData(data: FeedData | null | undefined): void {
+ if (!data) return;
+ if (data.boot) seedHomeBoot(data.boot);
+ if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot);
+}
+
+/** +data 入口(pages/feed/+data.ts 调用) */
+export async function loadFeedData(pageContext: PageContextServer): Promise {
+ // 客户端(Vike client router 触发的导航)不取数:SPA 内页面组件自行请求
+ if (!import.meta.env.SSR) return { kind: 'home' };
+
+ const headers = pageContext.headers ?? {};
+ const cookie = headers.cookie ?? '';
+ const apiHeaders: Record = {
+ // Go requestOrigin() 读 X-Forwarded-Host/Proto 推导站点 base URL
+ 'x-forwarded-host': headers['x-forwarded-host'] || headers.host || '',
+ 'x-forwarded-proto': headers['x-forwarded-proto'] || 'http',
+ };
+
+ const match = matchFeedSsrPath(pageContext.urlPathname);
+ // +route 已保证只放行 feed 路由;兜底降级 SPA 壳
+ if (!match) return { kind: 'home' };
+ const { pathname } = pageContext.urlParsed;
+ const searchParams = new URLSearchParams(pageContext.urlParsed.searchOriginal ?? '');
+
+ if (match.kind === 'post') {
+ return loadPostData(match.id, pathname, { cookie, apiHeaders });
+ }
+ return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders });
+}
+
+type ApiCtx = { cookie: string; apiHeaders: Record };
+
+/** 帖子详情:详情 + 评论 + 壳层并行取数 */
+async function loadPostData(id: string, pathname: string, api: ApiCtx): Promise {
+ const pid = /^\d+$/.test(id) ? Number(id) : NaN;
+ if (Number.isNaN(pid) || pid <= 0) {
+ throw render(404, { head: buildNotFoundHead(pathname) });
+ }
+
+ const [detail, commentsResp, boot] = await Promise.all([
+ serverApi(`/api/posts/${pid}`, api),
+ serverApi<{ comments: Comment[]; total: number }>(`/api/posts/${pid}/comments`, api),
+ serverApi('/api/home-boot', api),
+ ]);
+
+ const brand = boot?.branding ?? null;
+ const base = brand?.site_url?.trim() ?? '';
+
+ // 帖子不存在/无权限:壳层照常 seed(客户端渲染帖子自己的未找到态),HTTP 404 + noindex
+ if (!detail) {
+ throw render(404, {
+ head: buildNotFoundHead(pathname, brand, base),
+ boot: boot ?? undefined,
+ });
+ }
+
+ const comments = Array.isArray(commentsResp?.comments) ? commentsResp.comments : [];
+ const snapshot = buildPostDetailSnapshot(detail, comments);
+ const head = buildPostHead(detail.post, brand, boot?.limits ?? null, base);
+ return { kind: 'post', boot: boot ?? undefined, snapshot, head };
+}
+
+/** 首页/板块:单次 home-boot 聚合 */
+async function loadFeedListData(
+ kind: 'home' | 'board',
+ id: string,
+ pathname: string,
+ searchParams: URLSearchParams,
+ api: ApiCtx,
+): Promise {
+ // 旧版 /?board=id → 301 规范板块路径(保留其余 query,与旧 Go ServePublicSPA 一致)
+ if (kind === 'home') {
+ const legacy = searchParams.get('board');
+ if (legacy && /^\d+$/.test(legacy) && Number(legacy) > 0) {
+ searchParams.delete('board');
+ const rest = searchParams.toString();
+ throw redirect(`/board/${legacy}${rest ? `?${rest}` : ''}`, 301);
+ }
+ }
+
+ const boardId = kind === 'board' && /^\d+$/.test(id) ? Number(id) : 0;
+ if (kind === 'board' && boardId <= 0) {
+ throw render(404, { head: buildNotFoundHead(pathname) });
+ }
+
+ const qs = new URLSearchParams();
+ if (boardId > 0) qs.set('board_id', String(boardId));
+ for (const key of ['sort', 'keyword', 'tag', 'author', 'title_only'] as const) {
+ const v = searchParams.get(key);
+ if (v) qs.set(key, v);
+ }
+ const query = qs.toString();
+ const boot = await serverApi(`/api/home-boot${query ? `?${query}` : ''}`, api);
+ if (!boot) return { kind };
+
+ const brand = boot.branding ?? null;
+ const base = brand?.site_url?.trim() ?? '';
+
+ // 板块不存在:壳层数据随 abortReason 交给 _error 页(404 + noindex,对齐旧 Go serveNotFound)
+ if (kind === 'board' && !(boot.boards ?? []).some((b) => b.id === boardId)) {
+ throw render(404, {
+ head: buildNotFoundHead(pathname, brand, base),
+ boot,
+ });
+ }
+
+ return { kind, boot, head: buildFeedHead(boot, base) };
+}
diff --git a/frontend/src/ssr/head.ts b/frontend/src/ssr/head.ts
new file mode 100644
index 0000000..504caa4
--- /dev/null
+++ b/frontend/src/ssr/head.ts
@@ -0,0 +1,363 @@
+/**
+ * SEO head 构建:移植 Go 侧 embed_static/spa_meta.go + handler/seo.go 的 meta 生成逻辑。
+ *
+ * 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 ,
+ * 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。
+ */
+import type { ForumLimitsPublic, PostItem, SiteBranding, User } from '../api/types';
+import type { HomeBootPayload } from '../utils/homeBoot';
+import { normalizePermalinkExt } from '../utils/permalink';
+
+export type PageHead = {
+ /** 完整 (已含站点名) */
+ title: string;
+ description: string;
+ keywords?: string;
+ canonical?: string;
+ /** 默认 website */
+ ogType?: string;
+ ogImage?: string;
+ /** og:site_name */
+ siteName?: string;
+ /** 如 noindex,follow */
+ robots?: string;
+ /** 站点 favicon(相对或绝对 URL) */
+ favicon?: string;
+ /** 已序列化的 JSON-LD 对象(不含 script 标签) */
+ jsonLd?: string;
+};
+
+export const SITE_NAME_FALLBACK = '姜十三论坛';
+/** 与 Go seoDescMax / seoPrerenderMax 对齐 */
+const DESC_MAX = 160;
+const BODY_MAX = 4000;
+
+// —— 品牌字段派生(与 Go service/settings.go 方法对齐) ——
+
+export function siteNameOf(brand?: SiteBranding | null): string {
+ return brand?.name?.trim() || SITE_NAME_FALLBACK;
+}
+
+/** DocumentTitle:站点名 - 标语 */
+export function documentTitleOf(brand?: SiteBranding | null): string {
+ const name = brand?.name?.trim() ?? '';
+ const slogan = brand?.slogan?.trim() ?? '';
+ if (!name) return SITE_NAME_FALLBACK;
+ return slogan ? `${name} - ${slogan}` : name;
+}
+
+/** MetaDescription:优先简介,其次标语 */
+export function metaDescriptionOf(brand?: SiteBranding | null): string {
+ return brand?.description?.trim() || brand?.slogan?.trim() || '';
+}
+
+/** DefaultShareImage:专用 OG 图 → Logo → Favicon */
+export function defaultShareImageOf(brand?: SiteBranding | null): string {
+ return brand?.og_image?.trim() || brand?.logo?.trim() || brand?.favicon?.trim() || '';
+}
+
+/** pageTitle:页面名 - 站点名(与 Go handler/seo.go pageTitle 对齐) */
+export function pageTitle(page: string, siteName: string): string {
+ const p = page.trim();
+ const s = siteName.trim();
+ if (!p && !s) return SITE_NAME_FALLBACK;
+ if (!p) return s;
+ if (!s) return p;
+ return `${p} - ${s}`;
+}
+
+// —— 文本工具(与 Go service/seo.go 对齐) ——
+
+/** AbsoluteURL:相对路径拼绝对 URL;base 为空或非 http(s) 时返回空串 */
+export function absoluteURL(base: string, pathOrURL: string): string {
+ const path = pathOrURL.trim();
+ if (!path) return '';
+ if (/^https?:\/\//i.test(path)) return path;
+ const b = base.trim().replace(/\/+$/, '');
+ if (!b || !/^https?:\/\//i.test(b)) return '';
+ return b + (path.startsWith('/') ? path : `/${path}`);
+}
+
+/** TruncateRunes:按 Unicode code point 截断并加省略号 */
+export function truncateRunes(s: string, max: number): string {
+ const trimmed = s.trim();
+ if (max <= 0 || !trimmed) return trimmed;
+ const runes = Array.from(trimmed);
+ if (runes.length <= max) return trimmed;
+ if (max < 2) return runes.slice(0, max).join('');
+ return `${runes.slice(0, max - 1).join('')}…`;
+}
+
+const styleOrScriptRe = /<(style|script)[^>]*>[\s\S]*?<\/\1>/gi;
+const htmlTagRe = /<[^>]*>/g;
+
+/** StripHTMLForSearch:去标签取纯文本(与 Go service/content.go 对齐) */
+export function stripHTMLForSearch(html: string): string {
+ if (!html) return '';
+ const text = html
+ .replace(styleOrScriptRe, ' ')
+ .replace(htmlTagRe, ' ')
+ .replace(/ /g, ' ');
+ return text.split(/\s+/).filter(Boolean).join(' ');
+}
+
+const imgSrcRe = /
]+src=["']([^"']+)["']/i;
+
+/** FirstImageURL:正文中第一张图片的 src(忽略 data: 内联图) */
+export function firstImageURL(html: string): string {
+ const m = html.match(imgSrcRe);
+ const src = m?.[1]?.trim() ?? '';
+ if (!src || src.startsWith('data:')) return '';
+ return src;
+}
+
+/** DisplayName:昵称优先,其次用户名 */
+export function displayNameOf(user?: User | null): string {
+ return user?.nickname?.trim() || user?.username?.trim() || '';
+}
+
+function joinKeywords(...parts: Array): string {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const part of parts) {
+ if (!part) continue;
+ for (const raw of part.replace(/[,、;;]/g, ',').split(',')) {
+ const p = raw.trim();
+ if (!p || seen.has(p)) continue;
+ seen.add(p);
+ out.push(p);
+ }
+ }
+ return out.join(',');
+}
+
+// —— 伪静态路径(显式传入 limits,避免读缓存) ——
+
+type PermOpts = { permalink_enabled?: boolean; permalink_ext?: string };
+
+function permOpts(limits?: ForumLimitsPublic | null): PermOpts {
+ return { permalink_enabled: !!limits?.permalink_enabled, permalink_ext: limits?.permalink_ext ?? 'html' };
+}
+
+function suffixOf(limits?: ForumLimitsPublic | null): string {
+ if (!limits?.permalink_enabled) return '';
+ return `.${normalizePermalinkExt(limits.permalink_ext)}`;
+}
+
+function feedPostPath(id: number, limits?: ForumLimitsPublic | null): string {
+ return `/post/${id}${suffixOf(limits)}`;
+}
+
+function feedBoardPath(id: number, limits?: ForumLimitsPublic | null): string {
+ return `/board/${id}${suffixOf(limits)}`;
+}
+
+function feedUserPath(id: number, limits?: ForumLimitsPublic | null): string {
+ return `/user/${id}${suffixOf(limits)}`;
+}
+
+function toISO(time?: string): string {
+ if (!time) return '';
+ const d = new Date(time);
+ return Number.isNaN(d.getTime()) ? time : d.toISOString();
+}
+
+// —— head 构建 ——
+
+/** 首页/板块页 head(移植 Go buildSPAPageMeta 首页分支 + ServePublicSPA 板块分支) */
+export function buildFeedHead(boot: HomeBootPayload, base: string): PageHead {
+ const brand = boot.branding ?? null;
+ const siteName = siteNameOf(brand);
+ const siteKeywords = brand?.keywords?.trim() ?? '';
+ const ogImage = defaultShareImageOf(brand);
+ const limits = boot.limits ?? null;
+
+ const board = boot.board_id > 0
+ ? (boot.boards ?? []).find((b) => b.id === boot.board_id) ?? null
+ : null;
+ if (board) {
+ const desc = truncateRunes(board.description?.trim() || metaDescriptionOf(brand), DESC_MAX);
+ return {
+ title: pageTitle(board.name, siteName),
+ description: desc,
+ keywords: joinKeywords(board.name, siteKeywords),
+ canonical: absoluteURL(base, feedBoardPath(board.id, limits)) || undefined,
+ ogType: 'website',
+ ogImage: ogImage || undefined,
+ siteName,
+ favicon: brand?.favicon?.trim() || undefined,
+ };
+ }
+
+ // 站点首页(含搜索/标签/作者筛选:与旧 Go 一致 canonical 指向 /)
+ const desc = truncateRunes(metaDescriptionOf(brand), DESC_MAX);
+ const homeURL = absoluteURL(base, '/');
+ const jsonLd = homeURL
+ ? JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ name: siteName,
+ description: desc,
+ url: homeURL,
+ })
+ : undefined;
+ return {
+ title: documentTitleOf(brand),
+ description: desc,
+ keywords: siteKeywords || undefined,
+ canonical: homeURL || undefined,
+ ogType: 'website',
+ ogImage: ogImage || undefined,
+ siteName,
+ favicon: brand?.favicon?.trim() || undefined,
+ jsonLd,
+ };
+}
+
+/** 帖子详情页 head(移植 Go postPageMeta) */
+export function buildPostHead(
+ post: PostItem,
+ brand: SiteBranding | null,
+ limits: ForumLimitsPublic | null,
+ base: string,
+): PageHead {
+ const siteName = siteNameOf(brand);
+ const content = post.content ?? '';
+ const plain = stripHTMLForSearch(content);
+ const desc = truncateRunes(plain, DESC_MAX);
+ const canonical = absoluteURL(base, feedPostPath(post.id, limits));
+ const defImage = defaultShareImageOf(brand);
+
+ let ogImage = absoluteURL(base, firstImageURL(content));
+ if (!ogImage) ogImage = absoluteURL(base, post.user?.avatar ?? '');
+ if (!ogImage) ogImage = defImage;
+
+ const jsonLd: Record = {
+ '@context': 'https://schema.org',
+ '@type': 'DiscussionForumPosting',
+ headline: post.title,
+ description: desc,
+ datePublished: toISO(post.created_at),
+ dateModified: toISO(post.updated_at || post.created_at),
+ url: canonical,
+ mainEntityOfPage: canonical,
+ author: {
+ '@type': 'Person',
+ name: displayNameOf(post.user),
+ url: absoluteURL(base, feedUserPath(post.user_id, limits)),
+ },
+ interactionStatistic: {
+ '@type': 'InteractionCounter',
+ interactionType: 'https://schema.org/ViewAction',
+ userInteractionCount: post.view_count ?? 0,
+ },
+ };
+ if (post.board?.name) jsonLd.articleSection = post.board.name;
+ if (ogImage) jsonLd.image = [ogImage];
+ const body = truncateRunes(plain, BODY_MAX);
+ if (body) jsonLd.articleBody = body;
+
+ return {
+ title: pageTitle(post.title, siteName),
+ description: desc,
+ canonical: canonical || undefined,
+ ogType: 'article',
+ ogImage: ogImage || undefined,
+ siteName,
+ jsonLd: JSON.stringify(jsonLd),
+ };
+}
+
+/** 404 页 head(移植 Go notFoundPageMeta) */
+export function buildNotFoundHead(
+ pathname: string,
+ brand?: SiteBranding | null,
+ base = '',
+): PageHead {
+ return {
+ title: pageTitle('页面不存在', siteNameOf(brand)),
+ description: '您访问的页面不存在或已删除',
+ canonical: absoluteURL(base, pathname) || undefined,
+ ogType: 'website',
+ robots: 'noindex,follow',
+ siteName: siteNameOf(brand),
+ };
+}
+
+// —— HTML 渲染 ——
+
+function esc(s: string): string {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function twitterCard(ogImage?: string): string {
+ return ogImage?.trim() ? 'summary_large_image' : 'summary';
+}
+
+function firstNonEmpty(...vals: Array): string {
+ for (const v of vals) {
+ const s = v?.trim();
+ if (s) return s;
+ }
+ return '';
+}
+
+/**
+ * 渲染 内的 SEO 节点(title / meta / OG / Twitter / JSON-LD / favicon)。
+ * head 缺失时回退旧 SPA 入口的默认标题;返回值为自转义 HTML,调用方需经
+ * dangerouslySkipEscape 注入。
+ */
+export function renderHeadTags(head?: PageHead | null): string {
+ const h = head ?? null;
+ const title = firstNonEmpty(h?.title, `${SITE_NAME_FALLBACK} - 拾三一隅,自在交流`);
+ const description = h?.description?.trim() ?? '';
+ const siteName = firstNonEmpty(h?.siteName, SITE_NAME_FALLBACK);
+ const ogType = firstNonEmpty(h?.ogType, 'website');
+ const ogImage = h?.ogImage?.trim() ?? '';
+
+ const parts: string[] = [];
+ parts.push(`${esc(title)}`);
+ if (description) parts.push(``);
+ const keywords = h?.keywords?.trim() ?? '';
+ if (keywords) parts.push(``);
+ const canonical = h?.canonical?.trim() ?? '';
+ if (canonical) parts.push(``);
+ const favicon = h?.favicon?.trim() ?? '';
+ if (favicon) {
+ parts.push(``);
+ parts.push(``);
+ }
+ const robots = h?.robots?.trim() ?? '';
+ if (robots) parts.push(``);
+
+ const prop = (p: string, content: string) => {
+ if (content) parts.push(``);
+ };
+ const metaName = (name: string, content: string) => {
+ if (content) parts.push(``);
+ };
+
+ prop('og:type', ogType);
+ prop('og:site_name', siteName);
+ prop('og:locale', 'zh_CN');
+ prop('og:title', title);
+ prop('og:description', description);
+ prop('og:url', canonical);
+ prop('og:image', ogImage);
+ metaName('twitter:card', twitterCard(ogImage));
+ metaName('twitter:title', title);
+ metaName('twitter:description', description);
+ metaName('twitter:image', ogImage);
+
+ const jsonLd = h?.jsonLd?.trim() ?? '';
+ if (jsonLd) {
+ // 仅转义 < 防止提前闭合 script(与 Go 侧处理一致)
+ parts.push(``);
+ }
+ return parts.join('\n');
+}
diff --git a/frontend/src/ssr/scope.ts b/frontend/src/ssr/scope.ts
index d4aba72..bfb7d6b 100644
--- a/frontend/src/ssr/scope.ts
+++ b/frontend/src/ssr/scope.ts
@@ -16,13 +16,13 @@ let als: AsyncLocalStorageLike | null = null;
if (import.meta.env.SSR) {
type NodeProcess = {
- getBuiltinModule?: (id: string) => unknown;
+ getBuiltinModule?: (id: string) => Record | undefined;
};
- const mod = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks') as
- | (new () => AsyncLocalStorageLike)
- | undefined;
- if (mod) {
- als = new mod();
+ const hooks = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks');
+ // getBuiltinModule 返回模块命名空间对象,构造器在 AsyncLocalStorage 字段上
+ const ALS = hooks?.AsyncLocalStorage as (new () => AsyncLocalStorageLike) | undefined;
+ if (ALS) {
+ als = new ALS();
}
}
diff --git a/frontend/src/ssr/serverDom.ts b/frontend/src/ssr/serverDom.ts
new file mode 100644
index 0000000..9b7a603
--- /dev/null
+++ b/frontend/src/ssr/serverDom.ts
@@ -0,0 +1,33 @@
+/**
+ * 服务端 DOM polyfill(SSR 专用,只允许服务端入口导入,勿进客户端 bundle)。
+ *
+ * 帖子/评论内容处理(postContent/content/seoText/postHeadings)依赖
+ * DOMParser 与全局 document;服务端用 linkedom 提供同形实现:
+ * - DOMParser:new DOMParser().parseFromString(html, 'text/html')
+ * - document:空文档,供 document.createElement/createTreeWalker 等使用
+ * - Element / HTMLElement:instanceof 判定(linkedom 的 HTML 元素全部继承 HTMLElement)
+ * - NodeFilter:createTreeWalker 的 SHOW_* 常量
+ *
+ * DOMPurify 在无 window 环境 isSupported=false,sanitize 原样返回——
+ * SSR 内容均来自 Go API(服务端已消毒),跳过浏览器端二次消毒是安全的。
+ */
+import { DOMParser as LinkedomDOMParser, HTMLElement as LinkedomHTMLElement, NodeFilter as LinkedomNodeFilter } from 'linkedom';
+
+let installed = false;
+
+export function installServerDom(): void {
+ if (installed) return;
+ installed = true;
+
+ const g = globalThis as unknown as Record;
+ const parser = new LinkedomDOMParser();
+ const doc = parser.parseFromString('...', 'text/html');
+
+ g.DOMParser ??= LinkedomDOMParser;
+ g.NodeFilter ??= LinkedomNodeFilter;
+ g.HTMLElement ??= LinkedomHTMLElement;
+ // linkedom HTML 元素(含 unknown/custom 元素)统一继承 HTMLElement,
+ // 可同时充当 instanceof Element / instanceof HTMLElement 的判定目标
+ g.Element ??= LinkedomHTMLElement;
+ g.document ??= doc;
+}
diff --git a/frontend/src/utils/authBoot.ts b/frontend/src/utils/authBoot.ts
index 7155075..e184de8 100644
--- a/frontend/src/utils/authBoot.ts
+++ b/frontend/src/utils/authBoot.ts
@@ -1,7 +1,7 @@
import type { User } from '../api/types';
import { getScope } from '../ssr/scope';
-/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot)
+/** SSR boot 注入的鉴权种子(seedHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot)
* SSR 时写入请求作用域,客户端为模块级单例 */
let seededUser: User | null | undefined;
let seededUnread = 0;
diff --git a/frontend/src/utils/content.ts b/frontend/src/utils/content.ts
index 1a6e659..6b6320d 100644
--- a/frontend/src/utils/content.ts
+++ b/frontend/src/utils/content.ts
@@ -1,4 +1,4 @@
-import DOMPurify from 'dompurify';
+import { sanitizeHtml } from './purify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
@@ -64,7 +64,7 @@ function processMentionsInHtml(html: string): string {
/** 渲染评论内容:HTML 净化 + @提及高亮 + 代码块阅读态,兼容旧版纯文本 */
export function renderCommentContent(content: string): string {
if (isHtmlContent(content)) {
- const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG) as string;
+ const sanitized = sanitizeHtml(content, POST_CONTENT_PURIFY_CONFIG);
const withMentions = processMentionsInHtml(sanitized);
const doc = new DOMParser().parseFromString(``, 'text/html');
const root = doc.getElementById('j13-comment-root') ?? doc.body;
diff --git a/frontend/src/utils/homeBoot.ts b/frontend/src/utils/homeBoot.ts
index 19fce32..e47f221 100644
--- a/frontend/src/utils/homeBoot.ts
+++ b/frontend/src/utils/homeBoot.ts
@@ -33,7 +33,7 @@ import { checkInCacheKey } from '../hooks/useCheckIn';
import { isTimeDiffSignificant } from './content';
import type { FeedSort } from '../components/FeedSortBar';
-/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
+/** 与 Go homeBootPayload 对齐(boot 数据现经 Vike +data → pageContext 传递) */
export type HomeBootPayload = {
board_id: number;
sort: string;
@@ -58,25 +58,6 @@ export type HomeBootPayload = {
check_in?: CheckInStatus | null;
};
-declare global {
- interface Window {
- __J13_HOME_BOOT__?: HomeBootPayload;
- }
-}
-
-/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */
-export function consumeHomeBoot(): HomeBootPayload | null {
- if (typeof window === 'undefined') return null;
- const boot = window.__J13_HOME_BOOT__;
- try {
- delete window.__J13_HOME_BOOT__;
- } catch {
- window.__J13_HOME_BOOT__ = undefined;
- }
- if (!boot || typeof boot !== 'object') return null;
- return seedHomeBoot(boot);
-}
-
/** 将 boot 数据灌入当前缓存层(SSR 时经请求作用域隔离;客户端为模块单例) */
export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload {
if (boot.limits) seedForumLimitsCache(boot.limits);
diff --git a/frontend/src/utils/markdownContent.ts b/frontend/src/utils/markdownContent.ts
index 7934aef..3b72591 100644
--- a/frontend/src/utils/markdownContent.ts
+++ b/frontend/src/utils/markdownContent.ts
@@ -1,6 +1,6 @@
import TurndownService from 'turndown';
import { marked } from 'marked';
-import DOMPurify from 'dompurify';
+import { sanitizeHtml } from './purify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
import { mapOutsideFences, wrapFencedCode } from './markdownFences';
@@ -280,7 +280,7 @@ marked.use({
/** 净化并保留 members-only 标签 */
function sanitizeContentHtml(html: string): string {
- return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
+ return sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts
index da9b718..2a1639e 100644
--- a/frontend/src/utils/postContent.ts
+++ b/frontend/src/utils/postContent.ts
@@ -1,4 +1,4 @@
-import DOMPurify from 'dompurify';
+import { sanitizeHtml } from './purify';
import type { Config } from 'dompurify';
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
import { enhanceHeadingAnchors } from './postHeadings';
@@ -111,7 +111,7 @@ function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: strin
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
- DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
+ sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
// 有文本内容
@@ -130,7 +130,7 @@ export function renderPostContentHtml(
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
- DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
+ sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
diff --git a/frontend/src/utils/purify.ts b/frontend/src/utils/purify.ts
new file mode 100644
index 0000000..20e5601
--- /dev/null
+++ b/frontend/src/utils/purify.ts
@@ -0,0 +1,15 @@
+/**
+ * 双端 sanitize 封装。
+ *
+ * - 浏览器:DOMPurify 默认实例(绑定真实 window)
+ * - SSR:直接透传——内容均来自 Go API(服务端已消毒,与客户端 DOMPurify 输出一致);
+ * 且 DOMPurify 的 getGlobal 只认 `window`,Node 环境拿到的默认导出没有 sanitize,
+ * 注入 globalThis.window 又会翻转全项目 `typeof window` 守卫,故 SSR 走透传。
+ */
+import DOMPurify from 'dompurify';
+import type { Config } from 'dompurify';
+
+export function sanitizeHtml(html: string, config?: Config): string {
+ if (import.meta.env.SSR) return html;
+ return DOMPurify.sanitize(html, config) as string;
+}
diff --git a/frontend/src/utils/revisionDiff.ts b/frontend/src/utils/revisionDiff.ts
index 7462d4c..c914f21 100644
--- a/frontend/src/utils/revisionDiff.ts
+++ b/frontend/src/utils/revisionDiff.ts
@@ -1,5 +1,5 @@
import { diffLines, diffWords } from 'diff';
-import DOMPurify from 'dompurify';
+import { sanitizeHtml } from './purify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
export interface PostSnapshot {
@@ -25,7 +25,7 @@ export interface DiffPart {
export function htmlToDiffText(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
- DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
+ sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
diff --git a/handler/home_boot.go b/handler/home_boot.go
new file mode 100644
index 0000000..a549a6a
--- /dev/null
+++ b/handler/home_boot.go
@@ -0,0 +1,288 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
+)
+
+// homeBootPayload 与前端 HomeBootPayload 对齐(Vike +data 冷启动灌缓存)。
+type homeBootPayload struct {
+ BoardID uint `json:"board_id"`
+ Sort string `json:"sort"`
+ Keyword string `json:"keyword"`
+ Tag string `json:"tag"`
+ Author string `json:"author"`
+ TitleOnly bool `json:"title_only"`
+ Posts []service.PostListItem `json:"posts"`
+ PostTotal int64 `json:"post_total"`
+ Page int `json:"page"`
+ Boards []service.BoardWithStats `json:"boards"`
+ Stats homeBootStats `json:"stats"`
+ RecentComments []service.RecentCommentItem `json:"recent_comments"`
+ RecentUsers []service.RecentUserItem `json:"recent_users"`
+ Tags []service.TagCount `json:"tags"`
+ // omitempty:侧栏关闭或未拉取时不写字段,避免 SPA 把 [] 当成有效会话快照
+ Showcase []service.CommunityShowcaseItem `json:"showcase,omitempty"`
+ Pages []service.SitePageSummary `json:"pages"`
+ Limits service.ForumLimitsPublic `json:"limits"`
+ Branding service.SiteBranding `json:"branding"`
+ User *model.UserSelf `json:"user"`
+ UnreadMessages int `json:"unread_messages"`
+ CheckIn *service.CheckInStatus `json:"check_in"`
+}
+
+type homeBootStats struct {
+ Users int64 `json:"users"`
+ Posts int64 `json:"posts"`
+ Boards int64 `json:"boards"`
+ Comments int64 `json:"comments"`
+}
+
+// APIHomeBoot 首页/板块/壳层聚合数据(Vike SSR +data 使用,与前端 HomeBootPayload 对齐)。
+// 并发聚合:帖子列表、板块、统计、侧栏 widget、登录态、签到等。
+func (h *Handlers) APIHomeBoot(c *gin.Context) {
+ boardID := uint(parseUintOrZero(c.Query("board_id")))
+ boot, err := h.gatherHomeSSR(c, boardID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取数据失败"})
+ return
+ }
+ c.JSON(http.StatusOK, boot)
+}
+
+func (h *Handlers) gatherHomeSSR(c *gin.Context, boardID uint) (*homeBootPayload, error) {
+ limits := h.Settings.PublicLimits()
+ brand := h.Settings.SiteBranding()
+ brand.SiteURL = h.publicBaseURL(c)
+ defSort := h.Settings.DefaultFeedSort()
+
+ sort := strings.TrimSpace(c.Query("sort"))
+ if sort == "" {
+ sort = defSort
+ }
+ keyword := strings.TrimSpace(c.Query("keyword"))
+ tag := strings.TrimSpace(c.Query("tag"))
+ author := strings.TrimSpace(c.Query("author"))
+ titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
+ if tag != "" {
+ keyword = ""
+ author = ""
+ titleOnly = false
+ }
+
+ pageSize := h.Settings.PageSizeDefault()
+
+ out := &homeBootPayload{
+ BoardID: boardID,
+ Sort: sort,
+ Keyword: keyword,
+ Tag: tag,
+ Author: author,
+ TitleOnly: titleOnly,
+ Page: 1,
+ Posts: []service.PostListItem{},
+ Boards: []service.BoardWithStats{},
+ Pages: []service.SitePageSummary{},
+ Limits: limits,
+ Branding: brand,
+ }
+
+ var (
+ postsErr, boardsErr error
+ wg sync.WaitGroup
+ )
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ q := service.PostListQuery{
+ BoardID: boardID,
+ Page: 1,
+ Size: pageSize,
+ Sort: sort,
+ Keyword: keyword,
+ Tag: tag,
+ Author: author,
+ TitleOnly: titleOnly,
+ ViewerID: h.currentUserID(c),
+ ViewerIsAdmin: h.isAdmin(c),
+ }
+ items, total, err := h.Post.ListItems(q)
+ if err != nil {
+ postsErr = err
+ return
+ }
+ if items == nil {
+ items = []service.PostListItem{}
+ }
+ if h.Badge != nil {
+ users := make([]*model.User, 0, len(items))
+ for i := range items {
+ if items[i].User.ID > 0 {
+ users = append(users, &items[i].User)
+ }
+ }
+ h.Badge.AttachBadgeSummaries(users, 2)
+ }
+ out.Posts = items
+ out.PostTotal = total
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ boards, err := h.Board.ListWithStats()
+ if err != nil {
+ boardsErr = err
+ return
+ }
+ if boards == nil {
+ boards = []service.BoardWithStats{}
+ }
+ out.Boards = boards
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ var users, posts, boardsN, comments int64
+ model.DB.Model(&model.User{}).Count(&users)
+ model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts)
+ model.DB.Model(&model.Board{}).Count(&boardsN)
+ model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&comments)
+ out.Stats = homeBootStats{Users: users, Posts: posts, Boards: boardsN, Comments: comments}
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ pages, err := h.SitePage.ListPublished()
+ if err != nil || pages == nil {
+ pages = []service.SitePageSummary{}
+ }
+ out.Pages = pages
+ }()
+
+ widgetEnabled := map[string]bool{}
+ for _, w := range limits.AsideWidgets {
+ widgetEnabled[w.ID] = w.Enabled
+ }
+
+ if widgetEnabled[service.AsideWidgetRecentComments] {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ items, err := h.Comment.ListRecentPublic(8)
+ if err != nil || items == nil {
+ items = []service.RecentCommentItem{}
+ }
+ out.RecentComments = items
+ }()
+ } else {
+ out.RecentComments = []service.RecentCommentItem{}
+ }
+
+ if widgetEnabled[service.AsideWidgetRecentUsers] {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ items, err := h.User.ListRecentRegistered(8)
+ if err != nil || items == nil {
+ items = []service.RecentUserItem{}
+ }
+ out.RecentUsers = items
+ }()
+ } else {
+ out.RecentUsers = []service.RecentUserItem{}
+ }
+
+ if widgetEnabled[service.AsideWidgetTagCloud] {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ tags, err := h.Post.PopularTags(40)
+ if err != nil || tags == nil {
+ tags = []service.TagCount{}
+ }
+ out.Tags = tags
+ }()
+ } else {
+ out.Tags = []service.TagCount{}
+ }
+
+ if widgetEnabled[service.AsideWidgetShowcase] && h.Community != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ items, err := h.Community.ListShowcase(c.Request.Host)
+ if err != nil {
+ // 失败不写 showcase,让前端自行请求,避免假空粘死
+ return
+ }
+ if items == nil {
+ items = []service.CommunityShowcaseItem{}
+ }
+ out.Showcase = items
+ }()
+ }
+
+ uid := h.currentUserID(c)
+ if uid > 0 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ user, err := h.User.GetByID(uid)
+ if err != nil || user == nil {
+ return
+ }
+ view := user.ToSelf()
+ if h.Badge != nil {
+ _ = h.Badge.EvaluateAuto(user.ID)
+ if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
+ view.Badges = service.BadgeViews(badges, 0)
+ }
+ }
+ out.User = &view
+ }()
+ if h.Message != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ total, _, _, err := h.Message.UnreadCounts(uid)
+ if err == nil {
+ out.UnreadMessages = int(total)
+ }
+ }()
+ }
+ if h.Points != nil {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ st, err := h.Points.GetCheckInStatus(uid)
+ if err == nil {
+ out.CheckIn = &st
+ }
+ }()
+ }
+ }
+
+ wg.Wait()
+ if postsErr != nil {
+ return nil, postsErr
+ }
+ if boardsErr != nil {
+ return nil, boardsErr
+ }
+ return out, nil
+}
+
+func parseUintOrZero(s string) uint64 {
+ n, _ := strconv.ParseUint(s, 10, 64)
+ return n
+}