File
Blob: src/worker/sites/render-page-stream.tsx
| 1 | import { renderToReadableStream } from "react-dom/server"; |
| 2 | |
| 3 | import type { Db } from "@/worker/db/d1/client"; |
| 4 | import type { ResolvedPublishedPage, ResolvedSite } from "@/worker/lib/published-pages"; |
| 5 | import { buildSitePagePath } from "@/worker/lib/site-public-url"; |
| 6 | import { renderBlandSitesDocumentToReactElement } from "@/sites/static-renderer"; |
| 7 | import { collectSitesOutline } from "@/sites/static-renderer/outline"; |
| 8 | import { preWalkSitesJson } from "@/sites/json-prewalk"; |
| 9 | import { extractSiteDescription } from "@/sites/excerpt"; |
| 10 | import { SitePageDocument } from "@/sites/document"; |
| 11 | import { runWithSitesReactRenderContext, type SitesReactRenderContext } from "@/sites/react-render-context"; |
| 12 | import type { SitesPageMentionRenderInfo } from "@/sites/types"; |
| 13 | import { resolveOgCover } from "@/worker/sites/cover"; |
| 14 | import type { PreparedSitePageRender } from "@/worker/sites/prepare-page-render"; |
| 15 | |
| 16 | const UPLOAD_PATH = /^\/uploads\/([A-Za-z0-9_-]+)$/; |
| 17 | |
| 18 | export interface RenderSitePageDocumentStreamArgs { |
| 19 | db: Db; |
| 20 | site: ResolvedSite; |
| 21 | page: ResolvedPublishedPage; |
| 22 | prepared: PreparedSitePageRender; |
| 23 | } |
| 24 | |
| 25 | export 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 | |
| 67 | function 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 | |
| 83 | export 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 | } |