@alfacapital-tech/c4builder 0.8.0 → 0.9.0-rc.2
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/dist/cli/agent.js +21 -0
- package/dist/cli/commands/feedback.js +45 -0
- package/dist/cli/commands/site.comments.js +585 -0
- package/dist/cli/commands/site.js +242 -2
- package/dist/cli/dispatch.js +24 -10
- package/dist/config/defaults.js +2 -1
- package/dist/config/schema.js +3 -0
- package/dist/core/build.js +3 -3
- package/dist/core/compose/markdown.js +5 -1
- package/dist/core/feedback.js +59 -0
- package/dist/core/plugins/tree.js +3 -1
- package/dist/core/questions.js +227 -0
- package/dist/core/scan/tree.js +3 -1
- package/dist/plugins/openspec/index.js +6 -1
- package/dist/plugins/openspec/render.js +6 -3
- package/dist/util/lock.js +23 -0
- package/package.json +1 -1
- package/template/AGENTS.md +7 -1
|
@@ -1,14 +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";
|
|
8
|
+
import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
|
|
6
9
|
import { packageJson as pkg } from "../../util/paths.js";
|
|
7
10
|
import { pendingUpdate } from "../update.js";
|
|
11
|
+
import { COMMENTS_PATH, commentsSnippet, FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
|
|
12
|
+
const COMMENT_MAX = 2000;
|
|
8
13
|
const LIVERELOAD_PATH = '/__livereload';
|
|
9
14
|
// Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
|
|
10
15
|
// высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
|
|
11
16
|
// Комментарии держим здесь, а не в сниппете: он уходит в браузер пользователя.
|
|
17
|
+
// События агента (design D4) идут по тому же потоку и пересылаются сниппету комментариев
|
|
18
|
+
// DOM-событиями `c4builder:*` — второй EventSource странице не нужен.
|
|
12
19
|
const LIVERELOAD_SNIPPET = `
|
|
13
20
|
<script>
|
|
14
21
|
(function () {
|
|
@@ -45,6 +52,13 @@ const LIVERELOAD_SNIPPET = `
|
|
|
45
52
|
es.close();
|
|
46
53
|
location.reload();
|
|
47
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
|
+
});
|
|
48
62
|
es.onerror = function () {
|
|
49
63
|
es.close();
|
|
50
64
|
setTimeout(connect, delay);
|
|
@@ -76,19 +90,33 @@ c4builder ${next} is available (current ${pkg.version}). Run: <code>c4builder up
|
|
|
76
90
|
};
|
|
77
91
|
})();
|
|
78
92
|
</script>`;
|
|
79
|
-
export default (currentConfiguration, program, reloadEmitter
|
|
93
|
+
export default (currentConfiguration, program, reloadEmitter,
|
|
94
|
+
// Карта «страница → исходники» текущей сборки — геттером, чтобы видеть результат ребилдов watch.
|
|
95
|
+
pages = () => new Map()) => {
|
|
80
96
|
if (!currentConfiguration.DIST_FOLDER)
|
|
81
97
|
return console.log(chalk.red('No destination folder configured'));
|
|
82
98
|
const app = express();
|
|
83
99
|
const port = program.port || currentConfiguration.WEB_PORT;
|
|
84
100
|
const distFolder = path.resolve(currentConfiguration.DIST_FOLDER);
|
|
85
101
|
const liveReloadEnabled = !!(program.watch && reloadEmitter);
|
|
102
|
+
const agent = { ...(program.agent ?? { on: false, name: '', explicit: false }) };
|
|
86
103
|
// Кеш index.html — отдаём его из памяти, если на диске его временно нет
|
|
87
104
|
// (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
|
|
88
105
|
let lastIndexHtml = null;
|
|
89
106
|
// reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
|
|
90
107
|
// истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
|
|
91
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
|
+
};
|
|
92
120
|
app.get(LIVERELOAD_PATH, (req, res) => {
|
|
93
121
|
res.set({
|
|
94
122
|
'Content-Type': 'text/event-stream',
|
|
@@ -99,24 +127,234 @@ export default (currentConfiguration, program, reloadEmitter) => {
|
|
|
99
127
|
res.flushHeaders();
|
|
100
128
|
res.write('retry: 1000\n\n');
|
|
101
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`);
|
|
102
132
|
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
103
133
|
reloadEmitter.on('reload', onReload); // liveReloadEnabled ⇒ reloadEmitter задан
|
|
134
|
+
reloadEmitter.on('agent', onAgent);
|
|
135
|
+
reloadEmitter.on('delivered', onDelivered);
|
|
136
|
+
onAgent(agentState());
|
|
104
137
|
console.log(chalk.gray(`livereload: client connected (${reloadEmitter.listenerCount('reload')} total)`));
|
|
105
138
|
req.on('close', () => {
|
|
106
139
|
clearInterval(heartbeat);
|
|
107
140
|
reloadEmitter.off('reload', onReload);
|
|
141
|
+
reloadEmitter.off('agent', onAgent);
|
|
142
|
+
reloadEmitter.off('delivered', onDelivered);
|
|
108
143
|
console.log(chalk.gray(`livereload: client disconnected (${reloadEmitter.listenerCount('reload')} left)`));
|
|
109
144
|
});
|
|
110
145
|
});
|
|
146
|
+
// Ключ черновиков review в localStorage (design D5): страницы разных проектов на одном
|
|
147
|
+
// localhost:<port> не должны видеть чужой черновик.
|
|
148
|
+
const projectKey = createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8);
|
|
111
149
|
// Кэш проверки обновлений читается на каждую страницу: результат фоновой проверки,
|
|
112
150
|
// запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
|
|
113
151
|
const inject = (html) => {
|
|
114
152
|
const next = pendingUpdate();
|
|
115
|
-
const snippet =
|
|
153
|
+
const snippet = LIVERELOAD_SNIPPET +
|
|
154
|
+
commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
|
|
155
|
+
agent: agent.on,
|
|
156
|
+
listening,
|
|
157
|
+
project: projectKey
|
|
158
|
+
}) +
|
|
159
|
+
(next ? updateBanner(next) : '');
|
|
116
160
|
if (html.includes('</body>'))
|
|
117
161
|
return html.replace('</body>', `${snippet}</body>`);
|
|
118
162
|
return html + snippet;
|
|
119
163
|
};
|
|
164
|
+
// Комментарий к выделенному тексту → раздел вопросов исходного файла страницы
|
|
165
|
+
// (change site-comments, design D3). Сервер слушает только 127.0.0.1, но страница
|
|
166
|
+
// любого сайта в браузере может слать POST на localhost — требуем Origin самого сайта.
|
|
167
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
168
|
+
const heading = currentConfiguration.QUESTIONS_HEADING;
|
|
169
|
+
// Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
|
|
170
|
+
// присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
|
|
171
|
+
const localHost = (req) => {
|
|
172
|
+
const host = req.get('host');
|
|
173
|
+
const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
|
|
174
|
+
return host && local.includes(host) ? host : undefined;
|
|
175
|
+
};
|
|
176
|
+
const fromSite = (req) => {
|
|
177
|
+
const host = localHost(req);
|
|
178
|
+
return !!host && req.get('origin') === `http://${host}`;
|
|
179
|
+
};
|
|
180
|
+
// Общая часть POST/DELETE: проверка Origin и исходный файл страницы из тела запроса.
|
|
181
|
+
// Ошибка уже отправлена клиенту, когда возвращается undefined.
|
|
182
|
+
const resolveTarget = (req, res) => {
|
|
183
|
+
if (!fromSite(req)) {
|
|
184
|
+
res.status(403).json({ error: 'comments are accepted only from the site itself' });
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const body = (req.body ?? {});
|
|
188
|
+
const page = path.posix.normalize(str(body.page).replace(/^\/+/, ''));
|
|
189
|
+
const sources = page.startsWith('..') ? undefined : pages().get(page);
|
|
190
|
+
if (!sources) {
|
|
191
|
+
res.status(404).json({ error: `unknown page: ${page}` });
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const cwd = process.cwd();
|
|
195
|
+
const file = pickSource(sources.filter((s) => s.startsWith(cwd + path.sep)), heading);
|
|
196
|
+
if (!file) {
|
|
197
|
+
res.status(404).json({ error: 'this page has no source file to write to' });
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return { file, rel: path.relative(cwd, file).split(path.sep).join('/'), body };
|
|
201
|
+
};
|
|
202
|
+
app.post(COMMENTS_PATH, express.json({ limit: '64kb' }), (req, res) => {
|
|
203
|
+
const target = resolveTarget(req, res);
|
|
204
|
+
if (!target)
|
|
205
|
+
return;
|
|
206
|
+
const { file, rel, body } = target;
|
|
207
|
+
const comment = str(body.comment).trim();
|
|
208
|
+
const quote = str(body.quote);
|
|
209
|
+
const section = str(body.section);
|
|
210
|
+
if (!comment) {
|
|
211
|
+
res.status(400).json({ error: 'comment is empty' });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (comment.length > COMMENT_MAX || quote.length > COMMENT_MAX) {
|
|
215
|
+
res.status(400).json({
|
|
216
|
+
error: `comment and quote must be at most ${COMMENT_MAX} characters`
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const number = writeQuestion(file, heading, formatQuestion({ comment, quote, section }));
|
|
222
|
+
console.log(chalk.gray(`comment added to ${rel}${number ? ` as #${number}` : ''}`));
|
|
223
|
+
res.json({ file: rel, number });
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
res.status(500).json({ error: `could not write the comment: ${e.message}` });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
// Удаление вопроса: по номеру (числовая таблица) и тексту, как он виден на странице.
|
|
230
|
+
app.delete(COMMENTS_PATH, express.json({ limit: '64kb' }), (req, res) => {
|
|
231
|
+
const target = resolveTarget(req, res);
|
|
232
|
+
if (!target)
|
|
233
|
+
return;
|
|
234
|
+
const { file, rel, body } = target;
|
|
235
|
+
const text = str(body.text).trim();
|
|
236
|
+
if (!text) {
|
|
237
|
+
res.status(400).json({ error: 'question text is empty' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const number = typeof body.number === 'number' ? body.number : Number(str(body.number)) || null;
|
|
241
|
+
try {
|
|
242
|
+
const result = deleteQuestion(file, heading, { number, text });
|
|
243
|
+
if ('error' in result) {
|
|
244
|
+
const messages = {
|
|
245
|
+
'not-found': `question not found in ${rel} — the page may be stale, reload it`,
|
|
246
|
+
ambiguous: `several identical questions in ${rel} — remove it by hand`
|
|
247
|
+
};
|
|
248
|
+
res.status(result.error === 'not-found' ? 404 : 409).json({
|
|
249
|
+
error: messages[result.error]
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
console.log(chalk.gray(`comment removed from ${rel}`));
|
|
254
|
+
res.json({ file: rel });
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
res.status(500).json({ error: `could not remove the comment: ${e.message}` });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
|
|
261
|
+
// шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
|
|
262
|
+
// когда ответ записан; обрыв до этого возвращает её в голову очереди.
|
|
263
|
+
const { pollMs = 25_000, idleMs = 30_000 } = program;
|
|
264
|
+
const queue = [];
|
|
265
|
+
const waiters = [];
|
|
266
|
+
let polls = 0;
|
|
267
|
+
let idleTimer;
|
|
268
|
+
const disabled = () => agent.explicit && !agent.on;
|
|
269
|
+
const drop = (res) => {
|
|
270
|
+
const i = waiters.indexOf(res);
|
|
271
|
+
if (i >= 0)
|
|
272
|
+
waiters.splice(i, 1);
|
|
273
|
+
};
|
|
274
|
+
const pump = () => {
|
|
275
|
+
while (queue.length && waiters.length) {
|
|
276
|
+
const res = waiters.shift();
|
|
277
|
+
const item = queue.shift();
|
|
278
|
+
if (res && item)
|
|
279
|
+
deliver(res, item);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const deliver = (res, item) => {
|
|
283
|
+
res.on('close', () => {
|
|
284
|
+
if (!res.writableFinished) {
|
|
285
|
+
queue.unshift(item);
|
|
286
|
+
pump();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
|
|
290
|
+
reloadEmitter.emit('delivered', { id: item.id });
|
|
291
|
+
});
|
|
292
|
+
res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
|
|
293
|
+
};
|
|
294
|
+
app.post(FEEDBACK_PATH, express.json({ limit: '4mb' }), (req, res) => {
|
|
295
|
+
if (!fromSite(req)) {
|
|
296
|
+
res.status(403).json({ error: 'feedback is accepted only from the site itself' });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (disabled()) {
|
|
300
|
+
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const parsed = batchSchema.safeParse(req.body);
|
|
304
|
+
if (!parsed.success) {
|
|
305
|
+
const issue = parsed.error.issues[0];
|
|
306
|
+
res.status(400).json({
|
|
307
|
+
error: `invalid feedback: ${issue?.path.join('.')} ${issue?.message}`
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const batch = parsed.data;
|
|
312
|
+
if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
|
|
313
|
+
res.status(400).json({ error: 'nothing to send' });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const id = randomUUID();
|
|
317
|
+
const queued = !waiters.length;
|
|
318
|
+
queue.push({ id, batch });
|
|
319
|
+
pump();
|
|
320
|
+
console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
|
|
321
|
+
res.json({ id, queued });
|
|
322
|
+
});
|
|
323
|
+
// Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
|
|
324
|
+
// чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
|
|
325
|
+
app.get(`${FEEDBACK_PATH}/next`, (req, res) => {
|
|
326
|
+
if (!localHost(req) || req.get('origin') || req.get(FEEDBACK_HEADER) !== '1') {
|
|
327
|
+
res.status(403).json({ error: 'feedback is served only to the c4builder feedback command' });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (disabled()) {
|
|
331
|
+
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!agent.on)
|
|
335
|
+
console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
|
|
336
|
+
polls++;
|
|
337
|
+
clearTimeout(idleTimer);
|
|
338
|
+
setAgent(true, true);
|
|
339
|
+
let timer;
|
|
340
|
+
res.on('close', () => {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
drop(res);
|
|
343
|
+
if (--polls === 0)
|
|
344
|
+
idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
|
|
345
|
+
});
|
|
346
|
+
waiters.push(res);
|
|
347
|
+
pump();
|
|
348
|
+
if (!waiters.includes(res))
|
|
349
|
+
return;
|
|
350
|
+
timer = setTimeout(() => {
|
|
351
|
+
// Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
|
|
352
|
+
if (!waiters.includes(res))
|
|
353
|
+
return;
|
|
354
|
+
drop(res);
|
|
355
|
+
res.status(204).end();
|
|
356
|
+
}, req.query.wait === '0' ? 0 : pollMs);
|
|
357
|
+
});
|
|
120
358
|
app.use((req, res, next) => {
|
|
121
359
|
if (req.method !== 'GET')
|
|
122
360
|
return next();
|
|
@@ -167,6 +405,8 @@ export default (currentConfiguration, program, reloadEmitter) => {
|
|
|
167
405
|
console.log(`go to ${chalk.green(url)}`);
|
|
168
406
|
if (liveReloadEnabled)
|
|
169
407
|
console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
|
|
408
|
+
if (liveReloadEnabled && agent.on)
|
|
409
|
+
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`);
|
|
170
410
|
// --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
|
|
171
411
|
if (program.open && process.env.BROWSER !== 'none')
|
|
172
412
|
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";
|
|
@@ -17,7 +19,7 @@ import fs from 'node:fs';
|
|
|
17
19
|
import watch from 'node-watch';
|
|
18
20
|
import { EventEmitter } from 'node:events';
|
|
19
21
|
import { clearConsole } from "../util/utils.js";
|
|
20
|
-
import { acquireBuildLock, BuildLockHeldError } from "../util/lock.js";
|
|
22
|
+
import { acquireBuildLock, BuildLockHeldError, waitBuildLock } from "../util/lock.js";
|
|
21
23
|
// teardownD2 живёт здесь, а не в finally build(): в watch-режиме D2-воркер должен
|
|
22
24
|
// переживать ребилды (его пересоздание — секунды), гасим его только когда сборок
|
|
23
25
|
// больше не будет (одиночный запуск).
|
|
@@ -27,7 +29,7 @@ import { packageJson as pkg } from "../util/paths.js";
|
|
|
27
29
|
import { outputDirs, parseConfig } from "../config/options.js";
|
|
28
30
|
import { isValidPort } from "../config/schema.js";
|
|
29
31
|
// Позиционные подкоманды: allowExcessArguments пропускает любые, список — единственный фильтр.
|
|
30
|
-
const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update'];
|
|
32
|
+
const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update', 'feedback'];
|
|
31
33
|
const intro = () => {
|
|
32
34
|
console.log(chalk.blue(figlet.textSync('c4builder')));
|
|
33
35
|
console.log(chalk.gray('Blow up your software documentation writing skills'));
|
|
@@ -102,6 +104,7 @@ function getOptions(c) {
|
|
|
102
104
|
EXECUTE_SCRIPT: c.executeScript,
|
|
103
105
|
EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
|
|
104
106
|
USE_SYSTEM_FONTS: c.useSystemFonts,
|
|
107
|
+
QUESTIONS_HEADING: c.questionsHeading,
|
|
105
108
|
PLUGINS: c.plugins
|
|
106
109
|
};
|
|
107
110
|
}
|
|
@@ -119,6 +122,9 @@ export default async () => {
|
|
|
119
122
|
.option('--site', 'serve the generated site')
|
|
120
123
|
.option('-w, --watch', 'watch for changes and rebuild')
|
|
121
124
|
.option('-o, --open', 'open the generated site in the browser (with --site)')
|
|
125
|
+
.option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
|
|
126
|
+
.option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
|
|
127
|
+
.option('--wait', 'for `feedback`: wait for the next batch of page feedback')
|
|
122
128
|
.option('--docs', 'a brief explanation for the available configuration options')
|
|
123
129
|
.option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
|
|
124
130
|
.option('-p, --port <n>', 'port used for serving the generated site', (v) => {
|
|
@@ -139,8 +145,8 @@ export default async () => {
|
|
|
139
145
|
process.exit(1);
|
|
140
146
|
}
|
|
141
147
|
// --version/--help уже вышли в parse. Уведомление — по кэшу прошлой проверки, до её
|
|
142
|
-
// перезапуска; exit покрывает и выходы через process.exit.
|
|
143
|
-
if (cmd !== 'update') {
|
|
148
|
+
// перезапуска; exit покрывает и выходы через process.exit. Вывод `feedback` читает агент.
|
|
149
|
+
if (cmd !== 'update' && cmd !== 'feedback') {
|
|
144
150
|
const next = pendingUpdate();
|
|
145
151
|
if (next)
|
|
146
152
|
process.once('exit', () => process.stderr.write(`c4builder ${next} is available (current ${pkg.version}). Run: c4builder update\n`));
|
|
@@ -155,6 +161,9 @@ export default async () => {
|
|
|
155
161
|
// Обновление CLI — до Configstore, чтобы не создавать .c4builder в любом каталоге.
|
|
156
162
|
if (cmd === 'update')
|
|
157
163
|
return cmdUpdate();
|
|
164
|
+
// Замечания со страницы `site -w` — тоже до Configstore: команду запускают в любом каталоге.
|
|
165
|
+
if (cmd === 'feedback')
|
|
166
|
+
return cmdFeedback(path.join(process.cwd(), opts.configFile ?? '.c4builder'), opts);
|
|
158
167
|
// `site`/`new`/`config` — позиционные синонимы одноимённых флагов: эту форму печатают
|
|
159
168
|
// подсказки CLI и учат README, шаблон и скиллы.
|
|
160
169
|
if (cmd)
|
|
@@ -256,6 +265,9 @@ export default async () => {
|
|
|
256
265
|
});
|
|
257
266
|
const reloadEmitter = new EventEmitter();
|
|
258
267
|
reloadEmitter.setMaxListeners(0);
|
|
268
|
+
// Карта «страница сайта → исходники» последней удачной сборки: dev-сервер читает её
|
|
269
|
+
// геттером, поэтому после ребилда в watch видит свежую без рестарта.
|
|
270
|
+
let pages = new Map();
|
|
259
271
|
if (opts.watch) {
|
|
260
272
|
// node-watch: CJS-рантайм при ESM-.d.ts (export default) — дефолт-импорт
|
|
261
273
|
// типизируется как namespace. Каст к реальной сигнатуре, рантайм не меняется.
|
|
@@ -286,13 +298,15 @@ export default async () => {
|
|
|
286
298
|
let buildOk = true;
|
|
287
299
|
try {
|
|
288
300
|
// Лок на всю серию ребилдов: параллельная ручная сборка в этом же
|
|
289
|
-
// каталоге не потопчет dist_bk и не потеряет чексуммы кеша.
|
|
290
|
-
|
|
301
|
+
// каталоге не потопчет dist_bk и не потеряет чексуммы кеша. Занят другим
|
|
302
|
+
// живым процессом (второй `site -w` на той же папке) — ждём до минуты и
|
|
303
|
+
// собираем сами: иначе ребилд пропадает и браузер не получает reload.
|
|
304
|
+
const release = await waitBuildLock(lockPath, 60_000, (e) => console.log(chalk.gray(`waiting for another build: ${e.message}`)));
|
|
291
305
|
try {
|
|
292
|
-
await build(options, cacheConf, plugins);
|
|
306
|
+
({ pages } = await build(options, cacheConf, plugins));
|
|
293
307
|
while (attemptedWatchBuild) {
|
|
294
308
|
attemptedWatchBuild = false;
|
|
295
|
-
await build(options, cacheConf, plugins);
|
|
309
|
+
({ pages } = await build(options, cacheConf, plugins));
|
|
296
310
|
}
|
|
297
311
|
}
|
|
298
312
|
finally {
|
|
@@ -329,7 +343,7 @@ export default async () => {
|
|
|
329
343
|
process.exit(1);
|
|
330
344
|
}
|
|
331
345
|
try {
|
|
332
|
-
await build(options, cacheConf, plugins);
|
|
346
|
+
({ pages } = await build(options, cacheConf, plugins));
|
|
333
347
|
}
|
|
334
348
|
finally {
|
|
335
349
|
release();
|
|
@@ -342,7 +356,7 @@ export default async () => {
|
|
|
342
356
|
}
|
|
343
357
|
isBuilding = false;
|
|
344
358
|
if (opts.site)
|
|
345
|
-
return await cmdSite(options, opts, reloadEmitter);
|
|
359
|
+
return await cmdSite(options, { ...opts, agent: detectAgent(process.env, opts) }, reloadEmitter, () => pages);
|
|
346
360
|
if (options.GENERATE_WEBSITE && !opts.watch) {
|
|
347
361
|
console.log(chalk.gray('\nto view the generated website run'));
|
|
348
362
|
console.log(`> c4builder site`);
|
package/dist/config/defaults.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -57,6 +57,9 @@ export const configSchema = z.object({
|
|
|
57
57
|
d2Layout: str(defaultConfig.d2Layout),
|
|
58
58
|
charset: str(defaultConfig.charset),
|
|
59
59
|
useSystemFonts: bool(defaultConfig.useSystemFonts),
|
|
60
|
+
// Заголовок раздела, куда `site -w` дописывает комментарии к странице (в коде кириллицы
|
|
61
|
+
// быть не может — русский заголовок задаёт сам проект).
|
|
62
|
+
questionsHeading: str(defaultConfig.questionsHeading),
|
|
60
63
|
// Задаются явно (флаг/промпт) либо тонкой правкой файла — в defaultConfig не входят.
|
|
61
64
|
projectName: optStr(),
|
|
62
65
|
webFileName: optStr(),
|
package/dist/core/build.js
CHANGED
|
@@ -14,10 +14,9 @@ import { generateMD, generateWebMD, generateCompleteMD, generateLlmsTxt, generat
|
|
|
14
14
|
// Загружены заранее в cli/dispatch и приходят третьим аргументом (как cacheConf).
|
|
15
15
|
import { runAfterScan, runAfterBuild } from "./plugins/hooks.js";
|
|
16
16
|
import { injectPluginAssets } from "./plugins/assets.js";
|
|
17
|
-
// Оркестратор владеет lifecycle выходного каталога: dist бэкапится в dist_bk, откуда
|
|
18
|
-
// generateImages восстанавливает неизменённые по чексумме картинки; в конце бэкап сносится.
|
|
19
17
|
const build = async (options, cacheConf, plugins = []) => {
|
|
20
18
|
const start_date = new Date();
|
|
19
|
+
let pages = new Map();
|
|
21
20
|
const bkFolderName = options.DIST_FOLDER + DIST_BACKUP_FOLDER_SUFFIX;
|
|
22
21
|
// Generating local images, remove old backup image folder, rename current dist folder to new backup
|
|
23
22
|
if (options.GENERATE_LOCAL_IMAGES) {
|
|
@@ -72,7 +71,7 @@ const build = async (options, cacheConf, plugins = []) => {
|
|
|
72
71
|
}
|
|
73
72
|
if (options.GENERATE_WEBSITE) {
|
|
74
73
|
console.log(chalk.blue('generating docsify site'));
|
|
75
|
-
await generateWebMD(tree, options);
|
|
74
|
+
pages = await generateWebMD(tree, options);
|
|
76
75
|
await injectPluginAssets(plugins, options);
|
|
77
76
|
}
|
|
78
77
|
// llms.txt ссылается на .md страниц сайта — без website ссылаться не на что.
|
|
@@ -98,6 +97,7 @@ const build = async (options, cacheConf, plugins = []) => {
|
|
|
98
97
|
await runAfterBuild(options, plugins);
|
|
99
98
|
console.log(chalk.green(`built in ${(Date.now() - start_date.getTime()) / 1000} seconds`));
|
|
100
99
|
ok = true;
|
|
100
|
+
return { pages };
|
|
101
101
|
}
|
|
102
102
|
finally {
|
|
103
103
|
// Бэкап dist сносим ТОЛЬКО при успехе: при падении фазы dist_bk — единственная
|
|
@@ -315,6 +315,7 @@ export const generateMD = async (tree, options, onProgress) => {
|
|
|
315
315
|
export const generateWebMD = async (tree, options) => {
|
|
316
316
|
const filePromises = [];
|
|
317
317
|
let docsifySideBar = '';
|
|
318
|
+
const pages = new Map();
|
|
318
319
|
const getWebFileName = (originalFileName) => options.WEB_FILE_NAME || originalFileName;
|
|
319
320
|
for (const item of tree) {
|
|
320
321
|
if (inSidebar(item, options)) {
|
|
@@ -322,7 +323,9 @@ export const generateWebMD = async (tree, options) => {
|
|
|
322
323
|
}
|
|
323
324
|
const MD = await compilePage(item, options);
|
|
324
325
|
//write to disk
|
|
325
|
-
|
|
326
|
+
const pageRel = path.join(item.dir.replace(options.ROOT_FOLDER, ''), `${getWebFileName(item.name)}.md`);
|
|
327
|
+
pages.set(pageRel.split(path.sep).filter(Boolean).join('/'), item.sources);
|
|
328
|
+
filePromises.push(writeFile(path.join(options.DIST_FOLDER, pageRel), MD));
|
|
326
329
|
}
|
|
327
330
|
if (options.DOCSIFY_TEMPLATE && options.DOCSIFY_TEMPLATE !== '') {
|
|
328
331
|
// require ESM/TS-шаблона возвращает namespace { default: fn }, а не саму функцию —
|
|
@@ -375,4 +378,5 @@ export const generateWebMD = async (tree, options) => {
|
|
|
375
378
|
//sidebar
|
|
376
379
|
filePromises.push(writeFile(path.join(options.DIST_FOLDER, '_sidebar.md'), docsifySideBar));
|
|
377
380
|
await Promise.all(filePromises);
|
|
381
|
+
return pages;
|
|
378
382
|
};
|
|
@@ -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
|
+
};
|
|
@@ -52,7 +52,8 @@ export const addPage = (tree, options, page) => {
|
|
|
52
52
|
parent: parent.dir,
|
|
53
53
|
mdFiles: [],
|
|
54
54
|
diagrams: [],
|
|
55
|
-
descendants: []
|
|
55
|
+
descendants: [],
|
|
56
|
+
sources: []
|
|
56
57
|
};
|
|
57
58
|
VIRTUAL.add(item);
|
|
58
59
|
parent.descendants.push(seg);
|
|
@@ -66,6 +67,7 @@ export const addPage = (tree, options, page) => {
|
|
|
66
67
|
}
|
|
67
68
|
const md = page.markdown === undefined ? [] : Array.isArray(page.markdown) ? page.markdown : [page.markdown];
|
|
68
69
|
item.mdFiles.push(...md);
|
|
70
|
+
item.sources.push(...(page.sources ?? []).map((s) => path.resolve(s)));
|
|
69
71
|
for (const d of page.diagrams ?? []) {
|
|
70
72
|
const ext = path.extname(d.file).toLowerCase();
|
|
71
73
|
const engine = engineForExt(ext);
|