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
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
9
9
  import path from 'node:path';
10
10
  import { parse as parseYaml, Document } from 'yaml';
11
11
  import { blueprintId, blueprintTitle, propertyString } from './blueprint.js';
12
- import { FRAMEWORK_DIR, PLUGINS_DIR, TOOL_KEY } from './framework-model.js';
12
+ import { FRAMEWORK_DIR, PLUGINS_DIR, readValueStyle, TOOL_KEY } from './framework-model.js';
13
13
  import { kebab } from './naming.js';
14
14
  // Relative to `.eidos/`: the folder the standard reserves for this tool.
15
15
  export const MAPS_DIR = `${PLUGINS_DIR}/${TOOL_KEY}/maps`;
@@ -119,22 +119,7 @@ export function readBox(value) {
119
119
  return null;
120
120
  return { w: Math.max(40, Math.round(value['w'])), h: Math.max(24, Math.round(value['h'])) };
121
121
  }
122
- export function readStyle(value) {
123
- if (!isPlain(value))
124
- return null;
125
- const style = {};
126
- if (text(value['shape']) !== '')
127
- style.shape = text(value['shape']);
128
- if (text(value['color']) !== '')
129
- style.color = text(value['color']);
130
- if (value['fill'] === 'solid' || value['fill'] === 'tint' || value['fill'] === 'none')
131
- style.fill = value['fill'];
132
- if (text(value['fill_color']) !== '')
133
- style.fill_color = text(value['fill_color']);
134
- if (value['size'] === 'small' || value['size'] === 'medium' || value['size'] === 'large')
135
- style.size = value['size'];
136
- return Object.keys(style).length > 0 ? style : null;
137
- }
122
+ export const readStyle = readValueStyle;
138
123
  function readPage(value, index, problems) {
139
124
  const where = `pages[${index}]`;
140
125
  if (!isPlain(value) || text(value['id']) === '') {
@@ -393,44 +378,65 @@ export const NODE_WIDTH = 220;
393
378
  export function canvasProperties(framework) {
394
379
  return framework.schema.custom.filter((property) => property.canvas !== undefined);
395
380
  }
381
+ // The style a property gives one value, matched without regard to case.
382
+ export function valueStyle(hint, value) {
383
+ const key = Object.keys(hint.styles ?? {}).find((candidate) => candidate.toLowerCase() === value.toLowerCase());
384
+ return key ? (hint.styles?.[key] ?? null) : null;
385
+ }
396
386
  // The default look a node's property values give it. Properties are read in
397
- // schema order and the top-most that names the value wins each of shape and
398
- // color, so the order of the custom block is the precedence.
387
+ // schema order and, field by field, the top-most that sets it for the value
388
+ // wins, so the order of the custom block is the precedence.
399
389
  export function styleFor(framework, values) {
400
- let shape = null;
401
- let color = null;
390
+ const found = {};
402
391
  const shown = [];
403
392
  for (const property of canvasProperties(framework)) {
404
393
  const value = values[property.name];
405
394
  if (value === undefined || value === '')
406
395
  continue;
407
- const key = Object.keys(property.canvas.shape ?? {}).find((candidate) => candidate.toLowerCase() === value.toLowerCase());
408
- if (shape === null && key && property.canvas.shape?.[key])
409
- shape = property.canvas.shape[key];
410
- const colorKey = Object.keys(property.canvas.color ?? {}).find((candidate) => candidate.toLowerCase() === value.toLowerCase());
411
- if (color === null && colorKey && property.canvas.color?.[colorKey])
412
- color = property.canvas.color[colorKey];
396
+ const style = valueStyle(property.canvas, value);
397
+ if (style) {
398
+ if (found.shape === undefined && style.shape)
399
+ found.shape = style.shape;
400
+ if (found.color === undefined && style.color)
401
+ found.color = style.color;
402
+ if (found.fill === undefined && style.fill)
403
+ found.fill = style.fill;
404
+ if (found.fill_color === undefined && style.fill_color)
405
+ found.fill_color = style.fill_color;
406
+ if (found.size === undefined && style.size)
407
+ found.size = style.size;
408
+ }
413
409
  if (property.canvas.show)
414
410
  shown.push({ property: property.name, value });
415
411
  }
416
- return { shape: shape ?? 'box', color, shown };
412
+ return { ...withLegacyShape(found), shown };
417
413
  }
418
- // The override laid over the default: each of shape, color, fill, and size
419
- // the node sets itself, the rest from the properties. Shapes that were
420
- // really colors in earlier files (dark, accent, text) read as a box with
421
- // the fill they meant.
422
- function drawn(node, defaults) {
414
+ // Shapes that were really colors in the first canvases (dark, accent, text)
415
+ // read as a box with the fill they meant.
416
+ function withLegacyShape(style) {
423
417
  const legacy = { dark: { fill: 'solid', fill_color: '#191b1f' }, accent: { fill: 'tint' }, text: { fill: 'none' } };
424
- const chosen = node.style?.shape ?? defaults.shape;
425
- const meant = legacy[chosen];
418
+ const meant = style.shape ? legacy[style.shape] : undefined;
426
419
  return {
427
- shape: meant ? 'box' : chosen,
428
- color: node.style?.color ?? defaults.color,
429
- fill: node.style?.fill ?? meant?.fill ?? 'solid',
430
- fill_color: node.style?.fill_color ?? meant?.fill_color ?? null,
431
- size: node.style?.size ?? 'medium',
420
+ shape: meant ? 'box' : (style.shape ?? 'box'),
421
+ color: style.color ?? null,
422
+ fill: style.fill ?? meant?.fill ?? 'solid',
423
+ fill_color: style.fill_color ?? meant?.fill_color ?? null,
424
+ size: style.size ?? 'medium',
432
425
  };
433
426
  }
427
+ // The override laid over the default: each field the node sets itself, the
428
+ // rest from the properties.
429
+ function drawn(node, defaults) {
430
+ const style = node.style ?? {};
431
+ const own = withLegacyShape({
432
+ shape: style.shape ?? defaults.shape,
433
+ ...(style.color ?? defaults.color ? { color: style.color ?? defaults.color ?? '' } : {}),
434
+ fill: style.fill ?? defaults.fill,
435
+ ...(style.fill_color ?? defaults.fill_color ? { fill_color: style.fill_color ?? defaults.fill_color ?? '' } : {}),
436
+ size: style.size ?? defaults.size,
437
+ });
438
+ return { shape: own.shape, color: own.color, fill: own.fill, fill_color: own.fill_color, size: own.size };
439
+ }
434
440
  // A simple layered layout from the edges: a node's column is the longest path
435
441
  // from a source to it, its row its order within the column. Frames are drawn
436
442
  // around their members by the page. Overrides win.
@@ -520,7 +526,7 @@ export function renderPage(framework, blueprints, page) {
520
526
  style: drawn(node, style),
521
527
  font: null,
522
528
  box: node.box ?? null,
523
- defaults: { shape: style.shape, color: style.color },
529
+ defaults: { shape: style.shape, color: style.color, fill: style.fill, fill_color: style.fill_color, size: style.size },
524
530
  override: node.style ?? null,
525
531
  styled: node.styled === true,
526
532
  shown: style.shown,
@@ -543,7 +549,7 @@ export function renderPage(framework, blueprints, page) {
543
549
  style: { shape: 'text', color: node.style?.color ?? null, fill: 'none', fill_color: null, size: node.text.size ?? 'medium' },
544
550
  font: node.text.font ?? null,
545
551
  box: node.box ?? null,
546
- defaults: { shape: 'text', color: null },
552
+ defaults: { shape: 'text', color: null, fill: 'solid', fill_color: null, size: 'medium' },
547
553
  override: node.style ?? null,
548
554
  styled: node.styled === true,
549
555
  shown: [],
@@ -566,7 +572,7 @@ export function renderPage(framework, blueprints, page) {
566
572
  style: { shape: 'annotation', color: node.annotation.color ?? null, fill: 'solid', fill_color: null, size: node.style?.size ?? 'medium' },
567
573
  font: null,
568
574
  box: node.box ?? null,
569
- defaults: { shape: 'annotation', color: null },
575
+ defaults: { shape: 'annotation', color: null, fill: 'solid', fill_color: null, size: 'medium' },
570
576
  override: node.style ?? null,
571
577
  styled: node.styled === true,
572
578
  shown: [],
@@ -589,7 +595,7 @@ export function renderPage(framework, blueprints, page) {
589
595
  style: { shape: 'sticky', color: node.sticky.color ?? null, fill: 'solid', fill_color: null, size: node.style?.size ?? 'medium' },
590
596
  font: null,
591
597
  box: node.box ?? null,
592
- defaults: { shape: 'sticky', color: null },
598
+ defaults: { shape: 'sticky', color: null, fill: 'solid', fill_color: null, size: 'medium' },
593
599
  override: node.style ?? null,
594
600
  styled: node.styled === true,
595
601
  shown: [],
@@ -614,7 +620,7 @@ export function renderPage(framework, blueprints, page) {
614
620
  style: drawn(node, style),
615
621
  font: null,
616
622
  box: node.box ?? null,
617
- defaults: { shape: style.shape, color: style.color },
623
+ defaults: { shape: style.shape, color: style.color, fill: style.fill, fill_color: style.fill_color, size: style.size },
618
624
  override: node.style ?? null,
619
625
  styled: node.styled === true,
620
626
  shown: style.shown,
@@ -627,8 +633,9 @@ export function renderPage(framework, blueprints, page) {
627
633
  // `kind` and `status` read as the first shape- and color-styled properties.
628
634
  export function sketchValues(framework, sketch) {
629
635
  const values = {};
630
- const kindProperty = canvasProperties(framework).find((property) => property.canvas.shape);
631
- const statusProperty = canvasProperties(framework).find((property) => property.canvas.color);
636
+ const styles = (property) => Object.values(property.canvas.styles ?? {});
637
+ const kindProperty = canvasProperties(framework).find((property) => styles(property).some((style) => style.shape));
638
+ const statusProperty = canvasProperties(framework).find((property) => styles(property).some((style) => style.color));
632
639
  if (sketch.kind && kindProperty)
633
640
  values[kindProperty.name] = sketch.kind;
634
641
  if (sketch.status && statusProperty)
@@ -702,14 +709,17 @@ export function toJsonCanvas(framework, blueprints, page) {
702
709
  }
703
710
  // --- the software seed's default palette, written by init --------------------
704
711
  // The legend the owner already uses, as values of the seed's own properties.
712
+ const shaped = (shape) => ({ shape });
713
+ const colored = (color) => ({ color });
705
714
  export const DEFAULT_CANVAS_HINTS = {
706
715
  type: {
707
716
  show: true,
708
- shape: { page: 'pill', feature: 'box', capability: 'box', integration: 'box', background: 'box', module: 'box', decision: 'parallelogram', note: 'box' },
717
+ styles: { page: shaped('pill'), feature: shaped('box'), capability: shaped('box'), integration: shaped('box'), background: shaped('box'), module: shaped('box'), decision: shaped('parallelogram'), note: shaped('box') },
709
718
  },
710
719
  status: {
711
720
  show: true,
712
- color: { Draft: '#6b665e', Intake: '#7a4a1f', 'In Progress': '#2f6fd0', Done: '#2f6b3a', Archived: '#8a8a8a', Deprecated: '#8a8a8a', 'needs-clarification': '#2f6fd0', maybe: '#b23a2a', visionary: '#1f7a5a' },
721
+ // the browser's standard colors (PALETTE in the canvas model), so a default and a hand-picked swatch never clash
722
+ styles: { Draft: colored('#8b9099'), Intake: colored('#d3a53c'), 'In Progress': colored('#5f80cf'), Done: colored('#7fae6c'), Archived: colored('#8b9099'), Deprecated: colored('#cf7b8f'), 'needs-clarification': colored('#5f80cf'), maybe: colored('#e08a63'), visionary: colored('#4aa39f') },
713
723
  },
714
724
  };
715
725
  export function applyDefaultCanvasHints(properties) {
@@ -5,13 +5,14 @@
5
5
  // note and offer, never refuse.
6
6
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
7
7
  import path from 'node:path';
8
- import { blueprintId, blueprintTitle, propertyString, resolveVariant } from './blueprint.js';
9
- import { appliesTo, unitOf } from './framework.js';
8
+ import { blueprintId, blueprintTitle, groupDirs, propertyString, resolveVariant } from './blueprint.js';
9
+ import { appliesTo, isCollection, unitOf } from './framework.js';
10
10
  import { renderEmbeddedEntries } from './index-leaf.js';
11
11
  import { embeddedIndexOf } from './framework-structured.js';
12
12
  import { FRAME_REF, isFrameRef, listCanvases } from './canvas.js';
13
13
  import { normalizeNewlines } from './markdown.js';
14
14
  import { kebab } from './naming.js';
15
+ import { parseRegions, stripRegions } from './regions.js';
15
16
  import { loadTemplate } from './template.js';
16
17
  const WORK_TRACKING_FIELDS = new Set(['sprint', 'estimate', 'assignee', 'story_points']);
17
18
  const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'build']);
@@ -50,6 +51,19 @@ function typeProblem(property, value) {
50
51
  return null;
51
52
  }
52
53
  }
54
+ // Rule 9: a value off a property's declared options, exact, case included:
55
+ // the value (or the List's elements) not on the list, or null when every
56
+ // one is, or when the property declares none. The list rides along so the
57
+ // message can show it.
58
+ export function optionProblem(property, value) {
59
+ if (!property.options || isEmpty(value)) {
60
+ return null;
61
+ }
62
+ const declared = new Set(property.options);
63
+ const held = Array.isArray(value) ? value : [value];
64
+ const off = held.filter((item) => !isEmpty(item) && (typeof item !== 'string' || !declared.has(item))).map((item) => (typeof item === 'string' ? item : JSON.stringify(item)));
65
+ return off.length > 0 ? { off, options: property.options } : null;
66
+ }
53
67
  // Every markdown link target in a piece of text, outside code fences and
54
68
  // inline code. Scheme-bearing targets and pure anchors are not files.
55
69
  function linkTargets(text) {
@@ -110,6 +124,15 @@ class Collector {
110
124
  function rel(root, file) {
111
125
  return path.relative(root, file).split(path.sep).join('/');
112
126
  }
127
+ // Rule 20: a region's contents are never faulted, and the one thing about a
128
+ // region that is, whichever tool it names, is an opener with no closer.
129
+ function checkRegions(text, file, out) {
130
+ for (const region of parseRegions(text)) {
131
+ if (region.close === null) {
132
+ out.error('region-unclosed', file, `\`<!-- ${region.tool}:${region.region} -->\` opens a region that never closes; add \`<!-- /${region.tool}:${region.region} -->\` after its contents`);
133
+ }
134
+ }
135
+ }
113
136
  function checkFramework(framework, out, options) {
114
137
  const frameworkRel = rel(framework.root, framework.file);
115
138
  for (const problem of framework.problems) {
@@ -136,12 +159,17 @@ function checkFramework(framework, out, options) {
136
159
  if (/^versions:/m.test(readFileSync(framework.file, 'utf8'))) {
137
160
  out.warn('versions-legacy', frameworkRel, 'carries a versions key; since Eidos 5.0.0 a root\'s versions are a tool\'s own, and `eidos migrate` moves them to .eidos/plugins/eidosmd/versions.yaml');
138
161
  }
139
- if (!existsSync(path.join(framework.root, 'README.md'))) {
140
- out.warn('readme-missing', null, 'no README.md at the root; the visible front door is missing');
141
- }
142
162
  for (const doc of framework.topLevel) {
143
- if (isFileTarget(doc.path) && !linkExists(path.dirname(framework.file), doc.path)) {
163
+ if (!isFileTarget(doc.path)) {
164
+ continue;
165
+ }
166
+ if (!linkExists(path.dirname(framework.file), doc.path)) {
144
167
  out.warn('top-level-missing', frameworkRel, `## Top-Level lists ${doc.title} at ${doc.path}, which does not exist`);
168
+ continue;
169
+ }
170
+ const file = path.resolve(path.dirname(framework.file), doc.path.split('#')[0] ?? '');
171
+ if (file.startsWith(framework.root + path.sep) && /\.md$/i.test(file)) {
172
+ checkRegions(readFileSync(file, 'utf8'), rel(framework.root, file), out);
145
173
  }
146
174
  }
147
175
  // A collection's templates are named for its unit, `<unit>.<variant>.md`;
@@ -155,6 +183,9 @@ function checkFramework(framework, out, options) {
155
183
  if (template?.frontmatter) {
156
184
  out.error('template-frontmatter', rel(framework.root, template.path), 'carries a frontmatter block; a template is body only, its frontmatter is generated from the Properties table', 'Remove the block');
157
185
  }
186
+ if (template) {
187
+ checkRegions(template.text, rel(framework.root, template.path), out);
188
+ }
158
189
  if (!template) {
159
190
  out.error('template-missing', frameworkRel, `${collection.name}/${variant.name} points at .eidos/${variant.template}, which does not exist`);
160
191
  }
@@ -167,11 +198,28 @@ function checkFramework(framework, out, options) {
167
198
  out.warn('collection-folder-missing', null, `collection ${collection.name} is declared but has no folder`);
168
199
  }
169
200
  }
201
+ for (const folder of framework.folders) {
202
+ if (!isCollection(folder) && !existsSync(path.join(framework.root, folder.name))) {
203
+ out.warn('folder-missing', null, `${folder.name} is declared as ${folder.type === 'assets' ? 'an assets' : 'an other'} folder but has no folder`);
204
+ }
205
+ }
170
206
  return templates;
171
207
  }
172
- function checkLayout(framework, blueprints, out) {
173
- const declared = new Set(framework.collections.map((collection) => collection.name));
174
- const embedded = embeddedIndexOf(readFileSync(framework.file, 'utf8'));
208
+ // Rule 21: what sits at the root that the framework document does not
209
+ // declare. A folder is `.eidos/` or one under `folders`; a file is one under
210
+ // `top_level`. Hidden entries are the host's and exempt.
211
+ export function undeclaredAtRoot(framework) {
212
+ const declaredFolders = new Set(framework.folders.map((folder) => folder.name));
213
+ const declaredFiles = new Set();
214
+ for (const doc of framework.topLevel) {
215
+ if (!isFileTarget(doc.path))
216
+ continue;
217
+ const file = path.resolve(path.dirname(framework.file), doc.path.split('#')[0] ?? '');
218
+ if (path.dirname(file) === framework.root)
219
+ declaredFiles.add(path.basename(file));
220
+ }
221
+ const folders = [];
222
+ const files = [];
175
223
  let entries = [];
176
224
  try {
177
225
  entries = readdirSync(framework.root);
@@ -180,19 +228,30 @@ function checkLayout(framework, blueprints, out) {
180
228
  entries = [];
181
229
  }
182
230
  for (const entry of entries.sort()) {
183
- if (entry.startsWith('.') || entry === '.eidos' || SKIPPED_DIRS.has(entry) || declared.has(entry)) {
231
+ if (entry.startsWith('.') || SKIPPED_DIRS.has(entry))
184
232
  continue;
185
- }
186
233
  let isDir = false;
187
234
  try {
188
235
  isDir = statSync(path.join(framework.root, entry)).isDirectory();
189
236
  }
190
237
  catch {
191
- isDir = false;
192
- }
193
- if (isDir) {
194
- out.warn('folder-undeclared', entry, 'a folder at the root that no collection declares; declare it in the framework document or move it');
238
+ continue;
195
239
  }
240
+ if (isDir && !declaredFolders.has(entry))
241
+ folders.push(entry);
242
+ else if (!isDir && !declaredFiles.has(entry))
243
+ files.push(entry);
244
+ }
245
+ return { folders, files };
246
+ }
247
+ function checkLayout(framework, blueprints, out) {
248
+ const embedded = embeddedIndexOf(readFileSync(framework.file, 'utf8'));
249
+ const undeclared = undeclaredAtRoot(framework);
250
+ for (const entry of undeclared.folders) {
251
+ out.warn('folder-undeclared', entry, 'a folder at the root the framework document does not declare; declare it under folders (a collection, assets, or other) or move it');
252
+ }
253
+ for (const entry of undeclared.files) {
254
+ out.warn('file-undeclared', entry, 'a file at the root that top_level does not list; declare it (`eidos configure:doc add`) or move it');
196
255
  }
197
256
  for (const collection of framework.collections) {
198
257
  const folder = path.join(framework.root, collection.name);
@@ -201,13 +260,21 @@ function checkLayout(framework, blueprints, out) {
201
260
  }
202
261
  const own = blueprints.filter((blueprint) => blueprint.collection === collection);
203
262
  const groupsOnDisk = [...new Set(own.map((blueprint) => blueprint.group).filter((group) => group !== null))];
204
- if (collection.grouping && collection.grouping.groups.length > 0) {
205
- const declaredGroups = new Map(collection.grouping.groups.map((group) => [group.name.toLowerCase(), group.name]));
206
- for (const group of groupsOnDisk) {
207
- if (!declaredGroups.has(group.toLowerCase())) {
208
- out.warn('group-undeclared', `${collection.name}/${group}`, `not declared under ${collection.grouping.label} in the framework document`);
209
- }
263
+ // Rule 10: a collection is flat or grouped; every sub-folder is a declared
264
+ // group, and a group holds blueprints and nothing deeper.
265
+ const declaredGroups = new Map((collection.grouping?.groups ?? []).map((group) => [group.name.toLowerCase(), group.name]));
266
+ for (const group of groupDirs(folder)) {
267
+ if (!collection.grouping) {
268
+ out.warn('group-undeclared', `${collection.name}/${group}`, `a sub-folder of ${collection.name}, which declares no grouping; declare a grouping and the group, or move it`);
269
+ }
270
+ else if (!declaredGroups.has(group.toLowerCase())) {
271
+ out.warn('group-undeclared', `${collection.name}/${group}`, `not declared under ${collection.grouping.label} in the framework document`);
210
272
  }
273
+ for (const nested of groupDirs(path.join(folder, group))) {
274
+ out.warn('folder-nested', `${collection.name}/${group}/${nested}`, 'a folder inside a group; a group holds blueprints and nothing deeper');
275
+ }
276
+ }
277
+ if (collection.grouping) {
211
278
  for (const group of collection.grouping.groups) {
212
279
  if (!existsSync(path.join(folder, group.name))) {
213
280
  out.warn('group-folder-missing', `${collection.name}/${group.name}`, `declared under ${collection.grouping.label} but has no folder`);
@@ -239,7 +306,7 @@ function checkLayout(framework, blueprints, out) {
239
306
  function checkVocabulary(framework, blueprint, out) {
240
307
  if (framework.vocabulary.length === 0 || blueprint.body.trim() === '')
241
308
  return;
242
- const body = blueprint.body.toLowerCase();
309
+ const body = stripRegions(blueprint.body).toLowerCase();
243
310
  for (const entry of framework.vocabulary) {
244
311
  for (const clause of entry.not) {
245
312
  const phrase = clause.split(/[,;:(]/)[0]?.trim().toLowerCase() ?? '';
@@ -287,12 +354,14 @@ function checkBlueprint(framework, blueprint, templates, out) {
287
354
  const schemaProperties = [...framework.schema.custom, ...Object.values(framework.schema.tools).flat()];
288
355
  const applicable = schemaProperties.filter((property) => appliesTo(property, collection.name));
289
356
  const applicableNames = new Set(applicable.map((property) => property.name));
290
- for (const property of applicable) {
357
+ // Rule 4: a required property absent or blank is a gap to surface; an
358
+ // optional one absent is nothing, and present it is checked only by type.
359
+ for (const property of applicable.filter((candidate) => candidate.required)) {
291
360
  if (!(property.name in properties)) {
292
- out.warn('property-missing', file, `no \`${property.name}\` (${property.type}, applies to ${collection.name})`);
361
+ out.warn('property-missing', file, `no \`${property.name}\` (${property.type}, required, applies to ${collection.name})`);
293
362
  }
294
363
  else if (isEmpty(properties[property.name]) && property.type.toLowerCase() !== 'list') {
295
- out.warn('property-empty', file, `\`${property.name}\` has no value`);
364
+ out.warn('property-empty', file, `\`${property.name}\` is required but has no value`);
296
365
  }
297
366
  }
298
367
  for (const key of Object.keys(properties)) {
@@ -318,6 +387,11 @@ function checkBlueprint(framework, blueprint, templates, out) {
318
387
  const problem = typeProblem(property, properties[property.name]);
319
388
  if (problem) {
320
389
  out.warn('property-type', file, `\`${property.name}\` (${property.type}): ${problem}`);
390
+ continue;
391
+ }
392
+ const offList = optionProblem(property, properties[property.name]);
393
+ if (offList) {
394
+ out.warn('property-option', file, `\`${property.name}: ${offList.off.join(', ')}\` is not one of its options (${offList.options.join(', ')})`);
321
395
  }
322
396
  }
323
397
  if (collection.grouping?.property) {
@@ -337,8 +411,9 @@ function checkBlueprint(framework, blueprint, templates, out) {
337
411
  }
338
412
  }
339
413
  }
414
+ checkRegions(blueprint.body, file, out);
340
415
  const brokenInBody = new Map();
341
- for (const target of linkTargets(blueprint.body)) {
416
+ for (const target of linkTargets(stripRegions(blueprint.body))) {
342
417
  if (isFileTarget(target) && !linkExists(dir, target)) {
343
418
  brokenInBody.set(target, (brokenInBody.get(target) ?? 0) + 1);
344
419
  }