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.
This commit is contained in:
3 files changed
+216
-15
No files matched your search
@@ -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")
|
||||||
+186
-15
@@ -1,44 +1,215 @@
|
|||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
|
import { writeFile } from "node:fs/promises"
|
||||||
import { defineConfig } from "@rspress/core"
|
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"),
|
* Which deployment this build targets.
|
||||||
title: "OnixByte",
|
*
|
||||||
icon: "/onixbyte-icon.svg",
|
* The two builds differ in more than colour: `onixbyte.cn` is hosted in
|
||||||
logo: {
|
* mainland China, so its build points every absolute URL at the `.cn` origin
|
||||||
light: "/onixbyte-light-logo.svg",
|
* and omits links to sites that are not reliably reachable from there.
|
||||||
dark: "/onixbyte-dark-logo.svg",
|
*
|
||||||
},
|
* Build with `pnpm build:com` / `pnpm build:cn` — see `package.json`. Both
|
||||||
themeConfig: {
|
* write to `doc_build`, so run and deploy them one at a time.
|
||||||
socialLinks: [
|
*/
|
||||||
|
export type SiteTarget = "com" | "cn"
|
||||||
|
|
||||||
|
const ORIGINS: Record<SiteTarget, string> = {
|
||||||
|
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<string, string> = { "en-gb": "en-GB", "zh-hans": "zh-Hans" }
|
||||||
|
const OG_LOCALE: Record<string, string> = { "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: [
|
||||||
{
|
{
|
||||||
icon: "github",
|
icon: "github",
|
||||||
mode: "link",
|
mode: "link",
|
||||||
content: "https://git.onixbyte.com/onixbyte/homepage",
|
content: "https://github.com/onixbyte-opensource",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 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",
|
icon: "x",
|
||||||
mode: "link",
|
mode: "link",
|
||||||
content: "https://x.com/onixbyte",
|
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<Parameters<typeof pluginSitemap>[0]["customMaps"]>
|
||||||
|
type SitemapOverrides = Record<string, Omit<CustomMapsOption[string], "loc">>
|
||||||
|
|
||||||
|
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 `<head>` 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 [
|
||||||
|
`<link rel="canonical" href="${canonical}">`,
|
||||||
|
`<link rel="alternate" hreflang="${HREFLANG[route.lang]}" href="${canonical}">`,
|
||||||
|
`<link rel="alternate" hreflang="${HREFLANG[otherLang]}" href="${alternate}">`,
|
||||||
|
`<link rel="alternate" hreflang="x-default" href="${defaultUrl}">`,
|
||||||
|
`<meta property="og:url" content="${canonical}">`,
|
||||||
|
`<meta property="og:site_name" content="OnixByte">`,
|
||||||
|
`<meta property="og:locale" content="${OG_LOCALE[route.lang]}">`,
|
||||||
|
`<meta property="og:image" content="${origin}/og-image.png">`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image">`,
|
||||||
|
].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: {
|
markdown: {
|
||||||
showLineNumbers: true,
|
showLineNumbers: true,
|
||||||
},
|
},
|
||||||
lang: "en-gb",
|
lang: DEFAULT_LANG,
|
||||||
locales: [
|
locales: [
|
||||||
{
|
{
|
||||||
lang: "en-gb",
|
lang: "en-gb",
|
||||||
label: "English (Great Britain)",
|
label: "English (Great Britain)",
|
||||||
title: "OnixByte",
|
title: "OnixByte",
|
||||||
description: "OnixByte Official",
|
description:
|
||||||
|
"OnixByte builds self-developed SaaS products and delivers custom software engineering services.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
lang: "zh-hans",
|
lang: "zh-hans",
|
||||||
label: "简体中文",
|
label: "简体中文",
|
||||||
title: "OnixByte",
|
title: "OnixByte",
|
||||||
description: "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.<lang>.<hash>.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")
|
||||||
+20
@@ -1,6 +1,26 @@
|
|||||||
{
|
{
|
||||||
"outputDirectory": "doc_build",
|
"outputDirectory": "doc_build",
|
||||||
"redirects": [
|
"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(.*)",
|
"source": "/:path(.*)",
|
||||||
"has": [
|
"has": [
|
||||||
|
|||||||
Reference in new issue
Block a user