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

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