@alfacapital-tech/c4builder 0.9.0-rc.1 → 0.9.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +4 -2
- package/dist/cli/agent.js +21 -0
- package/dist/cli/commands/check.js +9 -6
- package/dist/cli/commands/feedback.js +45 -0
- package/dist/cli/commands/site.comments.js +318 -77
- package/dist/cli/commands/site.i18n.js +88 -0
- package/dist/cli/commands/site.js +152 -8
- package/dist/cli/dispatch.js +15 -4
- 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 +59 -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 +16 -4
- package/template/src/6 Mermaid Example/flow.mmd +6 -0
- package/template/src/6 Mermaid Example/mermaid.md +16 -0
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import express from 'express';
|
|
5
6
|
import open from 'open';
|
|
7
|
+
import { batchSchema, formatBatch } from "../../core/feedback.js";
|
|
6
8
|
import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
|
|
7
9
|
import { packageJson as pkg } from "../../util/paths.js";
|
|
8
10
|
import { pendingUpdate } from "../update.js";
|
|
9
|
-
import { COMMENTS_PATH, commentsSnippet } from "./site.comments.js";
|
|
11
|
+
import { COMMENTS_PATH, commentsSnippet, FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
|
|
10
12
|
const COMMENT_MAX = 2000;
|
|
11
13
|
const LIVERELOAD_PATH = '/__livereload';
|
|
12
14
|
// Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
|
|
13
15
|
// высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
|
|
14
16
|
// Комментарии держим здесь, а не в сниппете: он уходит в браузер пользователя.
|
|
17
|
+
// События агента (design D4) идут по тому же потоку и пересылаются сниппету комментариев
|
|
18
|
+
// DOM-событиями `c4builder:*` — второй EventSource странице не нужен.
|
|
15
19
|
const LIVERELOAD_SNIPPET = `
|
|
16
20
|
<script>
|
|
17
21
|
(function () {
|
|
@@ -48,6 +52,13 @@ const LIVERELOAD_SNIPPET = `
|
|
|
48
52
|
es.close();
|
|
49
53
|
location.reload();
|
|
50
54
|
});
|
|
55
|
+
['agent', 'delivered'].forEach(function (name) {
|
|
56
|
+
es.addEventListener(name, function (e) {
|
|
57
|
+
try {
|
|
58
|
+
window.dispatchEvent(new CustomEvent('c4builder:' + name, { detail: JSON.parse(e.data) }));
|
|
59
|
+
} catch (err) {}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
51
62
|
es.onerror = function () {
|
|
52
63
|
es.close();
|
|
53
64
|
setTimeout(connect, delay);
|
|
@@ -88,12 +99,24 @@ pages = () => new Map()) => {
|
|
|
88
99
|
const port = program.port || currentConfiguration.WEB_PORT;
|
|
89
100
|
const distFolder = path.resolve(currentConfiguration.DIST_FOLDER);
|
|
90
101
|
const liveReloadEnabled = !!(program.watch && reloadEmitter);
|
|
102
|
+
const agent = { ...(program.agent ?? { on: false, name: '', explicit: false }) };
|
|
91
103
|
// Кеш index.html — отдаём его из памяти, если на диске его временно нет
|
|
92
104
|
// (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
|
|
93
105
|
let lastIndexHtml = null;
|
|
94
106
|
// reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
|
|
95
107
|
// истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
|
|
96
108
|
if (liveReloadEnabled && reloadEmitter) {
|
|
109
|
+
// Состояние слушателя `c4builder feedback` (design D4): подключён, пока открыт long-poll
|
|
110
|
+
// или с конца последнего прошло меньше idleMs — зазор между повторами команды.
|
|
111
|
+
let listening = false;
|
|
112
|
+
const agentState = () => ({ on: agent.on, listening });
|
|
113
|
+
const setAgent = (on, listen) => {
|
|
114
|
+
if (on === agent.on && listen === listening)
|
|
115
|
+
return;
|
|
116
|
+
agent.on = on;
|
|
117
|
+
listening = listen;
|
|
118
|
+
reloadEmitter.emit('agent', agentState());
|
|
119
|
+
};
|
|
97
120
|
app.get(LIVERELOAD_PATH, (req, res) => {
|
|
98
121
|
res.set({
|
|
99
122
|
'Content-Type': 'text/event-stream',
|
|
@@ -104,21 +127,36 @@ pages = () => new Map()) => {
|
|
|
104
127
|
res.flushHeaders();
|
|
105
128
|
res.write('retry: 1000\n\n');
|
|
106
129
|
const onReload = () => res.write('event: reload\ndata: {}\n\n');
|
|
130
|
+
const onAgent = (data) => res.write(`event: agent\ndata: ${JSON.stringify(data)}\n\n`);
|
|
131
|
+
const onDelivered = (data) => res.write(`event: delivered\ndata: ${JSON.stringify(data)}\n\n`);
|
|
107
132
|
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
108
133
|
reloadEmitter.on('reload', onReload); // liveReloadEnabled ⇒ reloadEmitter задан
|
|
134
|
+
reloadEmitter.on('agent', onAgent);
|
|
135
|
+
reloadEmitter.on('delivered', onDelivered);
|
|
136
|
+
onAgent(agentState());
|
|
109
137
|
console.log(chalk.gray(`livereload: client connected (${reloadEmitter.listenerCount('reload')} total)`));
|
|
110
138
|
req.on('close', () => {
|
|
111
139
|
clearInterval(heartbeat);
|
|
112
140
|
reloadEmitter.off('reload', onReload);
|
|
141
|
+
reloadEmitter.off('agent', onAgent);
|
|
142
|
+
reloadEmitter.off('delivered', onDelivered);
|
|
113
143
|
console.log(chalk.gray(`livereload: client disconnected (${reloadEmitter.listenerCount('reload')} left)`));
|
|
114
144
|
});
|
|
115
145
|
});
|
|
146
|
+
// Ключ черновиков review в localStorage (design D5): страницы разных проектов на одном
|
|
147
|
+
// localhost:<port> не должны видеть чужой черновик.
|
|
148
|
+
const projectKey = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8);
|
|
116
149
|
// Кэш проверки обновлений читается на каждую страницу: результат фоновой проверки,
|
|
117
150
|
// запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
|
|
118
151
|
const inject = (html) => {
|
|
119
152
|
const next = pendingUpdate();
|
|
120
153
|
const snippet = LIVERELOAD_SNIPPET +
|
|
121
|
-
commentsSnippet(currentConfiguration.QUESTIONS_HEADING
|
|
154
|
+
commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
|
|
155
|
+
agent: agent.on,
|
|
156
|
+
listening,
|
|
157
|
+
project: projectKey,
|
|
158
|
+
language: currentConfiguration.UI_LANGUAGE
|
|
159
|
+
}) +
|
|
122
160
|
(next ? updateBanner(next) : '');
|
|
123
161
|
if (html.includes('</body>'))
|
|
124
162
|
return html.replace('</body>', `${snippet}</body>`);
|
|
@@ -129,15 +167,21 @@ pages = () => new Map()) => {
|
|
|
129
167
|
// любого сайта в браузере может слать POST на localhost — требуем Origin самого сайта.
|
|
130
168
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
131
169
|
const heading = currentConfiguration.QUESTIONS_HEADING;
|
|
170
|
+
// Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
|
|
171
|
+
// присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
|
|
172
|
+
const localHost = (req) => {
|
|
173
|
+
const host = req.get('host');
|
|
174
|
+
const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
|
|
175
|
+
return host && local.includes(host) ? host : undefined;
|
|
176
|
+
};
|
|
177
|
+
const fromSite = (req) => {
|
|
178
|
+
const host = localHost(req);
|
|
179
|
+
return !!host && req.get('origin') === `http://${host}`;
|
|
180
|
+
};
|
|
132
181
|
// Общая часть POST/DELETE: проверка Origin и исходный файл страницы из тела запроса.
|
|
133
182
|
// Ошибка уже отправлена клиенту, когда возвращается undefined.
|
|
134
183
|
const resolveTarget = (req, res) => {
|
|
135
|
-
|
|
136
|
-
const host = req.get('host');
|
|
137
|
-
// Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
|
|
138
|
-
// присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
|
|
139
|
-
const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
|
|
140
|
-
if (!origin || !host || !local.includes(host) || origin !== `http://${host}`) {
|
|
184
|
+
if (!fromSite(req)) {
|
|
141
185
|
res.status(403).json({ error: 'comments are accepted only from the site itself' });
|
|
142
186
|
return undefined;
|
|
143
187
|
}
|
|
@@ -214,6 +258,104 @@ pages = () => new Map()) => {
|
|
|
214
258
|
res.status(500).json({ error: `could not remove the comment: ${e.message}` });
|
|
215
259
|
}
|
|
216
260
|
});
|
|
261
|
+
// Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
|
|
262
|
+
// шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
|
|
263
|
+
// когда ответ записан; обрыв до этого возвращает её в голову очереди.
|
|
264
|
+
const { pollMs = 25_000, idleMs = 30_000 } = program;
|
|
265
|
+
const queue = [];
|
|
266
|
+
const waiters = [];
|
|
267
|
+
let polls = 0;
|
|
268
|
+
let idleTimer;
|
|
269
|
+
const disabled = () => agent.explicit && !agent.on;
|
|
270
|
+
const drop = (res) => {
|
|
271
|
+
const i = waiters.indexOf(res);
|
|
272
|
+
if (i >= 0)
|
|
273
|
+
waiters.splice(i, 1);
|
|
274
|
+
};
|
|
275
|
+
const pump = () => {
|
|
276
|
+
while (queue.length && waiters.length) {
|
|
277
|
+
const res = waiters.shift();
|
|
278
|
+
const item = queue.shift();
|
|
279
|
+
if (res && item)
|
|
280
|
+
deliver(res, item);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const deliver = (res, item) => {
|
|
284
|
+
res.on('close', () => {
|
|
285
|
+
if (!res.writableFinished) {
|
|
286
|
+
queue.unshift(item);
|
|
287
|
+
pump();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
|
|
291
|
+
reloadEmitter.emit('delivered', { id: item.id });
|
|
292
|
+
});
|
|
293
|
+
res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
|
|
294
|
+
};
|
|
295
|
+
app.post(FEEDBACK_PATH, express.json({ limit: '4mb' }), (req, res) => {
|
|
296
|
+
if (!fromSite(req)) {
|
|
297
|
+
res.status(403).json({ error: 'feedback is accepted only from the site itself' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (disabled()) {
|
|
301
|
+
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const parsed = batchSchema.safeParse(req.body);
|
|
305
|
+
if (!parsed.success) {
|
|
306
|
+
const issue = parsed.error.issues[0];
|
|
307
|
+
res.status(400).json({
|
|
308
|
+
error: `invalid feedback: ${issue?.path.join('.')} ${issue?.message}`
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const batch = parsed.data;
|
|
313
|
+
if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
|
|
314
|
+
res.status(400).json({ error: 'nothing to send' });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const id = randomUUID();
|
|
318
|
+
const queued = !waiters.length;
|
|
319
|
+
queue.push({ id, batch });
|
|
320
|
+
pump();
|
|
321
|
+
console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
|
|
322
|
+
res.json({ id, queued });
|
|
323
|
+
});
|
|
324
|
+
// Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
|
|
325
|
+
// чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
|
|
326
|
+
app.get(`${FEEDBACK_PATH}/next`, (req, res) => {
|
|
327
|
+
if (!localHost(req) || req.get('origin') || req.get(FEEDBACK_HEADER) !== '1') {
|
|
328
|
+
res.status(403).json({ error: 'feedback is served only to the c4builder feedback command' });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (disabled()) {
|
|
332
|
+
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!agent.on)
|
|
336
|
+
console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
|
|
337
|
+
polls++;
|
|
338
|
+
clearTimeout(idleTimer);
|
|
339
|
+
setAgent(true, true);
|
|
340
|
+
let timer;
|
|
341
|
+
res.on('close', () => {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
drop(res);
|
|
344
|
+
if (--polls === 0)
|
|
345
|
+
idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
|
|
346
|
+
});
|
|
347
|
+
waiters.push(res);
|
|
348
|
+
pump();
|
|
349
|
+
if (!waiters.includes(res))
|
|
350
|
+
return;
|
|
351
|
+
timer = setTimeout(() => {
|
|
352
|
+
// Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
|
|
353
|
+
if (!waiters.includes(res))
|
|
354
|
+
return;
|
|
355
|
+
drop(res);
|
|
356
|
+
res.status(204).end();
|
|
357
|
+
}, req.query.wait === '0' ? 0 : pollMs);
|
|
358
|
+
});
|
|
217
359
|
app.use((req, res, next) => {
|
|
218
360
|
if (req.method !== 'GET')
|
|
219
361
|
return next();
|
|
@@ -264,6 +406,8 @@ pages = () => new Map()) => {
|
|
|
264
406
|
console.log(`go to ${chalk.green(url)}`);
|
|
265
407
|
if (liveReloadEnabled)
|
|
266
408
|
console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
|
|
409
|
+
if (liveReloadEnabled && agent.on)
|
|
410
|
+
console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait${program.port ? ` -p ${port}` : ''}" — run it in the background and again after each batch`);
|
|
267
411
|
// --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
|
|
268
412
|
if (program.open && process.env.BROWSER !== 'none')
|
|
269
413
|
open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -9,6 +9,8 @@ import cmdJre from "./commands/jre.js";
|
|
|
9
9
|
import cmdCheck from "./commands/check.js";
|
|
10
10
|
import cmdList from "./commands/list.js";
|
|
11
11
|
import cmdSite from "./commands/site.js";
|
|
12
|
+
import cmdFeedback from "./commands/feedback.js";
|
|
13
|
+
import { detectAgent } from "./agent.js";
|
|
12
14
|
import { cmdUpdate, pendingUpdate, startUpdateCheck } from "./update.js";
|
|
13
15
|
import cmdCollect from "./wizard/collect.js";
|
|
14
16
|
import { build } from "../core/build.js";
|
|
@@ -23,11 +25,12 @@ import { acquireBuildLock, BuildLockHeldError, waitBuildLock } from "../util/loc
|
|
|
23
25
|
// больше не будет (одиночный запуск).
|
|
24
26
|
import { teardownD2 } from "../core/render/d2renderer.js";
|
|
25
27
|
import { teardownBpmn } from "../core/render/bpmnrenderer.js";
|
|
28
|
+
import { teardownMermaid } from "../core/render/mermaidrenderer.js";
|
|
26
29
|
import { packageJson as pkg } from "../util/paths.js";
|
|
27
30
|
import { outputDirs, parseConfig } from "../config/options.js";
|
|
28
31
|
import { isValidPort } from "../config/schema.js";
|
|
29
32
|
// Позиционные подкоманды: allowExcessArguments пропускает любые, список — единственный фильтр.
|
|
30
|
-
const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update'];
|
|
33
|
+
const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update', 'feedback'];
|
|
31
34
|
const intro = () => {
|
|
32
35
|
console.log(chalk.blue(figlet.textSync('c4builder')));
|
|
33
36
|
console.log(chalk.gray('Blow up your software documentation writing skills'));
|
|
@@ -103,6 +106,7 @@ function getOptions(c) {
|
|
|
103
106
|
EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
|
|
104
107
|
USE_SYSTEM_FONTS: c.useSystemFonts,
|
|
105
108
|
QUESTIONS_HEADING: c.questionsHeading,
|
|
109
|
+
UI_LANGUAGE: c.uiLanguage,
|
|
106
110
|
PLUGINS: c.plugins
|
|
107
111
|
};
|
|
108
112
|
}
|
|
@@ -120,6 +124,9 @@ export default async () => {
|
|
|
120
124
|
.option('--site', 'serve the generated site')
|
|
121
125
|
.option('-w, --watch', 'watch for changes and rebuild')
|
|
122
126
|
.option('-o, --open', 'open the generated site in the browser (with --site)')
|
|
127
|
+
.option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
|
|
128
|
+
.option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
|
|
129
|
+
.option('--wait', 'for `feedback`: wait for the next batch of page feedback')
|
|
123
130
|
.option('--docs', 'a brief explanation for the available configuration options')
|
|
124
131
|
.option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
|
|
125
132
|
.option('-p, --port <n>', 'port used for serving the generated site', (v) => {
|
|
@@ -140,8 +147,8 @@ export default async () => {
|
|
|
140
147
|
process.exit(1);
|
|
141
148
|
}
|
|
142
149
|
// --version/--help уже вышли в parse. Уведомление — по кэшу прошлой проверки, до её
|
|
143
|
-
// перезапуска; exit покрывает и выходы через process.exit.
|
|
144
|
-
if (cmd !== 'update') {
|
|
150
|
+
// перезапуска; exit покрывает и выходы через process.exit. Вывод `feedback` читает агент.
|
|
151
|
+
if (cmd !== 'update' && cmd !== 'feedback') {
|
|
145
152
|
const next = pendingUpdate();
|
|
146
153
|
if (next)
|
|
147
154
|
process.once('exit', () => process.stderr.write(`c4builder ${next} is available (current ${pkg.version}). Run: c4builder update\n`));
|
|
@@ -156,6 +163,9 @@ export default async () => {
|
|
|
156
163
|
// Обновление CLI — до Configstore, чтобы не создавать .c4builder в любом каталоге.
|
|
157
164
|
if (cmd === 'update')
|
|
158
165
|
return cmdUpdate();
|
|
166
|
+
// Замечания со страницы `site -w` — тоже до Configstore: команду запускают в любом каталоге.
|
|
167
|
+
if (cmd === 'feedback')
|
|
168
|
+
return cmdFeedback(path.join(process.cwd(), opts.configFile ?? '.c4builder'), opts);
|
|
159
169
|
// `site`/`new`/`config` — позиционные синонимы одноимённых флагов: эту форму печатают
|
|
160
170
|
// подсказки CLI и учат README, шаблон и скиллы.
|
|
161
171
|
if (cmd)
|
|
@@ -344,11 +354,12 @@ export default async () => {
|
|
|
344
354
|
if (!opts.watch) {
|
|
345
355
|
await teardownD2();
|
|
346
356
|
await teardownBpmn();
|
|
357
|
+
await teardownMermaid();
|
|
347
358
|
}
|
|
348
359
|
}
|
|
349
360
|
isBuilding = false;
|
|
350
361
|
if (opts.site)
|
|
351
|
-
return await cmdSite(options, opts, reloadEmitter, () => pages);
|
|
362
|
+
return await cmdSite(options, { ...opts, agent: detectAgent(process.env, opts) }, reloadEmitter, () => pages);
|
|
352
363
|
if (options.GENERATE_WEBSITE && !opts.watch) {
|
|
353
364
|
console.log(chalk.gray('\nto view the generated website run'));
|
|
354
365
|
console.log(`> c4builder site`);
|
package/dist/config/defaults.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -60,6 +60,8 @@ export const configSchema = z.object({
|
|
|
60
60
|
// Заголовок раздела, куда `site -w` дописывает комментарии к странице (в коде кириллицы
|
|
61
61
|
// быть не может — русский заголовок задаёт сам проект).
|
|
62
62
|
questionsHeading: str(defaultConfig.questionsHeading),
|
|
63
|
+
// Язык интерфейса страницы `site -w`; `auto` — по языку браузера (change site-ui-language).
|
|
64
|
+
uiLanguage: z.preprocess(strPre, z.enum(['auto', 'en', 'ru'], 'expected auto, en or ru').default(defaultConfig.uiLanguage)),
|
|
63
65
|
// Задаются явно (флаг/промпт) либо тонкой правкой файла — в defaultConfig не входят.
|
|
64
66
|
projectName: optStr(),
|
|
65
67
|
webFileName: optStr(),
|
|
@@ -223,7 +223,7 @@ export const generateLlmsTxt = (tree, options) => {
|
|
|
223
223
|
});
|
|
224
224
|
return (`# ${options.PROJECT_NAME}\n\n` +
|
|
225
225
|
'> Architecture documentation built with c4builder. Pages are markdown; full text with ' +
|
|
226
|
-
'diagram sources (PlantUML/D2/BPMN): [llms-full.txt](llms-full.txt).\n\n' +
|
|
226
|
+
'diagram sources (PlantUML/D2/BPMN/Mermaid): [llms-full.txt](llms-full.txt).\n\n' +
|
|
227
227
|
`## Pages\n\n${pages.join('')}`);
|
|
228
228
|
};
|
|
229
229
|
// Fence длиннее самой длинной серии обратных кавычек текста — иначе блок закрылся бы изнутри.
|
|
@@ -235,7 +235,8 @@ const FENCE_LANG = {
|
|
|
235
235
|
'.puml': 'plantuml',
|
|
236
236
|
'.iuml': 'plantuml',
|
|
237
237
|
'.d2': 'd2',
|
|
238
|
-
'.bpmn': 'xml'
|
|
238
|
+
'.bpmn': 'xml',
|
|
239
|
+
'.mmd': 'mermaid'
|
|
239
240
|
};
|
|
240
241
|
// llms-full.txt: состав complete-документа, но диаграмма — исходником (C4-PlantUML/D2-текст
|
|
241
242
|
// агенту полезнее SVG). Локальные include/импорты — один раз в приложении, а не инлайном:
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Пачка замечаний агенту со страницы `site -w` (change site-agent-feedback, design D6–D7):
|
|
2
|
+
// схема тела запроса страницы и markdown, который печатает `c4builder feedback`.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const TEXT_MAX = 2000;
|
|
6
|
+
const text = z.string().max(TEXT_MAX);
|
|
7
|
+
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
|
8
|
+
// Цитата — только ориентир для агента: длинное выделение обрезается, а не валит всю пачку.
|
|
9
|
+
const clip = (s) => {
|
|
10
|
+
const q = collapse(s);
|
|
11
|
+
return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
|
|
12
|
+
};
|
|
13
|
+
export const batchSchema = z.object({
|
|
14
|
+
decision: z.enum(['submitted', 'approved']),
|
|
15
|
+
comments: z
|
|
16
|
+
.array(z.object({
|
|
17
|
+
// Ключ карты «страница → исходники»: тот же вид, что у вопросов (без ведущего «/»).
|
|
18
|
+
page: z
|
|
19
|
+
.string()
|
|
20
|
+
.max(1000)
|
|
21
|
+
.transform((p) => path.posix.normalize(p.replace(/^\/+/, ''))),
|
|
22
|
+
quote: z.string().transform(clip),
|
|
23
|
+
section: text.default(''),
|
|
24
|
+
comment: text.trim().min(1)
|
|
25
|
+
}))
|
|
26
|
+
.max(200),
|
|
27
|
+
general: text.trim().default('')
|
|
28
|
+
});
|
|
29
|
+
const quoted = (s) => s
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((l) => (l.trim() ? `> ${l}` : '>'))
|
|
32
|
+
.join('\n');
|
|
33
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
34
|
+
// Исходники — относительно cwd: агент правит их из каталога проекта.
|
|
35
|
+
export const formatBatch = (batch, pages, cwd) => {
|
|
36
|
+
const { comments, general } = batch;
|
|
37
|
+
const pageCount = new Set(comments.map((c) => c.page)).size;
|
|
38
|
+
const out = [
|
|
39
|
+
'# Page feedback (c4builder)',
|
|
40
|
+
'',
|
|
41
|
+
`Decision: ${batch.decision}`,
|
|
42
|
+
`Comments: ${comments.length}${comments.length ? ` on ${plural(pageCount, 'page')}` : ''}`
|
|
43
|
+
];
|
|
44
|
+
comments.forEach((c, i) => {
|
|
45
|
+
const section = collapse(c.section);
|
|
46
|
+
const sources = (pages.get(c.page) ?? []).map((s) => path.relative(cwd, s).split(path.sep).join('/'));
|
|
47
|
+
// Страница схлопывается только в заголовке: перевод строки в ней сломал бы формат пачки,
|
|
48
|
+
// а ключ карты исходников остаётся как есть.
|
|
49
|
+
out.push('', `## ${i + 1}. ${collapse(c.page)}${section ? ` (§ ${section})` : ''}`);
|
|
50
|
+
if (sources.length)
|
|
51
|
+
out.push(`Sources: ${sources.join(', ')}`);
|
|
52
|
+
if (c.quote)
|
|
53
|
+
out.push(`Quote: "${c.quote}"`);
|
|
54
|
+
out.push('', quoted(c.comment));
|
|
55
|
+
});
|
|
56
|
+
if (general)
|
|
57
|
+
out.push('', '## General', '', quoted(general));
|
|
58
|
+
return `${out.join('\n')}\n`;
|
|
59
|
+
};
|
|
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import chalk from 'chalk';
|
|
12
12
|
import { DEFAULT_FONT_NAME } from "./fonts.js";
|
|
13
|
-
import {
|
|
13
|
+
import { ASCENT, DESCENT, clearTextWidths, textWidth } from "./textmetrics.js";
|
|
14
14
|
// Третий бэкенд рендера: BPMN 2.0 XML без координат → валидация (bpmnlint) → раскладка
|
|
15
15
|
// (bpmn-auto-layout) → рендер bpmn-js в jsdom → SVG. Все пакеты — optionalDependencies,
|
|
16
16
|
// грузятся лениво при первом .bpmn: проекты без BPMN их не трогают (как D2).
|
|
@@ -112,32 +112,7 @@ const C4BUILDER_RULES = {
|
|
|
112
112
|
})
|
|
113
113
|
};
|
|
114
114
|
// Ширины строк для переноса подписей: diagram-js меряет текст только через
|
|
115
|
-
// canvas.measureText, а jsdom канваса не
|
|
116
|
-
// resvg, что растрирует PNG: ширина = bbox «|текст|» минус bbox «||» — так получается
|
|
117
|
-
// advance-ширина с пробелами по краям, а не ширина чернил. Режим системных шрифтов на
|
|
118
|
-
// BPMN не влияет (как и на D2): детерминизм важнее.
|
|
119
|
-
const widthMemo = new Map();
|
|
120
|
-
const escapeXml = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
121
|
-
const inkWidth = (text, size) => {
|
|
122
|
-
const { Resvg } = getResvg();
|
|
123
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><text y="${size}" ` +
|
|
124
|
-
`font-family="${DEFAULT_FONT_NAME}" font-size="${size}" xml:space="preserve">${escapeXml(text)}</text></svg>`;
|
|
125
|
-
return new Resvg(svg, { font: resvgFontOptions(false) }).getBBox()?.width ?? 0;
|
|
126
|
-
};
|
|
127
|
-
const textWidth = (text, size) => {
|
|
128
|
-
const key = `${size}\0${text}`;
|
|
129
|
-
let width = widthMemo.get(key);
|
|
130
|
-
if (width === undefined) {
|
|
131
|
-
// Округление гасит f32-шум resvg между платформами: координаты текста в SVG
|
|
132
|
-
// считаются из этой ширины и должны совпадать побайтно.
|
|
133
|
-
width = Math.round((inkWidth(`|${text}|`, size) - inkWidth('||', size)) * 100) / 100;
|
|
134
|
-
widthMemo.set(key, width);
|
|
135
|
-
}
|
|
136
|
-
return width;
|
|
137
|
-
};
|
|
138
|
-
// hhea Nimbus Sans Regular: ascender 729, descender −271 на 1000 единиц (vendor/fonts).
|
|
139
|
-
const ASCENT = 0.729;
|
|
140
|
-
const DESCENT = 0.271;
|
|
115
|
+
// canvas.measureText, а jsdom канваса не умеет — меряем вендорный шрифт (textmetrics).
|
|
141
116
|
// Полифилы окна jsdom: ровно то, что bpmn-js зовёт при импорте и saveSVG. Трансформации
|
|
142
117
|
// пишутся в атрибут сразу — сериализатор берёт атрибуты, а не baseVal.
|
|
143
118
|
const installPolyfills = (window) => {
|
|
@@ -334,7 +309,7 @@ export const teardownBpmn = async () => {
|
|
|
334
309
|
return;
|
|
335
310
|
const pending = enginePromise;
|
|
336
311
|
enginePromise = null;
|
|
337
|
-
|
|
312
|
+
clearTextWidths();
|
|
338
313
|
try {
|
|
339
314
|
(await pending).window.close();
|
|
340
315
|
}
|
|
@@ -13,6 +13,8 @@ import { httpGetBuffer } from "../../util/http.js";
|
|
|
13
13
|
import { renderD2, foldD2Imports } from "./d2renderer.js";
|
|
14
14
|
// BPMN-бэкенд: пакеты движка грузятся лениво внутри renderBpmn.
|
|
15
15
|
import { renderBpmn, BPMN_CACHE_PACKAGES } from "./bpmnrenderer.js";
|
|
16
|
+
// Mermaid-бэкенд: пакет движка грузится лениво внутри renderMermaid.
|
|
17
|
+
import { renderMermaid, MERMAID_PACKAGE } from "./mermaidrenderer.js";
|
|
16
18
|
import { resolveJava } from "./jre.js";
|
|
17
19
|
// PNG-выход: SVG обоих движков растеризуется resvg (ленивая загрузка внутри модуля).
|
|
18
20
|
import { rasterizeSvgToPng } from "./pngraster.js";
|
|
@@ -65,7 +67,7 @@ const runPool = async (tasks, concurrency) => {
|
|
|
65
67
|
if (firstError !== undefined)
|
|
66
68
|
throw firstError;
|
|
67
69
|
};
|
|
68
|
-
// Версии пакетов движков (D2, BPMN) для чексуммы кэша (апгрейд движка → перерендер).
|
|
70
|
+
// Версии пакетов движков (D2, BPMN, mermaid) для чексуммы кэша (апгрейд движка → перерендер).
|
|
69
71
|
// Читаем package.json лениво и один раз: pure-PlantUML сборки их не трогают.
|
|
70
72
|
const versionCache = new Map();
|
|
71
73
|
const pkgVersion = (name) => {
|
|
@@ -100,7 +102,9 @@ export const renderKey = (diagram, options, outFormat) => (diagram.engine === 'd
|
|
|
100
102
|
? `d2\0${pkgVersion('@terrastruct/d2')}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
|
|
101
103
|
: diagram.engine === 'bpmn'
|
|
102
104
|
? `bpmn\0${BPMN_CACHE_PACKAGES.map(pkgVersion).join('\0')}\0fmt=${outFormat}`
|
|
103
|
-
:
|
|
105
|
+
: diagram.engine === 'mermaid'
|
|
106
|
+
? `mermaid\0${pkgVersion(MERMAID_PACKAGE)}\0fmt=${outFormat}`
|
|
107
|
+
: `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
|
|
104
108
|
`\0font=${fontCacheTag(options)}`;
|
|
105
109
|
export const getMime = (format) => {
|
|
106
110
|
if (format === 'svg')
|
|
@@ -279,19 +283,22 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
279
283
|
// BPMN рендерится в общем окне jsdom главного потока — параллельность ничего не даёт,
|
|
280
284
|
// а последовательная очередь исключает гонки на общем состоянии бандла bpmn-js.
|
|
281
285
|
const bpmnTasks = [];
|
|
286
|
+
// Mermaid — так же: своё окно jsdom, глобальный конфиг и подмены на время рендера.
|
|
287
|
+
const mermaidTasks = [];
|
|
282
288
|
const otherTasks = [];
|
|
283
289
|
for (const item of tree) {
|
|
284
290
|
for (const diagram of item.diagrams) {
|
|
285
291
|
// Чексумма = контент диаграммы + свёрнутый граф её зависимостей, чтобы
|
|
286
292
|
// правка включаемого/импортируемого файла инвалидировала кэш: PlantUML —
|
|
287
|
-
// !include-граф (.iuml и пр.), D2 — граф @/...@-импортов. У BPMN
|
|
293
|
+
// !include-граф (.iuml и пр.), D2 — граф @/...@-импортов. У BPMN и mermaid их нет.
|
|
288
294
|
const body = `${diagram.content || ''}`;
|
|
289
295
|
const entryPath = path.join(item.dir, diagram.dir);
|
|
290
296
|
const isD2 = diagram.engine === 'd2';
|
|
291
297
|
const isBpmn = diagram.engine === 'bpmn';
|
|
298
|
+
const isMermaid = diagram.engine === 'mermaid';
|
|
292
299
|
const includes = isD2
|
|
293
300
|
? foldD2Imports(entryPath, body)
|
|
294
|
-
: isBpmn
|
|
301
|
+
: isBpmn || isMermaid
|
|
295
302
|
? ''
|
|
296
303
|
: foldIncludes(body, item.dir, item.dir, new Set());
|
|
297
304
|
const outFormat = diagramOutputFormat(diagram, options);
|
|
@@ -340,12 +347,16 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
340
347
|
// Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
|
|
341
348
|
let rendered;
|
|
342
349
|
try {
|
|
350
|
+
// Путь для сообщений движка; у блоков в markdown — их страница.
|
|
351
|
+
const file = source ?? path.relative(process.cwd(), entryPath).split(path.sep).join('/');
|
|
343
352
|
if (isD2) {
|
|
344
353
|
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
|
|
345
354
|
}
|
|
346
355
|
else if (isBpmn) {
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
rendered = await renderBpmn(body, file);
|
|
357
|
+
}
|
|
358
|
+
else if (isMermaid) {
|
|
359
|
+
rendered = await renderMermaid(body, file);
|
|
349
360
|
}
|
|
350
361
|
else {
|
|
351
362
|
// Резолв JRE отложен до первого реального PlantUML-рендера (эта
|
|
@@ -380,18 +391,19 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
380
391
|
await writeFile(filePath, outFormat === 'png' ? await rasterizeSvgToPng(svg, options.USE_SYSTEM_FONTS) : svg);
|
|
381
392
|
}
|
|
382
393
|
});
|
|
383
|
-
(isD2 ? d2Tasks : isBpmn ? bpmnTasks : otherTasks).push(task);
|
|
394
|
+
(isD2 ? d2Tasks : isBpmn ? bpmnTasks : isMermaid ? mermaidTasks : otherTasks).push(task);
|
|
384
395
|
}
|
|
385
396
|
}
|
|
386
|
-
// PlantUML-пул, D2- и
|
|
387
|
-
// главный поток) и параллельно друг другу. allSettled — чтобы падение одной очереди не
|
|
397
|
+
// PlantUML-пул, D2-, BPMN- и mermaid-очереди работают независимо (процессы java,
|
|
398
|
+
// WASM-worker, главный поток) и параллельно друг другу. allSettled — чтобы падение одной очереди не
|
|
388
399
|
// оставляло другие как floating promise с висящими JVM: все доосушаются, затем бросаем первую
|
|
389
400
|
// ошибку (приоритет — PlantUML-пул, порядок между очередями не значим).
|
|
390
401
|
try {
|
|
391
402
|
const results = await Promise.allSettled([
|
|
392
403
|
runPool(otherTasks, renderConcurrency()),
|
|
393
404
|
runPool(d2Tasks, 1),
|
|
394
|
-
runPool(bpmnTasks, 1)
|
|
405
|
+
runPool(bpmnTasks, 1),
|
|
406
|
+
runPool(mermaidTasks, 1)
|
|
395
407
|
]);
|
|
396
408
|
const failed = results.find((r) => r.status === 'rejected');
|
|
397
409
|
if (failed && failed.status === 'rejected')
|