@alfacapital-tech/c4builder 0.4.0-rc.1 → 0.4.0-rc.3
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 +140 -1
- package/dist/cli/commands/help.js +4 -0
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/dispatch.js +34 -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 +92 -0
- package/dist/core/plugins/tree.js +85 -0
- package/dist/core/plugins/types.js +2 -0
- package/dist/core/render/d2renderer.js +12 -10
- package/dist/core/render/diagrams.js +40 -7
- 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 +58 -0
- package/dist/plugins/openspec/index.js +74 -0
- package/dist/plugins/openspec/render.js +224 -0
- package/dist/plugins/openspec/scan.js +99 -0
- package/dist/util/archive.js +61 -0
- package/dist/util/glob.js +69 -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,92 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { httpGetBuffer } from "../../util/http.js";
|
|
6
|
+
import { extractTarGz, extractZip } from "../../util/archive.js";
|
|
7
|
+
// Кэш скачанных архивов на процесс: в watch-режиме повторные сборки не качают заново.
|
|
8
|
+
// Ключ — URL; каталог на диске — по sha1(URL) в tmp, чистить не нужно.
|
|
9
|
+
const archiveCache = new Map();
|
|
10
|
+
// Формат по магическим байтам, а не по расширению: URL archive-API часто без него
|
|
11
|
+
// (`…/archive?format=zip`). zip — `PK\x03\x04`, gzip — `\x1f\x8b`.
|
|
12
|
+
const isZipBuffer = (b) => b.length > 3 && b[0] === 0x50 && b[1] === 0x4b && b[2] === 3 && b[3] === 4;
|
|
13
|
+
const isGzipBuffer = (b) => b.length > 1 && b[0] === 0x1f && b[1] === 0x8b;
|
|
14
|
+
// URL для сообщений: без query и userinfo — токен, переданный в URL
|
|
15
|
+
// (`?private_token=${TOKEN}`), не должен попадать в логи сборки/CI.
|
|
16
|
+
export const redactUrl = (url) => {
|
|
17
|
+
try {
|
|
18
|
+
const u = new URL(url);
|
|
19
|
+
return `${u.origin}${u.pathname}`;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// За корпоративным TLS-перехватом Node не доверяет подменённой цепочке: свой набор
|
|
26
|
+
// корней он берёт не из системы и не из SSL_CERT_FILE — только NODE_EXTRA_CA_CERTS.
|
|
27
|
+
export const tlsHint = (reason) => /certificate|self.signed|unable to (?:verify|get local issuer)/i.test(reason)
|
|
28
|
+
? ' — TLS-перехват? укажите путь к CA-бандлу в NODE_EXTRA_CA_CERTS'
|
|
29
|
+
: '';
|
|
30
|
+
const downloadArchive = async (url, headers) => {
|
|
31
|
+
const dir = path.join(os.tmpdir(), `c4builder-src-${crypto.createHash('sha1').update(url).digest('hex')}`);
|
|
32
|
+
// Распаковываем в свой staging и заменяем кэш атомарным rename — параллельная
|
|
33
|
+
// сборка того же URL не увидит полураспакованный каталог.
|
|
34
|
+
const stage = `${dir}.tmp-${process.pid}`;
|
|
35
|
+
const archive = `${stage}.archive`;
|
|
36
|
+
let body;
|
|
37
|
+
try {
|
|
38
|
+
body = await httpGetBuffer(url, { headers });
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const reason = e.message.replaceAll(url, redactUrl(url));
|
|
42
|
+
throw new Error(`архив недоступен: ${reason}${tlsHint(reason)}`);
|
|
43
|
+
}
|
|
44
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
45
|
+
fs.mkdirSync(stage, { recursive: true });
|
|
46
|
+
fs.writeFileSync(archive, body);
|
|
47
|
+
try {
|
|
48
|
+
if (isZipBuffer(body))
|
|
49
|
+
await extractZip(archive, stage);
|
|
50
|
+
else if (isGzipBuffer(body))
|
|
51
|
+
await extractTarGz(archive, stage);
|
|
52
|
+
else
|
|
53
|
+
throw new Error(`неизвестный формат архива ${redactUrl(url)} (ожидается zip или tar.gz)`);
|
|
54
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
fs.renameSync(stage, dir);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
fs.rmSync(archive, { force: true });
|
|
59
|
+
fs.rmSync(stage, { recursive: true, force: true }); // no-op после rename
|
|
60
|
+
}
|
|
61
|
+
// GitLab/GitHub archive кладут всё в `<repo>-<sha>/` — единственный корневой каталог снимаем.
|
|
62
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
return entries.length === 1 && entries[0].isDirectory() ? path.join(dir, entries[0].name) : dir;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Резолв источника плагина в локальный каталог: `dir` (+ `subdir`) — проверка
|
|
67
|
+
* существования; `archive` — скачивание (с непустыми `headers`), распаковка в tmp-кэш,
|
|
68
|
+
* снятие единственного корневого каталога, затем `subdir`. Ошибки называют путь/URL.
|
|
69
|
+
*/
|
|
70
|
+
export const resolveSource = async (spec, cwd = process.cwd()) => {
|
|
71
|
+
if (spec.dir !== undefined) {
|
|
72
|
+
const abs = path.resolve(cwd, spec.dir, spec.subdir ?? '');
|
|
73
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
|
|
74
|
+
throw new Error(`источник dir не найден: ${abs}`);
|
|
75
|
+
return abs;
|
|
76
|
+
}
|
|
77
|
+
if (spec.archive === undefined)
|
|
78
|
+
throw new Error('источник должен задавать dir либо archive');
|
|
79
|
+
const url = spec.archive;
|
|
80
|
+
const headers = Object.fromEntries(Object.entries(spec.headers ?? {}).filter(([, v]) => v !== ''));
|
|
81
|
+
let rootPromise = archiveCache.get(url);
|
|
82
|
+
if (!rootPromise) {
|
|
83
|
+
rootPromise = downloadArchive(url, headers);
|
|
84
|
+
archiveCache.set(url, rootPromise);
|
|
85
|
+
rootPromise.catch(() => archiveCache.delete(url)); // неудачу не кэшируем — следующая сборка повторит
|
|
86
|
+
}
|
|
87
|
+
const root = await rootPromise;
|
|
88
|
+
const result = spec.subdir ? path.join(root, spec.subdir) : root;
|
|
89
|
+
if (!fs.existsSync(result) || !fs.statSync(result).isDirectory())
|
|
90
|
+
throw new Error(`в архиве ${redactUrl(url)} нет каталога ${spec.subdir ?? '.'} (распаковано в ${root})`);
|
|
91
|
+
return result;
|
|
92
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
source: d.source,
|
|
81
|
+
soft: d.soft
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return item;
|
|
85
|
+
};
|
|
@@ -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. Сортировка по относительному
|
|
@@ -188,6 +188,22 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
|
|
|
188
188
|
// bkFolderName — каталог бэкапа dist, откуда восстанавливаются неизменённые картинки
|
|
189
189
|
// по чексумме. Его lifecycle (создание/удаление) держит оркестратор build(); сюда
|
|
190
190
|
// путь приходит явным параметром, а не пересчитывается из общей константы.
|
|
191
|
+
// Картинка-заглушка вместо упавшей «мягкой» диаграммы: ошибка видна на странице,
|
|
192
|
+
// сайт остаётся собранным (так же вёл себя онлайн-рендер docsify-plantuml).
|
|
193
|
+
const errorPlaceholderSvg = (name, reason) => {
|
|
194
|
+
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
195
|
+
const lines = [`${name}: диаграмма не отрендерена`, ...reason.split('\n')]
|
|
196
|
+
.flatMap((l) => l.match(/.{1,88}/g) ?? [''])
|
|
197
|
+
.slice(0, 8);
|
|
198
|
+
const w = 720;
|
|
199
|
+
const h = 34 + lines.length * 20;
|
|
200
|
+
const text = lines
|
|
201
|
+
.map((l, i) => `<text x="16" y="${34 + i * 20}" font-size="14">${esc(l)}</text>`)
|
|
202
|
+
.join('');
|
|
203
|
+
return (`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
|
|
204
|
+
`font-family="${DEFAULT_FONT_NAME}" fill="#8b0000">` +
|
|
205
|
+
`<rect x="1" y="1" width="${w - 2}" height="${h - 2}" fill="#fff5f5" stroke="#8b0000"/>${text}</svg>`);
|
|
206
|
+
};
|
|
191
207
|
export const generateImages = async (tree, options, onImageGenerated, cacheConf, bkFolderName) => {
|
|
192
208
|
// Чексуммы прошлого прогона — в Set: попадание проверяется O(1) на диаграмму
|
|
193
209
|
// (было O(N) find → O(N²) на всё дерево).
|
|
@@ -250,7 +266,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
250
266
|
// entryPath !== null ⟺ engine === 'd2' — используем как сужение типа вместо assertion.
|
|
251
267
|
const entryPath = diagram.engine === 'd2' ? path.join(item.dir, diagram.dir) : null;
|
|
252
268
|
const includes = entryPath !== null
|
|
253
|
-
? foldD2Imports(entryPath)
|
|
269
|
+
? foldD2Imports(entryPath, body)
|
|
254
270
|
: foldIncludes(body, item.dir, item.dir, new Set());
|
|
255
271
|
const outFormat = diagramOutputFormat(diagram, options);
|
|
256
272
|
// renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
|
|
@@ -262,20 +278,25 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
262
278
|
? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
|
|
263
279
|
: `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
|
|
264
280
|
`\0font=${fontCacheTag(options)}`;
|
|
281
|
+
const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
|
|
282
|
+
const relOut = path.join(item.dir.replace(options.ROOT_FOLDER, ''), outName);
|
|
283
|
+
// Путь выхода — тоже в чексумме: восстановление из бэкапа идёт по паре
|
|
284
|
+
// «чексумма встречалась» + «бэкап с этим именем есть», иначе диаграммы,
|
|
285
|
+
// обменявшиеся содержимым (или сдвинувшиеся имена fence-блоков плагинов),
|
|
286
|
+
// молча получали бы чужую старую картинку.
|
|
265
287
|
const cksum = crypto
|
|
266
288
|
.createHash('sha256')
|
|
267
|
-
.update(body
|
|
289
|
+
.update(`${body}${includes}${renderKey}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
|
|
268
290
|
.digest('hex');
|
|
269
291
|
// Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
|
|
270
292
|
// порядок массива не зависит от того, кто из воркеров финиширует раньше —
|
|
271
293
|
// .c4builder.cache детерминирован (важно для сравнения кэша между сборками).
|
|
272
294
|
const cksumEntry = { cksum, ok: false };
|
|
273
295
|
checksumEntries.push(cksumEntry);
|
|
274
|
-
const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
|
|
275
296
|
// path to backup image file
|
|
276
|
-
const bkFilePath = path.join(bkFolderName,
|
|
297
|
+
const bkFilePath = path.join(bkFolderName, relOut);
|
|
277
298
|
// path to image in dist folder
|
|
278
|
-
const filePath = path.join(options.DIST_FOLDER,
|
|
299
|
+
const filePath = path.join(options.DIST_FOLDER, relOut);
|
|
279
300
|
// if checksum exists (diagram untouched) and file/image exists - copy image back from backup folder
|
|
280
301
|
if (oldChecksums.has(cksum) && fs.existsSync(bkFilePath)) {
|
|
281
302
|
// Восстановление из бэкапа — без рендера; JVM/worker не задействованы,
|
|
@@ -293,13 +314,17 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
293
314
|
const diagramContent = diagram.content;
|
|
294
315
|
const includePath = item.dir;
|
|
295
316
|
const isDitaa = diagram.isDitaa;
|
|
317
|
+
// Имя картинки диаграммы виртуальной страницы производно от контента —
|
|
318
|
+
// без исходного файла в сообщении его не найти.
|
|
319
|
+
const source = diagram.source;
|
|
320
|
+
const soft = diagram.soft === true;
|
|
296
321
|
const task = withProgress(async () => {
|
|
297
322
|
// render diagram to image: D2 через WASM, PlantUML — прямым вызовом java.
|
|
298
323
|
// Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
|
|
299
324
|
let rendered;
|
|
300
325
|
try {
|
|
301
326
|
if (entryPath !== null) {
|
|
302
|
-
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT });
|
|
327
|
+
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
|
|
303
328
|
}
|
|
304
329
|
else {
|
|
305
330
|
// Резолв JRE отложен до первого реального PlantUML-рендера (эта
|
|
@@ -323,7 +348,15 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
323
348
|
}
|
|
324
349
|
catch (err) {
|
|
325
350
|
// Имя диаграммы в ошибку: renderDiagram/renderD2 сами его не знают.
|
|
326
|
-
|
|
351
|
+
const reason = `${err.message || err}`;
|
|
352
|
+
const msg = `Диаграмма "${outName}"${source ? ` (${source})` : ''}: ${reason}`;
|
|
353
|
+
if (!soft)
|
|
354
|
+
throw new Error(msg);
|
|
355
|
+
// Диаграммы из markdown-блоков плагинов писались под онлайн-рендер,
|
|
356
|
+
// где битый блок давал картинку с ошибкой, а не падение сборки.
|
|
357
|
+
console.log(chalk.yellow(`⚠ ${msg}`));
|
|
358
|
+
const svg = Buffer.from(errorPlaceholderSvg(outName, reason), 'utf-8');
|
|
359
|
+
await writeFile(filePath, outFormat === 'png' ? await rasterizeSvgToPng(svg, options.USE_SYSTEM_FONTS) : svg);
|
|
327
360
|
}
|
|
328
361
|
});
|
|
329
362
|
if (entryPath !== null)
|
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,58 @@
|
|
|
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, source) {
|
|
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
|
+
// soft: блок в артефакте — не файл проекта; синтаксическая
|
|
49
|
+
// ошибка в нём не должна ронять весь сайт (раньше такие блоки
|
|
50
|
+
// рендерил браузер и показывал картинку с ошибкой).
|
|
51
|
+
diagrams.push({ file, content, source, soft: true });
|
|
52
|
+
}
|
|
53
|
+
return `${indent}`;
|
|
54
|
+
});
|
|
55
|
+
return { markdown, diagrams };
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
};
|