@alfacapital-tech/c4builder 0.4.0-rc.4 → 0.5.0-rc.1

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.
Files changed (39) hide show
  1. package/README.MD +69 -615
  2. package/dist/cli/commands/check.js +43 -26
  3. package/dist/cli/commands/help.js +5 -2
  4. package/dist/cli/commands/jre.js +6 -6
  5. package/dist/cli/commands/list.js +4 -1
  6. package/dist/cli/commands/new.js +6 -6
  7. package/dist/cli/commands/site.js +3 -2
  8. package/dist/cli/dispatch.js +38 -18
  9. package/dist/cli/wizard/collect.js +16 -10
  10. package/dist/config/defaults.js +1 -0
  11. package/dist/config/options.js +1 -1
  12. package/dist/config/schema.js +5 -2
  13. package/dist/core/build.js +15 -6
  14. package/dist/core/compose/docsify.template.js +0 -1
  15. package/dist/core/compose/markdown.js +85 -19
  16. package/dist/core/plugins/hooks.js +1 -1
  17. package/dist/core/plugins/load.js +6 -6
  18. package/dist/core/plugins/source.js +6 -6
  19. package/dist/core/plugins/tree.js +5 -5
  20. package/dist/core/render/bpmnrenderer.js +522 -0
  21. package/dist/core/render/d2renderer.js +19 -19
  22. package/dist/core/render/diagrams.js +64 -45
  23. package/dist/core/render/jre.js +14 -14
  24. package/dist/core/render/pngraster.js +4 -4
  25. package/dist/{plugins/openspec → core/scan}/fences.js +11 -10
  26. package/dist/core/scan/tree.js +37 -14
  27. package/dist/plugins/openapi/index.js +17 -7
  28. package/dist/plugins/openspec/index.js +1 -1
  29. package/dist/plugins/openspec/render.js +5 -2
  30. package/dist/util/archive.js +1 -1
  31. package/dist/util/http.js +3 -3
  32. package/dist/util/lock.js +3 -3
  33. package/dist/util/paths.js +1 -1
  34. package/package.json +9 -4
  35. package/template/AGENTS.md +52 -0
  36. package/template/readme.md +3 -3
  37. package/template/src/5 BPMN Example/account-opening.bpmn +69 -0
  38. package/template/src/5 BPMN Example/account-opening.md +7 -0
  39. package/dist/util/glob.js +0 -69
@@ -7,9 +7,11 @@ import { encodeURIPath, readFile, writeFile, plantUmlServerUrl } from "../../uti
7
7
  import { VENDOR_DIR } from "../../util/paths.js";
8
8
  // Фаза scan: тип дерева и его элементов + производные (имя папки, формат выхода диаграммы)
9
9
  // и единый реестр расширений диаграмм (источник истины для regex ссылок ниже).
10
- import { getFolderName, diagramOutputFormat, DIAGRAM_ENGINES, engineSupportsRemote } from "../scan/tree.js";
10
+ import { getFolderName, diagramOutputFormat, DIAGRAM_ENGINES, engineSupportsRemote, collectIncludes } from "../scan/tree.js";
11
11
  // Фаза render: mime-тип формата и загрузка удалённо отрендеренной картинки (embed-ветка).
12
12
  import { getMime, httpGet } from "../render/diagrams.js";
13
+ // Граф D2-импортов — тот же, что у рендера (приложение llms-full.txt).
14
+ import { d2LocalImports } from "../render/d2renderer.js";
13
15
  // docsifyTemplate можно переопределить пользовательским шаблоном (см. generateWebMD),
14
16
  // поэтому это mutable-локаль, а не const-импорт; user-шаблон грузим синхронно через createRequire.
15
17
  const require = createRequire(import.meta.url);
@@ -164,6 +166,12 @@ const mdAnchor = (name) => `#${encodeURIPath(name).replace(/%20/g, '-')}`;
164
166
  // (rootRel — путь цели от ROOT_FOLDER). Один паттерн для TOC, навигации вверх и
165
167
  // меню подпапок — прежде дублировался трижды.
166
168
  const relativeMdLink = (fromLevel, rootRel, mdFileName) => encodeURIPath(path.join('./', fromLevel - 1 > 0 ? '../'.repeat(fromLevel - 1) : '', rootRel, `${mdFileName}.md`));
169
+ // Состав и ссылки sidebar сайта — общие с llms.txt, чтобы оглавление агента не
170
+ // разъехалось с навигацией. Чистоту string[] гарантирует zod-схема.
171
+ const inSidebar = (item, options) => !options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH?.some((pathToExclude) => item.dir.startsWith(pathToExclude));
172
+ // Путь веб-страницы от корня сайта, без .md. path.relative, а не отрезание первого
173
+ // сегмента: то ломалось на многосегментном rootFolder из рукописного конфига.
174
+ const sitePagePath = (item, options) => path.join(path.relative(options.ROOT_FOLDER, item.dir), options.WEB_FILE_NAME || item.name);
167
175
  // Тело страницы (титул + контент с диаграммами) — общий скелет generateMD и
168
176
  // generateWebMD. chrome (breadcrumbs/TOC/навигация) встаёт под заголовок: при
169
177
  // авто-титуле — сразу за ним, при пользовательском h1 — пост-вставкой после него.
@@ -176,8 +184,9 @@ const compilePage = async (item, options, chrome = '') => {
176
184
  MD = injectAfterFirstH1(MD, chrome);
177
185
  return MD.trimStart();
178
186
  };
179
- export const generateCompleteMD = async (tree, options) => {
180
- const filePromises = [];
187
+ // Единый документ со всеми страницами в порядке дерева. Стратегия диаграмм — у вызывающего:
188
+ // complete.md вставляет картинки, llms-full.txt — исходники; состав и порядок общие.
189
+ const composeComplete = async (tree, options, getDiagram) => {
181
190
  //title
182
191
  let MD = `# ${options.PROJECT_NAME}`;
183
192
  //table of contents
@@ -195,13 +204,67 @@ export const generateCompleteMD = async (tree, options) => {
195
204
  MD += `\n\n[${options.HOMEPAGE_NAME}](${mdAnchor(options.PROJECT_NAME)})`;
196
205
  }
197
206
  //concatenate markdown files
198
- MD = await compileDocument(MD, item, options, (item, diagram, options) =>
199
- // complete: ссылки на диаграммы префиксуются папкой элемента.
200
- buildDiagramMarkdown(item, diagram, options, 'complete'));
207
+ MD = await compileDocument(MD, item, options, getDiagram);
201
208
  }
202
- //write file to disk
203
- filePromises.push(writeFile(path.join(options.DIST_FOLDER, `${options.PROJECT_NAME}.md`), MD));
204
- await Promise.all(filePromises);
209
+ return MD;
210
+ };
211
+ export const generateCompleteMD = async (tree, options) => writeFile(path.join(options.DIST_FOLDER, `${options.PROJECT_NAME}.md`),
212
+ // complete: ссылки на диаграммы префиксуются папкой элемента.
213
+ await composeComplete(tree, options, (i, d, o) => buildDiagramMarkdown(i, d, o, 'complete')));
214
+ // llms.txt (llmstxt.org): плоский список — парсеры читают строки `- [..](..)` от начала
215
+ // строки и теряют вложенные; иерархию несёт заголовок «A / B / C».
216
+ export const generateLlmsTxt = (tree, options) => {
217
+ const pages = tree
218
+ .filter((item) => inSidebar(item, options))
219
+ .map((item) => {
220
+ const rel = path.relative(options.ROOT_FOLDER, item.dir);
221
+ const title = rel ? rel.split(path.sep).join(' / ') : options.HOMEPAGE_NAME;
222
+ return `- [${title}](${encodeURIPath(`${sitePagePath(item, options)}.md`)})\n`;
223
+ });
224
+ return (`# ${options.PROJECT_NAME}\n\n` +
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' +
227
+ `## Pages\n\n${pages.join('')}`);
228
+ };
229
+ // Fence длиннее самой длинной серии обратных кавычек текста — иначе блок закрылся бы изнутри.
230
+ const fenced = (text, lang = '') => {
231
+ const fence = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((run) => run.length + 1)));
232
+ return `${fence}${lang}\n${text.endsWith('\n') ? text : `${text}\n`}${fence}`;
233
+ };
234
+ const FENCE_LANG = {
235
+ '.puml': 'plantuml',
236
+ '.iuml': 'plantuml',
237
+ '.d2': 'd2',
238
+ '.bpmn': 'xml'
239
+ };
240
+ // llms-full.txt: состав complete-документа, но диаграмма — исходником (C4-PlantUML/D2-текст
241
+ // агенту полезнее SVG). Локальные include/импорты — один раз в приложении, а не инлайном:
242
+ // общий styles.iuml иначе повторился бы в каждой диаграмме. Резолв — тот же, что у рендера.
243
+ export const generateLlmsFull = async (tree, options) => {
244
+ const MD = await composeComplete(tree, options, async (_item, diagram) => fenced(`${diagram.content}`, FENCE_LANG[diagram.ext]));
245
+ const deps = new Map();
246
+ for (const item of tree) {
247
+ for (const diagram of item.diagrams) {
248
+ const body = `${diagram.content}`;
249
+ if (diagram.engine === 'd2') {
250
+ for (const [abs, content] of d2LocalImports(path.join(item.dir, diagram.dir), body))
251
+ deps.set(abs, content);
252
+ }
253
+ else if (diagram.engine === 'plantuml') {
254
+ for (const inc of collectIncludes(body, item.dir, item.dir, new Set()))
255
+ deps.set(inc.abs, inc.content);
256
+ }
257
+ }
258
+ }
259
+ if (!deps.size)
260
+ return `${MD}\n`;
261
+ const files = [...deps]
262
+ .map(([abs, content]) => [path.relative(process.cwd(), abs).split(path.sep).join('/'), content])
263
+ .sort(([a], [b]) => (a < b ? -1 : 1));
264
+ const appendix = files
265
+ .map(([p, content]) => `\n\n### ${p}\n\n${fenced(content, FENCE_LANG[path.extname(p).toLowerCase()])}`)
266
+ .join('');
267
+ return `${MD}\n\n## Included files${appendix}\n`;
205
268
  };
206
269
  export const generateMD = async (tree, options, onProgress) => {
207
270
  let processedCount = 0;
@@ -253,13 +316,9 @@ export const generateWebMD = async (tree, options) => {
253
316
  const filePromises = [];
254
317
  let docsifySideBar = '';
255
318
  const getWebFileName = (originalFileName) => options.WEB_FILE_NAME || originalFileName;
256
- // Чистоту string[] гарантирует zod-схема (не-строки отброшены препроцессом).
257
- const isExcluded = (dir) => !!options.EXCLUDE_SIDEBAR_FOLDER_BY_PATH?.some((pathToExclude) => dir.startsWith(pathToExclude));
258
319
  for (const item of tree) {
259
- //sidebar — путь от ROOT_FOLDER (path.relative, а не отрезание первого сегмента:
260
- // то ломалось на многосегментном rootFolder из рукописного конфига)
261
- if (!isExcluded(item.dir)) {
262
- docsifySideBar += `${' '.repeat(item.level - 1)}* [${item.name}](${encodeURIPath(path.join(path.relative(options.ROOT_FOLDER, item.dir), getWebFileName(item.name)))})\n`;
320
+ if (inSidebar(item, options)) {
321
+ docsifySideBar += `${' '.repeat(item.level - 1)}* [${item.name}](${encodeURIPath(sitePagePath(item, options))})\n`;
263
322
  }
264
323
  const MD = await compilePage(item, options);
265
324
  //write to disk
@@ -271,8 +330,8 @@ export const generateWebMD = async (tree, options) => {
271
330
  const loaded = require(path.join(process.cwd(), options.DOCSIFY_TEMPLATE));
272
331
  const fn = typeof loaded === 'function' ? loaded : loaded?.default;
273
332
  if (typeof fn !== 'function') {
274
- throw new Error(`docsifyTemplate «${options.DOCSIFY_TEMPLATE}» должен экспортировать функцию ` +
275
- `(module.exports = fn или export default fn), получено: ${typeof fn}`);
333
+ throw new Error(`docsifyTemplate "${options.DOCSIFY_TEMPLATE}" must export a function ` +
334
+ `(module.exports = fn or export default fn), got: ${typeof fn}`);
276
335
  }
277
336
  docsifyTemplate = fn;
278
337
  }
@@ -281,7 +340,7 @@ export const generateWebMD = async (tree, options) => {
281
340
  const rootName = () => {
282
341
  const root = tree.find((item) => !item.parent);
283
342
  if (!root)
284
- throw new Error('docsify: корневой элемент дерева не найден');
343
+ throw new Error('docsify: tree root not found');
285
344
  return root.name;
286
345
  };
287
346
  //docsify homepage
@@ -296,13 +355,20 @@ export const generateWebMD = async (tree, options) => {
296
355
  },
297
356
  stylesheet: options.WEB_THEME,
298
357
  alias: { '/.*/_sidebar.md': '/_sidebar.md' },
358
+ nativeEmoji: true,
299
359
  supportSearch: options.SUPPORT_SEARCH,
300
360
  executeScript: options.EXECUTE_SCRIPT
301
361
  })));
302
362
  //copy local docsify vendor files to dist
303
363
  const docsifyVendorSrc = path.join(VENDOR_DIR, 'docsify');
304
364
  if (fs.existsSync(docsifyVendorSrc)) {
305
- filePromises.push(fsextra.copy(docsifyVendorSrc, path.join(options.DIST_FOLDER, 'vendor')));
365
+ const vendorDist = path.join(options.DIST_FOLDER, 'vendor');
366
+ // Тема — без ведущего @import Google Fonts (у её шрифтов есть системный fallback).
367
+ // Правим копию, а не vendor/: повторное вендорение вернуло бы @import.
368
+ const theme = path.join(vendorDist, 'vue.css');
369
+ filePromises.push(fsextra
370
+ .copy(docsifyVendorSrc, vendorDist)
371
+ .then(async () => writeFile(theme, (await readFile(theme)).toString().replace(/^@import url\([^)]*\);/, ''))));
306
372
  }
307
373
  //github pages preparation
308
374
  filePromises.push(writeFile(path.join(options.DIST_FOLDER, `.nojekyll`), ''));
@@ -5,7 +5,7 @@ const wrap = async (name, hook, fn) => {
5
5
  await fn();
6
6
  }
7
7
  catch (e) {
8
- throw new Error(`Плагин ${name} (${hook}): ${e.message ?? e}`, { cause: e });
8
+ throw new Error(`Plugin ${name} (${hook}): ${e.message ?? e}`, { cause: e });
9
9
  }
10
10
  };
11
11
  export const runAfterScan = async (tree, options, plugins) => {
@@ -70,14 +70,14 @@ const importPlugin = async (id, cwd) => {
70
70
  if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED')
71
71
  resolved = resolveEsmMain(id, require.resolve.paths(id) ?? []);
72
72
  if (!resolved)
73
- throw new Error(`не найден ни среди встроенных (${Object.keys(BUILTIN_PLUGINS).join(', ')}), ` +
74
- `ни как npm-пакет от ${cwd}: ${e.message}`);
73
+ throw new Error(`not found among built-ins (${Object.keys(BUILTIN_PLUGINS).join(', ')}) ` +
74
+ `nor as an npm package from ${cwd}: ${e.message}`);
75
75
  }
76
76
  mod = await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href));
77
77
  }
78
78
  const plugin = mod.default ?? mod;
79
79
  if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string')
80
- throw new Error('модуль должен экспортировать по умолчанию объект плагина с полем name');
80
+ throw new Error('module must default-export a plugin object with a name field');
81
81
  return plugin;
82
82
  };
83
83
  /**
@@ -101,14 +101,14 @@ export const loadPlugins = async (entries, cwd, options) => {
101
101
  if (plugin.options) {
102
102
  const parsed = plugin.options.safeParse(opts);
103
103
  if (!parsed.success) {
104
- const issues = parsed.error.issues.map((iss) => `${iss.path.length ? iss.path.join('.') : '(опции)'} — ${iss.message}`);
104
+ const issues = parsed.error.issues.map((iss) => `${iss.path.length ? iss.path.join('.') : '(options)'} — ${iss.message}`);
105
105
  throw new Error(`plugins[${i}] ${plugin.name}: ${issues.join('; ')}`);
106
106
  }
107
107
  opts = parsed.data;
108
108
  }
109
109
  if (plugin.requires?.executeScript && !options.EXECUTE_SCRIPT) {
110
110
  options.EXECUTE_SCRIPT = true;
111
- console.log(chalk.gray(`плагин ${plugin.name} требует executeScript — включён для этой сборки`));
111
+ console.log(chalk.gray(`plugin ${plugin.name} requires executeScript — enabled for this build`));
112
112
  }
113
113
  loaded.push({ plugin, opts });
114
114
  }
@@ -120,6 +120,6 @@ export const pluginWatchPaths = (plugins, cwd, exists) => plugins.flatMap(({ plu
120
120
  .filter((p) => {
121
121
  if (exists(p))
122
122
  return true;
123
- console.log(chalk.yellow(`плагин ${plugin.name}: путь наблюдения не существует — ${p}`));
123
+ console.log(chalk.yellow(`plugin ${plugin.name}: watch path does not exist — ${p}`));
124
124
  return false;
125
125
  }));
@@ -25,7 +25,7 @@ export const redactUrl = (url) => {
25
25
  // За корпоративным TLS-перехватом Node не доверяет подменённой цепочке: свой набор
26
26
  // корней он берёт не из системы и не из SSL_CERT_FILE — только NODE_EXTRA_CA_CERTS.
27
27
  export const tlsHint = (reason) => /certificate|self.signed|unable to (?:verify|get local issuer)/i.test(reason)
28
- ? ' — TLS-перехват? укажите путь к CA-бандлу в NODE_EXTRA_CA_CERTS'
28
+ ? ' — TLS interception? set the CA bundle path in NODE_EXTRA_CA_CERTS'
29
29
  : '';
30
30
  const downloadArchive = async (url, headers) => {
31
31
  const dir = path.join(os.tmpdir(), `c4builder-src-${crypto.createHash('sha1').update(url).digest('hex')}`);
@@ -39,7 +39,7 @@ const downloadArchive = async (url, headers) => {
39
39
  }
40
40
  catch (e) {
41
41
  const reason = e.message.replaceAll(url, redactUrl(url));
42
- throw new Error(`архив недоступен: ${reason}${tlsHint(reason)}`);
42
+ throw new Error(`archive unavailable: ${reason}${tlsHint(reason)}`);
43
43
  }
44
44
  fs.rmSync(stage, { recursive: true, force: true });
45
45
  fs.mkdirSync(stage, { recursive: true });
@@ -50,7 +50,7 @@ const downloadArchive = async (url, headers) => {
50
50
  else if (isGzipBuffer(body))
51
51
  await extractTarGz(archive, stage);
52
52
  else
53
- throw new Error(`неизвестный формат архива ${redactUrl(url)} (ожидается zip или tar.gz)`);
53
+ throw new Error(`unknown archive format ${redactUrl(url)} (expected zip or tar.gz)`);
54
54
  fs.rmSync(dir, { recursive: true, force: true });
55
55
  fs.renameSync(stage, dir);
56
56
  }
@@ -71,11 +71,11 @@ export const resolveSource = async (spec, cwd = process.cwd()) => {
71
71
  if (spec.dir !== undefined) {
72
72
  const abs = path.resolve(cwd, spec.dir, spec.subdir ?? '');
73
73
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory())
74
- throw new Error(`источник dir не найден: ${abs}`);
74
+ throw new Error(`dir source not found: ${abs}`);
75
75
  return abs;
76
76
  }
77
77
  if (spec.archive === undefined)
78
- throw new Error('источник должен задавать dir либо archive');
78
+ throw new Error('source must set dir or archive');
79
79
  const url = spec.archive;
80
80
  const headers = Object.fromEntries(Object.entries(spec.headers ?? {}).filter(([, v]) => v !== ''));
81
81
  let rootPromise = archiveCache.get(url);
@@ -87,6 +87,6 @@ export const resolveSource = async (spec, cwd = process.cwd()) => {
87
87
  const root = await rootPromise;
88
88
  const result = spec.subdir ? path.join(root, spec.subdir) : root;
89
89
  if (!fs.existsSync(result) || !fs.statSync(result).isDirectory())
90
- throw new Error(`в архиве ${redactUrl(url)} нет каталога ${spec.subdir ?? '.'} (распаковано в ${root})`);
90
+ throw new Error(`archive ${redactUrl(url)} has no folder ${spec.subdir ?? '.'} (extracted to ${root})`);
91
91
  return result;
92
92
  };
@@ -25,10 +25,10 @@ const subtreeEnd = (tree, parent) => {
25
25
  export const addPage = (tree, options, page) => {
26
26
  const segments = page.path;
27
27
  if (!segments.length || segments.some((s) => !s || /[\\/]/.test(s) || s === '.' || s === '..'))
28
- throw new Error(`addPage: некорректный путь страницы ${JSON.stringify(segments)}`);
28
+ throw new Error(`addPage: invalid page path ${JSON.stringify(segments)}`);
29
29
  const root = tree.find((x) => !x.parent);
30
30
  if (!root)
31
- throw new Error('addPage: в дереве нет корневого элемента');
31
+ throw new Error('addPage: tree has no root');
32
32
  let parent = root;
33
33
  let item = root;
34
34
  for (const [i, seg] of segments.entries()) {
@@ -38,9 +38,9 @@ export const addPage = (tree, options, page) => {
38
38
  if (existing) {
39
39
  if (last) {
40
40
  if (!VIRTUAL.has(existing))
41
- throw new Error(`addPage: путь ${segments.join('/')} занят реальной папкой ${dir} — переименуйте раздел плагина (mount)`);
41
+ throw new Error(`addPage: path ${segments.join('/')} is taken by a real folder ${dir} — rename the plugin section (mount)`);
42
42
  if (existing.mdFiles.length || existing.diagrams.length)
43
- throw new Error(`addPage: страница ${segments.join('/')} уже добавлена`);
43
+ throw new Error(`addPage: page ${segments.join('/')} already added`);
44
44
  }
45
45
  item = existing;
46
46
  }
@@ -70,7 +70,7 @@ export const addPage = (tree, options, page) => {
70
70
  const ext = path.extname(d.file).toLowerCase();
71
71
  const engine = engineForExt(ext);
72
72
  if (!engine)
73
- throw new Error(`addPage: у диаграммы "${d.file}" страницы ${segments.join('/')} неизвестное расширение`);
73
+ throw new Error(`addPage: diagram "${d.file}" of page ${segments.join('/')} has an unknown extension`);
74
74
  item.diagrams.push({
75
75
  dir: d.file,
76
76
  ext,