@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 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/) or [BPMN](https://www.bpmn.org/) diagrams into documentation: a [docsify](https://docsify.js.org/)
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 .bpmn)');
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 jsString = (s) => JSON.stringify(s)
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
- export const commentsSnippet = (heading, { agent = false, listening = false, project = '' } = {}) => `
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 = ${jsString(heading)};
59
+ var HEADING = ${jsValue(heading)};
52
60
  var TOAST_KEY = '__c4builderCommentToast';
53
- var DRAFT_KEY = '__c4builderReview:' + ${jsString(project)};
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 = ${jsString(CSS)};
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 = 'Remove this question';
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 = 'Removed from ' + r.file;
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
- toast('Remove failed: ' + (err && err.message ? err.message : err), true);
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('Feedback', null, 'c4b-fb');
276
- fbBtn.title = 'Tell the agent what to change';
277
- var qBtn = button('Question');
278
- qBtn.title = 'Add a question to the ' + HEADING + ' section of the source file';
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 (what) {
351
- return function (err) { toast(what + ' failed: ' + (err && err.message ? err.message : err), true); };
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('Delivered to the agent');
386
+ if (delivered[r.id]) return toast(t('delivered'));
360
387
  pending[r.id] = true;
361
- toast(r.queued ? 'Queued \\u2014 agent is not listening' : 'Sent to the agent');
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('Delivered to the agent'); }
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 = 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)';
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('Review')).then(function () { busy = false; renderPanel(); });
452
+ }, failure('reviewFailed')).then(function () { busy = false; renderPanel(); });
426
453
  };
427
- var discard = button('Discard', function () {
454
+ var discard = button(t('discard'), function () {
428
455
  var n = draft().length;
429
- if (n && !window.confirm('Discard ' + n + ' pending comment' + (n === 1 ? '' : 's') + '?')) return;
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
- var approve = button('Approve', function () { finish('approved'); });
435
- approve.title = 'Tell the agent there is nothing more to change (pending comments go along)';
436
- var submit = button('Submit review', function () {
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
- [count, listen, discard, approve, submit].forEach(function (el) { head.appendChild(el); });
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 = items.length
458
- ? 'Review: ' + items.length + ' pending (' + n + (n === 1 ? ' page)' : ' pages)')
459
- : 'Review: nothing pending';
460
- count.disabled = !items.length;
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 = 'Remove from the review';
499
+ del.title = t('removeFromReview');
470
500
  li.appendChild(del);
471
501
  list.appendChild(li);
472
502
  });
473
- list.hidden = !expanded || !items.length;
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 || !items.length;
510
+ submit.disabled = busy || empty;
477
511
  };
478
512
  var renderAgent = function () {
479
513
  fbBtn.hidden = !AGENT.on;
480
514
  renderPanel();
481
- var text = 'agent: ' + (AGENT.listening ? 'listening \\u25cf' : 'not listening \\u25cb');
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 ? 'What should the agent change?' : 'Your question') + ' (Ctrl+Enter to send, Esc to cancel)';
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 = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
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('Cancel', close)];
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 ? 'Feedback' : 'Question')(err);
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('Add to review', run(function (c) {
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('Send now', run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
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('Add question', run(function (c) {
575
+ primary = button(t('addQuestion'), run(function (c) {
542
576
  return send(item(c)).then(function (r) {
543
- var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
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>'))
@@ -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;
@@ -32,6 +32,7 @@ const defaultConfig = {
32
32
  d2Layout: 'dagre',
33
33
  charset: 'UTF-8',
34
34
  useSystemFonts: false,
35
- questionsHeading: 'Open questions'
35
+ questionsHeading: 'Open questions',
36
+ uiLanguage: 'auto'
36
37
  };
37
38
  export { defaultConfig };
@@ -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 { getResvg, resvgFontOptions } from "./pngraster.js";
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 канваса не умеет. Меряем вендорный 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;
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
- widthMemo.clear();
312
+ clearTextWidths();
338
313
  try {
339
314
  (await pending).window.close();
340
315
  }