@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 +4 -2
- package/dist/cli/commands/check.js +9 -6
- package/dist/cli/commands/feedback.js +12 -2
- package/dist/cli/commands/site.comments.js +160 -60
- package/dist/cli/commands/site.i18n.js +102 -0
- package/dist/cli/commands/site.js +106 -51
- package/dist/cli/dispatch.js +4 -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/feedback.js +3 -0
- 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 +15 -8
- 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,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 ? '' : '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = ${
|
|
63
|
+
var HEADING = ${jsValue(heading)};
|
|
52
64
|
var TOAST_KEY = '__c4builderCommentToast';
|
|
53
|
-
var DRAFT_KEY = '__c4builderReview:' + ${
|
|
54
|
-
var
|
|
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 = ${
|
|
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 = '
|
|
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 = '
|
|
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
|
-
|
|
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('
|
|
276
|
-
fbBtn.title = '
|
|
277
|
-
var qBtn = button('
|
|
278
|
-
qBtn.title = '
|
|
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 (
|
|
351
|
-
return function (err) { toast(
|
|
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
|
-
|
|
359
|
-
|
|
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 ? '
|
|
408
|
+
toast(named(r.queued ? 'queued' : 'sent', r.to));
|
|
362
409
|
});
|
|
363
410
|
};
|
|
364
411
|
window.addEventListener('c4builder:delivered', function (e) {
|
|
365
|
-
var
|
|
366
|
-
delivered[id] =
|
|
367
|
-
if (pending[id]) { delete pending[id]; toast('
|
|
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
|
|
371
|
-
//
|
|
372
|
-
|
|
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(
|
|
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(
|
|
383
|
-
else localStorage.removeItem(
|
|
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 = '
|
|
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('
|
|
488
|
+
}, failure('reviewFailed')).then(function () { busy = false; renderPanel(); });
|
|
426
489
|
};
|
|
427
|
-
var discard = button('
|
|
490
|
+
var discard = button(t('discard'), function () {
|
|
428
491
|
var n = draft().length;
|
|
429
|
-
if (n && !window.confirm('
|
|
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
|
-
|
|
435
|
-
approve
|
|
436
|
-
var submit = button('
|
|
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
|
-
|
|
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 =
|
|
458
|
-
?
|
|
459
|
-
: '
|
|
460
|
-
count.disabled =
|
|
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 = '
|
|
535
|
+
del.title = t('removeFromReview');
|
|
470
536
|
li.appendChild(del);
|
|
471
537
|
list.appendChild(li);
|
|
472
538
|
});
|
|
473
|
-
list.hidden = !expanded ||
|
|
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 || !
|
|
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
|
-
|
|
481
|
-
|
|
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.
|
|
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 ===
|
|
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 ? '
|
|
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 = '
|
|
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('
|
|
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
|
-
|
|
615
|
+
sending = true;
|
|
616
|
+
sync();
|
|
519
617
|
action(comment).then(close, function (err) {
|
|
520
|
-
|
|
521
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
640
|
+
primary = button(t('addQuestion'), run(function (c) {
|
|
542
641
|
return send(item(c)).then(function (r) {
|
|
543
|
-
var msg =
|
|
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
|
+
};
|