diff --git a/AGENTS.md b/AGENTS.md index c2ad802..af91018 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ 2. 新增页面需同步 `lib/navigation.json` 对应分区侧栏;子分区按最长路径匹配,不能覆盖丢失。 3. 改动后运行 `pnpm check`,确保类型检查和完整构建通过。 4. Commit 不要带 AI 的 Co-Authors。Commit message 使用中文并说明原因。 -5. 不能删除或改名历史附件、中文 URL、旧锚点或独立 HTML 存档。变更时必须先解释兼容方案。 +5. 不能删除或改名历史附件、中文 URL 或独立 HTML 存档。变更时必须先解释兼容方案。 ## 内容与资源 @@ -16,7 +16,7 @@ - 图片、附件和独立 HTML 存档直接维护在根目录 `public/`,用根绝对路径引用;例如 `public/activity/2024/x.jpg` → `/activity/2024/x.jpg` - 年份为学年,例如 2024 指 2024.6–2025.6,与干部任期一致 - 内容页面使用无 `.html` 后缀的地址;独立 HTML 存档保持原地址 -- 标题锚点沿用旧规则;添加显式锚点使用 Fumadocs 支持的 `[#anchor]` +- 页面 URL 保持不变;标题锚点使用 Fumadocs 默认规则,无需兼容旧站锚点。添加显式锚点使用 `[#anchor]` ## React / MDX diff --git a/README.md b/README.md index 75dcc69..bbb292d 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ ## 本地开发 -需要 Node.js 24、pnpm 11。请完整克隆仓库(不要 shallow clone),以显示完整页面历史。 +需要 Node.js 24.14 或更新版本、pnpm 11。请完整克隆仓库(不要 shallow clone),以显示完整页面历史。 ```sh pnpm install --frozen-lockfile pnpm dev # http://localhost:3000 -pnpm check # 类型、静态构建与路由/资源/搜索定位检查 +pnpm check # 历史回归、类型、静态构建与路由/资源/搜索定位检查 pnpm preview # http://localhost:4173,预览 out/ 中的实际静态产物 ``` @@ -21,12 +21,12 @@ pnpm preview # http://localhost:4173,预览 out/ 中的实际静态产物 - `docs/**/*.mdx`:内容;原 Markdown 文件名/路由保留,Vue 片段已迁移为 React/MDX - `public/`:原有图片、PDF、视频、PDF.js 和独立历史页面,由 Next.js 直接读取,构建时原样输出到站点根目录 - `lib/navigation.json`:顶栏和按最长路径匹配的分区侧栏 -- `lib/git-history.mjs`:构建期读取页面历史 - `lib/people.js`、`lib/icons.js`:统一人员、头像、贡献者别名与社交图标 - `components/content.tsx`:聊天气泡、成员卡、团队页等 React 组件 -- `components/git-history-view.tsx`:页面完整 Git 历史、排序/展开、作者与共同作者 -- `source.config.ts`、`lib/remark-legacy-headings.mjs`:Fumadocs MDX 及历史标题锚点兼容 -- `scripts/`:仅保留发布产物检查;搜索由 Fumadocs 生成,站点地图由 Next.js 生成,本地静态预览使用 `serve` +- `components/mdx.tsx`:全局 MDX 组件与官方图片查看器接入;正文图片支持放大,带链接的图片保持跳转行为 +- `source.config.ts`:Fumadocs MDX 配置;通过 `remarkGitChangelog` 自动追加贡献者和页面历史,并由 Fumadocs 导出页面更新时间 +- `components/provider.tsx`:通过 Fumadocs 翻译接口组合官方简体中文语言包和页面历史中文预设 +- `scripts/`:发布产物检查和页面历史接入回归测试;搜索由 Fumadocs 生成,站点地图由 Next.js 生成,本地静态预览使用 `serve` - `out/`:GitHub Pages 静态输出,包含站点地图、CNAME 和原有独立 HTML 存档;文章使用无 `.html` 后缀的地址 ## 投稿与维护 @@ -35,6 +35,14 @@ pnpm preview # http://localhost:4173,预览 out/ 中的实际静态产物 图片和附件直接维护在根目录 `public/`。构建检查当前静态页面的站内链接、资源地址和锚点,不依赖旧站迁移快照。 +每个内容页面的 frontmatter 都需填写 `description`:用一两句话客观概括本页已有内容,不堆关键词,也不把待补充内容写成已有资料。摘要用于搜索、网页元信息和分享预览,不会自动显示在正文里。 + +`source.config.ts` 使用 Fumadocs 的 `includeProcessedMarkdown` 导出正文,`lib/llm-components.tsx` 提供本站成员卡片和聊天记录的文字形式。页面操作中的 Markdown、自动生成的 `/llms.txt` 索引及 `/llms-full.txt` 全文均使用这份处理后的内容;不再维护 `public/llms.txt`。独立 HTML 存档和 PDF 仍以正文中的原链接提供。 + +面包屑结构化数据复用可见导航的 Fumadocs 页面树,仅输出有实际页面链接的路径;不显示面包屑的团队页不额外添加。每页分享图由 Fumadocs 官方 OG 模板在构建时生成 `/og/**/*.png`,包含本页标题、摘要与站点标志,并同时接入 Open Graph 和 Twitter Card。中文字体及许可位于 `lib/og-assets/`,仅供生成图片使用。 + +页面历史直接使用 npm 发布的 `fumadocs-page-history`,无需本地补丁。本站在 `source.config.ts` 中通过 `getHistoryPaths` 显式关联迁移前的同路径 `.md` 文件,由包统一合并、去重、排序,不限制数量。迁移同时改写了 Vue/React 语法,部分文件未达到 Git 默认 50% 的重命名相似度阈值,显式路径用于补齐这部分记录。纯合并不重复展示,合并时产生的独立修改仍会保留。`scripts/page-history.test.mjs` 验证迁移前记录和建页记录不会丢失,纯合并与其他页面的模板历史不会混入。 + ## 许可 本站内容采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)许可。图片、文档等版权归原作者所有。 diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index 8f102ce..2c995c4 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -2,20 +2,19 @@ import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { DocsLayout } from '@fumadocs/base-ui/layouts/spacious'; import { HomeLayout } from '@fumadocs/base-ui/layouts/home'; -import { DocsPage, DocsBody, MarkdownCopyButton } from '@fumadocs/base-ui/layouts/spacious/page'; +import { DocsPage, DocsBody, MarkdownCopyButton, ViewOptionsPopover } from '@fumadocs/base-ui/layouts/spacious/page'; import { Heading } from '@fumadocs/base-ui/components/heading'; -import { source, getCanonicalPath, getMarkdownPath } from '@/lib/source'; +import { Lightbox } from '@fumadocs/base-ui/components/image-zoom'; +import { source, getCanonicalPath, getMarkdownPath, getOGImagePath } from '@/lib/source'; import { baseOptions } from '@/lib/layout.shared'; -import { getSidebar, getPagination, sectionTabs } from '@/lib/navigation'; +import { getLayoutTree, getPagination, sectionTabs } from '@/lib/navigation'; import { getMDXComponents } from '@/components/mdx'; import { TeamPageTitle } from '@/components/content'; -import { PageViewOptions } from '@/components/page-view-options'; -import { GitChangelog } from '@/components/git-changelog'; -import { shouldShowChangelog } from '@/lib/changelog'; import { CanonicalUrl } from '@/components/canonical-url'; import { Home } from '@/components/home'; import { SiteFooter } from '@/components/site-footer'; import { site } from '@/lib/site'; +import { BreadcrumbSchema } from '@/components/breadcrumb-schema'; type Props = { params: Promise<{ slug?: string[] }> }; export const dynamicParams = false; @@ -26,11 +25,13 @@ export async function generateMetadata({ params }: Props): Promise { const page = source.getPage(slug); if (!page) notFound(); const path = getCanonicalPath(page.path); + const images = [{ url: getOGImagePath(page.path), width: 1200, height: 630, alt: page.data.title }]; return { title: page.data.layout === 'home' ? { absolute: site.title } : page.data.title, description: page.data.description ?? site.description, alternates: { canonical: `${site.url}${path}` }, - openGraph: { title: page.data.title, description: page.data.description ?? site.description, url: `${site.url}${path}`, images: ['/WHUDAYS.png'] }, + openGraph: { type: 'website', locale: 'zh_CN', siteName: site.title, title: page.data.title, description: page.data.description ?? site.description, url: `${site.url}${path}`, images }, + twitter: { card: 'summary_large_image', title: page.data.title, description: page.data.description ?? site.description, images }, }; } export default async function Page({ params }: Props) { @@ -41,26 +42,26 @@ export default async function Page({ params }: Props) { if (data.layout === 'home') return <>; const MDX = data.body; const markdownUrl = getMarkdownPath(page.path); - const pageActions =
+ const pageActions = data.layout === 'page' && page.path.startsWith('about/hq/') ? null :
-
; - const content = <>{pageActions}, TeamPageTitle: (props) => <>{pageActions}, - })} />; + })} />; if (data.layout === 'page') return
{content}
; const outline = data.outline; - const toc = [...data.toc, ...(shouldShowChangelog(data) ? [{ title: '页面历史', url: '#页面历史', depth: 2 }] : [])].filter((entry) => entry.depth >= (Array.isArray(outline) ? outline[0] : 2) && entry.depth <= (Array.isArray(outline) ? outline[1] : typeof outline === 'number' ? outline : outline === 'deep' ? 6 : 2)); - return + const toc = data.toc.filter((entry) => entry.depth >= (Array.isArray(outline) ? outline[0] : 2) && entry.depth <= (Array.isArray(outline) ? outline[1] : typeof outline === 'number' ? outline : outline === 'deep' ? 6 : 2)); + return - + +
{content}
- 回到顶部 ↑
diff --git a/app/content.css b/app/content.css index 106f667..ff31865 100644 --- a/app/content.css +++ b/app/content.css @@ -1,10 +1,5 @@ /* Preserved page-specific styles from the VitePress content. */ -/* Migrated from docs/group/ani-key/activity/participants.md. */ -.ak-no-changelog{ display:none; } -.ak-no-changelog ~ h2, -.ak-no-changelog ~ div{ display:none !important; } - /* Migrated from docs/group/ani-key/index.md. */ /* 仅保留头像墙样式 */ .ak-hero__pics{ diff --git a/app/globals.css b/app/globals.css index 5226dfc..519d7ec 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,7 @@ @import '@fumadocs/base-ui/css/neutral.css'; @import '@fumadocs/base-ui/css/preset.css'; @import '@fumadocs/base-ui/css/generated/spacious.css'; +@import 'fumadocs-page-history/css/preset.css'; :root { --color-fd-primary: #3451b2; diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts new file mode 100644 index 0000000..1e0efe6 --- /dev/null +++ b/app/llms-full.txt/route.ts @@ -0,0 +1,7 @@ +import { llmExport } from '@/lib/llms'; + +export const dynamic = 'force-static'; + +export async function GET() { + return new Response(await llmExport.full(), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +} diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 0000000..7c974f7 --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,7 @@ +import { getLLMIndex } from '@/lib/llms'; + +export const dynamic = 'force-static'; + +export function GET() { + return new Response(getLLMIndex(), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +} diff --git a/app/markdown/[...slug]/route.ts b/app/markdown/[...slug]/route.ts index cda5176..09d40a2 100644 --- a/app/markdown/[...slug]/route.ts +++ b/app/markdown/[...slug]/route.ts @@ -1,4 +1,5 @@ import { source, getMarkdownPath } from '@/lib/source'; +import { llmExport } from '@/lib/llms'; export const dynamic = 'force-static'; export const dynamicParams = false; @@ -14,7 +15,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu const requestedPath = `/markdown/${slug.join('/')}`; const page = source.getPages().find(page => getMarkdownPath(page.path) === requestedPath); if (!page) return new Response('Not found', { status: 404 }); - return new Response(await page.data.getText('raw'), { + return new Response(await llmExport.page(page), { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } diff --git a/app/og/[...slug]/route.tsx b/app/og/[...slug]/route.tsx new file mode 100644 index 0000000..43f4827 --- /dev/null +++ b/app/og/[...slug]/route.tsx @@ -0,0 +1,16 @@ +import { source, getOGImagePath } from '@/lib/source'; +import { pageOGImage } from '@/lib/og'; + +export const dynamic = 'force-static'; +export const dynamicParams = false; + +export function generateStaticParams() { + return source.getPages().map(page => ({ slug: getOGImagePath(page.path).slice('/og/'.length).split('/') })); +} + +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params; + const page = source.getPages().find(page => getOGImagePath(page.path) === `/og/${slug.join('/')}`); + if (!page) return new Response('Not found', { status: 404 }); + return pageOGImage(page.data.title, page.data.description ?? ''); +} diff --git a/app/search-index.json/route.ts b/app/search-index.json/route.ts index 7ea734d..99a7e52 100644 --- a/app/search-index.json/route.ts +++ b/app/search-index.json/route.ts @@ -15,6 +15,7 @@ export async function GET() { url: page.url, title: page.data.title, description: page.data.description, + tag: page.slugs[0], structuredData: { ...page.data.structuredData, contents: [...page.data.structuredData.contents, ...text], diff --git a/app/sitemap.ts b/app/sitemap.ts index 933d03a..31c055c 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,16 +1,12 @@ import type { MetadataRoute } from 'next'; import { source, getCanonicalPath } from '@/lib/source'; -import { readPageHistory } from '@/lib/git-history.mjs'; import { site } from '@/lib/site'; export const dynamic = 'force-static'; export default function sitemap(): MetadataRoute.Sitemap { - return source.getPages().map(page => { - const lastModified = readPageHistory(`docs/${page.path}`)[0]?.date; - return { - url: `${site.url}${getCanonicalPath(page.path)}`, - ...(lastModified ? { lastModified } : {}), - }; - }); + return source.getPages().map(page => ({ + url: `${site.url}${getCanonicalPath(page.path)}`, + ...(page.data.lastModified ? { lastModified: page.data.lastModified } : {}), + })); } diff --git a/components/breadcrumb-schema.tsx b/components/breadcrumb-schema.tsx new file mode 100644 index 0000000..f1657bb --- /dev/null +++ b/components/breadcrumb-schema.tsx @@ -0,0 +1,20 @@ +import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb'; +import { getLayoutTree } from '@/lib/navigation'; +import { site } from '@/lib/site'; + +/** Use the same tree and options as the visible Fumadocs breadcrumbs. */ +export function BreadcrumbSchema({ url, section }: { url: string; section: string }) { + const seen = new Set(); + const items = getBreadcrumbItems(url, getLayoutTree(url), { + includeRoot: { url: `/${section}/` }, includePage: true, + }).flatMap(item => { + if (!item.url || typeof item.name !== 'string') return []; + const canonical = new URL(`${item.url.replace(/\/$/, '')}/`, site.url).href; + if (seen.has(canonical)) return []; + seen.add(canonical); + return [{ '@type': 'ListItem', position: seen.size, name: item.name, item: canonical }]; + }); + if (items.length < 2) return null; + const schema = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items }; + return