feat: Vike +data 服务端取数 / SEO Head 移植 / 404 错误页 / 服务端 DOM 模拟

- feedData.ts 聚合首页/板块/帖子详情数据,缺失内容 throw render(404)
- Go 侧 spa_meta/seo 的 head 生成逻辑移植到 src/ssr/head.ts
- _error 页复用站点壳渲染 404,+data 从 abortReason 恢复壳层数据
- linkedom 模拟服务端 DOM,DOMPurify 封装为 SSR 透传的 sanitizeHtml
- 新增 /api/home-boot 聚合端点(handler/home_boot.go)供 SSR 冷启动灌缓存
This commit is contained in:
2026-09-09 02:41:30 +08:00
parent e71c77b5ca
commit c99238c1e7
25 changed files with 1040 additions and 59 deletions

View File

@@ -34,6 +34,7 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compression": "^1.8.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^9.0.0", "diff": "^9.0.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
@@ -4152,6 +4153,60 @@
"node": ">= 6" "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": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -5731,6 +5786,15 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -6480,6 +6544,26 @@
"queue-microtask": "^1.2.2" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vike dev", "dev": "vike dev",
"build": "vike build", "build": "vike build",
"preview": "vike preview" "preview": "vike preview",
"start": "node server/index.mjs"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -35,6 +36,7 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compression": "^1.8.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"diff": "^9.0.0", "diff": "^9.0.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",

View File

@@ -1,6 +1,12 @@
import type { Config } from 'vike/types'; import type { Config } from 'vike/types';
export default { export default {
// pageProps 透传给客户端(boot 数据经 +data 的 pageContext.data 自动序列化) // pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed
passToClient: ['pageProps'], passToClient: ['pageProps', 'data'],
// 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false)
meta: {
ssr: {
env: { server: true },
},
},
} satisfies Config; } satisfies Config;

View File

@@ -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 <App url={url} />;
}

View File

@@ -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<FeedData> {
if (!import.meta.env.SSR) return { kind: 'notfound' };
const reason = pageContext.abortReason as NotFoundAbortReason | undefined;
return {
kind: 'notfound',
head: reason?.head,
boot: reason?.boot,
};
}

View File

@@ -4,7 +4,8 @@ import type { PageContext } from 'vike/types';
export { Page }; export { Page };
/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */ /** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
function Page(pageContext: PageContext) { function Page(pageContext: PageContext & { url?: string }) {
const { pathname, searchOriginal } = pageContext.urlParsed; // onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
return <App url={pathname + (searchOriginal ?? '')} />; const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
return <App url={url} />;
} }

View File

@@ -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);
}

View File

@@ -5,7 +5,7 @@ import type { PageContextClient } from 'vike/types';
import { applyTheme, getStoredTheme } from '../src/utils/theme'; import { applyTheme, getStoredTheme } from '../src/utils/theme';
import { beginHomeHydrate } from '../src/utils/homeHydrate'; import { beginHomeHydrate } from '../src/utils/homeHydrate';
import { reloadForStaleChunk } from '../src/utils/chunkLoad'; 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'; import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
export { onRenderClient }; export { onRenderClient };
@@ -40,7 +40,10 @@ function waitForStylesheets(): Promise<void> {
async function onRenderClient(pageContext: PageContextClient) { async function onRenderClient(pageContext: PageContextClient) {
applyTheme(getStoredTheme()); applyTheme(getStoredTheme());
consumeHomeBoot(); // hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
if (pageContext.isHydration) {
seedFeedData((pageContext as { data?: FeedData }).data);
}
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404) // Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
window.addEventListener('vite:preloadError', (event) => { window.addEventListener('vite:preloadError', (event) => {

View File

@@ -2,10 +2,15 @@ import type { ComponentType, ReactElement } from 'react';
import { renderToReadableStream } from 'react-dom/server'; import { renderToReadableStream } from 'react-dom/server';
import { escapeInject, dangerouslySkipEscape } from 'vike/server'; import { escapeInject, dangerouslySkipEscape } from 'vike/server';
import type { PageContextServer } from 'vike/types'; import type { PageContextServer } from 'vike/types';
import App from '../src/App';
import { createHomeStore } from '../src/store/homeStore'; import { createHomeStore } from '../src/store/homeStore';
import { createRequestScope } from '../src/ssr/scopeTypes'; import { createRequestScope } from '../src/ssr/scopeTypes';
import { runWithScope } from '../src/ssr/scope'; 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 }; export { onRenderHtml };
@@ -32,6 +37,7 @@ async function renderPageToString(element: ReactElement): Promise<string> {
async function onRenderHtml(pageContext: PageContextServer) { async function onRenderHtml(pageContext: PageContextServer) {
const Page = pageContext.Page as PageComponent | undefined; const Page = pageContext.Page as PageComponent | undefined;
const data = (pageContext as { data?: FeedData }).data;
let pageHtml = ''; let pageHtml = '';
if (Page) { if (Page) {
@@ -41,20 +47,26 @@ async function onRenderHtml(pageContext: PageContextServer) {
const { pathname, searchOriginal } = pageContext.urlParsed; const { pathname, searchOriginal } = pageContext.urlParsed;
const url = pathname + (searchOriginal ?? ''); const url = pathname + (searchOriginal ?? '');
try { try {
pageHtml = await runWithScope(scope, () => renderPageToString(<Page url={url} />)); pageHtml = await runWithScope(scope, () => {
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
seedFeedData(data);
return renderPageToString(<Page url={url} />);
});
} catch (err) { } catch (err) {
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err); console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
pageHtml = ''; pageHtml = '';
} }
} }
const headTags = renderHeadTags(data?.head);
// 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
return escapeInject`<!DOCTYPE html> return escapeInject`<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="拾三一隅,自在交流" /> ${dangerouslySkipEscape(headTags)}
<title>姜十三论坛 - 拾三一隅,自在交流</title>
<script>${dangerouslySkipEscape(THEME_INIT_SCRIPT)}</script> <script>${dangerouslySkipEscape(THEME_INIT_SCRIPT)}</script>
<script>${dangerouslySkipEscape(ZOOM_BLOCK_SCRIPT)}</script> <script>${dangerouslySkipEscape(ZOOM_BLOCK_SCRIPT)}</script>
</head> </head>

View File

@@ -3,19 +3,26 @@
* *
* - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001) * - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001)
* - 转发浏览器 cookie 以透传登录态 * - 转发浏览器 cookie 以透传登录态
* - 转发 host / X-Forwarded-* 让 Go 推导正确的站点 base URL(canonical/og:url 等)
* - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染 * - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染
*/ */
const GO_API_URL = process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`; const GO_API_URL = process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`;
export async function serverApi<T>( export type ServerApiOptions = {
path: string, /** 浏览器请求的 cookie 原样转发 */
opts?: { cookie?: string; timeoutMs?: number }, cookie?: string;
): Promise<T | null> { /** 额外转发给 Go 的请求头(host / x-forwarded-* 等) */
headers?: Record<string, string>;
timeoutMs?: number;
};
export async function serverApi<T>(path: string, opts?: ServerApiOptions): Promise<T | null> {
try { try {
const res = await fetch(`${GO_API_URL}${path}`, { const res = await fetch(`${GO_API_URL}${path}`, {
headers: { headers: {
...(opts?.cookie ? { cookie: opts.cookie } : {}), ...(opts?.cookie ? { cookie: opts.cookie } : {}),
...(opts?.headers ?? {}),
}, },
signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000), signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000),
cache: 'no-store', cache: 'no-store',

View File

@@ -9,7 +9,7 @@ import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import { TableKit } from '@tiptap/extension-table'; import { TableKit } from '@tiptap/extension-table';
import DOMPurify from 'dompurify'; import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote, Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole, List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
@@ -121,7 +121,7 @@ function buildMarkdownTableSnippet(opts: TableInsertOptions): string {
/** 净化编辑器 HTML,保留 members-only 自定义标签 */ /** 净化编辑器 HTML,保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG);
} }
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */ /** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */

View File

@@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify'; import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify';
import { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Bold, Italic, Underline as UnderlineIcon, Strikethrough,
List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon, List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon,
@@ -36,7 +36,7 @@ interface Props {
} }
function sanitizeHtml(html: string): string { 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 { function isEditorEmpty(editor: Editor): boolean {

View File

@@ -92,9 +92,9 @@ export default function MainLayout() {
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0); const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false); const asideEverLoaded = useRef(false);
/** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */ /** 冷启动门闩:main.tsx 已预热则可同步放行;SSR/hydrate 首帧数据已由 +data seed,直接放行保持同构 */
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured()); const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured() || isHomeHydrating());
const coldBootDone = useRef(isCompose || wasColdBootEnsured()); const coldBootDone = useRef(isCompose || wasColdBootEnsured() || isHomeHydrating());
const bootGen = useRef(0); const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => { const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);

View File

@@ -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 注入 <head>
*/
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;
/** <head> 元信息(服务端专用,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<FeedData> {
// 客户端(Vike client router 触发的导航)不取数:SPA 内页面组件自行请求
if (!import.meta.env.SSR) return { kind: 'home' };
const headers = pageContext.headers ?? {};
const cookie = headers.cookie ?? '';
const apiHeaders: Record<string, string> = {
// 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<string, string> };
/** 帖子详情:详情 + 评论 + 壳层并行取数 */
async function loadPostData(id: string, pathname: string, api: ApiCtx): Promise<FeedData> {
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<PostDetailResponse>(`/api/posts/${pid}`, api),
serverApi<{ comments: Comment[]; total: number }>(`/api/posts/${pid}/comments`, api),
serverApi<HomeBootPayload>('/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<FeedData> {
// 旧版 /?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<HomeBootPayload>(`/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) };
}

363
frontend/src/ssr/head.ts Normal file
View File

@@ -0,0 +1,363 @@
/**
* SEO head 构建:移植 Go 侧 embed_static/spa_meta.go + handler/seo.go 的 meta 生成逻辑。
*
* 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 <head>,
* 客户端路由切换时由 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>(已含站点名) */
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(/&nbsp;/g, ' ');
return text.split(/\s+/).filter(Boolean).join(' ');
}
const imgSrcRe = /<img[^>]+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 | undefined | null>): string {
const seen = new Set<string>();
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<string, unknown> = {
'@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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function twitterCard(ogImage?: string): string {
return ogImage?.trim() ? 'summary_large_image' : 'summary';
}
function firstNonEmpty(...vals: Array<string | undefined>): string {
for (const v of vals) {
const s = v?.trim();
if (s) return s;
}
return '';
}
/**
* 渲染 <head> 内的 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(`<title>${esc(title)}</title>`);
if (description) parts.push(`<meta name="description" content="${esc(description)}"/>`);
const keywords = h?.keywords?.trim() ?? '';
if (keywords) parts.push(`<meta name="keywords" content="${esc(keywords)}"/>`);
const canonical = h?.canonical?.trim() ?? '';
if (canonical) parts.push(`<link rel="canonical" href="${esc(canonical)}"/>`);
const favicon = h?.favicon?.trim() ?? '';
if (favicon) {
parts.push(`<link rel="icon" href="${esc(favicon)}"/>`);
parts.push(`<link rel="shortcut icon" href="${esc(favicon)}"/>`);
}
const robots = h?.robots?.trim() ?? '';
if (robots) parts.push(`<meta name="robots" content="${esc(robots)}"/>`);
const prop = (p: string, content: string) => {
if (content) parts.push(`<meta property="${esc(p)}" content="${esc(content)}"/>`);
};
const metaName = (name: string, content: string) => {
if (content) parts.push(`<meta name="${esc(name)}" content="${esc(content)}"/>`);
};
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(`<script type="application/ld+json">${jsonLd.replace(/</g, '\\u003c')}</script>`);
}
return parts.join('\n');
}

View File

@@ -16,13 +16,13 @@ let als: AsyncLocalStorageLike | null = null;
if (import.meta.env.SSR) { if (import.meta.env.SSR) {
type NodeProcess = { type NodeProcess = {
getBuiltinModule?: (id: string) => unknown; getBuiltinModule?: (id: string) => Record<string, unknown> | undefined;
}; };
const mod = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks') as const hooks = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks');
| (new () => AsyncLocalStorageLike) // getBuiltinModule 返回模块命名空间对象,构造器在 AsyncLocalStorage 字段上
| undefined; const ALS = hooks?.AsyncLocalStorage as (new () => AsyncLocalStorageLike) | undefined;
if (mod) { if (ALS) {
als = new mod(); als = new ALS();
} }
} }

View File

@@ -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<string, unknown>;
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;
}

View File

@@ -1,7 +1,7 @@
import type { User } from '../api/types'; import type { User } from '../api/types';
import { getScope } from '../ssr/scope'; import { getScope } from '../ssr/scope';
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) /** SSR boot 注入的鉴权种子(seedHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot)
* SSR 时写入请求作用域,客户端为模块级单例 */ * SSR 时写入请求作用域,客户端为模块级单例 */
let seededUser: User | null | undefined; let seededUser: User | null | undefined;
let seededUnread = 0; let seededUnread = 0;

View File

@@ -1,4 +1,4 @@
import DOMPurify from 'dompurify'; import { sanitizeHtml } from './purify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
import { enhanceCodeBlocks } from './enhanceCodeBlocks'; import { enhanceCodeBlocks } from './enhanceCodeBlocks';
@@ -64,7 +64,7 @@ function processMentionsInHtml(html: string): string {
/** 渲染评论内容:HTML 净化 + @提及高亮 + 代码块阅读态,兼容旧版纯文本 */ /** 渲染评论内容:HTML 净化 + @提及高亮 + 代码块阅读态,兼容旧版纯文本 */
export function renderCommentContent(content: string): string { export function renderCommentContent(content: string): string {
if (isHtmlContent(content)) { 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 withMentions = processMentionsInHtml(sanitized);
const doc = new DOMParser().parseFromString(`<div id="j13-comment-root">${withMentions}</div>`, 'text/html'); const doc = new DOMParser().parseFromString(`<div id="j13-comment-root">${withMentions}</div>`, 'text/html');
const root = doc.getElementById('j13-comment-root') ?? doc.body; const root = doc.getElementById('j13-comment-root') ?? doc.body;

View File

@@ -33,7 +33,7 @@ import { checkInCacheKey } from '../hooks/useCheckIn';
import { isTimeDiffSignificant } from './content'; import { isTimeDiffSignificant } from './content';
import type { FeedSort } from '../components/FeedSortBar'; import type { FeedSort } from '../components/FeedSortBar';
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */ /** 与 Go homeBootPayload 对齐(boot 数据现经 Vike +data → pageContext 传递) */
export type HomeBootPayload = { export type HomeBootPayload = {
board_id: number; board_id: number;
sort: string; sort: string;
@@ -58,25 +58,6 @@ export type HomeBootPayload = {
check_in?: CheckInStatus | null; 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 时经请求作用域隔离;客户端为模块单例) */ /** 将 boot 数据灌入当前缓存层(SSR 时经请求作用域隔离;客户端为模块单例) */
export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload { export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload {
if (boot.limits) seedForumLimitsCache(boot.limits); if (boot.limits) seedForumLimitsCache(boot.limits);

View File

@@ -1,6 +1,6 @@
import TurndownService from 'turndown'; import TurndownService from 'turndown';
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import { sanitizeHtml } from './purify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions'; import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
import { mapOutsideFences, wrapFencedCode } from './markdownFences'; import { mapOutsideFences, wrapFencedCode } from './markdownFences';
@@ -280,7 +280,7 @@ marked.use({
/** 净化并保留 members-only 标签 */ /** 净化并保留 members-only 标签 */
function sanitizeContentHtml(html: string): string { function sanitizeContentHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); return sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG);
} }
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */ /** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */

View File

@@ -1,4 +1,4 @@
import DOMPurify from 'dompurify'; import { sanitizeHtml } from './purify';
import type { Config } from 'dompurify'; import type { Config } from 'dompurify';
import { enhanceCodeBlocks } from './enhanceCodeBlocks'; import { enhanceCodeBlocks } from './enhanceCodeBlocks';
import { enhanceHeadingAnchors } from './postHeadings'; import { enhanceHeadingAnchors } from './postHeadings';
@@ -111,7 +111,7 @@ function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: strin
export function isHtmlEmpty(html: string): boolean { export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true; if (!html.trim()) return true;
const doc = new DOMParser().parseFromString( const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string, sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html', 'text/html',
); );
// 有文本内容 // 有文本内容
@@ -130,7 +130,7 @@ export function renderPostContentHtml(
if (!html.trim()) return ''; if (!html.trim()) return '';
const doc = new DOMParser().parseFromString( const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string, sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html', 'text/html',
); );

View File

@@ -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;
}

View File

@@ -1,5 +1,5 @@
import { diffLines, diffWords } from 'diff'; import { diffLines, diffWords } from 'diff';
import DOMPurify from 'dompurify'; import { sanitizeHtml } from './purify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
export interface PostSnapshot { export interface PostSnapshot {
@@ -25,7 +25,7 @@ export interface DiffPart {
export function htmlToDiffText(html: string): string { export function htmlToDiffText(html: string): string {
if (!html.trim()) return ''; if (!html.trim()) return '';
const doc = new DOMParser().parseFromString( const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG), sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG),
'text/html', 'text/html',
); );
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n')); doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));

288
handler/home_boot.go Normal file
View File

@@ -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
}