specleaf 1.0.0 → 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/lib/cli.js CHANGED
@@ -1,12 +1,32 @@
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";
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
4
  import { fileURLToPath } from "node:url";
5
5
  import $RefParser 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
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
+ ]);
10
30
  var maps = /* @__PURE__ */ new Set([
11
31
  "properties",
12
32
  "patternProperties",
@@ -33,7 +53,7 @@ function literalPayload(path, value) {
33
53
  }
34
54
  function contractEntry(root, configured = process.env.OPENAPI_ENTRY) {
35
55
  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 文件路径。");
56
+ if (/^[a-z][a-z\d+.-]*:/i.test(entry) && !/^[a-z]:[\\/]/i.test(entry)) throw new Error("OpenAPI 入口必须是本地 YAML / JSON 文件或模块目录路径。");
37
57
  return isAbsolute(entry) ? entry : resolve(root, entry);
38
58
  }
39
59
  function references(value, pointer = "#", result = []) {
@@ -68,15 +88,98 @@ async function loadContract(entry) {
68
88
  const locations = Object.entries(sources).flatMap(([file, value]) => references(value).map((ref) => `${file} ${ref}`));
69
89
  const relevant = locations.filter((location) => error.targetToken && location.includes(error.targetToken) || error.source && location.includes(error.source.split("/").at(-1)));
70
90
  const context = (relevant.length ? relevant : locations).slice(0, 8);
71
- throw new Error([
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([
72
106
  `OpenAPI 契约解析失败:${entry}`,
73
107
  error.message,
74
108
  error.source ? `来源文件:${error.source}` : "",
75
109
  error.path?.length ? `引用位置:#/${error.path.map(pointerEscape).join("/")}` : "",
76
110
  context.length ? `引用上下文:\n${context.join("\n")}` : ""
77
- ].filter(Boolean).join("\n"), { cause });
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 });
78
174
  }
79
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
+ }
80
183
  // build/serialize.ts
81
184
  function serializeContract(document) {
82
185
  return JSON.stringify(document).replace(/[<>&\u2028\u2029]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
@@ -101,7 +204,7 @@ async function renderHtml(contract) {
101
204
  ].map((file) => readFile(new URL(file, assets), "utf8")));
102
205
  const script = javascript.replace(/<\/script/gi, "<\\/script");
103
206
  const css = stylesheet.replace(/<\/style/gi, "\\3c /style");
104
- const title = escapeHtml(contract.document.info.title);
207
+ const title = escapeHtml(documentationTitle(contract.document));
105
208
  return `<!doctype html>
106
209
  <html lang="zh-CN">
107
210
  <head>
@@ -109,7 +212,7 @@ async function renderHtml(contract) {
109
212
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
110
213
  <meta name="theme-color" content="#7357e8">
111
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'">
112
- <meta name="description" content="${escapeHtml(contract.document.info.description || contract.document.info.title)}">
215
+ <meta name="description" content="${escapeHtml(isModuleCollection(contract.document) ? contract.document.title : contract.document.info.description || contract.document.info.title)}">
113
216
  <title>${title} · API 文档</title>
114
217
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${encodeURIComponent(favicon)}">
115
218
  <style>${css}</style>
@@ -138,7 +241,7 @@ async function protectSources(output, sources) {
138
241
  }
139
242
  }
140
243
  async function generate(input, destination) {
141
- const contract = await loadContract(input);
244
+ const contract = await loadDocumentation(input);
142
245
  const output = resolve(destination);
143
246
  let temporary;
144
247
  try {
@@ -162,7 +265,8 @@ async function generate(input, destination) {
162
265
  // build/cli.ts
163
266
  var help = `用法:specleaf <input> [-o <output.html>]
164
267
 
165
- 把本地 OpenAPI 3.0 / 3.1 YAML 或 JSON 转为单个离线 HTML。
268
+ 把本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为单个离线 HTML。
269
+ 目录递归发现 openapi.yaml、openapi.yml、openapi.json,各模块独立浏览。
166
270
  输入、输出路径相对调用目录解析,默认输出 ./openapi.html。
167
271
 
168
272
  选项:
@@ -196,10 +300,10 @@ function parseArguments(args) {
196
300
  output = argument.slice(9);
197
301
  if (!output) throw new Error("--output 缺少输出文件路径。");
198
302
  } else if (!positional && argument.startsWith("-")) throw new Error(`未知参数:${argument}`);
199
- else if (input) throw new Error(`只能指定一个输入文件,多余输入:${argument}`);
303
+ else if (input) throw new Error(`只能指定一个输入文件或目录,多余输入:${argument}`);
200
304
  else input = argument;
201
305
  }
202
- if (!input) throw new Error("缺少 OpenAPI 输入文件。");
306
+ if (!input) throw new Error("缺少 OpenAPI 输入文件或模块目录。");
203
307
  return {
204
308
  input,
205
309
  output
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "specleaf",
3
- "version": "1.0.0",
4
- "description": "将本地 OpenAPI 3.0 / 3.1 YAML 或 JSON 转为可离线阅读的单文件 HTML 文档。",
3
+ "version": "1.1.0",
4
+ "description": "将本地 OpenAPI 3.0 / 3.1 YAML / JSON 文件或模块目录转为按模块分组、离线阅读的单文件 HTML 文档。",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",