@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.
- package/README.MD +69 -615
- package/dist/cli/commands/check.js +43 -26
- package/dist/cli/commands/help.js +5 -2
- package/dist/cli/commands/jre.js +6 -6
- package/dist/cli/commands/list.js +4 -1
- package/dist/cli/commands/new.js +6 -6
- package/dist/cli/commands/site.js +3 -2
- package/dist/cli/dispatch.js +38 -18
- package/dist/cli/wizard/collect.js +16 -10
- package/dist/config/defaults.js +1 -0
- package/dist/config/options.js +1 -1
- package/dist/config/schema.js +5 -2
- package/dist/core/build.js +15 -6
- package/dist/core/compose/docsify.template.js +0 -1
- package/dist/core/compose/markdown.js +85 -19
- package/dist/core/plugins/hooks.js +1 -1
- package/dist/core/plugins/load.js +6 -6
- package/dist/core/plugins/source.js +6 -6
- package/dist/core/plugins/tree.js +5 -5
- package/dist/core/render/bpmnrenderer.js +522 -0
- package/dist/core/render/d2renderer.js +19 -19
- package/dist/core/render/diagrams.js +64 -45
- package/dist/core/render/jre.js +14 -14
- package/dist/core/render/pngraster.js +4 -4
- package/dist/{plugins/openspec → core/scan}/fences.js +11 -10
- package/dist/core/scan/tree.js +37 -14
- package/dist/plugins/openapi/index.js +17 -7
- package/dist/plugins/openspec/index.js +1 -1
- package/dist/plugins/openspec/render.js +5 -2
- package/dist/util/archive.js +1 -1
- package/dist/util/http.js +3 -3
- package/dist/util/lock.js +3 -3
- package/dist/util/paths.js +1 -1
- package/package.json +9 -4
- package/template/AGENTS.md +52 -0
- package/template/readme.md +3 -3
- package/template/src/5 BPMN Example/account-opening.bpmn +69 -0
- package/template/src/5 BPMN Example/account-opening.md +7 -0
- 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) => ({ '&': '&', '<': '<', '>': '>' })[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('
|
|
22
|
-
'
|
|
23
|
-
|
|
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
|
-
// потребители НЕ мутируют (
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
198
|
-
|
|
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
|
-
|
|
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 };
|