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.
- package/CHANGELOG.md +9 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/head/head.d.ts +1 -0
- package/build/client/head/head.js +9 -0
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/seo.d.ts +1 -0
- package/build/compiler/seo.js +1 -1
- package/build/compiler/template-build.d.ts +16 -0
- package/build/compiler/template-build.js +42 -1
- package/examples/basic/client/layout.tsx +4 -1
- package/package.json +1 -1
- package/src/client/head/head.ts +23 -0
- package/src/client/index.ts +1 -1
- package/src/compiler/seo.ts +1 -1
- package/src/compiler/template-build.ts +74 -1
- package/test/ssr-template.test.tsx +33 -0
|
@@ -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={[
|
|
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
package/src/client/head/head.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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';
|
package/src/compiler/seo.ts
CHANGED
|
@@ -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, '&')
|
|
129
129
|
.replace(/"/g, '"')
|
|
@@ -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
|
});
|