eidosmd 0.2.0 → 0.3.1
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/browser/dist/assets/index-D_Xs1hAc.css +1 -0
- package/browser/dist/assets/index-K_EgH2M8.js +46 -0
- package/browser/dist/index.html +2 -2
- package/dist/src/commands/check.js +9 -3
- package/dist/src/commands/configure.js +201 -0
- package/dist/src/commands/framework.js +15 -4
- package/dist/src/commands/init.js +4 -0
- package/dist/src/commands/property.js +125 -0
- package/dist/src/commands/setup.js +24 -12
- package/dist/src/commands/version.js +2 -2
- package/dist/src/core/canvas.js +59 -49
- package/dist/src/core/check.js +101 -26
- package/dist/src/core/edits.js +1381 -0
- package/dist/src/core/framework-markdown.js +9 -3
- package/dist/src/core/framework-model.js +43 -8
- package/dist/src/core/framework-structured.js +104 -28
- package/dist/src/core/frontmatter.js +61 -1
- package/dist/src/core/git.js +28 -3
- package/dist/src/core/links.js +87 -0
- package/dist/src/core/markdown.js +16 -9
- package/dist/src/core/migrate.js +91 -15
- package/dist/src/core/regions.js +117 -0
- package/dist/src/core/scaffold.js +15 -9
- package/dist/src/core/seed.js +56 -31
- package/dist/src/core/server.js +204 -31
- package/dist/src/core/settings.js +63 -12
- package/dist/src/core/store.js +73 -17
- package/dist/src/core/versions.js +10 -5
- package/dist/src/program.js +296 -11
- package/instructions/authoring.md +4 -2
- package/instructions/configuring.md +27 -11
- package/instructions/overview.md +6 -4
- package/instructions/validating.md +6 -4
- package/package.json +1 -1
- package/standard/EIDOS.md +135 -193
- package/standard/seeds/README.md +12 -16
- package/standard/seeds/book/Framework.yaml +30 -50
- package/standard/seeds/book/README.md +2 -1
- package/standard/seeds/book/_gitignore +7 -1
- package/standard/seeds/research/Framework.yaml +30 -50
- package/standard/seeds/research/README.md +2 -1
- package/standard/seeds/research/_gitignore +7 -1
- package/standard/seeds/software/Framework.yaml +31 -51
- package/standard/seeds/software/README.md +2 -1
- package/standard/seeds/software/_gitignore +7 -1
- package/browser/dist/assets/index-C2NMN_D4.css +0 -1
- package/browser/dist/assets/index-C65k1ihb.js +0 -46
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
// Framework edits: every mechanical change to the framework document, the
|
|
2
|
+
// frontmatter, the files, and the links between them, as a plan that is
|
|
3
|
+
// shown before it is applied. `eidos configure:<noun> <operation>` and
|
|
4
|
+
// Framework Settings both run these, so a rename from the shell and one from
|
|
5
|
+
// the page produce the same files.
|
|
6
|
+
//
|
|
7
|
+
// A plan is built without touching anything, lists every step with its file
|
|
8
|
+
// and its before and after, every value that would be lost, and every
|
|
9
|
+
// conflict, and is applied step by step in the order listed. After a plan
|
|
10
|
+
// the index is rewritten and the check runs, so a command never leaves the
|
|
11
|
+
// root with a finding it caused; the one way to is `preserve`, the owner's
|
|
12
|
+
// choice, which the plan says out loud.
|
|
13
|
+
//
|
|
14
|
+
// The line is data, not prose: a body's text is never edited, a link is a
|
|
15
|
+
// path substitution, and a value a property held is shown before it goes,
|
|
16
|
+
// never placed anywhere.
|
|
17
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
20
|
+
import { listBlueprints, propertyString } from './blueprint.js';
|
|
21
|
+
import { checkRoot, optionProblem } from './check.js';
|
|
22
|
+
import { appliesTo, defaultVariant, findCollection, findVariant, FRAMEWORK_DIR, isCollection, loadFramework, PROPERTY_TYPES, unitOf } from './framework.js';
|
|
23
|
+
import { editFrontmatterKey, formatScalar, hasKey } from './frontmatter.js';
|
|
24
|
+
import { moveFile } from './git.js';
|
|
25
|
+
import { buildIndexes } from './index-leaf.js';
|
|
26
|
+
import { movePath, rewriteLinks } from './links.js';
|
|
27
|
+
import { isRoleName, listRoles, meFile, readActor, roleFile, rolesDir } from './me.js';
|
|
28
|
+
import { convert, kebab } from './naming.js';
|
|
29
|
+
import { readSettings, writeSettings } from './settings.js';
|
|
30
|
+
export class EditError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'EditError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function editContext(root) {
|
|
37
|
+
const framework = loadFramework(root);
|
|
38
|
+
return { root, framework, blueprints: listBlueprints(framework) };
|
|
39
|
+
}
|
|
40
|
+
const rel = (root, file) => path.relative(root, file).split(path.sep).join('/');
|
|
41
|
+
const isEmpty = (value) => value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0);
|
|
42
|
+
// ---- the framework document, edited in place with its comments kept ---------
|
|
43
|
+
class FrameworkDoc {
|
|
44
|
+
doc;
|
|
45
|
+
file;
|
|
46
|
+
original;
|
|
47
|
+
constructor(framework) {
|
|
48
|
+
this.file = framework.file;
|
|
49
|
+
this.original = readFileSync(framework.file, 'utf8');
|
|
50
|
+
this.doc = parseDocument(this.original);
|
|
51
|
+
}
|
|
52
|
+
changed() {
|
|
53
|
+
return this.text() !== this.original;
|
|
54
|
+
}
|
|
55
|
+
text() {
|
|
56
|
+
return this.doc.toString({ lineWidth: 0 });
|
|
57
|
+
}
|
|
58
|
+
write() {
|
|
59
|
+
writeFileSync(this.file, this.text(), 'utf8');
|
|
60
|
+
}
|
|
61
|
+
// A sequence at a path, created empty when absent.
|
|
62
|
+
seq(keys) {
|
|
63
|
+
const held = this.doc.getIn(keys);
|
|
64
|
+
if (isSeq(held))
|
|
65
|
+
return held;
|
|
66
|
+
const node = this.doc.createNode([]);
|
|
67
|
+
this.doc.setIn(keys, node);
|
|
68
|
+
return node;
|
|
69
|
+
}
|
|
70
|
+
// The map in a sequence whose scalar field equals the value.
|
|
71
|
+
static find(seq, field, value) {
|
|
72
|
+
if (!seq)
|
|
73
|
+
return null;
|
|
74
|
+
for (const item of seq.items) {
|
|
75
|
+
if (isMap(item) && String(item.get(field) ?? '') === value)
|
|
76
|
+
return item;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
// A new entry in the style its siblings use: flow (`{ ... }`, one row per
|
|
81
|
+
// line) when any of them is, since a row a tool expanded with its own
|
|
82
|
+
// nested fields sits beside rows the seed wrote flat.
|
|
83
|
+
add(seq, value) {
|
|
84
|
+
const node = this.doc.createNode(value);
|
|
85
|
+
if (seq.items.some((item) => isMap(item) && item.flow))
|
|
86
|
+
node.flow = true;
|
|
87
|
+
seq.add(node);
|
|
88
|
+
return node;
|
|
89
|
+
}
|
|
90
|
+
static remove(seq, entry) {
|
|
91
|
+
const index = seq.items.indexOf(entry);
|
|
92
|
+
if (index !== -1)
|
|
93
|
+
seq.items.splice(index, 1);
|
|
94
|
+
}
|
|
95
|
+
custom() {
|
|
96
|
+
return this.seq(['properties', 'custom']);
|
|
97
|
+
}
|
|
98
|
+
topLevel() {
|
|
99
|
+
return this.seq(['top_level']);
|
|
100
|
+
}
|
|
101
|
+
// `folders` since 5.3.0; a root not yet migrated still keeps `collections`,
|
|
102
|
+
// and an edit lands where the document declares them rather than opening a
|
|
103
|
+
// second list beside it.
|
|
104
|
+
folders() {
|
|
105
|
+
const held = this.doc.getIn(['folders']);
|
|
106
|
+
if (isSeq(held))
|
|
107
|
+
return held;
|
|
108
|
+
const legacy = this.doc.getIn(['collections']);
|
|
109
|
+
if (isSeq(legacy))
|
|
110
|
+
return legacy;
|
|
111
|
+
return this.seq(['folders']);
|
|
112
|
+
}
|
|
113
|
+
collection(name) {
|
|
114
|
+
return FrameworkDoc.find(this.folders(), 'name', name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Every `applies_to` list in the document that names a collection.
|
|
118
|
+
function renameAppliesTo(doc, from, to) {
|
|
119
|
+
let count = 0;
|
|
120
|
+
const blocks = [doc.doc.getIn(['properties', 'custom'])];
|
|
121
|
+
const tools = doc.doc.getIn(['properties', 'tools']);
|
|
122
|
+
if (isMap(tools))
|
|
123
|
+
for (const pair of tools.items)
|
|
124
|
+
blocks.push(pair.value);
|
|
125
|
+
for (const block of blocks) {
|
|
126
|
+
if (!isSeq(block))
|
|
127
|
+
continue;
|
|
128
|
+
for (const entry of block.items) {
|
|
129
|
+
if (!isMap(entry))
|
|
130
|
+
continue;
|
|
131
|
+
const applies = entry.get('applies_to', true);
|
|
132
|
+
if (isSeq(applies)) {
|
|
133
|
+
for (const item of applies.items) {
|
|
134
|
+
if (isScalar(item) && String(item.value) === from) {
|
|
135
|
+
item.value = to;
|
|
136
|
+
count += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (isScalar(applies) && String(applies.value) === from) {
|
|
141
|
+
applies.value = to;
|
|
142
|
+
count += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
// ---- the files: frontmatter keys and links, one write per file -------------
|
|
149
|
+
// Edits gathered per file before anything is written, so a key change and a
|
|
150
|
+
// link rewrite on the same file land in one write.
|
|
151
|
+
class Files {
|
|
152
|
+
root;
|
|
153
|
+
edits = new Map();
|
|
154
|
+
constructor(root) {
|
|
155
|
+
this.root = root;
|
|
156
|
+
}
|
|
157
|
+
load(file) {
|
|
158
|
+
let held = this.edits.get(file);
|
|
159
|
+
if (!held) {
|
|
160
|
+
held = { text: readFileSync(file, 'utf8'), steps: [] };
|
|
161
|
+
this.edits.set(file, held);
|
|
162
|
+
}
|
|
163
|
+
return held;
|
|
164
|
+
}
|
|
165
|
+
key(blueprint, op, key, extra = {}) {
|
|
166
|
+
const held = this.load(blueprint.path);
|
|
167
|
+
const before = blueprint.properties?.[key];
|
|
168
|
+
if (op === 'rename' && extra.to !== undefined) {
|
|
169
|
+
held.text = editFrontmatterKey(held.text, { kind: 'rename', key, to: extra.to });
|
|
170
|
+
held.steps.push({ kind: 'key', path: blueprint.rel, op, key, to: extra.to });
|
|
171
|
+
}
|
|
172
|
+
else if (op === 'set') {
|
|
173
|
+
held.text = editFrontmatterKey(held.text, { kind: 'set', key, value: extra.value ?? '' });
|
|
174
|
+
held.steps.push({ kind: 'key', path: blueprint.rel, op, key, before, after: extra.value ?? '' });
|
|
175
|
+
}
|
|
176
|
+
else if (op === 'delete') {
|
|
177
|
+
held.text = editFrontmatterKey(held.text, { kind: 'delete', key });
|
|
178
|
+
held.steps.push({ kind: 'key', path: blueprint.rel, op, key, before });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Every markdown file in the root with its links rewritten for the moves.
|
|
182
|
+
links(moves) {
|
|
183
|
+
if (moves.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
for (const file of markdownFiles(this.root)) {
|
|
186
|
+
const held = this.edits.get(file) ?? { text: readFileSync(file, 'utf8'), steps: [] };
|
|
187
|
+
const after = movePath(file, moves);
|
|
188
|
+
const result = rewriteLinks(held.text, file, after, moves);
|
|
189
|
+
if (result.count === 0)
|
|
190
|
+
continue;
|
|
191
|
+
held.text = result.text;
|
|
192
|
+
held.steps.push({ kind: 'links', path: rel(this.root, file), count: result.count });
|
|
193
|
+
this.edits.set(file, held);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
steps() {
|
|
197
|
+
return [...this.edits.values()].flatMap((held) => held.steps);
|
|
198
|
+
}
|
|
199
|
+
files() {
|
|
200
|
+
return [...this.edits.keys()];
|
|
201
|
+
}
|
|
202
|
+
write() {
|
|
203
|
+
for (const [file, held] of this.edits) {
|
|
204
|
+
if (held.steps.length > 0)
|
|
205
|
+
writeFileSync(file, held.text, 'utf8');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const SKIPPED = new Set(['.git', 'node_modules', '.DS_Store']);
|
|
210
|
+
function markdownFiles(dir) {
|
|
211
|
+
const out = [];
|
|
212
|
+
let entries = [];
|
|
213
|
+
try {
|
|
214
|
+
entries = readdirSync(dir).sort();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (SKIPPED.has(entry))
|
|
221
|
+
continue;
|
|
222
|
+
const full = path.join(dir, entry);
|
|
223
|
+
let isDir = false;
|
|
224
|
+
try {
|
|
225
|
+
isDir = statSync(full).isDirectory();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (isDir)
|
|
231
|
+
out.push(...markdownFiles(full));
|
|
232
|
+
else if (/\.md$/i.test(entry))
|
|
233
|
+
out.push(full);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
function draft() {
|
|
238
|
+
return { steps: [], lost: [], conflicts: [], notes: [], destructive: false, actions: [] };
|
|
239
|
+
}
|
|
240
|
+
function finish(context, command, doc, files, work) {
|
|
241
|
+
const steps = [...files.steps(), ...work.steps];
|
|
242
|
+
if (doc && doc.changed())
|
|
243
|
+
steps.unshift({ kind: 'document', path: rel(context.root, doc.file), what: work.steps.filter((step) => step.kind === 'document').length > 0 ? '' : 'edited' });
|
|
244
|
+
const documentSteps = steps.filter((step) => step.kind === 'document');
|
|
245
|
+
// one line for the document, naming everything that changes in it
|
|
246
|
+
const merged = documentSteps.length > 1 ? [{ kind: 'document', path: rel(context.root, doc?.file ?? ''), what: documentSteps.map((step) => (step.kind === 'document' ? step.what : '')).filter((what) => what !== '' && what !== 'edited').join('; ') }, ...steps.filter((step) => step.kind !== 'document')] : steps;
|
|
247
|
+
return {
|
|
248
|
+
root: context.root,
|
|
249
|
+
command,
|
|
250
|
+
steps: merged,
|
|
251
|
+
lost: work.lost,
|
|
252
|
+
conflicts: work.conflicts,
|
|
253
|
+
notes: work.notes,
|
|
254
|
+
destructive: work.destructive || work.lost.length > 0,
|
|
255
|
+
apply: () => {
|
|
256
|
+
files.write();
|
|
257
|
+
for (const action of work.actions)
|
|
258
|
+
action();
|
|
259
|
+
if (doc && doc.changed())
|
|
260
|
+
doc.write();
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// The plan as --json prints it: everything but the closure.
|
|
265
|
+
export function planJson(plan) {
|
|
266
|
+
return { root: plan.root, command: plan.command, steps: plan.steps, lost: plan.lost, conflicts: plan.conflicts, notes: plan.notes, destructive: plan.destructive };
|
|
267
|
+
}
|
|
268
|
+
// Run a plan, then rewrite the index and check the root, reporting what the
|
|
269
|
+
// run introduced: a finding (by file and code) the root did not have before,
|
|
270
|
+
// with a file that moved followed to its new path.
|
|
271
|
+
export function applyPlan(plan, standardVersion) {
|
|
272
|
+
const before = editContext(plan.root);
|
|
273
|
+
const was = checkRoot(before.framework, before.blueprints, { standardVersion });
|
|
274
|
+
plan.apply();
|
|
275
|
+
const after = editContext(plan.root);
|
|
276
|
+
buildIndexes(after.framework, after.blueprints, { check: false });
|
|
277
|
+
const reloaded = editContext(plan.root);
|
|
278
|
+
const report = checkRoot(reloaded.framework, reloaded.blueprints, { standardVersion });
|
|
279
|
+
const moves = plan.steps.flatMap((step) => (step.kind === 'move' ? [{ from: step.from.replace(/\/$/, ''), to: step.to.replace(/\/$/, '') }] : []));
|
|
280
|
+
const followed = (file) => {
|
|
281
|
+
if (file === null)
|
|
282
|
+
return null;
|
|
283
|
+
for (const move of moves) {
|
|
284
|
+
if (file === move.from)
|
|
285
|
+
return move.to;
|
|
286
|
+
if (file.startsWith(`${move.from}/`))
|
|
287
|
+
return `${move.to}/${file.slice(move.from.length + 1)}`;
|
|
288
|
+
}
|
|
289
|
+
return file;
|
|
290
|
+
};
|
|
291
|
+
const seen = new Set(was.findings.map((finding) => `${followed(finding.path)}|${finding.code}`));
|
|
292
|
+
const newFindings = report.findings.filter((finding) => !seen.has(`${finding.path}|${finding.code}`));
|
|
293
|
+
return { plan, newFindings, errors: report.errors, warnings: report.warnings };
|
|
294
|
+
}
|
|
295
|
+
function propertyOf(context, name) {
|
|
296
|
+
const custom = context.framework.schema.custom.find((property) => property.name === name);
|
|
297
|
+
if (custom)
|
|
298
|
+
return custom;
|
|
299
|
+
if (context.framework.schema.core.some((property) => property.name === name))
|
|
300
|
+
throw new EditError(`${name} is the standard's own; its block moves with eidos_version and is never edited here`);
|
|
301
|
+
const tool = Object.entries(context.framework.schema.tools).find(([, block]) => block.some((property) => property.name === name));
|
|
302
|
+
if (tool)
|
|
303
|
+
throw new EditError(`${name} is declared by ${tool[0]}, in its own block; only that tool writes there`);
|
|
304
|
+
throw new EditError(`no custom property '${name}' (eidos framework lists them)`);
|
|
305
|
+
}
|
|
306
|
+
function assertFreeName(context, name) {
|
|
307
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name))
|
|
308
|
+
throw new EditError(`'${name}' is not a frontmatter key: letters, digits, _ and -, no spaces`);
|
|
309
|
+
const every = [...context.framework.schema.core, ...context.framework.schema.custom, ...Object.values(context.framework.schema.tools).flat()];
|
|
310
|
+
if (every.some((property) => property.name === name))
|
|
311
|
+
throw new EditError(`a property named '${name}' already exists`);
|
|
312
|
+
}
|
|
313
|
+
function checkType(type) {
|
|
314
|
+
const known = PROPERTY_TYPES.find((candidate) => candidate.toLowerCase() === type.toLowerCase());
|
|
315
|
+
if (!known)
|
|
316
|
+
throw new EditError(`'${type}' is not one of the standard's types: ${PROPERTY_TYPES.join(', ')}`);
|
|
317
|
+
return known;
|
|
318
|
+
}
|
|
319
|
+
function resolveAppliesTo(context, given) {
|
|
320
|
+
if (given === 'all')
|
|
321
|
+
return 'all';
|
|
322
|
+
return given.map((name) => {
|
|
323
|
+
const collection = findCollection(context.framework, name);
|
|
324
|
+
if (!collection)
|
|
325
|
+
throw new EditError(`no collection '${name}' (declared: ${context.framework.collections.map((entry) => entry.name).join(', ')})`);
|
|
326
|
+
return collection.name;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const blank = (type) => (type.toLowerCase() === 'list' ? [] : '');
|
|
330
|
+
// A declared list of options: non-empty, no repeats, on a Text or List
|
|
331
|
+
// property; the standard's one refinement of a type.
|
|
332
|
+
function checkOptions(type, options) {
|
|
333
|
+
const cleaned = [...new Set(options.map((option) => option.trim()).filter((option) => option !== ''))];
|
|
334
|
+
if (cleaned.length === 0)
|
|
335
|
+
throw new EditError('options must be a non-empty list of values (an open set is what no options already says)');
|
|
336
|
+
if (!['text', 'list'].includes(type.toLowerCase()))
|
|
337
|
+
throw new EditError(`options close a Text or List value; ${type} has none`);
|
|
338
|
+
return cleaned;
|
|
339
|
+
}
|
|
340
|
+
// `options` sits among the standard's six, before `meaning`, whether the
|
|
341
|
+
// entry is written whole or the key is added to one already there.
|
|
342
|
+
function setOptions(doc, entry, options) {
|
|
343
|
+
entry.delete('options');
|
|
344
|
+
if (options === null)
|
|
345
|
+
return;
|
|
346
|
+
const pair = doc.doc.createPair('options', options);
|
|
347
|
+
if (isSeq(pair.value))
|
|
348
|
+
pair.value.flow = true;
|
|
349
|
+
const at = entry.items.findIndex((item) => isScalar(item.key) && String(item.key.value) === 'meaning');
|
|
350
|
+
entry.items.splice(at === -1 ? entry.items.length : at, 0, pair);
|
|
351
|
+
}
|
|
352
|
+
// A blueprint value the new list does not hold, one conflict naming them
|
|
353
|
+
// all: narrowing a list is retiring values, so the owner sees every one
|
|
354
|
+
// before it leaves, and --force proceeds. Nothing is written to a blueprint;
|
|
355
|
+
// check reports each afterwards.
|
|
356
|
+
function surfaceOffList(context, work, name, property, options) {
|
|
357
|
+
const held = [];
|
|
358
|
+
for (const blueprint of context.blueprints) {
|
|
359
|
+
const problem = optionProblem({ ...property, options }, blueprint.properties?.[name]);
|
|
360
|
+
if (problem)
|
|
361
|
+
held.push(`${blueprint.rel}: ${problem.off.join(', ')}`);
|
|
362
|
+
}
|
|
363
|
+
if (held.length > 0)
|
|
364
|
+
work.conflicts.push({ message: `${held.length} blueprint${held.length === 1 ? ' carries' : 's carry'} a value off the list (${held.join('; ')}); check will report each, and --force declares it anyway`, force: true });
|
|
365
|
+
}
|
|
366
|
+
function inScope(blueprint, applies) {
|
|
367
|
+
return appliesTo({ name: '', type: 'Text', appliesTo: applies, required: false, meaning: '', core: false, owner: 'custom' }, blueprint.collection.name);
|
|
368
|
+
}
|
|
369
|
+
export function planPropertyAdd(context, spec) {
|
|
370
|
+
assertFreeName(context, spec.name);
|
|
371
|
+
const type = checkType(spec.type);
|
|
372
|
+
const applies = resolveAppliesTo(context, spec.appliesTo);
|
|
373
|
+
const options = spec.options ? checkOptions(type, spec.options) : null;
|
|
374
|
+
const doc = new FrameworkDoc(context.framework);
|
|
375
|
+
const files = new Files(context.root);
|
|
376
|
+
const work = draft();
|
|
377
|
+
const entry = doc.add(doc.custom(), { name: spec.name, type, applies_to: applies, ...(spec.required ? { required: true } : {}), meaning: spec.meaning });
|
|
378
|
+
if (options)
|
|
379
|
+
setOptions(doc, entry, options);
|
|
380
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `properties.custom: add ${spec.name}` });
|
|
381
|
+
if (spec.required) {
|
|
382
|
+
for (const blueprint of context.blueprints) {
|
|
383
|
+
if (inScope(blueprint, applies) && blueprint.properties && !(spec.name in blueprint.properties))
|
|
384
|
+
files.key(blueprint, 'set', spec.name, { value: blank(type) });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return finish(context, `configure:property add ${spec.name}`, doc, files, work);
|
|
388
|
+
}
|
|
389
|
+
export function planPropertyRename(context, name, to) {
|
|
390
|
+
const property = propertyOf(context, name);
|
|
391
|
+
assertFreeName(context, to);
|
|
392
|
+
const doc = new FrameworkDoc(context.framework);
|
|
393
|
+
const files = new Files(context.root);
|
|
394
|
+
const work = draft();
|
|
395
|
+
const entry = FrameworkDoc.find(doc.custom(), 'name', name);
|
|
396
|
+
if (!entry)
|
|
397
|
+
throw new EditError(`the document has no custom entry '${name}'`);
|
|
398
|
+
entry.set('name', to);
|
|
399
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `properties.custom: ${name} → ${to}` });
|
|
400
|
+
for (const collection of context.framework.collections) {
|
|
401
|
+
if (collection.grouping?.property === name) {
|
|
402
|
+
const node = doc.collection(collection.name);
|
|
403
|
+
const grouping = node?.get('grouping', true);
|
|
404
|
+
if (isMap(grouping) && String(grouping.get('property') ?? '') === name) {
|
|
405
|
+
grouping.set('property', to);
|
|
406
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.grouping.property → ${to}` });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const blueprint of context.blueprints) {
|
|
411
|
+
if (blueprint.properties && name in blueprint.properties)
|
|
412
|
+
files.key(blueprint, 'rename', name, { to });
|
|
413
|
+
}
|
|
414
|
+
renameInSettings(context, work, name, to);
|
|
415
|
+
void property;
|
|
416
|
+
return finish(context, `configure:property rename ${name} ${to}`, doc, files, work);
|
|
417
|
+
}
|
|
418
|
+
// This CLI's own references to a property: on-save rules and hidden columns.
|
|
419
|
+
function renameInSettings(context, work, name, to) {
|
|
420
|
+
const settings = readSettings(context.root);
|
|
421
|
+
const rules = settings.shared.on_save.filter((rule) => rule.property === name);
|
|
422
|
+
if (rules.length > 0) {
|
|
423
|
+
work.steps.push({ kind: 'settings', path: settings.files.shared, what: to ? `on_save: ${name} → ${to}` : `on_save: drop the rule on ${name}` });
|
|
424
|
+
work.actions.push(() => writeSettings(context.root, { shared: { on_save: readSettings(context.root).shared.on_save.flatMap((rule) => (rule.property === name ? (to ? [{ ...rule, property: to }] : []) : [rule])) } }));
|
|
425
|
+
}
|
|
426
|
+
const columns = Object.entries(settings.local.tables.columns).filter(([, hidden]) => hidden.includes(name));
|
|
427
|
+
if (columns.length > 0) {
|
|
428
|
+
work.steps.push({ kind: 'settings', path: settings.files.local, what: to ? `tables.columns: ${name} → ${to}` : `tables.columns: drop ${name}` });
|
|
429
|
+
work.actions.push(() => {
|
|
430
|
+
const current = readSettings(context.root).local.tables.columns;
|
|
431
|
+
const next = {};
|
|
432
|
+
for (const [collection, hidden] of Object.entries(current))
|
|
433
|
+
next[collection] = hidden.flatMap((key) => (key === name ? (to ? [to] : []) : [key]));
|
|
434
|
+
writeSettings(context.root, { local: { tables: { columns: next } } });
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
export function planPropertySet(context, name, changes, options) {
|
|
439
|
+
const property = propertyOf(context, name);
|
|
440
|
+
const doc = new FrameworkDoc(context.framework);
|
|
441
|
+
const files = new Files(context.root);
|
|
442
|
+
const work = draft();
|
|
443
|
+
const entry = FrameworkDoc.find(doc.custom(), 'name', name);
|
|
444
|
+
if (!entry)
|
|
445
|
+
throw new EditError(`the document has no custom entry '${name}'`);
|
|
446
|
+
const what = [];
|
|
447
|
+
const type = changes.type !== undefined ? checkType(changes.type) : property.type;
|
|
448
|
+
if (changes.type !== undefined && type !== property.type) {
|
|
449
|
+
entry.set('type', type);
|
|
450
|
+
what.push(`type ${property.type} → ${type}`);
|
|
451
|
+
}
|
|
452
|
+
if (changes.meaning !== undefined && changes.meaning !== property.meaning) {
|
|
453
|
+
entry.set('meaning', changes.meaning);
|
|
454
|
+
what.push('meaning');
|
|
455
|
+
}
|
|
456
|
+
const applies = changes.appliesTo !== undefined ? resolveAppliesTo(context, changes.appliesTo) : property.appliesTo;
|
|
457
|
+
if (changes.appliesTo !== undefined) {
|
|
458
|
+
entry.set('applies_to', applies === 'all' ? 'all' : doc.doc.createNode(applies));
|
|
459
|
+
what.push(`applies_to → ${applies === 'all' ? 'all' : applies.join(', ')}`);
|
|
460
|
+
for (const blueprint of context.blueprints) {
|
|
461
|
+
if (!blueprint.properties || !(name in blueprint.properties) || inScope(blueprint, applies))
|
|
462
|
+
continue;
|
|
463
|
+
if (options.preserve)
|
|
464
|
+
continue;
|
|
465
|
+
if (!isEmpty(blueprint.properties[name]))
|
|
466
|
+
work.lost.push({ path: blueprint.rel, key: name, value: blueprint.properties[name] });
|
|
467
|
+
files.key(blueprint, 'delete', name);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const required = changes.required ?? property.required;
|
|
471
|
+
if (changes.required !== undefined && required !== property.required) {
|
|
472
|
+
if (required)
|
|
473
|
+
entry.set('required', true);
|
|
474
|
+
else
|
|
475
|
+
entry.delete('required');
|
|
476
|
+
what.push(required ? 'required' : 'optional');
|
|
477
|
+
if (required) {
|
|
478
|
+
for (const blueprint of context.blueprints) {
|
|
479
|
+
if (inScope(blueprint, applies) && blueprint.properties && !(name in blueprint.properties))
|
|
480
|
+
files.key(blueprint, 'set', name, { value: blank(type) });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const held = property.options ?? null;
|
|
485
|
+
if (changes.options === null && held !== null) {
|
|
486
|
+
setOptions(doc, entry, null);
|
|
487
|
+
what.push('options dropped: any value is valid again');
|
|
488
|
+
}
|
|
489
|
+
else if (changes.options !== undefined && changes.options !== null) {
|
|
490
|
+
const options = checkOptions(type, changes.options);
|
|
491
|
+
if (held === null || options.join('\u0000') !== held.join('\u0000')) {
|
|
492
|
+
setOptions(doc, entry, options);
|
|
493
|
+
what.push(`options ${held === null ? 'declared' : '→'} ${options.join(', ')}`);
|
|
494
|
+
// declaring a list, or narrowing one, changes what conforms; widening touches nothing
|
|
495
|
+
if (held === null || held.some((option) => !options.includes(option)))
|
|
496
|
+
surfaceOffList(context, work, name, property, options);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else if (held !== null && !['text', 'list'].includes(type.toLowerCase())) {
|
|
500
|
+
throw new EditError(`${name} declares options, which a ${type} cannot hold; drop them with --open first`);
|
|
501
|
+
}
|
|
502
|
+
if (what.length === 0)
|
|
503
|
+
throw new EditError(`nothing to change on '${name}'`);
|
|
504
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `properties.custom ${name}: ${what.join(', ')}` });
|
|
505
|
+
return finish(context, `configure:property set ${name}`, doc, files, work);
|
|
506
|
+
}
|
|
507
|
+
export function planPropertyRemap(context, name, pairs) {
|
|
508
|
+
const property = propertyOf(context, name);
|
|
509
|
+
const doc = new FrameworkDoc(context.framework);
|
|
510
|
+
const files = new Files(context.root);
|
|
511
|
+
const work = draft();
|
|
512
|
+
const entry = FrameworkDoc.find(doc.custom(), 'name', name);
|
|
513
|
+
const styles = entry?.getIn(['eidosmd', 'canvas', 'styles'], true);
|
|
514
|
+
let options = property.options ?? null;
|
|
515
|
+
for (const [from, to] of pairs) {
|
|
516
|
+
for (const blueprint of context.blueprints) {
|
|
517
|
+
if (propertyString(blueprint, name) === from)
|
|
518
|
+
files.key(blueprint, 'set', name, { value: to });
|
|
519
|
+
}
|
|
520
|
+
// a value the list declares follows its rename, so the list still holds what the blueprints say
|
|
521
|
+
if (options && entry && options.includes(from) && !options.includes(to)) {
|
|
522
|
+
options = options.map((option) => (option === from ? to : option));
|
|
523
|
+
setOptions(doc, entry, options);
|
|
524
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${name}: option ${from} → ${to}` });
|
|
525
|
+
}
|
|
526
|
+
if (isMap(styles) && styles.has(from)) {
|
|
527
|
+
const pair = styles.items.find((item) => isScalar(item.key) && String(item.key.value) === from);
|
|
528
|
+
if (pair && isScalar(pair.key)) {
|
|
529
|
+
pair.key.value = to;
|
|
530
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${name}: canvas style ${from} → ${to}` });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return finish(context, `configure:property remap ${name}`, doc, files, work);
|
|
535
|
+
}
|
|
536
|
+
export function planPropertyRemove(context, name, options) {
|
|
537
|
+
propertyOf(context, name);
|
|
538
|
+
const doc = new FrameworkDoc(context.framework);
|
|
539
|
+
const files = new Files(context.root);
|
|
540
|
+
const work = draft();
|
|
541
|
+
const entry = FrameworkDoc.find(doc.custom(), 'name', name);
|
|
542
|
+
if (!entry)
|
|
543
|
+
throw new EditError(`the document has no custom entry '${name}'`);
|
|
544
|
+
FrameworkDoc.remove(doc.custom(), entry);
|
|
545
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `properties.custom: remove ${name}` });
|
|
546
|
+
for (const collection of context.framework.collections) {
|
|
547
|
+
if (collection.grouping?.property === name) {
|
|
548
|
+
const grouping = doc.collection(collection.name)?.get('grouping', true);
|
|
549
|
+
if (isMap(grouping) && grouping.has('property')) {
|
|
550
|
+
grouping.delete('property');
|
|
551
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.grouping.property dropped` });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const blueprint of context.blueprints) {
|
|
556
|
+
if (!blueprint.properties || !(name in blueprint.properties))
|
|
557
|
+
continue;
|
|
558
|
+
if (!isEmpty(blueprint.properties[name]))
|
|
559
|
+
work.lost.push({ path: blueprint.rel, key: name, value: blueprint.properties[name] });
|
|
560
|
+
if (!options.preserve)
|
|
561
|
+
files.key(blueprint, 'delete', name);
|
|
562
|
+
}
|
|
563
|
+
if (options.preserve && work.lost.length > 0)
|
|
564
|
+
work.notes.push(`--preserve leaves ${name} in ${work.lost.length} file(s), which check will report as property-unknown`);
|
|
565
|
+
renameInSettings(context, work, name, null);
|
|
566
|
+
return finish(context, `configure:property remove ${name}`, doc, files, work);
|
|
567
|
+
}
|
|
568
|
+
// ---- collections ------------------------------------------------------------
|
|
569
|
+
function collectionOf(context, name) {
|
|
570
|
+
const collection = findCollection(context.framework, name);
|
|
571
|
+
if (!collection)
|
|
572
|
+
throw new EditError(`no collection '${name}' (declared: ${context.framework.collections.map((entry) => entry.name).join(', ')})`);
|
|
573
|
+
return collection;
|
|
574
|
+
}
|
|
575
|
+
const templatePath = (unit, variant) => `templates/${kebab(unit)}.${kebab(variant)}.md`;
|
|
576
|
+
const BARE_TEMPLATE = '# {{title}}\n\n## Intent\n\n_Why this exists: the problem and who has it._\n';
|
|
577
|
+
// A folder name is taken by any folder the document declares, whatever its type.
|
|
578
|
+
function folderTaken(context, name) {
|
|
579
|
+
return context.framework.folders.some((folder) => folder.name.toLowerCase() === name.toLowerCase() || kebab(folder.name) === kebab(name));
|
|
580
|
+
}
|
|
581
|
+
export function planCollectionAdd(context, spec) {
|
|
582
|
+
const name = convert(spec.name, context.framework.naming);
|
|
583
|
+
if (folderTaken(context, name))
|
|
584
|
+
throw new EditError(`a folder named '${name}' is already declared`);
|
|
585
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(name))
|
|
586
|
+
throw new EditError(`'${name}' is not a folder name`);
|
|
587
|
+
const unit = kebab(spec.unit);
|
|
588
|
+
if (unit === '')
|
|
589
|
+
throw new EditError('a collection needs a unit, the word for one of its blueprints (spec, chapter, decision)');
|
|
590
|
+
const doc = new FrameworkDoc(context.framework);
|
|
591
|
+
const files = new Files(context.root);
|
|
592
|
+
const work = draft();
|
|
593
|
+
const template = templatePath(unit, 'default');
|
|
594
|
+
const templateFile = path.join(context.root, FRAMEWORK_DIR, template);
|
|
595
|
+
const folder = path.join(context.root, name);
|
|
596
|
+
doc.add(doc.folders(), { name, type: 'collection', ...(spec.description ? { description: spec.description } : {}), variants: [{ name: 'default', template, default: true }], ...(spec.grouping ? { grouping: { label: spec.grouping, groups: [] } } : {}) });
|
|
597
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders: add ${name} (collection, unit ${unit})` });
|
|
598
|
+
if (existsSync(templateFile))
|
|
599
|
+
work.notes.push(`${rel(context.root, templateFile)} already exists and is kept as it is`);
|
|
600
|
+
else {
|
|
601
|
+
work.steps.push({ kind: 'write', path: rel(context.root, templateFile), what: 'the default variant\'s template: a title and an Intent section' });
|
|
602
|
+
work.actions.push(() => {
|
|
603
|
+
mkdirSync(path.dirname(templateFile), { recursive: true });
|
|
604
|
+
writeFileSync(templateFile, BARE_TEMPLATE, 'utf8');
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
if (!existsSync(folder)) {
|
|
608
|
+
work.steps.push({ kind: 'write', path: `${name}/`, what: 'the collection folder' });
|
|
609
|
+
work.actions.push(() => mkdirSync(folder, { recursive: true }));
|
|
610
|
+
}
|
|
611
|
+
return finish(context, `configure:collection add ${name}`, doc, files, work);
|
|
612
|
+
}
|
|
613
|
+
export function planCollectionRename(context, name, to, unit) {
|
|
614
|
+
const collection = collectionOf(context, name);
|
|
615
|
+
const target = convert(to, context.framework.naming);
|
|
616
|
+
if (target !== collection.name && folderTaken(context, target))
|
|
617
|
+
throw new EditError(`a folder named '${target}' is already declared`);
|
|
618
|
+
const doc = new FrameworkDoc(context.framework);
|
|
619
|
+
const files = new Files(context.root);
|
|
620
|
+
const work = draft();
|
|
621
|
+
const node = doc.collection(collection.name);
|
|
622
|
+
if (!node)
|
|
623
|
+
throw new EditError(`the document has no collection '${collection.name}'`);
|
|
624
|
+
const moves = [];
|
|
625
|
+
if (target !== collection.name) {
|
|
626
|
+
node.set('name', target);
|
|
627
|
+
const count = renameAppliesTo(doc, collection.name, target);
|
|
628
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders: ${collection.name} → ${target}${count > 0 ? `; applies_to in ${count} propert${count === 1 ? 'y' : 'ies'}` : ''}` });
|
|
629
|
+
const from = path.join(context.root, collection.name);
|
|
630
|
+
const dest = path.join(context.root, target);
|
|
631
|
+
if (existsSync(dest))
|
|
632
|
+
work.conflicts.push({ message: `${target}/ already exists`, force: false });
|
|
633
|
+
else if (existsSync(from)) {
|
|
634
|
+
moves.push({ from, to: dest });
|
|
635
|
+
work.steps.push({ kind: 'move', from: `${collection.name}/`, to: `${target}/` });
|
|
636
|
+
work.actions.push(() => moveFile(context.root, from, dest));
|
|
637
|
+
}
|
|
638
|
+
const columns = readSettings(context.root).local.tables.columns;
|
|
639
|
+
if (columns[collection.name]) {
|
|
640
|
+
work.steps.push({ kind: 'settings', path: readSettings(context.root).files.local, what: `tables.columns: ${collection.name} → ${target}` });
|
|
641
|
+
work.actions.push(() => {
|
|
642
|
+
const current = { ...readSettings(context.root).local.tables.columns };
|
|
643
|
+
current[target] = current[collection.name] ?? [];
|
|
644
|
+
// null drops the old entry on the merge
|
|
645
|
+
current[collection.name] = null;
|
|
646
|
+
writeSettings(context.root, { local: { tables: { columns: current } } });
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (unit !== null) {
|
|
651
|
+
const nextUnit = kebab(unit);
|
|
652
|
+
const variants = node.get('variants', true);
|
|
653
|
+
if (isSeq(variants)) {
|
|
654
|
+
for (const variant of variants.items) {
|
|
655
|
+
if (!isMap(variant))
|
|
656
|
+
continue;
|
|
657
|
+
const held = String(variant.get('template') ?? '');
|
|
658
|
+
const variantName = String(variant.get('name') ?? '');
|
|
659
|
+
const next = templatePath(nextUnit, variantName);
|
|
660
|
+
if (held === next)
|
|
661
|
+
continue;
|
|
662
|
+
variant.set('template', next);
|
|
663
|
+
const from = path.join(context.root, FRAMEWORK_DIR, held);
|
|
664
|
+
const dest = path.join(context.root, FRAMEWORK_DIR, next);
|
|
665
|
+
if (existsSync(dest))
|
|
666
|
+
work.conflicts.push({ message: `${rel(context.root, dest)} already exists`, force: false });
|
|
667
|
+
else if (existsSync(from)) {
|
|
668
|
+
moves.push({ from, to: dest });
|
|
669
|
+
work.steps.push({ kind: 'move', from: rel(context.root, from), to: rel(context.root, dest) });
|
|
670
|
+
work.actions.push(() => moveFile(context.root, from, dest));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${target}: unit → ${nextUnit}` });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (moves.length === 0 && !doc.changed())
|
|
677
|
+
throw new EditError('nothing to rename');
|
|
678
|
+
files.links(moves);
|
|
679
|
+
return finish(context, `configure:collection rename ${collection.name} ${target}`, doc, files, work);
|
|
680
|
+
}
|
|
681
|
+
export function planCollectionRemove(context, name, options) {
|
|
682
|
+
const collection = collectionOf(context, name);
|
|
683
|
+
const doc = new FrameworkDoc(context.framework);
|
|
684
|
+
const files = new Files(context.root);
|
|
685
|
+
const work = draft();
|
|
686
|
+
const node = doc.collection(collection.name);
|
|
687
|
+
if (!node)
|
|
688
|
+
throw new EditError(`the document has no collection '${collection.name}'`);
|
|
689
|
+
FrameworkDoc.remove(doc.folders(), node);
|
|
690
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders: remove ${collection.name}` });
|
|
691
|
+
const index = doc.doc.getIn(['index']);
|
|
692
|
+
if (isMap(index) && index.has(collection.name))
|
|
693
|
+
index.delete(collection.name);
|
|
694
|
+
for (const variant of collection.variants) {
|
|
695
|
+
const file = path.join(context.root, FRAMEWORK_DIR, variant.template);
|
|
696
|
+
if (existsSync(file)) {
|
|
697
|
+
work.steps.push({ kind: 'remove', path: rel(context.root, file), what: `the ${variant.name} variant's template` });
|
|
698
|
+
work.actions.push(() => rmSync(file, { force: true }));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// a property scoped only to this collection goes with it, its values shown
|
|
702
|
+
for (const property of context.framework.schema.custom) {
|
|
703
|
+
if (property.appliesTo === 'all' || !property.appliesTo.some((entry) => entry.toLowerCase() === collection.name.toLowerCase()))
|
|
704
|
+
continue;
|
|
705
|
+
const remaining = property.appliesTo.filter((entry) => entry.toLowerCase() !== collection.name.toLowerCase());
|
|
706
|
+
const entry = FrameworkDoc.find(doc.custom(), 'name', property.name);
|
|
707
|
+
if (!entry)
|
|
708
|
+
continue;
|
|
709
|
+
if (remaining.length === 0) {
|
|
710
|
+
FrameworkDoc.remove(doc.custom(), entry);
|
|
711
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `properties.custom: remove ${property.name} (applied only to ${collection.name})` });
|
|
712
|
+
for (const blueprint of context.blueprints) {
|
|
713
|
+
if (blueprint.collection !== collection && blueprint.properties && property.name in blueprint.properties) {
|
|
714
|
+
if (!isEmpty(blueprint.properties[property.name]))
|
|
715
|
+
work.lost.push({ path: blueprint.rel, key: property.name, value: blueprint.properties[property.name] });
|
|
716
|
+
if (!options.preserve)
|
|
717
|
+
files.key(blueprint, 'delete', property.name);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
entry.set('applies_to', doc.doc.createNode(remaining));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const folder = path.join(context.root, collection.name);
|
|
726
|
+
const own = context.blueprints.filter((blueprint) => blueprint.collection === collection);
|
|
727
|
+
if (existsSync(folder)) {
|
|
728
|
+
if (options.preserve)
|
|
729
|
+
work.notes.push(`--preserve leaves ${collection.name}/ with ${own.length} blueprint(s), which check will report as folder-undeclared`);
|
|
730
|
+
else {
|
|
731
|
+
for (const blueprint of own) {
|
|
732
|
+
work.steps.push({ kind: 'remove', path: blueprint.rel, what: 'a blueprint' });
|
|
733
|
+
for (const [key, value] of Object.entries(blueprint.properties ?? {}))
|
|
734
|
+
if (!isEmpty(value))
|
|
735
|
+
work.lost.push({ path: blueprint.rel, key, value });
|
|
736
|
+
}
|
|
737
|
+
work.steps.push({ kind: 'remove', path: `${collection.name}/`, what: 'the collection folder' });
|
|
738
|
+
work.destructive = own.length > 0;
|
|
739
|
+
work.actions.push(() => rmSync(folder, { recursive: true, force: true }));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return finish(context, `configure:collection remove ${collection.name}`, doc, files, work);
|
|
743
|
+
}
|
|
744
|
+
// ---- folders that are not collections (assets, other) -----------------------
|
|
745
|
+
function folderOf(context, name) {
|
|
746
|
+
const found = context.framework.folders.find((folder) => !isCollection(folder) && (folder.name.toLowerCase() === name.toLowerCase() || kebab(folder.name) === kebab(name)));
|
|
747
|
+
if (found)
|
|
748
|
+
return found;
|
|
749
|
+
if (findCollection(context.framework, name))
|
|
750
|
+
throw new EditError(`'${name}' is a collection; configure:collection edits it`);
|
|
751
|
+
const declared = context.framework.folders.filter((folder) => !isCollection(folder)).map((folder) => folder.name);
|
|
752
|
+
throw new EditError(`no assets or other folder '${name}' (declared: ${declared.join(', ') || 'none'})`);
|
|
753
|
+
}
|
|
754
|
+
// Every file under a folder, whatever its kind: what a remove lists before it asks.
|
|
755
|
+
function filesUnder(dir) {
|
|
756
|
+
const out = [];
|
|
757
|
+
let entries = [];
|
|
758
|
+
try {
|
|
759
|
+
entries = readdirSync(dir).sort();
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
for (const entry of entries) {
|
|
765
|
+
const full = path.join(dir, entry);
|
|
766
|
+
let isDir = false;
|
|
767
|
+
try {
|
|
768
|
+
isDir = statSync(full).isDirectory();
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (isDir)
|
|
774
|
+
out.push(...filesUnder(full));
|
|
775
|
+
else
|
|
776
|
+
out.push(full);
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
export function planFolderAdd(context, spec) {
|
|
781
|
+
const name = convert(spec.name, context.framework.naming);
|
|
782
|
+
if (folderTaken(context, name))
|
|
783
|
+
throw new EditError(`a folder named '${name}' is already declared`);
|
|
784
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(name))
|
|
785
|
+
throw new EditError(`'${name}' is not a folder name`);
|
|
786
|
+
const doc = new FrameworkDoc(context.framework);
|
|
787
|
+
const work = draft();
|
|
788
|
+
doc.add(doc.folders(), { name, type: spec.type, description: spec.description.trim() });
|
|
789
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders: add ${name} (${spec.type})` });
|
|
790
|
+
const folder = path.join(context.root, name);
|
|
791
|
+
if (!existsSync(folder)) {
|
|
792
|
+
work.steps.push({ kind: 'write', path: `${name}/`, what: `the ${spec.type} folder` });
|
|
793
|
+
work.actions.push(() => mkdirSync(folder, { recursive: true }));
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
work.notes.push(`${name}/ already exists and is declared as it is`);
|
|
797
|
+
}
|
|
798
|
+
return finish(context, `configure:folder add ${name}`, doc, new Files(context.root), work);
|
|
799
|
+
}
|
|
800
|
+
export function planFolderRename(context, name, to) {
|
|
801
|
+
const folder = folderOf(context, name);
|
|
802
|
+
const target = convert(to, context.framework.naming);
|
|
803
|
+
if (target === folder.name)
|
|
804
|
+
throw new EditError('nothing to rename');
|
|
805
|
+
if (folderTaken(context, target))
|
|
806
|
+
throw new EditError(`a folder named '${target}' is already declared`);
|
|
807
|
+
const doc = new FrameworkDoc(context.framework);
|
|
808
|
+
const files = new Files(context.root);
|
|
809
|
+
const work = draft();
|
|
810
|
+
const node = FrameworkDoc.find(doc.folders(), 'name', folder.name);
|
|
811
|
+
if (!node)
|
|
812
|
+
throw new EditError(`the document has no folder '${folder.name}'`);
|
|
813
|
+
node.set('name', target);
|
|
814
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders: ${folder.name} → ${target}` });
|
|
815
|
+
const from = path.join(context.root, folder.name);
|
|
816
|
+
const dest = path.join(context.root, target);
|
|
817
|
+
const moves = [];
|
|
818
|
+
if (existsSync(dest))
|
|
819
|
+
work.conflicts.push({ message: `${target}/ already exists`, force: false });
|
|
820
|
+
else if (existsSync(from)) {
|
|
821
|
+
moves.push({ from, to: dest });
|
|
822
|
+
work.steps.push({ kind: 'move', from: `${folder.name}/`, to: `${target}/` });
|
|
823
|
+
work.actions.push(() => moveFile(context.root, from, dest));
|
|
824
|
+
}
|
|
825
|
+
files.links(moves);
|
|
826
|
+
return finish(context, `configure:folder rename ${folder.name} ${target}`, doc, files, work);
|
|
827
|
+
}
|
|
828
|
+
export function planFolderSet(context, name, changes) {
|
|
829
|
+
const folder = folderOf(context, name);
|
|
830
|
+
const doc = new FrameworkDoc(context.framework);
|
|
831
|
+
const work = draft();
|
|
832
|
+
const node = FrameworkDoc.find(doc.folders(), 'name', folder.name);
|
|
833
|
+
if (!node)
|
|
834
|
+
throw new EditError(`the document has no folder '${folder.name}'`);
|
|
835
|
+
const what = [];
|
|
836
|
+
if (changes.type !== undefined && changes.type !== folder.type) {
|
|
837
|
+
node.set('type', changes.type);
|
|
838
|
+
what.push(`type → ${changes.type}`);
|
|
839
|
+
}
|
|
840
|
+
if (changes.description !== undefined) {
|
|
841
|
+
node.set('description', changes.description.trim());
|
|
842
|
+
what.push('description');
|
|
843
|
+
}
|
|
844
|
+
if (!doc.changed())
|
|
845
|
+
throw new EditError(`nothing to change on '${folder.name}'`);
|
|
846
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders ${folder.name}: ${what.join('; ')}` });
|
|
847
|
+
return finish(context, `configure:folder set ${folder.name}`, doc, new Files(context.root), work);
|
|
848
|
+
}
|
|
849
|
+
// The entry goes and, unless preserved, the folder with every file in it,
|
|
850
|
+
// each listed: a folder with files needs a person's yes or --force.
|
|
851
|
+
export function planFolderRemove(context, name, options) {
|
|
852
|
+
const folder = folderOf(context, name);
|
|
853
|
+
const doc = new FrameworkDoc(context.framework);
|
|
854
|
+
const work = draft();
|
|
855
|
+
const node = FrameworkDoc.find(doc.folders(), 'name', folder.name);
|
|
856
|
+
if (!node)
|
|
857
|
+
throw new EditError(`the document has no folder '${folder.name}'`);
|
|
858
|
+
FrameworkDoc.remove(doc.folders(), node);
|
|
859
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `folders: remove ${folder.name}` });
|
|
860
|
+
const dir = path.join(context.root, folder.name);
|
|
861
|
+
if (existsSync(dir)) {
|
|
862
|
+
const held = filesUnder(dir);
|
|
863
|
+
if (options.preserve)
|
|
864
|
+
work.notes.push(`--preserve leaves ${folder.name}/ with ${held.length} file(s), which check will report as folder-undeclared`);
|
|
865
|
+
else {
|
|
866
|
+
for (const file of held)
|
|
867
|
+
work.steps.push({ kind: 'remove', path: rel(context.root, file), what: 'a file' });
|
|
868
|
+
work.steps.push({ kind: 'remove', path: `${folder.name}/`, what: `the ${folder.type} folder` });
|
|
869
|
+
work.destructive = held.length > 0;
|
|
870
|
+
work.actions.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return finish(context, `configure:folder remove ${folder.name}`, doc, new Files(context.root), work);
|
|
874
|
+
}
|
|
875
|
+
// ---- groups -----------------------------------------------------------------
|
|
876
|
+
function groupingOf(doc, collection) {
|
|
877
|
+
const node = doc.collection(collection.name);
|
|
878
|
+
const grouping = node?.get('grouping', true);
|
|
879
|
+
if (!isMap(grouping))
|
|
880
|
+
throw new EditError(`${collection.name} declares no grouping; add one under Framework Settings → Collections, or with configure:collection`);
|
|
881
|
+
return grouping;
|
|
882
|
+
}
|
|
883
|
+
function groupOf(collection, name) {
|
|
884
|
+
const found = collection.grouping?.groups.find((group) => group.name.toLowerCase() === name.toLowerCase() || kebab(group.name) === kebab(name));
|
|
885
|
+
if (!found)
|
|
886
|
+
throw new EditError(`no group '${name}' under ${collection.name}${collection.grouping ? ` (declared: ${collection.grouping.groups.map((group) => group.name).join(', ') || 'none'})` : ''}`);
|
|
887
|
+
return found.name;
|
|
888
|
+
}
|
|
889
|
+
export function planGroupAdd(context, collectionName, name, description) {
|
|
890
|
+
const collection = collectionOf(context, collectionName);
|
|
891
|
+
const doc = new FrameworkDoc(context.framework);
|
|
892
|
+
const files = new Files(context.root);
|
|
893
|
+
const work = draft();
|
|
894
|
+
const grouping = groupingOf(doc, collection);
|
|
895
|
+
const groupName = convert(name, context.framework.naming);
|
|
896
|
+
if (collection.grouping?.groups.some((group) => group.name.toLowerCase() === groupName.toLowerCase()))
|
|
897
|
+
throw new EditError(`${collection.name} already has a group '${groupName}'`);
|
|
898
|
+
const held = grouping.get('groups');
|
|
899
|
+
let groups;
|
|
900
|
+
if (isSeq(held))
|
|
901
|
+
groups = held;
|
|
902
|
+
else {
|
|
903
|
+
groups = doc.doc.createNode([]);
|
|
904
|
+
grouping.set('groups', groups);
|
|
905
|
+
}
|
|
906
|
+
doc.add(groups, { name: groupName, ...(description ? { description } : {}) });
|
|
907
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.grouping.groups: add ${groupName}` });
|
|
908
|
+
const folder = path.join(context.root, collection.name, groupName);
|
|
909
|
+
if (!existsSync(folder)) {
|
|
910
|
+
work.steps.push({ kind: 'write', path: `${collection.name}/${groupName}/`, what: 'the group folder' });
|
|
911
|
+
work.actions.push(() => mkdirSync(folder, { recursive: true }));
|
|
912
|
+
}
|
|
913
|
+
return finish(context, `configure:group add ${collection.name} ${groupName}`, doc, files, work);
|
|
914
|
+
}
|
|
915
|
+
export function planGroupRename(context, collectionName, name, to) {
|
|
916
|
+
const collection = collectionOf(context, collectionName);
|
|
917
|
+
const groupName = groupOf(collection, name);
|
|
918
|
+
const target = convert(to, context.framework.naming);
|
|
919
|
+
if (collection.grouping?.groups.some((group) => group.name.toLowerCase() === target.toLowerCase()))
|
|
920
|
+
throw new EditError(`${collection.name} already has a group '${target}'`);
|
|
921
|
+
const doc = new FrameworkDoc(context.framework);
|
|
922
|
+
const files = new Files(context.root);
|
|
923
|
+
const work = draft();
|
|
924
|
+
const grouping = groupingOf(doc, collection);
|
|
925
|
+
const held = grouping.get('groups');
|
|
926
|
+
const entry = FrameworkDoc.find(isSeq(held) ? held : null, 'name', groupName);
|
|
927
|
+
if (entry)
|
|
928
|
+
entry.set('name', target);
|
|
929
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.grouping.groups: ${groupName} → ${target}` });
|
|
930
|
+
const property = collection.grouping?.property ?? null;
|
|
931
|
+
for (const blueprint of context.blueprints) {
|
|
932
|
+
if (blueprint.collection === collection && blueprint.group === groupName && property && blueprint.properties && property in blueprint.properties)
|
|
933
|
+
files.key(blueprint, 'set', property, { value: target });
|
|
934
|
+
}
|
|
935
|
+
const from = path.join(context.root, collection.name, groupName);
|
|
936
|
+
const dest = path.join(context.root, collection.name, target);
|
|
937
|
+
const moves = [];
|
|
938
|
+
if (existsSync(dest))
|
|
939
|
+
work.conflicts.push({ message: `${collection.name}/${target}/ already exists`, force: false });
|
|
940
|
+
else if (existsSync(from)) {
|
|
941
|
+
moves.push({ from, to: dest });
|
|
942
|
+
work.steps.push({ kind: 'move', from: `${collection.name}/${groupName}/`, to: `${collection.name}/${target}/` });
|
|
943
|
+
work.actions.push(() => moveFile(context.root, from, dest));
|
|
944
|
+
}
|
|
945
|
+
files.links(moves);
|
|
946
|
+
return finish(context, `configure:group rename ${collection.name} ${groupName} ${target}`, doc, files, work);
|
|
947
|
+
}
|
|
948
|
+
export function planGroupRemove(context, collectionName, name, options) {
|
|
949
|
+
const collection = collectionOf(context, collectionName);
|
|
950
|
+
const groupName = groupOf(collection, name);
|
|
951
|
+
const doc = new FrameworkDoc(context.framework);
|
|
952
|
+
const files = new Files(context.root);
|
|
953
|
+
const work = draft();
|
|
954
|
+
const grouping = groupingOf(doc, collection);
|
|
955
|
+
const groups = grouping.get('groups');
|
|
956
|
+
const entry = isSeq(groups) ? FrameworkDoc.find(groups, 'name', groupName) : null;
|
|
957
|
+
if (entry && isSeq(groups))
|
|
958
|
+
FrameworkDoc.remove(groups, entry);
|
|
959
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.grouping.groups: remove ${groupName}` });
|
|
960
|
+
const folder = path.join(context.root, collection.name, groupName);
|
|
961
|
+
const own = context.blueprints.filter((blueprint) => blueprint.collection === collection && blueprint.group === groupName);
|
|
962
|
+
if (existsSync(folder)) {
|
|
963
|
+
if (options.preserve)
|
|
964
|
+
work.notes.push(`--preserve leaves ${collection.name}/${groupName}/ with ${own.length} blueprint(s), which check will report as group-undeclared`);
|
|
965
|
+
else {
|
|
966
|
+
for (const blueprint of own) {
|
|
967
|
+
work.steps.push({ kind: 'remove', path: blueprint.rel, what: 'a blueprint' });
|
|
968
|
+
for (const [key, value] of Object.entries(blueprint.properties ?? {}))
|
|
969
|
+
if (!isEmpty(value))
|
|
970
|
+
work.lost.push({ path: blueprint.rel, key, value });
|
|
971
|
+
}
|
|
972
|
+
work.steps.push({ kind: 'remove', path: `${collection.name}/${groupName}/`, what: 'the group folder' });
|
|
973
|
+
work.destructive = own.length > 0;
|
|
974
|
+
work.actions.push(() => rmSync(folder, { recursive: true, force: true }));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return finish(context, `configure:group remove ${collection.name} ${groupName}`, doc, files, work);
|
|
978
|
+
}
|
|
979
|
+
// ---- variants ---------------------------------------------------------------
|
|
980
|
+
function variantsSeq(doc, collection) {
|
|
981
|
+
const node = doc.collection(collection.name);
|
|
982
|
+
const variants = node?.get('variants', true);
|
|
983
|
+
if (!isSeq(variants))
|
|
984
|
+
throw new EditError(`${collection.name} declares no variants list in the document`);
|
|
985
|
+
return variants;
|
|
986
|
+
}
|
|
987
|
+
export function planVariantAdd(context, collectionName, name, description, from) {
|
|
988
|
+
const collection = collectionOf(context, collectionName);
|
|
989
|
+
const variantName = kebab(name);
|
|
990
|
+
if (variantName === '')
|
|
991
|
+
throw new EditError('a variant needs a name');
|
|
992
|
+
if (findVariant(collection, variantName))
|
|
993
|
+
throw new EditError(`${collection.name} already has a variant '${variantName}'`);
|
|
994
|
+
const unit = unitOf(collection);
|
|
995
|
+
if (!unit)
|
|
996
|
+
throw new EditError(`${collection.name} has no unit yet: its first variant's template names it (templates/<unit>.<variant>.md)`);
|
|
997
|
+
const source = from ? findVariant(collection, from) : defaultVariant(collection);
|
|
998
|
+
if (from && !source)
|
|
999
|
+
throw new EditError(`no variant '${from}' in ${collection.name}`);
|
|
1000
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1001
|
+
const files = new Files(context.root);
|
|
1002
|
+
const work = draft();
|
|
1003
|
+
const template = templatePath(unit, variantName);
|
|
1004
|
+
doc.add(variantsSeq(doc, collection), { name: variantName, template, ...(description ? { description } : {}) });
|
|
1005
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.variants: add ${variantName}` });
|
|
1006
|
+
const file = path.join(context.root, FRAMEWORK_DIR, template);
|
|
1007
|
+
const sourceFile = source ? path.join(context.root, FRAMEWORK_DIR, source.template) : null;
|
|
1008
|
+
if (existsSync(file))
|
|
1009
|
+
work.notes.push(`${rel(context.root, file)} already exists and is kept as it is`);
|
|
1010
|
+
else {
|
|
1011
|
+
work.steps.push({ kind: 'write', path: rel(context.root, file), what: sourceFile && existsSync(sourceFile) ? `a copy of ${source?.name}'s template` : 'a bare template' });
|
|
1012
|
+
work.actions.push(() => {
|
|
1013
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1014
|
+
if (sourceFile && existsSync(sourceFile))
|
|
1015
|
+
cpSync(sourceFile, file);
|
|
1016
|
+
else
|
|
1017
|
+
writeFileSync(file, BARE_TEMPLATE, 'utf8');
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return finish(context, `configure:variant add ${collection.name} ${variantName}`, doc, files, work);
|
|
1021
|
+
}
|
|
1022
|
+
export function planVariantRename(context, collectionName, name, to) {
|
|
1023
|
+
const collection = collectionOf(context, collectionName);
|
|
1024
|
+
const variant = findVariant(collection, name);
|
|
1025
|
+
if (!variant)
|
|
1026
|
+
throw new EditError(`no variant '${name}' in ${collection.name}`);
|
|
1027
|
+
const target = kebab(to);
|
|
1028
|
+
if (findVariant(collection, target))
|
|
1029
|
+
throw new EditError(`${collection.name} already has a variant '${target}'`);
|
|
1030
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1031
|
+
const files = new Files(context.root);
|
|
1032
|
+
const work = draft();
|
|
1033
|
+
const entry = FrameworkDoc.find(variantsSeq(doc, collection), 'name', variant.name);
|
|
1034
|
+
if (!entry)
|
|
1035
|
+
throw new EditError(`the document has no variant '${variant.name}'`);
|
|
1036
|
+
const template = templatePath(variant.unit || unitOf(collection) || kebab(collection.name), target);
|
|
1037
|
+
entry.set('name', target);
|
|
1038
|
+
entry.set('template', template);
|
|
1039
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.variants: ${variant.name} → ${target}` });
|
|
1040
|
+
const from = path.join(context.root, FRAMEWORK_DIR, variant.template);
|
|
1041
|
+
const dest = path.join(context.root, FRAMEWORK_DIR, template);
|
|
1042
|
+
if (existsSync(dest))
|
|
1043
|
+
work.conflicts.push({ message: `${rel(context.root, dest)} already exists`, force: false });
|
|
1044
|
+
else if (existsSync(from)) {
|
|
1045
|
+
work.steps.push({ kind: 'move', from: rel(context.root, from), to: rel(context.root, dest) });
|
|
1046
|
+
work.actions.push(() => moveFile(context.root, from, dest));
|
|
1047
|
+
}
|
|
1048
|
+
for (const blueprint of context.blueprints) {
|
|
1049
|
+
if (blueprint.collection === collection && propertyString(blueprint, 'variant')?.toLowerCase() === variant.name.toLowerCase())
|
|
1050
|
+
files.key(blueprint, 'set', 'variant', { value: target });
|
|
1051
|
+
}
|
|
1052
|
+
return finish(context, `configure:variant rename ${collection.name} ${variant.name} ${target}`, doc, files, work);
|
|
1053
|
+
}
|
|
1054
|
+
export function planVariantSetDefault(context, collectionName, name) {
|
|
1055
|
+
const collection = collectionOf(context, collectionName);
|
|
1056
|
+
const variant = findVariant(collection, name);
|
|
1057
|
+
if (!variant)
|
|
1058
|
+
throw new EditError(`no variant '${name}' in ${collection.name}`);
|
|
1059
|
+
if (variant.isDefault)
|
|
1060
|
+
throw new EditError(`${variant.name} is already the default`);
|
|
1061
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1062
|
+
const work = draft();
|
|
1063
|
+
for (const item of variantsSeq(doc, collection).items) {
|
|
1064
|
+
if (!isMap(item))
|
|
1065
|
+
continue;
|
|
1066
|
+
if (String(item.get('name') ?? '') === variant.name)
|
|
1067
|
+
item.set('default', true);
|
|
1068
|
+
else if (item.has('default'))
|
|
1069
|
+
item.delete('default');
|
|
1070
|
+
}
|
|
1071
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.variants: default → ${variant.name}` });
|
|
1072
|
+
return finish(context, `configure:variant set-default ${collection.name} ${variant.name}`, doc, new Files(context.root), work);
|
|
1073
|
+
}
|
|
1074
|
+
export function planVariantRemove(context, collectionName, name, options) {
|
|
1075
|
+
const collection = collectionOf(context, collectionName);
|
|
1076
|
+
const variant = findVariant(collection, name);
|
|
1077
|
+
if (!variant)
|
|
1078
|
+
throw new EditError(`no variant '${name}' in ${collection.name}`);
|
|
1079
|
+
if (variant.isDefault && collection.variants.length > 1)
|
|
1080
|
+
throw new EditError(`${variant.name} is ${collection.name}'s default; set another with configure:variant set-default first`);
|
|
1081
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1082
|
+
const files = new Files(context.root);
|
|
1083
|
+
const work = draft();
|
|
1084
|
+
const seq = variantsSeq(doc, collection);
|
|
1085
|
+
const entry = FrameworkDoc.find(seq, 'name', variant.name);
|
|
1086
|
+
if (entry)
|
|
1087
|
+
FrameworkDoc.remove(seq, entry);
|
|
1088
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `${collection.name}.variants: remove ${variant.name}` });
|
|
1089
|
+
const on = context.blueprints.filter((blueprint) => blueprint.collection === collection && propertyString(blueprint, 'variant')?.toLowerCase() === variant.name.toLowerCase());
|
|
1090
|
+
if (on.length > 0) {
|
|
1091
|
+
work.conflicts.push({ message: `${on.length} blueprint(s) name the variant ${variant.name}: ${on.map((blueprint) => blueprint.rel).join(', ')}; --force clears their variant so they follow the default`, force: true });
|
|
1092
|
+
if (options.force)
|
|
1093
|
+
for (const blueprint of on)
|
|
1094
|
+
files.key(blueprint, 'delete', 'variant');
|
|
1095
|
+
}
|
|
1096
|
+
const file = path.join(context.root, FRAMEWORK_DIR, variant.template);
|
|
1097
|
+
if (existsSync(file)) {
|
|
1098
|
+
work.steps.push({ kind: 'remove', path: rel(context.root, file), what: 'its template' });
|
|
1099
|
+
work.actions.push(() => rmSync(file, { force: true }));
|
|
1100
|
+
}
|
|
1101
|
+
return finish(context, `configure:variant remove ${collection.name} ${variant.name}`, doc, files, work);
|
|
1102
|
+
}
|
|
1103
|
+
function termEntry(doc, term) {
|
|
1104
|
+
const vocabulary = doc.doc.getIn(['vocabulary']);
|
|
1105
|
+
if (!isSeq(vocabulary))
|
|
1106
|
+
return null;
|
|
1107
|
+
for (const item of vocabulary.items) {
|
|
1108
|
+
if (isMap(item) && String(item.get('term') ?? '').toLowerCase() === term.toLowerCase())
|
|
1109
|
+
return item;
|
|
1110
|
+
}
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
export function planTermAdd(context, spec) {
|
|
1114
|
+
if (spec.term.trim() === '')
|
|
1115
|
+
throw new EditError('a term needs a word');
|
|
1116
|
+
if (context.framework.vocabulary.some((entry) => entry.term.toLowerCase() === spec.term.toLowerCase()))
|
|
1117
|
+
throw new EditError(`the Vocabulary already declares '${spec.term}'`);
|
|
1118
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1119
|
+
const work = draft();
|
|
1120
|
+
doc.add(doc.seq(['vocabulary']), { term: spec.term, means: spec.means, not: spec.not, ...(spec.see ? { see: spec.see } : {}) });
|
|
1121
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `vocabulary: add ${spec.term}` });
|
|
1122
|
+
return finish(context, `configure:term add ${spec.term}`, doc, new Files(context.root), work);
|
|
1123
|
+
}
|
|
1124
|
+
export function planTermSet(context, term, changes) {
|
|
1125
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1126
|
+
const entry = termEntry(doc, term);
|
|
1127
|
+
if (!entry)
|
|
1128
|
+
throw new EditError(`the Vocabulary declares no term '${term}'`);
|
|
1129
|
+
const work = draft();
|
|
1130
|
+
const what = [];
|
|
1131
|
+
if (changes.term !== undefined && changes.term !== term) {
|
|
1132
|
+
if (context.framework.vocabulary.some((held) => held.term.toLowerCase() === changes.term?.toLowerCase()))
|
|
1133
|
+
throw new EditError(`the Vocabulary already declares '${changes.term}'`);
|
|
1134
|
+
entry.set('term', changes.term);
|
|
1135
|
+
what.push(`term → ${changes.term}`);
|
|
1136
|
+
}
|
|
1137
|
+
if (changes.means !== undefined) {
|
|
1138
|
+
entry.set('means', changes.means);
|
|
1139
|
+
what.push('means');
|
|
1140
|
+
}
|
|
1141
|
+
if (changes.not !== undefined) {
|
|
1142
|
+
entry.set('not', doc.doc.createNode(changes.not));
|
|
1143
|
+
what.push('not');
|
|
1144
|
+
}
|
|
1145
|
+
if (changes.see !== undefined) {
|
|
1146
|
+
if (changes.see === null || changes.see === '')
|
|
1147
|
+
entry.delete('see');
|
|
1148
|
+
else
|
|
1149
|
+
entry.set('see', changes.see);
|
|
1150
|
+
what.push('see');
|
|
1151
|
+
}
|
|
1152
|
+
if (what.length === 0)
|
|
1153
|
+
throw new EditError(`nothing to change on '${term}'`);
|
|
1154
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `vocabulary ${term}: ${what.join(', ')}` });
|
|
1155
|
+
return finish(context, `configure:term set ${term}`, doc, new Files(context.root), work);
|
|
1156
|
+
}
|
|
1157
|
+
export function planTermRemove(context, term) {
|
|
1158
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1159
|
+
const entry = termEntry(doc, term);
|
|
1160
|
+
if (!entry)
|
|
1161
|
+
throw new EditError(`the Vocabulary declares no term '${term}'`);
|
|
1162
|
+
const work = draft();
|
|
1163
|
+
FrameworkDoc.remove(doc.seq(['vocabulary']), entry);
|
|
1164
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `vocabulary: remove ${term}` });
|
|
1165
|
+
return finish(context, `configure:term remove ${term}`, doc, new Files(context.root), work);
|
|
1166
|
+
}
|
|
1167
|
+
// ---- top-level docs -----------------------------------------------------------
|
|
1168
|
+
// A top-level doc's file is named for its title in the root's convention, so
|
|
1169
|
+
// what a person sees in the file system is what the page calls it, the
|
|
1170
|
+
// convention's casing apart; a title change renames the file, a README
|
|
1171
|
+
// included.
|
|
1172
|
+
export function docFileName(title, naming) {
|
|
1173
|
+
return `${convert(title.trim(), naming)}.md`;
|
|
1174
|
+
}
|
|
1175
|
+
// One `top_level` entry as declared, by title (case-insensitive) or by its file name.
|
|
1176
|
+
function docOf(context, ref) {
|
|
1177
|
+
const wanted = ref.trim().toLowerCase();
|
|
1178
|
+
const held = context.framework.topLevel.find((doc) => doc.title.toLowerCase() === wanted || path.basename(doc.path).toLowerCase() === wanted || path.basename(doc.path).toLowerCase() === `${wanted}.md`);
|
|
1179
|
+
if (!held)
|
|
1180
|
+
throw new EditError(`no top-level doc '${ref}' (eidos framework lists them)`);
|
|
1181
|
+
return { title: held.title, path: held.path, file: path.resolve(path.dirname(context.framework.file), held.path) };
|
|
1182
|
+
}
|
|
1183
|
+
// Files moved, every link in the root following: what a plan does through its
|
|
1184
|
+
// Files and actions, done at once for the browser, which saves the framework
|
|
1185
|
+
// document itself and only needs the files to follow.
|
|
1186
|
+
export function moveWithLinks(root, moves) {
|
|
1187
|
+
const files = new Files(root);
|
|
1188
|
+
files.links(moves);
|
|
1189
|
+
files.write();
|
|
1190
|
+
for (const move of moves)
|
|
1191
|
+
if (existsSync(move.from) && !existsSync(move.to))
|
|
1192
|
+
moveFile(root, move.from, move.to);
|
|
1193
|
+
}
|
|
1194
|
+
export function planDocAdd(context, title, description) {
|
|
1195
|
+
const clean = title.trim();
|
|
1196
|
+
if (clean === '')
|
|
1197
|
+
throw new EditError('a top-level doc needs a title');
|
|
1198
|
+
if (context.framework.topLevel.some((doc) => doc.title.toLowerCase() === clean.toLowerCase()))
|
|
1199
|
+
throw new EditError(`a top-level doc titled '${clean}' is already declared`);
|
|
1200
|
+
const name = docFileName(clean, context.framework.naming);
|
|
1201
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1202
|
+
const work = draft();
|
|
1203
|
+
doc.add(doc.topLevel(), { title: clean, path: `../${name}`, description: description.trim() });
|
|
1204
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `top_level: add ${clean} (${name})` });
|
|
1205
|
+
const file = path.join(context.root, name);
|
|
1206
|
+
if (!existsSync(file)) {
|
|
1207
|
+
work.steps.push({ kind: 'write', path: name, what: 'a title, nothing else' });
|
|
1208
|
+
work.actions.push(() => writeFileSync(file, `# ${clean}\n\n`, 'utf8'));
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
work.notes.push(`${name} already exists and is declared as it is`);
|
|
1212
|
+
}
|
|
1213
|
+
return finish(context, `configure:doc add ${clean}`, doc, new Files(context.root), work);
|
|
1214
|
+
}
|
|
1215
|
+
export function planDocRename(context, ref, to) {
|
|
1216
|
+
const held = docOf(context, ref);
|
|
1217
|
+
const title = to.trim();
|
|
1218
|
+
if (title === '')
|
|
1219
|
+
throw new EditError('a top-level doc needs a title');
|
|
1220
|
+
if (title !== held.title && context.framework.topLevel.some((doc) => doc.title.toLowerCase() === title.toLowerCase()))
|
|
1221
|
+
throw new EditError(`a top-level doc titled '${title}' is already declared`);
|
|
1222
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1223
|
+
const files = new Files(context.root);
|
|
1224
|
+
const work = draft();
|
|
1225
|
+
const entry = FrameworkDoc.find(doc.topLevel(), 'path', held.path);
|
|
1226
|
+
if (!entry)
|
|
1227
|
+
throw new EditError(`the document has no top_level entry at ${held.path}`);
|
|
1228
|
+
const what = [];
|
|
1229
|
+
if (title !== held.title) {
|
|
1230
|
+
entry.set('title', title);
|
|
1231
|
+
what.push(`${held.title} → ${title}`);
|
|
1232
|
+
}
|
|
1233
|
+
const moves = [];
|
|
1234
|
+
// the file follows the title; one that drifted (a seeded README.md under a
|
|
1235
|
+
// title of its own) is brought in line by a rename to the same title
|
|
1236
|
+
if (path.dirname(held.file) === context.root) {
|
|
1237
|
+
const name = docFileName(title, context.framework.naming);
|
|
1238
|
+
const dest = path.join(context.root, name);
|
|
1239
|
+
if (dest !== held.file) {
|
|
1240
|
+
entry.set('path', `../${name}`);
|
|
1241
|
+
what.push(`${path.basename(held.file)} → ${name}`);
|
|
1242
|
+
// a file system that ignores case says the target exists when only the casing differs
|
|
1243
|
+
const sameFile = dest.toLowerCase() === held.file.toLowerCase();
|
|
1244
|
+
if (existsSync(dest) && !sameFile)
|
|
1245
|
+
work.conflicts.push({ message: `${name} already exists`, force: false });
|
|
1246
|
+
else if (existsSync(held.file)) {
|
|
1247
|
+
moves.push({ from: held.file, to: dest });
|
|
1248
|
+
work.steps.push({ kind: 'move', from: rel(context.root, held.file), to: name });
|
|
1249
|
+
work.actions.push(() => moveFile(context.root, held.file, dest));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (!doc.changed())
|
|
1254
|
+
throw new EditError('nothing to rename');
|
|
1255
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `top_level: ${what.join('; ')}` });
|
|
1256
|
+
files.links(moves);
|
|
1257
|
+
return finish(context, `configure:doc rename ${held.title} ${title}`, doc, files, work);
|
|
1258
|
+
}
|
|
1259
|
+
export function planDocSet(context, ref, changes) {
|
|
1260
|
+
const held = docOf(context, ref);
|
|
1261
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1262
|
+
const work = draft();
|
|
1263
|
+
const entry = FrameworkDoc.find(doc.topLevel(), 'path', held.path);
|
|
1264
|
+
if (!entry)
|
|
1265
|
+
throw new EditError(`the document has no top_level entry at ${held.path}`);
|
|
1266
|
+
if (changes.description !== undefined)
|
|
1267
|
+
entry.set('description', changes.description.trim());
|
|
1268
|
+
if (!doc.changed())
|
|
1269
|
+
throw new EditError(`nothing to change on '${held.title}'`);
|
|
1270
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `top_level ${held.title}: description` });
|
|
1271
|
+
return finish(context, `configure:doc set ${held.title}`, doc, new Files(context.root), work);
|
|
1272
|
+
}
|
|
1273
|
+
// The entry goes and, unless preserved, the file with it: an explicit remove
|
|
1274
|
+
// removes, and git holds what was there.
|
|
1275
|
+
export function planDocRemove(context, ref, options) {
|
|
1276
|
+
const held = docOf(context, ref);
|
|
1277
|
+
const doc = new FrameworkDoc(context.framework);
|
|
1278
|
+
const work = draft();
|
|
1279
|
+
const entry = FrameworkDoc.find(doc.topLevel(), 'path', held.path);
|
|
1280
|
+
if (!entry)
|
|
1281
|
+
throw new EditError(`the document has no top_level entry at ${held.path}`);
|
|
1282
|
+
FrameworkDoc.remove(doc.topLevel(), entry);
|
|
1283
|
+
work.steps.push({ kind: 'document', path: rel(context.root, doc.file), what: `top_level: remove ${held.title}` });
|
|
1284
|
+
if (existsSync(held.file)) {
|
|
1285
|
+
if (options.preserve)
|
|
1286
|
+
work.notes.push(`--preserve leaves ${rel(context.root, held.file)} at the root, undeclared`);
|
|
1287
|
+
else {
|
|
1288
|
+
work.steps.push({ kind: 'remove', path: rel(context.root, held.file), what: 'the document' });
|
|
1289
|
+
work.actions.push(() => rmSync(held.file, { force: true }));
|
|
1290
|
+
work.destructive = true;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return finish(context, `configure:doc remove ${held.title}`, doc, new Files(context.root), work);
|
|
1294
|
+
}
|
|
1295
|
+
// ---- roles ------------------------------------------------------------------
|
|
1296
|
+
const roleSkeleton = (title) => `# ${title}\n\n## Who they are\n\n_Who holds this role, and what they own._\n\n## How to respond\n\n- **Vocabulary & depth:** _the terms to lead with._\n- **Decisions:** _which are theirs._\n- **Surface / hide:** _what to bring forward, what to fold away._\n`;
|
|
1297
|
+
export function planRoleAdd(context, name, from) {
|
|
1298
|
+
const roleName = kebab(name);
|
|
1299
|
+
if (!isRoleName(roleName))
|
|
1300
|
+
throw new EditError(`'${name}' is not a role name (lowercase words joined by hyphens)`);
|
|
1301
|
+
if (listRoles(context.root).some((role) => role.name === roleName))
|
|
1302
|
+
throw new EditError(`a role '${roleName}' already exists`);
|
|
1303
|
+
const source = from ? listRoles(context.root).find((role) => role.name === kebab(from)) : null;
|
|
1304
|
+
if (from && !source)
|
|
1305
|
+
throw new EditError(`no role '${from}' to copy (eidos roles lists them)`);
|
|
1306
|
+
const work = draft();
|
|
1307
|
+
const file = roleFile(context.root, roleName);
|
|
1308
|
+
work.steps.push({ kind: 'write', path: rel(context.root, file), what: source ? `a copy of ${source.name}` : 'a bare role file' });
|
|
1309
|
+
work.actions.push(() => {
|
|
1310
|
+
mkdirSync(rolesDir(context.root), { recursive: true });
|
|
1311
|
+
if (source)
|
|
1312
|
+
cpSync(source.file, file);
|
|
1313
|
+
else
|
|
1314
|
+
writeFileSync(file, roleSkeleton(roleName.split('-').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')), 'utf8');
|
|
1315
|
+
});
|
|
1316
|
+
return finish(context, `configure:role add ${roleName}`, null, new Files(context.root), work);
|
|
1317
|
+
}
|
|
1318
|
+
// A role's name is its file: renaming one moves the file and follows the name
|
|
1319
|
+
// into me.md (the link and the role line) and the users who hold it.
|
|
1320
|
+
export function renameRoleFiles(root, from, to) {
|
|
1321
|
+
const source = roleFile(root, from);
|
|
1322
|
+
const target = roleFile(root, to);
|
|
1323
|
+
if (existsSync(source) && !existsSync(target))
|
|
1324
|
+
moveFile(root, source, target);
|
|
1325
|
+
const me = meFile(root);
|
|
1326
|
+
if (existsSync(me)) {
|
|
1327
|
+
const text = readFileSync(me, 'utf8');
|
|
1328
|
+
const next = text.split(`roles/${from}.md`).join(`roles/${to}.md`);
|
|
1329
|
+
if (next !== text)
|
|
1330
|
+
writeFileSync(me, next, 'utf8');
|
|
1331
|
+
}
|
|
1332
|
+
const users = readSettings(root).shared.users;
|
|
1333
|
+
if (users.some((user) => user.role === from))
|
|
1334
|
+
writeSettings(root, { shared: { users: users.map((user) => (user.role === from ? { ...user, role: to } : user)) } });
|
|
1335
|
+
}
|
|
1336
|
+
export function planRoleRename(context, name, to) {
|
|
1337
|
+
const role = listRoles(context.root).find((held) => held.name === kebab(name));
|
|
1338
|
+
if (!role)
|
|
1339
|
+
throw new EditError(`no role '${name}' (eidos roles lists them)`);
|
|
1340
|
+
const target = kebab(to);
|
|
1341
|
+
if (!isRoleName(target))
|
|
1342
|
+
throw new EditError(`'${to}' is not a role name (lowercase words joined by hyphens)`);
|
|
1343
|
+
if (target === role.name)
|
|
1344
|
+
throw new EditError('nothing to rename');
|
|
1345
|
+
if (listRoles(context.root).some((held) => held.name === target))
|
|
1346
|
+
throw new EditError(`a role '${target}' already exists`);
|
|
1347
|
+
const work = draft();
|
|
1348
|
+
work.steps.push({ kind: 'move', from: rel(context.root, role.file), to: rel(context.root, roleFile(context.root, target)) });
|
|
1349
|
+
const actor = readActor(context.root);
|
|
1350
|
+
if (actor.exists && actor.actor.roleFile?.endsWith(`roles/${role.name}.md`))
|
|
1351
|
+
work.steps.push({ kind: 'write', path: rel(context.root, meFile(context.root)), what: `the role link → roles/${target}.md` });
|
|
1352
|
+
const settings = readSettings(context.root);
|
|
1353
|
+
if (settings.shared.users.some((user) => user.role === role.name))
|
|
1354
|
+
work.steps.push({ kind: 'settings', path: settings.files.shared, what: `users: role ${role.name} → ${target}` });
|
|
1355
|
+
work.actions.push(() => renameRoleFiles(context.root, role.name, target));
|
|
1356
|
+
return finish(context, `configure:role rename ${role.name} ${target}`, null, new Files(context.root), work);
|
|
1357
|
+
}
|
|
1358
|
+
export function planRoleRemove(context, name, options) {
|
|
1359
|
+
const role = listRoles(context.root).find((held) => held.name === kebab(name));
|
|
1360
|
+
if (!role)
|
|
1361
|
+
throw new EditError(`no role '${name}' (eidos roles lists them)`);
|
|
1362
|
+
const work = draft();
|
|
1363
|
+
const actor = readActor(context.root);
|
|
1364
|
+
if (actor.exists && actor.actor.role && kebab(actor.actor.role) === role.name) {
|
|
1365
|
+
work.conflicts.push({ message: `me.md names the role ${role.name}; --force removes it anyway`, force: true });
|
|
1366
|
+
}
|
|
1367
|
+
work.steps.push({ kind: 'remove', path: rel(context.root, role.file), what: 'the role file' });
|
|
1368
|
+
work.actions.push(() => rmSync(role.file, { force: true }));
|
|
1369
|
+
void options;
|
|
1370
|
+
return finish(context, `configure:role remove ${role.name}`, null, new Files(context.root), work);
|
|
1371
|
+
}
|
|
1372
|
+
// ---- one blueprint's frontmatter ----------------------------------------------
|
|
1373
|
+
// The value a set writes, checked against the property's declared type.
|
|
1374
|
+
export function blueprintKeySet(blueprint, key, value) {
|
|
1375
|
+
return editFrontmatterKey(readFileSync(blueprint.path, 'utf8'), { kind: 'set', key, value });
|
|
1376
|
+
}
|
|
1377
|
+
export function blueprintKeyUnset(blueprint, key) {
|
|
1378
|
+
const text = readFileSync(blueprint.path, 'utf8');
|
|
1379
|
+
return hasKey(text, key) ? editFrontmatterKey(text, { kind: 'delete', key }) : null;
|
|
1380
|
+
}
|
|
1381
|
+
export { formatScalar };
|