@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.
@@ -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
+ };
@@ -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) {
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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 + includes + renderKey, 'utf-8')
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, item.dir.replace(options.ROOT_FOLDER, ''), outName);
297
+ const bkFilePath = path.join(bkFolderName, relOut);
277
298
  // path to image in dist folder
278
- const filePath = path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), outName);
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
- throw new Error(`Диаграмма "${outName}": ${err.message || err}`);
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)
@@ -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
+ });
@@ -0,0 +1,58 @@
1
+ // Fenced-блоки ```plantuml / ```d2 в артефактах → диаграммы страницы (рендер общим
2
+ // локальным движком) + ссылка `![…](name.ext)` на их месте: 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}![${file}](${file})`;
54
+ });
55
+ return { markdown, diagrams };
56
+ }
57
+ };
58
+ };