openmanual 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/bin.js +673 -218
- 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 -16
- 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,30 +97,54 @@ const OpenManualConfigSchema = z.object({
|
|
|
61
97
|
contentPolicy: z.enum(["strict", "all"]).optional(),
|
|
62
98
|
favicon: FaviconSchema.optional(),
|
|
63
99
|
navbar: NavbarSchema.optional(),
|
|
100
|
+
header: TopBarSchema.optional(),
|
|
64
101
|
footer: FooterSchema.optional(),
|
|
65
102
|
sidebar: z.array(SidebarGroupSchema).optional(),
|
|
66
103
|
theme: ThemeSchema.optional(),
|
|
67
104
|
search: SearchSchema.optional(),
|
|
68
105
|
mdx: MdxSchema.optional(),
|
|
69
106
|
pageActions: PageActionsSchema.optional(),
|
|
70
|
-
i18n: I18nConfigSchema.optional()
|
|
107
|
+
i18n: I18nConfigSchema.optional(),
|
|
108
|
+
openapi: OpenApiSchema.optional()
|
|
71
109
|
});
|
|
72
|
-
function collectConfiguredSlugs(config) {
|
|
73
|
-
const slugs = /* @__PURE__ */ new Set();
|
|
74
|
-
if (config.sidebar) for (const group of config.sidebar) for (const page of group.pages) slugs.add(page.slug);
|
|
75
|
-
return slugs;
|
|
76
|
-
}
|
|
77
|
-
function buildTitleMap(config) {
|
|
78
|
-
const map = {};
|
|
79
|
-
if (config.sidebar) for (const group of config.sidebar) for (const page of group.pages) map[page.slug] = page.title;
|
|
80
|
-
return map;
|
|
81
|
-
}
|
|
82
110
|
function isI18nEnabled(config) {
|
|
83
111
|
return config.i18n?.enabled === true && (config.i18n.languages?.length ?? 0) > 1;
|
|
84
112
|
}
|
|
85
113
|
function isDirParser(config) {
|
|
86
114
|
return config.i18n?.parser === "dir";
|
|
87
115
|
}
|
|
116
|
+
function isOpenApiEnabled(config) {
|
|
117
|
+
if (config.openapi === void 0) return false;
|
|
118
|
+
const hasSpecs = config.openapi.specs !== void 0;
|
|
119
|
+
const hasSpecPath = typeof config.openapi?.specPath === "string";
|
|
120
|
+
return hasSpecs || hasSpecPath;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 从 OpenAPI 配置中解析出所有 spec 文件路径
|
|
124
|
+
* 兼容 specs(新)和 specPath(旧)两种格式
|
|
125
|
+
*/
|
|
126
|
+
function resolveOpenApiSpecPaths(config) {
|
|
127
|
+
const openApiCfg = config.openapi;
|
|
128
|
+
if (!openApiCfg) return [];
|
|
129
|
+
if (openApiCfg.specs !== void 0) {
|
|
130
|
+
if (typeof openApiCfg.specs === "string") return [openApiCfg.specs];
|
|
131
|
+
return openApiCfg.specs.map((s) => s.path);
|
|
132
|
+
}
|
|
133
|
+
if (typeof openApiCfg.specPath === "string") return [openApiCfg.specPath];
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 判断是否使用独立 Tab 模式(旧行为)
|
|
138
|
+
*/
|
|
139
|
+
function isSeparateTabMode(config) {
|
|
140
|
+
return config.openapi?.separateTab === true;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 判断是否启用了顶部横条(配置了 header 即启用)
|
|
144
|
+
*/
|
|
145
|
+
function isHeaderEnabled(config) {
|
|
146
|
+
return config.header !== void 0;
|
|
147
|
+
}
|
|
88
148
|
|
|
89
149
|
//#endregion
|
|
90
150
|
//#region src/core/config/loader.ts
|
|
@@ -98,7 +158,6 @@ const DEFAULT_CONFIG = {
|
|
|
98
158
|
primaryHue: 213,
|
|
99
159
|
darkMode: true
|
|
100
160
|
},
|
|
101
|
-
search: { enabled: true },
|
|
102
161
|
mdx: {},
|
|
103
162
|
pageActions: { enabled: true }
|
|
104
163
|
};
|
|
@@ -144,10 +203,7 @@ function mergeDefaults(config) {
|
|
|
144
203
|
...DEFAULT_CONFIG.theme,
|
|
145
204
|
...config.theme
|
|
146
205
|
},
|
|
147
|
-
search: {
|
|
148
|
-
...DEFAULT_CONFIG.search,
|
|
149
|
-
...config.search
|
|
150
|
-
},
|
|
206
|
+
search: config.search ? { position: config.search.position ?? "sidebar" } : void 0,
|
|
151
207
|
mdx: {
|
|
152
208
|
...DEFAULT_CONFIG.mdx,
|
|
153
209
|
...config.mdx
|
|
@@ -161,10 +217,125 @@ function mergeDefaults(config) {
|
|
|
161
217
|
defaultLanguage: config.i18n.defaultLanguage ?? config.locale ?? "zh",
|
|
162
218
|
languages: config.i18n.languages ?? [],
|
|
163
219
|
parser: config.i18n.parser ?? "dot"
|
|
220
|
+
} : void 0,
|
|
221
|
+
openapi: config.openapi ? {
|
|
222
|
+
specPath: config.openapi.specPath,
|
|
223
|
+
specs: config.openapi.specs,
|
|
224
|
+
label: config.openapi.label ?? "接口文档",
|
|
225
|
+
groupBy: config.openapi.groupBy ?? "tag",
|
|
226
|
+
separateTab: config.openapi.separateTab ?? false
|
|
164
227
|
} : void 0
|
|
165
228
|
};
|
|
166
229
|
}
|
|
167
230
|
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/core/content/meta-scanner.ts
|
|
233
|
+
async function scanMetaFiles(contentAbsDir, languages, useDirParser) {
|
|
234
|
+
let patterns;
|
|
235
|
+
if (useDirParser) patterns = languages.map((lang) => `${lang}/**/meta.json`);
|
|
236
|
+
else {
|
|
237
|
+
patterns = ["**/meta.json"];
|
|
238
|
+
if (languages.length > 1) for (const lang of languages) patterns.push(`**/meta.${lang}.json`);
|
|
239
|
+
}
|
|
240
|
+
const entries = await fg(patterns, {
|
|
241
|
+
cwd: contentAbsDir,
|
|
242
|
+
absolute: true,
|
|
243
|
+
ignore: ["node_modules"]
|
|
244
|
+
});
|
|
245
|
+
const groups = [];
|
|
246
|
+
for (const filePath of entries) {
|
|
247
|
+
const group = await parseMetaFile(filePath, contentAbsDir, languages, useDirParser);
|
|
248
|
+
if (group) groups.push(group);
|
|
249
|
+
}
|
|
250
|
+
return groups;
|
|
251
|
+
}
|
|
252
|
+
async function parseMetaFile(filePath, contentAbsDir, languages, useDirParser) {
|
|
253
|
+
try {
|
|
254
|
+
const raw = await readFile(filePath, "utf-8");
|
|
255
|
+
const data = JSON.parse(raw);
|
|
256
|
+
if (typeof data.title !== "string" || data.title.length === 0) return null;
|
|
257
|
+
const relPath = filePath.replace(contentAbsDir, "").replace(/^\/+/, "");
|
|
258
|
+
const dirPath = relPath.replace(/\/?meta(\.[^/]+)?\.json$/, "");
|
|
259
|
+
let isRoot = false;
|
|
260
|
+
if (useDirParser) isRoot = languages.includes(dirPath);
|
|
261
|
+
else {
|
|
262
|
+
if (/meta\.\w{2}(-\w{2})?\.json$/.test(relPath) && languages.length > 1) return null;
|
|
263
|
+
isRoot = dirPath === "";
|
|
264
|
+
}
|
|
265
|
+
const group = {
|
|
266
|
+
filePath,
|
|
267
|
+
dirPath,
|
|
268
|
+
isRoot,
|
|
269
|
+
title: data.title
|
|
270
|
+
};
|
|
271
|
+
if (typeof data.icon === "string") group.icon = data.icon;
|
|
272
|
+
if (typeof data.root === "boolean") group.root = data.root;
|
|
273
|
+
if (typeof data.defaultOpen === "boolean") group.defaultOpen = data.defaultOpen;
|
|
274
|
+
if (Array.isArray(data.pages)) group.pages = data.pages.filter((p) => typeof p === "string");
|
|
275
|
+
return group;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Collect all slugs from meta group infos.
|
|
282
|
+
*
|
|
283
|
+
* For root-level groups, page filenames are used directly as slugs.
|
|
284
|
+
* For directory-level groups, page filenames are prefixed with the directory path.
|
|
285
|
+
*/
|
|
286
|
+
function collectSlugsFromMeta(groups) {
|
|
287
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
288
|
+
for (const group of groups) {
|
|
289
|
+
if (!group.pages) continue;
|
|
290
|
+
for (const page of group.pages) if (group.isRoot) slugs.add(page);
|
|
291
|
+
else {
|
|
292
|
+
const slug = `${group.dirPath}/${page}`;
|
|
293
|
+
slugs.add(slug);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return slugs;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/core/content/scanner.ts
|
|
301
|
+
async function scanContentDir(contentDir) {
|
|
302
|
+
const entries = await fg("**/*.{md,mdx}", {
|
|
303
|
+
cwd: contentDir,
|
|
304
|
+
absolute: true,
|
|
305
|
+
ignore: ["node_modules"]
|
|
306
|
+
});
|
|
307
|
+
const files = [];
|
|
308
|
+
for (const filePath of entries) {
|
|
309
|
+
const file = await parseContentFile(filePath, contentDir);
|
|
310
|
+
if (file) files.push(file);
|
|
311
|
+
}
|
|
312
|
+
return files.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
313
|
+
}
|
|
314
|
+
async function parseContentFile(filePath, contentDir) {
|
|
315
|
+
try {
|
|
316
|
+
const { data: frontmatter, content } = matter(await readFile(filePath, "utf-8"));
|
|
317
|
+
const segments = relative(contentDir, filePath).replace(/\.(md|mdx)$/, "").split(sep);
|
|
318
|
+
const name = segments.at(-1) ?? "";
|
|
319
|
+
return {
|
|
320
|
+
filePath,
|
|
321
|
+
slug: segments.join("/"),
|
|
322
|
+
name,
|
|
323
|
+
frontmatter,
|
|
324
|
+
content,
|
|
325
|
+
segments
|
|
326
|
+
};
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/core/content/tree.ts
|
|
334
|
+
/** Format a directory name into a display title (e.g. "my-page" → "My Page") */
|
|
335
|
+
function formatTitle(name) {
|
|
336
|
+
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
337
|
+
}
|
|
338
|
+
|
|
168
339
|
//#endregion
|
|
169
340
|
//#region src/core/generator/callout-component.ts
|
|
170
341
|
function generateCalloutComponent() {
|
|
@@ -178,45 +349,9 @@ export { Callout, CalloutTitle, CalloutDescription } from 'openmanual/components
|
|
|
178
349
|
function generateGlobalCss(ctx) {
|
|
179
350
|
const { config } = ctx;
|
|
180
351
|
const primaryHue = config.theme?.primaryHue ?? 213;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
@custom-variant dark (&:is(.dark, .dark *));
|
|
185
|
-
|
|
186
|
-
:root {
|
|
187
|
-
--primary-hue: ${primaryHue};
|
|
188
|
-
|
|
189
|
-
/* 护眼暖色阅读背景 */
|
|
190
|
-
--color-fd-background: hsl(40, 22%, 96.5%); /* #faf9f6 纸张白 */
|
|
191
|
-
--color-fd-foreground: hsl(0, 0%, 17.3%); /* #2c2c2c 柔黑 */
|
|
192
|
-
--color-fd-muted: hsl(40, 15%, 95%); /* 柔和的暖灰背景 */
|
|
193
|
-
--color-fd-card: hsl(40, 18%, 94%); /* 卡片背景 */
|
|
194
|
-
--color-fd-popover: hsl(40, 20%, 97.5%); /* 弹窗背景 */
|
|
195
|
-
|
|
196
|
-
/* Callout 类型色 */
|
|
197
|
-
--callout-info-bg: hsl(210, 35%, 94%);
|
|
198
|
-
--callout-info-border: hsl(212, 40%, 80%);
|
|
199
|
-
--callout-info-text: hsl(213, 45%, 35%);
|
|
200
|
-
--callout-warning-bg: hsl(38, 60%, 93%);
|
|
201
|
-
--callout-warning-border: hsl(36, 55%, 78%);
|
|
202
|
-
--callout-warning-text: hsl(28, 55%, 35%);
|
|
203
|
-
--callout-danger-bg: hsl(0, 50%, 94%);
|
|
204
|
-
--callout-danger-border: hsl(0, 45%, 82%);
|
|
205
|
-
--callout-danger-text: hsl(0, 50%, 38%);
|
|
206
|
-
--callout-check-bg: hsl(150, 35%, 93%);
|
|
207
|
-
--callout-check-border: hsl(152, 35%, 78%);
|
|
208
|
-
--callout-check-text: hsl(155, 40%, 32%);
|
|
209
|
-
--callout-tip-bg: hsl(150, 35%, 93%);
|
|
210
|
-
--callout-tip-border: hsl(152, 35%, 78%);
|
|
211
|
-
--callout-tip-text: hsl(155, 40%, 32%);
|
|
212
|
-
--callout-note-bg: hsl(215, 20%, 94%);
|
|
213
|
-
--callout-note-border: hsl(215, 22%, 82%);
|
|
214
|
-
--callout-note-text: hsl(215, 25%, 40%);
|
|
215
|
-
--callout-key-bg: hsl(30, 55%, 93%);
|
|
216
|
-
--callout-key-border: hsl(28, 50%, 78%);
|
|
217
|
-
--callout-key-text: hsl(25, 50%, 35%);
|
|
218
|
-
}
|
|
219
|
-
${config.theme?.darkMode ?? true ? `
|
|
352
|
+
const darkMode = config.theme?.darkMode ?? true;
|
|
353
|
+
const isOApi = isOpenApiEnabled(config);
|
|
354
|
+
const darkBlock = darkMode ? `
|
|
220
355
|
.dark {
|
|
221
356
|
--primary-hue: ${primaryHue};
|
|
222
357
|
|
|
@@ -238,6 +373,7 @@ ${config.theme?.darkMode ?? true ? `
|
|
|
238
373
|
--color-fd-accent-foreground: hsl(35, 12%, 88%);
|
|
239
374
|
--color-fd-ring: hsl(30, 30%, 50%);
|
|
240
375
|
--color-fd-overlay: hsla(25, 20%, 5%, 0.5);
|
|
376
|
+
--color-fd-inputborder: hsla(30, 20%, 50%, 40%); /* 输入框边框色(hover 用)*/
|
|
241
377
|
|
|
242
378
|
/* Callout 类型色 */
|
|
243
379
|
--callout-info-bg: hsl(213, 25%, 16%);
|
|
@@ -266,7 +402,59 @@ ${config.theme?.darkMode ?? true ? `
|
|
|
266
402
|
.dark body {
|
|
267
403
|
background: linear-gradient(hsla(30, 30%, 15%, 0.4), transparent 20rem, transparent);
|
|
268
404
|
}
|
|
269
|
-
` : ""
|
|
405
|
+
` : "";
|
|
406
|
+
return `@import 'tailwindcss';
|
|
407
|
+
@source './node_modules/openmanual/dist/components/**/*.js';
|
|
408
|
+
@import 'fumadocs-ui/css/neutral.css';
|
|
409
|
+
@import 'fumadocs-ui/css/preset.css';${isOApi ? "\n@import 'fumadocs-openapi/css/preset.css';" : ""}
|
|
410
|
+
|
|
411
|
+
@layer base {
|
|
412
|
+
body {
|
|
413
|
+
@apply flex flex-col min-h-screen;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* 注册自定义颜色到 Tailwind v4 @theme,确保所有变体(hover/dark 等)可正确生成 */
|
|
418
|
+
@theme {
|
|
419
|
+
--color-fd-inputborder: hsla(30, 12%, 80%, 50%);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
:root {
|
|
423
|
+
--primary-hue: ${primaryHue};
|
|
424
|
+
|
|
425
|
+
/* 护眼暖色阅读背景 */
|
|
426
|
+
--color-fd-background: hsl(40, 22%, 96.5%); /* #faf9f6 纸张白 */
|
|
427
|
+
--color-fd-foreground: hsl(0, 0%, 17.3%); /* #2c2c2c 柔黑 */
|
|
428
|
+
--color-fd-muted: hsl(40, 15%, 95%); /* 柔和的暖灰背景 */
|
|
429
|
+
--color-fd-card: hsl(40, 18%, 94%); /* 卡片背景 */
|
|
430
|
+
--color-fd-popover: hsl(40, 20%, 97.5%); /* 弹窗背景 */
|
|
431
|
+
--color-fd-muted-foreground: hsl(30, 10%, 55%); /* 次要文字色 */
|
|
432
|
+
--color-fd-inputborder: hsla(30, 12%, 80%, 50%); /* 输入框边框色(hover 用)*/
|
|
433
|
+
|
|
434
|
+
/* Callout 类型色 */
|
|
435
|
+
--callout-info-bg: hsl(210, 35%, 94%);
|
|
436
|
+
--callout-info-border: hsl(212, 40%, 80%);
|
|
437
|
+
--callout-info-text: hsl(213, 45%, 35%);
|
|
438
|
+
--callout-warning-bg: hsl(38, 60%, 93%);
|
|
439
|
+
--callout-warning-border: hsl(36, 55%, 78%);
|
|
440
|
+
--callout-warning-text: hsl(28, 55%, 35%);
|
|
441
|
+
--callout-danger-bg: hsl(0, 50%, 94%);
|
|
442
|
+
--callout-danger-border: hsl(0, 45%, 82%);
|
|
443
|
+
--callout-danger-text: hsl(0, 50%, 38%);
|
|
444
|
+
--callout-check-bg: hsl(150, 35%, 93%);
|
|
445
|
+
--callout-check-border: hsl(152, 35%, 78%);
|
|
446
|
+
--callout-check-text: hsl(155, 40%, 32%);
|
|
447
|
+
--callout-tip-bg: hsl(150, 35%, 93%);
|
|
448
|
+
--callout-tip-border: hsl(152, 35%, 78%);
|
|
449
|
+
--callout-tip-text: hsl(155, 40%, 32%);
|
|
450
|
+
--callout-note-bg: hsl(215, 20%, 94%);
|
|
451
|
+
--callout-note-border: hsl(215, 22%, 82%);
|
|
452
|
+
--callout-note-text: hsl(215, 25%, 40%);
|
|
453
|
+
--callout-key-bg: hsl(30, 55%, 93%);
|
|
454
|
+
--callout-key-border: hsl(28, 50%, 78%);
|
|
455
|
+
--callout-key-text: hsl(25, 50%, 35%);
|
|
456
|
+
}
|
|
457
|
+
${darkBlock}
|
|
270
458
|
|
|
271
459
|
/* 代码块:去除 shadow,使用朴素边框;去除 max-height 限制 */
|
|
272
460
|
figure.shiki {
|
|
@@ -359,17 +547,25 @@ function resolveLogoPaths(logo) {
|
|
|
359
547
|
dark: logo.dark
|
|
360
548
|
};
|
|
361
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* 将 LogoConfig 解析为 NavLogo 组件的 props 字符串
|
|
552
|
+
*
|
|
553
|
+
* 消除 top-bar.ts 和 layout.ts 中重复的三分支判断。
|
|
554
|
+
*/
|
|
555
|
+
function resolveNavLogoProps(logo, alt) {
|
|
556
|
+
if (typeof logo === "string" && isImagePath(logo)) return `type="image" src="${logo}" alt="${alt}"`;
|
|
557
|
+
if (typeof logo === "object") {
|
|
558
|
+
const { light, dark } = resolveLogoPaths(logo);
|
|
559
|
+
if (light === dark) return `type="image" src="${light}" alt="${alt}"`;
|
|
560
|
+
return `type="image" srcLight="${light}" srcDark="${dark}" alt="${alt}"`;
|
|
561
|
+
}
|
|
562
|
+
return `type="text" text="${logo}"`;
|
|
563
|
+
}
|
|
362
564
|
function generateLayout(ctx) {
|
|
363
565
|
const { config } = ctx;
|
|
364
566
|
const logo = config.navbar?.logo ?? config.name;
|
|
365
567
|
const isI18n = config.i18n?.enabled === true;
|
|
366
|
-
|
|
367
|
-
if (typeof logo === "string" && isImagePath(logo)) logoProps = `type="image" src="${logo}" alt="${config.name}"`;
|
|
368
|
-
else if (typeof logo === "object") {
|
|
369
|
-
const { light, dark } = logo;
|
|
370
|
-
if (light === dark) logoProps = `type="image" src="${light}" alt="${config.name}"`;
|
|
371
|
-
else logoProps = `type="image" srcLight="${light}" srcDark="${dark}" alt="${config.name}"`;
|
|
372
|
-
} else logoProps = `type="text" text="${logo}"`;
|
|
568
|
+
const logoProps = resolveNavLogoProps(logo, config.name);
|
|
373
569
|
if (isI18n) return `import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
|
374
570
|
import type { ReactNode } from 'react';
|
|
375
571
|
import { NavLogo } from 'openmanual/components/nav-layout';
|
|
@@ -399,7 +595,58 @@ export function baseOptions(): BaseLayoutProps {
|
|
|
399
595
|
//#endregion
|
|
400
596
|
//#region src/core/generator/lib-source.ts
|
|
401
597
|
function generateLibSource(ctx) {
|
|
402
|
-
|
|
598
|
+
const isI18n = isI18nEnabled(ctx.config);
|
|
599
|
+
if (isOpenApiEnabled(ctx.config)) {
|
|
600
|
+
const separateTab = isSeparateTabMode(ctx.config);
|
|
601
|
+
const groupBy = ctx.config.openapi?.groupBy ?? "tag";
|
|
602
|
+
if (isI18n) return `import { docs } from '@/.source/server';
|
|
603
|
+
import { loader, multiple } from 'fumadocs-core/source';
|
|
604
|
+
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
|
605
|
+
import { openapi } from '@/lib/openapi';
|
|
606
|
+
import { i18n } from '@/lib/i18n';
|
|
607
|
+
|
|
608
|
+
const _omOpenApiFiles = [];
|
|
609
|
+
for (const lang of i18n.languages) {
|
|
610
|
+
const result = await openapiSource(openapi, {
|
|
611
|
+
baseDir: \`\${lang}/${separateTab ? "openapi" : "api"}\`,
|
|
612
|
+
${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
|
|
613
|
+
});
|
|
614
|
+
_omOpenApiFiles.push(...result.files);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export const source = loader(
|
|
618
|
+
multiple({
|
|
619
|
+
docs: docs.toFumadocsSource(),
|
|
620
|
+
openapi: { files: _omOpenApiFiles },
|
|
621
|
+
}),
|
|
622
|
+
{
|
|
623
|
+
baseUrl: '/',
|
|
624
|
+
i18n,
|
|
625
|
+
plugins: [openapiPlugin()],
|
|
626
|
+
},
|
|
627
|
+
);
|
|
628
|
+
`;
|
|
629
|
+
return `import { docs } from '@/.source/server';
|
|
630
|
+
import { loader, multiple } from 'fumadocs-core/source';
|
|
631
|
+
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
|
632
|
+
import { openapi } from '@/lib/openapi';
|
|
633
|
+
|
|
634
|
+
export const source = loader(
|
|
635
|
+
multiple({
|
|
636
|
+
docs: docs.toFumadocsSource(),
|
|
637
|
+
openapi: await openapiSource(openapi, {
|
|
638
|
+
baseDir: '${separateTab ? "openapi" : "api"}',
|
|
639
|
+
${!separateTab ? ` meta: true,\n groupBy: '${groupBy}',` : ""}
|
|
640
|
+
}),
|
|
641
|
+
}),
|
|
642
|
+
{
|
|
643
|
+
baseUrl: '/',
|
|
644
|
+
plugins: [openapiPlugin()],
|
|
645
|
+
},
|
|
646
|
+
);
|
|
647
|
+
`;
|
|
648
|
+
}
|
|
649
|
+
if (isI18n) return `import { docs } from '@/.source/server';
|
|
403
650
|
import { loader } from 'fumadocs-core/source';
|
|
404
651
|
import { i18n } from '@/lib/i18n';
|
|
405
652
|
|
|
@@ -467,34 +714,84 @@ export const config = {
|
|
|
467
714
|
function generateNextConfig(ctx) {
|
|
468
715
|
const { config } = ctx;
|
|
469
716
|
const siteUrl = config.siteUrl ?? "";
|
|
717
|
+
const isOApi = isOpenApiEnabled(config);
|
|
718
|
+
const outputLine = !ctx.dev && siteUrl ? `\n output: 'export',` : "";
|
|
719
|
+
const rewritesBlock = ctx.dev ? `\n async rewrites() {\n return [{ source: '/:path(.+)\\\\.md', destination: '/api/raw/:path' }];\n },` : "";
|
|
470
720
|
return `import { createMDX } from 'fumadocs-mdx/next';
|
|
471
721
|
|
|
472
722
|
const withMDX = createMDX();
|
|
473
723
|
|
|
474
724
|
/** @type {import('next').NextConfig} */
|
|
475
725
|
const config = {
|
|
476
|
-
reactStrictMode: true,${
|
|
477
|
-
serverExternalPackages: ['mermaid'],
|
|
726
|
+
reactStrictMode: true,${outputLine}
|
|
727
|
+
serverExternalPackages: ${isOApi ? "['mermaid', 'shiki']" : "['mermaid']"},
|
|
478
728
|
images: {
|
|
479
729
|
unoptimized: true,
|
|
480
|
-
},${
|
|
730
|
+
},${rewritesBlock}
|
|
481
731
|
};
|
|
482
732
|
|
|
483
733
|
export default withMDX(config);
|
|
484
734
|
`;
|
|
485
735
|
}
|
|
486
736
|
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/core/generator/openapi.ts
|
|
739
|
+
/**
|
|
740
|
+
* 生成 lib/openapi.ts — OpenAPI 实例定义
|
|
741
|
+
*
|
|
742
|
+
* 使用绝对路径解析 spec 文件,避免 SSG 构建时相对路径失效的问题。
|
|
743
|
+
* 支持多文件输入(specs 数组)和单文件(specPath 向后兼容)。
|
|
744
|
+
*/
|
|
745
|
+
function generateOpenApiLib(ctx) {
|
|
746
|
+
if (!isOpenApiEnabled(ctx.config)) return null;
|
|
747
|
+
const specPaths = resolveOpenApiSpecPaths(ctx.config);
|
|
748
|
+
if (specPaths.length === 0) return null;
|
|
749
|
+
return `import { createOpenAPI } from 'fumadocs-openapi/server';
|
|
750
|
+
|
|
751
|
+
export const openapi = createOpenAPI({
|
|
752
|
+
input: [${specPaths.map((p) => join(ctx.projectDir, p)).map((p) => `'${p}'`).join(", ")}],
|
|
753
|
+
});
|
|
754
|
+
`;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* 生成 components/api-page.client.tsx — APIPage 客户端配置
|
|
758
|
+
*
|
|
759
|
+
* createAPIPage 的 client 参数是 APIPageClientOptions(配置对象),
|
|
760
|
+
* 不是 React 组件。使用 defineClientConfig() 创建默认导出。
|
|
761
|
+
*/
|
|
762
|
+
function generateApiClientComponent() {
|
|
763
|
+
return `'use client';
|
|
764
|
+
|
|
765
|
+
import { defineClientConfig } from 'fumadocs-openapi/ui/client';
|
|
766
|
+
|
|
767
|
+
export default defineClientConfig();
|
|
768
|
+
`;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* 生成 components/api-page.tsx — APIPage 服务端组件包装器
|
|
772
|
+
*/
|
|
773
|
+
function generateApiPageComponent() {
|
|
774
|
+
return `import { openapi } from '@/lib/openapi';
|
|
775
|
+
import { createAPIPage } from 'fumadocs-openapi/ui';
|
|
776
|
+
import client from './api-page.client';
|
|
777
|
+
|
|
778
|
+
export const APIPage = createAPIPage(openapi, {
|
|
779
|
+
client,
|
|
780
|
+
});
|
|
781
|
+
`;
|
|
782
|
+
}
|
|
783
|
+
|
|
487
784
|
//#endregion
|
|
488
785
|
//#region src/core/generator/package-json.ts
|
|
489
786
|
function getOpenManualVersion() {
|
|
490
|
-
return "0.
|
|
787
|
+
return "0.14.0";
|
|
491
788
|
}
|
|
492
789
|
function generatePackageJson(ctx) {
|
|
493
790
|
const openmanualVersion = getOpenManualVersion();
|
|
494
791
|
let openmanualDep;
|
|
495
792
|
if (ctx.openmanualRoot && ctx.appDir) openmanualDep = `file:${relative(ctx.appDir, ctx.openmanualRoot)}`;
|
|
496
793
|
else openmanualDep = `^${openmanualVersion}`;
|
|
497
|
-
|
|
794
|
+
const pkg = {
|
|
498
795
|
name: "openmanual-app",
|
|
499
796
|
type: "module",
|
|
500
797
|
private: true,
|
|
@@ -518,13 +815,18 @@ function generatePackageJson(ctx) {
|
|
|
518
815
|
react: "^19.1.0",
|
|
519
816
|
"react-dom": "^19.1.0",
|
|
520
817
|
tailwindcss: "^4.1.15",
|
|
521
|
-
zod: "^4.0.0"
|
|
818
|
+
zod: "^4.0.0",
|
|
819
|
+
...isOpenApiEnabled(ctx.config) ? {
|
|
820
|
+
"fumadocs-openapi": "^10.7.1",
|
|
821
|
+
shiki: "^3.0.0"
|
|
822
|
+
} : {}
|
|
522
823
|
},
|
|
523
824
|
devDependencies: {
|
|
524
825
|
"@types/react": "^19.1.0",
|
|
525
826
|
"@types/react-dom": "^19.1.0"
|
|
526
827
|
}
|
|
527
|
-
}
|
|
828
|
+
};
|
|
829
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
528
830
|
}
|
|
529
831
|
|
|
530
832
|
//#endregion
|
|
@@ -532,17 +834,20 @@ function generatePackageJson(ctx) {
|
|
|
532
834
|
function generatePage(_ctx) {
|
|
533
835
|
const isStrict = _ctx.config.contentPolicy !== "all";
|
|
534
836
|
const pageActionsEnabled = _ctx.config.pageActions?.enabled !== false;
|
|
535
|
-
|
|
536
|
-
|
|
837
|
+
const isI18n = isI18nEnabled(_ctx.config);
|
|
838
|
+
const isOApi = isOpenApiEnabled(_ctx.config);
|
|
839
|
+
const allSlugs = _ctx.allSlugs ?? /* @__PURE__ */ new Set();
|
|
840
|
+
if (isI18n) return generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
|
|
841
|
+
return generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi);
|
|
537
842
|
}
|
|
538
|
-
function generatePageSingle(_ctx, isStrict, pageActionsEnabled) {
|
|
843
|
+
function generatePageSingle(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
|
|
539
844
|
const allowedSlugsSnippet = isStrict ? `
|
|
540
|
-
const allowedSlugs = new Set(${JSON.stringify([...
|
|
845
|
+
const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
|
|
541
846
|
|
|
542
847
|
function isAllowed(slug: string[] | undefined): boolean {
|
|
543
848
|
if (allowedSlugs.size === 0) return true;
|
|
544
|
-
const key = slug ? slug.join('/') : 'index';
|
|
545
|
-
return allowedSlugs.has(key);
|
|
849
|
+
const key = slug && slug.length > 0 ? slug.join('/') : 'index';
|
|
850
|
+
return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
|
|
546
851
|
}
|
|
547
852
|
` : "";
|
|
548
853
|
return `import { source } from '@/lib/source';
|
|
@@ -555,7 +860,7 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
|
|
|
555
860
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
556
861
|
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
|
557
862
|
import { Mermaid } from '@/components/mermaid';
|
|
558
|
-
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
|
|
863
|
+
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
|
|
559
864
|
${allowedSlugsSnippet}
|
|
560
865
|
export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
|
|
561
866
|
const { slug } = await params;
|
|
@@ -568,7 +873,18 @@ ${isStrict ? `
|
|
|
568
873
|
if (!page) {
|
|
569
874
|
notFound();
|
|
570
875
|
}
|
|
571
|
-
|
|
876
|
+
${isOApi ? `
|
|
877
|
+
if (page.data.type === 'openapi') {
|
|
878
|
+
return (
|
|
879
|
+
<DocsPage full>
|
|
880
|
+
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
|
|
881
|
+
<DocsBody>
|
|
882
|
+
<APIPage {...(page.data as any).getAPIPageProps()} />
|
|
883
|
+
</DocsBody>
|
|
884
|
+
</DocsPage>
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
` : ""}
|
|
572
888
|
const MDX = page.data.body;
|
|
573
889
|
|
|
574
890
|
return (
|
|
@@ -609,14 +925,15 @@ export function generateStaticParams() {
|
|
|
609
925
|
}`}
|
|
610
926
|
`;
|
|
611
927
|
}
|
|
612
|
-
function generatePageI18n(_ctx, isStrict, pageActionsEnabled) {
|
|
928
|
+
function generatePageI18n(_ctx, isStrict, pageActionsEnabled, allSlugs, isOApi) {
|
|
613
929
|
const allowedSlugsSnippet = isStrict ? `
|
|
614
|
-
const allowedSlugs = new Set(${JSON.stringify([...
|
|
930
|
+
const allowedSlugs = new Set<string>(${JSON.stringify([...allSlugs])});
|
|
615
931
|
|
|
616
|
-
function isAllowed(slug: string[] | undefined): boolean {
|
|
932
|
+
function isAllowed(slug: string[] | undefined, lang?: string): boolean {
|
|
617
933
|
if (allowedSlugs.size === 0) return true;
|
|
618
|
-
const
|
|
619
|
-
|
|
934
|
+
const rawKey = slug && slug.length > 0 ? slug.join('/') : 'index';
|
|
935
|
+
const key = lang ? \`\${lang}/\${rawKey}\` : rawKey;
|
|
936
|
+
return allowedSlugs.has(key) || (slug?.[0] === 'openapi') || (slug?.[0] === 'api');
|
|
620
937
|
}
|
|
621
938
|
` : "";
|
|
622
939
|
return `import { source } from '@/lib/source';
|
|
@@ -629,20 +946,31 @@ import { Files, File, Folder } from 'fumadocs-ui/components/files';
|
|
|
629
946
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
630
947
|
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
|
631
948
|
import { Mermaid } from '@/components/mermaid';
|
|
632
|
-
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}
|
|
949
|
+
import { Callout, CalloutTitle, CalloutDescription } from '@/components/callout';${pageActionsEnabled ? "\nimport { PageActions } from '@/components/page-actions';" : ""}${isOApi ? "\nimport { APIPage } from '@/components/api-page';" : ""}
|
|
633
950
|
${allowedSlugsSnippet}
|
|
634
951
|
export default async function Page({ params }: { params: Promise<{ slug?: string[]; lang: string }> }) {
|
|
635
952
|
const { slug, lang } = await params;
|
|
636
953
|
const page = source.getPage(slug, lang);
|
|
637
954
|
${isStrict ? `
|
|
638
|
-
if (!isAllowed(slug)) {
|
|
955
|
+
if (!isAllowed(slug, lang)) {
|
|
639
956
|
notFound();
|
|
640
957
|
}
|
|
641
958
|
` : ""}
|
|
642
959
|
if (!page) {
|
|
643
960
|
notFound();
|
|
644
961
|
}
|
|
645
|
-
|
|
962
|
+
${isOApi ? `
|
|
963
|
+
if (page.data.type === 'openapi') {
|
|
964
|
+
return (
|
|
965
|
+
<DocsPage full>
|
|
966
|
+
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
|
|
967
|
+
<DocsBody>
|
|
968
|
+
<APIPage {...(page.data as any).getAPIPageProps()} />
|
|
969
|
+
</DocsBody>
|
|
970
|
+
</DocsPage>
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
` : ""}
|
|
646
974
|
const MDX = page.data.body;
|
|
647
975
|
|
|
648
976
|
return (
|
|
@@ -668,7 +996,7 @@ ${pageActionsEnabled ? ` <div className="flex items-start justify-between g
|
|
|
668
996
|
${isStrict ? `
|
|
669
997
|
export function generateStaticParams() {
|
|
670
998
|
let params = source.generateParams();
|
|
671
|
-
params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug));
|
|
999
|
+
params = params.filter((p: { slug: string[]; lang: string }) => isAllowed(p.slug, p.lang));
|
|
672
1000
|
// Ensure every language has a homepage entry (slug: [])
|
|
673
1001
|
const languages = [...new Set(params.map((p: { lang: string }) => p.lang))];
|
|
674
1002
|
for (const lang of languages) {
|
|
@@ -730,7 +1058,7 @@ export default config;
|
|
|
730
1058
|
* 一次作为生成应用的依赖)导致的多实例 React Context 问题。
|
|
731
1059
|
*/
|
|
732
1060
|
function generateProvider(ctx) {
|
|
733
|
-
const searchEnabled = ctx.config.search
|
|
1061
|
+
const searchEnabled = ctx.config.search !== void 0;
|
|
734
1062
|
if (ctx.config.i18n?.enabled === true) return `'use client';
|
|
735
1063
|
import { RootProvider } from 'fumadocs-ui/provider/next';
|
|
736
1064
|
import SafeSearchDialog from './components/search-dialog';
|
|
@@ -1150,76 +1478,11 @@ export const { staticGET: GET } = createFromSource(source);
|
|
|
1150
1478
|
//#endregion
|
|
1151
1479
|
//#region src/core/generator/source-config.ts
|
|
1152
1480
|
function generateSourceConfig(_ctx) {
|
|
1153
|
-
const titleMap = buildTitleMap(_ctx.config);
|
|
1154
|
-
const titleMapEntries = Object.entries(titleMap).map(([slug, title]) => ` '${slug}': '${title.replace(/'/g, "\\'")}'`).join(",\n");
|
|
1155
|
-
const titleMapStr = titleMapEntries ? `{\n${titleMapEntries}\n}` : "{}";
|
|
1156
|
-
const isStrict = _ctx.config.contentPolicy !== "all";
|
|
1157
|
-
const isI18n = isI18nEnabled(_ctx.config);
|
|
1158
|
-
const useDirParser = isDirParser(_ctx.config);
|
|
1159
1481
|
return `import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
|
|
1160
1482
|
import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
|
|
1161
|
-
import { z } from 'zod';
|
|
1162
|
-
|
|
1163
|
-
const titleMap: Record<string, string> = ${titleMapStr};${isStrict ? `
|
|
1164
|
-
|
|
1165
|
-
const allowedSlugs = new Set(${JSON.stringify([...collectConfiguredSlugs(_ctx.config)])});
|
|
1166
|
-
|
|
1167
|
-
function slugFromPath(path: string): string {
|
|
1168
|
-
const normalized = path.replace(/\\\\/g, '/');
|
|
1169
|
-
const idx = normalized.indexOf('content/');
|
|
1170
|
-
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;${useDirParser ? `
|
|
1171
|
-
// dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
|
|
1172
|
-
const parts = relative.split('/');
|
|
1173
|
-
if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
|
|
1174
|
-
return parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
|
|
1175
|
-
}
|
|
1176
|
-
return relative.replace(/\\.(md|mdx)$/i, '');` : `
|
|
1177
|
-
let slug = relative.replace(/\\.(md|mdx)$/i, '');
|
|
1178
|
-
${isI18n ? ` // 剥离语言后缀:index.en -> index
|
|
1179
|
-
slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
|
|
1180
|
-
return slug;`}
|
|
1181
|
-
}` : ""}
|
|
1182
|
-
function titleFromPath(path: string): string {
|
|
1183
|
-
${useDirParser ? ` const normalized = path.replace(/\\\\/g, '/');
|
|
1184
|
-
const idx = normalized.indexOf('content/');
|
|
1185
|
-
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
|
|
1186
|
-
// dir parser: 剥离语言目录前缀 content/en/guide/configuration.mdx -> guide/configuration
|
|
1187
|
-
const parts = relative.split('/');
|
|
1188
|
-
if (parts.length > 1 && /^[a-z]{2}(-[A-Z]{2})?$/i.test(parts[0])) {
|
|
1189
|
-
const slug = parts.slice(1).join('/').replace(/\\.(md|mdx)$/i, '');
|
|
1190
|
-
return titleMap[slug] || slug.split('/').pop() || slug;
|
|
1191
|
-
}
|
|
1192
|
-
const slug = relative.replace(/\\.(md|mdx)$/i, '');
|
|
1193
|
-
return titleMap[slug] || slug.split('/').pop() || slug;` : ` const normalized = path.replace(/\\\\/g, '/');
|
|
1194
|
-
const idx = normalized.indexOf('content/');
|
|
1195
|
-
const relative = idx >= 0 ? normalized.slice(idx + 'content/'.length) : normalized;
|
|
1196
|
-
let slug = relative.replace(/\\.(md|mdx)$/i, '');
|
|
1197
|
-
${isI18n ? ` // 剥离语言后缀:guide/configuration.en -> guide/configuration
|
|
1198
|
-
slug = slug.replace(/\\.([a-z]{2}(-[A-Z]{2})?)$/, '');` : ""}
|
|
1199
|
-
return titleMap[slug] || slug.split('/').pop() || slug;`}
|
|
1200
|
-
}
|
|
1201
1483
|
|
|
1202
1484
|
export const docs = defineDocs({
|
|
1203
1485
|
dir: 'content',
|
|
1204
|
-
docs: {
|
|
1205
|
-
schema: (ctx) =>
|
|
1206
|
-
z.object({
|
|
1207
|
-
title: z.string().optional(),
|
|
1208
|
-
description: z.string().optional(),
|
|
1209
|
-
icon: z.string().optional(),
|
|
1210
|
-
full: z.boolean().optional(),
|
|
1211
|
-
}).transform((data) => ({
|
|
1212
|
-
...data,
|
|
1213
|
-
title: data.title ?? titleFromPath(ctx.path),
|
|
1214
|
-
}))${isStrict ? `
|
|
1215
|
-
.refine((_data) => {
|
|
1216
|
-
const slug = slugFromPath(ctx.path);
|
|
1217
|
-
if (allowedSlugs.size > 0 && !allowedSlugs.has(slug)) {
|
|
1218
|
-
return false;
|
|
1219
|
-
}
|
|
1220
|
-
return true;
|
|
1221
|
-
})` : ""},
|
|
1222
|
-
},
|
|
1223
1486
|
});
|
|
1224
1487
|
|
|
1225
1488
|
export default defineConfig({
|
|
@@ -1238,6 +1501,42 @@ export default defineConfig({
|
|
|
1238
1501
|
`;
|
|
1239
1502
|
}
|
|
1240
1503
|
|
|
1504
|
+
//#endregion
|
|
1505
|
+
//#region src/core/generator/top-bar.ts
|
|
1506
|
+
function generateTopBarComponent(ctx) {
|
|
1507
|
+
const { config } = ctx;
|
|
1508
|
+
const header = config.header;
|
|
1509
|
+
const height = header.height ?? "64px";
|
|
1510
|
+
const sticky = header.sticky ?? true;
|
|
1511
|
+
const bordered = header.bordered ?? true;
|
|
1512
|
+
const background = header.background ?? "";
|
|
1513
|
+
const logoProps = resolveNavLogoProps(header.logo ?? config.name, config.name);
|
|
1514
|
+
const linksJson = JSON.stringify(header.links ?? []);
|
|
1515
|
+
const backgroundProp = background ? `\n background='${background}',` : "";
|
|
1516
|
+
const isTopBarSearch = config.search?.position === "header";
|
|
1517
|
+
return `'use client';
|
|
1518
|
+
|
|
1519
|
+
import { TopBar } from 'openmanual/components/top-bar';
|
|
1520
|
+
import { NavLogo } from 'openmanual/components/nav-layout';
|
|
1521
|
+
import { NavLinks } from 'openmanual/components/nav-links';${isTopBarSearch ? "\nimport { TopBarSearchTrigger } from 'openmanual/components/top-bar-search-trigger';" : ""}
|
|
1522
|
+
|
|
1523
|
+
const navLinks = ${linksJson};
|
|
1524
|
+
${isTopBarSearch ? "const searchCenter = <TopBarSearchTrigger />;" : ""}
|
|
1525
|
+
|
|
1526
|
+
export function OmTopBar() {
|
|
1527
|
+
return (
|
|
1528
|
+
<TopBar
|
|
1529
|
+
height='${height}'
|
|
1530
|
+
sticky={${sticky}}${backgroundProp}
|
|
1531
|
+
bordered={${bordered}}
|
|
1532
|
+
left={<NavLogo ${logoProps} />}${isTopBarSearch ? "\n center={searchCenter}" : ""}
|
|
1533
|
+
right={<NavLinks links={navLinks} />}
|
|
1534
|
+
/>
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
`;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1241
1540
|
//#endregion
|
|
1242
1541
|
//#region src/core/generator/tsconfig.ts
|
|
1243
1542
|
function generateTsconfig() {
|
|
@@ -1278,6 +1577,44 @@ function generateTsconfig() {
|
|
|
1278
1577
|
//#region src/core/generator/index.ts
|
|
1279
1578
|
async function generateAll(ctx) {
|
|
1280
1579
|
const isI18n = isI18nEnabled(ctx.config);
|
|
1580
|
+
if (isOpenApiEnabled(ctx.config)) {
|
|
1581
|
+
const { access } = await import("node:fs/promises");
|
|
1582
|
+
const { extname, join } = await import("node:path");
|
|
1583
|
+
const supportedExts = [
|
|
1584
|
+
".json",
|
|
1585
|
+
".yaml",
|
|
1586
|
+
".yml"
|
|
1587
|
+
];
|
|
1588
|
+
const specPaths = resolveOpenApiSpecPaths(ctx.config);
|
|
1589
|
+
for (const specPath of specPaths) {
|
|
1590
|
+
const absolutePath = join(ctx.projectDir, specPath);
|
|
1591
|
+
const ext = extname(absolutePath).toLowerCase();
|
|
1592
|
+
if (!supportedExts.includes(ext)) throw new Error(`[openapi] 不支持的 OpenAPI 规范文件格式: "${ext}"(文件: ${specPath})。支持的格式: ${supportedExts.join(", ")}`);
|
|
1593
|
+
try {
|
|
1594
|
+
await access(absolutePath);
|
|
1595
|
+
} catch {
|
|
1596
|
+
throw new Error(`[openapi] OpenAPI 规范文件不存在: "${specPath}"。请确认 "openapi.specs" 或 "openapi.specPath" 在 openmanual.json 中配置的路径正确。`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
await computeAllSlugs(ctx);
|
|
1601
|
+
const isOApi = isOpenApiEnabled(ctx.config);
|
|
1602
|
+
const openapiFiles = [];
|
|
1603
|
+
if (isOApi) {
|
|
1604
|
+
const openapiLib = generateOpenApiLib(ctx);
|
|
1605
|
+
if (openapiLib) openapiFiles.push({
|
|
1606
|
+
path: "lib/openapi.ts",
|
|
1607
|
+
content: openapiLib
|
|
1608
|
+
});
|
|
1609
|
+
openapiFiles.push({
|
|
1610
|
+
path: "components/api-page.client.tsx",
|
|
1611
|
+
content: generateApiClientComponent()
|
|
1612
|
+
});
|
|
1613
|
+
openapiFiles.push({
|
|
1614
|
+
path: "components/api-page.tsx",
|
|
1615
|
+
content: generateApiPageComponent()
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1281
1618
|
const baseFiles = [
|
|
1282
1619
|
{
|
|
1283
1620
|
path: "source.config.ts",
|
|
@@ -1324,9 +1661,11 @@ async function generateAll(ctx) {
|
|
|
1324
1661
|
content: generatePageActionsComponent()
|
|
1325
1662
|
}
|
|
1326
1663
|
];
|
|
1664
|
+
const headerEnabled = isHeaderEnabled(ctx.config);
|
|
1327
1665
|
let files;
|
|
1328
1666
|
if (isI18n) files = [
|
|
1329
1667
|
...baseFiles,
|
|
1668
|
+
...openapiFiles,
|
|
1330
1669
|
{
|
|
1331
1670
|
path: "lib/i18n.ts",
|
|
1332
1671
|
content: generateI18nConfig(ctx)
|
|
@@ -1361,6 +1700,10 @@ async function generateAll(ctx) {
|
|
|
1361
1700
|
path: "app/[lang]/components/search-dialog.tsx",
|
|
1362
1701
|
content: generateSearchDialog(ctx)
|
|
1363
1702
|
},
|
|
1703
|
+
...headerEnabled ? [{
|
|
1704
|
+
path: "app/[lang]/components/top-bar.tsx",
|
|
1705
|
+
content: generateTopBarComponent(ctx)
|
|
1706
|
+
}] : [],
|
|
1364
1707
|
{
|
|
1365
1708
|
path: "app/[lang]/[[...slug]]/layout.tsx",
|
|
1366
1709
|
content: generateDocsLayout(ctx)
|
|
@@ -1372,6 +1715,7 @@ async function generateAll(ctx) {
|
|
|
1372
1715
|
];
|
|
1373
1716
|
else files = [
|
|
1374
1717
|
...baseFiles,
|
|
1718
|
+
...openapiFiles,
|
|
1375
1719
|
...ctx.dev ? [{
|
|
1376
1720
|
path: "app/api/raw/[...path]/route.ts",
|
|
1377
1721
|
content: generateRawContentRoute(ctx)
|
|
@@ -1394,6 +1738,10 @@ async function generateAll(ctx) {
|
|
|
1394
1738
|
path: "app/components/search-dialog.tsx",
|
|
1395
1739
|
content: generateSearchDialog(ctx)
|
|
1396
1740
|
},
|
|
1741
|
+
...headerEnabled ? [{
|
|
1742
|
+
path: "app/components/top-bar.tsx",
|
|
1743
|
+
content: generateTopBarComponent(ctx)
|
|
1744
|
+
}] : [],
|
|
1397
1745
|
{
|
|
1398
1746
|
path: "app/[[...slug]]/layout.tsx",
|
|
1399
1747
|
content: generateDocsLayout(ctx)
|
|
@@ -1420,10 +1768,8 @@ async function generateAll(ctx) {
|
|
|
1420
1768
|
function generateRootLayout(ctx) {
|
|
1421
1769
|
const { config } = ctx;
|
|
1422
1770
|
const favicon = config.favicon;
|
|
1423
|
-
|
|
1424
|
-
import {
|
|
1425
|
-
import type { ReactNode } from 'react';
|
|
1426
|
-
${favicon ? `import type { Metadata } from 'next';
|
|
1771
|
+
const headerEnabled = isHeaderEnabled(config);
|
|
1772
|
+
return `${favicon ? `import type { Metadata } from 'next';
|
|
1427
1773
|
|
|
1428
1774
|
export const metadata: Metadata = {
|
|
1429
1775
|
icons: {
|
|
@@ -1431,12 +1777,15 @@ export const metadata: Metadata = {
|
|
|
1431
1777
|
},
|
|
1432
1778
|
};
|
|
1433
1779
|
|
|
1434
|
-
` : ""}import '
|
|
1780
|
+
` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1781
|
+
import { AppProvider } from './provider';
|
|
1782
|
+
import type { ReactNode } from 'react';
|
|
1783
|
+
import '../global.css';
|
|
1435
1784
|
|
|
1436
1785
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
1437
1786
|
return (
|
|
1438
1787
|
<AppLayout>
|
|
1439
|
-
<AppProvider
|
|
1788
|
+
<AppProvider>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
|
|
1440
1789
|
</AppLayout>
|
|
1441
1790
|
);
|
|
1442
1791
|
}
|
|
@@ -1453,6 +1802,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|
|
1453
1802
|
function generateRootLayoutI18n(ctx) {
|
|
1454
1803
|
const { config } = ctx;
|
|
1455
1804
|
const favicon = config.favicon;
|
|
1805
|
+
const headerEnabled = isHeaderEnabled(config);
|
|
1456
1806
|
return `${favicon ? `import type { Metadata } from 'next';
|
|
1457
1807
|
|
|
1458
1808
|
export const metadata: Metadata = {
|
|
@@ -1461,7 +1811,7 @@ export const metadata: Metadata = {
|
|
|
1461
1811
|
},
|
|
1462
1812
|
};
|
|
1463
1813
|
|
|
1464
|
-
` : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1814
|
+
` : ""}${headerEnabled ? "import { OmTopBar } from './components/top-bar';\n" : ""}import { AppLayout } from 'openmanual/components/app-layout';
|
|
1465
1815
|
import { AppProvider } from './provider';
|
|
1466
1816
|
import type { ReactNode } from 'react';
|
|
1467
1817
|
import '../../global.css';
|
|
@@ -1477,7 +1827,7 @@ export default async function RootLayout({
|
|
|
1477
1827
|
|
|
1478
1828
|
return (
|
|
1479
1829
|
<AppLayout lang={lang}>
|
|
1480
|
-
<AppProvider lang={lang}
|
|
1830
|
+
<AppProvider lang={lang}>${headerEnabled ? "<OmTopBar />\n " : ""}{children}</AppProvider>
|
|
1481
1831
|
</AppLayout>
|
|
1482
1832
|
);
|
|
1483
1833
|
}
|
|
@@ -1489,6 +1839,8 @@ function generateDocsLayout(ctx) {
|
|
|
1489
1839
|
const navLinks = config.navbar?.links ?? [];
|
|
1490
1840
|
const footerText = config.footer?.text ?? "";
|
|
1491
1841
|
const isI18n = isI18nEnabled(config);
|
|
1842
|
+
const isOApi = isOpenApiEnabled(config);
|
|
1843
|
+
const rootGroups = ctx.rootGroups;
|
|
1492
1844
|
const linksArray = navLinks.map((l) => ({
|
|
1493
1845
|
text: l.label,
|
|
1494
1846
|
url: l.href,
|
|
@@ -1497,36 +1849,20 @@ function generateDocsLayout(ctx) {
|
|
|
1497
1849
|
const githubLine = githubLink ? `\n github: '${githubLink}',` : "";
|
|
1498
1850
|
const linksLine = linksArray.length > 0 ? `\n links: ${JSON.stringify(linksArray)},` : "";
|
|
1499
1851
|
const footerLine = footerText ? `\n footer: { children: '${footerText.replace(/'/g, "\\'")}' },` : "";
|
|
1500
|
-
const
|
|
1501
|
-
const
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
const
|
|
1510
|
-
group: g.group,
|
|
1511
|
-
icon: g.icon,
|
|
1512
|
-
collapsed: g.collapsed,
|
|
1513
|
-
pages: g.pages.map((p) => ({
|
|
1514
|
-
slug: p.slug,
|
|
1515
|
-
icon: p.icon
|
|
1516
|
-
}))
|
|
1517
|
-
})), null, 2)} as const;
|
|
1518
|
-
` : "";
|
|
1519
|
-
const lucideImportLine = hasIcons ? `\nimport { ${iconNameList.join(", ")} } from 'lucide-react';` : "";
|
|
1520
|
-
const iconMapSnippet = hasIcons ? `\nconst iconMap = {${iconNameList.map((name) => `\n ${name}: <${name} />,`).join("")}\n} as const;
|
|
1521
|
-
` : "";
|
|
1522
|
-
const treeLine = hasSidebar ? hasIcons ? isI18n ? "tree: restructureTree(source.getPageTree(lang), sidebarConfig, iconMap)," : "tree: restructureTree(source.getPageTree(), sidebarConfig, iconMap)," : isI18n ? "tree: restructureTree(source.getPageTree(lang), sidebarConfig)," : "tree: restructureTree(source.getPageTree(), sidebarConfig)," : isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
|
|
1523
|
-
const restructureTreeImport = hasSidebar ? "\nimport { restructureTree } from 'openmanual/utils/restructure-tree';" : "";
|
|
1852
|
+
const configDesc = config.description ?? "";
|
|
1853
|
+
const descLine = configDesc ? isI18n ? "" : `description: '${configDesc.replace(/'/g, "\\'")}',` : "";
|
|
1854
|
+
const treeLine = isI18n ? "tree: source.getPageTree(lang)," : "tree: source.getPageTree(),";
|
|
1855
|
+
const separateTab = isOpenApiEnabled(config) && isSeparateTabMode(config);
|
|
1856
|
+
const openapiTab = separateTab ? {
|
|
1857
|
+
title: config.openapi?.label ?? "接口文档",
|
|
1858
|
+
url: isI18n ? "/${lang}/openapi" : "/openapi",
|
|
1859
|
+
urls: /* @__PURE__ */ new Set()
|
|
1860
|
+
} : null;
|
|
1861
|
+
const sidebarTabsLine = rootGroups && rootGroups.length > 0 || openapiTab ? isI18n ? generateI18nSidebarTabs(config, rootGroups, openapiTab) : generateSingleSidebarTabs(config, rootGroups, openapiTab) : "";
|
|
1524
1862
|
if (isI18n) return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
1525
1863
|
import { baseOptions } from '@/lib/layout';
|
|
1526
1864
|
import { source } from '@/lib/source';
|
|
1527
|
-
import type { ReactNode } from 'react';${
|
|
1528
|
-
${sidebarConfigSnippet}${iconMapSnippet}
|
|
1529
|
-
|
|
1865
|
+
import type { ReactNode } from 'react';${configDesc ? `\nconst configDescription = '${configDesc.replace(/'/g, "\\'")}' as const;\n` : ""}
|
|
1530
1866
|
export default async function DocsLayoutWrapper({
|
|
1531
1867
|
params,
|
|
1532
1868
|
children,
|
|
@@ -1535,10 +1871,15 @@ export default async function DocsLayoutWrapper({
|
|
|
1535
1871
|
children: ReactNode;
|
|
1536
1872
|
}) {
|
|
1537
1873
|
const { lang } = await params;
|
|
1874
|
+
${isOApi && separateTab ? ` const _omFirstApi = source.getPages(lang)?.find((p: any) => p.data?.type === 'openapi');
|
|
1875
|
+
const _omApiUrl = _omFirstApi?.url ?? \`/\${lang}/openapi\`;
|
|
1876
|
+
` : ""}${configDesc ? `
|
|
1877
|
+
const indexPage = source.getPage([], lang);
|
|
1878
|
+
const siteDescription = indexPage?.data.description ?? configDescription;` : ""}
|
|
1538
1879
|
|
|
1539
1880
|
const docsOptions = {
|
|
1540
1881
|
...baseOptions(lang),
|
|
1541
|
-
${treeLine}${githubLine}${linksLine}${footerLine}
|
|
1882
|
+
${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${configDesc ? "\n description: siteDescription," : ""}
|
|
1542
1883
|
};
|
|
1543
1884
|
|
|
1544
1885
|
return (
|
|
@@ -1551,11 +1892,13 @@ export default async function DocsLayoutWrapper({
|
|
|
1551
1892
|
return `import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
1552
1893
|
import { baseOptions } from '@/lib/layout';
|
|
1553
1894
|
import { source } from '@/lib/source';
|
|
1554
|
-
import type { ReactNode } from 'react';${
|
|
1555
|
-
|
|
1895
|
+
import type { ReactNode } from 'react';${isOApi && separateTab ? `
|
|
1896
|
+
const _omFirstApi = source.getPages()?.find((p: any) => p.data?.type === 'openapi');
|
|
1897
|
+
const _omApiUrl = _omFirstApi?.url ?? '/openapi';
|
|
1898
|
+
` : ""}
|
|
1556
1899
|
const docsOptions = {
|
|
1557
1900
|
...baseOptions(),
|
|
1558
|
-
${treeLine}${githubLine}${linksLine}${footerLine}
|
|
1901
|
+
${treeLine}${sidebarTabsLine}${githubLine}${linksLine}${footerLine}${descLine}
|
|
1559
1902
|
};
|
|
1560
1903
|
|
|
1561
1904
|
export default function DocsLayoutWrapper({ children }: { children: ReactNode }) {
|
|
@@ -1567,6 +1910,37 @@ export default function DocsLayoutWrapper({ children }: { children: ReactNode })
|
|
|
1567
1910
|
}
|
|
1568
1911
|
`;
|
|
1569
1912
|
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Generate i18n sidebar.tabs string.
|
|
1915
|
+
* Extracted from generateDocsLayout to avoid deeply nested template literals
|
|
1916
|
+
* that confuse the oxc parser when escaped backticks are involved.
|
|
1917
|
+
*/
|
|
1918
|
+
function generateI18nSidebarTabs(config, rootGroups, openapiTab) {
|
|
1919
|
+
const entries = (rootGroups ?? []).map((g) => ({
|
|
1920
|
+
title: g.title,
|
|
1921
|
+
dirPath: g.dirPath,
|
|
1922
|
+
url: g.url,
|
|
1923
|
+
urls: g.urls
|
|
1924
|
+
}));
|
|
1925
|
+
const entriesJson = JSON.stringify(entries);
|
|
1926
|
+
const nameEscaped = config.name.replace(/'/g, "\\'");
|
|
1927
|
+
const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
|
|
1928
|
+
return `\n sidebar: {\n tabs: [\n ${`{ title: '${nameEscaped}', url: \`/\${lang}\` }`},\n ...${`(${entriesJson} as Array<{title:string;dirPath:string;url:string;urls:string[]}>).filter(g => g.dirPath.startsWith(\`\${lang}/\`)).map(g => ({ title: g.title, url: g.url, urls: new Set<string>(g.urls) }))`}${openapiTabLine}\n ],\n },`;
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Generate single-language (non-i18n) sidebar.tabs string.
|
|
1932
|
+
* Uses template literal to preserve Set<string> for urls property.
|
|
1933
|
+
*/
|
|
1934
|
+
function generateSingleSidebarTabs(config, rootGroups, openapiTab) {
|
|
1935
|
+
const nameEscaped = config.name.replace(/'/g, "\\'");
|
|
1936
|
+
const groupEntries = (rootGroups ?? []).map((g) => {
|
|
1937
|
+
const title = g.title.replace(/'/g, "\\'");
|
|
1938
|
+
const urlsArr = JSON.stringify(g.urls);
|
|
1939
|
+
return `{ title: '${title}', url: '${g.url}', urls: new Set(${urlsArr}) }`;
|
|
1940
|
+
}).join(",\n ");
|
|
1941
|
+
const openapiTabLine = openapiTab ? `,\n { title: '${openapiTab.title.replace(/'/g, "\\'")}', url: _omApiUrl, urls: new Set<string>() }` : "";
|
|
1942
|
+
return `\n sidebar: {\n tabs: [\n { title: '${nameEscaped}', url: '/' },${groupEntries ? "\n " + groupEntries : ""}${openapiTabLine}\n ],\n },`;
|
|
1943
|
+
}
|
|
1570
1944
|
function generateOpenManualLogoSvg(name, variant = "light") {
|
|
1571
1945
|
const textColor = variant === "dark" ? "#E8E0D4" : "#000000";
|
|
1572
1946
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190 32" width="190" height="32">
|
|
@@ -1589,34 +1963,115 @@ async function ensureLogoFile(ctx, logoPath, variant) {
|
|
|
1589
1963
|
}
|
|
1590
1964
|
}
|
|
1591
1965
|
/**
|
|
1592
|
-
* Generate
|
|
1593
|
-
*
|
|
1966
|
+
* Generate or enrich meta.json files for each content directory.
|
|
1967
|
+
*
|
|
1968
|
+
* Strategy:
|
|
1969
|
+
* 1. If meta.json files exist → enrich missing fields (icon/defaultOpen/pages)
|
|
1970
|
+
* 2. If no meta.json → auto-generate from file system structure
|
|
1971
|
+
*/
|
|
1972
|
+
/**
|
|
1973
|
+
* Compute all slugs from meta.json files, falling back to file system scan.
|
|
1974
|
+
* Stores result in ctx.allSlugs for use by generatePage().
|
|
1975
|
+
* Also extracts root groups (meta.json with root: true) into ctx.rootGroups.
|
|
1594
1976
|
*/
|
|
1595
|
-
async function
|
|
1596
|
-
const sidebar = ctx.config.sidebar;
|
|
1597
|
-
if (!sidebar || sidebar.length === 0) return;
|
|
1977
|
+
async function computeAllSlugs(ctx) {
|
|
1598
1978
|
const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
|
|
1599
1979
|
const isI18n = isI18nEnabled(ctx.config);
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
}
|
|
1611
|
-
if (isI18n) {
|
|
1612
|
-
const metaEnPath = join(dirPath, "meta.en.json");
|
|
1980
|
+
const useDirParser = isDirParser(ctx.config);
|
|
1981
|
+
const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
|
|
1982
|
+
const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
|
|
1983
|
+
if (metaGroups.length > 0) {
|
|
1984
|
+
ctx.allSlugs = collectSlugsFromMeta(metaGroups);
|
|
1985
|
+
const allFiles = await scanContentDir(contentAbsDir);
|
|
1986
|
+
for (const file of allFiles) if (useDirParser ? file.segments.length === 2 && languages.includes(file.segments[0]) : file.segments.length === 1) ctx.allSlugs.add(file.slug);
|
|
1987
|
+
const scannedDirCache = /* @__PURE__ */ new Map();
|
|
1988
|
+
for (const group of metaGroups) {
|
|
1989
|
+
const dirAbsPath = join(contentAbsDir, group.dirPath);
|
|
1613
1990
|
try {
|
|
1614
|
-
await
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
}
|
|
1991
|
+
const dirFiles = await scanContentDir(dirAbsPath);
|
|
1992
|
+
scannedDirCache.set(group.dirPath, dirFiles);
|
|
1993
|
+
if (!group.pages || group.pages.length === 0) for (const df of dirFiles) ctx.allSlugs.add(`${group.dirPath}/${df.slug}`);
|
|
1994
|
+
} catch {}
|
|
1619
1995
|
}
|
|
1996
|
+
ctx.rootGroups = metaGroups.filter((g) => g.root === true).map((g) => {
|
|
1997
|
+
const cached = scannedDirCache.get(g.dirPath);
|
|
1998
|
+
const firstPage = g.pages?.[0] ?? cached?.[0]?.name ?? "index";
|
|
1999
|
+
const allUrls = (cached ?? []).map((f) => `/${g.dirPath}/${f.name}`);
|
|
2000
|
+
return {
|
|
2001
|
+
title: g.title,
|
|
2002
|
+
dirPath: g.dirPath,
|
|
2003
|
+
url: `/${g.dirPath}/${firstPage}`,
|
|
2004
|
+
urls: allUrls
|
|
2005
|
+
};
|
|
2006
|
+
});
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const files = await scanContentDir(contentAbsDir);
|
|
2010
|
+
ctx.allSlugs = new Set(files.map((f) => f.slug));
|
|
2011
|
+
}
|
|
2012
|
+
async function generateMetaFiles(ctx) {
|
|
2013
|
+
const contentAbsDir = join(ctx.projectDir, ctx.contentDir);
|
|
2014
|
+
const isI18n = isI18nEnabled(ctx.config);
|
|
2015
|
+
const useDirParser = isDirParser(ctx.config);
|
|
2016
|
+
const languages = isI18n ? (ctx.config.i18n?.languages ?? []).map((l) => l.code) : [];
|
|
2017
|
+
const metaGroups = await scanMetaFiles(contentAbsDir, languages, useDirParser);
|
|
2018
|
+
if (metaGroups.length > 0) {
|
|
2019
|
+
for (const group of metaGroups) await enrichMetaFile(group);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
await autoGenerateMetaFromFS(ctx, contentAbsDir, languages, useDirParser);
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Validate an existing meta.json file is readable.
|
|
2026
|
+
* Does NOT modify the file — all user-set fields (including "root") are preserved as-is.
|
|
2027
|
+
* Fumadocs reads meta.json directly via its own content source pipeline.
|
|
2028
|
+
*/
|
|
2029
|
+
async function enrichMetaFile(_group) {
|
|
2030
|
+
try {
|
|
2031
|
+
await readFile(_group.filePath, "utf-8");
|
|
2032
|
+
} catch {}
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Auto-generate meta.json files from the file system structure.
|
|
2036
|
+
* Used when no meta.json and no sidebar config exist.
|
|
2037
|
+
*/
|
|
2038
|
+
async function autoGenerateMetaFromFS(_ctx, contentAbsDir, languages, useDirParser) {
|
|
2039
|
+
const files = await scanContentDir(contentAbsDir);
|
|
2040
|
+
const rootFiles = [];
|
|
2041
|
+
const dirGroups = /* @__PURE__ */ new Map();
|
|
2042
|
+
for (const file of files) if (file.segments.length <= 1) rootFiles.push(file);
|
|
2043
|
+
else {
|
|
2044
|
+
const dirName = file.segments[0];
|
|
2045
|
+
if (dirName === void 0) continue;
|
|
2046
|
+
if (!dirGroups.has(dirName)) dirGroups.set(dirName, []);
|
|
2047
|
+
dirGroups.get(dirName)?.push(file);
|
|
2048
|
+
}
|
|
2049
|
+
if (rootFiles.length > 0) {
|
|
2050
|
+
const rootMeta = {
|
|
2051
|
+
title: "Getting Started",
|
|
2052
|
+
pages: rootFiles.map((f) => f.name)
|
|
2053
|
+
};
|
|
2054
|
+
if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, "meta.json"), rootMeta);
|
|
2055
|
+
else await writeMetaIfNotExists(join(contentAbsDir, "meta.json"), rootMeta);
|
|
2056
|
+
}
|
|
2057
|
+
for (const [dirName, dirFiles] of dirGroups) {
|
|
2058
|
+
const dirMeta = {
|
|
2059
|
+
title: formatTitle(dirName),
|
|
2060
|
+
pages: dirFiles.map((f) => f.segments.slice(1).join("/"))
|
|
2061
|
+
};
|
|
2062
|
+
if (useDirParser) for (const lang of languages) await writeMetaIfNotExists(join(contentAbsDir, lang, dirName, "meta.json"), dirMeta);
|
|
2063
|
+
else await writeMetaIfNotExists(join(contentAbsDir, dirName, "meta.json"), dirMeta);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Write meta.json only if it does not already exist (preserve user edits).
|
|
2068
|
+
*/
|
|
2069
|
+
async function writeMetaIfNotExists(filePath, data) {
|
|
2070
|
+
try {
|
|
2071
|
+
await access(filePath);
|
|
2072
|
+
} catch {
|
|
2073
|
+
await mkdir(join(filePath, ".."), { recursive: true });
|
|
2074
|
+
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
1620
2075
|
}
|
|
1621
2076
|
}
|
|
1622
2077
|
|
|
@@ -2073,7 +2528,7 @@ const regenerateCommand = new Command("_regenerate").description("内部命令
|
|
|
2073
2528
|
//#endregion
|
|
2074
2529
|
//#region src/cli/bin.ts
|
|
2075
2530
|
function getVersion() {
|
|
2076
|
-
return "0.
|
|
2531
|
+
return "0.14.0";
|
|
2077
2532
|
}
|
|
2078
2533
|
const program = new Command();
|
|
2079
2534
|
const commandName = basename(process.argv[1] ?? "openmanual");
|