@transclude/core 0.9.0 → 0.10.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/bin/dev.js +13 -4
- package/package.json +1 -1
- package/skills/transclude/SKILL.md +4 -0
- package/src/app.js +8 -0
- package/src/compiler/codegen.js +70 -3
- package/src/document.js +31 -0
package/bin/dev.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
renderRoute,
|
|
19
19
|
responseOf,
|
|
20
20
|
runAction,
|
|
21
|
+
runGuards,
|
|
21
22
|
withEnvelope,
|
|
22
23
|
} from '../src/document.js';
|
|
23
24
|
import transclude, { clientEntryUrl, pageModuleId } from '../src/plugin.js';
|
|
@@ -222,10 +223,11 @@ const sendFragment = async (route, c, region, extra = {}) => {
|
|
|
222
223
|
/**
|
|
223
224
|
* A form submission, or anything else that is not a GET.
|
|
224
225
|
*
|
|
225
|
-
* The
|
|
226
|
-
* whole document, or one region if the URL asked for one.
|
|
227
|
-
* regions are for. POST a form to `?fragment=list` and
|
|
228
|
-
* the list, already rendered, by the same compiled region the
|
|
226
|
+
* The layouts answer first, then the action, then the request is answered the
|
|
227
|
+
* same way a GET is: the whole document, or one region if the URL asked for one.
|
|
228
|
+
* That last part is what regions are for. POST a form to `?fragment=list` and
|
|
229
|
+
* what comes back is the list, already rendered, by the same compiled region the
|
|
230
|
+
* page uses.
|
|
229
231
|
*/
|
|
230
232
|
const handleAction = async (route, c) => {
|
|
231
233
|
const page = await vite.ssrLoadModule(pageModuleId(route.id));
|
|
@@ -238,6 +240,13 @@ const handleAction = async (route, c) => {
|
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
const ctx = contextFor(route, c);
|
|
243
|
+
|
|
244
|
+
// The layouts answer before the handler does, exactly as production does it.
|
|
245
|
+
// A guard that only stopped the render would let the mutation happen and then
|
|
246
|
+
// send its redirect, which is the same response a stopped request gets.
|
|
247
|
+
const refused = await runGuards(page, ctx);
|
|
248
|
+
if (refused) return withEnvelope(refused, ctx);
|
|
249
|
+
|
|
241
250
|
const outcome = await runAction(page, ctx, c.req.method);
|
|
242
251
|
|
|
243
252
|
if (!outcome) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transclude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"html",
|
|
@@ -129,6 +129,10 @@ The action runs, then the loader renders what it left behind. `POST`, `PUT`,
|
|
|
129
129
|
`PATCH` and `DELETE` are the verbs. Return nothing to re-render, or return a
|
|
130
130
|
`Response` to redirect.
|
|
131
131
|
|
|
132
|
+
A layout's loader runs before the action, so a guard that returns a `Response`
|
|
133
|
+
stops the handler as well as the render. Write the check once, in the layout,
|
|
134
|
+
rather than at the top of every verb export below it.
|
|
135
|
+
|
|
132
136
|
## Endpoints
|
|
133
137
|
|
|
134
138
|
A `.js` file in `routes/` returns a `Response`.
|
package/src/app.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
renderRoute,
|
|
25
25
|
responseOf,
|
|
26
26
|
runAction,
|
|
27
|
+
runGuards,
|
|
27
28
|
withEnvelope,
|
|
28
29
|
} from './document.js';
|
|
29
30
|
import { pickEncoding } from './negotiate.js';
|
|
@@ -330,6 +331,13 @@ export function createApp({
|
|
|
330
331
|
}
|
|
331
332
|
|
|
332
333
|
const acting = contextFor(route, c);
|
|
334
|
+
|
|
335
|
+
// The layouts answer before the handler does. A guard that only stopped
|
|
336
|
+
// the render would let the mutation happen and then send its redirect,
|
|
337
|
+
// which is the same response a request that was stopped gets.
|
|
338
|
+
const refused = await runGuards(page, acting);
|
|
339
|
+
if (refused) return withEnvelope(refused, acting);
|
|
340
|
+
|
|
333
341
|
const outcome = await runAction(page, acting, c.req.method);
|
|
334
342
|
if (!outcome) {
|
|
335
343
|
return c.text(`${c.req.method} not allowed`, 405, { Allow: methodsOf(page).join(', ') });
|
package/src/compiler/codegen.js
CHANGED
|
@@ -152,6 +152,10 @@ class Codegen {
|
|
|
152
152
|
this.body = [];
|
|
153
153
|
this.head = [];
|
|
154
154
|
this.title = [];
|
|
155
|
+
// Depth inside the head buffer. Nothing in <head> is ever re-rendered, so a
|
|
156
|
+
// directive there is emitted plainly rather than as an updatable block: the
|
|
157
|
+
// anchors would be comments in <head> that nothing ever looks for.
|
|
158
|
+
this.inHead = 0;
|
|
155
159
|
// `<template slot="x">` at the top level of a page or layout fills the named
|
|
156
160
|
// slot of the level above it, so it compiles to its own buffer.
|
|
157
161
|
this.slots = new Map();
|
|
@@ -274,7 +278,16 @@ class Codegen {
|
|
|
274
278
|
|
|
275
279
|
if (dirs?.has('if')) {
|
|
276
280
|
const { chain, next } = gatherChain(nodes, i);
|
|
277
|
-
|
|
281
|
+
// Decided here rather than in emitElement, which is too late: by then the
|
|
282
|
+
// branch has already written `if (…) {` into this buffer, and the tag
|
|
283
|
+
// itself is about to be written into another one.
|
|
284
|
+
const target = this.hoistTargetOf(chain, out, topLevel);
|
|
285
|
+
const hoisted = target !== out;
|
|
286
|
+
|
|
287
|
+
if (hoisted) this.inHead++;
|
|
288
|
+
this.emitIfChain(chain, target, scope, topLevel);
|
|
289
|
+
if (hoisted) this.inHead--;
|
|
290
|
+
|
|
278
291
|
i = next;
|
|
279
292
|
continue;
|
|
280
293
|
}
|
|
@@ -287,18 +300,72 @@ class Codegen {
|
|
|
287
300
|
);
|
|
288
301
|
}
|
|
289
302
|
|
|
290
|
-
|
|
303
|
+
// `each` on a hoisted tag needs the same treatment for the same reason: the
|
|
304
|
+
// loop would be written here and the tag into <head>, once, unconditionally.
|
|
305
|
+
const target = dirs?.has('each') ? this.hoistTargetOf([{ node }], out, topLevel) : out;
|
|
306
|
+
const hoisted = target !== out;
|
|
307
|
+
|
|
308
|
+
if (hoisted) this.inHead++;
|
|
309
|
+
this.emitNode(node, target, scope, topLevel);
|
|
310
|
+
if (hoisted) this.inHead--;
|
|
311
|
+
|
|
291
312
|
i++;
|
|
292
313
|
}
|
|
293
314
|
}
|
|
294
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Where a directive-carrying top-level tag has to be written, given that
|
|
318
|
+
* `<title>`, `<meta>`, `<link>` and `<base>` are hoisted into <head>. The
|
|
319
|
+
* buffer for the head, or `out` when nothing here is hoisted.
|
|
320
|
+
*
|
|
321
|
+
* Every branch of an if-chain has to agree. One that mixes a hoisted tag with
|
|
322
|
+
* an ordinary one has no single answer, so it is refused rather than guessed.
|
|
323
|
+
*
|
|
324
|
+
* @param {{node: object}[]} chain the branches, or a single node in a list
|
|
325
|
+
* @param {any[]} out the buffer this level is being written into
|
|
326
|
+
* @param {boolean} topLevel
|
|
327
|
+
* @returns {any[]} the buffer to use
|
|
328
|
+
*/
|
|
329
|
+
hoistTargetOf(chain, out, topLevel) {
|
|
330
|
+
if (!topLevel || !this.page) return out;
|
|
331
|
+
|
|
332
|
+
const hoisted = chain.filter(({ node }) => HEAD_TAGS.has(node.tagName));
|
|
333
|
+
if (hoisted.length === 0) return out;
|
|
334
|
+
|
|
335
|
+
if (hoisted.length !== chain.length) {
|
|
336
|
+
const ordinary = chain.find(({ node }) => !HEAD_TAGS.has(node.tagName));
|
|
337
|
+
throw new CompileError(
|
|
338
|
+
`<${hoisted[0].node.tagName}> is hoisted into <head> and ` +
|
|
339
|
+
`<${ordinary.node.tagName}> is not, so this chain would be split in two. ` +
|
|
340
|
+
`Give each of them its own condition.`,
|
|
341
|
+
ordinary.node,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The innermost <title> winning is decided when this compiles, by whether a
|
|
346
|
+
// level has one at all. A condition would make that a question only the
|
|
347
|
+
// request can answer, and a false one would leave the document untitled with
|
|
348
|
+
// the layout's title already ruled out.
|
|
349
|
+
const titled = chain.find(({ node }) => node.tagName === 'title');
|
|
350
|
+
if (titled) {
|
|
351
|
+
throw new CompileError(
|
|
352
|
+
`<title> cannot carry a directive. Which level's title wins is settled ` +
|
|
353
|
+
`when this compiles. Interpolate the text instead, or move the ` +
|
|
354
|
+
`condition into the loader.`,
|
|
355
|
+
titled.node,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return this.head;
|
|
360
|
+
}
|
|
361
|
+
|
|
295
362
|
/**
|
|
296
363
|
* True where a structural block is addressable on its own. Anchors nest and
|
|
297
364
|
* the runtime counts depth, and a block inside a loop takes that loop's
|
|
298
365
|
* variables as arguments, so nesting is not a reason to give up on either.
|
|
299
366
|
*/
|
|
300
367
|
standalone() {
|
|
301
|
-
return this.blocks;
|
|
368
|
+
return this.blocks && this.inHead === 0;
|
|
302
369
|
}
|
|
303
370
|
|
|
304
371
|
/** Flat list of the loop variables in scope, outermost first. */
|
package/src/document.js
CHANGED
|
@@ -456,6 +456,37 @@ export async function renderFragment(page, ctx, { region = null, ...options } =
|
|
|
456
456
|
*/
|
|
457
457
|
export const ACTION_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
458
458
|
|
|
459
|
+
/**
|
|
460
|
+
* The layouts' answer to a request that is about to change something, or null
|
|
461
|
+
* when every one of them let it through.
|
|
462
|
+
*
|
|
463
|
+
* A layout loader returning a `Response` is how a login redirect is written once
|
|
464
|
+
* for everything below it, and until this existed that only held for the render.
|
|
465
|
+
* The action ran first, so a signed-out POST reached the handler, mutated, and
|
|
466
|
+
* then met the guard on the way back out. The reader got the redirect, which is
|
|
467
|
+
* what a request stopped at the door also gets, so nothing anywhere said the
|
|
468
|
+
* handler had run.
|
|
469
|
+
*
|
|
470
|
+
* The data is thrown away. `renderRoute` loads the chain again afterwards, which
|
|
471
|
+
* is a second run of every layout loader on an action request, and the price of
|
|
472
|
+
* the render seeing what the action just did rather than what was true before it.
|
|
473
|
+
*
|
|
474
|
+
* @param {object} page a compiled page module
|
|
475
|
+
* @param {object} ctx the request context
|
|
476
|
+
* @returns {Promise<Response|null>} the first layout that answered for itself
|
|
477
|
+
*/
|
|
478
|
+
export async function runGuards(page, ctx) {
|
|
479
|
+
let inherited = {};
|
|
480
|
+
|
|
481
|
+
for (const layout of page.layouts) {
|
|
482
|
+
const data = await layout.load({ ...ctx, layout: inherited });
|
|
483
|
+
if (data instanceof Response) return data;
|
|
484
|
+
inherited = { ...inherited, ...data };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
459
490
|
/**
|
|
460
491
|
* Runs the page's handler for a request that is not a GET.
|
|
461
492
|
*
|