mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: Introduce i18n config for Core APIs
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Export new `createI18nSearchAPIExperimental` API for i18n config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Introduce `i18n` config for Core APIs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Deprecated `languages` and `defaultLanguage` option on Source API, replaced with `i18n` config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Move I18n middleware to `fumadocs-core/i18n`
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['custom/next'],
|
||||
ignorePatterns: ['*.test.ts', '*.output.js'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './config';
|
||||
export * from './middleware';
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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('/'));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 @@
|
||||
{"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,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,
|
||||
|
||||
Reference in New Issue
Block a user