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

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.
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { engineForExt } from "../scan/tree.js";
4
+ // Виртуальные элементы дерева (добавлены плагинами, каталога на диске нет). Нужно
5
+ // лишь для правила коллизий в addPage: реальную папку затирать нельзя, а свой же
6
+ // пустой промежуточный узел плагин вправе наполнить позже.
7
+ const VIRTUAL = new WeakSet();
8
+ export const isVirtual = (item) => VIRTUAL.has(item);
9
+ // Индекс сразу за концом поддерева элемента: дерево DFS-упорядочено, потомки идут
10
+ // подряд за родителем — вставка туда сохраняет порядок обхода (sidebar/навигация).
11
+ const subtreeEnd = (tree, parent) => {
12
+ const prefix = parent.dir + path.sep;
13
+ let i = tree.indexOf(parent) + 1;
14
+ while (i < tree.length && tree[i].dir.startsWith(prefix))
15
+ i++;
16
+ return i;
17
+ };
18
+ /**
19
+ * Добавляет виртуальную страницу в дерево сборки: считает `dir`/`level`/`parent` как
20
+ * scan (dir = ROOT_FOLDER/сегменты), дополняет `descendants` родителя, вставляет в конец
21
+ * поддерева родителя; недостающие промежуточные сегменты создаёт пустыми узлами
22
+ * (авто-заголовок, как папка без README). Каталог в dist создаётся здесь же — compose
23
+ * пишет страницы без mkdir, полагаясь на scan.
24
+ */
25
+ export const addPage = (tree, options, page) => {
26
+ const segments = page.path;
27
+ if (!segments.length || segments.some((s) => !s || /[\\/]/.test(s) || s === '.' || s === '..'))
28
+ throw new Error(`addPage: некорректный путь страницы ${JSON.stringify(segments)}`);
29
+ const root = tree.find((x) => !x.parent);
30
+ if (!root)
31
+ throw new Error('addPage: в дереве нет корневого элемента');
32
+ let parent = root;
33
+ let item = root;
34
+ for (const [i, seg] of segments.entries()) {
35
+ const dir = path.join(parent.dir, seg);
36
+ const last = i === segments.length - 1;
37
+ const existing = tree.find((x) => x.dir === dir);
38
+ if (existing) {
39
+ if (last) {
40
+ if (!VIRTUAL.has(existing))
41
+ throw new Error(`addPage: путь ${segments.join('/')} занят реальной папкой ${dir} — переименуйте раздел плагина (mount)`);
42
+ if (existing.mdFiles.length || existing.diagrams.length)
43
+ throw new Error(`addPage: страница ${segments.join('/')} уже добавлена`);
44
+ }
45
+ item = existing;
46
+ }
47
+ else {
48
+ item = {
49
+ dir,
50
+ name: seg,
51
+ level: dir.split(path.sep).length,
52
+ parent: parent.dir,
53
+ mdFiles: [],
54
+ diagrams: [],
55
+ descendants: []
56
+ };
57
+ VIRTUAL.add(item);
58
+ parent.descendants.push(seg);
59
+ tree.splice(subtreeEnd(tree, parent), 0, item);
60
+ if (options.GENERATE_WEBSITE || options.GENERATE_MD || options.GENERATE_LOCAL_IMAGES)
61
+ fs.mkdirSync(path.join(options.DIST_FOLDER, dir.replace(options.ROOT_FOLDER, '')), {
62
+ recursive: true
63
+ });
64
+ }
65
+ parent = item;
66
+ }
67
+ const md = page.markdown === undefined ? [] : Array.isArray(page.markdown) ? page.markdown : [page.markdown];
68
+ item.mdFiles.push(...md);
69
+ for (const d of page.diagrams ?? []) {
70
+ const ext = path.extname(d.file).toLowerCase();
71
+ const engine = engineForExt(ext);
72
+ if (!engine)
73
+ throw new Error(`addPage: у диаграммы "${d.file}" страницы ${segments.join('/')} неизвестное расширение`);
74
+ item.diagrams.push({
75
+ dir: d.file,
76
+ ext,
77
+ engine,
78
+ content: d.content,
79
+ isDitaa: engine === 'plantuml' && /@startditaa/i.test(d.content)
80
+ });
81
+ }
82
+ return item;
83
+ };
@@ -0,0 +1,2 @@
1
+ /** Identity ради вывода типа опций из схемы. */
2
+ export const definePlugin = (plugin) => plugin;
@@ -81,7 +81,9 @@ const resolveImport = (ref, fromDir) => {
81
81
  // пробел — иначе инлайновый spread `x: { ...@lib }` захватил бы `lib }`. Флаг `u` —
82
82
  // корректный юникод. Хвостовые `.key`-сегменты частичного импорта отрезает resolveImport.
83
83
  const IMPORT_RE = /(?<!\w)@(?:"([^"\n]+)"|([^\s"#;}\n][^#;}\n]*?)\s*(?=[#;}\n]|$))/gu;
84
- const collectFiles = (entryAbs, acc = new Map()) => {
84
+ // seed — контент входного файла из дерева сборки: у виртуальных страниц плагинов
85
+ // файла на диске нет, а импорты (`@…`) по-прежнему резолвятся от его каталога.
86
+ const collectFiles = (entryAbs, acc = new Map(), seed) => {
85
87
  const abs = path.resolve(entryAbs);
86
88
  if (acc.has(abs))
87
89
  return acc;
@@ -89,7 +91,7 @@ const collectFiles = (entryAbs, acc = new Map()) => {
89
91
  try {
90
92
  // Строго UTF-8: язык D2 определён поверх UTF-8, опция CHARSET конфига относится
91
93
  // к PlantUML (уходит в -charset) и на .d2 сознательно не распространяется.
92
- content = fs.readFileSync(abs, 'utf-8');
94
+ content = seed ?? fs.readFileSync(abs, 'utf-8');
93
95
  }
94
96
  catch {
95
97
  return acc;
@@ -117,14 +119,14 @@ let filesMemo = null;
117
119
  const clearD2FileCache = () => {
118
120
  filesMemo = null;
119
121
  };
120
- const collectFilesCached = (entryAbs) => {
122
+ const collectFilesCached = (entryAbs, seed) => {
121
123
  const abs = path.resolve(entryAbs);
122
124
  if (!filesMemo)
123
125
  filesMemo = new Map();
124
126
  const hit = filesMemo.get(abs);
125
127
  if (hit)
126
128
  return hit;
127
- const built = collectFiles(abs);
129
+ const built = collectFiles(abs, new Map(), seed);
128
130
  filesMemo.set(abs, built);
129
131
  return built;
130
132
  };
@@ -144,8 +146,8 @@ const commonAncestor = (files) => {
144
146
  return common.join(path.sep) || path.sep;
145
147
  };
146
148
  // Подготовить аргументы compile(): виртуальная fs (все файлы графа) + inputPath.
147
- const buildCompileRequest = (entryAbs) => {
148
- const files = collectFilesCached(entryAbs);
149
+ const buildCompileRequest = (entryAbs, seed) => {
150
+ const files = collectFilesCached(entryAbs, seed);
149
151
  // Пустой граф = не прочитался сам входной файл (удалён/недоступен — гонка в watch).
150
152
  // Без guard'а commonAncestor([]) давал бы сырой TypeError (Math.min()=Infinity).
151
153
  if (files.size === 0) {
@@ -170,9 +172,9 @@ const d2ErrorText = (message) => {
170
172
  catch { }
171
173
  return message;
172
174
  };
173
- const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
175
+ const renderD2 = async (entryAbs, { layout = 'dagre', seed } = {}) => {
174
176
  const d2 = await getD2();
175
- const { fs: fsMap, inputPath } = buildCompileRequest(entryAbs);
177
+ const { fs: fsMap, inputPath } = buildCompileRequest(entryAbs, seed);
176
178
  let result;
177
179
  try {
178
180
  result = await d2.compile({ fs: fsMap, inputPath, options: { layout } });
@@ -196,9 +198,9 @@ const renderD2 = async (entryAbs, { layout = 'dagre' } = {}) => {
196
198
  // диаграммы — аналог foldIncludes для PlantUML.
197
199
  // Путь в материале — относительный к cwd, posix-разделители: чексумма не зависит
198
200
  // от машины/чекаута/ОС (абсолютный путь делал кеш непереносимым).
199
- const foldD2Imports = (entryAbs) => {
201
+ const foldD2Imports = (entryAbs, seed) => {
200
202
  const abs = path.resolve(entryAbs);
201
- const files = collectFilesCached(entryAbs);
203
+ const files = collectFilesCached(entryAbs, seed);
202
204
  const rel = (p) => path.relative(process.cwd(), p).split(path.sep).join('/');
203
205
  // Мемоизированную Map не мутируем (её же использует buildCompileRequest) —
204
206
  // входной файл исключаем фильтром, а не delete. Сортировка по относительному
@@ -250,7 +250,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
250
250
  // entryPath !== null ⟺ engine === 'd2' — используем как сужение типа вместо assertion.
251
251
  const entryPath = diagram.engine === 'd2' ? path.join(item.dir, diagram.dir) : null;
252
252
  const includes = entryPath !== null
253
- ? foldD2Imports(entryPath)
253
+ ? foldD2Imports(entryPath, body)
254
254
  : foldIncludes(body, item.dir, item.dir, new Set());
255
255
  const outFormat = diagramOutputFormat(diagram, options);
256
256
  // renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
@@ -262,20 +262,25 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
262
262
  ? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
263
263
  : `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
264
264
  `\0font=${fontCacheTag(options)}`;
265
+ const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
266
+ const relOut = path.join(item.dir.replace(options.ROOT_FOLDER, ''), outName);
267
+ // Путь выхода — тоже в чексумме: восстановление из бэкапа идёт по паре
268
+ // «чексумма встречалась» + «бэкап с этим именем есть», иначе диаграммы,
269
+ // обменявшиеся содержимым (или сдвинувшиеся имена fence-блоков плагинов),
270
+ // молча получали бы чужую старую картинку.
265
271
  const cksum = crypto
266
272
  .createHash('sha256')
267
- .update(body + includes + renderKey, 'utf-8')
273
+ .update(`${body}${includes}${renderKey}\0out=${relOut.split(path.sep).join('/')}`, 'utf-8')
268
274
  .digest('hex');
269
275
  // Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
270
276
  // порядок массива не зависит от того, кто из воркеров финиширует раньше —
271
277
  // .c4builder.cache детерминирован (важно для сравнения кэша между сборками).
272
278
  const cksumEntry = { cksum, ok: false };
273
279
  checksumEntries.push(cksumEntry);
274
- const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
275
280
  // path to backup image file
276
- const bkFilePath = path.join(bkFolderName, item.dir.replace(options.ROOT_FOLDER, ''), outName);
281
+ const bkFilePath = path.join(bkFolderName, relOut);
277
282
  // path to image in dist folder
278
- const filePath = path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), outName);
283
+ const filePath = path.join(options.DIST_FOLDER, relOut);
279
284
  // if checksum exists (diagram untouched) and file/image exists - copy image back from backup folder
280
285
  if (oldChecksums.has(cksum) && fs.existsSync(bkFilePath)) {
281
286
  // Восстановление из бэкапа — без рендера; JVM/worker не задействованы,
@@ -299,7 +304,7 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
299
304
  let rendered;
300
305
  try {
301
306
  if (entryPath !== null) {
302
- rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT });
307
+ rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT, seed: body });
303
308
  }
304
309
  else {
305
310
  // Резолв JRE отложен до первого реального PlantUML-рендера (эта
@@ -9,12 +9,12 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import os from 'node:os';
11
11
  import crypto from 'node:crypto';
12
- import { createRequire } from 'node:module';
13
12
  import { spawnSync } from 'node:child_process';
14
13
  import { httpGetStream, httpGetJson } from "../../util/http.js";
15
- // yauzl/tar грузятся лениво (только при распаковке скачанного JRE) — сохраняем это
16
- // синхронным require через createRequire, а не тянем их на импорте модуля.
17
- const require = createRequire(import.meta.url);
14
+ // Распаковка (zip-slip-guard общий) вынесена в util/archive — её же использует
15
+ // резолвер источников плагинов. isPathInside ре-экспортируется для тестов.
16
+ import { extractZip, extractTarGz, isPathInside } from "../../util/archive.js";
17
+ export { isPathInside };
18
18
  const MAJOR_MIN = 17; // минимальная годная мажорная версия системной java
19
19
  const TEMURIN_FEATURE = 21; // скачиваем ровно Temurin 21 JRE
20
20
  const JAVA_BIN = process.platform === 'win32' ? 'java.exe' : 'java';
@@ -207,60 +207,7 @@ const downloadAndVerify = async (link, expectedSha, destFile) => {
207
207
  throw new Error(`sha256 архива не совпал: ожидалось ${expectedSha}, получено ${actual}`);
208
208
  }
209
209
  };
210
- // Защита от zip-slip: путь записи, разрешённый относительно destDir, обязан
211
- // оставаться ВНУТРИ него (не `../` за пределы). Экспортируется для юнит-теста.
212
- export const isPathInside = (destDir, entryName) => {
213
- const root = path.resolve(destDir);
214
- const dest = path.resolve(root, entryName);
215
- return dest === root || dest.startsWith(root + path.sep);
216
- };
217
- const extractZip = (archive, destDir) => new Promise((resolve, reject) => {
218
- require('yauzl').open(archive, { lazyEntries: true }, (err, zip) => {
219
- if (err)
220
- return reject(err);
221
- zip.on('entry', (entry) => {
222
- // zip-slip: злонамеренная запись `../../evil` вышла бы за destDir. tar-ветка
223
- // защищена node-tar≥6 по умолчанию, zip (yauzl) — нет, проверяем сами.
224
- if (!isPathInside(destDir, entry.fileName)) {
225
- return reject(new Error(`Небезопасный путь в архиве JRE (выход за каталог): ${entry.fileName}`));
226
- }
227
- // Симлинки пропускаем: вне доверенного Adoptium-архива симлинк мог бы
228
- // указывать за пределы каталога. Temurin Windows-zip симлинков не содержит.
229
- const isSymlink = ((entry.externalFileAttributes >>> 16) & 0xffff & 0o170000) === 0o120000;
230
- if (isSymlink)
231
- return zip.readEntry();
232
- const dest = path.join(destDir, entry.fileName);
233
- if (entry.fileName.endsWith('/')) {
234
- fs.mkdirSync(dest, { recursive: true });
235
- return zip.readEntry();
236
- }
237
- fs.mkdirSync(path.dirname(dest), { recursive: true });
238
- zip.openReadStream(entry, (e, rs) => {
239
- if (e)
240
- return reject(e);
241
- const ws = fs.createWriteStream(dest);
242
- ws.on('error', reject);
243
- ws.on('finish', () => {
244
- const mode = (entry.externalFileAttributes >>> 16) & 0o777;
245
- if (mode) {
246
- try {
247
- fs.chmodSync(dest, mode);
248
- }
249
- catch {
250
- /* права не критичны на windows */
251
- }
252
- }
253
- zip.readEntry();
254
- });
255
- rs.pipe(ws);
256
- });
257
- });
258
- zip.on('end', resolve);
259
- zip.on('error', reject);
260
- zip.readEntry();
261
- });
262
- });
263
- const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : require('tar').x({ file: archive, cwd: destDir });
210
+ const extractArchive = (archive, destDir, isZip) => isZip ? extractZip(archive, destDir) : extractTarGz(archive, destDir);
264
211
  // (3) скачивание: assets API → sha256 (до распаковки) → распаковка в staging →
265
212
  // атомарная замена кеша. Распаковываем НЕ в финальный каталог, а в свой staging
266
213
  // (<dir>.tmp-<pid>, уникальный на процесс → параллельные сборки не топчут друг друга),
@@ -0,0 +1,4 @@
1
+ export const BUILTIN_PLUGINS = {
2
+ openspec: async () => (await import("./openspec/index.js")).default,
3
+ openapi: async () => (await import("./openapi/index.js")).default
4
+ };
@@ -0,0 +1,95 @@
1
+ // Встроенный плагин openapi: набор OpenAPI-спек (локальная папка или архив репозитория
2
+ // контрактов) → раздел сайта со страницей swagger-ui на спеку. Полностью офлайн:
3
+ // бандл swagger-ui и CSS — вендорные ассеты плагина, спеки копируются в dist статикой
4
+ // с сохранением относительных путей ($ref).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { z } from 'zod';
8
+ import { outputDirs } from "../../config/options.js";
9
+ import { redactUrl } from "../../core/plugins/source.js";
10
+ import { definePlugin } from "../../core/plugins/types.js";
11
+ import { globFiles } from "../../util/glob.js";
12
+ import { VENDOR_DIR } from "../../util/paths.js";
13
+ import { encodeURIPath } from "../../util/utils.js";
14
+ const optionsSchema = z
15
+ .object({
16
+ mount: z.string().min(1).default('API'),
17
+ dir: z.string().min(1).optional(),
18
+ archive: z.string().optional(),
19
+ subdir: z.string().optional(),
20
+ headers: z.record(z.string(), z.string()).optional(),
21
+ glob: z.string().default('**/openapi.{yaml,yml,json}')
22
+ })
23
+ .strict()
24
+ .refine((o) => (o.dir === undefined) !== (o.archive === undefined), {
25
+ message: 'нужен ровно один источник: dir либо archive'
26
+ });
27
+ // Имя страницы: родительская папка спеки; в корне источника — имя файла без расширения.
28
+ const pageName = (rel) => {
29
+ const dir = path.posix.dirname(rel);
30
+ return dir === '.' ? path.posix.basename(rel).replace(/\.[^.]+$/, '') : path.posix.basename(dir);
31
+ };
32
+ export default definePlugin({
33
+ name: 'openapi',
34
+ options: optionsSchema,
35
+ requires: { executeScript: true },
36
+ // Бандл объявлен ассетом, а не взят из шаблона docsify: пользовательский docsifyTemplate
37
+ // может его не подключать; при дефолтном шаблоне injectPluginAssets дубль не добавит.
38
+ assets: {
39
+ styles: [path.join(VENDOR_DIR, 'swagger-ui', 'swagger-ui.css')],
40
+ scripts: [path.join(VENDOR_DIR, 'docsify', 'swagger-ui-bundle.js')]
41
+ },
42
+ watchPaths: (o) => (o.dir ? [path.join(o.dir, o.subdir ?? '')] : []),
43
+ async afterScan(ctx, o) {
44
+ const { mount } = o;
45
+ const source = o.dir ?? redactUrl(o.archive ?? '');
46
+ const root = await ctx.source({
47
+ dir: o.dir,
48
+ archive: o.archive,
49
+ subdir: o.subdir,
50
+ headers: o.headers
51
+ });
52
+ // Источник может быть предком выходного каталога (dir: '.') — свои же копии
53
+ // спек прошлой сборки в dist/dist_bk не считаются источником.
54
+ const skip = outputDirs(ctx.options);
55
+ const files = globFiles(root, o.glob, skip);
56
+ if (!files.length)
57
+ throw new Error(`по шаблону "${o.glob}" в ${source} не найдено ни одной спеки`);
58
+ const names = new Map();
59
+ for (const rel of files) {
60
+ const name = pageName(rel);
61
+ const dup = names.get(name);
62
+ if (dup)
63
+ throw new Error(`две спеки дают одно имя страницы "${name}": ${dup} и ${rel}`);
64
+ names.set(name, rel);
65
+ }
66
+ // Статикой в dist — все yaml/json источника, а не только совпавшие с glob: $ref
67
+ // может вести в общие схемы (components), не являющиеся спеками.
68
+ for (const rel of globFiles(root, '**/*.{yaml,yml,json}', skip)) {
69
+ const dest = path.join(ctx.options.DIST_FOLDER, mount, '_specs', rel);
70
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
71
+ fs.copyFileSync(path.join(root, rel), dest);
72
+ }
73
+ const sorted = [...names.entries()].sort(([a], [b]) => a.localeCompare(b));
74
+ const link = (name) => encodeURIPath(path.posix.join(mount, name, ctx.options.WEB_FILE_NAME || name));
75
+ ctx.addPage({
76
+ path: [mount],
77
+ markdown: sorted.map(([name, rel]) => `- [${name}](${link(name)}) — \`${rel}\``).join('\n')
78
+ });
79
+ for (const [name, rel] of sorted) {
80
+ // Спека грузится swagger-ui по ссылке от корня сайта (SPA: base = index.html);
81
+ // относительные $ref внутри спек резолвятся от её URL — структура сохранена.
82
+ const specUrl = encodeURIPath(path.posix.join(mount, '_specs', rel));
83
+ const domId = `swagger-${name.replace(/[^A-Za-z0-9_-]/g, '_')}`;
84
+ ctx.addPage({
85
+ path: [mount, name],
86
+ markdown: [
87
+ `<div id="${domId}"></div>`,
88
+ '<script>',
89
+ `SwaggerUIBundle({ url: '${specUrl}', dom_id: '#${domId}' });`,
90
+ '</script>'
91
+ ].join('\n')
92
+ });
93
+ }
94
+ }
95
+ });
@@ -0,0 +1,55 @@
1
+ // Fenced-блоки ```plantuml / ```d2 в артефактах → диаграммы страницы (рендер общим
2
+ // локальным движком) + ссылка `![…](name.ext)` на их месте: compose подставит картинку
3
+ // inline, а клиентский docsify-plantuml (онлайн-рендер в браузере) не сработает —
4
+ // fence в выводе уже отсутствует.
5
+ import crypto from 'node:crypto';
6
+ // Fenced-блок по CommonMark: открывающая ``` или ~~~ (≥3) с любым отступом (fence в
7
+ // списке), инфо-строка, тело (может быть пустым), закрывающая того же вида не короче
8
+ // открывающей. Тело захватывается вместе с завершающим переводом строки.
9
+ const FENCE_RE = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\n]*)\n([\s\S]*?)^[ \t]*\2[`~]*[ \t]*$/gm;
10
+ const ENGINE_EXT = { plantuml: '.puml', puml: '.puml', d2: '.d2' };
11
+ /** Применить fn к тексту вне fenced-блоков (сами блоки — без изменений). */
12
+ export const mapOutsideFences = (md, fn) => {
13
+ let out = '';
14
+ let last = 0;
15
+ for (const m of md.matchAll(FENCE_RE)) {
16
+ out += fn(md.slice(last, m.index)) + m[0];
17
+ last = m.index + m[0].length;
18
+ }
19
+ return out + fn(md.slice(last));
20
+ };
21
+ /**
22
+ * Экстрактор диаграмм одной страницы. Имя файла — `<base>-<sha1 контента>`: не зависит
23
+ * от позиции fence, поэтому вставка/удаление соседнего блока не сдвигает имена и не
24
+ * подсовывает картинку соседа из кэша; одинаковые блоки дают одну диаграмму.
25
+ */
26
+ export const createFenceExtractor = () => {
27
+ const seen = new Set();
28
+ return {
29
+ extract(md, base) {
30
+ const diagrams = [];
31
+ const markdown = md.replace(FENCE_RE, (whole, indent, _fence, info, body) => {
32
+ const lang = info.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
33
+ const ext = ENGINE_EXT[lang];
34
+ if (!ext)
35
+ return whole;
36
+ // Отступ fence (в списке) снимаем со строк тела, как CommonMark.
37
+ const src = body
38
+ .replace(/\n$/, '')
39
+ .split('\n')
40
+ .map((l) => (l.startsWith(indent) ? l.slice(indent.length) : l))
41
+ .join('\n');
42
+ // PlantUML требует @startuml/@enduml; в артефактах их часто опускают.
43
+ const content = ext === '.puml' && !/@start\w+/.test(src) ? `@startuml\n${src}\n@enduml` : src;
44
+ const hash = crypto.createHash('sha1').update(content).digest('hex').slice(0, 8);
45
+ const file = `${base}-${hash}${ext}`;
46
+ if (!seen.has(file)) {
47
+ seen.add(file);
48
+ diagrams.push({ file, content });
49
+ }
50
+ return `${indent}![${file}](${file})`;
51
+ });
52
+ return { markdown, diagrams };
53
+ }
54
+ };
55
+ };
@@ -0,0 +1,73 @@
1
+ // Встроенный плагин openspec: локальный OpenSpec-store → раздел сайта (сводка, активные
2
+ // change'ы, спеки, архив). Диаграммы артефактов рендерятся локальным движком сборки.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { definePlugin } from "../../core/plugins/types.js";
7
+ import { scanStore } from "./scan.js";
8
+ import { renderArchiveIndex, renderChange, renderChangesIndex, renderMarkdown, renderSpecTree, renderSummary } from "./render.js";
9
+ const optionsSchema = z
10
+ .object({
11
+ dir: z.string().default('openspec'),
12
+ mount: z.string().min(1).default('OpenSpec'),
13
+ // Порядок артефактов change'а; первый выводится на странице change'а, остальные —
14
+ // подстраницами; не перечисленные — после, по алфавиту.
15
+ artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks'])
16
+ })
17
+ .strict();
18
+ export default definePlugin({
19
+ name: 'openspec',
20
+ options: optionsSchema,
21
+ watchPaths: (o) => [o.dir],
22
+ afterScan(ctx, o) {
23
+ const storeDir = path.resolve(o.dir);
24
+ if (!fs.existsSync(storeDir))
25
+ throw new Error(`OpenSpec store не найден: ${storeDir} (опция dir плагина openspec)`);
26
+ const store = scanStore(storeDir);
27
+ const { mount } = o;
28
+ const { options } = ctx;
29
+ // Страницы + файлы, на которые они ссылаются (картинки): рядом со страницей (каталог
30
+ // в dist уже создан addPage) и, как scan для реальных папок, в корень dist для
31
+ // complete-markdown; excludeOtherFiles действует и здесь.
32
+ const addPages = (pages, srcRoot) => {
33
+ for (const page of pages) {
34
+ ctx.addPage({ path: page.path, markdown: page.markdown, diagrams: page.diagrams });
35
+ if (options.EXCLUDE_OTHER_FILES)
36
+ continue;
37
+ for (const { from, to } of page.files) {
38
+ const src = path.join(srcRoot, from);
39
+ const dests = [path.join(options.DIST_FOLDER, ...page.path, to)];
40
+ // Ссылки вверх (`../` из дельты) от корня complete-файла всё равно никуда не ведут.
41
+ if (options.GENERATE_COMPLETE_MD_FILE && !path.posix.normalize(to).startsWith('..'))
42
+ dests.push(path.join(options.DIST_FOLDER, to));
43
+ for (const dest of dests) {
44
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
45
+ fs.copyFileSync(src, dest);
46
+ }
47
+ }
48
+ }
49
+ };
50
+ const addChange = (change, segments) => addPages(renderChange(change, segments, o.artifacts, options), change.dir);
51
+ ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options) });
52
+ ctx.addPage({
53
+ path: [mount, 'Changes'],
54
+ markdown: renderChangesIndex(store.changes, mount, options)
55
+ });
56
+ for (const change of store.changes)
57
+ addChange(change, [mount, 'Changes', change.id]);
58
+ // Specs: индекс, промежуточные папки, страницы спек — тем же рендером, что и
59
+ // дельты (fenced-диаграммы локально, ссылки на файлы рядом со спекой).
60
+ const specsDir = path.join(storeDir, 'specs');
61
+ addPages(renderSpecTree(store.specs, [mount, 'Specs'], options, (pagePath, spec) => renderMarkdown(pagePath, `spec-${spec.path.join('-')}`, spec.content, {
62
+ srcRoot: specsDir,
63
+ fromDir: spec.path.join('/'),
64
+ options
65
+ })), specsDir);
66
+ ctx.addPage({
67
+ path: [mount, 'Archive'],
68
+ markdown: renderArchiveIndex(store.archive, mount, options)
69
+ });
70
+ for (const change of store.archive)
71
+ addChange(change, [mount, 'Archive', change.id]);
72
+ }
73
+ });