toiljs 0.0.77 → 0.0.78

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.
@@ -10,7 +10,7 @@ import { generateSlotsModule } from './ssr-codegen.js';
10
10
  import { assignSlotIds, coherenceHash, encodeSlots, extractFromHtml, } from './template.js';
11
11
  import { createViteConfig } from './vite.js';
12
12
  import { extractStaticMetadata, loadTypeScript } from './prerender.js';
13
- import { injectSeoHtml, routeSeo } from './seo.js';
13
+ import { escapeAttr, escapeHtml, injectSeoHtml, routeSeo } from './seo.js';
14
14
  const SSR_MARKER = '<template id="__toil_ssr"></template>';
15
15
  const ROOT_DIV = '<div id="root"></div>';
16
16
  export function assembleRouteElement(Page, layouts, loaderData, loaderContext, h = createElement, SuspenseComp = Suspense) {
@@ -36,11 +36,49 @@ function stripHoistedResourceTags(html) {
36
36
  .replace(/<meta\b[^>]*>/gi, '')
37
37
  .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '');
38
38
  }
39
+ function headTag(tag, attrs) {
40
+ const pairs = Object.entries(attrs)
41
+ .filter((e) => e[1] !== undefined)
42
+ .map(([k, v]) => `${k}="${escapeAttr(v)}"`);
43
+ return ` <${tag} data-toil-head ${pairs.join(' ')} />`;
44
+ }
45
+ function injectComponentHead(html, head, routeMetadata) {
46
+ let out = html;
47
+ if (head.title !== undefined && (routeMetadata === null || routeMetadata.title === undefined)) {
48
+ const tag = `<title>${escapeHtml(head.title)}</title>`;
49
+ out = /<title>[\s\S]*?<\/title>/i.test(out)
50
+ ? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
51
+ : out.replace(/<\/head>/i, ` ${tag}\n </head>`);
52
+ }
53
+ const tags = [];
54
+ for (const m of head.meta) {
55
+ const key = m.name !== undefined
56
+ ? `name="${m.name}"`
57
+ : m.property !== undefined
58
+ ? `property="${m.property}"`
59
+ : null;
60
+ if (key === null || out.includes(key))
61
+ continue;
62
+ tags.push(headTag('meta', m));
63
+ }
64
+ for (const l of head.link) {
65
+ if (out.includes(`href="${l.href}"`))
66
+ continue;
67
+ tags.push(headTag('link', l));
68
+ }
69
+ if (tags.length > 0) {
70
+ out = out.includes('</head>')
71
+ ? out.replace(/<\/head>/i, `${tags.join('\n')}\n </head>`)
72
+ : `${tags.join('\n')}\n${out}`;
73
+ }
74
+ return out;
75
+ }
39
76
  export function extractRouteTemplate(input) {
40
77
  const h = input.createElement ?? createElement;
41
78
  const render = input.renderToString ?? renderToString;
42
79
  const SuspenseComp = input.Suspense ?? Suspense;
43
80
  const element = assembleRouteElement(input.Page, input.layouts, input.loaderData, input.loaderContext, h, SuspenseComp);
81
+ input.drainSsrHead?.();
44
82
  input.setSsrBuild(true);
45
83
  let routeHtml;
46
84
  try {
@@ -49,10 +87,12 @@ export function extractRouteTemplate(input) {
49
87
  finally {
50
88
  input.setSsrBuild(false);
51
89
  }
90
+ const componentHead = input.drainSsrHead?.() ?? { meta: [], link: [] };
52
91
  let full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
53
92
  if (input.seo) {
54
93
  full = injectSeoHtml(full, routeSeo(input.seo, input.metadata ?? null, input.pattern ?? '/'));
55
94
  }
95
+ full = injectComponentHead(full, componentHead, input.metadata ?? null);
56
96
  const extracted = extractFromHtml(full);
57
97
  const ids = assignSlotIds(extracted.slots);
58
98
  const hash = coherenceHash(extracted.tmpl, extracted.slots);
@@ -167,6 +207,7 @@ async function renderSsrRoutes(cfg, shell) {
167
207
  loaderData,
168
208
  loaderContext: client.LoaderDataContext,
169
209
  setSsrBuild: client.__setSsrBuild,
210
+ drainSsrHead: client.__drainSsrHead,
170
211
  shell,
171
212
  createElement: react.createElement,
172
213
  renderToString: reactDomServer.renderToString,
@@ -11,7 +11,10 @@ export default function Layout({ children }: { children?: ReactNode }) {
11
11
  A route's own `metadata` / `<Head>` overrides these. */}
12
12
  <Toil.Head
13
13
  title="ToilJS"
14
- meta={[{ name: 'description', content: 'Planet-scale apps from a single repo.' }]}
14
+ meta={[
15
+ { name: 'description', content: 'Planet-scale apps from a single repo.' },
16
+ { name: 'generator', content: 'ToilJS' },
17
+ ]}
15
18
  />
16
19
  <Header />
17
20
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.77",
4
+ "version": "0.0.78",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { useEffect, useLayoutEffect } from 'react';
8
8
 
9
+ import { __isSsrBuild } from '../ssr/markers';
10
+
9
11
  /** A `<meta>` tag. Use `name` or `property` (OpenGraph) as the dedup key; extra attrs pass through. */
10
12
  export interface MetaTag {
11
13
  readonly name?: string;
@@ -68,6 +70,12 @@ let baseTitle: string | null = null;
68
70
  // via `setRouteHead` on each navigation.
69
71
  let routeHead: HeadSpec | null = null;
70
72
 
73
+ // Component-level head (`useHead`/`useTitle`/<Head>) collected during the build's SSR extraction.
74
+ // renderToString doesn't run effects, so specs are pushed during render (see `useHead`); the template
75
+ // extractor drains them with `__drainSsrHead` after each route renders, to bake the route's
76
+ // component-level head into the server HTML (not just its static `metadata` export).
77
+ let ssrHeadSink: HeadSpec[] = [];
78
+
71
79
  function setAttrs(el: Element, attrs: Record<string, string | undefined>): void {
72
80
  el.setAttribute('data-toil-head', '');
73
81
  for (const [key, value] of Object.entries(attrs)) {
@@ -117,6 +125,9 @@ function removeHead(id: number): void {
117
125
  * Reverts on unmount. Compose freely, a root layout can set defaults a page overrides.
118
126
  */
119
127
  export function useHead(spec: HeadSpec): void {
128
+ // During the build's SSR extraction (no document, effects don't run), collect the spec so the
129
+ // route's component-level head reaches the server HTML. A no-op in the browser bundle.
130
+ if (__isSsrBuild()) ssrHeadSink.push(spec);
120
131
  const json = JSON.stringify(spec);
121
132
  useEffect(() => {
122
133
  const id = addHead(JSON.parse(json) as HeadSpec);
@@ -157,3 +168,15 @@ export function useRouteHead(spec: HeadSpec | undefined): void {
157
168
  };
158
169
  }, [json]);
159
170
  }
171
+
172
+ /**
173
+ * Drains the component-level head (`useHead`/`useTitle`/<Head>) collected during ONE route's SSR-build
174
+ * render and resolves it, then resets the sink for the next route. Called by the build's template
175
+ * extractor right after `renderToString`, so the server HTML carries the same component head the client
176
+ * computes — not just the route's static `metadata` export. (Build-only; unused in the browser.)
177
+ */
178
+ export function __drainSsrHead(): ResolvedHead {
179
+ const resolved = mergeHead(ssrHeadSink);
180
+ ssrHeadSink = [];
181
+ return resolved;
182
+ }
@@ -81,7 +81,7 @@ export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel
81
81
  export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
82
82
  export { makeStreamClient } from './stream/client.js';
83
83
  export type { StreamChannel, StreamConnectable, StreamClient } from './stream/client.js';
84
- export { useHead, useTitle, Head, mergeHead } from './head/head.js';
84
+ export { useHead, useTitle, Head, mergeHead, __drainSsrHead } from './head/head.js';
85
85
  export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
86
86
  export { resolveMetadata, useMetadata, Metadata } from './head/metadata.js';
87
87
  export type { GenerateMetadata, GenerateMetadataArgs, OpenGraph } from './head/metadata.js';
@@ -123,7 +123,7 @@ const AI_CRAWLERS: readonly string[] = [
123
123
  ];
124
124
 
125
125
  /** Escapes a value for use inside a double-quoted HTML attribute (prevents attribute-breakout XSS). */
126
- function escapeAttr(value: string): string {
126
+ export function escapeAttr(value: string): string {
127
127
  return value
128
128
  .replace(/&/g, '&amp;')
129
129
  .replace(/"/g, '&quot;')
@@ -46,7 +46,7 @@ import {
46
46
  } from './template.js';
47
47
  import { createViteConfig } from './vite.js';
48
48
  import { extractStaticMetadata, loadTypeScript } from './prerender.js';
49
- import { injectSeoHtml, routeSeo, type SeoConfig } from './seo.js';
49
+ import { escapeAttr, escapeHtml, injectSeoHtml, routeSeo, type SeoConfig } from './seo.js';
50
50
 
51
51
  /** Marker element the client `mount` looks for to switch to `hydrateRoot`. */
52
52
  const SSR_MARKER = '<template id="__toil_ssr"></template>';
@@ -88,6 +88,9 @@ export interface RouteRenderInput {
88
88
  metadata?: Record<string, unknown> | null;
89
89
  /** The route pattern, used for the canonical / `og:url`. */
90
90
  pattern?: string;
91
+ /** Drains the component-level head (`useHead`/`useTitle`/`<Head>`) collected during this route's
92
+ * render, so it's baked into the SSR `<head>` too. From `toiljs/client`'s `__drainSsrHead`. */
93
+ drainSsrHead?: () => SsrHead;
91
94
  }
92
95
 
93
96
  export interface TemplateArtifacts {
@@ -152,6 +155,65 @@ function stripHoistedResourceTags(html: string): string {
152
155
  .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '');
153
156
  }
154
157
 
158
+ /** The resolved component-level head drained from the client head manager during an SSR-build render
159
+ * (structurally `ResolvedHead` from `toiljs/client`). */
160
+ interface SsrHead {
161
+ title?: string;
162
+ meta: { name?: string; property?: string; content: string; [attr: string]: string | undefined }[];
163
+ link: { rel: string; href: string; [attr: string]: string | undefined }[];
164
+ }
165
+
166
+ /** Render one head `<meta>`/`<link>` to an HTML tag, marked `data-toil-head` so the client head
167
+ * manager owns it on hydration (it removes + re-emits `[data-toil-head]` tags on every navigation,
168
+ * exactly as it does for the tags it adds at runtime — so there's no duplication or stale leak). */
169
+ function headTag(tag: 'meta' | 'link', attrs: Record<string, string | undefined>): string {
170
+ const pairs = Object.entries(attrs)
171
+ .filter((e): e is [string, string] => e[1] !== undefined)
172
+ .map(([k, v]) => `${k}="${escapeAttr(v)}"`);
173
+ return ` <${tag} data-toil-head ${pairs.join(' ')} />`;
174
+ }
175
+
176
+ /**
177
+ * Bake the route's component-level head (layout `<Head>` + a page's `useHead`/`useTitle`, collected
178
+ * during render) into the document head: the title when the route's static `metadata` set none (the
179
+ * component title outranks the site default), plus any `<meta>`/`<link>` the route SEO didn't already
180
+ * carry — so the server HTML reflects the SAME head the client computes, not just the static metadata.
181
+ */
182
+ function injectComponentHead(
183
+ html: string,
184
+ head: SsrHead,
185
+ routeMetadata: Record<string, unknown> | null,
186
+ ): string {
187
+ let out = html;
188
+ if (head.title !== undefined && (routeMetadata === null || routeMetadata.title === undefined)) {
189
+ const tag = `<title>${escapeHtml(head.title)}</title>`;
190
+ out = /<title>[\s\S]*?<\/title>/i.test(out)
191
+ ? out.replace(/<title>[\s\S]*?<\/title>/i, tag)
192
+ : out.replace(/<\/head>/i, ` ${tag}\n </head>`);
193
+ }
194
+ const tags: string[] = [];
195
+ for (const m of head.meta) {
196
+ const key =
197
+ m.name !== undefined
198
+ ? `name="${m.name}"`
199
+ : m.property !== undefined
200
+ ? `property="${m.property}"`
201
+ : null;
202
+ if (key === null || out.includes(key)) continue; // already covered by the route SEO
203
+ tags.push(headTag('meta', m));
204
+ }
205
+ for (const l of head.link) {
206
+ if (out.includes(`href="${l.href}"`)) continue;
207
+ tags.push(headTag('link', l));
208
+ }
209
+ if (tags.length > 0) {
210
+ out = out.includes('</head>')
211
+ ? out.replace(/<\/head>/i, `${tags.join('\n')}\n </head>`)
212
+ : `${tags.join('\n')}\n${out}`;
213
+ }
214
+ return out;
215
+ }
216
+
155
217
  /** Render one route to its template artifacts (pure given its inputs). */
156
218
  export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
157
219
  const h = input.createElement ?? createElement;
@@ -165,6 +227,8 @@ export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts
165
227
  h,
166
228
  SuspenseComp,
167
229
  );
230
+ // Clear any specs leaked from a previously-failed route render before collecting this route's.
231
+ input.drainSsrHead?.();
168
232
  input.setSsrBuild(true);
169
233
  let routeHtml: string;
170
234
  try {
@@ -172,6 +236,9 @@ export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts
172
236
  } finally {
173
237
  input.setSsrBuild(false);
174
238
  }
239
+ // The component-level head (layout <Head> + page useHead/useTitle) was collected during the render
240
+ // above; drain it now, per route, so it can be baked alongside the static SEO.
241
+ const componentHead: SsrHead = input.drainSsrHead?.() ?? { meta: [], link: [] };
175
242
  let full = injectIntoShell(input.shell, stripHoistedResourceTags(routeHtml));
176
243
  // Bake the route's resolved SEO into the template <head>, mirroring the static prerender
177
244
  // (prerender.ts / ssg.ts) so an `ssr=true` route serves the SAME per-page metadata (title,
@@ -182,6 +249,10 @@ export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts
182
249
  if (input.seo) {
183
250
  full = injectSeoHtml(full, routeSeo(input.seo, input.metadata ?? null, input.pattern ?? '/'));
184
251
  }
252
+ // Then add the component-level head (a layout's <Head>, a page's useHead/useTitle) that the static
253
+ // metadata export + site SEO didn't already cover, so the server HTML carries the same head the
254
+ // client computes.
255
+ full = injectComponentHead(full, componentHead, input.metadata ?? null);
185
256
  const extracted: Extracted = extractFromHtml(full);
186
257
  const ids = assignSlotIds(extracted.slots);
187
258
  const hash = coherenceHash(extracted.tmpl, extracted.slots);
@@ -295,6 +366,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
295
366
  const client = (await server.ssrLoadModule('toiljs/client')) as unknown as {
296
367
  __setSsrBuild: (on: boolean) => void;
297
368
  __setSsrLocation: (path: string | null) => void;
369
+ __drainSsrHead: () => SsrHead;
298
370
  LoaderDataContext: Context<unknown>;
299
371
  };
300
372
 
@@ -372,6 +444,7 @@ async function renderSsrRoutes(cfg: ResolvedToilConfig, shell: string): Promise<
372
444
  loaderData,
373
445
  loaderContext: client.LoaderDataContext,
374
446
  setSsrBuild: client.__setSsrBuild,
447
+ drainSsrHead: client.__drainSsrHead,
375
448
  shell,
376
449
  createElement: react.createElement,
377
450
  renderToString: reactDomServer.renderToString,
@@ -446,4 +446,37 @@ describe('ssr build orchestration', () => {
446
446
  expect(tmpl).not.toContain('og:image');
447
447
  expect(tmpl).not.toContain('rel="canonical"');
448
448
  });
449
+
450
+ it('bakes the component-level head (useHead/<Head>) the static metadata did not set', () => {
451
+ const componentHead = {
452
+ title: 'Layout Title',
453
+ meta: [
454
+ { name: 'author', content: 'me' },
455
+ { name: 'description', content: 'layout desc' },
456
+ ],
457
+ link: [{ rel: 'me', href: 'https://x.test' }],
458
+ };
459
+ const art = extractRouteTemplate({
460
+ name: 'p',
461
+ Page: ProfilePage,
462
+ layouts: [Layout],
463
+ loaderData: sample,
464
+ loaderContext: LoaderDataContext,
465
+ setSsrBuild: __setSsrBuild,
466
+ shell: SHELL,
467
+ seo: { title: 'Site', description: 'site desc' },
468
+ metadata: { description: 'route desc' }, // route sets a description but NO title
469
+ pattern: '/p',
470
+ drainSsrHead: () => componentHead,
471
+ });
472
+ const tmpl = art.tmpl.toString('utf8');
473
+ // Component title wins because the route's static metadata set no title of its own.
474
+ expect(tmpl).toContain('<title>Layout Title</title>');
475
+ // A component meta the route SEO didn't carry is added, owned by the client (data-toil-head).
476
+ expect(tmpl).toContain('<meta data-toil-head name="author" content="me" />');
477
+ // The component description is deduped (the route SEO already baked one).
478
+ expect(tmpl.match(/name="description"/g)?.length).toBe(1);
479
+ // A component link is added too.
480
+ expect(tmpl).toContain('<link data-toil-head rel="me" href="https://x.test" />');
481
+ });
449
482
  });