@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.
- package/README.MD +4 -2
- package/dist/cli/commands/check.js +9 -6
- package/dist/cli/commands/feedback.js +12 -2
- package/dist/cli/commands/site.comments.js +160 -60
- package/dist/cli/commands/site.i18n.js +102 -0
- package/dist/cli/commands/site.js +106 -51
- package/dist/cli/dispatch.js +4 -0
- package/dist/config/defaults.js +2 -1
- package/dist/config/schema.js +2 -0
- package/dist/core/compose/markdown.js +3 -2
- package/dist/core/feedback.js +3 -0
- package/dist/core/render/bpmnrenderer.js +3 -28
- package/dist/core/render/diagrams.js +22 -10
- package/dist/core/render/mermaidrenderer.js +495 -0
- package/dist/core/render/textmetrics.js +36 -0
- package/dist/core/scan/fences.js +2 -2
- package/dist/core/scan/tree.js +14 -9
- package/dist/plugins/openspec/render.js +1 -1
- package/package.json +2 -1
- package/template/AGENTS.md +15 -8
- package/template/src/6 Mermaid Example/flow.mmd +6 -0
- package/template/src/6 Mermaid Example/mermaid.md +16 -0
|
@@ -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
|
-
//
|
|
110
|
-
// или с конца последнего прошло меньше idleMs — зазор между
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
return;
|
|
319
|
+
source.unshift(item);
|
|
320
|
+
pumpAll();
|
|
288
321
|
}
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
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>`));
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -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;
|
package/dist/config/defaults.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -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/импорты — один раз в приложении, а не инлайном:
|
package/dist/core/feedback.js
CHANGED
|
@@ -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 {
|
|
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 канваса не
|
|
116
|
-
// resvg, что растрирует PNG: ширина = bbox «|текст|» минус bbox «||» — так получается
|
|
117
|
-
// advance-ширина с пробелами по краям, а не ширина чернил. Режим системных шрифтов на
|
|
118
|
-
// BPMN не влияет (как и на D2): детерминизм важнее.
|
|
119
|
-
const widthMemo = new Map();
|
|
120
|
-
const escapeXml = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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- и
|
|
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')
|