@alfacapital-tech/c4builder 0.5.0 → 0.6.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.
- package/dist/cli/commands/site.js +33 -4
- package/dist/cli/dispatch.js +13 -1
- package/dist/cli/update.js +133 -0
- package/dist/plugins/openspec/render.js +28 -7
- package/dist/util/paths.js +1 -1
- package/package.json +1 -1
- package/template/AGENTS.md +4 -1
|
@@ -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">×</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>', `${
|
|
90
|
-
return html +
|
|
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 =
|
|
127
|
-
res.set('Cache-Control', 'no-store').type('html').send(
|
|
155
|
+
lastIndexHtml = page;
|
|
156
|
+
res.set('Cache-Control', 'no-store').type('html').send(page);
|
|
128
157
|
});
|
|
129
158
|
});
|
|
130
159
|
}
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -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
|
+
};
|
|
@@ -87,10 +87,13 @@ const FENCE_LANGS = ['plantuml', 'puml', 'd2'];
|
|
|
87
87
|
* Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
|
|
88
88
|
* на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
|
|
89
89
|
* относительные ссылки на существующие файлы источника регистрируются для копирования к
|
|
90
|
-
* странице (картинки docsify грузит относительно страницы, обычные ссылки — от корня
|
|
91
|
-
*
|
|
90
|
+
* странице (картинки и embed docsify грузит относительно страницы, обычные ссылки — от корня
|
|
91
|
+
* сайта). Embed `':include'` markdown-файла встраивается при сборке: сырой файл docsify
|
|
92
|
+
* показал бы без локального рендера диаграмм и переписывания ссылок.
|
|
93
|
+
* `fromDir` — posix-каталог файла относительно `srcRoot` (ссылки в нём резолвятся от файла),
|
|
94
|
+
* `pageDir` — каталог, от которого считаются пути файлов страницы (у встроенного — каталог страницы).
|
|
92
95
|
*/
|
|
93
|
-
export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, source, options }) => {
|
|
96
|
+
export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', pageDir = fromDir, included = new Set(), target = () => undefined, source, options }) => {
|
|
94
97
|
const { markdown, diagrams } = createFenceExtractor(FENCE_LANGS).extract(content, base, source);
|
|
95
98
|
const files = [];
|
|
96
99
|
const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
|
|
@@ -114,10 +117,28 @@ export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '',
|
|
|
114
117
|
const abs = path.join(srcRoot, rel);
|
|
115
118
|
if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
116
119
|
return whole;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
const embed = !bang && /:include\b/.test(title);
|
|
121
|
+
if (embed && /\.md$/i.test(rel) && !included.has(rel)) {
|
|
122
|
+
const dir = path.posix.dirname(rel);
|
|
123
|
+
const sub = renderMarkdown(pagePath, base, fs.readFileSync(abs, 'utf8'), {
|
|
124
|
+
srcRoot,
|
|
125
|
+
fromDir: dir === '.' ? '' : dir,
|
|
126
|
+
pageDir,
|
|
127
|
+
included: new Set([...included, rel]),
|
|
128
|
+
target,
|
|
129
|
+
source: relSource(srcRoot, rel),
|
|
130
|
+
options
|
|
131
|
+
});
|
|
132
|
+
diagrams.push(...sub.diagrams);
|
|
133
|
+
files.push(...sub.files);
|
|
134
|
+
return sub.markdown;
|
|
135
|
+
}
|
|
136
|
+
const sameDir = fromDir === pageDir;
|
|
137
|
+
const to = sameDir ? decoded : path.posix.relative(pageDir, rel);
|
|
138
|
+
files.push({ from: rel, to });
|
|
139
|
+
return bang || embed
|
|
140
|
+
? `${bang}[${inner}](${sameDir ? href : encodeURIPath(to)}${title})`
|
|
141
|
+
: `[${inner}](${encodeURIPath(path.posix.join(...pagePath, to))}${ignoreTitle(title)})`;
|
|
121
142
|
});
|
|
122
143
|
return { path: pagePath, markdown: mapOutsideFences(markdown, rewrite).trim(), diagrams, files };
|
|
123
144
|
};
|
package/dist/util/paths.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.6.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",
|
package/template/AGENTS.md
CHANGED
|
@@ -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
|
|