@alfacapital-tech/c4builder 0.4.0 → 0.5.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/README.MD +69 -615
- package/dist/cli/commands/check.js +43 -26
- package/dist/cli/commands/help.js +5 -2
- package/dist/cli/commands/jre.js +6 -6
- package/dist/cli/commands/list.js +4 -1
- package/dist/cli/commands/new.js +6 -6
- package/dist/cli/commands/site.js +3 -2
- package/dist/cli/dispatch.js +38 -18
- package/dist/cli/wizard/collect.js +16 -10
- package/dist/config/defaults.js +1 -0
- package/dist/config/options.js +1 -1
- package/dist/config/schema.js +5 -2
- package/dist/core/build.js +15 -6
- package/dist/core/compose/docsify.template.js +0 -1
- package/dist/core/compose/markdown.js +85 -19
- package/dist/core/plugins/hooks.js +1 -1
- package/dist/core/plugins/load.js +6 -6
- package/dist/core/plugins/source.js +6 -6
- package/dist/core/plugins/tree.js +5 -5
- package/dist/core/render/bpmnrenderer.js +522 -0
- package/dist/core/render/d2renderer.js +19 -19
- package/dist/core/render/diagrams.js +64 -45
- package/dist/core/render/jre.js +14 -14
- package/dist/core/render/pngraster.js +4 -4
- package/dist/{plugins/openspec → core/scan}/fences.js +11 -10
- package/dist/core/scan/tree.js +37 -14
- package/dist/plugins/openapi/index.js +17 -7
- package/dist/plugins/openspec/index.js +1 -1
- package/dist/plugins/openspec/render.js +5 -2
- package/dist/util/archive.js +1 -1
- package/dist/util/http.js +3 -3
- package/dist/util/lock.js +3 -3
- package/dist/util/paths.js +1 -1
- package/package.json +9 -4
- package/template/AGENTS.md +52 -0
- package/template/readme.md +3 -3
- package/template/src/5 BPMN Example/account-opening.bpmn +69 -0
- package/template/src/5 BPMN Example/account-opening.md +7 -0
- package/dist/util/glob.js +0 -69
|
@@ -11,6 +11,8 @@ import { httpGetBuffer } from "../../util/http.js";
|
|
|
11
11
|
// D2-бэкенд: только статические хелперы (парсинг импортов) грузятся сразу; сам
|
|
12
12
|
// движок @terrastruct/d2 тянется лениво внутри renderD2/teardownD2.
|
|
13
13
|
import { renderD2, foldD2Imports } from "./d2renderer.js";
|
|
14
|
+
// BPMN-бэкенд: пакеты движка грузятся лениво внутри renderBpmn.
|
|
15
|
+
import { renderBpmn, BPMN_CACHE_PACKAGES } from "./bpmnrenderer.js";
|
|
14
16
|
import { resolveJava } from "./jre.js";
|
|
15
17
|
// PNG-выход: SVG обоих движков растеризуется resvg (ленивая загрузка внутри модуля).
|
|
16
18
|
import { rasterizeSvgToPng } from "./pngraster.js";
|
|
@@ -63,24 +65,43 @@ const runPool = async (tasks, concurrency) => {
|
|
|
63
65
|
if (firstError !== undefined)
|
|
64
66
|
throw firstError;
|
|
65
67
|
};
|
|
66
|
-
//
|
|
67
|
-
// Читаем package.json лениво и один раз: pure-PlantUML сборки
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
// Версии пакетов движков (D2, BPMN) для чексуммы кэша (апгрейд движка → перерендер).
|
|
69
|
+
// Читаем package.json лениво и один раз: pure-PlantUML сборки их не трогают.
|
|
70
|
+
const versionCache = new Map();
|
|
71
|
+
const pkgVersion = (name) => {
|
|
72
|
+
let version = versionCache.get(name);
|
|
73
|
+
if (version === undefined) {
|
|
74
|
+
// package.json ищем по путям поиска node_modules, а не require(`${name}/package.json`):
|
|
75
|
+
// пакеты с полем exports (bpmn-auto-layout) этот подпуть не отдают — версия молча
|
|
76
|
+
// становилась бы 'unknown', и апгрейд движка не перерисовывал бы диаграммы.
|
|
77
|
+
try {
|
|
78
|
+
const paths = createRequire(import.meta.url).resolve.paths(name) ?? [];
|
|
79
|
+
const dir = paths.find((p) => fs.existsSync(path.join(p, name, 'package.json')));
|
|
80
|
+
if (!dir)
|
|
81
|
+
throw new Error(`${name} not found`);
|
|
82
|
+
version = JSON.parse(fs.readFileSync(path.join(dir, name, 'package.json'), 'utf8')).version;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
version = 'unknown';
|
|
86
|
+
}
|
|
87
|
+
versionCache.set(name, version);
|
|
78
88
|
}
|
|
79
|
-
return
|
|
89
|
+
return version;
|
|
80
90
|
};
|
|
81
91
|
// В ключ кеша: смена режима не должна отдавать картинку, отрисованную другим шрифтом
|
|
82
92
|
// (D2 тоже — его PNG растрирует общий resvg).
|
|
83
93
|
export const fontCacheTag = (options) => options.USE_SYSTEM_FONTS ? 'system' : DEFAULT_FONT_NAME;
|
|
94
|
+
// renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа импортов.
|
|
95
|
+
// Без него смена layout (D2) или charset (PlantUML) не меняла чексумму → из бэкапа
|
|
96
|
+
// копировалась старая картинка (новые параметры молча игнорировались, кэш чистился
|
|
97
|
+
// только через --reset). Формат/движок/версия движка тоже в ключе: их смена обязана
|
|
98
|
+
// приводить к перерендеру.
|
|
99
|
+
export const renderKey = (diagram, options, outFormat) => (diagram.engine === 'd2'
|
|
100
|
+
? `d2\0${pkgVersion('@terrastruct/d2')}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
|
|
101
|
+
: diagram.engine === 'bpmn'
|
|
102
|
+
? `bpmn\0${BPMN_CACHE_PACKAGES.map(pkgVersion).join('\0')}\0fmt=${outFormat}`
|
|
103
|
+
: `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
|
|
104
|
+
`\0font=${fontCacheTag(options)}`;
|
|
84
105
|
export const getMime = (format) => {
|
|
85
106
|
if (format === 'svg')
|
|
86
107
|
return `image/svg+xml`;
|
|
@@ -129,7 +150,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
|
|
|
129
150
|
// с одним guard вместо non-null assertion на каждом обращении.
|
|
130
151
|
const { stdin, stdout: childOut, stderr: childErr } = child;
|
|
131
152
|
if (!stdin || !childOut || !childErr) {
|
|
132
|
-
return reject(new Error('PlantUML:
|
|
153
|
+
return reject(new Error('PlantUML: failed to open stdio streams of the java process'));
|
|
133
154
|
}
|
|
134
155
|
// Smetana печатает диагностический шум (UNSURE_ABOUT…) — это не ошибка
|
|
135
156
|
// рендера, пользователю не показываем; остальной stderr пробрасываем.
|
|
@@ -149,7 +170,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
|
|
|
149
170
|
sigkillTimer = setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS);
|
|
150
171
|
sigkillTimer.unref();
|
|
151
172
|
const tail = cleanStderr();
|
|
152
|
-
reject(new Error(`PlantUML
|
|
173
|
+
reject(new Error(`PlantUML did not finish in ${RENDER_TIMEOUT_MS / 1000} s and was aborted` +
|
|
153
174
|
(tail ? `\n${tail}` : '')));
|
|
154
175
|
}, RENDER_TIMEOUT_MS);
|
|
155
176
|
timer.unref();
|
|
@@ -175,7 +196,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
|
|
|
175
196
|
settled = true;
|
|
176
197
|
const errText = cleanStderr();
|
|
177
198
|
if (code !== 0) {
|
|
178
|
-
return reject(new Error(`PlantUML
|
|
199
|
+
return reject(new Error(`PlantUML exited with code ${code}\n${errText}`));
|
|
179
200
|
}
|
|
180
201
|
if (errText)
|
|
181
202
|
process.stderr.write(`${errText}\n`);
|
|
@@ -192,7 +213,7 @@ export const renderDiagram = (content, options) => new Promise((resolve, reject)
|
|
|
192
213
|
// сайт остаётся собранным (так же вёл себя онлайн-рендер docsify-plantuml).
|
|
193
214
|
const errorPlaceholderSvg = (name, reason) => {
|
|
194
215
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
195
|
-
const lines = [`${name}:
|
|
216
|
+
const lines = [`${name}: diagram not rendered`, ...reason.split('\n')]
|
|
196
217
|
.flatMap((l) => l.match(/.{1,88}/g) ?? [''])
|
|
197
218
|
.slice(0, 8);
|
|
198
219
|
const w = 720;
|
|
@@ -222,8 +243,8 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
222
243
|
const pinned = options.LEGACY_PLANTUML_VERSION;
|
|
223
244
|
if (pinned && pinned !== 'latest' && pinned !== VENDORED_JAR.version) {
|
|
224
245
|
console.log(chalk.bold(chalk.yellow('WARNING:')));
|
|
225
|
-
console.log(chalk.yellow(
|
|
226
|
-
|
|
246
|
+
console.log(chalk.yellow(`Choosing the PlantUML version was removed — builds use the vendored JAR ${VENDORED_JAR.version}. ` +
|
|
247
|
+
`The plantumlVersion: "${pinned}" key can be removed from .c4builder.`));
|
|
227
248
|
}
|
|
228
249
|
// JRE резолвится ЛЕНИВО и один раз за сборку: только когда встретилась PlantUML-
|
|
229
250
|
// диаграмма, которую РЕАЛЬНО надо рендерить (кэш не сработал — restore-путь ниже
|
|
@@ -255,29 +276,25 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
255
276
|
// перепутываются). Движок всё равно однопоточный, так что сериализация D2 бесплатна.
|
|
256
277
|
// PlantUML — отдельные процессы java, их гоняем пулом renderConcurrency().
|
|
257
278
|
const d2Tasks = [];
|
|
279
|
+
// BPMN рендерится в общем окне jsdom главного потока — параллельность ничего не даёт,
|
|
280
|
+
// а последовательная очередь исключает гонки на общем состоянии бандла bpmn-js.
|
|
281
|
+
const bpmnTasks = [];
|
|
258
282
|
const otherTasks = [];
|
|
259
283
|
for (const item of tree) {
|
|
260
284
|
for (const diagram of item.diagrams) {
|
|
261
285
|
// Чексумма = контент диаграммы + свёрнутый граф её зависимостей, чтобы
|
|
262
286
|
// правка включаемого/импортируемого файла инвалидировала кэш: PlantUML —
|
|
263
|
-
// !include-граф (.iuml и пр.), D2 — граф @/...@-импортов.
|
|
287
|
+
// !include-граф (.iuml и пр.), D2 — граф @/...@-импортов. У BPMN зависимостей нет.
|
|
264
288
|
const body = `${diagram.content || ''}`;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
const includes =
|
|
289
|
+
const entryPath = path.join(item.dir, diagram.dir);
|
|
290
|
+
const isD2 = diagram.engine === 'd2';
|
|
291
|
+
const isBpmn = diagram.engine === 'bpmn';
|
|
292
|
+
const includes = isD2
|
|
269
293
|
? foldD2Imports(entryPath, body)
|
|
270
|
-
:
|
|
294
|
+
: isBpmn
|
|
295
|
+
? ''
|
|
296
|
+
: foldIncludes(body, item.dir, item.dir, new Set());
|
|
271
297
|
const outFormat = diagramOutputFormat(diagram, options);
|
|
272
|
-
// renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
|
|
273
|
-
// импортов. Без него смена layout (D2) или charset (PlantUML) не меняла
|
|
274
|
-
// чексумму → из бэкапа копировалась старая картинка (новые параметры молча
|
|
275
|
-
// игнорировались, кэш чистился только через --reset). Формат/движок/версия
|
|
276
|
-
// движка тоже в ключе: их смена обязана приводить к перерендеру.
|
|
277
|
-
const renderKey = (entryPath !== null
|
|
278
|
-
? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
|
|
279
|
-
: `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
|
|
280
|
-
`\0font=${fontCacheTag(options)}`;
|
|
281
298
|
const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
|
|
282
299
|
const relOut = path.join(item.dir.replace(options.ROOT_FOLDER, ''), outName);
|
|
283
300
|
// Путь выхода — тоже в чексумме: восстановление из бэкапа идёт по паре
|
|
@@ -286,7 +303,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
286
303
|
// молча получали бы чужую старую картинку.
|
|
287
304
|
const cksum = crypto
|
|
288
305
|
.createHash('sha256')
|
|
289
|
-
.update(`${body}${includes}${renderKey}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
|
|
306
|
+
.update(`${body}${includes}${renderKey(diagram, options, outFormat)}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
|
|
290
307
|
.digest('hex');
|
|
291
308
|
// Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
|
|
292
309
|
// порядок массива не зависит от того, кто из воркеров финиширует раньше —
|
|
@@ -323,9 +340,13 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
323
340
|
// Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
|
|
324
341
|
let rendered;
|
|
325
342
|
try {
|
|
326
|
-
if (
|
|
343
|
+
if (isD2) {
|
|
327
344
|
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
|
|
328
345
|
}
|
|
346
|
+
else if (isBpmn) {
|
|
347
|
+
// Путь для сообщений валидации; у виртуальных страниц — их источник.
|
|
348
|
+
rendered = await renderBpmn(body, source ?? path.relative(process.cwd(), entryPath).split(path.sep).join('/'));
|
|
349
|
+
}
|
|
329
350
|
else {
|
|
330
351
|
// Резолв JRE отложен до первого реального PlantUML-рендера (эта
|
|
331
352
|
// ветка исполняется лишь при промахе кэша). getJava мемоизирует.
|
|
@@ -349,7 +370,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
349
370
|
catch (err) {
|
|
350
371
|
// Имя диаграммы в ошибку: renderDiagram/renderD2 сами его не знают.
|
|
351
372
|
const reason = `${err.message || err}`;
|
|
352
|
-
const msg =
|
|
373
|
+
const msg = `Diagram "${outName}"${source ? ` (${source})` : ''}: ${reason}`;
|
|
353
374
|
if (!soft)
|
|
354
375
|
throw new Error(msg);
|
|
355
376
|
// Диаграммы из markdown-блоков плагинов писались под онлайн-рендер,
|
|
@@ -359,20 +380,18 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
359
380
|
await writeFile(filePath, outFormat === 'png' ? await rasterizeSvgToPng(svg, options.USE_SYSTEM_FONTS) : svg);
|
|
360
381
|
}
|
|
361
382
|
});
|
|
362
|
-
|
|
363
|
-
d2Tasks.push(task);
|
|
364
|
-
else
|
|
365
|
-
otherTasks.push(task);
|
|
383
|
+
(isD2 ? d2Tasks : isBpmn ? bpmnTasks : otherTasks).push(task);
|
|
366
384
|
}
|
|
367
385
|
}
|
|
368
|
-
// PlantUML
|
|
369
|
-
// параллельно друг другу. allSettled — чтобы падение одной очереди не
|
|
370
|
-
//
|
|
386
|
+
// PlantUML-пул, D2- и BPMN-очереди работают независимо (процессы java, WASM-worker,
|
|
387
|
+
// главный поток) и параллельно друг другу. allSettled — чтобы падение одной очереди не
|
|
388
|
+
// оставляло другие как floating promise с висящими JVM: все доосушаются, затем бросаем первую
|
|
371
389
|
// ошибку (приоритет — PlantUML-пул, порядок между очередями не значим).
|
|
372
390
|
try {
|
|
373
391
|
const results = await Promise.allSettled([
|
|
374
392
|
runPool(otherTasks, renderConcurrency()),
|
|
375
|
-
runPool(d2Tasks, 1)
|
|
393
|
+
runPool(d2Tasks, 1),
|
|
394
|
+
runPool(bpmnTasks, 1)
|
|
376
395
|
]);
|
|
377
396
|
const failed = results.find((r) => r.status === 'rejected');
|
|
378
397
|
if (failed && failed.status === 'rejected')
|
package/dist/core/render/jre.js
CHANGED
|
@@ -177,15 +177,15 @@ const fetchAssetMeta = async () => {
|
|
|
177
177
|
const json = await httpGetJson(url, { headers: JRE_UA });
|
|
178
178
|
const asset = Array.isArray(json) ? json.find((a) => a.binary?.package) : null;
|
|
179
179
|
if (!asset) {
|
|
180
|
-
throw new Error(`Adoptium
|
|
180
|
+
throw new Error(`Adoptium has no JRE build for ${adoptiumOs()}/${adoptiumArch()} (Temurin ${TEMURIN_FEATURE})`);
|
|
181
181
|
}
|
|
182
182
|
const pkg = asset.binary.package;
|
|
183
183
|
if (!pkg.link)
|
|
184
|
-
throw new Error('Adoptium
|
|
184
|
+
throw new Error('Adoptium returned no JRE archive link');
|
|
185
185
|
// Отсутствие checksum — ошибка, а не молчаливый пропуск проверки целостности.
|
|
186
186
|
if (!pkg.checksum) {
|
|
187
|
-
throw new Error(`Adoptium
|
|
188
|
-
'
|
|
187
|
+
throw new Error(`Adoptium returned no sha256 for ${pkg.name || 'the JRE archive'}: ` +
|
|
188
|
+
'refusing to accept an archive without an integrity check');
|
|
189
189
|
}
|
|
190
190
|
return { link: pkg.link, sha256: pkg.checksum, name: pkg.name, release: asset.release_name };
|
|
191
191
|
};
|
|
@@ -202,9 +202,9 @@ const downloadAndVerify = async (link, expectedSha, destFile) => {
|
|
|
202
202
|
});
|
|
203
203
|
const actual = hash.digest('hex').toLowerCase();
|
|
204
204
|
if (!expectedSha)
|
|
205
|
-
throw new Error('
|
|
205
|
+
throw new Error('No expected sha256 — cannot verify archive integrity');
|
|
206
206
|
if (actual !== expectedSha.toLowerCase()) {
|
|
207
|
-
throw new Error(`sha256
|
|
207
|
+
throw new Error(`archive sha256 mismatch: expected ${expectedSha}, got ${actual}`);
|
|
208
208
|
}
|
|
209
209
|
};
|
|
210
210
|
const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : extractTarGz(archive, destDir);
|
|
@@ -223,13 +223,13 @@ const downloadJre = async ({ log } = {}) => {
|
|
|
223
223
|
const stageDir = `${dir}.tmp-${process.pid}`;
|
|
224
224
|
try {
|
|
225
225
|
if (log)
|
|
226
|
-
log(
|
|
226
|
+
log(`Downloading Temurin ${TEMURIN_FEATURE} JRE (${meta.release || meta.name})…`);
|
|
227
227
|
await downloadAndVerify(meta.link, meta.sha256, tmpArchive);
|
|
228
228
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
229
229
|
fs.mkdirSync(stageDir, { recursive: true });
|
|
230
230
|
await extractArchive(tmpArchive, stageDir, isZip);
|
|
231
231
|
if (!findJavaUnder(stageDir)) {
|
|
232
|
-
throw new Error('JRE
|
|
232
|
+
throw new Error('JRE extracted, but the bin/java executable was not found');
|
|
233
233
|
}
|
|
234
234
|
fs.writeFileSync(markerPath(stageDir), JSON.stringify({
|
|
235
235
|
schema: JRE_CACHE_SCHEMA,
|
|
@@ -261,15 +261,15 @@ const downloadJre = async ({ log } = {}) => {
|
|
|
261
261
|
}
|
|
262
262
|
const bin = findJavaUnder(dir);
|
|
263
263
|
if (!bin)
|
|
264
|
-
throw new Error('JRE
|
|
264
|
+
throw new Error('JRE extracted, but the bin/java executable was not found');
|
|
265
265
|
return { path: bin, source: 'download' };
|
|
266
266
|
};
|
|
267
267
|
const failureMessage = (cause) => [
|
|
268
|
-
'
|
|
269
|
-
'
|
|
270
|
-
' •
|
|
271
|
-
' •
|
|
272
|
-
cause?.message ?
|
|
268
|
+
'Could not get Java to render PlantUML diagrams.',
|
|
269
|
+
'Neither system java (17+), the cache, nor a download from Adoptium worked. Do one of the following:',
|
|
270
|
+
' • install JRE 17+ (e.g. Eclipse Temurin) — java on PATH or set JAVA_HOME;',
|
|
271
|
+
' • run `c4builder jre install` to download a JRE into the local cache.',
|
|
272
|
+
cause?.message ? `Cause: ${cause.message}` : ''
|
|
273
273
|
]
|
|
274
274
|
.filter(Boolean)
|
|
275
275
|
.join('\n');
|
|
@@ -6,7 +6,7 @@ import { FONTS_DIR, DEFAULT_FONT_NAME } from "./fonts.js";
|
|
|
6
6
|
// растеризации, не на импорте модуля).
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
let resvgMod = null;
|
|
9
|
-
const getResvg = () => {
|
|
9
|
+
export const getResvg = () => {
|
|
10
10
|
if (resvgMod)
|
|
11
11
|
return resvgMod;
|
|
12
12
|
try {
|
|
@@ -14,9 +14,9 @@ const getResvg = () => {
|
|
|
14
14
|
}
|
|
15
15
|
catch (err) {
|
|
16
16
|
const e = err;
|
|
17
|
-
throw new Error('
|
|
18
|
-
'
|
|
19
|
-
|
|
17
|
+
throw new Error('PNG output (DIAGRAM_FORMAT=png) requires the @resvg/resvg-js package.\n' +
|
|
18
|
+
'Install it: npm install @resvg/resvg-js\n' +
|
|
19
|
+
`Original error: ${e.message || e}`);
|
|
20
20
|
}
|
|
21
21
|
return resvgMod;
|
|
22
22
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Fenced-блоки
|
|
2
|
-
// локальным движком) + ссылка `` на их месте:
|
|
3
|
-
// inline, а клиентский docsify-plantuml (онлайн-рендер в
|
|
4
|
-
// fence в выводе уже отсутствует.
|
|
1
|
+
// Fenced-блоки диаграмм в markdown (страницы проекта, артефакты плагинов) → диаграммы
|
|
2
|
+
// страницы (рендер общим локальным движком) + ссылка `` на их месте:
|
|
3
|
+
// compose подставит картинку inline, а клиентский docsify-plantuml (онлайн-рендер в
|
|
4
|
+
// браузере) не сработает — fence в выводе уже отсутствует.
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
// Fenced-блок по CommonMark: открывающая ``` или ~~~ (≥3) с любым отступом (fence в
|
|
7
7
|
// списке), инфо-строка, тело (может быть пустым), закрывающая того же вида не короче
|
|
@@ -19,18 +19,19 @@ export const mapOutsideFences = (md, fn) => {
|
|
|
19
19
|
return out + fn(md.slice(last));
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* Экстрактор диаграмм одной
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Экстрактор диаграмм одной страницы; извлекаются только блоки языков `langs` (из
|
|
23
|
+
* plantuml/puml/d2), остальные остаются кодом. Имя файла — `<base>-<sha1 контента>`: не
|
|
24
|
+
* зависит от позиции fence, поэтому вставка/удаление соседнего блока не сдвигает имена и
|
|
25
|
+
* не подсовывает картинку соседа из кэша; одинаковые блоки дают одну диаграмму.
|
|
25
26
|
*/
|
|
26
|
-
export const createFenceExtractor = () => {
|
|
27
|
+
export const createFenceExtractor = (langs) => {
|
|
27
28
|
const seen = new Set();
|
|
28
29
|
return {
|
|
29
30
|
extract(md, base, source) {
|
|
30
31
|
const diagrams = [];
|
|
31
32
|
const markdown = md.replace(FENCE_RE, (whole, indent, _fence, info, body) => {
|
|
32
33
|
const lang = info.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
33
|
-
const ext = ENGINE_EXT[lang];
|
|
34
|
+
const ext = langs.includes(lang) ? ENGINE_EXT[lang] : undefined;
|
|
34
35
|
if (!ext)
|
|
35
36
|
return whole;
|
|
36
37
|
// Отступ fence (в списке) снимаем со строк тела, как CommonMark.
|
|
@@ -45,7 +46,7 @@ export const createFenceExtractor = () => {
|
|
|
45
46
|
const file = `${base}-${hash}${ext}`;
|
|
46
47
|
if (!seen.has(file)) {
|
|
47
48
|
seen.add(file);
|
|
48
|
-
// soft: блок в
|
|
49
|
+
// soft: блок в markdown — не файл диаграммы; синтаксическая
|
|
49
50
|
// ошибка в нём не должна ронять весь сайт (раньше такие блоки
|
|
50
51
|
// рендерил браузер и показывал картинку с ошибкой).
|
|
51
52
|
diagrams.push({ file, content, source, soft: true });
|
package/dist/core/scan/tree.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fsextra from 'fs-extra';
|
|
4
4
|
import { makeDirectory, readFile } from "../../util/utils.js";
|
|
5
|
+
import { createFenceExtractor } from "./fences.js";
|
|
5
6
|
// Единый реестр движков диаграмм: расширение (с точкой) → рендерер + способности.
|
|
6
7
|
// ЕДИНСТВЕННЫЙ источник истины о том, какие файлы считаются диаграммами. Из него
|
|
7
8
|
// строятся фильтр scan (ниже), regex ссылок в compose/markdown.ts и ветвление
|
|
@@ -9,7 +10,8 @@ import { makeDirectory, readFile } from "../../util/utils.js";
|
|
|
9
10
|
// здесь; фазы подхватят его без синхронной правки.
|
|
10
11
|
export const DIAGRAM_ENGINES = [
|
|
11
12
|
{ ext: '.puml', engine: 'plantuml', remoteRender: true },
|
|
12
|
-
{ ext: '.d2', engine: 'd2', remoteRender: false }
|
|
13
|
+
{ ext: '.d2', engine: 'd2', remoteRender: false },
|
|
14
|
+
{ ext: '.bpmn', engine: 'bpmn', remoteRender: false }
|
|
13
15
|
];
|
|
14
16
|
// Расширение (lowercase, с точкой) → движок. undefined ⇒ файл не диаграмма.
|
|
15
17
|
export const engineForExt = (ext) => DIAGRAM_ENGINES.find((e) => e.ext === ext)?.engine;
|
|
@@ -73,10 +75,25 @@ export const generateTree = async (dir, options) => {
|
|
|
73
75
|
await build(childDir, dir);
|
|
74
76
|
}
|
|
75
77
|
}
|
|
78
|
+
// Блоки ```plantuml в страницах рендерим при сборке: иначе текст диаграммы доезжал
|
|
79
|
+
// до браузера и клиентский docsify-plantuml (в т.ч. в пользовательских шаблонах)
|
|
80
|
+
// отправлял его на plantuml.com. puml/d2 остаются кодом — так показывают исходник.
|
|
81
|
+
const fences = createFenceExtractor(['plantuml']);
|
|
76
82
|
const mdFiles = files.filter((x) => path.extname(x).toLowerCase() === '.md');
|
|
77
83
|
for (const mdFile of mdFiles) {
|
|
78
|
-
const
|
|
79
|
-
|
|
84
|
+
const mdPath = path.join(dir, mdFile);
|
|
85
|
+
const { markdown, diagrams } = fences.extract((await readFile(mdPath)).toString(), path.parse(mdFile).name, path.relative(process.cwd(), mdPath).split(path.sep).join('/'));
|
|
86
|
+
item.mdFiles.push(markdown);
|
|
87
|
+
for (const d of diagrams)
|
|
88
|
+
item.diagrams.push({
|
|
89
|
+
dir: d.file,
|
|
90
|
+
ext: '.puml',
|
|
91
|
+
engine: 'plantuml',
|
|
92
|
+
content: d.content,
|
|
93
|
+
isDitaa: /@startditaa/i.test(d.content),
|
|
94
|
+
source: d.source,
|
|
95
|
+
soft: d.soft
|
|
96
|
+
});
|
|
80
97
|
}
|
|
81
98
|
// Диаграммы: расширение → движок берём из DIAGRAM_ENGINES. Поле dir — имя файла
|
|
82
99
|
// (историческое), engine выбирает рендерер, isDitaa — только для PlantUML.
|
|
@@ -91,7 +108,7 @@ export const generateTree = async (dir, options) => {
|
|
|
91
108
|
item.diagrams.push({ dir: diagramFile, ext, engine, content: fileContents, isDitaa });
|
|
92
109
|
}
|
|
93
110
|
item.diagrams.sort((a, b) => `${a.dir}`.localeCompare(b.dir));
|
|
94
|
-
//copy all other files (.d2 исходники, как и .puml, не копируем — они рендерятся)
|
|
111
|
+
//copy all other files (.d2/.bpmn исходники, как и .puml, не копируем — они рендерятся)
|
|
95
112
|
const otherFiles = options.EXCLUDE_OTHER_FILES
|
|
96
113
|
? []
|
|
97
114
|
: files.filter((x) => {
|
|
@@ -118,8 +135,8 @@ export const generateTree = async (dir, options) => {
|
|
|
118
135
|
for (const d of item.diagrams) {
|
|
119
136
|
const out = `${path.parse(d.dir).name}.${diagramOutputFormat(d, options)}`;
|
|
120
137
|
if (seen.has(out))
|
|
121
|
-
throw new Error(
|
|
122
|
-
|
|
138
|
+
throw new Error(`Output name collision '${out}' in ${item.dir}: '${seen.get(out)}' and '${d.dir}' ` +
|
|
139
|
+
`render to the same file. Rename one of the diagrams.`);
|
|
123
140
|
seen.set(out, d.dir);
|
|
124
141
|
}
|
|
125
142
|
}
|
|
@@ -131,14 +148,13 @@ export const generateTree = async (dir, options) => {
|
|
|
131
148
|
// build() — в watch-режиме иначе отдавались бы устаревшие данные.
|
|
132
149
|
const includeReadCache = new Map();
|
|
133
150
|
export const clearIncludeCache = () => includeReadCache.clear();
|
|
134
|
-
//
|
|
135
|
-
//
|
|
151
|
+
// Локальные !include (.iuml и пр.) диаграммы рекурсивно, в DFS-порядке: каждый файл —
|
|
152
|
+
// один раз (visited). Один резолвер на чексумму кэша (foldIncludes) и приложение
|
|
153
|
+
// llms-full.txt — иначе граф зависимостей у них разъехался бы.
|
|
136
154
|
// URL (!include https://…) и stdlib (!include <…>) пропускаем: локально не меняются.
|
|
137
|
-
|
|
138
|
-
// машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
|
|
139
|
-
export const foldIncludes = (content, fileDir, searchDir, visited) => {
|
|
155
|
+
export const collectIncludes = (content, fileDir, searchDir, visited) => {
|
|
140
156
|
const re = /^[ \t]*!include(?:_once|_many|sub|url)?[ \t]+(.+?)[ \t]*$/gim;
|
|
141
|
-
|
|
157
|
+
const out = [];
|
|
142
158
|
let m;
|
|
143
159
|
// biome-ignore lint/suspicious/noAssignInExpressions: идиома regex.exec() в условии while
|
|
144
160
|
while ((m = re.exec(content)) !== null) {
|
|
@@ -166,8 +182,15 @@ export const foldIncludes = (content, fileDir, searchDir, visited) => {
|
|
|
166
182
|
}
|
|
167
183
|
if (inc === null)
|
|
168
184
|
continue;
|
|
169
|
-
out
|
|
170
|
-
out
|
|
185
|
+
out.push({ abs: resolved, content: inc });
|
|
186
|
+
out.push(...collectIncludes(inc, path.dirname(resolved), searchDir, visited)); // вложенные include
|
|
171
187
|
}
|
|
172
188
|
return out;
|
|
173
189
|
};
|
|
190
|
+
// Свернуть include-граф диаграммы в материал для чексуммы: правка включённого .iuml
|
|
191
|
+
// обязана инвалидировать кэш, иначе на сайт уезжает устаревший рендер.
|
|
192
|
+
// Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит от
|
|
193
|
+
// машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
|
|
194
|
+
export const foldIncludes = (content, fileDir, searchDir, visited) => collectIncludes(content, fileDir, searchDir, visited)
|
|
195
|
+
.map((inc) => ` ${path.relative(process.cwd(), inc.abs).split(path.sep).join('/')} ${inc.content}`)
|
|
196
|
+
.join('');
|
|
@@ -8,7 +8,7 @@ import { z } from 'zod';
|
|
|
8
8
|
import { outputDirs } from "../../config/options.js";
|
|
9
9
|
import { redactUrl } from "../../core/plugins/source.js";
|
|
10
10
|
import { definePlugin } from "../../core/plugins/types.js";
|
|
11
|
-
import {
|
|
11
|
+
import { isPathInside } from "../../util/archive.js";
|
|
12
12
|
import { VENDOR_DIR } from "../../util/paths.js";
|
|
13
13
|
import { encodeURIPath } from "../../util/utils.js";
|
|
14
14
|
const optionsSchema = z
|
|
@@ -22,8 +22,17 @@ const optionsSchema = z
|
|
|
22
22
|
})
|
|
23
23
|
.strict()
|
|
24
24
|
.refine((o) => (o.dir === undefined) !== (o.archive === undefined), {
|
|
25
|
-
message: '
|
|
25
|
+
message: 'exactly one source required: dir or archive'
|
|
26
26
|
});
|
|
27
|
+
// Файлы под root по glob: posix-пути, сортировка. Каталоги с точкой (.git) fs.glob не
|
|
28
|
+
// обходит сам. Выходные каталоги (skip) отсеиваются по результату, а не в exclude:
|
|
29
|
+
// под `**` Node передаёт в exclude лишь имя записи, и вложенный `x/docs` был бы
|
|
30
|
+
// неотличим от корневого `docs`.
|
|
31
|
+
const findFiles = (root, pattern, skip) => fs
|
|
32
|
+
.globSync(pattern, { cwd: root, exclude: (p) => path.basename(p) === 'node_modules' })
|
|
33
|
+
.filter((rel) => !skip.some((d) => isPathInside(d, path.resolve(root, rel))))
|
|
34
|
+
.map((rel) => rel.split(path.sep).join('/'))
|
|
35
|
+
.sort();
|
|
27
36
|
// Имя страницы: родительская папка спеки; в корне источника — имя файла без расширения.
|
|
28
37
|
const pageName = (rel) => {
|
|
29
38
|
const dir = path.posix.dirname(rel);
|
|
@@ -52,20 +61,20 @@ export default definePlugin({
|
|
|
52
61
|
// Источник может быть предком выходного каталога (dir: '.') — свои же копии
|
|
53
62
|
// спек прошлой сборки в dist/dist_bk не считаются источником.
|
|
54
63
|
const skip = outputDirs(ctx.options);
|
|
55
|
-
const files =
|
|
64
|
+
const files = findFiles(root, o.glob, skip);
|
|
56
65
|
if (!files.length)
|
|
57
|
-
throw new Error(
|
|
66
|
+
throw new Error(`no specs match "${o.glob}" in ${source}`);
|
|
58
67
|
const names = new Map();
|
|
59
68
|
for (const rel of files) {
|
|
60
69
|
const name = pageName(rel);
|
|
61
70
|
const dup = names.get(name);
|
|
62
71
|
if (dup)
|
|
63
|
-
throw new Error(
|
|
72
|
+
throw new Error(`two specs give the same page name "${name}": ${dup} and ${rel}`);
|
|
64
73
|
names.set(name, rel);
|
|
65
74
|
}
|
|
66
75
|
// Статикой в dist — все yaml/json источника, а не только совпавшие с glob: $ref
|
|
67
76
|
// может вести в общие схемы (components), не являющиеся спеками.
|
|
68
|
-
for (const rel of
|
|
77
|
+
for (const rel of findFiles(root, '**/*.{yaml,yml,json}', skip)) {
|
|
69
78
|
const dest = path.join(ctx.options.DIST_FOLDER, mount, '_specs', rel);
|
|
70
79
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
71
80
|
fs.copyFileSync(path.join(root, rel), dest);
|
|
@@ -79,6 +88,7 @@ export default definePlugin({
|
|
|
79
88
|
for (const [name, rel] of sorted) {
|
|
80
89
|
// Спека грузится swagger-ui по ссылке от корня сайта (SPA: base = index.html);
|
|
81
90
|
// относительные $ref внутри спек резолвятся от её URL — структура сохранена.
|
|
91
|
+
// validatorUrl: null — иначе swagger-ui шлёт адрес спеки на validator.swagger.io.
|
|
82
92
|
const specUrl = encodeURIPath(path.posix.join(mount, '_specs', rel));
|
|
83
93
|
const domId = `swagger-${name.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
84
94
|
ctx.addPage({
|
|
@@ -86,7 +96,7 @@ export default definePlugin({
|
|
|
86
96
|
markdown: [
|
|
87
97
|
`<div id="${domId}"></div>`,
|
|
88
98
|
'<script>',
|
|
89
|
-
`SwaggerUIBundle({ url: '${specUrl}', dom_id: '#${domId}' });`,
|
|
99
|
+
`SwaggerUIBundle({ url: '${specUrl}', dom_id: '#${domId}', validatorUrl: null });`,
|
|
90
100
|
'</script>'
|
|
91
101
|
].join('\n')
|
|
92
102
|
});
|
|
@@ -22,7 +22,7 @@ export default definePlugin({
|
|
|
22
22
|
afterScan(ctx, o) {
|
|
23
23
|
const storeDir = path.resolve(o.dir);
|
|
24
24
|
if (!fs.existsSync(storeDir))
|
|
25
|
-
throw new Error(`OpenSpec store
|
|
25
|
+
throw new Error(`OpenSpec store not found: ${storeDir} (dir option of the openspec plugin)`);
|
|
26
26
|
const store = scanStore(storeDir);
|
|
27
27
|
const { mount } = o;
|
|
28
28
|
const { options } = ctx;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Markdown страниц раздела OpenSpec: сводка, change и его подстраницы, индексы подразделов.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { createFenceExtractor, mapOutsideFences } from "../../core/scan/fences.js";
|
|
4
5
|
import { encodeURIPath } from "../../util/utils.js";
|
|
5
|
-
import { createFenceExtractor, mapOutsideFences } from "./fences.js";
|
|
6
6
|
// Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
|
|
7
7
|
// а docsify без relativePath резолвит ссылки от корня сайта.
|
|
8
8
|
export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
|
|
@@ -80,6 +80,9 @@ const ignoreTitle = (title) => {
|
|
|
80
80
|
};
|
|
81
81
|
/** Путь файла для сообщений — от cwd, чтобы его можно было открыть как есть. */
|
|
82
82
|
export const relSource = (root, rel) => path.relative(process.cwd(), path.join(root, rel)).split(path.sep).join('/');
|
|
83
|
+
// В артефактах OpenSpec любой блок диаграммы — иллюстрация, а не пример исходника
|
|
84
|
+
// (в отличие от страниц проекта, где извлекается только ```plantuml).
|
|
85
|
+
const FENCE_LANGS = ['plantuml', 'puml', 'd2'];
|
|
83
86
|
/**
|
|
84
87
|
* Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
|
|
85
88
|
* на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
|
|
@@ -88,7 +91,7 @@ export const relSource = (root, rel) => path.relative(process.cwd(), path.join(r
|
|
|
88
91
|
* `fromDir` — posix-каталог файла относительно `srcRoot` (ссылки в нём резолвятся от файла).
|
|
89
92
|
*/
|
|
90
93
|
export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, source, options }) => {
|
|
91
|
-
const { markdown, diagrams } = createFenceExtractor().extract(content, base, source);
|
|
94
|
+
const { markdown, diagrams } = createFenceExtractor(FENCE_LANGS).extract(content, base, source);
|
|
92
95
|
const files = [];
|
|
93
96
|
const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
|
|
94
97
|
const inner = text.includes('![') ? rewrite(text) : text;
|
package/dist/util/archive.js
CHANGED
|
@@ -20,7 +20,7 @@ export const extractZip = (archive, destDir) => new Promise((resolve, reject) =>
|
|
|
20
20
|
zip.on('entry', (entry) => {
|
|
21
21
|
// zip-slip: злонамеренная запись `../../evil` вышла бы за destDir.
|
|
22
22
|
if (!isPathInside(destDir, entry.fileName)) {
|
|
23
|
-
return reject(new Error(
|
|
23
|
+
return reject(new Error(`Unsafe path in archive (escapes the folder): ${entry.fileName}`));
|
|
24
24
|
}
|
|
25
25
|
// Симлинки пропускаем: в недоверенном архиве симлинк мог бы указывать
|
|
26
26
|
// за пределы каталога.
|
package/dist/util/http.js
CHANGED
|
@@ -25,21 +25,21 @@ export const httpGetStream = (url, opts = {}) => {
|
|
|
25
25
|
if (status >= 300 && status < 400 && location) {
|
|
26
26
|
res.resume();
|
|
27
27
|
if (redirectsLeft <= 0) {
|
|
28
|
-
return reject(new Error(
|
|
28
|
+
return reject(new Error(`Too many redirects (>${MAX_REDIRECTS}) for ${url}`));
|
|
29
29
|
}
|
|
30
30
|
const next = new URL(location, url).toString();
|
|
31
31
|
return resolve(httpGetStream(next, { headers, redirectsLeft: redirectsLeft - 1 }));
|
|
32
32
|
}
|
|
33
33
|
if (status < 200 || status > 299) {
|
|
34
34
|
res.resume();
|
|
35
|
-
return reject(new Error(
|
|
35
|
+
return reject(new Error(`Failed to download ${url}, status code: ${status}`));
|
|
36
36
|
}
|
|
37
37
|
// Коннект установлен → с таймаута коннекта переключаемся на таймаут простоя
|
|
38
38
|
// приёма (крупный ответ тянется дольше): молчащий сокет уронит таймаут.
|
|
39
39
|
req.setTimeout(IDLE_TIMEOUT_MS);
|
|
40
40
|
resolve(res);
|
|
41
41
|
});
|
|
42
|
-
req.on('timeout', () => req.destroy(new Error(
|
|
42
|
+
req.on('timeout', () => req.destroy(new Error(`Network timeout (${url})`)));
|
|
43
43
|
req.on('error', reject);
|
|
44
44
|
});
|
|
45
45
|
};
|