openmanual 0.10.2 → 0.12.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 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, parse, relative, resolve, sep } from "node:path";
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, copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
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.10.2";
490
+ return "0.12.0";
368
491
  }
369
492
  function generatePackageJson(ctx) {
370
493
  const openmanualVersion = getOpenManualVersion();
@@ -381,6 +504,7 @@ function generatePackageJson(ctx) {
381
504
  start: "next start"
382
505
  },
383
506
  dependencies: {
507
+ "@orama/orama": "^3.1.0",
384
508
  "@tailwindcss/postcss": "^4.1.15",
385
509
  "fumadocs-core": "^16.7.7",
386
510
  "fumadocs-mdx": "^14.2.11",
@@ -408,6 +532,10 @@ function generatePackageJson(ctx) {
408
532
  function generatePage(_ctx) {
409
533
  const isStrict = _ctx.config.contentPolicy !== "all";
410
534
  const pageActionsEnabled = _ctx.config.pageActions?.enabled !== false;
535
+ if (_ctx.config.i18n?.enabled === true) return generatePageI18n(_ctx, isStrict, pageActionsEnabled);
536
+ return generatePageSingle(_ctx, isStrict, pageActionsEnabled);
537
+ }
538
+ function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
411
539
  const allowedSlugsSnippet = isStrict ? `
412
540
  const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
413
541
 
@@ -481,6 +609,94 @@ export function generateStaticParams() {
481
609
  }`}
482
610
  `;
483
611
  }
612
+ function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
613
+ const allowedSlugsSnippet = isStrict ? `
614
+ const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
615
+
616
+ function isAllowed(slug: string[] | undefined): boolean {
617
+ if (allowedSlugs.size === 0) return true;
618
+ const key = slug ? slug.join('/') : 'index';
619
+ return allowedSlugs.has(key);
620
+ }
621
+ ` : "";
622
+ return `import { source } from '@/lib/source';
623
+ import { notFound } from 'next/navigation';
624
+ import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page';
625
+ import defaultMdxComponents from 'fumadocs-ui/mdx';
626
+ import { Steps, Step } from 'fumadocs-ui/components/steps';
627
+ import { Tabs, Tab } from 'fumadocs-ui/components/tabs';
628
+ import { Files, File, Folder } from 'fumadocs-ui/components/files';
629
+ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
630
+ import { TypeTable } from 'fumadocs-ui/components/type-table';
631
+ import { Mermaid } from '@/components/mermaid';
632
+ import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
633
+ ${allowedSlugsSnippet}
634
+ export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
635
+ const { slug, lang } = await params;
636
+ const page = source.getPage(slug, lang);
637
+ ${isStrict ? `
638
+ if (!isAllowed(slug)) {
639
+ notFound();
640
+ }
641
+ ` : ""}
642
+ if (!page) {
643
+ notFound();
644
+ }
645
+
646
+ const MDX = page.data.body;
647
+
648
+ return (
649
+ <DocsPage toc={page.data.toc}>
650
+ ${pageActionsEnabled ? ` <div className="flex items-start justify-between gap-4">
651
+ <div>
652
+ <DocsTitle>{page.data.title}</DocsTitle>
653
+ {page.data.description && (
654
+ <DocsDescription>{page.data.description}</DocsDescription>
655
+ )}
656
+ </div>
657
+ <PageActions />
658
+ </div>` : ` <DocsTitle>{page.data.title}</DocsTitle>
659
+ {page.data.description && (
660
+ <DocsDescription>{page.data.description}</DocsDescription>
661
+ )}`}
662
+ <DocsBody data-content-area>
663
+ <MDX components={{ ...defaultMdxComponents, Steps, Step, Tabs, Tab, Files, File, Folder, Accordion, Accordions, TypeTable, Mermaid, Callout, CalloutTitle, CalloutDescription }} />
664
+ </DocsBody>
665
+ </DocsPage>
666
+ );
667
+ }
668
+ ${isStrict ? `
669
+ export function generateStaticParams() {
670
+ let params = source.generateParams();
671
+ params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug));
672
+ // Ensure every language has a homepage entry (slug: [])
673
+ const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
674
+ for (const lang of languages) {
675
+ if (!params.some((p: { slug: string[]; lang: string }) => p.slug.length === 0 && p.lang === lang)) {
676
+ const firstForLang = params.find((p: { slug: string[]; lang: string }) => p.lang === lang);
677
+ if (firstForLang) {
678
+ params.unshift({ ...firstForLang, slug: [] });
679
+ }
680
+ }
681
+ }
682
+ return params;
683
+ }` : `
684
+ export function generateStaticParams() {
685
+ const params = source.generateParams();
686
+ // Ensure every language has a homepage entry (slug: [])
687
+ const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
688
+ for (const lang of languages) {
689
+ if (!params.some((p: { slug: string[]; lang: string }) => p.slug.length === 0 && p.lang === lang)) {
690
+ const firstForLang = params.find((p: { slug: string[]; lang: string }) => p.lang === lang);
691
+ if (firstForLang) {
692
+ params.unshift({ ...firstForLang, slug: [] });
693
+ }
694
+ }
695
+ }
696
+ return params;
697
+ }`}
698
+ `;
699
+ }
484
700
 
485
701
  //#endregion
486
702
  //#region src/core/generator/page-actions-component.ts
@@ -507,13 +723,35 @@ export default config;
507
723
  //#endregion
508
724
  //#region src/core/generator/provider.ts
509
725
  /**
510
- * 生成 app/provider.tsx
726
+ * 生成 app/provider.tsx(或 app/[lang]/provider.tsx)
511
727
  *
512
728
  * 重要:直接从 fumadocs-ui 导入组件,而非通过 openmanual/components/provider 中转。
513
729
  * 这避免了 pnpm file: 协议下 fumadocs-ui 被安装两次(一次作为 openmanual 的依赖,
514
730
  * 一次作为生成应用的依赖)导致的多实例 React Context 问题。
515
731
  */
516
732
  function generateProvider(ctx) {
733
+ const searchEnabled = ctx.config.search?.enabled !== false;
734
+ if (ctx.config.i18n?.enabled === true) return `'use client';
735
+ import { RootProvider } from 'fumadocs-ui/provider/next';
736
+ import SafeSearchDialog from './components/search-dialog';
737
+ import { i18nUI } from '@/lib/i18n-ui';
738
+ import type { ReactNode } from 'react';
739
+
740
+ export function AppProvider({ children, lang }: { children: ReactNode; lang: string }) {
741
+ return (
742
+ <RootProvider
743
+ i18n={i18nUI.provider(lang)}
744
+ search={{
745
+ enabled: ${searchEnabled},
746
+ SearchDialog: SafeSearchDialog,
747
+ options: { type: 'static', api: '/api/search' },
748
+ }}
749
+ >
750
+ {children}
751
+ </RootProvider>
752
+ );
753
+ }
754
+ `;
517
755
  return `'use client';
518
756
  import { RootProvider } from 'fumadocs-ui/provider/next';
519
757
  import SafeSearchDialog from './components/search-dialog';
@@ -523,7 +761,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
523
761
  return (
524
762
  <RootProvider
525
763
  search={{
526
- enabled: ${ctx.config.search?.enabled !== false},
764
+ enabled: ${searchEnabled},
527
765
  SearchDialog: SafeSearchDialog,
528
766
  options: { type: 'static', api: '/api/search' },
529
767
  }}
@@ -535,16 +773,29 @@ export function AppProvider({ children }: { children: ReactNode }) {
535
773
  `;
536
774
  }
537
775
  /**
538
- * 生成 app/components/search-dialog.tsx
776
+ * 生成 app/components/search-dialog.tsx(或 app/[lang]/components/search-dialog.tsx)
539
777
  *
540
778
  * SafeSearchDialog 组件直接放在生成应用中,确保所有 fumadocs-ui 导入
541
779
  * 都来自同一个实例,避免 React Context 跨实例失效的问题。
780
+ *
781
+ * 重要修复:注入自定义 initOrama 解决 Orama 不支持中文等语言的问题。
782
+ *
783
+ * 问题根因:
784
+ * fumadocs-core 的 orama-static.js 在运行时调用 create({ language: 'zh' }),
785
+ * 但 @orama/orama 不支持 'zh' 作为 language 参数(仅支持 31 种语言),
786
+ * 会抛出 LANGUAGE_NOT_SUPPORTED 错误导致搜索功能完全失效。
787
+ *
788
+ * 修复方案:
789
+ * 利用 fumadocs-core 的 StaticOptions.initOrama 扩展点,提供自定义的
790
+ * initOrama 函数。对于 Orama 不支持的语言(如 zh),不传 language 参数,
791
+ * 让 Orama 使用默认的 english 分词器;对于支持的语言正常传入。
542
792
  */
543
- function generateSearchDialog() {
793
+ function generateSearchDialog(_ctx) {
544
794
  return `'use client';
545
795
 
546
796
  import { useDocsSearch } from 'fumadocs-core/search/client';
547
797
  import { useOnChange } from 'fumadocs-core/utils/use-on-change';
798
+ import { create } from '@orama/orama';
548
799
  import {
549
800
  SearchDialog,
550
801
  SearchDialogClose,
@@ -561,6 +812,45 @@ import {
561
812
  import { useI18n } from 'fumadocs-ui/contexts/i18n';
562
813
  import { useMemo, useState } from 'react';
563
814
 
815
+ /**
816
+ * Orama 支持的语言名称集合。
817
+ *
818
+ * 不在此列表中的语言(如中文 zh)不能作为 language 参数传入 @orama/orama 的 create(),
819
+ * 否则会抛出 LANGUAGE_NOT_SUPPORTED 错误导致搜索功能完全失效。
820
+ *
821
+ * 来源:@orama/orama 内部的 SUPPORTED_LANGUAGES 列表,
822
+ * 与 fumadocs-core/dist/search/server.js 中的 STEMMERS 保持一致。
823
+ */
824
+ const SUPPORTED_ORAMA_LANGUAGES = new Set([
825
+ 'arabic', 'armenian', 'bulgarian', 'czech', 'danish', 'dutch',
826
+ 'english', 'finnish', 'french', 'german', 'greek', 'hungarian',
827
+ 'indian', 'indonesian', 'irish', 'italian', 'lithuanian', 'nepali',
828
+ 'norwegian', 'portuguese', 'romanian', 'russian', 'serbian',
829
+ 'slovenian', 'spanish', 'swedish', 'tamil', 'turkish',
830
+ 'ukrainian', 'sanskrit',
831
+ ]);
832
+
833
+ /**
834
+ * 将 locale code(如 'zh', 'en')映射为 Orama 支持的 language 全名。
835
+ *
836
+ * 对于不支持的语言返回 undefined,此时 create() 不传 language 参数,
837
+ * Orama 会默认使用 english 分词器(对中文等语言做基本的空格/标点分词)。
838
+ */
839
+ function resolveOramaLanguage(localeCode: string): string | undefined {
840
+ const map: Record<string, string> = {
841
+ ar: 'arabic', am: 'armenian', bg: 'bulgarian', cz: 'czech',
842
+ dk: 'danish', nl: 'dutch', en: 'english', fi: 'finnish',
843
+ fr: 'french', de: 'german', gr: 'greek', hu: 'hungarian',
844
+ in: 'indian', id: 'indonesian', ie: 'irish', it: 'italian',
845
+ lt: 'lithuanian', np: 'nepali', no: 'norwegian', pt: 'portuguese',
846
+ ro: 'romanian', ru: 'russian', rs: 'serbian', sl: 'slovenian',
847
+ es: 'spanish', se: 'swedish', ta: 'tamil', tr: 'turkish',
848
+ uk: 'ukrainian', sk: 'sanskrit',
849
+ };
850
+ const langName = map[localeCode];
851
+ return langName && SUPPORTED_ORAMA_LANGUAGES.has(langName) ? langName : undefined;
852
+ }
853
+
564
854
  interface SafeSearchDialogProps {
565
855
  defaultTag?: string;
566
856
  tags?: { value: string; name: string }[];
@@ -588,6 +878,24 @@ export default function SafeSearchDialog({
588
878
  }: SafeSearchDialogProps) {
589
879
  const { locale } = useI18n();
590
880
  const [tag, setTag] = useState(defaultTag);
881
+
882
+ /**
883
+ * 自定义 initOrama:根据 locale 是否受 Orama 支持,决定是否传入 language 参数。
884
+ *
885
+ * 这解决了 Orama 不支持 'zh' 等语言时抛出 LANGUAGE_NOT_SUPPORTED 导致搜索失效的问题。
886
+ * fumadocs-core 的 StaticOptions 类型已官方暴露 initOrama 参数供此用途。
887
+ */
888
+ const safeInitOrama = useMemo(
889
+ () => (localeCode?: string) => {
890
+ const lang = localeCode ? resolveOramaLanguage(localeCode) : undefined;
891
+ return create({
892
+ schema: { _: 'string' },
893
+ ...(lang ? { language: lang } : {}),
894
+ });
895
+ },
896
+ [],
897
+ );
898
+
591
899
  const { search, setSearch, query } = useDocsSearch(
592
900
  type === 'fetch'
593
901
  ? {
@@ -603,6 +911,7 @@ export default function SafeSearchDialog({
603
911
  ...(locale != null && { locale }),
604
912
  ...(tag != null && { tag }),
605
913
  ...(delayMs != null && { delayMs }),
914
+ initOrama: safeInitOrama,
606
915
  }
607
916
  );
608
917
 
@@ -660,7 +969,83 @@ export default function SafeSearchDialog({
660
969
 
661
970
  //#endregion
662
971
  //#region src/core/generator/raw-content-route.ts
663
- function generateRawContentRoute() {
972
+ function generateRawContentRoute(ctx) {
973
+ const isI18n = ctx.config.i18n?.enabled === true;
974
+ const useDirParser = isDirParser(ctx.config);
975
+ if (isI18n && useDirParser) return `import { readFile } from 'node:fs/promises';
976
+ import { join } from 'node:path';
977
+ import { NextResponse } from 'next/server';
978
+
979
+ const _defaultLang = '${ctx.config.i18n?.defaultLanguage ?? ctx.config.locale ?? "zh"}';
980
+
981
+ export async function GET(
982
+ request: Request,
983
+ { params }: { params: Promise<{ path: string[] }> },
984
+ ) {
985
+ const { path: segments } = await params;
986
+ const slug = segments.join('/');
987
+ // 从查询参数获取语言,回退到默认语言(API 路由不在 [lang] 路径段下)
988
+ const { searchParams } = new URL(request.url);
989
+ const lang = searchParams.get('lang') ?? _defaultLang;
990
+ // dir parser: 文件位于 content/{lang}/{slug}.ext
991
+ for (const ext of ['.mdx', '.md']) {
992
+ try {
993
+ const filePath = join(process.cwd(), 'content', lang, \`\${slug}\${ext}\`);
994
+ const content = await readFile(filePath, 'utf-8');
995
+ return new NextResponse(content, {
996
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
997
+ });
998
+ } catch {
999
+ /* try next extension */
1000
+ }
1001
+ }
1002
+ return new NextResponse('Not found', { status: 404 });
1003
+ }
1004
+ `;
1005
+ if (isI18n) return `import { readFile } from 'node:fs/promises';
1006
+ import { join } from 'node:path';
1007
+ import { NextResponse } from 'next/server';
1008
+
1009
+ const _defaultLang = '${ctx.config.i18n?.defaultLanguage ?? ctx.config.locale ?? "zh"}';
1010
+
1011
+ export async function GET(
1012
+ request: Request,
1013
+ { params }: { params: Promise<{ path: string[] }> },
1014
+ ) {
1015
+ const { path: segments } = await params;
1016
+ const slug = segments.join('/');
1017
+ // 从查询参数获取语言,回退到默认语言(API 路由不在 [lang] 路径段下)
1018
+ const { searchParams } = new URL(request.url);
1019
+ const lang = searchParams.get('lang') ?? _defaultLang;
1020
+ // 尝试带语言后缀的文件,再回退到默认语言文件
1021
+ const suffix = lang !== _defaultLang ? \`.\${lang}\` : '';
1022
+ for (const ext of ['.mdx', '.md']) {
1023
+ // 先尝试带后缀
1024
+ if (suffix) {
1025
+ try {
1026
+ const filePath = join(process.cwd(), 'content', \`\${slug}\${suffix}\${ext}\`);
1027
+ const content = await readFile(filePath, 'utf-8');
1028
+ return new NextResponse(content, {
1029
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
1030
+ });
1031
+ } catch {
1032
+ /* 回退 */
1033
+ }
1034
+ }
1035
+ // 再尝试不带后缀(默认语言或 fallback)
1036
+ try {
1037
+ const filePath = join(process.cwd(), 'content', \`\${slug}\${ext}\`);
1038
+ const content = await readFile(filePath, 'utf-8');
1039
+ return new NextResponse(content, {
1040
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
1041
+ });
1042
+ } catch {
1043
+ /* try next extension */
1044
+ }
1045
+ }
1046
+ return new NextResponse('Not found', { status: 404 });
1047
+ }
1048
+ `;
664
1049
  return `import { readFile } from 'node:fs/promises';
665
1050
  import { join } from 'node:path';
666
1051
  import { NextResponse } from 'next/server';
@@ -689,7 +1074,71 @@ export async function GET(
689
1074
 
690
1075
  //#endregion
691
1076
  //#region src/core/generator/search-route.ts
692
- function generateSearchRoute() {
1077
+ /**
1078
+ * Orama/FlexSearch 支持的语言映射(来自 fumadocs-core/dist/search/server.js STEMMERS)
1079
+ *
1080
+ * key = 语言全名(传给 tokenizer 的 language 值),value = 语言代码(locale code)
1081
+ *
1082
+ * 不在此列表中的语言(如中文 zh)不能作为 language 参数传入,
1083
+ * 否则构建时会抛出 "Language X is not supported" 错误。
1084
+ * 对于不支持的语言,传入空对象 {} 让 Orama 使用默认分词器。
1085
+ */
1086
+ const SUPPORTED_LOCALE_MAP = {
1087
+ arabic: "ar",
1088
+ armenian: "am",
1089
+ bulgarian: "bg",
1090
+ czech: "cz",
1091
+ danish: "dk",
1092
+ dutch: "nl",
1093
+ english: "en",
1094
+ finnish: "fi",
1095
+ french: "fr",
1096
+ german: "de",
1097
+ greek: "gr",
1098
+ hungarian: "hu",
1099
+ indian: "in",
1100
+ indonesian: "id",
1101
+ irish: "ie",
1102
+ italian: "it",
1103
+ lithuanian: "lt",
1104
+ nepali: "np",
1105
+ norwegian: "no",
1106
+ portuguese: "pt",
1107
+ romanian: "ro",
1108
+ russian: "ru",
1109
+ serbian: "rs",
1110
+ slovenian: "ru",
1111
+ spanish: "es",
1112
+ swedish: "se",
1113
+ tamil: "ta",
1114
+ turkish: "tr",
1115
+ ukrainian: "uk",
1116
+ sanskrit: "sk"
1117
+ };
1118
+ /**
1119
+ * 根据语言代码查找对应的支持的 language 名称
1120
+ * 例如:'en' → 'english','zh' → undefined(不支持)
1121
+ */
1122
+ function resolveLanguageName(localeCode) {
1123
+ return Object.keys(SUPPORTED_LOCALE_MAP).find((key) => SUPPORTED_LOCALE_MAP[key] === localeCode);
1124
+ }
1125
+ function generateSearchRoute(ctx) {
1126
+ const i18nCfg = ctx?.config.i18n;
1127
+ if (i18nCfg?.enabled === true && i18nCfg.languages && i18nCfg.languages.length >= 2) return `import { source } from '@/lib/source';
1128
+ import { createFromSource } from 'fumadocs-core/search/server';
1129
+
1130
+ export const revalidate = false;
1131
+ const _localeMap: Record<string, unknown> = {
1132
+ ${(i18nCfg?.languages ?? []).map((l) => {
1133
+ const langName = resolveLanguageName(l.code);
1134
+ if (langName) return ` ${l.code}: '${langName}'`;
1135
+ return ` ${l.code}: {}`;
1136
+ }).join(",\n")},
1137
+ };
1138
+ export const { staticGET: GET } = createFromSource(source, {
1139
+ localeMap: _localeMap as any,
1140
+ });
1141
+ `;
693
1142
  return `import { source } from '@/lib/source';
694
1143
  import { createFromSource } from 'fumadocs-core/search/server';
695
1144
 
@@ -705,6 +1154,8 @@ function generateSourceConfig(_ctx) {
705
1154
  const titleMapEntries = Object.entries(titleMap).map(([slug, title]) => ` '${slug}': '${title.replace(/'/g, "\\'")}'`).join(",\n");
706
1155
  const titleMapStr = titleMapEntries ? `{\n${titleMapEntries}\n}` : "{}";
707
1156
  const isStrict = _ctx.config.contentPolicy !== "all";
1157
+ const isI18n = isI18nEnabled(_ctx.config);
1158
+ const useDirParser = isDirParser(_ctx.config);
708
1159
  return `import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
709
1160
  import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
710
1161
  import { z } from 'zod';
@@ -716,16 +1167,36 @@ const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.co
716
1167
  function slugFromPath(path: string): string {
717
1168
  const normalized = path.replace(/\\\\/g, '/');
718
1169
  const idx = normalized.indexOf('content/');
719
- const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
720
- return relative.replace(/\\.(md|mdx)$/i, '');
721
- }
722
- ` : ""}
1170
+ const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;${useDirParser ? `
1171
+ // dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
1172
+ const parts = relative.split('/');
1173
+ if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
1174
+ return parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
1175
+ }
1176
+ return relative.replace(/\\.(md|mdx)$/i, '');` : `
1177
+ let slug = relative.replace(/\\.(md|mdx)$/i, '');
1178
+ ${isI18n ? ` // 剥离语言后缀:index.en -> index
1179
+ slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
1180
+ return slug;`}
1181
+ }` : ""}
723
1182
  function titleFromPath(path: string): string {
724
- const normalized = path.replace(/\\\\/g, '/');
1183
+ ${useDirParser ? ` const normalized = path.replace(/\\\\/g, '/');
725
1184
  const idx = normalized.indexOf('content/');
726
1185
  const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
1186
+ // dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
1187
+ const parts = relative.split('/');
1188
+ if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
1189
+ const slug = parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
1190
+ return titleMap[slug] || slug.split('/').pop() || slug;
1191
+ }
727
1192
  const slug = relative.replace(/\\.(md|mdx)$/i, '');
728
- return titleMap[slug] || slug.split('/').pop() || slug;
1193
+ return titleMap[slug] || slug.split('/').pop() || slug;` : ` const normalized = path.replace(/\\\\/g, '/');
1194
+ const idx = normalized.indexOf('content/');
1195
+ const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
1196
+ let slug = relative.replace(/\\.(md|mdx)$/i, '');
1197
+ ${isI18n ? ` // 剥离语言后缀:guide/configuration.en -> guide/configuration
1198
+ slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
1199
+ return titleMap[slug] || slug.split('/').pop() || slug;`}
729
1200
  }
730
1201
 
731
1202
  export const docs = defineDocs({
@@ -806,7 +1277,8 @@ function generateTsconfig() {
806
1277
  //#endregion
807
1278
  //#region src/core/generator/index.ts
808
1279
  async function generateAll(ctx) {
809
- const files = [
1280
+ const isI18n = isI18nEnabled(ctx.config);
1281
+ const baseFiles = [
810
1282
  {
811
1283
  path: "source.config.ts",
812
1284
  content: generateSourceConfig(ctx)
@@ -833,7 +1305,7 @@ async function generateAll(ctx) {
833
1305
  },
834
1306
  {
835
1307
  path: "lib/source.ts",
836
- content: generateLibSource()
1308
+ content: generateLibSource(ctx)
837
1309
  },
838
1310
  {
839
1311
  path: "lib/layout.tsx",
@@ -850,16 +1322,65 @@ async function generateAll(ctx) {
850
1322
  {
851
1323
  path: "components/page-actions.tsx",
852
1324
  content: generatePageActionsComponent()
1325
+ }
1326
+ ];
1327
+ let files;
1328
+ if (isI18n) files = [
1329
+ ...baseFiles,
1330
+ {
1331
+ path: "lib/i18n.ts",
1332
+ content: generateI18nConfig(ctx)
1333
+ },
1334
+ {
1335
+ path: "lib/i18n-ui.ts",
1336
+ content: generateI18nUI(ctx)
1337
+ },
1338
+ {
1339
+ path: "middleware.ts",
1340
+ content: generateMiddleware(ctx)
853
1341
  },
854
1342
  ...ctx.dev ? [{
855
1343
  path: "app/api/raw/[...path]/route.ts",
856
- content: generateRawContentRoute()
1344
+ content: generateRawContentRoute(ctx)
857
1345
  }, {
858
1346
  path: "app/api/search/route.ts",
859
- content: generateSearchRoute()
1347
+ content: generateSearchRoute(ctx)
860
1348
  }] : [{
861
1349
  path: "app/api/search/route.ts",
862
- content: generateSearchRoute()
1350
+ content: generateSearchRoute(ctx)
1351
+ }],
1352
+ {
1353
+ path: "app/[lang]/layout.tsx",
1354
+ content: generateRootLayoutI18n(ctx)
1355
+ },
1356
+ {
1357
+ path: "app/[lang]/provider.tsx",
1358
+ content: generateProvider(ctx)
1359
+ },
1360
+ {
1361
+ path: "app/[lang]/components/search-dialog.tsx",
1362
+ content: generateSearchDialog(ctx)
1363
+ },
1364
+ {
1365
+ path: "app/[lang]/[[...slug]]/layout.tsx",
1366
+ content: generateDocsLayout(ctx)
1367
+ },
1368
+ {
1369
+ path: "app/[lang]/[[...slug]]/page.tsx",
1370
+ content: generatePage(ctx)
1371
+ }
1372
+ ];
1373
+ else files = [
1374
+ ...baseFiles,
1375
+ ...ctx.dev ? [{
1376
+ path: "app/api/raw/[...path]/route.ts",
1377
+ content: generateRawContentRoute(ctx)
1378
+ }, {
1379
+ path: "app/api/search/route.ts",
1380
+ content: generateSearchRoute(ctx)
1381
+ }] : [{
1382
+ path: "app/api/search/route.ts",
1383
+ content: generateSearchRoute(ctx)
863
1384
  }],
864
1385
  {
865
1386
  path: "app/layout.tsx",
@@ -871,7 +1392,7 @@ async function generateAll(ctx) {
871
1392
  },
872
1393
  {
873
1394
  path: "app/components/search-dialog.tsx",
874
- content: generateSearchDialog()
1395
+ content: generateSearchDialog(ctx)
875
1396
  },
876
1397
  {
877
1398
  path: "app/[[...slug]]/layout.tsx",
@@ -921,11 +1442,53 @@ export default function RootLayout({ children }: { children: ReactNode }) {
921
1442
  }
922
1443
  `;
923
1444
  }
1445
+ /**
1446
+ * 生成 app/[lang]/layout.tsx — 多语言模式的根布局
1447
+ *
1448
+ * 与单语言模式的关键区别:
1449
+ * 1. 从 params 中获取 lang 参数
1450
+ * 2. AppLayout 接收 lang 参数设置 html lang 属性
1451
+ * 3. AppProvider 接收 lang 参数用于 i18n UI
1452
+ */
1453
+ function generateRootLayoutI18n(ctx) {
1454
+ const { config } = ctx;
1455
+ const favicon = config.favicon;
1456
+ return `${favicon ? `import type { Metadata } from 'next';
1457
+
1458
+ export const metadata: Metadata = {
1459
+ icons: {
1460
+ icon: '${favicon}',
1461
+ },
1462
+ };
1463
+
1464
+ ` : ""}import { AppLayout } from 'openmanual/components/app-layout';
1465
+ import { AppProvider } from './provider';
1466
+ import type { ReactNode } from 'react';
1467
+ import '../../global.css';
1468
+
1469
+ export default async function RootLayout({
1470
+ params,
1471
+ children,
1472
+ }: {
1473
+ params: Promise<{ lang: string }>;
1474
+ children: ReactNode;
1475
+ }) {
1476
+ const { lang } = await params;
1477
+
1478
+ return (
1479
+ <AppLayout lang={lang}>
1480
+ <AppProvider lang={lang}>{children}</AppProvider>
1481
+ </AppLayout>
1482
+ );
1483
+ }
1484
+ `;
1485
+ }
924
1486
  function generateDocsLayout(ctx) {
925
1487
  const { config } = ctx;
926
1488
  const githubLink = config.navbar?.github ?? "";
927
1489
  const navLinks = config.navbar?.links ?? [];
928
1490
  const footerText = config.footer?.text ?? "";
1491
+ const isI18n = isI18nEnabled(config);
929
1492
  const linksArray = navLinks.map((l) => ({
930
1493
  text: l.label,
931
1494
  url: l.href,
@@ -956,14 +1519,43 @@ function generateDocsLayout(ctx) {
956
1519
  const lucideImportLine = hasIcons ? `\nimport { ${iconNameList.join(", ")} } from 'lucide-react';` : "";
957
1520
  const iconMapSnippet = hasIcons ? `\nconst iconMap = {${iconNameList.map((name) => `\n ${name}: <${name} />,`).join("")}\n} as const;
958
1521
  ` : "";
1522
+ 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(),";
1523
+ const restructureTreeImport = hasSidebar ? "\nimport { restructureTree } from 'openmanual/utils/restructure-tree';" : "";
1524
+ if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1525
+ import { baseOptions } from '@/lib/layout';
1526
+ import { source } from '@/lib/source';
1527
+ import type { ReactNode } from 'react';${restructureTreeImport}${lucideImportLine}
1528
+ ${sidebarConfigSnippet}${iconMapSnippet}
1529
+
1530
+ export default async function DocsLayoutWrapper({
1531
+ params,
1532
+ children,
1533
+ }: {
1534
+ params: Promise<{ lang: string }>;
1535
+ children: ReactNode;
1536
+ }) {
1537
+ const { lang } = await params;
1538
+
1539
+ const docsOptions = {
1540
+ ...baseOptions(lang),
1541
+ ${treeLine}${githubLine}${linksLine}${footerLine}
1542
+ };
1543
+
1544
+ return (
1545
+ <DocsLayout {...docsOptions}>
1546
+ {children}
1547
+ </DocsLayout>
1548
+ );
1549
+ }
1550
+ `;
959
1551
  return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
960
1552
  import { baseOptions } from '@/lib/layout';
961
1553
  import { source } from '@/lib/source';
962
- import type { ReactNode } from 'react';${hasSidebar ? "\nimport { restructureTree } from 'openmanual/utils/restructure-tree';" : ""}${lucideImportLine}
1554
+ import type { ReactNode } from 'react';${restructureTreeImport}${lucideImportLine}
963
1555
  ${sidebarConfigSnippet}${iconMapSnippet}
964
1556
  const docsOptions = {
965
1557
  ...baseOptions(),
966
- ${hasSidebar ? hasIcons ? "tree: restructureTree(source.getPageTree(), sidebarConfig, iconMap)," : "tree: restructureTree(source.getPageTree(), sidebarConfig)," : "tree: source.getPageTree(),"}${githubLine}${linksLine}${footerLine}
1558
+ ${treeLine}${githubLine}${linksLine}${footerLine}
967
1559
  };
968
1560
 
969
1561
  export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
@@ -997,14 +1589,14 @@ async function ensureLogoFile(ctx, logoPath, variant) {
997
1589
  }
998
1590
  }
999
1591
  /**
1000
- * Generate meta.json for each sidebar group directory so that
1001
- * fumadocs displays the configured Chinese group name instead of
1002
- * auto-capitalizing the English directory name.
1592
+ * Generate meta.json (and meta.en.json in i18n mode) for each sidebar
1593
+ * group directory so that fumadocs displays the configured group name.
1003
1594
  */
1004
1595
  async function generateMetaFiles(ctx) {
1005
1596
  const sidebar = ctx.config.sidebar;
1006
1597
  if (!sidebar || sidebar.length === 0) return;
1007
1598
  const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
1599
+ const isI18n = isI18nEnabled(ctx.config);
1008
1600
  for (const group of sidebar) {
1009
1601
  const dirPrefix = group.pages.map((p) => p.slug).find((slug) => slug.includes("/"))?.split("/")[0];
1010
1602
  if (!dirPrefix) continue;
@@ -1012,30 +1604,17 @@ async function generateMetaFiles(ctx) {
1012
1604
  const metaPath = join(dirPath, "meta.json");
1013
1605
  try {
1014
1606
  await access(metaPath);
1015
- continue;
1016
- } catch {}
1017
- await mkdir(dirPath, { recursive: true });
1018
- await writeFile(metaPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
1019
- }
1020
- }
1021
-
1022
- //#endregion
1023
- //#region src/utils/copy-raw-markdown.ts
1024
- /**
1025
- * Recursively copy .mdx/.md files from contentDir to targetDir,
1026
- * renaming extensions to .md. Non-markdown files are skipped.
1027
- */
1028
- async function copyRawMarkdown(contentDir, targetDir) {
1029
- await mkdir(targetDir, { recursive: true });
1030
- const entries = await readdir(contentDir);
1031
- for (const entry of entries) {
1032
- const srcPath = join(contentDir, entry);
1033
- if ((await stat(srcPath)).isDirectory()) await copyRawMarkdown(srcPath, join(targetDir, entry));
1034
- else {
1035
- const ext = parse(entry).ext.toLowerCase();
1036
- if (ext === ".mdx" || ext === ".md") {
1037
- const { name } = parse(entry);
1038
- await copyFile(srcPath, join(targetDir, `${name}.md`));
1607
+ } catch {
1608
+ await mkdir(dirPath, { recursive: true });
1609
+ await writeFile(metaPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
1610
+ }
1611
+ if (isI18n) {
1612
+ const metaEnPath = join(dirPath, "meta.en.json");
1613
+ try {
1614
+ await access(metaEnPath);
1615
+ } catch {
1616
+ await mkdir(dirPath, { recursive: true });
1617
+ await writeFile(metaEnPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
1039
1618
  }
1040
1619
  }
1041
1620
  }
@@ -1101,7 +1680,7 @@ const logger = {
1101
1680
 
1102
1681
  //#endregion
1103
1682
  //#region src/utils/temp-dir.ts
1104
- const TEMP_DIR_NAME = ".openmanual";
1683
+ const TEMP_DIR_NAME = ".cache";
1105
1684
  function getTempDir(cwd) {
1106
1685
  return join(cwd, TEMP_DIR_NAME);
1107
1686
  }
@@ -1111,6 +1690,7 @@ function getAppDir(cwd) {
1111
1690
  async function ensureTempDir(cwd) {
1112
1691
  const tempDir = getTempDir(cwd);
1113
1692
  const appDir = getAppDir(cwd);
1693
+ await cleanTempDir(cwd);
1114
1694
  await mkdir(tempDir, { recursive: true });
1115
1695
  await mkdir(join(appDir, "app"), { recursive: true });
1116
1696
  return tempDir;
@@ -1185,8 +1765,21 @@ const buildCommand = new Command("build").description("构建静态站点").acti
1185
1765
  } catch {
1186
1766
  logger.warn("未找到静态导出产物,请检查 next.config.mjs 中 output: \"export\" 配置");
1187
1767
  }
1188
- logger.step("复制原始 Markdown 文件...");
1189
- await copyRawMarkdown(contentDir, outputDir);
1768
+ if (config.i18n?.enabled) {
1769
+ const defaultLang = config.i18n.defaultLanguage ?? config.locale ?? "zh";
1770
+ const redirectHtml = `<!DOCTYPE html>
1771
+ <html>
1772
+ <head>
1773
+ <meta charset="utf-8" />
1774
+ <meta http-equiv="refresh" content="0;url=/${defaultLang}" />
1775
+ <script>window.location.href='/${defaultLang}';<\/script>
1776
+ </head>
1777
+ <body>
1778
+ <p>Redirecting to <a href="/${defaultLang}">/${defaultLang}</a>...</p>
1779
+ </body>
1780
+ </html>`;
1781
+ await writeFile(resolve(outputDir, "index.html"), redirectHtml, "utf-8");
1782
+ }
1190
1783
  logger.step("清理临时文件...");
1191
1784
  await cleanTempDir(cwd);
1192
1785
  logger.success("构建完成!");
@@ -1480,7 +2073,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
1480
2073
  //#endregion
1481
2074
  //#region src/cli/bin.ts
1482
2075
  function getVersion() {
1483
- return "0.10.2";
2076
+ return "0.12.0";
1484
2077
  }
1485
2078
  const program = new Command();
1486
2079
  const commandName = basename(process.argv[1] ?? "openmanual");