eidosmd 0.2.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 (47) hide show
  1. package/browser/dist/assets/index-Cc3cNWHY.css +1 -0
  2. package/browser/dist/assets/index-DQgCQRa5.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
@@ -5,7 +5,7 @@
5
5
  // spellings (Schema, Flavors, shape) still read, so `migrate` can load a root
6
6
  // it is about to move.
7
7
  import { parseFrontmatter } from './frontmatter.js';
8
- import { groupingProperty, STANDARD_CORE, unitOf } from './framework-model.js';
8
+ import { coreRequires, groupingProperty, STANDARD_CORE, unitOf } from './framework-model.js';
9
9
  import { DEFAULT_NAMING, isNaming } from './naming.js';
10
10
  import { section, sections, stripInlineCode } from './markdown.js';
11
11
  import path from 'node:path';
@@ -64,6 +64,7 @@ function parseCollections(body) {
64
64
  const out = [];
65
65
  for (const { heading, body: text } of sections(block, 3)) {
66
66
  const collection = {
67
+ type: 'collection',
67
68
  name: heading.text,
68
69
  description: firstParagraph(text),
69
70
  framing: false,
@@ -175,9 +176,12 @@ function parseProperties(block, core, owner = core ? 'eidos' : 'custom') {
175
176
  continue;
176
177
  }
177
178
  const type = (cells[1] ?? 'Text').trim();
179
+ // the 4.x table had no Required column: the core requires what the
180
+ // standard says it does, and every other property is optional
181
+ const required = core && coreRequires(name);
178
182
  const property = core || cells.length < 4
179
- ? { name, type, appliesTo: 'all', meaning: (cells[Math.min(cells.length, standard) - 1] ?? '').trim(), core, owner }
180
- : { name, type, appliesTo: parseAppliesTo(cells[2] ?? ''), meaning: (cells[3] ?? '').trim(), core, owner };
183
+ ? { name, type, appliesTo: 'all', required, meaning: (cells[Math.min(cells.length, standard) - 1] ?? '').trim(), core, owner }
184
+ : { name, type, appliesTo: parseAppliesTo(cells[2] ?? ''), required, meaning: (cells[3] ?? '').trim(), core, owner };
181
185
  const tools = {};
182
186
  for (let index = standard; index < cells.length; index += 1) {
183
187
  const tool = header[index];
@@ -304,6 +308,8 @@ export function parseFrameworkMarkdown(text, root, file) {
304
308
  naming,
305
309
  namingError,
306
310
  topLevel: parseTopLevel(body),
311
+ // the markdown form predates folder types: every folder it declares is a collection
312
+ folders: collections,
307
313
  collections,
308
314
  schema: { core: core.length > 0 ? core : STANDARD_CORE, custom, tools },
309
315
  vocabulary: parseVocabulary(body),
@@ -1,8 +1,8 @@
1
1
  // The framework model: everything a framework document declares, whichever
2
2
  // of `.eidos/Framework.md` or `.yaml` it is. The version and naming
3
- // convention, the Top-Level docs, the Collections (each with variants and
4
- // grouping), the Properties table (`schema` here, `properties` on disk), the
5
- // Vocabulary, and the root's own Versions.
3
+ // convention, the Top-Level docs, the Folders (every folder at the root with
4
+ // its type; a collection carries its variants and grouping), the Properties
5
+ // table (`schema` here, `properties` on disk), and the Vocabulary.
6
6
  import { kebab } from './naming.js';
7
7
  export const FRAMEWORK_DIR = '.eidos';
8
8
  // This tool's name, as the standard has a tool sign what it keeps in a
@@ -19,6 +19,35 @@ export const FRAMEWORK_FILES = [
19
19
  { name: 'Framework.yml', format: 'yaml' },
20
20
  ];
21
21
  export const PROPERTY_TYPES = ['Text', 'List', 'Number', 'Checkbox', 'Date', 'Date & time'];
22
+ export function readValueStyle(value) {
23
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
24
+ return null;
25
+ const given = value;
26
+ const text = (field) => (typeof field === 'string' || typeof field === 'number' ? String(field).trim() : '');
27
+ const style = {};
28
+ if (text(given['shape']) !== '')
29
+ style.shape = text(given['shape']);
30
+ if (text(given['color']) !== '')
31
+ style.color = text(given['color']);
32
+ if (given['fill'] === 'solid' || given['fill'] === 'tint' || given['fill'] === 'none')
33
+ style.fill = given['fill'];
34
+ if (text(given['fill_color']) !== '')
35
+ style.fill_color = text(given['fill_color']);
36
+ if (given['size'] === 'small' || given['size'] === 'medium' || given['size'] === 'large')
37
+ style.size = given['size'];
38
+ return Object.keys(style).length > 0 ? style : null;
39
+ }
40
+ // Every folder at the root is declared with one of the standard's three
41
+ // types (Eidos 5.3.0): a collection holds blueprints; an assets folder holds
42
+ // files that are not markdown; an other folder is whatever its description
43
+ // says. The standard reads nothing inside the last two.
44
+ export const FOLDER_TYPES = ['collection', 'assets', 'other'];
45
+ export function isFolderType(value) {
46
+ return typeof value === 'string' && FOLDER_TYPES.includes(value);
47
+ }
48
+ export function isCollection(folder) {
49
+ return folder.type === 'collection';
50
+ }
22
51
  // The unit a collection speaks in, the word for one of its blueprints: what
23
52
  // its template files are named for (`<unit>.<variant>.md`), read off the
24
53
  // default variant. A collection whose unit is `frame` is the framing one, this
@@ -33,13 +62,19 @@ export class FrameworkError extends Error {
33
62
  this.name = 'FrameworkError';
34
63
  }
35
64
  }
36
- // The standard's own four (Eidos 5.0.0), used when a Framework.md carries no core block.
65
+ // The standard's own four (Eidos 5.3.0), two of them required, used when a
66
+ // framework document carries no core block.
37
67
  export const STANDARD_CORE = [
38
- { name: 'id', type: 'Text', appliesTo: 'all', meaning: 'Stable, unique identity, in any form: a slug, a number, a GUID. Assigned once, never changed.', core: true, owner: 'eidos' },
39
- { name: 'title', type: 'Text', appliesTo: 'all', meaning: 'Human-readable name.', core: true, owner: 'eidos' },
40
- { name: 'summary', type: 'Text', appliesTo: 'all', meaning: 'One plain line: what this blueprint is. The source for the collection index.', core: true, owner: 'eidos' },
41
- { name: 'variant', type: 'Text', appliesTo: 'all', meaning: 'Which variant this blueprint follows. Absent = the collection default.', core: true, owner: 'eidos' },
68
+ { name: 'id', type: 'Text', appliesTo: 'all', required: true, meaning: 'Stable, unique identity, in any form. Assigned once, never changed.', core: true, owner: 'eidos' },
69
+ { name: 'title', type: 'Text', appliesTo: 'all', required: true, meaning: 'Human-readable name.', core: true, owner: 'eidos' },
70
+ { name: 'summary', type: 'Text', appliesTo: 'all', required: false, meaning: 'One line: what this blueprint is. Feeds the index; absent, the index flags it.', core: true, owner: 'eidos' },
71
+ { name: 'variant', type: 'Text', appliesTo: 'all', required: false, meaning: "Which variant this blueprint follows. Absent = the collection's default.", core: true, owner: 'eidos' },
42
72
  ];
73
+ // Whether the standard's core requires a property of this name: what an
74
+ // absent `required` on a core row means, since the block is the standard's.
75
+ export function coreRequires(name) {
76
+ return STANDARD_CORE.some((property) => property.name === name && property.required);
77
+ }
43
78
  // Every property of the Properties table, whichever block owns it, core first.
44
79
  export function allProperties(schema) {
45
80
  return [...schema.core, ...schema.custom, ...Object.values(schema.tools).flat()];
@@ -1,14 +1,16 @@
1
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, TOOL_KEY, unitOf } 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';
11
- const STANDARD_PROPERTY_KEYS = new Set(['name', 'type', 'applies_to', 'meaning']);
13
+ const STANDARD_PROPERTY_KEYS = new Set(['name', 'type', 'applies_to', 'required', 'options', 'meaning']);
12
14
  function isPlain(value) {
13
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
14
16
  }
@@ -89,13 +91,32 @@ function readVocabulary(value, problems) {
89
91
  });
90
92
  return out;
91
93
  }
92
- function readCollection(value, index, problems) {
93
- const where = `collections[${index}]`;
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}]`;
94
100
  if (!isPlain(value) || text(value['name']) === '') {
95
- problems.push(`${where}: a collection needs a name`);
101
+ problems.push(`${where}: a folder needs a name`);
96
102
  return null;
97
103
  }
98
104
  const name = text(value['name']);
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) {
99
120
  const variants = list(value['variants'] ?? value['flavors'])
100
121
  .map((variant, position) => readVariant(variant, `${where} (${name}) variants[${position}]`, problems))
101
122
  .filter((variant) => variant !== null);
@@ -106,6 +127,7 @@ function readCollection(value, index, problems) {
106
127
  }
107
128
  }
108
129
  const collection = {
130
+ type: 'collection',
109
131
  name,
110
132
  description: text(value['description']),
111
133
  framing: false,
@@ -154,7 +176,34 @@ function readProperty(value, where, core, problems, owner = core ? 'eidos' : 'cu
154
176
  problems.push(`${where}: applies_to must be all or a list of collections`);
155
177
  }
156
178
  }
157
- const property = { name: text(value['name']), type: text(value['type']) || 'Text', appliesTo: applies, meaning: text(value['meaning']), core, owner };
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
+ }
158
207
  const tools = {};
159
208
  for (const [key, field] of Object.entries(value)) {
160
209
  if (!STANDARD_PROPERTY_KEYS.has(key) && key !== 'canvas' && field !== undefined && field !== null) {
@@ -185,6 +234,8 @@ function readStringMap(value) {
185
234
  }
186
235
  return Object.keys(out).length > 0 ? out : undefined;
187
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.
188
239
  export function readCanvasHint(value) {
189
240
  if (!isPlain(value)) {
190
241
  return null;
@@ -193,13 +244,24 @@ export function readCanvasHint(value) {
193
244
  if (value['show'] === true) {
194
245
  hint.show = true;
195
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
+ }
196
255
  const shape = readStringMap(value['shape']);
197
- if (shape) {
198
- hint.shape = shape;
256
+ for (const [key, item] of Object.entries(shape ?? {})) {
257
+ styles[key] = { ...(styles[key] ?? {}), shape: styles[key]?.shape ?? item };
199
258
  }
200
259
  const color = readStringMap(value['color']);
201
- if (color) {
202
- hint.color = 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;
203
265
  }
204
266
  return Object.keys(hint).length > 0 ? hint : null;
205
267
  }
@@ -247,12 +309,16 @@ export function parseFrameworkStructured(source, root, file) {
247
309
  namingError = `naming: ${text(declaredNaming)} is not one of kebab-case, TitleCase, Title Case`;
248
310
  }
249
311
  }
250
- const collections = list(document['collections'])
251
- .map((collection, index) => readCollection(collection, index, problems))
252
- .filter((collection) => collection !== null);
253
- if (!Array.isArray(document['collections']) && !error && isPlain(value)) {
254
- problems.push('collections must be a list');
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`);
255
320
  }
321
+ const collections = folders.filter(isCollection);
256
322
  const schema = isPlain(document['properties']) ? document['properties'] : isPlain(document['schema']) ? document['schema'] : {};
257
323
  const core = list(schema['core'])
258
324
  .map((property, index) => readProperty(property, `schema.core[${index}]`, true, problems))
@@ -285,16 +351,22 @@ export function parseFrameworkStructured(source, root, file) {
285
351
  naming,
286
352
  namingError,
287
353
  topLevel,
354
+ folders,
288
355
  collections,
289
356
  schema: { core: core.length > 0 ? core : STANDARD_CORE, custom, tools },
290
357
  vocabulary: readVocabulary(document['vocabulary'], problems),
291
358
  problems,
292
359
  };
293
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
+ }
294
366
  // The document form of a framework: what `eidos framework --json` prints and
295
367
  // what a YAML file holds, without the generated index.
296
368
  // Keys land in the order a reader expects: version and naming, then the
297
- // 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
298
370
  // `eidos index`.
299
371
  export function frameworkToDocument(framework) {
300
372
  const document = {};
@@ -311,11 +383,15 @@ export function frameworkToDocument(framework) {
311
383
  return entry;
312
384
  });
313
385
  }
314
- document.collections = framework.collections.map((collection) => {
315
- const entry = { name: collection.name };
316
- if (collection.description) {
317
- 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;
318
393
  }
394
+ const collection = folder;
319
395
  if (collection.canvas) {
320
396
  if (collection.canvas.mode === 'file') {
321
397
  entry.canvas = 'file';
@@ -354,16 +430,16 @@ export function frameworkToDocument(framework) {
354
430
  }
355
431
  return entry;
356
432
  });
357
- const entry = ({ name, type, appliesTo: applies, meaning, canvas, tools }) => {
433
+ const entry = ({ name, type, appliesTo: applies, required, options, meaning, canvas, tools }) => {
358
434
  const fields = { ...(tools ?? {}) };
359
435
  if (canvas) {
360
436
  const own = isPlain(fields[TOOL_KEY]) ? fields[TOOL_KEY] : {};
361
437
  fields[TOOL_KEY] = { ...own, canvas };
362
438
  }
363
- return { name, type, applies_to: applies, meaning, ...fields };
439
+ return { name, type, applies_to: applies, ...(required ? { required: true } : {}), ...(options ? { options } : {}), meaning, ...fields };
364
440
  };
365
441
  document.properties = {
366
- core: framework.schema.core.map(({ name, type, meaning, tools }) => ({ name, type, meaning, ...(tools ?? {}) })),
442
+ core: coreEntries(framework.schema.core),
367
443
  custom: framework.schema.custom.map(entry),
368
444
  };
369
445
  if (Object.keys(framework.schema.tools).length > 0) {
@@ -379,12 +455,12 @@ export function frameworkToDocument(framework) {
379
455
  }
380
456
  return document;
381
457
  }
382
- const YAML_GUIDANCE = {
458
+ export const YAML_GUIDANCE = {
383
459
  eidos_version: ' The Eidos version this framework targets; migrate reads and bumps it.',
384
460
  naming: ' How files, folders, and links are named: kebab-case | TitleCase | Title Case. Settled once.',
385
- top_level: ' The top-level documents, README first: one entry per one-of-a-kind doc (a Roadmap, a Vision).',
386
- collections: " 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/.",
387
- 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), meaning; `tools.<tool>` is a block a tool declared and alone writes.\n A key past those four 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.",
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.",
388
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.",
389
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.",
390
466
  index: ' Generated by `eidos index`; never hand-edited. One list per collection, in the order the markdown index would use.',
@@ -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
+ }
@@ -1,9 +1,12 @@
1
1
  // The little git the CLI needs: the commit a root sits at, whether a commit
2
- // exists, and a tag on one. Every call is read-only except `gitTag`, and
3
- // every one answers null or false rather than throwing where git is absent
4
- // or the root is not in a repository.
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.
5
6
  import { execFileSync } from 'node:child_process';
7
+ import { renameSync } from 'node:fs';
6
8
  import path from 'node:path';
9
+ import { readSettings } from './settings.js';
7
10
  function git(root, args) {
8
11
  try {
9
12
  return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
@@ -12,6 +15,28 @@ function git(root, args) {
12
15
  return null;
13
16
  }
14
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
+ }
15
40
  export function gitHead(root) {
16
41
  return git(root, ['rev-parse', 'HEAD']);
17
42
  }
@@ -0,0 +1,87 @@
1
+ // Rewriting the markdown links a move breaks. When a folder or a file moves,
2
+ // every `[text](path)` anywhere in the root whose target lands inside the
3
+ // moved path is rewritten to point at the new place, relative to where the
4
+ // file holding it ends up. It is a path substitution and nothing else: a
5
+ // link's text is untouched, a link in a fenced code block, in inline code,
6
+ // or inside a tool's region is left as it is, and a filename typed in prose
7
+ // without link syntax is not a link.
8
+ import path from 'node:path';
9
+ import { regionLines } from './regions.js';
10
+ export function movePath(absolute, moves) {
11
+ for (const move of moves) {
12
+ if (absolute === move.from)
13
+ return move.to;
14
+ if (absolute.startsWith(move.from + path.sep))
15
+ return path.join(move.to, path.relative(move.from, absolute));
16
+ }
17
+ return absolute;
18
+ }
19
+ const LINK = /\[([^\]]*)\]\(\s*(<[^>]*>|[^)\s]+)((?:\s+"[^"]*")?)\s*\)/g;
20
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
21
+ function isFileTarget(target) {
22
+ return target !== '' && !/^[a-z][a-z0-9+.-]*:/i.test(target) && !target.startsWith('#') && !target.startsWith('//');
23
+ }
24
+ // The spans of inline code on a line, so a link quoted in code is left alone.
25
+ function codeSpans(line) {
26
+ const out = [];
27
+ const pattern = /`+[^`]*`+/g;
28
+ let match = pattern.exec(line);
29
+ while (match !== null) {
30
+ out.push([match.index, match.index + match[0].length]);
31
+ match = pattern.exec(line);
32
+ }
33
+ return out;
34
+ }
35
+ // The text of a file with every link a move breaks rewritten. `before` is
36
+ // where the file is now and `after` where it will be once the moves are
37
+ // made, so a file inside a moved folder gets its outward links right too.
38
+ export function rewriteLinks(text, before, after, moves) {
39
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
40
+ const owned = regionLines(text);
41
+ let count = 0;
42
+ let fence = null;
43
+ const out = lines.map((line, index) => {
44
+ if (owned.has(index))
45
+ return line;
46
+ const fenced = FENCE.exec(line);
47
+ if (fence) {
48
+ if (fenced && fenced[1] && fenced[1][0] === fence.char && fenced[1].length >= fence.length && line.trim() === fenced[1])
49
+ fence = null;
50
+ return line;
51
+ }
52
+ if (fenced && fenced[1]) {
53
+ fence = { char: fenced[1][0] ?? '`', length: fenced[1].length };
54
+ return line;
55
+ }
56
+ const code = codeSpans(line);
57
+ return line.replace(LINK, (whole, label, rawTarget, title, offset) => {
58
+ if (code.some(([start, end]) => offset >= start && offset < end))
59
+ return whole;
60
+ const wrapped = rawTarget.startsWith('<') && rawTarget.endsWith('>');
61
+ const target = wrapped ? rawTarget.slice(1, -1) : rawTarget;
62
+ const hash = target.indexOf('#');
63
+ const file = hash === -1 ? target : target.slice(0, hash);
64
+ const anchor = hash === -1 ? '' : target.slice(hash);
65
+ if (!isFileTarget(file))
66
+ return whole;
67
+ let decoded = file;
68
+ try {
69
+ decoded = decodeURIComponent(file);
70
+ }
71
+ catch {
72
+ return whole;
73
+ }
74
+ const absolute = path.resolve(path.dirname(before), decoded);
75
+ const moved = movePath(absolute, moves);
76
+ let relative = path.relative(path.dirname(after), moved).split(path.sep).join('/');
77
+ if (relative === decoded.replace(/\\/g, '/'))
78
+ return whole;
79
+ if (relative === '')
80
+ relative = path.basename(moved);
81
+ count += 1;
82
+ const encoded = wrapped ? `<${relative}>` : /%20/.test(file) ? relative.replace(/ /g, '%20') : /\s/.test(relative) ? `<${relative}>` : relative;
83
+ return `[${label}](${encoded}${anchor}${title})`;
84
+ });
85
+ });
86
+ return { text: out.join('\n'), count };
87
+ }
@@ -1,6 +1,7 @@
1
1
  // Small markdown readers shared by the framework, shape, and blueprint parsers:
2
2
  // splitting frontmatter from body, and finding headings and sections outside
3
- // code fences and HTML comments.
3
+ // code fences, HTML comments, and the regions a tool owns.
4
+ import { regionLines } from './regions.js';
4
5
  export function normalizeNewlines(text) {
5
6
  return text.replace(/\r\n?/g, '\n');
6
7
  }
@@ -21,37 +22,43 @@ export function splitFrontmatter(text) {
21
22
  }
22
23
  return { frontmatter: null, body: lines.join('\n') };
23
24
  }
24
- // Every line with a flag saying whether it sits inside a fenced code block or
25
- // an HTML comment, so a `## heading` quoted in guidance is not read as one.
25
+ // Every line with a flag saying whether it sits inside a fenced code block, an
26
+ // HTML comment, or a tool's region, so a `## heading` quoted in guidance, or
27
+ // one a tool wrote inside its region, is not read as one of the person's.
26
28
  function visibleLines(text) {
27
29
  const out = [];
30
+ const owned = regionLines(text);
28
31
  let inFence = false;
29
32
  let inComment = false;
30
- for (const line of normalizeNewlines(text).split('\n')) {
33
+ normalizeNewlines(text).split('\n').forEach((line, index) => {
31
34
  const trimmed = line.trim();
35
+ if (owned.has(index)) {
36
+ out.push({ line, visible: false });
37
+ return;
38
+ }
32
39
  if (!inComment && /^(```|~~~)/.test(trimmed)) {
33
40
  inFence = !inFence;
34
41
  out.push({ line, visible: false });
35
- continue;
42
+ return;
36
43
  }
37
44
  if (inFence) {
38
45
  out.push({ line, visible: false });
39
- continue;
46
+ return;
40
47
  }
41
48
  if (!inComment && trimmed.startsWith('<!--')) {
42
49
  inComment = !trimmed.includes('-->');
43
50
  out.push({ line, visible: false });
44
- continue;
51
+ return;
45
52
  }
46
53
  if (inComment) {
47
54
  if (trimmed.includes('-->')) {
48
55
  inComment = false;
49
56
  }
50
57
  out.push({ line, visible: false });
51
- continue;
58
+ return;
52
59
  }
53
60
  out.push({ line, visible: true });
54
- }
61
+ });
55
62
  return out;
56
63
  }
57
64
  export function headings(text) {