Skip to content
Merged
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
22 changes: 18 additions & 4 deletions docs/src/components/ViewMarkdown.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { FileText } from "lucide-react";

import { defaultLanguage } from "../const.mjs";
import { useLanguage } from "../i18n.mjs";

export default function ViewMarkdown({ pathname }) {
const lang = useLanguage();
const canonical = pathname.replace(new RegExp(`^/${lang}`), "");

// Don't show on the homepage or language root
if (!canonical || canonical === "/") {
// English is canonical and lives at unprefixed URLs — `/en/...`
// exists only as a non-canonical form that the i18n middleware
// redirects to `/...`, so the `.md` link must strip `/en` to point
// at the real artifact. Non-default languages (`/ja/...`) keep
// their prefix because that's where their own translation is
// emitted (`/ja/api/dev.md`, not `/api/dev.md`).
const stripped = pathname.replace(
new RegExp(`^/${defaultLanguage}(?=/|$)`),
""
);

// Hide on the homepage and bare language roots (`/`, `/ja`, `/ja/`).
// Use the active language for this check so `/ja` collapses to ""
// even though we don't strip it from the link itself.
const visibilityPath = stripped.replace(new RegExp(`^/${lang}(?=/|$)`), "");
if (!visibilityPath || visibilityPath === "/") {
return null;
}

const mdUrl = `${canonical}.md`;
const mdUrl = `${stripped.replace(/\/$/, "")}.md`;

return (
<a
Expand Down
16 changes: 10 additions & 6 deletions docs/src/pages/(content-negotiation).middleware.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,17 @@ export default async function ContentNegotiation() {
return;
}

// Strip any leading language prefix; the canonical markdown is always
// served from the English path.
const stripped = pathname.replace(
new RegExp(`^/(${languages.join("|")})(?=/|$)`),
""
// Keep the language prefix — the markdown route resolves a separate
// translation per language (`/ja/foo.md` serves Japanese, `/foo.md`
// serves English). For the homepage, the language-only pathname
// (`/`, `/ja`, `/ja/`) collapses to empty so we fall through to the
// llms.txt summary below; agents asking for the site's overview
// get the same canonical document regardless of the URL they used.
const isLanguageOnly = languages.some(
(lang) => pathname === `/${lang}` || pathname === `/${lang}/`
);
const mdPath = stripped === "" || stripped === "/" ? "" : stripped;
const mdPath =
pathname === "/" || isLanguageOnly ? "" : pathname.replace(/\/$/, "");

// Always advertise the response varies on Accept so caches don't poison
// each other across HTML/markdown.
Expand Down
18 changes: 18 additions & 0 deletions docs/src/pages/en/(pages)/features/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ Static export. Default is `false`.

You can export your app as static HTML pages. You can define the routes to export in your `react-server.config.mjs` file. See more details at [Static generation](/router/static).

<Link name="build-export-concurrency">
### --export-concurrency
</Link>

Number of parallel processes used to render static export paths. Default is between `2` and `4`, scaled to the number of available CPU cores.

Pass `1` to run the export in a single in-process pipeline — paths are rendered one at a time in the main process, with no fork or IPC overhead. This is the lowest-overhead mode and is a good fit for small exports or for debugging.

Pass `>1` to fork that many render-worker child processes. Each child runs its own RSC main thread plus an SSR worker thread and renders one path at a time. The coordinator dispatches paths to free children over IPC; output bytes never cross the IPC boundary — every artifact (HTML, `.gz` / `.br` sidecars, `.postponed.json`, `.prerender-cache.json`) is written to disk inside the child. Both modes produce the same artifact set.

This is the right knob to reach for when exporting tens of thousands of pages or when individual pages do heavy server-side work (Shiki highlighting, large MDX trees, costly server components). On smaller exports the per-fork startup cost dominates and a value of `1` may be faster overall.

```sh
pnpm react-server build --export --export-concurrency 8
```

You can also set this in your `react-server.config.mjs` as `exportConcurrency`.

<Link name="build-compression">
### --compression
</Link>
Expand Down
32 changes: 32 additions & 0 deletions docs/src/pages/en/(pages)/router/static.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,38 @@ export default {
};
```

<Link name="streaming-export">
## Streaming the export path source
</Link>

The regular-function form of `export` receives an array — every path the file-system router resolved, all at once. That's fine for most apps, but for very large exports (tens of thousands of pages, paginated content sourced from a CMS, on-the-fly path generation) materializing the full list before rendering starts is wasteful: peak memory grows with the path count, and the first render can't start until the last path is collected.

Declare `export` as an `async function*` (an async generator) and you opt into streaming instead. The generator receives the live `AsyncIterable` of paths produced by the file-system router and `exportPaths`, and it can `yield` paths to the renderer one at a time. The renderer pulls paths only when a worker is free, so the generator stays one step ahead of the export — peak memory is `O(one path descriptor)` regardless of the total count, and rendering starts on the first yielded path. Pairs naturally with [`--export-concurrency`](/features/cli#build-export-concurrency) for parallel rendering.

```js filename="react-server.config.mjs"
export default {
async *export(paths) {
// Pass through whatever the file-system router resolved.
for await (const p of paths) {
yield p;
}

// Then yield additional paths lazily — e.g. fetched page-by-page from
// a CMS, computed from a large database query, or read from a file.
let cursor = null;
do {
const { items, next } = await fetchPage(cursor);
for (const item of items) {
yield { path: `/posts/${item.slug}` };
}
cursor = next;
} while (cursor);
},
};
```

> **Note:** detection is by function kind. You must write `async function*` (or `function*`) **directly** as the value of `export` — wrappers that return an ordinary function (memoization, decorators, …) fall back to the legacy array-transform contract. The regular-function form is unchanged and remains the right choice for "give me the array, let me return a transformed array."

<Link name="static-export-api-routes">
## Static export API routes
</Link>
Expand Down
18 changes: 18 additions & 0 deletions docs/src/pages/ja/(pages)/features/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ export default {

アプリケーションを静的HTMLページでエクスポートすることが出来ます。`react-server.config.mjs`でルーティングを定義してエクスポートすることが出来ます。詳細は[静的生成](/router/static)を参照してください。

<Link name="build-export-concurrency">
### --export-concurrency
</Link>

静的エクスポートのパスをレンダリングするのに使う並列プロセス数を指定します。デフォルトは利用可能なCPUコア数に応じて`2`から`4`の範囲で決定されます。

`1`を指定すると、エクスポートはメインプロセス内で単一のパイプラインとして実行され、パスは1つずつ順番にレンダリングされます。フォークやIPCのオーバーヘッドがないため最も軽量なモードで、小規模なエクスポートやデバッグに適しています。

`1`より大きい値を指定すると、その数だけレンダリング用の子プロセスがフォークされます。各子プロセスは独自のRSCメインスレッドとSSRワーカースレッドを持ち、1度に1つのパスをレンダリングします。コーディネーターはIPC経由で空いている子プロセスにパスを割り当てます。出力バイトはIPC境界を越えず、すべての成果物(HTML、`.gz` / `.br`サイドカー、`.postponed.json`、`.prerender-cache.json`)は子プロセス内で直接ディスクに書き出されます。どちらのモードでも生成される成果物のセットは同一です。

このオプションは、数万ページに及ぶ大規模なエクスポートを行う場合や、個々のページでサーバー側の処理が重い場合(Shikiによるシンタックスハイライト、大規模なMDXツリー、負荷の高いサーバーコンポーネントなど)に効果を発揮します。小規模なエクスポートではフォーク自体のコストが支配的になるため、`1`を指定したほうが速いこともあります。

```sh
pnpm react-server build --export --export-concurrency 8
```

`react-server.config.mjs`の`exportConcurrency`としても設定できます。

<Link name="build-compression">
### --compression
</Link>
Expand Down
32 changes: 32 additions & 0 deletions docs/src/pages/ja/(pages)/router/static.page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,38 @@ export default {
};
```

<Link name="streaming-export">
## エクスポートパスソースのストリーミング
</Link>

通常の関数形式の`export`は配列を受け取ります。ファイルシステムルーターが解決したすべてのパスを一度に受け取る形です。ほとんどのアプリではこれで十分ですが、非常に大規模なエクスポート(数万ページ、CMSから取得するページネーションされたコンテンツ、動的に生成されるパスなど)では、レンダリング開始前にリスト全体をメモリ上に展開するのは効率的ではありません。ピークメモリがパス数に比例して増大し、最後のパスが収集されるまで最初のレンダリングを開始できないからです。

`export`を`async function*`(非同期ジェネレーター)として宣言すると、ストリーミングモードに切り替わります。ジェネレーターはファイルシステムルーターと`exportPaths`が生成するライブな`AsyncIterable`を受け取り、レンダラーにパスを1つずつ`yield`できます。レンダラーはワーカーが空いたときにのみパスをプルするため、ジェネレーターはエクスポートのちょうど1ステップ先を進む形になります。ピークメモリは総パス数に関係なく`O(パス記述子1つ分)`に保たれ、最初に`yield`されたパスからレンダリングが開始されます。並列レンダリングのために[`--export-concurrency`](/features/cli#build-export-concurrency)と組み合わせると効果的です。

```js filename="react-server.config.mjs"
export default {
async *export(paths) {
// ファイルシステムルーターが解決したパスをそのまま流す。
for await (const p of paths) {
yield p;
}

// その後、追加のパスを遅延的にyieldする。例: CMSからページ単位で取得、
// 大規模なDBクエリから計算、ファイルから読み込み、など。
let cursor = null;
do {
const { items, next } = await fetchPage(cursor);
for (const item of items) {
yield { path: `/posts/${item.slug}` };
}
cursor = next;
} while (cursor);
},
};
```

> **注意:** 検出は関数の種別で行われます。`export`の値として`async function*`(または`function*`)を**直接**書く必要があります。通常の関数を返すラッパー(メモ化、デコレーターなど)を介すると、レガシーな配列変換契約にフォールバックします。通常の関数形式は変更されておらず、「配列を渡してくれれば、変換した配列を返す」という用途には引き続きこちらが適しています。

<Link name="static-export-api-routes">
## APIルートの静的エクスポート
</Link>
Expand Down
118 changes: 84 additions & 34 deletions docs/src/pages/md/GET.[...slug].server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,62 @@ import { join } from "node:path";

import { useMatch } from "@lazarv/react-server/router";

import { m } from "../../i18n.mjs";
import { defaultLanguage, languages } from "../../const.mjs";
import {
api_landing_title,
api_translation_banner,
} from "../../paraglide/messages.js";
import {
apiReferenceIndex,
renderApiReferenceLandingMarkdown,
renderApiReferencePageMarkdown,
} from "../../lib/api-reference.mjs";

// Lazy loaders for frontmatter only
// Lazy loaders for frontmatter only. Glob both languages so requests
// for `/md/<lang>/...` can resolve to the corresponding translation.
// API reference pages have no on-disk source — they render live from
// the `.d.ts` files; non-default languages get the English content
// with a translation banner.
const moduleLoaders = import.meta.glob([
"../en/*/**/*.{md,mdx}",
"../en/*.\\(index\\).{md,mdx}",
"../ja/*/**/*.{md,mdx}",
"../ja/*.\\(index\\).{md,mdx}",
]);

const apiSlugs = new Set(apiReferenceIndex().map((p) => p.slug));

function getSlug(key) {
function getSlug(relPath) {
// For pages in (pages)/ directory: (pages)/guide/quick-start.mdx → guide/quick-start
let match = key.match(/\(pages\)\/(.+?)\.mdx?$/);
let match = relPath.match(/\(pages\)\/(.+?)\.mdx?$/);
if (match) {
return match[1].replace(/\.page$/, "").replace(/\/index$/, "");
}
// For category index pages: guide.(index).mdx → guide
match = key.match(/^(.+?)\.\(index\)\.mdx?$/);
match = relPath.match(/^(.+?)\.\(index\)\.mdx?$/);
if (match) {
return match[1];
}
return null;
}

// Map from glob key to raw file path relative to pages/en/
function globKeyToRelPath(globKey) {
return globKey.replace(/^\.\.\/en\//, "");
}

// Build slug → keys mapping
const slugToKey = new Map();
// Build per-language slug maps. Each entry maps a slug like
// `guide/quick-start` to the glob key + relative path for that
// language, so the route handler can fetch the right translation.
const slugByLang = new Map();
for (const globKey of Object.keys(moduleLoaders)) {
const relPath = globKeyToRelPath(globKey);
const langMatch = globKey.match(/^\.\.\/([^/]+)\//);
const lang = langMatch?.[1];
if (!lang || !languages.includes(lang)) continue;
const relPath = globKey.replace(/^\.\.\/[^/]+\//, "");
const slug = getSlug(relPath);
if (slug) {
slugToKey.set(slug, { globKey, relPath });
}
if (!slug) continue;
if (!slugByLang.has(lang)) slugByLang.set(lang, new Map());
slugByLang.get(lang).set(slug, { globKey, relPath });
}

const enSlugs = slugByLang.get(defaultLanguage) ?? new Map();

function cleanMdx(raw) {
// Remove frontmatter
let content = raw.replace(/^---[\s\S]*?---\n*/m, "");
Expand Down Expand Up @@ -91,30 +103,59 @@ function cleanMdx(raw) {
}

// Export all available slugs so they can be used for static generation.
// In addition to the MDX-derived slugs, publish every API reference slug —
// those pages have no on-disk source; their `.md` form is rendered live
// by `renderApiReferencePageMarkdown`.
export const slugs = [
...slugToKey.keys(),
"api",
...[...apiSlugs].map((s) => `api/${s}`),
];
// Default-language slugs are exposed bare (e.g. `guide/quick-start`),
// every other configured language is also exposed under a language
// prefix (e.g. `ja/guide/quick-start`) so the file-router emits a
// per-language `.md` artifact for each page. API reference slugs are
// always exposed under every language prefix — there is no Japanese
// `.d.ts` source, so non-default languages get the English content
// with a translation banner via `api_translation_banner`.
export const slugs = (() => {
const out = [];
for (const lang of languages) {
const prefix = lang === defaultLanguage ? "" : `${lang}/`;
const langSlugs = slugByLang.get(lang) ?? new Map();
for (const slug of langSlugs.keys()) out.push(`${prefix}${slug}`);
out.push(`${prefix}api`);
for (const apiSlug of apiSlugs) out.push(`${prefix}api/${apiSlug}`);
}
return out;
})();

export default async function MarkdownRoute() {
const { slug } = useMatch("/md/[[...slug]]");
const path = slug?.join("/");
const segs = slug ?? [];

if (!path) {
if (segs.length === 0) {
return new Response("Not Found", { status: 404 });
}

// Dynamic API reference pages — rendered on demand from the `.d.ts`
// files in `packages/react-server`, no on-disk source exists.
// Detect a language prefix on the slug. URLs of the form
// `/md/<lang>/...` route to that language's translation; bare
// `/md/...` paths use the default language. Anything else
// (`segs[0]` not a known language) is treated as a default-language
// slug whose first segment happens to share a name with no language.
let lang = defaultLanguage;
let pathSegs = segs;
if (
segs.length > 0 &&
languages.includes(segs[0]) &&
segs[0] !== defaultLanguage
) {
lang = segs[0];
pathSegs = segs.slice(1);
}
const path = pathSegs.join("/");

// API reference: content is generated from `.d.ts` files (English
// only). Non-default languages get the English content with a
// translation banner; the banner itself is fetched in the active
// language so it reads naturally to the agent making the request.
if (path === "api") {
return new Response(
renderApiReferenceLandingMarkdown({
title: m.api_landing_title(),
banner: m.api_translation_banner(),
title: api_landing_title({}, { languageTag: lang }),
banner: api_translation_banner({}, { languageTag: lang }),
}),
{
headers: { "Content-Type": "text/markdown; charset=utf-8" },
Expand All @@ -125,7 +166,7 @@ export default async function MarkdownRoute() {
const apiSlug = path.slice("api/".length);
if (apiSlugs.has(apiSlug)) {
const markdown = renderApiReferencePageMarkdown(apiSlug, {
banner: m.api_translation_banner(),
banner: api_translation_banner({}, { languageTag: lang }),
});
if (markdown) {
return new Response(markdown, {
Expand All @@ -136,13 +177,22 @@ export default async function MarkdownRoute() {
return new Response("Not Found", { status: 404 });
}

const keys = slugToKey.get(path);
// MDX page lookup: prefer the requested language; if a translation
// is missing (e.g. `pages/ja/(pages)/advanced/...` doesn't exist
// for some pages that exist under en/), fall back to English so
// the URL still resolves with content rather than 404.
const langMap = slugByLang.get(lang) ?? new Map();
let keys = langMap.get(path);
let resolvedLang = lang;
if (!keys && lang !== defaultLanguage) {
keys = enSlugs.get(path);
resolvedLang = defaultLanguage;
}
if (!keys) {
return new Response("Not Found", { status: 404 });
}

// Read raw source file from disk (works in dev and during static export build)
const pagesDir = join(process.cwd(), "src", "pages", "en");
const pagesDir = join(process.cwd(), "src", "pages", resolvedLang);
const raw = await readFile(join(pagesDir, keys.relPath), "utf-8");
const mod = await moduleLoaders[keys.globKey]();
const title = mod?.frontmatter?.title;
Expand Down
4 changes: 3 additions & 1 deletion examples/ppr/react-server.config.json
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
{}
{
"export": ["/"]
}
4 changes: 4 additions & 0 deletions packages/react-server/bin/commands/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export default (cli) =>
.option("--no-check", "skip dependency checks", { default: false })
.option("--no-validation", "skip config validation", { default: false })
.option("--export", "[boolean] static export")
.option(
"--export-concurrency <n>",
"[number] number of parallel processes for static export (1 = single-process; >1 = multi-process)"
)
.option("--compression", "[boolean] enable compression", { default: false })
.option("--adapter <adapter>", "[boolean|string] adapter", {
default: "",
Expand Down
Loading
Loading