From 3f78e45899a19562c02af0d69d045f086b91b588 Mon Sep 17 00:00:00 2001 From: zihluwang Date: Fri, 25 Sep 2026 14:48:49 +0800 Subject: [PATCH] feat(seo): add sitemap, canonical metadata and legacy redirects - Split the config into a createConfig(target) factory plus a thin .cn wrapper, so the two deployments differ in one place. The .cn build points canonical, hreflang, og:url, sitemap and robots at that origin, and omits social links that are not reachable from mainland China. - Generate sitemap.xml and robots.txt per target. robots.txt is written at build time because one static file cannot carry the right origin for both domains. - Emit per-page canonical, hreflang and Open Graph tags. og:title and friends are left to the theme, which already emits them and does not de-duplicate against site-level head entries. - Redirect the retired /projects/ paths on Vercel, before the CN redirect so those visitors get a single hop. --- rspress.cn.config.ts | 10 ++ rspress.config.ts | 243 ++++++++++++++++++++++++++++++++++++------- vercel.json | 20 ++++ 3 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 rspress.cn.config.ts diff --git a/rspress.cn.config.ts b/rspress.cn.config.ts new file mode 100644 index 0000000..4eea842 --- /dev/null +++ b/rspress.cn.config.ts @@ -0,0 +1,10 @@ +/** + * Build configuration for `onixbyte.cn`. + * + * Thin wrapper over the shared factory — see `rspress.config.ts` for what the + * `cn` target changes. Invoke it with `pnpm build:cn`, which passes this file + * via `rspress build --config`. + */ +import { createConfig } from "./rspress.config" + +export default createConfig("cn") diff --git a/rspress.config.ts b/rspress.config.ts index 0ba997a..54bb328 100644 --- a/rspress.config.ts +++ b/rspress.config.ts @@ -1,44 +1,215 @@ import * as path from "node:path" +import { writeFile } from "node:fs/promises" import { defineConfig } from "@rspress/core" +import type { RspressPlugin, SocialLink, UserConfig } from "@rspress/core" +import { pluginSitemap } from "@rspress/plugin-sitemap" +import { DOUYIN_ICON } from "./theme/lib/social-icons" -export default defineConfig({ - root: path.join(__dirname, "docs"), - title: "OnixByte", - icon: "/onixbyte-icon.svg", - logo: { - light: "/onixbyte-light-logo.svg", - dark: "/onixbyte-dark-logo.svg", - }, - themeConfig: { - socialLinks: [ - { - icon: "github", - mode: "link", - content: "https://git.onixbyte.com/onixbyte/homepage", - }, - { - icon: "x", - mode: "link", - content: "https://x.com/onixbyte", - }, - ], - }, - markdown: { - showLineNumbers: true, - }, - lang: "en-gb", - locales: [ +/** + * Which deployment this build targets. + * + * The two builds differ in more than colour: `onixbyte.cn` is hosted in + * mainland China, so its build points every absolute URL at the `.cn` origin + * and omits links to sites that are not reliably reachable from there. + * + * Build with `pnpm build:com` / `pnpm build:cn` — see `package.json`. Both + * write to `doc_build`, so run and deploy them one at a time. + */ +export type SiteTarget = "com" | "cn" + +const ORIGINS: Record = { + com: "https://onixbyte.com", + cn: "https://onixbyte.cn", +} + +/** Locale used for the unprefixed routes. */ +const DEFAULT_LANG = "en-gb" + +/** hreflang and Open Graph locale codes, keyed by Rspress lang. */ +const HREFLANG: Record = { "en-gb": "en-GB", "zh-hans": "zh-Hans" } +const OG_LOCALE: Record = { "en-gb": "en_GB", "zh-hans": "zh_CN" } + +/** + * Social links, split by whether they are reliably reachable from mainland + * China. The `.cn` build renders only the `domestic` set. + * + * To move a link between builds, move it between these two arrays — nothing + * else needs to change. + */ +const SOCIAL_LINKS: Record<"domestic" | "international", SocialLink[]> = { + domestic: [ { - lang: "en-gb", - label: "English (Great Britain)", - title: "OnixByte", - description: "OnixByte Official", + icon: "github", + mode: "link", + content: "https://github.com/onixbyte-opensource", }, { - lang: "zh-hans", - label: "简体中文", - title: "OnixByte", - description: "OnixByte 官方站点", + // Rspress has no preset Douyin icon, so the artwork is supplied inline — + // see `theme/lib/social-icons.ts` for the expected markup. + icon: { svg: DOUYIN_ICON }, + mode: "link", + content: "https://v.douyin.com/8PlxMzXors0/", }, ], -}) + international: [ + { + icon: "x", + mode: "link", + content: "https://x.com/onixbyte", + }, + ], +} + +/** + * Per-route sitemap priorities. + * + * `loc` is deliberately absent. The plugin builds a full entry (including `loc`) + * and then spreads `customMaps[routePath]` over it, so `loc` is genuinely + * optional — its own documentation shows entries without one. The published + * type nonetheless marks `loc` as required, so the assertion at the call site + * bridges that mismatch. `SitemapOverrides` is what keeps the priority values + * themselves checked. + */ +type CustomMapsOption = NonNullable[0]["customMaps"]> +type SitemapOverrides = Record> + +const SITEMAP_PRIORITIES = { + "/products/": { priority: "1.0" }, + "/zh-hans/products/": { priority: "1.0" }, + "/services/": { priority: "1.0" }, + "/zh-hans/services/": { priority: "1.0" }, + "/opensource-projects/": { priority: "0.8" }, + "/zh-hans/opensource-projects/": { priority: "0.8" }, +} satisfies SitemapOverrides + +const localePrefix = (lang: string) => (lang === DEFAULT_LANG ? "" : `/${lang}`) + +/** Strips the language prefix, always returning a leading slash. */ +function bareRoutePath(routePath: string, lang: string): string { + const prefix = localePrefix(lang) + const rest = prefix && routePath.startsWith(prefix) ? routePath.slice(prefix.length) : routePath + return rest.startsWith("/") ? rest : `/${rest}` +} + +/** + * Writes `robots.txt` at build time. + * + * It cannot live in `docs/public/`, because a single static file would have to + * carry the wrong origin for one of the two targets. + */ +function pluginRobots(origin: string): RspressPlugin { + return { + name: "plugin-onixbyte-robots", + async afterBuild(config: UserConfig) { + const outDir = path.resolve(process.cwd(), config.outDir ?? "doc_build") + const body = ["User-agent: *", "Allow: /", "", `Sitemap: ${origin}/sitemap.xml`, ""].join( + "\n" + ) + await writeFile(path.join(outDir, "robots.txt"), body, "utf8") + }, + } +} + +export function createConfig(target: SiteTarget): UserConfig { + const origin = ORIGINS[target] + const socialLinks = [ + ...SOCIAL_LINKS.domestic, + ...(target === "cn" ? [] : SOCIAL_LINKS.international), + ] + + return defineConfig({ + root: path.join(__dirname, "docs"), + title: "OnixByte", + description: + "OnixByte builds self-developed SaaS products and delivers custom software engineering services.", + icon: "/onixbyte-icon.svg", + logo: { + light: "/onixbyte-light-logo.svg", + dark: "/onixbyte-dark-logo.svg", + }, + /** + * Per-route `` additions. + * + * `og:title`, `og:description`, `description` and `og:type` are + * deliberately absent: the theme's `HeadTags` already emits them from the + * page title and site description, and site-level `head` entries do not + * take part in its de-duplication — adding them here would produce + * duplicate tags. + */ + head: [ + (route) => { + const canonical = `${origin}${route.routePath}` + const bare = bareRoutePath(route.routePath, route.lang) + const otherLang = route.lang === DEFAULT_LANG ? "zh-hans" : DEFAULT_LANG + const alternate = `${origin}${localePrefix(otherLang)}${bare === "/" ? "/" : bare}` + const defaultUrl = `${origin}${bare === "/" ? "/" : bare}` + + return [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join("") + }, + ], + themeConfig: { + // NOTE: `footer` is intentionally not set — the site footer is the + // `SiteFooter` component wired through the Layout `bottom` slot. Setting + // `footer.message` here would render the built-in `HomeFooter` on top of + // it on the home page only. + // + // `nav` and `sidebar` are also intentionally not set: supplying either + // switches off auto-generation and would make every `_nav.json` and + // `_meta.json` inert. + socialLinks, + lastUpdated: true, + }, + markdown: { + showLineNumbers: true, + }, + lang: DEFAULT_LANG, + locales: [ + { + lang: "en-gb", + label: "English (Great Britain)", + title: "OnixByte", + description: + "OnixByte builds self-developed SaaS products and delivers custom software engineering services.", + }, + { + lang: "zh-hans", + label: "简体中文", + title: "OnixByte", + description: "曜珀科技自主研发 SaaS 产品,并提供定制化软件开发服务。", + }, + ], + // Local full-text search. This is already the Rspress v2 default; stated + // explicitly so the intent is visible. The index is emitted per locale to + // `static/search_index...json` at build time. + search: { + mode: "local", + codeBlocks: true, + }, + plugins: [ + pluginSitemap({ + siteUrl: origin, + defaultChangeFreq: "weekly", + defaultPriority: "0.5", + // Keys are full route paths, so the non-default locale needs its own + // entries — `/products/` does not match `/zh-hans/products/`. + // Double assertion rather than a plain cast: the plugin's `Sitemap` type + // requires `loc`, so the two types do not overlap enough for a single + // assertion. See `SITEMAP_PRIORITIES` above for why omitting it is right. + customMaps: SITEMAP_PRIORITIES as unknown as CustomMapsOption, + }), + pluginRobots(origin), + ], + }) +} + +export default createConfig("com") diff --git a/vercel.json b/vercel.json index 449e3c7..a67e627 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,26 @@ { "outputDirectory": "doc_build", "redirects": [ + { + "source": "/zh-hans/projects", + "destination": "/zh-hans/opensource-projects/", + "permanent": true + }, + { + "source": "/zh-hans/projects/:path*", + "destination": "/zh-hans/opensource-projects/:path*", + "permanent": true + }, + { + "source": "/projects", + "destination": "/opensource-projects/", + "permanent": true + }, + { + "source": "/projects/:path*", + "destination": "/opensource-projects/:path*", + "permanent": true + }, { "source": "/:path(.*)", "has": [