release: WRNexusJS 0.7.0
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import type { PageAst, ViewNode } from "@wrnexus/syntax";
|
||||
|
||||
export type RouteExecutionKind =
|
||||
| "static"
|
||||
| "static-interactive"
|
||||
| "request-ssr"
|
||||
| "authenticated-ssr"
|
||||
| "streaming-ssr"
|
||||
| "dynamic";
|
||||
|
||||
export interface RuntimeRequirements {
|
||||
kind: RouteExecutionKind;
|
||||
canPrerender: boolean;
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function hasEvent(nodes: ViewNode[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element") {
|
||||
if (node.attrs.some((attribute) => attribute.event)) return true;
|
||||
if (hasEvent(node.children)) return true;
|
||||
} else if (node.type === "each") {
|
||||
if (hasEvent(node.body) || hasEvent(node.empty)) return true;
|
||||
} else if (node.type === "if") {
|
||||
if (node.branches.some((branch) => hasEvent(branch.body))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
||||
const reasons: string[] = [];
|
||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||
const interactive =
|
||||
clientFunctions ||
|
||||
clientState ||
|
||||
ast.effects.length > 0 ||
|
||||
ast.watches.length > 0 ||
|
||||
hasEvent(ast.view);
|
||||
if (interactive) reasons.push("client interactivity");
|
||||
|
||||
const requestData =
|
||||
ast.loads.length > 0 ||
|
||||
ast.actions.length > 0 ||
|
||||
ast.dataApis.length > 0 ||
|
||||
ast.apis.length > 0 ||
|
||||
ast.realtimes.length > 0 ||
|
||||
ast.runtimeFunctions.some((fn) => fn.runtime === "server") ||
|
||||
ast.states.some((state) => state.runtime === "server");
|
||||
if (requestData) reasons.push("server/request data");
|
||||
|
||||
const authenticated = /^(?:required|true)$/i.test(ast.security.auth ?? "");
|
||||
if (authenticated) reasons.push("authentication required");
|
||||
|
||||
const streaming = /^(?:true|required)$/i.test(ast.security.streaming ?? "");
|
||||
if (streaming) reasons.push("streaming enabled");
|
||||
|
||||
let kind: RouteExecutionKind;
|
||||
if (streaming) kind = "streaming-ssr";
|
||||
else if (authenticated) kind = "authenticated-ssr";
|
||||
else if (requestData && interactive) kind = "dynamic";
|
||||
else if (requestData) kind = "request-ssr";
|
||||
else if (interactive) kind = "static-interactive";
|
||||
else kind = "static";
|
||||
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsClientRuntime: interactive && ast.hydrate !== "none" && ast.runtime !== "server",
|
||||
needsServerRuntime: requestData || authenticated || streaming || ast.runtime === "server",
|
||||
hydrationStrategy: interactive ? (ast.hydrate ?? "load") : null,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
@@ -77,6 +77,38 @@ function isHtmlBooleanAttribute(name: string): boolean {
|
||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
const URL_ATTRIBUTES = new Set([
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"cite",
|
||||
"background",
|
||||
"xlink:href",
|
||||
]);
|
||||
|
||||
function stripAsciiControlAndSpace(value: string): string {
|
||||
let result = "";
|
||||
for (const character of value) {
|
||||
if (character.charCodeAt(0) > 0x20) result += character;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeUrlAttribute(value: string): string {
|
||||
const compact = stripAsciiControlAndSpace(value.trim());
|
||||
const lower = compact.toLowerCase();
|
||||
if (/^(?:javascript|vbscript|file):/.test(lower)) return "about:blank";
|
||||
if (/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(lower)) return "about:blank";
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeAttributeValue(name: string, value: string): string {
|
||||
if (!URL_ATTRIBUTES.has(name.toLowerCase()) || value.includes("{")) return value;
|
||||
return sanitizeUrlAttribute(value);
|
||||
}
|
||||
|
||||
/** Escape a value placed inside a double-quoted HTML attribute. */
|
||||
function attrEscape(value: string): string {
|
||||
return value
|
||||
@@ -110,7 +142,9 @@ function renderAttr(attr: Attr): string {
|
||||
case "csrText":
|
||||
return "";
|
||||
default:
|
||||
return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`;
|
||||
return attr.boolean
|
||||
? ` ${attr.name}`
|
||||
: ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +210,7 @@ function renderAttrs(
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null) return base;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
return ` ${attr.name}="${attrEscape(URL_ATTRIBUTES.has(attr.name.toLowerCase()) ? sanitizeUrlAttribute(initial) : initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
})
|
||||
.join("");
|
||||
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
|
||||
@@ -2148,7 +2182,7 @@ function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): stri
|
||||
const expression = wholeAttributeExpression(attr.value);
|
||||
|
||||
if (!expression) {
|
||||
return ` ${attr.name}="${attrEscape(attr.value)}"`;
|
||||
return ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
||||
}
|
||||
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
|
||||
@@ -35,6 +35,8 @@ export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen
|
||||
export { createComponentContract } from "./component-contract.ts";
|
||||
export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts";
|
||||
export { createWrnSourceMap } from "./source-map.ts";
|
||||
export { analyzeRuntimeRequirements } from "./analysis.ts";
|
||||
export type { RouteExecutionKind, RuntimeRequirements } from "./analysis.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "@wrnexus/syntax";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
|
||||
|
||||
@@ -204,7 +204,7 @@ function __diagnostic(code, message, details) {
|
||||
}
|
||||
function __csrfToken() {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const match = /(?:^|;\\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie || "");
|
||||
const match = /(?:^|;\\s*)wire-csrf=([^;]+)/.exec(document.cookie || "");
|
||||
return match ? decodeURIComponent(match[1]) : undefined;
|
||||
}
|
||||
async function __callServerFunction(storeName, functionName, args, options) {
|
||||
@@ -215,7 +215,7 @@ async function __callServerFunction(storeName, functionName, args, options) {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal,
|
||||
headers: Object.assign({ "content-type": "application/json", "x-request-id": traceId }, csrf ? { "x-wrnexus-csrf": csrf } : {}, options.headers || {}),
|
||||
headers: Object.assign({ "content-type": "application/json", "x-request-id": traceId }, csrf ? { "x-csrf-token": csrf } : {}, options.headers || {}),
|
||||
body: JSON.stringify({ component: storeName, function: functionName, args: args }),
|
||||
});
|
||||
const payload = await response.json().catch(function () { return null; });
|
||||
|
||||
Reference in New Issue
Block a user