Core: Improve file info interface

This commit is contained in:
Fuma Nama
2025-03-19 20:19:53 +08:00
parent bb9d93d94c
commit 7c8a690e7d
11 changed files with 209 additions and 155 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Improve file info interface
@@ -4,7 +4,7 @@ import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { imageSize } from 'image-size';
import type { MdxjsEsm } from 'mdast-util-mdxjs-esm';
import { resolvePath, slash } from '@/utils/path';
import { joinPath, slash } from '@/utils/path';
import type { ISizeCalculationResult } from 'image-size/types/interface';
import { imageSizeFromFile } from 'image-size/fromFile';
@@ -211,7 +211,7 @@ async function getImageSize(
url = src;
} else if (EXTERNAL_URL_REGEX.test(dir) && isRelative) {
const base = new URL(dir);
base.pathname = resolvePath(base.pathname, src);
base.pathname = joinPath(base.pathname, src);
url = base.toString();
} else {
return imageSizeFromFile(isRelative ? path.join(dir, src) : src);
+15 -4
View File
@@ -1,6 +1,11 @@
import type { MetaData, PageData } from '@/source/types';
import { parseFilePath, parseFolderPath, type FileInfo } from './path';
import { splitPath } from '@/utils/path';
import {
parseFilePath,
parseFolderPath,
type FileInfo,
type FolderInfo,
} from './path';
import { joinPath, splitPath } from '@/utils/path';
export interface MetaFile {
file: FileInfo;
@@ -20,7 +25,7 @@ export interface PageFile {
export type File = MetaFile | PageFile;
export interface Folder {
file: FileInfo;
file: FolderInfo;
children: (File | Folder)[];
}
@@ -73,7 +78,13 @@ export class Storage {
this.makeDir(node.file.dirname);
this.readDir(node.file.dirname)?.children.push(node);
this.files.set(`${node.file.flattenedPath}.${node.format}`, node);
this.files.set(
joinPath(
node.file.dirname,
`${node.file.name}${node.file.locale}.${node.format}`,
),
node,
);
}
list(): File[] {
+6 -1
View File
@@ -9,4 +9,9 @@ export {
type VirtualFile,
} from './load-files';
export type * from './types';
export { type FileInfo, parseFilePath, parseFolderPath } from './path';
export {
type FileInfo,
type FolderInfo,
parseFilePath,
parseFolderPath,
} from './path';
+12 -9
View File
@@ -9,9 +9,9 @@ import {
import type { MetaData, PageData, UrlFn } from './types';
import type { BuildPageTreeOptions } from './page-tree-builder';
import { createPageTreeBuilder } from './page-tree-builder';
import { type FileInfo, getLocale } from './path';
import { type FileInfo } from './path';
import type { MetaFile, PageFile, Storage } from './file-system';
import * as path from 'node:path';
import { joinPath } from '@/utils/path';
export interface LoaderConfig {
source: SourceConfig;
@@ -88,7 +88,7 @@ export interface LoaderOutput<Config extends LoaderConfig> {
href: string,
options?: {
/**
* resolve relative file paths in `href` from specified directory
* resolve relative file paths in `href` from specified dirname, must be a virtual path.
*/
dir?: string;
},
@@ -156,16 +156,19 @@ function indexPages(
if (item.format === 'meta')
pathToFile.set(item.file.path, fileToMeta(item));
if (
item.format === 'page' &&
(item.file.locale ?? defaultLanguage) === defaultLanguage
) {
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 = getLocale(item.file.flattenedPath)[0];
const basePath = joinPath(item.file.dirname, item.file.name);
for (const lang of i18n.languages) {
if (lang === defaultLanguage) continue;
@@ -285,7 +288,7 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
value.startsWith('.') &&
(value.endsWith('.md') || value.endsWith('.mdx'))
) {
const hrefPath = path.join(dir, value);
const hrefPath = joinPath(dir, value);
const target = pages.find((item) => item.file.path === hrefPath);
if (target)
+19 -41
View File
@@ -2,9 +2,8 @@ import type { ReactElement } from 'react';
import type { I18nConfig } from '@/i18n';
import type * as PageTree from '../server/page-tree';
import type { File, Folder, MetaFile, PageFile, Storage } from './file-system';
import { resolvePath } from '@/utils/path';
import { joinPath } from '@/utils/path';
import { type UrlFn } from './types';
import { getLocale } from '@/source/path';
interface PageTreeBuilderContext {
lang?: string;
@@ -80,14 +79,16 @@ function buildAll(
for (const node of [...nodes].sort((a, b) =>
a.file.name.localeCompare(b.file.name),
)) {
const locale = node.file.locale ?? ctx.defaultLanguage;
if (isPageFile(node)) {
if (
node.file.locale.length > 0 &&
node.file.locale.slice(1) !== ctx.defaultLanguage
)
continue;
if (isPageFile(node) && locale === ctx.defaultLanguage) {
const localized = findFileOfLocale(
node.file.flattenedPath,
ctx.lang,
const localized = ctx.storage.read(
joinPath(node.file.dirname, `${node.file.name}.${ctx.lang}`),
'page',
ctx,
);
const treeNode = buildFileNode(localized ?? node, ctx);
@@ -163,21 +164,15 @@ function resolveFolderItem(
filename = item.slice(extractPrefix.length);
}
const path = resolvePath(folder.file.path, filename);
const path = joinPath(folder.file.path, filename);
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);
}
itemNode =
(ctx.lang ? ctx.storage.read(`${path}.${ctx.lang}`, 'page') : null) ??
ctx.storage.read(`${path}.${ctx.defaultLanguage}`, 'page') ??
ctx.storage.read(path, 'page');
}
if (!itemNode) return [];
@@ -199,13 +194,14 @@ function buildFolderNode(
isGlobalRoot: boolean,
ctx: PageTreeBuilderContext,
): PageTree.Folder {
const metaPath = resolvePath(folder.file.path, 'meta');
const metaPath = joinPath(folder.file.path, 'meta');
const meta =
findFileOfLocale(metaPath, ctx.lang, 'meta', ctx) ??
findFileOfLocale(metaPath, ctx.defaultLanguage, 'meta', ctx);
ctx.storage.read(`${metaPath}.${ctx.lang}`, 'meta') ??
ctx.storage.read(`${metaPath}.${ctx.defaultLanguage}`, 'meta') ??
ctx.storage.read(metaPath, 'meta');
const indexFile = ctx.storage.read(
resolvePath(folder.file.flattenedPath, 'index'),
joinPath(folder.file.path, 'index'),
'page',
);
@@ -327,24 +323,6 @@ export function createPageTreeBuilder(): PageTreeBuilder {
};
}
function findFileOfLocale<F extends File['format']>(
path: string,
locale: string | undefined | null,
format: F,
ctx: PageTreeBuilderContext,
): Extract<File, { format: F }> | undefined {
const pathWithoutLocale = getLocale(path)[0];
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);
}
}
/**
* Get item name from file name
*
+54 -29
View File
@@ -2,9 +2,18 @@ import { slash, splitPath } from '@/utils/path';
export interface FileInfo {
/**
* The locale extension of file
* locale extension from the last second `.`, like `.en`
*
* empty string if no locale
*/
locale?: string;
locale: string;
/**
* File path without extension
*
* @deprecated obtain it with `join(dirname, name)`
*/
flattenedPath: string;
/**
* Original path of file
@@ -12,12 +21,28 @@ export interface FileInfo {
path: string;
/**
* File path without extension
* File name without locale and extension
*/
flattenedPath: string;
name: string;
/**
* File name without locale and extension
* file extension from the last `.`, like `.md`
*
* empty string if no file extension
*/
ext: string;
dirname: string;
}
export interface FolderInfo {
/**
* Original path of folder
*/
path: string;
/**
* folder name
*/
name: string;
@@ -28,49 +53,49 @@ export function parseFilePath(path: string): FileInfo {
const segments = splitPath(slash(path));
const dirname = segments.slice(0, -1).join('/');
const base = segments.at(-1) ?? '';
let name = segments.at(-1) ?? '';
let ext = '';
let locale = '';
const dotIdx = base.lastIndexOf('.');
const nameWithLocale = dotIdx !== -1 ? base.slice(0, dotIdx) : base;
let dotIdx = name.lastIndexOf('.');
if (dotIdx !== -1) {
ext = name.substring(dotIdx);
name = name.substring(0, dotIdx);
}
const flattenedPath = [dirname, nameWithLocale]
.filter((p) => p.length > 0)
.join('/');
dotIdx = name.lastIndexOf('.');
if (dotIdx !== -1 && isLocale(name.substring(dotIdx))) {
locale = name.substring(dotIdx);
name = name.substring(0, dotIdx);
}
const [name, locale] = getLocale(nameWithLocale);
return {
dirname,
name,
flattenedPath,
locale,
path: segments.join('/'),
ext,
flattenedPath: [dirname, `${name}${locale}`]
.filter((p) => p.length > 0)
.join('/'),
};
}
export function parseFolderPath(path: string): FileInfo {
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) ?? '';
const [name, locale] = getLocale(base);
const flattenedPath = segments.join('/');
return {
dirname: segments.slice(0, -1).join('/'),
name,
flattenedPath,
locale,
path: flattenedPath,
name: base,
path: segments.join('/'),
};
}
export function getLocale(name: string): [string, string?] {
const sep = name.lastIndexOf('.');
if (sep === -1) return [name];
const locale = name.slice(sep + 1);
if (/\d+/.exec(locale)) return [name];
return [name.slice(0, sep), locale];
}
/**
* @param path - Relative path
* @returns Normalized path, with no trailing/leading slashes
+1 -1
View File
@@ -17,7 +17,7 @@ export function splitPath(path: string): string[] {
* ['a', '../b/c'] // 'b/c'
* ```
*/
export function resolvePath(from: string, join: string): string {
export function joinPath(from: string, join: string): string {
const v1 = splitPath(from),
v2 = splitPath(join);
+77 -53
View File
@@ -4,7 +4,7 @@ import type { Root } from '@/server/page-tree';
import { findNeighbour } from '@/server/page-tree-utils';
import { PageTree } from '../dist/server';
import { getBreadcrumbItems } from '@/breadcrumb';
import { resolvePath, splitPath } from '@/utils/path';
import { joinPath, splitPath } from '@/utils/path';
test('Find Neighbours', () => {
const tree: Root = {
@@ -38,71 +38,95 @@ test('Find Neighbours', () => {
describe('Path utilities', () => {
test('parse file path', () => {
expect(parseFilePath('test.mdx')).toEqual({
dirname: '',
name: 'test',
flattenedPath: 'test',
locale: undefined,
path: 'test.mdx',
});
expect(parseFilePath('test.mdx')).toMatchInlineSnapshot(`
{
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": "",
"name": "test",
"path": "test.mdx",
}
`);
expect(parseFilePath('nested/test.mdx')).toEqual({
dirname: 'nested',
name: 'test',
flattenedPath: 'nested/test',
locale: undefined,
path: 'nested/test.mdx',
});
expect(parseFilePath('nested/test.mdx')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"locale": "",
"name": "test",
"path": "nested/test.mdx",
}
`);
expect(parseFilePath('nested/test.cn.mdx')).toEqual({
dirname: 'nested',
name: 'test',
flattenedPath: 'nested/test.cn',
locale: 'cn',
path: 'nested/test.cn.mdx',
});
expect(parseFilePath('nested/test.cn.mdx')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"locale": ".cn",
"name": "test",
"path": "nested/test.cn.mdx",
}
`);
expect(parseFilePath('nested/test.01.mdx')).toEqual({
dirname: 'nested',
name: 'test.01',
flattenedPath: 'nested/test.01',
path: 'nested/test.01.mdx',
});
expect(parseFilePath('nested/test.01.mdx')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.01",
"locale": "",
"name": "test.01",
"path": "nested/test.01.mdx",
}
`);
expect(parseFilePath('nested\\test.cn.mdx')).toEqual(
parseFilePath('nested/test.cn.mdx'),
);
expect(parseFilePath('nested\\test.cn.mdx')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"locale": ".cn",
"name": "test",
"path": "nested/test.cn.mdx",
}
`);
});
test('parse folder path', () => {
expect(parseFolderPath('nested')).toEqual({
dirname: '',
name: 'nested',
flattenedPath: 'nested',
locale: undefined,
path: 'nested',
});
expect(parseFolderPath('')).toMatchInlineSnapshot(`
{
"dirname": "",
"name": "",
"path": "",
}
`);
expect(parseFolderPath('nested/nested')).toEqual({
dirname: 'nested',
name: 'nested',
flattenedPath: 'nested/nested',
locale: undefined,
path: 'nested/nested',
});
expect(parseFolderPath('nested/nested')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"name": "nested",
"path": "nested/nested",
}
`);
expect(parseFolderPath('nested\\nested')).toEqual(
parseFolderPath('nested/nested'),
);
expect(parseFolderPath('nested\\nested')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"name": "nested",
"path": "nested/nested",
}
`);
});
test('resolve paths', () => {
expect(resolvePath('a', 'b')).toBe('a/b');
expect(resolvePath('/a', '')).toBe('a');
expect(resolvePath('a/', '/b')).toBe('a/b');
expect(joinPath('a', 'b')).toBe('a/b');
expect(joinPath('/a', '')).toBe('a');
expect(joinPath('a/', '/b')).toBe('a/b');
expect(resolvePath('a/', '../b/c')).toBe('b/c');
expect(resolvePath('a/', './b/c')).toBe('a/b/c');
expect(joinPath('a/', '../b/c')).toBe('b/c');
expect(joinPath('a/', './b/c')).toBe('a/b/c');
});
test('split paths', () => {
+8 -4
View File
@@ -367,8 +367,9 @@ test('Internationalized Routing', () => {
},
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test.cn",
"locale": "cn",
"locale": ".cn",
"name": "test",
"path": "test.cn.mdx",
},
@@ -384,8 +385,9 @@ test('Internationalized Routing', () => {
},
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"locale": "cn",
"locale": ".cn",
"name": "test",
"path": "nested/test.cn.mdx",
},
@@ -407,8 +409,9 @@ test('Internationalized Routing', () => {
},
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": undefined,
"locale": "",
"name": "test",
"path": "test.mdx",
},
@@ -424,8 +427,9 @@ test('Internationalized Routing', () => {
},
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.en",
"locale": "en",
"locale": ".en",
"name": "test",
"path": "nested/test.en.mdx",
},
+10 -11
View File
@@ -75,8 +75,9 @@ test('Building File Graph', () => {
},
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": undefined,
"locale": "",
"name": "test",
"path": "test.mdx",
},
@@ -96,8 +97,9 @@ test('Building File Graph', () => {
},
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"locale": undefined,
"locale": "",
"name": "test",
"path": "nested/test.mdx",
},
@@ -118,8 +120,9 @@ test('Building File Graph', () => {
},
"file": {
"dirname": "nested/nested",
"ext": ".mdx",
"flattenedPath": "nested/nested/test",
"locale": undefined,
"locale": "",
"name": "test",
"path": "nested/nested/test.mdx",
},
@@ -128,8 +131,6 @@ test('Building File Graph', () => {
],
"file": {
"dirname": "nested",
"flattenedPath": "nested/nested",
"locale": undefined,
"name": "nested",
"path": "nested/nested",
},
@@ -137,8 +138,6 @@ test('Building File Graph', () => {
],
"file": {
"dirname": "",
"flattenedPath": "nested",
"locale": undefined,
"name": "nested",
"path": "nested",
},
@@ -166,8 +165,9 @@ test('Building File Graph - with root directory', () => {
},
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"locale": undefined,
"locale": "",
"name": "test",
"path": "test.mdx",
},
@@ -187,8 +187,9 @@ test('Building File Graph - with root directory', () => {
},
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"locale": undefined,
"locale": "",
"name": "test",
"path": "nested/test.mdx",
},
@@ -197,8 +198,6 @@ test('Building File Graph - with root directory', () => {
],
"file": {
"dirname": "",
"flattenedPath": "nested",
"locale": undefined,
"name": "nested",
"path": "nested",
},