@pylonsync/functions 0.22.60 → 0.22.61

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.
@@ -495,11 +495,11 @@ export function finalizeHeaders(
495
495
  * (static) or `export async function generateMetadata(props)` (dynamic,
496
496
  * e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
497
497
  *
498
- * React 19 hoists the resulting <title>/<meta>/<link> into <head>. A page
499
- * `title` overrides a layout's static `<title>` (both render; the browser
500
- * uses the last, which is the page's). React does NOT dedupe arbitrary
501
- * `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
502
- * not both, to avoid duplicate tags.
498
+ * A layout can export the same; the page's values win (`mergeMetadata`).
499
+ * The resulting <title>/<meta>/<link> are written into <head> by the server,
500
+ * whatever the layouts render. A tag from the metadata replaces a tag the
501
+ * root layout writes in its own <head> for the same title, `name`,
502
+ * `property` or canonical link (`dropReplacedHeadTags`).
503
503
  */
504
504
  /** A single OpenGraph image (for `openGraph.images` — multiple images). */
505
505
  export interface OgImage {
@@ -587,9 +587,9 @@ export interface SsrMetadata {
587
587
 
588
588
  /**
589
589
  * Build a React fragment of <title>/<meta>/<link> from a page's metadata.
590
- * React 19 auto-hoists these into <head> wherever they render, and the
591
- * host's </head> splice preserves them. React escapes all text/attrs, so
592
- * there's no manual XSS handling. Returns null when there's nothing to emit.
590
+ * The server renders it to HTML on its own and splices it in before
591
+ * </head>. React escapes all text/attrs, so there's no manual XSS
592
+ * handling. Returns null when there's nothing to emit.
593
593
  */
594
594
  export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
595
595
  if (!m) return null;
@@ -733,6 +733,136 @@ export function renderJsonLdHead(m: SsrMetadata | undefined): string {
733
733
  return html;
734
734
  }
735
735
 
736
+ /**
737
+ * The metadata a route renders with: each layout's, root first, then the
738
+ * page's. A later value replaces an earlier one. `openGraph`, `twitter`,
739
+ * `icons` and `alternates` merge field by field. A new `openGraph.image` or
740
+ * `images` drops the earlier image fields, and a new `twitter.image` drops
741
+ * the earlier `twitter.imageAlt`: they describe the other image.
742
+ */
743
+ export function mergeMetadata(
744
+ parent: SsrMetadata | undefined,
745
+ child: SsrMetadata | undefined,
746
+ ): SsrMetadata | undefined {
747
+ if (!parent) return child;
748
+ if (!child) return parent;
749
+ const out: SsrMetadata = { ...parent, ...child };
750
+ if (parent.openGraph && child.openGraph) {
751
+ const base: NonNullable<SsrMetadata["openGraph"]> = { ...parent.openGraph };
752
+ if (child.openGraph.image !== undefined || child.openGraph.images !== undefined) {
753
+ delete base.image;
754
+ delete base.imageSecureUrl;
755
+ delete base.imageType;
756
+ delete base.imageWidth;
757
+ delete base.imageHeight;
758
+ delete base.imageAlt;
759
+ delete base.images;
760
+ }
761
+ out.openGraph = { ...base, ...child.openGraph };
762
+ }
763
+ if (parent.twitter && child.twitter) {
764
+ const base: NonNullable<SsrMetadata["twitter"]> = { ...parent.twitter };
765
+ if (child.twitter.image !== undefined) delete base.imageAlt;
766
+ out.twitter = { ...base, ...child.twitter };
767
+ }
768
+ if (parent.icons && child.icons) out.icons = { ...parent.icons, ...child.icons };
769
+ if (parent.alternates && child.alternates) {
770
+ out.alternates = { ...parent.alternates, ...child.alternates };
771
+ }
772
+ return out;
773
+ }
774
+
775
+ /**
776
+ * The merged `metadata` / `generateMetadata` of a route's layouts, root
777
+ * first (see `mergeMetadata`). A layout module that fails to import is
778
+ * skipped here: the layout render reports that failure.
779
+ */
780
+ async function resolveLayoutMetadata(
781
+ cwd: string,
782
+ layouts: string[] | undefined,
783
+ props: any,
784
+ ): Promise<SsrMetadata | undefined> {
785
+ let merged: SsrMetadata | undefined;
786
+ for (const layoutPath of layouts ?? []) {
787
+ let lMod: any;
788
+ try {
789
+ lMod = await importModule(cwd, layoutPath);
790
+ } catch {
791
+ continue;
792
+ }
793
+ const own: SsrMetadata | undefined =
794
+ typeof lMod.generateMetadata === "function"
795
+ ? await lMod.generateMetadata(props)
796
+ : lMod.metadata;
797
+ merged = mergeMetadata(merged, own);
798
+ }
799
+ return merged;
800
+ }
801
+
802
+ // One tag in a document head: a <script>/<style> block (matched so tags
803
+ // written inside it are skipped), a <title> element, or a <meta>/<link> tag.
804
+ // Quoted attribute values may hold `>`.
805
+ const HEAD_TAG_RE =
806
+ /<(script|style)\b[\s\S]*?<\/\1\s*>|<title\b[^>]*>[\s\S]*?<\/title\s*>|<(?:meta|link)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
807
+ const HEAD_ATTR_RE = /\s([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
808
+
809
+ /** What a head tag sets: `title`, `name:<name>`, `property:<property>` or
810
+ * `canonical`. Null for a tag that page metadata never replaces. */
811
+ function headTagKey(tag: string): string | null {
812
+ const start = tag.slice(0, 6).toLowerCase();
813
+ if (start.startsWith("<title")) return "title";
814
+ const isMeta = start.startsWith("<meta");
815
+ if (!isMeta && !start.startsWith("<link")) return null;
816
+ const attrs: Record<string, string> = {};
817
+ for (const m of tag.matchAll(HEAD_ATTR_RE)) {
818
+ attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
819
+ }
820
+ if (!isMeta) return attrs.rel?.toLowerCase() === "canonical" ? "canonical" : null;
821
+ if (attrs.property) return `property:${attrs.property.toLowerCase()}`;
822
+ if (attrs.name) return `name:${attrs.name.toLowerCase()}`;
823
+ return null;
824
+ }
825
+
826
+ /** The keys (see `headTagKey`) that the tags in `html` set. */
827
+ export function headTagKeys(html: string): Set<string> {
828
+ const keys = new Set<string>();
829
+ for (const m of html.matchAll(HEAD_TAG_RE)) {
830
+ const key = headTagKey(m[0]);
831
+ if (key) keys.add(key);
832
+ }
833
+ return keys;
834
+ }
835
+
836
+ /**
837
+ * Remove from `head` (the document up to `</head>`) the tags that the page
838
+ * metadata replaces: a <title>, a <meta> with the same `name` or `property`,
839
+ * and a canonical link. A new `og:image` or `twitter:image` also removes the
840
+ * old image's `og:image:*` or `twitter:image:*` tags. So a layout can write
841
+ * default tags in its own <head>, and a page's metadata replaces them.
842
+ */
843
+ export function dropReplacedHeadTags(head: string, replacing: Set<string>): string {
844
+ if (replacing.size === 0) return head;
845
+ const replaced = (key: string): boolean => {
846
+ if (replacing.has(key)) return true;
847
+ for (const image of ["property:og:image", "name:twitter:image"]) {
848
+ if (key.startsWith(`${image}:`) && replacing.has(image)) return true;
849
+ }
850
+ return false;
851
+ };
852
+ return head.replace(HEAD_TAG_RE, (tag) => {
853
+ const key = headTagKey(tag);
854
+ return key && replaced(key) ? "" : tag;
855
+ });
856
+ }
857
+
858
+ /** The first <title> element in `html`, or "". */
859
+ export function firstTitleTag(html: string): string {
860
+ for (const m of html.matchAll(HEAD_TAG_RE)) {
861
+ if (headTagKey(m[0]) === "title") return m[0];
862
+ }
863
+ return "";
864
+ }
865
+
736
866
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
737
867
 
738
868
  /**
@@ -1509,12 +1639,16 @@ export async function streamWithHeadInjection(
1509
1639
  headBlob: string,
1510
1640
  sendChunk: (text: string) => void,
1511
1641
  bodyBlob = "",
1642
+ rewriteHead?: (head: string) => string,
1512
1643
  ): Promise<void> {
1513
1644
  // Injections in document order, each consumed the first time its marker is
1514
1645
  // seen. Once the list empties the reader is a straight pass-through, which
1515
- // is every chunk after the <head> on a normal page.
1516
- const pending: Array<{ marker: string; blob: string }> = [];
1517
- if (headBlob.length > 0) pending.push({ marker: "</head>", blob: headBlob });
1646
+ // is every chunk after the <head> on a normal page. With `rewriteHead`, the
1647
+ // document up to `</head>` is held and passed through it before it is sent.
1648
+ const pending: Array<{ marker: string; blob: string; rewrite?: (text: string) => string }> = [];
1649
+ if (headBlob.length > 0 || rewriteHead) {
1650
+ pending.push({ marker: "</head>", blob: headBlob, rewrite: rewriteHead });
1651
+ }
1518
1652
  if (bodyBlob.length > 0) pending.push({ marker: "</body>", blob: bodyBlob });
1519
1653
  let carry = "";
1520
1654
  const decoder = new TextDecoder("utf-8");
@@ -1525,7 +1659,8 @@ export async function streamWithHeadInjection(
1525
1659
  const next = pending[0];
1526
1660
  const idx = buf.indexOf(next.marker);
1527
1661
  if (idx < 0) break;
1528
- sendChunk(buf.slice(0, idx));
1662
+ const before = buf.slice(0, idx);
1663
+ sendChunk(next.rewrite ? next.rewrite(before) : before);
1529
1664
  sendChunk(next.blob);
1530
1665
  sendChunk(next.marker);
1531
1666
  buf = buf.slice(idx + next.marker.length);
@@ -1535,6 +1670,10 @@ export async function streamWithHeadInjection(
1535
1670
  if (buf) sendChunk(buf);
1536
1671
  return;
1537
1672
  }
1673
+ if (pending[0].rewrite) {
1674
+ carry = buf;
1675
+ return;
1676
+ }
1538
1677
  // Withhold len(marker) − 1 so a marker straddling a chunk boundary is
1539
1678
  // still matched once the next read arrives.
1540
1679
  const keep = pending[0].marker.length - 1;
@@ -2436,7 +2575,8 @@ async function renderBoundaryToClient(
2436
2575
  },
2437
2576
  // Design render: no hydration tail, inlined CSS, `<base href>`.
2438
2577
  design?: { headers: Record<string, string | undefined> },
2439
- // Page-metadata head HTML that is not React-rendered (JSON-LD).
2578
+ // The boundary's metadata as head HTML (see `renderMetadata`). It
2579
+ // replaces the layout's own tags for the same keys.
2440
2580
  metaHeadHtml = "",
2441
2581
  ): Promise<void> {
2442
2582
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
@@ -2492,11 +2632,15 @@ async function renderBoundaryToClient(
2492
2632
  data: Buffer.from(text, "utf8").toString("base64"),
2493
2633
  });
2494
2634
  };
2635
+ const replacedHeadTags = headTagKeys(metaHeadHtml);
2495
2636
  await streamWithHeadInjection(
2496
2637
  stream.getReader(),
2497
2638
  headBlob,
2498
2639
  sendChunk,
2499
2640
  design ? "" : buildCloudBadgeChunk(),
2641
+ replacedHeadTags.size > 0
2642
+ ? (head) => dropReplacedHeadTags(head, replacedHeadTags)
2643
+ : undefined,
2500
2644
  );
2501
2645
  if (tail && manifestRoute && !design) {
2502
2646
  const tailHtml = buildHydrationTail({
@@ -2563,21 +2707,24 @@ async function tryRenderBoundary(
2563
2707
  };
2564
2708
  compProps = { ...props, error: errorForClient, reset: () => {} };
2565
2709
  }
2566
- // A boundary's own `metadata` / `generateMetadata`, same as a page's.
2567
- // Without this a not-found.tsx rendered no <title> at all — and because
2568
- // the framework's built-in 404 body supplies one, taking over the boundary
2569
- // silently LOST the title rather than replacing it, leaving the tab
2570
- // showing the raw URL. The fragment goes first so React hoists its
2571
- // <title>/<meta> into the <head> a layout renders.
2710
+ // A boundary's own `metadata` / `generateMetadata`, same as a page's,
2711
+ // over its layouts' metadata. Without this a not-found.tsx rendered no
2712
+ // <title> at all — and because the framework's built-in 404 body
2713
+ // supplies one, taking over the boundary silently LOST the title rather
2714
+ // than replacing it, leaving the tab showing the raw URL.
2572
2715
  let boundaryMeta: SsrMetadata | undefined = mod.metadata;
2573
- if (typeof mod.generateMetadata === "function") {
2574
- try {
2716
+ try {
2717
+ if (typeof mod.generateMetadata === "function") {
2575
2718
  boundaryMeta = await mod.generateMetadata(compProps);
2576
- } catch {
2577
- // A boundary is already the failure path; don't fail it again over
2578
- // its title.
2579
- boundaryMeta = mod.metadata;
2580
2719
  }
2720
+ boundaryMeta = mergeMetadata(
2721
+ await resolveLayoutMetadata(cwd, boundaryLayouts, compProps),
2722
+ boundaryMeta,
2723
+ );
2724
+ } catch {
2725
+ // A boundary is already the failure path; don't fail it again over
2726
+ // its title.
2727
+ boundaryMeta = mod.metadata;
2581
2728
  }
2582
2729
  // The same file conventions a page gets, resolved against the BOUNDARY's
2583
2730
  // own directory: a colocated opengraph-image / icon next to not-found.tsx
@@ -2593,13 +2740,16 @@ async function tryRenderBoundary(
2593
2740
  true,
2594
2741
  );
2595
2742
  boundaryMeta = applyAutoIcons(rel, boundaryMeta);
2596
- const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
2743
+ // In <head>, outside the tree, as for a page (see handleRenderRoute).
2744
+ const boundaryMetaHtml =
2745
+ (await renderElementToString(renderToReadableStream, renderMetadata(React, boundaryMeta))) +
2746
+ renderJsonLdHead(boundaryMeta);
2597
2747
  // The same metadata slot a page gets: the browser hydrates a boundary
2598
2748
  // with the page's tree shape (ssr-page-tree.ts).
2599
2749
  let tree = withMetaSlot(
2600
2750
  React.createElement,
2601
2751
  React.Fragment,
2602
- boundaryMetaFragment,
2752
+ null,
2603
2753
  React.createElement(Comp, compProps),
2604
2754
  );
2605
2755
  tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
@@ -2622,7 +2772,7 @@ async function tryRenderBoundary(
2622
2772
  errorForClient,
2623
2773
  },
2624
2774
  opts.design,
2625
- renderJsonLdHead(boundaryMeta),
2775
+ boundaryMetaHtml,
2626
2776
  );
2627
2777
  return true;
2628
2778
  } catch (e) {
@@ -3574,13 +3724,15 @@ export async function handleRenderRoute(
3574
3724
  : null;
3575
3725
 
3576
3726
  // SEO metadata: static `export const metadata` or dynamic
3577
- // `export async function generateMetadata(props)`. Awaited before the
3578
- // first byte, so keep it to cheap derivations (params → title); heavy
3579
- // data belongs in the page body behind <Suspense>.
3727
+ // `export async function generateMetadata(props)`, from each layout and
3728
+ // then the page (page values win). Awaited before the first byte, so keep
3729
+ // it to cheap derivations (params → title); heavy data belongs in the
3730
+ // page body behind <Suspense>.
3580
3731
  let metadata: SsrMetadata | undefined = mod.metadata;
3581
3732
  if (typeof mod.generateMetadata === "function") {
3582
3733
  metadata = await mod.generateMetadata(props);
3583
3734
  }
3735
+ metadata = mergeMetadata(await resolveLayoutMetadata(cwd, msg.layouts, props), metadata);
3584
3736
  // File conventions: auto-wire <meta og:image>/<twitter:image> from a
3585
3737
  // colocated opengraph-image.* / twitter-image.*, and <link rel="icon">
3586
3738
  // from icon.* / apple-icon.* / favicon.ico — unless the page set them.
@@ -3595,7 +3747,14 @@ export async function handleRenderRoute(
3595
3747
  componentIsBoundary,
3596
3748
  );
3597
3749
  metadata = applyAutoIcons(msg.component, metadata);
3598
- const metaFragment = renderMetadata(React, metadata);
3750
+ // The metadata goes into <head> as HTML, outside the React tree. Inside
3751
+ // the tree it rendered only when every layout rendered its children: a
3752
+ // layout that shows a sign-in or loading screen on the server (an auth
3753
+ // gate decided in the browser) dropped the page's tags.
3754
+ const metaHeadHtml =
3755
+ (await renderElementToString(renderToReadableStream, renderMetadata(React, metadata))) +
3756
+ renderJsonLdHead(metadata);
3757
+ const replacedHeadTags = headTagKeys(metaHeadHtml);
3599
3758
 
3600
3759
  // Client-side navigation: the browser already has the runtime, the layouts
3601
3760
  // and the styles, and re-renders the page from `__PYLON_DATA__`. Measured
@@ -3661,10 +3820,9 @@ export async function handleRenderRoute(
3661
3820
  // root: start with the page component as `tree`, then for each
3662
3821
  // layout (innermost first) wrap it as the new tree. Result is
3663
3822
  // the outermost layout containing all nested layouts down to
3664
- // the page. The metadata is the FIRST child so React hoists its
3665
- // <title>/<meta> into the <head> a layout renders; the browser renders
3666
- // the same slot empty (ssr-page-tree.ts), so useId values match.
3667
- let tree: any = withMetaSlot(React.createElement, React.Fragment, metaFragment, pageLeaf);
3823
+ // the page. The page sits in the same metadata slot as in the browser
3824
+ // (ssr-page-tree.ts), empty on both sides, so useId values match.
3825
+ let tree: any = withMetaSlot(React.createElement, React.Fragment, null, pageLeaf);
3668
3826
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
3669
3827
  const element = tree;
3670
3828
  // Tracks whether the HTTP head (response_start) has actually gone out. A
@@ -3985,7 +4143,7 @@ export async function handleRenderRoute(
3985
4143
  // 404 page is unstyled. Hydration stays disabled (handled below).
3986
4144
  headBlob += await collectBoundaryHeadBlob();
3987
4145
  }
3988
- headBlob += renderJsonLdHead(metadata);
4146
+ headBlob += metaHeadHtml;
3989
4147
 
3990
4148
  // The host can dispatch a boundary module (`app/not-found` / `app/error`)
3991
4149
  // by name for an unmatched-URL 404. Boundaries render server-only — no
@@ -4009,18 +4167,31 @@ export async function handleRenderRoute(
4009
4167
  // Drain React's output and drop it. The render is what resolves the
4010
4168
  // serverData reads that fill `ssrData`; the markup it produces is the
4011
4169
  // part the client rebuilds for itself.
4170
+ // The head is kept for the layout's own <title>.
4012
4171
  const reader = stream.getReader();
4172
+ const decoder = new TextDecoder("utf-8");
4173
+ let head = "";
4174
+ let headDone = false;
4013
4175
  for (;;) {
4014
- const { done } = await reader.read();
4176
+ const { done, value } = await reader.read();
4015
4177
  if (done) break;
4178
+ if (headDone || !value) continue;
4179
+ head += decoder.decode(value, { stream: true });
4180
+ const end = head.indexOf("</head>");
4181
+ if (end >= 0) {
4182
+ head = head.slice(0, end);
4183
+ headDone = true;
4184
+ }
4016
4185
  }
4017
4186
  // A minimal document, so the client's existing parse path works
4018
4187
  // unchanged: it reads document.title, copies [data-pylon-meta], and
4019
- // pulls __PYLON_DATA__ — all of which this carries.
4188
+ // pulls __PYLON_DATA__ — all of which this carries. A page with no
4189
+ // title of its own gets the layout's, as on a full page load.
4190
+ const layoutTitle = replacedHeadTags.has("title") || !headDone ? "" : firstTitleTag(head);
4020
4191
  sendChunk(
4021
4192
  '<!DOCTYPE html><html><head><meta charset="utf-8">' +
4022
- (await renderElementToString(renderToReadableStream, metaFragment)) +
4023
- renderJsonLdHead(metadata) +
4193
+ layoutTitle +
4194
+ metaHeadHtml +
4024
4195
  "</head><body>",
4025
4196
  );
4026
4197
  } else {
@@ -4032,6 +4203,9 @@ export async function handleRenderRoute(
4032
4203
  headBlob,
4033
4204
  sendChunk,
4034
4205
  designRender ? "" : buildCloudBadgeChunk(),
4206
+ replacedHeadTags.size > 0
4207
+ ? (head) => dropReplacedHeadTags(head, replacedHeadTags)
4208
+ : undefined,
4035
4209
  );
4036
4210
  }
4037
4211