@mapled/vanilla 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,628 @@
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, 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 element as the page names it: `a#cta`. The id is the author's. */
50
+ function nameOf(el) {
51
+ const id = el.getAttribute("id");
52
+ return `${el.localName}${id && /^[\w-]{1,40}$/.test(id) ? `#${id}` : ""}`;
53
+ }
54
+ const listOf = (text) => (text ?? "")
55
+ .split(",")
56
+ .map((t) => t.trim())
57
+ .filter((t) => /^[a-z0-9][a-z0-9_.-]{0,127}$/.test(t));
58
+ const templateOf = (el) => Array.from(el.children).find((c) => c.localName === "template") ?? null;
59
+ function declaredOn(el, issues) {
60
+ let d = null;
61
+ const get = () => (d ??= { format: "text", attrs: [], expand: [] });
62
+ for (const attr of Array.from(el.attributes)) {
63
+ if (!attr.name.startsWith(P))
64
+ continue;
65
+ const name = attr.name.slice(P.length);
66
+ if (name === "bind")
67
+ get().bind = attr.value;
68
+ else if (name.startsWith("bind-")) {
69
+ const target = name.slice(5);
70
+ if (Object.prototype.hasOwnProperty.call(ATTRIBUTES, target))
71
+ get().attrs.push([target, attr.value]);
72
+ else
73
+ issues.push({ path: attr.value.slice(0, 80), reason: "unsupported", on: `${nameOf(el)}[${attr.name.slice(0, 60)}]` });
74
+ }
75
+ else if (name === "show")
76
+ get().show = attr.value;
77
+ else if (name === "hide")
78
+ get().hide = attr.value;
79
+ else if (name === "link")
80
+ get().link = attr.value;
81
+ // a name Mapled doesn't read is most likely a typo of one it does
82
+ else if (!NAMES.has(name))
83
+ issues.push({ path: attr.value.slice(0, 80), reason: "unsupported", on: `${nameOf(el)}[${attr.name.slice(0, 60)}]` });
84
+ }
85
+ if (!d)
86
+ return null;
87
+ const declared = d;
88
+ const format = el.getAttribute(`${P}format`);
89
+ if (format !== null) {
90
+ if (FORMATS.includes(format))
91
+ declared.format = format;
92
+ else
93
+ issues.push({ path: format.slice(0, 80), reason: "bad-path", on: `${nameOf(el)}[${P}format]` });
94
+ }
95
+ const width = parseInt(el.getAttribute(`${P}width`) ?? "", 10);
96
+ if (width >= 16 && width <= 4096)
97
+ declared.width = width;
98
+ declared.expand = listOf(el.getAttribute(`${P}expand`));
99
+ return declared;
100
+ }
101
+ function listOptions(el, issues) {
102
+ const options = {};
103
+ const sort = el.getAttribute(`${P}sort`);
104
+ if (sort !== null) {
105
+ if (/^-?[a-z0-9][a-z0-9_-]{0,63}$/.test(sort.trim()))
106
+ options.sort = sort.trim();
107
+ else
108
+ issues.push({ path: sort.slice(0, 80), reason: "bad-path", on: `${nameOf(el)}[${P}sort]` });
109
+ }
110
+ const limit = parseInt(el.getAttribute(`${P}limit`) ?? "", 10);
111
+ if (limit >= 1 && limit <= 100)
112
+ options.limit = limit;
113
+ const filter = el.getAttribute(`${P}filter`);
114
+ if (filter !== null) {
115
+ for (const pair of filter.split(";")) {
116
+ if (pair.trim() === "")
117
+ continue;
118
+ const at = pair.indexOf("=");
119
+ const key = at === -1 ? "" : pair.slice(0, at).trim();
120
+ const value = at === -1 ? "" : pair.slice(at + 1).trim();
121
+ if (KEY.test(key) && value !== "" && value.length <= 200)
122
+ (options.filter ??= {})[key] = value;
123
+ else
124
+ issues.push({ path: pair.trim().slice(0, 80), reason: "bad-path", on: `${nameOf(el)}[${P}filter]` });
125
+ }
126
+ }
127
+ const expand = listOf(el.getAttribute(`${P}expand`));
128
+ if (expand.length > 0)
129
+ options.expand = expand;
130
+ return options;
131
+ }
132
+ /** Everything declared under `container`. Inside a template's clone a
133
+ list or a record of its own is not read (`page` is false): what
134
+ repeats there is the record's own data. */
135
+ function compile(container, page) {
136
+ const block = { bound: [], eaches: [], lists: [], records: [], issues: [] };
137
+ const visit = (el) => {
138
+ if (el.hasAttributes()) {
139
+ const list = el.getAttribute(`${P}list`);
140
+ const record = el.getAttribute(`${P}record`);
141
+ const each = el.getAttribute(`${P}each`);
142
+ if (list !== null || record !== null) {
143
+ const collection = (list ?? record ?? "").trim();
144
+ if (!page)
145
+ block.issues.push({ path: collection.slice(0, 80), reason: "unsupported", on: nameOf(el) });
146
+ else if (!KEY.test(collection))
147
+ block.issues.push({ path: collection.slice(0, 80), reason: "bad-path", on: nameOf(el) });
148
+ else if (list !== null)
149
+ block.lists.push({ el, collection, options: listOptions(el, block.issues) });
150
+ else {
151
+ const param = el.getAttribute(`${P}slug-param`);
152
+ const slug = el.getAttribute(`${P}slug`) ?? (param === null ? null : new URLSearchParams(el.ownerDocument.location?.search ?? "").get(param));
153
+ const inner = compile(el, true);
154
+ // a record inside a record has no address of its own to read a slug from
155
+ for (const nested of inner.records)
156
+ block.issues.push({ path: nested.collection, reason: "unsupported", on: nameOf(nested.el) });
157
+ block.lists.push(...inner.lists);
158
+ block.issues.push(...inner.issues);
159
+ const own = declaredOn(el, block.issues);
160
+ if (own)
161
+ inner.bound.unshift({ el, d: own });
162
+ block.records.push({
163
+ el,
164
+ collection,
165
+ slug: slug !== null && slug.trim() !== "" && slug.length <= 200 ? slug.trim() : null,
166
+ expand: listOf(el.getAttribute(`${P}expand`)),
167
+ block: { ...inner, lists: [], records: [], issues: [] },
168
+ });
169
+ }
170
+ return;
171
+ }
172
+ if (each !== null) {
173
+ block.eaches.push({ el, path: each });
174
+ return;
175
+ }
176
+ const d = declaredOn(el, block.issues);
177
+ if (d)
178
+ block.bound.push({ el, d });
179
+ }
180
+ for (const child of Array.from(el.children))
181
+ visit(child);
182
+ };
183
+ for (const child of Array.from(container.children))
184
+ visit(child);
185
+ return block;
186
+ }
187
+ /** The first segments of every path a block reads: the singles of a
188
+ page, the fields of a template. */
189
+ function heads(block) {
190
+ const out = new Map();
191
+ const add = (path, expand = []) => {
192
+ const head = parsePath(path)?.[0];
193
+ if (!head)
194
+ return;
195
+ const set = out.get(head) ?? new Set();
196
+ for (const e of expand)
197
+ set.add(e);
198
+ out.set(head, set);
199
+ };
200
+ for (const { d } of block.bound) {
201
+ add(d.bind, d.expand);
202
+ for (const [, path] of d.attrs)
203
+ add(path, d.expand);
204
+ add(d.show, d.expand);
205
+ add(d.hide, d.expand);
206
+ for (const m of (d.link ?? "").matchAll(/\{([^{}]*)\}/g))
207
+ add(m[1], d.expand);
208
+ }
209
+ for (const each of block.eaches) {
210
+ add(each.path, listOf(each.el.getAttribute(`${P}expand`)));
211
+ }
212
+ return out;
213
+ }
214
+ function look(scope, path) {
215
+ const segments = parsePath(path);
216
+ if (!segments)
217
+ return { status: "bad-path" };
218
+ if (scope.kind === "record")
219
+ return { status: "value", value: valueAt(scope.source, segments) };
220
+ // from the document a path names a single, then a field of it
221
+ if (segments.length < 2)
222
+ return { status: "bad-path" };
223
+ const single = scope.singles.get(segments[0]);
224
+ if (!single || single.status !== "ok")
225
+ return { status: single?.status === "failed" ? "failed" : "missing" };
226
+ return { status: "value", value: valueAt(single.value, segments.slice(1)) };
227
+ }
228
+ /* ---- mount ---- */
229
+ export function mount(options) {
230
+ if (typeof options?.key !== "string" || !DELIVERY_KEY.test(options.key)) {
231
+ // the value is never echoed: it may be a secret that was pasted by mistake
232
+ 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.");
233
+ }
234
+ if (typeof document === "undefined")
235
+ throw new Error("Mapled: @mapled/vanilla fills a page — it runs in a browser. On a server read with @mapled/client.");
236
+ const root = options.root ?? document.documentElement;
237
+ const doc = root.ownerDocument;
238
+ const client = createClient({ key: options.key, apiUrl: options.apiUrl, appUrl: options.appUrl, fetch: options.fetch });
239
+ const sinks = { apiUrl: options.apiUrl, base: doc.baseURI };
240
+ /** what the author wrote, for everything a run changed: the next run starts from it */
241
+ const originals = new Map();
242
+ const original = (el) => {
243
+ let o = originals.get(el);
244
+ if (!o)
245
+ originals.set(el, (o = { attrs: new Map() }));
246
+ return o;
247
+ };
248
+ const keepContent = (el) => {
249
+ const o = original(el);
250
+ o.nodes ??= Array.from(el.childNodes);
251
+ };
252
+ const keepAttr = (el, name) => {
253
+ const o = original(el);
254
+ if (!o.attrs.has(name))
255
+ o.attrs.set(name, el.getAttribute(name));
256
+ };
257
+ const restore = () => {
258
+ for (const [el, o] of originals) {
259
+ if (o.nodes)
260
+ el.replaceChildren(...o.nodes);
261
+ for (const [name, value] of o.attrs) {
262
+ if (value === null)
263
+ el.removeAttribute(name);
264
+ else
265
+ el.setAttribute(name, value);
266
+ }
267
+ }
268
+ originals.clear();
269
+ };
270
+ let issues = [];
271
+ let bound = 0;
272
+ const note = (el, path, reason) => issues.push({ path: (path ?? "").trim().slice(0, 120), reason, on: nameOf(el) });
273
+ function fillElement(el, d, scope) {
274
+ if (d.bind !== undefined) {
275
+ const found = look(scope, d.bind);
276
+ if (found.status !== "value")
277
+ note(el, d.bind, found.status);
278
+ else if (!writable(el))
279
+ note(el, d.bind, "refused-element");
280
+ else {
281
+ const image = el.localName === "img";
282
+ if (image) {
283
+ keepAttr(el, "src");
284
+ keepAttr(el, "srcset");
285
+ keepAttr(el, "hidden");
286
+ }
287
+ else
288
+ keepContent(el);
289
+ if (isEmpty(found.value)) {
290
+ // published, and there is nothing to show here
291
+ if (image)
292
+ setVisible(el, false);
293
+ else
294
+ el.textContent = "";
295
+ note(el, d.bind, "empty");
296
+ }
297
+ else {
298
+ let refusal;
299
+ if (image)
300
+ refusal = writeImage(el, found.value, sinks, d.width);
301
+ else if (d.format === "markdown")
302
+ refusal = writeMarkdown(el, found.value, sinks);
303
+ else if (d.format === "text")
304
+ refusal = writeText(el, found.value);
305
+ else {
306
+ const words = formatted(found.value, d.format, el.closest("[lang]")?.getAttribute("lang") ?? undefined);
307
+ refusal = words === null ? "type" : writeText(el, words);
308
+ }
309
+ if (refusal)
310
+ note(el, d.bind, refusal);
311
+ else
312
+ bound++;
313
+ }
314
+ }
315
+ }
316
+ for (const [name, path] of d.attrs) {
317
+ const found = look(scope, path);
318
+ if (found.status !== "value") {
319
+ note(el, path, found.status);
320
+ continue;
321
+ }
322
+ keepAttr(el, name);
323
+ if (isEmpty(found.value)) {
324
+ // an image without a description is a decorative one; any other attribute goes
325
+ if (name === "alt")
326
+ el.setAttribute("alt", "");
327
+ else
328
+ el.removeAttribute(name);
329
+ note(el, path, "empty");
330
+ continue;
331
+ }
332
+ const before = el.getAttribute(name);
333
+ const refusal = writeAttribute(el, name, found.value, sinks, d.width);
334
+ if (refusal) {
335
+ if (before === null)
336
+ el.removeAttribute(name);
337
+ note(el, path, refusal);
338
+ }
339
+ else
340
+ bound++;
341
+ }
342
+ for (const [path, shows] of [[d.show, true], [d.hide, false]]) {
343
+ if (path === undefined)
344
+ continue;
345
+ const found = look(scope, path);
346
+ if (found.status !== "value") {
347
+ note(el, path, found.status);
348
+ continue;
349
+ }
350
+ keepAttr(el, "hidden");
351
+ const has = !isEmpty(found.value) && found.value !== false;
352
+ setVisible(el, has === shows);
353
+ bound++;
354
+ }
355
+ if (d.link !== undefined) {
356
+ const unread = [];
357
+ const filled = fillTemplate(d.link, sinks.base, (segments) => {
358
+ const found = look(scope, segments.join("."));
359
+ if (found.status === "value")
360
+ return found.value;
361
+ unread.push(found.status);
362
+ return undefined;
363
+ });
364
+ if (unread[0])
365
+ note(el, d.link, unread[0]);
366
+ else if (!filled.ok)
367
+ note(el, filled.path, filled.reason);
368
+ else {
369
+ keepAttr(el, "href");
370
+ const refusal = writeLink(el, filled.url);
371
+ if (refusal)
372
+ note(el, d.link, refusal);
373
+ else
374
+ bound++;
375
+ }
376
+ }
377
+ }
378
+ /** The element's template, once per item; what else it held is its fallback. */
379
+ function repeat(el, sources, depth) {
380
+ const template = templateOf(el);
381
+ if (!template) {
382
+ note(el, "template", "unsupported");
383
+ return;
384
+ }
385
+ keepContent(el);
386
+ const kept = [template];
387
+ for (const child of Array.from(el.children)) {
388
+ if (!child.hasAttribute(`${P}empty`))
389
+ continue;
390
+ keepAttr(child, "hidden");
391
+ setVisible(child, sources.length === 0);
392
+ kept.push(child);
393
+ }
394
+ const items = [];
395
+ for (const source of sources) {
396
+ const clone = template.content.cloneNode(true);
397
+ fill(compile(clone, false), { kind: "record", source }, depth + 1);
398
+ items.push(...Array.from(clone.childNodes));
399
+ }
400
+ el.replaceChildren(...kept, ...items);
401
+ bound++;
402
+ }
403
+ function fill(block, scope, depth) {
404
+ issues.push(...block.issues);
405
+ for (const { el, d } of block.bound)
406
+ fillElement(el, d, scope);
407
+ for (const each of block.eaches) {
408
+ const found = look(scope, each.path);
409
+ if (found.status !== "value") {
410
+ note(each.el, each.path, found.status);
411
+ continue;
412
+ }
413
+ if (depth >= MAX_EACH_DEPTH) {
414
+ note(each.el, each.path, "unsupported");
415
+ continue;
416
+ }
417
+ // a repeatable group is a list of items; one that isn't repeatable is a single item
418
+ const value = found.value;
419
+ const items = Array.isArray(value) ? value : isEmpty(value) ? [] : [value];
420
+ if (items.some((item) => item === null || typeof item !== "object" || Array.isArray(item))) {
421
+ note(each.el, each.path, "type");
422
+ continue;
423
+ }
424
+ if (items.length === 0)
425
+ note(each.el, each.path, "empty");
426
+ repeat(each.el, items.map((item) => ({ data: item })), depth);
427
+ }
428
+ }
429
+ async function read(load) {
430
+ try {
431
+ const value = await load();
432
+ return value === null ? { status: "missing" } : { status: "ok", value };
433
+ }
434
+ catch (err) {
435
+ // an unknown collection or single is the page's mistake, not an outage
436
+ return err instanceof MapledError && err.status === 404 && err.code !== "RELEASE_NOT_FOUND" ? { status: "missing" } : { status: "failed" };
437
+ }
438
+ }
439
+ let last = null;
440
+ let warned = "";
441
+ async function run(known) {
442
+ const first = last === null;
443
+ if (first)
444
+ root.setAttribute(`${P}state`, "loading");
445
+ const preview = await client.isPreview().catch(() => false);
446
+ const finish = (report) => {
447
+ last = report;
448
+ root.setAttribute(`${P}state`, report.state);
449
+ root.toggleAttribute(`${P}preview`, report.preview);
450
+ const loud = report.issues.filter((i) => i.reason !== "empty");
451
+ const text = loud.map((i) => `${i.path} (${i.reason}) on ${i.on}`).join("; ");
452
+ if (text !== warned) {
453
+ warned = text;
454
+ if (loud.length > 0)
455
+ console.warn(`[mapled] ${loud.length === 1 ? "1 place" : `${loud.length} places`} couldn't be filled: ${text}`);
456
+ }
457
+ doc.dispatchEvent(new CustomEvent(first ? "mapled:ready" : "mapled:update", { detail: report }));
458
+ return report;
459
+ };
460
+ // drafts have no release; what is published is read pinned to one, so a
461
+ // publish landing mid-read can't mix two — and a pinned answer is the
462
+ // browser's to keep
463
+ let release = null;
464
+ if (!preview) {
465
+ if (known !== undefined)
466
+ release = known;
467
+ else {
468
+ try {
469
+ const pointer = await client.getRelease();
470
+ if (pointer === null)
471
+ return finish({ state: "unpublished", release: null, preview, bound: 0, issues: [] });
472
+ release = pointer.release;
473
+ }
474
+ catch {
475
+ // out of reach: the page stays as it is — as written, or as last filled
476
+ return last !== null && last.state === "ready" ? last : finish({ state: "fallback", release: null, preview, bound: 0, issues: [] });
477
+ }
478
+ }
479
+ }
480
+ const pin = release === null ? {} : { release };
481
+ const page = compile(root, true);
482
+ // the root element's own attributes belong to the page too
483
+ const onRoot = declaredOn(root, page.issues);
484
+ if (onRoot)
485
+ page.bound.unshift({ el: root, d: onRoot });
486
+ const singles = new Map();
487
+ const lists = new Map();
488
+ const records = new Map();
489
+ const reads = [];
490
+ for (const [key, expand] of heads(page)) {
491
+ reads.push(read(() => client.getSingle(key, { ...pin, ...(expand.size > 0 ? { expand: [...expand] } : {}) })).then((r) => singles.set(key, r)));
492
+ }
493
+ for (const list of page.lists) {
494
+ const template = templateOf(list.el);
495
+ const fields = template ? [...heads(compile(template.content, false)).keys()] : [];
496
+ reads.push(read(async () => (await client.getRecords(list.collection, { ...list.options, ...pin, ...(fields.length > 0 ? { fields } : {}) })).records).then((r) => lists.set(list, r)));
497
+ }
498
+ for (const record of page.records) {
499
+ if (record.slug === null) {
500
+ records.set(record, { status: "missing" });
501
+ continue;
502
+ }
503
+ const slug = record.slug;
504
+ reads.push(read(() => client.getRecordBySlug(record.collection, slug, { ...pin, ...(record.expand.length > 0 ? { expand: record.expand } : {}) })).then((r) => records.set(record, r)));
505
+ }
506
+ await Promise.all(reads);
507
+ const answers = [...singles.values(), ...lists.values(), ...records.values()];
508
+ if (answers.length > 0 && answers.every((a) => a.status === "failed")) {
509
+ return last !== null && last.state === "ready" ? last : finish({ state: "fallback", release, preview, bound: 0, issues: [] });
510
+ }
511
+ // one synchronous pass: back to what the author wrote, then filled
512
+ restore();
513
+ issues = [];
514
+ bound = 0;
515
+ fill(page, { kind: "page", singles }, 0);
516
+ for (const list of page.lists) {
517
+ const answer = lists.get(list);
518
+ if (answer?.status === "ok")
519
+ repeat(list.el, answer.value, 0);
520
+ else
521
+ note(list.el, list.collection, answer?.status ?? "failed");
522
+ }
523
+ for (const record of page.records) {
524
+ const answer = records.get(record);
525
+ if (answer?.status === "ok")
526
+ fill(record.block, { kind: "record", source: answer.value }, 0);
527
+ else
528
+ note(record.el, record.collection, answer?.status ?? "failed");
529
+ }
530
+ wireForms();
531
+ return finish({ state: "ready", release, preview, bound, issues });
532
+ }
533
+ /* ---- forms ---- */
534
+ const wired = new WeakSet();
535
+ const worded = new WeakSet();
536
+ function wireForms() {
537
+ for (const form of Array.from(root.querySelectorAll(`form[${P}form]`))) {
538
+ if (wired.has(form))
539
+ continue;
540
+ wired.add(form);
541
+ form.addEventListener("submit", (event) => {
542
+ event.preventDefault();
543
+ void send(form);
544
+ });
545
+ }
546
+ }
547
+ async function send(form) {
548
+ if (form.getAttribute(`${P}form-state`) === "sending")
549
+ return;
550
+ const key = (form.getAttribute(`${P}form`) ?? "").trim();
551
+ const sent = form.querySelector(`[${P}form-sent]`);
552
+ const failed = form.querySelector(`[${P}form-failed]`);
553
+ const buttons = Array.from(form.querySelectorAll("button, input[type=submit]")).filter((b) => !b.disabled);
554
+ form.setAttribute(`${P}form-state`, "sending");
555
+ for (const b of buttons)
556
+ b.disabled = true;
557
+ // the label of the consent box the visitor ticked is kept with the submission
558
+ const box = form.querySelector(`input[type=checkbox][${P}consent]`);
559
+ const consent = box?.checked ? (box.labels?.[0]?.textContent ?? box.closest("label")?.textContent ?? "").trim().slice(0, 500) : "";
560
+ const answer = KEY.test(key)
561
+ ? await submitForm(key, form, { key: options.key, apiUrl: options.apiUrl, fetch: options.fetch, ...(consent ? { consent } : {}) })
562
+ : { ok: false, status: 0, code: "BAD_FORM", message: "This form isn't set up yet." };
563
+ for (const b of buttons)
564
+ b.disabled = false;
565
+ form.setAttribute(`${P}form-state`, answer.ok ? "sent" : "failed");
566
+ if (answer.ok)
567
+ form.reset();
568
+ if (sent)
569
+ setVisible(sent, answer.ok);
570
+ if (failed) {
571
+ // the page's own words win; an element it left empty gets Mapled's
572
+ if (!answer.ok && (worded.has(failed) || (failed.textContent ?? "").trim() === "")) {
573
+ worded.add(failed);
574
+ failed.textContent = answer.message;
575
+ }
576
+ setVisible(failed, !answer.ok);
577
+ }
578
+ doc.dispatchEvent(new CustomEvent("mapled:form", { detail: answer.ok ? { form: key, ok: true } : { form: key, ok: false, status: answer.status, code: answer.code } }));
579
+ }
580
+ /* ---- the page ---- */
581
+ wireForms();
582
+ const guarded = async (known) => {
583
+ try {
584
+ return await run(known);
585
+ }
586
+ catch (err) {
587
+ console.error(`[mapled] ${err instanceof Error ? err.message : "The page couldn't be filled."}`);
588
+ return last ?? { state: "fallback", release: null, preview: false, bound: 0, issues: [] };
589
+ }
590
+ };
591
+ let queue = guarded();
592
+ const refresh = (known) => (queue = queue.then(() => guarded(known)));
593
+ const onClick = (event) => {
594
+ const target = event.target;
595
+ if (!target?.closest?.(`[${P}end-preview]`))
596
+ return;
597
+ event.preventDefault();
598
+ endPreview();
599
+ void refresh();
600
+ };
601
+ doc.addEventListener("click", onClick);
602
+ let stopWatching = null;
603
+ if (options.live) {
604
+ const interval = typeof options.live === "number" ? options.live : undefined;
605
+ // drafts don't move with releases: a tab in preview doesn't watch
606
+ void queue.then((report) => {
607
+ if (!report.preview && stopWatching === null)
608
+ stopWatching = client.watch((next) => void refresh(next.release), { interval });
609
+ });
610
+ }
611
+ return {
612
+ ready: queue,
613
+ get report() {
614
+ return last;
615
+ },
616
+ refresh: () => refresh(),
617
+ endPreview: () => {
618
+ endPreview();
619
+ return refresh();
620
+ },
621
+ stop: () => {
622
+ stopWatching?.();
623
+ stopWatching = () => { };
624
+ doc.removeEventListener("click", onClick);
625
+ },
626
+ client,
627
+ };
628
+ }
@@ -0,0 +1,41 @@
1
+ /** The sinks: the only places where a value from Mapled touches the
2
+ page. Each one is named by the page's author (an attribute of the
3
+ page), never by the value; each holds the value to its own rule of
4
+ DOM_SECURITY.md and, when the value doesn't fit, leaves the page as it
5
+ was and says why. */
6
+ import { type AddressKind } from "./url.js";
7
+ export type Refusal = "refused-element" | "unsupported" | "unsafe-url" | "type";
8
+ /** The attributes content may fill (rule 3): the name that is written,
9
+ the elements it belongs to (null — any HTML element), what the value
10
+ is held to. */
11
+ export declare const ATTRIBUTES: Record<string, {
12
+ on: readonly string[] | null;
13
+ holds: AddressKind | "text";
14
+ }>;
15
+ export type SinkContext = {
16
+ /** the API the page reads — where asset ids resolve */
17
+ apiUrl?: string;
18
+ /** what relative addresses resolve against: the document's base */
19
+ base: string;
20
+ };
21
+ /** Whether the element may be written into at all. */
22
+ export declare const writable: (el: Element) => boolean;
23
+ /** The element's text. Markup in the value stays text: this is the one
24
+ assignment content reaches, and it parses nothing. */
25
+ export declare function writeText(el: Element, value: unknown): Refusal | null;
26
+ /** The element's content from Markdown. */
27
+ export declare function writeMarkdown(el: Element, value: unknown, ctx: SinkContext): Refusal | null;
28
+ /** `<img data-mapled-bind>`: the picture. */
29
+ export declare function writeImage(el: Element, value: unknown, ctx: SinkContext, width?: number): Refusal | null;
30
+ /** One attribute of the table. `name` is the page's spelling: it is
31
+ looked up, never written as it came. */
32
+ export declare function writeAttribute(el: Element, name: string, value: unknown, ctx: SinkContext, width?: number): Refusal | null;
33
+ /** A link built from the page's own template (url.ts › fillTemplate). */
34
+ export declare function writeLink(el: Element, url: string): Refusal | null;
35
+ /** Shown or not — the `hidden` attribute and nothing else. */
36
+ export declare function setVisible(el: Element, visible: boolean): void;
37
+ export type Format = "text" | "markdown" | "date" | "datetime" | "number";
38
+ export declare const FORMATS: readonly Format[];
39
+ /** A date or a number in the words of the page's language. Null when
40
+ the value isn't one. */
41
+ export declare function formatted(value: unknown, format: "date" | "datetime" | "number", lang: string | undefined): string | null;