@refrakt-md/runes 0.40.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/composed-rune.d.ts +2 -0
  2. package/dist/composed-rune.d.ts.map +1 -1
  3. package/dist/composed-rune.js +37 -8
  4. package/dist/composed-rune.js.map +1 -1
  5. package/dist/index.d.ts +8 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +5 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/composition-errors.d.ts +80 -0
  10. package/dist/lib/composition-errors.d.ts.map +1 -0
  11. package/dist/lib/composition-errors.js +82 -0
  12. package/dist/lib/composition-errors.js.map +1 -0
  13. package/dist/lib/composition.d.ts +15 -0
  14. package/dist/lib/composition.d.ts.map +1 -1
  15. package/dist/lib/composition.js +143 -81
  16. package/dist/lib/composition.js.map +1 -1
  17. package/dist/lib/index.d.ts +13 -1
  18. package/dist/lib/index.d.ts.map +1 -1
  19. package/dist/lib/index.js +73 -4
  20. package/dist/lib/index.js.map +1 -1
  21. package/dist/lib/resolver.d.ts +20 -4
  22. package/dist/lib/resolver.d.ts.map +1 -1
  23. package/dist/lib/resolver.js +62 -15
  24. package/dist/lib/resolver.js.map +1 -1
  25. package/dist/lib/schema-table.d.ts +3 -1
  26. package/dist/lib/schema-table.d.ts.map +1 -1
  27. package/dist/lib/schema-table.js +52 -1
  28. package/dist/lib/schema-table.js.map +1 -1
  29. package/dist/plugins.d.ts +28 -9
  30. package/dist/plugins.d.ts.map +1 -1
  31. package/dist/plugins.js +211 -82
  32. package/dist/plugins.js.map +1 -1
  33. package/dist/project-runes.d.ts +53 -0
  34. package/dist/project-runes.d.ts.map +1 -0
  35. package/dist/project-runes.js +85 -0
  36. package/dist/project-runes.js.map +1 -0
  37. package/dist/rune-dir.d.ts +52 -0
  38. package/dist/rune-dir.d.ts.map +1 -0
  39. package/dist/rune-dir.js +90 -0
  40. package/dist/rune-dir.js.map +1 -0
  41. package/dist/tags/sandbox.d.ts.map +1 -1
  42. package/dist/tags/sandbox.js +2 -0
  43. package/dist/tags/sandbox.js.map +1 -1
  44. package/package.json +3 -3
@@ -59,6 +59,7 @@
59
59
  */
60
60
  import Markdoc from '@markdoc/markdoc';
61
61
  import yaml from 'yaml';
62
+ import { CompositionError } from './composition-errors.js';
62
63
  import { METABLOCK_ATTR, METABLOCK_OWNER_ATTR } from '@refrakt-md/transform';
63
64
  import { createComponentRenderable } from './component.js';
64
65
  import { resolveEmitReference, sectionLookup } from './resolver.js';
@@ -79,6 +80,32 @@ export function compositionFor(schema) {
79
80
  /** Tags a template may not contain: they resolve at preprocess, a stage a
80
81
  * template comes after (D4). `include` is the route for a repeated block that
81
82
  * needs one. */
83
+ export { CompositionError, COMPOSITION_ERRORS } from './composition-errors.js';
84
+ /**
85
+ * The 0-based line, within a YAML frontmatter block, of the key at `path` —
86
+ * each key searched for after the line its parent was found on. A best-effort
87
+ * locator for error reporting, not a YAML parser: `undefined` when not found.
88
+ */
89
+ export function frontmatterKeyLine(frontmatter, path) {
90
+ const lines = frontmatter.split(/\r?\n/);
91
+ let from = 0;
92
+ let hit;
93
+ for (const key of path) {
94
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
95
+ const re = new RegExp(`^\\s*(?:-\\s+)?["']?${escaped}["']?\\s*:`);
96
+ hit = undefined;
97
+ for (let i = from; i < lines.length; i++) {
98
+ if (re.test(lines[i])) {
99
+ hit = i;
100
+ break;
101
+ }
102
+ }
103
+ if (hit === undefined)
104
+ return undefined;
105
+ from = hit + 1;
106
+ }
107
+ return hit;
108
+ }
82
109
  const PREPROCESSOR_TAGS = new Set(['data', 'snippet', 'include']);
83
110
  /** Markdoc's own tags, which are template vocabulary rather than placed runes. */
84
111
  const MARKDOC_TAGS = new Set(['if', 'else']);
@@ -124,7 +151,7 @@ function slotFields(model, fail) {
124
151
  const fields = new Map();
125
152
  const m = typeof model === 'function' ? model({}) : model;
126
153
  if ('when' in m) {
127
- fail('a conditional (`when`) content model is not supported by a composition — the template, not the model, is where an assembly varies.');
154
+ fail('content-model-type', 'a conditional (`when`) content model is not supported by a composition — the template, not the model, is where an assembly varies.');
128
155
  }
129
156
  const s = m;
130
157
  const addField = (f) => {
@@ -148,7 +175,7 @@ function slotFields(model, fail) {
148
175
  });
149
176
  }
150
177
  else {
151
- fail(`a \`${s.type}\` content model is not supported by a composition. Declare a \`sequence\` or \`sections\` model, whose fields are the slots.`);
178
+ fail('content-model-type', `a \`${s.type}\` content model is not supported by a composition. Declare a \`sequence\` or \`sections\` model, whose fields are the slots.`);
152
179
  }
153
180
  return fields;
154
181
  }
@@ -245,13 +272,19 @@ function soleSlot(n) {
245
272
  */
246
273
  export function compileComposition(template, options) {
247
274
  const { rune, tag, body } = template;
248
- const fail = (msg) => {
249
- throw new Error(`Rune "${rune}": invalid composition template — ${msg}`);
275
+ /** The template node being checked, so a failure can say where it is. */
276
+ let current;
277
+ const fail = (code, msg) => {
278
+ const err = new CompositionError(code, `Rune "${rune}": invalid composition template — ${msg}`);
279
+ const line = current?.lines?.[0];
280
+ if (typeof line === 'number')
281
+ err.bodyLine = line;
282
+ throw err;
250
283
  };
251
284
  if (typeof body !== 'string')
252
- fail('the template body must be a string.');
285
+ fail('invalid-value', 'the template body must be a string.');
253
286
  if (typeof tag !== 'string' || !/^[a-z][a-z0-9]*$/.test(tag)) {
254
- fail('`tag` must be an element name.');
287
+ fail('invalid-value', '`tag` must be an element name.');
255
288
  }
256
289
  const ast = Markdoc.parse(normaliseEach(body));
257
290
  const fields = slotFields(options.contentModel, fail);
@@ -265,20 +298,20 @@ export function compileComposition(template, options) {
265
298
  const [root, name] = path;
266
299
  if (root === 'attrs') {
267
300
  if (name === undefined)
268
- fail('`$attrs` must name an attribute, as `$attrs.<name>`.');
301
+ fail('attrs-undeclared', '`$attrs` must name an attribute, as `$attrs.<name>`.');
269
302
  if (!declared.has(name)) {
270
- fail(`\`$attrs.${name}\` names no declared attribute (declared: ${[...declared].join(', ') || 'none'}).`);
303
+ fail('attrs-undeclared', `\`$attrs.${name}\` names no declared attribute (declared: ${[...declared].join(', ') || 'none'}).`);
271
304
  }
272
305
  }
273
306
  else if (root === 'each') {
274
307
  if (!scope.each) {
275
- fail(`\`$each${name ? `.${name}` : ''}\` is used outside an \`each\` slot; it exists only inside one (D26).`);
308
+ fail('each-outside-slot', `\`$each${name ? `.${name}` : ''}\` is used outside an \`each\` slot; it exists only inside one (D26).`);
276
309
  }
277
310
  if (name === undefined)
278
- fail('`$each` must name a field, as `$each.<name>`.');
311
+ fail('each-field-not-emitted', '`$each` must name a field, as `$each.<name>`.');
279
312
  if (!scope.each.fields.includes(name)) {
280
313
  const exposed = scope.each.fields;
281
- fail(`\`$each.${name}\` names no field of slot \`${scope.each.slot}\`. \`$each\` exposes exactly the content model's \`emitAttributes\` (${exposed.join(', ') || 'none declared'}) — declare it there (D26).`);
314
+ fail('each-field-not-emitted', `\`$each.${name}\` names no field of slot \`${scope.each.slot}\`. \`$each\` exposes exactly the content model's \`emitAttributes\` (${exposed.join(', ') || 'none declared'}) — declare it there (D26).`);
282
315
  }
283
316
  }
284
317
  }
@@ -294,9 +327,22 @@ export function compileComposition(template, options) {
294
327
  };
295
328
  /** Check one template node and return its outline entries. */
296
329
  const check = (n, scope) => {
297
- if (n.type === 'error') {
298
- const errors = n.errors ?? [];
299
- fail(`Markdoc could not parse it: ${errors.map((e) => e.message).join('; ') || 'syntax error'}.`);
330
+ const parent = current;
331
+ current = n.lines?.length ? n : current;
332
+ try {
333
+ return checkNode(n, scope);
334
+ }
335
+ finally {
336
+ current = parent;
337
+ }
338
+ };
339
+ const checkNode = (n, scope) => {
340
+ // A syntax error is an `error` node; an unclosed or unopened tag parses as
341
+ // a tag carrying a critical error, and would otherwise build silently.
342
+ const errors = n
343
+ .errors;
344
+ if (n.type === 'error' || errors?.some((e) => e.level === 'critical')) {
345
+ fail('template-parse', `Markdoc could not parse it: ${(errors ?? []).map((e) => e.message).join('; ') || 'syntax error'}.`);
300
346
  }
301
347
  checkVariables(n, scope);
302
348
  if (isSlotTag(n))
@@ -305,14 +351,14 @@ export function compileComposition(template, options) {
305
351
  return [checkMetablock(n, scope)];
306
352
  if (n.type === 'tag' && n.tag) {
307
353
  if (PREPROCESSOR_TAGS.has(n.tag)) {
308
- fail(`it contains \`{% ${n.tag} %}\`, which resolves at preprocess, before a template renders (D4). A repeated block that needs \`${n.tag}\` is an \`{% include %}\`, not a composition.`);
354
+ fail('preprocessor-tag', `it contains \`{% ${n.tag} %}\`, which resolves at preprocess, before a template renders (D4). A repeated block that needs \`${n.tag}\` is an \`{% include %}\`, not a composition.`);
309
355
  }
310
356
  if (MARKDOC_TAGS.has(n.tag)) {
311
357
  const children = outlineOf(n.children, scope);
312
358
  return n.tag === 'if' ? [{ conditional: true, children }] : children;
313
359
  }
314
360
  if (selfNames.has(n.tag)) {
315
- fail(`it places \`{% ${n.tag} %}\`, which is the rune itself — a composition cycle.`);
361
+ fail('composition-cycle', `it places \`{% ${n.tag} %}\`, which is the rune itself — a composition cycle.`);
316
362
  }
317
363
  placements.push({ rune: n.tag, ...(scope.parentRune ? { parent: scope.parentRune } : {}) });
318
364
  const children = outlineOf(n.children, { ...scope, parentRune: n.tag });
@@ -329,38 +375,38 @@ export function compileComposition(template, options) {
329
375
  const each = n.attributes.each;
330
376
  for (const key of Object.keys(n.attributes)) {
331
377
  if (key !== 'name' && key !== 'each') {
332
- fail(`a \`{% slot %}\` takes \`name\` and \`each\` only, not \`${key}\`.`);
378
+ fail('slot-invalid', `a \`{% slot %}\` takes \`name\` and \`each\` only, not \`${key}\`.`);
333
379
  }
334
380
  }
335
381
  if (each !== undefined && each !== true)
336
- fail('`each` on a slot takes no value.');
382
+ fail('slot-invalid', '`each` on a slot takes no value.');
337
383
  if (name === undefined) {
338
384
  if (!scope.each) {
339
- fail('a bare `{% slot /%}` means "this item\'s content" and is only valid inside an `each` slot. Name the field it places.');
385
+ fail('bare-slot-misplaced', 'a bare `{% slot /%}` means "this item\'s content" and is only valid inside an `each` slot. Name the field it places.');
340
386
  }
341
387
  if (each)
342
- fail('a bare `{% slot /%}` cannot itself iterate.');
388
+ fail('slot-invalid', 'a bare `{% slot /%}` cannot itself iterate.');
343
389
  if (n.children.length > 0)
344
- fail('a bare `{% slot /%}` takes no fallback content.');
390
+ fail('slot-invalid', 'a bare `{% slot /%}` takes no fallback content.');
345
391
  return { slot: '' };
346
392
  }
347
393
  if (typeof name !== 'string' || name === '')
348
- fail('a slot `name` must be a non-empty string.');
394
+ fail('slot-invalid', 'a slot `name` must be a non-empty string.');
349
395
  if (scope.each) {
350
- fail(`slot \`${name}\` is placed inside the \`each\` slot \`${scope.each.slot}\`, which would place it once per item. Place it outside.`);
396
+ fail('slot-inside-each', `slot \`${name}\` is placed inside the \`each\` slot \`${scope.each.slot}\`, which would place it once per item. Place it outside.`);
351
397
  }
352
398
  const field = fields.get(name);
353
399
  if (!field) {
354
- fail(`slot \`${name}\` names no field of the content model (fields: ${[...fields.keys()].join(', ') || 'none'}) (D26).`);
400
+ fail('slot-unknown-field', `slot \`${name}\` names no field of the content model (fields: ${[...fields.keys()].join(', ') || 'none'}) (D26).`);
355
401
  }
356
402
  if (placedSlots.has(name)) {
357
- fail(`slot \`${name}\` is placed twice. A slot is placed exactly once; two positions are two declared fields, or a theme's concern (D26).`);
403
+ fail('slot-placed-twice', `slot \`${name}\` is placed twice. A slot is placed exactly once; two positions are two declared fields, or a theme's concern (D26).`);
358
404
  }
359
405
  placedSlots.set(name, n);
360
406
  slotOrder.push(name);
361
407
  if (each) {
362
408
  if (!field.list) {
363
- fail(`slot \`${name}\` iterates with \`each\`, but field \`${name}\` holds a single value, not a list (D26).`);
409
+ fail('each-on-single-field', `slot \`${name}\` iterates with \`each\`, but field \`${name}\` holds a single value, not a list (D26).`);
364
410
  }
365
411
  const children = outlineOf(n.children, {
366
412
  ...scope,
@@ -368,7 +414,7 @@ export function compileComposition(template, options) {
368
414
  });
369
415
  const sawBare = JSON.stringify(children).includes('"slot":""');
370
416
  if (!sawBare) {
371
- fail(`the \`each\` slot \`${name}\` never places its item — a bare \`{% slot /%}\` inside it is the item's content, and without one that content is dropped (D11).`);
417
+ fail('each-item-unplaced', `the \`each\` slot \`${name}\` never places its item — a bare \`{% slot /%}\` inside it is the item's content, and without one that content is dropped (D11).`);
372
418
  }
373
419
  return { slot: name, each: true, ...(children.length > 0 ? { children } : {}) };
374
420
  }
@@ -386,27 +432,27 @@ export function compileComposition(template, options) {
386
432
  function checkMetablock(n, scope) {
387
433
  for (const key of Object.keys(n.attributes)) {
388
434
  if (key !== 'name') {
389
- fail(`a \`{% metablock %}\` takes \`name\` only, not \`${key}\`.`);
435
+ fail('metablock-invalid', `a \`{% metablock %}\` takes \`name\` only, not \`${key}\`.`);
390
436
  }
391
437
  }
392
438
  const name = n.attributes.name;
393
439
  if (typeof name !== 'string' || name === '') {
394
- fail('a `{% metablock %}` needs a `name`: the declared block it places.');
440
+ fail('metablock-invalid', 'a `{% metablock %}` needs a `name`: the declared block it places.');
395
441
  }
396
442
  if (n.children.length > 0) {
397
- fail(`\`{% metablock name="${name}" %}\` takes no content; write it self-closing.`);
443
+ fail('metablock-invalid', `\`{% metablock name="${name}" %}\` takes no content; write it self-closing.`);
398
444
  }
399
445
  if (n.inline) {
400
- fail(`\`{% metablock name="${name}" /%}\` sits inside a line of text. A meta block is a block; place it on its own line.`);
446
+ fail('metablock-invalid', `\`{% metablock name="${name}" /%}\` sits inside a line of text. A meta block is a block; place it on its own line.`);
401
447
  }
402
448
  if (scope.each) {
403
- fail(`\`{% metablock name="${name}" /%}\` is placed inside the \`each\` slot \`${scope.each.slot}\`, which would place it once per item. Place it outside (D7).`);
449
+ fail('metablock-inside-each', `\`{% metablock name="${name}" /%}\` is placed inside the \`each\` slot \`${scope.each.slot}\`, which would place it once per item. Place it outside (D7).`);
404
450
  }
405
451
  if (!declaredBlocks.has(name)) {
406
- fail(`\`{% metablock name="${name}" /%}\` names no declared block (declared: ${[...declaredBlocks].join(', ') || 'none'}). Declare it under \`blocks\` (D7).`);
452
+ fail('metablock-undeclared', `\`{% metablock name="${name}" /%}\` names no declared block (declared: ${[...declaredBlocks].join(', ') || 'none'}). Declare it under \`blocks\` (D7).`);
407
453
  }
408
454
  if (placedBlocks.has(name)) {
409
- fail(`meta block \`${name}\` is placed twice. A block is placed exactly once: two placements would give two nodes one \`data-name\` (D7).`);
455
+ fail('metablock-placed-twice', `meta block \`${name}\` is placed twice. A block is placed exactly once: two placements would give two nodes one \`data-name\` (D7).`);
410
456
  }
411
457
  placedBlocks.add(name);
412
458
  return { metablock: name };
@@ -415,7 +461,7 @@ export function compileComposition(template, options) {
415
461
  // D11 — a field no slot places parses and then silently does not render.
416
462
  const unplaced = [...fields.keys()].filter((f) => !placedSlots.has(f));
417
463
  if (unplaced.length > 0) {
418
- fail(`content-model field${unplaced.length > 1 ? 's' : ''} ${unplaced.map((f) => `\`${f}\``).join(', ')} ${unplaced.length > 1 ? 'are' : 'is'} placed by no slot, so authored content would be dropped. Place ${unplaced.length > 1 ? 'each' : 'it'} with \`{% slot name="…" /%}\` (D11).`);
464
+ fail('field-unplaced', `content-model field${unplaced.length > 1 ? 's' : ''} ${unplaced.map((f) => `\`${f}\``).join(', ')} ${unplaced.length > 1 ? 'are' : 'is'} placed by no slot, so authored content would be dropped. Place ${unplaced.length > 1 ? 'each' : 'it'} with \`{% slot name="…" /%}\` (D11).`);
419
465
  }
420
466
  // The type the rune claims — on the table, its fallback, or any of its rows.
421
467
  const schemaType = options.schema?.type ??
@@ -752,69 +798,77 @@ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
752
798
  * silently preferred.
753
799
  */
754
800
  export function parseCompositionDefinition(rune, source) {
755
- const fail = (msg) => {
756
- throw new Error(`Rune "${rune}": invalid composition definition — ${msg}`);
801
+ let frontmatter = '';
802
+ /** Throw, located at the frontmatter key `at` names when it is given. */
803
+ const fail = (code, msg, at) => {
804
+ const err = new CompositionError(code, `Rune "${rune}": invalid composition definition — ${msg}`);
805
+ const index = typeof at === 'number' ? at : at ? frontmatterKeyLine(frontmatter, at) : undefined;
806
+ // The frontmatter's first line is the source's second, after `---`.
807
+ err.line = index === undefined ? 1 : index + 2;
808
+ throw err;
757
809
  };
758
810
  if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(rune)) {
759
- fail('the rune name must be kebab-case.');
811
+ fail('name-not-kebab', 'the rune name must be kebab-case.');
760
812
  }
761
813
  if (typeof source !== 'string')
762
- fail('the definition must be a string.');
814
+ fail('invalid-value', 'the definition must be a string.');
763
815
  const match = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/.exec(source);
764
816
  if (!match)
765
- fail('it has no frontmatter block (`---` … `---`) declaring its input.');
817
+ fail('no-frontmatter', 'it has no frontmatter block (`---` … `---`) declaring its input.');
818
+ frontmatter = match[1];
766
819
  let front;
767
820
  try {
768
- front = yaml.parse(match[1]) ?? {};
821
+ front = yaml.parse(frontmatter) ?? {};
769
822
  }
770
823
  catch (e) {
771
- fail(`its frontmatter is not valid YAML: ${e.message}`);
824
+ const pos = e.linePos?.[0]?.line;
825
+ fail('frontmatter-yaml', `its frontmatter is not valid YAML: ${e.message}`, typeof pos === 'number' ? pos - 1 : undefined);
772
826
  }
773
827
  if (!front || typeof front !== 'object' || Array.isArray(front)) {
774
- fail('its frontmatter must be a mapping.');
828
+ fail('frontmatter-yaml', 'its frontmatter must be a mapping.');
775
829
  }
776
830
  const fm = front;
777
831
  for (const key of Object.keys(fm)) {
778
832
  if (DEFINITION_KEYS.has(key))
779
833
  continue;
780
834
  if (key === 'rune' || key === 'name') {
781
- fail(`frontmatter key \`${key}\` restates the rune's name, which is the file's name. Remove it (SPEC-153 D9).`);
835
+ fail('name-restated', `frontmatter key \`${key}\` restates the rune's name, which is the file's name. Remove it (SPEC-153 D9).`, [key]);
782
836
  }
783
837
  if (STYLE_KEYS.has(key)) {
784
- fail(`frontmatter key \`${key}\` would ship styles. A composed rune has no block and ships no CSS: its appearance is the primitives' (D2). A rune that needs its own styling is a declared rune in a plugin.`);
838
+ fail('style-key', `frontmatter key \`${key}\` would ship styles. A composed rune has no block and ships no CSS: its appearance is the primitives' (D2). A rune that needs its own styling is a declared rune in a plugin.`, [key]);
785
839
  }
786
840
  if (EMIT_KEYS.has(key)) {
787
- fail(`frontmatter key \`${key}\` declares a second emit path. A definition's template is its emit path; exactly one of a slot declaration, a template or a \`transform\` (D5).`);
841
+ fail('second-emit-path', `frontmatter key \`${key}\` declares a second emit path. A definition's template is its emit path; exactly one of a slot declaration, a template or a \`transform\` (D5).`, [key]);
788
842
  }
789
843
  if (key === 'layout') {
790
- fail("frontmatter key `layout` cannot apply: `layout` places a rune's own containers, and a composed rune's containers belong to the primitives it places (D7). Arrange them in the template.");
844
+ fail('layout-key', "frontmatter key `layout` cannot apply: `layout` places a rune's own containers, and a composed rune's containers belong to the primitives it places (D7). Arrange them in the template.", [key]);
791
845
  }
792
- fail(`unknown frontmatter key \`${key}\` (expected ${[...DEFINITION_KEYS].join(', ')}).`);
846
+ fail('unknown-key', `unknown frontmatter key \`${key}\` (expected ${[...DEFINITION_KEYS].join(', ')}).`, [key]);
793
847
  }
794
848
  const tag = (fm.tag ?? 'div');
795
849
  if (typeof tag !== 'string' || !/^[a-z][a-z0-9]*$/.test(tag))
796
- fail('`tag` must be an element name.');
850
+ fail('invalid-value', '`tag` must be an element name.', ['tag']);
797
851
  let aliases;
798
852
  if (fm.aliases !== undefined) {
799
853
  if (!Array.isArray(fm.aliases) || !fm.aliases.every((a) => typeof a === 'string')) {
800
- fail('`aliases` must be a list of names.');
854
+ fail('invalid-value', '`aliases` must be a list of names.', ['aliases']);
801
855
  }
802
856
  aliases = fm.aliases;
803
857
  }
804
858
  if (fm.description !== undefined && typeof fm.description !== 'string') {
805
- fail('`description` must be a string.');
859
+ fail('invalid-value', '`description` must be a string.', ['description']);
806
860
  }
807
- const attributes = parseAttributes(fm.attributes, fail);
808
- const contentModel = parseContentModel(fm.content, fail);
861
+ const attributes = parseAttributes(fm.attributes, (code, msg, at) => fail(code, msg, ['attributes', ...(at ?? [])]));
862
+ const contentModel = parseContentModel(fm.content, (code, msg, at) => fail(code, msg, ['content', ...(at ?? [])]));
809
863
  for (const key of ['schema', 'registers', 'metaFields', 'blocks']) {
810
864
  const v = fm[key];
811
865
  if (v !== undefined && (!v || typeof v !== 'object' || Array.isArray(v))) {
812
- fail(`\`${key}\` must be a mapping.`);
866
+ fail('invalid-value', `\`${key}\` must be a mapping.`, [key]);
813
867
  }
814
868
  }
815
869
  if (fm.provides !== undefined &&
816
870
  (!Array.isArray(fm.provides) || !fm.provides.every((p) => typeof p === 'string'))) {
817
- fail('`provides` must be a list of names.');
871
+ fail('invalid-value', '`provides` must be a list of names.', ['provides']);
818
872
  }
819
873
  return {
820
874
  rune,
@@ -829,36 +883,41 @@ export function parseCompositionDefinition(rune, source) {
829
883
  ...(fm.blocks ? { blocks: fm.blocks } : {}),
830
884
  ...(fm.provides ? { provides: fm.provides } : {}),
831
885
  template: match[2],
886
+ // `---`, the frontmatter's lines, `---`: the body starts on the next.
887
+ templateLine: frontmatter.split(/\r?\n/).length + 3,
888
+ frontmatter,
832
889
  };
833
890
  }
834
- function parseAttributes(raw, fail) {
891
+ function parseAttributes(raw, sectionFail) {
892
+ const fail = (code, msg) => sectionFail(code, msg);
835
893
  if (raw === undefined)
836
894
  return {};
837
895
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
838
- fail('`attributes` must be a mapping.');
896
+ fail('attribute-invalid', '`attributes` must be a mapping.');
839
897
  const out = {};
840
898
  for (const [name, decl] of Object.entries(raw)) {
841
899
  const where = `attribute \`${name}\``;
900
+ const at = (code, msg) => sectionFail(code, msg, [name]);
842
901
  if (!/^[a-z][a-zA-Z0-9]*(-[a-z0-9]+)*$/.test(name))
843
- fail(`${where}: not a valid attribute name.`);
902
+ at('attribute-invalid', `${where}: not a valid attribute name.`);
844
903
  if (!decl || typeof decl !== 'object' || Array.isArray(decl)) {
845
- fail(`${where} must be a mapping such as \`{ type: string }\`.`);
904
+ at('attribute-invalid', `${where} must be a mapping such as \`{ type: string }\`.`);
846
905
  }
847
906
  const d = decl;
848
907
  for (const key of Object.keys(d)) {
849
908
  if (!ATTRIBUTE_KEYS.has(key)) {
850
- fail(`${where}: unknown key \`${key}\` (expected ${[...ATTRIBUTE_KEYS].join(', ')}).`);
909
+ at('attribute-invalid', `${where}: unknown key \`${key}\` (expected ${[...ATTRIBUTE_KEYS].join(', ')}).`);
851
910
  }
852
911
  }
853
912
  const type = ATTRIBUTE_TYPES[String(d.type ?? 'string')];
854
913
  if (!type)
855
- fail(`${where}: \`type\` must be string, number or boolean.`);
914
+ at('attribute-invalid', `${where}: \`type\` must be string, number or boolean.`);
856
915
  if (d.matches !== undefined) {
857
916
  if (!Array.isArray(d.matches) || d.matches.length === 0) {
858
- fail(`${where}: \`matches\` must be a non-empty list.`);
917
+ at('attribute-invalid', `${where}: \`matches\` must be a non-empty list.`);
859
918
  }
860
919
  if (RESERVED_MODIFIERS.has(kebab(name))) {
861
- fail(`${where} declares \`matches\`, so it would render as \`data-${kebab(name)}\` — an attribute the engine writes itself. Rename it (D2a).`);
920
+ at('attribute-reserved', `${where} declares \`matches\`, so it would render as \`data-${kebab(name)}\` — an attribute the engine writes itself. Rename it (D2a).`);
862
921
  }
863
922
  }
864
923
  out[name] = {
@@ -882,39 +941,42 @@ const FIELD_KEYS = new Set([
882
941
  ]);
883
942
  /** A field list as YAML writes it: a mapping keyed by name, or a list with `name`. */
884
943
  function parseFields(raw, where, fail) {
944
+ const section = where.split('.').pop();
885
945
  if (raw === undefined)
886
946
  return [];
887
947
  const entries = Array.isArray(raw)
888
948
  ? raw.map((f) => [String(f?.name ?? ''), f])
889
949
  : raw && typeof raw === 'object'
890
950
  ? Object.entries(raw)
891
- : fail(`\`${where}\` must be a mapping of fields.`);
951
+ : fail('content-model-invalid', `\`${where}\` must be a mapping of fields.`);
892
952
  return entries.map(([name, f]) => {
953
+ const at = (code, msg) => fail(code, msg, [section, name]);
893
954
  if (!/^[a-z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$/.test(name)) {
894
- fail(`\`${where}\`: \`${name}\` is not a valid field name.`);
955
+ at('content-model-invalid', `\`${where}\`: \`${name}\` is not a valid field name.`);
895
956
  }
896
957
  if (!f || typeof f !== 'object')
897
- fail(`\`${where}.${name}\` must be a mapping.`);
958
+ at('content-model-invalid', `\`${where}.${name}\` must be a mapping.`);
898
959
  const def = { ...f };
899
960
  delete def.name;
900
961
  for (const key of Object.keys(def)) {
901
962
  if (!FIELD_KEYS.has(key)) {
902
- fail(`\`${where}.${name}\`: unknown key \`${key}\` (expected ${[...FIELD_KEYS].join(', ')}).`);
963
+ at('content-model-invalid', `\`${where}.${name}\`: unknown key \`${key}\` (expected ${[...FIELD_KEYS].join(', ')}).`);
903
964
  }
904
965
  }
905
966
  if (typeof def.match !== 'string')
906
- fail(`\`${where}.${name}\` needs a \`match\`.`);
967
+ at('content-model-invalid', `\`${where}.${name}\` needs a \`match\`.`);
907
968
  return { name, ...def };
908
969
  });
909
970
  }
910
- function parseHeadingExtract(raw, fail) {
971
+ function parseHeadingExtract(raw, sectionFail) {
972
+ const fail = (code, msg) => sectionFail(code, msg, ['headingExtract']);
911
973
  const fields = raw?.fields;
912
974
  if (!Array.isArray(fields))
913
- fail('`content.headingExtract.fields` must be a list.');
975
+ fail('content-model-invalid', '`content.headingExtract.fields` must be a list.');
914
976
  return {
915
977
  fields: fields.map((f) => {
916
978
  if (typeof f?.name !== 'string' || typeof f.pattern !== 'string') {
917
- fail('a `headingExtract` field needs a `name` and a `pattern`.');
979
+ fail('content-model-invalid', 'a `headingExtract` field needs a `name` and a `pattern`.');
918
980
  }
919
981
  let pattern = 'remainder';
920
982
  if (f.pattern !== 'remainder') {
@@ -922,7 +984,7 @@ function parseHeadingExtract(raw, fail) {
922
984
  pattern = new RegExp(f.pattern);
923
985
  }
924
986
  catch (e) {
925
- fail(`headingExtract \`${f.name}\`: \`pattern\` is not a regular expression: ${e.message}`);
987
+ fail('content-model-invalid', `headingExtract \`${f.name}\`: \`pattern\` is not a regular expression: ${e.message}`);
926
988
  }
927
989
  }
928
990
  return {
@@ -950,15 +1012,15 @@ function parseContentModel(raw, fail) {
950
1012
  if (raw === undefined)
951
1013
  return { type: 'sequence', fields: [] };
952
1014
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
953
- fail('`content` must be a mapping.');
1015
+ fail('content-model-invalid', '`content` must be a mapping.');
954
1016
  const c = raw;
955
1017
  const allowed = c.type === 'sequence' ? SEQUENCE_KEYS : c.type === 'sections' ? SECTIONS_KEYS : null;
956
1018
  if (!allowed) {
957
- fail(`\`content.type\` must be \`sequence\` or \`sections\`${c.type ? `, not \`${String(c.type)}\`` : ''}: a composition's slots are the model's fields.`);
1019
+ fail('content-model-type', `\`content.type\` must be \`sequence\` or \`sections\`${c.type ? `, not \`${String(c.type)}\`` : ''}: a composition's slots are the model's fields.`);
958
1020
  }
959
1021
  for (const key of Object.keys(c)) {
960
1022
  if (!allowed.has(key)) {
961
- fail(`\`content\`: unknown key \`${key}\` for a ${String(c.type)} model.`);
1023
+ fail('content-model-invalid', `\`content\`: unknown key \`${key}\` for a ${String(c.type)} model.`, [key]);
962
1024
  }
963
1025
  }
964
1026
  if (c.type === 'sequence') {
@@ -968,21 +1030,21 @@ function parseContentModel(raw, fail) {
968
1030
  };
969
1031
  }
970
1032
  if (c.fields !== undefined && c.preamble !== undefined) {
971
- fail('`content` declares both `fields` and `preamble`; they are one thing — use `preamble`.');
1033
+ fail('content-model-invalid', '`content` declares both `fields` and `preamble`; they are one thing — use `preamble`.');
972
1034
  }
973
1035
  const sectionModel = c.sectionModel === undefined
974
1036
  ? {
975
1037
  type: 'sequence',
976
1038
  fields: [{ name: 'body', match: 'any', optional: true, greedy: true }],
977
1039
  }
978
- : parseContentModel(c.sectionModel, fail);
1040
+ : parseContentModel(c.sectionModel, (code, msg, at) => fail(code, msg, ['sectionModel', ...(at ?? [])]));
979
1041
  if (c.emitAttributes !== undefined) {
980
1042
  const ea = c.emitAttributes;
981
1043
  if (!ea ||
982
1044
  typeof ea !== 'object' ||
983
1045
  Array.isArray(ea) ||
984
1046
  !Object.values(ea).every((v) => typeof v === 'string')) {
985
- fail('`content.emitAttributes` must map names to `$heading`, `$<field>` or a literal.');
1047
+ fail('content-model-invalid', '`content.emitAttributes` must map names to `$heading`, `$<field>` or a literal.', ['emitAttributes']);
986
1048
  }
987
1049
  }
988
1050
  return {
@@ -1081,13 +1143,13 @@ export function checkCompositions(catalog, tableOf) {
1081
1143
  ? (parentEntry?.entry.dataRune ?? parentEntry?.name ?? parent)
1082
1144
  : undefined;
1083
1145
  if (parentRune !== requiredRune) {
1084
- throw new Error(`Rune "${name}": its template places \`{% ${placedName} %}\`, which requires \`{% ${requiredRune} %}\` as its parent, ${parent ? `but sits inside \`{% ${parent} %}\`` : `but the template's nearest rune around it is "${name}" itself`}. Place \`{% ${requiredRune} %}\` and let its content model produce the \`${placed.name}\` children (D12).`);
1146
+ throw new CompositionError('parent-required', `Rune "${name}": its template places \`{% ${placedName} %}\`, which requires \`{% ${requiredRune} %}\` as its parent, ${parent ? `but sits inside \`{% ${parent} %}\`` : `but the template's nearest rune around it is "${name}" itself`}. Place \`{% ${requiredRune} %}\` and let its content model produce the \`${placed.name}\` children (D12).`);
1085
1147
  }
1086
1148
  }
1087
1149
  if (info.schemaType && placed.entry.schema) {
1088
1150
  const peer = emittedTypes(placed.entry, tableOf(placed.entry.schema)).find((t) => !SUBORDINATE_SCHEMA_TYPES.has(t));
1089
1151
  if (peer) {
1090
- throw new Error(`Rune "${name}": it declares schema type \`${info.schemaType}\` and its template places \`{% ${placedName} %}\`, which declares the peer type \`${peer}\`. Two top-level entities in one subtree — compose into a type, never from one: place primitives that emit no type, or a subordinate one such as \`figure\` (D9).`);
1152
+ throw new CompositionError('peer-schema-type', `Rune "${name}": it declares schema type \`${info.schemaType}\` and its template places \`{% ${placedName} %}\`, which declares the peer type \`${peer}\`. Two top-level entities in one subtree — compose into a type, never from one: place primitives that emit no type, or a subordinate one such as \`figure\` (D9).`);
1091
1153
  }
1092
1154
  }
1093
1155
  }
@@ -1104,7 +1166,7 @@ export function checkCompositions(catalog, tableOf) {
1104
1166
  return;
1105
1167
  if (state.get(name) === 'visiting') {
1106
1168
  const cycle = [...stack.slice(stack.indexOf(name)), name];
1107
- throw new Error(`Composition cycle: ${cycle.map((n) => `"${n}"`).join(' places ')}. A composed rune cannot place itself, directly or through another.`);
1169
+ throw new CompositionError('composition-cycle', `Composition cycle: ${cycle.map((n) => `"${n}"`).join(' places ')}. A composed rune cannot place itself, directly or through another.`);
1108
1170
  }
1109
1171
  state.set(name, 'visiting');
1110
1172
  stack.push(name);