Skip to content
File

Blob: src/worker/sites/render-page-stream.tsx

typescript90 lines
1import { renderToReadableStream } from "react-dom/server";
2 
3import type { Db } from "@/worker/db/d1/client";
4import type { ResolvedPublishedPage, ResolvedSite } from "@/worker/lib/published-pages";
5import { buildSitePagePath } from "@/worker/lib/site-public-url";
6import { renderBlandSitesDocumentToReactElement } from "@/sites/static-renderer";
7import { collectSitesOutline } from "@/sites/static-renderer/outline";
8import { preWalkSitesJson } from "@/sites/json-prewalk";
9import { extractSiteDescription } from "@/sites/excerpt";
10import { SitePageDocument } from "@/sites/document";
11import { runWithSitesReactRenderContext, type SitesReactRenderContext } from "@/sites/react-render-context";
12import type { SitesPageMentionRenderInfo } from "@/sites/types";
13import { resolveOgCover } from "@/worker/sites/cover";
14import type { PreparedSitePageRender } from "@/worker/sites/prepare-page-render";
15 
16const UPLOAD_PATH = /^\/uploads\/([A-Za-z0-9_-]+)$/;
17 
18export interface RenderSitePageDocumentStreamArgs {
19 db: Db;
20 site: ResolvedSite;
21 page: ResolvedPublishedPage;
22 prepared: PreparedSitePageRender;
23}
24 
25export async function renderSitePageDocumentStream(
26 args: RenderSitePageDocumentStreamArgs,
27): Promise<ReadableStream<Uint8Array>> {
28 const { db, site, page, prepared } = args;
29 const renderContent = structuredClone(prepared.pmJson.content);
30 preWalkSitesJson(renderContent, { pageId: page.id, mentions: prepared.mentions });
31 const description = extractSiteDescription(renderContent);
32 
33 const outline = collectSitesOutline(renderContent);
34 const ogImage = await resolveOgCover(db, page, prepared.canonicalUrl);
35 const context: SitesReactRenderContext = {
36 kind: "page",
37 assets: prepared.assets,
38 headingAnchorIds: outline.headingAnchorIds,
39 resolvePageMention: (pageId) => buildMentionRenderInfo(pageId, prepared.mentions),
40 page: {
41 title: page.title || "Untitled",
42 icon: page.icon,
43 coverUrl: rewriteSiteCoverUrl(page.cover_url, page.id),
44 ogImage,
45 outline: outline.items,
46 metrics: prepared.pmJson.metrics,
47 description,
48 canonicalUrl: prepared.canonicalUrl,
49 site: {
50 workspaceName: site.workspace_name,
51 workspaceIcon: site.workspace_icon,
52 currentIsHome: prepared.currentIsHome,
53 homeHref: "/",
54 },
55 },
56 };
57 
58 return runWithSitesReactRenderContext(context, () => {
59 const bodyContent = renderBlandSitesDocumentToReactElement(renderContent);
60 return renderToReadableStream(
61 <SitePageDocument>{bodyContent}</SitePageDocument>,
62 prepared.assets.scriptSrc ? { bootstrapModules: [prepared.assets.scriptSrc] } : undefined,
63 );
64 });
65}
66 
67function buildMentionRenderInfo(
68 pageId: string,
69 mentions: Map<string, { reachable: boolean; title: string | null; icon: string | null }>,
70): SitesPageMentionRenderInfo {
71 const entry = mentions.get(pageId);
72 if (!entry || !entry.reachable) {
73 return { label: "Restricted", href: null, kind: "restricted" };
74 }
75 return {
76 label: entry.title ?? "Untitled",
77 href: buildSitePagePath(pageId, entry.title ?? ""),
78 icon: entry.icon,
79 kind: "accessible",
80 };
81}
82 
83export function rewriteSiteCoverUrl(cover: string | null, pageId: string): string | null {
84 if (!cover) return null;
85 if (cover.startsWith("linear-gradient(")) return cover;
86 const match = UPLOAD_PATH.exec(cover);
87 if (match) return `/_assets/${pageId}/${match[1]}`;
88 return cover;
89}