@alfacapital-tech/c4builder 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.MD +69 -615
  2. package/dist/cli/commands/check.js +43 -26
  3. package/dist/cli/commands/help.js +5 -2
  4. package/dist/cli/commands/jre.js +6 -6
  5. package/dist/cli/commands/list.js +4 -1
  6. package/dist/cli/commands/new.js +6 -6
  7. package/dist/cli/commands/site.js +3 -2
  8. package/dist/cli/dispatch.js +38 -18
  9. package/dist/cli/wizard/collect.js +16 -10
  10. package/dist/config/defaults.js +1 -0
  11. package/dist/config/options.js +1 -1
  12. package/dist/config/schema.js +5 -2
  13. package/dist/core/build.js +15 -6
  14. package/dist/core/compose/docsify.template.js +0 -1
  15. package/dist/core/compose/markdown.js +85 -19
  16. package/dist/core/plugins/hooks.js +1 -1
  17. package/dist/core/plugins/load.js +6 -6
  18. package/dist/core/plugins/source.js +6 -6
  19. package/dist/core/plugins/tree.js +5 -5
  20. package/dist/core/render/bpmnrenderer.js +522 -0
  21. package/dist/core/render/d2renderer.js +19 -19
  22. package/dist/core/render/diagrams.js +64 -45
  23. package/dist/core/render/jre.js +14 -14
  24. package/dist/core/render/pngraster.js +4 -4
  25. package/dist/{plugins/openspec → core/scan}/fences.js +11 -10
  26. package/dist/core/scan/tree.js +37 -14
  27. package/dist/plugins/openapi/index.js +17 -7
  28. package/dist/plugins/openspec/index.js +1 -1
  29. package/dist/plugins/openspec/render.js +5 -2
  30. package/dist/util/archive.js +1 -1
  31. package/dist/util/http.js +3 -3
  32. package/dist/util/lock.js +3 -3
  33. package/dist/util/paths.js +1 -1
  34. package/package.json +9 -4
  35. package/template/AGENTS.md +52 -0
  36. package/template/readme.md +3 -3
  37. package/template/src/5 BPMN Example/account-opening.bpmn +69 -0
  38. package/template/src/5 BPMN Example/account-opening.md +7 -0
  39. package/dist/util/glob.js +0 -69
@@ -11,6 +11,8 @@ import { httpGetBuffer } from "../../util/http.js";
11
11
  // D2-бэкенд: только статические хелперы (парсинг импортов) грузятся сразу; сам
12
12
  // движок @terrastruct/d2 тянется лениво внутри renderD2/teardownD2.
13
13
  import { renderD2, foldD2Imports } from "./d2renderer.js";
14
+ // BPMN-бэкенд: пакеты движка грузятся лениво внутри renderBpmn.
15
+ import { renderBpmn, BPMN_CACHE_PACKAGES } from "./bpmnrenderer.js";
14
16
  import { resolveJava } from "./jre.js";
15
17
  // PNG-выход: SVG обоих движков растеризуется resvg (ленивая загрузка внутри модуля).
16
18
  import { rasterizeSvgToPng } from "./pngraster.js";
@@ -63,24 +65,43 @@ const runPool = async (tasks, concurrency) => {
63
65
  if (firstError !== undefined)
64
66
  throw firstError;
65
67
  };
66
- // Версия движка @terrastruct/d2 для чексуммы кэша (апгрейд движка → перерендер).
67
- // Читаем package.json лениво и один раз: pure-PlantUML сборки его не трогают.
68
- let d2VersionCache = null;
69
- const d2EngineVersion = () => {
70
- if (d2VersionCache !== null)
71
- return d2VersionCache;
72
- try {
73
- const req = createRequire(import.meta.url);
74
- d2VersionCache = req('@terrastruct/d2/package.json').version;
75
- }
76
- catch {
77
- d2VersionCache = 'unknown';
68
+ // Версии пакетов движков (D2, BPMN) для чексуммы кэша (апгрейд движка → перерендер).
69
+ // Читаем package.json лениво и один раз: pure-PlantUML сборки их не трогают.
70
+ const versionCache = new Map();
71
+ const pkgVersion = (name) => {
72
+ let version = versionCache.get(name);
73
+ if (version === undefined) {
74
+ // package.json ищем по путям поиска node_modules, а не require(`${name}/package.json`):
75
+ // пакеты с полем exports (bpmn-auto-layout) этот подпуть не отдают — версия молча
76
+ // становилась бы 'unknown', и апгрейд движка не перерисовывал бы диаграммы.
77
+ try {
78
+ const paths = createRequire(import.meta.url).resolve.paths(name) ?? [];
79
+ const dir = paths.find((p) => fs.existsSync(path.join(p, name, 'package.json')));
80
+ if (!dir)
81
+ throw new Error(`${name} not found`);
82
+ version = JSON.parse(fs.readFileSync(path.join(dir, name, 'package.json'), 'utf8')).version;
83
+ }
84
+ catch {
85
+ version = 'unknown';
86
+ }
87
+ versionCache.set(name, version);
78
88
  }
79
- return d2VersionCache;
89
+ return version;
80
90
  };
81
91
  // В ключ кеша: смена режима не должна отдавать картинку, отрисованную другим шрифтом
82
92
  // (D2 тоже — его PNG растрирует общий resvg).
83
93
  export const fontCacheTag = (options) => options.USE_SYSTEM_FONTS ? 'system' : DEFAULT_FONT_NAME;
94
+ // renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа импортов.
95
+ // Без него смена layout (D2) или charset (PlantUML) не меняла чексумму → из бэкапа
96
+ // копировалась старая картинка (новые параметры молча игнорировались, кэш чистился
97
+ // только через --reset). Формат/движок/версия движка тоже в ключе: их смена обязана
98
+ // приводить к перерендеру.
99
+ export const renderKey = (diagram, options, outFormat) => (diagram.engine === 'd2'
100
+ ? `d2\0${pkgVersion('@terrastruct/d2')}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
101
+ : diagram.engine === 'bpmn'
102
+ ? `bpmn\0${BPMN_CACHE_PACKAGES.map(pkgVersion).join('\0')}\0fmt=${outFormat}`
103
+ : `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
104
+ `\0font=${fontCacheTag(options)}`;
84
105
  export const getMime = (format) => {
85
106
  if (format === 'svg')
86
107
  return `image/svg+xml`;
@@ -129,7 +150,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
129
150
  // с одним guard вместо non-null assertion на каждом обращении.
130
151
  const { stdin, stdout: childOut, stderr: childErr } = child;
131
152
  if (!stdin || !childOut || !childErr) {
132
- return reject(new Error('PlantUML: не удалось открыть stdio-потоки процесса java'));
153
+ return reject(new Error('PlantUML: failed to open stdio streams of the java process'));
133
154
  }
134
155
  // Smetana печатает диагностический шум (UNSURE_ABOUT…) — это не ошибка
135
156
  // рендера, пользователю не показываем; остальной stderr пробрасываем.
@@ -149,7 +170,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
149
170
  sigkillTimer = setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS);
150
171
  sigkillTimer.unref();
151
172
  const tail = cleanStderr();
152
- reject(new Error(`PlantUML не завершился за ${RENDER_TIMEOUT_MS / 1000} c и был прерван` +
173
+ reject(new Error(`PlantUML did not finish in ${RENDER_TIMEOUT_MS / 1000} s and was aborted` +
153
174
  (tail ? `\n${tail}` : '')));
154
175
  }, RENDER_TIMEOUT_MS);
155
176
  timer.unref();
@@ -175,7 +196,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
175
196
  settled = true;
176
197
  const errText = cleanStderr();
177
198
  if (code !== 0) {
178
- return reject(new Error(`PlantUML завершился с кодом ${code}\n${errText}`));
199
+ return reject(new Error(`PlantUML exited with code ${code}\n${errText}`));
179
200
  }
180
201
  if (errText)
181
202
  process.stderr.write(`${errText}\n`);
@@ -192,7 +213,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
192
213
  // сайт остаётся собранным (так же вёл себя онлайн-рендер docsify-plantuml).
193
214
  const errorPlaceholderSvg = (name, reason) => {
194
215
  const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
195
- const lines = [`${name}: диаграмма не отрендерена`, ...reason.split('\n')]
216
+ const lines = [`${name}: diagram not rendered`, ...reason.split('\n')]
196
217
  .flatMap((l) => l.match(/.{1,88}/g) ?? [''])
197
218
  .slice(0, 8);
198
219
  const w = 720;
@@ -222,8 +243,8 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
222
243
  const pinned = options.LEGACY_PLANTUML_VERSION;
223
244
  if (pinned && pinned !== 'latest' && pinned !== VENDORED_JAR.version) {
224
245
  console.log(chalk.bold(chalk.yellow('WARNING:')));
225
- console.log(chalk.yellow(`Выбор версии PlantUML удалён — сборка идёт вендорным JAR ${VENDORED_JAR.version}. ` +
226
- `Ключ plantumlVersion: "${pinned}" в .c4builder можно убрать.`));
246
+ console.log(chalk.yellow(`Choosing the PlantUML version was removed — builds use the vendored JAR ${VENDORED_JAR.version}. ` +
247
+ `The plantumlVersion: "${pinned}" key can be removed from .c4builder.`));
227
248
  }
228
249
  // JRE резолвится ЛЕНИВО и один раз за сборку: только когда встретилась PlantUML-
229
250
  // диаграмма, которую РЕАЛЬНО надо рендерить (кэш не сработал — restore-путь ниже
@@ -255,29 +276,25 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
255
276
  // перепутываются). Движок всё равно однопоточный, так что сериализация D2 бесплатна.
256
277
  // PlantUML — отдельные процессы java, их гоняем пулом renderConcurrency().
257
278
  const d2Tasks = [];
279
+ // BPMN рендерится в общем окне jsdom главного потока — параллельность ничего не даёт,
280
+ // а последовательная очередь исключает гонки на общем состоянии бандла bpmn-js.
281
+ const bpmnTasks = [];
258
282
  const otherTasks = [];
259
283
  for (const item of tree) {
260
284
  for (const diagram of item.diagrams) {
261
285
  // Чексумма = контент диаграммы + свёрнутый граф её зависимостей, чтобы
262
286
  // правка включаемого/импортируемого файла инвалидировала кэш: PlantUML —
263
- // !include-граф (.iuml и пр.), D2 — граф @/...@-импортов.
287
+ // !include-граф (.iuml и пр.), D2 — граф @/...@-импортов. У BPMN зависимостей нет.
264
288
  const body = `${diagram.content || ''}`;
265
- // entryPath нужен только D2 (граф импортов + рендер); для PlantUML не считаем.
266
- // entryPath !== null ⟺ engine === 'd2' — используем как сужение типа вместо assertion.
267
- const entryPath = diagram.engine === 'd2' ? path.join(item.dir, diagram.dir) : null;
268
- const includes = entryPath !== null
289
+ const entryPath = path.join(item.dir, diagram.dir);
290
+ const isD2 = diagram.engine === 'd2';
291
+ const isBpmn = diagram.engine === 'bpmn';
292
+ const includes = isD2
269
293
  ? foldD2Imports(entryPath, body)
270
- : foldIncludes(body, item.dir, item.dir, new Set());
294
+ : isBpmn
295
+ ? ''
296
+ : foldIncludes(body, item.dir, item.dir, new Set());
271
297
  const outFormat = diagramOutputFormat(diagram, options);
272
- // renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
273
- // импортов. Без него смена layout (D2) или charset (PlantUML) не меняла
274
- // чексумму → из бэкапа копировалась старая картинка (новые параметры молча
275
- // игнорировались, кэш чистился только через --reset). Формат/движок/версия
276
- // движка тоже в ключе: их смена обязана приводить к перерендеру.
277
- const renderKey = (entryPath !== null
278
- ? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
279
- : `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
280
- `\0font=${fontCacheTag(options)}`;
281
298
  const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
282
299
  const relOut = path.join(item.dir.replace(options.ROOT_FOLDER, ''), outName);
283
300
  // Путь выхода — тоже в чексумме: восстановление из бэкапа идёт по паре
@@ -286,7 +303,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
286
303
  // молча получали бы чужую старую картинку.
287
304
  const cksum = crypto
288
305
  .createHash('sha256')
289
- .update(`${body}${includes}${renderKey}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
306
+ .update(`${body}${includes}${renderKey(diagram, options, outFormat)}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
290
307
  .digest('hex');
291
308
  // Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
292
309
  // порядок массива не зависит от того, кто из воркеров финиширует раньше —
@@ -323,9 +340,13 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
323
340
  // Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
324
341
  let rendered;
325
342
  try {
326
- if (entryPath !== null) {
343
+ if (isD2) {
327
344
  rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
328
345
  }
346
+ else if (isBpmn) {
347
+ // Путь для сообщений валидации; у виртуальных страниц — их источник.
348
+ rendered = await renderBpmn(body, source ?? path.relative(process.cwd(), entryPath).split(path.sep).join('/'));
349
+ }
329
350
  else {
330
351
  // Резолв JRE отложен до первого реального PlantUML-рендера (эта
331
352
  // ветка исполняется лишь при промахе кэша). getJava мемоизирует.
@@ -349,7 +370,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
349
370
  catch (err) {
350
371
  // Имя диаграммы в ошибку: renderDiagram/renderD2 сами его не знают.
351
372
  const reason = `${err.message || err}`;
352
- const msg = `Диаграмма "${outName}"${source ? ` (${source})` : ''}: ${reason}`;
373
+ const msg = `Diagram "${outName}"${source ? ` (${source})` : ''}: ${reason}`;
353
374
  if (!soft)
354
375
  throw new Error(msg);
355
376
  // Диаграммы из markdown-блоков плагинов писались под онлайн-рендер,
@@ -359,20 +380,18 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
359
380
  await writeFile(filePath, outFormat === 'png' ? await rasterizeSvgToPng(svg, options.USE_SYSTEM_FONTS) : svg);
360
381
  }
361
382
  });
362
- if (entryPath !== null)
363
- d2Tasks.push(task);
364
- else
365
- otherTasks.push(task);
383
+ (isD2 ? d2Tasks : isBpmn ? bpmnTasks : otherTasks).push(task);
366
384
  }
367
385
  }
368
- // PlantUML-пул и D2-очередь работают независимо (процессы java vs WASM-worker) и
369
- // параллельно друг другу. allSettled — чтобы падение одной очереди не оставляло
370
- // вторую как floating promise с висящими JVM: обе доосушаются, затем бросаем первую
386
+ // PlantUML-пул, D2- и BPMN-очереди работают независимо (процессы java, WASM-worker,
387
+ // главный поток) и параллельно друг другу. allSettled — чтобы падение одной очереди не
388
+ // оставляло другие как floating promise с висящими JVM: все доосушаются, затем бросаем первую
371
389
  // ошибку (приоритет — PlantUML-пул, порядок между очередями не значим).
372
390
  try {
373
391
  const results = await Promise.allSettled([
374
392
  runPool(otherTasks, renderConcurrency()),
375
- runPool(d2Tasks, 1)
393
+ runPool(d2Tasks, 1),
394
+ runPool(bpmnTasks, 1)
376
395
  ]);
377
396
  const failed = results.find((r) => r.status === 'rejected');
378
397
  if (failed && failed.status === 'rejected')
@@ -177,15 +177,15 @@ const fetchAssetMeta = async () => {
177
177
  const json = await httpGetJson(url, { headers: JRE_UA });
178
178
  const asset = Array.isArray(json) ? json.find((a) => a.binary?.package) : null;
179
179
  if (!asset) {
180
- throw new Error(`Adoptium не отдал JRE-сборку под ${adoptiumOs()}/${adoptiumArch()} (Temurin ${TEMURIN_FEATURE})`);
180
+ throw new Error(`Adoptium has no JRE build for ${adoptiumOs()}/${adoptiumArch()} (Temurin ${TEMURIN_FEATURE})`);
181
181
  }
182
182
  const pkg = asset.binary.package;
183
183
  if (!pkg.link)
184
- throw new Error('Adoptium не вернул ссылку на архив JRE');
184
+ throw new Error('Adoptium returned no JRE archive link');
185
185
  // Отсутствие checksum — ошибка, а не молчаливый пропуск проверки целостности.
186
186
  if (!pkg.checksum) {
187
- throw new Error(`Adoptium не вернул sha256 для ${pkg.name || 'архива JRE'}: ` +
188
- 'отказ принимать архив без проверки целостности');
187
+ throw new Error(`Adoptium returned no sha256 for ${pkg.name || 'the JRE archive'}: ` +
188
+ 'refusing to accept an archive without an integrity check');
189
189
  }
190
190
  return { link: pkg.link, sha256: pkg.checksum, name: pkg.name, release: asset.release_name };
191
191
  };
@@ -202,9 +202,9 @@ const downloadAndVerify = async (link, expectedSha, destFile) => {
202
202
  });
203
203
  const actual = hash.digest('hex').toLowerCase();
204
204
  if (!expectedSha)
205
- throw new Error('Нет ожидаемого sha256 — проверка целостности архива невозможна');
205
+ throw new Error('No expected sha256 — cannot verify archive integrity');
206
206
  if (actual !== expectedSha.toLowerCase()) {
207
- throw new Error(`sha256 архива не совпал: ожидалось ${expectedSha}, получено ${actual}`);
207
+ throw new Error(`archive sha256 mismatch: expected ${expectedSha}, got ${actual}`);
208
208
  }
209
209
  };
210
210
  const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : extractTarGz(archive, destDir);
@@ -223,13 +223,13 @@ const downloadJre = async ({ log } = {}) => {
223
223
  const stageDir = `${dir}.tmp-${process.pid}`;
224
224
  try {
225
225
  if (log)
226
- log(`Скачивание Temurin ${TEMURIN_FEATURE} JRE (${meta.release || meta.name})…`);
226
+ log(`Downloading Temurin ${TEMURIN_FEATURE} JRE (${meta.release || meta.name})…`);
227
227
  await downloadAndVerify(meta.link, meta.sha256, tmpArchive);
228
228
  fs.rmSync(stageDir, { recursive: true, force: true });
229
229
  fs.mkdirSync(stageDir, { recursive: true });
230
230
  await extractArchive(tmpArchive, stageDir, isZip);
231
231
  if (!findJavaUnder(stageDir)) {
232
- throw new Error('JRE распакован, но исполняемый bin/java не найден');
232
+ throw new Error('JRE extracted, but the bin/java executable was not found');
233
233
  }
234
234
  fs.writeFileSync(markerPath(stageDir), JSON.stringify({
235
235
  schema: JRE_CACHE_SCHEMA,
@@ -261,15 +261,15 @@ const downloadJre = async ({ log } = {}) => {
261
261
  }
262
262
  const bin = findJavaUnder(dir);
263
263
  if (!bin)
264
- throw new Error('JRE распакован, но исполняемый bin/java не найден');
264
+ throw new Error('JRE extracted, but the bin/java executable was not found');
265
265
  return { path: bin, source: 'download' };
266
266
  };
267
267
  const failureMessage = (cause) => [
268
- 'Не удалось получить Java для рендеринга PlantUML-диаграмм.',
269
- 'Ни системная java (17+), ни кеш, ни скачивание с Adoptium не сработали. Сделайте одно из двух:',
270
- ' • установите JRE 17+ (например, Eclipse Temurin) — java на PATH или задайте JAVA_HOME;',
271
- ' • выполните `c4builder jre install`, чтобы загрузить JRE в локальный кеш.',
272
- cause?.message ? `Причина: ${cause.message}` : ''
268
+ 'Could not get Java to render PlantUML diagrams.',
269
+ 'Neither system java (17+), the cache, nor a download from Adoptium worked. Do one of the following:',
270
+ ' • install JRE 17+ (e.g. Eclipse Temurin) — java on PATH or set JAVA_HOME;',
271
+ ' • run `c4builder jre install` to download a JRE into the local cache.',
272
+ cause?.message ? `Cause: ${cause.message}` : ''
273
273
  ]
274
274
  .filter(Boolean)
275
275
  .join('\n');
@@ -6,7 +6,7 @@ import { FONTS_DIR, DEFAULT_FONT_NAME } from "./fonts.js";
6
6
  // растеризации, не на импорте модуля).
7
7
  const require = createRequire(import.meta.url);
8
8
  let resvgMod = null;
9
- const getResvg = () => {
9
+ export const getResvg = () => {
10
10
  if (resvgMod)
11
11
  return resvgMod;
12
12
  try {
@@ -14,9 +14,9 @@ const getResvg = () => {
14
14
  }
15
15
  catch (err) {
16
16
  const e = err;
17
- throw new Error('Для PNG-вывода (DIAGRAM_FORMAT=png) нужен пакет @resvg/resvg-js.\n' +
18
- 'Установите его: npm install @resvg/resvg-js\n' +
19
- `Исходная ошибка: ${e.message || e}`);
17
+ throw new Error('PNG output (DIAGRAM_FORMAT=png) requires the @resvg/resvg-js package.\n' +
18
+ 'Install it: npm install @resvg/resvg-js\n' +
19
+ `Original error: ${e.message || e}`);
20
20
  }
21
21
  return resvgMod;
22
22
  };
@@ -1,7 +1,7 @@
1
- // Fenced-блоки ```plantuml / ```d2 в артефактах → диаграммы страницы (рендер общим
2
- // локальным движком) + ссылка `![…](name.ext)` на их месте: compose подставит картинку
3
- // inline, а клиентский docsify-plantuml (онлайн-рендер в браузере) не сработает —
4
- // fence в выводе уже отсутствует.
1
+ // Fenced-блоки диаграмм в markdown (страницы проекта, артефакты плагинов) → диаграммы
2
+ // страницы (рендер общим локальным движком) + ссылка `![…](name.ext)` на их месте:
3
+ // compose подставит картинку inline, а клиентский docsify-plantuml (онлайн-рендер в
4
+ // браузере) не сработает — fence в выводе уже отсутствует.
5
5
  import crypto from 'node:crypto';
6
6
  // Fenced-блок по CommonMark: открывающая ``` или ~~~ (≥3) с любым отступом (fence в
7
7
  // списке), инфо-строка, тело (может быть пустым), закрывающая того же вида не короче
@@ -19,18 +19,19 @@ export const mapOutsideFences = (md, fn) => {
19
19
  return out + fn(md.slice(last));
20
20
  };
21
21
  /**
22
- * Экстрактор диаграмм одной страницы. Имя файла — `<base>-<sha1 контента>`: не зависит
23
- * от позиции fence, поэтому вставка/удаление соседнего блока не сдвигает имена и не
24
- * подсовывает картинку соседа из кэша; одинаковые блоки дают одну диаграмму.
22
+ * Экстрактор диаграмм одной страницы; извлекаются только блоки языков `langs` (из
23
+ * plantuml/puml/d2), остальные остаются кодом. Имя файла — `<base>-<sha1 контента>`: не
24
+ * зависит от позиции fence, поэтому вставка/удаление соседнего блока не сдвигает имена и
25
+ * не подсовывает картинку соседа из кэша; одинаковые блоки дают одну диаграмму.
25
26
  */
26
- export const createFenceExtractor = () => {
27
+ export const createFenceExtractor = (langs) => {
27
28
  const seen = new Set();
28
29
  return {
29
30
  extract(md, base, source) {
30
31
  const diagrams = [];
31
32
  const markdown = md.replace(FENCE_RE, (whole, indent, _fence, info, body) => {
32
33
  const lang = info.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
33
- const ext = ENGINE_EXT[lang];
34
+ const ext = langs.includes(lang) ? ENGINE_EXT[lang] : undefined;
34
35
  if (!ext)
35
36
  return whole;
36
37
  // Отступ fence (в списке) снимаем со строк тела, как CommonMark.
@@ -45,7 +46,7 @@ export const createFenceExtractor = () => {
45
46
  const file = `${base}-${hash}${ext}`;
46
47
  if (!seen.has(file)) {
47
48
  seen.add(file);
48
- // soft: блок в артефакте — не файл проекта; синтаксическая
49
+ // soft: блок в markdown — не файл диаграммы; синтаксическая
49
50
  // ошибка в нём не должна ронять весь сайт (раньше такие блоки
50
51
  // рендерил браузер и показывал картинку с ошибкой).
51
52
  diagrams.push({ file, content, source, soft: true });
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import fsextra from 'fs-extra';
4
4
  import { makeDirectory, readFile } from "../../util/utils.js";
5
+ import { createFenceExtractor } from "./fences.js";
5
6
  // Единый реестр движков диаграмм: расширение (с точкой) → рендерер + способности.
6
7
  // ЕДИНСТВЕННЫЙ источник истины о том, какие файлы считаются диаграммами. Из него
7
8
  // строятся фильтр scan (ниже), regex ссылок в compose/markdown.ts и ветвление
@@ -9,7 +10,8 @@ import { makeDirectory, readFile } from "../../util/utils.js";
9
10
  // здесь; фазы подхватят его без синхронной правки.
10
11
  export const DIAGRAM_ENGINES = [
11
12
  { ext: '.puml', engine: 'plantuml', remoteRender: true },
12
- { ext: '.d2', engine: 'd2', remoteRender: false }
13
+ { ext: '.d2', engine: 'd2', remoteRender: false },
14
+ { ext: '.bpmn', engine: 'bpmn', remoteRender: false }
13
15
  ];
14
16
  // Расширение (lowercase, с точкой) → движок. undefined ⇒ файл не диаграмма.
15
17
  export const engineForExt = (ext) => DIAGRAM_ENGINES.find((e) => e.ext === ext)?.engine;
@@ -73,10 +75,25 @@ export const generateTree = async (dir, options) => {
73
75
  await build(childDir, dir);
74
76
  }
75
77
  }
78
+ // Блоки ```plantuml в страницах рендерим при сборке: иначе текст диаграммы доезжал
79
+ // до браузера и клиентский docsify-plantuml (в т.ч. в пользовательских шаблонах)
80
+ // отправлял его на plantuml.com. puml/d2 остаются кодом — так показывают исходник.
81
+ const fences = createFenceExtractor(['plantuml']);
76
82
  const mdFiles = files.filter((x) => path.extname(x).toLowerCase() === '.md');
77
83
  for (const mdFile of mdFiles) {
78
- const fileContents = await readFile(path.join(dir, mdFile));
79
- item.mdFiles.push(fileContents);
84
+ const mdPath = path.join(dir, mdFile);
85
+ const { markdown, diagrams } = fences.extract((await readFile(mdPath)).toString(), path.parse(mdFile).name, path.relative(process.cwd(), mdPath).split(path.sep).join('/'));
86
+ item.mdFiles.push(markdown);
87
+ for (const d of diagrams)
88
+ item.diagrams.push({
89
+ dir: d.file,
90
+ ext: '.puml',
91
+ engine: 'plantuml',
92
+ content: d.content,
93
+ isDitaa: /@startditaa/i.test(d.content),
94
+ source: d.source,
95
+ soft: d.soft
96
+ });
80
97
  }
81
98
  // Диаграммы: расширение → движок берём из DIAGRAM_ENGINES. Поле dir — имя файла
82
99
  // (историческое), engine выбирает рендерер, isDitaa — только для PlantUML.
@@ -91,7 +108,7 @@ export const generateTree = async (dir, options) => {
91
108
  item.diagrams.push({ dir: diagramFile, ext, engine, content: fileContents, isDitaa });
92
109
  }
93
110
  item.diagrams.sort((a, b) => `${a.dir}`.localeCompare(b.dir));
94
- //copy all other files (.d2 исходники, как и .puml, не копируем — они рендерятся)
111
+ //copy all other files (.d2/.bpmn исходники, как и .puml, не копируем — они рендерятся)
95
112
  const otherFiles = options.EXCLUDE_OTHER_FILES
96
113
  ? []
97
114
  : files.filter((x) => {
@@ -118,8 +135,8 @@ export const generateTree = async (dir, options) => {
118
135
  for (const d of item.diagrams) {
119
136
  const out = `${path.parse(d.dir).name}.${diagramOutputFormat(d, options)}`;
120
137
  if (seen.has(out))
121
- throw new Error(`Коллизия имени выхода '${out}' в ${item.dir}: '${seen.get(out)}' и '${d.dir}' ` +
122
- `рендерятся в один файл. Переименуйте одну из диаграмм.`);
138
+ throw new Error(`Output name collision '${out}' in ${item.dir}: '${seen.get(out)}' and '${d.dir}' ` +
139
+ `render to the same file. Rename one of the diagrams.`);
123
140
  seen.set(out, d.dir);
124
141
  }
125
142
  }
@@ -131,14 +148,13 @@ export const generateTree = async (dir, options) => {
131
148
  // build() — в watch-режиме иначе отдавались бы устаревшие данные.
132
149
  const includeReadCache = new Map();
133
150
  export const clearIncludeCache = () => includeReadCache.clear();
134
- // Свернуть локальные !include (.iuml и пр.) диаграммы в материал для чексуммы — рекурсивно.
135
- // Иначе правка включённого .iuml не инвалидирует кэш и на сайт уезжает устаревший рендер.
151
+ // Локальные !include (.iuml и пр.) диаграммы рекурсивно, в DFS-порядке: каждый файл —
152
+ // один раз (visited). Один резолвер на чексумму кэша (foldIncludes) и приложение
153
+ // llms-full.txt — иначе граф зависимостей у них разъехался бы.
136
154
  // URL (!include https://…) и stdlib (!include <…>) пропускаем: локально не меняются.
137
- // Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит от
138
- // машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
139
- export const foldIncludes = (content, fileDir, searchDir, visited) => {
155
+ export const collectIncludes = (content, fileDir, searchDir, visited) => {
140
156
  const re = /^[ \t]*!include(?:_once|_many|sub|url)?[ \t]+(.+?)[ \t]*$/gim;
141
- let out = '';
157
+ const out = [];
142
158
  let m;
143
159
  // biome-ignore lint/suspicious/noAssignInExpressions: идиома regex.exec() в условии while
144
160
  while ((m = re.exec(content)) !== null) {
@@ -166,8 +182,15 @@ export const foldIncludes = (content, fileDir, searchDir, visited) => {
166
182
  }
167
183
  if (inc === null)
168
184
  continue;
169
- out += ` ${path.relative(process.cwd(), resolved).split(path.sep).join('/')} ${inc}`;
170
- out += foldIncludes(inc, path.dirname(resolved), searchDir, visited); // вложенные include
185
+ out.push({ abs: resolved, content: inc });
186
+ out.push(...collectIncludes(inc, path.dirname(resolved), searchDir, visited)); // вложенные include
171
187
  }
172
188
  return out;
173
189
  };
190
+ // Свернуть include-граф диаграммы в материал для чексуммы: правка включённого .iuml
191
+ // обязана инвалидировать кэш, иначе на сайт уезжает устаревший рендер.
192
+ // Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит от
193
+ // машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
194
+ export const foldIncludes = (content, fileDir, searchDir, visited) => collectIncludes(content, fileDir, searchDir, visited)
195
+ .map((inc) => ` ${path.relative(process.cwd(), inc.abs).split(path.sep).join('/')} ${inc.content}`)
196
+ .join('');
@@ -8,7 +8,7 @@ import { z } from 'zod';
8
8
  import { outputDirs } from "../../config/options.js";
9
9
  import { redactUrl } from "../../core/plugins/source.js";
10
10
  import { definePlugin } from "../../core/plugins/types.js";
11
- import { globFiles } from "../../util/glob.js";
11
+ import { isPathInside } from "../../util/archive.js";
12
12
  import { VENDOR_DIR } from "../../util/paths.js";
13
13
  import { encodeURIPath } from "../../util/utils.js";
14
14
  const optionsSchema = z
@@ -22,8 +22,17 @@ const optionsSchema = z
22
22
  })
23
23
  .strict()
24
24
  .refine((o) => (o.dir === undefined) !== (o.archive === undefined), {
25
- message: 'нужен ровно один источник: dir либо archive'
25
+ message: 'exactly one source required: dir or archive'
26
26
  });
27
+ // Файлы под root по glob: posix-пути, сортировка. Каталоги с точкой (.git) fs.glob не
28
+ // обходит сам. Выходные каталоги (skip) отсеиваются по результату, а не в exclude:
29
+ // под `**` Node передаёт в exclude лишь имя записи, и вложенный `x/docs` был бы
30
+ // неотличим от корневого `docs`.
31
+ const findFiles = (root, pattern, skip) => fs
32
+ .globSync(pattern, { cwd: root, exclude: (p) => path.basename(p) === 'node_modules' })
33
+ .filter((rel) => !skip.some((d) => isPathInside(d, path.resolve(root, rel))))
34
+ .map((rel) => rel.split(path.sep).join('/'))
35
+ .sort();
27
36
  // Имя страницы: родительская папка спеки; в корне источника — имя файла без расширения.
28
37
  const pageName = (rel) => {
29
38
  const dir = path.posix.dirname(rel);
@@ -52,20 +61,20 @@ export default definePlugin({
52
61
  // Источник может быть предком выходного каталога (dir: '.') — свои же копии
53
62
  // спек прошлой сборки в dist/dist_bk не считаются источником.
54
63
  const skip = outputDirs(ctx.options);
55
- const files = globFiles(root, o.glob, skip);
64
+ const files = findFiles(root, o.glob, skip);
56
65
  if (!files.length)
57
- throw new Error(`по шаблону "${o.glob}" в ${source} не найдено ни одной спеки`);
66
+ throw new Error(`no specs match "${o.glob}" in ${source}`);
58
67
  const names = new Map();
59
68
  for (const rel of files) {
60
69
  const name = pageName(rel);
61
70
  const dup = names.get(name);
62
71
  if (dup)
63
- throw new Error(`две спеки дают одно имя страницы "${name}": ${dup} и ${rel}`);
72
+ throw new Error(`two specs give the same page name "${name}": ${dup} and ${rel}`);
64
73
  names.set(name, rel);
65
74
  }
66
75
  // Статикой в dist — все yaml/json источника, а не только совпавшие с glob: $ref
67
76
  // может вести в общие схемы (components), не являющиеся спеками.
68
- for (const rel of globFiles(root, '**/*.{yaml,yml,json}', skip)) {
77
+ for (const rel of findFiles(root, '**/*.{yaml,yml,json}', skip)) {
69
78
  const dest = path.join(ctx.options.DIST_FOLDER, mount, '_specs', rel);
70
79
  fs.mkdirSync(path.dirname(dest), { recursive: true });
71
80
  fs.copyFileSync(path.join(root, rel), dest);
@@ -79,6 +88,7 @@ export default definePlugin({
79
88
  for (const [name, rel] of sorted) {
80
89
  // Спека грузится swagger-ui по ссылке от корня сайта (SPA: base = index.html);
81
90
  // относительные $ref внутри спек резолвятся от её URL — структура сохранена.
91
+ // validatorUrl: null — иначе swagger-ui шлёт адрес спеки на validator.swagger.io.
82
92
  const specUrl = encodeURIPath(path.posix.join(mount, '_specs', rel));
83
93
  const domId = `swagger-${name.replace(/[^A-Za-z0-9_-]/g, '_')}`;
84
94
  ctx.addPage({
@@ -86,7 +96,7 @@ export default definePlugin({
86
96
  markdown: [
87
97
  `<div id="${domId}"></div>`,
88
98
  '<script>',
89
- `SwaggerUIBundle({ url: '${specUrl}', dom_id: '#${domId}' });`,
99
+ `SwaggerUIBundle({ url: '${specUrl}', dom_id: '#${domId}', validatorUrl: null });`,
90
100
  '</script>'
91
101
  ].join('\n')
92
102
  });
@@ -22,7 +22,7 @@ export default definePlugin({
22
22
  afterScan(ctx, o) {
23
23
  const storeDir = path.resolve(o.dir);
24
24
  if (!fs.existsSync(storeDir))
25
- throw new Error(`OpenSpec store не найден: ${storeDir} (опция dir плагина openspec)`);
25
+ throw new Error(`OpenSpec store not found: ${storeDir} (dir option of the openspec plugin)`);
26
26
  const store = scanStore(storeDir);
27
27
  const { mount } = o;
28
28
  const { options } = ctx;
@@ -1,8 +1,8 @@
1
1
  // Markdown страниц раздела OpenSpec: сводка, change и его подстраницы, индексы подразделов.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { createFenceExtractor, mapOutsideFences } from "../../core/scan/fences.js";
4
5
  import { encodeURIPath } from "../../util/utils.js";
5
- import { createFenceExtractor, mapOutsideFences } from "./fences.js";
6
6
  // Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
7
7
  // а docsify без relativePath резолвит ссылки от корня сайта.
8
8
  export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
@@ -80,6 +80,9 @@ const ignoreTitle = (title) => {
80
80
  };
81
81
  /** Путь файла для сообщений — от cwd, чтобы его можно было открыть как есть. */
82
82
  export const relSource = (root, rel) => path.relative(process.cwd(), path.join(root, rel)).split(path.sep).join('/');
83
+ // В артефактах OpenSpec любой блок диаграммы — иллюстрация, а не пример исходника
84
+ // (в отличие от страниц проекта, где извлекается только ```plantuml).
85
+ const FENCE_LANGS = ['plantuml', 'puml', 'd2'];
83
86
  /**
84
87
  * Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
85
88
  * на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
@@ -88,7 +91,7 @@ export const relSource = (root, rel) => path.relative(process.cwd(), path.join(r
88
91
  * `fromDir` — posix-каталог файла относительно `srcRoot` (ссылки в нём резолвятся от файла).
89
92
  */
90
93
  export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, source, options }) => {
91
- const { markdown, diagrams } = createFenceExtractor().extract(content, base, source);
94
+ const { markdown, diagrams } = createFenceExtractor(FENCE_LANGS).extract(content, base, source);
92
95
  const files = [];
93
96
  const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
94
97
  const inner = text.includes('![') ? rewrite(text) : text;
@@ -20,7 +20,7 @@ export const extractZip = (archive, destDir) => new Promise((resolve, reject) =>
20
20
  zip.on('entry', (entry) => {
21
21
  // zip-slip: злонамеренная запись `../../evil` вышла бы за destDir.
22
22
  if (!isPathInside(destDir, entry.fileName)) {
23
- return reject(new Error(`Небезопасный путь в архиве (выход за каталог): ${entry.fileName}`));
23
+ return reject(new Error(`Unsafe path in archive (escapes the folder): ${entry.fileName}`));
24
24
  }
25
25
  // Симлинки пропускаем: в недоверенном архиве симлинк мог бы указывать
26
26
  // за пределы каталога.
package/dist/util/http.js CHANGED
@@ -25,21 +25,21 @@ export const httpGetStream = (url, opts = {}) => {
25
25
  if (status >= 300 && status < 400 && location) {
26
26
  res.resume();
27
27
  if (redirectsLeft <= 0) {
28
- return reject(new Error(`Слишком много редиректов (>${MAX_REDIRECTS}) для ${url}`));
28
+ return reject(new Error(`Too many redirects (>${MAX_REDIRECTS}) for ${url}`));
29
29
  }
30
30
  const next = new URL(location, url).toString();
31
31
  return resolve(httpGetStream(next, { headers, redirectsLeft: redirectsLeft - 1 }));
32
32
  }
33
33
  if (status < 200 || status > 299) {
34
34
  res.resume();
35
- return reject(new Error(`Не удалось загрузить ${url}, код ответа: ${status}`));
35
+ return reject(new Error(`Failed to download ${url}, status code: ${status}`));
36
36
  }
37
37
  // Коннект установлен → с таймаута коннекта переключаемся на таймаут простоя
38
38
  // приёма (крупный ответ тянется дольше): молчащий сокет уронит таймаут.
39
39
  req.setTimeout(IDLE_TIMEOUT_MS);
40
40
  resolve(res);
41
41
  });
42
- req.on('timeout', () => req.destroy(new Error(`Таймаут сети (${url})`)));
42
+ req.on('timeout', () => req.destroy(new Error(`Network timeout (${url})`)));
43
43
  req.on('error', reject);
44
44
  });
45
45
  };