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.
Files changed (47) hide show
  1. package/browser/dist/assets/index-D_Xs1hAc.css +1 -0
  2. package/browser/dist/assets/index-K_EgH2M8.js +46 -0
  3. package/browser/dist/index.html +2 -2
  4. package/dist/src/commands/check.js +9 -3
  5. package/dist/src/commands/configure.js +201 -0
  6. package/dist/src/commands/framework.js +15 -4
  7. package/dist/src/commands/init.js +4 -0
  8. package/dist/src/commands/property.js +125 -0
  9. package/dist/src/commands/setup.js +24 -12
  10. package/dist/src/commands/version.js +2 -2
  11. package/dist/src/core/canvas.js +59 -49
  12. package/dist/src/core/check.js +101 -26
  13. package/dist/src/core/edits.js +1381 -0
  14. package/dist/src/core/framework-markdown.js +9 -3
  15. package/dist/src/core/framework-model.js +43 -8
  16. package/dist/src/core/framework-structured.js +104 -28
  17. package/dist/src/core/frontmatter.js +61 -1
  18. package/dist/src/core/git.js +28 -3
  19. package/dist/src/core/links.js +87 -0
  20. package/dist/src/core/markdown.js +16 -9
  21. package/dist/src/core/migrate.js +91 -15
  22. package/dist/src/core/regions.js +117 -0
  23. package/dist/src/core/scaffold.js +15 -9
  24. package/dist/src/core/seed.js +56 -31
  25. package/dist/src/core/server.js +204 -31
  26. package/dist/src/core/settings.js +63 -12
  27. package/dist/src/core/store.js +73 -17
  28. package/dist/src/core/versions.js +10 -5
  29. package/dist/src/program.js +296 -11
  30. package/instructions/authoring.md +4 -2
  31. package/instructions/configuring.md +27 -11
  32. package/instructions/overview.md +6 -4
  33. package/instructions/validating.md +6 -4
  34. package/package.json +1 -1
  35. package/standard/EIDOS.md +135 -193
  36. package/standard/seeds/README.md +12 -16
  37. package/standard/seeds/book/Framework.yaml +30 -50
  38. package/standard/seeds/book/README.md +2 -1
  39. package/standard/seeds/book/_gitignore +7 -1
  40. package/standard/seeds/research/Framework.yaml +30 -50
  41. package/standard/seeds/research/README.md +2 -1
  42. package/standard/seeds/research/_gitignore +7 -1
  43. package/standard/seeds/software/Framework.yaml +31 -51
  44. package/standard/seeds/software/README.md +2 -1
  45. package/standard/seeds/software/_gitignore +7 -1
  46. package/browser/dist/assets/index-C2NMN_D4.css +0 -1
  47. package/browser/dist/assets/index-C65k1ihb.js +0 -46
@@ -1,23 +1,40 @@
1
- // `eidos migrate`: the mechanical hop from a 4.x root to the standard this CLI
2
- // carries (5.0.0). Five names move on disk and nothing is dropped: `_eidos/`
3
- // becomes `.eidos/`, `shapes/` becomes `templates/`, the framework document
4
- // takes the 5.0.0 keys, every blueprint's `flavor` becomes `variant`, and
5
- // `eidos_version` is set. A root that kept the markdown Framework.md gets
6
- // Framework.yaml in its place, the index inside it. A template that opens
7
- // with frontmatter loses the block; one named off its unit is noted, since
8
- // only the owner can say which name is right.
9
- import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
1
+ // `eidos migrate`: the mechanical hop from an older root to the standard this
2
+ // CLI carries. From 4.x, five names move on disk and nothing is dropped:
3
+ // `_eidos/` becomes `.eidos/`, `shapes/` becomes `templates/`, the framework
4
+ // document takes the 5.0.0 keys, every blueprint's `flavor` becomes
5
+ // `variant`, and `eidos_version` is set. A root that kept the markdown
6
+ // Framework.md gets Framework.yaml in its place, the index inside it. A
7
+ // template that opens with frontmatter loses the block; one named off its
8
+ // unit is noted, since only the owner can say which name is right. From
9
+ // 5.0.0 nothing on disk moves: the version is bumped and the standard's core
10
+ // block is rewritten with its required rows, every region carried as found.
11
+ // 5.2.0 named the one personal file a tool keeps, `local.yaml`, and the one
12
+ // line in `.eidos/.gitignore` that covers every tool's: the hop adds that
13
+ // line, and this CLI's own `.gitignore` beside its local file, which did the
14
+ // same job before, goes. 5.3.0 declares every folder at the root with a
15
+ // type: `collections` becomes `folders` and every entry gains
16
+ // `type: collection`; anything at the root the document does not declare is
17
+ // the owner's to declare or move, so it is reported, never touched.
18
+ import { existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
10
19
  import path from 'node:path';
11
20
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
12
- import { FRAMEWORK_DIR, FRAMEWORK_FILES, STANDARD_CORE, unitOf } from './framework-model.js';
21
+ import { FRAMEWORK_DIR, FRAMEWORK_FILES, isCollection, STANDARD_CORE, unitOf } from './framework-model.js';
13
22
  import { convertToYaml } from './convert.js';
14
- import { annotateYaml } from './framework-structured.js';
23
+ import { annotateYaml, coreEntries, YAML_GUIDANCE } from './framework-structured.js';
24
+ import { undeclaredAtRoot } from './check.js';
15
25
  import { parseFramework } from './framework.js';
16
26
  import { parseVersions } from './framework-markdown.js';
17
27
  import { stripFrontmatter } from './template.js';
28
+ import { LOCAL_IGNORE_PATTERN, rootIgnoresLocal, settingsDir } from './settings.js';
18
29
  import { readVersions, writeVersions, versionsFile } from './versions.js';
19
30
  import { standardVersion } from '../paths.js';
20
31
  export const LEGACY_DIR = '_eidos';
32
+ // The comment the seeds write above the pattern, so a migrated root reads as a fresh one does.
33
+ const LOCAL_IGNORE_NOTE = "# Eidos: a tool's local.yaml is personal and never shared.\n# A tool that keeps a folder under plugins/ may keep its personal settings there, one machine's (a\n# viewer command, an editor, a key path); everything else in its folder is the root's. This one\n# line covers every tool, so no tool writes a .gitignore of its own.\n";
34
+ // The guidance this CLI wrote above `collections` before 5.3.0. A document
35
+ // carrying it word for word gets the `folders` guidance in its place; any
36
+ // other comment there is the owner's or the seed's and stays.
37
+ const COLLECTIONS_GUIDANCE = " The collections. Each declares its variants (one default; each a template file named <unit>.<variant>.md under templates/, the unit being the word for one of its blueprints) and its grouping.\n Add a group under `grouping.groups` with a one-line description; add a variant with a template file in templates/.";
21
38
  export class MigrationError extends Error {
22
39
  constructor(message) {
23
40
  super(message);
@@ -67,23 +84,49 @@ const renameKey = (pair, to) => {
67
84
  };
68
85
  // The YAML form, key by key, comments kept: `schema` → `properties`,
69
86
  // `flavors` → `variants`, each variant's `shape` → `template` (with its path),
70
- // `unit` gone, the version set, and this CLI's guidance comments refreshed.
87
+ // `unit` gone, `collections` `folders` with `type: collection` on every
88
+ // entry, the core block rewritten, the version set. A document that still
89
+ // had the 4.x keys gets this CLI's guidance comments in their place; one
90
+ // already on 5.x keeps every comment it had, the seed's included.
71
91
  export function migrateYamlDocument(text, version) {
72
92
  const document = parseDocument(text);
73
93
  const top = document.contents;
74
94
  if (!isMap(top))
75
95
  return text;
76
96
  // the root's versions left the framework document in 5.0.0; migrateRoot moves them
97
+ const legacy = top.items.some((pair) => keyOf(pair) === 'schema' || keyOf(pair) === 'versions');
77
98
  top.items = top.items.filter((pair) => keyOf(pair) !== 'versions');
78
99
  for (const pair of top.items) {
79
100
  const key = keyOf(pair);
80
101
  if (key === 'schema')
81
102
  renameKey(pair, 'properties');
82
- // the standard's own block is migrate's to rewrite: its rows are the 5.0.0 core
103
+ if (key === 'collections') {
104
+ renameKey(pair, 'folders');
105
+ if (isScalar(pair.key) && pair.key.commentBefore === COLLECTIONS_GUIDANCE)
106
+ pair.key.commentBefore = YAML_GUIDANCE['folders'] ?? null;
107
+ if (isSeq(pair.value)) {
108
+ for (const folder of pair.value.items) {
109
+ if (!isMap(folder) || folder.has('type'))
110
+ continue;
111
+ const map = folder;
112
+ // the type sits beside the name, the way the seeds write it
113
+ const at = map.items.findIndex((entry) => keyOf(entry) === 'name');
114
+ map.items.splice(at === -1 ? 0 : at + 1, 0, document.createPair('type', 'collection'));
115
+ }
116
+ }
117
+ }
118
+ // the standard's own block is migrate's to rewrite: its rows are this version's core
83
119
  if ((key === 'schema' || key === 'properties') && isMap(pair.value)) {
84
120
  for (const entry of pair.value.items) {
85
121
  if (keyOf(entry) === 'core' && isSeq(entry.value) && entry.value.items.length > 0) {
86
- entry.value = document.createNode(STANDARD_CORE.map(({ name, type, meaning }) => ({ name, type, meaning })));
122
+ // one row per line, the flow style the seeds write the block in
123
+ const rows = document.createNode(coreEntries(STANDARD_CORE));
124
+ if (isSeq(rows)) {
125
+ for (const row of rows.items)
126
+ if (isMap(row))
127
+ row.flow = true;
128
+ }
129
+ entry.value = rows;
87
130
  }
88
131
  }
89
132
  }
@@ -115,7 +158,8 @@ export function migrateYamlDocument(text, version) {
115
158
  }
116
159
  }
117
160
  }
118
- annotateYaml(document);
161
+ if (legacy)
162
+ annotateYaml(document);
119
163
  return document.toString({ lineWidth: 0 });
120
164
  }
121
165
  // What a 4.x document recorded under `versions`, in either form, for the move
@@ -210,6 +254,19 @@ export function migrateRoot(root, options) {
210
254
  if (held.includes(`${LEGACY_DIR}/`))
211
255
  act(`point .gitignore at ${FRAMEWORK_DIR}/`, () => writeFileSync(ignore, held.replace(new RegExp(`${LEGACY_DIR}/`, 'g'), `${FRAMEWORK_DIR}/`), 'utf8'));
212
256
  }
257
+ // 5.2.0: one line keeps every tool's personal file out of the repository,
258
+ // and this CLI's private .gitignore, which covered only its own, is retired.
259
+ const frameworkIgnore = path.join(live, '.gitignore');
260
+ if (!rootIgnoresLocal(root)) {
261
+ act(`add ${LOCAL_IGNORE_PATTERN} to ${FRAMEWORK_DIR}/.gitignore, the one line every tool's personal file needs`, () => {
262
+ const held = existsSync(frameworkIgnore) ? readFileSync(frameworkIgnore, 'utf8') : '# Eidos: me.md is personal and never shared.\nme.md\n';
263
+ writeFileSync(frameworkIgnore, `${held.replace(/\s*$/, '')}\n\n${LOCAL_IGNORE_NOTE}${LOCAL_IGNORE_PATTERN}\n`, 'utf8');
264
+ });
265
+ }
266
+ const ownIgnore = path.join(settingsDir(root), '.gitignore');
267
+ if (existsSync(ownIgnore) && readFileSync(ownIgnore, 'utf8').trim() === 'local.yaml') {
268
+ act(`remove ${rel(root, ownIgnore)}; the root's .gitignore covers it now`, () => rmSync(ownIgnore, { force: true }));
269
+ }
213
270
  const framework = parseFramework(next, found.format, root, path.join(dir, found.name));
214
271
  // The markdown form left the standard in 5.0.0: the same framework is written
215
272
  // as data, and the markdown document and each collection's index.md go.
@@ -218,6 +275,25 @@ export function migrateRoot(root, options) {
218
275
  convertToYaml(framework, false);
219
276
  });
220
277
  }
278
+ // 5.3.0: everything at the root is declared, and only the owner can say
279
+ // what an undeclared entry is or whether a declared one with nothing behind
280
+ // it should stay; each is a note, never a move.
281
+ const undeclared = undeclaredAtRoot(framework);
282
+ // on a dry run the framework folder may still be the old one
283
+ for (const entry of undeclared.folders.filter((name) => name !== LEGACY_DIR))
284
+ notes.push(`${entry}/ is at the root and no folder declares it; declare it (eidos configure:folder add ${entry} --type assets|other, or configure:collection add) or move it`);
285
+ for (const entry of undeclared.files)
286
+ notes.push(`${entry} is at the root and top_level does not list it; declare it (eidos configure:doc add) or move it`);
287
+ for (const folder of framework.folders) {
288
+ if (!existsSync(path.join(root, folder.name)))
289
+ notes.push(`${folder.name}/ is declared as ${isCollection(folder) ? 'a collection' : `an ${folder.type} folder`} but is not there; create it or remove the entry`);
290
+ }
291
+ for (const doc of framework.topLevel) {
292
+ if (/^[a-z][a-z0-9+.-]*:/i.test(doc.path))
293
+ continue;
294
+ if (!existsSync(path.resolve(dir, doc.path.split('#')[0] ?? '')))
295
+ notes.push(`top_level lists ${doc.title} at ${doc.path}, which is not there; write it or remove the entry`);
296
+ }
221
297
  for (const collection of framework.collections) {
222
298
  for (const file of markdownFiles(path.join(root, collection.name))) {
223
299
  const content = readFileSync(file, 'utf8');
@@ -0,0 +1,117 @@
1
+ // Regions: the span inside a markdown file that a tool owns, fenced by two
2
+ // HTML comments carrying its name (Eidos 5.1.0). `<!-- <tool>:<region> <args> -->`
3
+ // opens one and `<!-- /<tool>:<region> -->` closes it, each alone on its line;
4
+ // what sits between is the tool's, rewritten wholesale by it and read by
5
+ // nothing else. The standard reads none of the contents: a check faults only
6
+ // an opener with no closer, a heading or a link inside is not the person's,
7
+ // and a renderer that does not know the tool shows the contents as they are.
8
+ // A tool's identifier and a region's name: lowercase letters, digits, and hyphens.
9
+ const NAME = '[a-z0-9][a-z0-9-]*';
10
+ // Both closers HTML allows, `-->` and `--!>`, end a marker.
11
+ const OPENER = new RegExp(`^<!--\\s*(${NAME}):(${NAME})(?:\\s+(.*?))?\\s*--!?>$`);
12
+ const CLOSER = new RegExp(`^<!--\\s*/(${NAME}):(${NAME})\\s*--!?>$`);
13
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
14
+ function parseArgs(text) {
15
+ const args = {};
16
+ for (const token of (text ?? '').split(/\s+/)) {
17
+ const eq = token.indexOf('=');
18
+ if (eq > 0)
19
+ args[token.slice(0, eq)] = token.slice(eq + 1);
20
+ }
21
+ return args;
22
+ }
23
+ // Every region in the text, in order. A marker inside a fenced code block
24
+ // (three or more backticks or tildes, closed by the same) is text; a region
25
+ // closes at the first closer naming its tool and region, and the lines
26
+ // between are not read for markers, so another tool's marker there is content.
27
+ export function parseRegions(text) {
28
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
29
+ const out = [];
30
+ let fence = null;
31
+ let index = 0;
32
+ while (index < lines.length) {
33
+ const line = (lines[index] ?? '').trim();
34
+ if (fence) {
35
+ const closing = FENCE.exec(lines[index] ?? '');
36
+ if (closing && closing[1] && closing[1][0] === fence.char && closing[1].length >= fence.length && (lines[index] ?? '').trim() === closing[1])
37
+ fence = null;
38
+ index += 1;
39
+ continue;
40
+ }
41
+ const opening = FENCE.exec(lines[index] ?? '');
42
+ if (opening && opening[1]) {
43
+ fence = { char: opening[1][0] ?? '`', length: opening[1].length };
44
+ index += 1;
45
+ continue;
46
+ }
47
+ const opener = OPENER.exec(line);
48
+ if (!opener || !opener[1] || !opener[2]) {
49
+ index += 1;
50
+ continue;
51
+ }
52
+ const region = { tool: opener[1], region: opener[2], args: parseArgs(opener[3]), open: index, close: null, contents: '' };
53
+ let end = index + 1;
54
+ while (end < lines.length) {
55
+ const closer = CLOSER.exec((lines[end] ?? '').trim());
56
+ if (closer && closer[1] === region.tool && closer[2] === region.region) {
57
+ region.close = end;
58
+ break;
59
+ }
60
+ end += 1;
61
+ }
62
+ region.contents = lines.slice(index + 1, region.close ?? lines.length).join('\n');
63
+ out.push(region);
64
+ index = (region.close ?? lines.length) + 1;
65
+ }
66
+ return out;
67
+ }
68
+ // The zero-based lines every region occupies, markers included; an unclosed
69
+ // one runs to the end of the text.
70
+ export function regionLines(text) {
71
+ const out = new Set();
72
+ const total = text.replace(/\r\n?/g, '\n').split('\n').length;
73
+ for (const region of parseRegions(text)) {
74
+ for (let line = region.open; line <= (region.close ?? total - 1); line += 1)
75
+ out.add(line);
76
+ }
77
+ return out;
78
+ }
79
+ // The text with every region's lines blanked, line count kept, so what the
80
+ // standard does read (headings, links, prose) is only the person's.
81
+ export function stripRegions(text) {
82
+ const inside = regionLines(text);
83
+ if (inside.size === 0)
84
+ return text;
85
+ return text
86
+ .replace(/\r\n?/g, '\n')
87
+ .split('\n')
88
+ .map((line, index) => (inside.has(index) ? '' : line))
89
+ .join('\n');
90
+ }
91
+ function escapeHtml(text) {
92
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
93
+ }
94
+ // The text with each region replaced by one HTML block that shows its
95
+ // contents as they are: a `<pre>` carrying the tool and region name, which a
96
+ // markdown renderer passes through whole (an HTML block that opens with
97
+ // `<pre` runs to `</pre>`, blank lines included), so nothing inside is
98
+ // rendered as markdown. A renderer that knows the tool draws its own view.
99
+ export function regionsAsHtml(text) {
100
+ const regions = parseRegions(text);
101
+ if (regions.length === 0)
102
+ return text;
103
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
104
+ const out = [];
105
+ let index = 0;
106
+ for (const region of regions) {
107
+ out.push(...lines.slice(index, region.open));
108
+ const args = Object.entries(region.args)
109
+ .map(([key, value]) => `${key}=${value}`)
110
+ .join(' ');
111
+ const attributes = [`data-tool="${escapeHtml(region.tool)}"`, `data-region="${escapeHtml(region.region)}"`, args !== '' ? `data-args="${escapeHtml(args)}"` : '', region.close === null ? 'data-unclosed=""' : ''].filter((attribute) => attribute !== '');
112
+ out.push('', `<pre class="region" ${attributes.join(' ')}>${escapeHtml(region.contents)}</pre>`, '');
113
+ index = (region.close ?? lines.length) + 1;
114
+ }
115
+ out.push(...lines.slice(index));
116
+ return out.join('\n');
117
+ }
@@ -1,10 +1,12 @@
1
- // Scaffolding one blueprint: frontmatter generated from the properties that
2
- // apply to its collection, body rendered from its variant's shape, filename in
3
- // the framework's convention with a permanent kebab-case id inside. Born
1
+ // Scaffolding one blueprint: frontmatter generated from the required
2
+ // properties that apply to its collection plus any optional one given a
3
+ // value, body rendered from its variant's template, filename in the
4
+ // framework's convention with a permanent kebab-case id inside. Born
4
5
  // conforming; the prose is still the owner's.
5
6
  import { existsSync } from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { blueprintId } from './blueprint.js';
9
+ import { optionProblem } from './check.js';
8
10
  import { defaultVariant, findCollection, findVariant, propertiesFor } from './framework.js';
9
11
  import { convert, kebab } from './naming.js';
10
12
  import { loadTemplate } from './template.js';
@@ -15,7 +17,8 @@ export class ScaffoldError extends Error {
15
17
  this.name = 'ScaffoldError';
16
18
  }
17
19
  }
18
- function coerce(type, raw) {
20
+ // A value typed on a command line, as the property's declared type holds it.
21
+ export function coerce(type, raw) {
19
22
  switch (type.toLowerCase()) {
20
23
  case 'list':
21
24
  return raw
@@ -84,20 +87,23 @@ export function scaffoldBlueprint(framework, existing, options) {
84
87
  title,
85
88
  summary: options.summary ?? '',
86
89
  variant: variant.name,
87
- status: 'Draft',
88
90
  date_created: options.date,
89
91
  date_modified: options.date,
90
92
  };
91
93
  if (group !== null && collection.grouping?.property) {
92
94
  values[collection.grouping.property] = group;
93
95
  }
94
- const types = new Map(propertiesFor(framework, collection.name).map((property) => [property.name, property.type]));
96
+ const declared = new Map(propertiesFor(framework, collection.name).map((property) => [property.name, property]));
97
+ const types = new Map([...declared].map(([name, property]) => [name, property.type]));
95
98
  for (const [key, raw] of Object.entries(options.set)) {
96
- const type = types.get(key);
97
- if (type === undefined) {
99
+ const property = declared.get(key);
100
+ if (property === undefined) {
98
101
  notes.push(`property '${key}' is not in the Properties table for ${collection.name}; written anyway`);
99
102
  }
100
- values[key] = coerce(type ?? 'Text', raw);
103
+ values[key] = coerce(property?.type ?? 'Text', raw);
104
+ const offList = property ? optionProblem(property, values[key]) : null;
105
+ if (offList)
106
+ notes.push(`'${key}: ${offList.off.join(', ')}' is not one of its options (${offList.options.join(', ')}); written anyway, and check reports it`);
101
107
  }
102
108
  let content = blankBlueprint(framework, collection.name, values, shape.text, title);
103
109
  const extra = Object.keys(options.set).filter((key) => !types.has(key));
@@ -1,7 +1,8 @@
1
1
  // Installing a seed: copy a starting framework into a new root's `.eidos/`,
2
- // set the naming convention, the starting groups, and the collection names in
3
- // its Framework.yaml (the seed's own comments kept), scaffold a folder per
4
- // collection, and drop one blank blueprint per framing variant.
2
+ // set the naming convention, the starting groups, and the folder names in
3
+ // its Framework.yaml (the seed's own comments kept), scaffold every declared
4
+ // folder (a collection's with its groups, an assets folder empty), and drop
5
+ // one blank blueprint per framing variant.
5
6
  // It writes no prose: the README's one-liner, each group's description, and
6
7
  // every scaffolded blueprint's summary and body stay the owner's.
7
8
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
@@ -12,7 +13,7 @@ import { buildIndexes } from './index-leaf.js';
12
13
  import { listBlueprints } from './blueprint.js';
13
14
  import { convert, kebab, titleize } from './naming.js';
14
15
  import { renderTemplate } from './template.js';
15
- import { TOOL_KEY } from './framework-model.js';
16
+ import { isCollection, TOOL_KEY } from './framework-model.js';
16
17
  import { DEFAULT_CANVAS_HINTS } from './canvas.js';
17
18
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
18
19
  const GITIGNORE_SHIPPED = '_gitignore';
@@ -76,13 +77,16 @@ class Writer {
76
77
  }
77
78
  const mapsIn = (value) => (isSeq(value) ? value.items.filter((item) => isMap(item)) : []);
78
79
  const nameOf = (map) => String(map.get('name') ?? '');
79
- // The seed's collections and the properties scoped to them, renamed into the
80
- // root's convention; every other key, and every comment, stays as the seed wrote it.
81
- function renameCollections(document, renames) {
82
- for (const collection of mapsIn(document.get('collections'))) {
83
- const to = renames.get(nameOf(collection));
80
+ // The `folders` list as the seed wrote it (`collections` in a seed older than 5.3.0).
81
+ const foldersOf = (document) => document.get('folders') ?? document.get('collections');
82
+ // The seed's folders and the properties scoped to its collections, renamed
83
+ // into the root's convention; every other key, and every comment, stays as
84
+ // the seed wrote it.
85
+ function renameFolders(document, renames) {
86
+ for (const folder of mapsIn(foldersOf(document))) {
87
+ const to = renames.get(nameOf(folder));
84
88
  if (to !== undefined)
85
- collection.set('name', to);
89
+ folder.set('name', to);
86
90
  }
87
91
  const properties = document.get('properties');
88
92
  if (!isMap(properties))
@@ -123,16 +127,26 @@ function renameInText(text, from, to) {
123
127
  const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
128
  return text.replace(new RegExp(`(?<![\\w-])${escaped}(?!\\w)`, 'g'), to);
125
129
  }
126
- // A blank blueprint: every property that applies to the collection, with the
127
- // values given and `[]` for an empty list, then the variant's template as the body.
130
+ // A value worth writing: anything but nothing, an empty string, or an empty list.
131
+ function hasValue(value) {
132
+ return value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && value.length === 0);
133
+ }
134
+ // A blank blueprint: every required property that applies to the collection
135
+ // (with the value given, else blank, `[]` for a list), every optional one
136
+ // that was given a value, then the variant's template as the body. An
137
+ // optional property left out is not a gap, so the file carries nothing it
138
+ // doesn't need.
128
139
  export function blankBlueprint(framework, collectionName, values, shapeTemplate, title) {
129
- const entries = propertiesFor(framework, collectionName).map((property) => {
140
+ const entries = [];
141
+ for (const property of propertiesFor(framework, collectionName)) {
130
142
  const value = values[property.name];
131
- if ((value === undefined || value === null || value === '') && property.type.toLowerCase() === 'list') {
132
- return [property.name, []];
143
+ if (hasValue(value)) {
144
+ entries.push([property.name, value]);
145
+ }
146
+ else if (property.required) {
147
+ entries.push([property.name, property.type.toLowerCase() === 'list' ? [] : '']);
133
148
  }
134
- return [property.name, value ?? ''];
135
- });
149
+ }
136
150
  return formatFrontmatter(entries) + '\n' + renderTemplate(shapeTemplate, title);
137
151
  }
138
152
  export function installSeed(options) {
@@ -178,9 +192,9 @@ export function installSeed(options) {
178
192
  // convention, edited in place so the seed's comments travel with it.
179
193
  document.set('naming', naming);
180
194
  for (const collection of leftOut) {
181
- const collections = document.get('collections');
182
- if (isSeq(collections))
183
- collections.items = collections.items.filter((item) => !(isMap(item) && nameOf(item) === collection.name));
195
+ const folders = foldersOf(document);
196
+ if (isSeq(folders))
197
+ folders.items = folders.items.filter((item) => !(isMap(item) && nameOf(item) === collection.name));
184
198
  for (const variant of collection.variants)
185
199
  writer.remove(path.join(root, '.eidos', variant.template));
186
200
  }
@@ -189,7 +203,7 @@ export function installSeed(options) {
189
203
  if (!grouped || !grouped.grouping) {
190
204
  throw new InstallError(`no collection in '${options.seed}' declares a grouping`);
191
205
  }
192
- const groupedNode = mapsIn(document.get('collections')).find((collection) => nameOf(collection) === grouped.name);
206
+ const groupedNode = mapsIn(foldersOf(document)).find((collection) => nameOf(collection) === grouped.name);
193
207
  const grouping = groupedNode?.get('grouping');
194
208
  if (!isMap(grouping)) {
195
209
  throw new InstallError(`${frameworkSrc}: ${grouped.name} declares a grouping the document does not carry`);
@@ -226,6 +240,8 @@ export function installSeed(options) {
226
240
  }
227
241
  docs.push({ target, body });
228
242
  }
243
+ // Every declared folder takes the naming convention, a collection and an
244
+ // assets folder alike; the files inside an assets folder keep their names.
229
245
  const folders = new Map();
230
246
  const renames = new Map();
231
247
  if (readme !== null && leftOut.length > 0) {
@@ -234,21 +250,27 @@ export function installSeed(options) {
234
250
  .filter((line) => !leftOut.some((collection) => line.includes(`[${collection.name}]`)))
235
251
  .join('\n');
236
252
  }
237
- for (const collection of installing) {
238
- const folder = convert(collection.name, naming);
239
- folders.set(collection.name, folder);
240
- if (folder !== collection.name) {
241
- renames.set(collection.name, folder);
253
+ const plain = seedFramework.folders.filter((folder) => !isCollection(folder));
254
+ for (const declared of [...installing, ...plain]) {
255
+ // a seed writes a collection's name in Title Case and its assets folder in
256
+ // lowercase; the convention is applied to the title-cased word either way
257
+ const folder = convert(titleize(declared.name), naming);
258
+ folders.set(declared.name, folder);
259
+ if (folder !== declared.name) {
260
+ renames.set(declared.name, folder);
242
261
  if (readme !== null) {
243
- readme = renameInText(readme, collection.name, folder);
262
+ readme = renameInText(readme, declared.name, folder);
244
263
  }
245
264
  }
246
265
  }
247
- renameCollections(document, renames);
266
+ renameFolders(document, renames);
248
267
  applyDefaultCanvasHints(document);
249
268
  let serialized = document.toString({ lineWidth: 0 });
269
+ // comments name the collections too, so the word follows; an assets folder's
270
+ // name is also a type (`type: assets`), so only its entry is renamed
250
271
  for (const [from, to] of renames) {
251
- serialized = renameInText(serialized, from, to);
272
+ if (installing.some((collection) => collection.name === from))
273
+ serialized = renameInText(serialized, from, to);
252
274
  }
253
275
  const frameworkFile = path.join(root, '.eidos', 'Framework.yaml');
254
276
  writer.write(frameworkFile, serialized);
@@ -259,9 +281,12 @@ export function installSeed(options) {
259
281
  for (const doc of docs) {
260
282
  writer.write(path.join(root, doc.target), doc.body);
261
283
  }
262
- // 3. a folder per collection; blank blueprints for the framing one.
284
+ // 3. a folder per declared folder; blank blueprints for the framing collection.
263
285
  const scaffolded = [];
264
286
  const groupsInConvention = options.groups.map((group) => convert(group, naming));
287
+ for (const folder of plain) {
288
+ writer.mkdir(path.join(root, folders.get(folder.name) ?? folder.name));
289
+ }
265
290
  for (const collection of installing) {
266
291
  const folderName = folders.get(collection.name) ?? collection.name;
267
292
  const folder = path.join(root, folderName);
@@ -285,7 +310,6 @@ export function installSeed(options) {
285
310
  id: kebab(variant.name),
286
311
  title,
287
312
  variant: variant.name,
288
- status: 'Draft',
289
313
  date_created: options.date,
290
314
  date_modified: options.date,
291
315
  };
@@ -306,6 +330,7 @@ export function installSeed(options) {
306
330
  naming,
307
331
  frameworkFile,
308
332
  collections: installing.map((collection) => folders.get(collection.name) ?? collection.name),
333
+ folders: plain.map((folder) => folders.get(folder.name) ?? folder.name),
309
334
  groupedCollection: grouped ? (folders.get(grouped.name) ?? grouped.name) : null,
310
335
  groups: groupsInConvention,
311
336
  scaffolded,