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:
84
frontend/package-lock.json
generated
84
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
frontend/pages/_error/+Page.tsx
Normal file
10
frontend/pages/_error/+Page.tsx
Normal 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} />;
|
||||
}
|
||||
24
frontend/pages/_error/+data.ts
Normal file
24
frontend/pages/_error/+data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 <App url={pathname + (searchOriginal ?? '')} />;
|
||||
function Page(pageContext: PageContext & { url?: string }) {
|
||||
// onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query)
|
||||
const url = pageContext.url ?? pageContext.urlOriginal ?? '/';
|
||||
return <App url={url} />;
|
||||
}
|
||||
|
||||
9
frontend/pages/feed/+data.ts
Normal file
9
frontend/pages/feed/+data.ts
Normal 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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
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(<Page url={url} />));
|
||||
pageHtml = await runWithScope(scope, () => {
|
||||
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
|
||||
seedFeedData(data);
|
||||
return renderPageToString(<Page url={url} />);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
|
||||
pageHtml = '';
|
||||
}
|
||||
}
|
||||
|
||||
const headTags = renderHeadTags(data?.head);
|
||||
|
||||
// 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="拾三一隅,自在交流" />
|
||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||
${dangerouslySkipEscape(headTags)}
|
||||
<script>${dangerouslySkipEscape(THEME_INIT_SCRIPT)}</script>
|
||||
<script>${dangerouslySkipEscape(ZOOM_BLOCK_SCRIPT)}</script>
|
||||
</head>
|
||||
|
||||
@@ -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<T>(
|
||||
path: string,
|
||||
opts?: { cookie?: string; timeoutMs?: number },
|
||||
): Promise<T | null> {
|
||||
export type ServerApiOptions = {
|
||||
/** 浏览器请求的 cookie 原样转发 */
|
||||
cookie?: string;
|
||||
/** 额外转发给 Go 的请求头(host / x-forwarded-* 等) */
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function serverApi<T>(path: string, opts?: ServerApiOptions): Promise<T | null> {
|
||||
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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})?)$/);
|
||||
|
||||
143
frontend/src/ssr/feedData.ts
Normal file
143
frontend/src/ssr/feedData.ts
Normal 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
363
frontend/src/ssr/head.ts
Normal 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(/ /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, '&')
|
||||
.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 | 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');
|
||||
}
|
||||
@@ -16,13 +16,13 @@ let als: AsyncLocalStorageLike | null = null;
|
||||
|
||||
if (import.meta.env.SSR) {
|
||||
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
|
||||
| (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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
frontend/src/ssr/serverDom.ts
Normal file
33
frontend/src/ssr/serverDom.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(`<div id="j13-comment-root">${withMentions}</div>`, 'text/html');
|
||||
const root = doc.getElementById('j13-comment-root') ?? doc.body;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 折叠缩进;跳过围栏代码块 */
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
15
frontend/src/utils/purify.ts
Normal file
15
frontend/src/utils/purify.ts
Normal 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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
288
handler/home_boot.go
Normal file
288
handler/home_boot.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user