specleaf 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +234 -2
- package/lib/assets/favicon.svg +1 -0
- package/lib/assets/ui.css +2 -0
- package/lib/assets/ui.js +41 -0
- package/lib/cli.js +327 -0
- package/package.json +58 -4
package/lib/cli.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import $RefParser from "@apidevtools/json-schema-ref-parser";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
// src/lib/openapi.ts
|
|
8
|
+
var pointerEscape = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
9
|
+
// build/contract.ts
|
|
10
|
+
var DocumentationLoadError = class extends Error {
|
|
11
|
+
files;
|
|
12
|
+
directories;
|
|
13
|
+
constructor(message, files, directories, options) {
|
|
14
|
+
super(message, options);
|
|
15
|
+
this.files = files;
|
|
16
|
+
this.directories = directories;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var moduleEntryNames = /* @__PURE__ */ new Set([
|
|
20
|
+
"openapi.yaml",
|
|
21
|
+
"openapi.yml",
|
|
22
|
+
"openapi.json"
|
|
23
|
+
]);
|
|
24
|
+
var ignoredDirectories = /* @__PURE__ */ new Set([
|
|
25
|
+
"node_modules",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
"coverage"
|
|
29
|
+
]);
|
|
30
|
+
var maps = /* @__PURE__ */ new Set([
|
|
31
|
+
"properties",
|
|
32
|
+
"patternProperties",
|
|
33
|
+
"$defs",
|
|
34
|
+
"definitions",
|
|
35
|
+
"schemas",
|
|
36
|
+
"examples",
|
|
37
|
+
"headers",
|
|
38
|
+
"links",
|
|
39
|
+
"paths",
|
|
40
|
+
"responses",
|
|
41
|
+
"callbacks",
|
|
42
|
+
"securitySchemes",
|
|
43
|
+
"requestBodies",
|
|
44
|
+
"parameters",
|
|
45
|
+
"dependentSchemas"
|
|
46
|
+
]);
|
|
47
|
+
function literalPayload(path, value) {
|
|
48
|
+
const tokens = path.split("/");
|
|
49
|
+
const key = tokens.at(-1);
|
|
50
|
+
const parent = tokens.at(-2);
|
|
51
|
+
if (maps.has(parent)) return false;
|
|
52
|
+
return key.startsWith("x-") || key === "example" || key === "default" || key === "const" || key === "enum" && Array.isArray(value) || key === "examples" && Array.isArray(value) || key === "value" && tokens.at(-3) === "examples";
|
|
53
|
+
}
|
|
54
|
+
function contractEntry(root, configured = process.env.OPENAPI_ENTRY) {
|
|
55
|
+
const entry = configured || "examples/openapi.yaml";
|
|
56
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(entry) && !/^[a-z]:[\\/]/i.test(entry)) throw new Error("OpenAPI 入口必须是本地 YAML / JSON 文件或模块目录路径。");
|
|
57
|
+
return isAbsolute(entry) ? entry : resolve(root, entry);
|
|
58
|
+
}
|
|
59
|
+
function references(value, pointer = "#", result = []) {
|
|
60
|
+
if (!value || typeof value !== "object") return result;
|
|
61
|
+
if (typeof value.$ref === "string") result.push(`${pointer}/$ref → ${value.$ref}`);
|
|
62
|
+
for (const [key, child] of Object.entries(value)) references(child, `${pointer}/${pointerEscape(key)}`, result);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
async function loadContract(entry) {
|
|
66
|
+
entry = contractEntry(process.cwd(), entry);
|
|
67
|
+
const parser = new $RefParser();
|
|
68
|
+
try {
|
|
69
|
+
const document = await parser.bundle(entry, {
|
|
70
|
+
resolve: { http: false },
|
|
71
|
+
parse: {
|
|
72
|
+
binary: false,
|
|
73
|
+
text: false
|
|
74
|
+
},
|
|
75
|
+
resolveExcludedPathMatcher: literalPayload,
|
|
76
|
+
bundle: { excludedPathMatcher: literalPayload }
|
|
77
|
+
});
|
|
78
|
+
if (!/^3\.(0|1)\.\d+$/.test(document.openapi || "")) throw new Error(`仅支持 OpenAPI 3.0 / 3.1,当前版本:${document.openapi ?? "缺失"}`);
|
|
79
|
+
if (!document.info?.title || !document.paths || typeof document.paths !== "object" || Array.isArray(document.paths)) throw new Error("契约必须包含 info.title 和 paths 对象。");
|
|
80
|
+
return {
|
|
81
|
+
document,
|
|
82
|
+
files: parser.$refs.paths(),
|
|
83
|
+
entry
|
|
84
|
+
};
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
const error = cause;
|
|
87
|
+
const sources = parser.$refs.values();
|
|
88
|
+
const locations = Object.entries(sources).flatMap(([file, value]) => references(value).map((ref) => `${file} ${ref}`));
|
|
89
|
+
const relevant = locations.filter((location) => error.targetToken && location.includes(error.targetToken) || error.source && location.includes(error.source.split("/").at(-1)));
|
|
90
|
+
const context = (relevant.length ? relevant : locations).slice(0, 8);
|
|
91
|
+
const files = /* @__PURE__ */ new Set([entry, ...parser.$refs.paths()]);
|
|
92
|
+
for (const [file, value] of Object.entries(sources)) {
|
|
93
|
+
const visit = (node, pointer = "#") => {
|
|
94
|
+
if (!node || typeof node !== "object" || literalPayload(pointer, node)) return;
|
|
95
|
+
if (typeof node.$ref === "string") {
|
|
96
|
+
const target = node.$ref.split("#")[0];
|
|
97
|
+
if (target && !/^[a-z][a-z\d+.-]*:/i.test(target)) try {
|
|
98
|
+
files.add(resolve(dirname(file), decodeURIComponent(target)));
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
for (const [key, child] of Object.entries(node)) visit(child, `${pointer}/${pointerEscape(key)}`);
|
|
102
|
+
};
|
|
103
|
+
visit(value);
|
|
104
|
+
}
|
|
105
|
+
throw new DocumentationLoadError([
|
|
106
|
+
`OpenAPI 契约解析失败:${entry}`,
|
|
107
|
+
error.message,
|
|
108
|
+
error.source ? `来源文件:${error.source}` : "",
|
|
109
|
+
error.path?.length ? `引用位置:#/${error.path.map(pointerEscape).join("/")}` : "",
|
|
110
|
+
context.length ? `引用上下文:\n${context.join("\n")}` : ""
|
|
111
|
+
].filter(Boolean).join("\n"), [...files], [...new Set([...files].map(dirname))], { cause });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function loadDocumentation(input) {
|
|
115
|
+
const entry = contractEntry(process.cwd(), input);
|
|
116
|
+
const files = /* @__PURE__ */ new Set();
|
|
117
|
+
const directories = /* @__PURE__ */ new Set([dirname(entry)]);
|
|
118
|
+
try {
|
|
119
|
+
if (!(await stat(entry)).isDirectory()) {
|
|
120
|
+
const contract = await loadContract(entry);
|
|
121
|
+
return {
|
|
122
|
+
...contract,
|
|
123
|
+
directories: [...new Set(contract.files.map(dirname))]
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const entries = [];
|
|
127
|
+
const discover = async (directory) => {
|
|
128
|
+
directories.add(directory);
|
|
129
|
+
const children = await readdir(directory, { withFileTypes: true });
|
|
130
|
+
const candidates = [];
|
|
131
|
+
for (const child of children) if (moduleEntryNames.has(child.name) && (child.isFile() || child.isSymbolicLink() && (await stat(join(directory, child.name))).isFile())) candidates.push(child);
|
|
132
|
+
candidates.forEach((child) => files.add(join(directory, child.name)));
|
|
133
|
+
if (candidates.length > 1) throw new Error(`模块目录存在多个 OpenAPI 入口:${directory}\n${candidates.map((child) => child.name).sort().join("、")}`);
|
|
134
|
+
if (candidates.length) entries.push({
|
|
135
|
+
id: relative(entry, directory).split(sep).join("/") || ".",
|
|
136
|
+
entry: join(directory, candidates[0].name)
|
|
137
|
+
});
|
|
138
|
+
for (const child of children) if (child.isDirectory() && !child.name.startsWith(".") && !ignoredDirectories.has(child.name)) await discover(join(directory, child.name));
|
|
139
|
+
};
|
|
140
|
+
await discover(entry);
|
|
141
|
+
if (!entries.length) throw new Error(`模块目录中未找到 openapi.yaml、openapi.yml 或 openapi.json:${entry}`);
|
|
142
|
+
entries.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
143
|
+
const modules = [];
|
|
144
|
+
for (const module of entries) try {
|
|
145
|
+
const contract = await loadContract(module.entry);
|
|
146
|
+
contract.files.forEach((file) => {
|
|
147
|
+
files.add(file);
|
|
148
|
+
directories.add(dirname(file));
|
|
149
|
+
});
|
|
150
|
+
modules.push({
|
|
151
|
+
id: module.id,
|
|
152
|
+
document: contract.document
|
|
153
|
+
});
|
|
154
|
+
} catch (cause) {
|
|
155
|
+
if (cause instanceof DocumentationLoadError) {
|
|
156
|
+
cause.files.forEach((file) => files.add(file));
|
|
157
|
+
cause.directories.forEach((directory) => directories.add(directory));
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`模块「${module.id}」解析失败\n${cause.message}`, { cause });
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
document: {
|
|
163
|
+
kind: "specleaf/modules",
|
|
164
|
+
title: basename(entry),
|
|
165
|
+
modules
|
|
166
|
+
},
|
|
167
|
+
files: [...files],
|
|
168
|
+
entry,
|
|
169
|
+
directories: [...directories]
|
|
170
|
+
};
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
if (cause instanceof DocumentationLoadError) throw cause;
|
|
173
|
+
throw new DocumentationLoadError(`OpenAPI 文档加载失败:${entry}\n${cause.message}`, [...files, entry], [...directories], { cause });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// src/lib/documentation.ts
|
|
177
|
+
function isModuleCollection(document) {
|
|
178
|
+
return document.kind === "specleaf/modules" && Array.isArray(document.modules) && !("openapi" in document);
|
|
179
|
+
}
|
|
180
|
+
function documentationTitle(document) {
|
|
181
|
+
return isModuleCollection(document) ? document.title : document.info.title;
|
|
182
|
+
}
|
|
183
|
+
// build/serialize.ts
|
|
184
|
+
function serializeContract(document) {
|
|
185
|
+
return JSON.stringify(document).replace(/[<>&\u2028\u2029]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
186
|
+
}
|
|
187
|
+
// build/generate.ts
|
|
188
|
+
var projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
189
|
+
function escapeHtml(value) {
|
|
190
|
+
return value.replace(/[&<>"']/g, (character) => ({
|
|
191
|
+
"&": "&",
|
|
192
|
+
"<": "<",
|
|
193
|
+
">": ">",
|
|
194
|
+
"\"": """,
|
|
195
|
+
"'": "'"
|
|
196
|
+
})[character]);
|
|
197
|
+
}
|
|
198
|
+
async function renderHtml(contract) {
|
|
199
|
+
const assets = new URL("../lib/assets/", import.meta.url);
|
|
200
|
+
const [javascript, stylesheet, favicon] = await Promise.all([
|
|
201
|
+
"ui.js",
|
|
202
|
+
"ui.css",
|
|
203
|
+
"favicon.svg"
|
|
204
|
+
].map((file) => readFile(new URL(file, assets), "utf8")));
|
|
205
|
+
const script = javascript.replace(/<\/script/gi, "<\\/script");
|
|
206
|
+
const css = stylesheet.replace(/<\/style/gi, "\\3c /style");
|
|
207
|
+
const title = escapeHtml(documentationTitle(contract.document));
|
|
208
|
+
return `<!doctype html>
|
|
209
|
+
<html lang="zh-CN">
|
|
210
|
+
<head>
|
|
211
|
+
<meta charset="UTF-8">
|
|
212
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
213
|
+
<meta name="theme-color" content="#7357e8">
|
|
214
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'">
|
|
215
|
+
<meta name="description" content="${escapeHtml(isModuleCollection(contract.document) ? contract.document.title : contract.document.info.description || contract.document.info.title)}">
|
|
216
|
+
<title>${title} · API 文档</title>
|
|
217
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${encodeURIComponent(favicon)}">
|
|
218
|
+
<style>${css}</style>
|
|
219
|
+
</head>
|
|
220
|
+
<body><div id="root"></div><script id="specleaf-contract" type="application/json">${serializeContract(contract.document)}<\/script><script>${script}<\/script></body>
|
|
221
|
+
</html>\n`;
|
|
222
|
+
}
|
|
223
|
+
async function canonical(path) {
|
|
224
|
+
try {
|
|
225
|
+
return await realpath(path);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error.code !== "ENOENT") throw error;
|
|
228
|
+
const parent = dirname(path);
|
|
229
|
+
if (parent === path) throw error;
|
|
230
|
+
return join(await canonical(parent), basename(path));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function protectSources(output, sources) {
|
|
234
|
+
const target = await canonical(output);
|
|
235
|
+
const targetStat = await stat(output).catch((error) => {
|
|
236
|
+
if (error.code !== "ENOENT") throw error;
|
|
237
|
+
});
|
|
238
|
+
for (const source of sources) {
|
|
239
|
+
const sourceStat = await stat(source);
|
|
240
|
+
if (target === await canonical(source) || targetStat && targetStat.dev === sourceStat.dev && targetStat.ino === sourceStat.ino) throw new Error(`禁止输出覆盖入口或引用文件:${output}\n来源文件:${source}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function generate(input, destination) {
|
|
244
|
+
const contract = await loadDocumentation(input);
|
|
245
|
+
const output = resolve(destination);
|
|
246
|
+
let temporary;
|
|
247
|
+
try {
|
|
248
|
+
await protectSources(output, contract.files);
|
|
249
|
+
const html = await renderHtml(contract);
|
|
250
|
+
await mkdir(dirname(output), { recursive: true });
|
|
251
|
+
temporary = join(dirname(output), `.${basename(output)}.${randomUUID()}.tmp`);
|
|
252
|
+
await writeFile(temporary, html, {
|
|
253
|
+
encoding: "utf8",
|
|
254
|
+
flag: "wx"
|
|
255
|
+
});
|
|
256
|
+
await protectSources(output, contract.files);
|
|
257
|
+
await rename(temporary, output);
|
|
258
|
+
return output;
|
|
259
|
+
} catch (cause) {
|
|
260
|
+
throw new Error(`HTML 生成或写入失败:${output}\n${cause.message}`, { cause });
|
|
261
|
+
} finally {
|
|
262
|
+
if (temporary) await rm(temporary, { force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// build/cli.ts
|
|
266
|
+
var help = `用法:specleaf <input> [-o <output.html>]
|
|
267
|
+
|
|
268
|
+
把本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为单个离线 HTML。
|
|
269
|
+
目录递归发现 openapi.yaml、openapi.yml、openapi.json,各模块独立浏览。
|
|
270
|
+
输入、输出路径相对调用目录解析,默认输出 ./openapi.html。
|
|
271
|
+
|
|
272
|
+
选项:
|
|
273
|
+
-o, --output <文件> 指定输出文件
|
|
274
|
+
-h, --help 显示帮助
|
|
275
|
+
-v, --version 显示版本
|
|
276
|
+
`;
|
|
277
|
+
function parseArguments(args) {
|
|
278
|
+
let input;
|
|
279
|
+
let output = "openapi.html";
|
|
280
|
+
let positional = false;
|
|
281
|
+
for (let index = 0; index < args.length; index++) {
|
|
282
|
+
const argument = args[index];
|
|
283
|
+
if (!positional && (argument === "--help" || argument === "-h")) return {
|
|
284
|
+
output,
|
|
285
|
+
help: true
|
|
286
|
+
};
|
|
287
|
+
if (!positional && (argument === "--version" || argument === "-v")) return {
|
|
288
|
+
output,
|
|
289
|
+
version: true
|
|
290
|
+
};
|
|
291
|
+
if (!positional && argument === "--") {
|
|
292
|
+
positional = true;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (!positional && (argument === "-o" || argument === "--output")) {
|
|
296
|
+
const value = args[++index];
|
|
297
|
+
if (!value || value.startsWith("-")) throw new Error(`${argument} 缺少输出文件路径。`);
|
|
298
|
+
output = value;
|
|
299
|
+
} else if (!positional && argument.startsWith("--output=")) {
|
|
300
|
+
output = argument.slice(9);
|
|
301
|
+
if (!output) throw new Error("--output 缺少输出文件路径。");
|
|
302
|
+
} else if (!positional && argument.startsWith("-")) throw new Error(`未知参数:${argument}`);
|
|
303
|
+
else if (input) throw new Error(`只能指定一个输入文件或目录,多余输入:${argument}`);
|
|
304
|
+
else input = argument;
|
|
305
|
+
}
|
|
306
|
+
if (!input) throw new Error("缺少 OpenAPI 输入文件或模块目录。");
|
|
307
|
+
return {
|
|
308
|
+
input,
|
|
309
|
+
output
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const args = parseArguments(process.argv.slice(2));
|
|
314
|
+
if (args.help) process.stdout.write(help);
|
|
315
|
+
else if (args.version) {
|
|
316
|
+
const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
317
|
+
process.stdout.write(`${version}\n`);
|
|
318
|
+
} else {
|
|
319
|
+
const cwd = fileURLToPath(import.meta.url) === resolve(projectRoot, "build/cli.ts") && process.env.npm_lifecycle_event === "generate" ? process.env.INIT_CWD || process.cwd() : process.cwd();
|
|
320
|
+
const output = await generate(contractEntry(cwd, args.input), resolve(cwd, args.output));
|
|
321
|
+
process.stdout.write(`已生成离线 HTML:${output}\n`);
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
process.stderr.write(`错误:${error.message}\n运行 specleaf --help 查看用法。\n`);
|
|
325
|
+
process.exitCode = 1;
|
|
326
|
+
}
|
|
327
|
+
export { parseArguments };
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specleaf",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "将本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为按模块分组、离线阅读的单文件 HTML 文档。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/SuperPx-CN/specleaf.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/SuperPx-CN/specleaf#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/SuperPx-CN/specleaf/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"specleaf": "lib/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/cli.js",
|
|
20
|
+
"lib/assets/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.19.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "vite --host 127.0.0.1",
|
|
29
|
+
"build": "npm run typecheck && npm run build:package && node build/build.ts",
|
|
30
|
+
"build:package": "node build/package.ts",
|
|
31
|
+
"prepack": "npm run typecheck && npm run build:package",
|
|
32
|
+
"pregenerate": "npm run build:package",
|
|
33
|
+
"generate": "node build/cli.ts",
|
|
34
|
+
"preview": "vite preview --host 127.0.0.1",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"pretest": "npm run build:package",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:package": "node tests/package.mjs",
|
|
39
|
+
"pretest:e2e": "npm run build",
|
|
40
|
+
"test:e2e": "playwright test"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@apidevtools/json-schema-ref-parser": "^16.0.5"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@playwright/test": "^1.64.1",
|
|
47
|
+
"@types/node": "^24.0.0",
|
|
48
|
+
"@types/react": "^19.0.0",
|
|
49
|
+
"@types/react-dom": "^19.0.0",
|
|
50
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
51
|
+
"lucide-react": "^0.468.0",
|
|
52
|
+
"react": "^19.3.1",
|
|
53
|
+
"react-dom": "^19.3.1",
|
|
54
|
+
"react-markdown": "^10.1.0",
|
|
55
|
+
"remark-gfm": "^4.0.1",
|
|
56
|
+
"typescript": "~5.9.3",
|
|
57
|
+
"vite": "^8.3.5",
|
|
58
|
+
"vitest": "^5.0.5"
|
|
59
|
+
}
|
|
60
|
+
}
|