Core: Support directory-based i18n routing

This commit is contained in:
Fuma Nama
2025-03-22 17:43:50 +08:00
parent 312b18b6e5
commit 3f580c44cf
10 changed files with 300 additions and 199 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support directory-based i18n routing
+1 -4
View File
@@ -79,10 +79,7 @@ export class Storage {
this.makeDir(node.file.dirname);
this.readDir(node.file.dirname)?.children.push(node);
this.files.set(
joinPath(
node.file.dirname,
`${node.file.name}${node.file.locale}.${node.format}`,
),
joinPath(node.file.dirname, `${node.file.name}.${node.format}`),
node,
);
}
+69 -10
View File
@@ -4,10 +4,17 @@ import { Storage } from './file-system';
export interface LoadOptions {
transformers?: Transformer[];
rootDir?: string;
getSlugs: (info: FileInfo) => string[];
}
export interface I18nLoadOptions extends LoadOptions {
i18n: {
parser: 'dot' | 'dir';
languages: string[];
defaultLanguage: string;
};
}
export interface VirtualFile {
/**
* Relative path
@@ -30,28 +37,27 @@ export type Transformer = (context: {
}) => void;
// Virtual files -> Virtual Storage -> Transformers -> Result
export function loadFiles(files: VirtualFile[], options: LoadOptions): Storage {
export function loadFiles<O extends LoadOptions>(
files: VirtualFile[],
options: O,
): Storage {
const { transformers = [] } = options;
const storage = new Storage();
const rootDir = normalizePath(options.rootDir ?? '');
for (const file of files) {
const normalizedPath = normalizePath(file.path);
if (!normalizedPath.startsWith(rootDir)) continue;
const relativePath = normalizedPath.slice(rootDir.length);
const parsedPath = normalizePath(file.path);
if (file.type === 'page') {
const slugs = file.slugs ?? options.getSlugs(parseFilePath(relativePath));
const slugs = file.slugs ?? options.getSlugs(parseFilePath(parsedPath));
storage.write(relativePath, file.type, {
storage.write(parsedPath, file.type, {
slugs,
data: file.data as PageData,
});
}
if (file.type === 'meta') {
storage.write(relativePath, file.type, file.data as MetaData);
storage.write(parsedPath, file.type, file.data as MetaData);
}
}
@@ -64,3 +70,56 @@ export function loadFiles(files: VirtualFile[], options: LoadOptions): Storage {
return storage;
}
export function loadFilesI18n(
files: VirtualFile[],
options: I18nLoadOptions,
): Record<string, Storage> {
const parser = options.i18n.parser === 'dir' ? dirParser : dotParser;
const storages: Record<string, Storage> = {};
for (const lang of options.i18n.languages) {
storages[lang] = loadFiles(
files.flatMap((file) => {
const [path, locale] = parser(normalizePath(file.path));
if ((locale ?? options.i18n.defaultLanguage) === lang) {
return {
...file,
path,
};
}
return [];
}),
options,
);
}
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];
}
+32 -30
View File
@@ -5,6 +5,8 @@ import {
type LoadOptions,
type VirtualFile,
type Transformer,
loadFilesI18n,
type I18nLoadOptions,
} from './load-files';
import type { MetaData, PageData, UrlFn } from './types';
import type { BuildPageTreeOptions } from './page-tree-builder';
@@ -24,12 +26,6 @@ export interface SourceConfig {
}
export interface LoaderOptions {
/**
* @deprecated It is now recommended to filter files on `source` level
* @defaultValue `''`
*/
rootDir?: string;
baseUrl: string;
icon?: NonNullable<BuildPageTreeOptions['resolveIcon']>;
@@ -48,7 +44,9 @@ export interface LoaderOptions {
/**
* Configure i18n
*/
i18n?: I18nConfig;
i18n?: I18nConfig & {
parser?: I18nLoadOptions['i18n']['parser'];
};
}
export interface Source<Config extends SourceConfig> {
@@ -56,7 +54,7 @@ export interface Source<Config extends SourceConfig> {
* @internal
*/
_config?: Config;
files: VirtualFile[] | ((rootDir: string) => VirtualFile[]);
files: VirtualFile[] | (() => VirtualFile[]);
}
export interface Page<Data = PageData> {
@@ -139,7 +137,7 @@ export interface LoaderOutput<Config extends LoaderConfig> {
}
function indexPages(
storage: Storage,
storages: Record<string, Storage>,
getUrl: UrlFn,
i18n?: I18nConfig,
): {
@@ -152,29 +150,23 @@ function indexPages(
const map = new Map<string, Page>();
const pathToFile = new Map<string, Page | Meta>();
for (const item of storage.list()) {
for (const item of storages[defaultLanguage].list()) {
if (item.format === 'meta')
pathToFile.set(item.file.path, fileToMeta(item));
if (item.format === 'page') {
if (
item.file.locale.length > 0 &&
item.file.locale.slice(1) !== defaultLanguage
)
continue;
const page = fileToPage(item, getUrl, defaultLanguage);
pathToFile.set(item.file.path, page);
map.set(`${defaultLanguage}.${page.slugs.join('/')}`, page);
if (!i18n) continue;
const basePath = joinPath(item.file.dirname, item.file.name);
const path = joinPath(item.file.dirname, item.file.name);
for (const lang of i18n.languages) {
if (lang === defaultLanguage) continue;
const localizedPage = fileToPage(
storage.read(`${basePath}.${lang}`, 'page') ?? item,
storages[lang].read(path, 'page') ?? item,
getUrl,
lang,
);
@@ -239,19 +231,29 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
console.warn('`loader()` now requires a `baseUrl` option to be defined.');
}
const { source, rootDir = '', slugs: slugsFn = getSlugs } = options;
const { source, slugs: slugsFn = getSlugs } = options;
const getUrl =
options.url ?? createGetUrl(options.baseUrl ?? '/', options.i18n);
const storage = loadFiles(
typeof source.files === 'function' ? source.files(rootDir) : source.files,
{
transformers: options.transformers,
rootDir,
getSlugs: slugsFn,
},
);
const walker = indexPages(storage, getUrl, options.i18n);
const files =
typeof source.files === 'function' ? source.files() : source.files;
const storages = options.i18n
? loadFilesI18n(files, {
i18n: {
...options.i18n,
parser: options.i18n.parser ?? 'dot',
},
transformers: options.transformers,
getSlugs: slugsFn,
})
: {
'': loadFiles(files, {
transformers: options.transformers,
getSlugs: slugsFn,
}),
};
const walker = indexPages(storages, getUrl, options.i18n);
const builder = createPageTreeBuilder();
let pageTree: LoaderOutput<LoaderConfig>['pageTree'] | undefined;
@@ -260,7 +262,7 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
get pageTree() {
if (options.i18n) {
pageTree ??= builder.buildI18n({
storage,
storages,
resolveIcon: options.icon,
getUrl,
i18n: options.i18n,
@@ -268,7 +270,7 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
}) as unknown as LoaderOutput<LoaderConfig>['pageTree'];
} else {
pageTree ??= builder.build({
storage,
storage: storages[''],
resolveIcon: options.icon,
getUrl,
...options.pageTree,
+24 -31
View File
@@ -6,15 +6,16 @@ import { joinPath } from '@/utils/path';
import { type UrlFn } from './types';
interface PageTreeBuilderContext {
lang?: string;
defaultLanguage?: string;
storage: Storage;
locale?: string;
localeStorage?: Storage;
builder: PageTreeBuilder;
options: BuildPageTreeOptions;
options: Options;
}
export interface BuildPageTreeOptions {
interface Options {
/**
* Remove references to the file path of original nodes (`$ref`)
*
@@ -30,12 +31,16 @@ export interface BuildPageTreeOptions {
) => PageTree.Folder;
attachSeparator?: (node: PageTree.Separator) => PageTree.Separator;
storage: Storage;
getUrl: UrlFn;
resolveIcon?: (icon: string | undefined) => ReactElement | undefined;
}
export interface BuildPageTreeOptionsWithI18n extends BuildPageTreeOptions {
export interface BuildPageTreeOptions extends Options {
storage: Storage;
}
export interface BuildPageTreeOptionsWithI18n extends Options {
storages: Record<string, Storage>;
i18n: I18nConfig;
}
@@ -63,7 +68,7 @@ function isPageFile(node: Folder | File): node is PageFile {
}
/**
* @param nodes - All nodes to be built
* @param nodes - All nodes to be built (in default locale)
* @param ctx - Context
* @param skipIndex - Skip index
* @returns Nodes with specified locale in context (sorted)
@@ -80,14 +85,8 @@ function buildAll(
a.file.name.localeCompare(b.file.name),
)) {
if (isPageFile(node)) {
if (
node.file.locale.length > 0 &&
node.file.locale.slice(1) !== ctx.defaultLanguage
)
continue;
const localized = ctx.storage.read(
joinPath(node.file.dirname, `${node.file.name}.${ctx.lang}`),
const localized = ctx.localeStorage?.read(
joinPath(node.file.dirname, node.file.name),
'page',
);
@@ -166,15 +165,10 @@ function resolveFolderItem(
const path = joinPath(folder.file.path, filename);
let itemNode: Folder | PageFile | undefined = ctx.storage.readDir(path);
if (!itemNode) {
itemNode =
(ctx.lang ? ctx.storage.read(`${path}.${ctx.lang}`, 'page') : null) ??
ctx.storage.read(`${path}.${ctx.defaultLanguage}`, 'page') ??
ctx.storage.read(path, 'page');
}
const itemNode =
ctx.storage.readDir(path) ??
ctx.localeStorage?.read(path, 'page') ??
ctx.storage.read(path, 'page');
if (!itemNode) return [];
addedNodePaths.add(itemNode.file.path);
@@ -196,8 +190,7 @@ function buildFolderNode(
): PageTree.Folder {
const metaPath = joinPath(folder.file.path, 'meta');
const meta =
ctx.storage.read(`${metaPath}.${ctx.lang}`, 'meta') ??
ctx.storage.read(`${metaPath}.${ctx.defaultLanguage}`, 'meta') ??
ctx.localeStorage?.read(metaPath, 'meta') ??
ctx.storage.read(metaPath, 'meta');
const indexFile = ctx.storage.read(
@@ -275,7 +268,7 @@ function buildFileNode(
name: file.data.data.title ?? pathToName(file.file.name),
description: file.data.data.description,
icon: ctx.options.resolveIcon?.(file.data.data.icon),
url: ctx.options.getUrl(file.data.slugs, ctx.lang),
url: ctx.options.getUrl(file.data.slugs, ctx.locale),
$ref: !ctx.options.noRef
? {
file: file.file.path,
@@ -308,11 +301,11 @@ export function createPageTreeBuilder(): PageTreeBuilder {
buildI18n({ i18n, ...options }) {
const entries = i18n.languages.map<[string, PageTree.Root]>((lang) => {
const tree = build({
lang,
options,
builder: this,
storage: options.storage,
defaultLanguage: i18n.defaultLanguage,
locale: lang,
storage: options.storages[i18n.defaultLanguage],
localeStorage: options.storages[lang],
});
return [lang, tree];
+2 -23
View File
@@ -1,13 +1,6 @@
import { slash, splitPath } from '@/utils/path';
export interface FileInfo {
/**
* locale extension from the last second `.`, like `.en`
*
* empty string if no locale
*/
locale: string;
/**
* File path without extension
*
@@ -55,36 +48,22 @@ export function parseFilePath(path: string): FileInfo {
const dirname = segments.slice(0, -1).join('/');
let name = segments.at(-1) ?? '';
let ext = '';
let locale = '';
let dotIdx = name.lastIndexOf('.');
const dotIdx = name.lastIndexOf('.');
if (dotIdx !== -1) {
ext = name.substring(dotIdx);
name = name.substring(0, dotIdx);
}
dotIdx = name.lastIndexOf('.');
if (dotIdx !== -1 && isLocale(name.substring(dotIdx))) {
locale = name.substring(dotIdx);
name = name.substring(0, dotIdx);
}
return {
dirname,
name,
locale,
path: segments.join('/'),
ext,
flattenedPath: [dirname, `${name}${locale}`]
.filter((p) => p.length > 0)
.join('/'),
flattenedPath: [dirname, name].filter((p) => p.length > 0).join('/'),
};
}
function isLocale(code: string): boolean {
return code.length > 0 && !/\d+/.test(code);
}
export function parseFolderPath(path: string): FolderInfo {
const segments = splitPath(slash(path));
const base = segments.at(-1) ?? '';
+10 -11
View File
@@ -7,8 +7,7 @@ export function splitPath(path: string): string[] {
/**
* Resolve paths, slashes within the path will be ignored
* @param from - Path to resolve from
* @param join - Paths to resolve
* @param paths - Paths to join
* @example
* ```
* ['a','b'] // 'a/b'
@@ -17,25 +16,25 @@ export function splitPath(path: string): string[] {
* ['a', '../b/c'] // 'b/c'
* ```
*/
export function joinPath(from: string, join: string): string {
const v1 = splitPath(from),
v2 = splitPath(join);
export function joinPath(...paths: string[]): string {
const out = [];
const parsed = paths.flatMap(splitPath);
while (v2.length > 0) {
switch (v2[0]) {
while (parsed.length > 0) {
switch (parsed[0]) {
case '..':
v1.pop();
out.pop();
break;
case '.':
break;
default:
v1.push(v2[0]);
out.push(parsed[0]);
}
v2.shift();
parsed.shift();
}
return v1.join('/');
return out.join('/');
}
export function slash(path: string): string {
+2 -7
View File
@@ -43,7 +43,6 @@ describe('Path utilities', () => {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": "",
"name": "test",
"path": "test.mdx",
}
@@ -54,7 +53,6 @@ describe('Path utilities', () => {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"locale": "",
"name": "test",
"path": "nested/test.mdx",
}
@@ -65,8 +63,7 @@ describe('Path utilities', () => {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"locale": ".cn",
"name": "test",
"name": "test.cn",
"path": "nested/test.cn.mdx",
}
`);
@@ -76,7 +73,6 @@ describe('Path utilities', () => {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.01",
"locale": "",
"name": "test.01",
"path": "nested/test.01.mdx",
}
@@ -87,8 +83,7 @@ describe('Path utilities', () => {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"locale": ".cn",
"name": "test",
"name": "test.cn",
"path": "nested/test.cn.mdx",
}
`);
+155 -20
View File
@@ -302,9 +302,9 @@ test('Internationalized Routing', () => {
"$ref": {},
"children": [
{
"$id": "nested/test.en.mdx",
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test.en.mdx",
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
@@ -324,9 +324,9 @@ test('Internationalized Routing', () => {
{
"children": [
{
"$id": "test.cn.mdx",
"$id": "test.mdx",
"$ref": {
"file": "test.cn.mdx",
"file": "test.mdx",
},
"name": "Hello Chinese",
"type": "page",
@@ -335,13 +335,13 @@ test('Internationalized Routing', () => {
{
"$id": "nested",
"$ref": {
"metaFile": "nested/meta.cn.json",
"metaFile": "nested/meta.json",
},
"children": [
{
"$id": "nested/test.cn.mdx",
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test.cn.mdx",
"file": "nested/test.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
@@ -368,10 +368,9 @@ test('Internationalized Routing', () => {
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test.cn",
"locale": ".cn",
"flattenedPath": "test",
"name": "test",
"path": "test.cn.mdx",
"path": "test.mdx",
},
"locale": "cn",
"slugs": [
@@ -386,10 +385,9 @@ test('Internationalized Routing', () => {
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"locale": ".cn",
"flattenedPath": "nested/test",
"name": "test",
"path": "nested/test.cn.mdx",
"path": "nested/test.mdx",
},
"locale": "cn",
"slugs": [
@@ -411,7 +409,6 @@ test('Internationalized Routing', () => {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": "",
"name": "test",
"path": "test.mdx",
},
@@ -428,10 +425,9 @@ test('Internationalized Routing', () => {
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.en",
"locale": ".en",
"flattenedPath": "nested/test",
"name": "test",
"path": "nested/test.en.mdx",
"path": "nested/test.mdx",
},
"locale": "en",
"slugs": [
@@ -476,7 +472,7 @@ test('Internationalized Routing: Hide Prefix', () => {
"$id": "nested",
"children": [
{
"$id": "nested/test.en.mdx",
"$id": "nested/test.mdx",
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
@@ -495,7 +491,7 @@ test('Internationalized Routing: Hide Prefix', () => {
{
"children": [
{
"$id": "test.cn.mdx",
"$id": "test.mdx",
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
@@ -504,7 +500,7 @@ test('Internationalized Routing: Hide Prefix', () => {
"$id": "nested",
"children": [
{
"$id": "nested/test.cn.mdx",
"$id": "nested/test.mdx",
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
@@ -625,3 +621,142 @@ test('Loader: Rest operator', () => {
}
`);
});
const i18nSource2: Source<SourceConfig> = {
files: [
{
type: 'page',
path: 'en/test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'page',
path: 'cn/test.mdx',
data: {
title: 'Hello Chinese',
},
},
{
type: 'meta',
path: 'en/meta.json',
data: {
title: 'Docs English',
pages: ['test', 'nested'],
},
},
{
type: 'meta',
path: 'cn/meta.json',
data: {
title: 'Docs Chinese',
pages: ['test', 'nested'],
},
},
{
type: 'page',
path: 'en/nested/test.mdx',
data: {
title: 'Nested Page',
},
},
{
type: 'page',
path: 'cn/nested/test.mdx',
data: {
title: 'Nested Page Chinese',
},
},
{
type: 'meta',
path: 'cn/nested/meta.json',
data: {
title: 'Nested Chinese',
},
},
],
};
test('Internationalized Routing: dir', () => {
const result = loader({
baseUrl: '/',
i18n: {
parser: 'dir',
languages: ['cn', 'en'],
defaultLanguage: 'en',
},
source: i18nSource2,
});
expect(removeUndefined(result.pageTree['en'], true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.mdx",
"$ref": {
"file": "test.mdx",
},
"name": "Hello",
"type": "page",
"url": "/en/test",
},
{
"$id": "nested",
"$ref": {},
"children": [
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
"url": "/en/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "Docs English",
}
`);
expect(removeUndefined(result.pageTree['cn'], true), 'Page Tree')
.toMatchInlineSnapshot(`
{
"children": [
{
"$id": "test.mdx",
"$ref": {
"file": "test.mdx",
},
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"$id": "nested",
"$ref": {
"metaFile": "nested/meta.json",
},
"children": [
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
});
-63
View File
@@ -77,7 +77,6 @@ test('Building File Graph', () => {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": "",
"name": "test",
"path": "test.mdx",
},
@@ -99,7 +98,6 @@ test('Building File Graph', () => {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"locale": "",
"name": "test",
"path": "nested/test.mdx",
},
@@ -122,7 +120,6 @@ test('Building File Graph', () => {
"dirname": "nested/nested",
"ext": ".mdx",
"flattenedPath": "nested/nested/test",
"locale": "",
"name": "test",
"path": "nested/nested/test.mdx",
},
@@ -145,63 +142,3 @@ test('Building File Graph', () => {
]
`);
});
test('Building File Graph - with root directory', () => {
const storage = loadFiles(demoFiles, {
rootDir: 'nested',
getSlugs,
});
expect(storage.root().children).toMatchInlineSnapshot(`
[
{
"data": {
"data": {
"title": "Nested Page",
},
"slugs": [
"test",
],
},
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": "",
"name": "test",
"path": "test.mdx",
},
"format": "page",
},
{
"children": [
{
"data": {
"data": {
"title": "Nested Nested Page",
},
"slugs": [
"nested",
"test",
],
},
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"locale": "",
"name": "test",
"path": "nested/test.mdx",
},
"format": "page",
},
],
"file": {
"dirname": "",
"name": "nested",
"path": "nested",
},
},
]
`);
});