@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.
- 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 +572 -0
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +60 -0
- package/dist/runtime.d.ts +84 -0
- package/dist/runtime.js +628 -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 +58 -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,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
|
+
}
|
package/dist/sinks.d.ts
ADDED
|
@@ -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;
|