@alfacapital-tech/c4builder 0.4.0-rc.1 → 0.4.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD 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
 
@@ -286,6 +287,128 @@ By default this is disabled. If enabled, the output will no longer include the a
286
287
 
287
288
  The current entity structure is inserted after the header
288
289
 
290
+ ## Plugins
291
+
292
+ Plugins extend the build with content from outside `rootFolder` (virtual pages that go through
293
+ the regular pipeline: sidebar, search, `generateMD`, complete markdown, local diagram render)
294
+ and with site assets. They are listed in `.c4builder` under `plugins` — either `"name"` or
295
+ `["name", { ...options }]`:
296
+
297
+ ```json
298
+ {
299
+ "plugins": [
300
+ "openspec",
301
+ ["openapi", { "dir": "../contracts", "glob": "*/openapi.yaml" }]
302
+ ]
303
+ }
304
+ ```
305
+
306
+ A plugin identifier resolves in this order: a built-in name (`openspec`, `openapi`) → a local
307
+ ESM module (`./plugins/my.mjs`, `../shared/plugin.mjs`, absolute path) → an npm package resolved
308
+ from the project directory. Options are validated by the plugin's own zod schema **before** the
309
+ build starts (built-in plugins are strict — an unknown option key is an error); `${ENV_VAR}`
310
+ placeholders inside string options are replaced from the environment (unset → empty string) and
311
+ never printed (archive URLs appear in messages without their query string, so a token passed as
312
+ `?private_token=${TOKEN}` stays out of the logs). A malformed `plugins` entry always fails the
313
+ build (it is the one key the wizard cannot repair). Older c4builder versions ignore the `plugins`
314
+ key, so a rollback keeps building.
315
+
316
+ ### `openspec` — publish a local OpenSpec store
317
+
318
+ Renders an [OpenSpec](https://github.com/Fission-AI/OpenSpec) store as a site section:
319
+ a summary (counters, task progress), a page per active change, the specs tree and the archive.
320
+ A change page shows the header (schema, dates, task progress, links to subpages) plus the first
321
+ artifact of `artifacts` (by default `proposal` — the "why" is visible without a click); the
322
+ remaining artifacts (`design`, `tasks`, then any other `*.md` alphabetically) are subpages, and
323
+ spec deltas live under `specs/…` mirroring the folder layout (a folder that has its own
324
+ `spec.md` and nested specs gets one page: the spec followed by the list of nested ones).
325
+ ```` ```plantuml ```` / ```` ```d2 ```` fences inside artifacts, deltas and store specs are
326
+ rendered by the **local** engines (nothing is sent to plantuml.com); the image name is derived
327
+ from the fence content, so inserting or reordering fences never reuses a neighbour's cached
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
331
+ (images, notes) are copied next to the page (and to the output root for the complete markdown;
332
+ `excludeOtherFiles` applies). The store folder is watched in `-w`.
333
+
334
+ | Option | Default | Meaning |
335
+ |-------------|--------------------------------|----------------------------------------------------------------|
336
+ | `dir` | `openspec` | store folder (relative to the project directory) |
337
+ | `mount` | `OpenSpec` | section name (and its path in the output folder) |
338
+ | `artifacts` | `["proposal","design","tasks"]`| artifact order; the first one is shown on the change page itself |
339
+
340
+ ### `openapi` — swagger-ui pages for a set of OpenAPI specs
341
+
342
+ Finds specs by `glob` in a local folder (`dir`) **or** in a `tar.gz`/`zip` archive fetched over
343
+ HTTP (`archive`, e.g. a GitLab/GitHub repository archive URL; `subdir` picks a folder inside it,
344
+ `headers` adds request headers such as `{"PRIVATE-TOKEN": "${GITLAB_TOKEN}"}` — empty values are
345
+ dropped, so the same config works with and without a token). One page per spec, named after the
346
+ spec's parent folder (file name for specs in the source root), plus an index. All `yaml`/`yml`/
347
+ `json` files of the source are copied into `<mount>/_specs/…` keeping their relative layout, so
348
+ relative `$ref`s (including shared schema files that do not match `glob`) keep resolving; pages
349
+ load swagger-ui from the bundled `vendor/` files — no CDN, no access to the source repository is
350
+ needed by readers. The plugin forces `executeScript` (logged) and injects `swagger-ui.css` and
351
+ `swagger-ui-bundle.js` into `index.html` after the template is rendered (skipped when the template
352
+ already links the bundle), so custom `docsifyTemplate`s need no change. The output folder is never
353
+ treated as a source (`dir: "."` is fine). Archives are downloaded once per process (watch mode
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.
357
+
358
+ | Option | Default | Meaning |
359
+ |-----------|----------------------------------|------------------------------------------|
360
+ | `mount` | `API` | section name |
361
+ | `dir` | — | local folder with specs (watched in `-w`)|
362
+ | `archive` | — | HTTP(S) URL of a tar.gz/zip archive |
363
+ | `subdir` | — | folder inside the archive / `dir` |
364
+ | `headers` | — | HTTP headers for the archive request |
365
+ | `glob` | `**/openapi.{yaml,yml,json}` | spec pattern (`**`, `*`, `?`, `{a,b}`) |
366
+
367
+ Example for a repository that keeps its `openspec/` store next to the C4 sources and consumes
368
+ contracts from another repository:
369
+
370
+ ```json
371
+ {
372
+ "plugins": [
373
+ "openspec",
374
+ ["openapi", {
375
+ "archive": "https://gitlab.example.com/api/v4/projects/123/repository/archive.tar.gz?sha=main",
376
+ "glob": "*/openapi.yaml",
377
+ "headers": { "PRIVATE-TOKEN": "${GITLAB_TOKEN}" }
378
+ }]
379
+ ]
380
+ }
381
+ ```
382
+
383
+ ### Writing a plugin
384
+
385
+ A plugin is an ESM module whose default export is a plain object (use `definePlugin` from
386
+ `@alfacapital-tech/c4builder/dist/core/plugins/types.js` for typing):
387
+
388
+ ```js
389
+ import { z } from 'zod';
390
+ export default {
391
+ name: 'changelog',
392
+ options: z.object({ file: z.string().default('CHANGELOG.md') }).strict(), // optional
393
+ watchPaths: (opts) => [opts.file], // optional
394
+ assets: { styles: ['/abs/path/x.css'], scripts: ['/abs/path/x.js'] }, // optional
395
+ requires: { executeScript: true }, // optional
396
+ async afterScan(ctx, opts) {
397
+ // ctx.tree, ctx.options — the build tree/options; ctx.source({ dir } | { archive, subdir, headers })
398
+ // resolves a folder; ctx.addPage adds a virtual page (missing parents become index pages).
399
+ ctx.addPage({
400
+ path: ['Changelog'],
401
+ markdown: '## Latest\n\n![flow](flow.puml)',
402
+ diagrams: [{ file: 'flow.puml', content: '@startuml\nA -> B\n@enduml' }]
403
+ });
404
+ },
405
+ async afterBuild(ctx, opts) { /* ctx.distFolder, ctx.options — copy extra files, run checks */ }
406
+ };
407
+ ```
408
+
409
+ Hooks run in the order of the `plugins` list; an exception aborts the build with the plugin's
410
+ name (the previous `dist` backup is kept as usual).
411
+
289
412
  # Future plans
290
413
 
291
414
  I would like to bind the **build** phase to the C4 model. It could parse the diagrams and know what Person/System/Component/Container is related to what and help with additional validation. Maybe even export to [structurizr](https://structurizr.com/) or vice versa.
@@ -355,10 +478,26 @@ Publishes to dist-tag `latest`.
355
478
 
356
479
  ## Unreleased
357
480
 
481
+ - Plugin system: `plugins` key in `.c4builder`, `afterScan`/`afterBuild` hooks, virtual pages,
482
+ plugin assets injected into `index.html`, `dir`/`archive` source resolver (see «Plugins»)
483
+ - Built-in plugins: `openspec` (local OpenSpec store → site section, local diagram render) and
484
+ `openapi` (offline swagger-ui pages for a set of OpenAPI specs; `vendor/swagger-ui/swagger-ui.css` 5.32.1)
358
485
  - `c4builder check <file...>` — validate individual `.puml` / `.iuml` / `.d2` files with the
359
486
  bundled engines, without a project build (#13)
360
487
  - D2 compile errors are printed as readable `file:line:col: message` lines instead of raw JSON
361
488
  - Regression test for two adjacent `![..](x.puml)` embeds (#12 — the bug itself was fixed in 0.3.0)
489
+ - Diagram cache key now includes the output path: diagrams that swap contents (or fence blocks
490
+ that shift position) are re-rendered instead of silently reusing a neighbour's cached image.
491
+ The first build after upgrading re-renders everything once.
492
+ - Watch mode ignores the output folder, its backup and `.c4builder*`, and de-duplicates nested
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)
362
501
 
363
502
  ## v0.3.0
364
503
 
@@ -36,6 +36,10 @@ ${chalk.cyan('Replace diagrams with a link')}
36
36
  Replaces the diagram images with links.
37
37
  ${chalk.cyan('Include breadcrumbs')}
38
38
  Shows the original folder hierarchy after each title.
39
+ ${chalk.cyan('Plugins')} (${chalk.gray('.c4builder → "plugins", no wizard prompt')})
40
+ List of build plugins: "name" or ["name", { options }]. Built-in: openspec (local OpenSpec store →
41
+ site section), openapi (swagger-ui pages for OpenAPI specs from a folder or an HTTP archive).
42
+ See README «Plugins».
39
43
  ${chalk.cyan('Place diagrams before text')}
40
44
  Choose to place diagrams before or after the text.
41
45
  ${chalk.cyan('Exclude other files')}
@@ -73,6 +73,9 @@ Charset: ${currentConfiguration.CHARSET !== undefined
73
73
  Exclude other files: ${currentConfiguration.EXCLUDE_OTHER_FILES !== undefined
74
74
  ? chalk.green(currentConfiguration.EXCLUDE_OTHER_FILES)
75
75
  : chalk.red('not set')}
76
+ Plugins: ${currentConfiguration.PLUGINS?.length
77
+ ? chalk.green(currentConfiguration.PLUGINS.map((p) => (typeof p === 'string' ? p : p[0])).join(', '))
78
+ : chalk.gray('none')}
76
79
  `);
77
80
  // Битые ключи показаны выше как «not set» (щадящий разбор их выбросил); помечаем их
78
81
  // сырым значением и причиной, чтобы было видно, что и почему требует правки/визарда.
@@ -11,6 +11,8 @@ import cmdList from "./commands/list.js";
11
11
  import cmdSite from "./commands/site.js";
12
12
  import cmdCollect from "./wizard/collect.js";
13
13
  import { build } from "../core/build.js";
14
+ import { loadPlugins, pluginWatchPaths } from "../core/plugins/load.js";
15
+ import fs from 'node:fs';
14
16
  import watch from 'node-watch';
15
17
  import { EventEmitter } from 'node:events';
16
18
  import { clearConsole } from "../util/utils.js";
@@ -20,7 +22,7 @@ import { acquireBuildLock, BuildLockHeldError } from "../util/lock.js";
20
22
  // больше не будет (одиночный запуск).
21
23
  import { teardownD2 } from "../core/render/d2renderer.js";
22
24
  import { packageJson as pkg } from "../util/paths.js";
23
- import { parseConfig } from "../config/options.js";
25
+ import { outputDirs, parseConfig } from "../config/options.js";
24
26
  import { isValidPort } from "../config/schema.js";
25
27
  const intro = () => {
26
28
  console.log(chalk.blue(figlet.textSync('c4builder')));
@@ -94,7 +96,8 @@ function getOptions(c) {
94
96
  SUPPORT_SEARCH: c.supportSearch,
95
97
  EXECUTE_SCRIPT: c.executeScript,
96
98
  EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
97
- USE_SYSTEM_FONTS: c.useSystemFonts
99
+ USE_SYSTEM_FONTS: c.useSystemFonts,
100
+ PLUGINS: c.plugins
98
101
  };
99
102
  }
100
103
  export default async () => {
@@ -128,13 +131,17 @@ export default async () => {
128
131
  // Проверка отдельных диаграмм — тоже без конфига проекта.
129
132
  if (program.args[0] === 'check')
130
133
  return cmdCheck(program.args.slice(1));
134
+ // `c4builder site` — подсказка после сборки печатает именно эту форму; без
135
+ // этой строки allowExcessArguments молча глотал бы её и просто пересобирал.
136
+ if (program.args[0] === 'site')
137
+ opts.site = true;
131
138
  let conf = { get: () => { } };
132
139
  let cacheConf = { get: () => { }, set: () => { }, clear: () => { } };
133
140
  // Лок сборки — рядом с конфигом проекта (см. util/lock.ts). Заполняется вместе с conf.
134
141
  let lockPath = '';
142
+ const configPath = path.join(process.cwd(), opts.configFile ?? '.c4builder');
135
143
  if (!opts.new) {
136
144
  const projectKey = process.cwd().split(path.sep).splice(1).join('_');
137
- const configPath = path.join(process.cwd(), opts.configFile ?? '.c4builder');
138
145
  lockPath = `${configPath}.lock`;
139
146
  conf = new Configstore(projectKey, {}, { configPath });
140
147
  cacheConf = new Configstore(`${projectKey}_cache`, {}, { configPath: `${configPath}.cache` });
@@ -178,9 +185,11 @@ export default async () => {
178
185
  // Повторная сборка установленного проекта (есть HAS_RUN, не --config) — строгий путь:
179
186
  // битый конфиг обязан упасть с внятной ошибкой сразу, а не быть тихо «починен» визардом.
180
187
  // Первый запуск (!HAS_RUN) и --config остаются щадящими — это штатный путь восстановления.
188
+ // `plugins` визард не спрашивает — salvage молча переписал бы его в `[]` и сборка
189
+ // прошла бы без раздела плагина; битый `plugins` роняет всегда.
181
190
  const strictBuild = !opts.config && !!currentConfig.HAS_RUN;
182
191
  if (configIssues.length) {
183
- if (strictBuild)
192
+ if (strictBuild || configIssues.some((i) => i.key === 'plugins'))
184
193
  failOnConfigIssues(configIssues);
185
194
  warnConfigIssues(configIssues);
186
195
  // Битые ключи чиним физически (salvage-значение = дефолт схемы, либо удаление):
@@ -210,13 +219,30 @@ export default async () => {
210
219
  const options = getOptions(built.value);
211
220
  if (opts.systemFonts)
212
221
  options.USE_SYSTEM_FONTS = true; // флаг сильнее ключа конфига
222
+ // Плагины грузятся и валидируются до первой сборки: битые опции — exit 1 сразу.
223
+ const plugins = await loadPlugins(options.PLUGINS, process.cwd(), options).catch((e) => {
224
+ console.error(chalk.red(`Ошибка загрузки плагинов: ${e.message}`));
225
+ return process.exit(1);
226
+ });
213
227
  const reloadEmitter = new EventEmitter();
214
228
  reloadEmitter.setMaxListeners(0);
215
229
  if (opts.watch) {
216
230
  // node-watch: CJS-рантайм при ESM-.d.ts (export default) — дефолт-импорт
217
231
  // типизируется как namespace. Каст к реальной сигнатуре, рантайм не меняется.
218
232
  const watchDir = watch;
219
- watchDir(options.ROOT_FOLDER, { recursive: true }, async (_evt, name) => {
233
+ const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep);
234
+ // Пути наблюдения — абсолютные и без вложенных друг в друга: node-watch дедуплицирует
235
+ // события по имени, а один файл под двумя корнями приходил бы дважды.
236
+ const candidates = [
237
+ path.resolve(options.ROOT_FOLDER),
238
+ ...pluginWatchPaths(plugins, process.cwd(), fs.existsSync)
239
+ ];
240
+ const watchPaths = candidates.filter((p, i) => !candidates.some((q, j) => j !== i && inside(p, q) && (p !== q || j < i)));
241
+ // Собственные записи сборки (dist, dist_bk, .c4builder*) не должны триггерить
242
+ // ребилд — при dir плагина, накрывающем cwd, это бесконечная петля.
243
+ const output = outputDirs(options);
244
+ const filter = (name, skip) => output.some((d) => inside(name, d)) || name.startsWith(configPath) ? skip : true;
245
+ watchDir(watchPaths, { recursive: true, filter }, async (_evt, name) => {
220
246
  // clearConsole();
221
247
  // intro();
222
248
  console.log(chalk.gray(`\n${name} changed. Rebuilding...`));
@@ -233,10 +259,10 @@ export default async () => {
233
259
  // каталоге не потопчет dist_bk и не потеряет чексуммы кеша.
234
260
  const release = acquireBuildLock(lockPath);
235
261
  try {
236
- await build(options, cacheConf);
262
+ await build(options, cacheConf, plugins);
237
263
  while (attemptedWatchBuild) {
238
264
  attemptedWatchBuild = false;
239
- await build(options, cacheConf);
265
+ await build(options, cacheConf, plugins);
240
266
  }
241
267
  }
242
268
  finally {
@@ -273,7 +299,7 @@ export default async () => {
273
299
  process.exit(1);
274
300
  }
275
301
  try {
276
- await build(options, cacheConf);
302
+ await build(options, cacheConf, plugins);
277
303
  }
278
304
  finally {
279
305
  release();
@@ -4,7 +4,13 @@
4
4
  // Форма файла `.c4builder` (camelCase) — единый тип задаёт zod-схема (config/schema),
5
5
  // её же валидацией опирается загрузка опций сборки. Ре-экспорт: потребители типа
6
6
  // (defaults, dispatch) продолжают импортировать его отсюда.
7
+ import path from 'node:path';
7
8
  import { configSchema } from "./schema.js";
9
+ /** Один невалидный ключ `.c4builder`: имя ключа верхнего уровня, его сырое значение и причина. */
10
+ /** Суффикс бэкапа выходного каталога на время сборки (`docs` → `docs_bk`). */
11
+ export const DIST_BACKUP_FOLDER_SUFFIX = '_bk';
12
+ /** Выходные каталоги сборки (абсолютные): dist и его бэкап — их нельзя ни сканировать, ни наблюдать. */
13
+ export const outputDirs = (options) => [options.DIST_FOLDER, options.DIST_FOLDER + DIST_BACKUP_FOLDER_SUFFIX].map((d) => path.resolve(d));
8
14
  /**
9
15
  * Разбирает сырой объект `.c4builder`, разделяя строгий и щадящий пути. Сам НЕ печатает
10
16
  * и НЕ завершает процесс — решение (ошибка+выход либо предупреждение) принимает CLI.
@@ -61,6 +61,14 @@ export const configSchema = z.object({
61
61
  // путей не валит сборку) — runtime дальше работает с чистым string[].
62
62
  excludeSidebarFolderByPath: nullish(z.preprocess((v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : v), z.array(z.string()).optional())),
63
63
  hasRun: optBool(),
64
+ // Плагины сборки: `"имя"` либо `["имя", {опции}]`. Опции здесь не валидируются —
65
+ // это делает схема самого плагина при загрузке (core/plugins/load); верхний уровень
66
+ // проверяет лишь форму записи, чтобы ошибка называла `plugins` и позицию.
67
+ plugins: nullish(z
68
+ .array(z.union([z.string(), z.tuple([z.string(), z.record(z.string(), z.unknown())])], {
69
+ error: 'ожидается "имя" либо ["имя", {опции}]'
70
+ }))
71
+ .default([])),
64
72
  // Легаси-ключи старых `.c4builder` — читаются лишь для однократного предупреждения.
65
73
  plantumlVersion: optStr(),
66
74
  generatePDF: optBool(),
@@ -5,14 +5,18 @@ import { makeDirectory, writeOnSameLine } from "../util/utils.js";
5
5
  // clearD2FileCache: сброс кеша графа D2-импортов на границах сборки (сам webworker
6
6
  // D2 гасит CLI после одиночной сборки — в watch-режиме он переживает ребилды).
7
7
  import { clearD2FileCache } from "./render/d2renderer.js";
8
+ import { DIST_BACKUP_FOLDER_SUFFIX } from "../config/options.js";
8
9
  // Фазы сборки: scan (дерево исходников) → render (диаграммы) → compose (markdown/сайт).
9
10
  import { generateTree, engineSupportsRemote, clearIncludeCache } from "./scan/tree.js";
10
11
  import { generateImages } from "./render/diagrams.js";
11
12
  import { generateMD, generateWebMD, generateCompleteMD, clearDiagramCache } from "./compose/markdown.js";
13
+ // Плагины: хуки afterScan (виртуальные страницы) / afterBuild и ассеты сайта.
14
+ // Загружены заранее в cli/dispatch и приходят третьим аргументом (как cacheConf).
15
+ import { runAfterScan, runAfterBuild } from "./plugins/hooks.js";
16
+ import { injectPluginAssets } from "./plugins/assets.js";
12
17
  // Оркестратор владеет lifecycle выходного каталога: dist бэкапится в dist_bk, откуда
13
18
  // generateImages восстанавливает неизменённые по чексумме картинки; в конце бэкап сносится.
14
- const DIST_BACKUP_FOLDER_SUFFIX = '_bk';
15
- const build = async (options, cacheConf) => {
19
+ const build = async (options, cacheConf, plugins = []) => {
16
20
  const start_date = new Date();
17
21
  const bkFolderName = options.DIST_FOLDER + DIST_BACKUP_FOLDER_SUFFIX;
18
22
  // Generating local images, remove old backup image folder, rename current dist folder to new backup
@@ -38,6 +42,9 @@ const build = async (options, cacheConf) => {
38
42
  console.log(chalk.green(`\nbuilding documentation in ./${options.DIST_FOLDER}`));
39
43
  const tree = await generateTree(options.ROOT_FOLDER, options);
40
44
  console.log(chalk.blue(`parsed ${tree.length} folders`));
45
+ // Виртуальные страницы плагинов встают в дерево до рендера — дальше они
46
+ // неотличимы от реальных папок (диаграммы, sidebar, поиск, complete).
47
+ await runAfterScan(tree, options, plugins);
41
48
  // У движков без онлайн-рендера (D2, см. remoteRender в DIAGRAM_ENGINES) при
42
49
  // выключенной локальной генерации диаграммы не во что превратить — ссылки на
43
50
  // SVG вели бы в никуда. Падаем сразу с понятной ошибкой, а не молча битым выводом.
@@ -66,6 +73,7 @@ const build = async (options, cacheConf) => {
66
73
  if (options.GENERATE_WEBSITE) {
67
74
  console.log(chalk.blue('generating docsify site'));
68
75
  await generateWebMD(tree, options);
76
+ await injectPluginAssets(plugins, options);
69
77
  }
70
78
  if (options.GENERATE_COMPLETE_MD_FILE) {
71
79
  console.log(chalk.blue('generating complete markdown file'));
@@ -78,6 +86,7 @@ const build = async (options, cacheConf) => {
78
86
  console.log(chalk.bold(chalk.yellow('WARNING:')));
79
87
  console.log(chalk.yellow(`PDF-вывод больше не поддерживается. Удалите вручную из .c4builder: ${options.LEGACY_PDF_KEYS.join(', ')}.`));
80
88
  }
89
+ await runAfterBuild(options, plugins);
81
90
  console.log(chalk.green(`built in ${(Date.now() - start_date.getTime()) / 1000} seconds`));
82
91
  ok = true;
83
92
  }
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { encodeURIPath } from "../../util/utils.js";
4
+ // Вставка тегов в готовый HTML: стили — перед последним `</head>`, скрипты — перед
5
+ // последним `</body>` (регистронезависимо); тега нет — в конец файла (браузер исполнит
6
+ // и так). Пост-обработка не зависит от того, чей это шаблон (docsifyTemplate пользователя
7
+ // править не требуется). Экспортируется для юнит-теста.
8
+ export const injectHtml = (html, { styles, scripts }) => {
9
+ const before = (doc, closing, block) => {
10
+ if (!block)
11
+ return doc;
12
+ const m = [...doc.matchAll(closing)].pop();
13
+ return m?.index === undefined
14
+ ? `${doc}\n${block}`
15
+ : `${doc.slice(0, m.index)}${block}\n${doc.slice(m.index)}`;
16
+ };
17
+ const styleTags = styles.map((s) => `<link rel="stylesheet" href="${s}">`).join('\n');
18
+ const scriptTags = scripts.map((s) => `<script src="${s}"></script>`).join('\n');
19
+ return before(before(html, /<\/head>/gi, styleTags), /<\/body>/gi, scriptTags);
20
+ };
21
+ /**
22
+ * Копирует ассеты плагинов в `dist/vendor/plugins/<name>/` и подключает их в
23
+ * `dist/index.html`. Зовётся после generateWebMD (index.html уже записан).
24
+ */
25
+ export const injectPluginAssets = async (plugins, options) => {
26
+ const indexPath = path.join(options.DIST_FOLDER, 'index.html');
27
+ const html = fs.readFileSync(indexPath, 'utf8');
28
+ // Файл с тем же именем уже подключён шаблоном (дефолтный шаблон сам грузит
29
+ // vendor/swagger-ui-bundle.js) — второй раз не копируем и не подключаем.
30
+ const linked = (file) => new RegExp(`(?:src|href)=["'][^"']*\\b${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`).test(html);
31
+ const styles = [];
32
+ const scripts = [];
33
+ for (const { plugin } of plugins) {
34
+ if (!plugin.assets)
35
+ continue;
36
+ const rel = path.posix.join('vendor', 'plugins', plugin.name);
37
+ const destDir = path.join(options.DIST_FOLDER, 'vendor', 'plugins', plugin.name);
38
+ const copy = (files, out) => {
39
+ for (const file of files ?? []) {
40
+ if (linked(file))
41
+ continue;
42
+ fs.mkdirSync(destDir, { recursive: true });
43
+ fs.copyFileSync(file, path.join(destDir, path.basename(file)));
44
+ out.push(encodeURIPath(path.posix.join(rel, path.basename(file))));
45
+ }
46
+ };
47
+ copy(plugin.assets.styles, styles);
48
+ copy(plugin.assets.scripts, scripts);
49
+ }
50
+ if (!styles.length && !scripts.length)
51
+ return;
52
+ fs.writeFileSync(indexPath, injectHtml(html, { styles, scripts }));
53
+ };
@@ -0,0 +1,30 @@
1
+ import { resolveSource } from "./source.js";
2
+ import { addPage } from "./tree.js";
3
+ const wrap = async (name, hook, fn) => {
4
+ try {
5
+ await fn();
6
+ }
7
+ catch (e) {
8
+ throw new Error(`Плагин ${name} (${hook}): ${e.message ?? e}`, { cause: e });
9
+ }
10
+ };
11
+ export const runAfterScan = async (tree, options, plugins) => {
12
+ for (const { plugin, opts } of plugins) {
13
+ if (!plugin.afterScan)
14
+ continue;
15
+ const ctx = {
16
+ tree,
17
+ options,
18
+ addPage: (page) => addPage(tree, options, page),
19
+ source: resolveSource
20
+ };
21
+ await wrap(plugin.name, 'afterScan', () => plugin.afterScan?.(ctx, opts));
22
+ }
23
+ };
24
+ export const runAfterBuild = async (options, plugins) => {
25
+ for (const { plugin, opts } of plugins) {
26
+ if (!plugin.afterBuild)
27
+ continue;
28
+ await wrap(plugin.name, 'afterBuild', () => plugin.afterBuild?.({ distFolder: options.DIST_FOLDER, options }, opts));
29
+ }
30
+ };
@@ -0,0 +1,125 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createRequire } from 'node:module';
12
+ import { pathToFileURL } from 'node:url';
13
+ import chalk from 'chalk';
14
+ import { BUILTIN_PLUGINS } from "../../plugins/index.js";
15
+ // Подстановка `${NAME}` из окружения во всех строках опций (рекурсивно). Незаданная
16
+ // переменная → пустая строка: так `headers: { 'PRIVATE-TOKEN': '${GITLAB_TOKEN}' }`
17
+ // без токена даёт пустой заголовок, который резолвер источников отбрасывает.
18
+ export const expandEnv = (value) => {
19
+ if (typeof value === 'string')
20
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] ?? '');
21
+ if (Array.isArray(value))
22
+ return value.map(expandEnv);
23
+ if (value && typeof value === 'object')
24
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expandEnv(v)]));
25
+ return value;
26
+ };
27
+ const isPathId = (id) => id.startsWith('./') || id.startsWith('../') || path.isAbsolute(id);
28
+ // Главный ESM-вход пакета по его package.json: `exports` (вложенные условия
29
+ // import/default, subpath ".", массивы-фолбэки), иначе `module`/`main`. Покрывает
30
+ // типовые формы; экзотические условия — за пределами (`ponytail`).
31
+ const resolveEsmMain = (id, searchPaths) => {
32
+ const dir = searchPaths
33
+ .map((p) => path.join(p, id))
34
+ .find((d) => fs.existsSync(path.join(d, 'package.json')));
35
+ if (!dir)
36
+ return undefined;
37
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
38
+ const pick = (e) => {
39
+ if (typeof e === 'string')
40
+ return e;
41
+ if (Array.isArray(e))
42
+ return e.map(pick).find(Boolean);
43
+ if (!e || typeof e !== 'object')
44
+ return undefined;
45
+ const o = e;
46
+ return '.' in o ? pick(o['.']) : pick(o.import ?? o.default ?? o.node);
47
+ };
48
+ const main = pick(pkg.exports) ?? pkg.module ?? pkg.main;
49
+ return main ? path.join(dir, main) : undefined;
50
+ };
51
+ // Идентификатор → модуль плагина: встроенный по имени → путь от cwd → npm-пакет,
52
+ // резолвится от cwd проекта (а не от установки c4builder — глобальный бинарь иначе
53
+ // не видел бы локальные node_modules).
54
+ const importPlugin = async (id, cwd) => {
55
+ if (Object.hasOwn(BUILTIN_PLUGINS, id))
56
+ return BUILTIN_PLUGINS[id]();
57
+ let mod;
58
+ if (isPathId(id)) {
59
+ mod = await import(__rewriteRelativeImportExtension(pathToFileURL(path.resolve(cwd, id)).href));
60
+ }
61
+ else {
62
+ const require = createRequire(path.join(cwd, 'package.json'));
63
+ let resolved;
64
+ try {
65
+ resolved = require.resolve(id);
66
+ }
67
+ catch (e) {
68
+ // ESM-only пакет (`exports` лишь с условием import) CJS-резолвер не берёт —
69
+ // находим пакет в цепочке node_modules от cwd и читаем его exports сами.
70
+ if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED')
71
+ resolved = resolveEsmMain(id, require.resolve.paths(id) ?? []);
72
+ if (!resolved)
73
+ throw new Error(`не найден ни среди встроенных (${Object.keys(BUILTIN_PLUGINS).join(', ')}), ` +
74
+ `ни как npm-пакет от ${cwd}: ${e.message}`);
75
+ }
76
+ mod = await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href));
77
+ }
78
+ const plugin = mod.default ?? mod;
79
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string')
80
+ throw new Error('модуль должен экспортировать по умолчанию объект плагина с полем name');
81
+ return plugin;
82
+ };
83
+ /**
84
+ * Загружает плагины из `plugins` конфига: резолв, импорт, подстановка окружения и
85
+ * валидация опций схемой плагина. Fail-fast — любая ошибка прерывает до старта сборки
86
+ * с указанием позиции записи и имени плагина. `requires.executeScript` включает
87
+ * `EXECUTE_SCRIPT` в опциях сборки (с одной строкой в лог, если ключ был выключен).
88
+ */
89
+ export const loadPlugins = async (entries, cwd, options) => {
90
+ const loaded = [];
91
+ for (const [i, entry] of entries.entries()) {
92
+ const [id, rawOpts] = typeof entry === 'string' ? [entry, {}] : entry;
93
+ let plugin;
94
+ try {
95
+ plugin = await importPlugin(id, cwd);
96
+ }
97
+ catch (e) {
98
+ throw new Error(`plugins[${i}] "${id}": ${e.message}`);
99
+ }
100
+ let opts = expandEnv(rawOpts);
101
+ if (plugin.options) {
102
+ const parsed = plugin.options.safeParse(opts);
103
+ if (!parsed.success) {
104
+ const issues = parsed.error.issues.map((iss) => `${iss.path.length ? iss.path.join('.') : '(опции)'} — ${iss.message}`);
105
+ throw new Error(`plugins[${i}] ${plugin.name}: ${issues.join('; ')}`);
106
+ }
107
+ opts = parsed.data;
108
+ }
109
+ if (plugin.requires?.executeScript && !options.EXECUTE_SCRIPT) {
110
+ options.EXECUTE_SCRIPT = true;
111
+ console.log(chalk.gray(`плагин ${plugin.name} требует executeScript — включён для этой сборки`));
112
+ }
113
+ loaded.push({ plugin, opts });
114
+ }
115
+ return loaded;
116
+ };
117
+ /** Существующие пути наблюдения всех плагинов (абсолютные); несуществующие — предупреждение. */
118
+ export const pluginWatchPaths = (plugins, cwd, exists) => plugins.flatMap(({ plugin, opts }) => (plugin.watchPaths?.(opts) ?? [])
119
+ .map((p) => path.resolve(cwd, p))
120
+ .filter((p) => {
121
+ if (exists(p))
122
+ return true;
123
+ console.log(chalk.yellow(`плагин ${plugin.name}: путь наблюдения не существует — ${p}`));
124
+ return false;
125
+ }));