From b9ebd9dc5b997ef9923a3bbac7b4e888fff89441 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 02:31:15 +0000 Subject: [PATCH] docs: redirect the docs root to the English landing Give every client a permanent redirect from / to /en, and describe the English and Chinese home pages with the install, S3, Kubernetes, erasure coding, and operations topics they cover. Co-authored-by: hector --- content/en/index.mdx | 4 +- content/zh/index.mdx | 4 +- press.config.tsx | 38 +++++++++++- public/_redirects | 4 ++ public/index.html | 1 + public/language-redirect.js | 4 ++ scripts/add-language-redirects.mjs | 29 +++++++++- scripts/language-redirects.mjs | 14 ++++- scripts/worker-redirects.test.mjs | 63 +++++++++++++++++++- src/components/docs-og.tsx | 92 ++++++++++++++++++++++++++++++ worker.mjs | 12 ++-- 11 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 src/components/docs-og.tsx diff --git a/content/en/index.mdx b/content/en/index.mdx index 9b9c775a1..8ea076e5b 100644 --- a/content/en/index.mdx +++ b/content/en/index.mdx @@ -1,6 +1,6 @@ --- -title: "RustFS Documentation" -description: "Install RustFS on Linux, containers, Kubernetes, macOS, or Windows." +title: "RustFS Documentation | S3-Compatible Object Storage" +description: "Install and operate RustFS, an S3-compatible distributed object storage system, including the S3 API, Kubernetes, erasure coding, MinIO-compatible migration, and cluster operations." ---
diff --git a/content/zh/index.mdx b/content/zh/index.mdx index 5b97b8b0b..ef29001dd 100644 --- a/content/zh/index.mdx +++ b/content/zh/index.mdx @@ -1,6 +1,6 @@ --- -title: "RustFS 文档" -description: "了解如何安装、配置、管理和维护 RustFS。" +title: "RustFS 文档 | 兼容 S3 的对象存储" +description: "了解如何安装和运维兼容 S3 的分布式对象存储 RustFS,包括 S3 API、Kubernetes、纠删码、兼容 MinIO 的迁移和集群运维。" ---
diff --git a/press.config.tsx b/press.config.tsx index 09ed77957..8bb27ab9f 100644 --- a/press.config.tsx +++ b/press.config.tsx @@ -13,6 +13,7 @@ import { defineI18n } from "fumadocs-core/i18n"; import defaultMdxComponents, { createRelativeLink } from "fumadocs-ui/mdx"; import { docs } from "./.source/server"; import { BrandLogo } from "./src/components/brand-logo"; +import { docsOgCard } from "./src/components/docs-og"; import { Mermaid } from "./src/components/mermaid"; import { Tab, Tabs } from "./src/components/tabs"; import { ArrowRight, ArrowUpRight } from "lucide-react"; @@ -245,8 +246,41 @@ gtag('config', 'G-TWW7WMTWL9');`, .plugins( localSearchPlugin(languages), llmsPlugin(), - takumiPlugin(), - sitemapPlugin(), + takumiPlugin({ + generate(page) { + const compact = page.url === "/en" || page.url === "/zh"; + return { + node: docsOgCard({ + title: page.data.title, + description: page.data.description, + site: this.siteConfig.name, + compact, + }), + }; + }, + }), + sitemapPlugin({ + async getEntry(page) { + // The thin language chooser is not a content page. Never list it. + if (page.url === "/" || page.url === "") return; + + const alternates = await this.getPageAlternates(page); + const defaultHref = + alternates.find((item) => item.hreflang === "en" || item.locale === "en")?.href ?? + this.absoluteUrl("/en"); + const withDefault = + alternates.length > 0 && !alternates.some((item) => item.hreflang === "x-default") + ? [...alternates, { hreflang: "x-default", href: defaultHref }] + : alternates; + + return { + loc: this.absoluteUrl(page.url), + lastmod: await this.getPageLastModified(page), + priority: page.url === "/en" ? 1 : page.url === "/zh" ? 0.9 : 0.8, + alternates: withDefault, + }; + }, + }), robotsPlugin(), rssPlugin(), ) diff --git a/public/_redirects b/public/_redirects index 5ab28291b..e83e18215 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,3 +1,7 @@ +# `/` is a permanent redirect to the default English docs. The language +# chooser in index.html is only a noindex fallback when this rule is not applied. +/ /en 301 +/index.html /en 301 /installation/console-first-steps /en/administration/console 301 /administration/data/data-tiering /en/administration/data/tiered-storage 301 /management /en/administration 301 diff --git a/public/index.html b/public/index.html index 6f4a4625a..a4a9e4d11 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ + RustFS Documentation diff --git a/public/language-redirect.js b/public/language-redirect.js index cf6fb32ce..b3ad1b13f 100644 --- a/public/language-redirect.js +++ b/public/language-redirect.js @@ -1,3 +1,7 @@ +// Runs only when a host serves the fallback document at `/`. +// Production `/` is a 301 to `/en` (see public/_redirects), so crawlers +// never depend on this script. Humans who do receive the fallback still +// follow their saved or browser language. (() => { const supportedLanguages = new Set(["en", "zh", "de", "fr", "ja"]); const storageKey = "rustfs-docs-language"; diff --git a/scripts/add-language-redirects.mjs b/scripts/add-language-redirects.mjs index d20440bf6..1c53d0bad 100644 --- a/scripts/add-language-redirects.mjs +++ b/scripts/add-language-redirects.mjs @@ -31,4 +31,31 @@ const ordered = [ const output = ordered.map(([source, target]) => `${source} ${target}`).join('\n'); fs.copyFileSync(SOURCE_INDEX, OUTPUT_INDEX); fs.writeFileSync(OUTPUT_REDIRECTS, `${output}\n`); -console.log(`language redirects: wrote the language entry page and ${rules.size} Cloudflare rules`); +stripThinRootFromSitemap(path.join(ROOT, 'dist', 'public', 'sitemap.xml')); +console.log(`language redirects: wrote the noindex fallback page and ${rules.size} Cloudflare rules`); + +/** + * Drop a sitemap entry whose path is only `/`, so the language chooser is not listed. + * + * @param {string} file + */ +function stripThinRootFromSitemap(file) { + if (!fs.existsSync(file)) return; + + const xml = fs.readFileSync(file, 'utf8'); + const next = xml.replace(/\s*([\s\S]*?)\s*<\/url>/g, (entry, body) => { + const loc = body.match(/([^<]*)<\/loc>/)?.[1]; + if (!loc) return entry; + + let pathname = loc; + try { + pathname = new URL(loc).pathname; + } catch { + // Relative sitemap locs are already pathnames. + } + + return pathname === '/' ? '' : entry; + }); + + if (next !== xml) fs.writeFileSync(file, next); +} diff --git a/scripts/language-redirects.mjs b/scripts/language-redirects.mjs index 7432d43e6..f42cf332e 100644 --- a/scripts/language-redirects.mjs +++ b/scripts/language-redirects.mjs @@ -6,9 +6,14 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SOURCE_REDIRECTS = path.join(ROOT, 'public', '_redirects'); const DEFAULT_LOCALE = 'en'; const LOCALE_DIR = path.join(ROOT, 'dist', 'public', DEFAULT_LOCALE); -// Generated pages that must keep their current behaviour: `/` serves the -// language chooser and `/404` is the Cloudflare Pages error page. +// `/404` is the Cloudflare Pages error page. `/` is not a content page; it is +// pinned to the default language below so crawlers never stop on the chooser. const SKIPPED_PAGES = new Set(['/404']); +/** @type {Array<[string, string]>} */ +const ROOT_REDIRECTS = [ + ['/', `/${DEFAULT_LOCALE} 301`], + ['/index.html', `/${DEFAULT_LOCALE} 301`], +]; /** @returns {Map} */ export function collectLanguageRedirects() { @@ -29,6 +34,11 @@ export function collectLanguageRedirects() { rules.set(source, `${target} ${status}`); } + // Always pin the docs root, even if public/_redirects drops the lines. + for (const [source, target] of ROOT_REDIRECTS) { + rules.set(source, target); + } + return rules; } diff --git a/scripts/worker-redirects.test.mjs b/scripts/worker-redirects.test.mjs index 4e41981e9..8206c4531 100644 --- a/scripts/worker-redirects.test.mjs +++ b/scripts/worker-redirects.test.mjs @@ -13,6 +13,7 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; +import { collectLanguageRedirects } from "./language-redirects.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const worker = (await import(path.join(here, "..", "worker.mjs"))).default; @@ -88,12 +89,33 @@ test("non-GET/HEAD requests fall through to assets untouched", async () => { assert.equal(await res.text(), "asset:/installation/"); // assets answered; the worker did not redirect }); -test("root path is left alone", async () => { +test("root path is left alone when no root rule exists", async () => { const res = await request(makeEnv(FIXTURE_RULES), "/"); assert.equal(res.status, 200); assert.equal(await res.text(), "asset:/"); }); +test("root and /index.html redirect permanently to the default language", async () => { + const rules = ["/ /en 301", "/index.html /en 301", FIXTURE_RULES].join("\n"); + const env = makeEnv(rules); + + const root = await request(env, "/"); + assert.equal(root.status, 301); + assert.equal(root.headers.get("location"), "https://docs.rustfs.com/en"); + + const withQuery = await request(env, "/?utm_source=docs"); + assert.equal(withQuery.status, 301); + assert.equal(withQuery.headers.get("location"), "https://docs.rustfs.com/en?utm_source=docs"); + + const index = await request(env, "/index.html"); + assert.equal(index.status, 301); + assert.equal(index.headers.get("location"), "https://docs.rustfs.com/en"); + + const head = await request(env, "/", { method: "HEAD" }); + assert.equal(head.status, 301); + assert.equal(head.headers.get("location"), "https://docs.rustfs.com/en"); +}); + test("unknown paths fall through to assets (no crash, no bogus redirect)", async () => { const env = makeEnv(FIXTURE_RULES); for (const candidate of ["/never-heard-of-it/", "/never-heard-of-it", "/en/never-heard-of-it/"]) { @@ -147,6 +169,12 @@ test("loop protection: a request that already equals its target falls through", assert.equal((await request(env, "/b/")).status, 200); }); +test("language redirect rules pin the docs root to English", () => { + const rules = collectLanguageRedirects(); + assert.equal(rules.get("/"), "/en 301"); + assert.equal(rules.get("/index.html"), "/en 301"); +}); + test("real build output (if present): /installation and /installation/ both 301", async (t) => { const redirectsPath = path.join(here, "..", "dist", "public", "_redirects"); if (!existsSync(redirectsPath)) { @@ -170,4 +198,37 @@ test("real build output (if present): /installation and /installation/ both 301" const splatSlashed = await request(env, "/management/iam/users/"); assert.equal(splatSlashed.status, 301); assert.equal(splatSlashed.headers.get("location"), "https://docs.rustfs.com/en/administration/iam/users"); + + const root = await request(env, "/"); + assert.equal(root.status, 301); + assert.equal(root.headers.get("location"), "https://docs.rustfs.com/en"); + + const index = await request(env, "/index.html"); + assert.equal(index.status, 301); + assert.equal(index.headers.get("location"), "https://docs.rustfs.com/en"); + + const sitemap = readFileSync(path.join(here, "..", "dist", "public", "sitemap.xml"), "utf8"); + assert.doesNotMatch(sitemap, /https:\/\/docs\.rustfs\.com\/?<\/loc>/); + /** @param {string} loc */ + const urlBlock = (loc) => { + const match = sitemap.match(new RegExp(`(?:(?!).)*${loc}(?:(?!).)*`)); + assert.ok(match, `missing sitemap entry ${loc}`); + return match[0]; + }; + const enEntry = urlBlock("https://docs\\.rustfs\\.com/en"); + const zhEntry = urlBlock("https://docs\\.rustfs\\.com/zh"); + assert.match(enEntry, /1<\/priority>/); + assert.match(zhEntry, /0\.9<\/priority>/); + assert.match(enEntry, /hreflang="x-default" href="https:\/\/docs\.rustfs\.com\/en"/); + + const enHtml = readFileSync(path.join(here, "..", "dist", "public", "en", "index.html"), "utf8"); + const zhHtml = readFileSync(path.join(here, "..", "dist", "public", "zh", "index.html"), "utf8"); + assert.match(enHtml, /RustFS Documentation \| S3-Compatible Object Storage<\/title>/); + assert.match(enHtml, /MinIO-compatible migration, and cluster operations\./); + assert.match(zhHtml, /<title>RustFS 文档 \| 兼容 S3 的对象存储<\/title>/); + assert.match(zhHtml, /兼容 MinIO 的迁移和集群运维。/); + + const fallback = readFileSync(path.join(here, "..", "dist", "public", "index.html"), "utf8"); + assert.match(fallback, /noindex, follow/); + assert.match(fallback, /rel="canonical" href="https:\/\/docs\.rustfs\.com\/en"/); }); diff --git a/src/components/docs-og.tsx b/src/components/docs-og.tsx new file mode 100644 index 000000000..364befd01 --- /dev/null +++ b/src/components/docs-og.tsx @@ -0,0 +1,92 @@ +const primaryColor = "rgba(255,150,255,0.3)"; +const primaryTextColor = "rgb(255,150,255)"; + +/** + * Social card used by the Takumi plugin. Home pages pass `compact` so the + * longer SEO title and description wrap inside the 1200×630 image. + */ +export function docsOgCard({ + title, + description, + site, + compact = false, +}: { + title?: string; + description?: string; + site?: string; + compact?: boolean; +}) { + return ( + <div + style={{ + display: "flex", + flexDirection: "column", + width: "100%", + height: "100%", + color: "white", + padding: "4rem", + backgroundColor: "#0c0c0c", + borderBottom: `18px solid ${primaryColor}`, + }} + > + <p + style={{ + fontWeight: 800, + fontSize: compact ? "52px" : "82px", + margin: 0, + }} + > + {title} + </p> + {description ? ( + <p + style={{ + fontSize: compact ? "34px" : "52px", + color: "rgba(240,240,240,0.8)", + margin: 0, + marginTop: "16px", + paddingBottom: "28px", + borderBottom: `10px dashed ${primaryColor}`, + }} + > + {description} + </p> + ) : null} + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "20px", + marginTop: "auto", + color: primaryTextColor, + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" + width="56" + height="56" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="11" stroke={primaryTextColor} strokeWidth="2" /> + </svg> + {site ? ( + <p + style={{ + fontSize: compact ? "40px" : "56px", + fontWeight: 600, + margin: 0, + }} + > + {site} + </p> + ) : null} + </div> + </div> + ); +} diff --git a/worker.mjs b/worker.mjs index 53536a6c6..ca2939868 100644 --- a/worker.mjs +++ b/worker.mjs @@ -1,4 +1,4 @@ -let redirectRulesPromise; +const redirectRulesByEnv = new WeakMap(); const DEFAULT_LOCALE = "en"; async function loadRedirectRules(env) { @@ -30,10 +30,14 @@ async function loadRedirectRules(env) { } async function getRedirectRules(env) { - if (!redirectRulesPromise) { - redirectRulesPromise = loadRedirectRules(env); + // Cache per env so one isolate keeps a single parse, and tests can supply + // a different rules file without inheriting the first request's payload. + let pending = redirectRulesByEnv.get(env); + if (!pending) { + pending = loadRedirectRules(env); + redirectRulesByEnv.set(env, pending); } - return redirectRulesPromise; + return pending; } function matchRedirect(pathname, rules) {