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

@@ -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',

View File

@@ -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);
}
/** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */

View File

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

View File

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

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) {
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();
}
}

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 { 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;

View File

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

View File

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

View File

@@ -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 折叠缩进;跳过围栏代码块 */

View File

@@ -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',
);

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 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'));