@refrakt-md/runes 0.42.0 → 0.44.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 (112) hide show
  1. package/dist/composed-rune.d.ts +9 -1
  2. package/dist/composed-rune.d.ts.map +1 -1
  3. package/dist/composed-rune.js +14 -2
  4. package/dist/composed-rune.js.map +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +27 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/examples.d.ts.map +1 -1
  9. package/dist/examples.js +2 -0
  10. package/dist/examples.js.map +1 -1
  11. package/dist/index.d.ts +5 -8
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +19 -8
  14. package/dist/index.js.map +1 -1
  15. package/dist/lib/component.d.ts +5 -1
  16. package/dist/lib/component.d.ts.map +1 -1
  17. package/dist/lib/component.js +0 -1
  18. package/dist/lib/component.js.map +1 -1
  19. package/dist/lib/composition-errors.d.ts +7 -0
  20. package/dist/lib/composition-errors.d.ts.map +1 -1
  21. package/dist/lib/composition-errors.js +7 -0
  22. package/dist/lib/composition-errors.js.map +1 -1
  23. package/dist/lib/composition-placement.d.ts +88 -0
  24. package/dist/lib/composition-placement.d.ts.map +1 -0
  25. package/dist/lib/composition-placement.js +180 -0
  26. package/dist/lib/composition-placement.js.map +1 -0
  27. package/dist/lib/composition.d.ts +60 -3
  28. package/dist/lib/composition.d.ts.map +1 -1
  29. package/dist/lib/composition.js +263 -38
  30. package/dist/lib/composition.js.map +1 -1
  31. package/dist/lib/header-roles.d.ts +32 -0
  32. package/dist/lib/header-roles.d.ts.map +1 -0
  33. package/dist/lib/header-roles.js +52 -0
  34. package/dist/lib/header-roles.js.map +1 -0
  35. package/dist/lib/index.d.ts +2 -2
  36. package/dist/lib/index.d.ts.map +1 -1
  37. package/dist/lib/index.js +161 -243
  38. package/dist/lib/index.js.map +1 -1
  39. package/dist/lib/resolver.d.ts +5 -0
  40. package/dist/lib/resolver.d.ts.map +1 -1
  41. package/dist/lib/resolver.js +25 -3
  42. package/dist/lib/resolver.js.map +1 -1
  43. package/dist/lib/slot-markers.js +9 -1
  44. package/dist/lib/slot-markers.js.map +1 -1
  45. package/dist/lib/slots.d.ts +5 -2
  46. package/dist/lib/slots.d.ts.map +1 -1
  47. package/dist/lib/slots.js +1 -1
  48. package/dist/lib/slots.js.map +1 -1
  49. package/dist/lib/universal-attribute-defs.d.ts +12 -0
  50. package/dist/lib/universal-attribute-defs.d.ts.map +1 -0
  51. package/dist/lib/universal-attribute-defs.js +225 -0
  52. package/dist/lib/universal-attribute-defs.js.map +1 -0
  53. package/dist/nodes.d.ts +11 -0
  54. package/dist/nodes.d.ts.map +1 -1
  55. package/dist/nodes.js +34 -0
  56. package/dist/nodes.js.map +1 -1
  57. package/dist/plugins.d.ts.map +1 -1
  58. package/dist/plugins.js +36 -1
  59. package/dist/plugins.js.map +1 -1
  60. package/dist/project-runes.d.ts +9 -2
  61. package/dist/project-runes.d.ts.map +1 -1
  62. package/dist/project-runes.js +5 -5
  63. package/dist/project-runes.js.map +1 -1
  64. package/dist/reference.d.ts.map +1 -1
  65. package/dist/reference.js +9 -0
  66. package/dist/reference.js.map +1 -1
  67. package/dist/tags/accordion.d.ts +1 -0
  68. package/dist/tags/accordion.d.ts.map +1 -1
  69. package/dist/tags/accordion.js +1 -1
  70. package/dist/tags/accordion.js.map +1 -1
  71. package/dist/tags/actions.d.ts +20 -0
  72. package/dist/tags/actions.d.ts.map +1 -0
  73. package/dist/tags/actions.js +72 -0
  74. package/dist/tags/actions.js.map +1 -0
  75. package/dist/tags/blog.d.ts.map +1 -1
  76. package/dist/tags/blog.js +0 -1
  77. package/dist/tags/blog.js.map +1 -1
  78. package/dist/tags/budget.d.ts.map +1 -1
  79. package/dist/tags/budget.js +0 -1
  80. package/dist/tags/budget.js.map +1 -1
  81. package/dist/tags/card.d.ts +3 -1
  82. package/dist/tags/card.d.ts.map +1 -1
  83. package/dist/tags/card.js +7 -4
  84. package/dist/tags/card.js.map +1 -1
  85. package/dist/tags/common.d.ts +9 -0
  86. package/dist/tags/common.d.ts.map +1 -1
  87. package/dist/tags/common.js +11 -0
  88. package/dist/tags/common.js.map +1 -1
  89. package/dist/tags/hint.d.ts.map +1 -1
  90. package/dist/tags/hint.js +0 -1
  91. package/dist/tags/hint.js.map +1 -1
  92. package/dist/tags/reveal.d.ts +1 -0
  93. package/dist/tags/reveal.d.ts.map +1 -1
  94. package/dist/tags/reveal.js +1 -1
  95. package/dist/tags/reveal.js.map +1 -1
  96. package/dist/tags/section.d.ts +43 -2
  97. package/dist/tags/section.d.ts.map +1 -1
  98. package/dist/tags/section.js +178 -10
  99. package/dist/tags/section.js.map +1 -1
  100. package/dist/tags/tabs.d.ts +1 -0
  101. package/dist/tags/tabs.d.ts.map +1 -1
  102. package/dist/tags/tabs.js +1 -1
  103. package/dist/tags/tabs.js.map +1 -1
  104. package/package.json +3 -3
  105. package/dist/documents/doc.d.ts +0 -4
  106. package/dist/documents/doc.d.ts.map +0 -1
  107. package/dist/documents/doc.js +0 -4
  108. package/dist/documents/doc.js.map +0 -1
  109. package/dist/documents/page.d.ts +0 -10
  110. package/dist/documents/page.d.ts.map +0 -1
  111. package/dist/documents/page.js +0 -30
  112. package/dist/documents/page.js.map +0 -1
@@ -68,6 +68,8 @@ import { SchemaSideTable } from './schema-side-table.js';
68
68
  import { declareSlotMarkers, declareSlotMarkersOnNodes } from './slot-markers.js';
69
69
  import { registersSources } from './registers.js';
70
70
  import { parse } from '../parse.js';
71
+ import { universalAttributeDefinition } from './universal-attribute-defs.js';
72
+ import { moveAttributes, resolvePlacements } from './composition-placement.js';
71
73
  const { Ast, Tag: MarkdocTag } = Markdoc;
72
74
  /** Compositions by schema — the same pattern as `schemaTables`: tooling has only
73
75
  * the schema to read from. */
@@ -212,6 +214,8 @@ function slotFields(model, fail) {
212
214
  refs,
213
215
  ...(listItems ? { listItems } : {}),
214
216
  ...(ambiguousList ? { ambiguousList } : {}),
217
+ ...(f.fills ? { fills: f.fills } : {}),
218
+ ...(f.itemModel ? { itemFields: f.itemModel.fields.map((i) => i.name) } : {}),
215
219
  });
216
220
  };
217
221
  if (s.type === 'sequence') {
@@ -259,47 +263,56 @@ function slotValues(model, resolved) {
259
263
  // Template parsing and the construction-time checks
260
264
  // ---------------------------------------------------------------------------
261
265
  /**
262
- * `{% slot name="x" each %}` is the spec's spelling, and Markdoc's tag grammar
263
- * has no bare boolean attribute. Rewrite it to `each=true` inside slot tags
264
- * only, outside quoted values, before parsing.
266
+ * `{% slot name="x" each %}` and `{% section chrome %}` are the spec's
267
+ * spellings (D26, D31), and Markdoc's tag grammar has no bare boolean
268
+ * attribute. Rewrite `each` inside slot tags, and `chrome` inside any tag, to
269
+ * `=true`, outside quoted values, before parsing.
265
270
  */
266
271
  export function normaliseEach(body) {
267
- return body.replace(/\{%-?\s*slot\b([^%]*?)(-?%\})/g, (whole, inner, close) => {
268
- let out = '';
269
- let quote = null;
270
- let i = 0;
271
- while (i < inner.length) {
272
- const ch = inner[i];
273
- if (quote) {
274
- if (ch === '\\') {
275
- out += inner.slice(i, i + 2);
276
- i += 2;
277
- continue;
278
- }
279
- if (ch === quote)
280
- quote = null;
281
- out += ch;
282
- i++;
283
- continue;
284
- }
285
- if (ch === '"' || ch === "'") {
286
- quote = ch;
287
- out += ch;
288
- i++;
272
+ return body.replace(/\{%-?\s*([a-z][\w-]*)\b([^%]*?)(-?%\})/g, (whole, tag, inner, close) => {
273
+ const bare = tag === 'slot' ? ['each', 'chrome'] : ['chrome'];
274
+ const out = bareToTrue(inner, bare);
275
+ return out === inner ? whole : whole.replace(inner + close, out + close);
276
+ });
277
+ }
278
+ /** Rewrite each bare word in `words` to `word=true`, outside quoted values. */
279
+ function bareToTrue(inner, words) {
280
+ let out = '';
281
+ let quote = null;
282
+ let i = 0;
283
+ while (i < inner.length) {
284
+ const ch = inner[i];
285
+ if (quote) {
286
+ if (ch === '\\') {
287
+ out += inner.slice(i, i + 2);
288
+ i += 2;
289
289
  continue;
290
290
  }
291
+ if (ch === quote)
292
+ quote = null;
293
+ out += ch;
294
+ i++;
295
+ continue;
296
+ }
297
+ if (ch === '"' || ch === "'") {
298
+ quote = ch;
299
+ out += ch;
300
+ i++;
301
+ continue;
302
+ }
303
+ if (i === 0 || /\s/.test(inner[i - 1])) {
291
304
  const rest = inner.slice(i);
292
- const m = /^each(?![\w=-])/.exec(rest);
293
- if (m && (i === 0 || /\s/.test(inner[i - 1]))) {
294
- out += 'each=true';
295
- i += m[0].length;
305
+ const word = words.find((w) => new RegExp(`^${w}(?![\\w=-])`).test(rest));
306
+ if (word) {
307
+ out += `${word}=true`;
308
+ i += word.length;
296
309
  continue;
297
310
  }
298
- out += ch;
299
- i++;
300
311
  }
301
- return whole.replace(inner + close, out + close);
302
- });
312
+ out += ch;
313
+ i++;
314
+ }
315
+ return out;
303
316
  }
304
317
  /** Variables a template node reads, by path, in attribute or content position. */
305
318
  function variablePaths(value, out = []) {
@@ -370,6 +383,8 @@ export function compileComposition(template, options) {
370
383
  const placedSlots = new Map();
371
384
  const slotOrder = [];
372
385
  const placements = [];
386
+ /** Every rune the template places, for the paint carrier and role owners. */
387
+ const templateRunes = [];
373
388
  const checkVariables = (n, scope) => {
374
389
  for (const path of variablePaths(n.attributes)) {
375
390
  const [root, name] = path;
@@ -438,6 +453,7 @@ export function compileComposition(template, options) {
438
453
  fail('composition-cycle', `it places \`{% ${n.tag} %}\`, which is the rune itself — a composition cycle.`);
439
454
  }
440
455
  placements.push({ rune: n.tag, ...(scope.parentRune ? { parent: scope.parentRune } : {}) });
456
+ templateRunes.push({ node: n, top: !scope.parentRune, inEach: !!scope.each });
441
457
  const children = outlineOf(n.children, { ...scope, parentRune: n.tag });
442
458
  return [{ rune: n.tag, ...(children.length > 0 ? { children } : {}) }];
443
459
  }
@@ -476,6 +492,11 @@ export function compileComposition(template, options) {
476
492
  if (!field) {
477
493
  fail('slot-unknown-field', `slot \`${name}\` names no field of the content model (fields: ${[...fields.keys()].join(', ') || 'none'}) (D26).`);
478
494
  }
495
+ if (field?.fills) {
496
+ fail('fill-placed', `slot \`${name}\` places field \`${name}\`, which fills ${Object.keys(field.fills)
497
+ .map((a) => `\`${a}\``)
498
+ .join(', ')}. A filling field is placed into the rune's values, which the template places through a meta block or \`$attrs\`; a slot as well would render it twice. Remove the slot (SPEC-182 D5).`);
499
+ }
479
500
  if (placedSlots.has(name)) {
480
501
  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).`);
481
502
  }
@@ -538,8 +559,63 @@ export function compileComposition(template, options) {
538
559
  return { metablock: name };
539
560
  }
540
561
  const outline = outlineOf(ast.children, {});
541
- // D11 — a field no slot places parses and then silently does not render.
542
- const unplaced = [...fields.keys()].filter((f) => !placedSlots.has(f));
562
+ // D14, D31, D33 — the paint carrier and every axis's target.
563
+ const plan = resolvePlacements(rune, templateRunes, (code, msg, at) => {
564
+ current = at ?? current;
565
+ return fail(code, msg);
566
+ });
567
+ current = undefined;
568
+ // D34 — each default lands where the author's attribute would. One that
569
+ // lands nowhere is a definition error, not a silent no-op.
570
+ const defaultsByNode = new Map();
571
+ const defaultInfo = {};
572
+ for (const [name, value] of Object.entries(template.defaults ?? {})) {
573
+ const target = name === 'density'
574
+ ? plan.carrier
575
+ ? { placement: 'carrier', node: plan.carrier, rune: plan.carrier.tag }
576
+ : { placement: 'carrier', dropped: `\`${rune}\` places no rune to carry it` }
577
+ : plan.axes[plan.axisOf[name]];
578
+ if (!target)
579
+ continue;
580
+ if (target.placement === 'root') {
581
+ defaultInfo[name] = { value, placement: 'root' };
582
+ continue;
583
+ }
584
+ if (!('node' in target)) {
585
+ const why = 'dropped' in target ? target.dropped : target.unavailable;
586
+ throw new CompositionError('defaults-invalid', `Rune "${rune}": \`defaults.${name}\` lands nowhere: ${why} (D34).`);
587
+ }
588
+ const into = defaultsByNode.get(target.node) ?? {};
589
+ into[name] = value;
590
+ defaultsByNode.set(target.node, into);
591
+ const literal = target.node.attributes[name];
592
+ defaultInfo[name] = {
593
+ value,
594
+ placement: target.placement,
595
+ rune: target.rune,
596
+ ...(literal !== undefined && typeof literal !== 'object' ? { literal: String(literal) } : {}),
597
+ };
598
+ }
599
+ // SPEC-182 D2 — `fills` names declared attributes and item-model fields.
600
+ for (const [name, field] of fields) {
601
+ if (!field.fills)
602
+ continue;
603
+ if (!field.itemFields) {
604
+ fail('fills-invalid', `field \`${name}\` declares \`fills\` but no \`itemModel\`: there is nothing to fill from. Give it an item model that reads the values (SPEC-182 D1).`);
605
+ }
606
+ for (const [attr, ref] of Object.entries(field.fills)) {
607
+ if (!declared.has(attr)) {
608
+ fail('fills-invalid', `field \`${name}\` fills \`${attr}\`, which the rune does not declare (declared: ${[...declared].join(', ') || 'none'}). Declare it under \`attributes\` (SPEC-182 D2).`);
609
+ }
610
+ const source = typeof ref === 'string' && ref.startsWith('$') ? ref.slice(1) : undefined;
611
+ if (!source || !field.itemFields.includes(source)) {
612
+ fail('fills-invalid', `field \`${name}\` fills \`${attr}\` from \`${ref}\`, which names no field of its item model (fields: ${field.itemFields.join(', ')}). Write it as \`$<field>\` (SPEC-182 D2).`);
613
+ }
614
+ }
615
+ }
616
+ // D11 — a field no slot places parses and then silently does not render. A
617
+ // filling field is placed into the rune's values (SPEC-182 D5).
618
+ const unplaced = [...fields.keys()].filter((f) => !placedSlots.has(f) && !fields.get(f).fills);
543
619
  if (unplaced.length > 0) {
544
620
  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).`);
545
621
  }
@@ -548,15 +624,26 @@ export function compileComposition(template, options) {
548
624
  options.schema?.fallback?.type ??
549
625
  Object.values(options.schema?.rows ?? {}).find((r) => r.type)?.type;
550
626
  const nodeSources = registersNodeSources(options.registers, fields, declared);
627
+ const axes = {};
628
+ for (const [axis, target] of Object.entries(plan.axes)) {
629
+ axes[axis] = {
630
+ placement: target.placement,
631
+ ...('rune' in target ? { rune: target.rune } : {}),
632
+ ...('dropped' in target ? { dropped: target.dropped } : {}),
633
+ ...('unavailable' in target ? { unavailable: target.unavailable } : {}),
634
+ };
635
+ }
551
636
  const info = {
552
637
  rune,
553
638
  slots: slotOrder,
554
639
  placements,
555
640
  outline,
641
+ axes,
642
+ ...(Object.keys(defaultInfo).length > 0 ? { defaults: defaultInfo } : {}),
556
643
  ...(schemaType ? { schemaType } : {}),
557
644
  };
558
645
  const attributeNames = [...declared];
559
- const transform = (resolved, attrs, config) => {
646
+ const place = (resolved, attrs) => {
560
647
  const bound = {};
561
648
  for (const name of attributeNames)
562
649
  bound[name] = attrs[name];
@@ -565,6 +652,8 @@ export function compileComposition(template, options) {
565
652
  resolved: slotValues(structural, resolved),
566
653
  attrs: bound,
567
654
  fields,
655
+ moves: moveAttributes(plan, attrs).moves,
656
+ defaults: defaultsByNode,
568
657
  };
569
658
  const placed = expand(ast.children, ctx);
570
659
  // Top-level template nodes are the composed rune's own (D10). A slot
@@ -573,6 +662,14 @@ export function compileComposition(template, options) {
573
662
  if (n.attributes[OWNER_ATTR] === undefined)
574
663
  n.attributes[OWNER_ATTR] = rune;
575
664
  }
665
+ return placed;
666
+ };
667
+ const transform = (resolved, attrs, config, node) => {
668
+ // SPEC-182 D2 — values a paragraph fills become the rune's own, before
669
+ // anything reads them. `attrs` is the caller's object, filled in place, so
670
+ // the schema table applied after this transform reads them too.
671
+ Object.assign(attrs, filledAttributes(resolved, node?.attributes ?? {}));
672
+ const placed = place(resolved, attrs);
576
673
  const children = Markdoc.transform(placed, templateConfig(config));
577
674
  // The field bag holds every attribute the author set (or that defaulted):
578
675
  // it is what `modifiers`, the schema row's attribute sources and
@@ -593,7 +690,61 @@ export function compileComposition(template, options) {
593
690
  copyRegisteredNodes(root, nodeSources);
594
691
  return root;
595
692
  };
596
- return { transform, info };
693
+ /** SPEC-182 D2, D4 — what the filling fields supply, for the attributes the
694
+ * author did not write. The author's attribute always wins. */
695
+ const filledAttributes = (resolved, written) => {
696
+ const out = {};
697
+ const values = slotValues(structural, resolved);
698
+ for (const [name, field] of fields) {
699
+ if (!field.fills)
700
+ continue;
701
+ const record = values[`${name}Data`];
702
+ if (!record)
703
+ continue;
704
+ for (const [attr, ref] of Object.entries(field.fills)) {
705
+ if (written[attr] !== undefined)
706
+ continue;
707
+ const value = record[ref.slice(1)];
708
+ if (value !== undefined && value !== '')
709
+ out[attr] = value;
710
+ }
711
+ }
712
+ return out;
713
+ };
714
+ /** SPEC-182 D4 — attributes the author wrote that a paragraph would also have
715
+ * filled: the paragraph is consumed and does not render. */
716
+ const overriddenFills = (resolved, written) => {
717
+ const out = [];
718
+ const values = slotValues(structural, resolved);
719
+ for (const [name, field] of fields) {
720
+ if (!field.fills || !values[`${name}Data`])
721
+ continue;
722
+ for (const attr of Object.keys(field.fills)) {
723
+ if (written[attr] !== undefined)
724
+ out.push({ attribute: attr, field: name });
725
+ }
726
+ }
727
+ return out;
728
+ };
729
+ const rootAttributes = (attrs) => {
730
+ const { moved } = moveAttributes(plan, attrs);
731
+ if (moved.size === 0)
732
+ return attrs;
733
+ const out = {};
734
+ for (const [name, value] of Object.entries(attrs))
735
+ if (!moved.has(name))
736
+ out[name] = value;
737
+ return out;
738
+ };
739
+ return {
740
+ transform,
741
+ info,
742
+ place,
743
+ placements: plan,
744
+ rootAttributes,
745
+ overriddenFills,
746
+ templateNodes: ast.children,
747
+ };
597
748
  }
598
749
  function asNodes(value) {
599
750
  if (Array.isArray(value))
@@ -663,6 +814,19 @@ function substituteNode(node, ctx) {
663
814
  }
664
815
  attributes[key] = resolved;
665
816
  }
817
+ if (node.type === 'tag') {
818
+ // D31 — the marker is the template's, consumed here: it reaches neither
819
+ // the placed rune's attributes nor the HTML.
820
+ delete attributes.chrome;
821
+ // D34.3 — author > template literal > definition default.
822
+ for (const [name, value] of Object.entries(ctx.defaults?.get(node) ?? {})) {
823
+ if (attributes[name] === undefined)
824
+ attributes[name] = value;
825
+ }
826
+ const moved = ctx.moves?.get(node);
827
+ if (moved)
828
+ Object.assign(attributes, moved);
829
+ }
666
830
  return cloneNode(node, attributes, expand(node.children, ctx));
667
831
  }
668
832
  /** The items a list field resolved to, each with its `$each` binding and content. */
@@ -858,6 +1022,7 @@ const DEFINITION_KEYS = new Set([
858
1022
  'metaFields',
859
1023
  'blocks',
860
1024
  'provides',
1025
+ 'defaults',
861
1026
  ]);
862
1027
  /** Keys that would give a composed rune styles of its own (D2). */
863
1028
  const STYLE_KEYS = new Set(['css', 'style', 'styles', 'stylesheet', 'block', 'class']);
@@ -970,6 +1135,7 @@ export function parseCompositionDefinition(rune, source) {
970
1135
  (!Array.isArray(fm.provides) || !fm.provides.every((p) => typeof p === 'string'))) {
971
1136
  fail('invalid-value', '`provides` must be a list of names.', ['provides']);
972
1137
  }
1138
+ const defaults = parseDefaults(fm.defaults, (code, msg, at) => fail(code, msg, ['defaults', ...(at ?? [])]));
973
1139
  return {
974
1140
  rune,
975
1141
  tag,
@@ -982,12 +1148,46 @@ export function parseCompositionDefinition(rune, source) {
982
1148
  ...(fm.metaFields ? { metaFields: fm.metaFields } : {}),
983
1149
  ...(fm.blocks ? { blocks: fm.blocks } : {}),
984
1150
  ...(fm.provides ? { provides: fm.provides } : {}),
1151
+ ...(defaults ? { defaults } : {}),
985
1152
  template: match[2],
986
1153
  // `---`, the frontmatter's lines, `---`: the body starts on the next.
987
1154
  templateLine: frontmatter.split(/\r?\n/).length + 3,
988
1155
  frontmatter,
989
1156
  };
990
1157
  }
1158
+ /** D34.6 — `density` has no author-facing attribute, so its scale is stated here. */
1159
+ const DENSITY_VALUES = ['full', 'compact', 'minimal'];
1160
+ /**
1161
+ * D34 — `defaults`: universal axes only, each with a value the axis accepts.
1162
+ * A rune's own attribute takes its default where it is declared.
1163
+ */
1164
+ function parseDefaults(raw, fail) {
1165
+ if (raw === undefined)
1166
+ return undefined;
1167
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
1168
+ fail('defaults-invalid', '`defaults` must be a mapping of universal attributes to values.');
1169
+ }
1170
+ const out = {};
1171
+ for (const [key, value] of Object.entries(raw)) {
1172
+ if (value === null || typeof value === 'object') {
1173
+ fail('defaults-invalid', `\`defaults.${key}\` must be a single value.`, [key]);
1174
+ }
1175
+ const text = String(value);
1176
+ const accepted = key === 'density'
1177
+ ? DENSITY_VALUES
1178
+ : universalAttributeDefinition(key) === undefined
1179
+ ? undefined
1180
+ : universalAttributeDefinition(key).matches;
1181
+ if (key !== 'density' && universalAttributeDefinition(key) === undefined) {
1182
+ fail('defaults-invalid', `\`defaults\` takes universal attributes only, and \`${key}\` is not one. A rune's own attribute takes its default where it is declared, under \`attributes\` (D34).`, [key]);
1183
+ }
1184
+ if (Array.isArray(accepted) && !accepted.includes(text)) {
1185
+ fail('defaults-invalid', `\`defaults.${key}\` is \`${text}\`, which \`${key}\` does not accept (${accepted.join(', ')}).`, [key]);
1186
+ }
1187
+ out[key] = text;
1188
+ }
1189
+ return out;
1190
+ }
991
1191
  function parseAttributes(raw, sectionFail) {
992
1192
  const fail = (code, msg) => sectionFail(code, msg);
993
1193
  if (raw === undefined)
@@ -1038,6 +1238,7 @@ const FIELD_KEYS = new Set([
1038
1238
  'itemModel',
1039
1239
  'emitTag',
1040
1240
  'emitAttributes',
1241
+ 'fills',
1041
1242
  ]);
1042
1243
  /** A field list as YAML writes it: a mapping keyed by name, or a list with `name`. */
1043
1244
  function parseFields(raw, where, fail) {
@@ -1302,6 +1503,13 @@ function parseDelimitedModel(c, fail) {
1302
1503
  * resolves a field against the rune's modifier values, exactly as it does for a
1303
1504
  * tree-owning rune — which must declare the same attributes as modifiers for
1304
1505
  * its own blocks to fill.
1506
+ *
1507
+ * An enum attribute the template only forwards to a placed rune
1508
+ * (`{% section media-position=$attrs["media-position"] %}`) is that rune's
1509
+ * input, not a modifier of the composition: as a `data-*` on the empty root it
1510
+ * would match the dimension rules meant for the rune it is forwarded to — a
1511
+ * `data-media-position="cover"` root is laid out as a cover well. It stays in
1512
+ * the field bag, which the schema row and registration read.
1305
1513
  */
1306
1514
  export function composedRuneConfig(def) {
1307
1515
  const read = new Set();
@@ -1313,22 +1521,39 @@ export function composedRuneConfig(def) {
1313
1521
  read.add(ref);
1314
1522
  }
1315
1523
  }
1524
+ const forwarded = forwardedAttributes(def.template);
1316
1525
  const modifiers = {};
1317
1526
  for (const [name, attr] of Object.entries(def.attributes)) {
1318
- if (!attr.matches && !read.has(name))
1527
+ if (!read.has(name) && (!attr.matches || forwarded.has(name)))
1319
1528
  continue;
1320
1529
  modifiers[name] = {
1321
1530
  source: 'meta',
1322
1531
  ...(attr.default !== undefined ? { default: String(attr.default) } : {}),
1323
1532
  };
1324
1533
  }
1534
+ // D34 — a root-placed axis's default is the generated config's, since a
1535
+ // template never writes on the root.
1536
+ const d = def.defaults ?? {};
1325
1537
  return {
1538
+ composed: true,
1539
+ ...(d.width ? { defaultWidth: d.width } : {}),
1540
+ ...(d.spacing ? { defaultSpacing: d.spacing } : {}),
1541
+ ...(d.inset ? { defaultInset: d.inset } : {}),
1326
1542
  ...(Object.keys(modifiers).length > 0 ? { modifiers } : {}),
1327
1543
  ...(def.metaFields ? { metaFields: def.metaFields } : {}),
1328
1544
  ...(def.blocks ? { blocks: def.blocks } : {}),
1329
1545
  ...(def.provides ? { provides: def.provides } : {}),
1330
1546
  };
1331
1547
  }
1548
+ /** The attributes a template passes to a placed rune as an attribute value:
1549
+ * `name=$attrs.x` or `name=$attrs["x"]`. */
1550
+ function forwardedAttributes(template) {
1551
+ const out = new Set();
1552
+ for (const m of template.matchAll(/[\w-]+\s*=\s*\$attrs(?:\.([\w-]+)|\[\s*["']([^"']+)["']\s*\])/g)) {
1553
+ out.add(m[1] ?? m[2]);
1554
+ }
1555
+ return out;
1556
+ }
1332
1557
  /** The schema types a rune can emit at its root — its table's, and its `seoType`. */
1333
1558
  function emittedTypes(entry, table) {
1334
1559
  return [