first commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# @wrnexus/ssr
|
||||
|
||||
> Server-side rendering: wraps a page's HTML body in a complete HTML document with a metadata-driven `<head>`.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
Pages in WrNexus return an HTML string for the body. `@wrnexus/ssr` takes that body and produces a full HTML document — building the `<head>` from page metadata and global SEO defaults, resolving canonical/Open Graph/Twitter tags, and injecting module preloads and `<script type="module">` tags. It is deliberately server-only: nothing in this package touches the DOM or ships to the browser, keeping server code genuinely server-only. Reach for it on the server when turning a rendered page body into a response document.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/ssr
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
The package has a single export.
|
||||
|
||||
### `renderDocument(opts: RenderOptions): string`
|
||||
|
||||
Renders a complete HTML document as a string, beginning with `<!doctype html>`. All metadata is HTML-escaped (via `escapeHtml` from `@wrnexus/core`), so a malicious title or description cannot break out of its element or attribute. The body is placed inside `<div id="app">`.
|
||||
|
||||
#### `RenderOptions`
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `meta` | `PageMeta` | Page metadata for the document head (required). |
|
||||
| `body` | `string` | Rendered HTML for the body, placed inside `#app` (required). |
|
||||
| `seo` | `SeoConfig` | Global SEO defaults, typically from `wrnexus.config.ts`. |
|
||||
| `url` | `URL` | Current request URL, used to resolve canonical/Open Graph URLs. |
|
||||
| `scripts` | `string[]` | URLs of `<script type="module">` tags to load (e.g. per-island chunks or the reactive runtime). Each also gets a `<link rel="modulepreload">`. |
|
||||
| `defaultTitle` | `string` | Default document title used when `meta.title` is absent. |
|
||||
| `extraHead` | `string` | Raw HTML injected at the end of `<head>` (trusted, framework-controlled — not escaped). |
|
||||
| `extraBody` | `string` | Raw HTML injected at the end of `<body>` (trusted, framework-controlled — not escaped). |
|
||||
| `htmlAttrs` | `string` | Attributes for the `<html>` element, e.g. ` data-theme="dark"` (trusted). |
|
||||
|
||||
`PageMeta` and `SeoConfig` come from `@wrnexus/core`. `PageMeta` is an alias of `SeoConfig`, whose fields are all optional:
|
||||
|
||||
```ts
|
||||
type SeoConfig = {
|
||||
title?: string;
|
||||
titleTemplate?: string; // e.g. "%s — My Site"; %s is replaced with the page title
|
||||
description?: string;
|
||||
canonical?: string;
|
||||
canonicalBase?: string; // origin used to absolutize canonical/image URLs
|
||||
robots?: string;
|
||||
keywords?: string | string[];
|
||||
image?: string;
|
||||
siteName?: string;
|
||||
type?: string; // Open Graph type; defaults to "website"
|
||||
locale?: string;
|
||||
twitterCard?: string; // defaults to "summary"
|
||||
twitterSite?: string;
|
||||
themeColor?: string;
|
||||
};
|
||||
```
|
||||
|
||||
#### Metadata resolution
|
||||
|
||||
`renderDocument` merges page metadata (`meta`) over global defaults (`seo`), field by field, so per-page values win. Notable behavior:
|
||||
|
||||
- **Title**: uses `meta.title`, else `seo.title`, else `defaultTitle`, else `"WrNexus"`. When the page sets its own title and `seo.titleTemplate` contains `%s`, the template is applied.
|
||||
- **Canonical / image URLs**: resolved against `canonicalBase` (or the request `url`'s origin) into absolute URLs when possible.
|
||||
- **Keywords**: an array is joined with `", "`.
|
||||
- **Emitted tags**: `<title>`, and as applicable `description`, `robots`, `keywords`, `theme-color`, and `canonical` link, plus Open Graph (`og:title`, `og:description`, `og:type`, `og:url`, `og:site_name`, `og:locale`, `og:image`) and Twitter (`twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`, `twitter:site`) meta tags. The document always includes `charset`, `viewport`, and a `/favicon.ico` icon link.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { renderDocument } from "@wrnexus/ssr";
|
||||
|
||||
const html = renderDocument({
|
||||
meta: {
|
||||
title: "About Us",
|
||||
description: "Learn more about our team.",
|
||||
},
|
||||
seo: {
|
||||
titleTemplate: "%s — Acme",
|
||||
siteName: "Acme",
|
||||
canonicalBase: "https://acme.example",
|
||||
twitterSite: "@acme",
|
||||
},
|
||||
url: new URL("https://acme.example/about"),
|
||||
body: "<h1>About Us</h1>",
|
||||
scripts: ["/_wire/runtime.js", "/_wire/islands/about.js"],
|
||||
htmlAttrs: ' data-theme="dark"',
|
||||
});
|
||||
|
||||
return new Response(html, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
```
|
||||
|
||||
The produced document has `<title>About Us — Acme</title>`, the SEO/Open Graph/Twitter tags derived from the merged metadata, a `modulepreload` link and module `<script>` for each entry in `scripts`, and the body wrapped in `<div id="app">`.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Server-only.** This module never imports or touches the DOM and is safe to keep out of client bundles.
|
||||
- **Depends on [`@wrnexus/core`](../core)** for `escapeHtml` and the `PageMeta` / `SeoConfig` types.
|
||||
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime (Node is not supported).
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @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}` : "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html${opts.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderDocument } from "../src/index.ts";
|
||||
|
||||
test("escapes metadata and script URLs while preserving trusted rendered body", () => {
|
||||
const html = renderDocument({
|
||||
meta: { title: '</title><script>alert("x")</script>', description: '" onload="x' },
|
||||
body: "<main>trusted</main>",
|
||||
scripts: ['"><script>alert(1)</script>'],
|
||||
});
|
||||
expect(html).not.toContain('</title><script>alert("x")</script>');
|
||||
expect(html).toContain("</title>");
|
||||
expect(html).toContain("<main>trusted</main>");
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
});
|
||||
|
||||
test("deduplicates runtime scripts and module preloads", () => {
|
||||
const html = renderDocument({
|
||||
meta: { title: "Page" },
|
||||
body: "",
|
||||
scripts: ["/app.js", "/app.js"],
|
||||
});
|
||||
expect(html.match(/rel="modulepreload"/g)).toHaveLength(1);
|
||||
expect(html.match(/src="\/app\.js"/g)).toHaveLength(1);
|
||||
});
|
||||
Reference in New Issue
Block a user