@alfacapital-tech/c4builder 0.4.0-rc.2 → 0.4.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD CHANGED
@@ -92,7 +92,8 @@ Options:
92
92
  ```
93
93
 
94
94
  There is also a `jre` subcommand: `c4builder jre install [--force]` pre-downloads the
95
- private JRE cache, `c4builder jre info` prints which Java would be used.
95
+ private JRE cache, `c4builder jre info` prints which Java would be used. `c4builder site`
96
+ is accepted as a positional subcommand too, equivalent to `--site`.
96
97
 
97
98
  ### Validating single diagrams
98
99
 
@@ -324,7 +325,9 @@ spec deltas live under `specs/…` mirroring the folder layout (a folder that ha
324
325
  ```` ```plantuml ```` / ```` ```d2 ```` fences inside artifacts, deltas and store specs are
325
326
  rendered by the **local** engines (nothing is sent to plantuml.com); the image name is derived
326
327
  from the fence content, so inserting or reordering fences never reuses a neighbour's cached
327
- image. Relative links between artifacts of a change point to their pages, referenced files
328
+ image. A block that fails to render does not fail the build (these blocks used to be rendered by
329
+ the browser, which showed an error image): the page gets a placeholder image and the build prints
330
+ a warning naming the artifact file. Relative links between artifacts of a change point to their pages, referenced files
328
331
  (images, notes) are copied next to the page (and to the output root for the complete markdown;
329
332
  `excludeOtherFiles` applies). The store folder is watched in `-w`.
330
333
 
@@ -348,7 +351,9 @@ needed by readers. The plugin forces `executeScript` (logged) and injects `swagg
348
351
  `swagger-ui-bundle.js` into `index.html` after the template is rendered (skipped when the template
349
352
  already links the bundle), so custom `docsifyTemplate`s need no change. The output folder is never
350
353
  treated as a source (`dir: "."` is fine). Archives are downloaded once per process (watch mode
351
- reuses them).
354
+ reuses them). Behind a TLS-intercepting proxy the download fails with `self-signed certificate in
355
+ certificate chain` — point `NODE_EXTRA_CA_CERTS` at the CA bundle (Node reads neither the system
356
+ store nor `SSL_CERT_FILE`); the error message says so as well.
352
357
 
353
358
  | Option | Default | Meaning |
354
359
  |-----------|----------------------------------|------------------------------------------|
@@ -486,6 +491,16 @@ Publishes to dist-tag `latest`.
486
491
  The first build after upgrading re-renders everything once.
487
492
  - Watch mode ignores the output folder, its backup and `.c4builder*`, and de-duplicates nested
488
493
  watch roots (a plugin `dir` covering the project no longer loops the rebuild)
494
+ - A broken ```` ```plantuml ````/```` ```d2 ```` block inside an OpenSpec artifact no longer fails
495
+ the build: the page gets a placeholder image and the warning names the source file
496
+ - Diagram errors name the file the diagram came from (fence names are content hashes)
497
+ - `{a,b}` glob alternatives accept `*`/`?` inside them (`{*.yaml,*.yml}`)
498
+ - Archive download failures caused by a TLS-intercepting proxy suggest `NODE_EXTRA_CA_CERTS`
499
+ - `c4builder site` works as a positional subcommand, the same as `--site` (it is the form the
500
+ post-build hint prints)
501
+ - PlantUML updated to 1.2026.8: filled arrowheads for `>>` decorations in Smetana layout (#14).
502
+ Note: `!pragma layout elk` now requires Java 21
503
+ - Security: runtime dependencies bumped (figlet, tar, qs) to fix npm audit advisories
489
504
 
490
505
  ## v0.3.0
491
506
 
@@ -131,6 +131,10 @@ export default async () => {
131
131
  // Проверка отдельных диаграмм — тоже без конфига проекта.
132
132
  if (program.args[0] === 'check')
133
133
  return cmdCheck(program.args.slice(1));
134
+ // `c4builder site` — подсказка после сборки печатает именно эту форму; без
135
+ // этой строки allowExcessArguments молча глотал бы её и просто пересобирал.
136
+ if (program.args[0] === 'site')
137
+ opts.site = true;
134
138
  let conf = { get: () => { } };
135
139
  let cacheConf = { get: () => { }, set: () => { }, clear: () => { } };
136
140
  // Лок сборки — рядом с конфигом проекта (см. util/lock.ts). Заполняется вместе с conf.
@@ -22,6 +22,11 @@ export const redactUrl = (url) => {
22
22
  return url;
23
23
  }
24
24
  };
25
+ // За корпоративным TLS-перехватом Node не доверяет подменённой цепочке: свой набор
26
+ // корней он берёт не из системы и не из SSL_CERT_FILE — только NODE_EXTRA_CA_CERTS.
27
+ export const tlsHint = (reason) => /certificate|self.signed|unable to (?:verify|get local issuer)/i.test(reason)
28
+ ? ' — TLS-перехват? укажите путь к CA-бандлу в NODE_EXTRA_CA_CERTS'
29
+ : '';
25
30
  const downloadArchive = async (url, headers) => {
26
31
  const dir = path.join(os.tmpdir(), `c4builder-src-${crypto.createHash('sha1').update(url).digest('hex')}`);
27
32
  // Распаковываем в свой staging и заменяем кэш атомарным rename — параллельная
@@ -33,7 +38,8 @@ const downloadArchive = async (url, headers) => {
33
38
  body = await httpGetBuffer(url, { headers });
34
39
  }
35
40
  catch (e) {
36
- throw new Error(`архив недоступен: ${e.message.replaceAll(url, redactUrl(url))}`);
41
+ const reason = e.message.replaceAll(url, redactUrl(url));
42
+ throw new Error(`архив недоступен: ${reason}${tlsHint(reason)}`);
37
43
  }
38
44
  fs.rmSync(stage, { recursive: true, force: true });
39
45
  fs.mkdirSync(stage, { recursive: true });
@@ -76,7 +76,9 @@ export const addPage = (tree, options, page) => {
76
76
  ext,
77
77
  engine,
78
78
  content: d.content,
79
- isDitaa: engine === 'plantuml' && /@startditaa/i.test(d.content)
79
+ isDitaa: engine === 'plantuml' && /@startditaa/i.test(d.content),
80
+ source: d.source,
81
+ soft: d.soft
80
82
  });
81
83
  }
82
84
  return item;
@@ -188,6 +188,22 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
188
188
  // bkFolderName — каталог бэкапа dist, откуда восстанавливаются неизменённые картинки
189
189
  // по чексумме. Его lifecycle (создание/удаление) держит оркестратор build(); сюда
190
190
  // путь приходит явным параметром, а не пересчитывается из общей константы.
191
+ // Картинка-заглушка вместо упавшей «мягкой» диаграммы: ошибка видна на странице,
192
+ // сайт остаётся собранным (так же вёл себя онлайн-рендер docsify-plantuml).
193
+ const errorPlaceholderSvg = (name, reason) => {
194
+ const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
195
+ const lines = [`${name}: диаграмма не отрендерена`, ...reason.split('\n')]
196
+ .flatMap((l) => l.match(/.{1,88}/g) ?? [''])
197
+ .slice(0, 8);
198
+ const w = 720;
199
+ const h = 34 + lines.length * 20;
200
+ const text = lines
201
+ .map((l, i) => `<text x="16" y="${34 + i * 20}" font-size="14">${esc(l)}</text>`)
202
+ .join('');
203
+ return (`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
204
+ `font-family="${DEFAULT_FONT_NAME}" fill="#8b0000">` +
205
+ `<rect x="1" y="1" width="${w - 2}" height="${h - 2}" fill="#fff5f5" stroke="#8b0000"/>${text}</svg>`);
206
+ };
191
207
  export const generateImages = async (tree, options, onImageGenerated, cacheConf, bkFolderName) => {
192
208
  // Чексуммы прошлого прогона — в Set: попадание проверяется O(1) на диаграмму
193
209
  // (было O(N) find → O(N²) на всё дерево).
@@ -298,6 +314,10 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
298
314
  const diagramContent = diagram.content;
299
315
  const includePath = item.dir;
300
316
  const isDitaa = diagram.isDitaa;
317
+ // Имя картинки диаграммы виртуальной страницы производно от контента —
318
+ // без исходного файла в сообщении его не найти.
319
+ const source = diagram.source;
320
+ const soft = diagram.soft === true;
301
321
  const task = withProgress(async () => {
302
322
  // render diagram to image: D2 через WASM, PlantUML — прямым вызовом java.
303
323
  // Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
@@ -328,7 +348,15 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
328
348
  }
329
349
  catch (err) {
330
350
  // Имя диаграммы в ошибку: renderDiagram/renderD2 сами его не знают.
331
- throw new Error(`Диаграмма "${outName}": ${err.message || err}`);
351
+ const reason = `${err.message || err}`;
352
+ const msg = `Диаграмма "${outName}"${source ? ` (${source})` : ''}: ${reason}`;
353
+ if (!soft)
354
+ throw new Error(msg);
355
+ // Диаграммы из markdown-блоков плагинов писались под онлайн-рендер,
356
+ // где битый блок давал картинку с ошибкой, а не падение сборки.
357
+ console.log(chalk.yellow(`⚠ ${msg}`));
358
+ const svg = Buffer.from(errorPlaceholderSvg(outName, reason), 'utf-8');
359
+ await writeFile(filePath, outFormat === 'png' ? await rasterizeSvgToPng(svg, options.USE_SYSTEM_FONTS) : svg);
332
360
  }
333
361
  });
334
362
  if (entryPath !== null)
@@ -26,7 +26,7 @@ export const mapOutsideFences = (md, fn) => {
26
26
  export const createFenceExtractor = () => {
27
27
  const seen = new Set();
28
28
  return {
29
- extract(md, base) {
29
+ extract(md, base, source) {
30
30
  const diagrams = [];
31
31
  const markdown = md.replace(FENCE_RE, (whole, indent, _fence, info, body) => {
32
32
  const lang = info.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
@@ -45,7 +45,10 @@ export const createFenceExtractor = () => {
45
45
  const file = `${base}-${hash}${ext}`;
46
46
  if (!seen.has(file)) {
47
47
  seen.add(file);
48
- diagrams.push({ file, content });
48
+ // soft: блок в артефакте — не файл проекта; синтаксическая
49
+ // ошибка в нём не должна ронять весь сайт (раньше такие блоки
50
+ // рендерил браузер и показывал картинку с ошибкой).
51
+ diagrams.push({ file, content, source, soft: true });
49
52
  }
50
53
  return `${indent}![${file}](${file})`;
51
54
  });
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { definePlugin } from "../../core/plugins/types.js";
7
7
  import { scanStore } from "./scan.js";
8
- import { renderArchiveIndex, renderChange, renderChangesIndex, renderMarkdown, renderSpecTree, renderSummary } from "./render.js";
8
+ import { renderArchiveIndex, renderChange, renderChangesIndex, relSource, renderMarkdown, renderSpecTree, renderSummary } from "./render.js";
9
9
  const optionsSchema = z
10
10
  .object({
11
11
  dir: z.string().default('openspec'),
@@ -61,6 +61,7 @@ export default definePlugin({
61
61
  addPages(renderSpecTree(store.specs, [mount, 'Specs'], options, (pagePath, spec) => renderMarkdown(pagePath, `spec-${spec.path.join('-')}`, spec.content, {
62
62
  srcRoot: specsDir,
63
63
  fromDir: spec.path.join('/'),
64
+ source: relSource(specsDir, `${spec.path.join('/')}/spec.md`),
64
65
  options
65
66
  })), specsDir);
66
67
  ctx.addPage({
@@ -78,6 +78,8 @@ const ignoreTitle = (title) => {
78
78
  const t = title.trim().replace(/^(["'])(.*)\1$/s, '$2');
79
79
  return ` ':ignore${t ? ` ${t}` : ''}'`;
80
80
  };
81
+ /** Путь файла для сообщений — от cwd, чтобы его можно было открыть как есть. */
82
+ export const relSource = (root, rel) => path.relative(process.cwd(), path.join(root, rel)).split(path.sep).join('/');
81
83
  /**
82
84
  * Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
83
85
  * на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
@@ -85,8 +87,8 @@ const ignoreTitle = (title) => {
85
87
  * странице (картинки docsify грузит относительно страницы, обычные ссылки — от корня сайта).
86
88
  * `fromDir` — posix-каталог файла относительно `srcRoot` (ссылки в нём резолвятся от файла).
87
89
  */
88
- export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, options }) => {
89
- const { markdown, diagrams } = createFenceExtractor().extract(content, base);
90
+ export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, source, options }) => {
91
+ const { markdown, diagrams } = createFenceExtractor().extract(content, base, source);
90
92
  const files = [];
91
93
  const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
92
94
  const inner = text.includes('![') ? rewrite(text) : text;
@@ -172,7 +174,13 @@ export const renderChange = (change, segments, order, options) => {
172
174
  const delta = deltas.find((d) => `specs/${d.path.join('/')}/spec.md` === rel);
173
175
  return delta && [...specsBase, ...delta.path];
174
176
  };
175
- const render = (pagePath, base, content, fromDir = '') => renderMarkdown(pagePath, base, content, { srcRoot: change.dir, fromDir, target, options });
177
+ const render = (pagePath, base, content, fromDir = '', file = `${base}.md`) => renderMarkdown(pagePath, base, content, {
178
+ srcRoot: change.dir,
179
+ fromDir,
180
+ target,
181
+ source: relSource(change.dir, path.posix.join(fromDir, file)),
182
+ options
183
+ });
176
184
  const subpages = ordered
177
185
  .filter((a) => a !== inline)
178
186
  .map((a) => render([...segments, a.name], a.name, a.content));
@@ -201,7 +209,7 @@ export const renderChange = (change, segments, order, options) => {
201
209
  ...subpages
202
210
  ];
203
211
  if (deltas.length) {
204
- const tree = renderSpecTree(deltas, specsBase, options, (pagePath, spec) => render(pagePath, `spec-${spec.path.join('-')}`, spec.content, `specs/${spec.path.join('/')}`));
212
+ const tree = renderSpecTree(deltas, specsBase, options, (pagePath, spec) => render(pagePath, `spec-${spec.path.join('-')}`, spec.content, `specs/${spec.path.join('/')}`, 'spec.md'));
205
213
  // Артефакт specs.md и корень дельт — один путь: сливаем в страницу артефакта.
206
214
  const artifact = pages.find((p) => p.path.length === segments.length + 1 && p.path.at(-1) === 'specs');
207
215
  for (const p of tree) {
package/dist/util/glob.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // `**/openapi.{yaml,yml,json}`; `fs.glob` появился только в Node 22, engines — ≥20.19.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- export const globToRegExp = (glob) => {
6
+ const convert = (glob) => {
7
7
  let re = '';
8
8
  for (let i = 0; i < glob.length; i++) {
9
9
  const c = glob[i];
@@ -28,10 +28,12 @@ export const globToRegExp = (glob) => {
28
28
  if (end === -1)
29
29
  re += '\\{';
30
30
  else {
31
+ // Альтернативы — тем же конвертером: `{*.yaml,*.yml}` должен работать
32
+ // как glob, а не как буквальная звёздочка.
31
33
  re += `(?:${glob
32
34
  .slice(i + 1, end)
33
35
  .split(',')
34
- .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
36
+ .map(convert)
35
37
  .join('|')})`;
36
38
  i = end;
37
39
  }
@@ -39,8 +41,9 @@ export const globToRegExp = (glob) => {
39
41
  else
40
42
  re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
43
  }
42
- return new RegExp(`^${re}$`);
44
+ return re;
43
45
  };
46
+ export const globToRegExp = (glob) => new RegExp(`^${convert(glob)}$`);
44
47
  /**
45
48
  * Файлы под root, чьи относительные posix-пути совпадают с glob (сортировано).
46
49
  * `.git`/`node_modules` и абсолютные каталоги `skipDirs` не обходятся.
@@ -107,7 +107,7 @@ const clearConsole = () => {
107
107
  // рендер (Smetana) не зависит от версии, старые JAR удалены (см. change
108
108
  // remove-plantuml-version). Одна точка правды для имени и версии JAR.
109
109
  const VENDORED_JAR = {
110
- version: '1.2025.2',
111
- jar: 'plantuml-1.2025.2.jar'
110
+ version: '1.2026.8',
111
+ jar: 'plantuml-1.2026.8.jar'
112
112
  };
113
113
  export { makeDirectory, readFile, writeFile, encodeURIPath, writeOnSameLine, clearConsole, plantUmlServerUrl, VENDORED_JAR };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfacapital-tech/c4builder",
3
- "version": "0.4.0-rc.2",
3
+ "version": "0.4.0-rc.4",
4
4
  "description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -61,12 +61,12 @@
61
61
  "commander": "^15.0.0",
62
62
  "configstore": "^7.1.0",
63
63
  "express": "^5.2.1",
64
- "figlet": "^1.11.0",
64
+ "figlet": "^1.11.3",
65
65
  "fs-extra": "^11.3.6",
66
66
  "inquirer": "^12.11.1",
67
67
  "node-watch": "^0.7.4",
68
68
  "open": "^10.2.0",
69
- "tar": "7.5.19",
69
+ "tar": "7.5.22",
70
70
  "yauzl": "3.4.0",
71
71
  "zod": "^4.4.3"
72
72
  },
index 2a49035..054b2d9 100644
Binary file