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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
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, relSource, 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
+ source: relSource(specsDir, `${spec.path.join('/')}/spec.md`),
65
+ options
66
+ })), specsDir);
67
+ ctx.addPage({
68
+ path: [mount, 'Archive'],
69
+ markdown: renderArchiveIndex(store.archive, mount, options)
70
+ });
71
+ for (const change of store.archive)
72
+ addChange(change, [mount, 'Archive', change.id]);
73
+ }
74
+ });
@@ -0,0 +1,224 @@
1
+ // Markdown страниц раздела OpenSpec: сводка, change и его подстраницы, индексы подразделов.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { encodeURIPath } from "../../util/utils.js";
5
+ import { createFenceExtractor, mapOutsideFences } from "./fences.js";
6
+ // Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
7
+ // а docsify без relativePath резолвит ссылки от корня сайта.
8
+ export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
9
+ const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
10
+ // Прогресс задач: нативный <progress> (docsify рендерит HTML в markdown) + числа и процент.
11
+ const progressBar = (done, total) => `<progress value="${done}" max="${total}"></progress> ${done}/${total} (${Math.round((done / total) * 100)}%)`;
12
+ const fmtProgress = (c) => c.progress ? progressBar(c.progress.done, c.progress.total) : '—';
13
+ /** Таблица change'ов (сводка и индекс Changes): по дате изменения от новых к старым. */
14
+ export const renderChangesIndex = (changes, mount, options) => {
15
+ if (!changes.length)
16
+ 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)} |`);
20
+ return ['| Change | Schema | Tasks | Updated |', '|---|---|---|---|', ...rows].join('\n');
21
+ };
22
+ /** Сводка store (страница mount): счётчики, суммарный прогресс, таблица активных change'ов. */
23
+ export const renderSummary = (store, mount, options) => {
24
+ const withProgress = store.changes.filter((c) => c.progress);
25
+ const done = withProgress.reduce((s, c) => s + (c.progress?.done ?? 0), 0);
26
+ const total = withProgress.reduce((s, c) => s + (c.progress?.total ?? 0), 0);
27
+ const link = (section) => pageLink([mount, section], options);
28
+ return [
29
+ `**Active changes:** [${store.changes.length}](${link('Changes')}) · ` +
30
+ `**Specs:** [${store.specs.length}](${link('Specs')}) · ` +
31
+ `**Archived:** [${store.archive.length}](${link('Archive')}) · ` +
32
+ `**Tasks:** ${total ? progressBar(done, total) : '—'}`,
33
+ '',
34
+ '## Active changes',
35
+ '',
36
+ renderChangesIndex(store.changes, mount, options)
37
+ ].join('\n');
38
+ };
39
+ export const renderArchiveIndex = (archive, mount, options) => archive.length
40
+ ? archive.map((c) => `- [${c.id}](${pageLink([mount, 'Archive', c.id], options)})`).join('\n')
41
+ : '_Archive is empty._';
42
+ /** Индекс папки спек (Specs, промежуточные папки, дельты change'а): вложенный список под prefix. */
43
+ export const renderSpecIndex = (specs, prefix, base, options) => {
44
+ const under = specs.filter((s) => prefix.every((p, i) => s.path[i] === p) && s.path.length > prefix.length);
45
+ if (!under.length)
46
+ return '_No specs._';
47
+ // Папки — тоже строки списка (со ссылкой на свою страницу-индекс), чтобы вложенность читалась.
48
+ const seen = new Set();
49
+ const lines = [];
50
+ for (const s of under) {
51
+ for (let depth = prefix.length + 1; depth <= s.path.length; depth++) {
52
+ const segs = s.path.slice(0, depth);
53
+ const key = segs.join('/');
54
+ if (seen.has(key))
55
+ continue;
56
+ seen.add(key);
57
+ const indent = ' '.repeat(depth - prefix.length - 1);
58
+ lines.push(`${indent}- [${segs[depth - 1]}](${pageLink([...base, ...segs], options)})`);
59
+ }
60
+ }
61
+ return lines.join('\n');
62
+ };
63
+ /** Промежуточные папки набора спек (для индексов): ['a'], ['a','b'], …, сортировано. */
64
+ export const specFolders = (specs) => {
65
+ const folders = new Set();
66
+ for (const s of specs)
67
+ for (let i = 1; i < s.path.length; i++)
68
+ folders.add(s.path.slice(0, i).join('/'));
69
+ return [...folders].sort().map((f) => f.split('/'));
70
+ };
71
+ // Ссылки markdown: `[text](href "title")` и `![alt](src)`. Текст ссылки может содержать
72
+ // вложенную картинку (`[![alt](img)](href)`) — альтернатива с ней стоит первой, чтобы
73
+ // не оборваться на её `)`.
74
+ const LINK_RE = /(!?)\[((?:!\[[^\]\n]*\]\([^)\n]*\)|[^\]\n])*)\]\(([^)\s]+)(\s+[^)]*)?\)/g;
75
+ // docsify-title `':ignore'` (ссылка не в роутер) поверх пользовательского title: docsify
76
+ // вырезает `:ignore` из title и оставляет остаток как обычный title.
77
+ const ignoreTitle = (title) => {
78
+ const t = title.trim().replace(/^(["'])(.*)\1$/s, '$2');
79
+ return ` ':ignore${t ? ` ${t}` : ''}'`;
80
+ };
81
+ /** Путь файла для сообщений — от cwd, чтобы его можно было открыть как есть. */
82
+ export const relSource = (root, rel) => path.relative(process.cwd(), path.join(root, rel)).split(path.sep).join('/');
83
+ /**
84
+ * Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
85
+ * на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
86
+ * относительные ссылки на существующие файлы источника регистрируются для копирования к
87
+ * странице (картинки docsify грузит относительно страницы, обычные ссылки — от корня сайта).
88
+ * `fromDir` — posix-каталог файла относительно `srcRoot` (ссылки в нём резолвятся от файла).
89
+ */
90
+ export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, source, options }) => {
91
+ const { markdown, diagrams } = createFenceExtractor().extract(content, base, source);
92
+ const files = [];
93
+ const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
94
+ const inner = text.includes('![') ? rewrite(text) : text;
95
+ if (/^[a-z][a-z0-9+.-]*:|^[#/]/i.test(href))
96
+ return `${bang}[${inner}](${href}${title})`; // абсолютные, якоря, от корня
97
+ const [rawPath, hash] = href.split('#');
98
+ let decoded;
99
+ try {
100
+ decoded = decodeURI(rawPath);
101
+ }
102
+ catch {
103
+ return whole; // битый %-escape — не ссылка на файл, оставляем как есть
104
+ }
105
+ const rel = path.posix.normalize(path.posix.join(fromDir, decoded));
106
+ if (rel.startsWith('../'))
107
+ return whole;
108
+ const page = target(rel);
109
+ if (page)
110
+ return `${bang}[${inner}](${pageLink(page, options)}${hash ? `?id=${hash}` : ''}${title})`;
111
+ const abs = path.join(srcRoot, rel);
112
+ if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory())
113
+ return whole;
114
+ files.push({ from: rel, to: decoded });
115
+ return bang
116
+ ? `![${inner}](${href}${title})`
117
+ : `[${inner}](${encodeURIPath(path.posix.join(...pagePath, decoded))}${ignoreTitle(title)})`;
118
+ });
119
+ return { path: pagePath, markdown: mapOutsideFences(markdown, rewrite).trim(), diagrams, files };
120
+ };
121
+ /**
122
+ * Страницы дерева спек под `base`: корень, промежуточные папки, спеки. Папка с собственной
123
+ * spec.md — одна страница (спека + список вложенных), иначе addPage упал бы на коллизии.
124
+ * `render` — рендер содержимого спеки в страницу с нужным источником.
125
+ */
126
+ export const renderSpecTree = (specs, base, options, render) => {
127
+ const byKey = new Map(specs.map((s) => [s.path.join('/'), s]));
128
+ const keys = new Set(['', ...specFolders(specs).map((f) => f.join('/')), ...byKey.keys()]);
129
+ return [...keys].sort().map((key) => {
130
+ const prefix = key ? key.split('/') : [];
131
+ const pagePath = [...base, ...prefix];
132
+ const spec = byKey.get(key);
133
+ const page = spec
134
+ ? render(pagePath, spec)
135
+ : { path: pagePath, markdown: '', diagrams: [], files: [] };
136
+ const nested = specs.some((s) => s.path.length > prefix.length && prefix.every((p, i) => s.path[i] === p));
137
+ if (nested || !spec)
138
+ page.markdown = [page.markdown, renderSpecIndex(specs, prefix, base, options)]
139
+ .filter(Boolean)
140
+ .join('\n\n');
141
+ return page;
142
+ });
143
+ };
144
+ /** Артефакты в порядке опции `artifacts`; не перечисленные — после, по алфавиту. */
145
+ const orderArtifacts = (artifacts, order) => [...artifacts].sort((a, b) => {
146
+ const ia = order.indexOf(a.name);
147
+ const ib = order.indexOf(b.name);
148
+ if (ia !== -1 || ib !== -1)
149
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
150
+ return a.name.localeCompare(b.name);
151
+ });
152
+ /**
153
+ * Страницы change'а: главная (шапка с метаданными, прогрессом и ссылками на подстраницы +
154
+ * первый артефакт из `order`, если он есть), подстраницы остальных артефактов и подраздел
155
+ * `specs` дельт (страница на capability, структура папок). Артефакт с именем `specs`
156
+ * делит страницу с индексом дельт.
157
+ */
158
+ export const renderChange = (change, segments, order, options) => {
159
+ const ordered = orderArtifacts(change.artifacts, order);
160
+ const inline = ordered[0]?.name === order[0] ? ordered[0] : undefined;
161
+ const deltas = change.deltas.map((d) => ({
162
+ path: d.relPath
163
+ .replace(/^specs\//, '')
164
+ .replace(/\/spec\.md$/, '')
165
+ .split('/'),
166
+ content: d.content
167
+ }));
168
+ const specsBase = [...segments, 'specs'];
169
+ // Страница сайта для относительной (от корня change'а) ссылки на артефакт/дельту.
170
+ const target = (rel) => {
171
+ const md = rel.match(/^([^/]+)\.md$/i);
172
+ if (md && change.artifacts.some((a) => a.name === md[1]))
173
+ return md[1] === inline?.name ? segments : [...segments, md[1]];
174
+ const delta = deltas.find((d) => `specs/${d.path.join('/')}/spec.md` === rel);
175
+ return delta && [...specsBase, ...delta.path];
176
+ };
177
+ const render = (pagePath, base, content, fromDir = '', file = `${base}.md`) => renderMarkdown(pagePath, base, content, {
178
+ srcRoot: change.dir,
179
+ fromDir,
180
+ target,
181
+ source: relSource(change.dir, path.posix.join(fromDir, file)),
182
+ options
183
+ });
184
+ const subpages = ordered
185
+ .filter((a) => a !== inline)
186
+ .map((a) => render([...segments, a.name], a.name, a.content));
187
+ const links = [
188
+ ...subpages.map((p) => `[${p.path[p.path.length - 1]}](${pageLink(p.path, options)})`),
189
+ ...(deltas.length && !subpages.some((p) => p.path[p.path.length - 1] === 'specs')
190
+ ? [`[specs](${pageLink(specsBase, options)})`]
191
+ : [])
192
+ ];
193
+ const head = [
194
+ change.schema && `**Schema:** ${change.schema}`,
195
+ change.created && `**Created:** ${change.created}`,
196
+ `**Tasks:** ${fmtProgress(change)}`,
197
+ `**Updated:** ${fmtDate(change.mtime)}`
198
+ ]
199
+ .filter(Boolean)
200
+ .join(' · ');
201
+ const main = inline ? render(segments, inline.name, inline.content) : undefined;
202
+ const pages = [
203
+ {
204
+ path: segments,
205
+ markdown: [head, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
206
+ diagrams: main?.diagrams ?? [],
207
+ files: main?.files ?? []
208
+ },
209
+ ...subpages
210
+ ];
211
+ if (deltas.length) {
212
+ const tree = renderSpecTree(deltas, specsBase, options, (pagePath, spec) => render(pagePath, `spec-${spec.path.join('-')}`, spec.content, `specs/${spec.path.join('/')}`, 'spec.md'));
213
+ // Артефакт specs.md и корень дельт — один путь: сливаем в страницу артефакта.
214
+ const artifact = pages.find((p) => p.path.length === segments.length + 1 && p.path.at(-1) === 'specs');
215
+ for (const p of tree) {
216
+ if (artifact && p.path.length === specsBase.length) {
217
+ artifact.markdown = [artifact.markdown, p.markdown].filter(Boolean).join('\n\n');
218
+ }
219
+ else
220
+ pages.push(p);
221
+ }
222
+ }
223
+ return pages;
224
+ };
@@ -0,0 +1,99 @@
1
+ // Сканер OpenSpec-store: активные change'ы, архив, спеки. Только файловая система —
2
+ // `openspec` CLI не шеллим (сборка не зависит от его наличия/версии).
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ const listDirs = (dir) => fs.existsSync(dir)
6
+ ? fs
7
+ .readdirSync(dir, { withFileTypes: true })
8
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
9
+ .map((e) => e.name)
10
+ .sort()
11
+ : [];
12
+ // Все spec.md под dir рекурсивно: относительные posix-пути (сортировано).
13
+ const findSpecFiles = (dir, rel = '') => {
14
+ if (!fs.existsSync(dir))
15
+ return [];
16
+ const out = [];
17
+ for (const e of fs
18
+ .readdirSync(dir, { withFileTypes: true })
19
+ .sort((a, b) => a.name.localeCompare(b.name))) {
20
+ const r = rel ? `${rel}/${e.name}` : e.name;
21
+ if (e.isDirectory())
22
+ out.push(...findSpecFiles(path.join(dir, e.name), r));
23
+ else if (e.name === 'spec.md')
24
+ out.push(r);
25
+ }
26
+ return out;
27
+ };
28
+ // Прогресс по чекбоксам: `- [ ]` / `- [x]` в любом месте tasks.md.
29
+ export const taskProgress = (tasks) => {
30
+ const boxes = [...tasks.matchAll(/^[ \t]*[-*+][ \t]+\[([ xX])\]/gm)];
31
+ if (!boxes.length)
32
+ return undefined;
33
+ return { done: boxes.filter((m) => m[1] !== ' ').length, total: boxes.length };
34
+ };
35
+ // Метаданные `.openspec.yaml` построчно: нужны два скаляра, yaml-парсер избыточен.
36
+ const readMeta = (file) => {
37
+ if (!fs.existsSync(file))
38
+ return {};
39
+ 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;
46
+ };
47
+ const scanChange = (dir) => {
48
+ const files = fs.readdirSync(dir, { withFileTypes: true });
49
+ let mtime = 0;
50
+ const touch = (file) => {
51
+ mtime = Math.max(mtime, fs.statSync(file).mtimeMs);
52
+ };
53
+ const artifacts = files
54
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
55
+ .map((e) => e.name)
56
+ .sort()
57
+ .map((name) => {
58
+ const file = path.join(dir, name);
59
+ touch(file);
60
+ return { name: name.slice(0, -3), content: fs.readFileSync(file, 'utf8') };
61
+ });
62
+ const deltas = findSpecFiles(path.join(dir, 'specs'), 'specs').map((relPath) => {
63
+ const file = path.join(dir, relPath);
64
+ touch(file);
65
+ return { relPath, content: fs.readFileSync(file, 'utf8') };
66
+ });
67
+ const metaFile = path.join(dir, '.openspec.yaml');
68
+ if (fs.existsSync(metaFile))
69
+ touch(metaFile);
70
+ const tasks = artifacts.find((a) => a.name === 'tasks');
71
+ return {
72
+ id: path.basename(dir),
73
+ dir,
74
+ artifacts,
75
+ deltas,
76
+ ...readMeta(metaFile),
77
+ progress: tasks ? taskProgress(tasks.content) : undefined,
78
+ mtime
79
+ };
80
+ };
81
+ /** Читает store целиком; отсутствующие подпапки (changes/, specs/) — пустые списки. */
82
+ export const scanStore = (storeDir) => {
83
+ const changesDir = path.join(storeDir, 'changes');
84
+ const archiveDir = path.join(changesDir, 'archive');
85
+ const specsDir = path.join(storeDir, 'specs');
86
+ return {
87
+ changes: listDirs(changesDir)
88
+ .filter((n) => n !== 'archive')
89
+ .map((n) => scanChange(path.join(changesDir, n))),
90
+ // Архив — от новых к старым: имя начинается с даты архивации.
91
+ archive: listDirs(archiveDir)
92
+ .reverse()
93
+ .map((n) => scanChange(path.join(archiveDir, n))),
94
+ specs: findSpecFiles(specsDir).map((rel) => ({
95
+ path: rel.split('/').slice(0, -1),
96
+ content: fs.readFileSync(path.join(specsDir, rel), 'utf8')
97
+ }))
98
+ };
99
+ };
@@ -0,0 +1,61 @@
1
+ // Распаковка архивов (zip через yauzl, tar.gz через node-tar) — общий слой для
2
+ // JRE-резолвера и резолвера источников плагинов. Оба формата защищены от выхода
3
+ // записей за целевой каталог: tar — самим node-tar (≥6 по умолчанию), zip — здесь.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ // yauzl/tar грузятся лениво (только при распаковке) — не тянем их на импорте модуля.
8
+ const require = createRequire(import.meta.url);
9
+ // Защита от zip-slip: путь записи, разрешённый относительно destDir, обязан
10
+ // оставаться ВНУТРИ него (не `../` за пределы). Экспортируется для юнит-теста.
11
+ export const isPathInside = (destDir, entryName) => {
12
+ const root = path.resolve(destDir);
13
+ const dest = path.resolve(root, entryName);
14
+ return dest === root || dest.startsWith(root + path.sep);
15
+ };
16
+ export const extractZip = (archive, destDir) => new Promise((resolve, reject) => {
17
+ require('yauzl').open(archive, { lazyEntries: true }, (err, zip) => {
18
+ if (err)
19
+ return reject(err);
20
+ zip.on('entry', (entry) => {
21
+ // zip-slip: злонамеренная запись `../../evil` вышла бы за destDir.
22
+ if (!isPathInside(destDir, entry.fileName)) {
23
+ return reject(new Error(`Небезопасный путь в архиве (выход за каталог): ${entry.fileName}`));
24
+ }
25
+ // Симлинки пропускаем: в недоверенном архиве симлинк мог бы указывать
26
+ // за пределы каталога.
27
+ const isSymlink = ((entry.externalFileAttributes >>> 16) & 0xffff & 0o170000) === 0o120000;
28
+ if (isSymlink)
29
+ return zip.readEntry();
30
+ const dest = path.join(destDir, entry.fileName);
31
+ if (entry.fileName.endsWith('/')) {
32
+ fs.mkdirSync(dest, { recursive: true });
33
+ return zip.readEntry();
34
+ }
35
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
36
+ zip.openReadStream(entry, (e, rs) => {
37
+ if (e)
38
+ return reject(e);
39
+ const ws = fs.createWriteStream(dest);
40
+ ws.on('error', reject);
41
+ ws.on('finish', () => {
42
+ const mode = (entry.externalFileAttributes >>> 16) & 0o777;
43
+ if (mode) {
44
+ try {
45
+ fs.chmodSync(dest, mode);
46
+ }
47
+ catch {
48
+ /* права не критичны на windows */
49
+ }
50
+ }
51
+ zip.readEntry();
52
+ });
53
+ rs.pipe(ws);
54
+ });
55
+ });
56
+ zip.on('end', resolve);
57
+ zip.on('error', reject);
58
+ zip.readEntry();
59
+ });
60
+ });
61
+ export const extractTarGz = (archive, destDir) => require('tar').x({ file: archive, cwd: destDir });
@@ -0,0 +1,69 @@
1
+ // Мини-glob без зависимостей: `**` (любые каталоги), `*` (в пределах сегмента),
2
+ // `?`, `{a,b}`; остальное — буквально. Покрывает `*/openapi.yaml` и
3
+ // `**/openapi.{yaml,yml,json}`; `fs.glob` появился только в Node 22, engines — ≥20.19.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ const convert = (glob) => {
7
+ let re = '';
8
+ for (let i = 0; i < glob.length; i++) {
9
+ const c = glob[i];
10
+ if (c === '*') {
11
+ if (glob[i + 1] === '*') {
12
+ // `**/` — ноль и более каталогов; одиночное `**` в конце — что угодно
13
+ i++;
14
+ if (glob[i + 1] === '/') {
15
+ i++;
16
+ re += '(?:.*/)?';
17
+ }
18
+ else
19
+ re += '.*';
20
+ }
21
+ else
22
+ re += '[^/]*';
23
+ }
24
+ else if (c === '?')
25
+ re += '[^/]';
26
+ else if (c === '{') {
27
+ const end = glob.indexOf('}', i);
28
+ if (end === -1)
29
+ re += '\\{';
30
+ else {
31
+ // Альтернативы — тем же конвертером: `{*.yaml,*.yml}` должен работать
32
+ // как glob, а не как буквальная звёздочка.
33
+ re += `(?:${glob
34
+ .slice(i + 1, end)
35
+ .split(',')
36
+ .map(convert)
37
+ .join('|')})`;
38
+ i = end;
39
+ }
40
+ }
41
+ else
42
+ re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+ }
44
+ return re;
45
+ };
46
+ export const globToRegExp = (glob) => new RegExp(`^${convert(glob)}$`);
47
+ /**
48
+ * Файлы под root, чьи относительные posix-пути совпадают с glob (сортировано).
49
+ * `.git`/`node_modules` и абсолютные каталоги `skipDirs` не обходятся.
50
+ */
51
+ export const globFiles = (root, glob, skipDirs = []) => {
52
+ const re = globToRegExp(glob);
53
+ const skip = new Set(skipDirs.map((d) => path.resolve(d)));
54
+ const out = [];
55
+ const walk = (rel) => {
56
+ for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
57
+ const r = rel ? `${rel}/${e.name}` : e.name;
58
+ if (e.isDirectory()) {
59
+ if (e.name === '.git' || e.name === 'node_modules' || skip.has(path.resolve(root, r)))
60
+ continue;
61
+ walk(r);
62
+ }
63
+ else if (re.test(r))
64
+ out.push(r);
65
+ }
66
+ };
67
+ walk('');
68
+ return out.sort();
69
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfacapital-tech/c4builder",
3
- "version": "0.4.0-rc.1",
3
+ "version": "0.4.0-rc.3",
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",
@@ -0,0 +1,6 @@
1
+ # swagger-ui
2
+
3
+ `swagger-ui.css` — стили swagger-ui-dist **5.32.1** (та же версия, что `docsify/swagger-ui-bundle.js`,
4
+ sha256 бандла совпадает с npm-пакетом). Подключается только плагином `openapi` (ассет →
5
+ `dist/vendor/plugins/openapi/`), поэтому лежит отдельно от `docsify/`, который копируется в каждый сайт целиком.
6
+ Обновлять парой с бандлом: `npm pack swagger-ui-dist@<v>` → `swagger-ui-bundle.js` и `swagger-ui.css`.