@alfacapital-tech/c4builder 0.9.0-rc.1 → 0.9.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.
@@ -1,17 +1,21 @@
1
1
  import chalk from 'chalk';
2
+ import { createHash, randomUUID } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import fs from 'node:fs';
4
5
  import express from 'express';
5
6
  import open from 'open';
7
+ import { batchSchema, formatBatch } from "../../core/feedback.js";
6
8
  import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
7
9
  import { packageJson as pkg } from "../../util/paths.js";
8
10
  import { pendingUpdate } from "../update.js";
9
- import { COMMENTS_PATH, commentsSnippet } from "./site.comments.js";
11
+ import { COMMENTS_PATH, commentsSnippet, FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
10
12
  const COMMENT_MAX = 2000;
11
13
  const LIVERELOAD_PATH = '/__livereload';
12
14
  // Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
13
15
  // высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
14
16
  // Комментарии держим здесь, а не в сниппете: он уходит в браузер пользователя.
17
+ // События агента (design D4) идут по тому же потоку и пересылаются сниппету комментариев
18
+ // DOM-событиями `c4builder:*` — второй EventSource странице не нужен.
15
19
  const LIVERELOAD_SNIPPET = `
16
20
  <script>
17
21
  (function () {
@@ -48,6 +52,13 @@ const LIVERELOAD_SNIPPET = `
48
52
  es.close();
49
53
  location.reload();
50
54
  });
55
+ ['agent', 'delivered'].forEach(function (name) {
56
+ es.addEventListener(name, function (e) {
57
+ try {
58
+ window.dispatchEvent(new CustomEvent('c4builder:' + name, { detail: JSON.parse(e.data) }));
59
+ } catch (err) {}
60
+ });
61
+ });
51
62
  es.onerror = function () {
52
63
  es.close();
53
64
  setTimeout(connect, delay);
@@ -88,12 +99,24 @@ pages = () => new Map()) => {
88
99
  const port = program.port || currentConfiguration.WEB_PORT;
89
100
  const distFolder = path.resolve(currentConfiguration.DIST_FOLDER);
90
101
  const liveReloadEnabled = !!(program.watch && reloadEmitter);
102
+ const agent = { ...(program.agent ?? { on: false, name: '', explicit: false }) };
91
103
  // Кеш index.html — отдаём его из памяти, если на диске его временно нет
92
104
  // (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
93
105
  let lastIndexHtml = null;
94
106
  // reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
95
107
  // истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
96
108
  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)
115
+ return;
116
+ agent.on = on;
117
+ listening = listen;
118
+ reloadEmitter.emit('agent', agentState());
119
+ };
97
120
  app.get(LIVERELOAD_PATH, (req, res) => {
98
121
  res.set({
99
122
  'Content-Type': 'text/event-stream',
@@ -104,21 +127,36 @@ pages = () => new Map()) => {
104
127
  res.flushHeaders();
105
128
  res.write('retry: 1000\n\n');
106
129
  const onReload = () => res.write('event: reload\ndata: {}\n\n');
130
+ const onAgent = (data) => res.write(`event: agent\ndata: ${JSON.stringify(data)}\n\n`);
131
+ const onDelivered = (data) => res.write(`event: delivered\ndata: ${JSON.stringify(data)}\n\n`);
107
132
  const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
108
133
  reloadEmitter.on('reload', onReload); // liveReloadEnabled ⇒ reloadEmitter задан
134
+ reloadEmitter.on('agent', onAgent);
135
+ reloadEmitter.on('delivered', onDelivered);
136
+ onAgent(agentState());
109
137
  console.log(chalk.gray(`livereload: client connected (${reloadEmitter.listenerCount('reload')} total)`));
110
138
  req.on('close', () => {
111
139
  clearInterval(heartbeat);
112
140
  reloadEmitter.off('reload', onReload);
141
+ reloadEmitter.off('agent', onAgent);
142
+ reloadEmitter.off('delivered', onDelivered);
113
143
  console.log(chalk.gray(`livereload: client disconnected (${reloadEmitter.listenerCount('reload')} left)`));
114
144
  });
115
145
  });
146
+ // Ключ черновиков review в localStorage (design D5): страницы разных проектов на одном
147
+ // localhost:<port> не должны видеть чужой черновик.
148
+ const projectKey = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8);
116
149
  // Кэш проверки обновлений читается на каждую страницу: результат фоновой проверки,
117
150
  // запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
118
151
  const inject = (html) => {
119
152
  const next = pendingUpdate();
120
153
  const snippet = LIVERELOAD_SNIPPET +
121
- commentsSnippet(currentConfiguration.QUESTIONS_HEADING) +
154
+ commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
155
+ agent: agent.on,
156
+ listening,
157
+ project: projectKey,
158
+ language: currentConfiguration.UI_LANGUAGE
159
+ }) +
122
160
  (next ? updateBanner(next) : '');
123
161
  if (html.includes('</body>'))
124
162
  return html.replace('</body>', `${snippet}</body>`);
@@ -129,15 +167,21 @@ pages = () => new Map()) => {
129
167
  // любого сайта в браузере может слать POST на localhost — требуем Origin самого сайта.
130
168
  const str = (v) => (typeof v === 'string' ? v : '');
131
169
  const heading = currentConfiguration.QUESTIONS_HEADING;
170
+ // Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
171
+ // присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
172
+ const localHost = (req) => {
173
+ const host = req.get('host');
174
+ const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
175
+ return host && local.includes(host) ? host : undefined;
176
+ };
177
+ const fromSite = (req) => {
178
+ const host = localHost(req);
179
+ return !!host && req.get('origin') === `http://${host}`;
180
+ };
132
181
  // Общая часть POST/DELETE: проверка Origin и исходный файл страницы из тела запроса.
133
182
  // Ошибка уже отправлена клиенту, когда возвращается undefined.
134
183
  const resolveTarget = (req, res) => {
135
- const origin = req.get('origin');
136
- const host = req.get('host');
137
- // Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
138
- // присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
139
- const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
140
- if (!origin || !host || !local.includes(host) || origin !== `http://${host}`) {
184
+ if (!fromSite(req)) {
141
185
  res.status(403).json({ error: 'comments are accepted only from the site itself' });
142
186
  return undefined;
143
187
  }
@@ -214,6 +258,104 @@ pages = () => new Map()) => {
214
258
  res.status(500).json({ error: `could not remove the comment: ${e.message}` });
215
259
  }
216
260
  });
261
+ // Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
262
+ // шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
263
+ // когда ответ записан; обрыв до этого возвращает её в голову очереди.
264
+ const { pollMs = 25_000, idleMs = 30_000 } = program;
265
+ const queue = [];
266
+ const waiters = [];
267
+ let polls = 0;
268
+ let idleTimer;
269
+ const disabled = () => agent.explicit && !agent.on;
270
+ const drop = (res) => {
271
+ const i = waiters.indexOf(res);
272
+ if (i >= 0)
273
+ waiters.splice(i, 1);
274
+ };
275
+ const pump = () => {
276
+ while (queue.length && waiters.length) {
277
+ const res = waiters.shift();
278
+ const item = queue.shift();
279
+ if (res && item)
280
+ deliver(res, item);
281
+ }
282
+ };
283
+ const deliver = (res, item) => {
284
+ res.on('close', () => {
285
+ if (!res.writableFinished) {
286
+ queue.unshift(item);
287
+ pump();
288
+ return;
289
+ }
290
+ console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
291
+ reloadEmitter.emit('delivered', { id: item.id });
292
+ });
293
+ res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
294
+ };
295
+ app.post(FEEDBACK_PATH, express.json({ limit: '4mb' }), (req, res) => {
296
+ if (!fromSite(req)) {
297
+ res.status(403).json({ error: 'feedback is accepted only from the site itself' });
298
+ return;
299
+ }
300
+ if (disabled()) {
301
+ res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
302
+ return;
303
+ }
304
+ const parsed = batchSchema.safeParse(req.body);
305
+ if (!parsed.success) {
306
+ const issue = parsed.error.issues[0];
307
+ res.status(400).json({
308
+ error: `invalid feedback: ${issue?.path.join('.')} ${issue?.message}`
309
+ });
310
+ return;
311
+ }
312
+ const batch = parsed.data;
313
+ if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
314
+ res.status(400).json({ error: 'nothing to send' });
315
+ return;
316
+ }
317
+ const id = randomUUID();
318
+ const queued = !waiters.length;
319
+ queue.push({ id, batch });
320
+ pump();
321
+ console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
322
+ res.json({ id, queued });
323
+ });
324
+ // Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
325
+ // чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
326
+ app.get(`${FEEDBACK_PATH}/next`, (req, res) => {
327
+ if (!localHost(req) || req.get('origin') || req.get(FEEDBACK_HEADER) !== '1') {
328
+ res.status(403).json({ error: 'feedback is served only to the c4builder feedback command' });
329
+ return;
330
+ }
331
+ if (disabled()) {
332
+ res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
333
+ return;
334
+ }
335
+ if (!agent.on)
336
+ console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
337
+ polls++;
338
+ clearTimeout(idleTimer);
339
+ setAgent(true, true);
340
+ let timer;
341
+ res.on('close', () => {
342
+ clearTimeout(timer);
343
+ drop(res);
344
+ if (--polls === 0)
345
+ idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
346
+ });
347
+ waiters.push(res);
348
+ pump();
349
+ if (!waiters.includes(res))
350
+ return;
351
+ timer = setTimeout(() => {
352
+ // Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
353
+ if (!waiters.includes(res))
354
+ return;
355
+ drop(res);
356
+ res.status(204).end();
357
+ }, req.query.wait === '0' ? 0 : pollMs);
358
+ });
217
359
  app.use((req, res, next) => {
218
360
  if (req.method !== 'GET')
219
361
  return next();
@@ -264,6 +406,8 @@ pages = () => new Map()) => {
264
406
  console.log(`go to ${chalk.green(url)}`);
265
407
  if (liveReloadEnabled)
266
408
  console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
409
+ if (liveReloadEnabled && agent.on)
410
+ 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`);
267
411
  // --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
268
412
  if (program.open && process.env.BROWSER !== 'none')
269
413
  open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
@@ -9,6 +9,8 @@ import cmdJre from "./commands/jre.js";
9
9
  import cmdCheck from "./commands/check.js";
10
10
  import cmdList from "./commands/list.js";
11
11
  import cmdSite from "./commands/site.js";
12
+ import cmdFeedback from "./commands/feedback.js";
13
+ import { detectAgent } from "./agent.js";
12
14
  import { cmdUpdate, pendingUpdate, startUpdateCheck } from "./update.js";
13
15
  import cmdCollect from "./wizard/collect.js";
14
16
  import { build } from "../core/build.js";
@@ -23,11 +25,12 @@ import { acquireBuildLock, BuildLockHeldError, waitBuildLock } from "../util/loc
23
25
  // больше не будет (одиночный запуск).
24
26
  import { teardownD2 } from "../core/render/d2renderer.js";
25
27
  import { teardownBpmn } from "../core/render/bpmnrenderer.js";
28
+ import { teardownMermaid } from "../core/render/mermaidrenderer.js";
26
29
  import { packageJson as pkg } from "../util/paths.js";
27
30
  import { outputDirs, parseConfig } from "../config/options.js";
28
31
  import { isValidPort } from "../config/schema.js";
29
32
  // Позиционные подкоманды: allowExcessArguments пропускает любые, список — единственный фильтр.
30
- const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update'];
33
+ const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update', 'feedback'];
31
34
  const intro = () => {
32
35
  console.log(chalk.blue(figlet.textSync('c4builder')));
33
36
  console.log(chalk.gray('Blow up your software documentation writing skills'));
@@ -103,6 +106,7 @@ function getOptions(c) {
103
106
  EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
104
107
  USE_SYSTEM_FONTS: c.useSystemFonts,
105
108
  QUESTIONS_HEADING: c.questionsHeading,
109
+ UI_LANGUAGE: c.uiLanguage,
106
110
  PLUGINS: c.plugins
107
111
  };
108
112
  }
@@ -120,6 +124,9 @@ export default async () => {
120
124
  .option('--site', 'serve the generated site')
121
125
  .option('-w, --watch', 'watch for changes and rebuild')
122
126
  .option('-o, --open', 'open the generated site in the browser (with --site)')
127
+ .option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
128
+ .option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
129
+ .option('--wait', 'for `feedback`: wait for the next batch of page feedback')
123
130
  .option('--docs', 'a brief explanation for the available configuration options')
124
131
  .option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
125
132
  .option('-p, --port <n>', 'port used for serving the generated site', (v) => {
@@ -140,8 +147,8 @@ export default async () => {
140
147
  process.exit(1);
141
148
  }
142
149
  // --version/--help уже вышли в parse. Уведомление — по кэшу прошлой проверки, до её
143
- // перезапуска; exit покрывает и выходы через process.exit.
144
- if (cmd !== 'update') {
150
+ // перезапуска; exit покрывает и выходы через process.exit. Вывод `feedback` читает агент.
151
+ if (cmd !== 'update' && cmd !== 'feedback') {
145
152
  const next = pendingUpdate();
146
153
  if (next)
147
154
  process.once('exit', () => process.stderr.write(`c4builder ${next} is available (current ${pkg.version}). Run: c4builder update\n`));
@@ -156,6 +163,9 @@ export default async () => {
156
163
  // Обновление CLI — до Configstore, чтобы не создавать .c4builder в любом каталоге.
157
164
  if (cmd === 'update')
158
165
  return cmdUpdate();
166
+ // Замечания со страницы `site -w` — тоже до Configstore: команду запускают в любом каталоге.
167
+ if (cmd === 'feedback')
168
+ return cmdFeedback(path.join(process.cwd(), opts.configFile ?? '.c4builder'), opts);
159
169
  // `site`/`new`/`config` — позиционные синонимы одноимённых флагов: эту форму печатают
160
170
  // подсказки CLI и учат README, шаблон и скиллы.
161
171
  if (cmd)
@@ -344,11 +354,12 @@ export default async () => {
344
354
  if (!opts.watch) {
345
355
  await teardownD2();
346
356
  await teardownBpmn();
357
+ await teardownMermaid();
347
358
  }
348
359
  }
349
360
  isBuilding = false;
350
361
  if (opts.site)
351
- return await cmdSite(options, opts, reloadEmitter, () => pages);
362
+ return await cmdSite(options, { ...opts, agent: detectAgent(process.env, opts) }, reloadEmitter, () => pages);
352
363
  if (options.GENERATE_WEBSITE && !opts.watch) {
353
364
  console.log(chalk.gray('\nto view the generated website run'));
354
365
  console.log(`> c4builder site`);
@@ -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/импорты — один раз в приложении, а не инлайном:
@@ -0,0 +1,59 @@
1
+ // Пачка замечаний агенту со страницы `site -w` (change site-agent-feedback, design D6–D7):
2
+ // схема тела запроса страницы и markdown, который печатает `c4builder feedback`.
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ const TEXT_MAX = 2000;
6
+ const text = z.string().max(TEXT_MAX);
7
+ const collapse = (s) => s.replace(/\s+/g, ' ').trim();
8
+ // Цитата — только ориентир для агента: длинное выделение обрезается, а не валит всю пачку.
9
+ const clip = (s) => {
10
+ const q = collapse(s);
11
+ return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
12
+ };
13
+ export const batchSchema = z.object({
14
+ decision: z.enum(['submitted', 'approved']),
15
+ comments: z
16
+ .array(z.object({
17
+ // Ключ карты «страница → исходники»: тот же вид, что у вопросов (без ведущего «/»).
18
+ page: z
19
+ .string()
20
+ .max(1000)
21
+ .transform((p) => path.posix.normalize(p.replace(/^\/+/, ''))),
22
+ quote: z.string().transform(clip),
23
+ section: text.default(''),
24
+ comment: text.trim().min(1)
25
+ }))
26
+ .max(200),
27
+ general: text.trim().default('')
28
+ });
29
+ const quoted = (s) => s
30
+ .split('\n')
31
+ .map((l) => (l.trim() ? `> ${l}` : '>'))
32
+ .join('\n');
33
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
34
+ // Исходники — относительно cwd: агент правит их из каталога проекта.
35
+ export const formatBatch = (batch, pages, cwd) => {
36
+ const { comments, general } = batch;
37
+ const pageCount = new Set(comments.map((c) => c.page)).size;
38
+ const out = [
39
+ '# Page feedback (c4builder)',
40
+ '',
41
+ `Decision: ${batch.decision}`,
42
+ `Comments: ${comments.length}${comments.length ? ` on ${plural(pageCount, 'page')}` : ''}`
43
+ ];
44
+ comments.forEach((c, i) => {
45
+ const section = collapse(c.section);
46
+ const sources = (pages.get(c.page) ?? []).map((s) => path.relative(cwd, s).split(path.sep).join('/'));
47
+ // Страница схлопывается только в заголовке: перевод строки в ней сломал бы формат пачки,
48
+ // а ключ карты исходников остаётся как есть.
49
+ out.push('', `## ${i + 1}. ${collapse(c.page)}${section ? ` (§ ${section})` : ''}`);
50
+ if (sources.length)
51
+ out.push(`Sources: ${sources.join(', ')}`);
52
+ if (c.quote)
53
+ out.push(`Quote: "${c.quote}"`);
54
+ out.push('', quoted(c.comment));
55
+ });
56
+ if (general)
57
+ out.push('', '## General', '', quoted(general));
58
+ return `${out.join('\n')}\n`;
59
+ };
@@ -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')