openmanual 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +787 -56
- package/dist/bin.js.map +1 -1
- package/dist/components/app-layout.d.ts +4 -1
- package/dist/components/app-layout.js +2 -2
- package/dist/components/provider.js +2 -0
- package/dist/components/safe-search-dialog.d.ts +5 -3
- package/dist/components/safe-search-dialog.js +2 -3
- package/dist/index.d.ts +12 -0
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/utils/temp-dir.js +2 -1
- package/package.json +3 -3
package/dist/bin.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { basename, dirname, extname, join,
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { access,
|
|
8
|
+
import { access, cp, lstat, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
|
|
11
11
|
//#region src/core/config/schema.ts
|
|
@@ -41,6 +41,16 @@ const ThemeSchema = z.object({
|
|
|
41
41
|
const SearchSchema = z.object({ enabled: z.boolean().optional() });
|
|
42
42
|
const MdxSchema = z.object({ latex: z.boolean().optional() });
|
|
43
43
|
const PageActionsSchema = z.object({ enabled: z.boolean().optional() });
|
|
44
|
+
const I18nLocaleSchema = z.object({
|
|
45
|
+
code: z.string().min(1),
|
|
46
|
+
name: z.string().min(1)
|
|
47
|
+
});
|
|
48
|
+
const I18nConfigSchema = z.object({
|
|
49
|
+
enabled: z.boolean().optional(),
|
|
50
|
+
defaultLanguage: z.string().optional(),
|
|
51
|
+
languages: z.array(I18nLocaleSchema).optional(),
|
|
52
|
+
parser: z.enum(["dot", "dir"]).optional()
|
|
53
|
+
});
|
|
44
54
|
const OpenManualConfigSchema = z.object({
|
|
45
55
|
name: z.string().min(1),
|
|
46
56
|
description: z.string().optional(),
|
|
@@ -56,7 +66,8 @@ const OpenManualConfigSchema = z.object({
|
|
|
56
66
|
theme: ThemeSchema.optional(),
|
|
57
67
|
search: SearchSchema.optional(),
|
|
58
68
|
mdx: MdxSchema.optional(),
|
|
59
|
-
pageActions: PageActionsSchema.optional()
|
|
69
|
+
pageActions: PageActionsSchema.optional(),
|
|
70
|
+
i18n: I18nConfigSchema.optional()
|
|
60
71
|
});
|
|
61
72
|
function collectConfiguredSlugs(config) {
|
|
62
73
|
const slugs = /* @__PURE__ */ new Set();
|
|
@@ -68,6 +79,12 @@ function buildTitleMap(config) {
|
|
|
68
79
|
if (config.sidebar) for (const group of config.sidebar) for (const page of group.pages) map[page.slug] = page.title;
|
|
69
80
|
return map;
|
|
70
81
|
}
|
|
82
|
+
function isI18nEnabled(config) {
|
|
83
|
+
return config.i18n?.enabled === true && (config.i18n.languages?.length ?? 0) > 1;
|
|
84
|
+
}
|
|
85
|
+
function isDirParser(config) {
|
|
86
|
+
return config.i18n?.parser === "dir";
|
|
87
|
+
}
|
|
71
88
|
|
|
72
89
|
//#endregion
|
|
73
90
|
//#region src/core/config/loader.ts
|
|
@@ -138,7 +155,13 @@ function mergeDefaults(config) {
|
|
|
138
155
|
pageActions: {
|
|
139
156
|
...DEFAULT_CONFIG.pageActions,
|
|
140
157
|
...config.pageActions
|
|
141
|
-
}
|
|
158
|
+
},
|
|
159
|
+
i18n: config.i18n ? {
|
|
160
|
+
enabled: config.i18n.enabled ?? false,
|
|
161
|
+
defaultLanguage: config.i18n.defaultLanguage ?? config.locale ?? "zh",
|
|
162
|
+
languages: config.i18n.languages ?? [],
|
|
163
|
+
parser: config.i18n.parser ?? "dot"
|
|
164
|
+
} : void 0
|
|
142
165
|
};
|
|
143
166
|
}
|
|
144
167
|
|
|
@@ -271,6 +294,48 @@ figure.shiki > div {
|
|
|
271
294
|
`;
|
|
272
295
|
}
|
|
273
296
|
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/core/generator/i18n-config.ts
|
|
299
|
+
/**
|
|
300
|
+
* 生成 lib/i18n.ts
|
|
301
|
+
*
|
|
302
|
+
* 使用 fumadocs-core 的 defineI18n 定义多语言配置。
|
|
303
|
+
*/
|
|
304
|
+
function generateI18nConfig(_ctx) {
|
|
305
|
+
const i18nCfg = _ctx.config.i18n;
|
|
306
|
+
if (!i18nCfg?.enabled || !i18nCfg.languages || i18nCfg.languages.length < 2) throw new Error("generateI18nConfig called but i18n is not properly configured");
|
|
307
|
+
return `import { defineI18n } from 'fumadocs-core/i18n';
|
|
308
|
+
|
|
309
|
+
export const i18n = defineI18n({
|
|
310
|
+
defaultLanguage: '${i18nCfg.defaultLanguage ?? _ctx.config.locale ?? "zh"}',
|
|
311
|
+
languages: [${i18nCfg.languages.map((l) => `'${l.code}'`).join(", ")}],${i18nCfg.parser === "dir" ? `\n parser: 'dir',` : ""}
|
|
312
|
+
});
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/core/generator/i18n-ui.ts
|
|
318
|
+
/**
|
|
319
|
+
* 生成 lib/i18n-ui.ts
|
|
320
|
+
*
|
|
321
|
+
* 使用 fumadocs-ui 的 defineI18nUI 定义各语言的 UI 翻译(显示名称等)。
|
|
322
|
+
*/
|
|
323
|
+
function generateI18nUI(_ctx) {
|
|
324
|
+
const i18nCfg = _ctx.config.i18n;
|
|
325
|
+
if (!i18nCfg?.languages || i18nCfg.languages.length === 0) throw new Error("generateI18nUI called but no languages configured");
|
|
326
|
+
return `import { defineI18nUI } from 'fumadocs-ui/i18n';
|
|
327
|
+
import { i18n } from '@/lib/i18n';
|
|
328
|
+
|
|
329
|
+
export const i18nUI = defineI18nUI(i18n, {
|
|
330
|
+
translations: {
|
|
331
|
+
${i18nCfg.languages.map((lang) => ` '${lang.code}': {
|
|
332
|
+
displayName: '${lang.name}',
|
|
333
|
+
}`).join(",\n")}
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
`;
|
|
337
|
+
}
|
|
338
|
+
|
|
274
339
|
//#endregion
|
|
275
340
|
//#region src/core/generator/layout.ts
|
|
276
341
|
const IMAGE_EXTENSIONS = [
|
|
@@ -297,6 +362,7 @@ function resolveLogoPaths(logo) {
|
|
|
297
362
|
function generateLayout(ctx) {
|
|
298
363
|
const { config } = ctx;
|
|
299
364
|
const logo = config.navbar?.logo ?? config.name;
|
|
365
|
+
const isI18n = config.i18n?.enabled === true;
|
|
300
366
|
let logoProps;
|
|
301
367
|
if (typeof logo === "string" && isImagePath(logo)) logoProps = `type="image" src="${logo}" alt="${config.name}"`;
|
|
302
368
|
else if (typeof logo === "object") {
|
|
@@ -304,6 +370,18 @@ function generateLayout(ctx) {
|
|
|
304
370
|
if (light === dark) logoProps = `type="image" src="${light}" alt="${config.name}"`;
|
|
305
371
|
else logoProps = `type="image" srcLight="${light}" srcDark="${dark}" alt="${config.name}"`;
|
|
306
372
|
} else logoProps = `type="text" text="${logo}"`;
|
|
373
|
+
if (isI18n) return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
374
|
+
import type { ReactNode } from 'react';
|
|
375
|
+
import { NavLogo } from 'openmanual/components/nav-layout';
|
|
376
|
+
|
|
377
|
+
export function baseOptions(_locale: string): BaseLayoutProps {
|
|
378
|
+
return {
|
|
379
|
+
nav: {
|
|
380
|
+
title: <NavLogo ${logoProps} /> as ReactNode,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
`;
|
|
307
385
|
return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
308
386
|
import type { ReactNode } from 'react';
|
|
309
387
|
import { NavLogo } from 'openmanual/components/nav-layout';
|
|
@@ -320,7 +398,17 @@ export function baseOptions(): BaseLayoutProps {
|
|
|
320
398
|
|
|
321
399
|
//#endregion
|
|
322
400
|
//#region src/core/generator/lib-source.ts
|
|
323
|
-
function generateLibSource() {
|
|
401
|
+
function generateLibSource(ctx) {
|
|
402
|
+
if (ctx.config.i18n?.enabled === true) return `import { docs } from '@/.source/server';
|
|
403
|
+
import { loader } from 'fumadocs-core/source';
|
|
404
|
+
import { i18n } from '@/lib/i18n';
|
|
405
|
+
|
|
406
|
+
export const source = loader({
|
|
407
|
+
baseUrl: '/',
|
|
408
|
+
source: docs.toFumadocsSource(),
|
|
409
|
+
i18n,
|
|
410
|
+
});
|
|
411
|
+
`;
|
|
324
412
|
return `import { docs } from '@/.source/server';
|
|
325
413
|
import { loader } from 'fumadocs-core/source';
|
|
326
414
|
|
|
@@ -339,6 +427,41 @@ export { Mermaid } from 'openmanual/components/mermaid';
|
|
|
339
427
|
`;
|
|
340
428
|
}
|
|
341
429
|
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/core/generator/middleware.ts
|
|
432
|
+
/**
|
|
433
|
+
* 生成 middleware.ts(或 proxy.ts)
|
|
434
|
+
*
|
|
435
|
+
* 使用自定义的轻量 i18n 中间件,仅处理:
|
|
436
|
+
* 1. 根路径 / → 重定向到默认语言(如 /zh)
|
|
437
|
+
* 2. 其他所有请求(包括静态资源)→ 直接放行
|
|
438
|
+
*
|
|
439
|
+
* 注意:Next.js 16 已废弃 middleware 推荐使用 proxy,
|
|
440
|
+
* 但 fumadocs-core 尚未提供 createI18nProxy,
|
|
441
|
+
* 因此使用自定义实现避免 createI18nMiddleware 拦截静态资源导致 404。
|
|
442
|
+
*/
|
|
443
|
+
function generateMiddleware(_ctx) {
|
|
444
|
+
return `import { NextResponse } from 'next/server';
|
|
445
|
+
|
|
446
|
+
const defaultLanguage = '${_ctx.config.i18n?.defaultLanguage ?? _ctx.config.locale ?? "zh"}';
|
|
447
|
+
|
|
448
|
+
export default function middleware(request: Request): NextResponse | undefined {
|
|
449
|
+
const { pathname } = new URL(request.url);
|
|
450
|
+
|
|
451
|
+
// 仅处理根路径重定向,其他请求(含静态资源)放行
|
|
452
|
+
if (pathname === '/') {
|
|
453
|
+
return NextResponse.redirect(new URL('/' + defaultLanguage, request.url));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export const config = {
|
|
460
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon\\.ico).*)'],
|
|
461
|
+
};
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
464
|
+
|
|
342
465
|
//#endregion
|
|
343
466
|
//#region src/core/generator/next-config.ts
|
|
344
467
|
function generateNextConfig(ctx) {
|
|
@@ -364,7 +487,7 @@ export default withMDX(config);
|
|
|
364
487
|
//#endregion
|
|
365
488
|
//#region src/core/generator/package-json.ts
|
|
366
489
|
function getOpenManualVersion() {
|
|
367
|
-
return "0.
|
|
490
|
+
return "0.11.0";
|
|
368
491
|
}
|
|
369
492
|
function generatePackageJson(ctx) {
|
|
370
493
|
const openmanualVersion = getOpenManualVersion();
|
|
@@ -408,6 +531,10 @@ function generatePackageJson(ctx) {
|
|
|
408
531
|
function generatePage(_ctx) {
|
|
409
532
|
const isStrict = _ctx.config.contentPolicy !== "all";
|
|
410
533
|
const pageActionsEnabled = _ctx.config.pageActions?.enabled !== false;
|
|
534
|
+
if (_ctx.config.i18n?.enabled === true) return generatePageI18n(_ctx, isStrict, pageActionsEnabled);
|
|
535
|
+
return generatePageSingle(_ctx, isStrict, pageActionsEnabled);
|
|
536
|
+
}
|
|
537
|
+
function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
|
|
411
538
|
const allowedSlugsSnippet = isStrict ? `
|
|
412
539
|
const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
|
|
413
540
|
|
|
@@ -481,6 +608,94 @@ export function generateStaticParams() {
|
|
|
481
608
|
}`}
|
|
482
609
|
`;
|
|
483
610
|
}
|
|
611
|
+
function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
|
|
612
|
+
const allowedSlugsSnippet = isStrict ? `
|
|
613
|
+
const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
|
|
614
|
+
|
|
615
|
+
function isAllowed(slug: string[] | undefined): boolean {
|
|
616
|
+
if (allowedSlugs.size === 0) return true;
|
|
617
|
+
const key = slug ? slug.join('/') : 'index';
|
|
618
|
+
return allowedSlugs.has(key);
|
|
619
|
+
}
|
|
620
|
+
` : "";
|
|
621
|
+
return `import { source } from '@/lib/source';
|
|
622
|
+
import { notFound } from 'next/navigation';
|
|
623
|
+
import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page';
|
|
624
|
+
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
|
625
|
+
import { Steps, Step } from 'fumadocs-ui/components/steps';
|
|
626
|
+
import { Tabs, Tab } from 'fumadocs-ui/components/tabs';
|
|
627
|
+
import { Files, File, Folder } from 'fumadocs-ui/components/files';
|
|
628
|
+
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
629
|
+
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
|
630
|
+
import { Mermaid } from '@/components/mermaid';
|
|
631
|
+
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
|
|
632
|
+
${allowedSlugsSnippet}
|
|
633
|
+
export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
|
|
634
|
+
const { slug, lang } = await params;
|
|
635
|
+
const page = source.getPage(slug, lang);
|
|
636
|
+
${isStrict ? `
|
|
637
|
+
if (!isAllowed(slug)) {
|
|
638
|
+
notFound();
|
|
639
|
+
}
|
|
640
|
+
` : ""}
|
|
641
|
+
if (!page) {
|
|
642
|
+
notFound();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const MDX = page.data.body;
|
|
646
|
+
|
|
647
|
+
return (
|
|
648
|
+
<DocsPage toc={page.data.toc}>
|
|
649
|
+
${pageActionsEnabled ? ` <div className="flex items-start justify-between gap-4">
|
|
650
|
+
<div>
|
|
651
|
+
<DocsTitle>{page.data.title}</DocsTitle>
|
|
652
|
+
{page.data.description && (
|
|
653
|
+
<DocsDescription>{page.data.description}</DocsDescription>
|
|
654
|
+
)}
|
|
655
|
+
</div>
|
|
656
|
+
<PageActions />
|
|
657
|
+
</div>` : ` <DocsTitle>{page.data.title}</DocsTitle>
|
|
658
|
+
{page.data.description && (
|
|
659
|
+
<DocsDescription>{page.data.description}</DocsDescription>
|
|
660
|
+
)}`}
|
|
661
|
+
<DocsBody data-content-area>
|
|
662
|
+
<MDX components={{ ...defaultMdxComponents, Steps, Step, Tabs, Tab, Files, File, Folder, Accordion, Accordions, TypeTable, Mermaid, Callout, CalloutTitle, CalloutDescription }} />
|
|
663
|
+
</DocsBody>
|
|
664
|
+
</DocsPage>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
${isStrict ? `
|
|
668
|
+
export function generateStaticParams() {
|
|
669
|
+
let params = source.generateParams();
|
|
670
|
+
params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug));
|
|
671
|
+
// Ensure every language has a homepage entry (slug: [])
|
|
672
|
+
const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
|
|
673
|
+
for (const lang of languages) {
|
|
674
|
+
if (!params.some((p: { slug: string[]; lang: string }) => p.slug.length === 0 && p.lang === lang)) {
|
|
675
|
+
const firstForLang = params.find((p: { slug: string[]; lang: string }) => p.lang === lang);
|
|
676
|
+
if (firstForLang) {
|
|
677
|
+
params.unshift({ ...firstForLang, slug: [] });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return params;
|
|
682
|
+
}` : `
|
|
683
|
+
export function generateStaticParams() {
|
|
684
|
+
const params = source.generateParams();
|
|
685
|
+
// Ensure every language has a homepage entry (slug: [])
|
|
686
|
+
const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
|
|
687
|
+
for (const lang of languages) {
|
|
688
|
+
if (!params.some((p: { slug: string[]; lang: string }) => p.slug.length === 0 && p.lang === lang)) {
|
|
689
|
+
const firstForLang = params.find((p: { slug: string[]; lang: string }) => p.lang === lang);
|
|
690
|
+
if (firstForLang) {
|
|
691
|
+
params.unshift({ ...firstForLang, slug: [] });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return params;
|
|
696
|
+
}`}
|
|
697
|
+
`;
|
|
698
|
+
}
|
|
484
699
|
|
|
485
700
|
//#endregion
|
|
486
701
|
//#region src/core/generator/page-actions-component.ts
|
|
@@ -506,18 +721,246 @@ export default config;
|
|
|
506
721
|
|
|
507
722
|
//#endregion
|
|
508
723
|
//#region src/core/generator/provider.ts
|
|
724
|
+
/**
|
|
725
|
+
* 生成 app/provider.tsx(或 app/[lang]/provider.tsx)
|
|
726
|
+
*
|
|
727
|
+
* 重要:直接从 fumadocs-ui 导入组件,而非通过 openmanual/components/provider 中转。
|
|
728
|
+
* 这避免了 pnpm file: 协议下 fumadocs-ui 被安装两次(一次作为 openmanual 的依赖,
|
|
729
|
+
* 一次作为生成应用的依赖)导致的多实例 React Context 问题。
|
|
730
|
+
*/
|
|
509
731
|
function generateProvider(ctx) {
|
|
732
|
+
const searchEnabled = ctx.config.search?.enabled !== false;
|
|
733
|
+
if (ctx.config.i18n?.enabled === true) return `'use client';
|
|
734
|
+
import { RootProvider } from 'fumadocs-ui/provider/next';
|
|
735
|
+
import SafeSearchDialog from './components/search-dialog';
|
|
736
|
+
import { i18nUI } from '@/lib/i18n-ui';
|
|
737
|
+
import type { ReactNode } from 'react';
|
|
738
|
+
|
|
739
|
+
export function AppProvider({ children, lang }: { children: ReactNode; lang: string }) {
|
|
740
|
+
return (
|
|
741
|
+
<RootProvider
|
|
742
|
+
i18n={i18nUI.provider(lang)}
|
|
743
|
+
search={{
|
|
744
|
+
enabled: ${searchEnabled},
|
|
745
|
+
SearchDialog: SafeSearchDialog,
|
|
746
|
+
options: { type: 'static', api: '/api/search' },
|
|
747
|
+
}}
|
|
748
|
+
>
|
|
749
|
+
{children}
|
|
750
|
+
</RootProvider>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
`;
|
|
510
754
|
return `'use client';
|
|
511
|
-
import {
|
|
755
|
+
import { RootProvider } from 'fumadocs-ui/provider/next';
|
|
756
|
+
import SafeSearchDialog from './components/search-dialog';
|
|
512
757
|
import type { ReactNode } from 'react';
|
|
513
758
|
|
|
514
759
|
export function AppProvider({ children }: { children: ReactNode }) {
|
|
515
760
|
return (
|
|
516
|
-
<
|
|
517
|
-
|
|
761
|
+
<RootProvider
|
|
762
|
+
search={{
|
|
763
|
+
enabled: ${searchEnabled},
|
|
764
|
+
SearchDialog: SafeSearchDialog,
|
|
765
|
+
options: { type: 'static', api: '/api/search' },
|
|
766
|
+
}}
|
|
518
767
|
>
|
|
519
768
|
{children}
|
|
520
|
-
</
|
|
769
|
+
</RootProvider>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* 生成 app/components/search-dialog.tsx(或 app/[lang]/components/search-dialog.tsx)
|
|
776
|
+
*
|
|
777
|
+
* SafeSearchDialog 组件直接放在生成应用中,确保所有 fumadocs-ui 导入
|
|
778
|
+
* 都来自同一个实例,避免 React Context 跨实例失效的问题。
|
|
779
|
+
*
|
|
780
|
+
* 重要修复:注入自定义 initOrama 解决 Orama 不支持中文等语言的问题。
|
|
781
|
+
*
|
|
782
|
+
* 问题根因:
|
|
783
|
+
* fumadocs-core 的 orama-static.js 在运行时调用 create({ language: 'zh' }),
|
|
784
|
+
* 但 @orama/orama 不支持 'zh' 作为 language 参数(仅支持 31 种语言),
|
|
785
|
+
* 会抛出 LANGUAGE_NOT_SUPPORTED 错误导致搜索功能完全失效。
|
|
786
|
+
*
|
|
787
|
+
* 修复方案:
|
|
788
|
+
* 利用 fumadocs-core 的 StaticOptions.initOrama 扩展点,提供自定义的
|
|
789
|
+
* initOrama 函数。对于 Orama 不支持的语言(如 zh),不传 language 参数,
|
|
790
|
+
* 让 Orama 使用默认的 english 分词器;对于支持的语言正常传入。
|
|
791
|
+
*/
|
|
792
|
+
function generateSearchDialog(_ctx) {
|
|
793
|
+
return `'use client';
|
|
794
|
+
|
|
795
|
+
import { useDocsSearch } from 'fumadocs-core/search/client';
|
|
796
|
+
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
|
|
797
|
+
import { create } from '@orama/orama';
|
|
798
|
+
import {
|
|
799
|
+
SearchDialog,
|
|
800
|
+
SearchDialogClose,
|
|
801
|
+
SearchDialogContent,
|
|
802
|
+
SearchDialogFooter,
|
|
803
|
+
SearchDialogHeader,
|
|
804
|
+
SearchDialogIcon,
|
|
805
|
+
SearchDialogInput,
|
|
806
|
+
SearchDialogList,
|
|
807
|
+
SearchDialogOverlay,
|
|
808
|
+
TagsList,
|
|
809
|
+
TagsListItem,
|
|
810
|
+
} from 'fumadocs-ui/components/dialog/search';
|
|
811
|
+
import { useI18n } from 'fumadocs-ui/contexts/i18n';
|
|
812
|
+
import { useMemo, useState } from 'react';
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Orama 支持的语言名称集合。
|
|
816
|
+
*
|
|
817
|
+
* 不在此列表中的语言(如中文 zh)不能作为 language 参数传入 @orama/orama 的 create(),
|
|
818
|
+
* 否则会抛出 LANGUAGE_NOT_SUPPORTED 错误导致搜索功能完全失效。
|
|
819
|
+
*
|
|
820
|
+
* 来源:@orama/orama 内部的 SUPPORTED_LANGUAGES 列表,
|
|
821
|
+
* 与 fumadocs-core/dist/search/server.js 中的 STEMMERS 保持一致。
|
|
822
|
+
*/
|
|
823
|
+
const SUPPORTED_ORAMA_LANGUAGES = new Set([
|
|
824
|
+
'arabic', 'armenian', 'bulgarian', 'czech', 'danish', 'dutch',
|
|
825
|
+
'english', 'finnish', 'french', 'german', 'greek', 'hungarian',
|
|
826
|
+
'indian', 'indonesian', 'irish', 'italian', 'lithuanian', 'nepali',
|
|
827
|
+
'norwegian', 'portuguese', 'romanian', 'russian', 'serbian',
|
|
828
|
+
'slovenian', 'spanish', 'swedish', 'tamil', 'turkish',
|
|
829
|
+
'ukrainian', 'sanskrit',
|
|
830
|
+
]);
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 将 locale code(如 'zh', 'en')映射为 Orama 支持的 language 全名。
|
|
834
|
+
*
|
|
835
|
+
* 对于不支持的语言返回 undefined,此时 create() 不传 language 参数,
|
|
836
|
+
* Orama 会默认使用 english 分词器(对中文等语言做基本的空格/标点分词)。
|
|
837
|
+
*/
|
|
838
|
+
function resolveOramaLanguage(localeCode: string): string | undefined {
|
|
839
|
+
const map: Record<string, string> = {
|
|
840
|
+
ar: 'arabic', am: 'armenian', bg: 'bulgarian', cz: 'czech',
|
|
841
|
+
dk: 'danish', nl: 'dutch', en: 'english', fi: 'finnish',
|
|
842
|
+
fr: 'french', de: 'german', gr: 'greek', hu: 'hungarian',
|
|
843
|
+
in: 'indian', id: 'indonesian', ie: 'irish', it: 'italian',
|
|
844
|
+
lt: 'lithuanian', np: 'nepali', no: 'norwegian', pt: 'portuguese',
|
|
845
|
+
ro: 'romanian', ru: 'russian', rs: 'serbian', sl: 'slovenian',
|
|
846
|
+
es: 'spanish', se: 'swedish', ta: 'tamil', tr: 'turkish',
|
|
847
|
+
uk: 'ukrainian', sk: 'sanskrit',
|
|
848
|
+
};
|
|
849
|
+
const langName = map[localeCode];
|
|
850
|
+
return langName && SUPPORTED_ORAMA_LANGUAGES.has(langName) ? langName : undefined;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
interface SafeSearchDialogProps {
|
|
854
|
+
defaultTag?: string;
|
|
855
|
+
tags?: { value: string; name: string }[];
|
|
856
|
+
api?: string;
|
|
857
|
+
delayMs?: number;
|
|
858
|
+
type?: 'fetch' | 'static';
|
|
859
|
+
allowClear?: boolean;
|
|
860
|
+
links?: [string, string][];
|
|
861
|
+
footer?: React.ReactNode;
|
|
862
|
+
open?: boolean;
|
|
863
|
+
onOpenChange?: (open: boolean) => void;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export default function SafeSearchDialog({
|
|
867
|
+
defaultTag,
|
|
868
|
+
tags = [],
|
|
869
|
+
api,
|
|
870
|
+
delayMs,
|
|
871
|
+
type = 'fetch',
|
|
872
|
+
allowClear = false,
|
|
873
|
+
links = [],
|
|
874
|
+
footer,
|
|
875
|
+
open = false,
|
|
876
|
+
onOpenChange = (): void => {},
|
|
877
|
+
}: SafeSearchDialogProps) {
|
|
878
|
+
const { locale } = useI18n();
|
|
879
|
+
const [tag, setTag] = useState(defaultTag);
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* 自定义 initOrama:根据 locale 是否受 Orama 支持,决定是否传入 language 参数。
|
|
883
|
+
*
|
|
884
|
+
* 这解决了 Orama 不支持 'zh' 等语言时抛出 LANGUAGE_NOT_SUPPORTED 导致搜索失效的问题。
|
|
885
|
+
* fumadocs-core 的 StaticOptions 类型已官方暴露 initOrama 参数供此用途。
|
|
886
|
+
*/
|
|
887
|
+
const safeInitOrama = useMemo(
|
|
888
|
+
() => (localeCode?: string) => {
|
|
889
|
+
const lang = localeCode ? resolveOramaLanguage(localeCode) : undefined;
|
|
890
|
+
return create({
|
|
891
|
+
schema: { _: 'string' },
|
|
892
|
+
...(lang ? { language: lang } : {}),
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
[],
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
const { search, setSearch, query } = useDocsSearch(
|
|
899
|
+
type === 'fetch'
|
|
900
|
+
? {
|
|
901
|
+
type: 'fetch',
|
|
902
|
+
...(api != null && { api }),
|
|
903
|
+
...(locale != null && { locale }),
|
|
904
|
+
...(tag != null && { tag }),
|
|
905
|
+
...(delayMs != null && { delayMs }),
|
|
906
|
+
}
|
|
907
|
+
: {
|
|
908
|
+
type: 'static',
|
|
909
|
+
...(api != null && { from: api }),
|
|
910
|
+
...(locale != null && { locale }),
|
|
911
|
+
...(tag != null && { tag }),
|
|
912
|
+
...(delayMs != null && { delayMs }),
|
|
913
|
+
initOrama: safeInitOrama,
|
|
914
|
+
}
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
const defaultItems = useMemo(() => {
|
|
918
|
+
if (links.length === 0) return null;
|
|
919
|
+
return links.map(([name, link]) => ({
|
|
920
|
+
type: 'page' as const,
|
|
921
|
+
id: name,
|
|
922
|
+
content: name,
|
|
923
|
+
url: link,
|
|
924
|
+
}));
|
|
925
|
+
}, [links]);
|
|
926
|
+
|
|
927
|
+
useOnChange(defaultTag, (v) => {
|
|
928
|
+
setTag(v);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// 核心修复:使用 Array.isArray 守卫,防止非数组值导致 .map() 报错
|
|
932
|
+
const safeItems = Array.isArray(query.data) ? query.data : defaultItems;
|
|
933
|
+
|
|
934
|
+
return (
|
|
935
|
+
<SearchDialog
|
|
936
|
+
open={open}
|
|
937
|
+
onOpenChange={onOpenChange}
|
|
938
|
+
search={search}
|
|
939
|
+
onSearchChange={setSearch}
|
|
940
|
+
isLoading={query.isLoading}
|
|
941
|
+
>
|
|
942
|
+
<SearchDialogOverlay />
|
|
943
|
+
<SearchDialogContent>
|
|
944
|
+
<SearchDialogHeader>
|
|
945
|
+
<SearchDialogIcon />
|
|
946
|
+
<SearchDialogInput />
|
|
947
|
+
<SearchDialogClose />
|
|
948
|
+
</SearchDialogHeader>
|
|
949
|
+
<SearchDialogList items={safeItems} />
|
|
950
|
+
</SearchDialogContent>
|
|
951
|
+
<SearchDialogFooter>
|
|
952
|
+
{tags.length > 0 && (
|
|
953
|
+
<TagsList {...(tag != null && { tag })} onTagChange={setTag} allowClear={allowClear}>
|
|
954
|
+
{tags.map((tagItem) => (
|
|
955
|
+
<TagsListItem key={tagItem.value} value={tagItem.value}>
|
|
956
|
+
{tagItem.name}
|
|
957
|
+
</TagsListItem>
|
|
958
|
+
))}
|
|
959
|
+
</TagsList>
|
|
960
|
+
)}
|
|
961
|
+
{footer}
|
|
962
|
+
</SearchDialogFooter>
|
|
963
|
+
</SearchDialog>
|
|
521
964
|
);
|
|
522
965
|
}
|
|
523
966
|
`;
|
|
@@ -525,7 +968,83 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|
|
525
968
|
|
|
526
969
|
//#endregion
|
|
527
970
|
//#region src/core/generator/raw-content-route.ts
|
|
528
|
-
function generateRawContentRoute() {
|
|
971
|
+
function generateRawContentRoute(ctx) {
|
|
972
|
+
const isI18n = ctx.config.i18n?.enabled === true;
|
|
973
|
+
const useDirParser = isDirParser(ctx.config);
|
|
974
|
+
if (isI18n && useDirParser) return `import { readFile } from 'node:fs/promises';
|
|
975
|
+
import { join } from 'node:path';
|
|
976
|
+
import { NextResponse } from 'next/server';
|
|
977
|
+
|
|
978
|
+
const _defaultLang = '${ctx.config.i18n?.defaultLanguage ?? ctx.config.locale ?? "zh"}';
|
|
979
|
+
|
|
980
|
+
export async function GET(
|
|
981
|
+
request: Request,
|
|
982
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
983
|
+
) {
|
|
984
|
+
const { path: segments } = await params;
|
|
985
|
+
const slug = segments.join('/');
|
|
986
|
+
// 从查询参数获取语言,回退到默认语言(API 路由不在 [lang] 路径段下)
|
|
987
|
+
const { searchParams } = new URL(request.url);
|
|
988
|
+
const lang = searchParams.get('lang') ?? _defaultLang;
|
|
989
|
+
// dir parser: 文件位于 content/{lang}/{slug}.ext
|
|
990
|
+
for (const ext of ['.mdx', '.md']) {
|
|
991
|
+
try {
|
|
992
|
+
const filePath = join(process.cwd(), 'content', lang, \`\${slug}\${ext}\`);
|
|
993
|
+
const content = await readFile(filePath, 'utf-8');
|
|
994
|
+
return new NextResponse(content, {
|
|
995
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
996
|
+
});
|
|
997
|
+
} catch {
|
|
998
|
+
/* try next extension */
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return new NextResponse('Not found', { status: 404 });
|
|
1002
|
+
}
|
|
1003
|
+
`;
|
|
1004
|
+
if (isI18n) return `import { readFile } from 'node:fs/promises';
|
|
1005
|
+
import { join } from 'node:path';
|
|
1006
|
+
import { NextResponse } from 'next/server';
|
|
1007
|
+
|
|
1008
|
+
const _defaultLang = '${ctx.config.i18n?.defaultLanguage ?? ctx.config.locale ?? "zh"}';
|
|
1009
|
+
|
|
1010
|
+
export async function GET(
|
|
1011
|
+
request: Request,
|
|
1012
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
1013
|
+
) {
|
|
1014
|
+
const { path: segments } = await params;
|
|
1015
|
+
const slug = segments.join('/');
|
|
1016
|
+
// 从查询参数获取语言,回退到默认语言(API 路由不在 [lang] 路径段下)
|
|
1017
|
+
const { searchParams } = new URL(request.url);
|
|
1018
|
+
const lang = searchParams.get('lang') ?? _defaultLang;
|
|
1019
|
+
// 尝试带语言后缀的文件,再回退到默认语言文件
|
|
1020
|
+
const suffix = lang !== _defaultLang ? \`.\${lang}\` : '';
|
|
1021
|
+
for (const ext of ['.mdx', '.md']) {
|
|
1022
|
+
// 先尝试带后缀
|
|
1023
|
+
if (suffix) {
|
|
1024
|
+
try {
|
|
1025
|
+
const filePath = join(process.cwd(), 'content', \`\${slug}\${suffix}\${ext}\`);
|
|
1026
|
+
const content = await readFile(filePath, 'utf-8');
|
|
1027
|
+
return new NextResponse(content, {
|
|
1028
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
1029
|
+
});
|
|
1030
|
+
} catch {
|
|
1031
|
+
/* 回退 */
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// 再尝试不带后缀(默认语言或 fallback)
|
|
1035
|
+
try {
|
|
1036
|
+
const filePath = join(process.cwd(), 'content', \`\${slug}\${ext}\`);
|
|
1037
|
+
const content = await readFile(filePath, 'utf-8');
|
|
1038
|
+
return new NextResponse(content, {
|
|
1039
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
1040
|
+
});
|
|
1041
|
+
} catch {
|
|
1042
|
+
/* try next extension */
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return new NextResponse('Not found', { status: 404 });
|
|
1046
|
+
}
|
|
1047
|
+
`;
|
|
529
1048
|
return `import { readFile } from 'node:fs/promises';
|
|
530
1049
|
import { join } from 'node:path';
|
|
531
1050
|
import { NextResponse } from 'next/server';
|
|
@@ -554,7 +1073,71 @@ export async function GET(
|
|
|
554
1073
|
|
|
555
1074
|
//#endregion
|
|
556
1075
|
//#region src/core/generator/search-route.ts
|
|
557
|
-
|
|
1076
|
+
/**
|
|
1077
|
+
* Orama/FlexSearch 支持的语言映射(来自 fumadocs-core/dist/search/server.js STEMMERS)
|
|
1078
|
+
*
|
|
1079
|
+
* key = 语言全名(传给 tokenizer 的 language 值),value = 语言代码(locale code)
|
|
1080
|
+
*
|
|
1081
|
+
* 不在此列表中的语言(如中文 zh)不能作为 language 参数传入,
|
|
1082
|
+
* 否则构建时会抛出 "Language X is not supported" 错误。
|
|
1083
|
+
* 对于不支持的语言,传入空对象 {} 让 Orama 使用默认分词器。
|
|
1084
|
+
*/
|
|
1085
|
+
const SUPPORTED_LOCALE_MAP = {
|
|
1086
|
+
arabic: "ar",
|
|
1087
|
+
armenian: "am",
|
|
1088
|
+
bulgarian: "bg",
|
|
1089
|
+
czech: "cz",
|
|
1090
|
+
danish: "dk",
|
|
1091
|
+
dutch: "nl",
|
|
1092
|
+
english: "en",
|
|
1093
|
+
finnish: "fi",
|
|
1094
|
+
french: "fr",
|
|
1095
|
+
german: "de",
|
|
1096
|
+
greek: "gr",
|
|
1097
|
+
hungarian: "hu",
|
|
1098
|
+
indian: "in",
|
|
1099
|
+
indonesian: "id",
|
|
1100
|
+
irish: "ie",
|
|
1101
|
+
italian: "it",
|
|
1102
|
+
lithuanian: "lt",
|
|
1103
|
+
nepali: "np",
|
|
1104
|
+
norwegian: "no",
|
|
1105
|
+
portuguese: "pt",
|
|
1106
|
+
romanian: "ro",
|
|
1107
|
+
russian: "ru",
|
|
1108
|
+
serbian: "rs",
|
|
1109
|
+
slovenian: "ru",
|
|
1110
|
+
spanish: "es",
|
|
1111
|
+
swedish: "se",
|
|
1112
|
+
tamil: "ta",
|
|
1113
|
+
turkish: "tr",
|
|
1114
|
+
ukrainian: "uk",
|
|
1115
|
+
sanskrit: "sk"
|
|
1116
|
+
};
|
|
1117
|
+
/**
|
|
1118
|
+
* 根据语言代码查找对应的支持的 language 名称
|
|
1119
|
+
* 例如:'en' → 'english','zh' → undefined(不支持)
|
|
1120
|
+
*/
|
|
1121
|
+
function resolveLanguageName(localeCode) {
|
|
1122
|
+
return Object.keys(SUPPORTED_LOCALE_MAP).find((key) => SUPPORTED_LOCALE_MAP[key] === localeCode);
|
|
1123
|
+
}
|
|
1124
|
+
function generateSearchRoute(ctx) {
|
|
1125
|
+
const i18nCfg = ctx?.config.i18n;
|
|
1126
|
+
if (i18nCfg?.enabled === true && i18nCfg.languages && i18nCfg.languages.length >= 2) return `import { source } from '@/lib/source';
|
|
1127
|
+
import { createFromSource } from 'fumadocs-core/search/server';
|
|
1128
|
+
|
|
1129
|
+
export const revalidate = false;
|
|
1130
|
+
const _localeMap: Record<string, unknown> = {
|
|
1131
|
+
${(i18nCfg?.languages ?? []).map((l) => {
|
|
1132
|
+
const langName = resolveLanguageName(l.code);
|
|
1133
|
+
if (langName) return ` ${l.code}: '${langName}'`;
|
|
1134
|
+
return ` ${l.code}: {}`;
|
|
1135
|
+
}).join(",\n")},
|
|
1136
|
+
};
|
|
1137
|
+
export const { staticGET: GET } = createFromSource(source, {
|
|
1138
|
+
localeMap: _localeMap as any,
|
|
1139
|
+
});
|
|
1140
|
+
`;
|
|
558
1141
|
return `import { source } from '@/lib/source';
|
|
559
1142
|
import { createFromSource } from 'fumadocs-core/search/server';
|
|
560
1143
|
|
|
@@ -570,6 +1153,8 @@ function generateSourceConfig(_ctx) {
|
|
|
570
1153
|
const titleMapEntries = Object.entries(titleMap).map(([slug, title]) => ` '${slug}': '${title.replace(/'/g, "\\'")}'`).join(",\n");
|
|
571
1154
|
const titleMapStr = titleMapEntries ? `{\n${titleMapEntries}\n}` : "{}";
|
|
572
1155
|
const isStrict = _ctx.config.contentPolicy !== "all";
|
|
1156
|
+
const isI18n = isI18nEnabled(_ctx.config);
|
|
1157
|
+
const useDirParser = isDirParser(_ctx.config);
|
|
573
1158
|
return `import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
|
|
574
1159
|
import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
|
|
575
1160
|
import { z } from 'zod';
|
|
@@ -581,16 +1166,36 @@ const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.co
|
|
|
581
1166
|
function slugFromPath(path: string): string {
|
|
582
1167
|
const normalized = path.replace(/\\\\/g, '/');
|
|
583
1168
|
const idx = normalized.indexOf('content/');
|
|
584
|
-
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1169
|
+
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;${useDirParser ? `
|
|
1170
|
+
// dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
|
|
1171
|
+
const parts = relative.split('/');
|
|
1172
|
+
if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
|
|
1173
|
+
return parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
|
|
1174
|
+
}
|
|
1175
|
+
return relative.replace(/\\.(md|mdx)$/i, '');` : `
|
|
1176
|
+
let slug = relative.replace(/\\.(md|mdx)$/i, '');
|
|
1177
|
+
${isI18n ? ` // 剥离语言后缀:index.en -> index
|
|
1178
|
+
slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
|
|
1179
|
+
return slug;`}
|
|
1180
|
+
}` : ""}
|
|
588
1181
|
function titleFromPath(path: string): string {
|
|
589
|
-
const normalized = path.replace(/\\\\/g, '/');
|
|
1182
|
+
${useDirParser ? ` const normalized = path.replace(/\\\\/g, '/');
|
|
590
1183
|
const idx = normalized.indexOf('content/');
|
|
591
1184
|
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
|
|
1185
|
+
// dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
|
|
1186
|
+
const parts = relative.split('/');
|
|
1187
|
+
if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
|
|
1188
|
+
const slug = parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
|
|
1189
|
+
return titleMap[slug] || slug.split('/').pop() || slug;
|
|
1190
|
+
}
|
|
592
1191
|
const slug = relative.replace(/\\.(md|mdx)$/i, '');
|
|
593
|
-
return titleMap[slug] || slug.split('/').pop() || slug;
|
|
1192
|
+
return titleMap[slug] || slug.split('/').pop() || slug;` : ` const normalized = path.replace(/\\\\/g, '/');
|
|
1193
|
+
const idx = normalized.indexOf('content/');
|
|
1194
|
+
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
|
|
1195
|
+
let slug = relative.replace(/\\.(md|mdx)$/i, '');
|
|
1196
|
+
${isI18n ? ` // 剥离语言后缀:guide/configuration.en -> guide/configuration
|
|
1197
|
+
slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
|
|
1198
|
+
return titleMap[slug] || slug.split('/').pop() || slug;`}
|
|
594
1199
|
}
|
|
595
1200
|
|
|
596
1201
|
export const docs = defineDocs({
|
|
@@ -671,7 +1276,8 @@ function generateTsconfig() {
|
|
|
671
1276
|
//#endregion
|
|
672
1277
|
//#region src/core/generator/index.ts
|
|
673
1278
|
async function generateAll(ctx) {
|
|
674
|
-
const
|
|
1279
|
+
const isI18n = isI18nEnabled(ctx.config);
|
|
1280
|
+
const baseFiles = [
|
|
675
1281
|
{
|
|
676
1282
|
path: "source.config.ts",
|
|
677
1283
|
content: generateSourceConfig(ctx)
|
|
@@ -698,7 +1304,7 @@ async function generateAll(ctx) {
|
|
|
698
1304
|
},
|
|
699
1305
|
{
|
|
700
1306
|
path: "lib/source.ts",
|
|
701
|
-
content: generateLibSource()
|
|
1307
|
+
content: generateLibSource(ctx)
|
|
702
1308
|
},
|
|
703
1309
|
{
|
|
704
1310
|
path: "lib/layout.tsx",
|
|
@@ -715,16 +1321,65 @@ async function generateAll(ctx) {
|
|
|
715
1321
|
{
|
|
716
1322
|
path: "components/page-actions.tsx",
|
|
717
1323
|
content: generatePageActionsComponent()
|
|
1324
|
+
}
|
|
1325
|
+
];
|
|
1326
|
+
let files;
|
|
1327
|
+
if (isI18n) files = [
|
|
1328
|
+
...baseFiles,
|
|
1329
|
+
{
|
|
1330
|
+
path: "lib/i18n.ts",
|
|
1331
|
+
content: generateI18nConfig(ctx)
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
path: "lib/i18n-ui.ts",
|
|
1335
|
+
content: generateI18nUI(ctx)
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
path: "middleware.ts",
|
|
1339
|
+
content: generateMiddleware(ctx)
|
|
1340
|
+
},
|
|
1341
|
+
...ctx.dev ? [{
|
|
1342
|
+
path: "app/api/raw/[...path]/route.ts",
|
|
1343
|
+
content: generateRawContentRoute(ctx)
|
|
1344
|
+
}, {
|
|
1345
|
+
path: "app/api/search/route.ts",
|
|
1346
|
+
content: generateSearchRoute(ctx)
|
|
1347
|
+
}] : [{
|
|
1348
|
+
path: "app/api/search/route.ts",
|
|
1349
|
+
content: generateSearchRoute(ctx)
|
|
1350
|
+
}],
|
|
1351
|
+
{
|
|
1352
|
+
path: "app/[lang]/layout.tsx",
|
|
1353
|
+
content: generateRootLayoutI18n(ctx)
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
path: "app/[lang]/provider.tsx",
|
|
1357
|
+
content: generateProvider(ctx)
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
path: "app/[lang]/components/search-dialog.tsx",
|
|
1361
|
+
content: generateSearchDialog(ctx)
|
|
718
1362
|
},
|
|
1363
|
+
{
|
|
1364
|
+
path: "app/[lang]/[[...slug]]/layout.tsx",
|
|
1365
|
+
content: generateDocsLayout(ctx)
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
path: "app/[lang]/[[...slug]]/page.tsx",
|
|
1369
|
+
content: generatePage(ctx)
|
|
1370
|
+
}
|
|
1371
|
+
];
|
|
1372
|
+
else files = [
|
|
1373
|
+
...baseFiles,
|
|
719
1374
|
...ctx.dev ? [{
|
|
720
1375
|
path: "app/api/raw/[...path]/route.ts",
|
|
721
|
-
content: generateRawContentRoute()
|
|
1376
|
+
content: generateRawContentRoute(ctx)
|
|
722
1377
|
}, {
|
|
723
1378
|
path: "app/api/search/route.ts",
|
|
724
|
-
content: generateSearchRoute()
|
|
1379
|
+
content: generateSearchRoute(ctx)
|
|
725
1380
|
}] : [{
|
|
726
1381
|
path: "app/api/search/route.ts",
|
|
727
|
-
content: generateSearchRoute()
|
|
1382
|
+
content: generateSearchRoute(ctx)
|
|
728
1383
|
}],
|
|
729
1384
|
{
|
|
730
1385
|
path: "app/layout.tsx",
|
|
@@ -734,6 +1389,10 @@ async function generateAll(ctx) {
|
|
|
734
1389
|
path: "app/provider.tsx",
|
|
735
1390
|
content: generateProvider(ctx)
|
|
736
1391
|
},
|
|
1392
|
+
{
|
|
1393
|
+
path: "app/components/search-dialog.tsx",
|
|
1394
|
+
content: generateSearchDialog(ctx)
|
|
1395
|
+
},
|
|
737
1396
|
{
|
|
738
1397
|
path: "app/[[...slug]]/layout.tsx",
|
|
739
1398
|
content: generateDocsLayout(ctx)
|
|
@@ -782,11 +1441,53 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|
|
782
1441
|
}
|
|
783
1442
|
`;
|
|
784
1443
|
}
|
|
1444
|
+
/**
|
|
1445
|
+
* 生成 app/[lang]/layout.tsx — 多语言模式的根布局
|
|
1446
|
+
*
|
|
1447
|
+
* 与单语言模式的关键区别:
|
|
1448
|
+
* 1. 从 params 中获取 lang 参数
|
|
1449
|
+
* 2. AppLayout 接收 lang 参数设置 html lang 属性
|
|
1450
|
+
* 3. AppProvider 接收 lang 参数用于 i18n UI
|
|
1451
|
+
*/
|
|
1452
|
+
function generateRootLayoutI18n(ctx) {
|
|
1453
|
+
const { config } = ctx;
|
|
1454
|
+
const favicon = config.favicon;
|
|
1455
|
+
return `${favicon ? `import type { Metadata } from 'next';
|
|
1456
|
+
|
|
1457
|
+
export const metadata: Metadata = {
|
|
1458
|
+
icons: {
|
|
1459
|
+
icon: '${favicon}',
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
` : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1464
|
+
import { AppProvider } from './provider';
|
|
1465
|
+
import type { ReactNode } from 'react';
|
|
1466
|
+
import '../../global.css';
|
|
1467
|
+
|
|
1468
|
+
export default async function RootLayout({
|
|
1469
|
+
params,
|
|
1470
|
+
children,
|
|
1471
|
+
}: {
|
|
1472
|
+
params: Promise<{ lang: string }>;
|
|
1473
|
+
children: ReactNode;
|
|
1474
|
+
}) {
|
|
1475
|
+
const { lang } = await params;
|
|
1476
|
+
|
|
1477
|
+
return (
|
|
1478
|
+
<AppLayout lang={lang}>
|
|
1479
|
+
<AppProvider lang={lang}>{children}</AppProvider>
|
|
1480
|
+
</AppLayout>
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
`;
|
|
1484
|
+
}
|
|
785
1485
|
function generateDocsLayout(ctx) {
|
|
786
1486
|
const { config } = ctx;
|
|
787
1487
|
const githubLink = config.navbar?.github ?? "";
|
|
788
1488
|
const navLinks = config.navbar?.links ?? [];
|
|
789
1489
|
const footerText = config.footer?.text ?? "";
|
|
1490
|
+
const isI18n = isI18nEnabled(config);
|
|
790
1491
|
const linksArray = navLinks.map((l) => ({
|
|
791
1492
|
text: l.label,
|
|
792
1493
|
url: l.href,
|
|
@@ -817,14 +1518,43 @@ function generateDocsLayout(ctx) {
|
|
|
817
1518
|
const lucideImportLine = hasIcons ? `\nimport { ${iconNameList.join(", ")} } from 'lucide-react';` : "";
|
|
818
1519
|
const iconMapSnippet = hasIcons ? `\nconst iconMap = {${iconNameList.map((name) => `\n ${name}: <${name} />,`).join("")}\n} as const;
|
|
819
1520
|
` : "";
|
|
1521
|
+
const treeLine = hasSidebar ? hasIcons ? isI18n ? "tree: restructureTree(source.getPageTree(lang), sidebarConfig, iconMap)," : "tree: restructureTree(source.getPageTree(), sidebarConfig, iconMap)," : isI18n ? "tree: restructureTree(source.getPageTree(lang), sidebarConfig)," : "tree: restructureTree(source.getPageTree(), sidebarConfig)," : isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
|
|
1522
|
+
const restructureTreeImport = hasSidebar ? "\nimport { restructureTree } from 'openmanual/utils/restructure-tree';" : "";
|
|
1523
|
+
if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
1524
|
+
import { baseOptions } from '@/lib/layout';
|
|
1525
|
+
import { source } from '@/lib/source';
|
|
1526
|
+
import type { ReactNode } from 'react';${restructureTreeImport}${lucideImportLine}
|
|
1527
|
+
${sidebarConfigSnippet}${iconMapSnippet}
|
|
1528
|
+
|
|
1529
|
+
export default async function DocsLayoutWrapper({
|
|
1530
|
+
params,
|
|
1531
|
+
children,
|
|
1532
|
+
}: {
|
|
1533
|
+
params: Promise<{ lang: string }>;
|
|
1534
|
+
children: ReactNode;
|
|
1535
|
+
}) {
|
|
1536
|
+
const { lang } = await params;
|
|
1537
|
+
|
|
1538
|
+
const docsOptions = {
|
|
1539
|
+
...baseOptions(lang),
|
|
1540
|
+
${treeLine}${githubLine}${linksLine}${footerLine}
|
|
1541
|
+
};
|
|
1542
|
+
|
|
1543
|
+
return (
|
|
1544
|
+
<DocsLayout {...docsOptions}>
|
|
1545
|
+
{children}
|
|
1546
|
+
</DocsLayout>
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
`;
|
|
820
1550
|
return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
821
1551
|
import { baseOptions } from '@/lib/layout';
|
|
822
1552
|
import { source } from '@/lib/source';
|
|
823
|
-
import type { ReactNode } from 'react';${
|
|
1553
|
+
import type { ReactNode } from 'react';${restructureTreeImport}${lucideImportLine}
|
|
824
1554
|
${sidebarConfigSnippet}${iconMapSnippet}
|
|
825
1555
|
const docsOptions = {
|
|
826
1556
|
...baseOptions(),
|
|
827
|
-
${
|
|
1557
|
+
${treeLine}${githubLine}${linksLine}${footerLine}
|
|
828
1558
|
};
|
|
829
1559
|
|
|
830
1560
|
export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
|
|
@@ -858,14 +1588,14 @@ async function ensureLogoFile(ctx, logoPath, variant) {
|
|
|
858
1588
|
}
|
|
859
1589
|
}
|
|
860
1590
|
/**
|
|
861
|
-
* Generate meta.json
|
|
862
|
-
* fumadocs displays the configured
|
|
863
|
-
* auto-capitalizing the English directory name.
|
|
1591
|
+
* Generate meta.json (and meta.en.json in i18n mode) for each sidebar
|
|
1592
|
+
* group directory so that fumadocs displays the configured group name.
|
|
864
1593
|
*/
|
|
865
1594
|
async function generateMetaFiles(ctx) {
|
|
866
1595
|
const sidebar = ctx.config.sidebar;
|
|
867
1596
|
if (!sidebar || sidebar.length === 0) return;
|
|
868
1597
|
const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
|
|
1598
|
+
const isI18n = isI18nEnabled(ctx.config);
|
|
869
1599
|
for (const group of sidebar) {
|
|
870
1600
|
const dirPrefix = group.pages.map((p) => p.slug).find((slug) => slug.includes("/"))?.split("/")[0];
|
|
871
1601
|
if (!dirPrefix) continue;
|
|
@@ -873,30 +1603,17 @@ async function generateMetaFiles(ctx) {
|
|
|
873
1603
|
const metaPath = join(dirPath, "meta.json");
|
|
874
1604
|
try {
|
|
875
1605
|
await access(metaPath);
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
* renaming extensions to .md. Non-markdown files are skipped.
|
|
888
|
-
*/
|
|
889
|
-
async function copyRawMarkdown(contentDir, targetDir) {
|
|
890
|
-
await mkdir(targetDir, { recursive: true });
|
|
891
|
-
const entries = await readdir(contentDir);
|
|
892
|
-
for (const entry of entries) {
|
|
893
|
-
const srcPath = join(contentDir, entry);
|
|
894
|
-
if ((await stat(srcPath)).isDirectory()) await copyRawMarkdown(srcPath, join(targetDir, entry));
|
|
895
|
-
else {
|
|
896
|
-
const ext = parse(entry).ext.toLowerCase();
|
|
897
|
-
if (ext === ".mdx" || ext === ".md") {
|
|
898
|
-
const { name } = parse(entry);
|
|
899
|
-
await copyFile(srcPath, join(targetDir, `${name}.md`));
|
|
1606
|
+
} catch {
|
|
1607
|
+
await mkdir(dirPath, { recursive: true });
|
|
1608
|
+
await writeFile(metaPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
|
|
1609
|
+
}
|
|
1610
|
+
if (isI18n) {
|
|
1611
|
+
const metaEnPath = join(dirPath, "meta.en.json");
|
|
1612
|
+
try {
|
|
1613
|
+
await access(metaEnPath);
|
|
1614
|
+
} catch {
|
|
1615
|
+
await mkdir(dirPath, { recursive: true });
|
|
1616
|
+
await writeFile(metaEnPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
|
|
900
1617
|
}
|
|
901
1618
|
}
|
|
902
1619
|
}
|
|
@@ -962,7 +1679,7 @@ const logger = {
|
|
|
962
1679
|
|
|
963
1680
|
//#endregion
|
|
964
1681
|
//#region src/utils/temp-dir.ts
|
|
965
|
-
const TEMP_DIR_NAME = ".
|
|
1682
|
+
const TEMP_DIR_NAME = ".cache";
|
|
966
1683
|
function getTempDir(cwd) {
|
|
967
1684
|
return join(cwd, TEMP_DIR_NAME);
|
|
968
1685
|
}
|
|
@@ -972,6 +1689,7 @@ function getAppDir(cwd) {
|
|
|
972
1689
|
async function ensureTempDir(cwd) {
|
|
973
1690
|
const tempDir = getTempDir(cwd);
|
|
974
1691
|
const appDir = getAppDir(cwd);
|
|
1692
|
+
await cleanTempDir(cwd);
|
|
975
1693
|
await mkdir(tempDir, { recursive: true });
|
|
976
1694
|
await mkdir(join(appDir, "app"), { recursive: true });
|
|
977
1695
|
return tempDir;
|
|
@@ -1046,8 +1764,21 @@ const buildCommand = new Command("build").description("构建静态站点").acti
|
|
|
1046
1764
|
} catch {
|
|
1047
1765
|
logger.warn("未找到静态导出产物,请检查 next.config.mjs 中 output: \"export\" 配置");
|
|
1048
1766
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1767
|
+
if (config.i18n?.enabled) {
|
|
1768
|
+
const defaultLang = config.i18n.defaultLanguage ?? config.locale ?? "zh";
|
|
1769
|
+
const redirectHtml = `<!DOCTYPE html>
|
|
1770
|
+
<html>
|
|
1771
|
+
<head>
|
|
1772
|
+
<meta charset="utf-8" />
|
|
1773
|
+
<meta http-equiv="refresh" content="0;url=/${defaultLang}" />
|
|
1774
|
+
<script>window.location.href='/${defaultLang}';<\/script>
|
|
1775
|
+
</head>
|
|
1776
|
+
<body>
|
|
1777
|
+
<p>Redirecting to <a href="/${defaultLang}">/${defaultLang}</a>...</p>
|
|
1778
|
+
</body>
|
|
1779
|
+
</html>`;
|
|
1780
|
+
await writeFile(resolve(outputDir, "index.html"), redirectHtml, "utf-8");
|
|
1781
|
+
}
|
|
1051
1782
|
logger.step("清理临时文件...");
|
|
1052
1783
|
await cleanTempDir(cwd);
|
|
1053
1784
|
logger.success("构建完成!");
|
|
@@ -1341,7 +2072,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
|
|
|
1341
2072
|
//#endregion
|
|
1342
2073
|
//#region src/cli/bin.ts
|
|
1343
2074
|
function getVersion() {
|
|
1344
|
-
return "0.
|
|
2075
|
+
return "0.11.0";
|
|
1345
2076
|
}
|
|
1346
2077
|
const program = new Command();
|
|
1347
2078
|
const commandName = basename(process.argv[1] ?? "openmanual");
|