@alfacapital-tech/c4builder 0.3.0-rc.1 → 0.3.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
@@ -42,6 +42,11 @@ With `diagramFormat: png`, diagrams are produced by rasterizing their SVG with
42
42
  native binary, no browser). This gives a single deterministic PNG for both PlantUML and
43
43
  D2, using the bundled font. ditaa diagrams stay native PlantUML PNG.
44
44
 
45
+ **Fonts.** Diagrams use the bundled Nimbus Sans (Helvetica metrics, Cyrillic and Greek —
46
+ see `vendor/fonts/README.md`), so diagram geometry is identical on any machine.
47
+ `useSystemFonts: true` in `.c4builder` (or `--system-fonts` for one run) renders with the
48
+ machine's fonts instead — machine-dependent by design.
49
+
45
50
  The easiest way to get started is with the _new project_ template.
46
51
 
47
52
  ![new](docs/images/new.gif)
@@ -110,7 +115,7 @@ Following the C4 model, folders inside the **source** represent Systems/Containe
110
115
  class.1.md
111
116
  ```
112
117
 
113
- The **destination** folder is automatically generated by the **build** process and can have various formats. The folder can provide a drill down style view of the Systems/Containers/Components in different ways. Either by pushing it as a site to gitpages, using it as a navigable markdown directly in the repository or by generating pdfs and using the traditional mail.
118
+ The **destination** folder is automatically generated by the **build** process and can have various formats. The folder can provide a drill down style view of the Systems/Containers/Components in different ways. Either by pushing it as a site to gitpages or using it as a navigable markdown directly in the repository.
114
119
 
115
120
  ## The build
116
121
 
@@ -210,18 +215,6 @@ The generated markdown file makes for an easy single page readme for the reposit
210
215
 
211
216
  ![singlemd](docs/images/single-md.jpg)
212
217
 
213
- ### Collection of pdf files each describing one entity
214
-
215
- A fast way to pass around information regarding only one particular System/Container/Component
216
-
217
- ![pdf](docs/images/pdf.jpg)
218
-
219
- ### Single pdf file with a table of contents
220
-
221
- The easiest format to share the full documentation
222
-
223
- ![pdffull](docs/images/full-pdf.jpg)
224
-
225
218
  ### Docsify site with a sidebar
226
219
 
227
220
  After choosing to generate a docsify site, it can be viewed locally by running `c4builder site` and accessing `http://localhost:3000`;
@@ -232,7 +225,7 @@ The sidebar is generated manually so that it only contains the main entities def
232
225
 
233
226
  ![site](docs/images/site.jpg)
234
227
 
235
- By going to the github repository settings and enabling GitHub pages the site be pushed with ease and provide an overview to all people involved in the project without having to worry about which version of pdf file got sent when.
228
+ By going to the github repository settings and enabling GitHub pages the site be pushed with ease and provide an overview to all people involved in the project.
236
229
 
237
230
  ![github](docs/images/githubpages.gif)
238
231
 
@@ -256,10 +249,6 @@ By default this is disabled. If enabled, the output will no longer include the a
256
249
 
257
250
  The current entity structure is inserted after the header
258
251
 
259
- ### Custom pdf
260
-
261
- If defined, this css is used in the generation of the pdf files.
262
-
263
252
  # Future plans
264
253
 
265
254
  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.
@@ -300,6 +289,10 @@ Publishes to dist-tag `latest`.
300
289
 
301
290
  # Change log
302
291
 
292
+ ## v0.3.0
293
+
294
+ PDF output removed — use the docsify site or the single/collection markdown outputs instead (the `pdf`/`pdfCss` options no longer exist).
295
+
303
296
  ## v0.2.20
304
297
 
305
298
  Fork by [Alfa Capital Technologies](https://alfacapital.tech)
@@ -30,8 +30,6 @@ ${chalk.cyan('Generate website')}
30
30
  Uses docsify to generate a website with a sidebar for navigation. The site can be easily deployed to github pages.
31
31
  ${chalk.cyan('Website docsify theme')}
32
32
  Changes the default theme of the generated docsify website
33
- ${chalk.cyan('PlantUML version')}
34
- Uses the specific plantuml version jar file for generating images.
35
33
  ${chalk.cyan('Generate diagram images locally')}
36
34
  Uses the localy installed plantuml package to build svg images for each diagram.
37
35
  ${chalk.cyan('Replace diagrams with a link')}
@@ -1,13 +1,20 @@
1
1
  import chalk from 'chalk';
2
- import { resolveJava, detectSystemJava, TEMURIN_FEATURE } from "../../core/render/jre.js";
3
- // Прогрев кеша: `c4builder jre install [--force]`. Резолвит/скачивает JRE заранее
2
+ import { resolveJava, detectSystemJava, TEMURIN_FEATURE, JRE_CACHE_SCHEMA, jreCacheDir } from "../../core/render/jre.js";
3
+ // `c4builder jre install [--force]` — прогрев кеша: резолвит/скачивает JRE заранее
4
4
  // (CI/офлайн) без полной сборки. При годной системной java по умолчанию сообщает,
5
5
  // что скачивание не требуется; `--force` форсирует загрузку Temurin в кеш.
6
+ // `c4builder jre info` — машиночитаемые параметры managed-JRE (JSON): путь кеша и
7
+ // материал ключа для внешних кешей (actions/cache в CI) из единственного источника
8
+ // истины, вместо grep констант по исходникам и хардкода пути.
6
9
  export default async (args = [], { force = false } = {}) => {
7
10
  const action = args[0];
11
+ if (action === 'info') {
12
+ console.log(JSON.stringify({ feature: TEMURIN_FEATURE, schema: JRE_CACHE_SCHEMA, cacheDir: jreCacheDir() }));
13
+ return;
14
+ }
8
15
  if (action !== 'install') {
9
16
  console.log(chalk.red(`неизвестная подкоманда: c4builder jre ${action || ''}`.trim()));
10
- console.log(chalk.gray('доступно: c4builder jre install [--force]'));
17
+ console.log(chalk.gray('доступно: c4builder jre install [--force] | jre info'));
11
18
  process.exit(1);
12
19
  }
13
20
  try {
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- export default (currentConfiguration) => {
2
+ export default (currentConfiguration, issues = []) => {
3
3
  console.log(`
4
4
  CURRENT CONFIGURATION
5
5
 
@@ -74,5 +74,13 @@ Exclude other files: ${currentConfiguration.EXCLUDE_OTHER_FILES !== undefined
74
74
  ? chalk.green(currentConfiguration.EXCLUDE_OTHER_FILES)
75
75
  : chalk.red('not set')}
76
76
  `);
77
+ // Битые ключи показаны выше как «not set» (щадящий разбор их выбросил); помечаем их
78
+ // сырым значением и причиной, чтобы было видно, что и почему требует правки/визарда.
79
+ if (issues.length) {
80
+ console.log(chalk.yellow('⚠ Невалидные ключи в .c4builder (показаны как «not set», чините через `c4builder --config`):'));
81
+ for (const { key, value, message } of issues)
82
+ console.log(chalk.yellow(` ⚠ ${key}: ${JSON.stringify(value)} — ${message}`));
83
+ console.log('');
84
+ }
77
85
  return;
78
86
  };
@@ -15,37 +15,18 @@ const validateProjectName = (name) => {
15
15
  if (name.indexOf('/') !== -1 || name.indexOf('\\') !== -1)
16
16
  return 'имя проекта не должно содержать «/» или «\\»';
17
17
  const target = path.join(process.cwd(), name);
18
- if (fs.existsSync(target) && fs.readdirSync(target).length > 0)
19
- return `папка «${name}» уже существует и не пуста`;
18
+ if (fs.existsSync(target)) {
19
+ // readdirSync по файлу бросает ENOTDIR — сперва различаем файл и каталог.
20
+ if (!fs.statSync(target).isDirectory())
21
+ return `«${name}» уже существует и является файлом`;
22
+ if (fs.readdirSync(target).length > 0)
23
+ return `папка «${name}» уже существует и не пуста`;
24
+ }
20
25
  return null;
21
26
  };
22
- const generateTemplate = async (dir, projectName) => {
23
- const build = async (dir, _parent) => {
24
- const files = fs.readdirSync(dir);
25
- for (const file of files) {
26
- if (fs.statSync(path.join(dir, file)).isDirectory()) {
27
- await makeDirectory(path.join(process.cwd(), projectName, dir.replace(TEMPLATE_DIR, ''), file));
28
- await build(path.join(dir, file), dir);
29
- }
30
- }
31
- const mdFiles = files.filter((x) => path.extname(x).toLowerCase() === '.md');
32
- for (const mdFile of mdFiles) {
33
- await fsextra.copy(path.join(dir, mdFile), path.join(process.cwd(), projectName, dir.replace(TEMPLATE_DIR, ''), mdFile));
34
- }
35
- const pumlFiles = files.filter((x) => path.extname(x).toLowerCase() === '.puml');
36
- for (const pumlFile of pumlFiles) {
37
- const fileContents = await readFile(path.join(dir, pumlFile));
38
- await writeFile(path.join(process.cwd(), projectName, dir.replace(TEMPLATE_DIR, ''), pumlFile), fileContents);
39
- }
40
- const otherFiles = files.filter((x) => ['.md', '.puml'].indexOf(path.extname(x).toLowerCase()) === -1);
41
- for (const otherFile of otherFiles) {
42
- if (fs.statSync(path.join(dir, otherFile)).isDirectory())
43
- continue;
44
- await fsextra.copy(path.join(dir, otherFile), path.join(process.cwd(), projectName, dir.replace(TEMPLATE_DIR, ''), otherFile));
45
- }
46
- };
47
- await build(dir);
48
- };
27
+ // Копия шаблона в новый проект как есть (включая dot-файлы) — раньше это была ручная
28
+ // рекурсия на 40 строк, повторявшая fsextra.copy по типам файлов.
29
+ const generateTemplate = (dir, projectName) => fsextra.copy(dir, path.join(process.cwd(), projectName));
49
30
  export default async (opts = {}) => {
50
31
  const nonInteractive = !!opts.yes;
51
32
  // --- имя проекта: флаг --name пропускает промпт; --yes без имени — фатально ---
@@ -1,5 +1,5 @@
1
1
  import figlet from 'figlet';
2
- import { program } from 'commander';
2
+ import { InvalidArgumentError, program } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import path from 'node:path';
5
5
  import Configstore from 'configstore';
@@ -13,27 +13,50 @@ import { build } from "../core/build.js";
13
13
  import watch from 'node-watch';
14
14
  import { EventEmitter } from 'node:events';
15
15
  import { clearConsole } from "../util/utils.js";
16
+ import { acquireBuildLock, BuildLockHeldError } from "../util/lock.js";
17
+ // teardownD2 живёт здесь, а не в finally build(): в watch-режиме D2-воркер должен
18
+ // переживать ребилды (его пересоздание — секунды), гасим его только когда сборок
19
+ // больше не будет (одиночный запуск).
20
+ import { teardownD2 } from "../core/render/d2renderer.js";
16
21
  import { packageJson as pkg } from "../util/paths.js";
17
- import { configSchema } from "../config/schema.js";
22
+ import { parseConfig } from "../config/options.js";
23
+ import { isValidPort } from "../config/schema.js";
18
24
  const intro = () => {
19
25
  console.log(chalk.blue(figlet.textSync('c4builder')));
20
26
  console.log(chalk.gray('Blow up your software documentation writing skills'));
21
27
  };
22
- function getOptions(conf, applyDefaults) {
28
+ // Читает сырой `.c4builder` и разбирает его щадяще (config/parseConfig): для визарда и
29
+ // `--list`. Возвращает лишь ЗАДАННЫЕ пользователем и ВАЛИДНЫЕ ключи (недостающее и битое
30
+ // = undefined, как читалось до валидации), чтобы визард спросил их на первом запуске,
31
+ // а `--list` всё равно показал конфиг. Битые ключи — в `issues` для предупреждения.
32
+ function readLenientConfig(conf) {
23
33
  const raw = (conf.all ?? {});
24
- const parsed = configSchema.safeParse(raw);
25
- if (!parsed.success) {
26
- const issue = parsed.error.issues[0];
27
- const key = issue?.path.join('.') || '(корень)';
28
- console.error(chalk.red(`Ошибка в .c4builder: ключ «${key}» — ${issue?.message ?? 'неверное значение'}`));
29
- process.exit(1);
30
- }
31
- const full = parsed.data;
32
- const c = applyDefaults === false
33
- ? Object.fromEntries(Object.keys(full)
34
- .filter((k) => k in raw)
35
- .map((k) => [k, full[k]]))
36
- : full;
34
+ const result = parseConfig(raw);
35
+ const source = result.ok ? result.value : result.salvaged;
36
+ const broken = new Set(result.ok ? [] : result.issues.map((i) => i.key));
37
+ const config = Object.fromEntries(Object.keys(source)
38
+ // null в файле = «не задан» (как отсутствие ключа): схема уже подставила
39
+ // дефолт, но визард обязан спросить значение, а не молча принять его.
40
+ .filter((k) => k in raw && raw[k] !== null && !broken.has(k))
41
+ .map((k) => [k, source[k]]));
42
+ return { config, issues: result.ok ? [] : result.issues, salvaged: source };
43
+ }
44
+ // Строгий путь (сборка) не терпит битого конфига: печатает внятную ошибку и завершает
45
+ // процесс с ненулевым кодом. Держим решение о выходе здесь, в CLI, а не в config/*.
46
+ function failOnConfigIssues(issues) {
47
+ console.error(chalk.red('Ошибка в .c4builder — невалидные значения не дают собрать документацию:'));
48
+ for (const { key, message } of issues)
49
+ console.error(chalk.red(` ${key} — ${message}`));
50
+ process.exit(1);
51
+ }
52
+ // Щадящий путь (--config/первый запуск визарда) предупреждает о битых ключах: они
53
+ // трактуются как «не задано» и будут перезаписаны валидными значениями после визарда.
54
+ function warnConfigIssues(issues) {
55
+ console.log(chalk.yellow('\n⚠ Невалидные ключи в .c4builder (трактуются как «не задано», визард их перезапишет):'));
56
+ for (const { key, value, message } of issues)
57
+ console.log(chalk.yellow(` ⚠ ${key}: ${JSON.stringify(value)} — ${message}`));
58
+ }
59
+ function getOptions(c) {
37
60
  return {
38
61
  // Легаси-детект: выбор версии PlantUML удалён; ключ plantumlVersion из старых
39
62
  // .c4builder отдаём build.js только для однократного предупреждения на пине.
@@ -64,12 +87,13 @@ function getOptions(conf, applyDefaults) {
64
87
  HAS_RUN: c.hasRun,
65
88
  PLANTUML_SERVER_URL: c.plantumlServerUrl,
66
89
  DIAGRAM_FORMAT: c.diagramFormat,
67
- D2_LAYOUT: c.d2Layout || 'dagre',
90
+ D2_LAYOUT: c.d2Layout,
68
91
  MD_FILE_NAME: 'README',
69
92
  WEB_FILE_NAME: c.webFileName,
70
93
  SUPPORT_SEARCH: c.supportSearch,
71
94
  EXECUTE_SCRIPT: c.executeScript,
72
- EXCLUDE_OTHER_FILES: c.excludeOtherFiles
95
+ EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
96
+ USE_SYSTEM_FONTS: c.useSystemFonts
73
97
  };
74
98
  }
75
99
  export default async () => {
@@ -87,7 +111,13 @@ export default async () => {
87
111
  .option('-w, --watch', 'watch for changes and rebuild')
88
112
  .option('-o, --open', 'open the generated site in the browser (with --site)')
89
113
  .option('--docs', 'a brief explanation for the available configuration options')
90
- .option('-p, --port <n>', 'port used for serving the generated site', parseInt)
114
+ .option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
115
+ .option('-p, --port <n>', 'port used for serving the generated site', (v) => {
116
+ // parseInt молча резал бы опечатку ('30OO' → 30) — валидируем строго.
117
+ if (!isValidPort(v))
118
+ throw new InvalidArgumentError('ожидается TCP-порт: целое число 1..65535');
119
+ return Number(v);
120
+ })
91
121
  .allowExcessArguments() // позиционные аргументы подкоманды `jre <action>`
92
122
  .parse(process.argv);
93
123
  const opts = program.opts();
@@ -96,9 +126,12 @@ export default async () => {
96
126
  return cmdJre(program.args.slice(1), { force: opts.force });
97
127
  let conf = { get: () => { } };
98
128
  let cacheConf = { get: () => { }, set: () => { }, clear: () => { } };
129
+ // Лок сборки — рядом с конфигом проекта (см. util/lock.ts). Заполняется вместе с conf.
130
+ let lockPath = '';
99
131
  if (!opts.new) {
100
132
  const projectKey = process.cwd().split(path.sep).splice(1).join('_');
101
133
  const configPath = path.join(process.cwd(), opts.configFile ?? '.c4builder');
134
+ lockPath = `${configPath}.lock`;
102
135
  conf = new Configstore(projectKey, {}, { configPath });
103
136
  cacheConf = new Configstore(`${projectKey}_cache`, {}, { configPath: `${configPath}.cache` });
104
137
  // Миграция: чексуммы раньше жили в .c4builder, переносим их в .c4builder.cache,
@@ -113,9 +146,20 @@ export default async () => {
113
146
  }
114
147
  if (opts.docs)
115
148
  return cmdHelp();
116
- // Начальный конфиг для визарда/--list: raw-вид (незаданные поля = undefined),
117
- // чтобы визард спросил недостающее на первом запуске.
118
- const currentConfig = getOptions(conf, false);
149
+ // --reset чистит .c4builder при ЛЮБОМ его содержимом — до строгой проверки конфига,
150
+ // чтобы сломанное значение не мешало собственному сбросу.
151
+ if (opts.reset && !opts.new) {
152
+ clearConsole();
153
+ intro();
154
+ conf.clear();
155
+ cacheConf.clear();
156
+ console.log(`configuration was reset`);
157
+ return;
158
+ }
159
+ // Начальный конфиг для визарда/--list: щадящий разбор (незаданные и битые поля =
160
+ // undefined), чтобы визард спросил недостающее, а --list всё равно показал конфиг.
161
+ const { config: rawConfig, issues: configIssues, salvaged } = readLenientConfig(conf);
162
+ const currentConfig = getOptions(rawConfig);
119
163
  if (opts.new || opts.config || !currentConfig.HAS_RUN)
120
164
  clearConsole();
121
165
  intro();
@@ -126,20 +170,42 @@ export default async () => {
126
170
  if (opts.new)
127
171
  return cmdNewProject(opts);
128
172
  if (opts.list)
129
- return cmdList(currentConfig);
130
- if (opts.reset) {
131
- conf.clear();
132
- cacheConf.clear();
133
- console.log(`configuration was reset`);
134
- return;
173
+ return cmdList(currentConfig, configIssues);
174
+ // Повторная сборка установленного проекта (есть HAS_RUN, не --config) — строгий путь:
175
+ // битый конфиг обязан упасть с внятной ошибкой сразу, а не быть тихо «починен» визардом.
176
+ // Первый запуск (!HAS_RUN) и --config остаются щадящими — это штатный путь восстановления.
177
+ const strictBuild = !opts.config && !!currentConfig.HAS_RUN;
178
+ if (configIssues.length) {
179
+ if (strictBuild)
180
+ failOnConfigIssues(configIssues);
181
+ warnConfigIssues(configIssues);
182
+ // Битые ключи чиним физически (salvage-значение = дефолт схемы, либо удаление):
183
+ // визард переспрашивает не всё (вложенные блоки гейтятся generate*-флагами), а
184
+ // строгий parseConfig после него обязан пройти — иначе exit 1 на каждом запуске.
185
+ for (const { key } of configIssues) {
186
+ const v = salvaged[key];
187
+ if (v === undefined)
188
+ conf.delete(key);
189
+ else
190
+ conf.set(key, v);
191
+ }
135
192
  }
136
193
  await cmdCollect(currentConfig, conf, opts);
137
194
  if (!opts.config) {
138
- conf.set('hasRun', true);
139
195
  let isBuilding = false;
140
196
  let attemptedWatchBuild = false;
141
- // Опции сборки после визарда: полный вид с дефолтами (базовые поля гарантированы).
142
- const options = getOptions(conf);
197
+ // Опции сборки после визарда: строгий разбор итогового конфига. Штатно валиден
198
+ // (визард только что записал корректные значения; повторная сборка с битым конфигом
199
+ // отсеяна выше) — падение здесь страхует от иного невалидного состояния.
200
+ const built = parseConfig((conf.all ?? {}));
201
+ if (!built.ok)
202
+ failOnConfigIssues(built.issues);
203
+ // hasRun — только ПОСЛЕ успешной валидации: битый конфиг с hasRun=true навсегда
204
+ // уводил бы последующие запуски на строгий путь (exit 1 до визарда).
205
+ conf.set('hasRun', true);
206
+ const options = getOptions(built.value);
207
+ if (opts.systemFonts)
208
+ options.USE_SYSTEM_FONTS = true; // флаг сильнее ключа конфига
143
209
  const reloadEmitter = new EventEmitter();
144
210
  reloadEmitter.setMaxListeners(0);
145
211
  if (opts.watch) {
@@ -159,17 +225,31 @@ export default async () => {
159
225
  isBuilding = true;
160
226
  let buildOk = true;
161
227
  try {
162
- await build(options, cacheConf);
163
- while (attemptedWatchBuild) {
164
- attemptedWatchBuild = false;
228
+ // Лок на всю серию ребилдов: параллельная ручная сборка в этом же
229
+ // каталоге не потопчет dist_bk и не потеряет чексуммы кеша.
230
+ const release = acquireBuildLock(lockPath);
231
+ try {
165
232
  await build(options, cacheConf);
233
+ while (attemptedWatchBuild) {
234
+ attemptedWatchBuild = false;
235
+ await build(options, cacheConf);
236
+ }
237
+ }
238
+ finally {
239
+ release();
166
240
  }
167
241
  }
168
242
  catch (err) {
169
243
  buildOk = false;
170
244
  attemptedWatchBuild = false;
171
- const e = err;
172
- console.log(chalk.red(`build failed: ${e?.stack ? e.stack : e}`));
245
+ if (err instanceof BuildLockHeldError) {
246
+ // Соседняя сборка ещё идёт — ребилд пропущен, watch жив.
247
+ console.log(chalk.yellow(`ребилд пропущен: ${err.message}`));
248
+ }
249
+ else {
250
+ const e = err;
251
+ console.log(chalk.red(`build failed: ${e?.stack ? e.stack : e}`));
252
+ }
173
253
  }
174
254
  finally {
175
255
  isBuilding = false;
@@ -179,7 +259,25 @@ export default async () => {
179
259
  });
180
260
  }
181
261
  isBuilding = true;
182
- await build(options, cacheConf);
262
+ let release;
263
+ try {
264
+ release = acquireBuildLock(lockPath);
265
+ }
266
+ catch (e) {
267
+ // Занято живой сборкой — падаем сразу с внятным сообщением, без стектрейса.
268
+ console.error(chalk.red(e.message));
269
+ process.exit(1);
270
+ }
271
+ try {
272
+ await build(options, cacheConf);
273
+ }
274
+ finally {
275
+ release();
276
+ // Одиночная сборка: D2-воркер больше не нужен, без teardown он держал бы
277
+ // процесс. В watch-режиме воркер живёт до конца процесса (Ctrl+C).
278
+ if (!opts.watch)
279
+ await teardownD2();
280
+ }
183
281
  isBuilding = false;
184
282
  if (opts.site)
185
283
  return await cmdSite(options, opts, reloadEmitter);