Pre Release New Changes
This commit is contained in:
+120
-6
@@ -41,6 +41,8 @@ export interface RenderOptions {
|
||||
defaultTitle?: string;
|
||||
/** Raw HTML injected at the end of `<head>` (trusted, framework-controlled). */
|
||||
extraHead?: string;
|
||||
/** CSP nonce applied to framework-promoted `.wrn` style blocks. */
|
||||
styleNonce?: 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). */
|
||||
@@ -64,6 +66,11 @@ export function renderDocument(opts: RenderOptions): string {
|
||||
const title = escapeHtml(seo.title);
|
||||
const scripts = normalizeScripts(opts.scripts ?? []);
|
||||
|
||||
const sourceDocument = opts.documentTemplate?.trim();
|
||||
const extracted = extractWrnexusStyles(sourceDocument ?? opts.body, opts.styleNonce);
|
||||
const renderedBody = sourceDocument ? opts.body : extracted.html;
|
||||
const renderedDocument = sourceDocument ? extracted.html : undefined;
|
||||
|
||||
const seoTags = renderSeoTags(seo);
|
||||
const preloadTags = scripts
|
||||
.filter((script) => (script.type ?? "module") === "module")
|
||||
@@ -71,15 +78,16 @@ export function renderDocument(opts: RenderOptions): string {
|
||||
.join("");
|
||||
const scriptTags = scripts.map((script) => `\n ${renderScriptTag(script)}`).join("");
|
||||
|
||||
const extraHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
|
||||
const globalHead = opts.extraHead ? `\n ${opts.extraHead}` : "";
|
||||
const localStyles = extracted.styles ? `\n ${extracted.styles}` : "";
|
||||
const extraBody = opts.extraBody ? `\n ${opts.extraBody}` : "";
|
||||
|
||||
const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "")
|
||||
? (opts.htmlAttrs ?? "")
|
||||
: `${opts.htmlAttrs ?? ""} lang="en"`;
|
||||
|
||||
if (opts.documentTemplate) {
|
||||
let document = opts.documentTemplate.trim();
|
||||
if (renderedDocument) {
|
||||
let document = renderedDocument;
|
||||
if (!/^<!doctype\s+html>/i.test(document)) document = `<!doctype html>\n${document}`;
|
||||
document = document.replace(/<html([^>]*)>/i, (_match, existing: string) => {
|
||||
const language = /(?:^|\s)lang\s*=/.test(`${existing}${opts.htmlAttrs ?? ""}`)
|
||||
@@ -91,8 +99,9 @@ export function renderDocument(opts: RenderOptions): string {
|
||||
<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}`;
|
||||
<title>${title}</title>${seoTags}${preloadTags}${globalHead}`;
|
||||
document = document.replace(/<head([^>]*)>/i, `<head$1>${headContent}`);
|
||||
document = appendBeforeClosingTag(document, "head", localStyles);
|
||||
document = document.replace(/<\/body>/i, `${scriptTags}${extraBody}\n </body>`);
|
||||
return `${document}\n`;
|
||||
}
|
||||
@@ -103,15 +112,120 @@ export function renderDocument(opts: RenderOptions): string {
|
||||
<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}
|
||||
<title>${title}</title>${seoTags}${preloadTags}${globalHead}${localStyles}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">${opts.body}</div>${scriptTags}${extraBody}
|
||||
<div id="app">${renderedBody}</div>${scriptTags}${extraBody}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
interface ExtractedStyle {
|
||||
id: string;
|
||||
owner: string;
|
||||
kind: "layout" | "page" | "component";
|
||||
css: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface ExtractedWrnexusStyles {
|
||||
html: string;
|
||||
styles: string;
|
||||
}
|
||||
|
||||
const WRNEXUS_STYLE_RE = /<style\b([^>]*)>([\s\S]*?)<\/style\s*>/gi;
|
||||
|
||||
function appendBeforeClosingTag(document: string, tag: string, html: string): string {
|
||||
if (!html) return document;
|
||||
const closing = new RegExp(`</${tag}\\s*>`, "i");
|
||||
return closing.test(document)
|
||||
? document.replace(closing, `${html}\n </${tag}>`)
|
||||
: `${document}${html}`;
|
||||
}
|
||||
|
||||
function readHtmlAttribute(attributes: string, name: string): string | undefined {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = attributes.match(
|
||||
new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"),
|
||||
);
|
||||
return match?.[1] ?? match?.[2] ?? match?.[3];
|
||||
}
|
||||
|
||||
function stableStyleHash(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function normalizeStyleKind(value: string | undefined): ExtractedStyle["kind"] {
|
||||
return value === "layout" || value === "page" ? value : "component";
|
||||
}
|
||||
|
||||
function styleKindOrder(kind: ExtractedStyle["kind"]): number {
|
||||
return kind === "layout" ? 0 : kind === "page" ? 1 : 2;
|
||||
}
|
||||
|
||||
function escapeStyleAttribute(value: string): string {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote compiler-emitted `.wrn` style blocks out of rendered markup and into
|
||||
* the document head. They are emitted after global stylesheets, deduplicated by
|
||||
* stable id, ordered layout -> page -> component, and nonce-tagged for CSP.
|
||||
*/
|
||||
export function extractWrnexusStyles(html: string, nonce?: string): ExtractedWrnexusStyles {
|
||||
const found: ExtractedStyle[] = [];
|
||||
let sourceOrder = 0;
|
||||
|
||||
const cleaned = html.replace(
|
||||
WRNEXUS_STYLE_RE,
|
||||
(whole: string, attributes: string, css: string) => {
|
||||
const legacyOwner = readHtmlAttribute(attributes, "data-wrnexus-style");
|
||||
const explicitId = readHtmlAttribute(attributes, "data-wrnexus-style-id");
|
||||
if (!legacyOwner && !explicitId) return whole;
|
||||
|
||||
const owner =
|
||||
readHtmlAttribute(attributes, "data-wrnexus-style-owner") ?? legacyOwner ?? "anonymous";
|
||||
const kind = normalizeStyleKind(readHtmlAttribute(attributes, "data-wrnexus-style-kind"));
|
||||
const id = explicitId ?? `wrn-${kind}-${stableStyleHash(`${kind}:${owner}`)}`;
|
||||
|
||||
found.push({ id, owner, kind, css: css.trim(), order: sourceOrder++ });
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
const byId = new Map<string, ExtractedStyle>();
|
||||
for (const style of found) {
|
||||
const current = byId.get(style.id);
|
||||
if (!current) {
|
||||
byId.set(style.id, style);
|
||||
continue;
|
||||
}
|
||||
// Repeated mounts of one component carry identical CSS. Keep the latest
|
||||
// body only when it differs while preserving the original cascade position.
|
||||
if (current.css !== style.css) byId.set(style.id, { ...style, order: current.order });
|
||||
}
|
||||
|
||||
const nonceAttribute = nonce ? ` nonce="${escapeStyleAttribute(nonce)}"` : "";
|
||||
const styles = [...byId.values()]
|
||||
.sort((left, right) => {
|
||||
const kind = styleKindOrder(left.kind) - styleKindOrder(right.kind);
|
||||
return kind || left.order - right.order;
|
||||
})
|
||||
.map(
|
||||
(style) =>
|
||||
`<style data-wrnexus-style-id="${escapeStyleAttribute(style.id)}" data-wrnexus-style-owner="${escapeStyleAttribute(style.owner)}" data-wrnexus-style-kind="${style.kind}"${nonceAttribute}>\n${style.css.replace(/<\/style/gi, "<\\/style")}\n</style>`,
|
||||
)
|
||||
.join("\n ");
|
||||
|
||||
return { html: cleaned, styles };
|
||||
}
|
||||
|
||||
function normalizeScripts(scripts: readonly RenderScript[]): ScriptAsset[] {
|
||||
const bySrc = new Map<string, ScriptAsset>();
|
||||
for (const script of scripts) {
|
||||
|
||||
@@ -88,3 +88,60 @@ test("renders rich script metadata and deduplicates by source", () => {
|
||||
expect(html).toContain('data-wrnexus-runtime-src="captcha"');
|
||||
expect(html).not.toContain('rel="modulepreload" href="/__wrnexus/assets/captcha.abc.js"');
|
||||
});
|
||||
|
||||
test("promotes WRN style blocks into head after global CSS with CSP nonce", () => {
|
||||
const html = renderDocument({
|
||||
meta: { title: "Styled" },
|
||||
extraHead: '<link rel="stylesheet" href="/global.css" />',
|
||||
styleNonce: "nonce-123",
|
||||
body:
|
||||
'<style data-wrnexus-style="Card" data-wrnexus-style-id="card" data-wrnexus-style-kind="component">.shared{color:blue}</style>' +
|
||||
'<style data-wrnexus-style="Page" data-wrnexus-style-id="page" data-wrnexus-style-kind="page">.shared{color:red}</style>' +
|
||||
'<style data-wrnexus-style="Layout" data-wrnexus-style-id="layout" data-wrnexus-style-kind="layout">.shared{color:green}</style>' +
|
||||
'<main class="shared">Styled</main>',
|
||||
});
|
||||
|
||||
expect(html).not.toContain('<div id="app"><style');
|
||||
expect(html).toContain('<main class="shared">Styled</main>');
|
||||
expect(html.match(/data-wrnexus-style-id=/g)).toHaveLength(3);
|
||||
expect(html.match(/nonce="nonce-123"/g)).toHaveLength(3);
|
||||
|
||||
const globalIndex = html.indexOf("/global.css");
|
||||
const layoutIndex = html.indexOf('data-wrnexus-style-id="layout"');
|
||||
const pageIndex = html.indexOf('data-wrnexus-style-id="page"');
|
||||
const componentIndex = html.indexOf('data-wrnexus-style-id="card"');
|
||||
expect(globalIndex).toBeLessThan(layoutIndex);
|
||||
expect(layoutIndex).toBeLessThan(pageIndex);
|
||||
expect(pageIndex).toBeLessThan(componentIndex);
|
||||
});
|
||||
|
||||
test("deduplicates repeated component style blocks", () => {
|
||||
const style =
|
||||
'<style data-wrnexus-style="Card" data-wrnexus-style-id="card" data-wrnexus-style-kind="component">.card{display:grid}</style>';
|
||||
const html = renderDocument({
|
||||
meta: { title: "Repeated" },
|
||||
body: `${style}<article>One</article>${style}<article>Two</article>`,
|
||||
});
|
||||
|
||||
expect(html.match(/data-wrnexus-style-id="card"/g)).toHaveLength(1);
|
||||
expect(html).toContain("<article>One</article><article>Two</article>");
|
||||
});
|
||||
|
||||
test("places document-layout WRN styles at the end of head", () => {
|
||||
const html = renderDocument({
|
||||
meta: { title: "Document styles" },
|
||||
styleNonce: "document-nonce",
|
||||
body: "",
|
||||
documentTemplate:
|
||||
'<html><head><link rel="stylesheet" href="/document-global.css" /></head><body>' +
|
||||
'<style data-wrnexus-style="Document" data-wrnexus-style-id="document-layout" data-wrnexus-style-kind="layout">.shell{display:block}</style>' +
|
||||
'<div id="app" class="shell">Page</div></body></html>',
|
||||
});
|
||||
|
||||
const globalIndex = html.indexOf("/document-global.css");
|
||||
const styleIndex = html.indexOf('data-wrnexus-style-id="document-layout"');
|
||||
const headEnd = html.indexOf("</head>");
|
||||
expect(globalIndex).toBeLessThan(styleIndex);
|
||||
expect(styleIndex).toBeLessThan(headEnd);
|
||||
expect(html).not.toContain("<body><style");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user