@alfacapital-tech/c4builder 0.9.0-rc.2 → 0.9.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +4 -2
- package/dist/cli/commands/check.js +9 -6
- package/dist/cli/commands/site.comments.js +76 -42
- package/dist/cli/commands/site.i18n.js +88 -0
- package/dist/cli/commands/site.js +2 -1
- package/dist/cli/dispatch.js +3 -0
- package/dist/config/defaults.js +2 -1
- package/dist/config/schema.js +2 -0
- package/dist/core/compose/markdown.js +3 -2
- package/dist/core/render/bpmnrenderer.js +3 -28
- package/dist/core/render/diagrams.js +22 -10
- package/dist/core/render/mermaidrenderer.js +495 -0
- package/dist/core/render/textmetrics.js +36 -0
- package/dist/core/scan/fences.js +2 -2
- package/dist/core/scan/tree.js +14 -9
- package/dist/plugins/openspec/render.js +1 -1
- package/package.json +2 -1
- package/template/AGENTS.md +10 -4
- package/template/src/6 Mermaid Example/flow.mmd +6 -0
- package/template/src/6 Mermaid Example/mermaid.md +16 -0
package/README.MD
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
**Architecture as code that an agent writes, checks and reads.**
|
|
6
6
|
|
|
7
7
|
C4-Builder is a CLI that builds a folder of markdown files and [PlantUML](https://plantuml.com/),
|
|
8
|
-
[D2](https://d2lang.com/)
|
|
8
|
+
[D2](https://d2lang.com/), [BPMN](https://www.bpmn.org/) or [Mermaid](https://mermaid.js.org/) diagrams into documentation: a [docsify](https://docsify.js.org/)
|
|
9
9
|
site, a markdown file per folder, or a single file. Folders follow the
|
|
10
10
|
[C4 model](https://c4model.com/) (system → container → component), and git keeps the history of
|
|
11
11
|
architecture decisions next to the code.
|
|
@@ -19,7 +19,7 @@ than a hundred diagrams, so we continue it as a maintained fork, adapted to work
|
|
|
19
19
|
- **Deterministic offline rendering.** The PlantUML jar, the diagram font and the engines ship
|
|
20
20
|
with the package; layout is computed by PlantUML's built-in Smetana engine, no Graphviz. The
|
|
21
21
|
same sources produce the same SVG on a laptop, in CI and in Docker, with no network access.
|
|
22
|
-
- **`c4builder check`** validates individual `.puml`/`.iuml`/`.d2`/`.bpmn` files with the build's own
|
|
22
|
+
- **`c4builder check`** validates individual `.puml`/`.iuml`/`.d2`/`.bpmn`/`.mmd` files with the build's own
|
|
23
23
|
engine and returns an exit code and the error line — fast feedback for agents, pre-commit
|
|
24
24
|
hooks and CI.
|
|
25
25
|
- **Plugins.** `openspec` publishes an [OpenSpec](https://github.com/Fission-AI/OpenSpec) store
|
|
@@ -29,6 +29,8 @@ than a hundred diagrams, so we continue it as a maintained fork, adapted to work
|
|
|
29
29
|
without a system Java, c4builder downloads a private JRE.
|
|
30
30
|
- **BPMN** processes written as BPMN 2.0 XML without coordinates: the build validates the model
|
|
31
31
|
and lays it out with horizontal pools and lanes.
|
|
32
|
+
- **Mermaid** `.mmd` files and ```` ```mermaid ```` blocks are rendered at build time by real mermaid,
|
|
33
|
+
without a browser: images in every output instead of client-side rendering.
|
|
32
34
|
- **Maintained.** TypeScript, golden rendering snapshots, CI on every pull request, releases to
|
|
33
35
|
npm and GHCR. The PDF output of the original was removed.
|
|
34
36
|
|
|
@@ -7,8 +7,9 @@ import { resolveJava } from "../../core/render/jre.js";
|
|
|
7
7
|
import { renderDiagram } from "../../core/render/diagrams.js";
|
|
8
8
|
import { renderD2, teardownD2 } from "../../core/render/d2renderer.js";
|
|
9
9
|
import { BpmnError, renderBpmn, teardownBpmn } from "../../core/render/bpmnrenderer.js";
|
|
10
|
+
import { MermaidError, renderMermaid, teardownMermaid } from "../../core/render/mermaidrenderer.js";
|
|
10
11
|
// `c4builder check <file...>` — валидация отдельных диаграмм ТЕМ ЖЕ движком, что и
|
|
11
|
-
// сборка (вендорный PlantUML jar + системная/managed java, бандл D2, конвейер BPMN), без проекта и
|
|
12
|
+
// сборка (вендорный PlantUML jar + системная/managed java, бандл D2, конвейер BPMN, mermaid), без проекта и
|
|
12
13
|
// `.c4builder`. Рендер идёт в память и выбрасывается: проверяем ровно то, что упало бы
|
|
13
14
|
// в сборке (системный `plantuml` может отличаться версией). Код выхода 0 — все файлы ок,
|
|
14
15
|
// 1 — есть ошибка (движка, неизвестного расширения или чтения файла).
|
|
@@ -64,11 +65,12 @@ const CHECKERS = {
|
|
|
64
65
|
'.puml': checkPlantUml,
|
|
65
66
|
'.iuml': checkPlantUml,
|
|
66
67
|
'.d2': (abs) => renderD2(abs),
|
|
67
|
-
'.bpmn': async (abs) => renderBpmn(await readFile(abs, 'utf8'), path.relative(process.cwd(), abs))
|
|
68
|
+
'.bpmn': async (abs) => renderBpmn(await readFile(abs, 'utf8'), path.relative(process.cwd(), abs)),
|
|
69
|
+
'.mmd': async (abs) => renderMermaid(await readFile(abs, 'utf8'), path.relative(process.cwd(), abs))
|
|
68
70
|
};
|
|
69
71
|
export default async (files) => {
|
|
70
72
|
if (files.length === 0) {
|
|
71
|
-
console.log(chalk.red('usage: c4builder check <file...> (.puml | .iuml | .d2 | .bpmn)'));
|
|
73
|
+
console.log(chalk.red('usage: c4builder check <file...> (.puml | .iuml | .d2 | .bpmn | .mmd)'));
|
|
72
74
|
process.exit(1);
|
|
73
75
|
}
|
|
74
76
|
let failed = 0;
|
|
@@ -78,20 +80,21 @@ export default async (files) => {
|
|
|
78
80
|
const check = CHECKERS[path.extname(abs).toLowerCase()];
|
|
79
81
|
try {
|
|
80
82
|
if (!check)
|
|
81
|
-
throw new Error('unsupported extension (expected .puml, .iuml, .d2 or .
|
|
83
|
+
throw new Error('unsupported extension (expected .puml, .iuml, .d2, .bpmn or .mmd)');
|
|
82
84
|
await check(abs);
|
|
83
85
|
console.log(chalk.green(`✓ ${file}`));
|
|
84
86
|
}
|
|
85
87
|
catch (e) {
|
|
86
88
|
failed++;
|
|
87
|
-
// BPMN: по строке на нарушение — агент правит их по id
|
|
88
|
-
const problems = e instanceof BpmnError ? e.problems : [e.message];
|
|
89
|
+
// BPMN: по строке на нарушение — агент правит их по id элемента; mermaid — `line N: …`.
|
|
90
|
+
const problems = e instanceof BpmnError || e instanceof MermaidError ? e.problems : [e.message];
|
|
89
91
|
for (const p of problems)
|
|
90
92
|
console.error(chalk.red(`✗ ${file}: ${p}`));
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
await teardownD2();
|
|
94
96
|
await teardownBpmn();
|
|
97
|
+
await teardownMermaid();
|
|
95
98
|
if (failed)
|
|
96
99
|
process.exit(1);
|
|
97
100
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UI_STRINGS } from "./site.i18n.js";
|
|
1
2
|
export const COMMENTS_PATH = '/__c4builder/comments';
|
|
2
3
|
export const FEEDBACK_PATH = '/__c4builder/feedback';
|
|
3
4
|
export const FEEDBACK_HEADER = 'X-C4builder-Feedback';
|
|
@@ -10,6 +11,9 @@ const CSS = [
|
|
|
10
11
|
'.c4b-cpop textarea,.c4b-review textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
|
|
11
12
|
'.c4b-cpop .c4b-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px}',
|
|
12
13
|
'.c4b-cpop small{color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
14
|
+
// Кнопки поповера не сжимаются: длинную подсказку обрезает многоточие, а не кнопки уходят
|
|
15
|
+
// на вторую строку (русские подписи и длинные названия разделов).
|
|
16
|
+
'.c4b-cpop .c4b-row>span{flex:none}',
|
|
13
17
|
'.c4b-cpop button,.c4b-review button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
|
|
14
18
|
'.c4b-cpop button.c4b-primary,.c4b-review button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
|
|
15
19
|
'.c4b-cpop button:disabled,.c4b-review button:disabled{opacity:.6;cursor:default}',
|
|
@@ -18,6 +22,9 @@ const CSS = [
|
|
|
18
22
|
'.c4b-review{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:1000;width:520px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:8px 12px;border:1px solid #3b82f6;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.2)}',
|
|
19
23
|
'.c4b-review .c4b-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
|
|
20
24
|
'.c4b-review .c4b-row small{flex:1;color:#888;white-space:nowrap}',
|
|
25
|
+
// Кнопки панели переносятся одной группой: иначе при нехватке места часть уезжает на
|
|
26
|
+
// вторую строку, а Discard остаётся один в первой.
|
|
27
|
+
'.c4b-review .c4b-acts{display:flex;gap:6px;margin-left:auto}',
|
|
21
28
|
'.c4b-review button.c4b-link{padding:0;border:0;background:none;color:#2c3e50;font-weight:700;text-decoration:underline dotted}',
|
|
22
29
|
'.c4b-review ul{margin:6px 0;padding:0;list-style:none;max-height:40vh;overflow:auto}',
|
|
23
30
|
'.c4b-review li{padding:3px 0;border-top:1px solid #eee;overflow-wrap:anywhere}',
|
|
@@ -36,27 +43,47 @@ const CSS = [
|
|
|
36
43
|
'.c4b-del:hover{color:#fff;background:#c0392b;border-color:#c0392b}',
|
|
37
44
|
'.c4b-del:disabled{opacity:.5;cursor:default}'
|
|
38
45
|
].join('');
|
|
39
|
-
const
|
|
46
|
+
const jsValue = (v) => JSON.stringify(v)
|
|
40
47
|
.replace(/</g, '\\u003c')
|
|
41
48
|
.replace(/[\u0080-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
42
49
|
// Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
|
|
43
50
|
// события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
|
|
44
|
-
|
|
51
|
+
// Язык выбирает сам скрипт: при `auto` — по navigator.languages (change site-ui-language, D1).
|
|
52
|
+
export const commentsSnippet = (heading, { agent = false, listening = false, project = '', language = 'auto' } = {}) => `
|
|
45
53
|
<script>
|
|
46
54
|
(function () {
|
|
47
55
|
if (window.__c4builderComments) return;
|
|
48
56
|
window.__c4builderComments = true;
|
|
49
57
|
var ENDPOINT = '${COMMENTS_PATH}';
|
|
50
58
|
var FEEDBACK = '${FEEDBACK_PATH}';
|
|
51
|
-
var HEADING = ${
|
|
59
|
+
var HEADING = ${jsValue(heading)};
|
|
52
60
|
var TOAST_KEY = '__c4builderCommentToast';
|
|
53
|
-
var DRAFT_KEY = '__c4builderReview:' + ${
|
|
61
|
+
var DRAFT_KEY = '__c4builderReview:' + ${jsValue(project)};
|
|
54
62
|
var AGENT = { on: ${agent}, listening: ${listening} };
|
|
55
63
|
var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
|
|
56
64
|
var style = document.createElement('style');
|
|
57
|
-
style.textContent = ${
|
|
65
|
+
style.textContent = ${jsValue(CSS)};
|
|
58
66
|
document.head.appendChild(style);
|
|
59
67
|
|
|
68
|
+
var STRINGS = ${jsValue(UI_STRINGS)};
|
|
69
|
+
var has = function (lang) { return Object.prototype.hasOwnProperty.call(STRINGS, lang); };
|
|
70
|
+
var LANG = (function (pref) {
|
|
71
|
+
if (has(pref)) return pref;
|
|
72
|
+
var prefs = [].concat(navigator.languages || [], navigator.language || []);
|
|
73
|
+
for (var i = 0; i < prefs.length; i++) {
|
|
74
|
+
var tag = String(prefs[i]).toLowerCase().split('-')[0];
|
|
75
|
+
if (has(tag)) return tag;
|
|
76
|
+
}
|
|
77
|
+
return 'en';
|
|
78
|
+
})(${jsValue(language)});
|
|
79
|
+
var plural = new Intl.PluralRules(LANG);
|
|
80
|
+
var t = function (key, vars) {
|
|
81
|
+
vars = vars || {};
|
|
82
|
+
var s = STRINGS[LANG][key];
|
|
83
|
+
if (typeof s === 'object') s = s[plural.select(vars.n)];
|
|
84
|
+
return s.replace(/\\{(\\w+)\\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
|
|
85
|
+
};
|
|
86
|
+
|
|
60
87
|
var collapse = function (s) { return String(s).replace(/\\s+/g, ' ').trim(); };
|
|
61
88
|
var same = function (a, b) { return collapse(a).toLowerCase() === collapse(b).toLowerCase(); };
|
|
62
89
|
|
|
@@ -203,18 +230,18 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
203
230
|
del.type = 'button';
|
|
204
231
|
del.className = 'c4b-del';
|
|
205
232
|
del.textContent = '\\u00d7';
|
|
206
|
-
del.title = '
|
|
233
|
+
del.title = t('removeQuestion');
|
|
207
234
|
del.addEventListener('click', function (e) {
|
|
208
235
|
e.preventDefault();
|
|
209
236
|
e.stopPropagation();
|
|
210
237
|
del.disabled = true;
|
|
211
238
|
send({ page: pageFile(), number: Number(item.number) || null, text: collapse(item.text) }, 'DELETE').then(function (r) {
|
|
212
|
-
var msg = '
|
|
239
|
+
var msg = t('removed', { file: r.file });
|
|
213
240
|
try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
|
|
214
241
|
toast(msg);
|
|
215
242
|
}, function (err) {
|
|
216
243
|
del.disabled = false;
|
|
217
|
-
|
|
244
|
+
failure('removeFailed')(err);
|
|
218
245
|
});
|
|
219
246
|
});
|
|
220
247
|
item.cell.appendChild(del);
|
|
@@ -272,10 +299,10 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
272
299
|
var btns = document.createElement('div');
|
|
273
300
|
btns.className = 'c4b-cbtns';
|
|
274
301
|
btns.hidden = true;
|
|
275
|
-
var fbBtn = button('
|
|
276
|
-
fbBtn.title = '
|
|
277
|
-
var qBtn = button('
|
|
278
|
-
qBtn.title = '
|
|
302
|
+
var fbBtn = button(t('feedback'), null, 'c4b-fb');
|
|
303
|
+
fbBtn.title = t('feedbackTitle');
|
|
304
|
+
var qBtn = button(t('question'));
|
|
305
|
+
qBtn.title = t('questionTitle', { heading: HEADING });
|
|
279
306
|
btns.appendChild(fbBtn);
|
|
280
307
|
btns.appendChild(qBtn);
|
|
281
308
|
document.body.appendChild(btns);
|
|
@@ -347,8 +374,8 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
347
374
|
});
|
|
348
375
|
});
|
|
349
376
|
};
|
|
350
|
-
var failure = function (
|
|
351
|
-
return function (err) { toast(
|
|
377
|
+
var failure = function (key) {
|
|
378
|
+
return function (err) { toast(t(key, { error: err && err.message ? err.message : err }), true); };
|
|
352
379
|
};
|
|
353
380
|
|
|
354
381
|
// Feedback to the agent: "Delivered" only after the server handed the batch to
|
|
@@ -356,15 +383,15 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
356
383
|
var delivered = {}, pending = {};
|
|
357
384
|
var sendBatch = function (decision, comments, general) {
|
|
358
385
|
return send({ decision: decision, comments: comments, general: general }, 'POST', FEEDBACK).then(function (r) {
|
|
359
|
-
if (delivered[r.id]) return toast('
|
|
386
|
+
if (delivered[r.id]) return toast(t('delivered'));
|
|
360
387
|
pending[r.id] = true;
|
|
361
|
-
toast(r.queued ? '
|
|
388
|
+
toast(t(r.queued ? 'queued' : 'sent'));
|
|
362
389
|
});
|
|
363
390
|
};
|
|
364
391
|
window.addEventListener('c4builder:delivered', function (e) {
|
|
365
392
|
var id = e.detail && e.detail.id;
|
|
366
393
|
delivered[id] = true;
|
|
367
|
-
if (pending[id]) { delete pending[id]; toast('
|
|
394
|
+
if (pending[id]) { delete pending[id]; toast(t('delivered')); }
|
|
368
395
|
});
|
|
369
396
|
|
|
370
397
|
// Review draft lives in localStorage per project: it survives livereload and a closed tab;
|
|
@@ -411,7 +438,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
411
438
|
listen.className = 'c4b-listen';
|
|
412
439
|
var list = document.createElement('ul');
|
|
413
440
|
var general = document.createElement('textarea');
|
|
414
|
-
general.placeholder = '
|
|
441
|
+
general.placeholder = t('generalPlaceholder');
|
|
415
442
|
general.hidden = true;
|
|
416
443
|
var finish = function (decision) {
|
|
417
444
|
var items = draft(), ids = items.map(function (it) { return it.id; });
|
|
@@ -422,18 +449,18 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
422
449
|
general.hidden = true;
|
|
423
450
|
expanded = false;
|
|
424
451
|
saveDraft(draft().filter(function (it) { return ids.indexOf(it.id) < 0; }));
|
|
425
|
-
}, failure('
|
|
452
|
+
}, failure('reviewFailed')).then(function () { busy = false; renderPanel(); });
|
|
426
453
|
};
|
|
427
|
-
var discard = button('
|
|
454
|
+
var discard = button(t('discard'), function () {
|
|
428
455
|
var n = draft().length;
|
|
429
|
-
if (n && !window.confirm('
|
|
456
|
+
if (n && !window.confirm(t('discardConfirm', { n: n }))) return;
|
|
430
457
|
general.value = '';
|
|
431
458
|
general.hidden = true;
|
|
432
459
|
saveDraft([]);
|
|
433
460
|
});
|
|
434
|
-
|
|
435
|
-
approve
|
|
436
|
-
var submit = button('
|
|
461
|
+
// One finishing action, two labels: on an empty draft "Approve" does not read as "finish".
|
|
462
|
+
var approve = button('', function () { finish('approved'); });
|
|
463
|
+
var submit = button(t('submitReview'), function () {
|
|
437
464
|
if (!general.hidden) return finish('submitted');
|
|
438
465
|
general.hidden = false;
|
|
439
466
|
general.focus();
|
|
@@ -444,20 +471,23 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
444
471
|
});
|
|
445
472
|
var head = document.createElement('div');
|
|
446
473
|
head.className = 'c4b-row';
|
|
447
|
-
|
|
474
|
+
var acts = document.createElement('span');
|
|
475
|
+
acts.className = 'c4b-acts';
|
|
476
|
+
[discard, approve, submit].forEach(function (el) { acts.appendChild(el); });
|
|
477
|
+
[count, listen, acts].forEach(function (el) { head.appendChild(el); });
|
|
448
478
|
panel.appendChild(head);
|
|
449
479
|
panel.appendChild(list);
|
|
450
480
|
panel.appendChild(general);
|
|
451
481
|
document.body.appendChild(panel);
|
|
452
482
|
var renderPanel = function () {
|
|
453
483
|
panel.hidden = !AGENT.on;
|
|
454
|
-
var items = draft(), pages = {};
|
|
484
|
+
var items = draft(), pages = {}, empty = !items.length;
|
|
455
485
|
items.forEach(function (it) { pages[it.page] = true; });
|
|
456
486
|
var n = Object.keys(pages).length;
|
|
457
|
-
count.textContent =
|
|
458
|
-
?
|
|
459
|
-
: '
|
|
460
|
-
count.disabled =
|
|
487
|
+
count.textContent = empty
|
|
488
|
+
? t('reviewEmpty')
|
|
489
|
+
: t('reviewCount', { comments: t('comments', { n: items.length }), pages: t('pages', { n: n }) });
|
|
490
|
+
count.disabled = empty;
|
|
461
491
|
list.textContent = '';
|
|
462
492
|
items.forEach(function (it) {
|
|
463
493
|
var li = document.createElement('li');
|
|
@@ -466,19 +496,23 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
466
496
|
var del = button('\\u00d7', function () {
|
|
467
497
|
saveDraft(draft().filter(function (x) { return x.id !== it.id; }));
|
|
468
498
|
}, 'c4b-del');
|
|
469
|
-
del.title = '
|
|
499
|
+
del.title = t('removeFromReview');
|
|
470
500
|
li.appendChild(del);
|
|
471
501
|
list.appendChild(li);
|
|
472
502
|
});
|
|
473
|
-
list.hidden = !expanded ||
|
|
503
|
+
list.hidden = !expanded || empty;
|
|
504
|
+
approve.textContent = t(empty ? 'done' : 'approve');
|
|
505
|
+
approve.title = t(empty ? 'doneTitle' : 'approveTitle');
|
|
506
|
+
approve.className = empty ? 'c4b-primary' : '';
|
|
507
|
+
discard.hidden = submit.hidden = empty;
|
|
474
508
|
discard.disabled = busy;
|
|
475
509
|
approve.disabled = busy;
|
|
476
|
-
submit.disabled = busy ||
|
|
510
|
+
submit.disabled = busy || empty;
|
|
477
511
|
};
|
|
478
512
|
var renderAgent = function () {
|
|
479
513
|
fbBtn.hidden = !AGENT.on;
|
|
480
514
|
renderPanel();
|
|
481
|
-
var text =
|
|
515
|
+
var text = t(AGENT.listening ? 'listening' : 'notListening');
|
|
482
516
|
var els = document.querySelectorAll('.c4b-listen');
|
|
483
517
|
for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
|
|
484
518
|
};
|
|
@@ -503,14 +537,14 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
503
537
|
var quote = document.createElement('blockquote');
|
|
504
538
|
quote.textContent = data.quote;
|
|
505
539
|
var ta = document.createElement('textarea');
|
|
506
|
-
ta.placeholder = (feedback ? '
|
|
540
|
+
ta.placeholder = t(feedback ? 'feedbackPlaceholder' : 'questionPlaceholder');
|
|
507
541
|
var row = document.createElement('div');
|
|
508
542
|
row.className = 'c4b-row';
|
|
509
543
|
var hint = document.createElement('small');
|
|
510
544
|
if (feedback) hint.className = 'c4b-listen';
|
|
511
|
-
else hint.textContent = hint.title = '
|
|
545
|
+
else hint.textContent = hint.title = t('questionTo', { target: HEADING + (data.section ? ' / ' + data.section : '') });
|
|
512
546
|
var actions = document.createElement('span');
|
|
513
|
-
var buttons = [button('
|
|
547
|
+
var buttons = [button(t('cancel'), close)];
|
|
514
548
|
var run = function (action) {
|
|
515
549
|
return function () {
|
|
516
550
|
var comment = ta.value.trim();
|
|
@@ -518,7 +552,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
518
552
|
buttons.forEach(function (b) { b.disabled = true; });
|
|
519
553
|
action(comment).then(close, function (err) {
|
|
520
554
|
buttons.forEach(function (b) { b.disabled = false; });
|
|
521
|
-
failure(feedback ? '
|
|
555
|
+
failure(feedback ? 'feedbackFailed' : 'questionFailed')(err);
|
|
522
556
|
});
|
|
523
557
|
};
|
|
524
558
|
};
|
|
@@ -527,20 +561,20 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
527
561
|
};
|
|
528
562
|
var primary;
|
|
529
563
|
if (feedback) {
|
|
530
|
-
var add = button('
|
|
564
|
+
var add = button(t('addToReview'), run(function (c) {
|
|
531
565
|
var it = item(c);
|
|
532
566
|
it.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
533
567
|
saveDraft(draft().concat([it]));
|
|
534
568
|
return Promise.resolve();
|
|
535
569
|
}));
|
|
536
|
-
var now = button('
|
|
570
|
+
var now = button(t('sendNow'), run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
|
|
537
571
|
buttons.push(add, now);
|
|
538
572
|
// As in a GitLab merge request: once a review is started, comments go into it.
|
|
539
573
|
primary = draft().length ? add : now;
|
|
540
574
|
} else {
|
|
541
|
-
primary = button('
|
|
575
|
+
primary = button(t('addQuestion'), run(function (c) {
|
|
542
576
|
return send(item(c)).then(function (r) {
|
|
543
|
-
var msg =
|
|
577
|
+
var msg = t(r.number ? 'addedNumber' : 'added', { file: r.file, number: r.number });
|
|
544
578
|
try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
|
|
545
579
|
toast(msg);
|
|
546
580
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Словарь интерфейса страницы `site -w` (change site-ui-language). Единственный файл `src/`, где
|
|
2
|
+
// тест cli-language разрешает кириллицу: это текст страницы в браузере, а не вывод CLI. Наборы
|
|
3
|
+
// ключей en и ru совпадают (проверяет тот же тест). `{name}` подставляет скрипт страницы; строка
|
|
4
|
+
// с числом — объект форм для Intl.PluralRules, число в ней — `{n}`.
|
|
5
|
+
export const UI_STRINGS = {
|
|
6
|
+
en: {
|
|
7
|
+
feedback: 'Feedback',
|
|
8
|
+
feedbackTitle: 'Tell the agent what to change',
|
|
9
|
+
question: 'Question',
|
|
10
|
+
questionTitle: 'Add a question to the {heading} section of the source file',
|
|
11
|
+
removeQuestion: 'Remove this question',
|
|
12
|
+
removed: 'Removed from {file}',
|
|
13
|
+
added: 'Added to {file}',
|
|
14
|
+
addedNumber: 'Added to {file} as #{number}',
|
|
15
|
+
sent: 'Sent to the agent',
|
|
16
|
+
delivered: 'Delivered to the agent',
|
|
17
|
+
queued: 'Queued — agent is not listening',
|
|
18
|
+
reviewFailed: 'Review failed: {error}',
|
|
19
|
+
feedbackFailed: 'Feedback failed: {error}',
|
|
20
|
+
questionFailed: 'Question failed: {error}',
|
|
21
|
+
removeFailed: 'Remove failed: {error}',
|
|
22
|
+
generalPlaceholder: 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)',
|
|
23
|
+
discard: 'Discard',
|
|
24
|
+
discardConfirm: { one: 'Discard {n} pending comment?', other: 'Discard {n} pending comments?' },
|
|
25
|
+
approve: 'Approve',
|
|
26
|
+
approveTitle: 'Tell the agent there is nothing more to change (pending comments go along)',
|
|
27
|
+
done: 'Done — no changes',
|
|
28
|
+
doneTitle: 'Tell the agent there is nothing to change',
|
|
29
|
+
submitReview: 'Submit review',
|
|
30
|
+
reviewEmpty: 'Review: nothing pending',
|
|
31
|
+
reviewCount: 'Review: {comments} ({pages})',
|
|
32
|
+
comments: { one: '{n} pending', other: '{n} pending' },
|
|
33
|
+
pages: { one: '{n} page', other: '{n} pages' },
|
|
34
|
+
removeFromReview: 'Remove from the review',
|
|
35
|
+
listening: 'agent: listening ●',
|
|
36
|
+
notListening: 'agent: not listening ○',
|
|
37
|
+
feedbackPlaceholder: 'What should the agent change? (Ctrl+Enter to send, Esc to cancel)',
|
|
38
|
+
questionPlaceholder: 'Your question (Ctrl+Enter to send, Esc to cancel)',
|
|
39
|
+
questionTo: 'To: {target}',
|
|
40
|
+
cancel: 'Cancel',
|
|
41
|
+
addToReview: 'Add to review',
|
|
42
|
+
sendNow: 'Send now',
|
|
43
|
+
addQuestion: 'Add question'
|
|
44
|
+
},
|
|
45
|
+
ru: {
|
|
46
|
+
feedback: 'Замечание',
|
|
47
|
+
feedbackTitle: 'Сказать агенту, что изменить',
|
|
48
|
+
question: 'Вопрос',
|
|
49
|
+
questionTitle: 'Добавить вопрос в раздел «{heading}» исходного файла',
|
|
50
|
+
removeQuestion: 'Удалить вопрос',
|
|
51
|
+
removed: 'Удалено из {file}',
|
|
52
|
+
added: 'Добавлено в {file}',
|
|
53
|
+
addedNumber: 'Добавлено в {file} под номером {number}',
|
|
54
|
+
sent: 'Отправлено агенту',
|
|
55
|
+
delivered: 'Доставлено агенту',
|
|
56
|
+
queued: 'В очереди: агент не слушает',
|
|
57
|
+
reviewFailed: 'Не удалось отправить ревью: {error}',
|
|
58
|
+
feedbackFailed: 'Не удалось отправить замечание: {error}',
|
|
59
|
+
questionFailed: 'Не удалось добавить вопрос: {error}',
|
|
60
|
+
removeFailed: 'Не удалось удалить вопрос: {error}',
|
|
61
|
+
generalPlaceholder: 'Общий комментарий (необязательно; Ctrl+Enter — отправить, Esc — скрыть)',
|
|
62
|
+
discard: 'Очистить',
|
|
63
|
+
discardConfirm: {
|
|
64
|
+
one: 'Удалить из ревью {n} замечание?',
|
|
65
|
+
few: 'Удалить из ревью {n} замечания?',
|
|
66
|
+
many: 'Удалить из ревью {n} замечаний?'
|
|
67
|
+
},
|
|
68
|
+
approve: 'Одобрить',
|
|
69
|
+
approveTitle: 'Сообщить агенту, что правок больше нет (замечания из ревью уйдут вместе с этим)',
|
|
70
|
+
done: 'Готово, правок нет',
|
|
71
|
+
doneTitle: 'Сообщить агенту, что правок нет',
|
|
72
|
+
submitReview: 'Отправить ревью',
|
|
73
|
+
reviewEmpty: 'Ревью: замечаний нет',
|
|
74
|
+
reviewCount: 'Ревью: {comments} {pages}',
|
|
75
|
+
comments: { one: '{n} замечание', few: '{n} замечания', many: '{n} замечаний' },
|
|
76
|
+
pages: { one: 'на {n} странице', few: 'на {n} страницах', many: 'на {n} страницах' },
|
|
77
|
+
removeFromReview: 'Убрать из ревью',
|
|
78
|
+
listening: 'агент: слушает ●',
|
|
79
|
+
notListening: 'агент: не слушает ○',
|
|
80
|
+
feedbackPlaceholder: 'Что должен изменить агент? (Ctrl+Enter — отправить, Esc — отмена)',
|
|
81
|
+
questionPlaceholder: 'Ваш вопрос (Ctrl+Enter — отправить, Esc — отмена)',
|
|
82
|
+
questionTo: 'Куда: {target}',
|
|
83
|
+
cancel: 'Отмена',
|
|
84
|
+
addToReview: 'В ревью',
|
|
85
|
+
sendNow: 'Отправить',
|
|
86
|
+
addQuestion: 'Добавить вопрос'
|
|
87
|
+
}
|
|
88
|
+
};
|
|
@@ -154,7 +154,8 @@ pages = () => new Map()) => {
|
|
|
154
154
|
commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
|
|
155
155
|
agent: agent.on,
|
|
156
156
|
listening,
|
|
157
|
-
project: projectKey
|
|
157
|
+
project: projectKey,
|
|
158
|
+
language: currentConfiguration.UI_LANGUAGE
|
|
158
159
|
}) +
|
|
159
160
|
(next ? updateBanner(next) : '');
|
|
160
161
|
if (html.includes('</body>'))
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -25,6 +25,7 @@ import { acquireBuildLock, BuildLockHeldError, waitBuildLock } from "../util/loc
|
|
|
25
25
|
// больше не будет (одиночный запуск).
|
|
26
26
|
import { teardownD2 } from "../core/render/d2renderer.js";
|
|
27
27
|
import { teardownBpmn } from "../core/render/bpmnrenderer.js";
|
|
28
|
+
import { teardownMermaid } from "../core/render/mermaidrenderer.js";
|
|
28
29
|
import { packageJson as pkg } from "../util/paths.js";
|
|
29
30
|
import { outputDirs, parseConfig } from "../config/options.js";
|
|
30
31
|
import { isValidPort } from "../config/schema.js";
|
|
@@ -105,6 +106,7 @@ function getOptions(c) {
|
|
|
105
106
|
EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
|
|
106
107
|
USE_SYSTEM_FONTS: c.useSystemFonts,
|
|
107
108
|
QUESTIONS_HEADING: c.questionsHeading,
|
|
109
|
+
UI_LANGUAGE: c.uiLanguage,
|
|
108
110
|
PLUGINS: c.plugins
|
|
109
111
|
};
|
|
110
112
|
}
|
|
@@ -352,6 +354,7 @@ export default async () => {
|
|
|
352
354
|
if (!opts.watch) {
|
|
353
355
|
await teardownD2();
|
|
354
356
|
await teardownBpmn();
|
|
357
|
+
await teardownMermaid();
|
|
355
358
|
}
|
|
356
359
|
}
|
|
357
360
|
isBuilding = false;
|
package/dist/config/defaults.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -60,6 +60,8 @@ export const configSchema = z.object({
|
|
|
60
60
|
// Заголовок раздела, куда `site -w` дописывает комментарии к странице (в коде кириллицы
|
|
61
61
|
// быть не может — русский заголовок задаёт сам проект).
|
|
62
62
|
questionsHeading: str(defaultConfig.questionsHeading),
|
|
63
|
+
// Язык интерфейса страницы `site -w`; `auto` — по языку браузера (change site-ui-language).
|
|
64
|
+
uiLanguage: z.preprocess(strPre, z.enum(['auto', 'en', 'ru'], 'expected auto, en or ru').default(defaultConfig.uiLanguage)),
|
|
63
65
|
// Задаются явно (флаг/промпт) либо тонкой правкой файла — в defaultConfig не входят.
|
|
64
66
|
projectName: optStr(),
|
|
65
67
|
webFileName: optStr(),
|
|
@@ -223,7 +223,7 @@ export const generateLlmsTxt = (tree, options) => {
|
|
|
223
223
|
});
|
|
224
224
|
return (`# ${options.PROJECT_NAME}\n\n` +
|
|
225
225
|
'> Architecture documentation built with c4builder. Pages are markdown; full text with ' +
|
|
226
|
-
'diagram sources (PlantUML/D2/BPMN): [llms-full.txt](llms-full.txt).\n\n' +
|
|
226
|
+
'diagram sources (PlantUML/D2/BPMN/Mermaid): [llms-full.txt](llms-full.txt).\n\n' +
|
|
227
227
|
`## Pages\n\n${pages.join('')}`);
|
|
228
228
|
};
|
|
229
229
|
// Fence длиннее самой длинной серии обратных кавычек текста — иначе блок закрылся бы изнутри.
|
|
@@ -235,7 +235,8 @@ const FENCE_LANG = {
|
|
|
235
235
|
'.puml': 'plantuml',
|
|
236
236
|
'.iuml': 'plantuml',
|
|
237
237
|
'.d2': 'd2',
|
|
238
|
-
'.bpmn': 'xml'
|
|
238
|
+
'.bpmn': 'xml',
|
|
239
|
+
'.mmd': 'mermaid'
|
|
239
240
|
};
|
|
240
241
|
// llms-full.txt: состав complete-документа, но диаграмма — исходником (C4-PlantUML/D2-текст
|
|
241
242
|
// агенту полезнее SVG). Локальные include/импорты — один раз в приложении, а не инлайном:
|
|
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import chalk from 'chalk';
|
|
12
12
|
import { DEFAULT_FONT_NAME } from "./fonts.js";
|
|
13
|
-
import {
|
|
13
|
+
import { ASCENT, DESCENT, clearTextWidths, textWidth } from "./textmetrics.js";
|
|
14
14
|
// Третий бэкенд рендера: BPMN 2.0 XML без координат → валидация (bpmnlint) → раскладка
|
|
15
15
|
// (bpmn-auto-layout) → рендер bpmn-js в jsdom → SVG. Все пакеты — optionalDependencies,
|
|
16
16
|
// грузятся лениво при первом .bpmn: проекты без BPMN их не трогают (как D2).
|
|
@@ -112,32 +112,7 @@ const C4BUILDER_RULES = {
|
|
|
112
112
|
})
|
|
113
113
|
};
|
|
114
114
|
// Ширины строк для переноса подписей: diagram-js меряет текст только через
|
|
115
|
-
// canvas.measureText, а jsdom канваса не
|
|
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;
|
|
115
|
+
// canvas.measureText, а jsdom канваса не умеет — меряем вендорный шрифт (textmetrics).
|
|
141
116
|
// Полифилы окна jsdom: ровно то, что bpmn-js зовёт при импорте и saveSVG. Трансформации
|
|
142
117
|
// пишутся в атрибут сразу — сериализатор берёт атрибуты, а не baseVal.
|
|
143
118
|
const installPolyfills = (window) => {
|
|
@@ -334,7 +309,7 @@ export const teardownBpmn = async () => {
|
|
|
334
309
|
return;
|
|
335
310
|
const pending = enginePromise;
|
|
336
311
|
enginePromise = null;
|
|
337
|
-
|
|
312
|
+
clearTextWidths();
|
|
338
313
|
try {
|
|
339
314
|
(await pending).window.close();
|
|
340
315
|
}
|