Core: Support fallbackLanguage for loader() API

This commit is contained in:
Fuma Nama
2025-08-22 17:08:35 +08:00
parent b4e61473e2
commit e1c84a229e
7 changed files with 84 additions and 59 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support `fallbackLanguage` for `loader()` i18n API
@@ -3,7 +3,7 @@ title: Internationalization
description: Support multiple languages in your documentation
---
## Introduction
## Define Config
Fumadocs core provides necessary middleware and options for i18n support.
@@ -18,13 +18,13 @@ You can define a config to share between utilities.
To hide the locale prefix (e.g. `/en/page` -> `/page`), use the `hideLocale` option.
```ts
import type { I18nConfig } from 'fumadocs-core/i18n';
import { defineI18n } from 'fumadocs-core/i18n';
export const i18n: I18nConfig = {
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['en', 'cn'],
hideLocale: 'default-locale',
};
});
```
| Mode | Description |
@@ -41,7 +41,26 @@ This may cause undesired cache problems, and need to pay extra attention on SEO
</Callout>
### Middleware
### Fallback Language
The fallback language to use when translations are missing for a page, default to your `defaultLanguage`.
Supported:
- A language in your `languages` array.
- When set to `null`, not fallback will be used.
```ts
import { defineI18n } from 'fumadocs-core/i18n';
export const i18n = defineI18n({
languages: ['en', 'cn'],
defaultLanguage: 'en',
fallbackLanguage: 'cn',
});
```
## Middleware
Redirects users to appropriate locale, it can be customised from `i18n.ts`.
@@ -79,7 +79,7 @@ export const source = loader({
With i18n enabled, loader will generate a page tree for every locale.
When looking for a page, it fallbacks to default locale if the page doesn't exist for specified locale.
If translations are missing for a page, it fallbacks to [`fallbackLanguage`](/docs/headless/internationalization#fallback-language).
## Output
+8 -1
View File
@@ -29,7 +29,14 @@ export interface I18nConfig<Languages extends string = string> {
*
* @defaultValue 'dot'
*/
parser?: 'dot' | 'dir';
parser?: 'dot' | 'dir' | 'none';
/**
* the fallback language when the page has no translations available for a given locale.
*
* Default to ``defaultLanguage`, no fallback when set to `null`.
*/
fallbackLanguage?: Languages | null;
}
export function defineI18n<Languages extends string>(
+19 -15
View File
@@ -3,6 +3,7 @@ import { FileSystem } from './file-system';
import { joinPath, slash, splitPath } from '@/utils/path';
import { type VirtualFile } from '@/source/loader';
import { basename, dirname } from '@/source/path';
import type { I18nConfig } from '@/i18n';
export interface LoadOptions {
transformers?: Transformer[];
@@ -74,14 +75,10 @@ const parsers = {
export function loadFiles(
files: VirtualFile[],
options: LoadOptions,
i18n: {
parser: keyof typeof parsers;
languages: string[];
defaultLanguage: string;
},
i18n: I18nConfig,
): Record<string, ContentStorage> {
const { buildFile, transformers = [] } = options;
const parser = parsers[i18n.parser];
const parser = parsers[i18n.parser ?? 'dot'];
const storages: Record<string, ContentStorage> = {};
const normalized = files.map((file) =>
buildFile({
@@ -89,9 +86,21 @@ export function loadFiles(
path: normalizePath(file.path),
}),
);
const fallbackLang =
i18n.fallbackLanguage !== null
? (i18n.fallbackLanguage ?? i18n.defaultLanguage)
: null;
function scan(lang: string, fallback?: ContentStorage): ContentStorage {
const storage: ContentStorage = new FileSystem(fallback);
function scan(lang: string) {
if (storages[lang]) return;
let storage: ContentStorage;
if (fallbackLang && fallbackLang !== lang) {
scan(fallbackLang);
storage = new FileSystem(storages[fallbackLang]);
} else {
storage = new FileSystem();
}
for (const item of normalized) {
const [path, locale = i18n.defaultLanguage] = parser(item.path);
@@ -106,15 +115,10 @@ export function loadFiles(
});
}
return storage;
}
storages[i18n.defaultLanguage] = scan(i18n.defaultLanguage);
for (const lang of i18n.languages) {
storages[lang] ??= scan(lang, storages[i18n.defaultLanguage]);
storages[lang] = storage;
}
for (const lang of i18n.languages) scan(lang);
return storages;
}
+8 -12
View File
@@ -274,13 +274,14 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
const transformerSlugs: Transformer = ({ storage }) => {
const indexFiles = new Set<string>();
const taken = new Set<string>();
// for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
const autoIndex = slugsFn === undefined;
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(path) && !slugsFn) {
if (isIndex(path) && autoIndex) {
indexFiles.add(path);
continue;
}
@@ -324,16 +325,11 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
},
transformers: [transformerSlugs, ...transformers],
},
i18n
? {
...i18n,
parser: i18n.parser ?? 'dot',
}
: {
defaultLanguage,
parser: 'none',
languages: [defaultLanguage],
},
i18n ?? {
defaultLanguage,
parser: 'none',
languages: [defaultLanguage],
},
);
const walker = indexPages(storages, getUrl);
+19 -25
View File
@@ -15,7 +15,7 @@ export interface PageTreeBuilderContext<
Meta extends MetaData = MetaData,
> {
/**
* @internal
* @internal resolve paths without extensions
*/
resolveName: (name: string, format: 'meta' | 'page') => string;
options: BaseOptions<Page, Meta>;
@@ -70,6 +70,12 @@ export interface BaseOptions<
noRef?: boolean;
transformers?: PageTreeTransformer<Page, Meta>[];
resolveIcon?: (icon: string | undefined) => ReactNode | undefined;
/**
* generate fallback page tree
*
* @defaultValue true
*/
generateFallback?: boolean;
}
export interface PageTreeBuilder<
@@ -79,15 +85,7 @@ export interface PageTreeBuilder<
build: (
options: BaseOptions<Page, Meta> & {
id?: string;
storage: ContentStorage<Page, Meta>;
/**
* generate fallback page tree
*
* @defaultValue true
*/
generateFallback?: boolean;
},
) => PageTree.Root;
@@ -347,7 +345,7 @@ function build(id: string, ctx: PageTreeBuilderContext): PageTree.Root {
}
export function createPageTreeBuilder(getUrl: UrlFn): PageTreeBuilder {
function getTransformers(options: BaseOptions, generateFallback = true) {
function getTransformers(options: BaseOptions, generateFallback: boolean) {
const transformers: PageTreeTransformer[] = [legacyTransformer(options)];
if (options.transformers) {
@@ -377,28 +375,24 @@ export function createPageTreeBuilder(getUrl: UrlFn): PageTreeBuilder {
}
return {
build({ storage, id, generateFallback, ...options }) {
const resolve = createFlattenPathResolver(storage);
build({ storage, id, ...options }) {
const key = '';
return build(id ?? 'root', {
transformers: getTransformers(options, generateFallback),
options,
builder: this,
storage,
getUrl,
resolveName(name, format) {
return resolve(name, format) ?? name;
},
});
return this.buildI18n({
id,
storages: { [key]: storage },
...options,
})[key];
},
buildI18n({ id, storages, ...options }) {
const transformers = getTransformers(options);
buildI18n({ id, storages, generateFallback = true, ...options }) {
const transformers = getTransformers(options, generateFallback);
const out: Record<string, PageTree.Root> = {};
for (const [locale, storage] of Object.entries(storages)) {
const resolve = createFlattenPathResolver(storage);
const branch = locale.length === 0 ? 'root' : locale;
out[locale] = build(id ?? (locale.length === 0 ? 'root' : locale), {
out[locale] = build(id ? `${id}-${branch}` : branch, {
transformers,
builder: this,
options,