@alfacapital-tech/c4builder 0.6.0 → 0.7.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.
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { definePlugin } from "../../core/plugins/types.js";
|
|
7
|
+
import { layoutChanges, pageOrder } from "./layout.js";
|
|
7
8
|
import { scanStore } from "./scan.js";
|
|
8
9
|
import { renderArchiveIndex, renderChange, renderChangesIndex, relSource, renderMarkdown, renderSpecTree, renderSummary } from "./render.js";
|
|
9
10
|
const optionsSchema = z
|
|
@@ -12,7 +13,10 @@ const optionsSchema = z
|
|
|
12
13
|
mount: z.string().min(1).default('OpenSpec'),
|
|
13
14
|
// Порядок артефактов change'а; первый выводится на странице change'а, остальные —
|
|
14
15
|
// подстраницами; не перечисленные — после, по алфавиту.
|
|
15
|
-
artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks'])
|
|
16
|
+
artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks']),
|
|
17
|
+
// Ключ `.openspec.yaml` со ссылкой на родительский change (например `x-parent`):
|
|
18
|
+
// вложенный change выводится страницей под родителем. Не задан — плоский список.
|
|
19
|
+
parentKey: z.string().min(1).optional()
|
|
16
20
|
})
|
|
17
21
|
.strict();
|
|
18
22
|
export default definePlugin({
|
|
@@ -47,14 +51,24 @@ export default definePlugin({
|
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
};
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
// Родитель добавляется раньше вложенных: в sidebar под ним сначала его артефакты,
|
|
55
|
+
// затем вложенные change'ы.
|
|
56
|
+
const addChanges = (changes, layout) => {
|
|
57
|
+
for (const change of pageOrder(changes, layout)) {
|
|
58
|
+
const at = layout.get(change);
|
|
59
|
+
if (at)
|
|
60
|
+
addPages(renderChange(change, at.segments, o.artifacts, options, layout), change.dir);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const { parentKey } = o;
|
|
64
|
+
const active = layoutChanges(store.changes, [mount, 'Changes'], { parentKey });
|
|
65
|
+
const archived = layoutChanges(store.archive, [mount, 'Archive'], { parentKey, archived: true });
|
|
66
|
+
ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options, active) });
|
|
52
67
|
ctx.addPage({
|
|
53
68
|
path: [mount, 'Changes'],
|
|
54
|
-
markdown: renderChangesIndex(store.changes, mount, options)
|
|
69
|
+
markdown: renderChangesIndex(store.changes, mount, options, active)
|
|
55
70
|
});
|
|
56
|
-
|
|
57
|
-
addChange(change, [mount, 'Changes', change.id]);
|
|
71
|
+
addChanges(store.changes, active);
|
|
58
72
|
// Specs: индекс, промежуточные папки, страницы спек — тем же рендером, что и
|
|
59
73
|
// дельты (fenced-диаграммы локально, ссылки на файлы рядом со спекой).
|
|
60
74
|
const specsDir = path.join(storeDir, 'specs');
|
|
@@ -66,9 +80,8 @@ export default definePlugin({
|
|
|
66
80
|
})), specsDir);
|
|
67
81
|
ctx.addPage({
|
|
68
82
|
path: [mount, 'Archive'],
|
|
69
|
-
markdown: renderArchiveIndex(store.archive, mount, options)
|
|
83
|
+
markdown: renderArchiveIndex(store.archive, mount, options, archived)
|
|
70
84
|
});
|
|
71
|
-
|
|
72
|
-
addChange(change, [mount, 'Archive', change.id]);
|
|
85
|
+
addChanges(store.archive, archived);
|
|
73
86
|
}
|
|
74
87
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Архивный change — `<YYYY-MM-DD>-<id>`; ссылка на родителя пишется по активному id.
|
|
2
|
+
const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
|
|
3
|
+
const originalId = (c, archived) => archived ? c.id.replace(DATE_PREFIX, '') : c.id;
|
|
4
|
+
/**
|
|
5
|
+
* Без `parentKey` — все change'ы на верхнем уровне `base` (прежнее поведение). С ним
|
|
6
|
+
* change, у которого в `.openspec.yaml` есть `parentKey`, указывающий на change того же
|
|
7
|
+
* списка, кладётся страницей под родителя. Вложенность — один уровень: родитель сам
|
|
8
|
+
* вложенный или не найден — change остаётся на верхнем уровне. Подпись вложенного — хвост
|
|
9
|
+
* id после `<id родителя>-`; если хвоста нет или он совпадает с подстраницей родителя
|
|
10
|
+
* (артефакт, `specs`) или с подписью соседа — полный id.
|
|
11
|
+
*/
|
|
12
|
+
export const layoutChanges = (changes, base, { parentKey, archived = false } = {}) => {
|
|
13
|
+
const byId = new Map(changes.map((c) => [originalId(c, archived), c]));
|
|
14
|
+
const parentOf = (c) => {
|
|
15
|
+
const ref = parentKey ? c.meta[parentKey] : undefined;
|
|
16
|
+
const p = ref ? byId.get(ref) : undefined;
|
|
17
|
+
return p && p !== c ? p : undefined;
|
|
18
|
+
};
|
|
19
|
+
const layout = new Map();
|
|
20
|
+
const top = changes.filter((c) => {
|
|
21
|
+
const p = parentOf(c);
|
|
22
|
+
return !p || parentOf(p);
|
|
23
|
+
});
|
|
24
|
+
for (const c of top)
|
|
25
|
+
layout.set(c, { segments: [...base, c.id], label: c.id, children: [] });
|
|
26
|
+
for (const c of changes) {
|
|
27
|
+
if (layout.has(c))
|
|
28
|
+
continue;
|
|
29
|
+
const parent = parentOf(c);
|
|
30
|
+
const host = layout.get(parent);
|
|
31
|
+
const taken = new Set([
|
|
32
|
+
...parent.artifacts.map((a) => a.name),
|
|
33
|
+
'specs',
|
|
34
|
+
...host.children.map((s) => layout.get(s).label)
|
|
35
|
+
]);
|
|
36
|
+
const own = originalId(c, archived);
|
|
37
|
+
const prefix = `${originalId(parent, archived)}-`;
|
|
38
|
+
const tail = own.startsWith(prefix) ? own.slice(prefix.length) : '';
|
|
39
|
+
const label = tail && !taken.has(tail) ? tail : c.id;
|
|
40
|
+
layout.set(c, { segments: [...host.segments, label], label, parent, children: [] });
|
|
41
|
+
host.children.push(c);
|
|
42
|
+
}
|
|
43
|
+
return layout;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Change'ы в порядке вывода: каждый верхнего уровня, сразу за ним — его вложенные. Порядок
|
|
47
|
+
* внутри обоих уровней — порядок `changes` (по имени для страниц, по дате для таблиц).
|
|
48
|
+
*/
|
|
49
|
+
export const pageOrder = (changes, layout) => {
|
|
50
|
+
const pos = new Map(changes.map((c, i) => [c, i]));
|
|
51
|
+
return changes
|
|
52
|
+
.filter((c) => !layout.get(c)?.parent)
|
|
53
|
+
.flatMap((c) => [
|
|
54
|
+
c,
|
|
55
|
+
...[...(layout.get(c)?.children ?? [])].sort((a, b) => (pos.get(a) ?? 0) - (pos.get(b) ?? 0))
|
|
56
|
+
]);
|
|
57
|
+
};
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createFenceExtractor, mapOutsideFences } from "../../core/scan/fences.js";
|
|
5
5
|
import { encodeURIPath } from "../../util/utils.js";
|
|
6
|
+
import { pageOrder } from "./layout.js";
|
|
6
7
|
// Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
|
|
7
8
|
// а docsify без relativePath резолвит ссылки от корня сайта.
|
|
8
9
|
export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
|
|
@@ -10,17 +11,23 @@ const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
|
10
11
|
// Прогресс задач: нативный <progress> (docsify рендерит HTML в markdown) + числа и процент.
|
|
11
12
|
const progressBar = (done, total) => `<progress value="${done}" max="${total}"></progress> ${done}/${total} (${Math.round((done / total) * 100)}%)`;
|
|
12
13
|
const fmtProgress = (c) => c.progress ? progressBar(c.progress.done, c.progress.total) : '—';
|
|
13
|
-
/**
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Таблица change'ов (сводка и индекс Changes): по дате изменения от новых к старым. С `layout`
|
|
16
|
+
* вложенные change'ы идут сразу под родителем (`↳` + подпись) и ведут на свои вложенные страницы.
|
|
17
|
+
*/
|
|
18
|
+
export const renderChangesIndex = (changes, mount, options, layout) => {
|
|
15
19
|
if (!changes.length)
|
|
16
20
|
return '_No changes._';
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const byDate = [...changes].sort((a, b) => b.mtime - a.mtime);
|
|
22
|
+
const rows = (layout ? pageOrder(byDate, layout) : byDate).map((c) => {
|
|
23
|
+
const at = layout?.get(c);
|
|
24
|
+
const link = `[${at?.label ?? c.id}](${pageLink(at?.segments ?? [mount, 'Changes', c.id], options)})`;
|
|
25
|
+
return `| ${at?.parent ? `↳ ${link}` : link} | ${c.schema ?? '—'} | ${fmtProgress(c)} | ${fmtDate(c.mtime)} |`;
|
|
26
|
+
});
|
|
20
27
|
return ['| Change | Schema | Tasks | Updated |', '|---|---|---|---|', ...rows].join('\n');
|
|
21
28
|
};
|
|
22
29
|
/** Сводка store (страница mount): счётчики, суммарный прогресс, таблица активных change'ов. */
|
|
23
|
-
export const renderSummary = (store, mount, options) => {
|
|
30
|
+
export const renderSummary = (store, mount, options, layout) => {
|
|
24
31
|
const withProgress = store.changes.filter((c) => c.progress);
|
|
25
32
|
const done = withProgress.reduce((s, c) => s + (c.progress?.done ?? 0), 0);
|
|
26
33
|
const total = withProgress.reduce((s, c) => s + (c.progress?.total ?? 0), 0);
|
|
@@ -33,11 +40,18 @@ export const renderSummary = (store, mount, options) => {
|
|
|
33
40
|
'',
|
|
34
41
|
'## Active changes',
|
|
35
42
|
'',
|
|
36
|
-
renderChangesIndex(store.changes, mount, options)
|
|
43
|
+
renderChangesIndex(store.changes, mount, options, layout)
|
|
37
44
|
].join('\n');
|
|
38
45
|
};
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
/** Список архива: от новых к старым; с `layout` вложенные — пунктами под родителем. */
|
|
47
|
+
export const renderArchiveIndex = (archive, mount, options, layout) => archive.length
|
|
48
|
+
? (layout ? pageOrder(archive, layout) : archive)
|
|
49
|
+
.map((c) => {
|
|
50
|
+
const at = layout?.get(c);
|
|
51
|
+
const link = pageLink(at?.segments ?? [mount, 'Archive', c.id], options);
|
|
52
|
+
return `${at?.parent ? ' ' : ''}- [${at?.label ?? c.id}](${link})`;
|
|
53
|
+
})
|
|
54
|
+
.join('\n')
|
|
41
55
|
: '_Archive is empty._';
|
|
42
56
|
/** Индекс папки спек (Specs, промежуточные папки, дельты change'а): вложенный список под prefix. */
|
|
43
57
|
export const renderSpecIndex = (specs, prefix, base, options) => {
|
|
@@ -179,7 +193,7 @@ const orderArtifacts = (artifacts, order) => [...artifacts].sort((a, b) => {
|
|
|
179
193
|
* `specs` дельт (страница на capability, структура папок). Артефакт с именем `specs`
|
|
180
194
|
* делит страницу с индексом дельт.
|
|
181
195
|
*/
|
|
182
|
-
export const renderChange = (change, segments, order, options) => {
|
|
196
|
+
export const renderChange = (change, segments, order, options, layout) => {
|
|
183
197
|
const ordered = orderArtifacts(change.artifacts, order);
|
|
184
198
|
const inline = ordered[0]?.name === order[0] ? ordered[0] : undefined;
|
|
185
199
|
const deltas = change.deltas.map((d) => ({
|
|
@@ -222,11 +236,23 @@ export const renderChange = (change, segments, order, options) => {
|
|
|
222
236
|
]
|
|
223
237
|
.filter(Boolean)
|
|
224
238
|
.join(' · ');
|
|
239
|
+
// Связи по опции parentKey: у вложенного — ссылка на родителя, у родителя — на вложенные.
|
|
240
|
+
const at = layout?.get(change);
|
|
241
|
+
const linkTo = (c) => {
|
|
242
|
+
const p = layout?.get(c);
|
|
243
|
+
return p ? `[${p.label}](${pageLink(p.segments, options)})` : c.id;
|
|
244
|
+
};
|
|
245
|
+
const family = [
|
|
246
|
+
at?.parent && `**Parent:** ${linkTo(at.parent)}`,
|
|
247
|
+
at?.children.length && `**Sub-changes:** ${at.children.map(linkTo).join(' · ')}`
|
|
248
|
+
]
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
.join(' · ');
|
|
225
251
|
const main = inline ? render(segments, inline.name, inline.content) : undefined;
|
|
226
252
|
const pages = [
|
|
227
253
|
{
|
|
228
254
|
path: segments,
|
|
229
|
-
markdown: [head, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
|
|
255
|
+
markdown: [head, family, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
|
|
230
256
|
diagrams: main?.diagrams ?? [],
|
|
231
257
|
files: main?.files ?? []
|
|
232
258
|
},
|
|
@@ -32,17 +32,19 @@ export const taskProgress = (tasks) => {
|
|
|
32
32
|
return undefined;
|
|
33
33
|
return { done: boxes.filter((m) => m[1] !== ' ').length, total: boxes.length };
|
|
34
34
|
};
|
|
35
|
-
// Метаданные `.openspec.yaml` построчно: нужны
|
|
35
|
+
// Метаданные `.openspec.yaml` построчно: нужны только скаляры верхнего уровня (schema,
|
|
36
|
+
// created и пользовательские ключи вроде `x-parent`), yaml-парсер избыточен.
|
|
36
37
|
const readMeta = (file) => {
|
|
37
|
-
if (!fs.existsSync(file))
|
|
38
|
-
return {};
|
|
39
38
|
const meta = {};
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
if (fs.existsSync(file))
|
|
40
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
41
|
+
const m = line.match(/^([A-Za-z0-9_.-]+):[ \t]*(.+?)\s*$/);
|
|
42
|
+
if (!m || m[2].startsWith('#'))
|
|
43
|
+
continue;
|
|
44
|
+
// Комментарий в конце строки (` # …`) — не часть значения.
|
|
45
|
+
meta[m[1]] = m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '');
|
|
46
|
+
}
|
|
47
|
+
return { schema: meta.schema, created: meta.created, meta };
|
|
46
48
|
};
|
|
47
49
|
const scanChange = (dir) => {
|
|
48
50
|
const files = fs.readdirSync(dir, { withFileTypes: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|