Core: Support external link in pages property

This commit is contained in:
Fuma Nama
2024-03-03 16:30:41 +08:00
parent d747d0909c
commit b5d1693854
10 changed files with 323 additions and 268 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support external link in `pages` property
+8 -8
View File
@@ -1,5 +1,10 @@
import type { FileInfo, MetaData, PageData } from './types';
import { parseFilePath, parseFolderPath, splitPath } from './path';
import type { MetaData, PageData } from './types';
import {
parseFilePath,
parseFolderPath,
splitPath,
type FileInfo,
} from './path';
export interface Meta<Data extends MetaData = MetaData> {
type: 'meta';
@@ -21,14 +26,9 @@ export interface Folder<
> {
type: 'folder';
file: FileInfo;
children: Node<MD, PD>[];
children: (Meta<MD> | Page<PD> | Folder<MD, PD>)[];
}
export type Node<
MD extends MetaData = MetaData,
PD extends PageData = PageData,
> = Meta<MD> | Page<PD> | Folder<MD, PD>;
/**
* A virtual file system that solves inconsistent behaviours
*
+1 -1
View File
@@ -1,5 +1,5 @@
export * from './page-tree-builder';
export * from './types';
export * from './create';
export * from './loader';
export * as FileSystem from './file-system';
export * from './load';
+15 -16
View File
@@ -1,6 +1,6 @@
import * as path from 'node:path';
import type { FileInfo, MetaData, PageData, Transformer } from './types';
import { parseFilePath } from './path';
import type { MetaData, PageData } from './types';
import { parseFilePath, type FileInfo } from './path';
import { Storage } from './file-system';
export interface LoadOptions {
@@ -19,28 +19,29 @@ export interface VirtualFile {
export interface LoadResult {
storage: Storage;
}
export type Transformer = (context: {
storage: Storage;
getSlugs: (info: FileInfo) => string[];
getUrl: (slugs: string[], locale?: string) => string;
data: Record<string, unknown>;
}
}) => void;
// Virtual files -> Virtual Storage -> Plugins -> Result
// Result should contain page tree and basic utilities
export function load(options: LoadOptions): LoadResult {
const { transformers = [] } = options;
const storage = buildStorage(options);
const ctx: LoadResult = {
getSlugs: options.getSlugs,
getUrl: options.getUrl,
storage,
data: {},
};
for (const transformer of transformers) {
transformer(ctx);
transformer({
storage,
getUrl: options.getUrl,
getSlugs: options.getSlugs,
});
}
return ctx;
return { storage };
}
function buildStorage(options: LoadOptions): Storage {
@@ -52,10 +53,8 @@ function buildStorage(options: LoadOptions): Storage {
path.basename(file.path),
);
const isRelative =
!relativePath.startsWith('..') && !path.isAbsolute(relativePath);
if (!isRelative) continue;
if (relativePath.startsWith('..') || path.isAbsolute(relativePath))
continue;
if (file.type === 'page') {
const parsedPath = parseFilePath(relativePath);
@@ -1,10 +1,15 @@
import type * as PageTree from '@/server/page-tree';
import { load, type LoadOptions, type VirtualFile } from './load';
import type { FileInfo, MetaData, PageData, Transformer } from './types';
import {
load,
type LoadOptions,
type VirtualFile,
type Transformer,
} from './load';
import type { MetaData, PageData } from './types';
import type { CreatePageTreeBuilderOptions } from './page-tree-builder';
import { createPageTreeBuilder } from './page-tree-builder';
import { joinPaths, splitPath } from './path';
import type { Node, Page } from './file-system';
import { joinPaths, splitPath, type FileInfo } from './path';
import type { Meta, Page, Storage } from './file-system';
interface LoaderConfig {
source: SourceConfig;
@@ -47,7 +52,10 @@ export interface LoaderOutput<Config extends LoaderConfig> {
? Record<string, PageTree.Root>
: PageTree.Root;
files: Node<Config['source']['metaData'], Config['source']['pageData']>[];
files: (
| Meta<Config['source']['metaData']>
| Page<Config['source']['pageData']>
)[];
/**
* Get list of pages from language, empty if language hasn't specified
@@ -65,30 +73,22 @@ export interface LoaderOutput<Config extends LoaderConfig> {
}
function groupByLanguages(
nodes: Node[],
storage: Storage,
languages: string[],
): Map<string, Page[]> {
const pageMap = new Map<string, Page>();
for (const node of nodes) {
if (node.type === 'page') pageMap.set(node.file.flattenedPath, node);
}
const langMap = new Map<string, Page[]>();
langMap.set('', []);
for (const lang of languages) {
langMap.set(lang, []);
}
for (const [key, node] of pageMap) {
if (node.file.locale) continue;
for (const node of storage.list()) {
if (node.type !== 'page' || node.file.locale) continue;
langMap.get('')?.push(node);
for (const lang of languages) {
const v = pageMap.get(`${key}.${lang}`) ?? node;
langMap.get(lang)?.push(v);
const list = langMap.get(lang) ?? [];
const page =
storage.readPage(`${node.file.flattenedPath}.${lang}`) ?? node;
list.push(page);
langMap.set(lang, list);
}
}
@@ -141,7 +141,7 @@ function createOutput({
getSlugs: slugs,
getUrl: url,
});
const i18nMap = groupByLanguages(result.storage.list(), languages ?? []);
const i18nMap = groupByLanguages(result.storage, languages ?? []);
const builder = createPageTreeBuilder({
storage: result.storage,
resolveIcon: icon,
+16 -4
View File
@@ -1,6 +1,6 @@
import type { ReactElement } from 'react';
import type * as PageTree from '../server/page-tree';
import type { Folder, Meta, Node, Page, Storage } from './file-system';
import type { Folder, Meta, Page, Storage } from './file-system';
import { joinPaths } from './path';
interface PageTreeBuilderContext {
@@ -31,6 +31,7 @@ export interface CreatePageTreeBuilderOptions {
resolveIcon?: (icon: string) => ReactElement | undefined;
}
const external = /\[(?<text>.+)\]\((?<url>.+)\)/;
const separator = /---(?<name>.*?)---/;
const rest = '...';
const extractor = /\.\.\.(?<name>.+)/;
@@ -40,7 +41,7 @@ const extractor = /\.\.\.(?<name>.+)/;
* @returns Nodes with specified locale in context (sorted)
*/
function buildAll(
nodes: Node[],
nodes: (Folder | Page | Meta)[],
ctx: PageTreeBuilderContext,
skipIndex: boolean,
): PageTree.Node[] {
@@ -93,7 +94,6 @@ function resolveFolderItem(
if (item === rest) return '...';
const separateResult = separator.exec(item);
if (separateResult?.groups) {
return [
{
@@ -103,6 +103,18 @@ function resolveFolderItem(
];
}
const externalResult = external.exec(item);
if (externalResult?.groups) {
return [
{
type: 'page',
name: externalResult.groups.text,
url: externalResult.groups.url,
external: true,
},
];
}
const extractResult = extractor.exec(item);
const path = joinPaths([
@@ -118,7 +130,7 @@ function resolveFolderItem(
if (itemNode.type === 'folder') {
const node = buildFolderNode(itemNode, false, ctx);
return extractResult?.groups ? node.children : [node];
return extractResult ? node.children : [node];
}
return [buildFileNode(itemNode, ctx)];
+21 -1
View File
@@ -1,6 +1,26 @@
import { parse } from 'node:path';
import slash from '@/utils/slash';
import type { FileInfo } from './types';
export interface FileInfo {
locale?: string;
/**
* Original path of file
*/
path: string;
/**
* File path without extension
*/
flattenedPath: string;
/**
* File name without locale and extension
*/
name: string;
dirname: string;
}
export function parseFilePath(path: string): FileInfo {
const slashedPath = slash(path);
+1 -25
View File
@@ -1,27 +1,5 @@
import type { LoaderOutput } from './create';
import type { LoaderOutput } from './loader';
import type { Meta, Page } from './file-system';
import type { LoadResult } from './load';
export interface FileInfo {
locale?: string;
/**
* Original path of file
*/
path: string;
/**
* File path without extension
*/
flattenedPath: string;
/**
* File name without locale and extension
*/
name: string;
dirname: string;
}
export interface MetaData {
icon?: string;
@@ -47,5 +25,3 @@ export type InferMetaType<Utils extends LoaderOutput<any>> =
Utils extends LoaderOutput<infer Config>
? Meta<Config['source']['metaData']>
: never;
export type Transformer = (context: LoadResult) => void;
-191
View File
@@ -1,191 +0,0 @@
import { describe, expect, test } from 'vitest';
import { loader } from '../src/source';
describe('Page Tree Builder', () => {
test('Simple', () => {
const result = loader({
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['test'],
},
},
],
},
});
expect(result.pageTree).toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello",
"type": "page",
"url": "/test",
},
],
"name": "",
}
`);
});
test('Nested Directories', async () => {
const result = loader({
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['test', 'nested'],
},
},
{
type: 'page',
path: '/nested/test.mdx',
data: {
title: 'Nested Page',
},
},
],
},
});
expect(result.pageTree).toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"children": [
{
"icon": undefined,
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"defaultOpen": undefined,
"icon": undefined,
"index": undefined,
"name": "Nested",
"root": undefined,
"type": "folder",
},
],
"name": "",
}
`);
});
test('Internationalized Routing', () => {
const result = loader({
languages: ['cn'],
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'page',
path: 'test.cn.mdx',
data: {
title: 'Hello Chinese',
},
},
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['test', 'nested'],
},
},
{
type: 'meta',
path: 'meta.cn.json',
data: {
title: 'Docs Chinese',
pages: ['test', 'nested'],
},
},
{
type: 'page',
path: '/nested/test.mdx',
data: {
title: 'Nested Page',
},
},
{
type: 'page',
path: '/nested/test.cn.mdx',
data: {
title: 'Nested Page Chinese',
},
},
{
type: 'meta',
path: '/nested/meta.cn.json',
data: {
title: 'Nested Chinese',
},
},
],
},
});
expect(result.pageTree['cn']).toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"children": [
{
"icon": undefined,
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"defaultOpen": undefined,
"icon": undefined,
"index": undefined,
"name": "Nested Chinese",
"root": undefined,
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
});
});
+234
View File
@@ -0,0 +1,234 @@
import { expect, test } from 'vitest';
import { loader } from '../src/source';
test('Simple', () => {
const result = loader({
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['test'],
},
},
],
},
});
expect(result.pageTree, 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello",
"type": "page",
"url": "/test",
},
],
"name": "",
}
`);
expect(result.getPages().length).toBe(1);
expect(result.getPage(['test'])).toBeDefined();
});
test('Nested Directories', async () => {
const result = loader({
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['test', 'nested', '[Text](https://google.com)'],
},
},
{
type: 'page',
path: '/nested/test.mdx',
data: {
title: 'Nested Page',
},
},
],
},
});
expect(result.pageTree, 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"children": [
{
"icon": undefined,
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"defaultOpen": undefined,
"icon": undefined,
"index": undefined,
"name": "Nested",
"root": undefined,
"type": "folder",
},
{
"external": true,
"name": "Text",
"type": "page",
"url": "https://google.com",
},
],
"name": "",
}
`);
expect(result.getPages().length).toBe(2);
expect(result.getPage(['nested', 'test'])).toBeDefined();
});
test('Internationalized Routing', () => {
const result = loader({
languages: ['cn', 'en'],
source: {
files: [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'page',
path: 'test.cn.mdx',
data: {
title: 'Hello Chinese',
},
},
{
type: 'meta',
path: 'meta.json',
data: {
pages: ['test', 'nested'],
},
},
{
type: 'meta',
path: 'meta.cn.json',
data: {
title: 'Docs Chinese',
pages: ['test', 'nested'],
},
},
{
type: 'page',
path: '/nested/test.mdx',
data: {
title: 'Nested Page',
},
},
{
type: 'page',
path: '/nested/test.cn.mdx',
data: {
title: 'Nested Page Chinese',
},
},
{
type: 'meta',
path: '/nested/meta.cn.json',
data: {
title: 'Nested Chinese',
},
},
],
},
});
expect(result.pageTree['en'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"children": [
{
"icon": undefined,
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"defaultOpen": undefined,
"icon": undefined,
"index": undefined,
"name": "Nested",
"root": undefined,
"type": "folder",
},
],
"name": "",
}
`);
expect(result.pageTree['cn'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"icon": undefined,
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"children": [
{
"icon": undefined,
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"defaultOpen": undefined,
"icon": undefined,
"index": undefined,
"name": "Nested Chinese",
"root": undefined,
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
expect(result.getPages().length).toBe(2);
expect(result.getPage(['test'])).toBeDefined();
expect(result.getPage(['test'], 'cn')).toBeDefined();
});