openmanual 0.13.0 → 0.14.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
@@ -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 {
@@ -354,17 +547,25 @@ function resolveLogoPaths(logo) {
354
547
  dark: logo.dark
355
548
  };
356
549
  }
550
+ /**
551
+ * 将 LogoConfig 解析为 NavLogo 组件的 props 字符串
552
+ *
553
+ * 消除 top-bar.ts 和 layout.ts 中重复的三分支判断。
554
+ */
555
+ function resolveNavLogoProps(logo, alt) {
556
+ if (typeof logo === "string" && isImagePath(logo)) return `type="image" src="${logo}" alt="${alt}"`;
557
+ if (typeof logo === "object") {
558
+ const { light, dark } = resolveLogoPaths(logo);
559
+ if (light === dark) return `type="image" src="${light}" alt="${alt}"`;
560
+ return `type="image" srcLight="${light}" srcDark="${dark}" alt="${alt}"`;
561
+ }
562
+ return `type="text" text="${logo}"`;
563
+ }
357
564
  function generateLayout(ctx) {
358
565
  const { config } = ctx;
359
566
  const logo = config.navbar?.logo ?? config.name;
360
567
  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}"`;
568
+ const logoProps = resolveNavLogoProps(logo, config.name);
368
569
  if (isI18n) return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
369
570
  import type { ReactNode } from 'react';
370
571
  import { NavLogo } from 'openmanual/components/nav-layout';
@@ -394,7 +595,58 @@ export function baseOptions(): BaseLayoutProps {
394
595
  //#endregion
395
596
  //#region src/core/generator/lib-source.ts
396
597
  function generateLibSource(ctx) {
397
- if (ctx.config.i18n?.enabled === true) return `import { docs } from '@/.source/server';
598
+ const isI18n = isI18nEnabled(ctx.config);
599
+ if (isOpenApiEnabled(ctx.config)) {
600
+ const separateTab = isSeparateTabMode(ctx.config);
601
+ const groupBy = ctx.config.openapi?.groupBy ?? "tag";
602
+ if (isI18n) return `import { docs } from '@/.source/server';
603
+ import { loader, multiple } from 'fumadocs-core/source';
604
+ import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
605
+ import { openapi } from '@/lib/openapi';
606
+ import { i18n } from '@/lib/i18n';
607
+
608
+ const _omOpenApiFiles = [];
609
+ for (const lang of i18n.languages) {
610
+ const result = await openapiSource(openapi, {
611
+ baseDir: \`\${lang}/${separateTab ? "openapi" : "api"}\`,
612
+ ${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
613
+ });
614
+ _omOpenApiFiles.push(...result.files);
615
+ }
616
+
617
+ export const source = loader(
618
+ multiple({
619
+ docs: docs.toFumadocsSource(),
620
+ openapi: { files: _omOpenApiFiles },
621
+ }),
622
+ {
623
+ baseUrl: '/',
624
+ i18n,
625
+ plugins: [openapiPlugin()],
626
+ },
627
+ );
628
+ `;
629
+ return `import { docs } from '@/.source/server';
630
+ import { loader, multiple } from 'fumadocs-core/source';
631
+ import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
632
+ import { openapi } from '@/lib/openapi';
633
+
634
+ export const source = loader(
635
+ multiple({
636
+ docs: docs.toFumadocsSource(),
637
+ openapi: await openapiSource(openapi, {
638
+ baseDir: '${separateTab ? "openapi" : "api"}',
639
+ ${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
640
+ }),
641
+ }),
642
+ {
643
+ baseUrl: '/',
644
+ plugins: [openapiPlugin()],
645
+ },
646
+ );
647
+ `;
648
+ }
649
+ if (isI18n) return `import { docs } from '@/.source/server';
398
650
  import { loader } from 'fumadocs-core/source';
399
651
  import { i18n } from '@/lib/i18n';
400
652
 
@@ -462,34 +714,84 @@ export const config = {
462
714
  function generateNextConfig(ctx) {
463
715
  const { config } = ctx;
464
716
  const siteUrl = config.siteUrl ?? "";
717
+ const isOApi = isOpenApiEnabled(config);
718
+ const outputLine = !ctx.dev && siteUrl ? `\n output: 'export',` : "";
719
+ const rewritesBlock = ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : "";
465
720
  return `import { createMDX } from 'fumadocs-mdx/next';
466
721
 
467
722
  const withMDX = createMDX();
468
723
 
469
724
  /** @type {import('next').NextConfig} */
470
725
  const config = {
471
- reactStrictMode: true,${!ctx.dev && siteUrl ? `\n output: 'export',` : ""}
472
- serverExternalPackages: ['mermaid'],
726
+ reactStrictMode: true,${outputLine}
727
+ serverExternalPackages: ${isOApi ? "['mermaid', 'shiki']" : "['mermaid']"},
473
728
  images: {
474
729
  unoptimized: true,
475
- },${ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : ""}
730
+ },${rewritesBlock}
476
731
  };
477
732
 
478
733
  export default withMDX(config);
479
734
  `;
480
735
  }
481
736
 
737
+ //#endregion
738
+ //#region src/core/generator/openapi.ts
739
+ /**
740
+ * 生成 lib/openapi.ts — OpenAPI 实例定义
741
+ *
742
+ * 使用绝对路径解析 spec 文件,避免 SSG 构建时相对路径失效的问题。
743
+ * 支持多文件输入(specs 数组)和单文件(specPath 向后兼容)。
744
+ */
745
+ function generateOpenApiLib(ctx) {
746
+ if (!isOpenApiEnabled(ctx.config)) return null;
747
+ const specPaths = resolveOpenApiSpecPaths(ctx.config);
748
+ if (specPaths.length === 0) return null;
749
+ return `import { createOpenAPI } from 'fumadocs-openapi/server';
750
+
751
+ export const openapi = createOpenAPI({
752
+ input: [${specPaths.map((p) => join(ctx.projectDir, p)).map((p) => `'${p}'`).join(", ")}],
753
+ });
754
+ `;
755
+ }
756
+ /**
757
+ * 生成 components/api-page.client.tsx — APIPage 客户端配置
758
+ *
759
+ * createAPIPage 的 client 参数是 APIPageClientOptions(配置对象),
760
+ * 不是 React 组件。使用 defineClientConfig() 创建默认导出。
761
+ */
762
+ function generateApiClientComponent() {
763
+ return `'use client';
764
+
765
+ import { defineClientConfig } from 'fumadocs-openapi/ui/client';
766
+
767
+ export default defineClientConfig();
768
+ `;
769
+ }
770
+ /**
771
+ * 生成 components/api-page.tsx — APIPage 服务端组件包装器
772
+ */
773
+ function generateApiPageComponent() {
774
+ return `import { openapi } from '@/lib/openapi';
775
+ import { createAPIPage } from 'fumadocs-openapi/ui';
776
+ import client from './api-page.client';
777
+
778
+ export const APIPage = createAPIPage(openapi, {
779
+ client,
780
+ });
781
+ `;
782
+ }
783
+
482
784
  //#endregion
483
785
  //#region src/core/generator/package-json.ts
484
786
  function getOpenManualVersion() {
485
- return "0.13.0";
787
+ return "0.14.0";
486
788
  }
487
789
  function generatePackageJson(ctx) {
488
790
  const openmanualVersion = getOpenManualVersion();
489
791
  let openmanualDep;
490
792
  if (ctx.openmanualRoot && ctx.appDir) openmanualDep = `file:${relative(ctx.appDir, ctx.openmanualRoot)}`;
491
793
  else openmanualDep = `^${openmanualVersion}`;
492
- return `${JSON.stringify({
794
+ const pkg = {
493
795
  name: "openmanual-app",
494
796
  type: "module",
495
797
  private: true,
@@ -513,13 +815,18 @@ function generatePackageJson(ctx) {
513
815
  react: "^19.1.0",
514
816
  "react-dom": "^19.1.0",
515
817
  tailwindcss: "^4.1.15",
516
- zod: "^4.0.0"
818
+ zod: "^4.0.0",
819
+ ...isOpenApiEnabled(ctx.config) ? {
820
+ "fumadocs-openapi": "^10.7.1",
821
+ shiki: "^3.0.0"
822
+ } : {}
517
823
  },
518
824
  devDependencies: {
519
825
  "@types/react": "^19.1.0",
520
826
  "@types/react-dom": "^19.1.0"
521
827
  }
522
- }, null, 2)}\n`;
828
+ };
829
+ return `${JSON.stringify(pkg, null, 2)}\n`;
523
830
  }
524
831
 
525
832
  //#endregion
@@ -527,17 +834,20 @@ function generatePackageJson(ctx) {
527
834
  function generatePage(_ctx) {
528
835
  const isStrict = _ctx.config.contentPolicy !== "all";
529
836
  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);
837
+ const isI18n = isI18nEnabled(_ctx.config);
838
+ const isOApi = isOpenApiEnabled(_ctx.config);
839
+ const allSlugs = _ctx.allSlugs ?? /* @__PURE__ */ new Set();
840
+ if (isI18n) return generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
841
+ return generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
532
842
  }
533
- function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
843
+ function generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
534
844
  const allowedSlugsSnippet = isStrict ? `
535
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
845
+ const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
536
846
 
537
847
  function isAllowed(slug: string[] | undefined): boolean {
538
848
  if (allowedSlugs.size === 0) return true;
539
- const key = slug ? slug.join('/') : 'index';
540
- return allowedSlugs.has(key);
849
+ const key = slug && slug.length > 0 ? slug.join('/') : 'index';
850
+ return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
541
851
  }
542
852
  ` : "";
543
853
  return `import { source } from '@/lib/source';
@@ -550,7 +860,7 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
550
860
  import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
551
861
  import { TypeTable } from 'fumadocs-ui/components/type-table';
552
862
  import { Mermaid } from '@/components/mermaid';
553
- import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
863
+ import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
554
864
  ${allowedSlugsSnippet}
555
865
  export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
556
866
  const { slug } = await params;
@@ -563,7 +873,18 @@ ${isStrict ? `
563
873
  if (!page) {
564
874
  notFound();
565
875
  }
566
-
876
+ ${isOApi ? `
877
+ if (page.data.type === 'openapi') {
878
+ return (
879
+ <DocsPage full>
880
+ <h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
881
+ <DocsBody>
882
+ <APIPage {...(page.data as any).getAPIPageProps()} />
883
+ </DocsBody>
884
+ </DocsPage>
885
+ );
886
+ }
887
+ ` : ""}
567
888
  const MDX = page.data.body;
568
889
 
569
890
  return (
@@ -604,14 +925,15 @@ export function generateStaticParams() {
604
925
  }`}
605
926
  `;
606
927
  }
607
- function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
928
+ function generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
608
929
  const allowedSlugsSnippet = isStrict ? `
609
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
930
+ const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
610
931
 
611
- function isAllowed(slug: string[] | undefined): boolean {
932
+ function isAllowed(slug: string[] | undefined, lang?: string): boolean {
612
933
  if (allowedSlugs.size === 0) return true;
613
- const key = slug ? slug.join('/') : 'index';
614
- return allowedSlugs.has(key);
934
+ const rawKey = slug && slug.length > 0 ? slug.join('/') : 'index';
935
+ const key = lang ? \`\${lang}/\${rawKey}\` : rawKey;
936
+ return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
615
937
  }
616
938
  ` : "";
617
939
  return `import { source } from '@/lib/source';
@@ -624,20 +946,31 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
624
946
  import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
625
947
  import { TypeTable } from 'fumadocs-ui/components/type-table';
626
948
  import { Mermaid } from '@/components/mermaid';
627
- import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
949
+ import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
628
950
  ${allowedSlugsSnippet}
629
951
  export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
630
952
  const { slug, lang } = await params;
631
953
  const page = source.getPage(slug, lang);
632
954
  ${isStrict ? `
633
- if (!isAllowed(slug)) {
955
+ if (!isAllowed(slug, lang)) {
634
956
  notFound();
635
957
  }
636
958
  ` : ""}
637
959
  if (!page) {
638
960
  notFound();
639
961
  }
640
-
962
+ ${isOApi ? `
963
+ if (page.data.type === 'openapi') {
964
+ return (
965
+ <DocsPage full>
966
+ <h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
967
+ <DocsBody>
968
+ <APIPage {...(page.data as any).getAPIPageProps()} />
969
+ </DocsBody>
970
+ </DocsPage>
971
+ );
972
+ }
973
+ ` : ""}
641
974
  const MDX = page.data.body;
642
975
 
643
976
  return (
@@ -663,7 +996,7 @@ ${pageActionsEnabled ? ` <div className="flex items-start justify-between g
663
996
  ${isStrict ? `
664
997
  export function generateStaticParams() {
665
998
  let params = source.generateParams();
666
- params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug));
999
+ params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug, p.lang));
667
1000
  // Ensure every language has a homepage entry (slug: [])
668
1001
  const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
669
1002
  for (const lang of languages) {
@@ -725,7 +1058,7 @@ export default config;
725
1058
  * 一次作为生成应用的依赖)导致的多实例 React Context 问题。
726
1059
  */
727
1060
  function generateProvider(ctx) {
728
- const searchEnabled = ctx.config.search?.enabled !== false;
1061
+ const searchEnabled = ctx.config.search !== void 0;
729
1062
  if (ctx.config.i18n?.enabled === true) return `'use client';
730
1063
  import { RootProvider } from 'fumadocs-ui/provider/next';
731
1064
  import SafeSearchDialog from './components/search-dialog';
@@ -1168,6 +1501,42 @@ export default defineConfig({
1168
1501
  `;
1169
1502
  }
1170
1503
 
1504
+ //#endregion
1505
+ //#region src/core/generator/top-bar.ts
1506
+ function generateTopBarComponent(ctx) {
1507
+ const { config } = ctx;
1508
+ const header = config.header;
1509
+ const height = header.height ?? "64px";
1510
+ const sticky = header.sticky ?? true;
1511
+ const bordered = header.bordered ?? true;
1512
+ const background = header.background ?? "";
1513
+ const logoProps = resolveNavLogoProps(header.logo ?? config.name, config.name);
1514
+ const linksJson = JSON.stringify(header.links ?? []);
1515
+ const backgroundProp = background ? `\n background='${background}',` : "";
1516
+ const isTopBarSearch = config.search?.position === "header";
1517
+ return `'use client';
1518
+
1519
+ import { TopBar } from 'openmanual/components/top-bar';
1520
+ import { NavLogo } from 'openmanual/components/nav-layout';
1521
+ import { NavLinks } from 'openmanual/components/nav-links';${isTopBarSearch ? "\nimport { TopBarSearchTrigger } from 'openmanual/components/top-bar-search-trigger';" : ""}
1522
+
1523
+ const navLinks = ${linksJson};
1524
+ ${isTopBarSearch ? "const searchCenter = <TopBarSearchTrigger />;" : ""}
1525
+
1526
+ export function OmTopBar() {
1527
+ return (
1528
+ <TopBar
1529
+ height='${height}'
1530
+ sticky={${sticky}}${backgroundProp}
1531
+ bordered={${bordered}}
1532
+ left={<NavLogo ${logoProps} />}${isTopBarSearch ? "\n center={searchCenter}" : ""}
1533
+ right={<NavLinks links={navLinks} />}
1534
+ />
1535
+ );
1536
+ }
1537
+ `;
1538
+ }
1539
+
1171
1540
  //#endregion
1172
1541
  //#region src/core/generator/tsconfig.ts
1173
1542
  function generateTsconfig() {
@@ -1208,6 +1577,44 @@ function generateTsconfig() {
1208
1577
  //#region src/core/generator/index.ts
1209
1578
  async function generateAll(ctx) {
1210
1579
  const isI18n = isI18nEnabled(ctx.config);
1580
+ if (isOpenApiEnabled(ctx.config)) {
1581
+ const { access } = await import("node:fs/promises");
1582
+ const { extname, join } = await import("node:path");
1583
+ const supportedExts = [
1584
+ ".json",
1585
+ ".yaml",
1586
+ ".yml"
1587
+ ];
1588
+ const specPaths = resolveOpenApiSpecPaths(ctx.config);
1589
+ for (const specPath of specPaths) {
1590
+ const absolutePath = join(ctx.projectDir, specPath);
1591
+ const ext = extname(absolutePath).toLowerCase();
1592
+ if (!supportedExts.includes(ext)) throw new Error(`[openapi] 不支持的 OpenAPI 规范文件格式: "${ext}"(文件: ${specPath})。支持的格式: ${supportedExts.join(", ")}`);
1593
+ try {
1594
+ await access(absolutePath);
1595
+ } catch {
1596
+ throw new Error(`[openapi] OpenAPI 规范文件不存在: "${specPath}"。请确认 "openapi.specs" 或 "openapi.specPath" 在 openmanual.json 中配置的路径正确。`);
1597
+ }
1598
+ }
1599
+ }
1600
+ await computeAllSlugs(ctx);
1601
+ const isOApi = isOpenApiEnabled(ctx.config);
1602
+ const openapiFiles = [];
1603
+ if (isOApi) {
1604
+ const openapiLib = generateOpenApiLib(ctx);
1605
+ if (openapiLib) openapiFiles.push({
1606
+ path: "lib/openapi.ts",
1607
+ content: openapiLib
1608
+ });
1609
+ openapiFiles.push({
1610
+ path: "components/api-page.client.tsx",
1611
+ content: generateApiClientComponent()
1612
+ });
1613
+ openapiFiles.push({
1614
+ path: "components/api-page.tsx",
1615
+ content: generateApiPageComponent()
1616
+ });
1617
+ }
1211
1618
  const baseFiles = [
1212
1619
  {
1213
1620
  path: "source.config.ts",
@@ -1254,9 +1661,11 @@ async function generateAll(ctx) {
1254
1661
  content: generatePageActionsComponent()
1255
1662
  }
1256
1663
  ];
1664
+ const headerEnabled = isHeaderEnabled(ctx.config);
1257
1665
  let files;
1258
1666
  if (isI18n) files = [
1259
1667
  ...baseFiles,
1668
+ ...openapiFiles,
1260
1669
  {
1261
1670
  path: "lib/i18n.ts",
1262
1671
  content: generateI18nConfig(ctx)
@@ -1291,6 +1700,10 @@ async function generateAll(ctx) {
1291
1700
  path: "app/[lang]/components/search-dialog.tsx",
1292
1701
  content: generateSearchDialog(ctx)
1293
1702
  },
1703
+ ...headerEnabled ? [{
1704
+ path: "app/[lang]/components/top-bar.tsx",
1705
+ content: generateTopBarComponent(ctx)
1706
+ }] : [],
1294
1707
  {
1295
1708
  path: "app/[lang]/[[...slug]]/layout.tsx",
1296
1709
  content: generateDocsLayout(ctx)
@@ -1302,6 +1715,7 @@ async function generateAll(ctx) {
1302
1715
  ];
1303
1716
  else files = [
1304
1717
  ...baseFiles,
1718
+ ...openapiFiles,
1305
1719
  ...ctx.dev ? [{
1306
1720
  path: "app/api/raw/[...path]/route.ts",
1307
1721
  content: generateRawContentRoute(ctx)
@@ -1324,6 +1738,10 @@ async function generateAll(ctx) {
1324
1738
  path: "app/components/search-dialog.tsx",
1325
1739
  content: generateSearchDialog(ctx)
1326
1740
  },
1741
+ ...headerEnabled ? [{
1742
+ path: "app/components/top-bar.tsx",
1743
+ content: generateTopBarComponent(ctx)
1744
+ }] : [],
1327
1745
  {
1328
1746
  path: "app/[[...slug]]/layout.tsx",
1329
1747
  content: generateDocsLayout(ctx)
@@ -1350,10 +1768,8 @@ async function generateAll(ctx) {
1350
1768
  function generateRootLayout(ctx) {
1351
1769
  const { config } = ctx;
1352
1770
  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';
1771
+ const headerEnabled = isHeaderEnabled(config);
1772
+ return `${favicon ? `import type { Metadata } from 'next';
1357
1773
 
1358
1774
  export const metadata: Metadata = {
1359
1775
  icons: {
@@ -1361,12 +1777,15 @@ export const metadata: Metadata = {
1361
1777
  },
1362
1778
  };
1363
1779
 
1364
- ` : ""}import '../global.css';
1780
+ ` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
1781
+ import { AppProvider } from './provider';
1782
+ import type { ReactNode } from 'react';
1783
+ import '../global.css';
1365
1784
 
1366
1785
  export default function RootLayout({ children }: { children: ReactNode }) {
1367
1786
  return (
1368
1787
  <AppLayout>
1369
- <AppProvider>{children}</AppProvider>
1788
+ <AppProvider>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
1370
1789
  </AppLayout>
1371
1790
  );
1372
1791
  }
@@ -1383,6 +1802,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
1383
1802
  function generateRootLayoutI18n(ctx) {
1384
1803
  const { config } = ctx;
1385
1804
  const favicon = config.favicon;
1805
+ const headerEnabled = isHeaderEnabled(config);
1386
1806
  return `${favicon ? `import type { Metadata } from 'next';
1387
1807
 
1388
1808
  export const metadata: Metadata = {
@@ -1391,7 +1811,7 @@ export const metadata: Metadata = {
1391
1811
  },
1392
1812
  };
1393
1813
 
1394
- ` : ""}import { AppLayout } from 'openmanual/components/app-layout';
1814
+ ` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
1395
1815
  import { AppProvider } from './provider';
1396
1816
  import type { ReactNode } from 'react';
1397
1817
  import '../../global.css';
@@ -1407,7 +1827,7 @@ export default async function RootLayout({
1407
1827
 
1408
1828
  return (
1409
1829
  <AppLayout lang={lang}>
1410
- <AppProvider lang={lang}>{children}</AppProvider>
1830
+ <AppProvider lang={lang}>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
1411
1831
  </AppLayout>
1412
1832
  );
1413
1833
  }
@@ -1419,6 +1839,8 @@ function generateDocsLayout(ctx) {
1419
1839
  const navLinks = config.navbar?.links ?? [];
1420
1840
  const footerText = config.footer?.text ?? "";
1421
1841
  const isI18n = isI18nEnabled(config);
1842
+ const isOApi = isOpenApiEnabled(config);
1843
+ const rootGroups = ctx.rootGroups;
1422
1844
  const linksArray = navLinks.map((l) => ({
1423
1845
  text: l.label,
1424
1846
  url: l.href,
@@ -1430,6 +1852,13 @@ function generateDocsLayout(ctx) {
1430
1852
  const configDesc = config.description ?? "";
1431
1853
  const descLine = configDesc ? isI18n ? "" : `description: '${configDesc.replace(/'/g, "\\'")}',` : "";
1432
1854
  const treeLine = isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
1855
+ const separateTab = isOpenApiEnabled(config) && isSeparateTabMode(config);
1856
+ const openapiTab = separateTab ? {
1857
+ title: config.openapi?.label ?? "接口文档",
1858
+ url: isI18n ? "/${lang}/openapi" : "/openapi",
1859
+ urls: /* @__PURE__ */ new Set()
1860
+ } : null;
1861
+ const sidebarTabsLine = rootGroups && rootGroups.length > 0 || openapiTab ? isI18n ? generateI18nSidebarTabs(config, rootGroups, openapiTab) : generateSingleSidebarTabs(config, rootGroups, openapiTab) : "";
1433
1862
  if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1434
1863
  import { baseOptions } from '@/lib/layout';
1435
1864
  import { source } from '@/lib/source';
@@ -1441,13 +1870,16 @@ export default async function DocsLayoutWrapper({
1441
1870
  params: Promise<{ lang: string }>;
1442
1871
  children: ReactNode;
1443
1872
  }) {
1444
- const { lang } = await params;${configDesc ? `
1873
+ const { lang } = await params;
1874
+ ${isOApi && separateTab ? ` const _omFirstApi = source.getPages(lang)?.find((p: any) => p.data?.type === 'openapi');
1875
+ const _omApiUrl = _omFirstApi?.url ?? \`/\${lang}/openapi\`;
1876
+ ` : ""}${configDesc ? `
1445
1877
  const indexPage = source.getPage([], lang);
1446
1878
  const siteDescription = indexPage?.data.description ?? configDescription;` : ""}
1447
1879
 
1448
1880
  const docsOptions = {
1449
1881
  ...baseOptions(lang),
1450
- ${treeLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}
1882
+ ${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}
1451
1883
  };
1452
1884
 
1453
1885
  return (
@@ -1460,10 +1892,13 @@ export default async function DocsLayoutWrapper({
1460
1892
  return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1461
1893
  import { baseOptions } from '@/lib/layout';
1462
1894
  import { source } from '@/lib/source';
1463
- import type { ReactNode } from 'react';
1895
+ import type { ReactNode } from 'react';${isOApi && separateTab ? `
1896
+ const _omFirstApi = source.getPages()?.find((p: any) => p.data?.type === 'openapi');
1897
+ const _omApiUrl = _omFirstApi?.url ?? '/openapi';
1898
+ ` : ""}
1464
1899
  const docsOptions = {
1465
1900
  ...baseOptions(),
1466
- ${treeLine}${githubLine}${linksLine}${footerLine}${descLine}
1901
+ ${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${descLine}
1467
1902
  };
1468
1903
 
1469
1904
  export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
@@ -1475,6 +1910,37 @@ export default function DocsLayoutWrapper({ children }: { children: ReactNode })
1475
1910
  }
1476
1911
  `;
1477
1912
  }
1913
+ /**
1914
+ * Generate i18n sidebar.tabs string.
1915
+ * Extracted from generateDocsLayout to avoid deeply nested template literals
1916
+ * that confuse the oxc parser when escaped backticks are involved.
1917
+ */
1918
+ function generateI18nSidebarTabs(config, rootGroups, openapiTab) {
1919
+ const entries = (rootGroups ?? []).map((g) => ({
1920
+ title: g.title,
1921
+ dirPath: g.dirPath,
1922
+ url: g.url,
1923
+ urls: g.urls
1924
+ }));
1925
+ const entriesJson = JSON.stringify(entries);
1926
+ const nameEscaped = config.name.replace(/'/g, "\\'");
1927
+ const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
1928
+ 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 },`;
1929
+ }
1930
+ /**
1931
+ * Generate single-language (non-i18n) sidebar.tabs string.
1932
+ * Uses template literal to preserve Set<string> for urls property.
1933
+ */
1934
+ function generateSingleSidebarTabs(config, rootGroups, openapiTab) {
1935
+ const nameEscaped = config.name.replace(/'/g, "\\'");
1936
+ const groupEntries = (rootGroups ?? []).map((g) => {
1937
+ const title = g.title.replace(/'/g, "\\'");
1938
+ const urlsArr = JSON.stringify(g.urls);
1939
+ return `{ title: '${title}', url: '${g.url}', urls: new Set(${urlsArr}) }`;
1940
+ }).join(",\n ");
1941
+ const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
1942
+ return `\n sidebar: {\n tabs: [\n { title: '${nameEscaped}', url: '/' },${groupEntries ? "\n " + groupEntries : ""}${openapiTabLine}\n ],\n },`;
1943
+ }
1478
1944
  function generateOpenManualLogoSvg(name, variant = "light") {
1479
1945
  const textColor = variant === "dark" ? "#E8E0D4" : "#000000";
1480
1946
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190 32" width="190" height="32">
@@ -1497,57 +1963,105 @@ async function ensureLogoFile(ctx, logoPath, variant) {
1497
1963
  }
1498
1964
  }
1499
1965
  /**
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().
1966
+ * Generate or enrich meta.json files for each content directory.
1503
1967
  *
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.
1968
+ * Strategy:
1969
+ * 1. If meta.json files exist enrich missing fields (icon/defaultOpen/pages)
1970
+ * 2. If no meta.json → auto-generate from file system structure
1507
1971
  */
1508
- async function generateMetaFiles(ctx) {
1509
- const sidebar = ctx.config.sidebar;
1510
- if (!sidebar || sidebar.length === 0) return;
1972
+ /**
1973
+ * Compute all slugs from meta.json files, falling back to file system scan.
1974
+ * Stores result in ctx.allSlugs for use by generatePage().
1975
+ * Also extracts root groups (meta.json with root: true) into ctx.rootGroups.
1976
+ */
1977
+ async function computeAllSlugs(ctx) {
1511
1978
  const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
1512
1979
  const isI18n = isI18nEnabled(ctx.config);
1513
1980
  const useDirParser = isDirParser(ctx.config);
1514
1981
  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
- }
1982
+ const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
1983
+ if (metaGroups.length > 0) {
1984
+ ctx.allSlugs = collectSlugsFromMeta(metaGroups);
1985
+ const allFiles = await scanContentDir(contentAbsDir);
1986
+ 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);
1987
+ const scannedDirCache = /* @__PURE__ */ new Map();
1988
+ for (const group of metaGroups) {
1989
+ const dirAbsPath = join(contentAbsDir, group.dirPath);
1990
+ try {
1991
+ const dirFiles = await scanContentDir(dirAbsPath);
1992
+ scannedDirCache.set(group.dirPath, dirFiles);
1993
+ if (!group.pages || group.pages.length === 0) for (const df of dirFiles) ctx.allSlugs.add(`${group.dirPath}/${df.slug}`);
1994
+ } catch {}
1535
1995
  }
1996
+ ctx.rootGroups = metaGroups.filter((g) => g.root === true).map((g) => {
1997
+ const cached = scannedDirCache.get(g.dirPath);
1998
+ const firstPage = g.pages?.[0] ?? cached?.[0]?.name ?? "index";
1999
+ const allUrls = (cached ?? []).map((f) => `/${g.dirPath}/${f.name}`);
2000
+ return {
2001
+ title: g.title,
2002
+ dirPath: g.dirPath,
2003
+ url: `/${g.dirPath}/${firstPage}`,
2004
+ urls: allUrls
2005
+ };
2006
+ });
2007
+ return;
2008
+ }
2009
+ const files = await scanContentDir(contentAbsDir);
2010
+ ctx.allSlugs = new Set(files.map((f) => f.slug));
2011
+ }
2012
+ async function generateMetaFiles(ctx) {
2013
+ const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
2014
+ const isI18n = isI18nEnabled(ctx.config);
2015
+ const useDirParser = isDirParser(ctx.config);
2016
+ const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
2017
+ const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
2018
+ if (metaGroups.length > 0) {
2019
+ for (const group of metaGroups) await enrichMetaFile(group);
2020
+ return;
1536
2021
  }
1537
- await injectPageFrontmatter(ctx);
2022
+ await autoGenerateMetaFromFS(ctx, contentAbsDir, languages, useDirParser);
1538
2023
  }
1539
2024
  /**
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).
2025
+ * Validate an existing meta.json file is readable.
2026
+ * Does NOT modify the file — all user-set fields (including "root") are preserved as-is.
2027
+ * Fumadocs reads meta.json directly via its own content source pipeline.
1542
2028
  */
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);
2029
+ async function enrichMetaFile(_group) {
2030
+ try {
2031
+ await readFile(_group.filePath, "utf-8");
2032
+ } catch {}
2033
+ }
2034
+ /**
2035
+ * Auto-generate meta.json files from the file system structure.
2036
+ * Used when no meta.json and no sidebar config exist.
2037
+ */
2038
+ async function autoGenerateMetaFromFS(_ctx, contentAbsDir, languages, useDirParser) {
2039
+ const files = await scanContentDir(contentAbsDir);
2040
+ const rootFiles = [];
2041
+ const dirGroups = /* @__PURE__ */ new Map();
2042
+ for (const file of files) if (file.segments.length <= 1) rootFiles.push(file);
2043
+ else {
2044
+ const dirName = file.segments[0];
2045
+ if (dirName === void 0) continue;
2046
+ if (!dirGroups.has(dirName)) dirGroups.set(dirName, []);
2047
+ dirGroups.get(dirName)?.push(file);
2048
+ }
2049
+ if (rootFiles.length > 0) {
2050
+ const rootMeta = {
2051
+ title: "Getting Started",
2052
+ pages: rootFiles.map((f) => f.name)
2053
+ };
2054
+ if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, "meta.json"), rootMeta);
2055
+ else await writeMetaIfNotExists(join(contentAbsDir, "meta.json"), rootMeta);
2056
+ }
2057
+ for (const [dirName, dirFiles] of dirGroups) {
2058
+ const dirMeta = {
2059
+ title: formatTitle(dirName),
2060
+ pages: dirFiles.map((f) => f.segments.slice(1).join("/"))
2061
+ };
2062
+ if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, dirName, "meta.json"), dirMeta);
2063
+ else await writeMetaIfNotExists(join(contentAbsDir, dirName, "meta.json"), dirMeta);
2064
+ }
1551
2065
  }
1552
2066
  /**
1553
2067
  * Write meta.json only if it does not already exist (preserve user edits).
@@ -1560,62 +2074,6 @@ async function writeMetaIfNotExists(filePath, data) {
1560
2074
  await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
1561
2075
  }
1562
2076
  }
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
2077
 
1620
2078
  //#endregion
1621
2079
  //#region src/utils/install-deps.ts
@@ -2070,7 +2528,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
2070
2528
  //#endregion
2071
2529
  //#region src/cli/bin.ts
2072
2530
  function getVersion() {
2073
- return "0.13.0";
2531
+ return "0.14.0";
2074
2532
  }
2075
2533
  const program = new Command();
2076
2534
  const commandName = basename(process.argv[1] ?? "openmanual");