@alfacapital-tech/c4builder 0.4.0-rc.4 → 0.5.0-rc.1

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.
Files changed (39) hide show
  1. package/README.MD +69 -615
  2. package/dist/cli/commands/check.js +43 -26
  3. package/dist/cli/commands/help.js +5 -2
  4. package/dist/cli/commands/jre.js +6 -6
  5. package/dist/cli/commands/list.js +4 -1
  6. package/dist/cli/commands/new.js +6 -6
  7. package/dist/cli/commands/site.js +3 -2
  8. package/dist/cli/dispatch.js +38 -18
  9. package/dist/cli/wizard/collect.js +16 -10
  10. package/dist/config/defaults.js +1 -0
  11. package/dist/config/options.js +1 -1
  12. package/dist/config/schema.js +5 -2
  13. package/dist/core/build.js +15 -6
  14. package/dist/core/compose/docsify.template.js +0 -1
  15. package/dist/core/compose/markdown.js +85 -19
  16. package/dist/core/plugins/hooks.js +1 -1
  17. package/dist/core/plugins/load.js +6 -6
  18. package/dist/core/plugins/source.js +6 -6
  19. package/dist/core/plugins/tree.js +5 -5
  20. package/dist/core/render/bpmnrenderer.js +522 -0
  21. package/dist/core/render/d2renderer.js +19 -19
  22. package/dist/core/render/diagrams.js +64 -45
  23. package/dist/core/render/jre.js +14 -14
  24. package/dist/core/render/pngraster.js +4 -4
  25. package/dist/{plugins/openspec → core/scan}/fences.js +11 -10
  26. package/dist/core/scan/tree.js +37 -14
  27. package/dist/plugins/openapi/index.js +17 -7
  28. package/dist/plugins/openspec/index.js +1 -1
  29. package/dist/plugins/openspec/render.js +5 -2
  30. package/dist/util/archive.js +1 -1
  31. package/dist/util/http.js +3 -3
  32. package/dist/util/lock.js +3 -3
  33. package/dist/util/paths.js +1 -1
  34. package/package.json +9 -4
  35. package/template/AGENTS.md +52 -0
  36. package/template/readme.md +3 -3
  37. package/template/src/5 BPMN Example/account-opening.bpmn +69 -0
  38. package/template/src/5 BPMN Example/account-opening.md +7 -0
  39. package/dist/util/glob.js +0 -69
@@ -0,0 +1,522 @@
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 { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+ import chalk from 'chalk';
12
+ import { DEFAULT_FONT_NAME } from "./fonts.js";
13
+ import { getResvg, resvgFontOptions } from "./pngraster.js";
14
+ // Третий бэкенд рендера: BPMN 2.0 XML без координат → валидация (bpmnlint) → раскладка
15
+ // (bpmn-auto-layout) → рендер bpmn-js в jsdom → SVG. Все пакеты — optionalDependencies,
16
+ // грузятся лениво при первом .bpmn: проекты без BPMN их не трогают (как D2).
17
+ const require = createRequire(import.meta.url);
18
+ // Версии этих пакетов идут в ключ кэша (render/diagrams): сдвиг раскладки, рендера или
19
+ // правил валидации обязан перерисовать диаграмму.
20
+ export const BPMN_CACHE_PACKAGES = ['bpmn-js', 'bpmn-auto-layout', 'bpmnlint'];
21
+ // Ошибка конвейера: problems — по строке на нарушение без пути файла (check печатает их
22
+ // как `✗ <file>: <problem>`), message — те же строки с путём для сборки.
23
+ export class BpmnError extends Error {
24
+ problems;
25
+ constructor(file, problems) {
26
+ super(problems.map((p) => `${file}: ${p}`).join('\n'));
27
+ this.problems = problems;
28
+ }
29
+ }
30
+ // Имя пакета переменной: у пакетов нет типов, а import() по литералу потребовал бы их.
31
+ const load = (name) => import(__rewriteRelativeImportExtension(name));
32
+ // Набор правил фиксирован (design §4): recommended без no-bpmndi (DI в источнике не нужен
33
+ // и всё равно отбрасывается) + правила c4builder. Проектного .bpmnlintrc нет сознательно:
34
+ // что проверил агент через check, то же проверит CI.
35
+ const lintConfig = (recommended) => ({
36
+ rules: {
37
+ ...recommended,
38
+ 'no-bpmndi': 'off',
39
+ 'c4builder/lane-membership': 'error',
40
+ 'c4builder/sequence-flow-in-pool': 'error',
41
+ 'c4builder/message-flow-between-pools': 'error',
42
+ 'c4builder/gateway-flow-labels': 'warn'
43
+ }
44
+ });
45
+ const processOf = (el) => {
46
+ let cur = el;
47
+ while (cur && !cur.$instanceOf('bpmn:Process'))
48
+ cur = cur.$parent;
49
+ return cur;
50
+ };
51
+ // Листовые дорожки: у вложенной дорожки родитель по стандарту перечисляет те же узлы.
52
+ const leafLanes = (laneSets = []) => laneSets
53
+ .flatMap((s) => s.lanes ?? [])
54
+ .flatMap((l) => (l.childLaneSet?.lanes?.length ? leafLanes([l.childLaneSet]) : [l]));
55
+ const C4BUILDER_RULES = {
56
+ 'lane-membership': () => ({
57
+ check(node, reporter) {
58
+ if (!node.$instanceOf('bpmn:Process'))
59
+ return;
60
+ const lanes = leafLanes(node.laneSets);
61
+ if (!lanes.length)
62
+ return;
63
+ const member = new Map();
64
+ for (const lane of lanes)
65
+ for (const ref of lane.flowNodeRef ?? [])
66
+ member.set(ref, [...(member.get(ref) ?? []), lane.id]);
67
+ for (const el of node.flowElements ?? []) {
68
+ if (!el.$instanceOf('bpmn:FlowNode'))
69
+ continue;
70
+ const of = member.get(el) ?? [];
71
+ // Граничное событие рисуется на границе своей задачи — дорожку берёт от неё.
72
+ if (!of.length && !el.$instanceOf('bpmn:BoundaryEvent'))
73
+ reporter.report(el.id, 'Flow node is not in any lane of its process');
74
+ if (of.length > 1)
75
+ reporter.report(el.id, `Flow node is in several lanes: ${of.join(', ')}`);
76
+ }
77
+ }
78
+ }),
79
+ 'sequence-flow-in-pool': () => ({
80
+ check(node, reporter) {
81
+ if (!node.$instanceOf('bpmn:SequenceFlow'))
82
+ return;
83
+ const own = processOf(node);
84
+ if (processOf(node.sourceRef) !== own || processOf(node.targetRef) !== own)
85
+ reporter.report(node.id, 'Sequence flow crosses a pool boundary; connect pools with a message flow');
86
+ }
87
+ }),
88
+ 'message-flow-between-pools': () => ({
89
+ check(node, reporter) {
90
+ if (!node.$instanceOf('bpmn:MessageFlow'))
91
+ return;
92
+ const participants = node.$parent?.participants ?? [];
93
+ const poolOf = (el) => el?.$instanceOf('bpmn:Participant')
94
+ ? el
95
+ : participants.find((p) => p.processRef && p.processRef === processOf(el));
96
+ const source = poolOf(node.sourceRef);
97
+ if (source && source === poolOf(node.targetRef))
98
+ reporter.report(node.id, 'Message flow connects elements of the same pool; use a sequence flow');
99
+ }
100
+ }),
101
+ 'gateway-flow-labels': () => ({
102
+ check(node, reporter) {
103
+ if (!node.$instanceOf('bpmn:ExclusiveGateway') && !node.$instanceOf('bpmn:InclusiveGateway'))
104
+ return;
105
+ const outgoing = node.outgoing ?? [];
106
+ if (outgoing.length < 2)
107
+ return;
108
+ for (const flow of outgoing)
109
+ if (!flow.name?.trim() && flow !== node.default)
110
+ reporter.report(flow.id, 'Outgoing flow of a diverging gateway has no label');
111
+ }
112
+ })
113
+ };
114
+ // Ширины строк для переноса подписей: diagram-js меряет текст только через
115
+ // canvas.measureText, а jsdom канваса не умеет. Меряем вендорный Nimbus Sans тем же
116
+ // resvg, что растрирует PNG: ширина = bbox «|текст|» минус bbox «||» — так получается
117
+ // advance-ширина с пробелами по краям, а не ширина чернил. Режим системных шрифтов на
118
+ // BPMN не влияет (как и на D2): детерминизм важнее.
119
+ const widthMemo = new Map();
120
+ const escapeXml = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
121
+ const inkWidth = (text, size) => {
122
+ const { Resvg } = getResvg();
123
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text y="${size}" ` +
124
+ `font-family="${DEFAULT_FONT_NAME}" font-size="${size}" xml:space="preserve">${escapeXml(text)}</text></svg>`;
125
+ return new Resvg(svg, { font: resvgFontOptions(false) }).getBBox()?.width ?? 0;
126
+ };
127
+ const textWidth = (text, size) => {
128
+ const key = `${size}\0${text}`;
129
+ let width = widthMemo.get(key);
130
+ if (width === undefined) {
131
+ // Округление гасит f32-шум resvg между платформами: координаты текста в SVG
132
+ // считаются из этой ширины и должны совпадать побайтно.
133
+ width = Math.round((inkWidth(`|${text}|`, size) - inkWidth('||', size)) * 100) / 100;
134
+ widthMemo.set(key, width);
135
+ }
136
+ return width;
137
+ };
138
+ // hhea Nimbus Sans Regular: ascender 729, descender −271 на 1000 единиц (vendor/fonts).
139
+ const ASCENT = 0.729;
140
+ const DESCENT = 0.271;
141
+ // Полифилы окна jsdom: ровно то, что bpmn-js зовёт при импорте и saveSVG. Трансформации
142
+ // пишутся в атрибут сразу — сериализатор берёт атрибуты, а не baseVal.
143
+ const installPolyfills = (window) => {
144
+ class Matrix {
145
+ a;
146
+ b;
147
+ c;
148
+ d;
149
+ e;
150
+ f;
151
+ constructor(a = 1, b = 0, c = 0, d = 1, e = 0, f = 0) {
152
+ this.a = a;
153
+ this.b = b;
154
+ this.c = c;
155
+ this.d = d;
156
+ this.e = e;
157
+ this.f = f;
158
+ }
159
+ multiply(m) {
160
+ return new Matrix(this.a * m.a + this.c * m.b, this.b * m.a + this.d * m.b, this.a * m.c + this.c * m.d, this.b * m.c + this.d * m.d, this.a * m.e + this.c * m.f + this.e, this.b * m.e + this.d * m.f + this.f);
161
+ }
162
+ }
163
+ class Transform {
164
+ matrix = new Matrix();
165
+ setMatrix(m) {
166
+ this.matrix = new Matrix(m.a, m.b, m.c, m.d, m.e, m.f);
167
+ }
168
+ setTranslate(x, y) {
169
+ this.matrix = new Matrix(1, 0, 0, 1, x, y);
170
+ }
171
+ setScale(x, y) {
172
+ this.matrix = new Matrix(x, 0, 0, y);
173
+ }
174
+ setRotate(angle) {
175
+ const r = (angle * Math.PI) / 180;
176
+ this.matrix = new Matrix(Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r));
177
+ }
178
+ }
179
+ const fromMatrix = (m) => {
180
+ const t = new Transform();
181
+ t.setMatrix(m);
182
+ return t;
183
+ };
184
+ // -0 и хвосты плавающей точки в атрибуте дали бы разный текст для одинаковой геометрии.
185
+ const num = (v) => Number(v.toFixed(6)) || 0;
186
+ class TransformList {
187
+ el;
188
+ items = [];
189
+ constructor(el) {
190
+ this.el = el;
191
+ }
192
+ clear() {
193
+ this.items = [];
194
+ this.el.removeAttribute('transform');
195
+ }
196
+ appendItem(t) {
197
+ this.items.push(t);
198
+ const m = this.items.reduce((acc, i) => acc.multiply(i.matrix), new Matrix());
199
+ this.el.setAttribute('transform', `matrix(${[m.a, m.b, m.c, m.d, m.e, m.f].map(num).join(' ')})`);
200
+ return t;
201
+ }
202
+ createSVGTransformFromMatrix = fromMatrix;
203
+ consolidate() {
204
+ return this.items.length
205
+ ? fromMatrix(this.items.reduce((acc, i) => acc.multiply(i.matrix), new Matrix()))
206
+ : null;
207
+ }
208
+ }
209
+ const polyline = (el) => {
210
+ const pts = [...(el.getAttribute('d') ?? '').matchAll(/[MLC]([^MLCZ]+)/gi)].map((m) => {
211
+ const n = m[1]
212
+ .trim()
213
+ .split(/[\s,]+/)
214
+ .map(Number);
215
+ return { x: n[n.length - 2], y: n[n.length - 1] };
216
+ });
217
+ const seg = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i].x, p.y - pts[i].y));
218
+ return {
219
+ length: seg.reduce((a, b) => a + b, 0),
220
+ pointAt(at) {
221
+ let rest = at;
222
+ for (const [i, len] of seg.entries()) {
223
+ if (rest <= len && len > 0) {
224
+ const t = rest / len;
225
+ return {
226
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * t,
227
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * t
228
+ };
229
+ }
230
+ rest -= len;
231
+ }
232
+ return pts[pts.length - 1] ?? { x: 0, y: 0 };
233
+ }
234
+ };
235
+ };
236
+ const lists = new WeakMap();
237
+ const svgProto = window.SVGElement.prototype;
238
+ Object.defineProperty(svgProto, 'transform', {
239
+ get() {
240
+ const list = lists.get(this) ?? new TransformList(this);
241
+ lists.set(this, list);
242
+ return { baseVal: list };
243
+ }
244
+ });
245
+ Object.assign(svgProto, {
246
+ createSVGMatrix: () => new Matrix(),
247
+ createSVGTransform: () => new Transform(),
248
+ createSVGTransformFromMatrix: fromMatrix,
249
+ getCTM: () => new Matrix(),
250
+ getScreenCTM: () => new Matrix(),
251
+ // saveSVG зовёт его для viewBox, который пересчитывается из DI (finalizeSvg).
252
+ // ponytail: подпись messageRef на message flow центрируется по getBBox и съезжает
253
+ // на полширины вправо — честный bbox текста добавить, если такие подписи пойдут в ход.
254
+ getBBox: () => ({ x: 0, y: 0, width: 0, height: 0 }),
255
+ // Середина message flow для конверта messageRef: ломаная по концам сегментов
256
+ // M/L/C пути diagram-js (скругления углов заменяются хордой).
257
+ getTotalLength() {
258
+ return polyline(this).length;
259
+ },
260
+ getPointAtLength(at) {
261
+ return polyline(this).pointAt(at);
262
+ }
263
+ });
264
+ window.SVGMatrix = Matrix;
265
+ window.structuredClone = structuredClone;
266
+ const ctx = {
267
+ font: '12px sans-serif',
268
+ measureText(text) {
269
+ const size = Number(/([\d.]+)px/.exec(this.font)?.[1] ?? 12);
270
+ return {
271
+ width: textWidth(text, size),
272
+ fontBoundingBoxAscent: ASCENT * size,
273
+ fontBoundingBoxDescent: DESCENT * size
274
+ };
275
+ }
276
+ };
277
+ window.HTMLCanvasElement.prototype.getContext = () => ctx;
278
+ };
279
+ let enginePromise = null;
280
+ const getEngine = () => {
281
+ enginePromise ??= (async () => {
282
+ let BpmnModdle;
283
+ let LinterClass;
284
+ let layoutProcess;
285
+ let JSDOM;
286
+ let VirtualConsole;
287
+ let bundlePath;
288
+ // Последовательно, не Promise.all: bpmnlint (CJS) делает require() ESM-зависимостей,
289
+ // которые параллельный import() ещё не догрузил, — Node падает на гонке.
290
+ try {
291
+ ({ BpmnModdle } = await load('bpmn-moddle'));
292
+ ({ Linter: LinterClass } = await load('bpmnlint'));
293
+ ({ layoutProcess } = await load('bpmn-auto-layout'));
294
+ ({ JSDOM, VirtualConsole } = await load('jsdom'));
295
+ bundlePath = require.resolve('bpmn-js/dist/bpmn-viewer.production.min.js');
296
+ }
297
+ catch (err) {
298
+ enginePromise = null; // провал импорта не кешируем — даём повторить
299
+ throw new Error('Rendering .bpmn diagrams requires the optional packages bpmn-js, bpmn-moddle, bpmnlint, ' +
300
+ 'bpmn-auto-layout and jsdom.\n' +
301
+ 'Reinstall without --omit=optional: npm i -g @alfacapital-tech/c4builder\n' +
302
+ `Original error: ${err.message || err}`);
303
+ }
304
+ const recommended = require('bpmnlint/config/recommended')
305
+ .rules;
306
+ const resolver = {
307
+ resolveRule: (pkg, name) => pkg === 'bpmnlint' ? require(`bpmnlint/rules/${name}`) : C4BUILDER_RULES[name],
308
+ resolveConfig: () => null
309
+ };
310
+ // Бандл Viewer исполняется внутри окна: глобальные объекты процесса Node не трогаются.
311
+ // Консоль окна глушим: сбои импорта bpmn-js пишет туда, мы берём их из warnings.
312
+ const { window } = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
313
+ runScripts: 'outside-only',
314
+ virtualConsole: new VirtualConsole()
315
+ });
316
+ installPolyfills(window);
317
+ window.eval(readFileSync(bundlePath, 'utf8'));
318
+ return {
319
+ moddle: new BpmnModdle(),
320
+ // Линтер — на каждый прогон: правила bpmnlint (no-duplicate-sequence-flows и др.)
321
+ // копят состояние в замыкании фабрики, и повторный lint тем же инстансом
322
+ // объявил бы дубликатами все потоки.
323
+ createLinter: () => new LinterClass({ config: lintConfig(recommended), resolver }),
324
+ layoutProcess,
325
+ window
326
+ };
327
+ })();
328
+ return enginePromise;
329
+ };
330
+ // Окно jsdom одно на процесс; закрывается после одиночной сборки и в check (как воркер
331
+ // D2), чтобы CLI завершался. Не бросает — иначе затёр бы исходную причину падения.
332
+ export const teardownBpmn = async () => {
333
+ if (!enginePromise)
334
+ return;
335
+ const pending = enginePromise;
336
+ enginePromise = null;
337
+ widthMemo.clear();
338
+ try {
339
+ (await pending).window.close();
340
+ }
341
+ catch {
342
+ // движок не поднялся — закрывать нечего
343
+ }
344
+ };
345
+ const visit = (el, fn) => {
346
+ fn(el);
347
+ for (const child of el.flowElements ?? [])
348
+ visit(child, fn);
349
+ };
350
+ // Разбор и нормализация (design §3.1). bpmnlint и раскладчик опираются на
351
+ // incoming/outgoing, а агент пишет только sourceRef/targetRef — восстанавливаем их по
352
+ // потокам, иначе каскад ложных no-disconnected. DI источника отбрасываем всегда.
353
+ export const parseBpmn = async (xml, file) => {
354
+ const { moddle } = await getEngine();
355
+ let parsed;
356
+ try {
357
+ parsed = await moddle.fromXML(xml.replace(/^/, ''));
358
+ }
359
+ catch (err) {
360
+ throw new BpmnError(file, [`invalid BPMN XML: ${oneLine(err.message)}`]);
361
+ }
362
+ if (parsed.warnings.length)
363
+ throw new BpmnError(file, parsed.warnings.map((w) => `${w.element?.id ? `${w.element.id}: ` : ''}${oneLine(w.message)}`));
364
+ const definitions = parsed.rootElement;
365
+ const elements = [];
366
+ for (const root of definitions.rootElements ?? [])
367
+ visit(root, (el) => elements.push(el));
368
+ for (const el of elements)
369
+ if (el.$instanceOf('bpmn:FlowNode')) {
370
+ el.incoming = [];
371
+ el.outgoing = [];
372
+ }
373
+ for (const flow of elements)
374
+ if (flow.$instanceOf('bpmn:SequenceFlow')) {
375
+ flow.sourceRef?.outgoing?.push(flow);
376
+ flow.targetRef?.incoming?.push(flow);
377
+ }
378
+ definitions.diagrams = [];
379
+ return definitions;
380
+ };
381
+ const oneLine = (s) => s.replace(/\s*\n\s*/g, ', ');
382
+ export const lintBpmn = async (definitions) => {
383
+ const linter = (await getEngine()).createLinter();
384
+ const errors = [];
385
+ const warnings = [];
386
+ for (const [rule, reports] of Object.entries(await linter.lint(definitions)))
387
+ for (const r of reports)
388
+ (r.category === 'warn' ? warnings : errors).push(`${r.id ?? '-'} [${rule}] ${r.message}`);
389
+ return { errors, warnings };
390
+ };
391
+ // Элементы, которые обязаны получить фигуру в DI (защита от bpmn-auto-layout#154:
392
+ // раскладчик молча теряет узлы и рёбра при пустых warnings).
393
+ const expectedDi = (definitions) => {
394
+ const out = [];
395
+ const lanes = (sets = []) => {
396
+ for (const lane of sets.flatMap((s) => s.lanes ?? [])) {
397
+ out.push(lane);
398
+ if (lane.childLaneSet)
399
+ lanes([lane.childLaneSet]);
400
+ }
401
+ };
402
+ for (const root of definitions.rootElements ?? []) {
403
+ out.push(...(root.participants ?? []), ...(root.messageFlows ?? []));
404
+ if (!root.$instanceOf('bpmn:Process'))
405
+ continue;
406
+ visit(root, (el) => {
407
+ lanes(el.laneSets);
408
+ if (el.$instanceOf('bpmn:FlowNode') || el.$instanceOf('bpmn:SequenceFlow'))
409
+ out.push(el);
410
+ });
411
+ }
412
+ return out;
413
+ };
414
+ export const layoutBpmn = async (definitions, file, layout) => {
415
+ const engine = await getEngine();
416
+ let result;
417
+ try {
418
+ result = await (layout ?? engine.layoutProcess)((await engine.moddle.toXML(definitions)).xml);
419
+ }
420
+ catch (err) {
421
+ const e = err;
422
+ throw new BpmnError(file, [
423
+ `layout failed: ${e.elementId ? `${e.elementId}: ` : ''}${oneLine(e.message || String(e))}`
424
+ ]);
425
+ }
426
+ const laidOut = (await engine.moddle.fromXML(result.xml)).rootElement;
427
+ const drawn = new Set((laidOut.diagrams ?? []).flatMap((d) => (d.plane?.planeElement ?? []).map((p) => p.bpmnElement?.id)));
428
+ const lost = expectedDi(laidOut)
429
+ .map((el) => el.id)
430
+ .filter((id) => !drawn.has(id));
431
+ if (lost.length)
432
+ throw new BpmnError(file, [
433
+ `layout lost ${lost.length} element(s): ${lost.join(', ')} (bpmn-auto-layout defect; ` +
434
+ 'simplify or split the model)'
435
+ ]);
436
+ return {
437
+ xml: result.xml,
438
+ warnings: result.warnings.map((w) => `${w.elementId ?? '-'} [layout${w.code ? `:${w.code}` : ''}] ${oneLine(w.message)}`)
439
+ };
440
+ };
441
+ // Постобработка SVG (design §3.5): viewBox по DI вместо getBBox, детерминированные id
442
+ // маркеров (bpmn-js генерирует случайные) и url(#…) без кавычек — resvg ссылки в
443
+ // кавычках игнорирует, и стрелки в PNG пропадают.
444
+ const PADDING = 10;
445
+ const finalizeSvg = (svg, elements) => {
446
+ let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
447
+ for (const el of elements) {
448
+ const points = el.waypoints ??
449
+ (el.x === undefined || el.y === undefined
450
+ ? []
451
+ : [
452
+ { x: el.x, y: el.y },
453
+ { x: el.x + (el.width ?? 0), y: el.y + (el.height ?? 0) }
454
+ ]);
455
+ for (const p of points) {
456
+ x0 = Math.min(x0, p.x);
457
+ y0 = Math.min(y0, p.y);
458
+ x1 = Math.max(x1, p.x);
459
+ y1 = Math.max(y1, p.y);
460
+ }
461
+ }
462
+ if (x0 === Infinity)
463
+ [x0, y0, x1, y1] = [0, 0, 0, 0];
464
+ const x = Math.floor(x0) - PADDING;
465
+ const y = Math.floor(y0) - PADDING;
466
+ const w = Math.ceil(x1) - Math.floor(x0) + 2 * PADDING;
467
+ const h = Math.ceil(y1) - Math.floor(y0) + 2 * PADDING;
468
+ const ids = new Map();
469
+ for (const [, id] of svg.matchAll(/id="(marker-[^"]+)"/g))
470
+ if (!ids.has(id))
471
+ ids.set(id, `marker-${ids.size + 1}`);
472
+ return svg
473
+ .replace(/ width="[^"]*" height="[^"]*" viewBox="[^"]*"/, ` width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}"`)
474
+ .replace(/marker-[0-9a-z]+/g, (m) => ids.get(m) ?? m)
475
+ .replace(/url\('#([^']+)'\)/g, 'url(#$1)');
476
+ };
477
+ const renderSvg = async (xml) => {
478
+ const { window } = await getEngine();
479
+ const container = window.document.createElement('div');
480
+ window.document.body.appendChild(container);
481
+ const viewer = new window.BpmnJS({
482
+ container,
483
+ // Nimbus Sans метрически совместим с Helvetica/Arial: в браузере без него SVG
484
+ // показывается ими с теми же переносами.
485
+ textRenderer: { defaultStyle: { fontFamily: `${DEFAULT_FONT_NAME}, Helvetica, Arial, sans-serif` } }
486
+ });
487
+ try {
488
+ // Элемент, который bpmn-js не смог нарисовать, иначе молча пропал бы из картинки.
489
+ const { warnings } = await viewer.importXML(xml);
490
+ if (warnings.length)
491
+ throw new Error(warnings
492
+ .map((w) => `${w.element?.id ? `${w.element.id}: ` : ''}${oneLine(w.message)}`)
493
+ .join('; '));
494
+ const { svg } = await viewer.saveSVG();
495
+ return finalizeSvg(svg, viewer.get('elementRegistry').getAll());
496
+ }
497
+ finally {
498
+ viewer.destroy();
499
+ container.remove();
500
+ }
501
+ };
502
+ // Полный конвейер одного файла — общий для сборки и `c4builder check`. file — путь для
503
+ // сообщений; предупреждения валидации и раскладки печатаются, но рендер не прерывают.
504
+ export const renderBpmn = async (xml, file) => {
505
+ const definitions = await parseBpmn(xml, file);
506
+ const lint = await lintBpmn(definitions);
507
+ for (const w of lint.warnings)
508
+ console.log(chalk.yellow(`⚠ ${file}: ${w}`));
509
+ if (lint.errors.length)
510
+ throw new BpmnError(file, lint.errors);
511
+ const laidOut = await layoutBpmn(definitions, file);
512
+ for (const w of laidOut.warnings)
513
+ console.log(chalk.yellow(`⚠ ${file}: ${w}`));
514
+ let svg;
515
+ try {
516
+ svg = await renderSvg(laidOut.xml);
517
+ }
518
+ catch (err) {
519
+ throw new BpmnError(file, [`render failed: ${oneLine(err.message || String(err))}`]);
520
+ }
521
+ return Buffer.from(svg, 'utf8');
522
+ };
@@ -18,9 +18,9 @@ const getD2 = () => {
18
18
  catch (err) {
19
19
  d2Promise = null; // провал импорта не кешируем — даём повторить
20
20
  const e = err;
21
- throw new Error('Для рендера .d2-диаграмм нужен пакет @terrastruct/d2 (опциональная зависимость, ~60 МБ).\n' +
22
- 'Установите его: npm install @terrastruct/d2\n' +
23
- `Исходная ошибка: ${e.message || e}`);
21
+ throw new Error('Rendering .d2 diagrams requires the @terrastruct/d2 package (optional dependency, ~60 MB).\n' +
22
+ 'Install it: npm install @terrastruct/d2\n' +
23
+ `Original error: ${e.message || e}`);
24
24
  }
25
25
  return new mod.D2();
26
26
  })();
@@ -114,7 +114,7 @@ const collectFiles = (entryAbs, acc = new Map(), seed) => {
114
114
  // buildCompileRequest (рендер) иначе читают один и тот же граф с диска дважды.
115
115
  // Ключ — абсолютный путь входного файла. Сбрасывается clearD2FileCache() на границах
116
116
  // build(), иначе watch-режим отдавал бы устаревшее содержимое. Возвращаемую Map
117
- // потребители НЕ мутируют (foldD2Imports исключает входной файл фильтром, не delete).
117
+ // потребители НЕ мутируют (d2LocalImports исключает входной файл фильтром, не delete).
118
118
  let filesMemo = null;
119
119
  const clearD2FileCache = () => {
120
120
  filesMemo = null;
@@ -151,7 +151,7 @@ const buildCompileRequest = (entryAbs, seed) => {
151
151
  // Пустой граф = не прочитался сам входной файл (удалён/недоступен — гонка в watch).
152
152
  // Без guard'а commonAncestor([]) давал бы сырой TypeError (Math.min()=Infinity).
153
153
  if (files.size === 0) {
154
- throw new Error(`D2: не удалось прочитать ${entryAbs} (файл удалён или недоступен?)`);
154
+ throw new Error(`D2: failed to read ${entryAbs} (file deleted or unavailable?)`);
155
155
  }
156
156
  const root = commonAncestor([...files.keys()]);
157
157
  const toKey = (abs) => path.relative(root, abs).split(path.sep).join('/');
@@ -181,7 +181,7 @@ const renderD2 = async (entryAbs, { layout = 'dagre', seed } = {}) => {
181
181
  }
182
182
  catch (err) {
183
183
  const e = err;
184
- throw new Error(`Ошибка компиляции D2 (${entryAbs}):\n${d2ErrorText(e.message || String(e))}`);
184
+ throw new Error(`D2 compile error (${entryAbs}):\n${d2ErrorText(e.message || String(e))}`);
185
185
  }
186
186
  let svg;
187
187
  try {
@@ -189,27 +189,27 @@ const renderD2 = async (entryAbs, { layout = 'dagre', seed } = {}) => {
189
189
  }
190
190
  catch (err) {
191
191
  const e = err;
192
- throw new Error(`Ошибка рендера D2 (${entryAbs}):\n${e.message || e}`);
192
+ throw new Error(`D2 render error (${entryAbs}):\n${e.message || e}`);
193
193
  }
194
194
  return Buffer.from(svg, 'utf8');
195
195
  };
196
- // Материал импортов для чексуммы кэша (без самого входного файла — его контент
197
- // хэшируется отдельно). Правка импортируемого .d2 меняет чексумму зависимой
198
- // диаграммы — аналог foldIncludes для PlantUML.
196
+ // Локальные импорты .d2 рекурсивно, без самого входного файла. Один граф на рендер,
197
+ // чексумму кэша (foldD2Imports) и приложение llms-full.txt.
198
+ const d2LocalImports = (entryAbs, seed) => {
199
+ const abs = path.resolve(entryAbs);
200
+ return [...collectFilesCached(entryAbs, seed).entries()].filter(([p]) => p !== abs);
201
+ };
202
+ // Материал импортов для чексуммы кэша (входной файл хэшируется отдельно). Правка
203
+ // импортируемого .d2 меняет чексумму зависимой диаграммы — аналог foldIncludes для PlantUML.
199
204
  // Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит
200
- // от машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
205
+ // от машины/чекаута/ОС (абсолютный путь делал кеш непереносимым). Сортировка по
206
+ // относительному ключу — тому же, что уходит в материал.
201
207
  const foldD2Imports = (entryAbs, seed) => {
202
- const abs = path.resolve(entryAbs);
203
- const files = collectFilesCached(entryAbs, seed);
204
208
  const rel = (p) => path.relative(process.cwd(), p).split(path.sep).join('/');
205
- // Мемоизированную Map не мутируем (её же использует buildCompileRequest) —
206
- // входной файл исключаем фильтром, а не delete. Сортировка по относительному
207
- // ключу — тому же, что уходит в материал.
208
- return [...files.entries()]
209
- .filter(([p]) => p !== abs)
209
+ return d2LocalImports(entryAbs, seed)
210
210
  .map(([p, content]) => [rel(p), content])
211
211
  .sort((a, b) => a[0].localeCompare(b[0]))
212
212
  .map(([p, content]) => ` ${p} ${content}`)
213
213
  .join('');
214
214
  };
215
- export { renderD2, foldD2Imports, teardownD2, clearD2FileCache };
215
+ export { renderD2, foldD2Imports, d2LocalImports, teardownD2, clearD2FileCache };