@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.
@@ -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 = next ? LIVERELOAD_SNIPPET + updateBanner(next) : LIVERELOAD_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')));
@@ -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
- const release = acquireBuildLock(lockPath);
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`);
@@ -31,6 +31,7 @@ const defaultConfig = {
31
31
  diagramFormat: 'svg',
32
32
  d2Layout: 'dagre',
33
33
  charset: 'UTF-8',
34
- useSystemFonts: false
34
+ useSystemFonts: false,
35
+ questionsHeading: 'Open questions'
35
36
  };
36
37
  export { defaultConfig };
@@ -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(),
@@ -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
- filePromises.push(writeFile(path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), `${getWebFileName(item.name)}.md`), MD));
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);