@refrakt-md/runes 0.38.0 → 0.40.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 (106) hide show
  1. package/dist/attribute-presets.d.ts +3 -2
  2. package/dist/attribute-presets.d.ts.map +1 -1
  3. package/dist/attribute-presets.js +2 -1
  4. package/dist/attribute-presets.js.map +1 -1
  5. package/dist/collection-helpers.d.ts +21 -2
  6. package/dist/collection-helpers.d.ts.map +1 -1
  7. package/dist/collection-helpers.js +72 -15
  8. package/dist/collection-helpers.js.map +1 -1
  9. package/dist/composed-rune.d.ts +59 -0
  10. package/dist/composed-rune.d.ts.map +1 -0
  11. package/dist/composed-rune.js +129 -0
  12. package/dist/composed-rune.js.map +1 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +73 -40
  15. package/dist/config.js.map +1 -1
  16. package/dist/data-pipeline.d.ts +24 -10
  17. package/dist/data-pipeline.d.ts.map +1 -1
  18. package/dist/data-pipeline.js +34 -56
  19. package/dist/data-pipeline.js.map +1 -1
  20. package/dist/file-ref-resolve.js +9 -13
  21. package/dist/file-ref-resolve.js.map +1 -1
  22. package/dist/index.d.ts +14 -4
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +19 -5
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/composition.d.ts +152 -0
  27. package/dist/lib/composition.d.ts.map +1 -0
  28. package/dist/lib/composition.js +1120 -0
  29. package/dist/lib/composition.js.map +1 -0
  30. package/dist/lib/index.d.ts +63 -4
  31. package/dist/lib/index.d.ts.map +1 -1
  32. package/dist/lib/index.js +137 -8
  33. package/dist/lib/index.js.map +1 -1
  34. package/dist/lib/preprocess.d.ts +64 -0
  35. package/dist/lib/preprocess.d.ts.map +1 -0
  36. package/dist/lib/preprocess.js +72 -0
  37. package/dist/lib/preprocess.js.map +1 -0
  38. package/dist/lib/registers.d.ts +132 -0
  39. package/dist/lib/registers.d.ts.map +1 -0
  40. package/dist/lib/registers.js +329 -0
  41. package/dist/lib/registers.js.map +1 -0
  42. package/dist/lib/resolver.d.ts +21 -1
  43. package/dist/lib/resolver.d.ts.map +1 -1
  44. package/dist/lib/resolver.js +41 -18
  45. package/dist/lib/resolver.js.map +1 -1
  46. package/dist/lib/schema-side-table.d.ts +26 -0
  47. package/dist/lib/schema-side-table.d.ts.map +1 -0
  48. package/dist/lib/schema-side-table.js +34 -0
  49. package/dist/lib/schema-side-table.js.map +1 -0
  50. package/dist/lib/schema-table.d.ts +59 -9
  51. package/dist/lib/schema-table.d.ts.map +1 -1
  52. package/dist/lib/schema-table.js +158 -22
  53. package/dist/lib/schema-table.js.map +1 -1
  54. package/dist/lib/slot-markers.d.ts +38 -0
  55. package/dist/lib/slot-markers.d.ts.map +1 -0
  56. package/dist/lib/slot-markers.js +105 -0
  57. package/dist/lib/slot-markers.js.map +1 -0
  58. package/dist/lib/slots.d.ts +99 -0
  59. package/dist/lib/slots.d.ts.map +1 -0
  60. package/dist/lib/slots.js +448 -0
  61. package/dist/lib/slots.js.map +1 -0
  62. package/dist/plugins.d.ts +3 -0
  63. package/dist/plugins.d.ts.map +1 -1
  64. package/dist/plugins.js +53 -4
  65. package/dist/plugins.js.map +1 -1
  66. package/dist/reference.d.ts +18 -0
  67. package/dist/reference.d.ts.map +1 -1
  68. package/dist/reference.js +34 -1
  69. package/dist/reference.js.map +1 -1
  70. package/dist/registers-pipeline.d.ts +51 -0
  71. package/dist/registers-pipeline.d.ts.map +1 -0
  72. package/dist/registers-pipeline.js +224 -0
  73. package/dist/registers-pipeline.js.map +1 -0
  74. package/dist/schema-row.d.ts +5 -0
  75. package/dist/schema-row.d.ts.map +1 -1
  76. package/dist/schema-row.js +1 -0
  77. package/dist/schema-row.js.map +1 -1
  78. package/dist/tags/data.d.ts +2 -2
  79. package/dist/tags/data.d.ts.map +1 -1
  80. package/dist/tags/data.js +6 -4
  81. package/dist/tags/data.js.map +1 -1
  82. package/dist/tags/figure.d.ts +10 -4
  83. package/dist/tags/figure.d.ts.map +1 -1
  84. package/dist/tags/figure.js +65 -20
  85. package/dist/tags/figure.js.map +1 -1
  86. package/dist/tags/include.d.ts +51 -7
  87. package/dist/tags/include.d.ts.map +1 -1
  88. package/dist/tags/include.js +216 -7
  89. package/dist/tags/include.js.map +1 -1
  90. package/dist/tags/sandbox.d.ts.map +1 -1
  91. package/dist/tags/sandbox.js +13 -3
  92. package/dist/tags/sandbox.js.map +1 -1
  93. package/dist/tags/snippet.d.ts +9 -10
  94. package/dist/tags/snippet.d.ts.map +1 -1
  95. package/dist/tags/snippet.js +213 -10
  96. package/dist/tags/snippet.js.map +1 -1
  97. package/dist/universal-attributes.d.ts +1 -1
  98. package/package.json +3 -3
  99. package/dist/include-pipeline.d.ts +0 -52
  100. package/dist/include-pipeline.d.ts.map +0 -1
  101. package/dist/include-pipeline.js +0 -206
  102. package/dist/include-pipeline.js.map +0 -1
  103. package/dist/snippet-pipeline.d.ts +0 -44
  104. package/dist/snippet-pipeline.d.ts.map +0 -1
  105. package/dist/snippet-pipeline.js +0 -307
  106. package/dist/snippet-pipeline.js.map +0 -1
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * Composed runes — SPEC-145.
3
+ *
4
+ * A composed rune is defined by a Markdoc template that places its authored
5
+ * content into slots of existing primitive runes. It has no block and ships no
6
+ * CSS (D2); it does own its identity, its field bag and, when it declares them,
7
+ * its schema row and registration (D3).
8
+ *
9
+ * A definition is two halves joined by slot names:
10
+ *
11
+ * ```md
12
+ * ---
13
+ * tag: aside
14
+ * attributes:
15
+ * from: { type: string, required: true }
16
+ * content:
17
+ * type: sequence
18
+ * fields:
19
+ * body: { match: any, optional: true, greedy: true }
20
+ * ---
21
+ *
22
+ * {% hint type="note" %}
23
+ * **{% $attrs.from %}**
24
+ *
25
+ * {% slot name="body" /%}
26
+ * {% /hint %}
27
+ * ```
28
+ *
29
+ * The frontmatter is the input declaration — SPEC-143's and SPEC-144's
30
+ * vocabulary — and the body is the output template. The rune's name is not in
31
+ * the frontmatter: it is the file's name (SPEC-153 D9), passed in here.
32
+ *
33
+ * ## When the template renders (D4)
34
+ *
35
+ * Where a `transform` would have: after the content model has resolved, at the
36
+ * one call site in `createContentModelSchema`. `compileComposition` checks and
37
+ * parses the template once, at construction, and returns an ordinary transform
38
+ * closure. Per instance, the closure substitutes the resolved fields and the
39
+ * attributes into a copy of the template AST and calls `Markdoc.transform` on it,
40
+ * so every rune the template places runs its own transform unchanged.
41
+ *
42
+ * ## The vocabulary, and nothing more
43
+ *
44
+ * - `{% slot name="x" /%}` places field `x`; with a body, the body is the
45
+ * fallback for an empty field.
46
+ * - `{% slot name="xs" each %}…{% /slot %}` describes **one** item of a list
47
+ * field; inside it, a bare `{% slot /%}` is that item's content and `$each`
48
+ * is bound to its `emitAttributes` (D26).
49
+ * - `$attrs.<name>` is a declared attribute, in content or attribute position.
50
+ * - `{% if %}` / `{% else /%}` are Markdoc's own.
51
+ *
52
+ * ## Markers (D10a, D10c)
53
+ *
54
+ * Every node a slot places carries `data-owner` (the composed rune) and
55
+ * `data-slot` (the slot's name); every node the template places at its top level
56
+ * carries `data-owner`. `data-owner` is bookkeeping and is stripped once the
57
+ * composed rune's own transform finishes (`releaseOwnedNodes`); `data-slot` is
58
+ * the published name of the part. A slot adds no element.
59
+ */
60
+ import Markdoc from '@markdoc/markdoc';
61
+ import yaml from 'yaml';
62
+ import { METABLOCK_ATTR, METABLOCK_OWNER_ATTR } from '@refrakt-md/transform';
63
+ import { createComponentRenderable } from './component.js';
64
+ import { resolveEmitReference, sectionLookup } from './resolver.js';
65
+ import { OWNER_ATTR, SLOT_ATTR, findAllByName } from './schema-table.js';
66
+ import { SchemaSideTable } from './schema-side-table.js';
67
+ import { declareSlotMarkers, declareSlotMarkersOnNodes } from './slot-markers.js';
68
+ import { registersSources } from './registers.js';
69
+ const { Ast, Tag: MarkdocTag } = Markdoc;
70
+ /** Compositions by schema — the same pattern as `schemaTables`: tooling has only
71
+ * the schema to read from. */
72
+ export const schemaCompositions = new SchemaSideTable();
73
+ /** The composition a schema renders, if it is one. */
74
+ export function compositionFor(schema) {
75
+ return schema && typeof schema === 'object'
76
+ ? schemaCompositions.get(schema)
77
+ : undefined;
78
+ }
79
+ /** Tags a template may not contain: they resolve at preprocess, a stage a
80
+ * template comes after (D4). `include` is the route for a repeated block that
81
+ * needs one. */
82
+ const PREPROCESSOR_TAGS = new Set(['data', 'snippet', 'include']);
83
+ /** Markdoc's own tags, which are template vocabulary rather than placed runes. */
84
+ const MARKDOC_TAGS = new Set(['if', 'else']);
85
+ /**
86
+ * The tag a template writes to place one of the rune's declared meta blocks
87
+ * (D7). It is template vocabulary, like `{% slot %}`: it is never registered as
88
+ * a page tag, so on a page it is an undefined tag, and authored slot content
89
+ * cannot reach the marker it renders to.
90
+ */
91
+ const METABLOCK_TAG = 'metablock';
92
+ /**
93
+ * The key the expanded marker is transformed under. `:` cannot appear in a
94
+ * Markdoc tag name, so no authored `{% … %}` — in a slot or anywhere else —
95
+ * resolves to it: only a template's own `{% metablock %}` becomes a marker.
96
+ */
97
+ const METABLOCK_MARKER = 'refrakt:metablock';
98
+ /** Renders a `{% metablock %}` to the marker the engine fills (D7). It carries
99
+ * the block's name and its owner, and nothing else. */
100
+ const metablockMarkerSchema = {
101
+ attributes: { name: { type: String, required: true }, owner: { type: String, required: true } },
102
+ transform(node) {
103
+ return new MarkdocTag('div', {
104
+ [METABLOCK_ATTR]: node.attributes.name,
105
+ [METABLOCK_OWNER_ATTR]: node.attributes.owner,
106
+ });
107
+ },
108
+ };
109
+ /** Schema types a placed rune may emit under a composed type: a media object
110
+ * that becomes a property of the outer entity, not a peer of it (D9). */
111
+ export const SUBORDINATE_SCHEMA_TYPES = new Set([
112
+ 'ImageObject',
113
+ 'ImageGallery',
114
+ 'VideoObject',
115
+ 'AudioObject',
116
+ 'MediaObject',
117
+ ]);
118
+ /**
119
+ * Every field the content model resolves, by name. A composition reads
120
+ * `sequence` and `sections` models; the others are rejected by name rather
121
+ * than half-supported.
122
+ */
123
+ function slotFields(model, fail) {
124
+ const fields = new Map();
125
+ const m = typeof model === 'function' ? model({}) : model;
126
+ 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.');
128
+ }
129
+ const s = m;
130
+ const addField = (f) => {
131
+ const emitted = f.emitTag !== undefined && f.itemModel !== undefined;
132
+ const refs = emitted ? (f.emitAttributes ?? {}) : {};
133
+ fields.set(f.name, { list: !!f.greedy || emitted, each: Object.keys(refs), refs });
134
+ };
135
+ if (s.type === 'sequence') {
136
+ for (const f of s.fields ?? [])
137
+ addField(f);
138
+ }
139
+ else if (s.type === 'sections') {
140
+ for (const f of s.fields ?? [])
141
+ addField(f);
142
+ const refs = s.emitAttributes ?? {};
143
+ fields.set('sections', {
144
+ list: true,
145
+ each: Object.keys(refs),
146
+ refs,
147
+ sections: s.emitTag === undefined,
148
+ });
149
+ }
150
+ 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.`);
152
+ }
153
+ return fields;
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Template parsing and the construction-time checks
157
+ // ---------------------------------------------------------------------------
158
+ /**
159
+ * `{% slot name="x" each %}` is the spec's spelling, and Markdoc's tag grammar
160
+ * has no bare boolean attribute. Rewrite it to `each=true` inside slot tags
161
+ * only, outside quoted values, before parsing.
162
+ */
163
+ function normaliseEach(body) {
164
+ return body.replace(/\{%-?\s*slot\b([^%]*?)(-?%\})/g, (whole, inner, close) => {
165
+ let out = '';
166
+ let quote = null;
167
+ let i = 0;
168
+ while (i < inner.length) {
169
+ const ch = inner[i];
170
+ if (quote) {
171
+ if (ch === '\\') {
172
+ out += inner.slice(i, i + 2);
173
+ i += 2;
174
+ continue;
175
+ }
176
+ if (ch === quote)
177
+ quote = null;
178
+ out += ch;
179
+ i++;
180
+ continue;
181
+ }
182
+ if (ch === '"' || ch === "'") {
183
+ quote = ch;
184
+ out += ch;
185
+ i++;
186
+ continue;
187
+ }
188
+ const rest = inner.slice(i);
189
+ const m = /^each(?![\w=-])/.exec(rest);
190
+ if (m && (i === 0 || /\s/.test(inner[i - 1]))) {
191
+ out += 'each=true';
192
+ i += m[0].length;
193
+ continue;
194
+ }
195
+ out += ch;
196
+ i++;
197
+ }
198
+ return whole.replace(inner + close, out + close);
199
+ });
200
+ }
201
+ /** Variables a template node reads, by path, in attribute or content position. */
202
+ function variablePaths(value, out = []) {
203
+ if (!value || typeof value !== 'object')
204
+ return out;
205
+ const v = value;
206
+ if (v.$$mdtype === 'Variable' && Array.isArray(v.path)) {
207
+ out.push(v.path.map(String));
208
+ return out;
209
+ }
210
+ if (v.$$mdtype === 'Function') {
211
+ variablePaths(v.parameters, out);
212
+ return out;
213
+ }
214
+ if (Array.isArray(value)) {
215
+ for (const x of value)
216
+ variablePaths(x, out);
217
+ return out;
218
+ }
219
+ if (Object.getPrototypeOf(value) === Object.prototype) {
220
+ for (const x of Object.values(value))
221
+ variablePaths(x, out);
222
+ }
223
+ return out;
224
+ }
225
+ /** Is this node a `{% slot %}` — the composition's own placement tag? */
226
+ function isSlotTag(n) {
227
+ return n.type === 'tag' && n.tag === 'slot';
228
+ }
229
+ /** A paragraph whose only content is one slot tag: a block slot written on its
230
+ * own line with inline fallback content. */
231
+ function soleSlot(n) {
232
+ if (n.type !== 'paragraph' || n.children.length !== 1)
233
+ return undefined;
234
+ const inline = n.children[0];
235
+ if (inline.type !== 'inline')
236
+ return undefined;
237
+ const meaningful = inline.children.filter((c) => !(c.type === 'text' && String(c.attributes.content ?? '').trim() === ''));
238
+ return meaningful.length === 1 && isSlotTag(meaningful[0]) ? meaningful[0] : undefined;
239
+ }
240
+ /**
241
+ * Check a template against the rune's content model and attributes, and build
242
+ * the transform that renders it. Called once, at schema construction. Throws,
243
+ * naming the rune and the offending tag, slot, field or name, rather than
244
+ * building anything it cannot honour.
245
+ */
246
+ export function compileComposition(template, options) {
247
+ const { rune, tag, body } = template;
248
+ const fail = (msg) => {
249
+ throw new Error(`Rune "${rune}": invalid composition template — ${msg}`);
250
+ };
251
+ if (typeof body !== 'string')
252
+ fail('the template body must be a string.');
253
+ if (typeof tag !== 'string' || !/^[a-z][a-z0-9]*$/.test(tag)) {
254
+ fail('`tag` must be an element name.');
255
+ }
256
+ const ast = Markdoc.parse(normaliseEach(body));
257
+ const fields = slotFields(options.contentModel, fail);
258
+ const declared = new Set(options.attributes);
259
+ const selfNames = new Set([rune, ...(template.aliases ?? [])]);
260
+ const placedSlots = new Map();
261
+ const slotOrder = [];
262
+ const placements = [];
263
+ const checkVariables = (n, scope) => {
264
+ for (const path of variablePaths(n.attributes)) {
265
+ const [root, name] = path;
266
+ if (root === 'attrs') {
267
+ if (name === undefined)
268
+ fail('`$attrs` must name an attribute, as `$attrs.<name>`.');
269
+ if (!declared.has(name)) {
270
+ fail(`\`$attrs.${name}\` names no declared attribute (declared: ${[...declared].join(', ') || 'none'}).`);
271
+ }
272
+ }
273
+ else if (root === 'each') {
274
+ if (!scope.each) {
275
+ fail(`\`$each${name ? `.${name}` : ''}\` is used outside an \`each\` slot; it exists only inside one (D26).`);
276
+ }
277
+ if (name === undefined)
278
+ fail('`$each` must name a field, as `$each.<name>`.');
279
+ if (!scope.each.fields.includes(name)) {
280
+ 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).`);
282
+ }
283
+ }
284
+ }
285
+ };
286
+ const outlineOf = (nodes, scope) => {
287
+ const out = [];
288
+ for (const n of nodes) {
289
+ const entry = check(n, scope);
290
+ if (entry)
291
+ out.push(...entry);
292
+ }
293
+ return out;
294
+ };
295
+ /** Check one template node and return its outline entries. */
296
+ 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'}.`);
300
+ }
301
+ checkVariables(n, scope);
302
+ if (isSlotTag(n))
303
+ return [checkSlot(n, scope)];
304
+ if (n.type === 'tag' && n.tag === METABLOCK_TAG)
305
+ return [checkMetablock(n, scope)];
306
+ if (n.type === 'tag' && n.tag) {
307
+ 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.`);
309
+ }
310
+ if (MARKDOC_TAGS.has(n.tag)) {
311
+ const children = outlineOf(n.children, scope);
312
+ return n.tag === 'if' ? [{ conditional: true, children }] : children;
313
+ }
314
+ if (selfNames.has(n.tag)) {
315
+ fail(`it places \`{% ${n.tag} %}\`, which is the rune itself — a composition cycle.`);
316
+ }
317
+ placements.push({ rune: n.tag, ...(scope.parentRune ? { parent: scope.parentRune } : {}) });
318
+ const children = outlineOf(n.children, { ...scope, parentRune: n.tag });
319
+ return [{ rune: n.tag, ...(children.length > 0 ? { children } : {}) }];
320
+ }
321
+ const children = outlineOf(n.children, scope);
322
+ // Inline structure is the block's own business; the outline names blocks.
323
+ if (n.inline || n.type === 'inline' || n.type === 'document')
324
+ return children;
325
+ return [{ node: n.type, ...(children.length > 0 ? { children } : {}) }];
326
+ };
327
+ const checkSlot = (n, scope) => {
328
+ const name = n.attributes.name;
329
+ const each = n.attributes.each;
330
+ for (const key of Object.keys(n.attributes)) {
331
+ if (key !== 'name' && key !== 'each') {
332
+ fail(`a \`{% slot %}\` takes \`name\` and \`each\` only, not \`${key}\`.`);
333
+ }
334
+ }
335
+ if (each !== undefined && each !== true)
336
+ fail('`each` on a slot takes no value.');
337
+ if (name === undefined) {
338
+ 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.');
340
+ }
341
+ if (each)
342
+ fail('a bare `{% slot /%}` cannot itself iterate.');
343
+ if (n.children.length > 0)
344
+ fail('a bare `{% slot /%}` takes no fallback content.');
345
+ return { slot: '' };
346
+ }
347
+ if (typeof name !== 'string' || name === '')
348
+ fail('a slot `name` must be a non-empty string.');
349
+ 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.`);
351
+ }
352
+ const field = fields.get(name);
353
+ if (!field) {
354
+ fail(`slot \`${name}\` names no field of the content model (fields: ${[...fields.keys()].join(', ') || 'none'}) (D26).`);
355
+ }
356
+ 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).`);
358
+ }
359
+ placedSlots.set(name, n);
360
+ slotOrder.push(name);
361
+ if (each) {
362
+ if (!field.list) {
363
+ fail(`slot \`${name}\` iterates with \`each\`, but field \`${name}\` holds a single value, not a list (D26).`);
364
+ }
365
+ const children = outlineOf(n.children, {
366
+ ...scope,
367
+ each: { slot: name, fields: field.each },
368
+ });
369
+ const sawBare = JSON.stringify(children).includes('"slot":""');
370
+ 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).`);
372
+ }
373
+ return { slot: name, each: true, ...(children.length > 0 ? { children } : {}) };
374
+ }
375
+ const fallback = outlineOf(n.children, scope);
376
+ return {
377
+ slot: name,
378
+ ...(n.children.length > 0 ? { fallback: true } : {}),
379
+ ...(fallback.length > 0 ? { children: fallback } : {}),
380
+ };
381
+ };
382
+ const declaredBlocks = new Set(template.blocks ?? []);
383
+ const placedBlocks = new Set();
384
+ /** D7 — a meta block placement: a declared block, placed once, standing on
385
+ * its own line. Its values resolve two stages later, in the engine. */
386
+ function checkMetablock(n, scope) {
387
+ for (const key of Object.keys(n.attributes)) {
388
+ if (key !== 'name') {
389
+ fail(`a \`{% metablock %}\` takes \`name\` only, not \`${key}\`.`);
390
+ }
391
+ }
392
+ const name = n.attributes.name;
393
+ if (typeof name !== 'string' || name === '') {
394
+ fail('a `{% metablock %}` needs a `name`: the declared block it places.');
395
+ }
396
+ if (n.children.length > 0) {
397
+ fail(`\`{% metablock name="${name}" %}\` takes no content; write it self-closing.`);
398
+ }
399
+ 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.`);
401
+ }
402
+ 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).`);
404
+ }
405
+ if (!declaredBlocks.has(name)) {
406
+ fail(`\`{% metablock name="${name}" /%}\` names no declared block (declared: ${[...declaredBlocks].join(', ') || 'none'}). Declare it under \`blocks\` (D7).`);
407
+ }
408
+ 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).`);
410
+ }
411
+ placedBlocks.add(name);
412
+ return { metablock: name };
413
+ }
414
+ const outline = outlineOf(ast.children, {});
415
+ // D11 — a field no slot places parses and then silently does not render.
416
+ const unplaced = [...fields.keys()].filter((f) => !placedSlots.has(f));
417
+ 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).`);
419
+ }
420
+ // The type the rune claims — on the table, its fallback, or any of its rows.
421
+ const schemaType = options.schema?.type ??
422
+ options.schema?.fallback?.type ??
423
+ Object.values(options.schema?.rows ?? {}).find((r) => r.type)?.type;
424
+ const nodeSources = registersNodeSources(options.registers, fields, declared);
425
+ const info = {
426
+ rune,
427
+ slots: slotOrder,
428
+ placements,
429
+ outline,
430
+ ...(schemaType ? { schemaType } : {}),
431
+ };
432
+ const attributeNames = [...declared];
433
+ const transform = (resolved, attrs, config) => {
434
+ const bound = {};
435
+ for (const name of attributeNames)
436
+ bound[name] = attrs[name];
437
+ const ctx = { rune, resolved, attrs: bound, fields };
438
+ const placed = expand(ast.children, ctx);
439
+ // Top-level template nodes are the composed rune's own (D10). A slot
440
+ // placed at the top level already carries both markers.
441
+ for (const n of placed) {
442
+ if (n.attributes[OWNER_ATTR] === undefined)
443
+ n.attributes[OWNER_ATTR] = rune;
444
+ }
445
+ const children = Markdoc.transform(placed, templateConfig(config));
446
+ // The field bag holds every attribute the author set (or that defaulted):
447
+ // it is what `modifiers`, the schema row's attribute sources and
448
+ // registration read.
449
+ const properties = {};
450
+ for (const name of attributeNames) {
451
+ const value = attrs[name];
452
+ if (value === undefined || value === null)
453
+ continue;
454
+ properties[name] = new MarkdocTag('meta', { content: value });
455
+ }
456
+ const root = createComponentRenderable({
457
+ rune,
458
+ tag: tag,
459
+ properties,
460
+ children,
461
+ });
462
+ copyRegisteredNodes(root, nodeSources);
463
+ return root;
464
+ };
465
+ return { transform, info };
466
+ }
467
+ function asNodes(value) {
468
+ if (Array.isArray(value))
469
+ return value;
470
+ if (value != null)
471
+ return [value];
472
+ return [];
473
+ }
474
+ /** A shallow copy of a node: its own attributes, the same children. */
475
+ function cloneNode(node, attributes, children) {
476
+ const copy = new Ast.Node(node.type, attributes, children, node.tag);
477
+ // The constructor takes four fields; the rest of what the parser sets is
478
+ // carried by hand, as `include` does — `inline` decides how a node renders.
479
+ if (node.lines)
480
+ copy.lines = node.lines;
481
+ if (node.location)
482
+ copy.location = node.location;
483
+ copy.inline = node.inline;
484
+ if (node.annotations?.length)
485
+ copy.annotations = node.annotations;
486
+ if (node.slots && Object.keys(node.slots).length > 0)
487
+ copy.slots = node.slots;
488
+ return copy;
489
+ }
490
+ /** Mark a node a slot places. The author's node is never mutated. */
491
+ function place(node, owner, slot) {
492
+ return cloneNode(node, { ...node.attributes, [OWNER_ATTR]: owner, [SLOT_ATTR]: slot }, node.children);
493
+ }
494
+ /** Replace `$attrs.*` and `$each.*` with their values. Any other variable is
495
+ * page scope and resolves at transform, as in any content. */
496
+ function substitute(value, ctx) {
497
+ if (!value || typeof value !== 'object')
498
+ return value;
499
+ const v = value;
500
+ if (v.$$mdtype === 'Variable' && Array.isArray(v.path)) {
501
+ const [root, name] = v.path;
502
+ if (root === 'attrs')
503
+ return ctx.attrs[name];
504
+ if (root === 'each')
505
+ return ctx.item?.each[name];
506
+ return value;
507
+ }
508
+ if (v.$$mdtype === 'Function') {
509
+ const copy = Object.assign(Object.create(Object.getPrototypeOf(value)), value);
510
+ copy.parameters = substitute(v.parameters, ctx);
511
+ return copy;
512
+ }
513
+ if (Array.isArray(value))
514
+ return value.map((x) => substitute(x, ctx));
515
+ if (Object.getPrototypeOf(value) === Object.prototype) {
516
+ const out = {};
517
+ for (const [k, x] of Object.entries(value))
518
+ out[k] = substitute(x, ctx);
519
+ return out;
520
+ }
521
+ return value;
522
+ }
523
+ function substituteNode(node, ctx) {
524
+ const attributes = {};
525
+ for (const [key, value] of Object.entries(node.attributes ?? {})) {
526
+ const resolved = substitute(value, ctx);
527
+ // An unset attribute leaves the placed rune's own default in force.
528
+ if (resolved === undefined) {
529
+ if (node.type === 'text' && key === 'content')
530
+ attributes[key] = '';
531
+ continue;
532
+ }
533
+ attributes[key] = resolved;
534
+ }
535
+ return cloneNode(node, attributes, expand(node.children, ctx));
536
+ }
537
+ /** The items a list field resolved to, each with its `$each` binding and content. */
538
+ function itemsOf(field, value) {
539
+ return asNodes(value).map((item) => {
540
+ if (field.sections) {
541
+ // A section entry: heading info under `$` keys, then the body's fields.
542
+ // The model has no `emitTag`, so `$each` is resolved here — by the
543
+ // resolver's own rule for the three reference forms (D4a).
544
+ const entry = item;
545
+ const extracted = {};
546
+ for (const [k, v] of Object.entries(entry)) {
547
+ if (k.startsWith('$'))
548
+ extracted[k.slice(1)] = v;
549
+ }
550
+ const lookup = sectionLookup(String(entry.$heading ?? ''), extracted);
551
+ const each = {};
552
+ for (const [key, ref] of Object.entries(field.refs)) {
553
+ each[key] = resolveEmitReference(ref, lookup);
554
+ }
555
+ const content = Object.entries(entry)
556
+ .filter(([k]) => !k.startsWith('$'))
557
+ .flatMap(([, v]) => asNodes(v));
558
+ return { each, content };
559
+ }
560
+ // A node — an emitted child tag carries its `emitAttributes` already.
561
+ const node = item;
562
+ const each = {};
563
+ for (const key of field.each)
564
+ each[key] = node.attributes?.[key];
565
+ return { each, content: [node] };
566
+ });
567
+ }
568
+ /** The paragraphs a slot places, opened up so they sit in a line of text. */
569
+ function inlineContent(nodes) {
570
+ return nodes.flatMap((n) => {
571
+ if (n.type !== 'paragraph')
572
+ return [n];
573
+ return n.children.flatMap((c) => (c.type === 'inline' ? c.children : [c]));
574
+ });
575
+ }
576
+ function expandSlot(slot, ctx, inline, block) {
577
+ const name = slot.attributes.name;
578
+ let placed;
579
+ if (name === undefined) {
580
+ const item = ctx.item;
581
+ placed = item.content.map((n) => place(n, ctx.rune, item.slot));
582
+ }
583
+ else if (slot.attributes.each === true) {
584
+ const field = ctx.fields.get(name);
585
+ placed = [];
586
+ for (const item of itemsOf(field, ctx.resolved[name])) {
587
+ placed.push(...expand(slot.children, { ...ctx, item: { slot: name, ...item } }).filter((n) => !(n.type === 'text' && String(n.attributes.content ?? '').trim() === '')));
588
+ }
589
+ return placed;
590
+ }
591
+ else {
592
+ const nodes = asNodes(ctx.resolved[name]);
593
+ if (nodes.length === 0) {
594
+ // Fallback content is the template's, not the field's: it carries no
595
+ // slot name, so the schema row never reads it as authored content.
596
+ const fallback = expand(slot.children, ctx);
597
+ if (block && fallback.length > 0 && fallback.every((n) => n.inline)) {
598
+ return [new Ast.Node('paragraph', {}, [new Ast.Node('inline', {}, fallback)])];
599
+ }
600
+ return fallback;
601
+ }
602
+ placed = nodes.map((n) => place(n, ctx.rune, name));
603
+ }
604
+ return inline ? inlineContent(placed) : placed;
605
+ }
606
+ /** Substitute into a list of template nodes, placing slots where they stand. */
607
+ function expand(nodes, ctx) {
608
+ const out = [];
609
+ for (const n of nodes) {
610
+ if (isSlotTag(n)) {
611
+ out.push(...expandSlot(n, ctx, !!n.inline, !n.inline));
612
+ continue;
613
+ }
614
+ if (n.type === 'tag' && n.tag === METABLOCK_TAG) {
615
+ out.push(new Ast.Node('tag', { name: n.attributes.name, owner: ctx.rune }, [], METABLOCK_MARKER));
616
+ continue;
617
+ }
618
+ const sole = soleSlot(n);
619
+ if (sole) {
620
+ out.push(...expandSlot(sole, ctx, false, true));
621
+ continue;
622
+ }
623
+ out.push(substituteNode(n, ctx));
624
+ }
625
+ return out;
626
+ }
627
+ // ---------------------------------------------------------------------------
628
+ // Markers and registration
629
+ // ---------------------------------------------------------------------------
630
+ /**
631
+ * The config a template is transformed against, with the slot markers declared
632
+ * on every schema. A site assembles its config that way already
633
+ * (`assembleMarkdocSchemas`); a config that was not is assembled here, so a
634
+ * marker is never lost to where the transform was called from.
635
+ */
636
+ function markerConfig(config) {
637
+ const nodes = (config.nodes ?? {});
638
+ const tags = (config.tags ?? {});
639
+ const declared = (s) => !!s?.attributes?.[SLOT_ATTR];
640
+ const ready = declared(nodes.paragraph) && Object.values(tags).every((s) => !s || declared(s));
641
+ if (ready)
642
+ return config;
643
+ return {
644
+ ...config,
645
+ tags: declareSlotMarkers(tags),
646
+ nodes: declareSlotMarkersOnNodes(nodes),
647
+ };
648
+ }
649
+ /** The config a template renders against: the slot markers declared, and the
650
+ * meta block marker resolvable — for the template's own `{% metablock %}`
651
+ * only, since nothing authored can name its key. */
652
+ function templateConfig(config) {
653
+ const marked = markerConfig(config);
654
+ return { ...marked, tags: { ...marked.tags, [METABLOCK_MARKER]: metablockMarkerSchema } };
655
+ }
656
+ /** The `registers` sources that name a slot rather than an attribute (D10b). */
657
+ function registersNodeSources(registers, fields, attributes) {
658
+ if (!registers)
659
+ return [];
660
+ const out = new Set();
661
+ for (const { source } of registersSources(registers)) {
662
+ if (fields.has(source) && !attributes.has(source))
663
+ out.add(source);
664
+ }
665
+ return [...out];
666
+ }
667
+ function textOf(node) {
668
+ if (typeof node === 'string')
669
+ return node;
670
+ if (!MarkdocTag.isTag(node))
671
+ return '';
672
+ return (node.children ?? []).map(textOf).join('');
673
+ }
674
+ /**
675
+ * SPEC-145 D10b — copy each node-sourced `registers` value into the composed
676
+ * rune's own field bag, now, while the slot markers still let the resolvers
677
+ * reach the placed node. Phase 2 reads it from the bag, through the fallback
678
+ * `readRegistersSource` already has; `findRef` and the strip timing are
679
+ * unchanged.
680
+ */
681
+ function copyRegisteredNodes(root, sources) {
682
+ if (sources.length === 0)
683
+ return;
684
+ let bag = {};
685
+ try {
686
+ bag = JSON.parse(String(root.attributes['data-rune-fields'] ?? '{}'));
687
+ }
688
+ catch {
689
+ bag = {};
690
+ }
691
+ let changed = false;
692
+ for (const source of sources) {
693
+ if (bag[source] !== undefined)
694
+ continue;
695
+ const [node] = findAllByName(root, source);
696
+ if (!node)
697
+ continue;
698
+ bag[source] = textOf(node).trim();
699
+ changed = true;
700
+ }
701
+ if (changed)
702
+ root.attributes['data-rune-fields'] = JSON.stringify(bag);
703
+ }
704
+ const DEFINITION_KEYS = new Set([
705
+ 'tag',
706
+ 'aliases',
707
+ 'description',
708
+ 'attributes',
709
+ 'content',
710
+ 'schema',
711
+ 'registers',
712
+ 'metaFields',
713
+ 'blocks',
714
+ 'provides',
715
+ ]);
716
+ /** Keys that would give a composed rune styles of its own (D2). */
717
+ const STYLE_KEYS = new Set(['css', 'style', 'styles', 'stylesheet', 'block', 'class']);
718
+ /** Keys that would declare a second emit path beside the template (D5). */
719
+ const EMIT_KEYS = new Set(['transform', 'emits', 'slots']);
720
+ const ATTRIBUTE_KEYS = new Set(['type', 'required', 'default', 'matches', 'description']);
721
+ const ATTRIBUTE_TYPES = {
722
+ string: String,
723
+ number: Number,
724
+ boolean: Boolean,
725
+ };
726
+ /**
727
+ * Attribute names the engine already writes as `data-*` on a rune root. An
728
+ * enum attribute becomes a `data-*` modifier (D2a), so one of these would
729
+ * overwrite engine structure.
730
+ */
731
+ const RESERVED_MODIFIERS = new Set([
732
+ 'name',
733
+ 'field',
734
+ 'rune',
735
+ 'rune-fields',
736
+ 'slot',
737
+ 'owner',
738
+ 'section',
739
+ 'zone',
740
+ ]);
741
+ /** kebab-case → PascalCase: the config key the engine finds a rune under. */
742
+ export function composedTypeName(rune) {
743
+ return rune
744
+ .split('-')
745
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
746
+ .join('');
747
+ }
748
+ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
749
+ /**
750
+ * Split and parse a definition. The rune's name is the file's name (SPEC-153
751
+ * D9), passed in; a frontmatter key restating it is rejected rather than
752
+ * silently preferred.
753
+ */
754
+ export function parseCompositionDefinition(rune, source) {
755
+ const fail = (msg) => {
756
+ throw new Error(`Rune "${rune}": invalid composition definition — ${msg}`);
757
+ };
758
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(rune)) {
759
+ fail('the rune name must be kebab-case.');
760
+ }
761
+ if (typeof source !== 'string')
762
+ fail('the definition must be a string.');
763
+ const match = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/.exec(source);
764
+ if (!match)
765
+ fail('it has no frontmatter block (`---` … `---`) declaring its input.');
766
+ let front;
767
+ try {
768
+ front = yaml.parse(match[1]) ?? {};
769
+ }
770
+ catch (e) {
771
+ fail(`its frontmatter is not valid YAML: ${e.message}`);
772
+ }
773
+ if (!front || typeof front !== 'object' || Array.isArray(front)) {
774
+ fail('its frontmatter must be a mapping.');
775
+ }
776
+ const fm = front;
777
+ for (const key of Object.keys(fm)) {
778
+ if (DEFINITION_KEYS.has(key))
779
+ continue;
780
+ 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).`);
782
+ }
783
+ 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.`);
785
+ }
786
+ 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).`);
788
+ }
789
+ 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.");
791
+ }
792
+ fail(`unknown frontmatter key \`${key}\` (expected ${[...DEFINITION_KEYS].join(', ')}).`);
793
+ }
794
+ const tag = (fm.tag ?? 'div');
795
+ if (typeof tag !== 'string' || !/^[a-z][a-z0-9]*$/.test(tag))
796
+ fail('`tag` must be an element name.');
797
+ let aliases;
798
+ if (fm.aliases !== undefined) {
799
+ if (!Array.isArray(fm.aliases) || !fm.aliases.every((a) => typeof a === 'string')) {
800
+ fail('`aliases` must be a list of names.');
801
+ }
802
+ aliases = fm.aliases;
803
+ }
804
+ if (fm.description !== undefined && typeof fm.description !== 'string') {
805
+ fail('`description` must be a string.');
806
+ }
807
+ const attributes = parseAttributes(fm.attributes, fail);
808
+ const contentModel = parseContentModel(fm.content, fail);
809
+ for (const key of ['schema', 'registers', 'metaFields', 'blocks']) {
810
+ const v = fm[key];
811
+ if (v !== undefined && (!v || typeof v !== 'object' || Array.isArray(v))) {
812
+ fail(`\`${key}\` must be a mapping.`);
813
+ }
814
+ }
815
+ if (fm.provides !== undefined &&
816
+ (!Array.isArray(fm.provides) || !fm.provides.every((p) => typeof p === 'string'))) {
817
+ fail('`provides` must be a list of names.');
818
+ }
819
+ return {
820
+ rune,
821
+ tag,
822
+ ...(aliases ? { aliases } : {}),
823
+ ...(fm.description ? { description: fm.description } : {}),
824
+ attributes,
825
+ contentModel,
826
+ ...(fm.schema ? { schema: fm.schema } : {}),
827
+ ...(fm.registers ? { registers: fm.registers } : {}),
828
+ ...(fm.metaFields ? { metaFields: fm.metaFields } : {}),
829
+ ...(fm.blocks ? { blocks: fm.blocks } : {}),
830
+ ...(fm.provides ? { provides: fm.provides } : {}),
831
+ template: match[2],
832
+ };
833
+ }
834
+ function parseAttributes(raw, fail) {
835
+ if (raw === undefined)
836
+ return {};
837
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
838
+ fail('`attributes` must be a mapping.');
839
+ const out = {};
840
+ for (const [name, decl] of Object.entries(raw)) {
841
+ const where = `attribute \`${name}\``;
842
+ if (!/^[a-z][a-zA-Z0-9]*(-[a-z0-9]+)*$/.test(name))
843
+ fail(`${where}: not a valid attribute name.`);
844
+ if (!decl || typeof decl !== 'object' || Array.isArray(decl)) {
845
+ fail(`${where} must be a mapping such as \`{ type: string }\`.`);
846
+ }
847
+ const d = decl;
848
+ for (const key of Object.keys(d)) {
849
+ if (!ATTRIBUTE_KEYS.has(key)) {
850
+ fail(`${where}: unknown key \`${key}\` (expected ${[...ATTRIBUTE_KEYS].join(', ')}).`);
851
+ }
852
+ }
853
+ const type = ATTRIBUTE_TYPES[String(d.type ?? 'string')];
854
+ if (!type)
855
+ fail(`${where}: \`type\` must be string, number or boolean.`);
856
+ if (d.matches !== undefined) {
857
+ if (!Array.isArray(d.matches) || d.matches.length === 0) {
858
+ fail(`${where}: \`matches\` must be a non-empty list.`);
859
+ }
860
+ 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).`);
862
+ }
863
+ }
864
+ out[name] = {
865
+ type,
866
+ required: d.required === true,
867
+ ...(d.default !== undefined ? { default: d.default } : {}),
868
+ ...(d.matches !== undefined ? { matches: d.matches } : {}),
869
+ ...(typeof d.description === 'string' ? { description: d.description } : {}),
870
+ };
871
+ }
872
+ return out;
873
+ }
874
+ const FIELD_KEYS = new Set([
875
+ 'match',
876
+ 'optional',
877
+ 'greedy',
878
+ 'description',
879
+ 'itemModel',
880
+ 'emitTag',
881
+ 'emitAttributes',
882
+ ]);
883
+ /** A field list as YAML writes it: a mapping keyed by name, or a list with `name`. */
884
+ function parseFields(raw, where, fail) {
885
+ if (raw === undefined)
886
+ return [];
887
+ const entries = Array.isArray(raw)
888
+ ? raw.map((f) => [String(f?.name ?? ''), f])
889
+ : raw && typeof raw === 'object'
890
+ ? Object.entries(raw)
891
+ : fail(`\`${where}\` must be a mapping of fields.`);
892
+ return entries.map(([name, f]) => {
893
+ if (!/^[a-z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$/.test(name)) {
894
+ fail(`\`${where}\`: \`${name}\` is not a valid field name.`);
895
+ }
896
+ if (!f || typeof f !== 'object')
897
+ fail(`\`${where}.${name}\` must be a mapping.`);
898
+ const def = { ...f };
899
+ delete def.name;
900
+ for (const key of Object.keys(def)) {
901
+ if (!FIELD_KEYS.has(key)) {
902
+ fail(`\`${where}.${name}\`: unknown key \`${key}\` (expected ${[...FIELD_KEYS].join(', ')}).`);
903
+ }
904
+ }
905
+ if (typeof def.match !== 'string')
906
+ fail(`\`${where}.${name}\` needs a \`match\`.`);
907
+ return { name, ...def };
908
+ });
909
+ }
910
+ function parseHeadingExtract(raw, fail) {
911
+ const fields = raw?.fields;
912
+ if (!Array.isArray(fields))
913
+ fail('`content.headingExtract.fields` must be a list.');
914
+ return {
915
+ fields: fields.map((f) => {
916
+ if (typeof f?.name !== 'string' || typeof f.pattern !== 'string') {
917
+ fail('a `headingExtract` field needs a `name` and a `pattern`.');
918
+ }
919
+ let pattern = 'remainder';
920
+ if (f.pattern !== 'remainder') {
921
+ try {
922
+ pattern = new RegExp(f.pattern);
923
+ }
924
+ catch (e) {
925
+ fail(`headingExtract \`${f.name}\`: \`pattern\` is not a regular expression: ${e.message}`);
926
+ }
927
+ }
928
+ return {
929
+ name: f.name,
930
+ match: 'text',
931
+ pattern,
932
+ ...(f.optional === true ? { optional: true } : {}),
933
+ };
934
+ }),
935
+ };
936
+ }
937
+ const SEQUENCE_KEYS = new Set(['type', 'fields']);
938
+ const SECTIONS_KEYS = new Set([
939
+ 'type',
940
+ 'sectionHeading',
941
+ 'fields',
942
+ 'preamble',
943
+ 'sectionModel',
944
+ 'emitTag',
945
+ 'emitAttributes',
946
+ 'headingExtract',
947
+ 'knownSections',
948
+ ]);
949
+ function parseContentModel(raw, fail) {
950
+ if (raw === undefined)
951
+ return { type: 'sequence', fields: [] };
952
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
953
+ fail('`content` must be a mapping.');
954
+ const c = raw;
955
+ const allowed = c.type === 'sequence' ? SEQUENCE_KEYS : c.type === 'sections' ? SECTIONS_KEYS : null;
956
+ 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.`);
958
+ }
959
+ for (const key of Object.keys(c)) {
960
+ if (!allowed.has(key)) {
961
+ fail(`\`content\`: unknown key \`${key}\` for a ${String(c.type)} model.`);
962
+ }
963
+ }
964
+ if (c.type === 'sequence') {
965
+ return {
966
+ type: 'sequence',
967
+ fields: parseFields(c.fields, 'content.fields', fail),
968
+ };
969
+ }
970
+ if (c.fields !== undefined && c.preamble !== undefined) {
971
+ fail('`content` declares both `fields` and `preamble`; they are one thing — use `preamble`.');
972
+ }
973
+ const sectionModel = c.sectionModel === undefined
974
+ ? {
975
+ type: 'sequence',
976
+ fields: [{ name: 'body', match: 'any', optional: true, greedy: true }],
977
+ }
978
+ : parseContentModel(c.sectionModel, fail);
979
+ if (c.emitAttributes !== undefined) {
980
+ const ea = c.emitAttributes;
981
+ if (!ea ||
982
+ typeof ea !== 'object' ||
983
+ Array.isArray(ea) ||
984
+ !Object.values(ea).every((v) => typeof v === 'string')) {
985
+ fail('`content.emitAttributes` must map names to `$heading`, `$<field>` or a literal.');
986
+ }
987
+ }
988
+ return {
989
+ type: 'sections',
990
+ sectionHeading: typeof c.sectionHeading === 'string' ? c.sectionHeading : 'heading',
991
+ fields: parseFields(c.preamble ?? c.fields, c.preamble ? 'content.preamble' : 'content.fields', fail),
992
+ sectionModel,
993
+ ...(typeof c.emitTag === 'string' ? { emitTag: c.emitTag } : {}),
994
+ ...(c.emitAttributes ? { emitAttributes: c.emitAttributes } : {}),
995
+ ...(c.headingExtract ? { headingExtract: parseHeadingExtract(c.headingExtract, fail) } : {}),
996
+ ...(c.knownSections
997
+ ? { knownSections: c.knownSections }
998
+ : {}),
999
+ };
1000
+ }
1001
+ /**
1002
+ * The generated, block-less engine config of a composed rune (D2a). Not
1003
+ * hand-written beside the definition: modifiers come from its enum attributes
1004
+ * and from every attribute a `metaFields` entry reads, and `metaFields` /
1005
+ * `blocks` are carried as declared.
1006
+ *
1007
+ * The second source is what lets a placed meta block render (D7): the engine
1008
+ * resolves a field against the rune's modifier values, exactly as it does for a
1009
+ * tree-owning rune — which must declare the same attributes as modifiers for
1010
+ * its own blocks to fill.
1011
+ */
1012
+ export function composedRuneConfig(def) {
1013
+ const read = new Set();
1014
+ for (const [name, field] of Object.entries(def.metaFields ?? {})) {
1015
+ read.add(name);
1016
+ const f = (field ?? {});
1017
+ for (const ref of [f.condition, f.href, f.rating?.total]) {
1018
+ if (typeof ref === 'string')
1019
+ read.add(ref);
1020
+ }
1021
+ }
1022
+ const modifiers = {};
1023
+ for (const [name, attr] of Object.entries(def.attributes)) {
1024
+ if (!attr.matches && !read.has(name))
1025
+ continue;
1026
+ modifiers[name] = {
1027
+ source: 'meta',
1028
+ ...(attr.default !== undefined ? { default: String(attr.default) } : {}),
1029
+ };
1030
+ }
1031
+ return {
1032
+ ...(Object.keys(modifiers).length > 0 ? { modifiers } : {}),
1033
+ ...(def.metaFields ? { metaFields: def.metaFields } : {}),
1034
+ ...(def.blocks ? { blocks: def.blocks } : {}),
1035
+ ...(def.provides ? { provides: def.provides } : {}),
1036
+ };
1037
+ }
1038
+ /** The schema types a rune can emit at its root — its table's, and its `seoType`. */
1039
+ function emittedTypes(entry, table) {
1040
+ return [
1041
+ entry.seoType,
1042
+ table?.type,
1043
+ table?.fallback?.type,
1044
+ ...Object.values(table?.rows ?? {}).map((r) => r.type),
1045
+ ].filter((t) => Boolean(t));
1046
+ }
1047
+ /**
1048
+ * The checks that need every rune in hand: a composition cycle (A places B
1049
+ * places A), a composed type placed over a peer type (D9), and a placed rune
1050
+ * that requires a parent the template does not give it (D12). Run once the
1051
+ * catalog is assembled — by `mergePlugins`, before any page renders — and
1052
+ * throws on the first problem, naming the runes involved.
1053
+ *
1054
+ * D9 is relational: a composition that declares no type of its own may place
1055
+ * an entity rune, because then exactly one type exists in the subtree. The
1056
+ * check fires only when the composed rune declares a type and a placed rune
1057
+ * declares a non-subordinate one.
1058
+ */
1059
+ export function checkCompositions(catalog, tableOf) {
1060
+ const byName = new Map();
1061
+ for (const [name, entry] of Object.entries(catalog)) {
1062
+ byName.set(name, { name, entry });
1063
+ for (const alias of entry.aliases ?? []) {
1064
+ if (!byName.has(alias))
1065
+ byName.set(alias, { name, entry });
1066
+ }
1067
+ }
1068
+ const composed = Object.entries(catalog)
1069
+ .map(([name, entry]) => ({ name, entry, info: compositionFor(entry.schema) }))
1070
+ .filter((c) => Boolean(c.info));
1071
+ for (const { name, info } of composed) {
1072
+ for (const { rune: placedName, parent } of info.placements) {
1073
+ const placed = byName.get(placedName);
1074
+ if (!placed)
1075
+ continue; // An unknown tag is Markdoc validation's to report.
1076
+ const required = placed.entry.requiresParent;
1077
+ if (required && required !== '*') {
1078
+ const requiredRune = kebab(required);
1079
+ const parentEntry = parent ? byName.get(parent) : undefined;
1080
+ const parentRune = parent
1081
+ ? (parentEntry?.entry.dataRune ?? parentEntry?.name ?? parent)
1082
+ : undefined;
1083
+ 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).`);
1085
+ }
1086
+ }
1087
+ if (info.schemaType && placed.entry.schema) {
1088
+ const peer = emittedTypes(placed.entry, tableOf(placed.entry.schema)).find((t) => !SUBORDINATE_SCHEMA_TYPES.has(t));
1089
+ 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).`);
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ // Cycles: depth-first over the placement graph, composed runes only.
1096
+ const edges = new Map();
1097
+ for (const { name, info } of composed) {
1098
+ edges.set(name, info.placements.map((p) => byName.get(p.rune)?.name).filter((n) => Boolean(n)));
1099
+ }
1100
+ const state = new Map();
1101
+ const stack = [];
1102
+ const visit = (name) => {
1103
+ if (state.get(name) === 'done')
1104
+ return;
1105
+ if (state.get(name) === 'visiting') {
1106
+ 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.`);
1108
+ }
1109
+ state.set(name, 'visiting');
1110
+ stack.push(name);
1111
+ for (const next of edges.get(name) ?? [])
1112
+ if (edges.has(next))
1113
+ visit(next);
1114
+ stack.pop();
1115
+ state.set(name, 'done');
1116
+ };
1117
+ for (const name of edges.keys())
1118
+ visit(name);
1119
+ }
1120
+ //# sourceMappingURL=composition.js.map