@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.
- package/README.md +192 -5
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mapled.js +18 -0
- package/dist/mapled.js.sha384 +1 -0
- package/dist/markdown.d.ts +15 -0
- package/dist/markdown.js +577 -0
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +60 -0
- package/dist/runtime.d.ts +85 -0
- package/dist/runtime.js +725 -0
- package/dist/sinks.d.ts +41 -0
- package/dist/sinks.js +142 -0
- package/dist/url.d.ts +30 -0
- package/dist/url.js +66 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +3 -0
- package/package.json +40 -5
package/dist/runtime.js
ADDED
|
@@ -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
|
+
}
|