Core: Introduce i18n config for Core APIs

This commit is contained in:
fuma-nama
2024-08-17 16:47:49 +08:00
parent a64a696b44
commit fd46eb69cb
25 changed files with 539 additions and 322 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Export new `createI18nSearchAPIExperimental` API for i18n config
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Introduce `i18n` config for Core APIs
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Deprecated `languages` and `defaultLanguage` option on Source API, replaced with `i18n` config
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Move I18n middleware to `fumadocs-core/i18n`
+2 -3
View File
@@ -1,10 +1,9 @@
import { getPages } from '@/app/source';
import { createI18nSearchAPI } from 'fumadocs-core/search/server';
import { languages } from '@/i18n';
import { i18n } from '@/i18n';
export const { GET } = createI18nSearchAPI('advanced', {
indexes: languages.map((lang) => {
indexes: i18n.languages.map((lang) => {
return {
language: lang,
indexes: getPages(lang)!.map((page) => ({
+2 -3
View File
@@ -1,12 +1,11 @@
import { map } from '@/.map';
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
import { defaultLanguage, languages } from '@/i18n';
import { i18n } from '@/i18n';
export const { getPage, getPages, getLanguages, pageTree } = loader({
baseUrl: '/',
rootDir: 'docs',
languages,
source: createMDXSource(map),
defaultLanguage,
i18n,
});
+6 -2
View File
@@ -1,2 +1,6 @@
export const defaultLanguage = 'en';
export const languages = ['en', 'cn'];
import { I18nConfig } from 'fumadocs-core/i18n';
export const i18n: I18nConfig = {
defaultLanguage: 'en',
languages: ['en', 'cn'],
};
+3 -7
View File
@@ -1,11 +1,7 @@
import { defaultLanguage, languages } from '@/i18n';
import { createI18nMiddleware } from 'fumadocs-core/middleware';
import { createI18nMiddleware } from 'fumadocs-core/i18n';
import { i18n } from '@/i18n';
export default createI18nMiddleware({
languages,
defaultLanguage,
hideLocale: 'default-locale',
});
export default createI18nMiddleware(i18n);
export const config = {
// Matcher ignoring `/_next/` and `/api/`
+3
View File
@@ -1,4 +1,7 @@
module.exports = {
extends: ['custom/next'],
ignorePatterns: ['*.test.ts', '*.output.js'],
rules: {
'no-console': 'off',
},
};
+7
View File
@@ -67,6 +67,10 @@
"./search-algolia/server": {
"import": "./dist/search-algolia/server.js",
"types": "./dist/search-algolia/server.d.ts"
},
"./i18n": {
"import": "./dist/i18n/index.js",
"types": "./dist/i18n/index.d.ts"
}
},
"typesVersions": {
@@ -112,6 +116,9 @@
],
"search-algolia/server": [
"./dist/search-algolia/server.d.ts"
],
"i18n": [
"./dist/i18n/index.d.ts"
]
}
},
+26
View File
@@ -0,0 +1,26 @@
export interface I18nConfig {
/**
* Supported locale codes.
*
* A page tree will be built for each language.
*/
languages: string[];
/**
* Default locale if not specified
*/
defaultLanguage: string;
/**
* Don't show the locale prefix on URL.
*
* - `always`: Always hide the prefix
* - `default-locale`: Only hide the default locale
* - `never`: Never hide the prefix
*
* This API uses `NextResponse.rewrite`.
*
* @defaultValue 'never'
*/
hideLocale?: 'always' | 'default-locale' | 'never';
}
+2
View File
@@ -0,0 +1,2 @@
export * from './config';
export * from './middleware';
+85
View File
@@ -0,0 +1,85 @@
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import type { NextMiddleware } from 'next/dist/server/web/types';
import { type NextRequest, NextResponse } from 'next/server';
import type { I18nConfig } from '@/i18n';
interface MiddlewareOptions extends I18nConfig {
/**
* A function that adds the locale prefix to path name
*/
format?: (locale: string, path: string) => string;
}
function getLocale(
request: NextRequest,
locales: string[],
defaultLanguage: string,
): string {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// Use negotiator and intl-localematcher to get best locale
const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales,
);
return matchLocale(languages, locales, defaultLanguage);
}
const defaultFormat: NonNullable<MiddlewareOptions['format']> = (
locale,
path,
) => {
return `/${locale}/${path}`;
};
export function createI18nMiddleware({
languages,
defaultLanguage,
format = defaultFormat,
hideLocale = 'never',
}: MiddlewareOptions): NextMiddleware {
function shouldHideLocale(locale: string): boolean {
return (
hideLocale === 'always' ||
(hideLocale === 'default-locale' && locale === defaultLanguage)
);
}
return (request) => {
const { pathname } = request.nextUrl;
const pathLocale = languages.find(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
if (!pathLocale) {
const locale = getLocale(request, languages, defaultLanguage);
let path = pathname;
while (path.startsWith('/')) {
path = path.slice(1);
}
const url = new URL(format(locale, path), request.url);
return shouldHideLocale(locale)
? NextResponse.rewrite(url)
: NextResponse.redirect(url);
}
// Remove explicit default locale
// (Only possible for default locale)
if (hideLocale === 'default-locale' && pathLocale === defaultLanguage) {
const path = pathname.slice(`/${pathLocale}`.length);
return NextResponse.redirect(
new URL(path.startsWith('/') ? path : `/${path}`, request.url),
);
}
return NextResponse.next();
};
}
+2 -107
View File
@@ -1,107 +1,2 @@
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import type { NextMiddleware } from 'next/dist/server/web/types';
import { type NextRequest, NextResponse } from 'next/server';
interface MiddlewareOptions {
/**
* Supported locale codes
*/
languages: string[];
/**
* Default locale if not specified
*/
defaultLanguage: string;
/**
* A function that adds the locale prefix to path name
*/
format?: (locale: string, path: string) => string;
/**
* Don't show the locale prefix on URL.
*
* - `always`: Always hide the prefix
* - `default-locale`: Only hide the default locale
* - `never`: Never hide the prefix
*
* This API uses `NextResponse.rewrite`.
*
* @defaultValue 'never'
*/
hideLocale?: 'always' | 'default-locale' | 'never';
}
function getLocale(
request: NextRequest,
locales: string[],
defaultLanguage: string,
): string {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
// Use negotiator and intl-localematcher to get best locale
const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales,
);
return matchLocale(languages, locales, defaultLanguage);
}
const defaultFormat: NonNullable<MiddlewareOptions['format']> = (
locale,
path,
) => {
return `/${locale}/${path}`;
};
export function createI18nMiddleware({
languages,
defaultLanguage,
format = defaultFormat,
hideLocale = 'never',
}: MiddlewareOptions): NextMiddleware {
function shouldHideLocale(locale: string): boolean {
return (
hideLocale === 'always' ||
(hideLocale === 'default-locale' && locale === defaultLanguage)
);
}
return (request) => {
const { pathname } = request.nextUrl;
const pathLocale = languages.find(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
if (!pathLocale) {
const locale = getLocale(request, languages, defaultLanguage);
let path = pathname;
while (path.startsWith('/')) {
path = path.slice(1);
}
const url = new URL(format(locale, path), request.url);
return shouldHideLocale(locale)
? NextResponse.rewrite(url)
: NextResponse.redirect(url);
}
// Remove explicit default locale
// (Only possible for default locale)
if (hideLocale === 'default-locale' && pathLocale === defaultLanguage) {
const path = pathname.slice(`/${pathLocale}`.length);
return NextResponse.redirect(
new URL(path.startsWith('/') ? path : `/${path}`, request.url),
);
}
return NextResponse.next();
};
}
// TODO: Remove this export (major)
export * from './i18n/middleware';
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { type SearchAPI } from '@/search/server';
export function createEndpoint(search: SearchAPI['search']): SearchAPI {
return {
search,
async GET(request) {
const query = request.nextUrl.searchParams.get('query');
if (!query) return NextResponse.json([]);
return NextResponse.json(
await search(query, {
tag: request.nextUrl.searchParams.get('tag') ?? undefined,
locale: request.nextUrl.searchParams.get('locale') ?? undefined,
}),
);
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import {
type AdvancedIndex,
type AdvancedOptions,
createSearchAPI,
type Dynamic,
type Index,
type SearchAPI,
type SimpleOptions,
} from '@/search/server';
import { createEndpoint } from '@/search/create-endpoint';
import { type I18nConfig } from '@/i18n';
interface I18nSimpleOptions
extends Omit<SimpleOptions, 'language' | 'indexes'> {
i18n: I18nConfig;
indexes: WithLocale<Index>[] | Dynamic<WithLocale<Index>>;
}
interface I18nAdvancedOptions
extends Omit<AdvancedOptions, 'language' | 'indexes'> {
i18n: I18nConfig;
indexes: WithLocale<AdvancedIndex>[] | Dynamic<WithLocale<AdvancedIndex>>;
}
type WithLocale<T extends object> = T & {
locale: string;
};
export function createI18nSearchAPI<T extends 'simple' | 'advanced'>(
type: T,
options: T extends 'simple' ? I18nSimpleOptions : I18nAdvancedOptions,
): SearchAPI {
const map = new Map<string, SearchAPI>();
return createEndpoint(async (query, searchOptions) => {
if (map.size === 0) {
const indexes =
typeof options.indexes === 'function'
? await options.indexes()
: options.indexes;
for (const locale of options.i18n.languages) {
const api = createSearchAPI(type, {
...options,
language: locale,
indexes: indexes.filter((index) => index.locale === locale),
});
map.set(locale, api);
}
}
const handler = map.get(
searchOptions?.locale ?? options.i18n.defaultLanguage,
);
if (handler) return handler.search(query, searchOptions);
return [];
});
}
@@ -0,0 +1,53 @@
import {
type AdvancedOptions,
createSearchAPI,
type SearchAPI,
type SimpleOptions,
} from '@/search/server';
import { createEndpoint } from '@/search/create-endpoint';
type ToI18n<T extends { indexes: unknown }> = Omit<
T,
'indexes' | 'language'
> & {
indexes: (
| [language: string, indexes: T['indexes']]
| {
language: string;
indexes: T['indexes'];
}
)[];
};
export function createI18nSearchAPI<T extends 'simple' | 'advanced'>(
type: T,
options: T extends 'simple' ? ToI18n<SimpleOptions> : ToI18n<AdvancedOptions>,
): SearchAPI {
const map = new Map<string, SearchAPI>();
for (const entry of options.indexes) {
const v = Array.isArray(entry)
? { language: entry[0], indexes: entry[1] }
: entry;
map.set(
v.language,
// @ts-expect-error -- Index depends on generic types
createSearchAPI(type, {
...options,
language: v.language,
indexes: v.indexes,
}),
);
}
return createEndpoint(async (query, searchOptions) => {
if (searchOptions?.locale) {
const handler = map.get(searchOptions.locale);
if (handler) return handler.search(query, searchOptions);
}
return [];
});
}
+15 -70
View File
@@ -1,9 +1,10 @@
import { Document } from 'flexsearch';
import { type NextRequest, NextResponse } from 'next/server';
import { type NextRequest, type NextResponse } from 'next/server';
import type { StructuredData } from '@/mdx-plugins/remark-structure';
import { createEndpoint } from '@/search/create-endpoint';
import type { SortedResult } from './shared';
interface SearchAPI {
export interface SearchAPI {
GET: (request: NextRequest) => Promise<NextResponse<SortedResult[]>>;
search: (
@@ -15,15 +16,16 @@ interface SearchAPI {
/**
* Resolve indexes dynamically
*/
type Dynamic<T> = () => T[] | Promise<T[]>;
export type Dynamic<T> = () => T[] | Promise<T[]>;
interface SimpleOptions {
export interface SimpleOptions {
indexes: Index[] | Dynamic<Index>;
language?: string;
}
interface AdvancedOptions {
export interface AdvancedOptions {
indexes: AdvancedIndex[] | Dynamic<AdvancedIndex>;
/**
* Enable search tags for filtering results
*
@@ -33,36 +35,6 @@ interface AdvancedOptions {
language?: string;
}
type ToI18n<T extends { indexes: unknown }> = Omit<
T,
'indexes' | 'language'
> & {
indexes: (
| [language: string, indexes: T['indexes']]
| {
language: string;
indexes: T['indexes'];
}
)[];
};
function create(search: SearchAPI['search']): SearchAPI {
return {
search,
async GET(request) {
const query = request.nextUrl.searchParams.get('query');
if (!query) return NextResponse.json([]);
return NextResponse.json(
await search(query, {
tag: request.nextUrl.searchParams.get('tag') ?? undefined,
locale: request.nextUrl.searchParams.get('locale') ?? undefined,
}),
);
},
};
}
export function createSearchAPI<T extends 'simple' | 'advanced'>(
type: T,
options: T extends 'simple' ? SimpleOptions : AdvancedOptions,
@@ -74,39 +46,6 @@ export function createSearchAPI<T extends 'simple' | 'advanced'>(
return initSearchAPIAdvanced(options as AdvancedOptions);
}
export function createI18nSearchAPI<T extends 'simple' | 'advanced'>(
type: T,
options: T extends 'simple' ? ToI18n<SimpleOptions> : ToI18n<AdvancedOptions>,
): SearchAPI {
const map = new Map<string, SearchAPI>();
for (const entry of options.indexes) {
const v = Array.isArray(entry)
? { language: entry[0], indexes: entry[1] }
: entry;
map.set(
v.language,
// @ts-expect-error -- Index depends on generic types
createSearchAPI(type, {
...options,
language: v.language,
indexes: v.indexes,
}),
);
}
return create(async (query, searchOptions) => {
if (searchOptions?.locale) {
const handler = map.get(searchOptions.locale);
if (handler) return handler.search(query, searchOptions);
}
return [];
});
}
export interface Index {
title: string;
description?: string;
@@ -171,7 +110,7 @@ export function initSearchAPI({ indexes, language }: SimpleOptions): SearchAPI {
}
const doc = getDocument();
return create(async (query) => {
return createEndpoint(async (query) => {
const results = (await doc).search(query, 5, {
enrich: true,
suggest: true,
@@ -302,7 +241,7 @@ export function initSearchAPIAdvanced({
const doc = getDocument();
return create(async (query, options) => {
return createEndpoint(async (query, options) => {
const index = await doc;
const results = index.search(query, 5, {
enrich: true,
@@ -355,3 +294,9 @@ export function initSearchAPIAdvanced({
return sortedResult;
});
}
// TODO: Use new i18n API (major)
// eslint-disable-next-line import/no-cycle -- bundler
export { createI18nSearchAPI as createI18nSearchAPIExperimental } from './i18n-api';
// eslint-disable-next-line import/no-cycle -- bundler
export { createI18nSearchAPI } from './legacy-i18n-api';
+52 -16
View File
@@ -1,4 +1,5 @@
import type * as PageTree from '@/server/page-tree';
import type { I18nConfig } from '@/i18n';
import {
loadFiles,
type LoadOptions,
@@ -6,10 +7,7 @@ import {
type Transformer,
} from './load-files';
import type { MetaData, PageData, UrlFn } from './types';
import type {
BuildPageTreeOptions,
BuildPageTreeOptionsWithI18n,
} from './page-tree-builder';
import type { BuildPageTreeOptions } from './page-tree-builder';
import { createPageTreeBuilder } from './page-tree-builder';
import { type FileInfo } from './path';
import type { File, PageFile, Storage } from './file-system';
@@ -24,8 +22,7 @@ export interface SourceConfig {
metaData: MetaData;
}
export interface LoaderOptions
extends Pick<BuildPageTreeOptionsWithI18n, 'languages' | 'defaultLanguage'> {
export interface LoaderOptions {
/**
* @defaultValue `''`
*/
@@ -38,6 +35,7 @@ export interface LoaderOptions
icon?: NonNullable<BuildPageTreeOptions['resolveIcon']>;
slugs?: LoadOptions['getSlugs'];
url?: UrlFn;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Inevitable
source: Source<any>;
transformers?: Transformer[];
@@ -46,6 +44,27 @@ export interface LoaderOptions
* Additional options for page tree builder
*/
pageTree?: Partial<Omit<BuildPageTreeOptions, 'storage' | 'getUrl'>>;
/**
* Configure i18n
*/
i18n?: I18nConfig;
/**
* Accepted languages.
*
* A page tree will be built for each language.
*
* @deprecated Use `i18n` instead
*/
languages?: string[];
/**
* Default locale when locale is not provided.
*
* @deprecated Use `i18n` instead
*/
defaultLanguage?: string;
}
export interface Source<Config extends SourceConfig> {
@@ -100,8 +119,8 @@ export interface LoaderOutput<Config extends LoaderConfig> {
function buildPageMap(
storage: Storage,
languages: string[],
getUrl: UrlFn,
languages: string[] = [],
): Map<string, Map<string, Page>> {
const map = new Map<string, Map<string, Page>>();
const defaultMap = new Map<string, Page>();
@@ -156,22 +175,40 @@ export function loader<Options extends LoaderOptions>(
options: Options,
): LoaderOutput<{
source: InferSourceConfig<Options['source']>;
i18n: Options['languages'] extends string[] ? true : false;
i18n: Options['i18n'] extends I18nConfig
? true
: Options['languages'] extends string[]
? true
: false;
}> {
// TODO: Remove deprecated `languages` option (major)
if (options.languages) {
console.warn(
"Fumadocs: It's highly recommended to use `i18n` config instead of passing `languages` to loader.",
);
return createOutput({
...options,
i18n: {
languages: options.languages,
defaultLanguage: options.defaultLanguage,
},
}) as ReturnType<typeof loader<Options>>;
}
return createOutput(options) as ReturnType<typeof loader<Options>>;
}
function createOutput({
source,
icon: resolveIcon,
languages,
rootDir = '',
transformers,
baseUrl = '/',
slugs: slugsFn = getSlugs,
url: getUrl = createGetUrl(baseUrl),
defaultLanguage,
pageTree: pageTreeOptions = {},
i18n,
}: LoaderOptions): LoaderOutput<LoaderConfig> {
const storage = loadFiles(
typeof source.files === 'function' ? source.files(rootDir) : source.files,
@@ -181,10 +218,10 @@ function createOutput({
getSlugs: slugsFn,
},
);
const i18nMap = buildPageMap(storage, languages ?? [], getUrl);
const i18nMap = buildPageMap(storage, getUrl, i18n?.languages);
const builder = createPageTreeBuilder();
const pageTree =
languages === undefined
i18n === undefined
? builder.build({
storage,
resolveIcon,
@@ -192,18 +229,17 @@ function createOutput({
...pageTreeOptions,
})
: builder.buildI18n({
languages,
storage,
resolveIcon,
getUrl,
defaultLanguage,
i18n,
...pageTreeOptions,
});
return {
pageTree: pageTree as LoaderOutput<LoaderConfig>['pageTree'],
files: storage.list(),
getPages(language = '') {
getPages(language = i18n?.defaultLanguage ?? '') {
return Array.from(i18nMap.get(language)?.values() ?? []);
},
getLanguages() {
@@ -220,7 +256,7 @@ function createOutput({
return list;
},
getPage(slugs = [], language = '') {
getPage(slugs = [], language = i18n?.defaultLanguage ?? '') {
return i18nMap.get(language)?.get(slugs.join('/'));
},
};
+26 -17
View File
@@ -1,4 +1,5 @@
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 './path';
@@ -10,6 +11,8 @@ interface PageTreeBuilderContext {
storage: Storage;
builder: PageTreeBuilder;
options: BuildPageTreeOptions;
i18n?: I18nConfig;
}
export interface BuildPageTreeOptions {
@@ -35,15 +38,7 @@ export interface BuildPageTreeOptions {
}
export interface BuildPageTreeOptionsWithI18n extends BuildPageTreeOptions {
/**
* Build a page tree for each language
*/
languages?: string[];
/**
* Hide the locale prefix from URLs if it is same as the specified default locale.
*/
defaultLanguage?: string;
i18n: I18nConfig;
}
export interface PageTreeBuilder {
@@ -61,6 +56,8 @@ const group = /^\((?<name>.+)\)$/;
const link = /^\[(?<text>.+)]\((?<url>.+)\)$/;
const separator = /^---(?<name>.*?)---$/;
const rest = '...';
const extractPrefix = '...';
const excludePrefix = '!';
/**
* @param nodes - All nodes to be built
@@ -133,14 +130,14 @@ function resolveFolderItem(
return [ctx.options.attachFile?.(node) ?? node];
}
const isExcept = item.startsWith('!'),
isExtract = item.startsWith('...');
const isExcept = item.startsWith(excludePrefix),
isExtract = item.startsWith(extractPrefix);
let filename = item;
if (isExcept) {
filename = item.slice(1);
filename = item.slice(excludePrefix.length);
} else if (isExtract) {
filename = item.slice(3);
filename = item.slice(extractPrefix.length);
}
const path = resolvePath(folder.file.path, filename);
@@ -228,14 +225,25 @@ function buildFileNode(
file: PageFile,
ctx: PageTreeBuilderContext,
): PageTree.Item {
let urlLocale: string | undefined;
const localized =
findLocalizedFile(file.file.flattenedPath, 'page', ctx) ?? file;
const hideLocale = ctx.i18n?.hideLocale ?? 'never';
if (hideLocale === 'never') {
urlLocale = ctx.lang;
} else if (
hideLocale === 'default-locale' &&
ctx.lang !== ctx.i18n?.defaultLanguage
) {
urlLocale = ctx.lang;
}
const item: PageTree.Item = {
type: 'page',
name: localized.data.data.title,
icon: ctx.options.resolveIcon?.(localized.data.data.icon),
url: ctx.options.getUrl(localized.data.slugs, ctx.lang),
url: ctx.options.getUrl(localized.data.slugs, urlLocale),
};
return removeUndefined(ctx.options.attachFile?.(item, file) ?? item);
@@ -260,13 +268,14 @@ export function createPageTreeBuilder(): PageTreeBuilder {
storage: options.storage,
});
},
buildI18n({ languages = [], defaultLanguage, ...options }) {
const entries = languages.map<[string, PageTree.Root]>((lang) => {
buildI18n({ i18n, ...options }) {
const entries = i18n.languages.map<[string, PageTree.Root]>((lang) => {
const tree = build({
lang: lang === defaultLanguage ? undefined : lang,
lang,
options,
builder: this,
storage: options.storage,
i18n,
});
return [lang, tree];
-25
View File
@@ -7,7 +7,6 @@ import {
import { describe, expect, test } from 'vitest';
import type { Root } from '@/server/page-tree';
import { findNeighbour } from '@/server/page-tree-utils';
import { getSlugs } from '@/source/loader';
test('Find Neighbours', () => {
const tree: Root = {
@@ -107,27 +106,3 @@ describe('Path utilities', () => {
expect(splitPath('/a/c')).toEqual(['a', 'c']);
});
});
test('get slugs', () => {
expect(getSlugs(parseFilePath('index.mdx'))).toStrictEqual([]);
expect(getSlugs(parseFilePath('page.mdx'))).toStrictEqual(['page']);
expect(getSlugs(parseFilePath('nested/index.mdx'))).toStrictEqual(['nested']);
expect(getSlugs(parseFilePath('nested/page.mdx'))).toStrictEqual([
'nested',
'page',
]);
});
test('get slugs: folder groups', () => {
expect(getSlugs(parseFilePath('(nested)/index.mdx'))).toStrictEqual([]);
expect(getSlugs(parseFilePath('folder/(nested)/page.mdx'))).toStrictEqual([
'folder',
'page',
]);
expect(getSlugs(parseFilePath('nested/(page).mdx'))).toStrictEqual([
'nested',
'(page)',
]);
});
@@ -1,19 +1,35 @@
import { expect, test } from 'vitest';
import { createGetUrl, getSlugs, loader } from '@/source/loader';
import {
createGetUrl,
getSlugs,
loader,
type Source,
type SourceConfig,
} from '@/source/loader';
import { parseFilePath } from '@/source';
test('Get Slugs', () => {
expect(getSlugs(parseFilePath('path/to/file'))).toStrictEqual([
'path',
'to',
'file',
test('get slugs', () => {
expect(getSlugs(parseFilePath('index.mdx'))).toStrictEqual([]);
expect(getSlugs(parseFilePath('page.mdx'))).toStrictEqual(['page']);
expect(getSlugs(parseFilePath('nested/index.mdx'))).toStrictEqual(['nested']);
expect(getSlugs(parseFilePath('nested/page.mdx'))).toStrictEqual([
'nested',
'page',
]);
expect(getSlugs(parseFilePath('path/(group)/file'))).toStrictEqual([
'path',
'file',
});
test('get slugs: folder groups', () => {
expect(getSlugs(parseFilePath('(nested)/index.mdx'))).toStrictEqual([]);
expect(getSlugs(parseFilePath('folder/(nested)/page.mdx'))).toStrictEqual([
'folder',
'page',
]);
expect(getSlugs(parseFilePath(''))).toStrictEqual([]);
expect(getSlugs(parseFilePath('nested/(page).mdx'))).toStrictEqual([
'nested',
'(page)',
]);
});
test('Get URL: Empty', () => {
@@ -29,7 +45,7 @@ test('Get URL: Base', () => {
expect(getUrl([''])).toBe('/docs');
});
test('Page Tree: Simple', () => {
test('Loader: Simple', () => {
const result = loader({
source: {
files: [
@@ -68,7 +84,7 @@ test('Page Tree: Simple', () => {
expect(result.getPage(['test'])).toBeDefined();
});
test('Page Tree: Nested Directories', async () => {
test('Nested Directories', async () => {
const result = loader({
source: {
files: [
@@ -159,63 +175,68 @@ test('Page Tree: Nested Directories', async () => {
expect(result.getPage(['hello'])).toBeDefined();
});
test('Page Tree: 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',
},
},
],
const i18nSource: Source<SourceConfig> = {
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',
},
},
],
};
test('Internationalized Routing', () => {
const result = loader({
i18n: {
languages: ['cn', 'en'],
defaultLanguage: 'en',
},
source: i18nSource,
});
expect(result.pageTree['en'], 'Page Tree').toMatchInlineSnapshot(`
@@ -267,6 +288,69 @@ test('Page Tree: Internationalized Routing', () => {
`);
expect(result.getPages().length).toBe(2);
expect(result.getPage(['test'])).toBeDefined();
expect(result.getPage(['test'], 'cn')).toBeDefined();
expect(result.getPage(['test'])?.url).toBe('/en/test');
expect(result.getPage(['test'], 'cn')?.url).toBe('/cn/test');
});
test('Internationalized Routing: Hide Prefix', () => {
const result = loader({
i18n: {
languages: ['cn', 'en'],
defaultLanguage: 'en',
hideLocale: 'default-locale',
},
source: i18nSource,
});
expect(result.pageTree['en'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello",
"type": "page",
"url": "/test",
},
{
"children": [
{
"name": "Nested Page",
"type": "page",
"url": "/nested/test",
},
],
"name": "Nested",
"type": "folder",
},
],
"name": "",
}
`);
expect(result.pageTree['cn'], 'Page Tree').toMatchInlineSnapshot(`
{
"children": [
{
"name": "Hello Chinese",
"type": "page",
"url": "/cn/test",
},
{
"children": [
{
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"type": "folder",
},
],
"name": "Docs Chinese",
}
`);
expect(result.getPages().length).toBe(2);
expect(result.getPage(['test'])?.url).toBe('/en/test');
expect(result.getPage(['test'], 'cn')?.url).toBe('/cn/test');
});
+1
View File
@@ -13,5 +13,6 @@ export default defineConfig({
'src/utils/use-on-change.ts',
'src/{search,search-algolia}/server.ts',
'src/middleware.ts',
'src/i18n/index.ts',
],
});
+1 -1
View File
@@ -1 +1 @@
{"fumadocs-core":"13.1.0","fumadocs-ui":"13.1.0","fumadocs-mdx":"9.0.0","@fumadocs/content-collections":"1.1.0"}
{"fumadocs-core":"13.2.2","fumadocs-ui":"13.2.2","fumadocs-mdx":"9.0.2","@fumadocs/content-collections":"1.1.2"}
+1 -1
View File
@@ -1,5 +1,5 @@
import * as path from 'node:path';
import { type TypescriptConfig, getProgram, getFileSymbol } from '../program';
import { type TypescriptConfig, getProgram, getFileSymbol } from '@/program';
import {
type DocEntry,
type GeneratedDoc,