openmanual 0.12.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,30 +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
- function buildTitleMap(config) {
78
- const map = {};
79
- if (config.sidebar) for (const group of config.sidebar) for (const page of group.pages) map[page.slug] = page.title;
80
- return map;
81
- }
82
110
  function isI18nEnabled(config) {
83
111
  return config.i18n?.enabled === true && (config.i18n.languages?.length ?? 0) > 1;
84
112
  }
85
113
  function isDirParser(config) {
86
114
  return config.i18n?.parser === "dir";
87
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
+ }
88
148
 
89
149
  //#endregion
90
150
  //#region src/core/config/loader.ts
@@ -98,7 +158,6 @@ const DEFAULT_CONFIG = {
98
158
  primaryHue: 213,
99
159
  darkMode: true
100
160
  },
101
- search: { enabled: true },
102
161
  mdx: {},
103
162
  pageActions: { enabled: true }
104
163
  };
@@ -144,10 +203,7 @@ function mergeDefaults(config) {
144
203
  ...DEFAULT_CONFIG.theme,
145
204
  ...config.theme
146
205
  },
147
- search: {
148
- ...DEFAULT_CONFIG.search,
149
- ...config.search
150
- },
206
+ search: config.search ? { position: config.search.position ?? "sidebar" } : void 0,
151
207
  mdx: {
152
208
  ...DEFAULT_CONFIG.mdx,
153
209
  ...config.mdx
@@ -161,10 +217,125 @@ function mergeDefaults(config) {
161
217
  defaultLanguage: config.i18n.defaultLanguage ?? config.locale ?? "zh",
162
218
  languages: config.i18n.languages ?? [],
163
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
164
227
  } : void 0
165
228
  };
166
229
  }
167
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
+
168
339
  //#endregion
169
340
  //#region src/core/generator/callout-component.ts
170
341
  function generateCalloutComponent() {
@@ -178,45 +349,9 @@ export { Callout, CalloutTitle, CalloutDescription } from 'openmanual/components
178
349
  function generateGlobalCss(ctx) {
179
350
  const { config } = ctx;
180
351
  const primaryHue = config.theme?.primaryHue ?? 213;
181
- return `@import 'tailwindcss';
182
- @source './node_modules/openmanual/dist/components/**/*.js';
183
- @import 'fumadocs-ui/style.css';
184
- @custom-variant dark (&:is(.dark, .dark *));
185
-
186
- :root {
187
- --primary-hue: ${primaryHue};
188
-
189
- /* 护眼暖色阅读背景 */
190
- --color-fd-background: hsl(40, 22%, 96.5%); /* #faf9f6 纸张白 */
191
- --color-fd-foreground: hsl(0, 0%, 17.3%); /* #2c2c2c 柔黑 */
192
- --color-fd-muted: hsl(40, 15%, 95%); /* 柔和的暖灰背景 */
193
- --color-fd-card: hsl(40, 18%, 94%); /* 卡片背景 */
194
- --color-fd-popover: hsl(40, 20%, 97.5%); /* 弹窗背景 */
195
-
196
- /* Callout 类型色 */
197
- --callout-info-bg: hsl(210, 35%, 94%);
198
- --callout-info-border: hsl(212, 40%, 80%);
199
- --callout-info-text: hsl(213, 45%, 35%);
200
- --callout-warning-bg: hsl(38, 60%, 93%);
201
- --callout-warning-border: hsl(36, 55%, 78%);
202
- --callout-warning-text: hsl(28, 55%, 35%);
203
- --callout-danger-bg: hsl(0, 50%, 94%);
204
- --callout-danger-border: hsl(0, 45%, 82%);
205
- --callout-danger-text: hsl(0, 50%, 38%);
206
- --callout-check-bg: hsl(150, 35%, 93%);
207
- --callout-check-border: hsl(152, 35%, 78%);
208
- --callout-check-text: hsl(155, 40%, 32%);
209
- --callout-tip-bg: hsl(150, 35%, 93%);
210
- --callout-tip-border: hsl(152, 35%, 78%);
211
- --callout-tip-text: hsl(155, 40%, 32%);
212
- --callout-note-bg: hsl(215, 20%, 94%);
213
- --callout-note-border: hsl(215, 22%, 82%);
214
- --callout-note-text: hsl(215, 25%, 40%);
215
- --callout-key-bg: hsl(30, 55%, 93%);
216
- --callout-key-border: hsl(28, 50%, 78%);
217
- --callout-key-text: hsl(25, 50%, 35%);
218
- }
219
- ${config.theme?.darkMode ?? true ? `
352
+ const darkMode = config.theme?.darkMode ?? true;
353
+ const isOApi = isOpenApiEnabled(config);
354
+ const darkBlock = darkMode ? `
220
355
  .dark {
221
356
  --primary-hue: ${primaryHue};
222
357
 
@@ -238,6 +373,7 @@ ${config.theme?.darkMode ?? true ? `
238
373
  --color-fd-accent-foreground: hsl(35, 12%, 88%);
239
374
  --color-fd-ring: hsl(30, 30%, 50%);
240
375
  --color-fd-overlay: hsla(25, 20%, 5%, 0.5);
376
+ --color-fd-inputborder: hsla(30, 20%, 50%, 40%); /* 输入框边框色(hover 用)*/
241
377
 
242
378
  /* Callout 类型色 */
243
379
  --callout-info-bg: hsl(213, 25%, 16%);
@@ -266,7 +402,59 @@ ${config.theme?.darkMode ?? true ? `
266
402
  .dark body {
267
403
  background: linear-gradient(hsla(30, 30%, 15%, 0.4), transparent 20rem, transparent);
268
404
  }
269
- ` : ""}
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}
270
458
 
271
459
  /* 代码块:去除 shadow,使用朴素边框;去除 max-height 限制 */
272
460
  figure.shiki {
@@ -359,17 +547,25 @@ function resolveLogoPaths(logo) {
359
547
  dark: logo.dark
360
548
  };
361
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
+ }
362
564
  function generateLayout(ctx) {
363
565
  const { config } = ctx;
364
566
  const logo = config.navbar?.logo ?? config.name;
365
567
  const isI18n = config.i18n?.enabled === true;
366
- let logoProps;
367
- if (typeof logo === "string" && isImagePath(logo)) logoProps = `type="image" src="${logo}" alt="${config.name}"`;
368
- else if (typeof logo === "object") {
369
- const { light, dark } = logo;
370
- if (light === dark) logoProps = `type="image" src="${light}" alt="${config.name}"`;
371
- else logoProps = `type="image" srcLight="${light}" srcDark="${dark}" alt="${config.name}"`;
372
- } else logoProps = `type="text" text="${logo}"`;
568
+ const logoProps = resolveNavLogoProps(logo, config.name);
373
569
  if (isI18n) return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
374
570
  import type { ReactNode } from 'react';
375
571
  import { NavLogo } from 'openmanual/components/nav-layout';
@@ -399,7 +595,58 @@ export function baseOptions(): BaseLayoutProps {
399
595
  //#endregion
400
596
  //#region src/core/generator/lib-source.ts
401
597
  function generateLibSource(ctx) {
402
- 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';
403
650
  import { loader } from 'fumadocs-core/source';
404
651
  import { i18n } from '@/lib/i18n';
405
652
 
@@ -467,34 +714,84 @@ export const config = {
467
714
  function generateNextConfig(ctx) {
468
715
  const { config } = ctx;
469
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 },` : "";
470
720
  return `import { createMDX } from 'fumadocs-mdx/next';
471
721
 
472
722
  const withMDX = createMDX();
473
723
 
474
724
  /** @type {import('next').NextConfig} */
475
725
  const config = {
476
- reactStrictMode: true,${!ctx.dev && siteUrl ? `\n output: 'export',` : ""}
477
- serverExternalPackages: ['mermaid'],
726
+ reactStrictMode: true,${outputLine}
727
+ serverExternalPackages: ${isOApi ? "['mermaid', 'shiki']" : "['mermaid']"},
478
728
  images: {
479
729
  unoptimized: true,
480
- },${ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : ""}
730
+ },${rewritesBlock}
481
731
  };
482
732
 
483
733
  export default withMDX(config);
484
734
  `;
485
735
  }
486
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
+
487
784
  //#endregion
488
785
  //#region src/core/generator/package-json.ts
489
786
  function getOpenManualVersion() {
490
- return "0.12.0";
787
+ return "0.14.0";
491
788
  }
492
789
  function generatePackageJson(ctx) {
493
790
  const openmanualVersion = getOpenManualVersion();
494
791
  let openmanualDep;
495
792
  if (ctx.openmanualRoot && ctx.appDir) openmanualDep = `file:${relative(ctx.appDir, ctx.openmanualRoot)}`;
496
793
  else openmanualDep = `^${openmanualVersion}`;
497
- return `${JSON.stringify({
794
+ const pkg = {
498
795
  name: "openmanual-app",
499
796
  type: "module",
500
797
  private: true,
@@ -518,13 +815,18 @@ function generatePackageJson(ctx) {
518
815
  react: "^19.1.0",
519
816
  "react-dom": "^19.1.0",
520
817
  tailwindcss: "^4.1.15",
521
- 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
+ } : {}
522
823
  },
523
824
  devDependencies: {
524
825
  "@types/react": "^19.1.0",
525
826
  "@types/react-dom": "^19.1.0"
526
827
  }
527
- }, null, 2)}\n`;
828
+ };
829
+ return `${JSON.stringify(pkg, null, 2)}\n`;
528
830
  }
529
831
 
530
832
  //#endregion
@@ -532,17 +834,20 @@ function generatePackageJson(ctx) {
532
834
  function generatePage(_ctx) {
533
835
  const isStrict = _ctx.config.contentPolicy !== "all";
534
836
  const pageActionsEnabled = _ctx.config.pageActions?.enabled !== false;
535
- if (_ctx.config.i18n?.enabled === true) return generatePageI18n(_ctx, isStrict, pageActionsEnabled);
536
- return generatePageSingle(_ctx, isStrict, pageActionsEnabled);
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);
537
842
  }
538
- function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
843
+ function generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
539
844
  const allowedSlugsSnippet = isStrict ? `
540
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
845
+ const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
541
846
 
542
847
  function isAllowed(slug: string[] | undefined): boolean {
543
848
  if (allowedSlugs.size === 0) return true;
544
- const key = slug ? slug.join('/') : 'index';
545
- 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');
546
851
  }
547
852
  ` : "";
548
853
  return `import { source } from '@/lib/source';
@@ -555,7 +860,7 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
555
860
  import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
556
861
  import { TypeTable } from 'fumadocs-ui/components/type-table';
557
862
  import { Mermaid } from '@/components/mermaid';
558
- 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';" : ""}
559
864
  ${allowedSlugsSnippet}
560
865
  export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
561
866
  const { slug } = await params;
@@ -568,7 +873,18 @@ ${isStrict ? `
568
873
  if (!page) {
569
874
  notFound();
570
875
  }
571
-
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
+ ` : ""}
572
888
  const MDX = page.data.body;
573
889
 
574
890
  return (
@@ -609,14 +925,15 @@ export function generateStaticParams() {
609
925
  }`}
610
926
  `;
611
927
  }
612
- function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
928
+ function generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
613
929
  const allowedSlugsSnippet = isStrict ? `
614
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
930
+ const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
615
931
 
616
- function isAllowed(slug: string[] | undefined): boolean {
932
+ function isAllowed(slug: string[] | undefined, lang?: string): boolean {
617
933
  if (allowedSlugs.size === 0) return true;
618
- const key = slug ? slug.join('/') : 'index';
619
- 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');
620
937
  }
621
938
  ` : "";
622
939
  return `import { source } from '@/lib/source';
@@ -629,20 +946,31 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
629
946
  import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
630
947
  import { TypeTable } from 'fumadocs-ui/components/type-table';
631
948
  import { Mermaid } from '@/components/mermaid';
632
- 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';" : ""}
633
950
  ${allowedSlugsSnippet}
634
951
  export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
635
952
  const { slug, lang } = await params;
636
953
  const page = source.getPage(slug, lang);
637
954
  ${isStrict ? `
638
- if (!isAllowed(slug)) {
955
+ if (!isAllowed(slug, lang)) {
639
956
  notFound();
640
957
  }
641
958
  ` : ""}
642
959
  if (!page) {
643
960
  notFound();
644
961
  }
645
-
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
+ ` : ""}
646
974
  const MDX = page.data.body;
647
975
 
648
976
  return (
@@ -668,7 +996,7 @@ ${pageActionsEnabled ? ` <div className="flex items-start justify-between g
668
996
  ${isStrict ? `
669
997
  export function generateStaticParams() {
670
998
  let params = source.generateParams();
671
- 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));
672
1000
  // Ensure every language has a homepage entry (slug: [])
673
1001
  const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
674
1002
  for (const lang of languages) {
@@ -730,7 +1058,7 @@ export default config;
730
1058
  * 一次作为生成应用的依赖)导致的多实例 React Context 问题。
731
1059
  */
732
1060
  function generateProvider(ctx) {
733
- const searchEnabled = ctx.config.search?.enabled !== false;
1061
+ const searchEnabled = ctx.config.search !== void 0;
734
1062
  if (ctx.config.i18n?.enabled === true) return `'use client';
735
1063
  import { RootProvider } from 'fumadocs-ui/provider/next';
736
1064
  import SafeSearchDialog from './components/search-dialog';
@@ -1150,76 +1478,11 @@ export const { staticGET: GET } = createFromSource(source);
1150
1478
  //#endregion
1151
1479
  //#region src/core/generator/source-config.ts
1152
1480
  function generateSourceConfig(_ctx) {
1153
- const titleMap = buildTitleMap(_ctx.config);
1154
- const titleMapEntries = Object.entries(titleMap).map(([slug, title]) => ` '${slug}': '${title.replace(/'/g, "\\'")}'`).join(",\n");
1155
- const titleMapStr = titleMapEntries ? `{\n${titleMapEntries}\n}` : "{}";
1156
- const isStrict = _ctx.config.contentPolicy !== "all";
1157
- const isI18n = isI18nEnabled(_ctx.config);
1158
- const useDirParser = isDirParser(_ctx.config);
1159
1481
  return `import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
1160
1482
  import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
1161
- import { z } from 'zod';
1162
-
1163
- const titleMap: Record<string, string> = ${titleMapStr};${isStrict ? `
1164
-
1165
- const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
1166
-
1167
- function slugFromPath(path: string): string {
1168
- const normalized = path.replace(/\\\\/g, '/');
1169
- const idx = normalized.indexOf('content/');
1170
- const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;${useDirParser ? `
1171
- // dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
1172
- const parts = relative.split('/');
1173
- if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
1174
- return parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
1175
- }
1176
- return relative.replace(/\\.(md|mdx)$/i, '');` : `
1177
- let slug = relative.replace(/\\.(md|mdx)$/i, '');
1178
- ${isI18n ? ` // 剥离语言后缀:index.en -> index
1179
- slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
1180
- return slug;`}
1181
- }` : ""}
1182
- function titleFromPath(path: string): string {
1183
- ${useDirParser ? ` const normalized = path.replace(/\\\\/g, '/');
1184
- const idx = normalized.indexOf('content/');
1185
- const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
1186
- // dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
1187
- const parts = relative.split('/');
1188
- if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
1189
- const slug = parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
1190
- return titleMap[slug] || slug.split('/').pop() || slug;
1191
- }
1192
- const slug = relative.replace(/\\.(md|mdx)$/i, '');
1193
- return titleMap[slug] || slug.split('/').pop() || slug;` : ` const normalized = path.replace(/\\\\/g, '/');
1194
- const idx = normalized.indexOf('content/');
1195
- const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
1196
- let slug = relative.replace(/\\.(md|mdx)$/i, '');
1197
- ${isI18n ? ` // 剥离语言后缀:guide/configuration.en -> guide/configuration
1198
- slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
1199
- return titleMap[slug] || slug.split('/').pop() || slug;`}
1200
- }
1201
1483
 
1202
1484
  export const docs = defineDocs({
1203
1485
  dir: 'content',
1204
- docs: {
1205
- schema: (ctx) =>
1206
- z.object({
1207
- title: z.string().optional(),
1208
- description: z.string().optional(),
1209
- icon: z.string().optional(),
1210
- full: z.boolean().optional(),
1211
- }).transform((data) => ({
1212
- ...data,
1213
- title: data.title ?? titleFromPath(ctx.path),
1214
- }))${isStrict ? `
1215
- .refine((_data) => {
1216
- const slug = slugFromPath(ctx.path);
1217
- if (allowedSlugs.size > 0 && !allowedSlugs.has(slug)) {
1218
- return false;
1219
- }
1220
- return true;
1221
- })` : ""},
1222
- },
1223
1486
  });
1224
1487
 
1225
1488
  export default defineConfig({
@@ -1238,6 +1501,42 @@ export default defineConfig({
1238
1501
  `;
1239
1502
  }
1240
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
+
1241
1540
  //#endregion
1242
1541
  //#region src/core/generator/tsconfig.ts
1243
1542
  function generateTsconfig() {
@@ -1278,6 +1577,44 @@ function generateTsconfig() {
1278
1577
  //#region src/core/generator/index.ts
1279
1578
  async function generateAll(ctx) {
1280
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
+ }
1281
1618
  const baseFiles = [
1282
1619
  {
1283
1620
  path: "source.config.ts",
@@ -1324,9 +1661,11 @@ async function generateAll(ctx) {
1324
1661
  content: generatePageActionsComponent()
1325
1662
  }
1326
1663
  ];
1664
+ const headerEnabled = isHeaderEnabled(ctx.config);
1327
1665
  let files;
1328
1666
  if (isI18n) files = [
1329
1667
  ...baseFiles,
1668
+ ...openapiFiles,
1330
1669
  {
1331
1670
  path: "lib/i18n.ts",
1332
1671
  content: generateI18nConfig(ctx)
@@ -1361,6 +1700,10 @@ async function generateAll(ctx) {
1361
1700
  path: "app/[lang]/components/search-dialog.tsx",
1362
1701
  content: generateSearchDialog(ctx)
1363
1702
  },
1703
+ ...headerEnabled ? [{
1704
+ path: "app/[lang]/components/top-bar.tsx",
1705
+ content: generateTopBarComponent(ctx)
1706
+ }] : [],
1364
1707
  {
1365
1708
  path: "app/[lang]/[[...slug]]/layout.tsx",
1366
1709
  content: generateDocsLayout(ctx)
@@ -1372,6 +1715,7 @@ async function generateAll(ctx) {
1372
1715
  ];
1373
1716
  else files = [
1374
1717
  ...baseFiles,
1718
+ ...openapiFiles,
1375
1719
  ...ctx.dev ? [{
1376
1720
  path: "app/api/raw/[...path]/route.ts",
1377
1721
  content: generateRawContentRoute(ctx)
@@ -1394,6 +1738,10 @@ async function generateAll(ctx) {
1394
1738
  path: "app/components/search-dialog.tsx",
1395
1739
  content: generateSearchDialog(ctx)
1396
1740
  },
1741
+ ...headerEnabled ? [{
1742
+ path: "app/components/top-bar.tsx",
1743
+ content: generateTopBarComponent(ctx)
1744
+ }] : [],
1397
1745
  {
1398
1746
  path: "app/[[...slug]]/layout.tsx",
1399
1747
  content: generateDocsLayout(ctx)
@@ -1420,10 +1768,8 @@ async function generateAll(ctx) {
1420
1768
  function generateRootLayout(ctx) {
1421
1769
  const { config } = ctx;
1422
1770
  const favicon = config.favicon;
1423
- return `import { AppLayout } from 'openmanual/components/app-layout';
1424
- import { AppProvider } from './provider';
1425
- import type { ReactNode } from 'react';
1426
- ${favicon ? `import type { Metadata } from 'next';
1771
+ const headerEnabled = isHeaderEnabled(config);
1772
+ return `${favicon ? `import type { Metadata } from 'next';
1427
1773
 
1428
1774
  export const metadata: Metadata = {
1429
1775
  icons: {
@@ -1431,12 +1777,15 @@ export const metadata: Metadata = {
1431
1777
  },
1432
1778
  };
1433
1779
 
1434
- ` : ""}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';
1435
1784
 
1436
1785
  export default function RootLayout({ children }: { children: ReactNode }) {
1437
1786
  return (
1438
1787
  <AppLayout>
1439
- <AppProvider>{children}</AppProvider>
1788
+ <AppProvider>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
1440
1789
  </AppLayout>
1441
1790
  );
1442
1791
  }
@@ -1453,6 +1802,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
1453
1802
  function generateRootLayoutI18n(ctx) {
1454
1803
  const { config } = ctx;
1455
1804
  const favicon = config.favicon;
1805
+ const headerEnabled = isHeaderEnabled(config);
1456
1806
  return `${favicon ? `import type { Metadata } from 'next';
1457
1807
 
1458
1808
  export const metadata: Metadata = {
@@ -1461,7 +1811,7 @@ export const metadata: Metadata = {
1461
1811
  },
1462
1812
  };
1463
1813
 
1464
- ` : ""}import { AppLayout } from 'openmanual/components/app-layout';
1814
+ ` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
1465
1815
  import { AppProvider } from './provider';
1466
1816
  import type { ReactNode } from 'react';
1467
1817
  import '../../global.css';
@@ -1477,7 +1827,7 @@ export default async function RootLayout({
1477
1827
 
1478
1828
  return (
1479
1829
  <AppLayout lang={lang}>
1480
- <AppProvider lang={lang}>{children}</AppProvider>
1830
+ <AppProvider lang={lang}>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
1481
1831
  </AppLayout>
1482
1832
  );
1483
1833
  }
@@ -1489,6 +1839,8 @@ function generateDocsLayout(ctx) {
1489
1839
  const navLinks = config.navbar?.links ?? [];
1490
1840
  const footerText = config.footer?.text ?? "";
1491
1841
  const isI18n = isI18nEnabled(config);
1842
+ const isOApi = isOpenApiEnabled(config);
1843
+ const rootGroups = ctx.rootGroups;
1492
1844
  const linksArray = navLinks.map((l) => ({
1493
1845
  text: l.label,
1494
1846
  url: l.href,
@@ -1497,36 +1849,20 @@ function generateDocsLayout(ctx) {
1497
1849
  const githubLine = githubLink ? `\n github: '${githubLink}',` : "";
1498
1850
  const linksLine = linksArray.length > 0 ? `\n links: ${JSON.stringify(linksArray)},` : "";
1499
1851
  const footerLine = footerText ? `\n footer: { children: '${footerText.replace(/'/g, "\\'")}' },` : "";
1500
- const sidebar = config.sidebar;
1501
- const hasSidebar = sidebar && sidebar.length > 0;
1502
- const iconNames = /* @__PURE__ */ new Set();
1503
- if (hasSidebar) for (const g of sidebar ?? []) {
1504
- if (g.icon) iconNames.add(g.icon);
1505
- for (const p of g.pages) if (p.icon) iconNames.add(p.icon);
1506
- }
1507
- const hasIcons = iconNames.size > 0;
1508
- const iconNameList = [...iconNames];
1509
- const sidebarConfigSnippet = hasSidebar ? `\nconst sidebarConfig = ${JSON.stringify((sidebar ?? []).map((g) => ({
1510
- group: g.group,
1511
- icon: g.icon,
1512
- collapsed: g.collapsed,
1513
- pages: g.pages.map((p) => ({
1514
- slug: p.slug,
1515
- icon: p.icon
1516
- }))
1517
- })), null, 2)} as const;
1518
- ` : "";
1519
- const lucideImportLine = hasIcons ? `\nimport { ${iconNameList.join(", ")} } from 'lucide-react';` : "";
1520
- const iconMapSnippet = hasIcons ? `\nconst iconMap = {${iconNameList.map((name) => `\n ${name}: <${name} />,`).join("")}\n} as const;
1521
- ` : "";
1522
- const treeLine = hasSidebar ? hasIcons ? isI18n ? "tree: restructureTree(source.getPageTree(lang), sidebarConfig, iconMap)," : "tree: restructureTree(source.getPageTree(), sidebarConfig, iconMap)," : isI18n ? "tree: restructureTree(source.getPageTree(lang), sidebarConfig)," : "tree: restructureTree(source.getPageTree(), sidebarConfig)," : isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
1523
- const restructureTreeImport = hasSidebar ? "\nimport { restructureTree } from 'openmanual/utils/restructure-tree';" : "";
1852
+ const configDesc = config.description ?? "";
1853
+ const descLine = configDesc ? isI18n ? "" : `description: '${configDesc.replace(/'/g, "\\'")}',` : "";
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) : "";
1524
1862
  if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1525
1863
  import { baseOptions } from '@/lib/layout';
1526
1864
  import { source } from '@/lib/source';
1527
- import type { ReactNode } from 'react';${restructureTreeImport}${lucideImportLine}
1528
- ${sidebarConfigSnippet}${iconMapSnippet}
1529
-
1865
+ import type { ReactNode } from 'react';${configDesc ? `\nconst configDescription = '${configDesc.replace(/'/g, "\\'")}' as const;\n` : ""}
1530
1866
  export default async function DocsLayoutWrapper({
1531
1867
  params,
1532
1868
  children,
@@ -1535,10 +1871,15 @@ export default async function DocsLayoutWrapper({
1535
1871
  children: ReactNode;
1536
1872
  }) {
1537
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 ? `
1877
+ const indexPage = source.getPage([], lang);
1878
+ const siteDescription = indexPage?.data.description ?? configDescription;` : ""}
1538
1879
 
1539
1880
  const docsOptions = {
1540
1881
  ...baseOptions(lang),
1541
- ${treeLine}${githubLine}${linksLine}${footerLine}
1882
+ ${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}
1542
1883
  };
1543
1884
 
1544
1885
  return (
@@ -1551,11 +1892,13 @@ export default async function DocsLayoutWrapper({
1551
1892
  return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
1552
1893
  import { baseOptions } from '@/lib/layout';
1553
1894
  import { source } from '@/lib/source';
1554
- import type { ReactNode } from 'react';${restructureTreeImport}${lucideImportLine}
1555
- ${sidebarConfigSnippet}${iconMapSnippet}
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
+ ` : ""}
1556
1899
  const docsOptions = {
1557
1900
  ...baseOptions(),
1558
- ${treeLine}${githubLine}${linksLine}${footerLine}
1901
+ ${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${descLine}
1559
1902
  };
1560
1903
 
1561
1904
  export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
@@ -1567,6 +1910,37 @@ export default function DocsLayoutWrapper({ children }: { children: ReactNode })
1567
1910
  }
1568
1911
  `;
1569
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
+ }
1570
1944
  function generateOpenManualLogoSvg(name, variant = "light") {
1571
1945
  const textColor = variant === "dark" ? "#E8E0D4" : "#000000";
1572
1946
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190 32" width="190" height="32">
@@ -1589,34 +1963,115 @@ async function ensureLogoFile(ctx, logoPath, variant) {
1589
1963
  }
1590
1964
  }
1591
1965
  /**
1592
- * Generate meta.json (and meta.en.json in i18n mode) for each sidebar
1593
- * group directory so that fumadocs displays the configured group name.
1966
+ * Generate or enrich meta.json files for each content directory.
1967
+ *
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
1971
+ */
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.
1594
1976
  */
1595
- async function generateMetaFiles(ctx) {
1596
- const sidebar = ctx.config.sidebar;
1597
- if (!sidebar || sidebar.length === 0) return;
1977
+ async function computeAllSlugs(ctx) {
1598
1978
  const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
1599
1979
  const isI18n = isI18nEnabled(ctx.config);
1600
- for (const group of sidebar) {
1601
- const dirPrefix = group.pages.map((p) => p.slug).find((slug) => slug.includes("/"))?.split("/")[0];
1602
- if (!dirPrefix) continue;
1603
- const dirPath = join(contentAbsDir, dirPrefix);
1604
- const metaPath = join(dirPath, "meta.json");
1605
- try {
1606
- await access(metaPath);
1607
- } catch {
1608
- await mkdir(dirPath, { recursive: true });
1609
- await writeFile(metaPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
1610
- }
1611
- if (isI18n) {
1612
- const metaEnPath = join(dirPath, "meta.en.json");
1980
+ const useDirParser = isDirParser(ctx.config);
1981
+ const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
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);
1613
1990
  try {
1614
- await access(metaEnPath);
1615
- } catch {
1616
- await mkdir(dirPath, { recursive: true });
1617
- await writeFile(metaEnPath, `${JSON.stringify({ title: group.group }, null, 2)}\n`, "utf-8");
1618
- }
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 {}
1619
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;
2021
+ }
2022
+ await autoGenerateMetaFromFS(ctx, contentAbsDir, languages, useDirParser);
2023
+ }
2024
+ /**
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.
2028
+ */
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
+ }
2065
+ }
2066
+ /**
2067
+ * Write meta.json only if it does not already exist (preserve user edits).
2068
+ */
2069
+ async function writeMetaIfNotExists(filePath, data) {
2070
+ try {
2071
+ await access(filePath);
2072
+ } catch {
2073
+ await mkdir(join(filePath, ".."), { recursive: true });
2074
+ await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
1620
2075
  }
1621
2076
  }
1622
2077
 
@@ -2073,7 +2528,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
2073
2528
  //#endregion
2074
2529
  //#region src/cli/bin.ts
2075
2530
  function getVersion() {
2076
- return "0.12.0";
2531
+ return "0.14.0";
2077
2532
  }
2078
2533
  const program = new Command();
2079
2534
  const commandName = basename(process.argv[1] ?? "openmanual");