@mapled/cli 0.16.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/README.md +25 -2
- package/dist/args.js +1 -1
- package/dist/callbackPage.d.ts +50 -0
- package/dist/callbackPage.js +141 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +104 -16
- package/dist/doctor.d.ts +2 -0
- package/dist/envPush.d.ts +44 -0
- package/dist/envPush.js +285 -0
- package/dist/index.js +10 -1
- package/dist/logo.d.ts +4 -0
- package/dist/logo.js +4 -0
- package/dist/manifest.d.ts +34 -7
- package/dist/manifest.js +86 -18
- package/dist/media.d.ts +37 -0
- package/dist/media.js +133 -0
- package/dist/oauth.d.ts +14 -2
- package/dist/oauth.js +34 -11
- package/dist/pin.js +63 -43
- package/dist/scanHtml.d.ts +11 -4
- package/dist/scanHtml.js +343 -35
- package/dist/schema.d.ts +17 -1
- package/dist/schema.js +20 -0
- package/dist/types.js +25 -5
- package/dist/vanillaDigests.js +1 -0
- package/package.json +1 -1
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
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
284
|
-
if (
|
|
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
|
|
367
|
-
if (!
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
381
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
510
|
-
Deeper paths (a
|
|
511
|
-
bind
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(" | ")};`);
|
package/dist/vanillaDigests.js
CHANGED
|
@@ -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.
|
|
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",
|