specleaf 1.0.0 → 1.1.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 +40 -7
- package/lib/assets/ui.css +1 -1
- package/lib/assets/ui.js +25 -25
- package/lib/cli.js +164 -13
- package/package.json +2 -2
package/lib/cli.js
CHANGED
|
@@ -1,12 +1,76 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import $RefParser from "@apidevtools/json-schema-ref-parser";
|
|
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, pathToFileURL } from "node:url";
|
|
5
|
+
import $RefParser, { getJsonSchemaRefParserDefaultOptions } from "@apidevtools/json-schema-ref-parser";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
// src/lib/openapi.ts
|
|
8
8
|
var pointerEscape = (value) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
9
|
+
// build/references.ts
|
|
10
|
+
function rootReferenceTracker(entry, excluded) {
|
|
11
|
+
const root = pathToFileURL(entry);
|
|
12
|
+
const origins = /* @__PURE__ */ new WeakMap();
|
|
13
|
+
function remember(value, source, seen = /* @__PURE__ */ new WeakSet(), namedResource = false) {
|
|
14
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
15
|
+
seen.add(value);
|
|
16
|
+
namedResource ||= typeof value.$id === "string";
|
|
17
|
+
if (!namedResource) origins.set(value, source);
|
|
18
|
+
for (const child of Object.values(value)) remember(child, source, seen, namedResource);
|
|
19
|
+
}
|
|
20
|
+
const defaults = getJsonSchemaRefParserDefaultOptions().parse;
|
|
21
|
+
const parsers = Object.fromEntries(["json", "yaml"].map((name) => {
|
|
22
|
+
const plugin = defaults[name];
|
|
23
|
+
const parse = plugin.parse;
|
|
24
|
+
return [name, {
|
|
25
|
+
...plugin,
|
|
26
|
+
async parse(file) {
|
|
27
|
+
const value = typeof parse === "function" ? await parse.call(this, file) : parse;
|
|
28
|
+
remember(value, new URL(file.url.replace(/^([a-z]):\//i, "file:///$1:/"), root));
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
}];
|
|
32
|
+
}));
|
|
33
|
+
function internalize(document) {
|
|
34
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
35
|
+
function visit(value, path = "#") {
|
|
36
|
+
if (!value || typeof value !== "object" || excluded(path, value) || seen.has(value)) return;
|
|
37
|
+
seen.add(value);
|
|
38
|
+
if (typeof value.$ref === "string" && !value.$ref.startsWith("#")) {
|
|
39
|
+
const source = origins.get(value);
|
|
40
|
+
const target = source ? new URL(value.$ref, source) : void 0;
|
|
41
|
+
if (target && target.href.split("#")[0] === root.href) value.$ref = target.hash || "#";
|
|
42
|
+
if (!value.$ref.startsWith("#")) throw new Error(`契约合并后仍有外部引用:${path}/$ref → ${value.$ref}`);
|
|
43
|
+
}
|
|
44
|
+
for (const [key, child] of Object.entries(value)) visit(child, `${path}/${pointerEscape(key)}`);
|
|
45
|
+
}
|
|
46
|
+
visit(document);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
parsers,
|
|
50
|
+
internalize
|
|
51
|
+
};
|
|
52
|
+
}
|
|
9
53
|
// build/contract.ts
|
|
54
|
+
var DocumentationLoadError = class extends Error {
|
|
55
|
+
files;
|
|
56
|
+
directories;
|
|
57
|
+
constructor(message, files, directories, options) {
|
|
58
|
+
super(message, options);
|
|
59
|
+
this.files = files;
|
|
60
|
+
this.directories = directories;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var moduleEntryNames = /* @__PURE__ */ new Set([
|
|
64
|
+
"openapi.yaml",
|
|
65
|
+
"openapi.yml",
|
|
66
|
+
"openapi.json"
|
|
67
|
+
]);
|
|
68
|
+
var ignoredDirectories = /* @__PURE__ */ new Set([
|
|
69
|
+
"node_modules",
|
|
70
|
+
"dist",
|
|
71
|
+
"build",
|
|
72
|
+
"coverage"
|
|
73
|
+
]);
|
|
10
74
|
var maps = /* @__PURE__ */ new Set([
|
|
11
75
|
"properties",
|
|
12
76
|
"patternProperties",
|
|
@@ -33,7 +97,7 @@ function literalPayload(path, value) {
|
|
|
33
97
|
}
|
|
34
98
|
function contractEntry(root, configured = process.env.OPENAPI_ENTRY) {
|
|
35
99
|
const entry = configured || "examples/openapi.yaml";
|
|
36
|
-
if (/^[a-z][a-z\d+.-]*:/i.test(entry) && !/^[a-z]:[\\/]/i.test(entry)) throw new Error("OpenAPI 入口必须是本地 YAML / JSON
|
|
100
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(entry) && !/^[a-z]:[\\/]/i.test(entry)) throw new Error("OpenAPI 入口必须是本地 YAML / JSON 文件或模块目录路径。");
|
|
37
101
|
return isAbsolute(entry) ? entry : resolve(root, entry);
|
|
38
102
|
}
|
|
39
103
|
function references(value, pointer = "#", result = []) {
|
|
@@ -46,9 +110,11 @@ async function loadContract(entry) {
|
|
|
46
110
|
entry = contractEntry(process.cwd(), entry);
|
|
47
111
|
const parser = new $RefParser();
|
|
48
112
|
try {
|
|
113
|
+
const references = rootReferenceTracker(entry, literalPayload);
|
|
49
114
|
const document = await parser.bundle(entry, {
|
|
50
115
|
resolve: { http: false },
|
|
51
116
|
parse: {
|
|
117
|
+
...references.parsers,
|
|
52
118
|
binary: false,
|
|
53
119
|
text: false
|
|
54
120
|
},
|
|
@@ -57,6 +123,7 @@ async function loadContract(entry) {
|
|
|
57
123
|
});
|
|
58
124
|
if (!/^3\.(0|1)\.\d+$/.test(document.openapi || "")) throw new Error(`仅支持 OpenAPI 3.0 / 3.1,当前版本:${document.openapi ?? "缺失"}`);
|
|
59
125
|
if (!document.info?.title || !document.paths || typeof document.paths !== "object" || Array.isArray(document.paths)) throw new Error("契约必须包含 info.title 和 paths 对象。");
|
|
126
|
+
references.internalize(document);
|
|
60
127
|
return {
|
|
61
128
|
document,
|
|
62
129
|
files: parser.$refs.paths(),
|
|
@@ -68,15 +135,98 @@ async function loadContract(entry) {
|
|
|
68
135
|
const locations = Object.entries(sources).flatMap(([file, value]) => references(value).map((ref) => `${file} ${ref}`));
|
|
69
136
|
const relevant = locations.filter((location) => error.targetToken && location.includes(error.targetToken) || error.source && location.includes(error.source.split("/").at(-1)));
|
|
70
137
|
const context = (relevant.length ? relevant : locations).slice(0, 8);
|
|
71
|
-
|
|
138
|
+
const files = /* @__PURE__ */ new Set([entry, ...parser.$refs.paths()]);
|
|
139
|
+
for (const [file, value] of Object.entries(sources)) {
|
|
140
|
+
const visit = (node, pointer = "#") => {
|
|
141
|
+
if (!node || typeof node !== "object" || literalPayload(pointer, node)) return;
|
|
142
|
+
if (typeof node.$ref === "string") {
|
|
143
|
+
const target = node.$ref.split("#")[0];
|
|
144
|
+
if (target && !/^[a-z][a-z\d+.-]*:/i.test(target)) try {
|
|
145
|
+
files.add(resolve(dirname(file), decodeURIComponent(target)));
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
for (const [key, child] of Object.entries(node)) visit(child, `${pointer}/${pointerEscape(key)}`);
|
|
149
|
+
};
|
|
150
|
+
visit(value);
|
|
151
|
+
}
|
|
152
|
+
throw new DocumentationLoadError([
|
|
72
153
|
`OpenAPI 契约解析失败:${entry}`,
|
|
73
154
|
error.message,
|
|
74
155
|
error.source ? `来源文件:${error.source}` : "",
|
|
75
156
|
error.path?.length ? `引用位置:#/${error.path.map(pointerEscape).join("/")}` : "",
|
|
76
157
|
context.length ? `引用上下文:\n${context.join("\n")}` : ""
|
|
77
|
-
].filter(Boolean).join("\n"), { cause });
|
|
158
|
+
].filter(Boolean).join("\n"), [...files], [...new Set([...files].map(dirname))], { cause });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function loadDocumentation(input) {
|
|
162
|
+
const entry = contractEntry(process.cwd(), input);
|
|
163
|
+
const files = /* @__PURE__ */ new Set();
|
|
164
|
+
const directories = /* @__PURE__ */ new Set([dirname(entry)]);
|
|
165
|
+
try {
|
|
166
|
+
if (!(await stat(entry)).isDirectory()) {
|
|
167
|
+
const contract = await loadContract(entry);
|
|
168
|
+
return {
|
|
169
|
+
...contract,
|
|
170
|
+
directories: [...new Set(contract.files.map(dirname))]
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const entries = [];
|
|
174
|
+
const discover = async (directory) => {
|
|
175
|
+
directories.add(directory);
|
|
176
|
+
const children = await readdir(directory, { withFileTypes: true });
|
|
177
|
+
const candidates = [];
|
|
178
|
+
for (const child of children) if (moduleEntryNames.has(child.name) && (child.isFile() || child.isSymbolicLink() && (await stat(join(directory, child.name))).isFile())) candidates.push(child);
|
|
179
|
+
candidates.forEach((child) => files.add(join(directory, child.name)));
|
|
180
|
+
if (candidates.length > 1) throw new Error(`模块目录存在多个 OpenAPI 入口:${directory}\n${candidates.map((child) => child.name).sort().join("、")}`);
|
|
181
|
+
if (candidates.length) entries.push({
|
|
182
|
+
id: relative(entry, directory).split(sep).join("/") || ".",
|
|
183
|
+
entry: join(directory, candidates[0].name)
|
|
184
|
+
});
|
|
185
|
+
for (const child of children) if (child.isDirectory() && !child.name.startsWith(".") && !ignoredDirectories.has(child.name)) await discover(join(directory, child.name));
|
|
186
|
+
};
|
|
187
|
+
await discover(entry);
|
|
188
|
+
if (!entries.length) throw new Error(`模块目录中未找到 openapi.yaml、openapi.yml 或 openapi.json:${entry}`);
|
|
189
|
+
entries.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
190
|
+
const modules = [];
|
|
191
|
+
for (const module of entries) try {
|
|
192
|
+
const contract = await loadContract(module.entry);
|
|
193
|
+
contract.files.forEach((file) => {
|
|
194
|
+
files.add(file);
|
|
195
|
+
directories.add(dirname(file));
|
|
196
|
+
});
|
|
197
|
+
modules.push({
|
|
198
|
+
id: module.id,
|
|
199
|
+
document: contract.document
|
|
200
|
+
});
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
if (cause instanceof DocumentationLoadError) {
|
|
203
|
+
cause.files.forEach((file) => files.add(file));
|
|
204
|
+
cause.directories.forEach((directory) => directories.add(directory));
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`模块「${module.id}」解析失败\n${cause.message}`, { cause });
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
document: {
|
|
210
|
+
kind: "specleaf/modules",
|
|
211
|
+
title: basename(entry),
|
|
212
|
+
modules
|
|
213
|
+
},
|
|
214
|
+
files: [...files],
|
|
215
|
+
entry,
|
|
216
|
+
directories: [...directories]
|
|
217
|
+
};
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
if (cause instanceof DocumentationLoadError) throw cause;
|
|
220
|
+
throw new DocumentationLoadError(`OpenAPI 文档加载失败:${entry}\n${cause.message}`, [...files, entry], [...directories], { cause });
|
|
78
221
|
}
|
|
79
222
|
}
|
|
223
|
+
// src/lib/documentation.ts
|
|
224
|
+
function isModuleCollection(document) {
|
|
225
|
+
return document.kind === "specleaf/modules" && Array.isArray(document.modules) && !("openapi" in document);
|
|
226
|
+
}
|
|
227
|
+
function documentationTitle(document) {
|
|
228
|
+
return isModuleCollection(document) ? document.title : document.info.title;
|
|
229
|
+
}
|
|
80
230
|
// build/serialize.ts
|
|
81
231
|
function serializeContract(document) {
|
|
82
232
|
return JSON.stringify(document).replace(/[<>&\u2028\u2029]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
@@ -101,7 +251,7 @@ async function renderHtml(contract) {
|
|
|
101
251
|
].map((file) => readFile(new URL(file, assets), "utf8")));
|
|
102
252
|
const script = javascript.replace(/<\/script/gi, "<\\/script");
|
|
103
253
|
const css = stylesheet.replace(/<\/style/gi, "\\3c /style");
|
|
104
|
-
const title = escapeHtml(contract.document
|
|
254
|
+
const title = escapeHtml(documentationTitle(contract.document));
|
|
105
255
|
return `<!doctype html>
|
|
106
256
|
<html lang="zh-CN">
|
|
107
257
|
<head>
|
|
@@ -109,7 +259,7 @@ async function renderHtml(contract) {
|
|
|
109
259
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
110
260
|
<meta name="theme-color" content="#7357e8">
|
|
111
261
|
<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'">
|
|
112
|
-
<meta name="description" content="${escapeHtml(contract.document.info.description || contract.document.info.title)}">
|
|
262
|
+
<meta name="description" content="${escapeHtml(isModuleCollection(contract.document) ? contract.document.title : contract.document.info.description || contract.document.info.title)}">
|
|
113
263
|
<title>${title} · API 文档</title>
|
|
114
264
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${encodeURIComponent(favicon)}">
|
|
115
265
|
<style>${css}</style>
|
|
@@ -138,7 +288,7 @@ async function protectSources(output, sources) {
|
|
|
138
288
|
}
|
|
139
289
|
}
|
|
140
290
|
async function generate(input, destination) {
|
|
141
|
-
const contract = await
|
|
291
|
+
const contract = await loadDocumentation(input);
|
|
142
292
|
const output = resolve(destination);
|
|
143
293
|
let temporary;
|
|
144
294
|
try {
|
|
@@ -162,7 +312,8 @@ async function generate(input, destination) {
|
|
|
162
312
|
// build/cli.ts
|
|
163
313
|
var help = `用法:specleaf <input> [-o <output.html>]
|
|
164
314
|
|
|
165
|
-
把本地 OpenAPI 3.0 / 3.1 YAML
|
|
315
|
+
把本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为单个离线 HTML。
|
|
316
|
+
目录递归发现 openapi.yaml、openapi.yml、openapi.json,各模块独立浏览。
|
|
166
317
|
输入、输出路径相对调用目录解析,默认输出 ./openapi.html。
|
|
167
318
|
|
|
168
319
|
选项:
|
|
@@ -196,10 +347,10 @@ function parseArguments(args) {
|
|
|
196
347
|
output = argument.slice(9);
|
|
197
348
|
if (!output) throw new Error("--output 缺少输出文件路径。");
|
|
198
349
|
} else if (!positional && argument.startsWith("-")) throw new Error(`未知参数:${argument}`);
|
|
199
|
-
else if (input) throw new Error(
|
|
350
|
+
else if (input) throw new Error(`只能指定一个输入文件或目录,多余输入:${argument}`);
|
|
200
351
|
else input = argument;
|
|
201
352
|
}
|
|
202
|
-
if (!input) throw new Error("缺少 OpenAPI
|
|
353
|
+
if (!input) throw new Error("缺少 OpenAPI 输入文件或模块目录。");
|
|
203
354
|
return {
|
|
204
355
|
input,
|
|
205
356
|
output
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specleaf",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "将本地 OpenAPI 3.0 / 3.1 YAML
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "将本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为按模块分组、离线阅读的单文件 HTML 文档。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|