feat(ssr): 扩展 SSR 覆盖至 /page/:slug 与 /user/:id

- feedPaths 白名单加入自定义单页与用户主页(兼容 .html 伪静态)
- head.ts 新增 buildPageHead(ogType=article, WebPage JSON-LD)
  与 buildUserHead(ogType=profile, Person JSON-LD)
- feedData 新增 loadPageData / loadUserData,并行取页面/用户
  数据 + 壳层,404 走 buildNotFoundHead + noindex
- feedSeed 按 useSessionResource 的 key 约定 seed:
  sitepage:${slug}、user:${id}、user:${id}:posts:1:${size},
  使 SitePageView / UserProfilePage 首帧直接命中快照渲染
- MainLayout postOutline 首帧派生扩展 /page/:slug 分支,
  文章目录随正文一同 SSR
- 验证:两页 SSR HTML 含完整内容与 head,0 hydration 错误,
  hydration 后仅剩 pageview 请求;404 正确;已有页面无回归
This commit is contained in:
2026-09-10 19:26:14 +08:00
parent 3c6d8cb891
commit 45cc864971
5 changed files with 236 additions and 29 deletions

View File

@@ -12,7 +12,7 @@ import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User, UserActivityStats, UserPublic } from '../api/types'; import type { Board, RecentComment, RecentUser, ForumStats, SitePage, TagCount, User, UserActivityStats, UserPublic } from '../api/types';
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings'; import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
import { renderPostContentHtml } from '../utils/postContent'; import { renderPostContentHtml } from '../utils/postContent';
import { getSessionSnapshot } from '../utils/sessionPageCache'; import { getSessionSnapshot } from '../utils/sessionPageCache';
@@ -80,28 +80,49 @@ export default function MainLayout() {
// 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行, // 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行,
// 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。 // 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。
const [postOutline, setPostOutline] = useState<PostOutlineState>(() => { const [postOutline, setPostOutline] = useState<PostOutlineState>(() => {
const m = loc.pathname.match(/^\/post\/(\d+)/); const openLinks = getCachedForumLimits()?.open_content_links_in_new_tab;
if (!m || /\/edit$/.test(loc.pathname)) return null; // 帖子详情:从 post 快照派生作者卡 + 目录
const id = Number(m[1]); const postM = loc.pathname.match(/^\/post\/(\d+)/);
if (!id) return null; if (postM && !/\/edit$/.test(loc.pathname)) {
const snap = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(id)); const id = Number(postM[1]);
if (!snap?.post) return null; if (id) {
const content = snap.post.content ?? ''; const snap = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(id));
const rendered = content.trim() if (snap?.post) {
? renderPostContentHtml(content, !!user, { const content = snap.post.content ?? '';
openLinksInNewTab: getCachedForumLimits()?.open_content_links_in_new_tab, const rendered = content.trim()
}) ? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
: ''; : '';
return { return {
headings: extractHeadingsFromHtml(rendered), headings: extractHeadingsFromHtml(rendered),
scrollRoot: null, scrollRoot: null,
title: '文章目录', title: '文章目录',
author: snap.post.user ?? null, author: snap.post.user ?? null,
publishedAt: snap.post.created_at, publishedAt: snap.post.created_at,
viewCount: snap.post.view_count, viewCount: snap.post.view_count,
authorProfile: snap.authorProfile ?? null, authorProfile: snap.authorProfile ?? null,
authorStats: snap.authorStats ?? null, authorStats: snap.authorStats ?? null,
}; };
}
}
}
// 自定义单页:从 sitepage 快照派生目录(无作者卡)
const pageM = loc.pathname.match(/^\/page\/([^/]+?)(?:\.html?)?$/);
if (pageM) {
const slug = pageM[1];
const page = getSessionSnapshot<SitePage>(`sitepage:${slug}`);
if (page) {
const content = page.content ?? '';
const rendered = content.trim()
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
: '';
return {
headings: extractHeadingsFromHtml(rendered),
scrollRoot: null,
title: '文章目录',
};
}
}
return null;
}); });
const [asideOpen, setAsideOpen] = useState(false); const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);

View File

@@ -10,11 +10,11 @@
import { redirect, render } from 'vike/abort'; import { redirect, render } from 'vike/abort';
import type { PageContextServer } from 'vike/types'; import type { PageContextServer } from 'vike/types';
import { serverApi } from '../api/serverClient'; import { serverApi } from '../api/serverClient';
import type { Comment, PostDetailResponse, UserActivityStats, UserPublic } from '../api/types'; import type { Comment, PostDetailResponse, PostItem, SitePage, UserActivityStats, UserPublic } from '../api/types';
import type { HomeBootPayload } from '../utils/homeBoot'; import type { HomeBootPayload } from '../utils/homeBoot';
import { buildPostDetailSnapshot } from '../utils/homeBoot'; import { buildPostDetailSnapshot } from '../utils/homeBoot';
import { matchFeedSsrPath } from './feedPaths'; import { matchFeedSsrPath } from './feedPaths';
import { buildFeedHead, buildNotFoundHead, buildPostHead } from './head'; import { buildFeedHead, buildNotFoundHead, buildPageHead, buildPostHead, buildUserHead } from './head';
import type { FeedData } from './feedSeed'; import type { FeedData } from './feedSeed';
export type { FeedData }; export type { FeedData };
@@ -41,6 +41,12 @@ export async function loadFeedData(pageContext: PageContextServer): Promise<Feed
if (match.kind === 'post') { if (match.kind === 'post') {
return loadPostData(match.id, pathname, { cookie, apiHeaders }); return loadPostData(match.id, pathname, { cookie, apiHeaders });
} }
if (match.kind === 'page') {
return loadPageData(match.id, pathname, { cookie, apiHeaders });
}
if (match.kind === 'user') {
return loadUserData(match.id, pathname, { cookie, apiHeaders });
}
return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders }); return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders });
} }
@@ -128,3 +134,84 @@ async function loadFeedListData(
return { kind, boot, head: buildFeedHead(boot, base) }; return { kind, boot, head: buildFeedHead(boot, base) };
} }
/** 自定义单页:page + 壳层并行取数 */
async function loadPageData(slug: string, pathname: string, api: ApiCtx): Promise<FeedData> {
if (!slug) {
throw render(404, { head: buildNotFoundHead(pathname) });
}
const [pageResp, boot] = await Promise.all([
serverApi<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`, api),
serverApi<HomeBootPayload>('/api/home-boot', api),
]);
const brand = boot?.branding ?? null;
const base = brand?.site_url?.trim() ?? '';
const page = pageResp?.page;
// 页面不存在或未发布:404 + noindex
if (!page || !page.published) {
throw render(404, {
head: buildNotFoundHead(pathname, brand, base),
boot: boot ?? undefined,
});
}
// slug 与页面组件 parsePermalinkSlug() 对齐(小写),保证 useSessionResource key 命中
return {
kind: 'page',
boot: boot ?? undefined,
sitePage: { slug: page.slug.toLowerCase(), page },
head: buildPageHead(page, brand, boot?.limits ?? null, base),
};
}
/** 用户主页:profile + 首屏帖子 + 壳层并行取数 */
async function loadUserData(id: string, pathname: string, api: ApiCtx): Promise<FeedData> {
const uid = /^\d+$/.test(id) ? Number(id) : NaN;
if (Number.isNaN(uid) || uid <= 0) {
throw render(404, { head: buildNotFoundHead(pathname) });
}
const [profileResp, boot] = await Promise.all([
serverApi<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${uid}`, api),
serverApi<HomeBootPayload>('/api/home-boot', api),
]);
const brand = boot?.branding ?? null;
const base = brand?.site_url?.trim() ?? '';
const profile = profileResp?.user;
// 用户不存在:404 + noindex
if (!profile) {
throw render(404, {
head: buildNotFoundHead(pathname, brand, base),
boot: boot ?? undefined,
});
}
// 首屏帖子列表(第 1 页,page_size 从 limits 取)
const limits = boot?.limits ?? null;
const pageSize = (limits?.page_size_default ?? 0) > 0 ? limits!.page_size_default : 20;
const postsResp = await serverApi<{ posts: PostItem[]; total: number }>(
`/api/posts?user_id=${uid}&page=1&size=${pageSize}&sort=latest`,
api,
);
const posts = Array.isArray(postsResp?.posts) ? postsResp.posts : [];
const postTotal = postsResp?.total ?? 0;
return {
kind: 'user',
boot: boot ?? undefined,
userProfile: {
id: uid,
profile,
stats: profileResp?.stats ?? null,
posts,
postTotal,
pageSize,
},
head: buildUserHead(profile, brand, limits, base),
};
}

View File

@@ -1,20 +1,25 @@
/** /**
* SSR feed 页路由匹配(Vike +route.ts 用)。 * SSR feed 页路由匹配(Vike +route.ts 用)。
* *
* 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id` * 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id`、
* 自定义单页 `/page/:slug`、用户主页 `/user/:id`
* (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。 * (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。
*/ */
export type FeedSsrMatch = export type FeedSsrMatch =
| { kind: 'home'; id: '' } | { kind: 'home'; id: '' }
| { kind: 'board'; id: string } | { kind: 'board'; id: string }
| { kind: 'post'; id: string }; | { kind: 'post'; id: string }
| { kind: 'page'; id: string }
| { kind: 'user'; id: string };
const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [ const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [
{ re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) }, { re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) },
{ re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) }, { re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) },
// 123 与 123.html(伪静态后缀由后台配置,两种形态都接住) // 123 与 123.html(伪静态后缀由后台配置,两种形态都接住)
{ re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) }, { re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) },
{ re: /^\/page\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'page', id: m[1] }) },
{ re: /^\/user\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'user', id: m[1] }) },
]; ];
export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null { export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null {

View File

@@ -13,11 +13,22 @@ import { setSessionSnapshot } from '../utils/sessionPageCache';
import type { PageHead } from './head'; import type { PageHead } from './head';
export type FeedData = { export type FeedData = {
kind: 'home' | 'board' | 'post' | 'notfound'; kind: 'home' | 'board' | 'post' | 'page' | 'user' | 'notfound';
/** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */ /** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
boot?: HomeBootPayload; boot?: HomeBootPayload;
/** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */ /** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
snapshot?: PostDetailSnapshot; snapshot?: PostDetailSnapshot;
/** 自定义单页内容(SitePageView useSessionResource key=sitepage:${slug} 首帧命中) */
sitePage?: { slug: string; page: import('../api/types').SitePage };
/** 用户主页资料 + 首屏帖子列表(UserProfilePage 首帧命中) */
userProfile?: {
id: number;
profile: import('../api/types').UserPublic;
stats: import('../api/types').UserActivityStats | null;
posts: import('../api/types').PostItem[];
postTotal: number;
pageSize: number;
};
/** <head> 元信息(服务端专用,onRenderHtml 消费) */ /** <head> 元信息(服务端专用,onRenderHtml 消费) */
head?: PageHead; head?: PageHead;
}; };
@@ -27,4 +38,12 @@ export function seedFeedData(data: FeedData | null | undefined): void {
if (!data) return; if (!data) return;
if (data.boot) seedHomeBoot(data.boot); if (data.boot) seedHomeBoot(data.boot);
if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot); if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot);
if (data.sitePage) {
setSessionSnapshot(`sitepage:${data.sitePage.slug}`, data.sitePage.page);
}
if (data.userProfile) {
const { id, profile, stats, posts, postTotal, pageSize } = data.userProfile;
setSessionSnapshot(`user:${id}`, { profile, stats });
setSessionSnapshot(`user:${id}:posts:1:${pageSize}`, { posts, total: postTotal });
}
} }

View File

@@ -4,7 +4,7 @@
* 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 <head>, * 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 <head>,
* 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。 * 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。
*/ */
import type { ForumLimitsPublic, PostItem, SiteBranding, User } from '../api/types'; import type { ForumLimitsPublic, PostItem, SiteBranding, SitePage, User, UserPublic } from '../api/types';
import type { HomeBootPayload } from '../utils/homeBoot'; import type { HomeBootPayload } from '../utils/homeBoot';
import { normalizePermalinkExt } from '../utils/permalink'; import { normalizePermalinkExt } from '../utils/permalink';
@@ -156,6 +156,10 @@ function feedUserPath(id: number, limits?: ForumLimitsPublic | null): string {
return `/user/${id}${suffixOf(limits)}`; return `/user/${id}${suffixOf(limits)}`;
} }
function feedPagePath(slug: string, limits?: ForumLimitsPublic | null): string {
return `/page/${slug}${suffixOf(limits)}`;
}
function toISO(time?: string): string { function toISO(time?: string): string {
if (!time) return ''; if (!time) return '';
const d = new Date(time); const d = new Date(time);
@@ -268,6 +272,77 @@ export function buildPostHead(
}; };
} }
/** 自定义单页 head(关于我们/版规等,ogType=article) */
export function buildPageHead(
page: SitePage,
brand: SiteBranding | null,
limits: ForumLimitsPublic | null,
base: string,
): PageHead {
const siteName = siteNameOf(brand);
const content = page.content ?? '';
const plain = stripHTMLForSearch(content);
const desc = truncateRunes(plain || page.title, DESC_MAX);
const canonical = absoluteURL(base, feedPagePath(page.slug, limits));
const defImage = defaultShareImageOf(brand);
const ogImage = absoluteURL(base, firstImageURL(content)) || defImage;
const jsonLd: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: page.title,
description: desc,
url: canonical,
datePublished: toISO(page.created_at),
dateModified: toISO(page.updated_at || page.created_at),
};
if (ogImage) jsonLd.image = [ogImage];
return {
title: pageTitle(page.title, siteName),
description: desc,
canonical: canonical || undefined,
ogType: 'article',
ogImage: ogImage || undefined,
siteName,
jsonLd: JSON.stringify(jsonLd),
};
}
/** 用户主页 head(ogType=profile,Person JSON-LD) */
export function buildUserHead(
profile: UserPublic,
brand: SiteBranding | null,
limits: ForumLimitsPublic | null,
base: string,
): PageHead {
const siteName = siteNameOf(brand);
const name = displayNameOf(profile);
const sig = (profile.signature ?? '').trim();
const desc = truncateRunes(sig || `${name} 的主页`, DESC_MAX);
const canonical = absoluteURL(base, feedUserPath(profile.id, limits));
const ogImage = absoluteURL(base, profile.avatar ?? '') || defaultShareImageOf(brand);
const jsonLd: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Person',
name,
description: desc,
url: canonical,
};
if (ogImage) jsonLd.image = ogImage;
return {
title: pageTitle(`${name} 的主页`, siteName),
description: desc,
canonical: canonical || undefined,
ogType: 'profile',
ogImage: ogImage || undefined,
siteName,
jsonLd: JSON.stringify(jsonLd),
};
}
/** 404 页 head(移植 Go notFoundPageMeta) */ /** 404 页 head(移植 Go notFoundPageMeta) */
export function buildNotFoundHead( export function buildNotFoundHead(
pathname: string, pathname: string,