@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
|
@@ -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
|
*/
|
package/dist/core/scan/tree.js
CHANGED
|
@@ -11,7 +11,8 @@ import { createFenceExtractor } from "./fences.js";
|
|
|
11
11
|
export const DIAGRAM_ENGINES = [
|
|
12
12
|
{ ext: '.puml', engine: 'plantuml', remoteRender: true },
|
|
13
13
|
{ ext: '.d2', engine: 'd2', remoteRender: false },
|
|
14
|
-
{ ext: '.bpmn', engine: 'bpmn', remoteRender: false }
|
|
14
|
+
{ ext: '.bpmn', engine: 'bpmn', remoteRender: false },
|
|
15
|
+
{ ext: '.mmd', engine: 'mermaid', remoteRender: false }
|
|
15
16
|
];
|
|
16
17
|
// Расширение (lowercase, с точкой) → движок. undefined ⇒ файл не диаграмма.
|
|
17
18
|
export const engineForExt = (ext) => DIAGRAM_ENGINES.find((e) => e.ext === ext)?.engine;
|
|
@@ -76,26 +77,30 @@ export const generateTree = async (dir, options) => {
|
|
|
76
77
|
await build(childDir, dir);
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
|
-
// Блоки ```plantuml в страницах рендерим при сборке: иначе текст
|
|
80
|
-
// до браузера и клиентский docsify-plantuml
|
|
81
|
-
// отправлял его
|
|
82
|
-
|
|
80
|
+
// Блоки ```plantuml и ```mermaid в страницах рендерим при сборке: иначе текст
|
|
81
|
+
// диаграммы доезжал до браузера и клиентский рендер (docsify-plantuml, в т.ч. в
|
|
82
|
+
// пользовательских шаблонах) отправлял его наружу. puml/d2 остаются кодом — так
|
|
83
|
+
// показывают исходник.
|
|
84
|
+
const fences = createFenceExtractor(['plantuml', 'mermaid']);
|
|
83
85
|
const mdFiles = files.filter((x) => path.extname(x).toLowerCase() === '.md');
|
|
84
86
|
for (const mdFile of mdFiles) {
|
|
85
87
|
const mdPath = path.join(dir, mdFile);
|
|
86
88
|
const { markdown, diagrams } = fences.extract((await readFile(mdPath)).toString(), path.parse(mdFile).name, path.relative(process.cwd(), mdPath).split(path.sep).join('/'));
|
|
87
89
|
item.mdFiles.push(markdown);
|
|
88
90
|
item.sources.push(path.resolve(mdPath));
|
|
89
|
-
for (const d of diagrams)
|
|
91
|
+
for (const d of diagrams) {
|
|
92
|
+
const ext = path.extname(d.file);
|
|
93
|
+
const engine = engineForExt(ext) ?? 'plantuml';
|
|
90
94
|
item.diagrams.push({
|
|
91
95
|
dir: d.file,
|
|
92
|
-
ext
|
|
93
|
-
engine
|
|
96
|
+
ext,
|
|
97
|
+
engine,
|
|
94
98
|
content: d.content,
|
|
95
|
-
isDitaa: /@startditaa/i.test(d.content),
|
|
99
|
+
isDitaa: engine === 'plantuml' && /@startditaa/i.test(d.content),
|
|
96
100
|
source: d.source,
|
|
97
101
|
soft: d.soft
|
|
98
102
|
});
|
|
103
|
+
}
|
|
99
104
|
}
|
|
100
105
|
// Диаграммы: расширение → движок берём из DIAGRAM_ENGINES. Поле dir — имя файла
|
|
101
106
|
// (историческое), engine выбирает рендерер, isDitaa — только для PlantUML.
|
|
@@ -119,7 +119,7 @@ const ignoreTitle = (title) => {
|
|
|
119
119
|
export const relSource = (root, rel) => path.relative(process.cwd(), path.join(root, rel)).split(path.sep).join('/');
|
|
120
120
|
// В артефактах OpenSpec любой блок диаграммы — иллюстрация, а не пример исходника
|
|
121
121
|
// (в отличие от страниц проекта, где извлекается только ```plantuml).
|
|
122
|
-
const FENCE_LANGS = ['plantuml', 'puml', 'd2'];
|
|
122
|
+
const FENCE_LANGS = ['plantuml', 'puml', 'd2', 'mermaid'];
|
|
123
123
|
/**
|
|
124
124
|
* Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
|
|
125
125
|
* на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.9.0-rc.
|
|
3
|
+
"version": "0.9.0-rc.4",
|
|
4
4
|
"description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"vitest": "^4.1.9"
|
|
81
81
|
},
|
|
82
82
|
"optionalDependencies": {
|
|
83
|
+
"@mermaid-js/tiny": "12.2.0",
|
|
83
84
|
"@terrastruct/d2": "0.1.33",
|
|
84
85
|
"bpmn-auto-layout": "2.0.0-alpha.2",
|
|
85
86
|
"bpmn-js": "18.31.0",
|