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.
- package/CHANGELOG.md +18 -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/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/ssr.js +15 -1
- package/examples/basic/client/layout.tsx +4 -1
- package/package.json +1 -1
- package/server/runtime/ssr/slots.ts +8 -0
- 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/src/devserver/ssr.ts +23 -1
- package/test/built-ssr.test.ts +50 -0
- package/test/ssr-template.test.tsx +33 -0
|
@@ -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,
|
package/src/devserver/ssr.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/test/built-ssr.test.ts
CHANGED
|
@@ -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
|
});
|