@alfacapital-tech/c4builder 0.3.0 → 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.
@@ -0,0 +1,125 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createRequire } from 'node:module';
12
+ import { pathToFileURL } from 'node:url';
13
+ import chalk from 'chalk';
14
+ import { BUILTIN_PLUGINS } from "../../plugins/index.js";
15
+ // Подстановка `${NAME}` из окружения во всех строках опций (рекурсивно). Незаданная
16
+ // переменная → пустая строка: так `headers: { 'PRIVATE-TOKEN': '${GITLAB_TOKEN}' }`
17
+ // без токена даёт пустой заголовок, который резолвер источников отбрасывает.
18
+ export const expandEnv = (value) => {
19
+ if (typeof value === 'string')
20
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] ?? '');
21
+ if (Array.isArray(value))
22
+ return value.map(expandEnv);
23
+ if (value && typeof value === 'object')
24
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expandEnv(v)]));
25
+ return value;
26
+ };
27
+ const isPathId = (id) => id.startsWith('./') || id.startsWith('../') || path.isAbsolute(id);
28
+ // Главный ESM-вход пакета по его package.json: `exports` (вложенные условия
29
+ // import/default, subpath ".", массивы-фолбэки), иначе `module`/`main`. Покрывает
30
+ // типовые формы; экзотические условия — за пределами (`ponytail`).
31
+ const resolveEsmMain = (id, searchPaths) => {
32
+ const dir = searchPaths
33
+ .map((p) => path.join(p, id))
34
+ .find((d) => fs.existsSync(path.join(d, 'package.json')));
35
+ if (!dir)
36
+ return undefined;
37
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
38
+ const pick = (e) => {
39
+ if (typeof e === 'string')
40
+ return e;
41
+ if (Array.isArray(e))
42
+ return e.map(pick).find(Boolean);
43
+ if (!e || typeof e !== 'object')
44
+ return undefined;
45
+ const o = e;
46
+ return '.' in o ? pick(o['.']) : pick(o.import ?? o.default ?? o.node);
47
+ };
48
+ const main = pick(pkg.exports) ?? pkg.module ?? pkg.main;
49
+ return main ? path.join(dir, main) : undefined;
50
+ };
51
+ // Идентификатор → модуль плагина: встроенный по имени → путь от cwd → npm-пакет,
52
+ // резолвится от cwd проекта (а не от установки c4builder — глобальный бинарь иначе
53
+ // не видел бы локальные node_modules).
54
+ const importPlugin = async (id, cwd) => {
55
+ if (Object.hasOwn(BUILTIN_PLUGINS, id))
56
+ return BUILTIN_PLUGINS[id]();
57
+ let mod;
58
+ if (isPathId(id)) {
59
+ mod = await import(__rewriteRelativeImportExtension(pathToFileURL(path.resolve(cwd, id)).href));
60
+ }
61
+ else {
62
+ const require = createRequire(path.join(cwd, 'package.json'));
63
+ let resolved;
64
+ try {
65
+ resolved = require.resolve(id);
66
+ }
67
+ catch (e) {
68
+ // ESM-only пакет (`exports` лишь с условием import) CJS-резолвер не берёт —
69
+ // находим пакет в цепочке node_modules от cwd и читаем его exports сами.
70
+ if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED')
71
+ resolved = resolveEsmMain(id, require.resolve.paths(id) ?? []);
72
+ if (!resolved)
73
+ throw new Error(`не найден ни среди встроенных (${Object.keys(BUILTIN_PLUGINS).join(', ')}), ` +
74
+ `ни как npm-пакет от ${cwd}: ${e.message}`);
75
+ }
76
+ mod = await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href));
77
+ }
78
+ const plugin = mod.default ?? mod;
79
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string')
80
+ throw new Error('модуль должен экспортировать по умолчанию объект плагина с полем name');
81
+ return plugin;
82
+ };
83
+ /**
84
+ * Загружает плагины из `plugins` конфига: резолв, импорт, подстановка окружения и
85
+ * валидация опций схемой плагина. Fail-fast — любая ошибка прерывает до старта сборки
86
+ * с указанием позиции записи и имени плагина. `requires.executeScript` включает
87
+ * `EXECUTE_SCRIPT` в опциях сборки (с одной строкой в лог, если ключ был выключен).
88
+ */
89
+ export const loadPlugins = async (entries, cwd, options) => {
90
+ const loaded = [];
91
+ for (const [i, entry] of entries.entries()) {
92
+ const [id, rawOpts] = typeof entry === 'string' ? [entry, {}] : entry;
93
+ let plugin;
94
+ try {
95
+ plugin = await importPlugin(id, cwd);
96
+ }
97
+ catch (e) {
98
+ throw new Error(`plugins[${i}] "${id}": ${e.message}`);
99
+ }
100
+ let opts = expandEnv(rawOpts);
101
+ if (plugin.options) {
102
+ const parsed = plugin.options.safeParse(opts);
103
+ if (!parsed.success) {
104
+ const issues = parsed.error.issues.map((iss) => `${iss.path.length ? iss.path.join('.') : '(опции)'} — ${iss.message}`);
105
+ throw new Error(`plugins[${i}] ${plugin.name}: ${issues.join('; ')}`);
106
+ }
107
+ opts = parsed.data;
108
+ }
109
+ if (plugin.requires?.executeScript && !options.EXECUTE_SCRIPT) {
110
+ options.EXECUTE_SCRIPT = true;
111
+ console.log(chalk.gray(`плагин ${plugin.name} требует executeScript — включён для этой сборки`));
112
+ }
113
+ loaded.push({ plugin, opts });
114
+ }
115
+ return loaded;
116
+ };
117
+ /** Существующие пути наблюдения всех плагинов (абсолютные); несуществующие — предупреждение. */
118
+ export const pluginWatchPaths = (plugins, cwd, exists) => plugins.flatMap(({ plugin, opts }) => (plugin.watchPaths?.(opts) ?? [])
119
+ .map((p) => path.resolve(cwd, p))
120
+ .filter((p) => {
121
+ if (exists(p))
122
+ return true;
123
+ console.log(chalk.yellow(`плагин ${plugin.name}: путь наблюдения не существует — ${p}`));
124
+ return false;
125
+ }));
@@ -0,0 +1,86 @@
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
+ const downloadArchive = async (url, headers) => {
26
+ const dir = path.join(os.tmpdir(), `c4builder-src-${crypto.createHash('sha1').update(url).digest('hex')}`);
27
+ // Распаковываем в свой staging и заменяем кэш атомарным rename — параллельная
28
+ // сборка того же URL не увидит полураспакованный каталог.
29
+ const stage = `${dir}.tmp-${process.pid}`;
30
+ const archive = `${stage}.archive`;
31
+ let body;
32
+ try {
33
+ body = await httpGetBuffer(url, { headers });
34
+ }
35
+ catch (e) {
36
+ throw new Error(`архив недоступен: ${e.message.replaceAll(url, redactUrl(url))}`);
37
+ }
38
+ fs.rmSync(stage, { recursive: true, force: true });
39
+ fs.mkdirSync(stage, { recursive: true });
40
+ fs.writeFileSync(archive, body);
41
+ try {
42
+ if (isZipBuffer(body))
43
+ await extractZip(archive, stage);
44
+ else if (isGzipBuffer(body))
45
+ await extractTarGz(archive, stage);
46
+ else
47
+ throw new Error(`неизвестный формат архива ${redactUrl(url)} (ожидается zip или tar.gz)`);
48
+ fs.rmSync(dir, { recursive: true, force: true });
49
+ fs.renameSync(stage, dir);
50
+ }
51
+ finally {
52
+ fs.rmSync(archive, { force: true });
53
+ fs.rmSync(stage, { recursive: true, force: true }); // no-op после rename
54
+ }
55
+ // GitLab/GitHub archive кладут всё в `<repo>-<sha>/` — единственный корневой каталог снимаем.
56
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
57
+ return entries.length === 1 && entries[0].isDirectory() ? path.join(dir, entries[0].name) : dir;
58
+ };
59
+ /**
60
+ * Резолв источника плагина в локальный каталог: `dir` (+ `subdir`) — проверка
61
+ * существования; `archive` — скачивание (с непустыми `headers`), распаковка в tmp-кэш,
62
+ * снятие единственного корневого каталога, затем `subdir`. Ошибки называют путь/URL.
63
+ */
64
+ export const resolveSource = async (spec, cwd = process.cwd()) => {
65
+ if (spec.dir !== undefined) {
66
+ const abs = path.resolve(cwd, spec.dir, spec.subdir ?? '');
67
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
68
+ throw new Error(`источник dir не найден: ${abs}`);
69
+ return abs;
70
+ }
71
+ if (spec.archive === undefined)
72
+ throw new Error('источник должен задавать dir либо archive');
73
+ const url = spec.archive;
74
+ const headers = Object.fromEntries(Object.entries(spec.headers ?? {}).filter(([, v]) => v !== ''));
75
+ let rootPromise = archiveCache.get(url);
76
+ if (!rootPromise) {
77
+ rootPromise = downloadArchive(url, headers);
78
+ archiveCache.set(url, rootPromise);
79
+ rootPromise.catch(() => archiveCache.delete(url)); // неудачу не кэшируем — следующая сборка повторит
80
+ }
81
+ const root = await rootPromise;
82
+ const result = spec.subdir ? path.join(root, spec.subdir) : root;
83
+ if (!fs.existsSync(result) || !fs.statSync(result).isDirectory())
84
+ throw new Error(`в архиве ${redactUrl(url)} нет каталога ${spec.subdir ?? '.'} (распаковано в ${root})`);
85
+ return result;
86
+ };
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ /** Identity ради вывода типа опций из схемы. */
2
+ export const definePlugin = (plugin) => plugin;
@@ -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
- const collectFiles = (entryAbs, acc = new Map()) => {
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) {
@@ -159,16 +161,27 @@ const buildCompileRequest = (entryAbs) => {
159
161
  return { fs: fsMap, inputPath: toKey(path.resolve(entryAbs)) };
160
162
  };
161
163
  // Рендер .d2 → SVG (Buffer). Ошибки компиляции пробрасываются с путём файла.
162
- const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
164
+ // Ошибка компиляции движок отдаёт JSON-массивом [{range, errmsg}] — вытаскиваем
165
+ // человекочитаемые errmsg (`file:line:col: текст`); иной формат отдаём как есть.
166
+ const d2ErrorText = (message) => {
167
+ try {
168
+ const parsed = JSON.parse(message);
169
+ if (Array.isArray(parsed) && parsed.every((x) => typeof x?.errmsg === 'string'))
170
+ return parsed.map((x) => x.errmsg).join('\n');
171
+ }
172
+ catch { }
173
+ return message;
174
+ };
175
+ const renderD2 = async (entryAbs, { layout = 'dagre', seed } = {}) => {
163
176
  const d2 = await getD2();
164
- const { fs: fsMap, inputPath } = buildCompileRequest(entryAbs);
177
+ const { fs: fsMap, inputPath } = buildCompileRequest(entryAbs, seed);
165
178
  let result;
166
179
  try {
167
180
  result = await d2.compile({ fs: fsMap, inputPath, options: { layout } });
168
181
  }
169
182
  catch (err) {
170
183
  const e = err;
171
- throw new Error(`Ошибка компиляции D2 (${entryAbs}):\n${e.message || e}`);
184
+ throw new Error(`Ошибка компиляции D2 (${entryAbs}):\n${d2ErrorText(e.message || String(e))}`);
172
185
  }
173
186
  let svg;
174
187
  try {
@@ -185,9 +198,9 @@ const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
185
198
  // диаграммы — аналог foldIncludes для PlantUML.
186
199
  // Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит
187
200
  // от машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
188
- const foldD2Imports = (entryAbs) => {
201
+ const foldD2Imports = (entryAbs, seed) => {
189
202
  const abs = path.resolve(entryAbs);
190
- const files = collectFilesCached(entryAbs);
203
+ const files = collectFilesCached(entryAbs, seed);
191
204
  const rel = (p) => path.relative(process.cwd(), p).split(path.sep).join('/');
192
205
  // Мемоизированную Map не мутируем (её же использует buildCompileRequest) —
193
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 + includes + renderKey, 'utf-8')
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, item.dir.replace(options.ROOT_FOLDER, ''), outName);
281
+ const bkFilePath = path.join(bkFolderName, relOut);
277
282
  // path to image in dist folder
278
- const filePath = path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), outName);
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-рендера (эта
@@ -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
- // yauzl/tar грузятся лениво (только при распаковке скачанного JRE) — сохраняем это
16
- // синхронным require через createRequire, а не тянем их на импорте модуля.
17
- const require = createRequire(import.meta.url);
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
- // Защита от zip-slip: путь записи, разрешённый относительно destDir, обязан
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,4 @@
1
+ export const BUILTIN_PLUGINS = {
2
+ openspec: async () => (await import("./openspec/index.js")).default,
3
+ openapi: async () => (await import("./openapi/index.js")).default
4
+ };
@@ -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
+ });