File
Blob: src/sites/document.tsx
| 1 | import { renderToStaticMarkup } from "react-dom/server"; |
| 2 | |
| 3 | import { |
| 4 | DOC_PAGE_BALANCED_INLINE_OUTLINE_BEFORE_CLASS, |
| 5 | DocumentFrame, |
| 6 | PAGE_SHELL_CLASS, |
| 7 | } from "@/shared/editor/presentation/document-layout"; |
| 8 | import { EditorMetricsPresentation } from "@/shared/editor/presentation/metrics"; |
| 9 | import { OutlinePresentation } from "@/shared/editor/presentation/outline"; |
| 10 | import { SiteOutlineControllerIslandHost } from "@/sites/island-hosts/site-outline-controller"; |
| 11 | import { SiteFooter } from "./footer"; |
| 12 | import { SiteHeader } from "./header"; |
| 13 | import { SiteIconMark } from "./icons"; |
| 14 | import { |
| 15 | createSitesApexRenderContext, |
| 16 | createSitesNotFoundRenderContext, |
| 17 | readSitesDocumentAssets, |
| 18 | readSitesHeaderRenderState, |
| 19 | readSitesPageRenderState, |
| 20 | runWithSitesReactRenderContext, |
| 21 | } from "./react-render-context"; |
| 22 | import type { ApexDocumentProps, NotFoundDocumentProps, SiteOgImage, SitePageDocumentProps } from "./types"; |
| 23 | |
| 24 | function isGradient(cover: string): boolean { |
| 25 | return cover.startsWith("linear-gradient("); |
| 26 | } |
| 27 | |
| 28 | function PageIcon({ icon }: { icon: string }) { |
| 29 | return ( |
| 30 | <SiteIconMark |
| 31 | icon={icon} |
| 32 | imageClassName="site-icon-img block h-7 w-7" |
| 33 | glyphClassName="site-icon-glyph text-[1.75rem] leading-none" |
| 34 | imageSize={28} |
| 35 | /> |
| 36 | ); |
| 37 | } |
| 38 | |
| 39 | function SiteHead({ |
| 40 | title, |
| 41 | canonicalUrl, |
| 42 | ogTitle, |
| 43 | ogUrl, |
| 44 | ogType, |
| 45 | ogImage, |
| 46 | description, |
| 47 | includeModulePreloads = true, |
| 48 | }: { |
| 49 | title: string; |
| 50 | canonicalUrl?: string; |
| 51 | ogTitle?: string; |
| 52 | ogUrl?: string; |
| 53 | ogType?: "article" | "website"; |
| 54 | ogImage?: SiteOgImage | null; |
| 55 | description?: string | null; |
| 56 | includeModulePreloads?: boolean; |
| 57 | }) { |
| 58 | const assets = readSitesDocumentAssets(); |
| 59 | |
| 60 | return ( |
| 61 | <head> |
| 62 | <meta charSet="utf-8" /> |
| 63 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 64 | <title>{title}</title> |
| 65 | <link rel="icon" href="/icons/favicon.ico" sizes="any" /> |
| 66 | <link rel="icon" type="image/svg+xml" href="/icons/icon.svg" /> |
| 67 | <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" /> |
| 68 | <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" /> |
| 69 | <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" /> |
| 70 | <meta name="theme-color" content="#171717" /> |
| 71 | <link rel="preload" as="style" href={assets.fontStylesheetHref} /> |
| 72 | <link rel="stylesheet" href={assets.stylesheetHref} precedence="default" /> |
| 73 | {canonicalUrl ? <link rel="canonical" href={canonicalUrl} /> : null} |
| 74 | {description ? <meta name="description" content={description} /> : null} |
| 75 | {ogType ? <meta property="og:type" content={ogType} /> : null} |
| 76 | {ogTitle ? <meta property="og:title" content={ogTitle} /> : null} |
| 77 | {description ? <meta property="og:description" content={description} /> : null} |
| 78 | {ogUrl ? <meta property="og:url" content={ogUrl} /> : null} |
| 79 | {ogImage ? ( |
| 80 | <> |
| 81 | <meta property="og:image" content={ogImage.url} /> |
| 82 | <meta property="og:image:type" content={ogImage.type} /> |
| 83 | <meta property="og:image:width" content={String(ogImage.width)} /> |
| 84 | <meta property="og:image:height" content={String(ogImage.height)} /> |
| 85 | </> |
| 86 | ) : null} |
| 87 | {includeModulePreloads |
| 88 | ? assets.modulePreloadHrefs.map((href) => <link key={href} rel="modulepreload" href={href} />) |
| 89 | : null} |
| 90 | </head> |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | function SiteDeferredStyles() { |
| 95 | const assets = readSitesDocumentAssets(); |
| 96 | return <link rel="stylesheet" href={assets.fontStylesheetHref} />; |
| 97 | } |
| 98 | |
| 99 | export function SitePageDocument({ children }: SitePageDocumentProps) { |
| 100 | const { |
| 101 | page: { title, icon, coverUrl, ogImage, outline, metrics, description, canonicalUrl, site }, |
| 102 | } = readSitesPageRenderState(); |
| 103 | const coverStyle = coverUrl |
| 104 | ? { backgroundImage: isGradient(coverUrl) ? coverUrl : `url(${JSON.stringify(coverUrl)})` } |
| 105 | : null; |
| 106 | const hasOutline = outline.length > 0; |
| 107 | |
| 108 | return ( |
| 109 | <html lang="en" className="dark scheme-dark"> |
| 110 | <SiteHead |
| 111 | title={title} |
| 112 | canonicalUrl={canonicalUrl} |
| 113 | ogTitle={title} |
| 114 | ogUrl={canonicalUrl} |
| 115 | ogType="article" |
| 116 | ogImage={ogImage} |
| 117 | description={description} |
| 118 | /> |
| 119 | <body className="site-shell flex min-h-screen flex-col bg-canvas font-sans font-[450] text-zinc-100 antialiased"> |
| 120 | <SiteHeader /> |
| 121 | <main id="main-content" className={`site-main flex-1 ${PAGE_SHELL_CLASS}`}> |
| 122 | <DocumentFrame |
| 123 | main={ |
| 124 | <div className="site-container mx-auto min-w-0 max-w-3xl"> |
| 125 | {coverStyle ? ( |
| 126 | <div |
| 127 | className="site-cover-wrapper group/cover relative -mx-4 -mt-10 mb-6 sm:-mx-8 lg:mx-0" |
| 128 | aria-hidden="true" |
| 129 | > |
| 130 | <div |
| 131 | className="site-cover h-48 w-full overflow-hidden rounded-b-lg bg-cover bg-center bg-no-repeat" |
| 132 | style={coverStyle} |
| 133 | /> |
| 134 | </div> |
| 135 | ) : null} |
| 136 | {icon ? ( |
| 137 | <div className="site-icon mb-4 flex min-h-9 items-center leading-none"> |
| 138 | <PageIcon icon={icon} /> |
| 139 | </div> |
| 140 | ) : null} |
| 141 | <h1 className="site-title mb-1.5 font-display text-3xl font-bold leading-9 tracking-[-0.03em] text-zinc-100 sm:text-4xl sm:leading-10"> |
| 142 | {title} |
| 143 | </h1> |
| 144 | <div className="tiptap-document-lead"> |
| 145 | {hasOutline ? ( |
| 146 | <div className={DOC_PAGE_BALANCED_INLINE_OUTLINE_BEFORE_CLASS}> |
| 147 | <OutlinePresentation items={outline} mode="link" variant="card" /> |
| 148 | </div> |
| 149 | ) : null} |
| 150 | <div className="tiptap tiptap-page-body">{children}</div> |
| 151 | </div> |
| 152 | {site.currentIsHome ? null : ( |
| 153 | <div className="mt-4 flex flex-wrap items-center justify-end gap-x-4 gap-y-1"> |
| 154 | <EditorMetricsPresentation metrics={metrics} /> |
| 155 | </div> |
| 156 | )} |
| 157 | </div> |
| 158 | } |
| 159 | rail={hasOutline ? <SiteOutlineControllerIslandHost items={outline} /> : undefined} |
| 160 | railBalance="content" |
| 161 | /> |
| 162 | </main> |
| 163 | <SiteFooter /> |
| 164 | <SiteDeferredStyles /> |
| 165 | </body> |
| 166 | </html> |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | export function NotFoundDocument() { |
| 171 | const header = readSitesHeaderRenderState(); |
| 172 | const site = header |
| 173 | ? { workspaceName: header.workspaceName, workspaceIcon: header.workspaceIcon, homeHref: header.homeHref } |
| 174 | : null; |
| 175 | const heading = site ? "Nothing here to read." : "Nothing here."; |
| 176 | const sub = site ? "This page is not published, or never was." : "This URL doesn't point to a bland site."; |
| 177 | const ctaLabel = site ? `Back to ${site.workspaceName}` : "Visit bland.tools"; |
| 178 | const ctaHref = site ? site.homeHref : "https://bland.tools"; |
| 179 | const docTitle = site ? `Not found - ${site.workspaceName}` : "Not found - bland"; |
| 180 | |
| 181 | return ( |
| 182 | <SiteStatusDocument |
| 183 | mark="404" |
| 184 | variantClass="site-not-found" |
| 185 | heading={heading} |
| 186 | sub={sub} |
| 187 | ctaLabel={ctaLabel} |
| 188 | ctaHref={ctaHref} |
| 189 | docTitle={docTitle} |
| 190 | showHeader={Boolean(site)} |
| 191 | /> |
| 192 | ); |
| 193 | } |
| 194 | |
| 195 | export function BuildingDocument() { |
| 196 | const header = readSitesHeaderRenderState(); |
| 197 | const site = header |
| 198 | ? { workspaceName: header.workspaceName, workspaceIcon: header.workspaceIcon, homeHref: header.homeHref } |
| 199 | : null; |
| 200 | const ctaLabel = site ? `Back to ${site.workspaceName}` : "Visit bland.tools"; |
| 201 | const ctaHref = site ? site.homeHref : "https://bland.tools"; |
| 202 | const docTitle = site ? `Preparing page - ${site.workspaceName}` : "Preparing page - bland"; |
| 203 | |
| 204 | return ( |
| 205 | <SiteStatusDocument |
| 206 | mark="hold on" |
| 207 | variantClass="site-building" |
| 208 | heading="Hang tight." |
| 209 | sub="This page is being prepared. Come back in a moment." |
| 210 | ctaLabel={ctaLabel} |
| 211 | ctaHref={ctaHref} |
| 212 | docTitle={docTitle} |
| 213 | showHeader={Boolean(site)} |
| 214 | /> |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | function SiteStatusDocument({ |
| 219 | mark, |
| 220 | variantClass, |
| 221 | heading, |
| 222 | sub, |
| 223 | ctaLabel, |
| 224 | ctaHref, |
| 225 | docTitle, |
| 226 | showHeader, |
| 227 | }: { |
| 228 | mark: string; |
| 229 | variantClass: string; |
| 230 | heading: string; |
| 231 | sub: string; |
| 232 | ctaLabel: string; |
| 233 | ctaHref: string; |
| 234 | docTitle: string; |
| 235 | showHeader: boolean; |
| 236 | }) { |
| 237 | return ( |
| 238 | <html lang="en" className="dark scheme-dark"> |
| 239 | <SiteHead title={docTitle} includeModulePreloads={false} /> |
| 240 | <body |
| 241 | className={`${variantClass} flex min-h-screen flex-col bg-canvas font-sans font-[450] text-zinc-100 antialiased`} |
| 242 | > |
| 243 | {showHeader ? <SiteHeader /> : null} |
| 244 | <main className="flex flex-1 items-center justify-center px-4 py-16"> |
| 245 | <div className="max-w-md text-center"> |
| 246 | <p className="font-display text-7xl font-bold tracking-tighter text-zinc-700">{mark}</p> |
| 247 | <h1 className="mt-4 font-display text-xl font-semibold text-zinc-200">{heading}</h1> |
| 248 | <p className="mt-2 text-sm text-zinc-400">{sub}</p> |
| 249 | <a |
| 250 | href={ctaHref} |
| 251 | className="mt-6 inline-flex items-center gap-1 rounded-md border border-zinc-700 px-4 py-2 text-sm text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas" |
| 252 | > |
| 253 | {ctaLabel} |
| 254 | </a> |
| 255 | </div> |
| 256 | </main> |
| 257 | <SiteDeferredStyles /> |
| 258 | </body> |
| 259 | </html> |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | export function ApexDocument() { |
| 264 | return ( |
| 265 | <html lang="en" className="dark scheme-dark"> |
| 266 | <SiteHead title="bland" includeModulePreloads={false} /> |
| 267 | <body className="site-apex flex min-h-screen items-center justify-center bg-canvas font-sans text-[1.75rem] tracking-[-0.02em] antialiased"> |
| 268 | <a |
| 269 | href="https://bland.tools" |
| 270 | rel="noopener noreferrer" |
| 271 | aria-label="bland, visit bland.tools" |
| 272 | className="site-apex-mark inline-block bg-[linear-gradient(135deg,oklch(0.72_0.22_25),oklch(0.78_0.18_60),oklch(0.88_0.18_95),oklch(0.78_0.20_145),oklch(0.70_0.20_240),oklch(0.65_0.22_305))] bg-clip-text font-semibold text-transparent transition-[filter,transform] duration-200 ease-out hover:scale-[1.02] hover:saturate-[1.15]" |
| 273 | > |
| 274 | bland. |
| 275 | </a> |
| 276 | <SiteDeferredStyles /> |
| 277 | </body> |
| 278 | </html> |
| 279 | ); |
| 280 | } |
| 281 | |
| 282 | export function renderSiteNotFoundDocumentHtml(props: NotFoundDocumentProps): string { |
| 283 | return runWithSitesReactRenderContext( |
| 284 | createSitesNotFoundRenderContext(props), |
| 285 | () => `<!doctype html>${renderToStaticMarkup(<NotFoundDocument />)}`, |
| 286 | ); |
| 287 | } |
| 288 | |
| 289 | export function renderSiteBuildingDocumentHtml(props: NotFoundDocumentProps): string { |
| 290 | return runWithSitesReactRenderContext( |
| 291 | createSitesNotFoundRenderContext(props), |
| 292 | () => `<!doctype html>${renderToStaticMarkup(<BuildingDocument />)}`, |
| 293 | ); |
| 294 | } |
| 295 | |
| 296 | export function renderApexDocumentHtml(props: ApexDocumentProps): string { |
| 297 | return runWithSitesReactRenderContext( |
| 298 | createSitesApexRenderContext(props), |
| 299 | () => `<!doctype html>${renderToStaticMarkup(<ApexDocument />)}`, |
| 300 | ); |
| 301 | } |
| 302 | |
| 303 | export function renderRobotsTxt(): string { |
| 304 | return "User-agent: *\nAllow: /\n"; |
| 305 | } |