openmanual 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/bin.js +660 -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 {
|
|
@@ -286,6 +479,13 @@ figure.shiki > div {
|
|
|
286
479
|
[style*="--callout-color"] {
|
|
287
480
|
box-shadow: none;
|
|
288
481
|
}
|
|
482
|
+
${config.search?.position === "header" ? `
|
|
483
|
+
|
|
484
|
+
/* header 搜索模式:隐藏侧边栏折叠面板中的搜索图标 */
|
|
485
|
+
[data-sidebar-panel] [data-search] {
|
|
486
|
+
display: none;
|
|
487
|
+
}
|
|
488
|
+
` : ""}
|
|
289
489
|
`;
|
|
290
490
|
}
|
|
291
491
|
|
|
@@ -354,17 +554,25 @@ function resolveLogoPaths(logo) {
|
|
|
354
554
|
dark: logo.dark
|
|
355
555
|
};
|
|
356
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* 将 LogoConfig 解析为 NavLogo 组件的 props 字符串
|
|
559
|
+
*
|
|
560
|
+
* 消除 top-bar.ts 和 layout.ts 中重复的三分支判断。
|
|
561
|
+
*/
|
|
562
|
+
function resolveNavLogoProps(logo, alt) {
|
|
563
|
+
if (typeof logo === "string" && isImagePath(logo)) return `type="image" src="${logo}" alt="${alt}"`;
|
|
564
|
+
if (typeof logo === "object") {
|
|
565
|
+
const { light, dark } = resolveLogoPaths(logo);
|
|
566
|
+
if (light === dark) return `type="image" src="${light}" alt="${alt}"`;
|
|
567
|
+
return `type="image" srcLight="${light}" srcDark="${dark}" alt="${alt}"`;
|
|
568
|
+
}
|
|
569
|
+
return `type="text" text="${logo}"`;
|
|
570
|
+
}
|
|
357
571
|
function generateLayout(ctx) {
|
|
358
572
|
const { config } = ctx;
|
|
359
573
|
const logo = config.navbar?.logo ?? config.name;
|
|
360
574
|
const isI18n = config.i18n?.enabled === true;
|
|
361
|
-
|
|
362
|
-
if (typeof logo === "string" && isImagePath(logo)) logoProps = `type="image" src="${logo}" alt="${config.name}"`;
|
|
363
|
-
else if (typeof logo === "object") {
|
|
364
|
-
const { light, dark } = logo;
|
|
365
|
-
if (light === dark) logoProps = `type="image" src="${light}" alt="${config.name}"`;
|
|
366
|
-
else logoProps = `type="image" srcLight="${light}" srcDark="${dark}" alt="${config.name}"`;
|
|
367
|
-
} else logoProps = `type="text" text="${logo}"`;
|
|
575
|
+
const logoProps = resolveNavLogoProps(logo, config.name);
|
|
368
576
|
if (isI18n) return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
369
577
|
import type { ReactNode } from 'react';
|
|
370
578
|
import { NavLogo } from 'openmanual/components/nav-layout';
|
|
@@ -394,7 +602,58 @@ export function baseOptions(): BaseLayoutProps {
|
|
|
394
602
|
//#endregion
|
|
395
603
|
//#region src/core/generator/lib-source.ts
|
|
396
604
|
function generateLibSource(ctx) {
|
|
397
|
-
|
|
605
|
+
const isI18n = isI18nEnabled(ctx.config);
|
|
606
|
+
if (isOpenApiEnabled(ctx.config)) {
|
|
607
|
+
const separateTab = isSeparateTabMode(ctx.config);
|
|
608
|
+
const groupBy = ctx.config.openapi?.groupBy ?? "tag";
|
|
609
|
+
if (isI18n) return `import { docs } from '@/.source/server';
|
|
610
|
+
import { loader, multiple } from 'fumadocs-core/source';
|
|
611
|
+
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
|
612
|
+
import { openapi } from '@/lib/openapi';
|
|
613
|
+
import { i18n } from '@/lib/i18n';
|
|
614
|
+
|
|
615
|
+
const _omOpenApiFiles = [];
|
|
616
|
+
for (const lang of i18n.languages) {
|
|
617
|
+
const result = await openapiSource(openapi, {
|
|
618
|
+
baseDir: \`\${lang}/${separateTab ? "openapi" : "api"}\`,
|
|
619
|
+
${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
|
|
620
|
+
});
|
|
621
|
+
_omOpenApiFiles.push(...result.files);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export const source = loader(
|
|
625
|
+
multiple({
|
|
626
|
+
docs: docs.toFumadocsSource(),
|
|
627
|
+
openapi: { files: _omOpenApiFiles },
|
|
628
|
+
}),
|
|
629
|
+
{
|
|
630
|
+
baseUrl: '/',
|
|
631
|
+
i18n,
|
|
632
|
+
plugins: [openapiPlugin()],
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
`;
|
|
636
|
+
return `import { docs } from '@/.source/server';
|
|
637
|
+
import { loader, multiple } from 'fumadocs-core/source';
|
|
638
|
+
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
|
639
|
+
import { openapi } from '@/lib/openapi';
|
|
640
|
+
|
|
641
|
+
export const source = loader(
|
|
642
|
+
multiple({
|
|
643
|
+
docs: docs.toFumadocsSource(),
|
|
644
|
+
openapi: await openapiSource(openapi, {
|
|
645
|
+
baseDir: '${separateTab ? "openapi" : "api"}',
|
|
646
|
+
${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
|
|
647
|
+
}),
|
|
648
|
+
}),
|
|
649
|
+
{
|
|
650
|
+
baseUrl: '/',
|
|
651
|
+
plugins: [openapiPlugin()],
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
656
|
+
if (isI18n) return `import { docs } from '@/.source/server';
|
|
398
657
|
import { loader } from 'fumadocs-core/source';
|
|
399
658
|
import { i18n } from '@/lib/i18n';
|
|
400
659
|
|
|
@@ -462,34 +721,84 @@ export const config = {
|
|
|
462
721
|
function generateNextConfig(ctx) {
|
|
463
722
|
const { config } = ctx;
|
|
464
723
|
const siteUrl = config.siteUrl ?? "";
|
|
724
|
+
const isOApi = isOpenApiEnabled(config);
|
|
725
|
+
const outputLine = !ctx.dev && siteUrl ? `\n output: 'export',` : "";
|
|
726
|
+
const rewritesBlock = ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : "";
|
|
465
727
|
return `import { createMDX } from 'fumadocs-mdx/next';
|
|
466
728
|
|
|
467
729
|
const withMDX = createMDX();
|
|
468
730
|
|
|
469
731
|
/** @type {import('next').NextConfig} */
|
|
470
732
|
const config = {
|
|
471
|
-
reactStrictMode: true,${
|
|
472
|
-
serverExternalPackages: ['mermaid'],
|
|
733
|
+
reactStrictMode: true,${outputLine}
|
|
734
|
+
serverExternalPackages: ${isOApi ? "['mermaid', 'shiki']" : "['mermaid']"},
|
|
473
735
|
images: {
|
|
474
736
|
unoptimized: true,
|
|
475
|
-
},${
|
|
737
|
+
},${rewritesBlock}
|
|
476
738
|
};
|
|
477
739
|
|
|
478
740
|
export default withMDX(config);
|
|
479
741
|
`;
|
|
480
742
|
}
|
|
481
743
|
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/core/generator/openapi.ts
|
|
746
|
+
/**
|
|
747
|
+
* 生成 lib/openapi.ts — OpenAPI 实例定义
|
|
748
|
+
*
|
|
749
|
+
* 使用绝对路径解析 spec 文件,避免 SSG 构建时相对路径失效的问题。
|
|
750
|
+
* 支持多文件输入(specs 数组)和单文件(specPath 向后兼容)。
|
|
751
|
+
*/
|
|
752
|
+
function generateOpenApiLib(ctx) {
|
|
753
|
+
if (!isOpenApiEnabled(ctx.config)) return null;
|
|
754
|
+
const specPaths = resolveOpenApiSpecPaths(ctx.config);
|
|
755
|
+
if (specPaths.length === 0) return null;
|
|
756
|
+
return `import { createOpenAPI } from 'fumadocs-openapi/server';
|
|
757
|
+
|
|
758
|
+
export const openapi = createOpenAPI({
|
|
759
|
+
input: [${specPaths.map((p) => join(ctx.projectDir, p)).map((p) => `'${p}'`).join(", ")}],
|
|
760
|
+
});
|
|
761
|
+
`;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* 生成 components/api-page.client.tsx — APIPage 客户端配置
|
|
765
|
+
*
|
|
766
|
+
* createAPIPage 的 client 参数是 APIPageClientOptions(配置对象),
|
|
767
|
+
* 不是 React 组件。使用 defineClientConfig() 创建默认导出。
|
|
768
|
+
*/
|
|
769
|
+
function generateApiClientComponent() {
|
|
770
|
+
return `'use client';
|
|
771
|
+
|
|
772
|
+
import { defineClientConfig } from 'fumadocs-openapi/ui/client';
|
|
773
|
+
|
|
774
|
+
export default defineClientConfig();
|
|
775
|
+
`;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* 生成 components/api-page.tsx — APIPage 服务端组件包装器
|
|
779
|
+
*/
|
|
780
|
+
function generateApiPageComponent() {
|
|
781
|
+
return `import { openapi } from '@/lib/openapi';
|
|
782
|
+
import { createAPIPage } from 'fumadocs-openapi/ui';
|
|
783
|
+
import client from './api-page.client';
|
|
784
|
+
|
|
785
|
+
export const APIPage = createAPIPage(openapi, {
|
|
786
|
+
client,
|
|
787
|
+
});
|
|
788
|
+
`;
|
|
789
|
+
}
|
|
790
|
+
|
|
482
791
|
//#endregion
|
|
483
792
|
//#region src/core/generator/package-json.ts
|
|
484
793
|
function getOpenManualVersion() {
|
|
485
|
-
return "0.
|
|
794
|
+
return "0.14.1";
|
|
486
795
|
}
|
|
487
796
|
function generatePackageJson(ctx) {
|
|
488
797
|
const openmanualVersion = getOpenManualVersion();
|
|
489
798
|
let openmanualDep;
|
|
490
799
|
if (ctx.openmanualRoot && ctx.appDir) openmanualDep = `file:${relative(ctx.appDir, ctx.openmanualRoot)}`;
|
|
491
800
|
else openmanualDep = `^${openmanualVersion}`;
|
|
492
|
-
|
|
801
|
+
const pkg = {
|
|
493
802
|
name: "openmanual-app",
|
|
494
803
|
type: "module",
|
|
495
804
|
private: true,
|
|
@@ -513,13 +822,18 @@ function generatePackageJson(ctx) {
|
|
|
513
822
|
react: "^19.1.0",
|
|
514
823
|
"react-dom": "^19.1.0",
|
|
515
824
|
tailwindcss: "^4.1.15",
|
|
516
|
-
zod: "^4.0.0"
|
|
825
|
+
zod: "^4.0.0",
|
|
826
|
+
...isOpenApiEnabled(ctx.config) ? {
|
|
827
|
+
"fumadocs-openapi": "^10.7.1",
|
|
828
|
+
shiki: "^3.0.0"
|
|
829
|
+
} : {}
|
|
517
830
|
},
|
|
518
831
|
devDependencies: {
|
|
519
832
|
"@types/react": "^19.1.0",
|
|
520
833
|
"@types/react-dom": "^19.1.0"
|
|
521
834
|
}
|
|
522
|
-
}
|
|
835
|
+
};
|
|
836
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
523
837
|
}
|
|
524
838
|
|
|
525
839
|
//#endregion
|
|
@@ -527,17 +841,20 @@ function generatePackageJson(ctx) {
|
|
|
527
841
|
function generatePage(_ctx) {
|
|
528
842
|
const isStrict = _ctx.config.contentPolicy !== "all";
|
|
529
843
|
const pageActionsEnabled = _ctx.config.pageActions?.enabled !== false;
|
|
530
|
-
|
|
531
|
-
|
|
844
|
+
const isI18n = isI18nEnabled(_ctx.config);
|
|
845
|
+
const isOApi = isOpenApiEnabled(_ctx.config);
|
|
846
|
+
const allSlugs = _ctx.allSlugs ?? /* @__PURE__ */ new Set();
|
|
847
|
+
if (isI18n) return generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
|
|
848
|
+
return generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
|
|
532
849
|
}
|
|
533
|
-
function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
|
|
850
|
+
function generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
|
|
534
851
|
const allowedSlugsSnippet = isStrict ? `
|
|
535
|
-
const allowedSlugs = new Set(${JSON.stringify([...
|
|
852
|
+
const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
|
|
536
853
|
|
|
537
854
|
function isAllowed(slug: string[] | undefined): boolean {
|
|
538
855
|
if (allowedSlugs.size === 0) return true;
|
|
539
|
-
const key = slug ? slug.join('/') : 'index';
|
|
540
|
-
return allowedSlugs.has(key);
|
|
856
|
+
const key = slug && slug.length > 0 ? slug.join('/') : 'index';
|
|
857
|
+
return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
|
|
541
858
|
}
|
|
542
859
|
` : "";
|
|
543
860
|
return `import { source } from '@/lib/source';
|
|
@@ -550,7 +867,7 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
|
|
|
550
867
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
551
868
|
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
|
552
869
|
import { Mermaid } from '@/components/mermaid';
|
|
553
|
-
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
|
|
870
|
+
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
|
|
554
871
|
${allowedSlugsSnippet}
|
|
555
872
|
export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
|
|
556
873
|
const { slug } = await params;
|
|
@@ -563,7 +880,18 @@ ${isStrict ? `
|
|
|
563
880
|
if (!page) {
|
|
564
881
|
notFound();
|
|
565
882
|
}
|
|
566
|
-
|
|
883
|
+
${isOApi ? `
|
|
884
|
+
if (page.data.type === 'openapi') {
|
|
885
|
+
return (
|
|
886
|
+
<DocsPage full>
|
|
887
|
+
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
|
|
888
|
+
<DocsBody>
|
|
889
|
+
<APIPage {...(page.data as any).getAPIPageProps()} />
|
|
890
|
+
</DocsBody>
|
|
891
|
+
</DocsPage>
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
` : ""}
|
|
567
895
|
const MDX = page.data.body;
|
|
568
896
|
|
|
569
897
|
return (
|
|
@@ -604,14 +932,15 @@ export function generateStaticParams() {
|
|
|
604
932
|
}`}
|
|
605
933
|
`;
|
|
606
934
|
}
|
|
607
|
-
function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
|
|
935
|
+
function generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
|
|
608
936
|
const allowedSlugsSnippet = isStrict ? `
|
|
609
|
-
const allowedSlugs = new Set(${JSON.stringify([...
|
|
937
|
+
const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
|
|
610
938
|
|
|
611
|
-
function isAllowed(slug: string[] | undefined): boolean {
|
|
939
|
+
function isAllowed(slug: string[] | undefined, lang?: string): boolean {
|
|
612
940
|
if (allowedSlugs.size === 0) return true;
|
|
613
|
-
const
|
|
614
|
-
|
|
941
|
+
const rawKey = slug && slug.length > 0 ? slug.join('/') : 'index';
|
|
942
|
+
const key = lang ? \`\${lang}/\${rawKey}\` : rawKey;
|
|
943
|
+
return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
|
|
615
944
|
}
|
|
616
945
|
` : "";
|
|
617
946
|
return `import { source } from '@/lib/source';
|
|
@@ -624,20 +953,31 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
|
|
|
624
953
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
625
954
|
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
|
626
955
|
import { Mermaid } from '@/components/mermaid';
|
|
627
|
-
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
|
|
956
|
+
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
|
|
628
957
|
${allowedSlugsSnippet}
|
|
629
958
|
export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
|
|
630
959
|
const { slug, lang } = await params;
|
|
631
960
|
const page = source.getPage(slug, lang);
|
|
632
961
|
${isStrict ? `
|
|
633
|
-
if (!isAllowed(slug)) {
|
|
962
|
+
if (!isAllowed(slug, lang)) {
|
|
634
963
|
notFound();
|
|
635
964
|
}
|
|
636
965
|
` : ""}
|
|
637
966
|
if (!page) {
|
|
638
967
|
notFound();
|
|
639
968
|
}
|
|
640
|
-
|
|
969
|
+
${isOApi ? `
|
|
970
|
+
if (page.data.type === 'openapi') {
|
|
971
|
+
return (
|
|
972
|
+
<DocsPage full>
|
|
973
|
+
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
|
|
974
|
+
<DocsBody>
|
|
975
|
+
<APIPage {...(page.data as any).getAPIPageProps()} />
|
|
976
|
+
</DocsBody>
|
|
977
|
+
</DocsPage>
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
` : ""}
|
|
641
981
|
const MDX = page.data.body;
|
|
642
982
|
|
|
643
983
|
return (
|
|
@@ -663,7 +1003,7 @@ ${pageActionsEnabled ? ` <div className="flex items-start justify-between g
|
|
|
663
1003
|
${isStrict ? `
|
|
664
1004
|
export function generateStaticParams() {
|
|
665
1005
|
let params = source.generateParams();
|
|
666
|
-
params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug));
|
|
1006
|
+
params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug, p.lang));
|
|
667
1007
|
// Ensure every language has a homepage entry (slug: [])
|
|
668
1008
|
const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
|
|
669
1009
|
for (const lang of languages) {
|
|
@@ -725,7 +1065,7 @@ export default config;
|
|
|
725
1065
|
* 一次作为生成应用的依赖)导致的多实例 React Context 问题。
|
|
726
1066
|
*/
|
|
727
1067
|
function generateProvider(ctx) {
|
|
728
|
-
const searchEnabled = ctx.config.search
|
|
1068
|
+
const searchEnabled = ctx.config.search !== void 0;
|
|
729
1069
|
if (ctx.config.i18n?.enabled === true) return `'use client';
|
|
730
1070
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
|
731
1071
|
import SafeSearchDialog from './components/search-dialog';
|
|
@@ -1168,6 +1508,42 @@ export default defineConfig({
|
|
|
1168
1508
|
`;
|
|
1169
1509
|
}
|
|
1170
1510
|
|
|
1511
|
+
//#endregion
|
|
1512
|
+
//#region src/core/generator/top-bar.ts
|
|
1513
|
+
function generateTopBarComponent(ctx) {
|
|
1514
|
+
const { config } = ctx;
|
|
1515
|
+
const header = config.header;
|
|
1516
|
+
const height = header.height ?? "64px";
|
|
1517
|
+
const sticky = header.sticky ?? true;
|
|
1518
|
+
const bordered = header.bordered ?? true;
|
|
1519
|
+
const background = header.background ?? "";
|
|
1520
|
+
const logoProps = resolveNavLogoProps(header.logo ?? config.name, config.name);
|
|
1521
|
+
const linksJson = JSON.stringify(header.links ?? []);
|
|
1522
|
+
const backgroundProp = background ? `\n background='${background}',` : "";
|
|
1523
|
+
const isTopBarSearch = config.search?.position === "header";
|
|
1524
|
+
return `'use client';
|
|
1525
|
+
|
|
1526
|
+
import { TopBar } from 'openmanual/components/top-bar';
|
|
1527
|
+
import { NavLogo } from 'openmanual/components/nav-layout';
|
|
1528
|
+
import { NavLinks } from 'openmanual/components/nav-links';${isTopBarSearch ? "\nimport { TopBarSearchTrigger } from 'openmanual/components/top-bar-search-trigger';" : ""}
|
|
1529
|
+
|
|
1530
|
+
const navLinks = ${linksJson};
|
|
1531
|
+
${isTopBarSearch ? "const searchCenter = <TopBarSearchTrigger />;" : ""}
|
|
1532
|
+
|
|
1533
|
+
export function OmTopBar() {
|
|
1534
|
+
return (
|
|
1535
|
+
<TopBar
|
|
1536
|
+
height='${height}'
|
|
1537
|
+
sticky={${sticky}}${backgroundProp}
|
|
1538
|
+
bordered={${bordered}}
|
|
1539
|
+
left={<NavLogo ${logoProps} />}${isTopBarSearch ? "\n center={searchCenter}" : ""}
|
|
1540
|
+
right={<NavLinks links={navLinks} />}
|
|
1541
|
+
/>
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
`;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1171
1547
|
//#endregion
|
|
1172
1548
|
//#region src/core/generator/tsconfig.ts
|
|
1173
1549
|
function generateTsconfig() {
|
|
@@ -1208,6 +1584,44 @@ function generateTsconfig() {
|
|
|
1208
1584
|
//#region src/core/generator/index.ts
|
|
1209
1585
|
async function generateAll(ctx) {
|
|
1210
1586
|
const isI18n = isI18nEnabled(ctx.config);
|
|
1587
|
+
if (isOpenApiEnabled(ctx.config)) {
|
|
1588
|
+
const { access } = await import("node:fs/promises");
|
|
1589
|
+
const { extname, join } = await import("node:path");
|
|
1590
|
+
const supportedExts = [
|
|
1591
|
+
".json",
|
|
1592
|
+
".yaml",
|
|
1593
|
+
".yml"
|
|
1594
|
+
];
|
|
1595
|
+
const specPaths = resolveOpenApiSpecPaths(ctx.config);
|
|
1596
|
+
for (const specPath of specPaths) {
|
|
1597
|
+
const absolutePath = join(ctx.projectDir, specPath);
|
|
1598
|
+
const ext = extname(absolutePath).toLowerCase();
|
|
1599
|
+
if (!supportedExts.includes(ext)) throw new Error(`[openapi] 不支持的 OpenAPI 规范文件格式: "${ext}"(文件: ${specPath})。支持的格式: ${supportedExts.join(", ")}`);
|
|
1600
|
+
try {
|
|
1601
|
+
await access(absolutePath);
|
|
1602
|
+
} catch {
|
|
1603
|
+
throw new Error(`[openapi] OpenAPI 规范文件不存在: "${specPath}"。请确认 "openapi.specs" 或 "openapi.specPath" 在 openmanual.json 中配置的路径正确。`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
await computeAllSlugs(ctx);
|
|
1608
|
+
const isOApi = isOpenApiEnabled(ctx.config);
|
|
1609
|
+
const openapiFiles = [];
|
|
1610
|
+
if (isOApi) {
|
|
1611
|
+
const openapiLib = generateOpenApiLib(ctx);
|
|
1612
|
+
if (openapiLib) openapiFiles.push({
|
|
1613
|
+
path: "lib/openapi.ts",
|
|
1614
|
+
content: openapiLib
|
|
1615
|
+
});
|
|
1616
|
+
openapiFiles.push({
|
|
1617
|
+
path: "components/api-page.client.tsx",
|
|
1618
|
+
content: generateApiClientComponent()
|
|
1619
|
+
});
|
|
1620
|
+
openapiFiles.push({
|
|
1621
|
+
path: "components/api-page.tsx",
|
|
1622
|
+
content: generateApiPageComponent()
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1211
1625
|
const baseFiles = [
|
|
1212
1626
|
{
|
|
1213
1627
|
path: "source.config.ts",
|
|
@@ -1254,9 +1668,11 @@ async function generateAll(ctx) {
|
|
|
1254
1668
|
content: generatePageActionsComponent()
|
|
1255
1669
|
}
|
|
1256
1670
|
];
|
|
1671
|
+
const headerEnabled = isHeaderEnabled(ctx.config);
|
|
1257
1672
|
let files;
|
|
1258
1673
|
if (isI18n) files = [
|
|
1259
1674
|
...baseFiles,
|
|
1675
|
+
...openapiFiles,
|
|
1260
1676
|
{
|
|
1261
1677
|
path: "lib/i18n.ts",
|
|
1262
1678
|
content: generateI18nConfig(ctx)
|
|
@@ -1291,6 +1707,10 @@ async function generateAll(ctx) {
|
|
|
1291
1707
|
path: "app/[lang]/components/search-dialog.tsx",
|
|
1292
1708
|
content: generateSearchDialog(ctx)
|
|
1293
1709
|
},
|
|
1710
|
+
...headerEnabled ? [{
|
|
1711
|
+
path: "app/[lang]/components/top-bar.tsx",
|
|
1712
|
+
content: generateTopBarComponent(ctx)
|
|
1713
|
+
}] : [],
|
|
1294
1714
|
{
|
|
1295
1715
|
path: "app/[lang]/[[...slug]]/layout.tsx",
|
|
1296
1716
|
content: generateDocsLayout(ctx)
|
|
@@ -1302,6 +1722,7 @@ async function generateAll(ctx) {
|
|
|
1302
1722
|
];
|
|
1303
1723
|
else files = [
|
|
1304
1724
|
...baseFiles,
|
|
1725
|
+
...openapiFiles,
|
|
1305
1726
|
...ctx.dev ? [{
|
|
1306
1727
|
path: "app/api/raw/[...path]/route.ts",
|
|
1307
1728
|
content: generateRawContentRoute(ctx)
|
|
@@ -1324,6 +1745,10 @@ async function generateAll(ctx) {
|
|
|
1324
1745
|
path: "app/components/search-dialog.tsx",
|
|
1325
1746
|
content: generateSearchDialog(ctx)
|
|
1326
1747
|
},
|
|
1748
|
+
...headerEnabled ? [{
|
|
1749
|
+
path: "app/components/top-bar.tsx",
|
|
1750
|
+
content: generateTopBarComponent(ctx)
|
|
1751
|
+
}] : [],
|
|
1327
1752
|
{
|
|
1328
1753
|
path: "app/[[...slug]]/layout.tsx",
|
|
1329
1754
|
content: generateDocsLayout(ctx)
|
|
@@ -1350,10 +1775,8 @@ async function generateAll(ctx) {
|
|
|
1350
1775
|
function generateRootLayout(ctx) {
|
|
1351
1776
|
const { config } = ctx;
|
|
1352
1777
|
const favicon = config.favicon;
|
|
1353
|
-
|
|
1354
|
-
import {
|
|
1355
|
-
import type { ReactNode } from 'react';
|
|
1356
|
-
${favicon ? `import type { Metadata } from 'next';
|
|
1778
|
+
const headerEnabled = isHeaderEnabled(config);
|
|
1779
|
+
return `${favicon ? `import type { Metadata } from 'next';
|
|
1357
1780
|
|
|
1358
1781
|
export const metadata: Metadata = {
|
|
1359
1782
|
icons: {
|
|
@@ -1361,12 +1784,15 @@ export const metadata: Metadata = {
|
|
|
1361
1784
|
},
|
|
1362
1785
|
};
|
|
1363
1786
|
|
|
1364
|
-
` : ""}import '
|
|
1787
|
+
` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1788
|
+
import { AppProvider } from './provider';
|
|
1789
|
+
import type { ReactNode } from 'react';
|
|
1790
|
+
import '../global.css';
|
|
1365
1791
|
|
|
1366
1792
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
1367
1793
|
return (
|
|
1368
1794
|
<AppLayout>
|
|
1369
|
-
<AppProvider
|
|
1795
|
+
<AppProvider>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
|
|
1370
1796
|
</AppLayout>
|
|
1371
1797
|
);
|
|
1372
1798
|
}
|
|
@@ -1383,6 +1809,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|
|
1383
1809
|
function generateRootLayoutI18n(ctx) {
|
|
1384
1810
|
const { config } = ctx;
|
|
1385
1811
|
const favicon = config.favicon;
|
|
1812
|
+
const headerEnabled = isHeaderEnabled(config);
|
|
1386
1813
|
return `${favicon ? `import type { Metadata } from 'next';
|
|
1387
1814
|
|
|
1388
1815
|
export const metadata: Metadata = {
|
|
@@ -1391,7 +1818,7 @@ export const metadata: Metadata = {
|
|
|
1391
1818
|
},
|
|
1392
1819
|
};
|
|
1393
1820
|
|
|
1394
|
-
` : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1821
|
+
` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1395
1822
|
import { AppProvider } from './provider';
|
|
1396
1823
|
import type { ReactNode } from 'react';
|
|
1397
1824
|
import '../../global.css';
|
|
@@ -1407,7 +1834,7 @@ export default async function RootLayout({
|
|
|
1407
1834
|
|
|
1408
1835
|
return (
|
|
1409
1836
|
<AppLayout lang={lang}>
|
|
1410
|
-
<AppProvider lang={lang}
|
|
1837
|
+
<AppProvider lang={lang}>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
|
|
1411
1838
|
</AppLayout>
|
|
1412
1839
|
);
|
|
1413
1840
|
}
|
|
@@ -1419,6 +1846,9 @@ function generateDocsLayout(ctx) {
|
|
|
1419
1846
|
const navLinks = config.navbar?.links ?? [];
|
|
1420
1847
|
const footerText = config.footer?.text ?? "";
|
|
1421
1848
|
const isI18n = isI18nEnabled(config);
|
|
1849
|
+
const isOApi = isOpenApiEnabled(config);
|
|
1850
|
+
const rootGroups = ctx.rootGroups;
|
|
1851
|
+
const isHeaderSearch = config.search?.position === "header";
|
|
1422
1852
|
const linksArray = navLinks.map((l) => ({
|
|
1423
1853
|
text: l.label,
|
|
1424
1854
|
url: l.href,
|
|
@@ -1430,6 +1860,13 @@ function generateDocsLayout(ctx) {
|
|
|
1430
1860
|
const configDesc = config.description ?? "";
|
|
1431
1861
|
const descLine = configDesc ? isI18n ? "" : `description: '${configDesc.replace(/'/g, "\\'")}',` : "";
|
|
1432
1862
|
const treeLine = isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
|
|
1863
|
+
const separateTab = isOpenApiEnabled(config) && isSeparateTabMode(config);
|
|
1864
|
+
const openapiTab = separateTab ? {
|
|
1865
|
+
title: config.openapi?.label ?? "接口文档",
|
|
1866
|
+
url: isI18n ? "/${lang}/openapi" : "/openapi",
|
|
1867
|
+
urls: /* @__PURE__ */ new Set()
|
|
1868
|
+
} : null;
|
|
1869
|
+
const sidebarTabsLine = rootGroups && rootGroups.length > 0 || openapiTab ? isI18n ? generateI18nSidebarTabs(config, rootGroups, openapiTab) : generateSingleSidebarTabs(config, rootGroups, openapiTab) : "";
|
|
1433
1870
|
if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
1434
1871
|
import { baseOptions } from '@/lib/layout';
|
|
1435
1872
|
import { source } from '@/lib/source';
|
|
@@ -1441,13 +1878,16 @@ export default async function DocsLayoutWrapper({
|
|
|
1441
1878
|
params: Promise<{ lang: string }>;
|
|
1442
1879
|
children: ReactNode;
|
|
1443
1880
|
}) {
|
|
1444
|
-
const { lang } = await params
|
|
1881
|
+
const { lang } = await params;
|
|
1882
|
+
${isOApi && separateTab ? ` const _omFirstApi = source.getPages(lang)?.find((p: any) => p.data?.type === 'openapi');
|
|
1883
|
+
const _omApiUrl = _omFirstApi?.url ?? \`/\${lang}/openapi\`;
|
|
1884
|
+
` : ""}${configDesc ? `
|
|
1445
1885
|
const indexPage = source.getPage([], lang);
|
|
1446
1886
|
const siteDescription = indexPage?.data.description ?? configDescription;` : ""}
|
|
1447
1887
|
|
|
1448
1888
|
const docsOptions = {
|
|
1449
1889
|
...baseOptions(lang),
|
|
1450
|
-
${treeLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}
|
|
1890
|
+
${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}${isHeaderSearch ? "\n searchToggle: { enabled: false }," : ""}
|
|
1451
1891
|
};
|
|
1452
1892
|
|
|
1453
1893
|
return (
|
|
@@ -1460,10 +1900,13 @@ export default async function DocsLayoutWrapper({
|
|
|
1460
1900
|
return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
1461
1901
|
import { baseOptions } from '@/lib/layout';
|
|
1462
1902
|
import { source } from '@/lib/source';
|
|
1463
|
-
import type { ReactNode } from 'react'
|
|
1903
|
+
import type { ReactNode } from 'react';${isOApi && separateTab ? `
|
|
1904
|
+
const _omFirstApi = source.getPages()?.find((p: any) => p.data?.type === 'openapi');
|
|
1905
|
+
const _omApiUrl = _omFirstApi?.url ?? '/openapi';
|
|
1906
|
+
` : ""}
|
|
1464
1907
|
const docsOptions = {
|
|
1465
1908
|
...baseOptions(),
|
|
1466
|
-
${treeLine}${githubLine}${linksLine}${footerLine}${descLine}
|
|
1909
|
+
${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${descLine}${isHeaderSearch ? "\n searchToggle: { enabled: false }," : ""}
|
|
1467
1910
|
};
|
|
1468
1911
|
|
|
1469
1912
|
export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
|
|
@@ -1475,6 +1918,37 @@ export default function DocsLayoutWrapper({ children }: { children: ReactNode })
|
|
|
1475
1918
|
}
|
|
1476
1919
|
`;
|
|
1477
1920
|
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Generate i18n sidebar.tabs string.
|
|
1923
|
+
* Extracted from generateDocsLayout to avoid deeply nested template literals
|
|
1924
|
+
* that confuse the oxc parser when escaped backticks are involved.
|
|
1925
|
+
*/
|
|
1926
|
+
function generateI18nSidebarTabs(config, rootGroups, openapiTab) {
|
|
1927
|
+
const entries = (rootGroups ?? []).map((g) => ({
|
|
1928
|
+
title: g.title,
|
|
1929
|
+
dirPath: g.dirPath,
|
|
1930
|
+
url: g.url,
|
|
1931
|
+
urls: g.urls
|
|
1932
|
+
}));
|
|
1933
|
+
const entriesJson = JSON.stringify(entries);
|
|
1934
|
+
const nameEscaped = config.name.replace(/'/g, "\\'");
|
|
1935
|
+
const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
|
|
1936
|
+
return `\n sidebar: {\n tabs: [\n ${`{ title: '${nameEscaped}', url: \`/\${lang}\` }`},\n ...${`(${entriesJson} as Array<{title:string;dirPath:string;url:string;urls:string[]}>).filter(g => g.dirPath.startsWith(\`\${lang}/\`)).map(g => ({ title: g.title, url: g.url, urls: new Set<string>(g.urls) }))`}${openapiTabLine}\n ],\n },`;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Generate single-language (non-i18n) sidebar.tabs string.
|
|
1940
|
+
* Uses template literal to preserve Set<string> for urls property.
|
|
1941
|
+
*/
|
|
1942
|
+
function generateSingleSidebarTabs(config, rootGroups, openapiTab) {
|
|
1943
|
+
const nameEscaped = config.name.replace(/'/g, "\\'");
|
|
1944
|
+
const groupEntries = (rootGroups ?? []).map((g) => {
|
|
1945
|
+
const title = g.title.replace(/'/g, "\\'");
|
|
1946
|
+
const urlsArr = JSON.stringify(g.urls);
|
|
1947
|
+
return `{ title: '${title}', url: '${g.url}', urls: new Set(${urlsArr}) }`;
|
|
1948
|
+
}).join(",\n ");
|
|
1949
|
+
const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
|
|
1950
|
+
return `\n sidebar: {\n tabs: [\n { title: '${nameEscaped}', url: '/' },${groupEntries ? "\n " + groupEntries : ""}${openapiTabLine}\n ],\n },`;
|
|
1951
|
+
}
|
|
1478
1952
|
function generateOpenManualLogoSvg(name, variant = "light") {
|
|
1479
1953
|
const textColor = variant === "dark" ? "#E8E0D4" : "#000000";
|
|
1480
1954
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190 32" width="190" height="32">
|
|
@@ -1497,57 +1971,105 @@ async function ensureLogoFile(ctx, logoPath, variant) {
|
|
|
1497
1971
|
}
|
|
1498
1972
|
}
|
|
1499
1973
|
/**
|
|
1500
|
-
* Generate
|
|
1501
|
-
* Writes title, icon, defaultOpen, and pages ordering so that Fumadocs
|
|
1502
|
-
* can render the sidebar natively without restructureTree().
|
|
1974
|
+
* Generate or enrich meta.json files for each content directory.
|
|
1503
1975
|
*
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1976
|
+
* Strategy:
|
|
1977
|
+
* 1. If meta.json files exist → enrich missing fields (icon/defaultOpen/pages)
|
|
1978
|
+
* 2. If no meta.json → auto-generate from file system structure
|
|
1507
1979
|
*/
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1980
|
+
/**
|
|
1981
|
+
* Compute all slugs from meta.json files, falling back to file system scan.
|
|
1982
|
+
* Stores result in ctx.allSlugs for use by generatePage().
|
|
1983
|
+
* Also extracts root groups (meta.json with root: true) into ctx.rootGroups.
|
|
1984
|
+
*/
|
|
1985
|
+
async function computeAllSlugs(ctx) {
|
|
1511
1986
|
const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
|
|
1512
1987
|
const isI18n = isI18nEnabled(ctx.config);
|
|
1513
1988
|
const useDirParser = isDirParser(ctx.config);
|
|
1514
1989
|
const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
|
|
1515
|
-
|
|
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
|
-
}
|
|
1990
|
+
const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
|
|
1991
|
+
if (metaGroups.length > 0) {
|
|
1992
|
+
ctx.allSlugs = collectSlugsFromMeta(metaGroups);
|
|
1993
|
+
const allFiles = await scanContentDir(contentAbsDir);
|
|
1994
|
+
for (const file of allFiles) if (useDirParser ? file.segments.length === 2 && languages.includes(file.segments[0]) : file.segments.length === 1) ctx.allSlugs.add(file.slug);
|
|
1995
|
+
const scannedDirCache = /* @__PURE__ */ new Map();
|
|
1996
|
+
for (const group of metaGroups) {
|
|
1997
|
+
const dirAbsPath = join(contentAbsDir, group.dirPath);
|
|
1998
|
+
try {
|
|
1999
|
+
const dirFiles = await scanContentDir(dirAbsPath);
|
|
2000
|
+
scannedDirCache.set(group.dirPath, dirFiles);
|
|
2001
|
+
if (!group.pages || group.pages.length === 0) for (const df of dirFiles) ctx.allSlugs.add(`${group.dirPath}/${df.slug}`);
|
|
2002
|
+
} catch {}
|
|
1535
2003
|
}
|
|
2004
|
+
ctx.rootGroups = metaGroups.filter((g) => g.root === true).map((g) => {
|
|
2005
|
+
const cached = scannedDirCache.get(g.dirPath);
|
|
2006
|
+
const firstPage = g.pages?.[0] ?? cached?.[0]?.name ?? "index";
|
|
2007
|
+
const allUrls = (cached ?? []).map((f) => `/${g.dirPath}/${f.name}`);
|
|
2008
|
+
return {
|
|
2009
|
+
title: g.title,
|
|
2010
|
+
dirPath: g.dirPath,
|
|
2011
|
+
url: `/${g.dirPath}/${firstPage}`,
|
|
2012
|
+
urls: allUrls
|
|
2013
|
+
};
|
|
2014
|
+
});
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const files = await scanContentDir(contentAbsDir);
|
|
2018
|
+
ctx.allSlugs = new Set(files.map((f) => f.slug));
|
|
2019
|
+
}
|
|
2020
|
+
async function generateMetaFiles(ctx) {
|
|
2021
|
+
const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
|
|
2022
|
+
const isI18n = isI18nEnabled(ctx.config);
|
|
2023
|
+
const useDirParser = isDirParser(ctx.config);
|
|
2024
|
+
const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
|
|
2025
|
+
const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
|
|
2026
|
+
if (metaGroups.length > 0) {
|
|
2027
|
+
for (const group of metaGroups) await enrichMetaFile(group);
|
|
2028
|
+
return;
|
|
1536
2029
|
}
|
|
1537
|
-
await
|
|
2030
|
+
await autoGenerateMetaFromFS(ctx, contentAbsDir, languages, useDirParser);
|
|
1538
2031
|
}
|
|
1539
2032
|
/**
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
2033
|
+
* Validate an existing meta.json file is readable.
|
|
2034
|
+
* Does NOT modify the file — all user-set fields (including "root") are preserved as-is.
|
|
2035
|
+
* Fumadocs reads meta.json directly via its own content source pipeline.
|
|
1542
2036
|
*/
|
|
1543
|
-
async function
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
2037
|
+
async function enrichMetaFile(_group) {
|
|
2038
|
+
try {
|
|
2039
|
+
await readFile(_group.filePath, "utf-8");
|
|
2040
|
+
} catch {}
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Auto-generate meta.json files from the file system structure.
|
|
2044
|
+
* Used when no meta.json and no sidebar config exist.
|
|
2045
|
+
*/
|
|
2046
|
+
async function autoGenerateMetaFromFS(_ctx, contentAbsDir, languages, useDirParser) {
|
|
2047
|
+
const files = await scanContentDir(contentAbsDir);
|
|
2048
|
+
const rootFiles = [];
|
|
2049
|
+
const dirGroups = /* @__PURE__ */ new Map();
|
|
2050
|
+
for (const file of files) if (file.segments.length <= 1) rootFiles.push(file);
|
|
2051
|
+
else {
|
|
2052
|
+
const dirName = file.segments[0];
|
|
2053
|
+
if (dirName === void 0) continue;
|
|
2054
|
+
if (!dirGroups.has(dirName)) dirGroups.set(dirName, []);
|
|
2055
|
+
dirGroups.get(dirName)?.push(file);
|
|
2056
|
+
}
|
|
2057
|
+
if (rootFiles.length > 0) {
|
|
2058
|
+
const rootMeta = {
|
|
2059
|
+
title: "Getting Started",
|
|
2060
|
+
pages: rootFiles.map((f) => f.name)
|
|
2061
|
+
};
|
|
2062
|
+
if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, "meta.json"), rootMeta);
|
|
2063
|
+
else await writeMetaIfNotExists(join(contentAbsDir, "meta.json"), rootMeta);
|
|
2064
|
+
}
|
|
2065
|
+
for (const [dirName, dirFiles] of dirGroups) {
|
|
2066
|
+
const dirMeta = {
|
|
2067
|
+
title: formatTitle(dirName),
|
|
2068
|
+
pages: dirFiles.map((f) => f.segments.slice(1).join("/"))
|
|
2069
|
+
};
|
|
2070
|
+
if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, dirName, "meta.json"), dirMeta);
|
|
2071
|
+
else await writeMetaIfNotExists(join(contentAbsDir, dirName, "meta.json"), dirMeta);
|
|
2072
|
+
}
|
|
1551
2073
|
}
|
|
1552
2074
|
/**
|
|
1553
2075
|
* Write meta.json only if it does not already exist (preserve user edits).
|
|
@@ -1560,62 +2082,6 @@ async function writeMetaIfNotExists(filePath, data) {
|
|
|
1560
2082
|
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
1561
2083
|
}
|
|
1562
2084
|
}
|
|
1563
|
-
/**
|
|
1564
|
-
* Inject page-level title and icon from sidebar config into each .mdx file's frontmatter.
|
|
1565
|
-
* Uses upsert semantics: only writes fields that are missing from existing frontmatter.
|
|
1566
|
-
*/
|
|
1567
|
-
async function injectPageFrontmatter(ctx) {
|
|
1568
|
-
const sidebar = ctx.config.sidebar;
|
|
1569
|
-
if (!sidebar) return;
|
|
1570
|
-
const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
|
|
1571
|
-
const useDirParser = isDirParser(ctx.config);
|
|
1572
|
-
const languages = isI18nEnabled(ctx.config) ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
|
|
1573
|
-
for (const group of sidebar) for (const page of group.pages) {
|
|
1574
|
-
const fieldsToInject = {};
|
|
1575
|
-
if (page.title) fieldsToInject.title = page.title;
|
|
1576
|
-
if (page.icon) fieldsToInject.icon = page.icon;
|
|
1577
|
-
if (Object.keys(fieldsToInject).length === 0) continue;
|
|
1578
|
-
const targets = resolveMdxPaths(contentAbsDir, page.slug, useDirParser, languages);
|
|
1579
|
-
for (const mdxPath of targets) try {
|
|
1580
|
-
const content = await readFile(mdxPath, "utf-8");
|
|
1581
|
-
const updated = upsertFrontmatter(content, fieldsToInject);
|
|
1582
|
-
if (updated !== content) await writeFile(mdxPath, updated, "utf-8");
|
|
1583
|
-
} catch {}
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
/**
|
|
1587
|
-
* Resolve .mdx file paths for a given slug across all applicable locales.
|
|
1588
|
-
*/
|
|
1589
|
-
function resolveMdxPaths(contentAbsDir, slug, useDirParser, languages) {
|
|
1590
|
-
if (useDirParser) return languages.map((lang) => join(contentAbsDir, lang, `${slug}.mdx`));
|
|
1591
|
-
return [join(contentAbsDir, `${slug}.mdx`)];
|
|
1592
|
-
}
|
|
1593
|
-
/**
|
|
1594
|
-
* Upsert fields into MDX frontmatter. Only adds missing fields; never overwrites existing ones.
|
|
1595
|
-
*
|
|
1596
|
-
* Handles:
|
|
1597
|
-
* - No frontmatter → creates one with the injected fields
|
|
1598
|
-
* - Existing frontmatter missing some fields → adds only the missing ones
|
|
1599
|
-
* - All fields already present → returns original content unchanged
|
|
1600
|
-
*/
|
|
1601
|
-
function upsertFrontmatter(content, fields) {
|
|
1602
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1603
|
-
if (!match) return `---\n${Object.entries(fields).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${content}`;
|
|
1604
|
-
const existingLines = (match[1] ?? "").split("\n");
|
|
1605
|
-
const existingKeys = /* @__PURE__ */ new Set();
|
|
1606
|
-
for (const line of existingLines) {
|
|
1607
|
-
const trimmed = line.trim();
|
|
1608
|
-
if (trimmed && !trimmed.startsWith("#")) {
|
|
1609
|
-
const keyMatch = trimmed.match(/^(\w[\w-]*)\s*:/);
|
|
1610
|
-
if (keyMatch?.[1]) existingKeys.add(keyMatch[1]);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
const newFields = Object.entries(fields).filter(([key]) => !existingKeys.has(key));
|
|
1614
|
-
if (newFields.length === 0) return content;
|
|
1615
|
-
const newFieldLines = newFields.map(([key, value]) => `${key}: ${value}`).join("\n");
|
|
1616
|
-
const insertionPoint = (match.index ?? 0) + match[0].length - 3;
|
|
1617
|
-
return content.slice(0, insertionPoint) + "\n" + newFieldLines + "\n" + content.slice(insertionPoint);
|
|
1618
|
-
}
|
|
1619
2085
|
|
|
1620
2086
|
//#endregion
|
|
1621
2087
|
//#region src/utils/install-deps.ts
|
|
@@ -2070,7 +2536,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
|
|
|
2070
2536
|
//#endregion
|
|
2071
2537
|
//#region src/cli/bin.ts
|
|
2072
2538
|
function getVersion() {
|
|
2073
|
-
return "0.
|
|
2539
|
+
return "0.14.1";
|
|
2074
2540
|
}
|
|
2075
2541
|
const program = new Command();
|
|
2076
2542
|
const commandName = basename(process.argv[1] ?? "openmanual");
|