Core: Support markdown files with default local code in file name

This commit is contained in:
Fuma Nama
2025-03-18 23:30:56 +08:00
parent 5163e9202a
commit f3cde4f1de
6 changed files with 92 additions and 99 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support markdown files with default local code in file name
@@ -1,16 +1,12 @@
You can create a Markdown or meta file for specific language by adding `.{locale}` to your file name, like `page.fr.md` and `meta.fr.json`.
You can add Markdown/meta files for different languages by attending `.{locale}` to your file name, like `page.fr.md` and `meta.fr.json`.
Be aware that you must create a file for the default locale first.
Make sure to create a file for the default locale first, the locale code is optional (e.g. both `get-started.mdx` and `get-started.en.mdx` are accepted).
> For the default language, don't add the locale code to file name, just leave it like `get-started.mdx`.
### Example
Assume your default language is `en`.
| Name | |
| ----------------- | --------------------------------------------- |
| file.mdx | Correct |
| file.cn.mdx | Correct |
| file.en.mdx | Default locale doesn't need a locale code |
| components.cn.mdx | Missing `components.mdx` for default language |
<Files>
<Folder name="content/docs" defaultOpen>
<File name="meta.json" />
<File name="meta.cn.json" />
<File name="get-started.mdx" />
<File name="get-started.cn.mdx" />
</Folder>
</Files>
+13 -9
View File
@@ -9,7 +9,7 @@ import {
import type { MetaData, PageData, UrlFn } from './types';
import type { BuildPageTreeOptions } from './page-tree-builder';
import { createPageTreeBuilder } from './page-tree-builder';
import { type FileInfo } from './path';
import { type FileInfo, getLocale } from './path';
import type { MetaFile, PageFile, Storage } from './file-system';
import * as path from 'node:path';
@@ -156,21 +156,25 @@ function indexPages(
if (item.format === 'meta')
pathToFile.set(item.file.path, fileToMeta(item));
if (item.format === 'page') {
const page = fileToPage(item, getUrl, item.file.locale);
if (
item.format === 'page' &&
(item.file.locale ?? defaultLanguage) === defaultLanguage
) {
const page = fileToPage(item, getUrl, defaultLanguage);
pathToFile.set(item.file.path, page);
if (item.file.locale) continue;
map.set(`${defaultLanguage}.${page.slugs.join('/')}`, page);
if (!i18n) continue;
const basePath = getLocale(item.file.flattenedPath)[0];
for (const lang of i18n.languages) {
const localized = storage.read(
`${item.file.flattenedPath}.${lang}`,
'page',
);
if (lang === defaultLanguage) continue;
const localizedPage = fileToPage(localized ?? item, getUrl, lang);
const localizedPage = fileToPage(
storage.read(`${basePath}.${lang}`, 'page') ?? item,
getUrl,
lang,
);
map.set(`${lang}.${localizedPage.slugs.join('/')}`, localizedPage);
}
}
+55 -28
View File
@@ -4,15 +4,15 @@ import type * as PageTree from '../server/page-tree';
import type { File, Folder, MetaFile, PageFile, Storage } from './file-system';
import { resolvePath } from '@/utils/path';
import { type UrlFn } from './types';
import { getLocale } from '@/source/path';
interface PageTreeBuilderContext {
lang?: string;
defaultLanguage?: string;
storage: Storage;
builder: PageTreeBuilder;
options: BuildPageTreeOptions;
i18n?: I18nConfig;
}
export interface BuildPageTreeOptions {
@@ -77,11 +77,20 @@ function buildAll(
const output: PageTree.Node[] = [];
const folders: PageTree.Folder[] = [];
for (const node of [...nodes].sort((a, b) =>
for (const node of nodes.toSorted((a, b) =>
a.file.name.localeCompare(b.file.name),
)) {
if (isPageFile(node) && !node.file.locale) {
const treeNode = buildFileNode(node, ctx);
const locale = node.file.locale ?? ctx.defaultLanguage;
if (isPageFile(node) && locale === ctx.defaultLanguage) {
const localized = findFileOfLocale(
node.file.flattenedPath,
ctx.lang,
'page',
ctx,
);
const treeNode = buildFileNode(localized ?? node, ctx);
if (node.file.name === 'index') {
if (!skipIndex) output.unshift(treeNode);
@@ -92,14 +101,12 @@ function buildAll(
}
if ('children' in node) {
if (
node.children.length === 1 &&
node.children[0].file.name === 'index' &&
isPageFile(node.children[0])
) {
output.push(buildFileNode(node.children[0], ctx));
const folder = buildFolderNode(node, false, ctx);
if (folder.children.length === 0 && folder.index) {
output.push(folder.index);
} else {
folders.push(buildFolderNode(node, false, ctx));
folders.push(folder);
}
}
}
@@ -158,7 +165,21 @@ function resolveFolderItem(
const path = resolvePath(folder.file.path, filename);
const itemNode = ctx.storage.readDir(path) ?? ctx.storage.read(path, 'page');
let itemNode: Folder | PageFile | undefined = ctx.storage.readDir(path);
if (!itemNode) {
itemNode = ctx.storage.read(path, 'page');
// find localized page
// use target locale if no forced locale, or specified file doesn't exist
if (ctx.lang && !itemNode?.file.locale) {
itemNode =
findFileOfLocale(path, ctx.lang, 'page', ctx) ??
// fallback to default
findFileOfLocale(path, ctx.defaultLanguage, 'page', ctx);
}
}
if (!itemNode) return [];
addedNodePaths.add(itemNode.file.path);
@@ -180,8 +201,9 @@ function buildFolderNode(
): PageTree.Folder {
const metaPath = resolvePath(folder.file.path, 'meta');
const meta =
findLocalizedFile(metaPath, 'meta', ctx) ??
ctx.storage.read(metaPath, 'meta');
findFileOfLocale(metaPath, ctx.lang, 'meta', ctx) ??
findFileOfLocale(metaPath, ctx.defaultLanguage, 'meta', ctx);
const indexFile = ctx.storage.read(
resolvePath(folder.file.flattenedPath, 'index'),
'page',
@@ -251,19 +273,16 @@ function buildFileNode(
file: PageFile,
ctx: PageTreeBuilderContext,
): PageTree.Item {
const localized =
findLocalizedFile(file.file.flattenedPath, 'page', ctx) ?? file;
const item: PageTree.Item = {
$id: localized.file.path,
$id: file.file.path,
type: 'page',
name: localized.data.data.title ?? pathToName(localized.file.name),
description: localized.data.data.description,
icon: ctx.options.resolveIcon?.(localized.data.data.icon),
url: ctx.options.getUrl(localized.data.slugs, ctx.lang),
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),
$ref: !ctx.options.noRef
? {
file: localized.file.path,
file: file.file.path,
}
: undefined,
};
@@ -297,7 +316,7 @@ export function createPageTreeBuilder(): PageTreeBuilder {
options,
builder: this,
storage: options.storage,
i18n,
defaultLanguage: i18n.defaultLanguage,
});
return [lang, tree];
@@ -308,14 +327,22 @@ export function createPageTreeBuilder(): PageTreeBuilder {
};
}
function findLocalizedFile<F extends File['format']>(
function findFileOfLocale<F extends File['format']>(
path: string,
locale: string | undefined | null,
format: F,
ctx: PageTreeBuilderContext,
): Extract<File, { format: F }> | undefined {
if (!ctx.lang) return;
const pathWithoutLocale = getLocale(path)[0];
return ctx.storage.read(`${path}.${ctx.lang}`, format);
if (locale) {
const result = ctx.storage.read(`${pathWithoutLocale}.${locale}`, format);
if (result) return result;
}
if ((locale ?? ctx.defaultLanguage) === ctx.defaultLanguage) {
return ctx.storage.read(pathWithoutLocale, format);
}
}
/**
+1 -1
View File
@@ -62,7 +62,7 @@ export function parseFolderPath(path: string): FileInfo {
};
}
function getLocale(name: string): [string, string?] {
export function getLocale(name: string): [string, string?] {
const sep = name.lastIndexOf('.');
if (sep === -1) return [name];
const locale = name.slice(sep + 1);
+8 -47
View File
@@ -226,7 +226,7 @@ const i18nSource: Source<SourceConfig> = {
},
{
type: 'meta',
path: 'meta.json',
path: 'meta.en.json',
data: {
title: 'Docs English',
pages: ['test', 'nested'],
@@ -242,7 +242,7 @@ const i18nSource: Source<SourceConfig> = {
},
{
type: 'page',
path: '/nested/test.mdx',
path: '/nested/test.en.mdx',
data: {
title: 'Nested Page',
},
@@ -302,9 +302,9 @@ test('Internationalized Routing', () => {
"$ref": {},
"children": [
{
"$id": "nested/test.mdx",
"$id": "nested/test.en.mdx",
"$ref": {
"file": "nested/test.mdx",
"file": "nested/test.en.mdx",
},
"name": "Nested Page",
"type": "page",
@@ -356,45 +356,6 @@ test('Internationalized Routing', () => {
}
`);
expect(result.getPages()).toMatchInlineSnapshot(`
[
{
"data": {
"title": "Hello",
},
"file": {
"dirname": "",
"flattenedPath": "test",
"locale": undefined,
"name": "test",
"path": "test.mdx",
},
"locale": "en",
"slugs": [
"test",
],
"url": "/en/test",
},
{
"data": {
"title": "Nested Page",
},
"file": {
"dirname": "nested",
"flattenedPath": "nested/test",
"locale": undefined,
"name": "test",
"path": "nested/test.mdx",
},
"locale": "en",
"slugs": [
"nested",
"test",
],
"url": "/en/nested/test",
},
]
`);
expect(result.getLanguages()).toMatchInlineSnapshot(`
[
{
@@ -463,10 +424,10 @@ test('Internationalized Routing', () => {
},
"file": {
"dirname": "nested",
"flattenedPath": "nested/test",
"locale": undefined,
"flattenedPath": "nested/test.en",
"locale": "en",
"name": "test",
"path": "nested/test.mdx",
"path": "nested/test.en.mdx",
},
"locale": "en",
"slugs": [
@@ -511,7 +472,7 @@ test('Internationalized Routing: Hide Prefix', () => {
"$id": "nested",
"children": [
{
"$id": "nested/test.mdx",
"$id": "nested/test.en.mdx",
"name": "Nested Page",
"type": "page",
"url": "/nested/test",