release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
|
||||
import { escapeHtml, type PageMeta, type SeoConfig } from "@wrnexus/core";
|
||||
|
||||
export interface ScriptAsset {
|
||||
src: string;
|
||||
/** Module scripts are the default for backward compatibility. */
|
||||
type?: "module" | "classic";
|
||||
async?: boolean;
|
||||
defer?: boolean;
|
||||
integrity?: string;
|
||||
crossOrigin?: "anonymous" | "use-credentials";
|
||||
nonce?: string;
|
||||
attributes?: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
export type RenderScript = string | ScriptAsset;
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Page metadata for the document head. */
|
||||
meta: PageMeta;
|
||||
@@ -22,7 +36,7 @@ export interface RenderOptions {
|
||||
* 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[];
|
||||
scripts?: RenderScript[];
|
||||
/** Optional default document title used when meta.title is absent. */
|
||||
defaultTitle?: string;
|
||||
/** Raw HTML injected at the end of `<head>` (trusted, framework-controlled). */
|
||||
@@ -48,15 +62,14 @@ export interface RenderOptions {
|
||||
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 scripts = normalizeScripts(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>`)
|
||||
.filter((script) => (script.type ?? "module") === "module")
|
||||
.map((script) => `\n <link rel="modulepreload" href="${escapeHtml(script.src)}" />`)
|
||||
.join("");
|
||||
const scriptTags = scripts.map((script) => `\n ${renderScriptTag(script)}`).join("");
|
||||
|
||||
const extraHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
|
||||
const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : "";
|
||||
@@ -99,6 +112,40 @@ export function renderDocument(opts: RenderOptions): string {
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeScripts(scripts: readonly RenderScript[]): ScriptAsset[] {
|
||||
const bySrc = new Map<string, ScriptAsset>();
|
||||
for (const script of scripts) {
|
||||
const normalized: ScriptAsset = typeof script === "string" ? { src: script } : script;
|
||||
if (!normalized.src || bySrc.has(normalized.src)) continue;
|
||||
bySrc.set(normalized.src, normalized);
|
||||
}
|
||||
return [...bySrc.values()];
|
||||
}
|
||||
|
||||
function renderScriptTag(script: ScriptAsset): string {
|
||||
const type = script.type ?? "module";
|
||||
const attrs: string[] = [];
|
||||
if (type === "module") attrs.push('type="module"');
|
||||
if (script.async) attrs.push("async");
|
||||
if (script.defer || (type === "classic" && !script.async)) attrs.push("defer");
|
||||
attrs.push(`src="${escapeHtml(script.src)}"`);
|
||||
if (script.integrity) attrs.push(`integrity="${escapeHtml(script.integrity)}"`);
|
||||
if (script.crossOrigin) attrs.push(`crossorigin="${escapeHtml(script.crossOrigin)}"`);
|
||||
if (script.nonce) attrs.push(`nonce="${escapeHtml(script.nonce)}"`);
|
||||
const reserved = new Set(["src", "type", "async", "defer", "integrity", "crossorigin", "nonce"]);
|
||||
for (const [name, value] of Object.entries(script.attributes ?? {})) {
|
||||
if (
|
||||
reserved.has(name.toLowerCase()) ||
|
||||
!/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(name) ||
|
||||
value === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
attrs.push(value === true ? name : `${name}="${escapeHtml(value)}"`);
|
||||
}
|
||||
return `<script ${attrs.join(" ")}></script>`;
|
||||
}
|
||||
|
||||
interface ResolvedSeo {
|
||||
title: string;
|
||||
description?: string;
|
||||
|
||||
@@ -64,3 +64,27 @@ test("streams async body chunks inside the document shell", async () => {
|
||||
).text();
|
||||
expect(html).toContain('<div id="app"><h1>Shell</h1><p>Later</p></div>');
|
||||
});
|
||||
|
||||
test("renders rich script metadata and deduplicates by source", () => {
|
||||
const html = renderDocument({
|
||||
meta: { title: "Runtime" },
|
||||
body: '<main data-wrnexus-runtime="captcha"></main>',
|
||||
scripts: [
|
||||
{
|
||||
src: "/__wrnexus/assets/captcha.abc.js",
|
||||
type: "classic",
|
||||
defer: true,
|
||||
integrity: "sha256-test",
|
||||
crossOrigin: "anonymous",
|
||||
attributes: { "data-wrnexus-runtime-src": "captcha" },
|
||||
},
|
||||
{ src: "/__wrnexus/assets/captcha.abc.js", type: "classic" },
|
||||
],
|
||||
});
|
||||
expect(html.match(/captcha\.abc\.js/g)).toHaveLength(1);
|
||||
expect(html).toContain("defer");
|
||||
expect(html).toContain('integrity="sha256-test"');
|
||||
expect(html).toContain('crossorigin="anonymous"');
|
||||
expect(html).toContain('data-wrnexus-runtime-src="captcha"');
|
||||
expect(html).not.toContain('rel="modulepreload" href="/__wrnexus/assets/captcha.abc.js"');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user