@alfacapital-tech/c4builder 0.9.0-rc.2 → 0.9.0-rc.4

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.
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import express from 'express';
6
6
  import open from 'open';
7
- import { batchSchema, formatBatch } from "../../core/feedback.js";
7
+ import { batchSchema, formatBatch, LISTENER_NAME } from "../../core/feedback.js";
8
8
  import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
9
9
  import { packageJson as pkg } from "../../util/paths.js";
10
10
  import { pendingUpdate } from "../update.js";
@@ -103,20 +103,51 @@ pages = () => new Map()) => {
103
103
  // Кеш index.html — отдаём его из памяти, если на диске его временно нет
104
104
  // (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
105
105
  let lastIndexHtml = null;
106
+ let sweeper;
106
107
  // reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
107
108
  // истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
108
109
  if (liveReloadEnabled && reloadEmitter) {
109
- // Состояние слушателя `c4builder feedback` (design D4): подключён, пока открыт long-poll
110
- // или с конца последнего прошло меньше idleMs — зазор между повторами команды.
111
- let listening = false;
112
- const agentState = () => ({ on: agent.on, listening });
113
- const setAgent = (on, listen) => {
114
- if (on === agent.on && listen === listening)
110
+ // Ящики слушателей `c4builder feedback --as <name>` (change feedback-mailboxes, D3–D4).
111
+ // Слушает, пока открыт long-poll или с конца последнего прошло меньше idleMs — зазор между
112
+ // повторами команды. Пачки без адресата (ящиков ещё не было) достаются любому имени.
113
+ const { pollMs = 25_000, idleMs = 30_000, mailboxTtlMs = 30 * 60_000 } = program;
114
+ const mailboxes = new Map();
115
+ const unaddressed = [];
116
+ const box = (name) => {
117
+ const b = mailboxes.get(name) ?? {
118
+ queue: [],
119
+ waiters: [],
120
+ polls: 0,
121
+ listening: false,
122
+ lastSeen: Date.now()
123
+ };
124
+ mailboxes.set(name, b);
125
+ return b;
126
+ };
127
+ const agentState = () => ({
128
+ on: agent.on,
129
+ mailboxes: [...mailboxes].map(([name, b]) => ({
130
+ name,
131
+ state: b.listening ? 'listening' : 'reconnecting',
132
+ queued: b.queue.length
133
+ }))
134
+ });
135
+ // Событие — только когда снимок изменился: publish зовётся после любой смены очередей.
136
+ let published = JSON.stringify(agentState());
137
+ const publish = () => {
138
+ const state = agentState();
139
+ const json = JSON.stringify(state);
140
+ if (json === published)
115
141
  return;
116
- agent.on = on;
117
- listening = listen;
118
- reloadEmitter.emit('agent', agentState());
142
+ published = json;
143
+ reloadEmitter.emit('agent', state);
119
144
  };
145
+ sweeper = setInterval(() => {
146
+ for (const [name, b] of mailboxes)
147
+ if (!b.polls && !b.queue.length && Date.now() - b.lastSeen >= mailboxTtlMs)
148
+ mailboxes.delete(name);
149
+ publish();
150
+ }, Math.min(mailboxTtlMs, 60_000)).unref();
120
151
  app.get(LIVERELOAD_PATH, (req, res) => {
121
152
  res.set({
122
153
  'Content-Type': 'text/event-stream',
@@ -153,8 +184,9 @@ pages = () => new Map()) => {
153
184
  const snippet = LIVERELOAD_SNIPPET +
154
185
  commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
155
186
  agent: agent.on,
156
- listening,
157
- project: projectKey
187
+ mailboxes: agentState().mailboxes,
188
+ project: projectKey,
189
+ language: currentConfiguration.UI_LANGUAGE
158
190
  }) +
159
191
  (next ? updateBanner(next) : '');
160
192
  if (html.includes('</body>'))
@@ -259,35 +291,39 @@ pages = () => new Map()) => {
259
291
  });
260
292
  // Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
261
293
  // шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
262
- // когда ответ записан; обрыв до этого возвращает её в голову очереди.
263
- const { pollMs = 25_000, idleMs = 30_000 } = program;
264
- const queue = [];
265
- const waiters = [];
266
- let polls = 0;
267
- let idleTimer;
294
+ // когда ответ записан; обрыв до этого возвращает её в голову очереди, откуда её взяли.
268
295
  const disabled = () => agent.explicit && !agent.on;
269
- const drop = (res) => {
270
- const i = waiters.indexOf(res);
296
+ const drop = (b, res) => {
297
+ const i = b.waiters.indexOf(res);
271
298
  if (i >= 0)
272
- waiters.splice(i, 1);
299
+ b.waiters.splice(i, 1);
273
300
  };
274
- const pump = () => {
275
- while (queue.length && waiters.length) {
276
- const res = waiters.shift();
277
- const item = queue.shift();
301
+ const pump = (name, b) => {
302
+ for (;;) {
303
+ const source = b.queue.length ? b.queue : unaddressed;
304
+ if (!source.length || !b.waiters.length)
305
+ return;
306
+ const res = b.waiters.shift();
307
+ const item = source.shift();
278
308
  if (res && item)
279
- deliver(res, item);
309
+ deliver(name, res, item, source);
280
310
  }
281
311
  };
282
- const deliver = (res, item) => {
312
+ const pumpAll = () => {
313
+ for (const [name, b] of mailboxes)
314
+ pump(name, b);
315
+ };
316
+ const deliver = (name, res, item, source) => {
283
317
  res.on('close', () => {
284
318
  if (!res.writableFinished) {
285
- queue.unshift(item);
286
- pump();
287
- return;
319
+ source.unshift(item);
320
+ pumpAll();
288
321
  }
289
- console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
290
- reloadEmitter.emit('delivered', { id: item.id });
322
+ else {
323
+ console.log(chalk.gray(`feedback: batch delivered to ${name} (${item.batch.decision})`));
324
+ reloadEmitter.emit('delivered', { id: item.id, to: name });
325
+ }
326
+ publish();
291
327
  });
292
328
  res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
293
329
  };
@@ -308,17 +344,21 @@ pages = () => new Map()) => {
308
344
  });
309
345
  return;
310
346
  }
311
- const batch = parsed.data;
347
+ const { to, ...batch } = parsed.data;
312
348
  if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
313
349
  res.status(400).json({ error: 'nothing to send' });
314
350
  return;
315
351
  }
316
352
  const id = randomUUID();
317
- const queued = !waiters.length;
318
- queue.push({ id, batch });
319
- pump();
320
- console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
321
- res.json({ id, queued });
353
+ // Ящик адресата создаётся и здесь: выбранный на вкладке адресат не теряется, даже если
354
+ // его пустой ящик успели удалить.
355
+ const target = to === undefined ? undefined : box(to);
356
+ const queued = !(target ? [target] : [...mailboxes.values()]).some((b) => b.waiters.length);
357
+ (target?.queue ?? unaddressed).push({ id, batch });
358
+ pumpAll();
359
+ publish();
360
+ console.log(chalk.gray(`feedback for ${to ?? 'any listener'}: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, not listening' : ''}`));
361
+ res.json({ id, queued, to });
322
362
  });
323
363
  // Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
324
364
  // чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
@@ -331,27 +371,41 @@ pages = () => new Map()) => {
331
371
  res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
332
372
  return;
333
373
  }
374
+ const name = req.query.as;
375
+ if (typeof name !== 'string' || !LISTENER_NAME.test(name)) {
376
+ res.status(400).json({
377
+ error: '--as <name> is required: 1-64 letters, digits, ".", "_" or "-" (c4builder feedback --wait --as <name>)'
378
+ });
379
+ return;
380
+ }
334
381
  if (!agent.on)
335
- console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
336
- polls++;
337
- clearTimeout(idleTimer);
338
- setAgent(true, true);
382
+ console.log(chalk.gray(`agent mode on: "c4builder feedback --as ${name}" connected`));
383
+ agent.on = true;
384
+ const b = box(name);
385
+ b.polls++;
386
+ b.listening = true;
387
+ clearTimeout(b.idle);
388
+ publish();
339
389
  let timer;
340
390
  res.on('close', () => {
341
391
  clearTimeout(timer);
342
- drop(res);
343
- if (--polls === 0)
344
- idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
392
+ drop(b, res);
393
+ b.lastSeen = Date.now();
394
+ if (--b.polls === 0)
395
+ b.idle = setTimeout(() => {
396
+ b.listening = false;
397
+ publish();
398
+ }, idleMs);
345
399
  });
346
- waiters.push(res);
347
- pump();
348
- if (!waiters.includes(res))
400
+ b.waiters.push(res);
401
+ pump(name, b);
402
+ if (!b.waiters.includes(res))
349
403
  return;
350
404
  timer = setTimeout(() => {
351
405
  // Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
352
- if (!waiters.includes(res))
406
+ if (!b.waiters.includes(res))
353
407
  return;
354
- drop(res);
408
+ drop(b, res);
355
409
  res.status(204).end();
356
410
  }, req.query.wait === '0' ? 0 : pollMs);
357
411
  });
@@ -406,12 +460,13 @@ pages = () => new Map()) => {
406
460
  if (liveReloadEnabled)
407
461
  console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
408
462
  if (liveReloadEnabled && agent.on)
409
- console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait${program.port ? ` -p ${port}` : ''}" — run it in the background and again after each batch`);
463
+ console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait --as <name>${program.port ? ` -p ${port}` : ''}" — run it in the background, <name> is your change or task; run it again with the same name after each batch`);
410
464
  // --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
411
465
  if (program.open && process.env.BROWSER !== 'none')
412
466
  open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
413
467
  resolve(server);
414
468
  });
469
+ server.on('close', () => clearInterval(sweeper));
415
470
  server.on('error', (err) => {
416
471
  if (err.code === 'EADDRINUSE') {
417
472
  console.log(chalk.red(`port ${port} is already in use — pass a different one with -p <port>`));
@@ -25,6 +25,7 @@ import { acquireBuildLock, BuildLockHeldError, waitBuildLock } from "../util/loc
25
25
  // больше не будет (одиночный запуск).
26
26
  import { teardownD2 } from "../core/render/d2renderer.js";
27
27
  import { teardownBpmn } from "../core/render/bpmnrenderer.js";
28
+ import { teardownMermaid } from "../core/render/mermaidrenderer.js";
28
29
  import { packageJson as pkg } from "../util/paths.js";
29
30
  import { outputDirs, parseConfig } from "../config/options.js";
30
31
  import { isValidPort } from "../config/schema.js";
@@ -105,6 +106,7 @@ function getOptions(c) {
105
106
  EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
106
107
  USE_SYSTEM_FONTS: c.useSystemFonts,
107
108
  QUESTIONS_HEADING: c.questionsHeading,
109
+ UI_LANGUAGE: c.uiLanguage,
108
110
  PLUGINS: c.plugins
109
111
  };
110
112
  }
@@ -125,6 +127,7 @@ export default async () => {
125
127
  .option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
126
128
  .option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
127
129
  .option('--wait', 'for `feedback`: wait for the next batch of page feedback')
130
+ .option('--as <name>', 'for `feedback`: listener name (your change or task) — the page sends batches to it')
128
131
  .option('--docs', 'a brief explanation for the available configuration options')
129
132
  .option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
130
133
  .option('-p, --port <n>', 'port used for serving the generated site', (v) => {
@@ -352,6 +355,7 @@ export default async () => {
352
355
  if (!opts.watch) {
353
356
  await teardownD2();
354
357
  await teardownBpmn();
358
+ await teardownMermaid();
355
359
  }
356
360
  }
357
361
  isBuilding = false;
@@ -32,6 +32,7 @@ const defaultConfig = {
32
32
  d2Layout: 'dagre',
33
33
  charset: 'UTF-8',
34
34
  useSystemFonts: false,
35
- questionsHeading: 'Open questions'
35
+ questionsHeading: 'Open questions',
36
+ uiLanguage: 'auto'
36
37
  };
37
38
  export { defaultConfig };
@@ -60,6 +60,8 @@ export const configSchema = z.object({
60
60
  // Заголовок раздела, куда `site -w` дописывает комментарии к странице (в коде кириллицы
61
61
  // быть не может — русский заголовок задаёт сам проект).
62
62
  questionsHeading: str(defaultConfig.questionsHeading),
63
+ // Язык интерфейса страницы `site -w`; `auto` — по языку браузера (change site-ui-language).
64
+ uiLanguage: z.preprocess(strPre, z.enum(['auto', 'en', 'ru'], 'expected auto, en or ru').default(defaultConfig.uiLanguage)),
63
65
  // Задаются явно (флаг/промпт) либо тонкой правкой файла — в defaultConfig не входят.
64
66
  projectName: optStr(),
65
67
  webFileName: optStr(),
@@ -223,7 +223,7 @@ export const generateLlmsTxt = (tree, options) => {
223
223
  });
224
224
  return (`# ${options.PROJECT_NAME}\n\n` +
225
225
  '> Architecture documentation built with c4builder. Pages are markdown; full text with ' +
226
- 'diagram sources (PlantUML/D2/BPMN): [llms-full.txt](llms-full.txt).\n\n' +
226
+ 'diagram sources (PlantUML/D2/BPMN/Mermaid): [llms-full.txt](llms-full.txt).\n\n' +
227
227
  `## Pages\n\n${pages.join('')}`);
228
228
  };
229
229
  // Fence длиннее самой длинной серии обратных кавычек текста — иначе блок закрылся бы изнутри.
@@ -235,7 +235,8 @@ const FENCE_LANG = {
235
235
  '.puml': 'plantuml',
236
236
  '.iuml': 'plantuml',
237
237
  '.d2': 'd2',
238
- '.bpmn': 'xml'
238
+ '.bpmn': 'xml',
239
+ '.mmd': 'mermaid'
239
240
  };
240
241
  // llms-full.txt: состав complete-документа, но диаграмма — исходником (C4-PlantUML/D2-текст
241
242
  // агенту полезнее SVG). Локальные include/импорты — один раз в приложении, а не инлайном:
@@ -10,7 +10,10 @@ const clip = (s) => {
10
10
  const q = collapse(s);
11
11
  return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
12
12
  };
13
+ // Имя слушателя `c4builder feedback --as` и адресат пачки (change feedback-mailboxes, D1).
14
+ export const LISTENER_NAME = /^[A-Za-z0-9._-]{1,64}$/;
13
15
  export const batchSchema = z.object({
16
+ to: z.string().regex(LISTENER_NAME).optional(),
14
17
  decision: z.enum(['submitted', 'approved']),
15
18
  comments: z
16
19
  .array(z.object({
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { createRequire } from 'node:module';
11
11
  import chalk from 'chalk';
12
12
  import { DEFAULT_FONT_NAME } from "./fonts.js";
13
- import { getResvg, resvgFontOptions } from "./pngraster.js";
13
+ import { ASCENT, DESCENT, clearTextWidths, textWidth } from "./textmetrics.js";
14
14
  // Третий бэкенд рендера: BPMN 2.0 XML без координат → валидация (bpmnlint) → раскладка
15
15
  // (bpmn-auto-layout) → рендер bpmn-js в jsdom → SVG. Все пакеты — optionalDependencies,
16
16
  // грузятся лениво при первом .bpmn: проекты без BPMN их не трогают (как D2).
@@ -112,32 +112,7 @@ const C4BUILDER_RULES = {
112
112
  })
113
113
  };
114
114
  // Ширины строк для переноса подписей: diagram-js меряет текст только через
115
- // canvas.measureText, а jsdom канваса не умеет. Меряем вендорный Nimbus Sans тем же
116
- // resvg, что растрирует PNG: ширина = bbox «|текст|» минус bbox «||» — так получается
117
- // advance-ширина с пробелами по краям, а не ширина чернил. Режим системных шрифтов на
118
- // BPMN не влияет (как и на D2): детерминизм важнее.
119
- const widthMemo = new Map();
120
- const escapeXml = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
121
- const inkWidth = (text, size) => {
122
- const { Resvg } = getResvg();
123
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text y="${size}" ` +
124
- `font-family="${DEFAULT_FONT_NAME}" font-size="${size}" xml:space="preserve">${escapeXml(text)}</text></svg>`;
125
- return new Resvg(svg, { font: resvgFontOptions(false) }).getBBox()?.width ?? 0;
126
- };
127
- const textWidth = (text, size) => {
128
- const key = `${size}\0${text}`;
129
- let width = widthMemo.get(key);
130
- if (width === undefined) {
131
- // Округление гасит f32-шум resvg между платформами: координаты текста в SVG
132
- // считаются из этой ширины и должны совпадать побайтно.
133
- width = Math.round((inkWidth(`|${text}|`, size) - inkWidth('||', size)) * 100) / 100;
134
- widthMemo.set(key, width);
135
- }
136
- return width;
137
- };
138
- // hhea Nimbus Sans Regular: ascender 729, descender −271 на 1000 единиц (vendor/fonts).
139
- const ASCENT = 0.729;
140
- const DESCENT = 0.271;
115
+ // canvas.measureText, а jsdom канваса не умеет — меряем вендорный шрифт (textmetrics).
141
116
  // Полифилы окна jsdom: ровно то, что bpmn-js зовёт при импорте и saveSVG. Трансформации
142
117
  // пишутся в атрибут сразу — сериализатор берёт атрибуты, а не baseVal.
143
118
  const installPolyfills = (window) => {
@@ -334,7 +309,7 @@ export const teardownBpmn = async () => {
334
309
  return;
335
310
  const pending = enginePromise;
336
311
  enginePromise = null;
337
- widthMemo.clear();
312
+ clearTextWidths();
338
313
  try {
339
314
  (await pending).window.close();
340
315
  }
@@ -13,6 +13,8 @@ import { httpGetBuffer } from "../../util/http.js";
13
13
  import { renderD2, foldD2Imports } from "./d2renderer.js";
14
14
  // BPMN-бэкенд: пакеты движка грузятся лениво внутри renderBpmn.
15
15
  import { renderBpmn, BPMN_CACHE_PACKAGES } from "./bpmnrenderer.js";
16
+ // Mermaid-бэкенд: пакет движка грузится лениво внутри renderMermaid.
17
+ import { renderMermaid, MERMAID_PACKAGE } from "./mermaidrenderer.js";
16
18
  import { resolveJava } from "./jre.js";
17
19
  // PNG-выход: SVG обоих движков растеризуется resvg (ленивая загрузка внутри модуля).
18
20
  import { rasterizeSvgToPng } from "./pngraster.js";
@@ -65,7 +67,7 @@ const runPool = async (tasks, concurrency) => {
65
67
  if (firstError !== undefined)
66
68
  throw firstError;
67
69
  };
68
- // Версии пакетов движков (D2, BPMN) для чексуммы кэша (апгрейд движка → перерендер).
70
+ // Версии пакетов движков (D2, BPMN, mermaid) для чексуммы кэша (апгрейд движка → перерендер).
69
71
  // Читаем package.json лениво и один раз: pure-PlantUML сборки их не трогают.
70
72
  const versionCache = new Map();
71
73
  const pkgVersion = (name) => {
@@ -100,7 +102,9 @@ export const renderKey = (diagram, options, outFormat) => (diagram.engine === 'd
100
102
  ? `d2\0${pkgVersion('@terrastruct/d2')}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
101
103
  : diagram.engine === 'bpmn'
102
104
  ? `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}`) +
105
+ : diagram.engine === 'mermaid'
106
+ ? `mermaid\0${pkgVersion(MERMAID_PACKAGE)}\0fmt=${outFormat}`
107
+ : `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
104
108
  `\0font=${fontCacheTag(options)}`;
105
109
  export const getMime = (format) => {
106
110
  if (format === 'svg')
@@ -279,19 +283,22 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
279
283
  // BPMN рендерится в общем окне jsdom главного потока — параллельность ничего не даёт,
280
284
  // а последовательная очередь исключает гонки на общем состоянии бандла bpmn-js.
281
285
  const bpmnTasks = [];
286
+ // Mermaid — так же: своё окно jsdom, глобальный конфиг и подмены на время рендера.
287
+ const mermaidTasks = [];
282
288
  const otherTasks = [];
283
289
  for (const item of tree) {
284
290
  for (const diagram of item.diagrams) {
285
291
  // Чексумма = контент диаграммы + свёрнутый граф её зависимостей, чтобы
286
292
  // правка включаемого/импортируемого файла инвалидировала кэш: PlantUML —
287
- // !include-граф (.iuml и пр.), D2 — граф @/...@-импортов. У BPMN зависимостей нет.
293
+ // !include-граф (.iuml и пр.), D2 — граф @/...@-импортов. У BPMN и mermaid их нет.
288
294
  const body = `${diagram.content || ''}`;
289
295
  const entryPath = path.join(item.dir, diagram.dir);
290
296
  const isD2 = diagram.engine === 'd2';
291
297
  const isBpmn = diagram.engine === 'bpmn';
298
+ const isMermaid = diagram.engine === 'mermaid';
292
299
  const includes = isD2
293
300
  ? foldD2Imports(entryPath, body)
294
- : isBpmn
301
+ : isBpmn || isMermaid
295
302
  ? ''
296
303
  : foldIncludes(body, item.dir, item.dir, new Set());
297
304
  const outFormat = diagramOutputFormat(diagram, options);
@@ -340,12 +347,16 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
340
347
  // Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
341
348
  let rendered;
342
349
  try {
350
+ // Путь для сообщений движка; у блоков в markdown — их страница.
351
+ const file = source ?? path.relative(process.cwd(), entryPath).split(path.sep).join('/');
343
352
  if (isD2) {
344
353
  rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
345
354
  }
346
355
  else if (isBpmn) {
347
- // Путь для сообщений валидации; у виртуальных страниц — их источник.
348
- rendered = await renderBpmn(body, source ?? path.relative(process.cwd(), entryPath).split(path.sep).join('/'));
356
+ rendered = await renderBpmn(body, file);
357
+ }
358
+ else if (isMermaid) {
359
+ rendered = await renderMermaid(body, file);
349
360
  }
350
361
  else {
351
362
  // Резолв JRE отложен до первого реального PlantUML-рендера (эта
@@ -380,18 +391,19 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
380
391
  await writeFile(filePath, outFormat === 'png' ? await rasterizeSvgToPng(svg, options.USE_SYSTEM_FONTS) : svg);
381
392
  }
382
393
  });
383
- (isD2 ? d2Tasks : isBpmn ? bpmnTasks : otherTasks).push(task);
394
+ (isD2 ? d2Tasks : isBpmn ? bpmnTasks : isMermaid ? mermaidTasks : otherTasks).push(task);
384
395
  }
385
396
  }
386
- // PlantUML-пул, D2- и BPMN-очереди работают независимо (процессы java, WASM-worker,
387
- // главный поток) и параллельно друг другу. allSettled — чтобы падение одной очереди не
397
+ // PlantUML-пул, D2-, BPMN- и mermaid-очереди работают независимо (процессы java,
398
+ // WASM-worker, главный поток) и параллельно друг другу. allSettled — чтобы падение одной очереди не
388
399
  // оставляло другие как floating promise с висящими JVM: все доосушаются, затем бросаем первую
389
400
  // ошибку (приоритет — PlantUML-пул, порядок между очередями не значим).
390
401
  try {
391
402
  const results = await Promise.allSettled([
392
403
  runPool(otherTasks, renderConcurrency()),
393
404
  runPool(d2Tasks, 1),
394
- runPool(bpmnTasks, 1)
405
+ runPool(bpmnTasks, 1),
406
+ runPool(mermaidTasks, 1)
395
407
  ]);
396
408
  const failed = results.find((r) => r.status === 'rejected');
397
409
  if (failed && failed.status === 'rejected')