Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions content/en/index.mdx
Original file line number Diff line number Diff line change
@@ -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."
---

<div className="rustfs-install-home">
Expand Down
4 changes: 2 additions & 2 deletions content/zh/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "RustFS 文档"
description: "了解如何安装、配置、管理和维护 RustFS。"
title: "RustFS 文档 | 兼容 S3 的对象存储"
description: "了解如何安装和运维兼容 S3 的分布式对象存储 RustFS,包括 S3 API、Kubernetes、纠删码、兼容 MinIO 的迁移和集群运维。"
---

<div className="rustfs-install-home">
Expand Down
38 changes: 36 additions & 2 deletions press.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
)
Expand Down
4 changes: 4 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, follow" />
<link rel="canonical" href="https://docs.rustfs.com/en" />
<title>RustFS Documentation</title>
<script src="/language-redirect.js"></script>
</head>
Expand Down
4 changes: 4 additions & 0 deletions public/language-redirect.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
29 changes: 28 additions & 1 deletion scripts/add-language-redirects.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<url>\s*([\s\S]*?)\s*<\/url>/g, (entry, body) => {
const loc = body.match(/<loc>([^<]*)<\/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);
}
14 changes: 12 additions & 2 deletions scripts/language-redirects.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>} */
export function collectLanguageRedirects() {
Expand All @@ -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;
}

Expand Down
63 changes: 62 additions & 1 deletion scripts/worker-redirects.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/"]) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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, /<loc>https:\/\/docs\.rustfs\.com\/?<\/loc>/);
/** @param {string} loc */
const urlBlock = (loc) => {
const match = sitemap.match(new RegExp(`<url>(?:(?!</url>).)*<loc>${loc}</loc>(?:(?!</url>).)*</url>`));
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, /<priority>1<\/priority>/);
assert.match(zhEntry, /<priority>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, /<title>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"/);
});
92 changes: 92 additions & 0 deletions src/components/docs-og.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
12 changes: 8 additions & 4 deletions worker.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
let redirectRulesPromise;
const redirectRulesByEnv = new WeakMap();
const DEFAULT_LOCALE = "en";

async function loadRedirectRules(env) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading