openmanual 0.13.0 → 0.14.1

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
@@ -7,6 +7,8 @@ import { Command } from "commander";
7
7
  import { spawn } from "node:child_process";
8
8
  import { access, cp, lstat, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
9
9
  import { z } from "zod";
10
+ import fg from "fast-glob";
11
+ import matter from "gray-matter";
10
12
 
11
13
  //#region src/core/config/schema.ts
12
14
  const LogoSchema = z.union([z.string(), z.object({
@@ -38,7 +40,7 @@ const ThemeSchema = z.object({
38
40
  primaryHue: z.number().min(0).max(360).optional(),
39
41
  darkMode: z.boolean().optional()
40
42
  });
41
- const SearchSchema = z.object({ enabled: z.boolean().optional() });
43
+ const SearchSchema = z.object({ position: z.enum(["sidebar", "header"]).optional() });
42
44
  const MdxSchema = z.object({ latex: z.boolean().optional() });
43
45
  const PageActionsSchema = z.object({ enabled: z.boolean().optional() });
44
46
  const I18nLocaleSchema = z.object({
@@ -51,6 +53,40 @@ const I18nConfigSchema = z.object({
51
53
  languages: z.array(I18nLocaleSchema).optional(),
52
54
  parser: z.enum(["dot", "dir"]).optional()
53
55
  });
56
+ const OpenApiSpecSchema = z.object({
57
+ path: z.string(),
58
+ group: z.string().optional()
59
+ });
60
+ const OpenApiSchema = z.object({
61
+ specPath: z.string().optional(),
62
+ specs: z.union([z.string(), z.array(OpenApiSpecSchema)]).optional(),
63
+ label: z.string().optional(),
64
+ groupBy: z.enum([
65
+ "tag",
66
+ "route",
67
+ "none"
68
+ ]).optional().default("tag"),
69
+ separateTab: z.boolean().optional().default(false)
70
+ });
71
+ /** 顶部横条链接项 */
72
+ const TopBarLinkSchema = z.object({
73
+ label: z.string().optional(),
74
+ icon: z.string().optional(),
75
+ href: z.string(),
76
+ external: z.boolean().optional().default(true)
77
+ }).refine((data) => data.label || data.icon, {
78
+ message: "至少需要提供 label 或 icon 中的一个",
79
+ path: ["label"]
80
+ });
81
+ /** 顶部横条配置 */
82
+ const TopBarSchema = z.object({
83
+ height: z.string().optional(),
84
+ logo: LogoSchema.optional(),
85
+ links: z.array(TopBarLinkSchema).optional(),
86
+ sticky: z.boolean().optional().default(true),
87
+ background: z.string().optional(),
88
+ bordered: z.boolean().optional().default(true)
89
+ });
54
90
  const OpenManualConfigSchema = z.object({
55
91
  name: z.string().min(1),
56
92
  description: z.string().optional(),
@@ -61,25 +97,54 @@ const OpenManualConfigSchema = z.object({
61
97
  contentPolicy: z.enum(["strict", "all"]).optional(),
62
98
  favicon: FaviconSchema.optional(),
63
99
  navbar: NavbarSchema.optional(),
100
+ header: TopBarSchema.optional(),
64
101
  footer: FooterSchema.optional(),
65
102
  sidebar: z.array(SidebarGroupSchema).optional(),
66
103
  theme: ThemeSchema.optional(),
67
104
  search: SearchSchema.optional(),
68
105
  mdx: MdxSchema.optional(),
69
106
  pageActions: PageActionsSchema.optional(),
70
- i18n: I18nConfigSchema.optional()
107
+ i18n: I18nConfigSchema.optional(),
108
+ openapi: OpenApiSchema.optional()
71
109
  });
72
- function collectConfiguredSlugs(config) {
73
- const slugs = /* @__PURE__ */ new Set();
74
- if (config.sidebar) for (const group of config.sidebar) for (const page of group.pages) slugs.add(page.slug);
75
- return slugs;
76
- }
77
110
  function isI18nEnabled(config) {
78
111
  return config.i18n?.enabled === true && (config.i18n.languages?.length ?? 0) > 1;
79
112
  }
80
113
  function isDirParser(config) {
81
114
  return config.i18n?.parser === "dir";
82
115
  }
116
+ function isOpenApiEnabled(config) {
117
+ if (config.openapi === void 0) return false;
118
+ const hasSpecs = config.openapi.specs !== void 0;
119
+ const hasSpecPath = typeof config.openapi?.specPath === "string";
120
+ return hasSpecs || hasSpecPath;
121
+ }
122
+ /**
123
+ * 从 OpenAPI 配置中解析出所有 spec 文件路径
124
+ * 兼容 specs(新)和 specPath(旧)两种格式
125
+ */
126
+ function resolveOpenApiSpecPaths(config) {
127
+ const openApiCfg = config.openapi;
128
+ if (!openApiCfg) return [];
129
+ if (openApiCfg.specs !== void 0) {
130
+ if (typeof openApiCfg.specs === "string") return [openApiCfg.specs];
131
+ return openApiCfg.specs.map((s) => s.path);
132
+ }
133
+ if (typeof openApiCfg.specPath === "string") return [openApiCfg.specPath];
134
+ return [];
135
+ }
136
+ /**
137
+ * 判断是否使用独立 Tab 模式(旧行为)
138
+ */
139
+ function isSeparateTabMode(config) {
140
+ return config.openapi?.separateTab === true;
141
+ }
142
+ /**
143
+ * 判断是否启用了顶部横条(配置了 header 即启用)
144
+ */
145
+ function isHeaderEnabled(config) {
146
+ return config.header !== void 0;
147
+ }
83
148
 
84
149
  //#endregion
85
150
  //#region src/core/config/loader.ts
@@ -93,7 +158,6 @@ const DEFAULT_CONFIG = {
93
158
  primaryHue: 213,
94
159
  darkMode: true
95
160
  },
96
- search: { enabled: true },
97
161
  mdx: {},
98
162
  pageActions: { enabled: true }
99
163
  };
@@ -139,10 +203,7 @@ function mergeDefaults(config) {
139
203
  ...DEFAULT_CONFIG.theme,
140
204
  ...config.theme
141
205
  },
142
- search: {
143
- ...DEFAULT_CONFIG.search,
144
- ...config.search
145
- },
206
+ search: config.search ? { position: config.search.position ?? "sidebar" } : void 0,
146
207
  mdx: {
147
208
  ...DEFAULT_CONFIG.mdx,
148
209
  ...config.mdx
@@ -156,10 +217,125 @@ function mergeDefaults(config) {
156
217
  defaultLanguage: config.i18n.defaultLanguage ?? config.locale ?? "zh",
157
218
  languages: config.i18n.languages ?? [],
158
219
  parser: config.i18n.parser ?? "dot"
220
+ } : void 0,
221
+ openapi: config.openapi ? {
222
+ specPath: config.openapi.specPath,
223
+ specs: config.openapi.specs,
224
+ label: config.openapi.label ?? "接口文档",
225
+ groupBy: config.openapi.groupBy ?? "tag",
226
+ separateTab: config.openapi.separateTab ?? false
159
227
  } : void 0
160
228
  };
161
229
  }
162
230
 
231
+ //#endregion
232
+ //#region src/core/content/meta-scanner.ts
233
+ async function scanMetaFiles(contentAbsDir, languages, useDirParser) {
234
+ let patterns;
235
+ if (useDirParser) patterns = languages.map((lang) => `${lang}/**/meta.json`);
236
+ else {
237
+ patterns = ["**/meta.json"];
238
+ if (languages.length > 1) for (const lang of languages) patterns.push(`**/meta.${lang}.json`);
239
+ }
240
+ const entries = await fg(patterns, {
241
+ cwd: contentAbsDir,
242
+ absolute: true,
243
+ ignore: ["node_modules"]
244
+ });
245
+ const groups = [];
246
+ for (const filePath of entries) {
247
+ const group = await parseMetaFile(filePath, contentAbsDir, languages, useDirParser);
248
+ if (group) groups.push(group);
249
+ }
250
+ return groups;
251
+ }
252
+ async function parseMetaFile(filePath, contentAbsDir, languages, useDirParser) {
253
+ try {
254
+ const raw = await readFile(filePath, "utf-8");
255
+ const data = JSON.parse(raw);
256
+ if (typeof data.title !== "string" || data.title.length === 0) return null;
257
+ const relPath = filePath.replace(contentAbsDir, "").replace(/^\/+/, "");
258
+ const dirPath = relPath.replace(/\/?meta(\.[^/]+)?\.json$/, "");
259
+ let isRoot = false;
260
+ if (useDirParser) isRoot = languages.includes(dirPath);
261
+ else {
262
+ if (/meta\.\w{2}(-\w{2})?\.json$/.test(relPath) && languages.length > 1) return null;
263
+ isRoot = dirPath === "";
264
+ }
265
+ const group = {
266
+ filePath,
267
+ dirPath,
268
+ isRoot,
269
+ title: data.title
270
+ };
271
+ if (typeof data.icon === "string") group.icon = data.icon;
272
+ if (typeof data.root === "boolean") group.root = data.root;
273
+ if (typeof data.defaultOpen === "boolean") group.defaultOpen = data.defaultOpen;
274
+ if (Array.isArray(data.pages)) group.pages = data.pages.filter((p) => typeof p === "string");
275
+ return group;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+ /**
281
+ * Collect all slugs from meta group infos.
282
+ *
283
+ * For root-level groups, page filenames are used directly as slugs.
284
+ * For directory-level groups, page filenames are prefixed with the directory path.
285
+ */
286
+ function collectSlugsFromMeta(groups) {
287
+ const slugs = /* @__PURE__ */ new Set();
288
+ for (const group of groups) {
289
+ if (!group.pages) continue;
290
+ for (const page of group.pages) if (group.isRoot) slugs.add(page);
291
+ else {
292
+ const slug = `${group.dirPath}/${page}`;
293
+ slugs.add(slug);
294
+ }
295
+ }
296
+ return slugs;
297
+ }
298
+
299
+ //#endregion
300
+ //#region src/core/content/scanner.ts
301
+ async function scanContentDir(contentDir) {
302
+ const entries = await fg("**/*.{md,mdx}", {
303
+ cwd: contentDir,
304
+ absolute: true,
305
+ ignore: ["node_modules"]
306
+ });
307
+ const files = [];
308
+ for (const filePath of entries) {
309
+ const file = await parseContentFile(filePath, contentDir);
310
+ if (file) files.push(file);
311
+ }
312
+ return files.sort((a, b) => a.slug.localeCompare(b.slug));
313
+ }
314
+ async function parseContentFile(filePath, contentDir) {
315
+ try {
316
+ const { data: frontmatter, content } = matter(await readFile(filePath, "utf-8"));
317
+ const segments = relative(contentDir, filePath).replace(/\.(md|mdx)$/, "").split(sep);
318
+ const name = segments.at(-1) ?? "";
319
+ return {
320
+ filePath,
321
+ slug: segments.join("/"),
322
+ name,
323
+ frontmatter,
324
+ content,
325
+ segments
326
+ };
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+
332
+ //#endregion
333
+ //#region src/core/content/tree.ts
334
+ /** Format a directory name into a display title (e.g. "my-page" → "My Page") */
335
+ function formatTitle(name) {
336
+ return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
337
+ }
338
+
163
339
  //#endregion
164
340
  //#region src/core/generator/callout-component.ts
165
341
  function generateCalloutComponent() {
@@ -173,45 +349,9 @@ export { Callout, CalloutTitle, CalloutDescription } from 'openmanual/components
173
349
  function generateGlobalCss(ctx) {
174
350
  const { config } = ctx;
175
351
  const primaryHue = config.theme?.primaryHue ?? 213;
176
- return `@import 'tailwindcss';
177
- @source './node_modules/openmanual/dist/components/**/*.js';
178
- @import 'fumadocs-ui/style.css';
179
- @custom-variant dark (&:is(.dark, .dark *));
180
-
181
- :root {
182
- --primary-hue: ${primaryHue};
183
-
184
- /* 护眼暖色阅读背景 */
185
- --color-fd-background: hsl(40, 22%, 96.5%); /* #faf9f6 纸张白 */
186
- --color-fd-foreground: hsl(0, 0%, 17.3%); /* #2c2c2c 柔黑 */
187
- --color-fd-muted: hsl(40, 15%, 95%); /* 柔和的暖灰背景 */
188
- --color-fd-card: hsl(40, 18%, 94%); /* 卡片背景 */
189
- --color-fd-popover: hsl(40, 20%, 97.5%); /* 弹窗背景 */
190
-
191
- /* Callout 类型色 */
192
- --callout-info-bg: hsl(210, 35%, 94%);
193
- --callout-info-border: hsl(212, 40%, 80%);
194
- --callout-info-text: hsl(213, 45%, 35%);
195
- --callout-warning-bg: hsl(38, 60%, 93%);
196
- --callout-warning-border: hsl(36, 55%, 78%);
197
- --callout-warning-text: hsl(28, 55%, 35%);
198
- --callout-danger-bg: hsl(0, 50%, 94%);
199
- --callout-danger-border: hsl(0, 45%, 82%);
200
- --callout-danger-text: hsl(0, 50%, 38%);
201
- --callout-check-bg: hsl(150, 35%, 93%);
202
- --callout-check-border: hsl(152, 35%, 78%);
203
- --callout-check-text: hsl(155, 40%, 32%);
204
- --callout-tip-bg: hsl(150, 35%, 93%);
205
- --callout-tip-border: hsl(152, 35%, 78%);
206
- --callout-tip-text: hsl(155, 40%, 32%);
207
- --callout-note-bg: hsl(215, 20%, 94%);
208
- --callout-note-border: hsl(215, 22%, 82%);
209
- --callout-note-text: hsl(215, 25%, 40%);
210
- --callout-key-bg: hsl(30, 55%, 93%);
211
- --callout-key-border: hsl(28, 50%, 78%);
212
- --callout-key-text: hsl(25, 50%, 35%);
213
- }
214
- ${config.theme?.darkMode ?? true ? `
352
+ const darkMode = config.theme?.darkMode ?? true;
353
+ const isOApi = isOpenApiEnabled(config);
354
+ const darkBlock = darkMode ? `
215
355
  .dark {
216
356
  --primary-hue: ${primaryHue};
217
357
 
@@ -233,6 +373,7 @@ ${config.theme?.darkMode ?? true ? `
233
373
  --color-fd-accent-foreground: hsl(35, 12%, 88%);
234
374
  --color-fd-ring: hsl(30, 30%, 50%);
235
375
  --color-fd-overlay: hsla(25, 20%, 5%, 0.5);
376
+ --color-fd-inputborder: hsla(30, 20%, 50%, 40%); /* 输入框边框色(hover 用)*/
236
377
 
237
378
  /* Callout 类型色 */
238
379
  --callout-info-bg: hsl(213, 25%, 16%);
@@ -261,7 +402,59 @@ ${config.theme?.darkMode ?? true ? `
261
402
  .dark body {
262
403
  background: linear-gradient(hsla(30, 30%, 15%, 0.4), transparent 20rem, transparent);
263
404
  }
264
- ` : ""}
405
+ ` : "";
406
+ return `@import 'tailwindcss';
407
+ @source './node_modules/openmanual/dist/components/**/*.js';
408
+ @import 'fumadocs-ui/css/neutral.css';
409
+ @import 'fumadocs-ui/css/preset.css';${isOApi ? "\n@import 'fumadocs-openapi/css/preset.css';" : ""}
410
+
411
+ @layer base {
412
+ body {
413
+ @apply flex flex-col min-h-screen;
414
+ }
415
+ }
416
+
417
+ /* 注册自定义颜色到 Tailwind v4 @theme,确保所有变体(hover/dark 等)可正确生成 */
418
+ @theme {
419
+ --color-fd-inputborder: hsla(30, 12%, 80%, 50%);
420
+ }
421
+
422
+ :root {
423
+ --primary-hue: ${primaryHue};
424
+
425
+ /* 护眼暖色阅读背景 */
426
+ --color-fd-background: hsl(40, 22%, 96.5%); /* #faf9f6 纸张白 */
427
+ --color-fd-foreground: hsl(0, 0%, 17.3%); /* #2c2c2c 柔黑 */
428
+ --color-fd-muted: hsl(40, 15%, 95%); /* 柔和的暖灰背景 */
429
+ --color-fd-card: hsl(40, 18%, 94%); /* 卡片背景 */
430
+ --color-fd-popover: hsl(40, 20%, 97.5%); /* 弹窗背景 */
431
+ --color-fd-muted-foreground: hsl(30, 10%, 55%); /* 次要文字色 */
432
+ --color-fd-inputborder: hsla(30, 12%, 80%, 50%); /* 输入框边框色(hover 用)*/
433
+
434
+ /* Callout 类型色 */
435
+ --callout-info-bg: hsl(210, 35%, 94%);
436
+ --callout-info-border: hsl(212, 40%, 80%);
437
+ --callout-info-text: hsl(213, 45%, 35%);
438
+ --callout-warning-bg: hsl(38, 60%, 93%);
439
+ --callout-warning-border: hsl(36, 55%, 78%);
440
+ --callout-warning-text: hsl(28, 55%, 35%);
441
+ --callout-danger-bg: hsl(0, 50%, 94%);
442
+ --callout-danger-border: hsl(0, 45%, 82%);
443
+ --callout-danger-text: hsl(0, 50%, 38%);
444
+ --callout-check-bg: hsl(150, 35%, 93%);
445
+ --callout-check-border: hsl(152, 35%, 78%);
446
+ --callout-check-text: hsl(155, 40%, 32%);
447
+ --callout-tip-bg: hsl(150, 35%, 93%);
448
+ --callout-tip-border: hsl(152, 35%, 78%);
449
+ --callout-tip-text: hsl(155, 40%, 32%);
450
+ --callout-note-bg: hsl(215, 20%, 94%);
451
+ --callout-note-border: hsl(215, 22%, 82%);
452
+ --callout-note-text: hsl(215, 25%, 40%);
453
+ --callout-key-bg: hsl(30, 55%, 93%);
454
+ --callout-key-border: hsl(28, 50%, 78%);
455
+ --callout-key-text: hsl(25, 50%, 35%);
456
+ }
457
+ ${darkBlock}
265
458
 
266
459
  /* 代码块:去除 shadow,使用朴素边框;去除 max-height 限制 */
267
460
  figure.shiki {
@@ -286,6 +479,13 @@ figure.shiki > div {
286
479
  [style*="--callout-color"] {
287
480
  box-shadow: none;
288
481
  }
482
+ ${config.search?.position === "header" ? `
483
+
484
+ /* header 搜索模式:隐藏侧边栏折叠面板中的搜索图标 */
485
+ [data-sidebar-panel] [data-search] {
486
+ display: none;
487
+ }
488
+ ` : ""}
289
489
  `;
290
490
  }
291
491
 
@@ -354,17 +554,25 @@ function resolveLogoPaths(logo) {
354
554
  dark: logo.dark
355
555
  };
356
556
  }
557
+ /**
558
+ * 将 LogoConfig 解析为 NavLogo 组件的 props 字符串
559
+ *
560
+ * 消除 top-bar.ts 和 layout.ts 中重复的三分支判断。
561
+ */
562
+ function resolveNavLogoProps(logo, alt) {
563
+ if (typeof logo === "string" && isImagePath(logo)) return `type="image" src="${logo}" alt="${alt}"`;
564
+ if (typeof logo === "object") {
565
+ const { light, dark } = resolveLogoPaths(logo);
566
+ if (light === dark) return `type="image" src="${light}" alt="${alt}"`;
567
+ return `type="image" srcLight="${light}" srcDark="${dark}" alt="${alt}"`;
568
+ }
569
+ return `type="text" text="${logo}"`;
570
+ }
357
571
  function generateLayout(ctx) {
358
572
  const { config } = ctx;
359
573
  const logo = config.navbar?.logo ?? config.name;
360
574
  const isI18n = config.i18n?.enabled === true;
361
- let logoProps;
362
- if (typeof logo === "string" && isImagePath(logo)) logoProps = `type="image" src="${logo}" alt="${config.name}"`;
363
- else if (typeof logo === "object") {
364
- const { light, dark } = logo;
365
- if (light === dark) logoProps = `type="image" src="${light}" alt="${config.name}"`;
366
- else logoProps = `type="image" srcLight="${light}" srcDark="${dark}" alt="${config.name}"`;
367
- } else logoProps = `type="text" text="${logo}"`;
575
+ const logoProps = resolveNavLogoProps(logo, config.name);
368
576
  if (isI18n) return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
369
577
  import type { ReactNode } from 'react';
370
578
  import { NavLogo } from 'openmanual/components/nav-layout';
@@ -394,7 +602,58 @@ export function baseOptions(): BaseLayoutProps {
394
602
  //#endregion
395
603
  //#region src/core/generator/lib-source.ts
396
604
  function generateLibSource(ctx) {
397
- if (ctx.config.i18n?.enabled === true) return `import { docs } from '@/.source/server';
605
+ const isI18n = isI18nEnabled(ctx.config);
606
+ if (isOpenApiEnabled(ctx.config)) {
607
+ const separateTab = isSeparateTabMode(ctx.config);
608
+ const groupBy = ctx.config.openapi?.groupBy ?? "tag";
609
+ if (isI18n) return `import { docs } from '@/.source/server';
610
+ import { loader, multiple } from 'fumadocs-core/source';
611
+ import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
612
+ import { openapi } from '@/lib/openapi';
613
+ import { i18n } from '@/lib/i18n';
614
+
615
+ const _omOpenApiFiles = [];
616
+ for (const lang of i18n.languages) {
617
+ const result = await openapiSource(openapi, {
618
+ baseDir: \`\${lang}/${separateTab ? "openapi" : "api"}\`,
619
+ ${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
620
+ });
621
+ _omOpenApiFiles.push(...result.files);
622
+ }
623
+
624
+ export const source = loader(
625
+ multiple({
626
+ docs: docs.toFumadocsSource(),
627
+ openapi: { files: _omOpenApiFiles },
628
+ }),
629
+ {
630
+ baseUrl: '/',
631
+ i18n,
632
+ plugins: [openapiPlugin()],
633
+ },
634
+ );
635
+ `;
636
+ return `import { docs } from '@/.source/server';
637
+ import { loader, multiple } from 'fumadocs-core/source';
638
+ import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
639
+ import { openapi } from '@/lib/openapi';
640
+
641
+ export const source = loader(
642
+ multiple({
643
+ docs: docs.toFumadocsSource(),
644
+ openapi: await openapiSource(openapi, {
645
+ baseDir: '${separateTab ? "openapi" : "api"}',
646
+ ${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
647
+ }),
648
+ }),
649
+ {
650
+ baseUrl: '/',
651
+ plugins: [openapiPlugin()],
652
+ },
653
+ );
654
+ `;
655
+ }
656
+ if (isI18n) return `import { docs } from '@/.source/server';
398
657
  import { loader } from 'fumadocs-core/source';
399
658
  import { i18n } from '@/lib/i18n';
400
659
 
@@ -462,34 +721,84 @@ export const config = {
462
721
  function generateNextConfig(ctx) {
463
722
  const { config } = ctx;
464
723
  const siteUrl = config.siteUrl ?? "";
724
+ const isOApi = isOpenApiEnabled(config);
725
+ const outputLine = !ctx.dev && siteUrl ? `\n output: 'export',` : "";
726
+ const rewritesBlock = ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : "";
465
727
  return `import { createMDX } from 'fumadocs-mdx/next';
466
728
 
467
729
  const withMDX = createMDX();
468
730
 
469
731
  /** @type {import('next').NextConfig} */
470
732
  const config = {
471
- reactStrictMode: true,${!ctx.dev && siteUrl ? `\n output: 'export',` : ""}
472
- serverExternalPackages: ['mermaid'],
733
+ reactStrictMode: true,${outputLine}
734
+ serverExternalPackages: ${isOApi ? "['mermaid', 'shiki']" : "['mermaid']"},
473
735
  images: {
474
736
  unoptimized: true,
475
- },${ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : ""}
737
+ },${rewritesBlock}
476
738
  };
477
739
 
478
740
  export default withMDX(config);
479
741
  `;
480
742
  }
481
743
 
744
+ //#endregion
745
+ //#region src/core/generator/openapi.ts
746
+ /**
747
+ * 生成 lib/openapi.ts — OpenAPI 实例定义
748
+ *
749
+ * 使用绝对路径解析 spec 文件,避免 SSG 构建时相对路径失效的问题。
750
+ * 支持多文件输入(specs 数组)和单文件(specPath 向后兼容)。
751
+ */
752
+ function generateOpenApiLib(ctx) {
753
+ if (!isOpenApiEnabled(ctx.config)) return null;
754
+ const specPaths = resolveOpenApiSpecPaths(ctx.config);
755
+ if (specPaths.length === 0) return null;
756
+ return `import { createOpenAPI } from 'fumadocs-openapi/server';
757
+
758
+ export const openapi = createOpenAPI({
759
+ input: [${specPaths.map((p) => join(ctx.projectDir, p)).map((p) => `'${p}'`).join(", ")}],
760
+ });
761
+ `;
762
+ }
763
+ /**
764
+ * 生成 components/api-page.client.tsx — APIPage 客户端配置
765
+ *
766
+ * createAPIPage 的 client 参数是 APIPageClientOptions(配置对象),
767
+ * 不是 React 组件。使用 defineClientConfig() 创建默认导出。
768
+ */
769
+ function generateApiClientComponent() {
770
+ return `'use client';
771
+
772
+ import { defineClientConfig } from 'fumadocs-openapi/ui/client';
773
+
774
+ export default defineClientConfig();
775
+ `;
776
+ }
777
+ /**
778
+ * 生成 components/api-page.tsx — APIPage 服务端组件包装器
779
+ */
780
+ function generateApiPageComponent() {
781
+ return `import { openapi } from '@/lib/openapi';
782
+ import { createAPIPage } from 'fumadocs-openapi/ui';
783
+ import client from './api-page.client';
784
+
785
+ export const APIPage = createAPIPage(openapi, {
786
+ client,
787
+ });
788
+ `;
789
+ }
790
+
482
791
  //#endregion
483
792
  //#region src/core/generator/package-json.ts
484
793
  function getOpenManualVersion() {
485
- return "0.13.0";
794
+ return "0.14.1";
486
795
  }
487
796
  function generatePackageJson(ctx) {
488
797
  const openmanualVersion = getOpenManualVersion();
489
798
  let openmanualDep;
490
799
  if (ctx.openmanualRoot && ctx.appDir) openmanualDep = `file:${relative(ctx.appDir, ctx.openmanualRoot)}`;
491
800
  else openmanualDep = `^${openmanualVersion}`;
492
- return `${JSON.stringify({
801
+ const pkg = {
493
802
  name: "openmanual-app",
494
803
  type: "module",
495
804
  private: true,
@@ -513,13 +822,18 @@ function generatePackageJson(ctx) {
513
822
  react: "^19.1.0",
514
823
  "react-dom": "^19.1.0",
515
824
  tailwindcss: "^4.1.15",
516
- zod: "^4.0.0"
825
+ zod: "^4.0.0",
826
+ ...isOpenApiEnabled(ctx.config) ? {
827
+ "fumadocs-openapi": "^10.7.1",
828
+ shiki: "^3.0.0"
829
+ } : {}
517
830
  },
518
831
  devDependencies: {
519
832
  "@types/react": "^19.1.0",
520
833
  "@types/react-dom": "^19.1.0"
521
834
  }
522
- }, null, 2)}\n`;
835
+ };
836
+ return `${JSON.stringify(pkg, null, 2)}\n`;
523
837
  }
524
838
 
525
839
  //#endregion
@@ -527,17 +841,20 @@ function generatePackageJson(ctx) {
527
841
  function generatePage(_ctx) {
528
842
  const isStrict = _ctx.config.contentPolicy !== "all";
529
843
  const pageActionsEnabled = _ctx.config.pageActions?.enabled !== false;
530
- if (_ctx.config.i18n?.enabled === true) return generatePageI18n(_ctx, isStrict, pageActionsEnabled);
531
- return generatePageSingle(_ctx, isStrict, pageActionsEnabled);
844
+ const isI18n = isI18nEnabled(_ctx.config);
845
+ const isOApi = isOpenApiEnabled(_ctx.config);
846
+ const allSlugs = _ctx.allSlugs ?? /* @__PURE__ */ new Set();
847
+ if (isI18n) return generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
848
+ return generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
532
849
  }
533
- function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
850
+ function generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
534
851
  const allowedSlugsSnippet = isStrict ? `
535
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
852
+ const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
536
853
 
537
854
  function isAllowed(slug: string[] | undefined): boolean {
538
855
  if (allowedSlugs.size === 0) return true;
539
- const key = slug ? slug.join('/') : 'index';
540
- return allowedSlugs.has(key);
856
+ const key = slug && slug.length > 0 ? slug.join('/') : 'index';
857
+ return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
541
858
  }
542
859
  ` : "";
543
860
  return `import { source } from '@/lib/source';
@@ -550,7 +867,7 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
550
867
  import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
551
868
  import { TypeTable } from 'fumadocs-ui/components/type-table';
552
869
  import { Mermaid } from '@/components/mermaid';
553
- import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
870
+ import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
554
871
  ${allowedSlugsSnippet}
555
872
  export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
556
873
  const { slug } = await params;
@@ -563,7 +880,18 @@ ${isStrict ? `
563
880
  if (!page) {
564
881
  notFound();
565
882
  }
566
-
883
+ ${isOApi ? `
884
+ if (page.data.type === 'openapi') {
885
+ return (
886
+ <DocsPage full>
887
+ <h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
888
+ <DocsBody>
889
+ <APIPage {...(page.data as any).getAPIPageProps()} />
890
+ </DocsBody>
891
+ </DocsPage>
892
+ );
893
+ }
894
+ ` : ""}
567
895
  const MDX = page.data.body;
568
896
 
569
897
  return (
@@ -604,14 +932,15 @@ export function generateStaticParams() {
604
932
  }`}
605
933
  `;
606
934
  }
607
- function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
935
+ function generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
608
936
  const allowedSlugsSnippet = isStrict ? `
609
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
937
+ const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
610
938
 
611
- function isAllowed(slug: string[] | undefined): boolean {
939
+ function isAllowed(slug: string[] | undefined, lang?: string): boolean {
612
940
  if (allowedSlugs.size === 0) return true;
613
- const key = slug ? slug.join('/') : 'index';
614
- return allowedSlugs.has(key);
941
+ const rawKey = slug && slug.length > 0 ? slug.join('/') : 'index';
942
+ const key = lang ? \`\${lang}/\${rawKey}\` : rawKey;
943
+ return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
615
944
  }
616
945
  ` : "";
617
946
  return `import { source } from '@/lib/source';
@@ -624,20 +953,31 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
624
953
  import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
625
954
  import { TypeTable } from 'fumadocs-ui/components/type-table';
626
955
  import { Mermaid } from '@/components/mermaid';
627
- import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
956
+ import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
628
957
  ${allowedSlugsSnippet}
629
958
  export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
630
959
  const { slug, lang } = await params;
631
960
  const page = source.getPage(slug, lang);
632
961
  ${isStrict ? `
633
- if (!isAllowed(slug)) {
962
+ if (!isAllowed(slug, lang)) {
634
963
  notFound();
635
964
  }
636
965
  ` : ""}
637
966
  if (!page) {
638
967
  notFound();
639
968
  }
640
-
969
+ ${isOApi ? `
970
+ if (page.data.type === 'openapi') {
971
+ return (
972
+ <DocsPage full>
973
+ <h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
974
+ <DocsBody>
975
+ <APIPage {...(page.data as any).getAPIPageProps()} />
976
+ </DocsBody>
977
+ </DocsPage>
978
+ );
979
+ }
980
+ ` : ""}
641
981
  const MDX = page.data.body;
642
982
 
643
983
  return (
@@ -663,7 +1003,7 @@ ${pageActionsEnabled ? ` <div className="flex items-start justify-between g
663
1003
  ${isStrict ? `
664
1004
  export function generateStaticParams() {
665
1005
  let params = source.generateParams();
666
- params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug));
1006
+ params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug, p.lang));
667
1007
  // Ensure every language has a homepage entry (slug: [])
668
1008
  const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
669
1009
  for (const lang of languages) {
@@ -725,7 +1065,7 @@ export default config;
725
1065
  * 一次作为生成应用的依赖)导致的多实例 React Context 问题。
726
1066
  */
727
1067
  function generateProvider(ctx) {
728
- const searchEnabled = ctx.config.search?.enabled !== false;
1068
+ const searchEnabled = ctx.config.search !== void 0;
729
1069
  if (ctx.config.i18n?.enabled === true) return `'use client';
730
1070
  import { RootProvider } from 'fumadocs-ui/provider/next';
731
1071
  import SafeSearchDialog from './components/search-dialog';
@@ -1168,6 +1508,42 @@ export default defineConfig({
1168
1508
  `;
1169
1509
  }
1170
1510
 
1511
+ //#endregion
1512
+ //#region src/core/generator/top-bar.ts
1513
+ function generateTopBarComponent(ctx) {
1514
+ const { config } = ctx;
1515
+ const header = config.header;
1516
+ const height = header.height ?? "64px";
1517
+ const sticky = header.sticky ?? true;
1518
+ const bordered = header.bordered ?? true;
1519
+ const background = header.background ?? "";
1520
+ const logoProps = resolveNavLogoProps(header.logo ?? config.name, config.name);
1521
+ const linksJson = JSON.stringify(header.links ?? []);
1522
+ const backgroundProp = background ? `\n background='${background}',` : "";
1523
+ const isTopBarSearch = config.search?.position === "header";
1524
+ return `'use client';
1525
+
1526
+ import { TopBar } from 'openmanual/components/top-bar';
1527
+ import { NavLogo } from 'openmanual/components/nav-layout';
1528
+ import { NavLinks } from 'openmanual/components/nav-links';${isTopBarSearch ? "\nimport { TopBarSearchTrigger } from 'openmanual/components/top-bar-search-trigger';" : ""}
1529
+
1530
+ const navLinks = ${linksJson};
1531
+ ${isTopBarSearch ? "const searchCenter = <TopBarSearchTrigger />;" : ""}
1532
+
1533
+ export function OmTopBar() {
1534
+ return (
1535
+ <TopBar
1536
+ height='${height}'
1537
+ sticky={${sticky}}${backgroundProp}
1538
+ bordered={${bordered}}
1539
+ left={<NavLogo ${logoProps} />}${isTopBarSearch ? "\n center={searchCenter}" : ""}
1540
+ right={<NavLinks links={navLinks} />}
1541
+ />
1542
+ );
1543
+ }
1544
+ `;
1545
+ }
1546
+
1171
1547
  //#endregion
1172
1548
  //#region src/core/generator/tsconfig.ts
1173
1549
  function generateTsconfig() {
@@ -1208,6 +1584,44 @@ function generateTsconfig() {
1208
1584
  //#region src/core/generator/index.ts
1209
1585
  async function generateAll(ctx) {
1210
1586
  const isI18n = isI18nEnabled(ctx.config);
1587
+ if (isOpenApiEnabled(ctx.config)) {
1588
+ const { access } = await import("node:fs/promises");
1589
+ const { extname, join } = await import("node:path");
1590
+ const supportedExts = [
1591
+ ".json",
1592
+ ".yaml",
1593
+ ".yml"
1594
+ ];
1595
+ const specPaths = resolveOpenApiSpecPaths(ctx.config);
1596
+ for (const specPath of specPaths) {
1597
+ const absolutePath = join(ctx.projectDir, specPath);
1598
+ const ext = extname(absolutePath).toLowerCase();
1599
+ if (!supportedExts.includes(ext)) throw new Error(`[openapi] 不支持的 OpenAPI 规范文件格式: "${ext}"(文件: ${specPath})。支持的格式: ${supportedExts.join(", ")}`);
1600
+ try {
1601
+ await access(absolutePath);
1602
+ } catch {
1603
+ throw new Error(`[openapi] OpenAPI 规范文件不存在: "${specPath}"。请确认 "openapi.specs" 或 "openapi.specPath" 在 openmanual.json 中配置的路径正确。`);
1604
+ }
1605
+ }
1606
+ }
1607
+ await computeAllSlugs(ctx);
1608
+ const isOApi = isOpenApiEnabled(ctx.config);
1609
+ const openapiFiles = [];
1610
+ if (isOApi) {
1611
+ const openapiLib = generateOpenApiLib(ctx);
1612
+ if (openapiLib) openapiFiles.push({
1613
+ path: "lib/openapi.ts",
1614
+ content: openapiLib
1615
+ });
1616
+ openapiFiles.push({
1617
+ path: "components/api-page.client.tsx",
1618
+ content: generateApiClientComponent()
1619
+ });
1620
+ openapiFiles.push({
1621
+ path: "components/api-page.tsx",
1622
+ content: generateApiPageComponent()
1623
+ });
1624
+ }
1211
1625
  const baseFiles = [
1212
1626
  {
1213
1627
  path: "source.config.ts",
@@ -1254,9 +1668,11 @@ async function generateAll(ctx) {
1254
1668
  content: generatePageActionsComponent()
1255
1669
  }
1256
1670
  ];
1671
+ const headerEnabled = isHeaderEnabled(ctx.config);
1257
1672
  let files;
1258
1673
  if (isI18n) files = [
1259
1674
  ...baseFiles,
1675
+ ...openapiFiles,
1260
1676
  {
1261
1677
  path: "lib/i18n.ts",
1262
1678
  content: generateI18nConfig(ctx)
@@ -1291,6 +1707,10 @@ async function generateAll(ctx) {
1291
1707
  path: "app/[lang]/components/search-dialog.tsx",
1292
1708
  content: generateSearchDialog(ctx)
1293
1709
  },
1710
+ ...headerEnabled ? [{
1711
+ path: "app/[lang]/components/top-bar.tsx",
1712
+ content: generateTopBarComponent(ctx)
1713
+ }] : [],
1294
1714
  {
1295
1715
  path: "app/[lang]/[[...slug]]/layout.tsx",
1296
1716
  content: generateDocsLayout(ctx)
@@ -1302,6 +1722,7 @@ async function generateAll(ctx) {
1302
1722
  ];
1303
1723
  else files = [
1304
1724
  ...baseFiles,
1725
+ ...openapiFiles,
1305
1726
  ...ctx.dev ? [{
1306
1727
  path: "app/api/raw/[...path]/route.ts",
1307
1728
  content: generateRawContentRoute(ctx)
@@ -1324,6 +1745,10 @@ async function generateAll(ctx) {
1324
1745
  path: "app/components/search-dialog.tsx",
1325
1746
  content: generateSearchDialog(ctx)
1326
1747
  },
1748
+ ...headerEnabled ? [{
1749
+ path: "app/components/top-bar.tsx",
1750
+ content: generateTopBarComponent(ctx)
1751
+ }] : [],
1327
1752
  {
1328
1753
  path: "app/[[...slug]]/layout.tsx",
1329
1754
  content: generateDocsLayout(ctx)
@@ -1350,10 +1775,8 @@ async function generateAll(ctx) {
1350
1775
  function generateRootLayout(ctx) {
1351
1776
  const { config } = ctx;
1352
1777
  const favicon = config.favicon;
1353
- return `import { AppLayout } from 'openmanual/components/app-layout';
1354
- import { AppProvider } from './provider';
1355
- import type { ReactNode } from 'react';
1356
- ${favicon ? `import type { Metadata } from 'next';
1778
+ const headerEnabled = isHeaderEnabled(config);
1779
+ return `${favicon ? `import type { Metadata } from 'next';
1357
1780
 
1358
1781
  export const metadata: Metadata = {
1359
1782
  icons: {
@@ -1361,12 +1784,15 @@ export const metadata: Metadata = {
1361
1784
  },
1362
1785
  };
1363
1786
 
1364
- ` : ""}import '../global.css';
1787
+ ` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
1788
+ import { AppProvider } from './provider';
1789
+ import type { ReactNode } from 'react';
1790
+ import '../global.css';
1365
1791
 
1366
1792
  export default function RootLayout({ children }: { children: ReactNode }) {
1367
1793
  return (
1368
1794
  <AppLayout>
1369
- <AppProvider>{children}</AppProvider>
1795
+ <AppProvider>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
1370
1796
  </AppLayout>
1371
1797
  );
1372
1798
  }
@@ -1383,6 +1809,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
1383
1809
  function generateRootLayoutI18n(ctx) {
1384
1810
  const { config } = ctx;
1385
1811
  const favicon = config.favicon;
1812
+ const headerEnabled = isHeaderEnabled(config);
1386
1813
  return `${favicon ? `import type { Metadata } from 'next';
1387
1814
 
1388
1815
  export const metadata: Metadata = {
@@ -1391,7 +1818,7 @@ export const metadata: Metadata = {
1391
1818
  },
1392
1819
  };
1393
1820
 
1394
- ` : ""}import { AppLayout } from 'openmanual/components/app-layout';
1821
+ ` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
1395
1822
  import { AppProvider } from './provider';
1396
1823
  import type { ReactNode } from 'react';
1397
1824
  import '../../global.css';
@@ -1407,7 +1834,7 @@ export default async function RootLayout({
1407
1834
 
1408
1835
  return (
1409
1836
  <AppLayout lang={lang}>
1410
- <AppProvider lang={lang}>{children}</AppProvider>
1837
+ <AppProvider lang={lang}>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
1411
1838
  </AppLayout>
1412
1839
  );
1413
1840
  }
@@ -1419,6 +1846,9 @@ function generateDocsLayout(ctx) {
1419
1846
  const navLinks = config.navbar?.links ?? [];
1420
1847
  const footerText = config.footer?.text ?? "";
1421
1848
  const isI18n = isI18nEnabled(config);
1849
+ const isOApi = isOpenApiEnabled(config);
1850
+ const rootGroups = ctx.rootGroups;
1851
+ const isHeaderSearch = config.search?.position === "header";
1422
1852
  const linksArray = navLinks.map((l) => ({
1423
1853
  text: l.label,
1424
1854
  url: l.href,
@@ -1430,6 +1860,13 @@ function generateDocsLayout(ctx) {
1430
1860
  const configDesc = config.description ?? "";
1431
1861
  const descLine = configDesc ? isI18n ? "" : `description: '${configDesc.replace(/'/g, "\\'")}',` : "";
1432
1862
  const treeLine = isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
1863
+ const separateTab = isOpenApiEnabled(config) && isSeparateTabMode(config);
1864
+ const openapiTab = separateTab ? {
1865
+ title: config.openapi?.label ?? "接口文档",
1866
+ url: isI18n ? "/${lang}/openapi" : "/openapi",
1867
+ urls: /* @__PURE__ */ new Set()
1868
+ } : null;
1869
+ const sidebarTabsLine = rootGroups && rootGroups.length > 0 || openapiTab ? isI18n ? generateI18nSidebarTabs(config, rootGroups, openapiTab) : generateSingleSidebarTabs(config, rootGroups, openapiTab) : "";
1433
1870
  if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1434
1871
  import { baseOptions } from '@/lib/layout';
1435
1872
  import { source } from '@/lib/source';
@@ -1441,13 +1878,16 @@ export default async function DocsLayoutWrapper({
1441
1878
  params: Promise<{ lang: string }>;
1442
1879
  children: ReactNode;
1443
1880
  }) {
1444
- const { lang } = await params;${configDesc ? `
1881
+ const { lang } = await params;
1882
+ ${isOApi && separateTab ? ` const _omFirstApi = source.getPages(lang)?.find((p: any) => p.data?.type === 'openapi');
1883
+ const _omApiUrl = _omFirstApi?.url ?? \`/\${lang}/openapi\`;
1884
+ ` : ""}${configDesc ? `
1445
1885
  const indexPage = source.getPage([], lang);
1446
1886
  const siteDescription = indexPage?.data.description ?? configDescription;` : ""}
1447
1887
 
1448
1888
  const docsOptions = {
1449
1889
  ...baseOptions(lang),
1450
- ${treeLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}
1890
+ ${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}${isHeaderSearch ? "\n searchToggle: { enabled: false }," : ""}
1451
1891
  };
1452
1892
 
1453
1893
  return (
@@ -1460,10 +1900,13 @@ export default async function DocsLayoutWrapper({
1460
1900
  return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1461
1901
  import { baseOptions } from '@/lib/layout';
1462
1902
  import { source } from '@/lib/source';
1463
- import type { ReactNode } from 'react';
1903
+ import type { ReactNode } from 'react';${isOApi && separateTab ? `
1904
+ const _omFirstApi = source.getPages()?.find((p: any) => p.data?.type === 'openapi');
1905
+ const _omApiUrl = _omFirstApi?.url ?? '/openapi';
1906
+ ` : ""}
1464
1907
  const docsOptions = {
1465
1908
  ...baseOptions(),
1466
- ${treeLine}${githubLine}${linksLine}${footerLine}${descLine}
1909
+ ${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${descLine}${isHeaderSearch ? "\n searchToggle: { enabled: false }," : ""}
1467
1910
  };
1468
1911
 
1469
1912
  export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
@@ -1475,6 +1918,37 @@ export default function DocsLayoutWrapper({ children }: { children: ReactNode })
1475
1918
  }
1476
1919
  `;
1477
1920
  }
1921
+ /**
1922
+ * Generate i18n sidebar.tabs string.
1923
+ * Extracted from generateDocsLayout to avoid deeply nested template literals
1924
+ * that confuse the oxc parser when escaped backticks are involved.
1925
+ */
1926
+ function generateI18nSidebarTabs(config, rootGroups, openapiTab) {
1927
+ const entries = (rootGroups ?? []).map((g) => ({
1928
+ title: g.title,
1929
+ dirPath: g.dirPath,
1930
+ url: g.url,
1931
+ urls: g.urls
1932
+ }));
1933
+ const entriesJson = JSON.stringify(entries);
1934
+ const nameEscaped = config.name.replace(/'/g, "\\'");
1935
+ const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
1936
+ return `\n sidebar: {\n tabs: [\n ${`{ title: '${nameEscaped}', url: \`/\${lang}\` }`},\n ...${`(${entriesJson} as Array<{title:string;dirPath:string;url:string;urls:string[]}>).filter(g => g.dirPath.startsWith(\`\${lang}/\`)).map(g => ({ title: g.title, url: g.url, urls: new Set<string>(g.urls) }))`}${openapiTabLine}\n ],\n },`;
1937
+ }
1938
+ /**
1939
+ * Generate single-language (non-i18n) sidebar.tabs string.
1940
+ * Uses template literal to preserve Set<string> for urls property.
1941
+ */
1942
+ function generateSingleSidebarTabs(config, rootGroups, openapiTab) {
1943
+ const nameEscaped = config.name.replace(/'/g, "\\'");
1944
+ const groupEntries = (rootGroups ?? []).map((g) => {
1945
+ const title = g.title.replace(/'/g, "\\'");
1946
+ const urlsArr = JSON.stringify(g.urls);
1947
+ return `{ title: '${title}', url: '${g.url}', urls: new Set(${urlsArr}) }`;
1948
+ }).join(",\n ");
1949
+ const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
1950
+ return `\n sidebar: {\n tabs: [\n { title: '${nameEscaped}', url: '/' },${groupEntries ? "\n " + groupEntries : ""}${openapiTabLine}\n ],\n },`;
1951
+ }
1478
1952
  function generateOpenManualLogoSvg(name, variant = "light") {
1479
1953
  const textColor = variant === "dark" ? "#E8E0D4" : "#000000";
1480
1954
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190 32" width="190" height="32">
@@ -1497,57 +1971,105 @@ async function ensureLogoFile(ctx, logoPath, variant) {
1497
1971
  }
1498
1972
  }
1499
1973
  /**
1500
- * Generate complete meta.json for each sidebar group directory.
1501
- * Writes title, icon, defaultOpen, and pages ordering so that Fumadocs
1502
- * can render the sidebar natively without restructureTree().
1974
+ * Generate or enrich meta.json files for each content directory.
1503
1975
  *
1504
- * In dir-parser mode, generates meta.json inside each language subdirectory
1505
- * (e.g. content/zh/guide/meta.json). In dot-parser mode, generates at the
1506
- * group directory level with locale-suffixed files for i18n.
1976
+ * Strategy:
1977
+ * 1. If meta.json files exist enrich missing fields (icon/defaultOpen/pages)
1978
+ * 2. If no meta.json → auto-generate from file system structure
1507
1979
  */
1508
- async function generateMetaFiles(ctx) {
1509
- const sidebar = ctx.config.sidebar;
1510
- if (!sidebar || sidebar.length === 0) return;
1980
+ /**
1981
+ * Compute all slugs from meta.json files, falling back to file system scan.
1982
+ * Stores result in ctx.allSlugs for use by generatePage().
1983
+ * Also extracts root groups (meta.json with root: true) into ctx.rootGroups.
1984
+ */
1985
+ async function computeAllSlugs(ctx) {
1511
1986
  const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
1512
1987
  const isI18n = isI18nEnabled(ctx.config);
1513
1988
  const useDirParser = isDirParser(ctx.config);
1514
1989
  const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
1515
- for (const group of sidebar) {
1516
- if (group.pages.every((p) => !p.slug.includes("/"))) {
1517
- await generateRootMetaJson(ctx, group, contentAbsDir, languages, useDirParser);
1518
- continue;
1519
- }
1520
- const dirPrefix = group.pages.map((p) => p.slug).find((slug) => slug.includes("/"))?.split("/")[0];
1521
- if (!dirPrefix) continue;
1522
- const metaObj = { title: group.group };
1523
- if (group.icon) metaObj.icon = group.icon;
1524
- if (group.collapsed !== void 0) metaObj.defaultOpen = !group.collapsed;
1525
- const pageFiles = group.pages.filter((p) => p.slug.startsWith(`${dirPrefix}/`)).map((p) => p.slug.split("/").slice(1).join("/"));
1526
- if (pageFiles.length > 0) metaObj.pages = pageFiles;
1527
- if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(join(contentAbsDir, lang, dirPrefix), "meta.json"), metaObj);
1528
- else {
1529
- const dirPath = join(contentAbsDir, dirPrefix);
1530
- await writeMetaIfNotExists(join(dirPath, "meta.json"), metaObj);
1531
- if (isI18n) for (const lang of languages) {
1532
- if (lang === ctx.config.i18n?.defaultLanguage) continue;
1533
- await writeMetaIfNotExists(join(dirPath, `meta.${lang}.json`), metaObj);
1534
- }
1990
+ const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
1991
+ if (metaGroups.length > 0) {
1992
+ ctx.allSlugs = collectSlugsFromMeta(metaGroups);
1993
+ const allFiles = await scanContentDir(contentAbsDir);
1994
+ for (const file of allFiles) if (useDirParser ? file.segments.length === 2 && languages.includes(file.segments[0]) : file.segments.length === 1) ctx.allSlugs.add(file.slug);
1995
+ const scannedDirCache = /* @__PURE__ */ new Map();
1996
+ for (const group of metaGroups) {
1997
+ const dirAbsPath = join(contentAbsDir, group.dirPath);
1998
+ try {
1999
+ const dirFiles = await scanContentDir(dirAbsPath);
2000
+ scannedDirCache.set(group.dirPath, dirFiles);
2001
+ if (!group.pages || group.pages.length === 0) for (const df of dirFiles) ctx.allSlugs.add(`${group.dirPath}/${df.slug}`);
2002
+ } catch {}
1535
2003
  }
2004
+ ctx.rootGroups = metaGroups.filter((g) => g.root === true).map((g) => {
2005
+ const cached = scannedDirCache.get(g.dirPath);
2006
+ const firstPage = g.pages?.[0] ?? cached?.[0]?.name ?? "index";
2007
+ const allUrls = (cached ?? []).map((f) => `/${g.dirPath}/${f.name}`);
2008
+ return {
2009
+ title: g.title,
2010
+ dirPath: g.dirPath,
2011
+ url: `/${g.dirPath}/${firstPage}`,
2012
+ urls: allUrls
2013
+ };
2014
+ });
2015
+ return;
2016
+ }
2017
+ const files = await scanContentDir(contentAbsDir);
2018
+ ctx.allSlugs = new Set(files.map((f) => f.slug));
2019
+ }
2020
+ async function generateMetaFiles(ctx) {
2021
+ const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
2022
+ const isI18n = isI18nEnabled(ctx.config);
2023
+ const useDirParser = isDirParser(ctx.config);
2024
+ const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
2025
+ const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
2026
+ if (metaGroups.length > 0) {
2027
+ for (const group of metaGroups) await enrichMetaFile(group);
2028
+ return;
1536
2029
  }
1537
- await injectPageFrontmatter(ctx);
2030
+ await autoGenerateMetaFromFS(ctx, contentAbsDir, languages, useDirParser);
1538
2031
  }
1539
2032
  /**
1540
- * Generate meta.json for root-level page groups (pages without directory prefix).
1541
- * Writes to content/{lang}/meta.json (dir-parser) or content/meta.json (dot-parser).
2033
+ * Validate an existing meta.json file is readable.
2034
+ * Does NOT modify the file — all user-set fields (including "root") are preserved as-is.
2035
+ * Fumadocs reads meta.json directly via its own content source pipeline.
1542
2036
  */
1543
- async function generateRootMetaJson(_ctx, group, contentAbsDir, languages, useDirParser) {
1544
- const metaObj = { title: group.group };
1545
- if (group.icon) metaObj.icon = group.icon;
1546
- if (group.collapsed !== void 0) metaObj.defaultOpen = !group.collapsed;
1547
- const pageFiles = group.pages.map((p) => p.slug);
1548
- if (pageFiles.length > 0) metaObj.pages = pageFiles;
1549
- if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, "meta.json"), metaObj);
1550
- else await writeMetaIfNotExists(join(contentAbsDir, "meta.json"), metaObj);
2037
+ async function enrichMetaFile(_group) {
2038
+ try {
2039
+ await readFile(_group.filePath, "utf-8");
2040
+ } catch {}
2041
+ }
2042
+ /**
2043
+ * Auto-generate meta.json files from the file system structure.
2044
+ * Used when no meta.json and no sidebar config exist.
2045
+ */
2046
+ async function autoGenerateMetaFromFS(_ctx, contentAbsDir, languages, useDirParser) {
2047
+ const files = await scanContentDir(contentAbsDir);
2048
+ const rootFiles = [];
2049
+ const dirGroups = /* @__PURE__ */ new Map();
2050
+ for (const file of files) if (file.segments.length <= 1) rootFiles.push(file);
2051
+ else {
2052
+ const dirName = file.segments[0];
2053
+ if (dirName === void 0) continue;
2054
+ if (!dirGroups.has(dirName)) dirGroups.set(dirName, []);
2055
+ dirGroups.get(dirName)?.push(file);
2056
+ }
2057
+ if (rootFiles.length > 0) {
2058
+ const rootMeta = {
2059
+ title: "Getting Started",
2060
+ pages: rootFiles.map((f) => f.name)
2061
+ };
2062
+ if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, "meta.json"), rootMeta);
2063
+ else await writeMetaIfNotExists(join(contentAbsDir, "meta.json"), rootMeta);
2064
+ }
2065
+ for (const [dirName, dirFiles] of dirGroups) {
2066
+ const dirMeta = {
2067
+ title: formatTitle(dirName),
2068
+ pages: dirFiles.map((f) => f.segments.slice(1).join("/"))
2069
+ };
2070
+ if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, dirName, "meta.json"), dirMeta);
2071
+ else await writeMetaIfNotExists(join(contentAbsDir, dirName, "meta.json"), dirMeta);
2072
+ }
1551
2073
  }
1552
2074
  /**
1553
2075
  * Write meta.json only if it does not already exist (preserve user edits).
@@ -1560,62 +2082,6 @@ async function writeMetaIfNotExists(filePath, data) {
1560
2082
  await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
1561
2083
  }
1562
2084
  }
1563
- /**
1564
- * Inject page-level title and icon from sidebar config into each .mdx file's frontmatter.
1565
- * Uses upsert semantics: only writes fields that are missing from existing frontmatter.
1566
- */
1567
- async function injectPageFrontmatter(ctx) {
1568
- const sidebar = ctx.config.sidebar;
1569
- if (!sidebar) return;
1570
- const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
1571
- const useDirParser = isDirParser(ctx.config);
1572
- const languages = isI18nEnabled(ctx.config) ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
1573
- for (const group of sidebar) for (const page of group.pages) {
1574
- const fieldsToInject = {};
1575
- if (page.title) fieldsToInject.title = page.title;
1576
- if (page.icon) fieldsToInject.icon = page.icon;
1577
- if (Object.keys(fieldsToInject).length === 0) continue;
1578
- const targets = resolveMdxPaths(contentAbsDir, page.slug, useDirParser, languages);
1579
- for (const mdxPath of targets) try {
1580
- const content = await readFile(mdxPath, "utf-8");
1581
- const updated = upsertFrontmatter(content, fieldsToInject);
1582
- if (updated !== content) await writeFile(mdxPath, updated, "utf-8");
1583
- } catch {}
1584
- }
1585
- }
1586
- /**
1587
- * Resolve .mdx file paths for a given slug across all applicable locales.
1588
- */
1589
- function resolveMdxPaths(contentAbsDir, slug, useDirParser, languages) {
1590
- if (useDirParser) return languages.map((lang) => join(contentAbsDir, lang, `${slug}.mdx`));
1591
- return [join(contentAbsDir, `${slug}.mdx`)];
1592
- }
1593
- /**
1594
- * Upsert fields into MDX frontmatter. Only adds missing fields; never overwrites existing ones.
1595
- *
1596
- * Handles:
1597
- * - No frontmatter → creates one with the injected fields
1598
- * - Existing frontmatter missing some fields → adds only the missing ones
1599
- * - All fields already present → returns original content unchanged
1600
- */
1601
- function upsertFrontmatter(content, fields) {
1602
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1603
- if (!match) return `---\n${Object.entries(fields).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${content}`;
1604
- const existingLines = (match[1] ?? "").split("\n");
1605
- const existingKeys = /* @__PURE__ */ new Set();
1606
- for (const line of existingLines) {
1607
- const trimmed = line.trim();
1608
- if (trimmed && !trimmed.startsWith("#")) {
1609
- const keyMatch = trimmed.match(/^(\w[\w-]*)\s*:/);
1610
- if (keyMatch?.[1]) existingKeys.add(keyMatch[1]);
1611
- }
1612
- }
1613
- const newFields = Object.entries(fields).filter(([key]) => !existingKeys.has(key));
1614
- if (newFields.length === 0) return content;
1615
- const newFieldLines = newFields.map(([key, value]) => `${key}: ${value}`).join("\n");
1616
- const insertionPoint = (match.index ?? 0) + match[0].length - 3;
1617
- return content.slice(0, insertionPoint) + "\n" + newFieldLines + "\n" + content.slice(insertionPoint);
1618
- }
1619
2085
 
1620
2086
  //#endregion
1621
2087
  //#region src/utils/install-deps.ts
@@ -2070,7 +2536,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
2070
2536
  //#endregion
2071
2537
  //#region src/cli/bin.ts
2072
2538
  function getVersion() {
2073
- return "0.13.0";
2539
+ return "0.14.1";
2074
2540
  }
2075
2541
  const program = new Command();
2076
2542
  const commandName = basename(process.argv[1] ?? "openmanual");