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
7 changes: 7 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ jobs:
# can be repointed at unreviewed code); the trailing comment names the
# release Dependabot keeps in step.
steps:
# Full history, blobless: `sitemap.xml` `lastmod` comes from
# `git log -1 -- <source>` (website/plugins/sitemap-lastmod.ts), which
# needs every commit and tree but no old file contents. On a shallow
# clone the plugin omits `lastmod` rather than dating every page today.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
filter: blob:none
- uses: ./.github/actions/setup-workspace
with:
node-version: 22.19.0
Expand Down
228 changes: 228 additions & 0 deletions website/plugins/sitemap-lastmod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import type { RspressPlugin, UserConfig } from '@rspress/core';
import { type PluginSitemapOptions, pluginSitemap } from '@rspress/plugin-sitemap';

const API_SOURCE = 'packages/agent-bundle/src';
const CAPABILITIES_SOURCE = 'packages/agent-bundle/src/adapters/capabilities';
const DIAGNOSTICS_SOURCE = 'docs/diagnostics.md';
const LASTMOD_PATTERN = /<lastmod>[^<]*<\/lastmod>/;
const LOC_PATTERN = /<loc>([^<]*)<\/loc>/;
const URL_PATTERN = /<url>[\s\S]*?<\/url>/g;

export interface SitemapLastmodRewriteOptions {
readonly base: string;
readonly siteOrigin: string;
readonly lastmodForRoute: (routePath: string) => string | undefined;
}

export interface SitemapLastmodOptions {
/** Absolute repository root used as the Git working directory. */
readonly repoRoot: string;
/** Passed through to `@rspress/plugin-sitemap`. */
readonly sitemap?: PluginSitemapOptions;
}

function decodeXmlText(value: string): string {
return value
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'");
}

function routeFromLoc(loc: string, siteOrigin: string, base: string): string | undefined {
let location: URL;
let siteBase: URL;
try {
location = new URL(decodeXmlText(loc));
siteBase = new URL(base, siteOrigin);
} catch {
return undefined;
}

if (location.origin !== siteBase.origin) {
return undefined;
}

const basePath = siteBase.pathname.endsWith('/') ? siteBase.pathname : `${siteBase.pathname}/`;
const baseWithoutSlash = basePath.slice(0, -1);
let routePath: string;
if (location.pathname === baseWithoutSlash || location.pathname === basePath) {
routePath = '/';
} else if (location.pathname.startsWith(basePath)) {
routePath = `/${location.pathname.slice(basePath.length)}`;
} else {
return undefined;
}

try {
return decodeURIComponent(routePath);
} catch {
return undefined;
}
}

/**
* Replace only sitemap `lastmod` nodes, leaving every other byte untouched.
*
* The callback keeps this transformation independent of Git and filesystem
* access so it can be exercised directly over emitted sitemap XML.
*/
export function rewriteSitemapLastmod(
xml: string,
options: SitemapLastmodRewriteOptions,
): string {
return xml.replace(URL_PATTERN, urlNode => {
const loc = LOC_PATTERN.exec(urlNode)?.[1];
const routePath =
loc === undefined ? undefined : routeFromLoc(loc, options.siteOrigin, options.base);
const lastmod =
routePath === undefined ? undefined : options.lastmodForRoute(routePath);

if (lastmod === undefined) {
return urlNode.replace(LASTMOD_PATTERN, '');
}

const lastmodNode = `<lastmod>${lastmod}</lastmod>`;
return LASTMOD_PATTERN.test(urlNode)
? urlNode.replace(LASTMOD_PATTERN, lastmodNode)
: urlNode.replace('</loc>', `</loc>${lastmodNode}`);
});
}

function sourcePathForRoute(
routePath: string,
repoRoot: string,
): string | undefined {
const normalized = routePath.replace(/^\/+|\/+$/g, '');
const isChinese = normalized === 'zh' || normalized.startsWith('zh/');
const locale = isChinese ? 'zh' : 'en';
const relativeRoute = isChinese
? normalized.slice('zh'.length).replace(/^\/+/, '')
: normalized;

if (relativeRoute === 'api' || relativeRoute.startsWith('api/')) {
return API_SOURCE;
}
if (
relativeRoute === 'reference/hosts' ||
relativeRoute === 'reference/events' ||
relativeRoute === 'reference/notices'
) {
return CAPABILITIES_SOURCE;
}
if (relativeRoute === 'reference/diagnostics') {
return DIAGNOSTICS_SOURCE;
}

const routeSegments = relativeRoute === '' ? [] : relativeRoute.split('/');
if (routeSegments.some(segment => segment === '' || segment === '.' || segment === '..')) {
return undefined;
}

const sourceStem = path.posix.join('website', 'docs', locale, ...routeSegments);
const candidates = [
`${sourceStem}.mdx`,
`${sourceStem}.md`,
path.posix.join(sourceStem, 'index.mdx'),
path.posix.join(sourceStem, 'index.md'),
];
return candidates.find(candidate => existsSync(path.join(repoRoot, ...candidate.split('/'))));
}

function isShallowRepository(repoRoot: string): boolean | undefined {
try {
return (
execFileSync('git', ['rev-parse', '--is-shallow-repository'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim() === 'true'
);
} catch {
return undefined;
}
}

export function createGitLastmodResolver(
repoRoot: string,
): (routePath: string) => string | undefined {
const shallow = isShallowRepository(repoRoot);
const cache = new Map<string, string | undefined>();

if (shallow !== false) {
return () => undefined;
}

return routePath => {
const sourcePath = sourcePathForRoute(routePath, repoRoot);
if (sourcePath === undefined) {
return undefined;
}
if (cache.has(sourcePath)) {
return cache.get(sourcePath);
}

let lastmod: string | undefined;
try {
const value = execFileSync(
'git',
['log', '-1', '--format=%cI', '--', sourcePath],
{ cwd: repoRoot, encoding: 'utf8' },
).trim();
if (value !== '' && !Number.isNaN(Date.parse(value))) {
lastmod = value;
}
} catch {
lastmod = undefined;
}
cache.set(sourcePath, lastmod);
return lastmod;
};
}

function sitemapPath(config: UserConfig): string {
const distPath =
typeof config.builderConfig?.output?.distPath === 'string'
? config.builderConfig.output.distPath
: config.builderConfig?.output?.distPath?.root;
const outDir = config.outDir || distPath || 'doc_build';
return path.resolve(outDir, 'sitemap.xml');
}

/**
* `@rspress/plugin-sitemap` with `lastmod` taken from git history.
*
* Rspress runs every plugin's `afterBuild` in parallel
* (`PluginDriver._runParallelAsyncHook`), so a second plugin cannot rely on
* running after the sitemap has been written. This wraps the sitemap plugin
* instead: its own hooks are kept as they are and the rewrite is chained onto
* its `afterBuild`, so `sitemap.xml` exists before it is read.
*/
export function sitemapLastmod(options: SitemapLastmodOptions): RspressPlugin {
const sitemap = pluginSitemap(options.sitemap);
return {
...sitemap,
name: 'agent-bundle/sitemap-lastmod',
async afterBuild(config, isProd) {
await sitemap.afterBuild?.(config, isProd);
if (!isProd || !config.siteOrigin) {
return;
}

const outputPath = sitemapPath(config);
const xml = await readFile(outputPath, 'utf8');
const rewritten = rewriteSitemapLastmod(xml, {
base: config.base ?? '/',
siteOrigin: config.siteOrigin,
lastmodForRoute: createGitLastmodResolver(options.repoRoot),
});
if (rewritten !== xml) {
await writeFile(outputPath, rewritten, 'utf8');
}
},
};
}
8 changes: 4 additions & 4 deletions website/rspress.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import path from 'node:path';
import { defineConfig } from '@rspress/core';
import { pluginLlms } from '@rspress/plugin-llms';
import { pluginSitemap } from '@rspress/plugin-sitemap';
import { pluginTwoslash } from '@rspress/plugin-twoslash';
import { pluginTypeDoc } from '@rspress/plugin-typedoc';
import { transformerNotationHighlight } from '@shikijs/transformers';
import ts from 'typescript';
import { generatedReference } from './plugins/generated-reference.ts';
import { cleanGeneratedApiMarkdown, mirrorApiLocale } from './plugins/mirror-api-locale.ts';
import { rehypeTableCellBreaks } from './plugins/rehype-table-cell-breaks.ts';
import { sitemapLastmod } from './plugins/sitemap-lastmod.ts';

const websiteDir = import.meta.dirname;
const docsDir = path.join(websiteDir, 'docs');
Expand Down Expand Up @@ -95,8 +95,8 @@ const siteDescription =
'Compile skills, hooks, MCP servers, and scripts from one typed config into installable Claude Code, Codex, and Cursor artifacts.';
const siteDescriptionZh =
'用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为可直接安装到 Claude Code、Codex 与 Cursor 的产物。';
/** `--rp-c-brand` in `styles/index.css`. */
const brandColor = '#0d8f80';
/** `--rp-c-brand` in `styles/index.css` (light theme; 4.83:1 on white). */
const brandColor = '#0b8072';

/**
* `llms.txt` and `llms-full.txt` are emitted as build assets rather than
Expand Down Expand Up @@ -258,6 +258,6 @@ export default defineConfig({
exclude: ({ page }) => page.routePath.includes('/api/'),
},
]),
pluginSitemap(),
sitemapLastmod({ repoRoot }),
],
});
11 changes: 11 additions & 0 deletions website/scripts/check-built-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ const main = () => {
if (!idsOf(target).has(fragment)) report(`no id="${fragment}" in ${path.relative(options.dir, target)}`);
}
}
// Sitemap `lastmod` (plugins/sitemap-lastmod.ts) comes from git history, or is
// omitted on a shallow clone. Whatever is present must parse, must not be in
// the future, and — the regression this guards — must not all be the same
// instant, which is what the source-mtime default produced in CI.
const sitemap = files.find(file => file.endsWith('sitemap.xml'));
if (sitemap) {
const stamps = [...fs.readFileSync(sitemap, 'utf8').matchAll(/<lastmod>([^<]*)<\/lastmod>/g)].map(([, value]) => value);
const invalid = stamps.filter(value => Number.isNaN(Date.parse(value)) || Date.parse(value) > Date.now() + 60_000);
if (invalid.length > 0) broken.set('sitemap:lastmod', `sitemap.xml — ${invalid.length} unparseable or future <lastmod> (first: ${invalid[0]})`);
else if (stamps.length > 10 && new Set(stamps).size < 2) broken.set('sitemap:lastmod', `sitemap.xml — every <lastmod> is ${stamps[0]}; dates are not coming from git history`);
}
for (const line of [...broken.values()].sort()) console.log(line);
console.log(`${broken.size} broken links / ${anchors} anchors checked (${links} internal links across ${files.length} files under ${options.dir})`);
if (broken.size > 0) process.exitCode = 1;
Expand Down
Loading
Loading