@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.
- package/dist/attribute-presets.d.ts +3 -2
- package/dist/attribute-presets.d.ts.map +1 -1
- package/dist/attribute-presets.js +2 -1
- package/dist/attribute-presets.js.map +1 -1
- package/dist/collection-helpers.d.ts +21 -2
- package/dist/collection-helpers.d.ts.map +1 -1
- package/dist/collection-helpers.js +72 -15
- package/dist/collection-helpers.js.map +1 -1
- package/dist/composed-rune.d.ts +59 -0
- package/dist/composed-rune.d.ts.map +1 -0
- package/dist/composed-rune.js +129 -0
- package/dist/composed-rune.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +73 -40
- package/dist/config.js.map +1 -1
- package/dist/data-pipeline.d.ts +24 -10
- package/dist/data-pipeline.d.ts.map +1 -1
- package/dist/data-pipeline.js +34 -56
- package/dist/data-pipeline.js.map +1 -1
- package/dist/file-ref-resolve.js +9 -13
- package/dist/file-ref-resolve.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/dist/lib/composition.d.ts +152 -0
- package/dist/lib/composition.d.ts.map +1 -0
- package/dist/lib/composition.js +1120 -0
- package/dist/lib/composition.js.map +1 -0
- package/dist/lib/index.d.ts +63 -4
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +137 -8
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/preprocess.d.ts +64 -0
- package/dist/lib/preprocess.d.ts.map +1 -0
- package/dist/lib/preprocess.js +72 -0
- package/dist/lib/preprocess.js.map +1 -0
- package/dist/lib/registers.d.ts +132 -0
- package/dist/lib/registers.d.ts.map +1 -0
- package/dist/lib/registers.js +329 -0
- package/dist/lib/registers.js.map +1 -0
- package/dist/lib/resolver.d.ts +21 -1
- package/dist/lib/resolver.d.ts.map +1 -1
- package/dist/lib/resolver.js +41 -18
- package/dist/lib/resolver.js.map +1 -1
- package/dist/lib/schema-side-table.d.ts +26 -0
- package/dist/lib/schema-side-table.d.ts.map +1 -0
- package/dist/lib/schema-side-table.js +34 -0
- package/dist/lib/schema-side-table.js.map +1 -0
- package/dist/lib/schema-table.d.ts +59 -9
- package/dist/lib/schema-table.d.ts.map +1 -1
- package/dist/lib/schema-table.js +158 -22
- package/dist/lib/schema-table.js.map +1 -1
- package/dist/lib/slot-markers.d.ts +38 -0
- package/dist/lib/slot-markers.d.ts.map +1 -0
- package/dist/lib/slot-markers.js +105 -0
- package/dist/lib/slot-markers.js.map +1 -0
- package/dist/lib/slots.d.ts +99 -0
- package/dist/lib/slots.d.ts.map +1 -0
- package/dist/lib/slots.js +448 -0
- package/dist/lib/slots.js.map +1 -0
- package/dist/plugins.d.ts +3 -0
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +53 -4
- package/dist/plugins.js.map +1 -1
- package/dist/reference.d.ts +18 -0
- package/dist/reference.d.ts.map +1 -1
- package/dist/reference.js +34 -1
- package/dist/reference.js.map +1 -1
- package/dist/registers-pipeline.d.ts +51 -0
- package/dist/registers-pipeline.d.ts.map +1 -0
- package/dist/registers-pipeline.js +224 -0
- package/dist/registers-pipeline.js.map +1 -0
- package/dist/schema-row.d.ts +5 -0
- package/dist/schema-row.d.ts.map +1 -1
- package/dist/schema-row.js +1 -0
- package/dist/schema-row.js.map +1 -1
- package/dist/tags/data.d.ts +2 -2
- package/dist/tags/data.d.ts.map +1 -1
- package/dist/tags/data.js +6 -4
- package/dist/tags/data.js.map +1 -1
- package/dist/tags/figure.d.ts +10 -4
- package/dist/tags/figure.d.ts.map +1 -1
- package/dist/tags/figure.js +65 -20
- package/dist/tags/figure.js.map +1 -1
- package/dist/tags/include.d.ts +51 -7
- package/dist/tags/include.d.ts.map +1 -1
- package/dist/tags/include.js +216 -7
- package/dist/tags/include.js.map +1 -1
- package/dist/tags/sandbox.d.ts.map +1 -1
- package/dist/tags/sandbox.js +13 -3
- package/dist/tags/sandbox.js.map +1 -1
- package/dist/tags/snippet.d.ts +9 -10
- package/dist/tags/snippet.d.ts.map +1 -1
- package/dist/tags/snippet.js +213 -10
- package/dist/tags/snippet.js.map +1 -1
- package/dist/universal-attributes.d.ts +1 -1
- package/package.json +3 -3
- package/dist/include-pipeline.d.ts +0 -52
- package/dist/include-pipeline.d.ts.map +0 -1
- package/dist/include-pipeline.js +0 -206
- package/dist/include-pipeline.js.map +0 -1
- package/dist/snippet-pipeline.d.ts +0 -44
- package/dist/snippet-pipeline.d.ts.map +0 -1
- package/dist/snippet-pipeline.js +0 -307
- 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
|