@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.
- package/dist/offline-sw.d.ts +2 -0
- package/dist/ssr-client-bundler.d.ts +1 -1
- package/dist/ssr-page-tree.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +29 -9
- package/package.json +1 -1
- package/src/offline-sw.test.ts +36 -1
- package/src/offline-sw.ts +26 -1
- package/src/ssr-client-bundler.ts +90 -10
- package/src/ssr-client-nav-runtime.fixture.ts +168 -8
- package/src/ssr-client-nav-runtime.test.ts +68 -4
- package/src/ssr-metadata.test.ts +251 -0
- package/src/ssr-page-tree.ts +5 -8
- package/src/ssr-runtime.ts +215 -41
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
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
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
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
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
2568
|
-
// the framework's built-in 404 body
|
|
2569
|
-
//
|
|
2570
|
-
//
|
|
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
|
-
|
|
2574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
3578
|
-
//
|
|
3579
|
-
// data belongs in the
|
|
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
|
-
|
|
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
|
|
3665
|
-
//
|
|
3666
|
-
|
|
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 +=
|
|
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
|
-
|
|
4023
|
-
|
|
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
|
|