@alfacapital-tech/c4builder 0.9.0-rc.2 → 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.
@@ -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')
@@ -0,0 +1,495 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import crypto from 'node:crypto';
10
+ import { readFileSync } from 'node:fs';
11
+ import vm from 'node:vm';
12
+ import { DEFAULT_FONT_NAME } from "./fonts.js";
13
+ import { getResvg, resvgFontOptions } from "./pngraster.js";
14
+ import { ASCENT, DESCENT, X_HEIGHT, clearTextWidths, textWidth } from "./textmetrics.js";
15
+ // Четвёртый бэкенд рендера: настоящий mermaid (официальная однофайловая сборка
16
+ // @mermaid-js/tiny) в окне jsdom → SVG. Геометрию, которой у jsdom нет (getBBox), считает
17
+ // resvg по вендорному шрифту. Пакет — optionalDependency, грузится лениво при первой
18
+ // mermaid-диаграмме: проекты без mermaid его не трогают (как D2 и BPMN).
19
+ // Версия пакета идёт в ключ кэша (render/diagrams): апгрейд mermaid обязан перерисовать.
20
+ export const MERMAID_PACKAGE = '@mermaid-js/tiny';
21
+ // problems — строка без пути файла (check печатает `✗ <file>: <problem>`), message — с путём.
22
+ export class MermaidError extends Error {
23
+ problems;
24
+ constructor(file, problem) {
25
+ super(`${file}: ${problem}`);
26
+ this.problems = [problem];
27
+ }
28
+ }
29
+ // Конфигурация фиксирована (design §4): подписи — SVG <text> (foreignObject resvg не
30
+ // рисует), классический вид без фильтров neo, ширина gantt вместо нулевой ширины контейнера
31
+ // jsdom. Ключи, ломающие офлайн-рендер, закреплены через secure — %%{init}%% и frontmatter
32
+ // их не переопределят; остальное (тема, flowchart.curve…) mermaid применяет сам.
33
+ const CONFIG = {
34
+ startOnLoad: false,
35
+ securityLevel: 'strict',
36
+ look: 'classic',
37
+ theme: 'default',
38
+ // Метрики считаются по Nimbus Sans; он метрически совместим с Helvetica/Arial — в
39
+ // браузере без него SVG показывается ими с теми же размерами.
40
+ fontFamily: `"${DEFAULT_FONT_NAME}", Helvetica, Arial, sans-serif`,
41
+ htmlLabels: false,
42
+ flowchart: { htmlLabels: false },
43
+ journey: { textPlacement: 'tspan' },
44
+ gantt: { useWidth: 800 },
45
+ deterministicIds: true,
46
+ suppressErrorRendering: true,
47
+ secure: [
48
+ 'secure',
49
+ 'securityLevel',
50
+ 'startOnLoad',
51
+ 'maxTextSize',
52
+ 'suppressErrorRendering',
53
+ 'maxEdges',
54
+ 'look',
55
+ 'htmlLabels',
56
+ 'fontFamily',
57
+ 'themeCSS'
58
+ ]
59
+ };
60
+ const SVG_NS = 'http://www.w3.org/2000/svg';
61
+ const FONT_SIZE = 16;
62
+ // Мини-каскад CSS для раскладки текста: getComputedStyle в jsdom не применяет стили mermaid
63
+ // к SVG (отдаёт начальные значения), а от text-anchor, кегля и baseline зависят все размеры.
64
+ const TEXT_PROPS = ['font-size', 'font-weight', 'text-anchor', 'dominant-baseline', 'alignment-baseline'];
65
+ const declarations = (css) => {
66
+ const out = {};
67
+ for (const [, name, value] of css.matchAll(/([\w-]+)\s*:\s*([^;]+)/g))
68
+ if (TEXT_PROPS.includes(name.toLowerCase()))
69
+ out[name.toLowerCase()] = value.replace(/!important/, '').trim();
70
+ return out;
71
+ };
72
+ const specificity = (selector) => (selector.match(/#[\w-]+/g)?.length ?? 0) * 10_000 +
73
+ (selector.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g)?.length ?? 0) * 100 +
74
+ (selector.replace(/#[\w-]+|\.[\w-]+|\[[^\]]*\]|::?[\w-]+(\([^)]*\))?/g, ' ').match(/[a-z][\w-]*/gi)
75
+ ?.length ?? 0);
76
+ // Правила одного <style>, отсортированные по специфичности и порядку — побеждает последнее
77
+ // совпавшее. Кэш по элементу: mermaid пишет стили диаграммы один раз.
78
+ const rulesMemo = new WeakMap();
79
+ const styleRules = (style) => {
80
+ let rules = rulesMemo.get(style);
81
+ if (!rules) {
82
+ const ranked = [];
83
+ for (const [, selectors, body] of (style.textContent ?? '').matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
84
+ const decls = declarations(body);
85
+ if (!Object.keys(decls).length)
86
+ continue;
87
+ for (const selector of selectors.split(/,(?![^(]*\))/))
88
+ ranked.push({
89
+ selector: selector.trim(),
90
+ decls,
91
+ rank: specificity(selector) * 1e4 + ranked.length
92
+ });
93
+ }
94
+ rules = ranked.sort((a, b) => a.rank - b.rank);
95
+ rulesMemo.set(style, rules);
96
+ }
97
+ return rules;
98
+ };
99
+ // Стили mermaid — прямые дети корневого <svg>; обход всего дерева на каждый замер давал
100
+ // минуту на диаграмму в тысячи узлов.
101
+ const cssRules = (el) => {
102
+ const svg = el.closest('svg');
103
+ return svg ? [...svg.children].filter((c) => c.localName === 'style').flatMap(styleRules) : [];
104
+ };
105
+ const matches = (el, selector) => {
106
+ try {
107
+ return el.matches(selector);
108
+ }
109
+ catch {
110
+ return false; // селектор, который nwsapi не разбирает, браузерный и здесь не нужен
111
+ }
112
+ };
113
+ // Значения на время одного замера: getBBox группы раскладывает все её тексты с общими
114
+ // предками. Между замерами mermaid меняет DOM — там кэш не живёт.
115
+ let valueMemo;
116
+ const measure = (fn) => {
117
+ if (valueMemo)
118
+ return fn();
119
+ valueMemo = new Map();
120
+ try {
121
+ return fn();
122
+ }
123
+ finally {
124
+ valueMemo = undefined;
125
+ }
126
+ };
127
+ // Значение у самого элемента: inline style > CSS > атрибут представления.
128
+ const ownValue = (el, name) => {
129
+ const memo = valueMemo?.get(el);
130
+ const cached = memo?.get(name);
131
+ if (cached !== undefined)
132
+ return cached;
133
+ let value = declarations(el.getAttribute('style') ?? '')[name] ?? '';
134
+ if (!value) {
135
+ for (const rule of cssRules(el))
136
+ if (rule.decls[name] && matches(el, rule.selector))
137
+ value = rule.decls[name];
138
+ value ||= el.getAttribute(name) ?? '';
139
+ }
140
+ if (valueMemo)
141
+ valueMemo.set(el, (memo ?? new Map()).set(name, value));
142
+ return value;
143
+ };
144
+ const inherited = (el, name) => {
145
+ for (let e = el; e; e = e.parentElement) {
146
+ const value = ownValue(e, name);
147
+ if (value)
148
+ return value;
149
+ }
150
+ return '';
151
+ };
152
+ const fontSize = (el) => {
153
+ for (let e = el; e; e = e.parentElement) {
154
+ const value = ownValue(e, 'font-size');
155
+ const n = Number.parseFloat(value);
156
+ if (Number.isNaN(n))
157
+ continue;
158
+ if (value.endsWith('%'))
159
+ return (n / 100) * fontSize(e.parentElement);
160
+ return value.endsWith('em') ? n * fontSize(e.parentElement) : n;
161
+ }
162
+ return FONT_SIZE;
163
+ };
164
+ const isBold = (el) => {
165
+ const weight = inherited(el, 'font-weight');
166
+ return weight === 'bold' || weight === 'bolder' || Number(weight) >= 600;
167
+ };
168
+ const length = (value, size) => {
169
+ const n = Number.parseFloat(value ?? '');
170
+ if (Number.isNaN(n))
171
+ return 0;
172
+ return value?.trim().endsWith('em') ? n * size : n;
173
+ };
174
+ const EMPTY = { x: 0, y: 0, width: 0, height: 0 };
175
+ // Округление гасит f32-шум resvg между платформами: из bbox считаются координаты SVG.
176
+ const round2 = (v) => Math.round(v * 100) / 100;
177
+ // Bbox <text>, как у браузера: advance-ширина строк и em-box по ascent/descent шрифта,
178
+ // округлённым до пикселя (так делает Chrome; mermaid центрирует подписи по этой высоте).
179
+ // Строка — фрагмент от элемента с абсолютными x/y (так mermaid раскладывает переносы).
180
+ // ponytail: tspan только с dy без x продолжает строку по x в браузере, здесь — нет; у mermaid
181
+ // таких нет.
182
+ const textBox = (text) => {
183
+ const lines = [];
184
+ let x = 0;
185
+ let y = 0;
186
+ let line;
187
+ const walk = (node) => {
188
+ if (node.nodeType === node.TEXT_NODE) {
189
+ const el = node.parentElement;
190
+ const value = (node.nodeValue ?? '').replace(/\s+/g, ' ');
191
+ if (!el || (!line && !value.trim()))
192
+ return;
193
+ if (!line) {
194
+ line = { x, y, el, runs: [] };
195
+ lines.push(line);
196
+ }
197
+ line.runs.push({ text: value, size: fontSize(el), bold: isBold(el) });
198
+ return;
199
+ }
200
+ if (node.nodeType !== node.ELEMENT_NODE)
201
+ return;
202
+ const el = node;
203
+ const size = fontSize(el);
204
+ const ax = el.getAttribute('x');
205
+ const ay = el.getAttribute('y');
206
+ if (ax !== null)
207
+ x = length(ax, size);
208
+ if (ay !== null)
209
+ y = length(ay, size);
210
+ x += length(el.getAttribute('dx'), size);
211
+ y += length(el.getAttribute('dy'), size);
212
+ if (ax !== null || ay !== null)
213
+ line = undefined;
214
+ for (const child of el.childNodes)
215
+ walk(child);
216
+ };
217
+ walk(text);
218
+ let box;
219
+ for (const l of lines) {
220
+ // Пробелы по краям строки браузер схлопывает.
221
+ const runs = l.runs.map((r, i) => ({
222
+ ...r,
223
+ text: i === 0 ? r.text.trimStart() : r.text
224
+ }));
225
+ const last = runs[runs.length - 1];
226
+ last.text = last.text.trimEnd();
227
+ const width = runs.reduce((sum, r) => sum + textWidth(r.text, r.size, r.bold), 0);
228
+ if (width <= 0)
229
+ continue;
230
+ const size = runs[0].size;
231
+ const anchor = inherited(l.el, 'text-anchor');
232
+ const x0 = l.x - (anchor === 'middle' ? width / 2 : anchor === 'end' ? width : 0);
233
+ const ascent = Math.round(ASCENT * size);
234
+ const descent = Math.round(DESCENT * size);
235
+ const baseline = inherited(l.el, 'dominant-baseline') || inherited(l.el, 'alignment-baseline');
236
+ const shift = baseline === 'central'
237
+ ? (ascent - descent) / 2
238
+ : baseline === 'middle'
239
+ ? (X_HEIGHT * size) / 2
240
+ : 0;
241
+ const b = { x0, y0: l.y + shift - ascent, x1: x0 + width, y1: l.y + shift + descent };
242
+ box = box
243
+ ? {
244
+ x0: Math.min(box.x0, b.x0),
245
+ y0: Math.min(box.y0, b.y0),
246
+ x1: Math.max(box.x1, b.x1),
247
+ y1: Math.max(box.y1, b.y1)
248
+ }
249
+ : b;
250
+ }
251
+ return box
252
+ ? {
253
+ x: round2(box.x0),
254
+ y: round2(box.y0),
255
+ width: round2(box.x1 - box.x0),
256
+ height: round2(box.y1 - box.y0)
257
+ }
258
+ : EMPTY;
259
+ };
260
+ // Атрибуты оформления, которых нет в getBBox браузера (обводки, маркеры, фильтры);
261
+ // классы и стили mermaid в изолированном поддереве всё равно не применяются.
262
+ const PAINT_ATTRS = /\s(?:fill|stroke|stroke-width|stroke-dasharray|marker-start|marker-mid|marker-end|filter|opacity|fill-opacity|stroke-opacity|style|class)="[^"]*"/g;
263
+ // Bbox поддерева (design §3): тексты заменяются прямоугольниками своей раскладки, остальную
264
+ // геометрию в координатах элемента (без его transform) считает resvg. Тонкая обводка
265
+ // оставляет в bbox и линии без заливки.
266
+ const subtreeBox = (window, el) => {
267
+ const tag = el.localName;
268
+ if (tag === 'text' || tag === 'tspan')
269
+ return textBox(el);
270
+ const live = [...el.querySelectorAll('text')];
271
+ const clone = el.cloneNode(true);
272
+ clone.removeAttribute('transform');
273
+ for (const [i, t] of [...clone.querySelectorAll('text')].entries()) {
274
+ const rect = window.document.createElementNS(SVG_NS, 'rect');
275
+ for (const [k, v] of Object.entries(textBox(live[i])))
276
+ rect.setAttribute(k, String(v));
277
+ const transform = t.getAttribute('transform');
278
+ if (transform)
279
+ rect.setAttribute('transform', transform);
280
+ t.replaceWith(rect);
281
+ }
282
+ for (const style of clone.querySelectorAll('style'))
283
+ style.remove();
284
+ const serializer = new window.XMLSerializer();
285
+ const inner = (tag === 'svg' ? [...clone.childNodes] : [clone])
286
+ .map((n) => serializer.serializeToString(n))
287
+ .join('')
288
+ .replace(PAINT_ATTRS, '');
289
+ const { Resvg } = getResvg();
290
+ const b = new Resvg(`<svg xmlns="${SVG_NS}" fill="black" stroke="black" stroke-width="0.001">${inner}</svg>`, { font: resvgFontOptions(false) }).getBBox();
291
+ return b ? { x: round2(b.x), y: round2(b.y), width: round2(b.width), height: round2(b.height) } : EMPTY;
292
+ };
293
+ // Полифилы окна: ровно то, что mermaid зовёт при раскладке. getBoundingClientRect нужен pie
294
+ // (ширина легенды); позиция без CTM — mermaid берёт от него только размеры.
295
+ const installPolyfills = (window) => {
296
+ Object.assign(window.SVGElement.prototype, {
297
+ getBBox() {
298
+ return measure(() => subtreeBox(window, this));
299
+ },
300
+ getComputedTextLength() {
301
+ return measure(() => textBox(this).width);
302
+ },
303
+ getBoundingClientRect() {
304
+ const b = measure(() => subtreeBox(window, this));
305
+ return { ...b, left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height };
306
+ }
307
+ });
308
+ window.structuredClone = structuredClone;
309
+ };
310
+ // Имя пакета переменной: у jsdom нет типов, а import() по литералу потребовал бы их.
311
+ const load = (name) => import(__rewriteRelativeImportExtension(name));
312
+ let enginePromise = null;
313
+ const getEngine = () => {
314
+ enginePromise ??= (async () => {
315
+ let JSDOM;
316
+ let VirtualConsole;
317
+ let bundle;
318
+ try {
319
+ ({ JSDOM, VirtualConsole } = await load('jsdom'));
320
+ // import.meta.resolve, а не require.resolve: на Node 22 только он проходит через
321
+ // хуки резолва module.registerHooks (тесты «пакет не установлен»).
322
+ bundle = readFileSync(new URL(import.meta.resolve(MERMAID_PACKAGE)), 'utf8');
323
+ }
324
+ catch (err) {
325
+ enginePromise = null; // провал импорта не кешируем — даём повторить
326
+ throw new Error(`Rendering mermaid diagrams requires the optional packages ${MERMAID_PACKAGE} and jsdom.\n` +
327
+ 'Reinstall without --omit=optional: npm i -g @alfacapital-tech/c4builder\n' +
328
+ `Original error: ${err.message || err}`);
329
+ }
330
+ // Консоль окна глушим: mermaid пишет туда отладку, ошибки мы берём из исключений.
331
+ const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
332
+ runScripts: 'outside-only',
333
+ virtualConsole: new VirtualConsole()
334
+ });
335
+ installPolyfills(dom.window);
336
+ // Бандл исполняется в VM-контексте окна: глобальные объекты процесса Node не трогаются.
337
+ // Не window.eval: бандл в strict-режиме, и его var в strict-eval не попал бы в window.
338
+ vm.runInContext(bundle, dom.getInternalVMContext());
339
+ dom.window.mermaid.initialize(CONFIG);
340
+ return { window: dom.window };
341
+ })();
342
+ return enginePromise;
343
+ };
344
+ // Окно jsdom одно на процесс; закрывается после одиночной сборки и в check (как у BPMN),
345
+ // чтобы CLI завершался. Не бросает — иначе затёр бы исходную причину падения.
346
+ export const teardownMermaid = async () => {
347
+ if (!enginePromise)
348
+ return;
349
+ const pending = enginePromise;
350
+ enginePromise = null;
351
+ clearTextWidths();
352
+ try {
353
+ (await pending).window.close();
354
+ }
355
+ catch {
356
+ // движок не поднялся — закрывать нечего
357
+ }
358
+ };
359
+ // mulberry32: детерминированная замена Math.random на время рендера.
360
+ const prng = (seed) => {
361
+ let state = seed;
362
+ return () => {
363
+ state = (state + 0x6d2b79f5) | 0;
364
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
365
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
366
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
367
+ };
368
+ };
369
+ // Детерминизм (design §5): Math.random (кривые связей ER и др.) — от seed источника,
370
+ // «сегодня» (маркер текущего дня gantt) — эпоха 0, иначе SVG менялся бы от сборки к сборке.
371
+ // Глобальный конфиг mermaid и подмены на параллельность не рассчитаны: рендеры строго
372
+ // последовательные (очередь mermaid в render/diagrams, check — по файлу).
373
+ const renderSvg = async (window, id, seed, source) => {
374
+ const { Math: math, Date: RealDate } = window;
375
+ const random = math.random;
376
+ math.random = prng(seed);
377
+ window.Date = new Proxy(RealDate, {
378
+ construct: (target, args) => Reflect.construct(target, args.length ? args : [0]),
379
+ get: (target, key) => (key === 'now' ? () => 0 : Reflect.get(target, key))
380
+ });
381
+ try {
382
+ return (await window.mermaid.render(id, source)).svg;
383
+ }
384
+ finally {
385
+ math.random = random;
386
+ window.Date = RealDate;
387
+ }
388
+ };
389
+ // Строки источника, которые разбирает mermaid, — по номерам файла: mermaid вырезает
390
+ // frontmatter, директивы %%{…}%%, комментарии %% (вместе с пустыми строками перед ними) и
391
+ // пустые строки в начале, а номера строк в ошибках считает от остатка.
392
+ // ponytail: многострочная директива в середине диаграммы сдвинет номера — такие не встречались.
393
+ const FRONTMATTER = /^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s;
394
+ const parsedLines = (source) => {
395
+ const fm = FRONTMATTER.exec(source)?.[0] ?? '';
396
+ const offset = fm.split('\n').length - 1;
397
+ const body = source
398
+ .slice(fm.length)
399
+ .replace(/%%\{[\s\S]*?\}%%/g, (d) => '\n'.repeat(d.split('\n').length - 1));
400
+ const kept = [];
401
+ let blanks = [];
402
+ for (const [i, line] of body.split(/\r?\n/).entries()) {
403
+ if (/^\s*%%(?!\{)./.test(line))
404
+ blanks = [];
405
+ else if (!line.trim())
406
+ blanks.push(offset + i + 1);
407
+ else {
408
+ if (kept.length)
409
+ kept.push(...blanks);
410
+ blanks = [];
411
+ kept.push(offset + i + 1);
412
+ }
413
+ }
414
+ return kept;
415
+ };
416
+ const UNSUPPORTED = /^(mindmap|architecture[\w-]*)$/;
417
+ const oneLine = (s) => s.replace(/\s*\n\s*/g, '; ').trim();
418
+ // Ошибка движка → `line N: …` с номером строки файла (design §7). Ошибки из окна — объекты
419
+ // другого realm, instanceof Error для них ложен.
420
+ const problemOf = (err, lines, type) => {
421
+ const raw = String(err?.message ?? err);
422
+ const toFile = (n) => lines[Number(n) - 1] ?? Number(n);
423
+ if (/No diagram type detected/.test(raw))
424
+ return `line ${lines[0] ?? 1}: unknown mermaid diagram type '${type || '(empty)'}'`;
425
+ // jison: «Parse error on line N:», фрагмент, строка-указатель «---^», ожидаемые токены.
426
+ const jison = /^Parse error on line (\d+):\n([\s\S]*)$/.exec(raw);
427
+ if (jison)
428
+ return `line ${toFile(jison[1])}: ${oneLine(jison[2]
429
+ .split('\n')
430
+ .filter((l) => !/^-*\^$/.test(l.trim()))
431
+ .join('\n'))}`;
432
+ const at = /\bline (\d+)/.exec(raw);
433
+ const message = oneLine(raw.replace(/\bline (\d+)/g, (_, n) => `line ${toFile(n)}`));
434
+ return at ? `line ${toFile(at[1])}: ${message}` : message;
435
+ };
436
+ // Постобработка SVG: id внутри документа — по порядку появления от корня (счётчики mermaid
437
+ // растут между рендерами в одном окне), ссылки url(#…) без кавычек (в кавычках resvg их
438
+ // игнорирует), без фильтров (resvg не рисует узел с невалидным фильтром).
439
+ const finalizeSvg = (svg, root) => {
440
+ const ids = new Map();
441
+ for (const [, id] of svg.matchAll(/\sid="([^"]+)"/g))
442
+ if (id !== root && !ids.has(id))
443
+ ids.set(id, `${root}-${ids.size}`);
444
+ const rename = (id) => ids.get(id) ?? id;
445
+ return svg
446
+ .replace(/<filter\b[\s\S]*?<\/filter>|<filter\b[^>]*\/>/g, '')
447
+ .replace(/\sfilter="[^"]*"/g, '')
448
+ .replace(/(\sid=")([^"]+)"/g, (_, attr, id) => `${attr}${rename(id)}"`)
449
+ .replace(/url\((['"]?)#([^)'"]+)\1\)/g, (_, _q, id) => `url(#${rename(id)})`)
450
+ .replace(/(\s(?:xlink:)?href="#)([^"]+)"/g, (_, attr, id) => `${attr}${rename(id)}"`)
451
+ .replace(/(\saria-(?:labelledby|describedby)=")([^"]+)"/g, (_, attr, list) => `${attr}${list.split(' ').map(rename).join(' ')}"`)
452
+ .replace(/<style>([\s\S]*?)<\/style>/g, (_, css) => `<style>${resvgCss(css)}</style>`)
453
+ .replace(/rgba?\(([\d.\s,]+)\)/g, (whole, args) => whole.replace(args, args
454
+ .split(',')
455
+ .map((a, i) => (i < 3 ? String(Math.round(Number.parseFloat(a))) : a.trim()))
456
+ .join(', ')));
457
+ };
458
+ // CSS под resvg: правило со списком селекторов, где хоть один ему непонятен
459
+ // ([id$=…]), выбрасывается целиком — разносим список по правилам. Дробные каналы rgba()
460
+ // resvg не разбирает и красит чёрным — округляем (выше, для всего SVG).
461
+ const resvgCss = (css) => css
462
+ .replace(/filter:[^;}]*;?/g, '')
463
+ .replace(/([^{}]+)\{([^{}]*)\}/g, (whole, selectors, body) => {
464
+ const list = selectors.split(/,(?![^(]*\))/);
465
+ return list.length > 1 ? list.map((s) => `${s.trim()}{${body}}`).join('') : whole;
466
+ });
467
+ const lineOf = (source, index) => source.slice(0, index).split('\n').length;
468
+ // Полный конвейер одного источника — общий для сборки и `c4builder check`. file — путь для
469
+ // сообщений (у блока в markdown — его страница).
470
+ export const renderMermaid = async (source, file) => {
471
+ const text = source.replace(/^/, '');
472
+ const lines = parsedLines(text);
473
+ const type = /^[\w-]+/.exec(text.split(/\r?\n/)[(lines[0] ?? 1) - 1]?.trim() ?? '')?.[0] ?? '';
474
+ // tiny-сборка не включает эти типы и KaTeX: без проверки — невнятное «No diagram type
475
+ // detected» или формула сырым текстом на картинке.
476
+ if (UNSUPPORTED.test(type))
477
+ throw new MermaidError(file, `line ${lines[0]}: unsupported mermaid diagram type '${type}'`);
478
+ const math = /\$\$[\s\S]+?\$\$/.exec(text);
479
+ if (math)
480
+ throw new MermaidError(file, `line ${lineOf(text, math.index)}: unsupported mermaid feature 'KaTeX math' ($$...$$)`);
481
+ const { window } = await getEngine();
482
+ const hash = crypto.createHash('sha1').update(text).digest('hex');
483
+ const id = `m${hash.slice(0, 8)}`;
484
+ let svg;
485
+ try {
486
+ svg = await renderSvg(window, id, Number.parseInt(hash.slice(8, 16), 16), text);
487
+ }
488
+ catch (err) {
489
+ throw new MermaidError(file, problemOf(err, lines, type));
490
+ }
491
+ // Тип, который игнорирует htmlLabels, дал бы в PNG пустые подписи.
492
+ if (svg.includes('<foreignObject'))
493
+ throw new MermaidError(file, `diagram type '${type}' uses HTML labels, which cannot be rendered offline`);
494
+ return Buffer.from(finalizeSvg(svg, id), 'utf8');
495
+ };
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_FONT_NAME } from "./fonts.js";
2
+ import { getResvg, resvgFontOptions } from "./pngraster.js";
3
+ // Метрики вендорного Nimbus Sans для движков, которые в jsdom раскладывают текст сами
4
+ // (BPMN, mermaid): jsdom не умеет ни canvas.measureText, ни getBBox. Меряем тем же resvg,
5
+ // что растрирует PNG. Режим системных шрифтов на них не влияет (как и на D2): детерминизм
6
+ // важнее.
7
+ // hhea и OS/2 Nimbus Sans Regular на 1000 единиц (vendor/fonts): ascender 729,
8
+ // descender −271, sxHeight 516.
9
+ export const ASCENT = 0.729;
10
+ export const DESCENT = 0.271;
11
+ export const X_HEIGHT = 0.516;
12
+ const widthMemo = new Map();
13
+ export const escapeXml = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
14
+ const inkWidth = (text, size, bold) => {
15
+ const { Resvg } = getResvg();
16
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text y="${size}" ` +
17
+ `font-family="${DEFAULT_FONT_NAME}" font-size="${size}"${bold ? ' font-weight="bold"' : ''} ` +
18
+ `xml:space="preserve">${escapeXml(text)}</text></svg>`;
19
+ return new Resvg(svg, { font: resvgFontOptions(false) }).getBBox()?.width ?? 0;
20
+ };
21
+ // Advance-ширина строки, как у браузера: bbox «|текст|» минус bbox «||» — с пробелами по
22
+ // краям, а не ширина чернил.
23
+ export const textWidth = (text, size, bold = false) => {
24
+ if (!text)
25
+ return 0;
26
+ const key = `${size}\0${bold}\0${text}`;
27
+ let width = widthMemo.get(key);
28
+ if (width === undefined) {
29
+ // Округление гасит f32-шум resvg между платформами: координаты текста в SVG
30
+ // считаются из этой ширины и должны совпадать побайтно.
31
+ width = Math.round((inkWidth(`|${text}|`, size, bold) - inkWidth('||', size, bold)) * 100) / 100;
32
+ widthMemo.set(key, width);
33
+ }
34
+ return width;
35
+ };
36
+ export const clearTextWidths = () => widthMemo.clear();
@@ -7,7 +7,7 @@ import crypto from 'node:crypto';
7
7
  // списке), инфо-строка, тело (может быть пустым), закрывающая того же вида не короче
8
8
  // открывающей. Тело захватывается вместе с завершающим переводом строки.
9
9
  const FENCE_RE = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\n]*)\n([\s\S]*?)^[ \t]*\2[`~]*[ \t]*$/gm;
10
- const ENGINE_EXT = { plantuml: '.puml', puml: '.puml', d2: '.d2' };
10
+ const ENGINE_EXT = { plantuml: '.puml', puml: '.puml', d2: '.d2', mermaid: '.mmd' };
11
11
  /** Применить fn к тексту вне fenced-блоков (сами блоки — без изменений). */
12
12
  export const mapOutsideFences = (md, fn) => {
13
13
  let out = '';
@@ -20,7 +20,7 @@ export const mapOutsideFences = (md, fn) => {
20
20
  };
21
21
  /**
22
22
  * Экстрактор диаграмм одной страницы; извлекаются только блоки языков `langs` (из
23
- * plantuml/puml/d2), остальные остаются кодом. Имя файла — `<base>-<sha1 контента>`: не
23
+ * plantuml/puml/d2/mermaid), остальные остаются кодом. Имя файла — `<base>-<sha1 контента>`: не
24
24
  * зависит от позиции fence, поэтому вставка/удаление соседнего блока не сдвигает имена и
25
25
  * не подсовывает картинку соседа из кэша; одинаковые блоки дают одну диаграмму.
26
26
  */