@mapled/cli 0.17.0 → 0.17.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/scanHtml.js CHANGED
@@ -10,7 +10,7 @@ const MAX_DEPTH = MAX_OPEN;
10
10
  a page nests up to MAX_OPEN elements, deeper throws TooDeep; a
11
11
  numeric character reference parse5 can't read throws BadReference. */
12
12
  export function parseHtml(text) {
13
- const root = { name: "#document", attrs: new Map(), children: [] };
13
+ const root = { name: "#document", html: true, attrs: new Map(), children: [] };
14
14
  const pending = [[parseDocument(text), root]];
15
15
  for (let at = 0; at < pending.length; at++) {
16
16
  const [node, into] = pending[at];
@@ -22,7 +22,7 @@ export function parseHtml(text) {
22
22
  for (const a of child.attrs ?? [])
23
23
  if (!a.namespace)
24
24
  attrs.set(a.name, a.value);
25
- const el = { name: child.tagName, attrs, children: [] };
25
+ const el = { name: child.tagName, html: child.namespaceURI === HTML_NS, attrs, children: [] };
26
26
  into.children.push(el);
27
27
  const template = child.tagName === "template" && child.namespaceURI === HTML_NS;
28
28
  pending.push([template && child.content ? child.content : child, el]);
@@ -116,8 +116,49 @@ function segmentsOf(text) {
116
116
  return null;
117
117
  return segments.every((s) => SEGMENT.test(s)) ? segments : null;
118
118
  }
119
- /** The attributes the runtime fills (sinks.ts ATTRIBUTES). */
120
- const ATTRIBUTES = new Set(["href", "src", "alt", "title", "aria-label", "placeholder", "datetime"]);
119
+ /** The attributes the runtime fills and the HTML elements each belongs
120
+ to, null — any (sinks.ts ATTRIBUTES, DOM rule 3). */
121
+ const ATTRIBUTES = new Map([
122
+ ["href", ["a", "area"]],
123
+ ["src", ["img", "video", "audio", "source"]],
124
+ ["alt", ["img", "area"]],
125
+ ["title", null],
126
+ ["aria-label", null],
127
+ ["placeholder", ["input", "textarea"]],
128
+ ["datetime", ["time", "del", "ins"]],
129
+ ]);
130
+ /** Never written into, by local name in any namespace (sinks.ts REFUSED, DOM rule 2). */
131
+ const REFUSED = new Set(["script", "style", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "link", "meta", "template", "noscript"]);
132
+ /** Whether the runtime writes an element's text (sinks.ts writeText) — or,
133
+ `htmlOnly`, its picture, its Markdown or an attribute (writeImage,
134
+ writeMarkdown, writeAttribute), which it writes in HTML alone. */
135
+ const writable = (el, htmlOnly) => !REFUSED.has(el.name.toLowerCase()) && (el.html || !htmlOnly);
136
+ const MAX_EXPAND_DEPTH = 2;
137
+ /** What an element names in `data-mapled-expand`, each as its steps (runtime.ts, listOf). */
138
+ const expandOf = (el) => (el.attrs.get(`${P}expand`) ?? "")
139
+ .split(",")
140
+ .map((t) => t.trim())
141
+ .filter((t) => /^[a-z0-9][a-z0-9_.-]{0,127}$/.test(t))
142
+ .map((t) => t.split("."));
143
+ /** What of an expand reaches past a step: past `sections`, `sections.picks` is `picks`. */
144
+ const past = (expand, names) => expand.filter((e) => e.length > 1 && names(e[0])).map((e) => e.slice(1));
145
+ /** The paths an element's own attributes read (runtime.ts, declaredOn and heads). */
146
+ function declaredPaths(el) {
147
+ const out = [];
148
+ for (const [name, value] of el.attrs) {
149
+ if (!name.startsWith(P))
150
+ continue;
151
+ const attr = name.slice(P.length);
152
+ if (attr === "bind" || attr === "show" || attr === "hide")
153
+ out.push(value);
154
+ else if (attr.startsWith("bind-") && ATTRIBUTES.has(attr.slice(5)))
155
+ out.push(value);
156
+ else if (attr === "link")
157
+ for (const m of value.matchAll(/\{([^{}]*)\}/g))
158
+ out.push(m[1]);
159
+ }
160
+ return out;
161
+ }
121
162
  /** Field types the runtime renders in a sink the API's table doesn't
122
163
  list for it (lib/bindings.ts COMPATIBLE): a yes or no becomes "true" or
123
164
  "false" (paths.ts asText); a link template takes a string or a number,
@@ -151,6 +192,16 @@ export async function scanHtml(dir, opts) {
151
192
  const tooDeep = [];
152
193
  const badReferences = [];
153
194
  const notGroups = [];
195
+ /** eaches over a relation the read doesn't expand */
196
+ const unexpanded = [];
197
+ /** templates with a type where the items have none: a list's records, a relation's, a group's items */
198
+ const typedUnused = [];
199
+ /** templates of a block type the components field doesn't take */
200
+ const foreignTypes = [];
201
+ /** block types the field takes that no template on the page shows */
202
+ const untemplated = [];
203
+ /** places the runtime doesn't write into: a code element, an attribute off its elements, SVG */
204
+ const refused = [];
154
205
  const seenRoutes = new Set();
155
206
  const pageKeys = new Set();
156
207
  for (const file of files) {
@@ -189,6 +240,24 @@ export async function scanHtml(dir, opts) {
189
240
  const routes = new Set();
190
241
  /** reads of each collection so far — a record with none inside still reads its collection */
191
242
  const reads = new Map();
243
+ /** What the runtime reads each single with (runtime.ts, heads): the
244
+ links `data-mapled-expand` names on the page's elements that read
245
+ it — outside lists, records and eaches — and on its eaches. */
246
+ const singleReads = new Map();
247
+ const readSingles = (el, root, depth) => {
248
+ if (depth > MAX_DEPTH || el.name === "template" || (!root && (el.attrs.has(`${P}list`) || el.attrs.has(`${P}record`))))
249
+ return;
250
+ // the document element's own attributes read as any element's; inside, an each reads its path
251
+ const each = root ? undefined : el.attrs.get(`${P}each`);
252
+ for (const p of each !== undefined ? [each] : declaredPaths(el)) {
253
+ const single = segmentsOf(p)?.[0];
254
+ if (single)
255
+ singleReads.set(single, [...(singleReads.get(single) ?? []), ...expandOf(el)]);
256
+ }
257
+ if (each === undefined)
258
+ for (const c of el.children)
259
+ readSingles(c, false, depth + 1);
260
+ };
192
261
  const add = (route, b) => {
193
262
  routes.add(route);
194
263
  reads.set(b.collection, (reads.get(b.collection) ?? 0) + 1);
@@ -208,8 +277,25 @@ export async function scanHtml(dir, opts) {
208
277
  return;
209
278
  }
210
279
  if (scope.kind === "group") {
211
- if (scope.fields && !scope.fields.has(segments[0]))
280
+ const sub = segments[0];
281
+ // the owners that hold it — or, when none does, each one: Mapled marks the reads outdated
282
+ const holding = scope.owners.filter((o) => o.subs.has(sub));
283
+ if (scope.fields && !scope.fields.has(sub))
212
284
  unknown.push(at(path.trim()));
285
+ // an each repeats the items of a block's group sub-field: the runtime refuses a sub-field that holds no object with "type"
286
+ else if (sink === "each" && segments.length === 1 && holding.length > 0 && holding.every((o) => !["group", "json", "location", "relation"].includes(o.subs.get(sub).type)))
287
+ notGroups.push(at(path.trim()));
288
+ for (const owner of holding.length > 0 ? holding : scope.owners) {
289
+ const def = owner.subs.get(sub);
290
+ add(route, {
291
+ base: `${pageKey}:${scope.collection}.${owner.path}.${sub}`,
292
+ collection: scope.collection,
293
+ field: `${owner.path}.${sub}`,
294
+ target: targetOf(sink, def, segments.length),
295
+ natural: def ? targetFor(def.type) : "other",
296
+ ...(sink === "presence" ? { presence: true } : {}),
297
+ });
298
+ }
213
299
  return;
214
300
  }
215
301
  let collection;
@@ -241,9 +327,24 @@ export async function scanHtml(dir, opts) {
241
327
  // a group's sub-field: the schema knows which there are
242
328
  else if (field?.group && rest.length > 1 && !field.group.fields.some((f) => f.key === rest[1]))
243
329
  unknown.push(at(path.trim()));
244
- // an each repeats a group's items: the runtime refuses anything else with "type"
245
- else if (sink === "each" && field && rest.length === 1 && !field.group && !["json", "location"].includes(effectiveType(field)))
330
+ // an each repeats a group's items, a components field's blocks or the records a relation links to: the runtime refuses anything else with "type"
331
+ else if (sink === "each" && field && rest.length === 1 && !field.group && !["json", "location", "components", "relation"].includes(effectiveType(field)))
246
332
  notGroups.push(at(path.trim()));
333
+ // a sub-field of a group that holds one item is read where it is: it binds
334
+ // as itself (`seo.title`); a list's items only through an each
335
+ const sub = field?.group && !field.group.repeatable && rest.length > 1 ? rest[1] : null;
336
+ if (sub !== null) {
337
+ const def = field.group.fields.find((f) => f.key === sub);
338
+ add(route, {
339
+ base: `${pageKey}:${collectionKey}.${fieldKey}.${sub}`,
340
+ collection: collectionKey,
341
+ field: `${fieldKey}.${sub}`,
342
+ target: targetOf(sink, def, rest.length - 1),
343
+ natural: def ? targetFor(def.type) : "other",
344
+ ...(sink === "presence" ? { presence: true } : {}),
345
+ });
346
+ return;
347
+ }
247
348
  add(route, {
248
349
  base: `${pageKey}:${collectionKey}.${fieldKey}`,
249
350
  collection: collectionKey,
@@ -253,17 +354,30 @@ export async function scanHtml(dir, opts) {
253
354
  ...(sink === "presence" ? { presence: true } : {}),
254
355
  });
255
356
  };
256
- /** The attributes an element declares for itself. */
357
+ /** The attributes an element declares for itself — bound only where
358
+ the runtime writes the value (sinks.ts); elsewhere the page keeps
359
+ its own HTML, and the scan says so. Shown or hidden works on any
360
+ element. */
257
361
  const own = (el, scope, route) => {
362
+ const refuse = (attr, value) => refused.push(at(`<${el.name} ${P}${attr}="${value.trim().slice(0, 40)}">`));
258
363
  const bind = el.attrs.get(`${P}bind`);
259
- if (bind !== undefined)
260
- use(scope, route, bind, bindSink(el));
364
+ if (bind !== undefined) {
365
+ if (writable(el, el.name === "img" || el.attrs.get(`${P}format`) === "markdown"))
366
+ use(scope, route, bind, bindSink(el));
367
+ else
368
+ refuse("bind", bind);
369
+ }
261
370
  for (const [name, value] of el.attrs) {
262
371
  if (!name.startsWith(`${P}bind-`))
263
372
  continue;
264
373
  const attr = name.slice(P.length + 5);
265
- if (ATTRIBUTES.has(attr))
374
+ const on = ATTRIBUTES.get(attr);
375
+ if (on === undefined)
376
+ continue;
377
+ if (writable(el, true) && (on === null || on.includes(el.name)))
266
378
  use(scope, route, value, attributeSink(el.name, attr));
379
+ else
380
+ refuse(`bind-${attr}`, value);
267
381
  }
268
382
  for (const name of ["show", "hide"]) {
269
383
  const value = el.attrs.get(`${P}${name}`);
@@ -271,21 +385,48 @@ export async function scanHtml(dir, opts) {
271
385
  use(scope, route, value, "presence");
272
386
  }
273
387
  const link = el.attrs.get(`${P}link`);
274
- if (link !== undefined)
388
+ if (link === undefined)
389
+ return;
390
+ if (el.html && (el.name === "a" || el.name === "area"))
275
391
  for (const m of link.matchAll(/\{([^{}]*)\}/g))
276
392
  use(scope, route, m[1], "template");
393
+ else
394
+ refuse("link", link);
395
+ };
396
+ /** The templates of a list or an each as the runtime reads them
397
+ (runtime.ts, templatesOf): the first one without a type, and the
398
+ first one of each type a block can name; a type outside the
399
+ grammar of keys is the author's typo. */
400
+ const templatesOf = (el) => {
401
+ let plain = null;
402
+ const typed = new Map();
403
+ for (const c of el.children) {
404
+ if (c.name !== "template")
405
+ continue;
406
+ const type = c.attrs.get(`${P}type`);
407
+ if (type === undefined)
408
+ plain ??= c;
409
+ else if (!KEY.test(type.trim()))
410
+ badPaths.push(at(type.trim().slice(0, 40) || '""'));
411
+ else if (!typed.has(type.trim()))
412
+ typed.set(type.trim(), c);
413
+ }
414
+ return { plain, typed };
277
415
  };
278
- /** A list: the collection, and the fields of its first template. The
416
+ /** A list: the collection, and the fields of the template its records
417
+ take — a record has no type, so the first one without a type. The
279
418
  runtime reads every list of the page from the document, wherever
280
419
  it sits — the page's own route. */
281
420
  const readList = (el, collectionKey, depth) => {
282
421
  add(served, { base: `${pageKey}:${collectionKey}`, collection: collectionKey, target: "collection", natural: "collection" });
283
- const template = el.children.find((c) => c.name === "template");
284
- if (!template)
422
+ const { plain, typed } = templatesOf(el);
423
+ if (typed.size > 0)
424
+ typedUnused.push(at(collectionKey));
425
+ if (plain)
426
+ for (const c of plain.children)
427
+ visit(c, { kind: "record", collection: collectionKey, expand: expandOf(el), level: 0 }, served, true, false, depth + 1);
428
+ else if (typed.size === 0)
285
429
  noTemplate.push(at(collectionKey));
286
- else
287
- for (const c of template.children)
288
- visit(c, { kind: "record", collection: collectionKey }, served, true, false, depth + 1);
289
430
  };
290
431
  /** A record inside a record isn't read — but the lists inside it are:
291
432
  the runtime lifts them to the page (runtime.ts, compile). */
@@ -347,7 +488,7 @@ export async function scanHtml(dir, opts) {
347
488
  const byQuery = param !== undefined && el.attrs.get(`${P}slug`) === undefined;
348
489
  // the runtime reads the parameter by its exact name
349
490
  const address = byQuery ? `${served}?${encodeURIComponent(param)}=[${slugField ?? "slug"}]` : served;
350
- const inner = { kind: "record", collection: collectionKey };
491
+ const inner = { kind: "record", collection: collectionKey, expand: expandOf(el), level: 0 };
351
492
  const before = reads.get(collectionKey) ?? 0;
352
493
  own(el, inner, address);
353
494
  for (const c of el.children)
@@ -363,35 +504,190 @@ export async function scanHtml(dir, opts) {
363
504
  }
364
505
  if (each !== undefined) {
365
506
  use(scope, route, each, "each");
366
- const template = el.children.find((c) => c.name === "template");
367
- if (!template) {
507
+ const { plain, typed } = templatesOf(el);
508
+ if (!plain && typed.size === 0) {
368
509
  noTemplate.push(at(each.trim()));
369
510
  return;
370
511
  }
371
- const items = { kind: "group", fields: groupFields(scope, each) };
372
- for (const c of template.children)
373
- visit(c, items, route, true, inRecord, depth + 1);
512
+ // the records a relation links to, when the read expands it (§45.4): records have no type, so
513
+ // they take the template without one — read at the page's own address, as a list inside a
514
+ // record is: a record's would lend them its [slug], which Preview fills with their own (lib/recordRoutes.ts)
515
+ const linked = linkedBy(scope, each);
516
+ if (linked) {
517
+ if (!linked.expanded)
518
+ unexpanded.push(at(each.trim()));
519
+ else if (plain)
520
+ for (const collection of linked.collections)
521
+ for (const c of plain.children)
522
+ visit(c, { kind: "record", collection, ...linked.reads }, served, true, inRecord, depth + 1);
523
+ // blocks of other types may hold the sub-field as a group: its items take the same templates
524
+ if (!linked.others) {
525
+ if (typed.size > 0)
526
+ typedUnused.push(at(each.trim()));
527
+ return;
528
+ }
529
+ }
530
+ const repeated = eachField(scope, each);
531
+ const filled = scope.kind === "group" ? filledByItems(scope, each.trim(), plain, typed) : filledBy(repeated, each.trim(), plain, typed);
532
+ // a block's relations come along as the read of its record or single expands them past the
533
+ // field — `sections.picks` → `picks`; a group's items hold none
534
+ let reads = { expand: [], level: scope.kind === "page" ? 0 : scope.level };
535
+ if (scope.kind !== "group" && repeated) {
536
+ const of = collections.get(repeated.collection);
537
+ reads = { expand: past(repeated.reads.expand, (step) => fieldOf(of, step) === repeated.field), level: repeated.reads.level };
538
+ }
539
+ for (const [template, fields, owners] of filled) {
540
+ const inner = { kind: "group", fields, collection: scope.kind === "group" ? scope.collection : (repeated?.collection ?? ""), owners, ...reads };
541
+ // a linked record's items are read at the page's own address, as its linked records are
542
+ for (const c of template.children)
543
+ visit(c, inner, repeated?.linked ? served : route, true, inRecord, depth + 1);
544
+ }
374
545
  return;
375
546
  }
376
547
  own(el, scope, route);
377
548
  for (const c of el.children)
378
549
  visit(c, scope, route, inTemplate, inRecord, depth + 1);
379
550
  };
380
- /** The sub-fields of the group an each repeats, when the schema says. */
381
- const groupFields = (scope, each) => {
551
+ /** The field an each repeats, when the schema says: its collection,
552
+ the key the page reads it by (a previous one too), the field and
553
+ what the read expands where it is — a field of the single or the
554
+ record, or of the one record a relation on the way links to, which
555
+ the runtime walks into when the read expands it (paths.ts, walk);
556
+ past ids or a list of records the path finds nothing. `linked`:
557
+ reached through such a relation. */
558
+ const eachField = (scope, each) => {
559
+ const segments = segmentsOf(each);
560
+ if (!segments || scope.kind === "group" || (scope.kind === "page" && segments.length < 2))
561
+ return undefined;
562
+ // on the page a path starts at a single, read with what the runtime reads it with
563
+ let collection = scope.kind === "page" ? segments[0] : scope.collection;
564
+ let reads = scope.kind === "page" ? { expand: singleReads.get(collection) ?? [], level: 0 } : { expand: scope.expand, level: scope.level };
565
+ const steps = scope.kind === "page" ? segments.slice(1) : segments;
566
+ for (const step of steps.slice(0, -1)) {
567
+ const of = collections.get(collection);
568
+ const via = fieldOf(of, step);
569
+ const names = (s) => fieldOf(of, s) === via;
570
+ if (!via?.relation || effectiveType(via) !== "relation" || via.relation.cardinality !== "one" || !reads.expand.some((e) => names(e[0])))
571
+ return undefined;
572
+ const level = reads.level + 1;
573
+ collection = via.relation.target;
574
+ reads = { expand: level < MAX_EXPAND_DEPTH ? past(reads.expand, names) : [], level };
575
+ }
576
+ const key = steps[steps.length - 1];
577
+ const field = fieldOf(collections.get(collection), key);
578
+ return field ? { collection, key, field, reads, linked: steps.length > 1 } : undefined;
579
+ };
580
+ /** What an each repeats when it names a relation (§45.4) — a field of
581
+ the record or the single (or of a record linked on the way, as
582
+ eachField reads it), or a sub-field the blocks of some types
583
+ hold as one: the records it links to, of the collections the schema
584
+ knows — only when the read expands the relation (as the API keys
585
+ it: a field by any of its keys, a sub-field by its own), else the
586
+ ids, which the runtime doesn't repeat — and what the read expands
587
+ inside them. `others`: blocks of other types hold the sub-field as
588
+ something else, which repeats as it is. Null: no relation. */
589
+ const linkedBy = (scope, each) => {
382
590
  const segments = segmentsOf(each);
383
591
  if (!segments)
384
592
  return null;
385
- let field;
386
- if (scope.kind === "page" && segments.length === 2)
387
- field = fieldOf(collections.get(segments[0]), segments[1]);
388
- else if (scope.kind === "record" && segments.length === 1)
389
- field = fieldOf(collections.get(scope.collection), segments[0]);
390
- return field?.group ? new Set(field.group.fields.map((f) => f.key)) : null;
593
+ let relations;
594
+ let outer;
595
+ let names;
596
+ let others = false;
597
+ if (scope.kind === "group") {
598
+ const sub = segments.length === 1 ? segments[0] : null;
599
+ const defs = sub === null ? [] : scope.owners.flatMap((o) => o.subs.get(sub) ?? []);
600
+ relations = defs.filter((d) => d.type === "relation");
601
+ if (relations.length === 0)
602
+ return null;
603
+ others = relations.length < defs.length;
604
+ outer = scope;
605
+ names = (step) => step === sub;
606
+ }
607
+ else {
608
+ const repeated = eachField(scope, each);
609
+ if (!repeated || effectiveType(repeated.field) !== "relation")
610
+ return null;
611
+ relations = [repeated.field];
612
+ outer = repeated.reads;
613
+ const of = collections.get(repeated.collection);
614
+ names = (step) => fieldOf(of, step) === repeated.field;
615
+ }
616
+ const level = outer.level + 1;
617
+ return {
618
+ collections: [...new Set(relations.flatMap((r) => (r.relation && collections.has(r.relation.target) ? [r.relation.target] : [])))],
619
+ expanded: outer.expand.some((e) => names(e[0])),
620
+ reads: { expand: level < MAX_EXPAND_DEPTH ? past(outer.expand, names) : [], level },
621
+ others,
622
+ };
623
+ };
624
+ /** The templates the runtime fills for an each, the fields their
625
+ paths can name (null: the schema doesn't say) and whose they are.
626
+ A group's items take the template without a type. A components
627
+ field's blocks take the template of their type, else that one — so
628
+ it names the fields of the types without a template of their own;
629
+ a type the field doesn't take is never filled. A JSON field's
630
+ objects pick like blocks when they name a `_type`, and the schema
631
+ doesn't say which do. */
632
+ const filledBy = (repeated, each, plain, typed) => {
633
+ const unknownShape = () => [...(plain ? [plain] : []), ...typed.values()].map((t) => [t, null, []]);
634
+ const field = repeated?.field;
635
+ // a JSON value's objects pick by their own _type, as blocks do — what they hold, the schema doesn't say
636
+ if (!repeated || !field || effectiveType(field) === "json")
637
+ return unknownShape();
638
+ const subsOf = (fields) => new Map(fields.map((f) => [f.key, f]));
639
+ if (effectiveType(field) !== "components") {
640
+ if (typed.size > 0)
641
+ typedUnused.push(at(each));
642
+ if (!plain)
643
+ return [];
644
+ return [field.group ? [plain, new Set(field.group.fields.map((f) => f.key)), [{ path: repeated.key, subs: subsOf(field.group.fields) }]] : [plain, null, []]];
645
+ }
646
+ const allowed = field.components?.allowed;
647
+ const types = opts.schema.componentTypes;
648
+ if (!allowed || !types)
649
+ return unknownShape();
650
+ const ownerOf = (type) => ({ path: `${repeated.key}[${type}]`, subs: subsOf(types.find((t) => t.key === type)?.fields ?? []) });
651
+ const out = [];
652
+ for (const [type, template] of typed) {
653
+ if (!allowed.includes(type)) {
654
+ foreignTypes.push(at(`${each}: ${type}`));
655
+ continue;
656
+ }
657
+ const owner = ownerOf(type);
658
+ out.push([template, new Set(owner.subs.keys()), [owner]]);
659
+ }
660
+ const rest = allowed.filter((type) => !typed.has(type));
661
+ if (rest.length > 0 && plain) {
662
+ const owners = rest.map(ownerOf);
663
+ out.push([plain, new Set(owners.flatMap((o) => [...o.subs.keys()])), owners]);
664
+ }
665
+ else if (rest.length > 0)
666
+ untemplated.push(at(`${each}: ${rest.join(", ")}`));
667
+ return out;
668
+ };
669
+ /** The templates an each inside a repeated template fills: the items
670
+ of a block's group sub-field (§45.4, `tiers` in a pricing block's
671
+ template) take the template without a type, and their paths name
672
+ its sub-fields under `sections[pricing].tiers`; what else repeats
673
+ there — a JSON value's objects — the schema doesn't say. */
674
+ const filledByItems = (scope, each, plain, typed) => {
675
+ const segments = segmentsOf(each);
676
+ const key = segments?.length === 1 ? segments[0] : null;
677
+ const owners = scope.owners.flatMap((o) => {
678
+ const def = key === null ? undefined : o.subs.get(key);
679
+ return def?.type === "group" && def.group ? [{ path: `${o.path}.${key}`, subs: new Map(def.group.fields.map((f) => [f.key, f])) }] : [];
680
+ });
681
+ if (owners.length === 0)
682
+ return [...(plain ? [plain] : []), ...typed.values()].map((t) => [t, null, []]);
683
+ if (typed.size > 0)
684
+ typedUnused.push(at(each));
685
+ return plain ? [[plain, new Set(owners.flatMap((o) => [...o.subs.keys()])), owners]] : [];
391
686
  };
392
687
  // the runtime's root is the document element: its own attributes, then what is inside
393
688
  const html = doc.children.find((c) => c.name === "html");
394
689
  if (html) {
690
+ readSingles(html, true, 1);
395
691
  own(html, { kind: "page" }, served);
396
692
  for (const c of html.children)
397
693
  visit(c, { kind: "page" }, served, false, false, 1);
@@ -423,8 +719,19 @@ export async function scanHtml(dir, opts) {
423
719
  notes.push(`A list or a record inside a repeated template, or a record inside a record, isn't read: ${list(nested)}.`);
424
720
  if (notGroups.length > 0)
425
721
  notes.push(`An each repeats the items of a group — ${count(notGroups) === 1 ? "this field isn't one" : "these fields aren't"}: ${list(notGroups)}.`);
722
+ if (unexpanded.length > 0)
723
+ notes.push(`An each repeats a relation's records only when the read expands it — name it in data-mapled-expand (sections.picks for a block's), or the page keeps its own HTML there: ${list(unexpanded)}.`);
426
724
  if (noTemplate.length > 0)
427
725
  notes.push(`A list or an each without a <template> isn't filled: ${list(noTemplate)}.`);
726
+ if (typedUnused.length > 0)
727
+ notes.push(`A list's records, the records a relation links to and a group's items have no type, so a <template data-mapled-type> there is never filled — they take the <template> without one: ${list(typedUnused)}.`);
728
+ if (foreignTypes.length > 0)
729
+ notes.push(`A <template data-mapled-type> names a block type the field doesn't take, so it's never filled: ${list(foreignTypes)}.`);
730
+ if (untemplated.length > 0)
731
+ notes.push(`Blocks of these types have no <template> on the page and don't show — add one with data-mapled-type, or one without a type: ${list(untemplated)}.`);
732
+ if (refused.length > 0) {
733
+ notes.push(`Nothing is written into ${count(refused) === 1 ? "this place" : "these places"}, so the page keeps its own HTML there: ${list(refused)}. <script>, <style> and other code elements take nothing, inside <svg> only plain text goes in, and an attribute goes only onto the HTML elements it belongs to — href onto <a>, src onto <img> or media, data-mapled-link onto <a>.`);
734
+ }
428
735
  if (tooDeep.length > 0)
429
736
  notes.push(`Elements nest deeper than ${MAX_OPEN} levels, so the scan didn't read ${count(tooDeep) === 1 ? "this page" : "these pages"}: ${list(tooDeep)}.`);
430
737
  if (badReferences.length > 0)
@@ -506,9 +813,10 @@ function attributeSink(element, attr) {
506
813
  return "text";
507
814
  }
508
815
  }
509
- /** The binding's target: the sink, for a value read at the field itself.
510
- Deeper paths (a group's sub-field, a linked record's field) and eaches
511
- bind the field as a whole — Mapled grades those as it can't check. */
816
+ /** The binding's target: the sink, for a value read at the field (or
817
+ the sub-field) itself. Deeper paths (a linked record's field, a key of
818
+ a JSON value) and eaches bind it as a whole — Mapled grades those as
819
+ it can't check. */
512
820
  function targetOf(sink, field, depth) {
513
821
  if (sink === "each" || depth > 1)
514
822
  return "other";
package/dist/schema.d.ts CHANGED
@@ -9,6 +9,21 @@ export type SchemaSubField = {
9
9
  helpText?: string | null;
10
10
  options?: string[] | null;
11
11
  sensitive?: boolean;
12
+ /** A component type's group sub-field (§45.4): its own sub-fields, a
13
+ list of items. Absent on older servers. */
14
+ group?: {
15
+ fields: SchemaSubField[];
16
+ repeatable: boolean;
17
+ maxItems?: number;
18
+ } | null;
19
+ /** A component type's relation sub-field (§45.4): the collection its
20
+ items link into — ids of its records, as a relation field's. Absent
21
+ on older servers. */
22
+ relation?: {
23
+ target: string;
24
+ cardinality: "one" | "many";
25
+ onDelete?: string;
26
+ } | null;
12
27
  };
13
28
  export type SchemaField = {
14
29
  key: string;
@@ -53,7 +68,8 @@ export type SchemaField = {
53
68
  } | null;
54
69
  };
55
70
  /** A component type (§45.4): the key an item names in `_type`, the name,
56
- the sub-fields in a group's shape. */
71
+ the sub-fields in a group's shape — one of them may be a group of its
72
+ own sub-fields. */
57
73
  export type SchemaComponentType = {
58
74
  key: string;
59
75
  displayName: string;
package/dist/schema.js CHANGED
@@ -55,6 +55,26 @@ export function schemaHash(schema) {
55
55
  required: s.required,
56
56
  options: s.options ?? null,
57
57
  sensitive: s.sensitive === true,
58
+ // a group sub-field's own sub-fields (§45.4): present only for it,
59
+ // so types without one hash as before
60
+ ...(s.type === "group" && s.group
61
+ ? {
62
+ group: {
63
+ repeatable: s.group.repeatable,
64
+ maxItems: s.group.maxItems ?? null,
65
+ fields: s.group.fields.map((r) => ({
66
+ key: r.key,
67
+ displayName: r.displayName,
68
+ type: r.type,
69
+ required: r.required,
70
+ options: r.options ?? null,
71
+ sensitive: r.sensitive === true,
72
+ })),
73
+ },
74
+ }
75
+ : {}),
76
+ // a relation sub-field's collection (§45.4): present only for it
77
+ ...(s.type === "relation" && s.relation ? { relation: { target: s.relation.target, cardinality: s.relation.cardinality } } : {}),
58
78
  })),
59
79
  })),
60
80
  }
package/dist/types.js CHANGED
@@ -84,6 +84,8 @@ function scalarType(type, options) {
84
84
  return { type: "MapledAssetId", note: "image" };
85
85
  case "file":
86
86
  return { type: "MapledAssetId", note: "file" };
87
+ case "gallery":
88
+ return { type: "MapledAssetId[]", note: "gallery, images in order" };
87
89
  case "enum":
88
90
  return { type: literalUnion(options), note: "choice" };
89
91
  case "json":
@@ -224,12 +226,27 @@ export function componentNames(types, declared) {
224
226
  return out;
225
227
  }
226
228
  /** One interface per component type: `_type` as its key, `_key` the
227
- item's own id, then its sub-fields. */
228
- function renderComponent(type, name) {
229
+ item's own id, then its sub-fields — a group sub-field as a list of
230
+ its item interface, which goes to `extras` under a declared name; a
231
+ relation sub-field as the ids of the records it links, as a relation
232
+ field (`names`: the collections' type names). */
233
+ function renderComponent(type, name, declared, extras, names) {
229
234
  const lines = [
230
235
  ` /** The component's type */\n _type: ${JSON.stringify(type.key)};`,
231
236
  " /** The item's own id — stays with it across saves */\n _key: string;",
232
- ...type.fields.map(renderSubField),
237
+ ...type.fields.map((sub) => {
238
+ if (sub.type === "relation" && sub.relation) {
239
+ const target = names.get(sub.relation.target) ?? sub.relation.target;
240
+ const many = sub.relation.cardinality === "many";
241
+ return fieldLine(sub.key, sub.displayName, sub.required, { type: many ? "MapledRecordId[]" : "MapledRecordId", note: `relation to ${comment(target)}${many ? " (many)" : ""}` });
242
+ }
243
+ if (sub.type !== "group" || !sub.group)
244
+ return renderSubField(sub);
245
+ const itemName = declared.claim(`${pascal(wordsOf(type.key))}${pascal(wordsOf(sub.key))}Item`);
246
+ extras.push(`/** ${comment(sub.displayName)} — an item of ${name} */\n` + `export interface ${itemName} {\n${sub.group.fields.map(renderSubField).join("\n")}\n}`);
247
+ const note = `group${sub.group.maxItems ? `, up to ${sub.group.maxItems} items` : ""}`;
248
+ return fieldLine(sub.key, sub.displayName, sub.required, { type: `${itemName}[]`, note });
249
+ }),
233
250
  ];
234
251
  return `/** ${comment(type.displayName)} — a component (key "${comment(type.key)}") */\nexport interface ${name} {\n${lines.join("\n")}\n}`;
235
252
  }
@@ -257,8 +274,11 @@ export function generateTypes(schema, opts = {}) {
257
274
  "export type MapledLocation = { lat: number; lng: number };",
258
275
  ].join("\n"));
259
276
  // the component types come first: a components field names them
260
- for (const t of componentTypes)
261
- blocks.push(renderComponent(t, components.get(t.key)));
277
+ for (const t of componentTypes) {
278
+ const extras = [];
279
+ const block = renderComponent(t, components.get(t.key), declared, extras, names);
280
+ blocks.push(...extras, block);
281
+ }
262
282
  if (componentTypes.length > 0) {
263
283
  blocks.push("/** Any component of the project — switch on `_type` to render an item of a components field. */\n" +
264
284
  `export type MapledComponent = ${componentTypes.map((t) => components.get(t.key)).join(" | ")};`);
@@ -13,4 +13,5 @@ export const VANILLA_DIGESTS = {
13
13
  "0.2.1": "sha384-6apTHYxXrozzwFCqNU+70Z9pa/FDnhArNomcKQNWbeKRX4scjFiOc9d7wWKCOvTU",
14
14
  "0.2.2": "sha384-lfhwQoPUtrbLcD2HXSLr2o1z+aUAjotB62i3LFMGxzBlK5lfCQPQPLOoWL+1DBMc",
15
15
  "0.2.3": "sha384-SuKH/yk2hpNI+Ec2QGKpZ7jVxe+0Ep0x/fchL560g+XuzG5hnVCIhG9OKX6BKdpY",
16
+ "0.3.0": "sha384-evhHWpu15FOXqD3S7ZXIrtTTDv71hVrOE6l9eHWeeo2NzJh4o9eE3VUkQOnkMj/R",
16
17
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms, preview drafts and check the site's integration.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",