@alfacapital-tech/c4builder 0.3.0 → 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.
- package/README.MD +167 -5
- package/dist/cli/commands/check.js +80 -0
- package/dist/cli/commands/help.js +4 -0
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/dispatch.js +35 -9
- package/dist/config/options.js +6 -0
- package/dist/config/schema.js +8 -0
- package/dist/core/build.js +11 -2
- package/dist/core/plugins/assets.js +53 -0
- package/dist/core/plugins/hooks.js +30 -0
- package/dist/core/plugins/load.js +125 -0
- package/dist/core/plugins/source.js +86 -0
- package/dist/core/plugins/tree.js +83 -0
- package/dist/core/plugins/types.js +2 -0
- package/dist/core/render/d2renderer.js +24 -11
- package/dist/core/render/diagrams.js +11 -6
- package/dist/core/render/jre.js +5 -58
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/openapi/index.js +95 -0
- package/dist/plugins/openspec/fences.js +55 -0
- package/dist/plugins/openspec/index.js +73 -0
- package/dist/plugins/openspec/render.js +216 -0
- package/dist/plugins/openspec/scan.js +99 -0
- package/dist/util/archive.js +61 -0
- package/dist/util/glob.js +66 -0
- package/package.json +1 -1
- package/vendor/swagger-ui/README.md +6 -0
- package/vendor/swagger-ui/swagger-ui.css +3 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Fenced-блоки ```plantuml / ```d2 в артефактах → диаграммы страницы (рендер общим
|
|
2
|
+
// локальным движком) + ссылка `` на их месте: 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}`;
|
|
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
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
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")` и ``. Текст ссылки может содержать
|
|
72
|
+
// вложенную картинку (`[](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
|
+
/**
|
|
82
|
+
* Markdown одного исходного файла → страница сайта: fenced-диаграммы вырезаются, ссылки
|
|
83
|
+
* на другие страницы источника (`target`, `#x` → `?id=x`) — на их адреса, прочие
|
|
84
|
+
* относительные ссылки на существующие файлы источника регистрируются для копирования к
|
|
85
|
+
* странице (картинки docsify грузит относительно страницы, обычные ссылки — от корня сайта).
|
|
86
|
+
* `fromDir` — posix-каталог файла относительно `srcRoot` (ссылки в нём резолвятся от файла).
|
|
87
|
+
*/
|
|
88
|
+
export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', target = () => undefined, options }) => {
|
|
89
|
+
const { markdown, diagrams } = createFenceExtractor().extract(content, base);
|
|
90
|
+
const files = [];
|
|
91
|
+
const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
|
|
92
|
+
const inner = text.includes('![') ? rewrite(text) : text;
|
|
93
|
+
if (/^[a-z][a-z0-9+.-]*:|^[#/]/i.test(href))
|
|
94
|
+
return `${bang}[${inner}](${href}${title})`; // абсолютные, якоря, от корня
|
|
95
|
+
const [rawPath, hash] = href.split('#');
|
|
96
|
+
let decoded;
|
|
97
|
+
try {
|
|
98
|
+
decoded = decodeURI(rawPath);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return whole; // битый %-escape — не ссылка на файл, оставляем как есть
|
|
102
|
+
}
|
|
103
|
+
const rel = path.posix.normalize(path.posix.join(fromDir, decoded));
|
|
104
|
+
if (rel.startsWith('../'))
|
|
105
|
+
return whole;
|
|
106
|
+
const page = target(rel);
|
|
107
|
+
if (page)
|
|
108
|
+
return `${bang}[${inner}](${pageLink(page, options)}${hash ? `?id=${hash}` : ''}${title})`;
|
|
109
|
+
const abs = path.join(srcRoot, rel);
|
|
110
|
+
if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
111
|
+
return whole;
|
|
112
|
+
files.push({ from: rel, to: decoded });
|
|
113
|
+
return bang
|
|
114
|
+
? ``
|
|
115
|
+
: `[${inner}](${encodeURIPath(path.posix.join(...pagePath, decoded))}${ignoreTitle(title)})`;
|
|
116
|
+
});
|
|
117
|
+
return { path: pagePath, markdown: mapOutsideFences(markdown, rewrite).trim(), diagrams, files };
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Страницы дерева спек под `base`: корень, промежуточные папки, спеки. Папка с собственной
|
|
121
|
+
* spec.md — одна страница (спека + список вложенных), иначе addPage упал бы на коллизии.
|
|
122
|
+
* `render` — рендер содержимого спеки в страницу с нужным источником.
|
|
123
|
+
*/
|
|
124
|
+
export const renderSpecTree = (specs, base, options, render) => {
|
|
125
|
+
const byKey = new Map(specs.map((s) => [s.path.join('/'), s]));
|
|
126
|
+
const keys = new Set(['', ...specFolders(specs).map((f) => f.join('/')), ...byKey.keys()]);
|
|
127
|
+
return [...keys].sort().map((key) => {
|
|
128
|
+
const prefix = key ? key.split('/') : [];
|
|
129
|
+
const pagePath = [...base, ...prefix];
|
|
130
|
+
const spec = byKey.get(key);
|
|
131
|
+
const page = spec
|
|
132
|
+
? render(pagePath, spec)
|
|
133
|
+
: { path: pagePath, markdown: '', diagrams: [], files: [] };
|
|
134
|
+
const nested = specs.some((s) => s.path.length > prefix.length && prefix.every((p, i) => s.path[i] === p));
|
|
135
|
+
if (nested || !spec)
|
|
136
|
+
page.markdown = [page.markdown, renderSpecIndex(specs, prefix, base, options)]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join('\n\n');
|
|
139
|
+
return page;
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
/** Артефакты в порядке опции `artifacts`; не перечисленные — после, по алфавиту. */
|
|
143
|
+
const orderArtifacts = (artifacts, order) => [...artifacts].sort((a, b) => {
|
|
144
|
+
const ia = order.indexOf(a.name);
|
|
145
|
+
const ib = order.indexOf(b.name);
|
|
146
|
+
if (ia !== -1 || ib !== -1)
|
|
147
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
148
|
+
return a.name.localeCompare(b.name);
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* Страницы change'а: главная (шапка с метаданными, прогрессом и ссылками на подстраницы +
|
|
152
|
+
* первый артефакт из `order`, если он есть), подстраницы остальных артефактов и подраздел
|
|
153
|
+
* `specs` дельт (страница на capability, структура папок). Артефакт с именем `specs`
|
|
154
|
+
* делит страницу с индексом дельт.
|
|
155
|
+
*/
|
|
156
|
+
export const renderChange = (change, segments, order, options) => {
|
|
157
|
+
const ordered = orderArtifacts(change.artifacts, order);
|
|
158
|
+
const inline = ordered[0]?.name === order[0] ? ordered[0] : undefined;
|
|
159
|
+
const deltas = change.deltas.map((d) => ({
|
|
160
|
+
path: d.relPath
|
|
161
|
+
.replace(/^specs\//, '')
|
|
162
|
+
.replace(/\/spec\.md$/, '')
|
|
163
|
+
.split('/'),
|
|
164
|
+
content: d.content
|
|
165
|
+
}));
|
|
166
|
+
const specsBase = [...segments, 'specs'];
|
|
167
|
+
// Страница сайта для относительной (от корня change'а) ссылки на артефакт/дельту.
|
|
168
|
+
const target = (rel) => {
|
|
169
|
+
const md = rel.match(/^([^/]+)\.md$/i);
|
|
170
|
+
if (md && change.artifacts.some((a) => a.name === md[1]))
|
|
171
|
+
return md[1] === inline?.name ? segments : [...segments, md[1]];
|
|
172
|
+
const delta = deltas.find((d) => `specs/${d.path.join('/')}/spec.md` === rel);
|
|
173
|
+
return delta && [...specsBase, ...delta.path];
|
|
174
|
+
};
|
|
175
|
+
const render = (pagePath, base, content, fromDir = '') => renderMarkdown(pagePath, base, content, { srcRoot: change.dir, fromDir, target, options });
|
|
176
|
+
const subpages = ordered
|
|
177
|
+
.filter((a) => a !== inline)
|
|
178
|
+
.map((a) => render([...segments, a.name], a.name, a.content));
|
|
179
|
+
const links = [
|
|
180
|
+
...subpages.map((p) => `[${p.path[p.path.length - 1]}](${pageLink(p.path, options)})`),
|
|
181
|
+
...(deltas.length && !subpages.some((p) => p.path[p.path.length - 1] === 'specs')
|
|
182
|
+
? [`[specs](${pageLink(specsBase, options)})`]
|
|
183
|
+
: [])
|
|
184
|
+
];
|
|
185
|
+
const head = [
|
|
186
|
+
change.schema && `**Schema:** ${change.schema}`,
|
|
187
|
+
change.created && `**Created:** ${change.created}`,
|
|
188
|
+
`**Tasks:** ${fmtProgress(change)}`,
|
|
189
|
+
`**Updated:** ${fmtDate(change.mtime)}`
|
|
190
|
+
]
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.join(' · ');
|
|
193
|
+
const main = inline ? render(segments, inline.name, inline.content) : undefined;
|
|
194
|
+
const pages = [
|
|
195
|
+
{
|
|
196
|
+
path: segments,
|
|
197
|
+
markdown: [head, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
|
|
198
|
+
diagrams: main?.diagrams ?? [],
|
|
199
|
+
files: main?.files ?? []
|
|
200
|
+
},
|
|
201
|
+
...subpages
|
|
202
|
+
];
|
|
203
|
+
if (deltas.length) {
|
|
204
|
+
const tree = renderSpecTree(deltas, specsBase, options, (pagePath, spec) => render(pagePath, `spec-${spec.path.join('-')}`, spec.content, `specs/${spec.path.join('/')}`));
|
|
205
|
+
// Артефакт specs.md и корень дельт — один путь: сливаем в страницу артефакта.
|
|
206
|
+
const artifact = pages.find((p) => p.path.length === segments.length + 1 && p.path.at(-1) === 'specs');
|
|
207
|
+
for (const p of tree) {
|
|
208
|
+
if (artifact && p.path.length === specsBase.length) {
|
|
209
|
+
artifact.markdown = [artifact.markdown, p.markdown].filter(Boolean).join('\n\n');
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
pages.push(p);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return pages;
|
|
216
|
+
};
|
|
@@ -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,66 @@
|
|
|
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
|
+
export const globToRegExp = (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
|
+
re += `(?:${glob
|
|
32
|
+
.slice(i + 1, end)
|
|
33
|
+
.split(',')
|
|
34
|
+
.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
35
|
+
.join('|')})`;
|
|
36
|
+
i = end;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else
|
|
40
|
+
re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
}
|
|
42
|
+
return new RegExp(`^${re}$`);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Файлы под root, чьи относительные posix-пути совпадают с glob (сортировано).
|
|
46
|
+
* `.git`/`node_modules` и абсолютные каталоги `skipDirs` не обходятся.
|
|
47
|
+
*/
|
|
48
|
+
export const globFiles = (root, glob, skipDirs = []) => {
|
|
49
|
+
const re = globToRegExp(glob);
|
|
50
|
+
const skip = new Set(skipDirs.map((d) => path.resolve(d)));
|
|
51
|
+
const out = [];
|
|
52
|
+
const walk = (rel) => {
|
|
53
|
+
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
54
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
55
|
+
if (e.isDirectory()) {
|
|
56
|
+
if (e.name === '.git' || e.name === 'node_modules' || skip.has(path.resolve(root, r)))
|
|
57
|
+
continue;
|
|
58
|
+
walk(r);
|
|
59
|
+
}
|
|
60
|
+
else if (re.test(r))
|
|
61
|
+
out.push(r);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
walk('');
|
|
65
|
+
return out.sort();
|
|
66
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-rc.2",
|
|
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`.
|