180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
/**
|
|
* @wrnexus/ssr — server-side rendering.
|
|
*
|
|
* Pages return an HTML string for the body; this module wraps that body in a
|
|
* full document with a `<head>` built from page metadata. It is intentionally
|
|
* isolated from any client runtime: nothing here touches the DOM or ships to
|
|
* the browser, which keeps "server-only code" genuinely server-only.
|
|
*/
|
|
|
|
import { escapeHtml, type PageMeta, type SeoConfig } from "@wrnexus/core";
|
|
|
|
export interface RenderOptions {
|
|
/** Page metadata for the document head. */
|
|
meta: PageMeta;
|
|
/** Global SEO defaults from `wrnexus.config.ts`. */
|
|
seo?: SeoConfig;
|
|
/** Current request URL, used to resolve canonical/Open Graph URLs. */
|
|
url?: URL;
|
|
/** Rendered HTML for the body (placed inside `#app`). */
|
|
body: string;
|
|
/**
|
|
* URLs of `<script type="module">` tags to load (e.g. per-island chunks or
|
|
* the reactive runtime). Only the scripts a page actually needs are passed.
|
|
*/
|
|
scripts?: string[];
|
|
/** Optional default document title used when meta.title is absent. */
|
|
defaultTitle?: string;
|
|
/** Raw HTML injected at the end of `<head>` (trusted, framework-controlled). */
|
|
extraHead?: string;
|
|
/** Raw HTML injected at the end of `<body>` (trusted, framework-controlled). */
|
|
extraBody?: string;
|
|
/** Attributes for the `<html>` element, e.g. ` data-theme="dark"` (trusted). */
|
|
htmlAttrs?: string;
|
|
}
|
|
|
|
/**
|
|
* Render a complete HTML document.
|
|
*
|
|
* Metadata is HTML-escaped so a malicious title/description can never break
|
|
* out of its element or attribute.
|
|
*/
|
|
export function renderDocument(opts: RenderOptions): string {
|
|
const seo = mergeSeo(opts.seo, opts.meta, opts.defaultTitle, opts.url);
|
|
const title = escapeHtml(seo.title);
|
|
const scripts = [...new Set(opts.scripts ?? [])];
|
|
|
|
const seoTags = renderSeoTags(seo);
|
|
const preloadTags = scripts
|
|
.map((src) => `\n <link rel="modulepreload" href="${escapeHtml(src)}" />`)
|
|
.join("");
|
|
const scriptTags = scripts
|
|
.map((src) => `\n <script type="module" src="${escapeHtml(src)}"></script>`)
|
|
.join("");
|
|
|
|
const extraHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
|
|
const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : "";
|
|
|
|
const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "")
|
|
? (opts.htmlAttrs ?? "")
|
|
: `${opts.htmlAttrs ?? ""} lang="en"`;
|
|
|
|
return `<!doctype html>
|
|
<html${htmlAttrs}>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="icon" href="/favicon.ico" />
|
|
<title>${title}</title>${seoTags}${preloadTags}${extraHead}
|
|
</head>
|
|
<body>
|
|
<div id="app">${opts.body}</div>${scriptTags}${extraBody}
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
interface ResolvedSeo {
|
|
title: string;
|
|
description?: string;
|
|
canonical?: string;
|
|
robots?: string;
|
|
keywords?: string;
|
|
image?: string;
|
|
siteName?: string;
|
|
type: string;
|
|
locale?: string;
|
|
twitterCard: string;
|
|
twitterSite?: string;
|
|
themeColor?: string;
|
|
}
|
|
|
|
function mergeSeo(
|
|
globalSeo: SeoConfig | undefined,
|
|
pageSeo: PageMeta,
|
|
defaultTitle: string | undefined,
|
|
url: URL | undefined,
|
|
): ResolvedSeo {
|
|
const pageTitle = pageSeo.title;
|
|
const baseTitle = pageTitle ?? globalSeo?.title ?? defaultTitle ?? "WrNexus";
|
|
const titleTemplate = pageTitle ? globalSeo?.titleTemplate : undefined;
|
|
const title = titleTemplate?.includes("%s") ? titleTemplate.replace("%s", baseTitle) : baseTitle;
|
|
|
|
const canonical = resolveSeoUrl(
|
|
pageSeo.canonical ??
|
|
globalSeo?.canonical ??
|
|
(globalSeo?.canonicalBase && url ? url.pathname : undefined),
|
|
pageSeo.canonicalBase ?? globalSeo?.canonicalBase,
|
|
url,
|
|
);
|
|
const image = resolveSeoUrl(
|
|
pageSeo.image ?? globalSeo?.image,
|
|
pageSeo.canonicalBase ?? globalSeo?.canonicalBase,
|
|
url,
|
|
);
|
|
const keywords = pageSeo.keywords ?? globalSeo?.keywords;
|
|
|
|
return {
|
|
title,
|
|
description: pageSeo.description ?? globalSeo?.description,
|
|
canonical,
|
|
robots: pageSeo.robots ?? globalSeo?.robots,
|
|
keywords: Array.isArray(keywords) ? keywords.join(", ") : keywords,
|
|
image,
|
|
siteName: pageSeo.siteName ?? globalSeo?.siteName,
|
|
type: pageSeo.type ?? globalSeo?.type ?? "website",
|
|
locale: pageSeo.locale ?? globalSeo?.locale,
|
|
twitterCard: pageSeo.twitterCard ?? globalSeo?.twitterCard ?? "summary",
|
|
twitterSite: pageSeo.twitterSite ?? globalSeo?.twitterSite,
|
|
themeColor: pageSeo.themeColor ?? globalSeo?.themeColor,
|
|
};
|
|
}
|
|
|
|
function renderSeoTags(seo: ResolvedSeo): string {
|
|
const tags: string[] = [];
|
|
|
|
if (seo.description) tags.push(meta("description", seo.description));
|
|
if (seo.robots) tags.push(meta("robots", seo.robots));
|
|
if (seo.keywords) tags.push(meta("keywords", seo.keywords));
|
|
if (seo.themeColor) tags.push(meta("theme-color", seo.themeColor));
|
|
if (seo.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(seo.canonical)}" />`);
|
|
|
|
tags.push(property("og:title", seo.title));
|
|
if (seo.description) tags.push(property("og:description", seo.description));
|
|
tags.push(property("og:type", seo.type));
|
|
if (seo.canonical) tags.push(property("og:url", seo.canonical));
|
|
if (seo.siteName) tags.push(property("og:site_name", seo.siteName));
|
|
if (seo.locale) tags.push(property("og:locale", seo.locale));
|
|
if (seo.image) tags.push(property("og:image", seo.image));
|
|
|
|
tags.push(meta("twitter:card", seo.twitterCard));
|
|
tags.push(meta("twitter:title", seo.title));
|
|
if (seo.description) tags.push(meta("twitter:description", seo.description));
|
|
if (seo.image) tags.push(meta("twitter:image", seo.image));
|
|
if (seo.twitterSite) tags.push(meta("twitter:site", seo.twitterSite));
|
|
|
|
return tags.length ? `\n ${tags.join("\n ")}` : "";
|
|
}
|
|
|
|
function meta(name: string, content: string): string {
|
|
return `<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}" />`;
|
|
}
|
|
|
|
function property(name: string, content: string): string {
|
|
return `<meta property="${escapeHtml(name)}" content="${escapeHtml(content)}" />`;
|
|
}
|
|
|
|
function resolveSeoUrl(
|
|
value: string | undefined,
|
|
base: string | undefined,
|
|
currentUrl: URL | undefined,
|
|
): string | undefined {
|
|
if (!value) return undefined;
|
|
try {
|
|
const origin = base ?? (currentUrl ? currentUrl.origin : undefined);
|
|
return origin ? new URL(value, origin).toString() : value;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|