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 diff --git a/website/plugins/sitemap-lastmod.ts b/website/plugins/sitemap-lastmod.ts new file mode 100644 index 000000000..cea7c280c --- /dev/null +++ b/website/plugins/sitemap-lastmod.ts @@ -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>/; +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; + /** Passed through to `@rspress/plugin-sitemap`. */ + readonly sitemap?: PluginSitemapOptions; +} + +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'); +} + +/** + * `@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'); + } + }, + }; +} diff --git a/website/rspress.config.ts b/website/rspress.config.ts index d836696fc..a39885b54 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -1,7 +1,6 @@ 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'; @@ -9,6 +8,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'); @@ -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 @@ -258,6 +258,6 @@ export default defineConfig({ exclude: ({ page }) => page.routePath.includes('/api/'), }, ]), - pluginSitemap(), + sitemapLastmod({ repoRoot }), ], }); diff --git a/website/scripts/check-built-links.mjs b/website/scripts/check-built-links.mjs index a72041fab..fb62b4bec 100644 --- a/website/scripts/check-built-links.mjs +++ b/website/scripts/check-built-links.mjs @@ -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>/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 (first: ${invalid[0]})`); + else if (stamps.length > 10 && new Set(stamps).size < 2) broken.set('sitemap:lastmod', `sitemap.xml — every 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; diff --git a/website/styles/index.css b/website/styles/index.css index dd12bbe2f..28356d199 100644 --- a/website/styles/index.css +++ b/website/styles/index.css @@ -6,7 +6,14 @@ */ :root:not(.rp-dark) { - --rp-c-brand: #0d8f80; + /* + * Brand text (active nav item, heading anchors, edit link, search matches) + * at 14 px needs 4.5:1. #0d8f80 measured 3.99:1 on #fff; same hue and + * saturation (hsl 173° 84%), lightness 31% → 27%: 4.83:1 on #fff, 4.55:1 + * on --rp-c-bg-soft (search panel). Restated as `brandColor` in + * rspress.config.ts (the theme-color meta); keep the two in step. + */ + --rp-c-brand: #0b8072; --rp-c-brand-light: #14b8a6; --rp-c-brand-lighter: #99f6e4; --rp-c-brand-dark: #0f766e; @@ -41,6 +48,104 @@ ); } +/* + * Contrast (#590). Ratios are WCAG 2.x, computed from the declared colours + * composited over the theme backgrounds (#fff / #121212 unless noted); text + * below 18.66 px bold needs 4.5:1. + * + * `--rp-c-text-3` is the theme's tertiary text token: prev/next labels + * (12 px), the home footer message and last-updated line (14 px), the + * external-link arrow, the code-block copy/wrap icons and fold button. It + * shipped at 1.85:1 light and 3.19:1 dark. Kept in the theme's alpha form so + * it still composites over --rp-c-bg-soft and --rp-c-bg-mute. + * + * Code blocks use Shiki's `css-variables` theme, so token colours are these + * variables (`styles/vars/shiki-vars.css`, declared through `:where()`). + * The light string token measured 3.04:1; the light comment (2.06:1) and + * parameter (2.30:1) tokens and the dark comment token (3.84:1) fail the same + * way and are corrected alongside it. Each pick keeps the original hue. + */ +:root:not(.rp-dark) { + /* #3c3c3c at 75 %: 5.18:1 on #fff, 5.01:1 on --rp-c-bg-soft, 4.86:1 on --rp-c-bg-mute. */ + --rp-c-text-3: #3c3c3cbf; + /* #31a94d 3.04:1 → 5.37:1 on #fff, 4.78:1 on a highlighted line (`{n}` / `[!code highlight]`, #ebf2fe). */ + --shiki-token-string: #227a37; + /* #b6b4b4 2.06:1 → 4.74:1 */ + --shiki-token-comment: #737373; + /* #f59403 2.30:1 → 4.66:1 */ + --shiki-token-parameter: #a76502; +} + +:root.rp-dark { + /* #ebebeb at 65 %: 7.08:1 on #121212, 5.08:1 on --rp-c-bg-mute (prev/next hover). */ + --rp-c-text-3: #ebebeba6; + /* #6a727b 3.84:1 → 5.02:1 */ + --shiki-token-comment: #7d8590; +} + +/* + * The theme fades the whole prev/next card to 70 % on hover + * (`PrevNextPage/index.css`), which takes the label above back down to + * 2.75:1 light / 2.81:1 dark; the `--rp-c-bg-mute` background change is + * enough hover feedback on its own. + */ +.rp-prev-next-page__item:hover { + opacity: 1; +} + +/* + * Keyboard access (#590). The appearance switch (theme/index.tsx) is now a + * tab stop; its focus ring is declared here so the two custom controls (this + * switch and the skip link) share one visible style instead of the UA default. + * Ring is --rp-c-brand on --rp-c-bg: 4.83:1 light, 10.06:1 dark. + */ +.rp-switch-appearance:focus-visible { + border-radius: 4px; + outline: 2px solid var(--rp-c-brand); + outline-offset: 2px; +} + +/* Wrapper the theme adds around the default social links; stays out of the nav's flex layout. */ +.ab-social-links { + display: contents; +} + +/* + * Skip link (theme/index.tsx `SkipLink`): the first focusable element on the + * page, clipped until it receives focus, then pinned over the sticky nav. + * Text is --rp-c-brand on --rp-c-bg: 4.83:1 light, 10.06:1 dark. + */ +.ab-skip-link { + position: fixed; + top: 0; + left: 0; + z-index: calc(var(--rp-z-index-nav) + 1); + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.ab-skip-link:focus { + width: auto; + height: auto; + overflow: visible; + clip-path: none; + padding: 0.5rem 1rem; + border-radius: 0 0 var(--rp-radius-small) 0; + background: var(--rp-c-bg); + color: var(--rp-c-brand); + font-weight: 600; + outline: 2px solid var(--rp-c-brand); + outline-offset: -2px; +} + +/* Zero-size focus target; a focus ring around an empty box would render as a stray dot. */ +.ab-skip-target { + outline: none; +} + /* * Desktop doc layout. Rspress sizes the doc column as * 100vw − sidebar − outline − 2 × content padding (`layout/DocLayout/index.css`), diff --git a/website/theme/index.tsx b/website/theme/index.tsx index e3cf0c65f..3b63e078d 100644 --- a/website/theme/index.tsx +++ b/website/theme/index.tsx @@ -1,18 +1,40 @@ import { MDXProvider } from '@mdx-js/react'; -import { Content, useI18n, usePageData, withBase } from '@rspress/core/runtime'; +import type { SocialLink } from '@rspress/core'; +import { Content, ThemeContext, useI18n, useLang, usePageData, withBase } from '@rspress/core/runtime'; import { EditLink as BasicEditLink, + HomeLayout as BasicHomeLayout, Layout as BasicLayout, LlmsCopyRow as BasicLlmsCopyRow, LlmsHint as BasicLlmsHint, LlmsOpenRow as BasicLlmsOpenRow, NotFoundLayout as BasicNotFoundLayout, + SocialLinks as BasicSocialLinks, + HomeBackground, + HomeFeature, + HomeFooter, + HomeHero, + type HomeLayoutProps, IconEdit, + IconMoon, + IconSun, Link, SvgWrapper, getCustomMDXComponent, } from '@rspress/core/theme-original'; -import { type JSX, useEffect } from 'react'; +import { type JSX, useContext, useEffect, useRef, useState } from 'react'; + +declare global { + interface ImportMeta { + /** + * Defined by Rspress for every bundle it compiles (`node/initRsbuild.js`, + * `source.define`): `true` only in the Markdown render that feeds + * `@rspress/plugin-llms`. The default theme branches on the same flag; the + * package ships no declaration for it. + */ + readonly env: { readonly SSG_MD: boolean }; + } +} /** * The default `HomeLayout` renders only the frontmatter hero and feature cards @@ -31,7 +53,197 @@ const HomeBody = () => ( ); -const Layout = () => } />; +/** Focus target of the skip link; placed as the first child of `
`. */ +const contentId = 'ab-content'; + +/** + * Zero-size, programmatically focusable marker. `DocLayout` opens `
` + * with the `beforeDocContent` slot and `HomeLayout` below does the same, so + * following the skip link moves focus (and the sequential-focus start) to the + * top of the main content on every page that has one. + */ +const SkipTarget = () =>
; + +/** + * Page types whose layout has no `
` and therefore no skip target. The + * 404 page is not among them: `NotFoundLayout` below wraps the default in one. + */ +const pagesWithoutContent = new Set(['custom', 'blank']); + +/** + * First focusable element on the page, rendered through the `top` slot ahead + * of the nav. A plain hash anchor, like the theme's own heading anchors and + * `Link` for hash-only hrefs: the browser moves focus to the marker and + * `useScrollAfterNav` scrolls it under the sticky nav. + */ +const SkipLink = () => { + const lang = useLang(); + const { page } = usePageData(); + if (pagesWithoutContent.has(page.pageType)) return null; + return ( + + {lang === 'zh' ? '跳至主要内容' : 'Skip to main content'} + + ); +}; + +/** + * The default `HomeLayout` has no `
`: hero, feature grid and footer are + * siblings under `#root`, so the home page exposes no main landmark + * (`DocLayout` gives doc pages one). `Layout` accepts a `HomeLayout` prop, so + * this restates the default's markup with the hero and features inside + * `
`; the background and the footer stay outside so the footer keeps its + * `contentinfo` role. The Markdown render keeps the original, so the + * `index.md` twin produced for `llms.txt` is unchanged. + * + * The hero title stays a `
` (`HomeHero` renders `.rp-home-hero__title` + * as a div, upstream too); giving the home page an `

` would mean forking + * that component, so it is left as is. + */ +const HomeLayout = ({ + beforeHero, + afterHero, + beforeHeroActions, + afterHeroActions, + beforeFeatures, + afterFeatures, +}: HomeLayoutProps) => { + if (import.meta.env.SSG_MD) { + return ( + + ); + } + return ( + <> + +
+ + {beforeHero} + + {afterHero} + {beforeFeatures} + + {afterFeatures} +
+ + + ); +}; + +const Layout = () => ( + } + beforeDocContent={} + afterFeatures={} + HomeLayout={HomeLayout} + /> +); + +/** + * The default `SwitchAppearance` is a click-only `
`: no role, no name, + * not in the tab order. `Nav`, `NavScreen` and `NavHamburger` import it from + * `@rspress/core/theme`, so this named export replaces it site-wide. It stays + * a `
` — given `role="button"`, a translated name, a tab stop and + * Enter/Space handling — instead of becoming a `