@transclude/core 0.11.3 → 0.12.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/build.js CHANGED
@@ -207,6 +207,7 @@ async function render(route, { url, params }) {
207
207
  stylesheet,
208
208
  csp: config.csp,
209
209
  lang: config.lang,
210
+ canonical: config.canonical,
210
211
  speculate: speculateRules,
211
212
  include,
212
213
  });
package/bin/dev.js CHANGED
@@ -191,6 +191,7 @@ const renderPage = async (route, c, status = null, extra = {}) => {
191
191
  stylesheet: config.stylesheet ? `/${config.stylesheet}` : null,
192
192
  csp: config.csp,
193
193
  lang: config.lang,
194
+ canonical: config.canonical,
194
195
  include,
195
196
  });
196
197
  // A loader answered for itself: a redirect, or something that is not a page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.11.3",
3
+ "version": "0.12.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",
package/src/app.js CHANGED
@@ -358,6 +358,7 @@ export function createApp({
358
358
  stylesheet: manifest.stylesheet,
359
359
  csp: config.csp,
360
360
  lang: config.lang,
361
+ canonical: config.canonical,
361
362
  // Written by the build and carried here, so a server-rendered
362
363
  // page says the same thing about speculation that a file does.
363
364
  speculate: manifest.speculate ?? null,
@@ -429,6 +430,7 @@ export function createApp({
429
430
  stylesheet: manifest.stylesheet,
430
431
  csp: config.csp,
431
432
  lang: config.lang,
433
+ canonical: config.canonical,
432
434
  speculate: manifest.speculate ?? null,
433
435
  include,
434
436
  });
@@ -69,6 +69,7 @@ class Bindgen {
69
69
  components = new Map(),
70
70
  shadowTags = new Set(),
71
71
  blockOf = new Map(),
72
+ anchoredOf = new Set(),
72
73
  refs = new Map(),
73
74
  } = {}) {
74
75
  this.components = components;
@@ -76,6 +77,10 @@ class Bindgen {
76
77
  // Which tree node owns which compiled block. Sharing the map is what keeps
77
78
  // this pass and the renderer from drifting apart over the same tree.
78
79
  this.blockOf = blockOf;
80
+ // The blocks the renderer fenced in anchors, which is more of them than it
81
+ // compiled. Shared for the same reason, and read for one question: is this a
82
+ // block the walk can step over.
83
+ this.anchoredOf = anchoredOf;
79
84
  // tag -> the local name the renderer imported that component under.
80
85
  this.refs = refs;
81
86
  this.frames = [new Frame(new Scope())];
@@ -165,6 +170,11 @@ class Bindgen {
165
170
  }
166
171
  }
167
172
 
173
+ /** A `<slot>` the renderer fenced, which is a light element's and never a shadow one's. */
174
+ isHole(node) {
175
+ return node.tagName === 'slot' && this.anchoredOf.has(node);
176
+ }
177
+
168
178
  abandon(slot) {
169
179
  if (slot.kind === 'text') {
170
180
  for (const node of slot.nodes) this.giveUpText(node.value);
@@ -208,14 +218,36 @@ class Bindgen {
208
218
  if (slot.kind === 'block') {
209
219
  const ref = this.bindBlock(slot, here);
210
220
  if (ref === null) {
211
- for (const rest of rendered.slice(i)) this.abandon(rest);
212
- this.frame.gaveUp = true;
213
- return;
221
+ // A light element's block: rendered once and never rebuilt. What it
222
+ // read is volatile and its nodes are nothing to hold, but its anchors
223
+ // are in the markup, so the walk goes on past it. A block without them
224
+ // has no past it, and nothing after it binds.
225
+ if (!this.anchoredOf.has(slot.nodes[0])) {
226
+ for (const rest of rendered.slice(i)) this.abandon(rest);
227
+ this.frame.gaveUp = true;
228
+ return;
229
+ }
230
+ this.abandon(slot);
214
231
  }
215
232
  // Past a block the node count is not knowable, so addressing becomes
216
233
  // relative from here on.
217
234
  cursor = cursor ?? this.cursor();
218
- this.locate(`${cursor} = __b[${ref}].end.nextSibling;`);
235
+ const past = ref === null ? `__afterBlock(${here})` : `__b[${ref}].end.nextSibling`;
236
+ this.locate(`${cursor} = ${past};`);
237
+ continue;
238
+ }
239
+
240
+ // A light element's `<slot>` is a compile-time hole holding the caller's
241
+ // markup, which is fenced for the same reason a block is. Nothing in it is
242
+ // ours to write, and the walk steps over it to reach what follows.
243
+ //
244
+ // The tag is asked for, not just the set: a branch arrives at its own part
245
+ // bare, with its directive already consumed, so a block's node reaches here
246
+ // as an ordinary element and is in the set too.
247
+ if (slot.kind === 'element' && this.isHole(slot.nodes[0])) {
248
+ this.abandon(slot);
249
+ cursor = cursor ?? this.cursor();
250
+ this.locate(`${cursor} = __afterBlock(${here});`);
219
251
  continue;
220
252
  }
221
253
 
@@ -98,6 +98,7 @@ export function compileFragment(nodes, opts = {}) {
98
98
  at,
99
99
  blockDefs: gen.blockDefs.join('\n'),
100
100
  blockOf: gen.blockOf,
101
+ anchoredOf: gen.anchoredOf,
101
102
  slots: Object.fromEntries(slots.map(([name, out]) => [name, out.code])),
102
103
  regions: Object.fromEntries(regions.map(([name, out]) => [name, out.code])),
103
104
  regionIncludes: gen.regionIncludes,
@@ -129,13 +130,16 @@ class Codegen {
129
130
  // `blocks` on, an `if` or `each` compiles to its own module-scope function,
130
131
  // and a `__fragment` passed from render would not be in scope inside it.
131
132
  this.fragments = fragments;
132
- // With `blocks` on, `if` and `each` at the top level compile to their own
133
- // function and are wrapped in comment anchors, so an update can re-render
134
- // one region instead of the whole shadow root. Only an element is ever
135
- // updated, so nothing else pays for the anchors.
136
- this.blocks = blocks && !layout;
133
+ // With `blocks` on, every `if` and `each` is wrapped in comment anchors, so
134
+ // whatever binds this markup can find where one ends. Only an element is ever
135
+ // updated, so nothing else pays for them.
136
+ this.blocks = blocks;
137
137
  this.blockDefs = [];
138
138
  this.blockOf = new Map();
139
+ // Every block that got anchors, whether or not it also got a function. The
140
+ // binding pass reads it the way it reads `blockOf`: to know that a block it
141
+ // cannot bind is still one it can step over.
142
+ this.anchoredOf = new Set();
139
143
  this.inBlock = 0;
140
144
  // The loop variables in scope, outermost first, two per level. A block
141
145
  // inside a loop renders from them, so its function has to take them.
@@ -360,14 +364,25 @@ class Codegen {
360
364
  }
361
365
 
362
366
  /**
363
- * True where a structural block is addressable on its own. Anchors nest and
364
- * the runtime counts depth, and a block inside a loop takes that loop's
365
- * variables as arguments, so nesting is not a reason to give up on either.
367
+ * True where a block is wrapped in anchors, which is where anything binds this
368
+ * markup at all. Anchors nest and the runtime counts depth, so nesting is not a
369
+ * reason to give up.
366
370
  */
367
- standalone() {
371
+ anchored() {
368
372
  return this.blocks && this.inHead === 0;
369
373
  }
370
374
 
375
+ /**
376
+ * True where a block also compiles to a function of its own, which is what
377
+ * re-rendering one region rather than the whole root needs. Not in a layout,
378
+ * and a light element is compiled as one: `<slot>` there is a compile-time hole
379
+ * reading `__slots`, a parameter of `render` that a module-scope block function
380
+ * would not have. A light element rebuilds nothing anyway.
381
+ */
382
+ standalone() {
383
+ return this.anchored() && !this.layout;
384
+ }
385
+
371
386
  /** Flat list of the loop variables in scope, outermost first. */
372
387
  loopArgs() {
373
388
  return this.loops.flatMap((loop) => [loop.item, loop.index]);
@@ -382,6 +397,7 @@ class Codegen {
382
397
  const id = this.blockDefs.length;
383
398
  const params = ['__d', ...args].join(', ');
384
399
  this.blockOf.set(node, id);
400
+ this.anchoredOf.add(node);
385
401
  this.blockDefs.push(
386
402
  `const __blk${id} = { ${extra}html: (${params}) => { let __o = '';\n${joinOut(body).code}\nreturn __o; } };`,
387
403
  );
@@ -400,7 +416,16 @@ class Codegen {
400
416
  this.emitBlock(chain[0].node, out, body, '', args);
401
417
  return;
402
418
  }
419
+ // Emitted where it stands, and still fenced. The markup is rendered once and
420
+ // nothing will replace it, but the anchors are how a walk gets to the nodes
421
+ // after it: what a branch renders is only known once the data is.
422
+ const fenced = this.anchored();
423
+ if (fenced) {
424
+ this.anchoredOf.add(chain[0].node);
425
+ this.s(out, ANCHOR_OPEN);
426
+ }
403
427
  this.emitBranches(chain, out, scope, topLevel);
428
+ if (fenced) this.s(out, ANCHOR_CLOSE);
404
429
  }
405
430
 
406
431
  emitBranches(chain, out, scope, topLevel) {
@@ -537,6 +562,7 @@ class Codegen {
537
562
  const id = this.blockDefs.length;
538
563
  this.blockDefs.push('');
539
564
  this.blockOf.set(el, id);
565
+ this.anchoredOf.add(el);
540
566
 
541
567
  // A <template each> renders several nodes per item, so an item is a
542
568
  // region rather than a node and needs anchors of its own to be found.
@@ -562,7 +588,15 @@ class Codegen {
562
588
  return;
563
589
  }
564
590
 
591
+ // The same fence an inline `if` gets, and for the same reason: how many nodes
592
+ // the loop produces is a question only the data answers.
593
+ const fenced = this.anchored();
594
+ if (fenced) {
595
+ this.anchoredOf.add(el);
596
+ this.s(out, ANCHOR_OPEN);
597
+ }
565
598
  this.emitEachBody(el, out, scope, topLevel);
599
+ if (fenced) this.s(out, ANCHOR_CLOSE);
566
600
  }
567
601
 
568
602
  /** `list`, `key` and `item`. One loop, taken apart so it can be reconciled. */
@@ -653,13 +687,24 @@ class Codegen {
653
687
  const filled = `__slots[${JSON.stringify(name)}]`;
654
688
  const fallback = childrenOf(el);
655
689
 
656
- if (!fallback.length) {
690
+ // The caller's markup, and how many nodes it is only this render knows. So
691
+ // it is fenced like a block, and for the same reason: nothing that binds
692
+ // this markup afterwards can count its way past it.
693
+ const fenced = this.anchored();
694
+ if (fenced) {
695
+ this.anchoredOf.add(el);
696
+ this.s(out, ANCHOR_OPEN);
697
+ }
698
+
699
+ if (fallback.length) {
700
+ this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
701
+ this.emitChildren(fallback, out, scope);
702
+ this.c(out, `}`);
703
+ } else {
657
704
  this.c(out, `__o += ${filled} ?? '';`);
658
- return;
659
705
  }
660
- this.c(out, `if (${filled}) { __o += ${filled}; } else {`);
661
- this.emitChildren(fallback, out, scope);
662
- this.c(out, `}`);
706
+
707
+ if (fenced) this.s(out, ANCHOR_CLOSE);
663
708
  return;
664
709
  }
665
710
 
@@ -279,6 +279,7 @@ export function compileComponent(
279
279
  components,
280
280
  shadowTags,
281
281
  blockOf: template.blockOf,
282
+ anchoredOf: template.anchoredOf,
282
283
  refs: new Map(template.components.map(({ tag: name, ref }) => [name, ref])),
283
284
  // The runtime prepends <style> to the shadow root, so a component's own
284
285
  // first node is not at index 0. A light element's styles are hoisted
@@ -851,7 +852,7 @@ function unusedProps(defaultNode, reads, blocks) {
851
852
  // ---- module assembly helpers ---------------------------------------------
852
853
 
853
854
  function runtimeImport(runtime) {
854
- return `import { escape as __e, attr as __a, attrProp as __ap, str as __str, json, shadow as __sh, data as __data, included as __incl, textAt as __textAt, setText as __setText, setParts as __setParts, setAttr as __setAttr, setAttrProp as __setAttrProp, blockAt as __blockAt, updateBlock as __updateBlock, coerceProps, defineComponent, defineLight, html } from ${JSON.stringify(runtime)};`;
855
+ return `import { escape as __e, attr as __a, attrProp as __ap, str as __str, json, shadow as __sh, data as __data, included as __incl, textAt as __textAt, setText as __setText, setParts as __setParts, setAttr as __setAttr, setAttrProp as __setAttrProp, blockAt as __blockAt, afterBlock as __afterBlock, updateBlock as __updateBlock, coerceProps, defineComponent, defineLight, html } from ${JSON.stringify(runtime)};`;
855
856
  }
856
857
 
857
858
  function layoutImports(layouts) {
package/src/defaults.js CHANGED
@@ -34,6 +34,11 @@ export const DEFAULTS = {
34
34
  csrf: true,
35
35
  csp: false,
36
36
  speculate: false,
37
+ // `<link rel="canonical">` on every page, pointing at the page's own URL. Off
38
+ // by default because a page mounted at a second URL on purpose would get a
39
+ // wrong one, and a wrong canonical is worse than none: it hands the ranking to
40
+ // the other URL.
41
+ canonical: false,
37
42
  // `(source, file) => html`, and a `.md` page under `routes/` without one is an
38
43
  // error naming the file. This package ships no Markdown parser: which flavor
39
44
  // and which extensions are the app's to pick, the same way `cache` is a store
@@ -41,6 +46,31 @@ export const DEFAULTS = {
41
46
  markdown: null,
42
47
  };
43
48
 
49
+ /**
50
+ * Keys a config may set that have no default, listed so the check below does not
51
+ * read them as typos.
52
+ *
53
+ * A key is here when leaving it out has to mean something other than a value.
54
+ * There is no feed to write down as the default feed, and no store to write down
55
+ * as the default `cache`: absent is how an app says it wants neither.
56
+ */
57
+ const UNDEFAULTED = [
58
+ 'cache',
59
+ 'cookieSecret',
60
+ 'feed',
61
+ 'fragmentHeader',
62
+ 'metadataBase',
63
+ 'onError',
64
+ 'port',
65
+ 'precache',
66
+ 'proxy',
67
+ 'sitemap',
68
+ 'watchElements',
69
+ ];
70
+
71
+ /** Every key `transclude.config.js` may set. */
72
+ export const KEYS = new Set([...Object.keys(DEFAULTS), ...UNDEFAULTED]);
73
+
44
74
  /**
45
75
  * A config with every default filled in.
46
76
  *
@@ -50,7 +80,34 @@ export const DEFAULTS = {
50
80
  *
51
81
  * @param {object} [config] whatever `transclude.config.js` exported
52
82
  * @returns {object} the same keys, plus the ones it did not mention
83
+ * @throws when `canonical` is on and there is no origin to build a URL from
53
84
  */
54
85
  export function withDefaults(config = {}) {
55
- return { ...DEFAULTS, ...config };
86
+ // A key nothing reads is a line the author believes is doing something. The
87
+ // failure it replaces is silent and expensive: `stylesheeet` cost a site its
88
+ // whole stylesheet and said nothing, because an ignored key looks exactly like
89
+ // a key that worked.
90
+ const unknown = Object.keys(config).filter((key) => !KEYS.has(key));
91
+ if (unknown.length) {
92
+ throw new Error(
93
+ `[transclude] transclude.config.js sets ${unknown.join(', ')}, which nothing reads. ` +
94
+ `The keys are ${[...KEYS].sort().join(', ')}.`,
95
+ );
96
+ }
97
+
98
+ const merged = { ...DEFAULTS, ...config };
99
+
100
+ // Refused here because there are four places that render a page and only two of
101
+ // them could fall back to a request's origin. Left to the render, `canonical`
102
+ // would work in dev and throw in the build, which is the dev-and-production
103
+ // disagreement this file exists to stop.
104
+ if (merged.canonical && !merged.metadataBase) {
105
+ throw new Error(
106
+ `[transclude] \`canonical: true\` needs \`metadataBase\`, which is the origin the ` +
107
+ `URL is built from. A request's own origin is the wrong one twice: behind a proxy ` +
108
+ `it is the internal address, and a prerendered page has no request at all.`,
109
+ );
110
+ }
111
+
112
+ return merged;
56
113
  }
package/src/document.js CHANGED
@@ -177,6 +177,23 @@ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
177
177
 
178
178
  const escapeAttr = (value) => String(value).replace(/[&<>"]/g, (c) => ESCAPES[c]);
179
179
 
180
+ /**
181
+ * The head the framework writes, as the outermost level of the merge.
182
+ *
183
+ * Through `mergeHead` rather than beside it, so a page or a layout writing its
184
+ * own `viewport` or `canonical` replaces this one instead of shipping a second
185
+ * copy. `charset` is not here: it has to be inside the first 1024 bytes, and it
186
+ * is not something to override.
187
+ *
188
+ * @param {string|null} canonical the page's own URL, absolute, or null for none
189
+ * @returns {string} one level's worth of head markup
190
+ */
191
+ function frameworkHead(canonical) {
192
+ const tags = ['<meta name="viewport" content="width=device-width, initial-scale=1">'];
193
+ if (canonical) tags.push(`<link rel="canonical" href="${escapeAttr(canonical)}">`);
194
+ return tags.join('\n');
195
+ }
196
+
180
197
  /**
181
198
  * `<html …>`, with `lang` first and whatever a loader added after it.
182
199
  *
@@ -239,7 +256,8 @@ function openTag(tag, attrs) {
239
256
  *
240
257
  * @param {object} page a compiled page module
241
258
  * @param {object} ctx the request context
242
- * @param {object} [options] `clientEntry`, `stylesheet`, `csp`, `lang`, `include`
259
+ * @param {object} [options] `clientEntry`, `stylesheet`, `csp`, `lang`, `include`,
260
+ * `canonical`
243
261
  * @returns {Promise<string|Response>} a Response when a loader answered for itself
244
262
  */
245
263
  export async function renderRoute(page, ctx, options = {}) {
@@ -266,7 +284,18 @@ export async function renderRoute(page, ctx, options = {}) {
266
284
  if (mod !== page) inherited = { ...inherited, ...data };
267
285
  }
268
286
 
269
- const html = renderDocument(chain, datas, options);
287
+ // The config's `canonical` is a yes or no; the document's is the URL. Turned
288
+ // into one here because this is the layer that holds the request, and because
289
+ // the four callers that render a page would otherwise each compute it.
290
+ //
291
+ // `route.path` and not the request's URL: a canonical URL names the page, so a
292
+ // query parameter has no place in it. That the path is already free of a
293
+ // trailing slash is Hono's doing under `trailingSlash: 'ignore'`, which is the
294
+ // setting that makes this option worth having.
295
+ const html = renderDocument(chain, datas, {
296
+ ...options,
297
+ canonical: options.canonical ? ctx.absolute(ctx.route.path) : null,
298
+ });
270
299
 
271
300
  // After the document exists, because the policy is built from what it inlined.
272
301
  // A prerendered page runs this once at build time and carries the result.
@@ -598,13 +627,15 @@ export function methodsOf(page) {
598
627
  * @param {object[]} chain the compiled modules, outermost first
599
628
  * @param {object[]} datas one per level, in the same order
600
629
  * @param {{ clientEntry?: string|null, stylesheet?: string|null, lang?: string,
601
- * speculate?: string|null }} [options]
630
+ * speculate?: string|null, canonical?: string|null }} [options] `canonical` is
631
+ * the URL itself, already absolute. `renderRoute` is what turns the config's
632
+ * yes-or-no into one, because this function sees no request.
602
633
  * @returns {string} the document, starting at `<!doctype html>`
603
634
  */
604
635
  export function renderDocument(
605
636
  chain,
606
637
  datas,
607
- { clientEntry, stylesheet, lang = 'en', speculate = null } = {},
638
+ { clientEntry, stylesheet, lang = 'en', speculate = null, canonical = null } = {},
608
639
  ) {
609
640
  // Each level renders to a slot map and hands it to the level above, so a page
610
641
  // can fill more than one hole in its layout.
@@ -624,13 +655,10 @@ export function renderDocument(
624
655
  }
625
656
 
626
657
  // Everything else accumulates outermost first, so a page's <meta> comes last
627
- // and a page's <style> can override a layout's.
628
- // The framework's own defaults go through the merge as the outermost level, so
629
- // a page or a layout writing its own `viewport` replaces this one instead of
630
- // shipping beside it. `charset` is not here: it has to be inside the first
631
- // 1024 bytes and is not something to override.
658
+ // and a page's <style> can override a layout's. The framework's own head is the
659
+ // outermost level; `frameworkHead` says what is in it and why.
632
660
  const [defaults, ...rest] = mergeHead([
633
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
661
+ frameworkHead(canonical),
634
662
  ...chain.map((mod, i) => mod.renderHead(datas[i])),
635
663
  ]);
636
664
  const head = rest.filter(Boolean);
@@ -308,6 +308,20 @@ function closingAnchor(open) {
308
308
  return null;
309
309
  }
310
310
 
311
+ /**
312
+ * The node after a block nobody bound.
313
+ *
314
+ * A light element renders its blocks once and never rebuilds them, so it holds
315
+ * no state for one. The walk that finds every node after it still has to get
316
+ * past it, and how wide it is only the anchors say.
317
+ *
318
+ * @param {Comment} open the opening anchor
319
+ * @returns {Node|null}
320
+ */
321
+ export function afterBlock(open) {
322
+ return closingAnchor(open)?.nextSibling ?? null;
323
+ }
324
+
311
325
  /**
312
326
  * An item spans one node or, where it renders several, the region between its
313
327
  * own anchors. Everything downstream works on the range, so a single-element
package/src/server.js CHANGED
@@ -69,7 +69,9 @@ export function baseApp(options = {}) {
69
69
  * So 'never' means strict routing plus a 301 to the one URL, and every
70
70
  * URL this framework generates is already that form: `routes/about.html` is
71
71
  * `/about`. 'ignore' is the loose router, which answers both with 200. Two URLs
72
- * for one page, and nothing emits <link rel="canonical">.
72
+ * for one page, which is what `canonical: true` in the config answers: the
73
+ * loose router hands `c.req.path` over with the slash already gone, so the URL
74
+ * the tag names is the one form either way.
73
75
  *
74
76
  * `alwaysRedirect` matters because Hono's default only redirects a request that
75
77
  * already 404'd, and a catch-all route answers before it can. `/docs/intro/`