toiljs 0.0.77 → 0.0.79

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.
@@ -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,
@@ -169,6 +169,19 @@ export interface SsrResult {
169
169
  html: Uint8Array;
170
170
  }
171
171
 
172
+ /** The internal header a guest's `SlotValues.setTitle` rides in: the host splices its value into the
173
+ * document `<title>` and strips it, so a per-request title never reaches the client as a real header. */
174
+ const SSR_TITLE_HEADER = 'x-toil-ssr-title';
175
+
176
+ /** Replace the document `<title>` content (the guest set a per-request title; the value is already
177
+ * React-escaped). Mirrors the host `assemble` so dev and prod agree. */
178
+ function replaceTitle(html: Uint8Array, title: string): Uint8Array {
179
+ const out = new TextDecoder()
180
+ .decode(html)
181
+ .replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`);
182
+ return new TextEncoder().encode(out);
183
+ }
184
+
172
185
  /**
173
186
  * Decode the guest envelope and splice it into `route`'s template. Returns null
174
187
  * to fall back to client rendering: a fail-safe envelope (status >= 500, e.g. no
@@ -182,5 +195,14 @@ export function assembleSsr(route: SsrRoute, envelope: Uint8Array): SsrResult |
182
195
  const inserts = route.entries
183
196
  .map((e) => ({ offset: e.offset, value: decoded.values.get(e.id) ?? new Uint8Array(0) }))
184
197
  .sort((a, b) => a.offset - b.offset);
185
- return { status: decoded.status, headers: decoded.headers, html: splice(route.tmpl, inserts) };
198
+ let html = splice(route.tmpl, inserts);
199
+ // A guest-set per-request <title> (SlotValues.setTitle) rides in an internal header: splice it into
200
+ // the <title> and strip the header so it never reaches the client.
201
+ let headers = decoded.headers;
202
+ const ti = headers.findIndex(([k]) => k.toLowerCase() === SSR_TITLE_HEADER);
203
+ if (ti >= 0) {
204
+ html = replaceTitle(html, headers[ti][1]);
205
+ headers = headers.filter((_, i) => i !== ti);
206
+ }
207
+ return { status: decoded.status, headers, html };
186
208
  }
@@ -54,6 +54,41 @@ function valuesEnvelope(hash: Buffer, value: string): Buffer {
54
54
  return buf;
55
55
  }
56
56
 
57
+ /** An envelope with one header (the internal SSR-title header) plus one text slot. */
58
+ function envelopeWithTitle(hash: Buffer, value: string, title: string): Buffer {
59
+ const name = Buffer.from('x-toil-ssr-title');
60
+ const titleBytes = Buffer.from(title);
61
+ const valueBytes = Buffer.from(value);
62
+ const buf = Buffer.alloc(
63
+ 2 + 32 + 2 + (2 + 2 + name.length + titleBytes.length) + 2 + (2 + 1 + 4 + valueBytes.length),
64
+ );
65
+ let o = 0;
66
+ buf.writeUInt16LE(200, o);
67
+ o += 2;
68
+ hash.copy(buf, o);
69
+ o += 32;
70
+ buf.writeUInt16LE(1, o); // 1 header
71
+ o += 2;
72
+ buf.writeUInt16LE(name.length, o);
73
+ o += 2;
74
+ buf.writeUInt16LE(titleBytes.length, o);
75
+ o += 2;
76
+ name.copy(buf, o);
77
+ o += name.length;
78
+ titleBytes.copy(buf, o);
79
+ o += titleBytes.length;
80
+ buf.writeUInt16LE(1, o); // 1 slot
81
+ o += 2;
82
+ buf.writeUInt16LE(0, o); // slot id 0
83
+ o += 2;
84
+ buf.writeUInt8(0, o); // text kind
85
+ o += 1;
86
+ buf.writeUInt32LE(valueBytes.length, o);
87
+ o += 4;
88
+ valueBytes.copy(buf, o);
89
+ return buf;
90
+ }
91
+
57
92
  describe('built SSR templates', () => {
58
93
  it('loads the built shell, including production CSS links, from _ssr artifacts', () => {
59
94
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-built-ssr-'));
@@ -95,4 +130,19 @@ describe('built SSR templates', () => {
95
130
  );
96
131
  expect(assembleSsr(route, valuesEnvelope(Buffer.alloc(32, 2), 'X'))).toBeNull();
97
132
  });
133
+
134
+ it('replaces the <title> from a guest setTitle header, then strips the internal header', () => {
135
+ const hash = Buffer.alloc(32, 1);
136
+ const route: SsrRoute = {
137
+ test: () => true,
138
+ tmpl: Buffer.from('<title>Static</title>ab'),
139
+ entries: [{ id: 0, offset: 22 }],
140
+ hash,
141
+ };
142
+ const out = assembleSsr(route, envelopeWithTitle(hash, 'X', 'Dynamic'))!;
143
+ // the body slot is spliced AND the <title> is replaced with the guest's per-request value
144
+ expect(Buffer.from(out.html).toString()).toBe('<title>Dynamic</title>aXb');
145
+ // the internal header never reaches the client
146
+ expect(out.headers.some(([k]) => k.toLowerCase() === 'x-toil-ssr-title')).toBe(false);
147
+ });
98
148
  });
@@ -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
  });