@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.
- package/README.MD +4 -2
- package/dist/cli/commands/check.js +9 -6
- package/dist/cli/commands/site.comments.js +76 -42
- package/dist/cli/commands/site.i18n.js +88 -0
- package/dist/cli/commands/site.js +2 -1
- package/dist/cli/dispatch.js +3 -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/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 +10 -4
- package/template/src/6 Mermaid Example/flow.mmd +6 -0
- package/template/src/6 Mermaid Example/mermaid.md +16 -0
|
@@ -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')
|
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>' })[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();
|
package/dist/core/scan/fences.js
CHANGED
|
@@ -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
|
*/
|