React Router: rewrite i18n example

This commit is contained in:
Fuma Nama
2025-08-18 21:26:04 +08:00
parent 563aabfbf3
commit 514052ea6a
4 changed files with 161 additions and 131 deletions
+41
View File
@@ -0,0 +1,41 @@
---
'fumadocs-core': minor
---
**Include locale code into `page.path`**
Previously when i18n is enabled, `page.path` is not equal to the virtual file paths you passed into `loader()`:
```ts
const source = loader({
source: {
files: [{
path: 'folder/index.cn.mdx'
// ...
}]
}
});
console.log(source.getPages("cn"));
// path: folder/index.mdx
```
This can be confusing, the only solution to obtain the original path was `page.absolutePath`.
From now, the `page.path` will also include the locale code:
```ts
const source = loader({
source: {
files: [{
path: 'folder/index.cn.mdx'
// ...
}]
}
});
console.log(source.getPages('cn'));
// path: folder/index.cn.mdx
```
While this change doesn't affect intended API usages, it **may lead to minor bugs** when advanced usage/hacks involved around `page.path`.
+62 -68
View File
@@ -1,11 +1,12 @@
import { type MetaData, type PageData } from '@/source/types';
import { FileSystem } from './file-system';
import { slash, splitPath } from '@/utils/path';
import { VirtualFile } from '@/source/loader';
import { joinPath, slash, splitPath } from '@/utils/path';
import { type VirtualFile } from '@/source/loader';
import { basename, dirname } from '@/source/path';
export interface LoadOptions {
transformers?: Transformer[];
buildFiles: (files: VirtualFile[]) => (MetaFile | PageFile)[];
buildFile: (file: VirtualFile) => MetaFile | PageFile;
}
export type ContentStorage = FileSystem<MetaFile | PageFile>;
@@ -32,90 +33,83 @@ export type Transformer = (context: {
options: LoadOptions;
}) => void;
function isLocaleValid(locale: string) {
return locale.length > 0 && !/\d+/.test(locale);
}
const parsers = {
dir(path: string): [string, string?] {
const [locale, ...segs] = path.split('/');
if (locale && segs.length > 0 && isLocaleValid(locale))
return [segs.join('/'), locale];
return [path];
},
dot(path: string): [string, string?] {
const dir = dirname(path);
const base = basename(path);
const parts = base.split('.');
if (parts.length < 3) return [path];
const [locale] = parts.splice(parts.length - 2, 1);
if (!isLocaleValid(locale)) return [path];
return [joinPath(dir, parts.join('.')), locale];
},
none(path: string): [string, string?] {
return [path];
},
};
// Virtual files -> Virtual Storage -> Transformers -> Result
/**
* @returns a map of locale and its content storage.
*
* in the storage, locale codes are removed from file paths, hence the same file will have same file paths in every storage.
*/
export function loadFiles(
files: VirtualFile[],
options: LoadOptions,
): ContentStorage {
const { transformers = [] } = options;
const storage: ContentStorage = new FileSystem();
const normalized: VirtualFile[] = files.map((file) => ({
...file,
path: normalizePath(file.path),
}));
for (const item of options.buildFiles(normalized)) {
storage.write(item.path, item);
}
for (const transformer of transformers) {
transformer({
storage,
options,
});
}
return storage;
}
export function loadFilesI18n(
files: VirtualFile[],
options: LoadOptions,
i18n: {
parser: 'dot' | 'dir';
parser: keyof typeof parsers;
languages: string[];
defaultLanguage: string;
},
): Record<string, ContentStorage> {
const parser = i18n.parser === 'dir' ? dirParser : dotParser;
const { buildFile, transformers = [] } = options;
const parser = parsers[i18n.parser];
const storages: Record<string, ContentStorage> = {};
const normalized = files.map((file) =>
buildFile({
...file,
path: normalizePath(file.path),
}),
);
for (const lang of i18n.languages) {
storages[lang] = loadFiles(
files.flatMap((file) => {
const [path, locale] = parser(normalizePath(file.path));
const storage: ContentStorage = new FileSystem();
if ((locale ?? i18n.defaultLanguage) === lang) {
return {
...file,
path,
};
}
for (const item of normalized) {
const [path, locale = i18n.defaultLanguage] = parser(item.path);
return [];
}),
options,
);
if (locale !== lang) continue;
storage.write(path, item);
}
for (const transformer of transformers) {
transformer({
storage,
options,
});
}
storages[lang] = storage;
}
return storages;
}
function dirParser(path: string): [string, string?] {
const parsed = path.split('/');
if (parsed.length >= 2) return [parsed.slice(1).join('/'), parsed[0]];
return [path];
}
function dotParser(path: string): [string, string?] {
const segs = path.split('/');
if (segs.length === 0) return [path];
const name = segs[segs.length - 1].split('.');
if (name.length >= 3) {
const locale = name.splice(name.length - 2, 1)[0];
if (locale.length > 0 && !/\d+/.test(locale)) {
segs[segs.length - 1] = name.join('.');
return [segs.join('/'), locale];
}
}
return [path];
}
/**
* @param path - Relative path
* @returns Normalized path, with no trailing/leading slashes
+46 -51
View File
@@ -3,7 +3,6 @@ import type { I18nConfig } from '@/i18n';
import {
type ContentStorage,
loadFiles,
loadFilesI18n,
type MetaFile,
type PageFile,
type Transformer,
@@ -213,17 +212,15 @@ function indexPages(
for (const filePath of storages[defaultLanguage].getFiles()) {
const item = storages[defaultLanguage].read(filePath)!;
const path = `${defaultLanguage}.${filePath}`;
if (item.format === 'meta') {
result.pathToMeta.set(
`${defaultLanguage}.${item.path}`,
fileToMeta(item),
);
result.pathToMeta.set(path, fileToMeta(item));
}
if (item.format === 'page') {
const page = fileToPage(item, getUrl, defaultLanguage);
result.pathToPage.set(`${defaultLanguage}.${item.path}`, page);
result.pathToPage.set(path, page);
result.pages.set(`${defaultLanguage}.${page.slugs.join('/')}`, page);
if (!i18n) continue;
@@ -238,7 +235,7 @@ function indexPages(
);
if (localizedItem) {
result.pathToPage.set(`${lang}.${item.path}`, localizedPage);
result.pathToPage.set(`${lang}.${filePath}`, localizedPage);
}
result.pages.set(
@@ -299,78 +296,76 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
i18n,
slugs: slugsFn,
url: getUrl = createGetUrl(baseUrl ?? '/', i18n),
transformers,
transformers = [],
} = options;
const defaultLanguage = i18n?.defaultLanguage ?? '';
const files =
typeof source.files === 'function' ? source.files() : source.files;
function buildFiles(files: VirtualFile[]) {
const indexFiles: VirtualFile[] = [];
const transformerSlugs: Transformer = ({ storage }) => {
const indexFiles = new Set<string>();
const taken = new Set<string>();
for (const file of files) {
if (file.type !== 'page' || file.slugs) continue;
for (const path of storage.getFiles()) {
const file = storage.read(path);
if (!file || file.format !== 'page' || file.slugs) continue;
// for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
if (isIndex(file.path) && !slugsFn) {
indexFiles.push(file);
if (isIndex(path) && !slugsFn) {
indexFiles.add(path);
continue;
}
file.slugs = slugsFn
? slugsFn(parseFilePath(file.path))
: getSlugs(file.path);
file.slugs = slugsFn ? slugsFn(parseFilePath(path)) : getSlugs(path);
const key = file.slugs.join('/');
if (taken.has(key)) throw new Error('Duplicated slugs');
taken.add(key);
}
for (const file of indexFiles) {
file.slugs = getSlugs(file.path);
for (const path of indexFiles) {
const file = storage.read(path);
if (file?.format !== 'page') continue;
file.slugs = getSlugs(path);
if (taken.has(file.slugs.join('/'))) file.slugs.push('index');
}
};
const storages = loadFiles(
files,
{
buildFile(file) {
if (file.type === 'page') {
return {
format: 'page',
path: file.path,
slugs: file.slugs,
data: file.data,
absolutePath: file.absolutePath ?? '',
} as PageFile;
}
return files.map((file) => {
if (file.type === 'page') {
return {
format: 'page',
format: 'meta',
path: file.path,
slugs: file.slugs!,
data: file.data,
absolutePath: file.absolutePath ?? '',
} as PageFile;
}
return {
format: 'meta',
path: file.path,
absolutePath: file.absolutePath ?? '',
data: file.data,
} as MetaFile;
});
}
const storages = i18n
? loadFilesI18n(
files,
{
buildFiles,
transformers,
},
{
data: file.data,
} as MetaFile;
},
transformers: [transformerSlugs, ...transformers],
},
i18n
? {
...i18n,
parser: i18n.parser ?? 'dot',
}
: {
defaultLanguage,
parser: 'none',
languages: [defaultLanguage],
},
)
: {
'': loadFiles(files, {
transformers,
buildFiles,
}),
};
);
const walker = indexPages(storages, getUrl, i18n);
const builder = createPageTreeBuilder(getUrl);
+12 -12
View File
@@ -10,12 +10,12 @@
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"name": "test",
"path": "test.mdx",
"flattenedPath": "test.cn",
"name": "test.cn",
"path": "test.cn.mdx",
},
"locale": "cn",
"path": "test.mdx",
"path": "test.cn.mdx",
"slugs": [
"test",
],
@@ -29,12 +29,12 @@
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"name": "test",
"path": "nested/test.mdx",
"flattenedPath": "nested/test.cn",
"name": "test.cn",
"path": "nested/test.cn.mdx",
},
"locale": "cn",
"path": "nested/test.mdx",
"path": "nested/test.cn.mdx",
"slugs": [
"nested",
"test",
@@ -73,12 +73,12 @@
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"name": "test",
"path": "nested/test.mdx",
"flattenedPath": "nested/test.en",
"name": "test.en",
"path": "nested/test.en.mdx",
},
"locale": "en",
"path": "nested/test.mdx",
"path": "nested/test.en.mdx",
"slugs": [
"nested",
"test",