@alfacapital-tech/c4builder 0.4.0-rc.1 → 0.4.0-rc.2
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 +127 -0
- package/dist/cli/commands/help.js +4 -0
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/dispatch.js +30 -8
- package/dist/config/options.js +6 -0
- package/dist/config/schema.js +8 -0
- package/dist/core/build.js +11 -2
- package/dist/core/plugins/assets.js +53 -0
- package/dist/core/plugins/hooks.js +30 -0
- package/dist/core/plugins/load.js +125 -0
- package/dist/core/plugins/source.js +86 -0
- package/dist/core/plugins/tree.js +83 -0
- package/dist/core/plugins/types.js +2 -0
- package/dist/core/render/d2renderer.js +12 -10
- package/dist/core/render/diagrams.js +11 -6
- package/dist/core/render/jre.js +5 -58
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/openapi/index.js +95 -0
- package/dist/plugins/openspec/fences.js +55 -0
- package/dist/plugins/openspec/index.js +73 -0
- package/dist/plugins/openspec/render.js +216 -0
- package/dist/plugins/openspec/scan.js +99 -0
- package/dist/util/archive.js +61 -0
- package/dist/util/glob.js +66 -0
- package/package.json +1 -1
- package/vendor/swagger-ui/README.md +6 -0
- package/vendor/swagger-ui/swagger-ui.css +3 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { engineForExt } from "../scan/tree.js";
|
|
4
|
+
// Виртуальные элементы дерева (добавлены плагинами, каталога на диске нет). Нужно
|
|
5
|
+
// лишь для правила коллизий в addPage: реальную папку затирать нельзя, а свой же
|
|
6
|
+
// пустой промежуточный узел плагин вправе наполнить позже.
|
|
7
|
+
const VIRTUAL = new WeakSet();
|
|
8
|
+
export const isVirtual = (item) => VIRTUAL.has(item);
|
|
9
|
+
// Индекс сразу за концом поддерева элемента: дерево DFS-упорядочено, потомки идут
|
|
10
|
+
// подряд за родителем — вставка туда сохраняет порядок обхода (sidebar/навигация).
|
|
11
|
+
const subtreeEnd = (tree, parent) => {
|
|
12
|
+
const prefix = parent.dir + path.sep;
|
|
13
|
+
let i = tree.indexOf(parent) + 1;
|
|
14
|
+
while (i < tree.length && tree[i].dir.startsWith(prefix))
|
|
15
|
+
i++;
|
|
16
|
+
return i;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Добавляет виртуальную страницу в дерево сборки: считает `dir`/`level`/`parent` как
|
|
20
|
+
* scan (dir = ROOT_FOLDER/сегменты), дополняет `descendants` родителя, вставляет в конец
|
|
21
|
+
* поддерева родителя; недостающие промежуточные сегменты создаёт пустыми узлами
|
|
22
|
+
* (авто-заголовок, как папка без README). Каталог в dist создаётся здесь же — compose
|
|
23
|
+
* пишет страницы без mkdir, полагаясь на scan.
|
|
24
|
+
*/
|
|
25
|
+
export const addPage = (tree, options, page) => {
|
|
26
|
+
const segments = page.path;
|
|
27
|
+
if (!segments.length || segments.some((s) => !s || /[\\/]/.test(s) || s === '.' || s === '..'))
|
|
28
|
+
throw new Error(`addPage: некорректный путь страницы ${JSON.stringify(segments)}`);
|
|
29
|
+
const root = tree.find((x) => !x.parent);
|
|
30
|
+
if (!root)
|
|
31
|
+
throw new Error('addPage: в дереве нет корневого элемента');
|
|
32
|
+
let parent = root;
|
|
33
|
+
let item = root;
|
|
34
|
+
for (const [i, seg] of segments.entries()) {
|
|
35
|
+
const dir = path.join(parent.dir, seg);
|
|
36
|
+
const last = i === segments.length - 1;
|
|
37
|
+
const existing = tree.find((x) => x.dir === dir);
|
|
38
|
+
if (existing) {
|
|
39
|
+
if (last) {
|
|
40
|
+
if (!VIRTUAL.has(existing))
|
|
41
|
+
throw new Error(`addPage: путь ${segments.join('/')} занят реальной папкой ${dir} — переименуйте раздел плагина (mount)`);
|
|
42
|
+
if (existing.mdFiles.length || existing.diagrams.length)
|
|
43
|
+
throw new Error(`addPage: страница ${segments.join('/')} уже добавлена`);
|
|
44
|
+
}
|
|
45
|
+
item = existing;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
item = {
|
|
49
|
+
dir,
|
|
50
|
+
name: seg,
|
|
51
|
+
level: dir.split(path.sep).length,
|
|
52
|
+
parent: parent.dir,
|
|
53
|
+
mdFiles: [],
|
|
54
|
+
diagrams: [],
|
|
55
|
+
descendants: []
|
|
56
|
+
};
|
|
57
|
+
VIRTUAL.add(item);
|
|
58
|
+
parent.descendants.push(seg);
|
|
59
|
+
tree.splice(subtreeEnd(tree, parent), 0, item);
|
|
60
|
+
if (options.GENERATE_WEBSITE || options.GENERATE_MD || options.GENERATE_LOCAL_IMAGES)
|
|
61
|
+
fs.mkdirSync(path.join(options.DIST_FOLDER, dir.replace(options.ROOT_FOLDER, '')), {
|
|
62
|
+
recursive: true
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
parent = item;
|
|
66
|
+
}
|
|
67
|
+
const md = page.markdown === undefined ? [] : Array.isArray(page.markdown) ? page.markdown : [page.markdown];
|
|
68
|
+
item.mdFiles.push(...md);
|
|
69
|
+
for (const d of page.diagrams ?? []) {
|
|
70
|
+
const ext = path.extname(d.file).toLowerCase();
|
|
71
|
+
const engine = engineForExt(ext);
|
|
72
|
+
if (!engine)
|
|
73
|
+
throw new Error(`addPage: у диаграммы "${d.file}" страницы ${segments.join('/')} неизвестное расширение`);
|
|
74
|
+
item.diagrams.push({
|
|
75
|
+
dir: d.file,
|
|
76
|
+
ext,
|
|
77
|
+
engine,
|
|
78
|
+
content: d.content,
|
|
79
|
+
isDitaa: engine === 'plantuml' && /@startditaa/i.test(d.content)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return item;
|
|
83
|
+
};
|
|
@@ -81,7 +81,9 @@ const resolveImport = (ref, fromDir) => {
|
|
|
81
81
|
// пробел — иначе инлайновый spread `x: { ...@lib }` захватил бы `lib }`. Флаг `u` —
|
|
82
82
|
// корректный юникод. Хвостовые `.key`-сегменты частичного импорта отрезает resolveImport.
|
|
83
83
|
const IMPORT_RE = /(?<!\w)@(?:"([^"\n]+)"|([^\s"#;}\n][^#;}\n]*?)\s*(?=[#;}\n]|$))/gu;
|
|
84
|
-
|
|
84
|
+
// seed — контент входного файла из дерева сборки: у виртуальных страниц плагинов
|
|
85
|
+
// файла на диске нет, а импорты (`@…`) по-прежнему резолвятся от его каталога.
|
|
86
|
+
const collectFiles = (entryAbs, acc = new Map(), seed) => {
|
|
85
87
|
const abs = path.resolve(entryAbs);
|
|
86
88
|
if (acc.has(abs))
|
|
87
89
|
return acc;
|
|
@@ -89,7 +91,7 @@ const collectFiles = (entryAbs, acc = new Map()) => {
|
|
|
89
91
|
try {
|
|
90
92
|
// Строго UTF-8: язык D2 определён поверх UTF-8, опция CHARSET конфига относится
|
|
91
93
|
// к PlantUML (уходит в -charset) и на .d2 сознательно не распространяется.
|
|
92
|
-
content = fs.readFileSync(abs, 'utf-8');
|
|
94
|
+
content = seed ?? fs.readFileSync(abs, 'utf-8');
|
|
93
95
|
}
|
|
94
96
|
catch {
|
|
95
97
|
return acc;
|
|
@@ -117,14 +119,14 @@ let filesMemo = null;
|
|
|
117
119
|
const clearD2FileCache = () => {
|
|
118
120
|
filesMemo = null;
|
|
119
121
|
};
|
|
120
|
-
const collectFilesCached = (entryAbs) => {
|
|
122
|
+
const collectFilesCached = (entryAbs, seed) => {
|
|
121
123
|
const abs = path.resolve(entryAbs);
|
|
122
124
|
if (!filesMemo)
|
|
123
125
|
filesMemo = new Map();
|
|
124
126
|
const hit = filesMemo.get(abs);
|
|
125
127
|
if (hit)
|
|
126
128
|
return hit;
|
|
127
|
-
const built = collectFiles(abs);
|
|
129
|
+
const built = collectFiles(abs, new Map(), seed);
|
|
128
130
|
filesMemo.set(abs, built);
|
|
129
131
|
return built;
|
|
130
132
|
};
|
|
@@ -144,8 +146,8 @@ const commonAncestor = (files) => {
|
|
|
144
146
|
return common.join(path.sep) || path.sep;
|
|
145
147
|
};
|
|
146
148
|
// Подготовить аргументы compile(): виртуальная fs (все файлы графа) + inputPath.
|
|
147
|
-
const buildCompileRequest = (entryAbs) => {
|
|
148
|
-
const files = collectFilesCached(entryAbs);
|
|
149
|
+
const buildCompileRequest = (entryAbs, seed) => {
|
|
150
|
+
const files = collectFilesCached(entryAbs, seed);
|
|
149
151
|
// Пустой граф = не прочитался сам входной файл (удалён/недоступен — гонка в watch).
|
|
150
152
|
// Без guard'а commonAncestor([]) давал бы сырой TypeError (Math.min()=Infinity).
|
|
151
153
|
if (files.size === 0) {
|
|
@@ -170,9 +172,9 @@ const d2ErrorText = (message) => {
|
|
|
170
172
|
catch { }
|
|
171
173
|
return message;
|
|
172
174
|
};
|
|
173
|
-
const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
|
|
175
|
+
const renderD2 = async (entryAbs, { layout = 'dagre', seed } = {}) => {
|
|
174
176
|
const d2 = await getD2();
|
|
175
|
-
const { fs: fsMap, inputPath } = buildCompileRequest(entryAbs);
|
|
177
|
+
const { fs: fsMap, inputPath } = buildCompileRequest(entryAbs, seed);
|
|
176
178
|
let result;
|
|
177
179
|
try {
|
|
178
180
|
result = await d2.compile({ fs: fsMap, inputPath, options: { layout } });
|
|
@@ -196,9 +198,9 @@ const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
|
|
|
196
198
|
// диаграммы — аналог foldIncludes для PlantUML.
|
|
197
199
|
// Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит
|
|
198
200
|
// от машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
|
|
199
|
-
const foldD2Imports = (entryAbs) => {
|
|
201
|
+
const foldD2Imports = (entryAbs, seed) => {
|
|
200
202
|
const abs = path.resolve(entryAbs);
|
|
201
|
-
const files = collectFilesCached(entryAbs);
|
|
203
|
+
const files = collectFilesCached(entryAbs, seed);
|
|
202
204
|
const rel = (p) => path.relative(process.cwd(), p).split(path.sep).join('/');
|
|
203
205
|
// Мемоизированную Map не мутируем (её же использует buildCompileRequest) —
|
|
204
206
|
// входной файл исключаем фильтром, а не delete. Сортировка по относительному
|
|
@@ -250,7 +250,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
250
250
|
// entryPath !== null ⟺ engine === 'd2' — используем как сужение типа вместо assertion.
|
|
251
251
|
const entryPath = diagram.engine === 'd2' ? path.join(item.dir, diagram.dir) : null;
|
|
252
252
|
const includes = entryPath !== null
|
|
253
|
-
? foldD2Imports(entryPath)
|
|
253
|
+
? foldD2Imports(entryPath, body)
|
|
254
254
|
: foldIncludes(body, item.dir, item.dir, new Set());
|
|
255
255
|
const outFormat = diagramOutputFormat(diagram, options);
|
|
256
256
|
// renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
|
|
@@ -262,20 +262,25 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
262
262
|
? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
|
|
263
263
|
: `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
|
|
264
264
|
`\0font=${fontCacheTag(options)}`;
|
|
265
|
+
const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
|
|
266
|
+
const relOut = path.join(item.dir.replace(options.ROOT_FOLDER, ''), outName);
|
|
267
|
+
// Путь выхода — тоже в чексумме: восстановление из бэкапа идёт по паре
|
|
268
|
+
// «чексумма встречалась» + «бэкап с этим именем есть», иначе диаграммы,
|
|
269
|
+
// обменявшиеся содержимым (или сдвинувшиеся имена fence-блоков плагинов),
|
|
270
|
+
// молча получали бы чужую старую картинку.
|
|
265
271
|
const cksum = crypto
|
|
266
272
|
.createHash('sha256')
|
|
267
|
-
.update(body
|
|
273
|
+
.update(`${body}${includes}${renderKey}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
|
|
268
274
|
.digest('hex');
|
|
269
275
|
// Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
|
|
270
276
|
// порядок массива не зависит от того, кто из воркеров финиширует раньше —
|
|
271
277
|
// .c4builder.cache детерминирован (важно для сравнения кэша между сборками).
|
|
272
278
|
const cksumEntry = { cksum, ok: false };
|
|
273
279
|
checksumEntries.push(cksumEntry);
|
|
274
|
-
const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
|
|
275
280
|
// path to backup image file
|
|
276
|
-
const bkFilePath = path.join(bkFolderName,
|
|
281
|
+
const bkFilePath = path.join(bkFolderName, relOut);
|
|
277
282
|
// path to image in dist folder
|
|
278
|
-
const filePath = path.join(options.DIST_FOLDER,
|
|
283
|
+
const filePath = path.join(options.DIST_FOLDER, relOut);
|
|
279
284
|
// if checksum exists (diagram untouched) and file/image exists - copy image back from backup folder
|
|
280
285
|
if (oldChecksums.has(cksum) && fs.existsSync(bkFilePath)) {
|
|
281
286
|
// Восстановление из бэкапа — без рендера; JVM/worker не задействованы,
|
|
@@ -299,7 +304,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
299
304
|
let rendered;
|
|
300
305
|
try {
|
|
301
306
|
if (entryPath !== null) {
|
|
302
|
-
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT });
|
|
307
|
+
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
|
|
303
308
|
}
|
|
304
309
|
else {
|
|
305
310
|
// Резолв JRE отложен до первого реального PlantUML-рендера (эта
|
package/dist/core/render/jre.js
CHANGED
|
@@ -9,12 +9,12 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
|
-
import { createRequire } from 'node:module';
|
|
13
12
|
import { spawnSync } from 'node:child_process';
|
|
14
13
|
import { httpGetStream, httpGetJson } from "../../util/http.js";
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
14
|
+
// Распаковка (zip-slip-guard общий) вынесена в util/archive — её же использует
|
|
15
|
+
// резолвер источников плагинов. isPathInside ре-экспортируется для тестов.
|
|
16
|
+
import { extractZip, extractTarGz, isPathInside } from "../../util/archive.js";
|
|
17
|
+
export { isPathInside };
|
|
18
18
|
const MAJOR_MIN = 17; // минимальная годная мажорная версия системной java
|
|
19
19
|
const TEMURIN_FEATURE = 21; // скачиваем ровно Temurin 21 JRE
|
|
20
20
|
const JAVA_BIN = process.platform === 'win32' ? 'java.exe' : 'java';
|
|
@@ -207,60 +207,7 @@ const downloadAndVerify = async (link, expectedSha, destFile) => {
|
|
|
207
207
|
throw new Error(`sha256 архива не совпал: ожидалось ${expectedSha}, получено ${actual}`);
|
|
208
208
|
}
|
|
209
209
|
};
|
|
210
|
-
|
|
211
|
-
// оставаться ВНУТРИ него (не `../` за пределы). Экспортируется для юнит-теста.
|
|
212
|
-
export const isPathInside = (destDir, entryName) => {
|
|
213
|
-
const root = path.resolve(destDir);
|
|
214
|
-
const dest = path.resolve(root, entryName);
|
|
215
|
-
return dest === root || dest.startsWith(root + path.sep);
|
|
216
|
-
};
|
|
217
|
-
const extractZip = (archive, destDir) => new Promise((resolve, reject) => {
|
|
218
|
-
require('yauzl').open(archive, { lazyEntries: true }, (err, zip) => {
|
|
219
|
-
if (err)
|
|
220
|
-
return reject(err);
|
|
221
|
-
zip.on('entry', (entry) => {
|
|
222
|
-
// zip-slip: злонамеренная запись `../../evil` вышла бы за destDir. tar-ветка
|
|
223
|
-
// защищена node-tar≥6 по умолчанию, zip (yauzl) — нет, проверяем сами.
|
|
224
|
-
if (!isPathInside(destDir, entry.fileName)) {
|
|
225
|
-
return reject(new Error(`Небезопасный путь в архиве JRE (выход за каталог): ${entry.fileName}`));
|
|
226
|
-
}
|
|
227
|
-
// Симлинки пропускаем: вне доверенного Adoptium-архива симлинк мог бы
|
|
228
|
-
// указывать за пределы каталога. Temurin Windows-zip симлинков не содержит.
|
|
229
|
-
const isSymlink = ((entry.externalFileAttributes >>> 16) & 0xffff & 0o170000) === 0o120000;
|
|
230
|
-
if (isSymlink)
|
|
231
|
-
return zip.readEntry();
|
|
232
|
-
const dest = path.join(destDir, entry.fileName);
|
|
233
|
-
if (entry.fileName.endsWith('/')) {
|
|
234
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
235
|
-
return zip.readEntry();
|
|
236
|
-
}
|
|
237
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
238
|
-
zip.openReadStream(entry, (e, rs) => {
|
|
239
|
-
if (e)
|
|
240
|
-
return reject(e);
|
|
241
|
-
const ws = fs.createWriteStream(dest);
|
|
242
|
-
ws.on('error', reject);
|
|
243
|
-
ws.on('finish', () => {
|
|
244
|
-
const mode = (entry.externalFileAttributes >>> 16) & 0o777;
|
|
245
|
-
if (mode) {
|
|
246
|
-
try {
|
|
247
|
-
fs.chmodSync(dest, mode);
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
/* права не критичны на windows */
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
zip.readEntry();
|
|
254
|
-
});
|
|
255
|
-
rs.pipe(ws);
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
zip.on('end', resolve);
|
|
259
|
-
zip.on('error', reject);
|
|
260
|
-
zip.readEntry();
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : require('tar').x({ file: archive, cwd: destDir });
|
|
210
|
+
const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : extractTarGz(archive, destDir);
|
|
264
211
|
// (3) скачивание: assets API → sha256 (до распаковки) → распаковка в staging →
|
|
265
212
|
// атомарная замена кеша. Распаковываем НЕ в финальный каталог, а в свой staging
|
|
266
213
|
// (<dir>.tmp-<pid>, уникальный на процесс → параллельные сборки не топчут друг друга),
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Встроенный плагин openapi: набор OpenAPI-спек (локальная папка или архив репозитория
|
|
2
|
+
// контрактов) → раздел сайта со страницей swagger-ui на спеку. Полностью офлайн:
|
|
3
|
+
// бандл swagger-ui и CSS — вендорные ассеты плагина, спеки копируются в dist статикой
|
|
4
|
+
// с сохранением относительных путей ($ref).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { outputDirs } from "../../config/options.js";
|
|
9
|
+
import { redactUrl } from "../../core/plugins/source.js";
|
|
10
|
+
import { definePlugin } from "../../core/plugins/types.js";
|
|
11
|
+
import { globFiles } from "../../util/glob.js";
|
|
12
|
+
import { VENDOR_DIR } from "../../util/paths.js";
|
|
13
|
+
import { encodeURIPath } from "../../util/utils.js";
|
|
14
|
+
const optionsSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
mount: z.string().min(1).default('API'),
|
|
17
|
+
dir: z.string().min(1).optional(),
|
|
18
|
+
archive: z.string().optional(),
|
|
19
|
+
subdir: z.string().optional(),
|
|
20
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
21
|
+
glob: z.string().default('**/openapi.{yaml,yml,json}')
|
|
22
|
+
})
|
|
23
|
+
.strict()
|
|
24
|
+
.refine((o) => (o.dir === undefined) !== (o.archive === undefined), {
|
|
25
|
+
message: 'нужен ровно один источник: dir либо archive'
|
|
26
|
+
});
|
|
27
|
+
// Имя страницы: родительская папка спеки; в корне источника — имя файла без расширения.
|
|
28
|
+
const pageName = (rel) => {
|
|
29
|
+
const dir = path.posix.dirname(rel);
|
|
30
|
+
return dir === '.' ? path.posix.basename(rel).replace(/\.[^.]+$/, '') : path.posix.basename(dir);
|
|
31
|
+
};
|
|
32
|
+
export default definePlugin({
|
|
33
|
+
name: 'openapi',
|
|
34
|
+
options: optionsSchema,
|
|
35
|
+
requires: { executeScript: true },
|
|
36
|
+
// Бандл объявлен ассетом, а не взят из шаблона docsify: пользовательский docsifyTemplate
|
|
37
|
+
// может его не подключать; при дефолтном шаблоне injectPluginAssets дубль не добавит.
|
|
38
|
+
assets: {
|
|
39
|
+
styles: [path.join(VENDOR_DIR, 'swagger-ui', 'swagger-ui.css')],
|
|
40
|
+
scripts: [path.join(VENDOR_DIR, 'docsify', 'swagger-ui-bundle.js')]
|
|
41
|
+
},
|
|
42
|
+
watchPaths: (o) => (o.dir ? [path.join(o.dir, o.subdir ?? '')] : []),
|
|
43
|
+
async afterScan(ctx, o) {
|
|
44
|
+
const { mount } = o;
|
|
45
|
+
const source = o.dir ?? redactUrl(o.archive ?? '');
|
|
46
|
+
const root = await ctx.source({
|
|
47
|
+
dir: o.dir,
|
|
48
|
+
archive: o.archive,
|
|
49
|
+
subdir: o.subdir,
|
|
50
|
+
headers: o.headers
|
|
51
|
+
});
|
|
52
|
+
// Источник может быть предком выходного каталога (dir: '.') — свои же копии
|
|
53
|
+
// спек прошлой сборки в dist/dist_bk не считаются источником.
|
|
54
|
+
const skip = outputDirs(ctx.options);
|
|
55
|
+
const files = globFiles(root, o.glob, skip);
|
|
56
|
+
if (!files.length)
|
|
57
|
+
throw new Error(`по шаблону "${o.glob}" в ${source} не найдено ни одной спеки`);
|
|
58
|
+
const names = new Map();
|
|
59
|
+
for (const rel of files) {
|
|
60
|
+
const name = pageName(rel);
|
|
61
|
+
const dup = names.get(name);
|
|
62
|
+
if (dup)
|
|
63
|
+
throw new Error(`две спеки дают одно имя страницы "${name}": ${dup} и ${rel}`);
|
|
64
|
+
names.set(name, rel);
|
|
65
|
+
}
|
|
66
|
+
// Статикой в dist — все yaml/json источника, а не только совпавшие с glob: $ref
|
|
67
|
+
// может вести в общие схемы (components), не являющиеся спеками.
|
|
68
|
+
for (const rel of globFiles(root, '**/*.{yaml,yml,json}', skip)) {
|
|
69
|
+
const dest = path.join(ctx.options.DIST_FOLDER, mount, '_specs', rel);
|
|
70
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
71
|
+
fs.copyFileSync(path.join(root, rel), dest);
|
|
72
|
+
}
|
|
73
|
+
const sorted = [...names.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
74
|
+
const link = (name) => encodeURIPath(path.posix.join(mount, name, ctx.options.WEB_FILE_NAME || name));
|
|
75
|
+
ctx.addPage({
|
|
76
|
+
path: [mount],
|
|
77
|
+
markdown: sorted.map(([name, rel]) => `- [${name}](${link(name)}) — \`${rel}\``).join('\n')
|
|
78
|
+
});
|
|
79
|
+
for (const [name, rel] of sorted) {
|
|
80
|
+
// Спека грузится swagger-ui по ссылке от корня сайта (SPA: base = index.html);
|
|
81
|
+
// относительные $ref внутри спек резолвятся от её URL — структура сохранена.
|
|
82
|
+
const specUrl = encodeURIPath(path.posix.join(mount, '_specs', rel));
|
|
83
|
+
const domId = `swagger-${name.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
84
|
+
ctx.addPage({
|
|
85
|
+
path: [mount, name],
|
|
86
|
+
markdown: [
|
|
87
|
+
`<div id="${domId}"></div>`,
|
|
88
|
+
'<script>',
|
|
89
|
+
`SwaggerUIBundle({ url: '${specUrl}', dom_id: '#${domId}' });`,
|
|
90
|
+
'</script>'
|
|
91
|
+
].join('\n')
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Fenced-блоки ```plantuml / ```d2 в артефактах → диаграммы страницы (рендер общим
|
|
2
|
+
// локальным движком) + ссылка `` на их месте: compose подставит картинку
|
|
3
|
+
// inline, а клиентский docsify-plantuml (онлайн-рендер в браузере) не сработает —
|
|
4
|
+
// fence в выводе уже отсутствует.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
// Fenced-блок по CommonMark: открывающая ``` или ~~~ (≥3) с любым отступом (fence в
|
|
7
|
+
// списке), инфо-строка, тело (может быть пустым), закрывающая того же вида не короче
|
|
8
|
+
// открывающей. Тело захватывается вместе с завершающим переводом строки.
|
|
9
|
+
const FENCE_RE = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\n]*)\n([\s\S]*?)^[ \t]*\2[`~]*[ \t]*$/gm;
|
|
10
|
+
const ENGINE_EXT = { plantuml: '.puml', puml: '.puml', d2: '.d2' };
|
|
11
|
+
/** Применить fn к тексту вне fenced-блоков (сами блоки — без изменений). */
|
|
12
|
+
export const mapOutsideFences = (md, fn) => {
|
|
13
|
+
let out = '';
|
|
14
|
+
let last = 0;
|
|
15
|
+
for (const m of md.matchAll(FENCE_RE)) {
|
|
16
|
+
out += fn(md.slice(last, m.index)) + m[0];
|
|
17
|
+
last = m.index + m[0].length;
|
|
18
|
+
}
|
|
19
|
+
return out + fn(md.slice(last));
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Экстрактор диаграмм одной страницы. Имя файла — `<base>-<sha1 контента>`: не зависит
|
|
23
|
+
* от позиции fence, поэтому вставка/удаление соседнего блока не сдвигает имена и не
|
|
24
|
+
* подсовывает картинку соседа из кэша; одинаковые блоки дают одну диаграмму.
|
|
25
|
+
*/
|
|
26
|
+
export const createFenceExtractor = () => {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
return {
|
|
29
|
+
extract(md, base) {
|
|
30
|
+
const diagrams = [];
|
|
31
|
+
const markdown = md.replace(FENCE_RE, (whole, indent, _fence, info, body) => {
|
|
32
|
+
const lang = info.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
33
|
+
const ext = ENGINE_EXT[lang];
|
|
34
|
+
if (!ext)
|
|
35
|
+
return whole;
|
|
36
|
+
// Отступ fence (в списке) снимаем со строк тела, как CommonMark.
|
|
37
|
+
const src = body
|
|
38
|
+
.replace(/\n$/, '')
|
|
39
|
+
.split('\n')
|
|
40
|
+
.map((l) => (l.startsWith(indent) ? l.slice(indent.length) : l))
|
|
41
|
+
.join('\n');
|
|
42
|
+
// PlantUML требует @startuml/@enduml; в артефактах их часто опускают.
|
|
43
|
+
const content = ext === '.puml' && !/@start\w+/.test(src) ? `@startuml\n${src}\n@enduml` : src;
|
|
44
|
+
const hash = crypto.createHash('sha1').update(content).digest('hex').slice(0, 8);
|
|
45
|
+
const file = `${base}-${hash}${ext}`;
|
|
46
|
+
if (!seen.has(file)) {
|
|
47
|
+
seen.add(file);
|
|
48
|
+
diagrams.push({ file, content });
|
|
49
|
+
}
|
|
50
|
+
return `${indent}`;
|
|
51
|
+
});
|
|
52
|
+
return { markdown, diagrams };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Встроенный плагин openspec: локальный OpenSpec-store → раздел сайта (сводка, активные
|
|
2
|
+
// change'ы, спеки, архив). Диаграммы артефактов рендерятся локальным движком сборки.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { definePlugin } from "../../core/plugins/types.js";
|
|
7
|
+
import { scanStore } from "./scan.js";
|
|
8
|
+
import { renderArchiveIndex, renderChange, renderChangesIndex, renderMarkdown, renderSpecTree, renderSummary } from "./render.js";
|
|
9
|
+
const optionsSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
dir: z.string().default('openspec'),
|
|
12
|
+
mount: z.string().min(1).default('OpenSpec'),
|
|
13
|
+
// Порядок артефактов change'а; первый выводится на странице change'а, остальные —
|
|
14
|
+
// подстраницами; не перечисленные — после, по алфавиту.
|
|
15
|
+
artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks'])
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
export default definePlugin({
|
|
19
|
+
name: 'openspec',
|
|
20
|
+
options: optionsSchema,
|
|
21
|
+
watchPaths: (o) => [o.dir],
|
|
22
|
+
afterScan(ctx, o) {
|
|
23
|
+
const storeDir = path.resolve(o.dir);
|
|
24
|
+
if (!fs.existsSync(storeDir))
|
|
25
|
+
throw new Error(`OpenSpec store не найден: ${storeDir} (опция dir плагина openspec)`);
|
|
26
|
+
const store = scanStore(storeDir);
|
|
27
|
+
const { mount } = o;
|
|
28
|
+
const { options } = ctx;
|
|
29
|
+
// Страницы + файлы, на которые они ссылаются (картинки): рядом со страницей (каталог
|
|
30
|
+
// в dist уже создан addPage) и, как scan для реальных папок, в корень dist для
|
|
31
|
+
// complete-markdown; excludeOtherFiles действует и здесь.
|
|
32
|
+
const addPages = (pages, srcRoot) => {
|
|
33
|
+
for (const page of pages) {
|
|
34
|
+
ctx.addPage({ path: page.path, markdown: page.markdown, diagrams: page.diagrams });
|
|
35
|
+
if (options.EXCLUDE_OTHER_FILES)
|
|
36
|
+
continue;
|
|
37
|
+
for (const { from, to } of page.files) {
|
|
38
|
+
const src = path.join(srcRoot, from);
|
|
39
|
+
const dests = [path.join(options.DIST_FOLDER, ...page.path, to)];
|
|
40
|
+
// Ссылки вверх (`../` из дельты) от корня complete-файла всё равно никуда не ведут.
|
|
41
|
+
if (options.GENERATE_COMPLETE_MD_FILE && !path.posix.normalize(to).startsWith('..'))
|
|
42
|
+
dests.push(path.join(options.DIST_FOLDER, to));
|
|
43
|
+
for (const dest of dests) {
|
|
44
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
45
|
+
fs.copyFileSync(src, dest);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const addChange = (change, segments) => addPages(renderChange(change, segments, o.artifacts, options), change.dir);
|
|
51
|
+
ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options) });
|
|
52
|
+
ctx.addPage({
|
|
53
|
+
path: [mount, 'Changes'],
|
|
54
|
+
markdown: renderChangesIndex(store.changes, mount, options)
|
|
55
|
+
});
|
|
56
|
+
for (const change of store.changes)
|
|
57
|
+
addChange(change, [mount, 'Changes', change.id]);
|
|
58
|
+
// Specs: индекс, промежуточные папки, страницы спек — тем же рендером, что и
|
|
59
|
+
// дельты (fenced-диаграммы локально, ссылки на файлы рядом со спекой).
|
|
60
|
+
const specsDir = path.join(storeDir, 'specs');
|
|
61
|
+
addPages(renderSpecTree(store.specs, [mount, 'Specs'], options, (pagePath, spec) => renderMarkdown(pagePath, `spec-${spec.path.join('-')}`, spec.content, {
|
|
62
|
+
srcRoot: specsDir,
|
|
63
|
+
fromDir: spec.path.join('/'),
|
|
64
|
+
options
|
|
65
|
+
})), specsDir);
|
|
66
|
+
ctx.addPage({
|
|
67
|
+
path: [mount, 'Archive'],
|
|
68
|
+
markdown: renderArchiveIndex(store.archive, mount, options)
|
|
69
|
+
});
|
|
70
|
+
for (const change of store.archive)
|
|
71
|
+
addChange(change, [mount, 'Archive', change.id]);
|
|
72
|
+
}
|
|
73
|
+
});
|