@alfacapital-tech/c4builder 0.5.1 → 0.7.0

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.
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import express from 'express';
5
5
  import open from 'open';
6
+ import { packageJson as pkg } from "../../util/paths.js";
7
+ import { pendingUpdate } from "../update.js";
6
8
  const LIVERELOAD_PATH = '/__livereload';
7
9
  // Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
8
10
  // высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
@@ -52,6 +54,28 @@ const LIVERELOAD_SNIPPET = `
52
54
  connect();
53
55
  })();
54
56
  </script>`;
57
+ // Плашка о новой версии — только в отдаче dev-сервера, в dist не попадает. Флаг закрытия — в
58
+ // sessionStorage: переживает reload ребилдом и живёт до закрытия вкладки.
59
+ const updateBanner = (next) => `
60
+ <div id="c4builder-update" style="position:fixed;right:16px;bottom:16px;z-index:1000;max-width:360px;padding:10px 36px 10px 14px;border:1px solid #e0c060;border-radius:6px;background:#fff8dc;color:#333;font:14px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.15)">
61
+ c4builder ${next} is available (current ${pkg.version}). Run: <code>c4builder update</code>
62
+ <button type="button" aria-label="Close" style="position:absolute;top:4px;right:8px;border:0;background:none;color:inherit;font-size:18px;line-height:1;cursor:pointer">&times;</button>
63
+ </div>
64
+ <script>
65
+ (function () {
66
+ var KEY = '__c4builderUpdateClosed';
67
+ var el = document.getElementById('c4builder-update');
68
+ try {
69
+ if (sessionStorage.getItem(KEY)) return el.remove();
70
+ } catch (e) {}
71
+ el.querySelector('button').onclick = function () {
72
+ el.remove();
73
+ try {
74
+ sessionStorage.setItem(KEY, '1');
75
+ } catch (e) {}
76
+ };
77
+ })();
78
+ </script>`;
55
79
  export default (currentConfiguration, program, reloadEmitter) => {
56
80
  if (!currentConfiguration.DIST_FOLDER)
57
81
  return console.log(chalk.red('No destination folder configured'));
@@ -84,10 +108,14 @@ export default (currentConfiguration, program, reloadEmitter) => {
84
108
  console.log(chalk.gray(`livereload: client disconnected (${reloadEmitter.listenerCount('reload')} left)`));
85
109
  });
86
110
  });
111
+ // Кэш проверки обновлений читается на каждую страницу: результат фоновой проверки,
112
+ // запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
87
113
  const inject = (html) => {
114
+ const next = pendingUpdate();
115
+ const snippet = next ? LIVERELOAD_SNIPPET + updateBanner(next) : LIVERELOAD_SNIPPET;
88
116
  if (html.includes('</body>'))
89
- return html.replace('</body>', `${LIVERELOAD_SNIPPET}</body>`);
90
- return html + LIVERELOAD_SNIPPET;
117
+ return html.replace('</body>', `${snippet}</body>`);
118
+ return html + snippet;
91
119
  };
92
120
  app.use((req, res, next) => {
93
121
  if (req.method !== 'GET')
@@ -122,9 +150,10 @@ export default (currentConfiguration, program, reloadEmitter) => {
122
150
  }
123
151
  return next();
124
152
  }
153
+ const page = inject(data);
125
154
  if (isIndex)
126
- lastIndexHtml = inject(data);
127
- res.set('Cache-Control', 'no-store').type('html').send(inject(data));
155
+ lastIndexHtml = page;
156
+ res.set('Cache-Control', 'no-store').type('html').send(page);
128
157
  });
129
158
  });
130
159
  }
@@ -9,6 +9,7 @@ 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 { cmdUpdate, pendingUpdate, startUpdateCheck } from "./update.js";
12
13
  import cmdCollect from "./wizard/collect.js";
13
14
  import { build } from "../core/build.js";
14
15
  import { loadPlugins, pluginWatchPaths } from "../core/plugins/load.js";
@@ -26,7 +27,7 @@ import { packageJson as pkg } from "../util/paths.js";
26
27
  import { outputDirs, parseConfig } from "../config/options.js";
27
28
  import { isValidPort } from "../config/schema.js";
28
29
  // Позиционные подкоманды: allowExcessArguments пропускает любые, список — единственный фильтр.
29
- const COMMANDS = ['check', 'jre', 'site', 'new', 'config'];
30
+ const COMMANDS = ['check', 'jre', 'site', 'new', 'config', 'update'];
30
31
  const intro = () => {
31
32
  console.log(chalk.blue(figlet.textSync('c4builder')));
32
33
  console.log(chalk.gray('Blow up your software documentation writing skills'));
@@ -137,12 +138,23 @@ export default async () => {
137
138
  console.error(`available commands: ${COMMANDS.join(', ')} (see c4builder --help for options)`);
138
139
  process.exit(1);
139
140
  }
141
+ // --version/--help уже вышли в parse. Уведомление — по кэшу прошлой проверки, до её
142
+ // перезапуска; exit покрывает и выходы через process.exit.
143
+ if (cmd !== 'update') {
144
+ const next = pendingUpdate();
145
+ if (next)
146
+ process.once('exit', () => process.stderr.write(`c4builder ${next} is available (current ${pkg.version}). Run: c4builder update\n`));
147
+ startUpdateCheck();
148
+ }
140
149
  // Прогрев JRE — до загрузки конфига проекта и intro: команда самостоятельна.
141
150
  if (cmd === 'jre')
142
151
  return cmdJre(cmdArgs, { force: opts.force });
143
152
  // Проверка отдельных диаграмм — тоже без конфига проекта.
144
153
  if (cmd === 'check')
145
154
  return cmdCheck(cmdArgs);
155
+ // Обновление CLI — до Configstore, чтобы не создавать .c4builder в любом каталоге.
156
+ if (cmd === 'update')
157
+ return cmdUpdate();
146
158
  // `site`/`new`/`config` — позиционные синонимы одноимённых флагов: эту форму печатают
147
159
  // подсказки CLI и учат README, шаблон и скиллы.
148
160
  if (cmd)
@@ -0,0 +1,133 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import chalk from 'chalk';
6
+ import { isPathInside } from "../util/archive.js";
7
+ import { PACKAGE_ROOT, packageJson as pkg } from "../util/paths.js";
8
+ const DAY_MS = 24 * 60 * 60 * 1000;
9
+ const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
10
+ // Без лимитов npm в сети, молча теряющей пакеты, висит минутами (повторы с паузами до 60 с).
11
+ const VIEW = ['view', pkg.name, 'dist-tags', '--json', '--fetch-retries=0', '--fetch-timeout=5000'];
12
+ // npm на Windows — npm.cmd, а .cmd Node ≥ 20.12 запускает только через shell. Команду склеиваем
13
+ // сами: shell с массивом аргументов Node помечает DEP0190. Аргументы фиксированные или прошли SEMVER.
14
+ const WIN = process.platform === 'win32';
15
+ const npmCommand = (args) => WIN ? [['npm', ...args].join(' '), []] : ['npm', args];
16
+ const npmSync = (args, options) => {
17
+ const [cmd, argv] = npmCommand(args);
18
+ return spawnSync(cmd, argv, { ...options, encoding: 'utf8', shell: WIN, windowsHide: true });
19
+ };
20
+ // CI ставят GitLab CI, GitHub Actions и большинство CI; C4BUILDER_DOCKER — наш Dockerfile;
21
+ // NO_UPDATE_NOTIFIER — общая конвенция с пакетом update-notifier.
22
+ const disabled = () => !!(process.env.CI || process.env.NO_UPDATE_NOTIFIER || process.env.C4BUILDER_DOCKER);
23
+ const cacheFile = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'c4builder', 'dist-tags.json');
24
+ // Порядок semver без зависимости semver: numeric-коллатор сравнивает цифровые серии численно
25
+ // (0.10.0 > 0.9.0, rc.10 > rc.2); стабильная версия старше пререлиза того же ядра.
26
+ const collator = new Intl.Collator('en', { numeric: true });
27
+ export const compareVersions = (a, b) => {
28
+ const [coreA = '', preA = ''] = a.split(/-(.*)/);
29
+ const [coreB = '', preB = ''] = b.split(/-(.*)/);
30
+ return collator.compare(coreA, coreB) || Number(!preA) - Number(!preB) || collator.compare(preA, preB);
31
+ };
32
+ // Версия для обновления по dist-tags от npm, или undefined: нет данных (пустой, битый, недописанный
33
+ // кэш, JSON-ошибка npm) либо ничего новее текущей. Пререлиз смотрит и на rc, который может отстать
34
+ // от latest. npm 12 отдаёт dist-tags массивом из одного объекта, npm ≤ 11 — самим объектом.
35
+ export const newestVersion = (current, distTags) => {
36
+ let tags;
37
+ try {
38
+ tags = [JSON.parse(distTags)].flat()[0];
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ let newest = current;
44
+ for (const tag of current.includes('-') ? ['latest', 'rc'] : ['latest']) {
45
+ const v = tags?.[tag];
46
+ if (typeof v === 'string' && SEMVER.test(v) && compareVersions(v, newest) > 0)
47
+ newest = v;
48
+ }
49
+ return newest === current ? undefined : newest;
50
+ };
51
+ // Обновление, известное по прошлой проверке; читать до startUpdateCheck — она обнуляет кэш.
52
+ export const pendingUpdate = () => {
53
+ if (disabled())
54
+ return undefined;
55
+ try {
56
+ return newestVersion(pkg.version, fs.readFileSync(cacheFile(), 'utf8'));
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ };
62
+ // Не чаще раза в сутки — отсоединённый `npm view` со stdout прямо в файл кэша: CLI его не ждёт,
63
+ // а реестр, прокси и сертификаты npm берёт из npmrc — тот же путь, что у установки обновления.
64
+ // mtime кэша — время последней попытки, удачной или нет.
65
+ export const startUpdateCheck = () => {
66
+ if (disabled())
67
+ return;
68
+ try {
69
+ const file = cacheFile();
70
+ if (Date.now() - (fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0) < DAY_MS)
71
+ return;
72
+ fs.mkdirSync(path.dirname(file), { recursive: true });
73
+ const fd = fs.openSync(file, 'w');
74
+ try {
75
+ // open('w') уже пустого файла (прошлая попытка без сети) обновляет mtime не на всех ФС.
76
+ fs.futimesSync(fd, new Date(), new Date());
77
+ // fd/ignore, а не pipe: pipe ребёнка держал бы event loop CLI до выхода npm.
78
+ const [cmd, argv] = npmCommand(VIEW);
79
+ spawn(cmd, argv, {
80
+ detached: true,
81
+ stdio: ['ignore', fd, 'ignore'],
82
+ shell: WIN,
83
+ windowsHide: true
84
+ })
85
+ .on('error', () => { }) // нет npm в PATH: без обработчика ENOENT роняет CLI
86
+ .unref();
87
+ }
88
+ finally {
89
+ fs.closeSync(fd);
90
+ }
91
+ }
92
+ catch {
93
+ // Проверка необязательна: недоступный на запись каталог конфигурации не должен мешать CLI.
94
+ }
95
+ };
96
+ // realpath: npm link и симлинки prefix'а (nvm, Homebrew) — сравниваем реальные пути.
97
+ const installedGlobally = (globalRoot) => {
98
+ try {
99
+ return isPathInside(fs.realpathSync(globalRoot), fs.realpathSync(PACKAGE_ROOT));
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ };
105
+ // `c4builder update`: ставит новейшую версию при глобальной npm-установке, иначе подсказывает,
106
+ // как обновиться. Ставим точную версию, а не @latest: для пререлиза новейшей бывает и latest.
107
+ export const cmdUpdate = () => {
108
+ if (process.env.C4BUILDER_DOCKER) {
109
+ console.log('c4builder runs in the Docker image, pull the new image instead:');
110
+ console.log(' docker pull ghcr.io/alfacapital-tech/c4builder:latest');
111
+ process.exit(1);
112
+ }
113
+ const view = npmSync(VIEW, { stdio: ['ignore', 'pipe', 'inherit'], timeout: 60_000 });
114
+ if (view.status !== 0) {
115
+ const reason = view.error?.message ?? `npm exited with code ${view.status}`;
116
+ console.error(chalk.red(`could not query the npm registry: ${reason}`));
117
+ process.exit(1);
118
+ }
119
+ const next = newestVersion(pkg.version, view.stdout);
120
+ if (!next) {
121
+ console.log(chalk.green(`c4builder ${pkg.version} is up to date`));
122
+ return;
123
+ }
124
+ const root = npmSync(['root', '-g'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000 });
125
+ if (root.status === 0 && installedGlobally(root.stdout.trim())) {
126
+ console.log(`updating c4builder ${pkg.version} -> ${next}`);
127
+ process.exit(npmSync(['install', '-g', `${pkg.name}@${next}`], { stdio: 'inherit' }).status ?? 1);
128
+ }
129
+ console.log(`c4builder ${next} is available (current ${pkg.version}), but this copy is not a global npm install.`);
130
+ console.log(`update the project dependency: npm i -D ${pkg.name}@${next}`);
131
+ console.log(`or run the new version once: npx ${pkg.name}@${next}`);
132
+ process.exit(1);
133
+ };
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { definePlugin } from "../../core/plugins/types.js";
7
+ import { layoutChanges, pageOrder } from "./layout.js";
7
8
  import { scanStore } from "./scan.js";
8
9
  import { renderArchiveIndex, renderChange, renderChangesIndex, relSource, renderMarkdown, renderSpecTree, renderSummary } from "./render.js";
9
10
  const optionsSchema = z
@@ -12,7 +13,10 @@ const optionsSchema = z
12
13
  mount: z.string().min(1).default('OpenSpec'),
13
14
  // Порядок артефактов change'а; первый выводится на странице change'а, остальные —
14
15
  // подстраницами; не перечисленные — после, по алфавиту.
15
- artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks'])
16
+ artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks']),
17
+ // Ключ `.openspec.yaml` со ссылкой на родительский change (например `x-parent`):
18
+ // вложенный change выводится страницей под родителем. Не задан — плоский список.
19
+ parentKey: z.string().min(1).optional()
16
20
  })
17
21
  .strict();
18
22
  export default definePlugin({
@@ -47,14 +51,24 @@ export default definePlugin({
47
51
  }
48
52
  }
49
53
  };
50
- const addChange = (change, segments) => addPages(renderChange(change, segments, o.artifacts, options), change.dir);
51
- ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options) });
54
+ // Родитель добавляется раньше вложенных: в sidebar под ним сначала его артефакты,
55
+ // затем вложенные change'ы.
56
+ const addChanges = (changes, layout) => {
57
+ for (const change of pageOrder(changes, layout)) {
58
+ const at = layout.get(change);
59
+ if (at)
60
+ addPages(renderChange(change, at.segments, o.artifacts, options, layout), change.dir);
61
+ }
62
+ };
63
+ const { parentKey } = o;
64
+ const active = layoutChanges(store.changes, [mount, 'Changes'], { parentKey });
65
+ const archived = layoutChanges(store.archive, [mount, 'Archive'], { parentKey, archived: true });
66
+ ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options, active) });
52
67
  ctx.addPage({
53
68
  path: [mount, 'Changes'],
54
- markdown: renderChangesIndex(store.changes, mount, options)
69
+ markdown: renderChangesIndex(store.changes, mount, options, active)
55
70
  });
56
- for (const change of store.changes)
57
- addChange(change, [mount, 'Changes', change.id]);
71
+ addChanges(store.changes, active);
58
72
  // Specs: индекс, промежуточные папки, страницы спек — тем же рендером, что и
59
73
  // дельты (fenced-диаграммы локально, ссылки на файлы рядом со спекой).
60
74
  const specsDir = path.join(storeDir, 'specs');
@@ -66,9 +80,8 @@ export default definePlugin({
66
80
  })), specsDir);
67
81
  ctx.addPage({
68
82
  path: [mount, 'Archive'],
69
- markdown: renderArchiveIndex(store.archive, mount, options)
83
+ markdown: renderArchiveIndex(store.archive, mount, options, archived)
70
84
  });
71
- for (const change of store.archive)
72
- addChange(change, [mount, 'Archive', change.id]);
85
+ addChanges(store.archive, archived);
73
86
  }
74
87
  });
@@ -0,0 +1,57 @@
1
+ // Архивный change — `<YYYY-MM-DD>-<id>`; ссылка на родителя пишется по активному id.
2
+ const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
3
+ const originalId = (c, archived) => archived ? c.id.replace(DATE_PREFIX, '') : c.id;
4
+ /**
5
+ * Без `parentKey` — все change'ы на верхнем уровне `base` (прежнее поведение). С ним
6
+ * change, у которого в `.openspec.yaml` есть `parentKey`, указывающий на change того же
7
+ * списка, кладётся страницей под родителя. Вложенность — один уровень: родитель сам
8
+ * вложенный или не найден — change остаётся на верхнем уровне. Подпись вложенного — хвост
9
+ * id после `<id родителя>-`; если хвоста нет или он совпадает с подстраницей родителя
10
+ * (артефакт, `specs`) или с подписью соседа — полный id.
11
+ */
12
+ export const layoutChanges = (changes, base, { parentKey, archived = false } = {}) => {
13
+ const byId = new Map(changes.map((c) => [originalId(c, archived), c]));
14
+ const parentOf = (c) => {
15
+ const ref = parentKey ? c.meta[parentKey] : undefined;
16
+ const p = ref ? byId.get(ref) : undefined;
17
+ return p && p !== c ? p : undefined;
18
+ };
19
+ const layout = new Map();
20
+ const top = changes.filter((c) => {
21
+ const p = parentOf(c);
22
+ return !p || parentOf(p);
23
+ });
24
+ for (const c of top)
25
+ layout.set(c, { segments: [...base, c.id], label: c.id, children: [] });
26
+ for (const c of changes) {
27
+ if (layout.has(c))
28
+ continue;
29
+ const parent = parentOf(c);
30
+ const host = layout.get(parent);
31
+ const taken = new Set([
32
+ ...parent.artifacts.map((a) => a.name),
33
+ 'specs',
34
+ ...host.children.map((s) => layout.get(s).label)
35
+ ]);
36
+ const own = originalId(c, archived);
37
+ const prefix = `${originalId(parent, archived)}-`;
38
+ const tail = own.startsWith(prefix) ? own.slice(prefix.length) : '';
39
+ const label = tail && !taken.has(tail) ? tail : c.id;
40
+ layout.set(c, { segments: [...host.segments, label], label, parent, children: [] });
41
+ host.children.push(c);
42
+ }
43
+ return layout;
44
+ };
45
+ /**
46
+ * Change'ы в порядке вывода: каждый верхнего уровня, сразу за ним — его вложенные. Порядок
47
+ * внутри обоих уровней — порядок `changes` (по имени для страниц, по дате для таблиц).
48
+ */
49
+ export const pageOrder = (changes, layout) => {
50
+ const pos = new Map(changes.map((c, i) => [c, i]));
51
+ return changes
52
+ .filter((c) => !layout.get(c)?.parent)
53
+ .flatMap((c) => [
54
+ c,
55
+ ...[...(layout.get(c)?.children ?? [])].sort((a, b) => (pos.get(a) ?? 0) - (pos.get(b) ?? 0))
56
+ ]);
57
+ };
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { createFenceExtractor, mapOutsideFences } from "../../core/scan/fences.js";
5
5
  import { encodeURIPath } from "../../util/utils.js";
6
+ import { pageOrder } from "./layout.js";
6
7
  // Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
7
8
  // а docsify без relativePath резолвит ссылки от корня сайта.
8
9
  export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
@@ -10,17 +11,23 @@ const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
10
11
  // Прогресс задач: нативный <progress> (docsify рендерит HTML в markdown) + числа и процент.
11
12
  const progressBar = (done, total) => `<progress value="${done}" max="${total}"></progress> ${done}/${total} (${Math.round((done / total) * 100)}%)`;
12
13
  const fmtProgress = (c) => c.progress ? progressBar(c.progress.done, c.progress.total) : '—';
13
- /** Таблица change'ов (сводка и индекс Changes): по дате изменения от новых к старым. */
14
- export const renderChangesIndex = (changes, mount, options) => {
14
+ /**
15
+ * Таблица change'ов (сводка и индекс Changes): по дате изменения от новых к старым. С `layout`
16
+ * вложенные change'ы идут сразу под родителем (`↳` + подпись) и ведут на свои вложенные страницы.
17
+ */
18
+ export const renderChangesIndex = (changes, mount, options, layout) => {
15
19
  if (!changes.length)
16
20
  return '_No changes._';
17
- const rows = [...changes]
18
- .sort((a, b) => b.mtime - a.mtime)
19
- .map((c) => `| [${c.id}](${pageLink([mount, 'Changes', c.id], options)}) | ${c.schema ?? '—'} | ${fmtProgress(c)} | ${fmtDate(c.mtime)} |`);
21
+ const byDate = [...changes].sort((a, b) => b.mtime - a.mtime);
22
+ const rows = (layout ? pageOrder(byDate, layout) : byDate).map((c) => {
23
+ const at = layout?.get(c);
24
+ const link = `[${at?.label ?? c.id}](${pageLink(at?.segments ?? [mount, 'Changes', c.id], options)})`;
25
+ return `| ${at?.parent ? `↳ ${link}` : link} | ${c.schema ?? '—'} | ${fmtProgress(c)} | ${fmtDate(c.mtime)} |`;
26
+ });
20
27
  return ['| Change | Schema | Tasks | Updated |', '|---|---|---|---|', ...rows].join('\n');
21
28
  };
22
29
  /** Сводка store (страница mount): счётчики, суммарный прогресс, таблица активных change'ов. */
23
- export const renderSummary = (store, mount, options) => {
30
+ export const renderSummary = (store, mount, options, layout) => {
24
31
  const withProgress = store.changes.filter((c) => c.progress);
25
32
  const done = withProgress.reduce((s, c) => s + (c.progress?.done ?? 0), 0);
26
33
  const total = withProgress.reduce((s, c) => s + (c.progress?.total ?? 0), 0);
@@ -33,11 +40,18 @@ export const renderSummary = (store, mount, options) => {
33
40
  '',
34
41
  '## Active changes',
35
42
  '',
36
- renderChangesIndex(store.changes, mount, options)
43
+ renderChangesIndex(store.changes, mount, options, layout)
37
44
  ].join('\n');
38
45
  };
39
- export const renderArchiveIndex = (archive, mount, options) => archive.length
40
- ? archive.map((c) => `- [${c.id}](${pageLink([mount, 'Archive', c.id], options)})`).join('\n')
46
+ /** Список архива: от новых к старым; с `layout` вложенные — пунктами под родителем. */
47
+ export const renderArchiveIndex = (archive, mount, options, layout) => archive.length
48
+ ? (layout ? pageOrder(archive, layout) : archive)
49
+ .map((c) => {
50
+ const at = layout?.get(c);
51
+ const link = pageLink(at?.segments ?? [mount, 'Archive', c.id], options);
52
+ return `${at?.parent ? ' ' : ''}- [${at?.label ?? c.id}](${link})`;
53
+ })
54
+ .join('\n')
41
55
  : '_Archive is empty._';
42
56
  /** Индекс папки спек (Specs, промежуточные папки, дельты change'а): вложенный список под prefix. */
43
57
  export const renderSpecIndex = (specs, prefix, base, options) => {
@@ -179,7 +193,7 @@ const orderArtifacts = (artifacts, order) => [...artifacts].sort((a, b) => {
179
193
  * `specs` дельт (страница на capability, структура папок). Артефакт с именем `specs`
180
194
  * делит страницу с индексом дельт.
181
195
  */
182
- export const renderChange = (change, segments, order, options) => {
196
+ export const renderChange = (change, segments, order, options, layout) => {
183
197
  const ordered = orderArtifacts(change.artifacts, order);
184
198
  const inline = ordered[0]?.name === order[0] ? ordered[0] : undefined;
185
199
  const deltas = change.deltas.map((d) => ({
@@ -222,11 +236,23 @@ export const renderChange = (change, segments, order, options) => {
222
236
  ]
223
237
  .filter(Boolean)
224
238
  .join(' · ');
239
+ // Связи по опции parentKey: у вложенного — ссылка на родителя, у родителя — на вложенные.
240
+ const at = layout?.get(change);
241
+ const linkTo = (c) => {
242
+ const p = layout?.get(c);
243
+ return p ? `[${p.label}](${pageLink(p.segments, options)})` : c.id;
244
+ };
245
+ const family = [
246
+ at?.parent && `**Parent:** ${linkTo(at.parent)}`,
247
+ at?.children.length && `**Sub-changes:** ${at.children.map(linkTo).join(' · ')}`
248
+ ]
249
+ .filter(Boolean)
250
+ .join(' · ');
225
251
  const main = inline ? render(segments, inline.name, inline.content) : undefined;
226
252
  const pages = [
227
253
  {
228
254
  path: segments,
229
- markdown: [head, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
255
+ markdown: [head, family, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
230
256
  diagrams: main?.diagrams ?? [],
231
257
  files: main?.files ?? []
232
258
  },
@@ -32,17 +32,19 @@ export const taskProgress = (tasks) => {
32
32
  return undefined;
33
33
  return { done: boxes.filter((m) => m[1] !== ' ').length, total: boxes.length };
34
34
  };
35
- // Метаданные `.openspec.yaml` построчно: нужны два скаляра, yaml-парсер избыточен.
35
+ // Метаданные `.openspec.yaml` построчно: нужны только скаляры верхнего уровня (schema,
36
+ // created и пользовательские ключи вроде `x-parent`), yaml-парсер избыточен.
36
37
  const readMeta = (file) => {
37
- if (!fs.existsSync(file))
38
- return {};
39
38
  const meta = {};
40
- for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
41
- const m = line.match(/^(schema|created):\s*(.+?)\s*$/);
42
- if (m)
43
- meta[m[1]] = m[2].replace(/^["']|["']$/g, '');
44
- }
45
- return meta;
39
+ if (fs.existsSync(file))
40
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
41
+ const m = line.match(/^([A-Za-z0-9_.-]+):[ \t]*(.+?)\s*$/);
42
+ if (!m || m[2].startsWith('#'))
43
+ continue;
44
+ // Комментарий в конце строки (` # …`) — не часть значения.
45
+ meta[m[1]] = m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '');
46
+ }
47
+ return { schema: meta.schema, created: meta.created, meta };
46
48
  };
47
49
  const scanChange = (dir) => {
48
50
  const files = fs.readdirSync(dir, { withFileTypes: true });
@@ -20,5 +20,5 @@ const findPackageRoot = (startDir) => {
20
20
  export const PACKAGE_ROOT = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
21
21
  export const VENDOR_DIR = path.join(PACKAGE_ROOT, 'vendor');
22
22
  export const TEMPLATE_DIR = path.join(PACKAGE_ROOT, 'template');
23
- // Потребляется только как pkg.version (cli/dispatch) — типизируем по факту.
23
+ // Потребляются только version и name (cli/dispatch, cli/update) — типизируем по факту.
24
24
  export const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfacapital-tech/c4builder",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
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",
@@ -12,7 +12,8 @@ c4builder --site -w # live preview on http://localhost:3000 - never exi
12
12
  ```
13
13
 
14
14
  Not installed: `npm i -g @alfacapital-tech/c4builder` (Node.js 22.17+; Java 17+ is used
15
- when present, otherwise c4builder downloads a private JRE).
15
+ when present, otherwise c4builder downloads a private JRE). Installed: `c4builder update`
16
+ updates it.
16
17
 
17
18
  ## Workflow
18
19
 
@@ -22,6 +23,8 @@ when present, otherwise c4builder downloads a private JRE).
22
23
  Fix until the exit code is 0.
23
24
  3. Run `c4builder` and make sure it exits with code 0. Leave `c4builder --site -w` to a
24
25
  human or run it in the background.
26
+ 4. A stderr line `c4builder <new> is available (current <current>). Run: c4builder update`
27
+ means a newer c4builder is out: tell the user, run `c4builder update` only if they ask.
25
28
 
26
29
  ## Layout
27
30