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/README.md +1 -0
- package/dist/bin.js +652 -194
- package/dist/bin.js.map +1 -1
- package/dist/components/callout.js +4 -4
- package/dist/components/lib/utils.js +8 -0
- package/dist/components/mermaid.js +2 -1
- package/dist/components/nav-links.d.ts +36 -0
- package/dist/components/nav-links.js +82 -0
- package/dist/components/page-actions.js +1 -3
- package/dist/components/provider.d.ts +1 -0
- package/dist/components/top-bar-search-trigger.d.ts +12 -0
- package/dist/components/top-bar-search-trigger.js +43 -0
- package/dist/components/top-bar.d.ts +31 -0
- package/dist/components/top-bar.js +38 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +46 -7
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/utils/restructure-tree.d.ts +0 -20
- package/dist/utils/restructure-tree.js +0 -70
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({
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,${
|
|
472
|
-
serverExternalPackages: ['mermaid'],
|
|
726
|
+
reactStrictMode: true,${outputLine}
|
|
727
|
+
serverExternalPackages: ${isOApi ? "['mermaid', 'shiki']" : "['mermaid']"},
|
|
473
728
|
images: {
|
|
474
729
|
unoptimized: true,
|
|
475
|
-
},${
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
531
|
-
|
|
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([...
|
|
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([...
|
|
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
|
|
614
|
-
|
|
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
|
|
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
|
-
|
|
1354
|
-
import {
|
|
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 '
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
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
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
2022
|
+
await autoGenerateMetaFromFS(ctx, contentAbsDir, languages, useDirParser);
|
|
1538
2023
|
}
|
|
1539
2024
|
/**
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
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
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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.
|
|
2531
|
+
return "0.14.0";
|
|
2074
2532
|
}
|
|
2075
2533
|
const program = new Command();
|
|
2076
2534
|
const commandName = basename(process.argv[1] ?? "openmanual");
|