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, /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 (
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {site ? (
+
+ {site}
+
+ ) : null}
+
+
+ );
+}
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) {