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