@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.235",
3
+ "version": "0.3.236",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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,
@@ -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. The component string is project-
295
- // relative without extension; try .tsx → .ts → .jsx → .js so
296
- // any of the common page-file shapes work. cwd was captured
297
- // above for the react resolver.
298
- const baseName = `${cwd}/${msg.component}`;
299
- let mod: any = null;
300
- let lastErr: unknown = null;
301
- for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
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
- let tree: any = React.createElement(Component, props);
338
- const layouts = msg.layouts ?? [];
339
- if (layouts.length > 0) {
340
- // Resolve all layouts first so we fail fast on a missing one
341
- // BEFORE we start emitting headers / chunks.
342
- const layoutMods: any[] = [];
343
- for (const layoutPath of layouts) {
344
- const lBase = `${cwd}/${layoutPath}`;
345
- let lMod: any = null;
346
- for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
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,