From 1e9da62729593777c8cb8b0087214f720e329f7b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:41:16 +0000 Subject: [PATCH 1/6] feat(website): sitemap lastmod from git history, omitted when unknown (#590) --- website/plugins/sitemap-lastmod.ts | 213 +++++++++++++++++++++++++++++ website/rspress.config.ts | 2 + 2 files changed, 215 insertions(+) create mode 100644 website/plugins/sitemap-lastmod.ts diff --git a/website/plugins/sitemap-lastmod.ts b/website/plugins/sitemap-lastmod.ts new file mode 100644 index 000000000..14e232fc0 --- /dev/null +++ b/website/plugins/sitemap-lastmod.ts @@ -0,0 +1,213 @@ +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'; + +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>/; +const LOC_PATTERN = /([^<]*)<\/loc>/; +const URL_PATTERN = /[\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; +} + +function decodeXmlText(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); +} + +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}`; + return LASTMOD_PATTERN.test(urlNode) + ? urlNode.replace(LASTMOD_PATTERN, lastmodNode) + : urlNode.replace('', `${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(); + + 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'); +} + +export function sitemapLastmod(options: SitemapLastmodOptions): RspressPlugin { + return { + name: 'agent-bundle/sitemap-lastmod', + async 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'); + } + }, + }; +} diff --git a/website/rspress.config.ts b/website/rspress.config.ts index d836696fc..8663064ab 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -9,6 +9,7 @@ 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'); @@ -259,5 +260,6 @@ export default defineConfig({ }, ]), pluginSitemap(), + sitemapLastmod({ repoRoot }), ], }); From 9bff7114423951ba67579203c6d1f67de0849bf2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:06:40 +0000 Subject: [PATCH 2/6] ci(docs): blobless full-history checkout so sitemap lastmod reflects git dates (#590) --- .github/workflows/docs.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a929abb48..ba8530f23 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 -- ` (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 From 88f0ceb191c2b5f468f21bb39341c685f4cb3a6b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:12:41 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(website):=20theme=20a11y=20=E2=80=94=20?= =?UTF-8?q?focusable=20appearance=20switch,=20named=20social=20link,=20ski?= =?UTF-8?q?p=20link,=20home=20
,=20contrast=20tokens=20(#590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theme-level fixes for the P3 "theme a11y / contrast" audit items, measured at 1440×900 on Rspress 2.0.21 defaults. Only website/theme/index.tsx and website/styles/index.css change. - SwitchAppearance: re-exported as div[role=button] with tabIndex, a translated aria-label, aria-pressed (set after mount to avoid a hydration mismatch) and Enter/Space handling. Kept a div because NavHamburger mounts it inside its own