@pylonsync/functions 0.3.235 → 0.3.236
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/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/ssr-runtime.ts +153 -56
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export { slugifyName, availableSlug } from "./slugify";
|
|
|
25
25
|
// SSR page response controller — pages/layouts receive `response` in
|
|
26
26
|
// props (response.setStatus / redirect / notFound / setHeader / setCookie).
|
|
27
27
|
// Type-only; the runtime instance is injected per-render by the SSR adapter.
|
|
28
|
-
export type { SsrResponse, SsrCookieOptions } from "./ssr-runtime";
|
|
28
|
+
export type { SsrResponse, SsrCookieOptions, SsrMetadata } from "./ssr-runtime";
|
|
29
29
|
export type {
|
|
30
30
|
QueryCtx,
|
|
31
31
|
MutationCtx,
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -240,6 +240,130 @@ function finalizeHeaders(
|
|
|
240
240
|
* has flushed) are uncatchable here — React's `onError` would have
|
|
241
241
|
* to feed into a separate signal, deferred to Phase 1.5.
|
|
242
242
|
*/
|
|
243
|
+
/**
|
|
244
|
+
* Page SEO metadata. A page exports `export const metadata = {...}`
|
|
245
|
+
* (static) or `export async function generateMetadata(props)` (dynamic,
|
|
246
|
+
* e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
|
|
247
|
+
*
|
|
248
|
+
* React 19 hoists the resulting <title>/<meta>/<link> into <head>. A page
|
|
249
|
+
* `title` overrides a layout's static `<title>` (both render; the browser
|
|
250
|
+
* uses the last, which is the page's). React does NOT dedupe arbitrary
|
|
251
|
+
* `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
|
|
252
|
+
* not both, to avoid duplicate tags.
|
|
253
|
+
*/
|
|
254
|
+
export interface SsrMetadata {
|
|
255
|
+
title?: string;
|
|
256
|
+
description?: string;
|
|
257
|
+
keywords?: string | string[];
|
|
258
|
+
canonical?: string;
|
|
259
|
+
robots?: string;
|
|
260
|
+
openGraph?: {
|
|
261
|
+
title?: string;
|
|
262
|
+
description?: string;
|
|
263
|
+
image?: string;
|
|
264
|
+
url?: string;
|
|
265
|
+
type?: string;
|
|
266
|
+
};
|
|
267
|
+
twitter?: {
|
|
268
|
+
card?: string;
|
|
269
|
+
title?: string;
|
|
270
|
+
description?: string;
|
|
271
|
+
image?: string;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build a React fragment of <title>/<meta>/<link> from a page's metadata.
|
|
277
|
+
* React 19 auto-hoists these into <head> wherever they render, and the
|
|
278
|
+
* host's </head> splice preserves them. React escapes all text/attrs, so
|
|
279
|
+
* there's no manual XSS handling. Returns null when there's nothing to emit.
|
|
280
|
+
*/
|
|
281
|
+
function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
282
|
+
if (!m) return null;
|
|
283
|
+
const el = React.createElement;
|
|
284
|
+
const kids: any[] = [];
|
|
285
|
+
if (m.title != null) kids.push(el("title", { key: "t" }, m.title));
|
|
286
|
+
if (m.description != null) {
|
|
287
|
+
kids.push(el("meta", { key: "d", name: "description", content: m.description }));
|
|
288
|
+
}
|
|
289
|
+
const kw = Array.isArray(m.keywords) ? m.keywords.join(", ") : m.keywords;
|
|
290
|
+
if (kw) kids.push(el("meta", { key: "kw", name: "keywords", content: kw }));
|
|
291
|
+
if (m.robots) kids.push(el("meta", { key: "r", name: "robots", content: m.robots }));
|
|
292
|
+
if (m.canonical) {
|
|
293
|
+
kids.push(el("link", { key: "c", rel: "canonical", href: m.canonical }));
|
|
294
|
+
}
|
|
295
|
+
const og = m.openGraph;
|
|
296
|
+
if (og) {
|
|
297
|
+
if (og.title != null) kids.push(el("meta", { key: "ogt", property: "og:title", content: og.title }));
|
|
298
|
+
if (og.description != null) kids.push(el("meta", { key: "ogd", property: "og:description", content: og.description }));
|
|
299
|
+
if (og.image) kids.push(el("meta", { key: "ogi", property: "og:image", content: og.image }));
|
|
300
|
+
if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
|
|
301
|
+
if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
|
|
302
|
+
}
|
|
303
|
+
const tw = m.twitter;
|
|
304
|
+
if (tw) {
|
|
305
|
+
if (tw.card) kids.push(el("meta", { key: "twc", name: "twitter:card", content: tw.card }));
|
|
306
|
+
if (tw.title != null) kids.push(el("meta", { key: "twt", name: "twitter:title", content: tw.title }));
|
|
307
|
+
if (tw.description != null) kids.push(el("meta", { key: "twd", name: "twitter:description", content: tw.description }));
|
|
308
|
+
if (tw.image) kids.push(el("meta", { key: "twi", name: "twitter:image", content: tw.image }));
|
|
309
|
+
}
|
|
310
|
+
return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
314
|
+
|
|
315
|
+
/** Import a project-relative module, trying each common extension. */
|
|
316
|
+
async function importModule(cwd: string, relPath: string): Promise<any> {
|
|
317
|
+
const base = `${cwd}/${relPath}`;
|
|
318
|
+
let lastErr: unknown = null;
|
|
319
|
+
for (const ext of MODULE_EXTS) {
|
|
320
|
+
try {
|
|
321
|
+
return await import(`${base}${ext}`);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
lastErr = e;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
throw lastErr ?? new Error(`could not import module "${relPath}"`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Wrap a leaf element in its layout chain (leaf → root). Resolves ALL
|
|
331
|
+
* layouts first so a missing one fails before any chunk is emitted. Reused
|
|
332
|
+
* by the page render and by the not-found / error boundary render.
|
|
333
|
+
*/
|
|
334
|
+
async function buildLayoutTree(
|
|
335
|
+
cwd: string,
|
|
336
|
+
leaf: any,
|
|
337
|
+
layouts: string[] | undefined,
|
|
338
|
+
props: any,
|
|
339
|
+
React: any,
|
|
340
|
+
): Promise<any> {
|
|
341
|
+
if (!layouts || layouts.length === 0) return leaf;
|
|
342
|
+
const layoutComps: any[] = [];
|
|
343
|
+
for (const layoutPath of layouts) {
|
|
344
|
+
let lMod: any;
|
|
345
|
+
try {
|
|
346
|
+
lMod = await importModule(cwd, layoutPath);
|
|
347
|
+
} catch {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const LayoutComp = lMod.default ?? lMod.Layout ?? lMod.layout;
|
|
353
|
+
if (typeof LayoutComp !== "function") {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`layout "${layoutPath}" has no default export (or named export "Layout")`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
layoutComps.push(LayoutComp);
|
|
359
|
+
}
|
|
360
|
+
let tree = leaf;
|
|
361
|
+
for (let i = layoutComps.length - 1; i >= 0; i--) {
|
|
362
|
+
tree = React.createElement(layoutComps[i], props, tree);
|
|
363
|
+
}
|
|
364
|
+
return tree;
|
|
365
|
+
}
|
|
366
|
+
|
|
243
367
|
export async function handleRenderRoute(
|
|
244
368
|
msg: RenderRouteMessage,
|
|
245
369
|
send: Send,
|
|
@@ -291,23 +415,14 @@ export async function handleRenderRoute(
|
|
|
291
415
|
);
|
|
292
416
|
}
|
|
293
417
|
|
|
294
|
-
// Resolve the page module
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
mod = await import(`${baseName}${ext}`);
|
|
304
|
-
break;
|
|
305
|
-
} catch (e) {
|
|
306
|
-
lastErr = e;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (!mod) {
|
|
310
|
-
throw lastErr ?? new Error(`could not import component "${msg.component}"`);
|
|
418
|
+
// Resolve the page module (project-relative, extension-agnostic).
|
|
419
|
+
let mod: any;
|
|
420
|
+
try {
|
|
421
|
+
mod = await importModule(cwd, msg.component);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
throw e instanceof Error
|
|
424
|
+
? e
|
|
425
|
+
: new Error(`could not import component "${msg.component}"`);
|
|
311
426
|
}
|
|
312
427
|
const Component = mod.default ?? mod.Page ?? mod.page;
|
|
313
428
|
if (typeof Component !== "function") {
|
|
@@ -328,50 +443,32 @@ export async function handleRenderRoute(
|
|
|
328
443
|
response,
|
|
329
444
|
};
|
|
330
445
|
|
|
446
|
+
// SEO metadata: static `export const metadata` or dynamic
|
|
447
|
+
// `export async function generateMetadata(props)`. Awaited before the
|
|
448
|
+
// first byte, so keep it to cheap derivations (params → title); heavy
|
|
449
|
+
// data belongs in the page body behind <Suspense>.
|
|
450
|
+
let metadata: SsrMetadata | undefined = mod.metadata;
|
|
451
|
+
if (typeof mod.generateMetadata === "function") {
|
|
452
|
+
metadata = await mod.generateMetadata(props);
|
|
453
|
+
}
|
|
454
|
+
const metaFragment = renderMetadata(React, metadata);
|
|
455
|
+
|
|
331
456
|
// Resolve the layout chain. Each layout module exports a default
|
|
332
457
|
// function that accepts the same props + `children`. Walk leaf →
|
|
333
458
|
// root: start with the page component as `tree`, then for each
|
|
334
459
|
// layout (innermost first) wrap it as the new tree. Result is
|
|
335
460
|
// the outermost layout containing all nested layouts down to
|
|
336
|
-
// the page.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
lMod = await import(`${lBase}${ext}`);
|
|
349
|
-
break;
|
|
350
|
-
} catch {
|
|
351
|
-
// try next extension
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (!lMod) {
|
|
355
|
-
throw new Error(
|
|
356
|
-
`could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
const LayoutComp =
|
|
360
|
-
lMod.default ?? lMod.Layout ?? lMod.layout;
|
|
361
|
-
if (typeof LayoutComp !== "function") {
|
|
362
|
-
throw new Error(
|
|
363
|
-
`layout "${layoutPath}" has no default export (or named export "Layout")`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
layoutMods.push(LayoutComp);
|
|
367
|
-
}
|
|
368
|
-
// Walk LEAF → ROOT (reverse iteration on the layouts array).
|
|
369
|
-
// The innermost layout wraps the page first; each outer layout
|
|
370
|
-
// wraps the result.
|
|
371
|
-
for (let i = layoutMods.length - 1; i >= 0; i--) {
|
|
372
|
-
tree = React.createElement(layoutMods[i], props, tree);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
461
|
+
// the page. The metadata fragment is the FIRST child so React hoists
|
|
462
|
+
// its <title>/<meta> into the <head> a layout renders.
|
|
463
|
+
let tree: any = metaFragment
|
|
464
|
+
? React.createElement(
|
|
465
|
+
React.Fragment,
|
|
466
|
+
null,
|
|
467
|
+
metaFragment,
|
|
468
|
+
React.createElement(Component, props),
|
|
469
|
+
)
|
|
470
|
+
: React.createElement(Component, props);
|
|
471
|
+
tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
|
|
375
472
|
const element = tree;
|
|
376
473
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
|
|
377
474
|
element,
|