eidosmd 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +50 -29
  2. package/browser/dist/assets/index-Cc3cNWHY.css +1 -0
  3. package/browser/dist/assets/index-DQgCQRa5.js +46 -0
  4. package/browser/dist/favicon.svg +5 -0
  5. package/browser/dist/index.html +15 -0
  6. package/browser/dist/mark.svg +4 -0
  7. package/dist/src/cli.js +7 -0
  8. package/dist/src/commands/agents.js +1 -1
  9. package/dist/src/commands/canvas.js +77 -0
  10. package/dist/src/commands/check.js +10 -4
  11. package/dist/src/commands/configure.js +201 -0
  12. package/dist/src/commands/framework.js +37 -7
  13. package/dist/src/commands/index.js +4 -4
  14. package/dist/src/commands/init.js +5 -0
  15. package/dist/src/commands/instructions.js +1 -1
  16. package/dist/src/commands/list.js +8 -8
  17. package/dist/src/commands/migrate.js +32 -0
  18. package/dist/src/commands/new.js +3 -3
  19. package/dist/src/commands/property.js +125 -0
  20. package/dist/src/commands/seeds.js +5 -5
  21. package/dist/src/commands/setup.js +131 -0
  22. package/dist/src/commands/version.js +44 -0
  23. package/dist/src/commands/whoami.js +4 -4
  24. package/dist/src/context.js +5 -5
  25. package/dist/src/core/blueprint.js +16 -11
  26. package/dist/src/core/canvas-schema.js +148 -0
  27. package/dist/src/core/canvas.js +732 -0
  28. package/dist/src/core/check.js +221 -70
  29. package/dist/src/core/convert.js +7 -6
  30. package/dist/src/core/edits.js +1381 -0
  31. package/dist/src/core/framework-markdown.js +119 -34
  32. package/dist/src/core/framework-model.js +62 -10
  33. package/dist/src/core/framework-structured.js +222 -47
  34. package/dist/src/core/framework.js +13 -13
  35. package/dist/src/core/frontmatter.js +61 -1
  36. package/dist/src/core/git.js +84 -0
  37. package/dist/src/core/index-leaf.js +2 -2
  38. package/dist/src/core/links.js +87 -0
  39. package/dist/src/core/markdown.js +16 -9
  40. package/dist/src/core/me.js +16 -8
  41. package/dist/src/core/migrate.js +319 -0
  42. package/dist/src/core/naming.js +1 -1
  43. package/dist/src/core/regions.js +117 -0
  44. package/dist/src/core/root.js +2 -2
  45. package/dist/src/core/scaffold.js +33 -27
  46. package/dist/src/core/seed.js +187 -71
  47. package/dist/src/core/server.js +1410 -53
  48. package/dist/src/core/settings.js +232 -0
  49. package/dist/src/core/store.js +315 -0
  50. package/dist/src/core/template.js +32 -0
  51. package/dist/src/core/versions.js +84 -0
  52. package/dist/src/output.js +4 -1
  53. package/dist/src/program.js +421 -41
  54. package/instructions/authoring.md +15 -12
  55. package/instructions/configuring.md +81 -34
  56. package/instructions/init-required.md +4 -4
  57. package/instructions/overview.md +21 -9
  58. package/instructions/validating.md +9 -6
  59. package/package.json +21 -12
  60. package/standard/EIDOS.md +142 -259
  61. package/standard/seeds/README.md +12 -16
  62. package/standard/seeds/book/Framework.yaml +61 -0
  63. package/standard/seeds/book/README.md +10 -5
  64. package/standard/seeds/book/_gitignore +9 -3
  65. package/standard/seeds/book/me.md +1 -1
  66. package/standard/seeds/book/roles/README.md +3 -3
  67. package/standard/seeds/book/roles/framework-owner.md +2 -2
  68. package/standard/seeds/book/{shapes → templates}/chapter.full.md +0 -8
  69. package/standard/seeds/book/{shapes → templates}/chapter.sketch.md +0 -7
  70. package/standard/seeds/book/{shapes → templates}/frame.market.md +0 -6
  71. package/standard/seeds/book/templates/frame.premise.md +17 -0
  72. package/standard/seeds/book/{shapes → templates}/frame.reader.md +0 -6
  73. package/standard/seeds/book/{shapes → templates}/frame.voice.md +0 -7
  74. package/standard/seeds/research/Framework.yaml +61 -0
  75. package/standard/seeds/research/README.md +10 -5
  76. package/standard/seeds/research/_gitignore +9 -3
  77. package/standard/seeds/research/me.md +1 -1
  78. package/standard/seeds/research/roles/README.md +3 -3
  79. package/standard/seeds/research/roles/framework-owner.md +2 -2
  80. package/standard/seeds/research/{shapes → templates}/frame.ethics.md +0 -6
  81. package/standard/seeds/research/{shapes → templates}/frame.method.md +0 -7
  82. package/standard/seeds/research/{shapes → templates}/frame.prior-work.md +0 -6
  83. package/standard/seeds/research/{shapes → templates}/frame.question.md +0 -7
  84. package/standard/seeds/research/{shapes → templates}/investigation.full.md +0 -8
  85. package/standard/seeds/research/{shapes → templates}/investigation.note.md +0 -7
  86. package/standard/seeds/software/Framework.yaml +62 -0
  87. package/standard/seeds/software/README.md +7 -6
  88. package/standard/seeds/software/_gitignore +9 -3
  89. package/standard/seeds/software/me.md +1 -1
  90. package/standard/seeds/software/roles/README.md +3 -3
  91. package/standard/seeds/software/roles/framework-owner.md +2 -2
  92. package/standard/seeds/software/roles/project-manager.md +2 -2
  93. package/standard/seeds/software/roles/stakeholder.md +1 -1
  94. package/standard/seeds/software/{shapes → templates}/frame.architecture.md +0 -7
  95. package/standard/seeds/software/{shapes → templates}/frame.audience.md +1 -8
  96. package/standard/seeds/software/{shapes → templates}/frame.criteria.md +0 -8
  97. package/standard/seeds/software/{shapes → templates}/frame.market.md +0 -8
  98. package/standard/seeds/software/{shapes → templates}/spec.full.md +0 -8
  99. package/standard/seeds/software/{shapes → templates}/spec.micro.md +0 -9
  100. package/browser/index.html +0 -268
  101. package/dist/src/commands/convert.js +0 -30
  102. package/dist/src/core/shape.js +0 -26
  103. package/standard/seeds/book/Framework.md +0 -87
  104. package/standard/seeds/book/shapes/frame.premise.md +0 -24
  105. package/standard/seeds/research/Framework.md +0 -88
  106. package/standard/seeds/software/Framework.md +0 -88
  107. /package/standard/seeds/software/{shapes → templates}/.gitkeep +0 -0
@@ -1,13 +1,16 @@
1
- // The YAML framework document, `_eidos/Framework.yaml`: the same model as the
1
+ // The YAML framework document, `.eidos/Framework.yaml`: the same model as the
2
2
  // markdown form, as fields in the frontmatter's snake_case, for a root that
3
3
  // scripts and agents read.
4
- // Paths are relative to `_eidos/`, as the markdown links are. A structured
4
+ // Paths are relative to `.eidos/`, as the markdown links are. Every folder
5
+ // at the root is declared under `folders` with its type (a 5.2.x root's
6
+ // `collections` still reads, every entry a collection). A structured
5
7
  // document also carries the generated index under `index`, one list per
6
8
  // collection, which `eidos index` rewrites in place without disturbing
7
9
  // anything a person wrote around it.
8
10
  import { Document, isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml';
9
- import { groupingProperty, STANDARD_CORE, } from './framework-model.js';
11
+ import { readValueStyle, coreRequires, groupingProperty, isCollection, isFolderType, STANDARD_CORE, TOOL_KEY, unitOf } from './framework-model.js';
10
12
  import { DEFAULT_NAMING, isNaming } from './naming.js';
13
+ const STANDARD_PROPERTY_KEYS = new Set(['name', 'type', 'applies_to', 'required', 'options', 'meaning']);
11
14
  function isPlain(value) {
12
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
13
16
  }
@@ -51,48 +54,89 @@ function readCanvas(value, where, problems) {
51
54
  problems.push(`${where}: canvas must be file, card, or { mode: card, section: <Section> }`);
52
55
  return null;
53
56
  }
54
- function readFlavor(value, where, problems) {
55
- if (!isPlain(value) || text(value['name']) === '' || text(value['shape']) === '') {
56
- problems.push(`${where}: every flavor needs a name and a shape`);
57
+ // `template` (`shape` before 5.0.0) is the file's path relative to `.eidos/`.
58
+ function readVariant(value, where, problems) {
59
+ const given = isPlain(value) ? text(value['template']) || text(value['shape']) : '';
60
+ if (!isPlain(value) || text(value['name']) === '' || given === '') {
61
+ problems.push(`${where}: every variant needs a name and a template`);
57
62
  return null;
58
63
  }
59
- const shape = text(value['shape']).replace(/\\/g, '/');
60
- const file = shape.split('/').pop() ?? shape;
64
+ const template = given.replace(/\\/g, '/');
65
+ const file = template.split('/').pop() ?? template;
61
66
  const stem = file.replace(/\.md$/i, '');
62
67
  const dot = stem.indexOf('.');
63
68
  return {
64
69
  name: text(value['name']),
65
- kind: dot === -1 ? stem : stem.slice(0, dot),
66
- shape,
70
+ unit: dot === -1 ? stem : stem.slice(0, dot),
71
+ template,
67
72
  description: text(value['description']),
68
73
  isDefault: value['default'] === true,
69
74
  };
70
75
  }
71
- function readCollection(value, index, problems) {
72
- const where = `collections[${index}]`;
76
+ // `vocabulary`: one entry per term; `not` is a list (a single string reads as one entry).
77
+ function readVocabulary(value, problems) {
78
+ const out = [];
79
+ list(value).forEach((entry, index) => {
80
+ if (!isPlain(entry) || text(entry['term']) === '') {
81
+ problems.push(`vocabulary[${index}]: a term needs a term`);
82
+ return;
83
+ }
84
+ const nots = Array.isArray(entry['not']) ? entry['not'] : entry['not'] === undefined || entry['not'] === null ? [] : [entry['not']];
85
+ out.push({
86
+ term: text(entry['term']),
87
+ means: text(entry['means']),
88
+ not: nots.map((item) => text(item)).filter((item) => item !== ''),
89
+ see: text(entry['see']) === '' ? null : text(entry['see']),
90
+ });
91
+ });
92
+ return out;
93
+ }
94
+ // One entry of `folders` (or of a 5.2.x root's `collections`, every one a
95
+ // collection). A type outside the standard's three is a fault; the folder is
96
+ // still read as declared (as `other`) so the root is not also reported as
97
+ // undeclared.
98
+ function readFolder(value, key, index, problems) {
99
+ const where = `${key}[${index}]`;
73
100
  if (!isPlain(value) || text(value['name']) === '') {
74
- problems.push(`${where}: a collection needs a name`);
101
+ problems.push(`${where}: a folder needs a name`);
75
102
  return null;
76
103
  }
77
104
  const name = text(value['name']);
78
- const flavors = list(value['flavors'])
79
- .map((flavor, position) => readFlavor(flavor, `${where} (${name}) flavors[${position}]`, problems))
80
- .filter((flavor) => flavor !== null);
81
- if (flavors.length > 0 && !flavors.some((flavor) => flavor.isDefault)) {
82
- const first = flavors[0];
105
+ const declared = value['type'];
106
+ let type = 'collection';
107
+ if (isFolderType(declared)) {
108
+ type = declared;
109
+ }
110
+ else if (key === 'folders') {
111
+ problems.push(`${where} (${name}): type must be one of collection, assets, other${declared === undefined || declared === null ? '' : `, not ${text(declared)}`}`);
112
+ type = 'other';
113
+ }
114
+ if (type !== 'collection') {
115
+ return { type, name, description: text(value['description']) };
116
+ }
117
+ return readCollection(value, where, name, problems);
118
+ }
119
+ function readCollection(value, where, name, problems) {
120
+ const variants = list(value['variants'] ?? value['flavors'])
121
+ .map((variant, position) => readVariant(variant, `${where} (${name}) variants[${position}]`, problems))
122
+ .filter((variant) => variant !== null);
123
+ if (variants.length > 0 && !variants.some((variant) => variant.isDefault)) {
124
+ const first = variants[0];
83
125
  if (first) {
84
126
  first.isDefault = true;
85
127
  }
86
128
  }
87
129
  const collection = {
130
+ type: 'collection',
88
131
  name,
89
132
  description: text(value['description']),
90
- framing: index === 0,
133
+ framing: false,
91
134
  leaf: null,
92
- flavors,
135
+ variants,
93
136
  canvas: readCanvas(value['canvas'], `${where} (${name})`, problems),
94
137
  grouping: null,
95
138
  };
139
+ collection.framing = unitOf(collection) === 'frame';
96
140
  const grouping = value['grouping'];
97
141
  if (grouping !== undefined && grouping !== null) {
98
142
  if (!isPlain(grouping) || text(grouping['label']) === '') {
@@ -111,7 +155,7 @@ function readCollection(value, index, problems) {
111
155
  }
112
156
  return collection;
113
157
  }
114
- function readProperty(value, where, core, problems) {
158
+ function readProperty(value, where, core, problems, owner = core ? 'eidos' : 'custom') {
115
159
  if (!isPlain(value) || text(value['name']) === '') {
116
160
  problems.push(`${where}: a property needs a name`);
117
161
  return null;
@@ -132,7 +176,94 @@ function readProperty(value, where, core, problems) {
132
176
  problems.push(`${where}: applies_to must be all or a list of collections`);
133
177
  }
134
178
  }
135
- return { name: text(value['name']), type: text(value['type']) || 'Text', appliesTo: applies, meaning: text(value['meaning']), core };
179
+ // `required` is true or false, absent meaning false; on the standard's own
180
+ // block an absent key means what the core for this version says, so a root
181
+ // written before the key existed still requires its id and title.
182
+ const declaredRequired = value['required'];
183
+ let required = declaredRequired === true;
184
+ if (declaredRequired === undefined || declaredRequired === null) {
185
+ required = core && coreRequires(text(value['name']));
186
+ }
187
+ else if (typeof declaredRequired !== 'boolean') {
188
+ problems.push(`${where}: required must be true or false`);
189
+ }
190
+ const property = { name: text(value['name']), type: text(value['type']) || 'Text', appliesTo: applies, required, meaning: text(value['meaning']), core, owner };
191
+ // `options` closes the value to a declared set: a non-empty list on a Text
192
+ // or List property. An empty list is a fault, since the absent key already
193
+ // says the set is open; on any other type the list means nothing.
194
+ const declaredOptions = value['options'];
195
+ if (declaredOptions !== undefined && declaredOptions !== null) {
196
+ const options = Array.isArray(declaredOptions) ? declaredOptions.map(text).filter((option) => option !== '') : [];
197
+ if (!Array.isArray(declaredOptions) || options.length === 0) {
198
+ problems.push(`${where}: options must be a non-empty list of values`);
199
+ }
200
+ else if (!['text', 'list'].includes(property.type.toLowerCase())) {
201
+ problems.push(`${where}: options apply to a Text or List property, not ${property.type}`);
202
+ }
203
+ else {
204
+ property.options = options;
205
+ }
206
+ }
207
+ const tools = {};
208
+ for (const [key, field] of Object.entries(value)) {
209
+ if (!STANDARD_PROPERTY_KEYS.has(key) && key !== 'canvas' && field !== undefined && field !== null) {
210
+ tools[key] = field;
211
+ }
212
+ }
213
+ if (Object.keys(tools).length > 0) {
214
+ property.tools = tools;
215
+ }
216
+ // This tool's hint sits under its own key; a bare `canvas` (how the first
217
+ // canvases were written) still reads.
218
+ const own = isPlain(tools[TOOL_KEY]) ? tools[TOOL_KEY]['canvas'] : undefined;
219
+ const hint = readCanvasHint(own) ?? readCanvasHint(value['canvas']);
220
+ if (hint) {
221
+ property.canvas = hint;
222
+ }
223
+ return property;
224
+ }
225
+ function readStringMap(value) {
226
+ if (!isPlain(value)) {
227
+ return undefined;
228
+ }
229
+ const out = {};
230
+ for (const [key, item] of Object.entries(value)) {
231
+ if (typeof item === 'string' || typeof item === 'number') {
232
+ out[key] = String(item);
233
+ }
234
+ }
235
+ return Object.keys(out).length > 0 ? out : undefined;
236
+ }
237
+ // `styles` maps a value to a whole node style; the first canvases wrote a
238
+ // `shape` map and a `color` map instead, which still read, folded into it.
239
+ export function readCanvasHint(value) {
240
+ if (!isPlain(value)) {
241
+ return null;
242
+ }
243
+ const hint = {};
244
+ if (value['show'] === true) {
245
+ hint.show = true;
246
+ }
247
+ const styles = {};
248
+ if (isPlain(value['styles'])) {
249
+ for (const [key, item] of Object.entries(value['styles'])) {
250
+ const style = readValueStyle(item);
251
+ if (style)
252
+ styles[key] = style;
253
+ }
254
+ }
255
+ const shape = readStringMap(value['shape']);
256
+ for (const [key, item] of Object.entries(shape ?? {})) {
257
+ styles[key] = { ...(styles[key] ?? {}), shape: styles[key]?.shape ?? item };
258
+ }
259
+ const color = readStringMap(value['color']);
260
+ for (const [key, item] of Object.entries(color ?? {})) {
261
+ styles[key] = { ...(styles[key] ?? {}), color: styles[key]?.color ?? item };
262
+ }
263
+ if (Object.keys(styles).length > 0) {
264
+ hint.styles = styles;
265
+ }
266
+ return Object.keys(hint).length > 0 ? hint : null;
136
267
  }
137
268
  export function readEmbeddedIndex(value) {
138
269
  if (!isPlain(value)) {
@@ -178,19 +309,31 @@ export function parseFrameworkStructured(source, root, file) {
178
309
  namingError = `naming: ${text(declaredNaming)} is not one of kebab-case, TitleCase, Title Case`;
179
310
  }
180
311
  }
181
- const collections = list(document['collections'])
182
- .map((collection, index) => readCollection(collection, index, problems))
183
- .filter((collection) => collection !== null);
184
- if (!Array.isArray(document['collections']) && !error && isPlain(value)) {
185
- problems.push('collections must be a list; the first is the framing collection');
312
+ // `folders` since 5.3.0; a root not yet migrated still declares `collections`,
313
+ // every one a collection, and reads so that `migrate` and `check` can see it.
314
+ const foldersKey = document['folders'] === undefined && document['collections'] !== undefined ? 'collections' : 'folders';
315
+ const folders = list(document[foldersKey])
316
+ .map((folder, index) => readFolder(folder, foldersKey, index, problems))
317
+ .filter((folder) => folder !== null);
318
+ if (!Array.isArray(document[foldersKey]) && !error && isPlain(value)) {
319
+ problems.push(`${foldersKey} must be a list`);
186
320
  }
187
- const schema = isPlain(document['schema']) ? document['schema'] : {};
321
+ const collections = folders.filter(isCollection);
322
+ const schema = isPlain(document['properties']) ? document['properties'] : isPlain(document['schema']) ? document['schema'] : {};
188
323
  const core = list(schema['core'])
189
324
  .map((property, index) => readProperty(property, `schema.core[${index}]`, true, problems))
190
325
  .filter((property) => property !== null);
191
326
  const custom = list(schema['custom'])
192
327
  .map((property, index) => readProperty(property, `schema.custom[${index}]`, false, problems))
193
328
  .filter((property) => property !== null);
329
+ const tools = {};
330
+ if (isPlain(schema['tools'])) {
331
+ for (const [tool, block] of Object.entries(schema['tools'])) {
332
+ tools[tool] = list(block)
333
+ .map((property, index) => readProperty(property, `schema.tools.${tool}[${index}]`, false, problems, tool))
334
+ .filter((property) => property !== null);
335
+ }
336
+ }
194
337
  for (const collection of collections) {
195
338
  if (collection.grouping && collection.grouping.property === null) {
196
339
  collection.grouping.property = groupingProperty(collection, custom);
@@ -208,15 +351,22 @@ export function parseFrameworkStructured(source, root, file) {
208
351
  naming,
209
352
  namingError,
210
353
  topLevel,
354
+ folders,
211
355
  collections,
212
- schema: { core: core.length > 0 ? core : STANDARD_CORE, custom },
356
+ schema: { core: core.length > 0 ? core : STANDARD_CORE, custom, tools },
357
+ vocabulary: readVocabulary(document['vocabulary'], problems),
213
358
  problems,
214
359
  };
215
360
  }
361
+ // The standard's block as a document writes it: `required` only where it is
362
+ // true, since absent means false. `migrate` writes the same rows.
363
+ export function coreEntries(core) {
364
+ return core.map(({ name, type, required, options, meaning, tools }) => ({ name, type, ...(required ? { required: true } : {}), ...(options ? { options } : {}), meaning, ...(tools ?? {}) }));
365
+ }
216
366
  // The document form of a framework: what `eidos framework --json` prints and
217
367
  // what a YAML file holds, without the generated index.
218
368
  // Keys land in the order a reader expects: version and naming, then the
219
- // top-level docs, the collections, and the schema; `index` is added last by
369
+ // top-level docs, the folders, and the schema; `index` is added last by
220
370
  // `eidos index`.
221
371
  export function frameworkToDocument(framework) {
222
372
  const document = {};
@@ -233,11 +383,15 @@ export function frameworkToDocument(framework) {
233
383
  return entry;
234
384
  });
235
385
  }
236
- document.collections = framework.collections.map((collection) => {
237
- const entry = { name: collection.name };
238
- if (collection.description) {
239
- entry.description = collection.description;
386
+ document.folders = framework.folders.map((folder) => {
387
+ const entry = { name: folder.name, type: folder.type };
388
+ if (folder.description) {
389
+ entry.description = folder.description;
390
+ }
391
+ if (!isCollection(folder)) {
392
+ return entry;
240
393
  }
394
+ const collection = folder;
241
395
  if (collection.canvas) {
242
396
  if (collection.canvas.mode === 'file') {
243
397
  entry.canvas = 'file';
@@ -249,12 +403,12 @@ export function frameworkToDocument(framework) {
249
403
  entry.canvas = { mode: 'card', section: collection.canvas.section };
250
404
  }
251
405
  }
252
- entry.flavors = collection.flavors.map((flavor) => {
253
- const item = { name: flavor.name, shape: flavor.shape };
254
- if (flavor.description) {
255
- item.description = flavor.description;
406
+ entry.variants = collection.variants.map((variant) => {
407
+ const item = { name: variant.name, template: variant.template };
408
+ if (variant.description) {
409
+ item.description = variant.description;
256
410
  }
257
- if (flavor.isDefault) {
411
+ if (variant.isDefault) {
258
412
  item.default = true;
259
413
  }
260
414
  return item;
@@ -276,23 +430,44 @@ export function frameworkToDocument(framework) {
276
430
  }
277
431
  return entry;
278
432
  });
279
- document.schema = {
280
- core: framework.schema.core.map(({ name, type, meaning }) => ({ name, type, meaning })),
281
- custom: framework.schema.custom.map(({ name, type, appliesTo: applies, meaning }) => ({ name, type, applies_to: applies, meaning })),
433
+ const entry = ({ name, type, appliesTo: applies, required, options, meaning, canvas, tools }) => {
434
+ const fields = { ...(tools ?? {}) };
435
+ if (canvas) {
436
+ const own = isPlain(fields[TOOL_KEY]) ? fields[TOOL_KEY] : {};
437
+ fields[TOOL_KEY] = { ...own, canvas };
438
+ }
439
+ return { name, type, applies_to: applies, ...(required ? { required: true } : {}), ...(options ? { options } : {}), meaning, ...fields };
440
+ };
441
+ document.properties = {
442
+ core: coreEntries(framework.schema.core),
443
+ custom: framework.schema.custom.map(entry),
282
444
  };
445
+ if (Object.keys(framework.schema.tools).length > 0) {
446
+ document.properties.tools = Object.fromEntries(Object.entries(framework.schema.tools).map(([tool, block]) => [tool, block.map(entry)]));
447
+ }
448
+ if (framework.vocabulary.length > 0) {
449
+ document.vocabulary = framework.vocabulary.map((term) => {
450
+ const entry = { term: term.term, means: term.means, not: term.not };
451
+ if (term.see)
452
+ entry.see = term.see;
453
+ return entry;
454
+ });
455
+ }
283
456
  return document;
284
457
  }
285
- const YAML_GUIDANCE = {
458
+ export const YAML_GUIDANCE = {
286
459
  eidos_version: ' The Eidos version this framework targets; migrate reads and bumps it.',
287
460
  naming: ' How files, folders, and links are named: kebab-case | TitleCase | Title Case. Settled once.',
288
- top_level: ' The top-level documents, README first: one entry per one-of-a-kind doc (a Roadmap, a Vision).',
289
- collections: " The collections, the first of them the framing one. Each declares its flavors (one default), how a canvas draws it, and its grouping.\n Add a group under `grouping.groups` with a one-line description; add a flavor with a shape file in shapes/.",
290
- schema: ' The property contract. `core` is the standard\'s and moves with eidos_version; `custom` is yours: name, type (Text | List | Number | Checkbox | Date | Date & time), applies_to (all or a list), meaning.',
461
+ top_level: ' Every file at the root, one entry each, in the order they are read (the browser lands on the first): a README, a Roadmap, a Vision.',
462
+ folders: " Every folder at the root, one entry each, with its type: collection | assets | other. A collection 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; an assets or other folder is a name, a type, and a description, and nothing inside it is read.\n Add a group under `grouping.groups` with a one-line description; add a variant with a template file in templates/.",
463
+ properties: " The Properties table, one block per owner. `core` is the standard's and moves with eidos_version; `custom` is yours: name, type (Text | List | Number | Checkbox | Date | Date & time), applies_to (all or a list), required (true puts it on every new blueprint and notes one that lacks it; absent = false, written when it has a value), options (the closed set a Text value is one of, or a List's elements are, in order; absent = any value), meaning; `tools.<tool>` is a block a tool declared and alone writes.\n A key past those six on a row is a tool's, named for the tool; `eidosmd.canvas` is how this CLI styles a canvas node by the property's value.",
464
+ vocabulary: " The root's own terms, one entry each: term, means (one line), not (the near-misses, each with why it is a different thing), and see (the blueprint that defines it in full) when one does. Absent = none declared.",
465
+ versions: " Snapshots of the root, taken on purpose, newest first: version (the root's own number), commit (the sha that is the snapshot), tag (blueprints/<version>, when made). Absent = none recorded, the normal state.",
291
466
  index: ' Generated by `eidos index`; never hand-edited. One list per collection, in the order the markdown index would use.',
292
467
  };
293
468
  // A YAML document a person will edit gets the guidance the markdown seed
294
469
  // keeps as prose, as comments above each top-level key.
295
- function annotateYaml(document) {
470
+ export function annotateYaml(document) {
296
471
  if (!isMap(document.contents)) {
297
472
  return;
298
473
  }
@@ -1,6 +1,6 @@
1
- // Finding and loading the root's framework document: exactly one of
2
- // `_eidos/Framework.md` or `Framework.yaml`, parsed into
3
- // the one model every command reads.
1
+ // Finding and loading the root's framework document, `.eidos/Framework.yaml`,
2
+ // parsed into the one model every command reads. A 4.x root's Framework.md
3
+ // is found and parsed too, only so `migrate` can move it.
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { parseFrameworkMarkdown } from './framework-markdown.js';
@@ -8,7 +8,7 @@ import { parseFrameworkStructured } from './framework-structured.js';
8
8
  import { appliesTo, FRAMEWORK_DIR, FRAMEWORK_FILES, FrameworkError } from './framework-model.js';
9
9
  import { kebab } from './naming.js';
10
10
  export * from './framework-model.js';
11
- // The one framework document in a folder's `_eidos/`, or null; two is an error.
11
+ // The one framework document in a folder's `.eidos/`, or null; two is an error.
12
12
  export function findFrameworkFile(dir) {
13
13
  const present = FRAMEWORK_FILES.map((candidate) => ({ file: path.join(dir, FRAMEWORK_DIR, candidate.name), format: candidate.format })).filter((candidate) => existsSync(candidate.file));
14
14
  if (present.length > 1) {
@@ -28,7 +28,7 @@ export function parseFramework(text, format, root, file) {
28
28
  export function loadFramework(root) {
29
29
  const found = findFrameworkFile(root);
30
30
  if (!found) {
31
- throw new FrameworkError(`${path.join(root, FRAMEWORK_DIR)} holds no framework document (Framework.md or Framework.yaml)`);
31
+ throw new FrameworkError(`${path.join(root, FRAMEWORK_DIR)} holds no framework document (Framework.yaml)`);
32
32
  }
33
33
  return parseFramework(readFileSync(found.file, 'utf8'), found.format, root, found.file);
34
34
  }
@@ -38,19 +38,19 @@ export function findCollection(framework, name) {
38
38
  framework.collections.find((collection) => kebab(collection.name) === kebab(name)) ??
39
39
  null);
40
40
  }
41
- export function findFlavor(collection, name) {
41
+ export function findVariant(collection, name) {
42
42
  const wanted = name.trim().toLowerCase();
43
- return (collection.flavors.find((flavor) => flavor.name.toLowerCase() === wanted) ??
44
- collection.flavors.find((flavor) => kebab(flavor.name) === kebab(name)) ??
43
+ return (collection.variants.find((variant) => variant.name.toLowerCase() === wanted) ??
44
+ collection.variants.find((variant) => kebab(variant.name) === kebab(name)) ??
45
45
  null);
46
46
  }
47
- export function defaultFlavor(collection) {
48
- return collection.flavors.find((flavor) => flavor.isDefault) ?? collection.flavors[0] ?? null;
47
+ export function defaultVariant(collection) {
48
+ return collection.variants.find((variant) => variant.isDefault) ?? collection.variants[0] ?? null;
49
49
  }
50
50
  // The properties a blueprint in this collection carries, core first.
51
51
  export function propertiesFor(framework, collectionName) {
52
- return [...framework.schema.core, ...framework.schema.custom.filter((property) => appliesTo(property, collectionName))];
52
+ return [...framework.schema.core, ...[...framework.schema.custom, ...Object.values(framework.schema.tools).flat()].filter((property) => appliesTo(property, collectionName))];
53
53
  }
54
- export function shapePath(framework, flavor) {
55
- return path.join(framework.root, FRAMEWORK_DIR, flavor.shape);
54
+ export function templatePath(framework, variant) {
55
+ return path.join(framework.root, FRAMEWORK_DIR, variant.template);
56
56
  }
@@ -3,7 +3,7 @@
3
3
  // every seed and scaffold already uses, so a generated file diffs cleanly
4
4
  // against a hand-written one.
5
5
  import { parse as parseYaml } from 'yaml';
6
- import { splitFrontmatter } from './markdown.js';
6
+ import { normalizeNewlines, splitFrontmatter } from './markdown.js';
7
7
  // One `key: value` per line, read without YAML: what an unquoted summary with
8
8
  // a colon in it still means to a person, and what Obsidian will refuse to show.
9
9
  function parseLeniently(frontmatter) {
@@ -114,3 +114,63 @@ export function formatFrontmatter(entries) {
114
114
  lines.push('---');
115
115
  return lines.join('\n') + '\n';
116
116
  }
117
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
118
+ // The lines a top-level key occupies inside [from, to): its own line and
119
+ // every line beneath it that is indented or a `- ` item, so a block list
120
+ // or a folded scalar moves as one.
121
+ export function keySpan(lines, from, to, key) {
122
+ const head = new RegExp(`^${escapeRegExp(key)}\\s*:`);
123
+ for (let index = from; index < to; index += 1) {
124
+ if (!head.test(lines[index] ?? ''))
125
+ continue;
126
+ let end = index + 1;
127
+ while (end < to && /^(\s+\S|-\s)/.test(lines[end] ?? ''))
128
+ end += 1;
129
+ return { start: index, end };
130
+ }
131
+ return null;
132
+ }
133
+ // Whether a file's frontmatter carries the key at the top level.
134
+ export function hasKey(text, key) {
135
+ const lines = normalizeNewlines(text).split('\n');
136
+ const close = closingFence(lines);
137
+ return close !== null && keySpan(lines, 1, close, key) !== null;
138
+ }
139
+ function closingFence(lines) {
140
+ if (lines[0]?.trim() !== '---')
141
+ return null;
142
+ for (let index = 1; index < lines.length; index += 1) {
143
+ if (lines[index]?.trim() === '---')
144
+ return index;
145
+ }
146
+ return null;
147
+ }
148
+ // The file with one key edited in its frontmatter. A file with no block, or
149
+ // a rename or delete of a key it lacks, comes back unchanged; a set of a key
150
+ // it lacks appends the key at the end of the block.
151
+ export function editFrontmatterKey(text, edit) {
152
+ const lines = normalizeNewlines(text).split('\n');
153
+ const close = closingFence(lines);
154
+ if (close === null)
155
+ return text;
156
+ const span = keySpan(lines, 1, close, edit.key);
157
+ if (edit.kind === 'rename') {
158
+ if (!span)
159
+ return text;
160
+ const line = lines[span.start] ?? '';
161
+ lines[span.start] = line.replace(new RegExp(`^${escapeRegExp(edit.key)}(\\s*:)`), `${edit.to}$1`);
162
+ return lines.join('\n');
163
+ }
164
+ if (edit.kind === 'delete') {
165
+ if (!span)
166
+ return text;
167
+ lines.splice(span.start, span.end - span.start);
168
+ return lines.join('\n');
169
+ }
170
+ const rendered = formatFrontmatter([[edit.key, edit.value]]).split('\n').slice(1, -2);
171
+ if (span)
172
+ lines.splice(span.start, span.end - span.start, ...rendered);
173
+ else
174
+ lines.splice(close, 0, ...rendered);
175
+ return lines.join('\n');
176
+ }
@@ -0,0 +1,84 @@
1
+ // The little git the CLI needs: the commit a root sits at, whether a commit
2
+ // exists, a tag on one, and a move that keeps history. Every call is
3
+ // read-only except `gitTag` and `moveFile`, and every one answers null or
4
+ // false rather than throwing where git is absent or the root is not in a
5
+ // repository.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { renameSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { readSettings } from './settings.js';
10
+ function git(root, args) {
11
+ try {
12
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ // A command run for its effect: true when git exits clean, whatever it prints.
19
+ function gitRun(root, args) {
20
+ try {
21
+ execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore'] });
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ // Whether git tracks this path (a file, or a folder with tracked files in it).
29
+ export function gitTracks(root, absolute) {
30
+ return git(root, ['ls-files', '--error-unmatch', '--', absolute]) !== null;
31
+ }
32
+ // A file or folder moved the way the root wants: with git on for the root
33
+ // and the path tracked, `git mv`, so history follows the rename; otherwise,
34
+ // or when git refuses, a plain rename.
35
+ export function moveFile(root, from, to) {
36
+ if (readSettings(root).shared.features.git && gitTracks(root, from) && gitRun(root, ['mv', '--', from, to]))
37
+ return;
38
+ renameSync(from, to);
39
+ }
40
+ export function gitHead(root) {
41
+ return git(root, ['rev-parse', 'HEAD']);
42
+ }
43
+ // The full sha of a commit named any way git accepts (a short sha, HEAD, a
44
+ // tag), or null when nothing by that name is a commit here.
45
+ export function gitResolve(root, ref) {
46
+ return git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
47
+ }
48
+ export function gitTagExists(root, tag) {
49
+ return git(root, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]) !== null;
50
+ }
51
+ export function gitTag(root, tag, sha) {
52
+ return git(root, ['tag', tag, sha]) !== null || gitTagExists(root, tag);
53
+ }
54
+ // Who git says is committing here: the repository's configured user, falling
55
+ // back to the global one the way git itself does. Null outside a repository
56
+ // or where no name is set.
57
+ export function gitUser(root) {
58
+ if (gitHead(root) === null && git(root, ['rev-parse', '--git-dir']) === null)
59
+ return null;
60
+ const name = git(root, ['config', 'user.name']);
61
+ if (name === null)
62
+ return null;
63
+ return { name, email: git(root, ['config', 'user.email']) };
64
+ }
65
+ // The commits that touched one file, newest first, following renames.
66
+ export function gitLog(root, file, limit = 50) {
67
+ const out = git(root, ['log', `--max-count=${limit}`, '--follow', '--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s', '--', file]);
68
+ if (out === null)
69
+ return [];
70
+ return out
71
+ .split('\n')
72
+ .map((line) => line.split('\x1f'))
73
+ .filter((parts) => parts.length === 6)
74
+ .map(([sha, short, author, email, date, subject]) => ({ sha: sha ?? '', short: short ?? '', author: author ?? '', email: email ?? '', date: date ?? '', subject: subject ?? '' }));
75
+ }
76
+ // The file as one commit held it, or null when that commit has no such file.
77
+ export function gitShowFile(root, sha, file) {
78
+ try {
79
+ return execFileSync('git', ['show', `${sha}:${file.split(path.sep).join('/')}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
@@ -2,8 +2,8 @@
2
2
  // `summary`, kept inside the YAML framework document under `index`, one list
3
3
  // per collection, regenerated wholesale. `eidos index` rewrites that key and
4
4
  // nothing else in the document, so the comments and layout a person chose
5
- // survive. (A markdown root keeps a per-collection `index.md` instead; the
6
- // CLI converts such a root rather than maintaining that form.)
5
+ // survive. (A 4.x markdown root kept a per-collection `index.md` instead;
6
+ // `migrate` converts such a root.)
7
7
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { blueprintId, blueprintTitle, propertyString } from './blueprint.js';