@alfacapital-tech/c4builder 0.9.0-rc.2 → 0.9.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD 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,11 +1,20 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'node:fs';
3
3
  import { parseConfig } from "../../config/options.js";
4
+ import { LISTENER_NAME } from "../../core/feedback.js";
4
5
  import { FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
5
6
  // `c4builder feedback` (change site-agent-feedback, design D9): забирает пачку замечаний у `site -w`.
6
7
  // Конфиг читается как JSON без Configstore — тот создал бы `.c4builder` в любом каталоге. Ошибки —
7
8
  // через exitCode, а не process.exit: напечатанная в pipe пачка должна успеть уйти целиком.
8
9
  export default async (configPath, opts) => {
10
+ // Имя обязательно и без умолчания (change feedback-mailboxes, D2): с ним два агента со старой
11
+ // инструкцией молча делили бы один ящик. Код 2, а не 1: код 1 инструкции агентов читают как
12
+ // «нет сервера, перезапусти сайт».
13
+ if (!opts.as || !LISTENER_NAME.test(opts.as)) {
14
+ console.error(chalk.red('--as <name> is required: name this listener after your change or task (1-64 letters, digits, ".", "_" or "-"), e.g. "c4builder feedback --wait --as add-dark-mode"; use the same name after each batch'));
15
+ process.exitCode = 2;
16
+ return;
17
+ }
9
18
  let raw;
10
19
  try {
11
20
  raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
@@ -20,7 +29,7 @@ export default async (configPath, opts) => {
20
29
  };
21
30
  // 127.0.0.1, а не localhost: сервер слушает только IPv4, а `localhost` → `::1` на части машин
22
31
  // подвешивает соединение.
23
- const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next${opts.wait ? '' : '?wait=0'}`;
32
+ const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next?as=${opts.as}${opts.wait ? '' : '&wait=0'}`;
24
33
  for (;;) {
25
34
  let res;
26
35
  try {
@@ -40,6 +49,7 @@ export default async (configPath, opts) => {
40
49
  }
41
50
  if (res.status === 404)
42
51
  return fail(`agent feedback is disabled on ${shown} — the server runs without -w or with --no-agent`);
43
- return fail(`unexpected response ${res.status} from ${shown}`);
52
+ const error = await res.json().then((body) => body?.error, () => undefined);
53
+ return fail(`unexpected response ${res.status} from ${shown}${typeof error === 'string' ? `: ${error}` : ''}`);
44
54
  }
45
55
  };
@@ -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,13 @@ 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
+ // Выбор адресата сжимается вместе с select: длинные имена не выталкивают кнопки из строки.
26
+ '.c4b-review .c4b-to{flex:1;min-width:0;display:flex;align-items:center;gap:4px;color:#888;white-space:nowrap}',
27
+ '.c4b-review .c4b-to[hidden]{display:none}',
28
+ '.c4b-review select{min-width:0;max-width:100%;padding:2px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;font:13px sans-serif}',
29
+ // Кнопки панели переносятся одной группой: иначе при нехватке места часть уезжает на
30
+ // вторую строку, а Discard остаётся один в первой.
31
+ '.c4b-review .c4b-acts{display:flex;gap:6px;margin-left:auto}',
21
32
  '.c4b-review button.c4b-link{padding:0;border:0;background:none;color:#2c3e50;font-weight:700;text-decoration:underline dotted}',
22
33
  '.c4b-review ul{margin:6px 0;padding:0;list-style:none;max-height:40vh;overflow:auto}',
23
34
  '.c4b-review li{padding:3px 0;border-top:1px solid #eee;overflow-wrap:anywhere}',
@@ -36,27 +47,48 @@ const CSS = [
36
47
  '.c4b-del:hover{color:#fff;background:#c0392b;border-color:#c0392b}',
37
48
  '.c4b-del:disabled{opacity:.5;cursor:default}'
38
49
  ].join('');
39
- const jsString = (s) => JSON.stringify(s)
50
+ const jsValue = (v) => JSON.stringify(v)
40
51
  .replace(/</g, '\\u003c')
41
52
  .replace(/[\u0080-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
42
53
  // Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
43
54
  // события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
44
- export const commentsSnippet = (heading, { agent = false, listening = false, project = '' } = {}) => `
55
+ // Язык выбирает сам скрипт: при `auto` — по navigator.languages (change site-ui-language, D1).
56
+ export const commentsSnippet = (heading, { agent = false, mailboxes = [], project = '', language = 'auto' } = {}) => `
45
57
  <script>
46
58
  (function () {
47
59
  if (window.__c4builderComments) return;
48
60
  window.__c4builderComments = true;
49
61
  var ENDPOINT = '${COMMENTS_PATH}';
50
62
  var FEEDBACK = '${FEEDBACK_PATH}';
51
- var HEADING = ${jsString(heading)};
63
+ var HEADING = ${jsValue(heading)};
52
64
  var TOAST_KEY = '__c4builderCommentToast';
53
- var DRAFT_KEY = '__c4builderReview:' + ${jsString(project)};
54
- var AGENT = { on: ${agent}, listening: ${listening} };
65
+ var DRAFT_KEY = '__c4builderReview:' + ${jsValue(project)};
66
+ var TARGET_KEY = '__c4builderTarget:' + ${jsValue(project)};
67
+ var AGENT = { on: ${agent}, mailboxes: ${jsValue(mailboxes)} };
55
68
  var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
56
69
  var style = document.createElement('style');
57
- style.textContent = ${jsString(CSS)};
70
+ style.textContent = ${jsValue(CSS)};
58
71
  document.head.appendChild(style);
59
72
 
73
+ var STRINGS = ${jsValue(UI_STRINGS)};
74
+ var has = function (lang) { return Object.prototype.hasOwnProperty.call(STRINGS, lang); };
75
+ var LANG = (function (pref) {
76
+ if (has(pref)) return pref;
77
+ var prefs = [].concat(navigator.languages || [], navigator.language || []);
78
+ for (var i = 0; i < prefs.length; i++) {
79
+ var tag = String(prefs[i]).toLowerCase().split('-')[0];
80
+ if (has(tag)) return tag;
81
+ }
82
+ return 'en';
83
+ })(${jsValue(language)});
84
+ var plural = new Intl.PluralRules(LANG);
85
+ var t = function (key, vars) {
86
+ vars = vars || {};
87
+ var s = STRINGS[LANG][key];
88
+ if (typeof s === 'object') s = s[plural.select(vars.n)];
89
+ return s.replace(/\\{(\\w+)\\}/g, function (m, k) { return k in vars ? String(vars[k]) : m; });
90
+ };
91
+
60
92
  var collapse = function (s) { return String(s).replace(/\\s+/g, ' ').trim(); };
61
93
  var same = function (a, b) { return collapse(a).toLowerCase() === collapse(b).toLowerCase(); };
62
94
 
@@ -203,18 +235,18 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
203
235
  del.type = 'button';
204
236
  del.className = 'c4b-del';
205
237
  del.textContent = '\\u00d7';
206
- del.title = 'Remove this question';
238
+ del.title = t('removeQuestion');
207
239
  del.addEventListener('click', function (e) {
208
240
  e.preventDefault();
209
241
  e.stopPropagation();
210
242
  del.disabled = true;
211
243
  send({ page: pageFile(), number: Number(item.number) || null, text: collapse(item.text) }, 'DELETE').then(function (r) {
212
- var msg = 'Removed from ' + r.file;
244
+ var msg = t('removed', { file: r.file });
213
245
  try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
214
246
  toast(msg);
215
247
  }, function (err) {
216
248
  del.disabled = false;
217
- toast('Remove failed: ' + (err && err.message ? err.message : err), true);
249
+ failure('removeFailed')(err);
218
250
  });
219
251
  });
220
252
  item.cell.appendChild(del);
@@ -272,10 +304,10 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
272
304
  var btns = document.createElement('div');
273
305
  btns.className = 'c4b-cbtns';
274
306
  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';
307
+ var fbBtn = button(t('feedback'), null, 'c4b-fb');
308
+ fbBtn.title = t('feedbackTitle');
309
+ var qBtn = button(t('question'));
310
+ qBtn.title = t('questionTitle', { heading: HEADING });
279
311
  btns.appendChild(fbBtn);
280
312
  btns.appendChild(qBtn);
281
313
  document.body.appendChild(btns);
@@ -347,44 +379,69 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
347
379
  });
348
380
  });
349
381
  };
350
- var failure = function (what) {
351
- return function (err) { toast(what + ' failed: ' + (err && err.message ? err.message : err), true); };
382
+ var failure = function (key) {
383
+ return function (err) { toast(t(key, { error: err && err.message ? err.message : err }), true); };
352
384
  };
353
385
 
386
+ // The tab's recipient lives in sessionStorage: it survives livereload, is per tab and is
387
+ // copied with a duplicated tab. Recipients are the server's mailboxes plus the tab's own.
388
+ var target = '';
389
+ try { target = sessionStorage.getItem(TARGET_KEY) || ''; } catch (e) {}
390
+ var recipients = function () {
391
+ var all = AGENT.mailboxes.slice();
392
+ var known = all.some(function (m) { return m.name === target; });
393
+ if (target && !known) all.push({ name: target, state: 'reconnecting', queued: 0 });
394
+ return all;
395
+ };
396
+ // Without mailboxes a batch goes to whoever connects first; with several, only to a chosen one.
397
+ var canSend = function () { return !!target || !AGENT.mailboxes.length; };
398
+ var named = function (key, name) { return name ? t(key + 'To', { name: name }) : t(key); };
399
+
354
400
  // Feedback to the agent: "Delivered" only after the server handed the batch to
355
401
  // "c4builder feedback"; the event may outrun the POST response, hence both maps.
356
402
  var delivered = {}, pending = {};
357
403
  var sendBatch = function (decision, comments, general) {
358
- return send({ decision: decision, comments: comments, general: general }, 'POST', FEEDBACK).then(function (r) {
359
- if (delivered[r.id]) return toast('Delivered to the agent');
404
+ var body = { decision: decision, comments: comments, general: general, to: target || undefined };
405
+ return send(body, 'POST', FEEDBACK).then(function (r) {
406
+ if (r.id in delivered) return toast(named('delivered', delivered[r.id]));
360
407
  pending[r.id] = true;
361
- toast(r.queued ? 'Queued \\u2014 agent is not listening' : 'Sent to the agent');
408
+ toast(named(r.queued ? 'queued' : 'sent', r.to));
362
409
  });
363
410
  };
364
411
  window.addEventListener('c4builder:delivered', function (e) {
365
- var id = e.detail && e.detail.id;
366
- delivered[id] = true;
367
- if (pending[id]) { delete pending[id]; toast('Delivered to the agent'); }
412
+ var d = e.detail || {};
413
+ delivered[d.id] = d.to || '';
414
+ if (pending[d.id]) { delete pending[d.id]; toast(named('delivered', d.to)); }
368
415
  });
369
416
 
370
- // Review draft lives in localStorage per project: it survives livereload and a closed tab;
371
- // without storage it lives until the page reloads.
372
- var memDraft = [];
417
+ // Review draft lives in localStorage per project and recipient: it survives livereload and a
418
+ // closed tab, and a tab never sends comments typed for another agent. Without storage it lives
419
+ // until the page reloads. Without a recipient the rc key is used, so earlier drafts survive.
420
+ var memDraft = {};
421
+ var draftKey = function () { return DRAFT_KEY + (target ? ':' + target : ''); };
373
422
  var draft = function () {
374
423
  try {
375
- var v = JSON.parse(localStorage.getItem(DRAFT_KEY) || '[]');
424
+ var v = JSON.parse(localStorage.getItem(draftKey()) || '[]');
376
425
  return Array.isArray(v) ? v : [];
377
- } catch (e) { return memDraft; }
426
+ } catch (e) { return memDraft[draftKey()] || []; }
378
427
  };
379
428
  var saveDraft = function (items) {
380
- memDraft = items;
429
+ memDraft[draftKey()] = items;
381
430
  try {
382
- if (items.length) localStorage.setItem(DRAFT_KEY, JSON.stringify(items));
383
- else localStorage.removeItem(DRAFT_KEY);
431
+ if (items.length) localStorage.setItem(draftKey(), JSON.stringify(items));
432
+ else localStorage.removeItem(draftKey());
384
433
  } catch (e) {}
385
434
  renderPanel();
386
435
  highlightDraft();
387
436
  };
437
+ // A draft started before the tab had a recipient goes to its first recipient.
438
+ var setTarget = function (name) {
439
+ var orphan = target ? [] : draft();
440
+ if (orphan.length) saveDraft([]);
441
+ target = name;
442
+ try { sessionStorage.setItem(TARGET_KEY, name); } catch (e) {}
443
+ saveDraft(draft().concat(orphan));
444
+ };
388
445
  var highlightDraft = function () {
389
446
  var root = content();
390
447
  if (!root) return;
@@ -409,9 +466,15 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
409
466
  var count = button('', function () { expanded = !expanded; renderPanel(); }, 'c4b-link');
410
467
  var listen = document.createElement('small');
411
468
  listen.className = 'c4b-listen';
469
+ var sendTo = document.createElement('label');
470
+ sendTo.className = 'c4b-to';
471
+ sendTo.textContent = t('sendTo');
472
+ var select = document.createElement('select');
473
+ select.addEventListener('change', function () { setTarget(select.value); renderAgent(); });
474
+ sendTo.appendChild(select);
412
475
  var list = document.createElement('ul');
413
476
  var general = document.createElement('textarea');
414
- general.placeholder = 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)';
477
+ general.placeholder = t('generalPlaceholder');
415
478
  general.hidden = true;
416
479
  var finish = function (decision) {
417
480
  var items = draft(), ids = items.map(function (it) { return it.id; });
@@ -422,18 +485,18 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
422
485
  general.hidden = true;
423
486
  expanded = false;
424
487
  saveDraft(draft().filter(function (it) { return ids.indexOf(it.id) < 0; }));
425
- }, failure('Review')).then(function () { busy = false; renderPanel(); });
488
+ }, failure('reviewFailed')).then(function () { busy = false; renderPanel(); });
426
489
  };
427
- var discard = button('Discard', function () {
490
+ var discard = button(t('discard'), function () {
428
491
  var n = draft().length;
429
- if (n && !window.confirm('Discard ' + n + ' pending comment' + (n === 1 ? '' : 's') + '?')) return;
492
+ if (n && !window.confirm(t('discardConfirm', { n: n }))) return;
430
493
  general.value = '';
431
494
  general.hidden = true;
432
495
  saveDraft([]);
433
496
  });
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 () {
497
+ // One finishing action, two labels: on an empty draft "Approve" does not read as "finish".
498
+ var approve = button('', function () { finish('approved'); });
499
+ var submit = button(t('submitReview'), function () {
437
500
  if (!general.hidden) return finish('submitted');
438
501
  general.hidden = false;
439
502
  general.focus();
@@ -444,20 +507,23 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
444
507
  });
445
508
  var head = document.createElement('div');
446
509
  head.className = 'c4b-row';
447
- [count, listen, discard, approve, submit].forEach(function (el) { head.appendChild(el); });
510
+ var acts = document.createElement('span');
511
+ acts.className = 'c4b-acts';
512
+ [discard, approve, submit].forEach(function (el) { acts.appendChild(el); });
513
+ [count, listen, sendTo, acts].forEach(function (el) { head.appendChild(el); });
448
514
  panel.appendChild(head);
449
515
  panel.appendChild(list);
450
516
  panel.appendChild(general);
451
517
  document.body.appendChild(panel);
452
518
  var renderPanel = function () {
453
519
  panel.hidden = !AGENT.on;
454
- var items = draft(), pages = {};
520
+ var items = draft(), pages = {}, empty = !items.length;
455
521
  items.forEach(function (it) { pages[it.page] = true; });
456
522
  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;
523
+ count.textContent = empty
524
+ ? t('reviewEmpty')
525
+ : t('reviewCount', { comments: t('comments', { n: items.length }), pages: t('pages', { n: n }) });
526
+ count.disabled = empty;
461
527
  list.textContent = '';
462
528
  items.forEach(function (it) {
463
529
  var li = document.createElement('li');
@@ -466,34 +532,61 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
466
532
  var del = button('\\u00d7', function () {
467
533
  saveDraft(draft().filter(function (x) { return x.id !== it.id; }));
468
534
  }, 'c4b-del');
469
- del.title = 'Remove from the review';
535
+ del.title = t('removeFromReview');
470
536
  li.appendChild(del);
471
537
  list.appendChild(li);
472
538
  });
473
- list.hidden = !expanded || !items.length;
539
+ list.hidden = !expanded || empty;
540
+ approve.textContent = t(empty ? 'done' : 'approve');
541
+ approve.title = t(empty ? 'doneTitle' : 'approveTitle');
542
+ approve.className = empty ? 'c4b-primary' : '';
543
+ discard.hidden = submit.hidden = empty;
474
544
  discard.disabled = busy;
475
- approve.disabled = busy;
476
- submit.disabled = busy || !items.length;
545
+ approve.disabled = busy || !canSend();
546
+ submit.disabled = busy || empty || !canSend();
547
+ };
548
+ var label = function (m) {
549
+ return t(m.state === 'listening' ? 'stateListening' : 'stateReconnecting', { name: m.name }) +
550
+ (m.queued ? ', ' + t('inQueue', { n: m.queued }) : '');
477
551
  };
552
+ // The popover's send button follows the recipient too; set while the popover is open.
553
+ var syncPop = null;
478
554
  var renderAgent = function () {
555
+ // A lone mailbox is chosen and remembered: the tab stays with it when a second agent comes.
556
+ if (!target && AGENT.mailboxes.length === 1) setTarget(AGENT.mailboxes[0].name);
479
557
  fbBtn.hidden = !AGENT.on;
480
- renderPanel();
481
- var text = 'agent: ' + (AGENT.listening ? 'listening \\u25cf' : 'not listening \\u25cb');
558
+ var all = recipients(), current = null;
559
+ all.forEach(function (m) { if (m.name === target) current = m; });
560
+ var text = current ? label(current) : t(all.length ? 'chooseAgent' : 'notListening');
482
561
  var els = document.querySelectorAll('.c4b-listen');
483
562
  for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
563
+ listen.hidden = all.length > 1;
564
+ sendTo.hidden = !listen.hidden;
565
+ select.textContent = '';
566
+ if (!target) all.unshift({ name: '', label: t('chooseAgent') });
567
+ all.forEach(function (m) {
568
+ var o = document.createElement('option');
569
+ o.value = m.name;
570
+ o.textContent = m.label || label(m);
571
+ select.appendChild(o);
572
+ });
573
+ select.value = target;
574
+ renderPanel();
575
+ if (syncPop) syncPop();
484
576
  };
485
577
  window.addEventListener('c4builder:agent', function (e) {
486
578
  var d = e.detail || {};
487
579
  AGENT.on = !!d.on;
488
- AGENT.listening = !!d.listening;
580
+ AGENT.mailboxes = Array.isArray(d.mailboxes) ? d.mailboxes : [];
489
581
  renderAgent();
490
582
  });
491
583
  window.addEventListener('storage', function (e) {
492
- if (e.key === DRAFT_KEY) { renderPanel(); highlightDraft(); }
584
+ if (e.key === draftKey()) { renderPanel(); highlightDraft(); }
493
585
  });
494
586
 
495
587
  var close = function () {
496
588
  if (pop) { pop.remove(); pop = null; }
589
+ syncPop = null;
497
590
  btns.hidden = true;
498
591
  };
499
592
  var open = function (data, rect, feedback) {
@@ -503,22 +596,28 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
503
596
  var quote = document.createElement('blockquote');
504
597
  quote.textContent = data.quote;
505
598
  var ta = document.createElement('textarea');
506
- ta.placeholder = (feedback ? 'What should the agent change?' : 'Your question') + ' (Ctrl+Enter to send, Esc to cancel)';
599
+ ta.placeholder = t(feedback ? 'feedbackPlaceholder' : 'questionPlaceholder');
507
600
  var row = document.createElement('div');
508
601
  row.className = 'c4b-row';
509
602
  var hint = document.createElement('small');
510
603
  if (feedback) hint.className = 'c4b-listen';
511
- else hint.textContent = hint.title = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
604
+ else hint.textContent = hint.title = t('questionTo', { target: HEADING + (data.section ? ' / ' + data.section : '') });
512
605
  var actions = document.createElement('span');
513
- var buttons = [button('Cancel', close)];
606
+ var buttons = [button(t('cancel'), close)];
607
+ var sending = false, now = null;
608
+ var sync = function () {
609
+ buttons.forEach(function (b) { b.disabled = sending || (b === now && !canSend()); });
610
+ };
514
611
  var run = function (action) {
515
612
  return function () {
516
613
  var comment = ta.value.trim();
517
614
  if (!comment) { ta.focus(); return; }
518
- buttons.forEach(function (b) { b.disabled = true; });
615
+ sending = true;
616
+ sync();
519
617
  action(comment).then(close, function (err) {
520
- buttons.forEach(function (b) { b.disabled = false; });
521
- failure(feedback ? 'Feedback' : 'Question')(err);
618
+ sending = false;
619
+ sync();
620
+ failure(feedback ? 'feedbackFailed' : 'questionFailed')(err);
522
621
  });
523
622
  };
524
623
  };
@@ -527,20 +626,20 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
527
626
  };
528
627
  var primary;
529
628
  if (feedback) {
530
- var add = button('Add to review', run(function (c) {
629
+ var add = button(t('addToReview'), run(function (c) {
531
630
  var it = item(c);
532
631
  it.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
533
632
  saveDraft(draft().concat([it]));
534
633
  return Promise.resolve();
535
634
  }));
536
- var now = button('Send now', run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
635
+ now = button(t('sendNow'), run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
537
636
  buttons.push(add, now);
538
637
  // As in a GitLab merge request: once a review is started, comments go into it.
539
638
  primary = draft().length ? add : now;
540
639
  } else {
541
- primary = button('Add question', run(function (c) {
640
+ primary = button(t('addQuestion'), run(function (c) {
542
641
  return send(item(c)).then(function (r) {
543
- var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
642
+ var msg = t(r.number ? 'addedNumber' : 'added', { file: r.file, number: r.number });
544
643
  try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
545
644
  toast(msg);
546
645
  });
@@ -558,6 +657,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
558
657
  pop.appendChild(ta);
559
658
  pop.appendChild(row);
560
659
  document.body.appendChild(pop);
660
+ syncPop = sync;
561
661
  renderAgent();
562
662
  place(pop, rect);
563
663
  ta.focus();
@@ -0,0 +1,102 @@
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
+ sentTo: 'Sent to {name}',
19
+ deliveredTo: 'Delivered to {name}',
20
+ queuedTo: 'Queued for {name} — not listening',
21
+ reviewFailed: 'Review failed: {error}',
22
+ feedbackFailed: 'Feedback failed: {error}',
23
+ questionFailed: 'Question failed: {error}',
24
+ removeFailed: 'Remove failed: {error}',
25
+ generalPlaceholder: 'Overall comment (optional, Ctrl+Enter to submit, Esc to hide)',
26
+ discard: 'Discard',
27
+ discardConfirm: { one: 'Discard {n} pending comment?', other: 'Discard {n} pending comments?' },
28
+ approve: 'Approve',
29
+ approveTitle: 'Tell the agent there is nothing more to change (pending comments go along)',
30
+ done: 'Done — no changes',
31
+ doneTitle: 'Tell the agent there is nothing to change',
32
+ submitReview: 'Submit review',
33
+ reviewEmpty: 'Review: nothing pending',
34
+ reviewCount: 'Review: {comments} ({pages})',
35
+ comments: { one: '{n} pending', other: '{n} pending' },
36
+ pages: { one: '{n} page', other: '{n} pages' },
37
+ removeFromReview: 'Remove from the review',
38
+ notListening: 'agent: not listening ○',
39
+ stateListening: '{name}: listening ●',
40
+ stateReconnecting: '{name}: reconnecting ○',
41
+ inQueue: '{n} queued',
42
+ sendTo: 'Send to',
43
+ chooseAgent: 'choose an agent',
44
+ feedbackPlaceholder: 'What should the agent change? (Ctrl+Enter to send, Esc to cancel)',
45
+ questionPlaceholder: 'Your question (Ctrl+Enter to send, Esc to cancel)',
46
+ questionTo: 'To: {target}',
47
+ cancel: 'Cancel',
48
+ addToReview: 'Add to review',
49
+ sendNow: 'Send now',
50
+ addQuestion: 'Add question'
51
+ },
52
+ ru: {
53
+ feedback: 'Замечание',
54
+ feedbackTitle: 'Сказать агенту, что изменить',
55
+ question: 'Вопрос',
56
+ questionTitle: 'Добавить вопрос в раздел «{heading}» исходного файла',
57
+ removeQuestion: 'Удалить вопрос',
58
+ removed: 'Удалено из {file}',
59
+ added: 'Добавлено в {file}',
60
+ addedNumber: 'Добавлено в {file} под номером {number}',
61
+ sent: 'Отправлено агенту',
62
+ delivered: 'Доставлено агенту',
63
+ queued: 'В очереди: агент не слушает',
64
+ sentTo: 'Отправлено агенту {name}',
65
+ deliveredTo: 'Доставлено агенту {name}',
66
+ queuedTo: 'В очереди у {name}: агент не слушает',
67
+ reviewFailed: 'Не удалось отправить ревью: {error}',
68
+ feedbackFailed: 'Не удалось отправить замечание: {error}',
69
+ questionFailed: 'Не удалось добавить вопрос: {error}',
70
+ removeFailed: 'Не удалось удалить вопрос: {error}',
71
+ generalPlaceholder: 'Общий комментарий (необязательно; Ctrl+Enter — отправить, Esc — скрыть)',
72
+ discard: 'Очистить',
73
+ discardConfirm: {
74
+ one: 'Удалить из ревью {n} замечание?',
75
+ few: 'Удалить из ревью {n} замечания?',
76
+ many: 'Удалить из ревью {n} замечаний?'
77
+ },
78
+ approve: 'Одобрить',
79
+ approveTitle: 'Сообщить агенту, что правок больше нет (замечания из ревью уйдут вместе с этим)',
80
+ done: 'Готово, правок нет',
81
+ doneTitle: 'Сообщить агенту, что правок нет',
82
+ submitReview: 'Отправить ревью',
83
+ reviewEmpty: 'Ревью: замечаний нет',
84
+ reviewCount: 'Ревью: {comments} {pages}',
85
+ comments: { one: '{n} замечание', few: '{n} замечания', many: '{n} замечаний' },
86
+ pages: { one: 'на {n} странице', few: 'на {n} страницах', many: 'на {n} страницах' },
87
+ removeFromReview: 'Убрать из ревью',
88
+ notListening: 'агент: не слушает ○',
89
+ stateListening: '{name}: слушает ●',
90
+ stateReconnecting: '{name}: переподключается ○',
91
+ inQueue: 'в очереди: {n}',
92
+ sendTo: 'Кому',
93
+ chooseAgent: 'выберите агента',
94
+ feedbackPlaceholder: 'Что должен изменить агент? (Ctrl+Enter — отправить, Esc — отмена)',
95
+ questionPlaceholder: 'Ваш вопрос (Ctrl+Enter — отправить, Esc — отмена)',
96
+ questionTo: 'Куда: {target}',
97
+ cancel: 'Отмена',
98
+ addToReview: 'В ревью',
99
+ sendNow: 'Отправить',
100
+ addQuestion: 'Добавить вопрос'
101
+ }
102
+ };