@transclude/core 0.9.0 → 0.10.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/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 action runs first, then the request is answered the same way a GET is: the
226
- * whole document, or one region if the URL asked for one. That last part is what
227
- * regions are for. POST a form to `?fragment=list` and what comes back is
228
- * the list, already rendered, by the same compiled region the page uses.
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.9.0",
3
+ "version": "0.10.1",
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(', ') });
@@ -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
- this.emitIfChain(chain, out, scope, topLevel);
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
- this.emitNode(node, out, scope, topLevel);
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. */
@@ -18,6 +18,7 @@ import { ambientJsdoc } from './ambient.js';
18
18
  import { parseEach } from './directives.js';
19
19
  import { childrenOf } from './codegen.js';
20
20
  import { splitInterpolations } from './interp.js';
21
+ import { GLOBALS as EXPRESSION_GLOBALS } from './expr.js';
21
22
  import { splitBlocks } from './index.js';
22
23
  import { planLift } from './script.js';
23
24
  import { ACTION_METHODS } from '../document.js';
@@ -700,10 +701,17 @@ function collectRoots(node, scope, out) {
700
701
  }
701
702
  }
702
703
 
703
- const GLOBALS = new Set([
704
- 'html', 'Math', 'JSON', 'String', 'Number', 'Boolean', 'Array', 'Object', 'Date',
705
- 'isNaN', 'parseInt', 'parseFloat', 'undefined', 'NaN', 'Infinity', 'true', 'false', 'null',
706
- ]);
704
+ /**
705
+ * The names an expression resolves without a data lookup, plus the three
706
+ * literals acorn hands back as identifiers where jsep does not.
707
+ *
708
+ * Taken from the expression layer rather than written out again. The two lists
709
+ * were separate and drifted: `json` was in one and not the other, so the
710
+ * documented helper compiled, rendered, and then failed `npm run check` saying
711
+ * it was not a field of the page's data. Anything the compiler resolves has to
712
+ * resolve here too, or the shim checks code the compiler never emits.
713
+ */
714
+ const GLOBALS = new Set([...EXPRESSION_GLOBALS, 'true', 'false', 'null']);
707
715
 
708
716
  function collectUsedTags(nodes, components, found = new Set()) {
709
717
  for (const node of nodes) {
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
  *