Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
2. 新增页面需同步 `lib/navigation.json` 对应分区侧栏;子分区按最长路径匹配,不能覆盖丢失。
3. 改动后运行 `pnpm check`,确保类型检查和完整构建通过。
4. Commit 不要带 AI 的 Co-Authors。Commit message 使用中文并说明原因。
5. 不能删除或改名历史附件、中文 URL、旧锚点或独立 HTML 存档。变更时必须先解释兼容方案。
5. 不能删除或改名历史附件、中文 URL 或独立 HTML 存档。变更时必须先解释兼容方案。

## 内容与资源

- 内容在 `docs/<区域>/**/*.mdx`;区域为 activity、about、department、group、message-box、maintainer
- 图片、附件和独立 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

Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/ 中的实际静态产物
```

Expand All @@ -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` 后缀的地址

## 投稿与维护
Expand All @@ -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)许可。图片、文档等版权归原作者所有。
31 changes: 16 additions & 15 deletions app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,11 +25,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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) {
Expand All @@ -41,26 +42,26 @@ export default async function Page({ params }: Props) {
if (data.layout === 'home') return <><CanonicalUrl path="/" /><Home hero={data.hero} features={data.features} /></>;
const MDX = data.body;
const markdownUrl = getMarkdownPath(page.path);
const pageActions = <div className="not-prose my-6 flex flex-wrap items-center gap-2" aria-label="页面操作">
const pageActions = data.layout === 'page' && page.path.startsWith('about/hq/') ? null : <div className="not-prose my-6 flex flex-wrap items-center gap-2" aria-label="页面操作">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<PageViewOptions markdownUrl={markdownUrl}
<ViewOptionsPopover markdownUrl={markdownUrl}
githubUrl={`https://github.com/WHUDAYS/WHUDAYS.github.io/blob/main/docs/${page.path.split('/').map(encodeURIComponent).join('/')}`}
pageUrl={`${site.url}${getCanonicalPath(page.path)}`} />
</div>;
const content = <MDX components={getMDXComponents({
const content = <Lightbox><MDX components={getMDXComponents({
h1: (props) => <><Heading as="h1" {...props} />{pageActions}</>,
TeamPageTitle: (props) => <><TeamPageTitle {...props} />{pageActions}</>,
})} />;
})} /></Lightbox>;
if (data.layout === 'page') return <HomeLayout {...baseOptions()}><CanonicalUrl path={getCanonicalPath(page.path)} />
<main id="main-content" className="team-document"><div data-search-content>{content}<span data-search-content-end /></div></main><SiteFooter />
</HomeLayout>;
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 <DocsLayout {...baseOptions()} links={[]} tree={getSidebar(page.url)} tabs={sectionTabs}>
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 <DocsLayout {...baseOptions()} links={[]} tree={getLayoutTree(page.url)} tabs={sectionTabs}>
<CanonicalUrl path={getCanonicalPath(page.path)} />
<DocsPage id="main-content" toc={toc} footer={{ items: getPagination(page.url) }} breadcrumb={{ enabled: false }} tableOfContent={{ enabled: outline !== false, style: 'clerk' }} tableOfContentPopover={{ enabled: outline !== false, style: 'clerk' }}>
<BreadcrumbSchema url={page.url} section={page.slugs[0]} />
<DocsPage id="main-content" toc={toc} footer={{ items: getPagination(page.url) }} breadcrumb={{ includeRoot: { url: `/${page.slugs[0]}/` }, includePage: true, 'aria-label': '当前位置' }} tableOfContent={{ enabled: outline !== false, style: 'clerk' }} tableOfContentPopover={{ enabled: outline !== false, style: 'clerk' }}>
<DocsBody><div data-search-content>{content}<span data-search-content-end /></div></DocsBody>
<GitChangelog sourcePath={`docs/${page.path}`} frontmatter={data} />
<a className="back-to-top" href="#main-content">回到顶部 ↑</a>
<SiteFooter />
</DocsPage>
Expand Down
5 changes: 0 additions & 5 deletions app/content.css
Original file line number Diff line number Diff line change
@@ -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{
Expand Down
1 change: 1 addition & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions app/llms-full.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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' } });
}
7 changes: 7 additions & 0 deletions app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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' } });
}
3 changes: 2 additions & 1 deletion app/markdown/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { source, getMarkdownPath } from '@/lib/source';
import { llmExport } from '@/lib/llms';

export const dynamic = 'force-static';
export const dynamicParams = false;
Expand All @@ -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' },
});
}
16 changes: 16 additions & 0 deletions app/og/[...slug]/route.tsx
Original file line number Diff line number Diff line change
@@ -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 ?? '');
}
1 change: 1 addition & 0 deletions app/search-index.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
12 changes: 4 additions & 8 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
}));
}
20 changes: 20 additions & 0 deletions components/breadcrumb-schema.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema).replace(/</g, '\\u003c') }} />;
}
Loading
Loading