@transclude/core 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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
package/src/document.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { withPolicy } from './csp.js';
|
|
2
|
+
|
|
3
|
+
// Assembles the document around a layout chain.
|
|
4
|
+
//
|
|
5
|
+
// The chain is outermost layout first, page last. Body markup folds inward-out:
|
|
6
|
+
// the page renders, then each layout renders around what it got.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loads a page's chain and renders the document. Layout loaders run outermost
|
|
10
|
+
* first, each one given what the ones above returned, so they have to run one
|
|
11
|
+
* after another.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The part of the answer that is not markup: a status and some headers.
|
|
15
|
+
*
|
|
16
|
+
* One object, handed to every loader in the chain and mutated in place. Loaders
|
|
17
|
+
* are called with `{ ...ctx, layout }`, so a scalar assigned onto `ctx` would be
|
|
18
|
+
* lost on the copy. This survives because the copy carries the same reference.
|
|
19
|
+
* Built here rather than in each server, because there are three of them and
|
|
20
|
+
* that is exactly how two servers end up disagreeing.
|
|
21
|
+
*/
|
|
22
|
+
export function responseOf() {
|
|
23
|
+
return { status: 200, headers: new Headers() };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `ctx.absolute('/og.png')` -> `https://site.com/og.png`.
|
|
28
|
+
*
|
|
29
|
+
* A canonical URL, an `og:image` and a feed all have to be absolute, and the
|
|
30
|
+
* request's own origin is the wrong answer twice: behind a proxy it is the
|
|
31
|
+
* internal one, and while prerendering there is no request at all. So the origin
|
|
32
|
+
* comes from `metadataBase` when it is set, and falls back to the request.
|
|
33
|
+
*
|
|
34
|
+
* A path that is already absolute is returned untouched, so a value that came
|
|
35
|
+
* from somewhere else can be passed through without checking it first.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|null|undefined} base `metadataBase` from the config
|
|
38
|
+
* @param {string} requestUrl
|
|
39
|
+
* @returns {URL}
|
|
40
|
+
*/
|
|
41
|
+
export function absoluteFrom(base, requestUrl) {
|
|
42
|
+
return (path) => {
|
|
43
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return path;
|
|
44
|
+
|
|
45
|
+
const origin = base ?? requestUrl;
|
|
46
|
+
if (!origin) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[transclude] absolute(${JSON.stringify(path)}) has no origin to resolve against. ` +
|
|
49
|
+
`Set \`metadataBase\` in the config, which is what a prerendered page uses.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return new URL(path, origin).href;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which `<meta>` and `<link>` may appear once, and what identifies them.
|
|
58
|
+
*
|
|
59
|
+
* `name`, `property` and `http-equiv` each name a different meta. Only
|
|
60
|
+
* `rel="canonical"` is unique among links: a page has several `alternate`s for
|
|
61
|
+
* its feeds and translations, and several `preload`s, all meant.
|
|
62
|
+
*/
|
|
63
|
+
function headKey(tag) {
|
|
64
|
+
const name = tagAttr(tag, 'name');
|
|
65
|
+
if (name !== null) return `meta name=${name.toLowerCase()}`;
|
|
66
|
+
|
|
67
|
+
const property = tagAttr(tag, 'property');
|
|
68
|
+
if (property !== null) return `meta property=${property.toLowerCase()}`;
|
|
69
|
+
|
|
70
|
+
const equiv = tagAttr(tag, 'http-equiv');
|
|
71
|
+
if (equiv !== null) return `meta http-equiv=${equiv.toLowerCase()}`;
|
|
72
|
+
|
|
73
|
+
const rel = tagAttr(tag, 'rel');
|
|
74
|
+
if (rel !== null && rel.toLowerCase() === 'canonical') return 'link rel=canonical';
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ATTR = /([a-zA-Z][\w-]*)\s*=\s*"([^"]*)"/g;
|
|
80
|
+
|
|
81
|
+
/** A quoted value cannot hold a `"`, because both escapers turn it into `"`. */
|
|
82
|
+
function tagAttr(tag, want) {
|
|
83
|
+
ATTR.lastIndex = 0;
|
|
84
|
+
for (let m = ATTR.exec(tag); m; m = ATTR.exec(tag)) {
|
|
85
|
+
if (m[1].toLowerCase() === want) return m[2];
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where a `<meta>` or `<link>` ends.
|
|
92
|
+
*
|
|
93
|
+
* Quote-aware, because `escapeAttr` leaves `>` alone: `content="a > b"` is legal
|
|
94
|
+
* HTML and stops nothing, and looking for the first `>` would cut the tag in
|
|
95
|
+
* half.
|
|
96
|
+
*/
|
|
97
|
+
function tagEnd(html, from) {
|
|
98
|
+
let quote = null;
|
|
99
|
+
for (let i = from; i < html.length; i++) {
|
|
100
|
+
const c = html[i];
|
|
101
|
+
if (quote) {
|
|
102
|
+
if (c === quote) quote = null;
|
|
103
|
+
} else if (c === '"' || c === "'") {
|
|
104
|
+
quote = c;
|
|
105
|
+
} else if (c === '>') {
|
|
106
|
+
return i + 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return html.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const HEAD_TAG = /<(meta|link)\b/gi;
|
|
113
|
+
|
|
114
|
+
/** Every uniquely-named tag in one level's head, as `{ start, end, key }`. */
|
|
115
|
+
function keyedTags(html) {
|
|
116
|
+
const found = [];
|
|
117
|
+
HEAD_TAG.lastIndex = 0;
|
|
118
|
+
for (let m = HEAD_TAG.exec(html); m; m = HEAD_TAG.exec(html)) {
|
|
119
|
+
const end = tagEnd(html, m.index);
|
|
120
|
+
const key = headKey(html.slice(m.index, end));
|
|
121
|
+
if (key) found.push({ start: m.index, end, key });
|
|
122
|
+
HEAD_TAG.lastIndex = end;
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A tag an inner level restates, dropped from the outer one.
|
|
129
|
+
*
|
|
130
|
+
* The chain was concatenated, so a root layout with a default `og:image` and a
|
|
131
|
+
* page with its own shipped both. That is not an override: a crawler reads two
|
|
132
|
+
* `og:image` as two images and takes the first, which is the outermost, which is
|
|
133
|
+
* backwards. This is the rule `htmlAttrsOf` applies to `<html>`, which `<head>`
|
|
134
|
+
* never got.
|
|
135
|
+
*
|
|
136
|
+
* Across levels only. Two `og:image` at one level are deliberate and both stay.
|
|
137
|
+
*
|
|
138
|
+
* @param {string[]} parts one rendered head per level, outermost first
|
|
139
|
+
* @returns {string[]} the same, with what an inner level owns removed
|
|
140
|
+
*/
|
|
141
|
+
function mergeHead(parts) {
|
|
142
|
+
if (parts.length < 2) return parts;
|
|
143
|
+
|
|
144
|
+
const tags = parts.map((html) => (html ? keyedTags(html) : []));
|
|
145
|
+
const claimed = new Set();
|
|
146
|
+
const out = new Array(parts.length);
|
|
147
|
+
|
|
148
|
+
// Innermost first, so the first level to claim a key is the one that keeps it.
|
|
149
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
150
|
+
const drop = tags[i].filter((tag) => claimed.has(tag.key));
|
|
151
|
+
|
|
152
|
+
if (drop.length) {
|
|
153
|
+
let html = '';
|
|
154
|
+
let at = 0;
|
|
155
|
+
for (const tag of drop) {
|
|
156
|
+
html += parts[i].slice(at, tag.start);
|
|
157
|
+
at = tag.end;
|
|
158
|
+
}
|
|
159
|
+
out[i] = (html + parts[i].slice(at)).trim();
|
|
160
|
+
} else {
|
|
161
|
+
out[i] = parts[i];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// After the level is done, so two of one key written here both survive.
|
|
165
|
+
for (const tag of tags[i]) claimed.add(tag.key);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A name that cannot break out of the tag it is written into. */
|
|
171
|
+
const ATTR_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
172
|
+
|
|
173
|
+
// The same four the runtime's `attr` escapes. A quoted value only strictly needs
|
|
174
|
+
// `&` and `"`, but two escapers that disagree is a difference somebody has to
|
|
175
|
+
// hold in their head.
|
|
176
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
177
|
+
|
|
178
|
+
const escapeAttr = (value) => String(value).replace(/[&<>"]/g, (c) => ESCAPES[c]);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `<html …>`, with `lang` first and whatever a loader added after it.
|
|
182
|
+
*
|
|
183
|
+
* Values are escaped, because the reason this exists is putting a preference on
|
|
184
|
+
* the element, and a preference usually comes from a cookie. `true` writes the
|
|
185
|
+
* name bare and `false` drops it, the same rule the template compiler uses.
|
|
186
|
+
*/
|
|
187
|
+
/**
|
|
188
|
+
* Every `<html>` in the chain, merged by name.
|
|
189
|
+
*
|
|
190
|
+
* Outermost first so the innermost wins, per attribute rather than outright: a
|
|
191
|
+
* root layout setting the theme and a page setting `dir` both survive. Written
|
|
192
|
+
* as one tag, because two `data-theme` attributes would leave the parser taking
|
|
193
|
+
* the first, which is the outermost, which is backwards.
|
|
194
|
+
*/
|
|
195
|
+
function htmlAttrsOf(chain, datas) {
|
|
196
|
+
const merged = { __proto__: null };
|
|
197
|
+
for (let i = 0; i < chain.length; i++) {
|
|
198
|
+
Object.assign(merged, chain[i].renderHtmlAttrs?.(datas[i]));
|
|
199
|
+
}
|
|
200
|
+
return merged;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function htmlOpenTag(lang, attrs) {
|
|
204
|
+
const parts = [];
|
|
205
|
+
|
|
206
|
+
for (const [name, value] of Object.entries({ lang, ...attrs })) {
|
|
207
|
+
if (value === false || value === null || value === undefined) continue;
|
|
208
|
+
if (!ATTR_NAME.test(name)) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`[transclude] \`${name}\` cannot be an attribute on <html>. ` +
|
|
211
|
+
`Use lowercase letters, digits and dashes.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
parts.push(value === true ? name : `${name}="${escapeAttr(value)}"`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return `<html ${parts.join(' ')}>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Loads a page's chain and renders the document, or returns the `Response` a
|
|
222
|
+
* loader answered with instead.
|
|
223
|
+
*
|
|
224
|
+
* Returning one is how a loader redirects, or serves something that is not this
|
|
225
|
+
* page at all. It is the same convention an action already uses, so there is one
|
|
226
|
+
* rule rather than two. A layout can do it too, which is what makes an auth redirect
|
|
227
|
+
* a layout's job: nothing below it runs.
|
|
228
|
+
*
|
|
229
|
+
* For everything else the page still renders, and `ctx.response` decides what it
|
|
230
|
+
* is wrapped in: a 404 status on a page that renders its own "not found" body, an
|
|
231
|
+
* `HX-Trigger` header, a `Set-Cookie`.
|
|
232
|
+
*
|
|
233
|
+
* @param {object} page a compiled page module
|
|
234
|
+
* @param {object} ctx the request context
|
|
235
|
+
* @param {object} [options] `clientEntry`, `stylesheet`, `csp`, `lang`, `include`
|
|
236
|
+
* @returns {Promise<string|Response>} a Response when a loader answered for itself
|
|
237
|
+
*/
|
|
238
|
+
export async function renderRoute(page, ctx, options = {}) {
|
|
239
|
+
// Held for this request only. A page including one route twice should run its
|
|
240
|
+
// loaders once; a store that outlived the request would be a second page cache
|
|
241
|
+
// nobody audited.
|
|
242
|
+
const request = { ...options, includeMemo: options.includeMemo ?? new Map() };
|
|
243
|
+
const chain = [...page.layouts, page];
|
|
244
|
+
const datas = [];
|
|
245
|
+
let inherited = {};
|
|
246
|
+
|
|
247
|
+
for (const mod of chain) {
|
|
248
|
+
const data = await mod.load({ ...ctx, layout: inherited });
|
|
249
|
+
if (data instanceof Response) return data;
|
|
250
|
+
|
|
251
|
+
// Before the render, because render is synchronous the whole way down and
|
|
252
|
+
// reading another document is not. A layout and a page each resolve their
|
|
253
|
+
// own, so neither can see the other's by accident.
|
|
254
|
+
datas.push(
|
|
255
|
+
mod.includes?.length
|
|
256
|
+
? { ...data, __included: await resolveIncludes(mod.includes, ctx, request) }
|
|
257
|
+
: data,
|
|
258
|
+
);
|
|
259
|
+
if (mod !== page) inherited = { ...inherited, ...data };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const html = renderDocument(chain, datas, options);
|
|
263
|
+
|
|
264
|
+
// After the document exists, because the policy is built from what it inlined.
|
|
265
|
+
// A prerendered page runs this once at build time and carries the result.
|
|
266
|
+
return withPolicy(html, options.csp);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The markup for every `<transclude>` naming another document.
|
|
271
|
+
*
|
|
272
|
+
* All of them at once: ten includes off one page should be one round of work,
|
|
273
|
+
* and the resolver holds the parsed document so several from one source cost one
|
|
274
|
+
* read. A source that cannot be read is null here and the element falls back to
|
|
275
|
+
* its own children, or throws if it has none.
|
|
276
|
+
*/
|
|
277
|
+
export const INCLUDE_DEPTH = 10;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The parameters a route's pattern takes from a path, or null if it does not
|
|
281
|
+
* match.
|
|
282
|
+
*
|
|
283
|
+
* Only what a route pattern can hold: `:name` and a trailing `:name{.+}`. An
|
|
284
|
+
* include names a path an author wrote, so this answers the same question the
|
|
285
|
+
* router does without needing the router.
|
|
286
|
+
*
|
|
287
|
+
* @param {{ pattern: string }} route
|
|
288
|
+
* @param {string} pathname
|
|
289
|
+
* @returns {Record<string, string>|null} null when the route does not match
|
|
290
|
+
*/
|
|
291
|
+
export function paramsFor(route, pathname) {
|
|
292
|
+
const names = [];
|
|
293
|
+
const source = route.pattern
|
|
294
|
+
.replace(/\/:([A-Za-z0-9_]+)\{\.\+\}/g, (_, name) => (names.push(name), '/(.+)'))
|
|
295
|
+
.replace(/:([A-Za-z0-9_]+)/g, (_, name) => (names.push(name), '([^/]+)'));
|
|
296
|
+
|
|
297
|
+
const found = new RegExp(`^${source}$`).exec(pathname);
|
|
298
|
+
if (!found) return null;
|
|
299
|
+
|
|
300
|
+
return Object.fromEntries(names.map((name, at) => [name, decodeURIComponent(found[at + 1])]));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {Array<{ key: string, kind: string, where: string, id: string }>} includes
|
|
305
|
+
* @param {object} ctx
|
|
306
|
+
* @param {object} [options] carries `include`, `includeMemo` and `includeChain`
|
|
307
|
+
* @returns {Promise<Record<string, string|null>>} keyed by the src as written
|
|
308
|
+
*/
|
|
309
|
+
export async function resolveIncludes(includes, ctx, options = {}) {
|
|
310
|
+
const include = options.include ?? null;
|
|
311
|
+
const chain = options.includeChain ?? [];
|
|
312
|
+
|
|
313
|
+
const pairs = await Promise.all(
|
|
314
|
+
includes.map(async ({ key, kind, where, id }) => {
|
|
315
|
+
// A page that includes itself, directly or through three others, would
|
|
316
|
+
// otherwise render until the stack ran out. The chain is carried so the
|
|
317
|
+
// error can name the way round.
|
|
318
|
+
if (chain.includes(key)) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`[transclude] <transclude> includes itself: ` +
|
|
321
|
+
`${[...chain, key].join(' includes ')}.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (chain.length >= INCLUDE_DEPTH) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`[transclude] <transclude src="${key}"> is ${chain.length} includes deep, ` +
|
|
327
|
+
`past the limit of ${INCLUDE_DEPTH}.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const next = { ...options, includeChain: [...chain, key] };
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
if (kind === 'route') {
|
|
335
|
+
if (!include?.route) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`[transclude] <transclude src="${key}"> reads another route, ` +
|
|
338
|
+
`and this renderer was given no way to reach one.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return [key, await include.route(where, id, ctx, next)];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!include?.resolve) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`[transclude] <transclude src="${key}"> reads another site, ` +
|
|
347
|
+
`and no host is allowed to be read. Name one in \`proxy.allow\`.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return [key, await include.resolve(where, id)];
|
|
351
|
+
} catch (error) {
|
|
352
|
+
// A source that is unreachable is what the fallback is for. A source
|
|
353
|
+
// that is misconfigured, or a loop, is a mistake and should be heard.
|
|
354
|
+
if (/includes itself|past the limit|no host is allowed|no way to reach/.test(error.message)) {
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
return [key, null];
|
|
358
|
+
}
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
return Object.fromEntries(pairs);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* One region of a page, for swapping into a document that already exists.
|
|
366
|
+
*
|
|
367
|
+
* The layout loaders still run: a page's own loader is handed what they
|
|
368
|
+
* returned, so skipping them would change the data the region renders from.
|
|
369
|
+
* What is skipped is the layouts' markup. A fragment is a piece of the page, not a
|
|
370
|
+
* document.
|
|
371
|
+
*
|
|
372
|
+
* Returns null when the page has no region by that name, which is a 404 rather
|
|
373
|
+
* than an empty swap: asking for something that does not exist should say so.
|
|
374
|
+
*
|
|
375
|
+
* @param {object} page
|
|
376
|
+
* @param {object} ctx
|
|
377
|
+
* @param {{ region?: string|null, include?: object, includeMemo?: Map<string, unknown> }}
|
|
378
|
+
* [options] everything but `region` travels on to the includes
|
|
379
|
+
* @returns {Promise<string|Response|null>} null when no such region
|
|
380
|
+
*/
|
|
381
|
+
export async function renderFragment(page, ctx, { region = null, ...options } = {}) {
|
|
382
|
+
const target = region ? regionOf(page, region) : null;
|
|
383
|
+
if (region && !target) return null;
|
|
384
|
+
|
|
385
|
+
const chain = [...page.layouts, page];
|
|
386
|
+
let inherited = {};
|
|
387
|
+
let data = {};
|
|
388
|
+
|
|
389
|
+
for (const mod of chain) {
|
|
390
|
+
data = await mod.load({ ...ctx, layout: inherited });
|
|
391
|
+
// A loader answering with a Response outranks the region that was asked for.
|
|
392
|
+
if (data instanceof Response) return data;
|
|
393
|
+
if (mod !== page) inherited = { ...inherited, ...data };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const last = chain[chain.length - 1];
|
|
397
|
+
if (last.includes?.length) {
|
|
398
|
+
// The same memo `renderRoute` makes, for the same reason: one route
|
|
399
|
+
// included twice renders once, and the store dies with the request.
|
|
400
|
+
const request = { ...options, includeMemo: options.includeMemo ?? new Map() };
|
|
401
|
+
data = { ...data, __included: await resolveIncludes(last.includes, ctx, request) };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// No region named: the page's whole body, still without its layouts.
|
|
405
|
+
if (!target) return page.render(data, {}, true).default ?? '';
|
|
406
|
+
return target(data, {}, true);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The methods a server routes to `runAction`. Both servers register all of them
|
|
411
|
+
* for every route: a page that answers none of them should say 405 with an
|
|
412
|
+
* `Allow` header rather than fall through to the not-found page, because the URL
|
|
413
|
+
* is not what was wrong.
|
|
414
|
+
*
|
|
415
|
+
* A `<form>` only ever sends GET or POST. The rest are here for the callers that
|
|
416
|
+
* are not forms.
|
|
417
|
+
*/
|
|
418
|
+
export const ACTION_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Runs the page's handler for a request that is not a GET.
|
|
422
|
+
*
|
|
423
|
+
* A `Response` is the author's own answer and goes out as it is: a redirect after
|
|
424
|
+
* a POST, JSON, a 404. Anything else becomes `ctx.action`, and the page
|
|
425
|
+
* then renders exactly the way it does for a GET: `load` stays the one thing
|
|
426
|
+
* that decides what a page renders, whatever method asked for it. So a form
|
|
427
|
+
* that re-renders with an error reads the same as a form that redirects, and
|
|
428
|
+
* neither has to restate the page's data.
|
|
429
|
+
*
|
|
430
|
+
* `null` is "this page does not answer that method", which is a 405 rather than
|
|
431
|
+
* a 404. The URL exists.
|
|
432
|
+
*
|
|
433
|
+
* @param {object} page
|
|
434
|
+
* @param {object} ctx
|
|
435
|
+
* @param {string} method
|
|
436
|
+
* @returns {Promise<object>} what the handler returned, for the render after it
|
|
437
|
+
*/
|
|
438
|
+
export async function runAction(page, ctx, method) {
|
|
439
|
+
const action = page[method];
|
|
440
|
+
if (typeof action !== 'function') return null;
|
|
441
|
+
|
|
442
|
+
const result = await action(ctx);
|
|
443
|
+
return result instanceof Response ? { response: result } : { action: result ?? {} };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A short-circuiting `Response`, carrying whatever the envelope collected.
|
|
448
|
+
*
|
|
449
|
+
* Set a session cookie and then redirect, which is an ordinary thing for an action
|
|
450
|
+
* to do, and the cookie would otherwise be dropped. The action's `Response` is
|
|
451
|
+
* returned directly, and nothing looks at `ctx.response` on that path.
|
|
452
|
+
* `Response.redirect()` makes it worse than a silent loss, because its headers
|
|
453
|
+
* are immutable and appending to them throws.
|
|
454
|
+
*
|
|
455
|
+
* So the headers go onto a copy. `new Response(body, response)` keeps the status
|
|
456
|
+
* and every header the author set, and comes with a mutable guard.
|
|
457
|
+
*
|
|
458
|
+
* @param {Response} response
|
|
459
|
+
* @param {object} ctx
|
|
460
|
+
* @returns {Response} a copy, because a redirect's headers cannot be written to
|
|
461
|
+
*/
|
|
462
|
+
export function withEnvelope(response, ctx) {
|
|
463
|
+
const collected = [...(ctx?.response?.headers ?? [])];
|
|
464
|
+
if (!collected.length) return response;
|
|
465
|
+
|
|
466
|
+
const merged = new Response(response.body, response);
|
|
467
|
+
for (const [name, value] of collected) merged.headers.append(name, value);
|
|
468
|
+
return merged;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Whether a page can answer for a region name. An empty name is the page's own
|
|
473
|
+
* body, which always exists.
|
|
474
|
+
*
|
|
475
|
+
* Asked *before* an action runs. A misspelled region is a 404 either way, but a
|
|
476
|
+
* request that cannot be answered should not have mutated anything on its way to
|
|
477
|
+
* saying so.
|
|
478
|
+
*/
|
|
479
|
+
/**
|
|
480
|
+
* The render function for a named region, or null.
|
|
481
|
+
*
|
|
482
|
+
* Own properties only, and it has to be a function. The name comes from a query
|
|
483
|
+
* string, so a plain lookup answers for everything on `Object.prototype`:
|
|
484
|
+
* `?fragment=constructor` found `Object`, which is truthy and callable, so the
|
|
485
|
+
* region was "found", the action before it ran, and the reply was whatever
|
|
486
|
+
* `Object(data)` stringifies to. `hasRegion` is the check that an action runs
|
|
487
|
+
* behind, so a name that gets past it gets past that too.
|
|
488
|
+
*
|
|
489
|
+
* @param {object|null|undefined} page a compiled page module
|
|
490
|
+
* @param {string} region the name from the URL
|
|
491
|
+
* @returns {Function|null}
|
|
492
|
+
*/
|
|
493
|
+
export function regionOf(page, region) {
|
|
494
|
+
const regions = page?.regions;
|
|
495
|
+
if (!regions || !Object.hasOwn(regions, region)) return null;
|
|
496
|
+
return typeof regions[region] === 'function' ? regions[region] : null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Whether a page answers for this region. An empty name means the whole page,
|
|
501
|
+
* which every page answers for.
|
|
502
|
+
*
|
|
503
|
+
* @param {object|null|undefined} page
|
|
504
|
+
* @param {string} region
|
|
505
|
+
* @returns {boolean}
|
|
506
|
+
*/
|
|
507
|
+
export function hasRegion(page, region) {
|
|
508
|
+
return !region || regionOf(page, region) !== null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* What a page answers, for an `Allow` header. GET is not optional.
|
|
513
|
+
*
|
|
514
|
+
* @param {object|null|undefined} page
|
|
515
|
+
* @returns {string[]} for an Allow header
|
|
516
|
+
*/
|
|
517
|
+
export function methodsOf(page) {
|
|
518
|
+
return ['GET', ...ACTION_METHODS.filter((method) => typeof page?.[method] === 'function')];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function renderDocument(
|
|
522
|
+
chain,
|
|
523
|
+
datas,
|
|
524
|
+
{ clientEntry, stylesheet, lang = 'en' } = {},
|
|
525
|
+
) {
|
|
526
|
+
// Each level renders to a slot map and hands it to the level above, so a page
|
|
527
|
+
// can fill more than one hole in its layout.
|
|
528
|
+
let slots = {};
|
|
529
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
530
|
+
slots = chain[i].render(datas[i], slots);
|
|
531
|
+
}
|
|
532
|
+
const body = slots.default ?? '';
|
|
533
|
+
|
|
534
|
+
// The innermost <title> wins outright. Kept as its own render function rather
|
|
535
|
+
// than sliced out of rendered head markup, so this is a compile-time fact.
|
|
536
|
+
let title = '';
|
|
537
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
538
|
+
if (!chain[i].hasTitle) continue;
|
|
539
|
+
title = chain[i].renderTitle(datas[i]);
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Everything else accumulates outermost first, so a page's <meta> comes last
|
|
544
|
+
// and a page's <style> can override a layout's.
|
|
545
|
+
// The framework's own defaults go through the merge as the outermost level, so
|
|
546
|
+
// a page or a layout writing its own `viewport` replaces this one instead of
|
|
547
|
+
// shipping beside it. `charset` is not here: it has to be inside the first
|
|
548
|
+
// 1024 bytes and is not something to override.
|
|
549
|
+
const [defaults, ...rest] = mergeHead([
|
|
550
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
551
|
+
...chain.map((mod, i) => mod.renderHead(datas[i])),
|
|
552
|
+
]);
|
|
553
|
+
const head = rest.filter(Boolean);
|
|
554
|
+
|
|
555
|
+
// Ahead of the stylesheet, because a <link> blocks the scripts after it and
|
|
556
|
+
// the point of a head script is to run before anything else.
|
|
557
|
+
const headScripts = chain.map((mod) => mod.headScript).filter(Boolean);
|
|
558
|
+
// A light element's styles are @scope-d and belong in <head> exactly once,
|
|
559
|
+
// however many times it was rendered. A shadow one carries its own.
|
|
560
|
+
//
|
|
561
|
+
// One <style> per tag rather than one block for all of them, each named. The
|
|
562
|
+
// name is what lets the client answer "are these already here?" for an element
|
|
563
|
+
// that arrives later in a fragment. The document says what it has, so nothing
|
|
564
|
+
// has to be tracked beside it.
|
|
565
|
+
const seen = new Set();
|
|
566
|
+
const scoped = [];
|
|
567
|
+
const collect = (defs) => {
|
|
568
|
+
for (const def of defs ?? []) {
|
|
569
|
+
if (seen.has(def.tag)) continue;
|
|
570
|
+
seen.add(def.tag);
|
|
571
|
+
if (def.light && def.css) {
|
|
572
|
+
scoped.push(`<style data-transclude="${def.tag}">\n${def.css}\n</style>`);
|
|
573
|
+
}
|
|
574
|
+
collect(def.elements);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
for (const mod of chain) collect(mod.elements);
|
|
578
|
+
|
|
579
|
+
// Marked, and last: a page's own rules override an element's, and a style
|
|
580
|
+
// adopted later has to know where to insert itself to keep that true.
|
|
581
|
+
const own = chain.map((mod) => mod.css).filter(Boolean);
|
|
582
|
+
const css = [
|
|
583
|
+
...scoped,
|
|
584
|
+
...(own.length ? [`<style data-transclude-page>\n${own.join('\n')}\n</style>`] : []),
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
return `<!doctype html>
|
|
588
|
+
${htmlOpenTag(lang, htmlAttrsOf(chain, datas))}
|
|
589
|
+
<head>
|
|
590
|
+
<meta charset="utf-8">
|
|
591
|
+
${defaults}
|
|
592
|
+
${title}
|
|
593
|
+
${headScripts.join('\n')}
|
|
594
|
+
${stylesheet ? `<link rel="stylesheet" href="${stylesheet}">` : ''}
|
|
595
|
+
${head.join('\n')}
|
|
596
|
+
${css.join('\n')}
|
|
597
|
+
</head>
|
|
598
|
+
<body>
|
|
599
|
+
${body}
|
|
600
|
+
${clientEntry ? `<script type="module" src="${clientEntry}"></script>` : ''}
|
|
601
|
+
</body>
|
|
602
|
+
</html>
|
|
603
|
+
`;
|
|
604
|
+
}
|