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) {
|
||||
|
||||
Reference in New Issue
Block a user