@mapled/vanilla 0.0.1 → 0.1.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.
@@ -0,0 +1,725 @@
1
+ /** The runtime of Mapled attributes (§19.2): finds what a page declared
2
+ with `data-mapled-*`, reads the release that is live, fills the page,
3
+ and says what it couldn't fill. The page's own HTML is the fallback —
4
+ nothing is touched before its replacement is in hand, and every run
5
+ starts again from what the author wrote.
6
+
7
+ What a page may declare:
8
+
9
+ data-mapled-bind="home.headline" the element's content — text;
10
+ on <img>, the picture
11
+ data-mapled-format="markdown" …as rich text; or "date",
12
+ "datetime", "number"
13
+ data-mapled-bind-href="home.cta-link" an attribute: href, src, alt,
14
+ title, aria-label,
15
+ placeholder, datetime
16
+ data-mapled-link="/post.html?slug={slug}" an address from a template
17
+ data-mapled-show / data-mapled-hide shown while there is a value
18
+ data-mapled-list="posts" a <template> inside, once per
19
+ record: data-mapled-sort,
20
+ -limit, -filter, -expand
21
+ data-mapled-each="features" the same for the items of a
22
+ repeatable group
23
+ data-mapled-record="posts" one record for what is inside:
24
+ data-mapled-slug, or
25
+ data-mapled-slug-param="slug"
26
+ <form data-mapled-form="contact"> posted to Mapled's form
27
+
28
+ Paths start at a single from the document (`home.headline`) and at the
29
+ record inside a list, an each or a record (`title`, `author.name`). */
30
+ import { createClient, endPreview, MapledError, previewSession, submitForm } from "@mapled/client";
31
+ import { isEmpty, parsePath, valueAt } from "./paths.js";
32
+ import { ATTRIBUTES, FORMATS, formatted, setVisible, writable, writeAttribute, writeImage, writeLink, writeMarkdown, writeText } from "./sinks.js";
33
+ import { fillTemplate } from "./url.js";
34
+ /** Why a place wasn't filled. `missing` — no such single, record or
35
+ collection in the release; `empty` — the record is there, a value at
36
+ this path isn't; `failed` — the read didn't come back; `bad-path` —
37
+ the page's attribute isn't a path, a key or an option Mapled reads;
38
+ the rest are the sinks' refusals. */
39
+ export const ISSUE_REASONS = ["missing", "empty", "failed", "bad-path", "unsafe-url", "type", "refused-element", "unsupported"];
40
+ const P = "data-mapled-";
41
+ /** Every attribute the runtime reads or writes, besides `bind-…`. */
42
+ export const NAMES = new Set([
43
+ "bind", "format", "width", "show", "hide", "link", "list", "each", "record", "slug", "slug-param", "sort", "limit", "filter", "expand", "empty",
44
+ "form", "form-state", "form-sent", "form-failed", "consent", "end-preview", "state", "preview", "key", "api", "live",
45
+ ]);
46
+ const KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/;
47
+ const DELIVERY_KEY = /^mk_[A-Za-z0-9_]{8,128}$/;
48
+ const MAX_EACH_DEPTH = 4;
49
+ /** The most a template repeats: the API's page, and the most items a
50
+ repeatable group holds — an answer that brings more is cut here. */
51
+ const MAX_ITEMS = 100;
52
+ /** How deep `expand` reaches: `author`, `related.author`. */
53
+ const MAX_EXPAND_DEPTH = 2;
54
+ /** A record as Mapled serves one — what every path walks: `data` an
55
+ object; `expanded`, when there, one linked record, a list of them or
56
+ null under each key, no deeper than `expand` can ask. An answer is
57
+ data, and so is its shape: what isn't a record is read as no answer. */
58
+ function isRecord(value, depth = 0) {
59
+ if (value === null || typeof value !== "object" || Array.isArray(value))
60
+ return false;
61
+ const { data, expanded } = value;
62
+ if (data === null || typeof data !== "object" || Array.isArray(data))
63
+ return false;
64
+ if (expanded === undefined)
65
+ return true;
66
+ if (expanded === null || typeof expanded !== "object" || Array.isArray(expanded))
67
+ return false;
68
+ const linked = Object.values(expanded);
69
+ if (depth >= MAX_EXPAND_DEPTH)
70
+ return linked.length === 0;
71
+ return linked.every((one) => one === null || (Array.isArray(one) ? one.every((r) => isRecord(r, depth + 1)) : isRecord(one, depth + 1)));
72
+ }
73
+ const isRecordList = (value) => Array.isArray(value) && value.every((r) => isRecord(r));
74
+ /** The element as the page names it: `a#cta`. The id is the author's. */
75
+ function nameOf(el) {
76
+ const id = el.getAttribute("id");
77
+ return `${el.localName}${id && /^[\w-]{1,40}$/.test(id) ? `#${id}` : ""}`;
78
+ }
79
+ const listOf = (text) => (text ?? "")
80
+ .split(",")
81
+ .map((t) => t.trim())
82
+ .filter((t) => /^[a-z0-9][a-z0-9_.-]{0,127}$/.test(t));
83
+ const templateOf = (el) => Array.from(el.children).find((c) => c.localName === "template") ?? null;
84
+ function declaredOn(el, issues) {
85
+ let d = null;
86
+ const get = () => (d ??= { format: "text", attrs: [], expand: [] });
87
+ for (const attr of Array.from(el.attributes)) {
88
+ if (!attr.name.startsWith(P))
89
+ continue;
90
+ const name = attr.name.slice(P.length);
91
+ if (name === "bind")
92
+ get().bind = attr.value;
93
+ else if (name.startsWith("bind-")) {
94
+ const target = name.slice(5);
95
+ if (Object.prototype.hasOwnProperty.call(ATTRIBUTES, target))
96
+ get().attrs.push([target, attr.value]);
97
+ else
98
+ issues.push({ path: attr.value.slice(0, 80), reason: "unsupported", on: `${nameOf(el)}[${attr.name.slice(0, 60)}]` });
99
+ }
100
+ else if (name === "show")
101
+ get().show = attr.value;
102
+ else if (name === "hide")
103
+ get().hide = attr.value;
104
+ else if (name === "link")
105
+ get().link = attr.value;
106
+ // a name Mapled doesn't read is most likely a typo of one it does
107
+ else if (!NAMES.has(name))
108
+ issues.push({ path: attr.value.slice(0, 80), reason: "unsupported", on: `${nameOf(el)}[${attr.name.slice(0, 60)}]` });
109
+ }
110
+ if (!d)
111
+ return null;
112
+ const declared = d;
113
+ const format = el.getAttribute(`${P}format`);
114
+ if (format !== null) {
115
+ if (FORMATS.includes(format))
116
+ declared.format = format;
117
+ else
118
+ issues.push({ path: format.slice(0, 80), reason: "bad-path", on: `${nameOf(el)}[${P}format]` });
119
+ }
120
+ const width = parseInt(el.getAttribute(`${P}width`) ?? "", 10);
121
+ if (width >= 16 && width <= 4096)
122
+ declared.width = width;
123
+ declared.expand = listOf(el.getAttribute(`${P}expand`));
124
+ return declared;
125
+ }
126
+ function listOptions(el, issues) {
127
+ const options = {};
128
+ const sort = el.getAttribute(`${P}sort`);
129
+ if (sort !== null) {
130
+ if (/^-?[a-z0-9][a-z0-9_-]{0,63}$/.test(sort.trim()))
131
+ options.sort = sort.trim();
132
+ else
133
+ issues.push({ path: sort.slice(0, 80), reason: "bad-path", on: `${nameOf(el)}[${P}sort]` });
134
+ }
135
+ const limit = parseInt(el.getAttribute(`${P}limit`) ?? "", 10);
136
+ if (limit >= 1 && limit <= 100)
137
+ options.limit = limit;
138
+ const filter = el.getAttribute(`${P}filter`);
139
+ if (filter !== null) {
140
+ for (const pair of filter.split(";")) {
141
+ if (pair.trim() === "")
142
+ continue;
143
+ const at = pair.indexOf("=");
144
+ const key = at === -1 ? "" : pair.slice(0, at).trim();
145
+ const value = at === -1 ? "" : pair.slice(at + 1).trim();
146
+ if (KEY.test(key) && value !== "" && value.length <= 200)
147
+ (options.filter ??= {})[key] = value;
148
+ else
149
+ issues.push({ path: pair.trim().slice(0, 80), reason: "bad-path", on: `${nameOf(el)}[${P}filter]` });
150
+ }
151
+ }
152
+ const expand = listOf(el.getAttribute(`${P}expand`));
153
+ if (expand.length > 0)
154
+ options.expand = expand;
155
+ return options;
156
+ }
157
+ /** Everything declared under `container`. Inside a template's clone a
158
+ list or a record of its own is not read (`page` is false): what
159
+ repeats there is the record's own data. */
160
+ function compile(container, page) {
161
+ const block = { bound: [], eaches: [], lists: [], records: [], issues: [] };
162
+ const visit = (el) => {
163
+ if (el.hasAttributes()) {
164
+ const list = el.getAttribute(`${P}list`);
165
+ const record = el.getAttribute(`${P}record`);
166
+ const each = el.getAttribute(`${P}each`);
167
+ if (list !== null || record !== null) {
168
+ const collection = (list ?? record ?? "").trim();
169
+ if (!page)
170
+ block.issues.push({ path: collection.slice(0, 80), reason: "unsupported", on: nameOf(el) });
171
+ else if (!KEY.test(collection))
172
+ block.issues.push({ path: collection.slice(0, 80), reason: "bad-path", on: nameOf(el) });
173
+ else if (list !== null)
174
+ block.lists.push({ el, collection, options: listOptions(el, block.issues) });
175
+ else {
176
+ const param = el.getAttribute(`${P}slug-param`);
177
+ const slug = el.getAttribute(`${P}slug`) ?? (param === null ? null : new URLSearchParams(el.ownerDocument.location?.search ?? "").get(param));
178
+ const inner = compile(el, true);
179
+ // a record inside a record has no address of its own to read a slug from
180
+ for (const nested of inner.records)
181
+ block.issues.push({ path: nested.collection, reason: "unsupported", on: nameOf(nested.el) });
182
+ block.lists.push(...inner.lists);
183
+ block.issues.push(...inner.issues);
184
+ const own = declaredOn(el, block.issues);
185
+ if (own)
186
+ inner.bound.unshift({ el, d: own });
187
+ block.records.push({
188
+ el,
189
+ collection,
190
+ slug: slug !== null && slug.trim() !== "" && slug.length <= 200 ? slug.trim() : null,
191
+ expand: listOf(el.getAttribute(`${P}expand`)),
192
+ block: { ...inner, lists: [], records: [], issues: [] },
193
+ });
194
+ }
195
+ return;
196
+ }
197
+ if (each !== null) {
198
+ block.eaches.push({ el, path: each });
199
+ return;
200
+ }
201
+ const d = declaredOn(el, block.issues);
202
+ if (d)
203
+ block.bound.push({ el, d });
204
+ }
205
+ for (const child of Array.from(el.children))
206
+ visit(child);
207
+ };
208
+ for (const child of Array.from(container.children))
209
+ visit(child);
210
+ return block;
211
+ }
212
+ /** The first segments of every path a block reads: the singles of a
213
+ page, the fields of a template. */
214
+ function heads(block) {
215
+ const out = new Map();
216
+ const add = (path, expand = []) => {
217
+ const head = parsePath(path)?.[0];
218
+ if (!head)
219
+ return;
220
+ const set = out.get(head) ?? new Set();
221
+ for (const e of expand)
222
+ set.add(e);
223
+ out.set(head, set);
224
+ };
225
+ for (const { d } of block.bound) {
226
+ add(d.bind, d.expand);
227
+ for (const [, path] of d.attrs)
228
+ add(path, d.expand);
229
+ add(d.show, d.expand);
230
+ add(d.hide, d.expand);
231
+ for (const m of (d.link ?? "").matchAll(/\{([^{}]*)\}/g))
232
+ add(m[1], d.expand);
233
+ }
234
+ for (const each of block.eaches) {
235
+ add(each.path, listOf(each.el.getAttribute(`${P}expand`)));
236
+ }
237
+ return out;
238
+ }
239
+ function look(scope, path) {
240
+ const segments = parsePath(path);
241
+ if (!segments)
242
+ return { status: "bad-path" };
243
+ if (scope.kind === "record")
244
+ return { status: "value", value: valueAt(scope.source, segments) };
245
+ // from the document a path names a single, then a field of it
246
+ if (segments.length < 2)
247
+ return { status: "bad-path" };
248
+ const single = scope.singles.get(segments[0]);
249
+ if (!single || single.status !== "ok")
250
+ return { status: single?.status === "failed" ? "failed" : "missing" };
251
+ return { status: "value", value: valueAt(single.value, segments.slice(1)) };
252
+ }
253
+ /* ---- mount ---- */
254
+ export function mount(options) {
255
+ if (typeof options?.key !== "string" || !DELIVERY_KEY.test(options.key)) {
256
+ // the value is never echoed: it may be a secret that was pasted by mistake
257
+ throw new Error("Mapled: data-mapled-key takes the delivery key (mk_…) — the one key that is safe in a page. A server key or a secret never goes into HTML.");
258
+ }
259
+ if (typeof document === "undefined")
260
+ throw new Error("Mapled: @mapled/vanilla fills a page — it runs in a browser. On a server read with @mapled/client.");
261
+ const root = options.root ?? document.documentElement;
262
+ const doc = root.ownerDocument;
263
+ const client = createClient({ key: options.key, apiUrl: options.apiUrl, appUrl: options.appUrl, fetch: options.fetch });
264
+ const sinks = { apiUrl: options.apiUrl, base: doc.baseURI };
265
+ /** what the author wrote, for everything a run changed: the next run starts from it */
266
+ const originals = new Map();
267
+ const original = (el) => {
268
+ let o = originals.get(el);
269
+ if (!o)
270
+ originals.set(el, (o = { attrs: new Map() }));
271
+ return o;
272
+ };
273
+ const keepContent = (el) => {
274
+ const o = original(el);
275
+ o.nodes ??= Array.from(el.childNodes);
276
+ };
277
+ const keepAttr = (el, name) => {
278
+ const o = original(el);
279
+ if (!o.attrs.has(name))
280
+ o.attrs.set(name, el.getAttribute(name));
281
+ };
282
+ const restore = () => {
283
+ for (const [el, o] of originals) {
284
+ if (o.nodes)
285
+ el.replaceChildren(...o.nodes);
286
+ for (const [name, value] of o.attrs) {
287
+ if (value === null)
288
+ el.removeAttribute(name);
289
+ else
290
+ el.setAttribute(name, value);
291
+ }
292
+ }
293
+ originals.clear();
294
+ };
295
+ let issues = [];
296
+ let bound = 0;
297
+ const note = (el, path, reason) => issues.push({ path: (path ?? "").trim().slice(0, 120), reason, on: nameOf(el) });
298
+ function fillElement(el, d, scope) {
299
+ if (d.bind !== undefined) {
300
+ const found = look(scope, d.bind);
301
+ if (found.status !== "value")
302
+ note(el, d.bind, found.status);
303
+ else if (!writable(el))
304
+ note(el, d.bind, "refused-element");
305
+ else {
306
+ const image = el.localName === "img";
307
+ if (image) {
308
+ keepAttr(el, "src");
309
+ keepAttr(el, "srcset");
310
+ keepAttr(el, "hidden");
311
+ }
312
+ else
313
+ keepContent(el);
314
+ if (isEmpty(found.value)) {
315
+ // published, and there is nothing to show here
316
+ if (image)
317
+ setVisible(el, false);
318
+ else
319
+ el.textContent = "";
320
+ note(el, d.bind, "empty");
321
+ }
322
+ else {
323
+ let refusal;
324
+ if (image)
325
+ refusal = writeImage(el, found.value, sinks, d.width);
326
+ else if (d.format === "markdown")
327
+ refusal = writeMarkdown(el, found.value, sinks);
328
+ else if (d.format === "text")
329
+ refusal = writeText(el, found.value);
330
+ else {
331
+ const words = formatted(found.value, d.format, el.closest("[lang]")?.getAttribute("lang") ?? undefined);
332
+ refusal = words === null ? "type" : writeText(el, words);
333
+ }
334
+ if (refusal)
335
+ note(el, d.bind, refusal);
336
+ else
337
+ bound++;
338
+ }
339
+ }
340
+ }
341
+ for (const [name, path] of d.attrs) {
342
+ const found = look(scope, path);
343
+ if (found.status !== "value") {
344
+ note(el, path, found.status);
345
+ continue;
346
+ }
347
+ keepAttr(el, name);
348
+ if (isEmpty(found.value)) {
349
+ // an image without a description is a decorative one; any other attribute goes
350
+ if (name === "alt")
351
+ el.setAttribute("alt", "");
352
+ else
353
+ el.removeAttribute(name);
354
+ note(el, path, "empty");
355
+ continue;
356
+ }
357
+ const before = el.getAttribute(name);
358
+ const refusal = writeAttribute(el, name, found.value, sinks, d.width);
359
+ if (refusal) {
360
+ if (before === null)
361
+ el.removeAttribute(name);
362
+ note(el, path, refusal);
363
+ }
364
+ else
365
+ bound++;
366
+ }
367
+ for (const [path, shows] of [[d.show, true], [d.hide, false]]) {
368
+ if (path === undefined)
369
+ continue;
370
+ const found = look(scope, path);
371
+ if (found.status !== "value") {
372
+ note(el, path, found.status);
373
+ continue;
374
+ }
375
+ keepAttr(el, "hidden");
376
+ const has = !isEmpty(found.value) && found.value !== false;
377
+ setVisible(el, has === shows);
378
+ bound++;
379
+ }
380
+ if (d.link !== undefined) {
381
+ const unread = [];
382
+ const filled = fillTemplate(d.link, sinks.base, (segments) => {
383
+ const found = look(scope, segments.join("."));
384
+ if (found.status === "value")
385
+ return found.value;
386
+ unread.push(found.status);
387
+ return undefined;
388
+ });
389
+ if (unread[0])
390
+ note(el, d.link, unread[0]);
391
+ else if (!filled.ok)
392
+ note(el, filled.path, filled.reason);
393
+ else {
394
+ keepAttr(el, "href");
395
+ const refusal = writeLink(el, filled.url);
396
+ if (refusal)
397
+ note(el, d.link, refusal);
398
+ else
399
+ bound++;
400
+ }
401
+ }
402
+ }
403
+ /** The element's template, once per item; what else it held is its fallback. */
404
+ function repeat(el, sources, depth) {
405
+ const template = templateOf(el);
406
+ if (!template) {
407
+ note(el, "template", "unsupported");
408
+ return;
409
+ }
410
+ keepContent(el);
411
+ const kept = [template];
412
+ for (const child of Array.from(el.children)) {
413
+ if (!child.hasAttribute(`${P}empty`))
414
+ continue;
415
+ keepAttr(child, "hidden");
416
+ setVisible(child, sources.length === 0);
417
+ kept.push(child);
418
+ }
419
+ const items = [];
420
+ for (const source of sources.slice(0, MAX_ITEMS)) {
421
+ const clone = template.content.cloneNode(true);
422
+ fill(compile(clone, false), { kind: "record", source }, depth + 1);
423
+ items.push(...Array.from(clone.childNodes));
424
+ }
425
+ el.replaceChildren(...kept, ...items);
426
+ bound++;
427
+ }
428
+ function fill(block, scope, depth) {
429
+ issues.push(...block.issues);
430
+ for (const { el, d } of block.bound)
431
+ fillElement(el, d, scope);
432
+ for (const each of block.eaches) {
433
+ const found = look(scope, each.path);
434
+ if (found.status !== "value") {
435
+ note(each.el, each.path, found.status);
436
+ continue;
437
+ }
438
+ if (depth >= MAX_EACH_DEPTH) {
439
+ note(each.el, each.path, "unsupported");
440
+ continue;
441
+ }
442
+ // a repeatable group is a list of items; one that isn't repeatable is a single item
443
+ const value = found.value;
444
+ const items = Array.isArray(value) ? value : isEmpty(value) ? [] : [value];
445
+ if (items.some((item) => item === null || typeof item !== "object" || Array.isArray(item))) {
446
+ note(each.el, each.path, "type");
447
+ continue;
448
+ }
449
+ if (items.length === 0)
450
+ note(each.el, each.path, "empty");
451
+ repeat(each.el, items.map((item) => ({ data: item })), depth);
452
+ }
453
+ }
454
+ async function read(load, wellFormed) {
455
+ try {
456
+ const value = await load();
457
+ if (value === null)
458
+ return { status: "missing" };
459
+ // an answer of another shape than Mapled's is no answer: the place keeps the author's HTML
460
+ return wellFormed(value) ? { status: "ok", value } : { status: "failed", answered: true };
461
+ }
462
+ catch (err) {
463
+ // an unknown collection or single is the page's mistake, not an outage
464
+ return err instanceof MapledError && err.status === 404 && err.code !== "RELEASE_NOT_FOUND" ? { status: "missing" } : { status: "failed" };
465
+ }
466
+ }
467
+ let last = null;
468
+ let warned = "";
469
+ let stopWatching = null;
470
+ let expiry;
471
+ let stopped = false;
472
+ /** moves when preview is left: a fill that began before it commits nothing */
473
+ let generation = 0;
474
+ /** the generation of the last fill that committed: an exit's own fill is skipped when one already has */
475
+ let committed = -1;
476
+ /** how many times a fill starts over when the session changed under it */
477
+ const MAX_ATTEMPTS = 3;
478
+ const interval = typeof options.live === "number" ? options.live : undefined;
479
+ /** Preview is over — the page's own way out, or the session's end: the
480
+ page is as written again, and says so, before anything is asked of
481
+ Mapled; what is published follows when it is in hand. */
482
+ function leave() {
483
+ restore();
484
+ last = { state: "fallback", release: null, preview: false, bound: 0, issues: [] };
485
+ root.setAttribute(`${P}state`, "fallback");
486
+ root.removeAttribute(`${P}preview`);
487
+ }
488
+ /** Preview ends now, not after the fill in flight: the draft goes at
489
+ once, and whatever that fill brings back is dropped. */
490
+ function drop() {
491
+ generation++;
492
+ clearTimeout(expiry);
493
+ if (last?.preview)
494
+ leave();
495
+ }
496
+ /** The session's end is a fill of its own: no draft is shown past it —
497
+ the timer stays set through fills that fail or stall. */
498
+ function watchExpiry() {
499
+ clearTimeout(expiry);
500
+ const session = previewSession();
501
+ if (!session || stopped)
502
+ return;
503
+ const left = Date.parse(session.expiresAt) - Date.now();
504
+ expiry = setTimeout(() => {
505
+ if (previewSession() === null)
506
+ drop();
507
+ void refresh();
508
+ }, Math.min(Math.max(left, 0) + 1, 0x7fffffff));
509
+ }
510
+ async function run(known, attempt = 1) {
511
+ const first = last === null;
512
+ if (first)
513
+ root.setAttribute(`${P}state`, "loading");
514
+ const mine = generation;
515
+ const preview = await client.isPreview().catch(() => false);
516
+ // the session the drafts are read with: an answer read with another one is not this fill's
517
+ const session = preview ? (previewSession()?.draftToken ?? null) : null;
518
+ // a tab that left preview, or whose session ended, shows nothing of the drafts — before anything is asked
519
+ if (last !== null && last.preview && !preview)
520
+ leave();
521
+ // releases only grow (a rollback is a new one): a fill pinned to one the page already shows filled, or an older one, has nothing to add — a page left as written keeps trying
522
+ if (!preview && known !== undefined && last !== null && last.state === "ready" && !last.preview && last.release !== null && known <= last.release)
523
+ return last;
524
+ const finish = (report) => {
525
+ last = report;
526
+ committed = mine;
527
+ root.setAttribute(`${P}state`, report.state);
528
+ root.toggleAttribute(`${P}preview`, report.preview);
529
+ const loud = report.issues.filter((i) => i.reason !== "empty");
530
+ const text = loud.map((i) => `${i.path} (${i.reason}) on ${i.on}`).join("; ");
531
+ if (text !== warned) {
532
+ warned = text;
533
+ if (loud.length > 0)
534
+ console.warn(`[mapled] ${loud.length === 1 ? "1 place" : `${loud.length} places`} couldn't be filled: ${text}`);
535
+ }
536
+ doc.dispatchEvent(new CustomEvent(first ? "mapled:ready" : "mapled:update", { detail: report }));
537
+ if (report.preview)
538
+ watchExpiry();
539
+ else {
540
+ clearTimeout(expiry);
541
+ // drafts don't move with releases: a tab watches once it shows what is published
542
+ if (options.live && stopWatching === null)
543
+ stopWatching = client.watch((next) => void refresh(next.release), { interval });
544
+ }
545
+ return report;
546
+ };
547
+ // drafts have no release; what is published is read pinned to one, so a
548
+ // publish landing mid-read can't mix two — and a pinned answer is the
549
+ // browser's to keep
550
+ let release = null;
551
+ if (!preview) {
552
+ if (known !== undefined)
553
+ release = known;
554
+ else {
555
+ try {
556
+ const pointer = await client.getRelease();
557
+ if (pointer === null) {
558
+ // nothing is published: the page is as written — a draft it showed goes too
559
+ restore();
560
+ return finish({ state: "unpublished", release: null, preview, bound: 0, issues: [] });
561
+ }
562
+ release = pointer.release;
563
+ }
564
+ catch {
565
+ // out of reach: the page stays as it is — as written, or as last filled from what is published
566
+ return last !== null && last.state === "ready" ? last : finish({ state: "fallback", release: null, preview, bound: 0, issues: [] });
567
+ }
568
+ }
569
+ }
570
+ const pin = release === null ? {} : { release };
571
+ const page = compile(root, true);
572
+ // the root element's own attributes belong to the page too
573
+ const onRoot = declaredOn(root, page.issues);
574
+ if (onRoot)
575
+ page.bound.unshift({ el: root, d: onRoot });
576
+ const singles = new Map();
577
+ const lists = new Map();
578
+ const records = new Map();
579
+ const reads = [];
580
+ for (const [key, expand] of heads(page)) {
581
+ reads.push(read(() => client.getSingle(key, { ...pin, ...(expand.size > 0 ? { expand: [...expand] } : {}) }), isRecord).then((r) => singles.set(key, r)));
582
+ }
583
+ for (const list of page.lists) {
584
+ const template = templateOf(list.el);
585
+ const fields = template ? [...heads(compile(template.content, false)).keys()] : [];
586
+ reads.push(read(async () => (await client.getRecords(list.collection, { ...list.options, ...pin, ...(fields.length > 0 ? { fields } : {}) })).records, isRecordList).then((r) => lists.set(list, r)));
587
+ }
588
+ for (const record of page.records) {
589
+ if (record.slug === null) {
590
+ records.set(record, { status: "missing" });
591
+ continue;
592
+ }
593
+ const slug = record.slug;
594
+ reads.push(read(() => client.getRecordBySlug(record.collection, slug, { ...pin, ...(record.expand.length > 0 ? { expand: record.expand } : {}) }), isRecord).then((r) => records.set(record, r)));
595
+ }
596
+ await Promise.all(reads);
597
+ // preview was left, or the session ended or was replaced, while the reads were on their way:
598
+ // what they brought is nobody's now — read again, with whatever the tab has
599
+ const now = (await client.isPreview().catch(() => false)) ? (previewSession()?.draftToken ?? null) : null;
600
+ if (mine !== generation || now !== session) {
601
+ if (attempt < MAX_ATTEMPTS)
602
+ return run(known, attempt + 1);
603
+ // the session keeps changing under the reads: show nothing of any of them — the page as written, until the next fill
604
+ restore();
605
+ return finish({ state: "fallback", release: null, preview: now !== null, bound: 0, issues: [] });
606
+ }
607
+ // nothing came back at all: an outage, and the page stays as it is — a wrong-shaped answer is still an answer, reported by its place
608
+ const answers = [...singles.values(), ...lists.values(), ...records.values()];
609
+ if (answers.length > 0 && answers.every((a) => a.status === "failed" && !a.answered)) {
610
+ return last !== null && last.state === "ready" ? last : finish({ state: "fallback", release, preview, bound: 0, issues: [] });
611
+ }
612
+ // one synchronous pass: back to what the author wrote, then filled
613
+ restore();
614
+ issues = [];
615
+ bound = 0;
616
+ fill(page, { kind: "page", singles }, 0);
617
+ for (const list of page.lists) {
618
+ const answer = lists.get(list);
619
+ if (answer?.status === "ok")
620
+ repeat(list.el, answer.value, 0);
621
+ else
622
+ note(list.el, list.collection, answer?.status ?? "failed");
623
+ }
624
+ for (const record of page.records) {
625
+ const answer = records.get(record);
626
+ if (answer?.status === "ok")
627
+ fill(record.block, { kind: "record", source: answer.value }, 0);
628
+ else
629
+ note(record.el, record.collection, answer?.status ?? "failed");
630
+ }
631
+ wireForms();
632
+ return finish({ state: "ready", release, preview, bound, issues });
633
+ }
634
+ /* ---- forms ---- */
635
+ const wired = new WeakSet();
636
+ const worded = new WeakSet();
637
+ function wireForms() {
638
+ for (const form of Array.from(root.querySelectorAll(`form[${P}form]`))) {
639
+ if (wired.has(form))
640
+ continue;
641
+ wired.add(form);
642
+ form.addEventListener("submit", (event) => {
643
+ event.preventDefault();
644
+ void send(form);
645
+ });
646
+ }
647
+ }
648
+ async function send(form) {
649
+ if (form.getAttribute(`${P}form-state`) === "sending")
650
+ return;
651
+ const key = (form.getAttribute(`${P}form`) ?? "").trim();
652
+ const sent = form.querySelector(`[${P}form-sent]`);
653
+ const failed = form.querySelector(`[${P}form-failed]`);
654
+ const buttons = Array.from(form.querySelectorAll("button, input[type=submit]")).filter((b) => !b.disabled);
655
+ form.setAttribute(`${P}form-state`, "sending");
656
+ for (const b of buttons)
657
+ b.disabled = true;
658
+ // the label of the consent box the visitor ticked is kept with the submission
659
+ const box = form.querySelector(`input[type=checkbox][${P}consent]`);
660
+ const consent = box?.checked ? (box.labels?.[0]?.textContent ?? box.closest("label")?.textContent ?? "").trim().slice(0, 500) : "";
661
+ const answer = KEY.test(key)
662
+ ? await submitForm(key, form, { key: options.key, apiUrl: options.apiUrl, fetch: options.fetch, ...(consent ? { consent } : {}) })
663
+ : { ok: false, status: 0, code: "BAD_FORM", message: "This form isn't set up yet." };
664
+ for (const b of buttons)
665
+ b.disabled = false;
666
+ form.setAttribute(`${P}form-state`, answer.ok ? "sent" : "failed");
667
+ if (answer.ok)
668
+ form.reset();
669
+ if (sent)
670
+ setVisible(sent, answer.ok);
671
+ if (failed) {
672
+ // the page's own words win; an element it left empty gets Mapled's
673
+ if (!answer.ok && (worded.has(failed) || (failed.textContent ?? "").trim() === "")) {
674
+ worded.add(failed);
675
+ failed.textContent = answer.message;
676
+ }
677
+ setVisible(failed, !answer.ok);
678
+ }
679
+ doc.dispatchEvent(new CustomEvent("mapled:form", { detail: answer.ok ? { form: key, ok: true } : { form: key, ok: false, status: answer.status, code: answer.code } }));
680
+ }
681
+ /* ---- the page ---- */
682
+ wireForms();
683
+ const guarded = async (known) => {
684
+ try {
685
+ return await run(known);
686
+ }
687
+ catch (err) {
688
+ console.error(`[mapled] ${err instanceof Error ? err.message : "The page couldn't be filled."}`);
689
+ return last ?? { state: "fallback", release: null, preview: false, bound: 0, issues: [] };
690
+ }
691
+ };
692
+ let queue = guarded();
693
+ const refresh = (known) => (queue = queue.then(() => guarded(known)));
694
+ /** Preview is left: at once on the page, then one fill — unless a fill that began after the exit already committed. */
695
+ const exit = () => {
696
+ endPreview();
697
+ drop();
698
+ const after = generation;
699
+ return (queue = queue.then(() => (committed >= after && last !== null ? last : guarded())));
700
+ };
701
+ const onClick = (event) => {
702
+ const target = event.target;
703
+ if (!target?.closest?.(`[${P}end-preview]`))
704
+ return;
705
+ event.preventDefault();
706
+ void exit();
707
+ };
708
+ doc.addEventListener("click", onClick);
709
+ return {
710
+ ready: queue,
711
+ get report() {
712
+ return last;
713
+ },
714
+ refresh: () => refresh(),
715
+ endPreview: exit,
716
+ stop: () => {
717
+ stopped = true;
718
+ stopWatching?.();
719
+ stopWatching = () => { };
720
+ clearTimeout(expiry);
721
+ doc.removeEventListener("click", onClick);
722
+ },
723
+ client,
724
+ };
725
+ }