@alfacapital-tech/c4builder 0.7.0 → 0.8.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.
|
@@ -16,7 +16,16 @@ const optionsSchema = z
|
|
|
16
16
|
artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks']),
|
|
17
17
|
// Ключ `.openspec.yaml` со ссылкой на родительский change (например `x-parent`):
|
|
18
18
|
// вложенный change выводится страницей под родителем. Не задан — плоский список.
|
|
19
|
-
parentKey: z.string().min(1).optional()
|
|
19
|
+
parentKey: z.string().min(1).optional(),
|
|
20
|
+
// Ключ `.openspec.yaml` с группой change'а (например `x-team`): change'ы верхнего
|
|
21
|
+
// уровня раскладываются по папкам групп `Changes/<группа>/<id>`. Не задан — без групп.
|
|
22
|
+
groupKey: z.string().min(1).optional(),
|
|
23
|
+
// Ключ `.openspec.yaml` с человеческим именем change'а (например `x-name`): подпись и
|
|
24
|
+
// сегмент пути change'а верхнего уровня вместо id. Не задан — id.
|
|
25
|
+
nameKey: z.string().min(1).optional(),
|
|
26
|
+
// Дополнительные колонки таблицы Changes из `.openspec.yaml` (например исполнитель):
|
|
27
|
+
// `[{ key: "x-assignee", title: "Assignee" }]`. Не задана — прежняя таблица.
|
|
28
|
+
columns: z.array(z.object({ key: z.string().min(1), title: z.string().min(1) }).strict()).optional()
|
|
20
29
|
})
|
|
21
30
|
.strict();
|
|
22
31
|
export default definePlugin({
|
|
@@ -52,23 +61,36 @@ export default definePlugin({
|
|
|
52
61
|
}
|
|
53
62
|
};
|
|
54
63
|
// Родитель добавляется раньше вложенных: в sidebar под ним сначала его артефакты,
|
|
55
|
-
// затем вложенные change'ы.
|
|
56
|
-
|
|
64
|
+
// затем вложенные change'ы. Страница группы — перед первым её change'ем, со своей
|
|
65
|
+
// таблицей (без секций — она сама и есть секция).
|
|
66
|
+
const addChanges = (changes, layout, section, index) => {
|
|
67
|
+
const seen = new Set();
|
|
57
68
|
for (const change of pageOrder(changes, layout)) {
|
|
58
69
|
const at = layout.get(change);
|
|
59
|
-
if (at)
|
|
60
|
-
|
|
70
|
+
if (!at)
|
|
71
|
+
continue;
|
|
72
|
+
if (at.group && !seen.has(at.group)) {
|
|
73
|
+
seen.add(at.group);
|
|
74
|
+
const members = changes.filter((c) => layout.get(c)?.group === at.group);
|
|
75
|
+
ctx.addPage({ path: [mount, section, at.group], markdown: index(members) });
|
|
76
|
+
}
|
|
77
|
+
addPages(renderChange(change, at.segments, o.artifacts, options, layout), change.dir);
|
|
61
78
|
}
|
|
62
79
|
};
|
|
63
|
-
const { parentKey } = o;
|
|
64
|
-
const active = layoutChanges(store.changes, [mount, 'Changes'], { parentKey });
|
|
65
|
-
const archived = layoutChanges(store.archive, [mount, 'Archive'], {
|
|
66
|
-
|
|
80
|
+
const { parentKey, groupKey, nameKey } = o;
|
|
81
|
+
const active = layoutChanges(store.changes, [mount, 'Changes'], { parentKey, groupKey, nameKey });
|
|
82
|
+
const archived = layoutChanges(store.archive, [mount, 'Archive'], {
|
|
83
|
+
parentKey,
|
|
84
|
+
groupKey,
|
|
85
|
+
nameKey,
|
|
86
|
+
archived: true
|
|
87
|
+
});
|
|
88
|
+
ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options, active, o.columns) });
|
|
67
89
|
ctx.addPage({
|
|
68
90
|
path: [mount, 'Changes'],
|
|
69
|
-
markdown: renderChangesIndex(store.changes, mount, options, active)
|
|
91
|
+
markdown: renderChangesIndex(store.changes, mount, options, active, { columns: o.columns })
|
|
70
92
|
});
|
|
71
|
-
addChanges(store.changes, active);
|
|
93
|
+
addChanges(store.changes, active, 'Changes', (members) => renderChangesIndex(members, mount, options, active, { sections: false, columns: o.columns }));
|
|
72
94
|
// Specs: индекс, промежуточные папки, страницы спек — тем же рендером, что и
|
|
73
95
|
// дельты (fenced-диаграммы локально, ссылки на файлы рядом со спекой).
|
|
74
96
|
const specsDir = path.join(storeDir, 'specs');
|
|
@@ -82,6 +104,6 @@ export default definePlugin({
|
|
|
82
104
|
path: [mount, 'Archive'],
|
|
83
105
|
markdown: renderArchiveIndex(store.archive, mount, options, archived)
|
|
84
106
|
});
|
|
85
|
-
addChanges(store.archive, archived);
|
|
107
|
+
addChanges(store.archive, archived, 'Archive', (members) => renderArchiveIndex(members, mount, options, archived, { sections: false }));
|
|
86
108
|
}
|
|
87
109
|
});
|
|
@@ -8,8 +8,15 @@ const originalId = (c, archived) => archived ? c.id.replace(DATE_PREFIX, '') : c
|
|
|
8
8
|
* вложенный или не найден — change остаётся на верхнем уровне. Подпись вложенного — хвост
|
|
9
9
|
* id после `<id родителя>-`; если хвоста нет или он совпадает с подстраницей родителя
|
|
10
10
|
* (артефакт, `specs`) или с подписью соседа — полный id.
|
|
11
|
+
* С `groupKey` change верхнего уровня, у которого задан этот ключ, кладётся под папку
|
|
12
|
+
* группы: `<base>/<группа>/<id>`; вложенные идут за родителем. Значение со `/` или
|
|
13
|
+
* совпадающее с подписью change'а без группы (коллизия путей) — группой не считается.
|
|
14
|
+
* С `nameKey` подпись и сегмент пути change'а верхнего уровня — значение ключа (например
|
|
15
|
+
* `x-name: Оплата картой`): sidebar и таблицы читаются по-человечески, а адрес страницы
|
|
16
|
+
* стабилен, пока значение не меняют. Недопустимое значение (`/`, `\`, `#`, `?`, `.`, `..`) или
|
|
17
|
+
* занятое в той же папке — подпись остаётся id; для группы — то же, change остаётся без группы.
|
|
11
18
|
*/
|
|
12
|
-
export const layoutChanges = (changes, base, { parentKey, archived = false } = {}) => {
|
|
19
|
+
export const layoutChanges = (changes, base, { parentKey, groupKey, nameKey, archived = false } = {}) => {
|
|
13
20
|
const byId = new Map(changes.map((c) => [originalId(c, archived), c]));
|
|
14
21
|
const parentOf = (c) => {
|
|
15
22
|
const ref = parentKey ? c.meta[parentKey] : undefined;
|
|
@@ -21,8 +28,41 @@ export const layoutChanges = (changes, base, { parentKey, archived = false } = {
|
|
|
21
28
|
const p = parentOf(c);
|
|
22
29
|
return !p || parentOf(p);
|
|
23
30
|
});
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
// Значение ключа как сегмент пути: addPage отвергает `/`, `\`, `.`, `..` и пустые, а `#` и
|
|
32
|
+
// `?` encodeURI оставляет как есть — ссылка в sidebar оборвалась бы на якоре или запросе.
|
|
33
|
+
const segment = (key, c) => {
|
|
34
|
+
const v = key ? c.meta[key]?.trim() : undefined;
|
|
35
|
+
return v && !/[\\/#?]/.test(v) && v !== '.' && v !== '..' ? v : undefined;
|
|
36
|
+
};
|
|
37
|
+
// Имя, совпадающее с id другого change'а, дало бы тот же путь — остаётся id.
|
|
38
|
+
const ids = new Set(changes.map((c) => c.id));
|
|
39
|
+
const wanted = (c) => {
|
|
40
|
+
const name = segment(nameKey, c);
|
|
41
|
+
return name && !ids.has(name) ? name : c.id;
|
|
42
|
+
};
|
|
43
|
+
// Подписи change'ов без группы заняты на уровне base — группа с таким именем дала бы коллизию.
|
|
44
|
+
const flat = new Set(top.filter((c) => !segment(groupKey, c)).map(wanted));
|
|
45
|
+
const groups = new Set();
|
|
46
|
+
const used = new Map();
|
|
47
|
+
for (const c of top) {
|
|
48
|
+
const g = segment(groupKey, c);
|
|
49
|
+
const group = g && !flat.has(g) ? g : undefined;
|
|
50
|
+
if (group)
|
|
51
|
+
groups.add(group);
|
|
52
|
+
const folder = group ?? '';
|
|
53
|
+
const taken = used.get(folder) ?? new Set();
|
|
54
|
+
used.set(folder, taken);
|
|
55
|
+
// Повтор подписи в папке (или имя группы на уровне base) — откат на уникальный id.
|
|
56
|
+
const want = wanted(c);
|
|
57
|
+
const label = taken.has(want) || (!group && want !== c.id && groups.has(want)) ? c.id : want;
|
|
58
|
+
taken.add(label);
|
|
59
|
+
layout.set(c, {
|
|
60
|
+
segments: group ? [...base, group, label] : [...base, label],
|
|
61
|
+
label,
|
|
62
|
+
children: [],
|
|
63
|
+
group
|
|
64
|
+
});
|
|
65
|
+
}
|
|
26
66
|
for (const c of changes) {
|
|
27
67
|
if (layout.has(c))
|
|
28
68
|
continue;
|
|
@@ -37,19 +77,34 @@ export const layoutChanges = (changes, base, { parentKey, archived = false } = {
|
|
|
37
77
|
const prefix = `${originalId(parent, archived)}-`;
|
|
38
78
|
const tail = own.startsWith(prefix) ? own.slice(prefix.length) : '';
|
|
39
79
|
const label = tail && !taken.has(tail) ? tail : c.id;
|
|
40
|
-
layout.set(c, {
|
|
80
|
+
layout.set(c, {
|
|
81
|
+
segments: [...host.segments, label],
|
|
82
|
+
label,
|
|
83
|
+
parent,
|
|
84
|
+
children: [],
|
|
85
|
+
group: host.group
|
|
86
|
+
});
|
|
41
87
|
host.children.push(c);
|
|
42
88
|
}
|
|
43
89
|
return layout;
|
|
44
90
|
};
|
|
91
|
+
/** Группы раскладки по алфавиту; change'ы без группы в список не входят. */
|
|
92
|
+
export const groupNames = (changes, layout) => [...new Set(changes.map((c) => layout.get(c)?.group).filter((g) => !!g))].sort((a, b) => a.localeCompare(b));
|
|
45
93
|
/**
|
|
46
94
|
* Change'ы в порядке вывода: каждый верхнего уровня, сразу за ним — его вложенные. Порядок
|
|
47
|
-
* внутри обоих уровней — порядок `changes` (по имени для страниц, по дате для таблиц)
|
|
95
|
+
* внутри обоих уровней — порядок `changes` (по имени для страниц, по дате для таблиц); с
|
|
96
|
+
* группами — сначала группы по алфавиту, затем change'ы без группы.
|
|
48
97
|
*/
|
|
49
98
|
export const pageOrder = (changes, layout) => {
|
|
50
99
|
const pos = new Map(changes.map((c, i) => [c, i]));
|
|
100
|
+
const groups = groupNames(changes, layout);
|
|
101
|
+
const rank = (c) => {
|
|
102
|
+
const g = layout.get(c)?.group;
|
|
103
|
+
return g ? groups.indexOf(g) : groups.length;
|
|
104
|
+
};
|
|
51
105
|
return changes
|
|
52
106
|
.filter((c) => !layout.get(c)?.parent)
|
|
107
|
+
.sort((a, b) => rank(a) - rank(b) || (pos.get(a) ?? 0) - (pos.get(b) ?? 0))
|
|
53
108
|
.flatMap((c) => [
|
|
54
109
|
c,
|
|
55
110
|
...[...(layout.get(c)?.children ?? [])].sort((a, b) => (pos.get(a) ?? 0) - (pos.get(b) ?? 0))
|
|
@@ -3,7 +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
|
+
import { groupNames, pageOrder } from "./layout.js";
|
|
7
7
|
// Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
|
|
8
8
|
// а docsify без relativePath резолвит ссылки от корня сайта.
|
|
9
9
|
export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
|
|
@@ -11,23 +11,46 @@ const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
|
11
11
|
// Прогресс задач: нативный <progress> (docsify рендерит HTML в markdown) + числа и процент.
|
|
12
12
|
const progressBar = (done, total) => `<progress value="${done}" max="${total}"></progress> ${done}/${total} (${Math.round((done / total) * 100)}%)`;
|
|
13
13
|
const fmtProgress = (c) => c.progress ? progressBar(c.progress.done, c.progress.total) : '—';
|
|
14
|
+
// Секции по группам (опция groupKey): `### <группа>` над своей частью таблицы или списка,
|
|
15
|
+
// change'ы без группы — последней секцией `### Ungrouped`. Групп нет или `sections` выключен
|
|
16
|
+
// (страница самой группы) — один блок без заголовка, как раньше.
|
|
17
|
+
const bySections = (changes, layout, sections, render) => {
|
|
18
|
+
const groups = layout && sections ? groupNames(changes, layout) : [];
|
|
19
|
+
if (!groups.length)
|
|
20
|
+
return render(changes);
|
|
21
|
+
const groupOf = (c) => layout?.get(c)?.group;
|
|
22
|
+
const parts = groups.map((g) => `### ${g}\n\n${render(changes.filter((c) => groupOf(c) === g))}`);
|
|
23
|
+
const rest = changes.filter((c) => !groupOf(c));
|
|
24
|
+
if (rest.length)
|
|
25
|
+
parts.push(`### Ungrouped\n\n${render(rest)}`);
|
|
26
|
+
return parts.join('\n\n');
|
|
27
|
+
};
|
|
28
|
+
// Значение ячейки markdown-таблицы: `|` разорвал бы строку на колонки.
|
|
29
|
+
const cell = (v) => (v ? v.replace(/\|/g, '\\|') : '—');
|
|
14
30
|
/**
|
|
15
31
|
* Таблица change'ов (сводка и индекс Changes): по дате изменения от новых к старым. С `layout`
|
|
16
|
-
* вложенные change'ы идут сразу под родителем (`↳` + подпись) и ведут на свои вложенные
|
|
32
|
+
* вложенные change'ы идут сразу под родителем (`↳` + подпись) и ведут на свои вложенные
|
|
33
|
+
* страницы, а при группах таблица делится на секции по группам. `columns` — колонки из
|
|
34
|
+
* `.openspec.yaml` после стандартных (например исполнитель); нет значения — `—`.
|
|
17
35
|
*/
|
|
18
|
-
export const renderChangesIndex = (changes, mount, options, layout) => {
|
|
36
|
+
export const renderChangesIndex = (changes, mount, options, layout, { sections = true, columns = [] } = {}) => {
|
|
19
37
|
if (!changes.length)
|
|
20
38
|
return '_No changes._';
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
const head = ['Change', 'Schema', 'Tasks', 'Updated', ...columns.map((c) => cell(c.title))];
|
|
40
|
+
const table = (part) => {
|
|
41
|
+
const byDate = [...part].sort((a, b) => b.mtime - a.mtime);
|
|
42
|
+
const rows = (layout ? pageOrder(byDate, layout) : byDate).map((c) => {
|
|
43
|
+
const at = layout?.get(c);
|
|
44
|
+
const link = `[${at?.label ?? c.id}](${pageLink(at?.segments ?? [mount, 'Changes', c.id], options)})`;
|
|
45
|
+
const extra = columns.map((col) => ` ${cell(c.meta[col.key])} |`).join('');
|
|
46
|
+
return `| ${at?.parent ? `↳ ${link}` : link} | ${c.schema ?? '—'} | ${fmtProgress(c)} | ${fmtDate(c.mtime)} |${extra}`;
|
|
47
|
+
});
|
|
48
|
+
return [`| ${head.join(' | ')} |`, `|${'---|'.repeat(head.length)}`, ...rows].join('\n');
|
|
49
|
+
};
|
|
50
|
+
return bySections(changes, layout, sections, table);
|
|
28
51
|
};
|
|
29
52
|
/** Сводка store (страница mount): счётчики, суммарный прогресс, таблица активных change'ов. */
|
|
30
|
-
export const renderSummary = (store, mount, options, layout) => {
|
|
53
|
+
export const renderSummary = (store, mount, options, layout, columns = []) => {
|
|
31
54
|
const withProgress = store.changes.filter((c) => c.progress);
|
|
32
55
|
const done = withProgress.reduce((s, c) => s + (c.progress?.done ?? 0), 0);
|
|
33
56
|
const total = withProgress.reduce((s, c) => s + (c.progress?.total ?? 0), 0);
|
|
@@ -40,18 +63,18 @@ export const renderSummary = (store, mount, options, layout) => {
|
|
|
40
63
|
'',
|
|
41
64
|
'## Active changes',
|
|
42
65
|
'',
|
|
43
|
-
renderChangesIndex(store.changes, mount, options, layout)
|
|
66
|
+
renderChangesIndex(store.changes, mount, options, layout, { columns })
|
|
44
67
|
].join('\n');
|
|
45
68
|
};
|
|
46
|
-
/** Список архива: от новых к старым; с `layout` вложенные — пунктами под
|
|
47
|
-
export const renderArchiveIndex = (archive, mount, options, layout) => archive.length
|
|
48
|
-
? (layout ? pageOrder(
|
|
69
|
+
/** Список архива: от новых к старым; с `layout` вложенные — пунктами под родителем, по секциям групп. */
|
|
70
|
+
export const renderArchiveIndex = (archive, mount, options, layout, { sections = true } = {}) => archive.length
|
|
71
|
+
? bySections(archive, layout, sections, (part) => (layout ? pageOrder(part, layout) : part)
|
|
49
72
|
.map((c) => {
|
|
50
73
|
const at = layout?.get(c);
|
|
51
74
|
const link = pageLink(at?.segments ?? [mount, 'Archive', c.id], options);
|
|
52
75
|
return `${at?.parent ? ' ' : ''}- [${at?.label ?? c.id}](${link})`;
|
|
53
76
|
})
|
|
54
|
-
.join('\n')
|
|
77
|
+
.join('\n'))
|
|
55
78
|
: '_Archive is empty._';
|
|
56
79
|
/** Индекс папки спек (Specs, промежуточные папки, дельты change'а): вложенный список под prefix. */
|
|
57
80
|
export const renderSpecIndex = (specs, prefix, base, options) => {
|
|
@@ -41,8 +41,10 @@ const readMeta = (file) => {
|
|
|
41
41
|
const m = line.match(/^([A-Za-z0-9_.-]+):[ \t]*(.+?)\s*$/);
|
|
42
42
|
if (!m || m[2].startsWith('#'))
|
|
43
43
|
continue;
|
|
44
|
-
//
|
|
45
|
-
|
|
44
|
+
// Значение в кавычках — целиком (`x-name: "Задача #5"`); у остальных комментарий в
|
|
45
|
+
// конце строки (` # …`) — не часть значения.
|
|
46
|
+
const quoted = m[2].match(/^(["'])(.*?)\1(?:\s+#.*)?$/);
|
|
47
|
+
meta[m[1]] = quoted ? quoted[2] : m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '');
|
|
46
48
|
}
|
|
47
49
|
return { schema: meta.schema, created: meta.created, meta };
|
|
48
50
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|