My diagnosis in the previous commit was wrong. The component root was not being replaced by a reactive re-render: the client router owns popstate and swaps the whole page shell on back and forward, which discards component state entirely. Every mechanism that tried to push state into the component from outside was therefore doomed -- clicking a tab, announcing an event, tracking the last applied value. In url mode the query parameter is now simply the source of truth, read where the selection is computed. Whatever render happens next produces the right tab, with no listener to lose and nothing to keep in step. This deletes the runtime tab sync entirely -- 1590 bytes -- and fixes the back/forward cases that were previously broken. Verified in the showcase: click writes the url, two backs and two forwards each land on the right tab, and a ?tab= deep link opens on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5436 lines
169 KiB
TypeScript
5436 lines
169 KiB
TypeScript
/**
|
|
* Browser reactive runtime (Point 2: reactive directives).
|
|
*
|
|
* Served verbatim at `/__wrnexus/reactive.js` for any page that contains a
|
|
* `data-scope`. It is plain browser JS (no build step) and self-contained: it
|
|
* inlines a tiny `signal()` so it has no imports to resolve.
|
|
*
|
|
* Supported directives (this is exactly what the `.wrn` compiler emits):
|
|
* data-scope="count: 0, name: 'x'" declare reactive state on a subtree
|
|
* data-on-<event>="count++" run a statement in scope on an event
|
|
* data-text="expr" element textContent follows an expression
|
|
* data-wrn-loop-locals="base64-json" preserves SSR {#each} item/index values
|
|
* data-wrnexus-csr="id" target for generated CSR fetch bindings
|
|
* {{expr}} or {expr} interpolation inside text nodes
|
|
*
|
|
* Expressions are evaluated by a tiny parser instead of `eval`/`new Function`,
|
|
* so production can use a strong CSP without `unsafe-eval`.
|
|
*/
|
|
|
|
export const REACTIVE_RUNTIME = String.raw`
|
|
(function () {
|
|
var behaviorInstances = new WeakMap();
|
|
var mountedBehaviorRoots = new Set();
|
|
var pendingUpdateHooks = new Map();
|
|
var updateHooksScheduled = false;
|
|
var behaviorObserver;
|
|
var clientModuleCache = new Map();
|
|
|
|
/*
|
|
* Globals the expression engine resolves for client code. Kept as explicit
|
|
* tables rather than falling through to window[name]: an implicit fallback
|
|
* would let any expression reach every global on the page (and would make a
|
|
* typo silently resolve to some unrelated window property) -- these lists
|
|
* say exactly what client code may reach.
|
|
*
|
|
* dialogGlobals must be bound to window or the browser throws
|
|
* "Illegal invocation" when they are called detached.
|
|
*/
|
|
var dialogGlobals = {
|
|
alert: 1,
|
|
confirm: 1,
|
|
prompt: 1,
|
|
fetch: 1,
|
|
print: 1,
|
|
open: 1,
|
|
scrollTo: 1,
|
|
scrollBy: 1,
|
|
matchMedia: 1,
|
|
getComputedStyle: 1,
|
|
structuredClone: 1,
|
|
queueMicrotask: 1,
|
|
btoa: 1,
|
|
atob: 1,
|
|
};
|
|
|
|
// Language builtins. Wrapped in thunks so referencing one that a given
|
|
// engine lacks cannot throw at table-definition time.
|
|
var jsGlobals = {
|
|
Object: function () { return Object; },
|
|
Boolean: function () { return Boolean; },
|
|
RegExp: function () { return RegExp; },
|
|
Promise: function () { return typeof Promise === "undefined" ? undefined : Promise; },
|
|
Set: function () { return typeof Set === "undefined" ? undefined : Set; },
|
|
Map: function () { return typeof Map === "undefined" ? undefined : Map; },
|
|
Error: function () { return Error; },
|
|
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
|
|
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
|
|
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
|
|
parseInt: function () { return parseInt; },
|
|
parseFloat: function () { return parseFloat; },
|
|
isNaN: function () { return isNaN; },
|
|
isFinite: function () { return isFinite; },
|
|
encodeURIComponent: function () { return encodeURIComponent; },
|
|
decodeURIComponent: function () { return decodeURIComponent; },
|
|
encodeURI: function () { return encodeURI; },
|
|
decodeURI: function () { return decodeURI; },
|
|
NaN: function () { return NaN; },
|
|
Infinity: function () { return Infinity; },
|
|
undefined: function () { return undefined; },
|
|
};
|
|
|
|
/*
|
|
* toast(...) -- raise a notification from any client expression.
|
|
*
|
|
* The core runtime deliberately knows nothing about the Toaster component:
|
|
* it only dispatches a window event, and whatever toaster is mounted picks
|
|
* it up. That keeps @wrnexus/ui out of the runtime's dependencies and lets
|
|
* an app swap in its own toaster by listening for the same event.
|
|
*
|
|
* When nothing is listening the message would otherwise vanish silently,
|
|
* which is a miserable thing to debug -- so fall back to the console and
|
|
* say why once.
|
|
*/
|
|
var toastFallbackWarned = false;
|
|
|
|
// Construct from the page window rather than the ambient global: in a
|
|
// non-browser DOM the two are different classes, and a listener added on
|
|
// the page window then never matches an event built from the other one.
|
|
function dispatchWindowEvent(name, detail) {
|
|
var Ctor = window.CustomEvent || CustomEvent;
|
|
window.dispatchEvent(new Ctor(name, { detail: detail || {} }));
|
|
}
|
|
|
|
function emitToast(message, options, tone) {
|
|
var detail =
|
|
message && typeof message === "object" && !(message instanceof Error)
|
|
? Object.assign({}, message)
|
|
: { message: message instanceof Error ? message.message : String(message) };
|
|
|
|
if (options && typeof options === "object") {
|
|
Object.keys(options).forEach(function (key) { detail[key] = options[key]; });
|
|
}
|
|
if (tone && !detail.tone) detail.tone = tone;
|
|
|
|
// Detected from the DOM rather than a flag the host has to set, so any
|
|
// element marked data-toaster counts -- including a custom one.
|
|
if (!document.querySelector("[data-toaster]")) {
|
|
if (!toastFallbackWarned) {
|
|
toastFallbackWarned = true;
|
|
console.info(
|
|
"[wrnexus] toast() was called but no <Toaster /> is mounted on this page; " +
|
|
"falling back to the console. Add <Toaster /> to your layout to show toasts.",
|
|
);
|
|
}
|
|
console.info("[wrnexus:toast]", detail.tone || "info", detail.title || "", detail.message);
|
|
return detail;
|
|
}
|
|
|
|
dispatchWindowEvent("wrnexus:toast", detail);
|
|
return detail;
|
|
}
|
|
|
|
var toastApi = function (message, options) { return emitToast(message, options); };
|
|
toastApi.success = function (message, options) { return emitToast(message, options, "success"); };
|
|
toastApi.error = function (message, options) { return emitToast(message, options, "danger"); };
|
|
toastApi.danger = toastApi.error;
|
|
toastApi.warning = function (message, options) { return emitToast(message, options, "warning"); };
|
|
toastApi.info = function (message, options) { return emitToast(message, options, "info"); };
|
|
toastApi.dismiss = function (id) { dispatchWindowEvent("wrnexus:toast:dismiss", { id: id }); };
|
|
toastApi.clear = function () { dispatchWindowEvent("wrnexus:toast:clear", {}); };
|
|
|
|
/*
|
|
* Published as a real global, not just an identifier the expression engine
|
|
* knows about. Client functions are compiled to an ES module and run as
|
|
* ordinary JavaScript, so a bare toast(...) there is a global lookup that
|
|
* the engine never sees -- without this it throws ReferenceError while the
|
|
* identical call in an inline handler works, which is a maddening
|
|
* inconsistency.
|
|
*
|
|
* wireToast is the stable name. toast is the ergonomic alias and defers to
|
|
* an application that already defines one.
|
|
*/
|
|
window.wireToast = toastApi;
|
|
if (!window.toast) window.toast = toastApi;
|
|
|
|
// Read straight off window, no binding needed (objects, not functions).
|
|
var windowGlobals = {
|
|
localStorage: 1,
|
|
sessionStorage: 1,
|
|
screen: 1,
|
|
performance: 1,
|
|
crypto: 1,
|
|
CustomEvent: 1,
|
|
Event: 1,
|
|
FormData: 1,
|
|
URLSearchParams: 1,
|
|
AbortController: 1,
|
|
Notification: 1,
|
|
IntersectionObserver: 1,
|
|
ResizeObserver: 1,
|
|
MutationObserver: 1,
|
|
devicePixelRatio: 1,
|
|
innerWidth: 1,
|
|
innerHeight: 1,
|
|
scrollX: 1,
|
|
scrollY: 1,
|
|
};
|
|
|
|
function reportDiagnostic(code, message, element, detail) {
|
|
var payload = {
|
|
code: code,
|
|
message: message,
|
|
hydrationId: element && element.getAttribute ? element.getAttribute("data-wrn-hydration") : null,
|
|
detail: detail || null,
|
|
};
|
|
console.error("[wrnexus:" + code + "] " + message, detail || "");
|
|
try {
|
|
window.dispatchEvent(new CustomEvent("wrnexus:diagnostic", { detail: payload }));
|
|
} catch (_) {
|
|
// CustomEvent can be unavailable in minimal DOM test environments.
|
|
}
|
|
}
|
|
|
|
function scheduleUpdateHook(element, callback) {
|
|
pendingUpdateHooks.set(element, callback);
|
|
if (updateHooksScheduled) return;
|
|
updateHooksScheduled = true;
|
|
queueMicrotask(function () {
|
|
updateHooksScheduled = false;
|
|
var pending = Array.from(pendingUpdateHooks.entries());
|
|
pendingUpdateHooks.clear();
|
|
pending.forEach(function (entry) {
|
|
var root = entry[0];
|
|
var hook = entry[1];
|
|
if (!root.isConnected) return;
|
|
try { hook(); } catch (error) {
|
|
console.error("[wrnexus] component update hook failed", error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function decodeScopePayload(value) {
|
|
var binary = window.atob(value);
|
|
var bytes =
|
|
new Uint8Array(binary.length);
|
|
|
|
for (
|
|
var index = 0;
|
|
index < binary.length;
|
|
index++
|
|
) {
|
|
bytes[index] =
|
|
binary.charCodeAt(index);
|
|
}
|
|
|
|
var decoded =
|
|
new TextDecoder(
|
|
"utf-8",
|
|
).decode(bytes);
|
|
|
|
var parsed =
|
|
JSON.parse(decoded);
|
|
|
|
return parsed &&
|
|
typeof parsed === "object"
|
|
? parsed
|
|
: {};
|
|
}
|
|
|
|
function decodeBehaviorPayload(value) {
|
|
var binary = window.atob(value);
|
|
var bytes = new Uint8Array(binary.length);
|
|
|
|
for (var index = 0; index < binary.length; index++) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
|
|
return new TextDecoder("utf-8").decode(bytes);
|
|
}
|
|
|
|
function parseBehavior(element) {
|
|
var raw = element.getAttribute(
|
|
"data-wrn-behavior",
|
|
);
|
|
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
var decoded =
|
|
decodeBehaviorPayload(raw);
|
|
|
|
var parsed = JSON.parse(decoded);
|
|
|
|
if (
|
|
!parsed ||
|
|
typeof parsed !== "object"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return parsed;
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to parse component behavior",
|
|
error,
|
|
);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractBehaviorFunctions(source) {
|
|
var functions = [];
|
|
var re = /(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{/g;
|
|
var match;
|
|
while ((match = re.exec(source || ""))) {
|
|
var start = re.lastIndex;
|
|
var depth = 1;
|
|
var quote = "";
|
|
var i = start;
|
|
for (; i < source.length; i++) {
|
|
var ch = source[i];
|
|
if (quote) {
|
|
if (ch === "\\") i++;
|
|
else if (ch === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'" || ch === "\`") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === "{") depth++;
|
|
else if (ch === "}" && --depth === 0) break;
|
|
}
|
|
if (depth !== 0) break;
|
|
functions.push({
|
|
name: match[1],
|
|
args: match[2].split(",").map(function (arg) { return arg.trim(); }).filter(Boolean),
|
|
body: source.slice(start, i),
|
|
});
|
|
re.lastIndex = i + 1;
|
|
}
|
|
return functions;
|
|
}
|
|
|
|
/*
|
|
* Remove comments before anything tries to read the code.
|
|
*
|
|
* Statement bodies reaching the interpreter -- a lifecycle mount hook, a
|
|
* multi-line inline handler -- are ordinary authored code. A comment line
|
|
* became its own "statement" and blew up the whole body, so the hook simply
|
|
* never ran. Comments are replaced by a space rather than deleted so they
|
|
* cannot glue two tokens together.
|
|
*/
|
|
function stripComments(input) {
|
|
var out = "";
|
|
var quote = "";
|
|
var index = 0;
|
|
|
|
while (index < input.length) {
|
|
var ch = input[index];
|
|
|
|
if (quote) {
|
|
out += ch;
|
|
if (ch === "\\") {
|
|
out += input[index + 1] || "";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (ch === quote) quote = "";
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"' || ch === "'" || ch === "\`") {
|
|
quote = ch;
|
|
out += ch;
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "/" && input[index + 1] === "/") {
|
|
var lineEnd = input.indexOf("\n", index + 2);
|
|
if (lineEnd === -1) break;
|
|
out += "\n";
|
|
index = lineEnd + 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "/" && input[index + 1] === "*") {
|
|
var blockEnd = input.indexOf("*/", index + 2);
|
|
out += " ";
|
|
if (blockEnd === -1) break;
|
|
index = blockEnd + 2;
|
|
continue;
|
|
}
|
|
|
|
out += ch;
|
|
index++;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function splitStatements(rawInput) {
|
|
var input = stripComments(rawInput);
|
|
var parts = [];
|
|
var start = 0;
|
|
var depth = 0;
|
|
var quote = "";
|
|
|
|
function continuesOnNextLine(index) {
|
|
var previousIndex = index - 1;
|
|
var nextIndex = index + 1;
|
|
|
|
while (
|
|
previousIndex >= start &&
|
|
/\s/.test(input[previousIndex])
|
|
) {
|
|
previousIndex--;
|
|
}
|
|
|
|
while (
|
|
nextIndex < input.length &&
|
|
/\s/.test(input[nextIndex])
|
|
) {
|
|
nextIndex++;
|
|
}
|
|
|
|
var previous =
|
|
previousIndex >= start
|
|
? input[previousIndex]
|
|
: "";
|
|
var next =
|
|
nextIndex < input.length
|
|
? input[nextIndex]
|
|
: "";
|
|
|
|
var currentLine = input
|
|
.slice(start, previousIndex + 1)
|
|
.trim();
|
|
|
|
// Postfix updates are complete statements. Treating their trailing + or
|
|
// - as a multiline operator merges the following assignment into the
|
|
// same statement (for example count++ followed by message = ...).
|
|
if (/\+\+$|--$/.test(currentLine)) {
|
|
return false;
|
|
}
|
|
|
|
// Newlines are formatting whitespace while either side is an
|
|
// incomplete expression. This covers multiline assignments and
|
|
// ternaries emitted by formatted .wrn component functions.
|
|
return (
|
|
/[=+\-*/%?:,.!&|<>]/.test(
|
|
previous,
|
|
) ||
|
|
/[.?+:*/%&|<>=]/.test(next)
|
|
);
|
|
}
|
|
|
|
for (var i = 0; i < input.length; i++) {
|
|
var ch = input[i];
|
|
if (quote) {
|
|
if (ch === "\\") i++;
|
|
else if (ch === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'" || ch === "\`") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
else if (
|
|
(
|
|
ch === ";" ||
|
|
ch === "\n" ||
|
|
ch === "\r"
|
|
) &&
|
|
depth === 0
|
|
) {
|
|
if (
|
|
ch !== ";" &&
|
|
continuesOnNextLine(i)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
var nextIndex = i + 1;
|
|
|
|
while (
|
|
nextIndex < input.length &&
|
|
/\s/.test(input[nextIndex])
|
|
) {
|
|
nextIndex++;
|
|
}
|
|
|
|
if (
|
|
input.slice(
|
|
nextIndex,
|
|
nextIndex + 4,
|
|
) === "else"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
var part = input
|
|
.slice(start, i)
|
|
.trim();
|
|
|
|
if (part) {
|
|
parts.push(part);
|
|
}
|
|
|
|
start = i + 1;
|
|
}
|
|
}
|
|
var tail = input.slice(start).trim();
|
|
if (tail) parts.push(tail);
|
|
return parts;
|
|
}
|
|
|
|
function signal(initial) {
|
|
var value = initial;
|
|
var subscribers = new Set();
|
|
var notifying = false;
|
|
|
|
return {
|
|
get: function () {
|
|
return value;
|
|
},
|
|
|
|
set: function (nextValue) {
|
|
if (
|
|
Object.is(
|
|
nextValue,
|
|
value,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var previous = value;
|
|
value = nextValue;
|
|
|
|
/*
|
|
* Prevent nested synchronous notification of the
|
|
* same signal.
|
|
*/
|
|
if (notifying) {
|
|
return;
|
|
}
|
|
|
|
notifying = true;
|
|
|
|
try {
|
|
/*
|
|
* Always iterate a snapshot. A renderer can read
|
|
* this signal and subscribe again while it runs.
|
|
*/
|
|
var snapshot =
|
|
Array.from(subscribers);
|
|
|
|
for (
|
|
var index = 0;
|
|
index < snapshot.length;
|
|
index++
|
|
) {
|
|
var subscriber =
|
|
snapshot[index];
|
|
|
|
if (
|
|
subscribers.has(
|
|
subscriber,
|
|
)
|
|
) {
|
|
subscriber(
|
|
value,
|
|
previous,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
notifying = false;
|
|
}
|
|
},
|
|
|
|
subscribe: function (
|
|
subscriber,
|
|
) {
|
|
subscribers.add(
|
|
subscriber,
|
|
);
|
|
|
|
return function () {
|
|
subscribers.delete(
|
|
subscriber,
|
|
);
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function routeSnapshot() {
|
|
var url = new URL(window.location.href);
|
|
var params = window.__wrnexusRouteParams || {};
|
|
var marker = document.querySelector("[data-wrn-route-params]");
|
|
if (marker) {
|
|
try { params = JSON.parse(marker.getAttribute("data-wrn-route-params") || "{}"); }
|
|
catch (_) { params = {}; }
|
|
window.__wrnexusRouteParams = params;
|
|
marker.remove();
|
|
}
|
|
return {
|
|
url: url.href,
|
|
pathname: url.pathname,
|
|
search: url.search,
|
|
hash: url.hash,
|
|
searchParams: url.searchParams,
|
|
params: params,
|
|
};
|
|
}
|
|
function sanitizeReactiveUrl(value) {
|
|
var raw = String(value == null ? "" : value);
|
|
var compact = raw.trim().replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
|
if (/^(?:javascript|vbscript|file):/.test(compact)) return "about:blank";
|
|
if (/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(compact)) return "about:blank";
|
|
return raw;
|
|
}
|
|
|
|
function applyReactiveAttribute(node, name, value) {
|
|
var lowerName = String(name || "").toLowerCase();
|
|
if (
|
|
value != null &&
|
|
["href", "src", "action", "formaction", "poster", "cite", "background", "xlink:href"].indexOf(lowerName) !== -1
|
|
) {
|
|
value = sanitizeReactiveUrl(value);
|
|
}
|
|
|
|
if (
|
|
lowerName === "value" &&
|
|
(
|
|
node instanceof HTMLInputElement ||
|
|
node instanceof HTMLTextAreaElement ||
|
|
node instanceof HTMLSelectElement
|
|
)
|
|
) {
|
|
var nextValue =
|
|
value == null ? "" : String(value);
|
|
|
|
if (node.value !== nextValue) {
|
|
node.value = nextValue;
|
|
}
|
|
|
|
if (value == null) {
|
|
node.removeAttribute("value");
|
|
} else {
|
|
node.setAttribute(
|
|
"value",
|
|
nextValue,
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
lowerName === "checked" &&
|
|
node instanceof HTMLInputElement
|
|
) {
|
|
var checked = !!value;
|
|
|
|
if (node.checked !== checked) {
|
|
node.checked = checked;
|
|
}
|
|
|
|
if (checked) {
|
|
node.setAttribute(
|
|
"checked",
|
|
"",
|
|
);
|
|
} else {
|
|
node.removeAttribute(
|
|
"checked",
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
lowerName === "selected" &&
|
|
node instanceof HTMLOptionElement
|
|
) {
|
|
var selected = !!value;
|
|
|
|
node.selected = selected;
|
|
|
|
if (selected) {
|
|
node.setAttribute(
|
|
"selected",
|
|
"",
|
|
);
|
|
} else {
|
|
node.removeAttribute(
|
|
"selected",
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
lowerName === "disabled" ||
|
|
lowerName === "required" ||
|
|
lowerName === "readonly" ||
|
|
lowerName === "multiple"
|
|
) {
|
|
var enabled = !!value;
|
|
|
|
if (lowerName in node) {
|
|
try {
|
|
node[lowerName] = enabled;
|
|
} catch (_) {
|
|
// Fall through to attribute handling.
|
|
}
|
|
}
|
|
|
|
if (enabled) {
|
|
node.setAttribute(
|
|
name,
|
|
"",
|
|
);
|
|
} else {
|
|
node.removeAttribute(name);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
lowerName.startsWith("data-") ||
|
|
lowerName.startsWith("aria-")
|
|
) {
|
|
if (value == null) {
|
|
node.removeAttribute(name);
|
|
} else {
|
|
node.setAttribute(name, String(value));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (value === false || value == null) {
|
|
node.removeAttribute(name);
|
|
} else {
|
|
node.setAttribute(
|
|
name,
|
|
value === true ? "" : String(value),
|
|
);
|
|
}
|
|
}
|
|
|
|
function loadClientModule(element) {
|
|
var url = element.getAttribute("data-wrn-client-module");
|
|
if (!url) return Promise.resolve(null);
|
|
var promise = clientModuleCache.get(url);
|
|
if (!promise) {
|
|
promise = import(url).catch(function (error) {
|
|
clientModuleCache.delete(url);
|
|
reportDiagnostic("WRN-CLIENT-MODULE", "Failed to load browser function module '" + url + "'.", element, error);
|
|
return null;
|
|
});
|
|
clientModuleCache.set(url, promise);
|
|
}
|
|
return promise;
|
|
}
|
|
|
|
function setupScope(el) {
|
|
if (el.__wrnexusScope) return;
|
|
|
|
el.__wrnexusScope = true;
|
|
el.__wrnexusHydrated = true;
|
|
|
|
var encodedScope =
|
|
el.getAttribute(
|
|
"data-wrn-scope",
|
|
);
|
|
|
|
var initial;
|
|
|
|
if (encodedScope) {
|
|
try {
|
|
initial =
|
|
decodeScopePayload(
|
|
encodedScope,
|
|
);
|
|
} catch (error) {
|
|
reportDiagnostic(
|
|
"WRN-HYDRATE-SCOPE",
|
|
"Failed to decode the server-rendered scope payload.",
|
|
el,
|
|
error,
|
|
);
|
|
|
|
initial = {};
|
|
}
|
|
} else {
|
|
var declaration =
|
|
el.getAttribute(
|
|
"data-scope",
|
|
) || "";
|
|
|
|
initial =
|
|
parseScopeDecl(
|
|
declaration,
|
|
);
|
|
}
|
|
|
|
var signals = {};
|
|
Object.keys(initial).forEach(function (k) { signals[k] = signal(initial[k]); });
|
|
var behavior = parseBehavior(el);
|
|
var computedDefinitions = {};
|
|
var computing = new Set();
|
|
if (behavior && Array.isArray(behavior.computed)) {
|
|
behavior.computed.forEach(function (entry) {
|
|
if (entry && typeof entry.name === "string" && typeof entry.expr === "string") {
|
|
computedDefinitions[entry.name] = entry.expr;
|
|
}
|
|
});
|
|
}
|
|
|
|
var renderers = [];
|
|
// Dependency-tracked rendering: while a renderer runs, every signal it reads
|
|
// subscribes THAT renderer (not a blanket "re-render everything"). A signal
|
|
// change then re-runs only the renderers that actually read it. The Set in
|
|
// signal.subscribe dedupes, so re-subscribing each run is cheap and bounded.
|
|
var currentRenderer = null;
|
|
var pendingRenderers = new Set();
|
|
var batchDepth = 0;
|
|
var flushingRenderers = false;
|
|
|
|
function flushRenderers() {
|
|
if (flushingRenderers || batchDepth > 0) return;
|
|
|
|
flushingRenderers = true;
|
|
|
|
try {
|
|
while (pendingRenderers.size > 0) {
|
|
var queue = Array.from(pendingRenderers);
|
|
pendingRenderers.clear();
|
|
queue.forEach(function (run) { run(); });
|
|
}
|
|
} finally {
|
|
flushingRenderers = false;
|
|
}
|
|
}
|
|
|
|
function scheduleRenderer(renderer) {
|
|
pendingRenderers.add(renderer);
|
|
flushRenderers();
|
|
}
|
|
|
|
function batchUpdates(callback) {
|
|
batchDepth++;
|
|
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
batchDepth--;
|
|
flushRenderers();
|
|
}
|
|
}
|
|
|
|
function reactive(fn) {
|
|
var running = false;
|
|
|
|
function run() {
|
|
if (running) {
|
|
return;
|
|
}
|
|
|
|
running = true;
|
|
|
|
var previousRenderer =
|
|
currentRenderer;
|
|
|
|
currentRenderer = schedule;
|
|
|
|
try {
|
|
fn();
|
|
} finally {
|
|
currentRenderer =
|
|
previousRenderer;
|
|
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
function schedule() {
|
|
scheduleRenderer(run);
|
|
}
|
|
|
|
renderers.push(run);
|
|
|
|
return run;
|
|
}
|
|
/*
|
|
* An index loop, deliberately, not forEach.
|
|
*
|
|
* forEach fixes its range before the first callback, so an effect
|
|
* registered WHILE the sweep is running was never executed -- and an
|
|
* effect that never runs never subscribes, so it stayed dead for the life
|
|
* of the page. Every binding inside a data-for row registers exactly then,
|
|
* from inside its loop render, which is why a row attribute could not
|
|
* react to anything its list expression did not already read. Re-reading
|
|
* length each step picks those up in the same sweep.
|
|
*
|
|
* The cap is a guard against an effect that registers another effect on
|
|
* every run; it is far above any real component.
|
|
*/
|
|
function renderAll() {
|
|
for (var index = 0; index < renderers.length && index < 10000; index++) {
|
|
renderers[index]();
|
|
}
|
|
}
|
|
function decodeLoopLocals(node) {
|
|
if (!node) {
|
|
return {};
|
|
}
|
|
|
|
var element =
|
|
node.nodeType === 1
|
|
? node
|
|
: node.parentElement;
|
|
|
|
var owner =
|
|
element &&
|
|
element.closest &&
|
|
element.closest(
|
|
"[data-wrn-loop-locals]",
|
|
);
|
|
|
|
if (!owner) {
|
|
return {};
|
|
}
|
|
|
|
var encoded =
|
|
owner.getAttribute(
|
|
"data-wrn-loop-locals",
|
|
);
|
|
|
|
if (!encoded) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
var binary =
|
|
window.atob(encoded);
|
|
|
|
var bytes =
|
|
new Uint8Array(binary.length);
|
|
|
|
for (
|
|
var index = 0;
|
|
index < binary.length;
|
|
index++
|
|
) {
|
|
bytes[index] =
|
|
binary.charCodeAt(index);
|
|
}
|
|
|
|
var decoded =
|
|
new TextDecoder("utf-8").decode(
|
|
bytes,
|
|
);
|
|
|
|
var parsed =
|
|
JSON.parse(decoded);
|
|
|
|
return parsed &&
|
|
typeof parsed === "object"
|
|
? parsed
|
|
: {};
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to decode loop locals",
|
|
error,
|
|
);
|
|
|
|
return {};
|
|
}
|
|
}
|
|
|
|
var behaviorFunctions = {};
|
|
var moduleBindings = {};
|
|
var componentEventTarget = el.hasAttribute("data-wrn-events")
|
|
? el
|
|
: el.querySelector("[data-wrn-events]") || el;
|
|
var declaredEvents = new Set(
|
|
String(componentEventTarget.getAttribute("data-wrn-events") || "")
|
|
.split(",")
|
|
.map(function (name) { return name.trim(); })
|
|
.filter(Boolean),
|
|
);
|
|
var outputHandlers = componentEventTarget.__wrnexusOutputHandlers || (componentEventTarget.__wrnexusOutputHandlers = {});
|
|
var outputProxy = new Proxy({}, {
|
|
get: function (_target, property) {
|
|
return function (payload) {
|
|
return invokeComponentOutput(componentEventTarget, String(property), payload);
|
|
};
|
|
},
|
|
});
|
|
var componentRpcName = componentEventTarget.getAttribute("data-wrn-component") || componentEventTarget.getAttribute("data-wrn-hydration") || "component";
|
|
var serverProxy = new Proxy({}, {
|
|
get: function (_target, property) {
|
|
return function () {
|
|
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
|
|
};
|
|
},
|
|
});
|
|
var propsProxy = new Proxy({}, {
|
|
get: function (_target, property) { return peekScope(String(property)); },
|
|
set: function () { throw new TypeError("WRN-PROP-READONLY: props are readonly"); },
|
|
});
|
|
var refsProxy = new Proxy({}, {
|
|
get: function (_target, property) { return el.querySelector('[data-ref="' + String(property).replace(/"/g, '\"') + '"]'); },
|
|
});
|
|
var stateWatchers = {};
|
|
var anyStateListeners = new Set();
|
|
var cleanupCallbacks = [];
|
|
var disposed = false;
|
|
var routeValue = signal(routeSnapshot());
|
|
|
|
function updateRouteValue() {
|
|
routeValue.set(routeSnapshot());
|
|
}
|
|
|
|
window.addEventListener("wrnexus:navigated", updateRouteValue);
|
|
window.addEventListener("popstate", updateRouteValue);
|
|
window.addEventListener("hashchange", updateRouteValue);
|
|
cleanupCallbacks.push(function () {
|
|
window.removeEventListener("wrnexus:navigated", updateRouteValue);
|
|
window.removeEventListener("popstate", updateRouteValue);
|
|
window.removeEventListener("hashchange", updateRouteValue);
|
|
});
|
|
|
|
function readGlobal(name) {
|
|
if (declaredEvents.has(name)) {
|
|
return function (detail) {
|
|
return dispatchComponentEvent(componentEventTarget, name, detail);
|
|
};
|
|
}
|
|
if (name === "output") return outputProxy;
|
|
if (name === "server") return serverProxy;
|
|
if (name === "props") return propsProxy;
|
|
if (name === "refs") return refsProxy;
|
|
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
|
|
if (name === "$emit") {
|
|
return function (eventName, detail) {
|
|
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
|
};
|
|
}
|
|
if (name === "window") return window;
|
|
if (name === "document") return document;
|
|
if (name === "console") return console;
|
|
if (name === "Array") return Array;
|
|
if (name === "Number") return Number;
|
|
if (name === "String") return String;
|
|
if (name === "Math") return Math;
|
|
if (name === "JSON") return JSON;
|
|
if (name === "Date") return Date;
|
|
if (name === "URL") return URL;
|
|
if (name === "location") return window.location;
|
|
if (name === "history") return window.history;
|
|
if (name === "navigator") return window.navigator;
|
|
if (name === "$route" || name === "route") {
|
|
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
|
return routeValue.get();
|
|
}
|
|
if (name === "setTimeout") return window.setTimeout.bind(window);
|
|
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
|
if (name === "setInterval") return window.setInterval.bind(window);
|
|
if (name === "clearInterval") return window.clearInterval.bind(window);
|
|
if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
|
|
if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
|
|
/*
|
|
* Ordinary browser and language globals.
|
|
*
|
|
* This engine resolves identifiers itself rather than handing the
|
|
* expression to eval/new Function, which is what lets a page run under
|
|
* a strict CSP with no unsafe-eval. The cost is that an unknown
|
|
* identifier quietly evaluates to undefined -- so an author who writes
|
|
* alert(...) or Object.keys(...) in a client function gets
|
|
* "undefined is not a function" and no clue why. Resolving the normal
|
|
* globals here makes client code behave the way its author expects.
|
|
*
|
|
* Bound where the callee needs a window receiver (illegal-invocation
|
|
* otherwise), and read lazily so a server-side or minimal DOM that
|
|
* lacks one of these does not break the rest.
|
|
*/
|
|
if (name === "toast") return toastApi;
|
|
if (dialogGlobals[name] && typeof window[name] === "function") {
|
|
return window[name].bind(window);
|
|
}
|
|
if (jsGlobals[name]) {
|
|
var builtin = jsGlobals[name]();
|
|
if (builtin !== undefined) return builtin;
|
|
}
|
|
if (windowGlobals[name]) {
|
|
try {
|
|
return window[name];
|
|
} catch (error) {
|
|
return undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readScope(name) {
|
|
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
|
if (computing.has(name)) {
|
|
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
|
return undefined;
|
|
}
|
|
computing.add(name);
|
|
try {
|
|
return evalExpr(computedDefinitions[name]);
|
|
} finally {
|
|
computing.delete(name);
|
|
}
|
|
}
|
|
var sig = signals[name];
|
|
if (sig) {
|
|
if (currentRenderer) sig.subscribe(currentRenderer);
|
|
return sig.get();
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
|
return behaviorFunctions[name];
|
|
}
|
|
return readGlobal(name);
|
|
}
|
|
|
|
function peekScope(name) {
|
|
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
|
return readScope(name);
|
|
}
|
|
if (signals[name]) return signals[name].get();
|
|
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
|
return behaviorFunctions[name];
|
|
}
|
|
return readGlobal(name);
|
|
}
|
|
|
|
function notifyState(name, value, previous) {
|
|
var watchers = stateWatchers[name];
|
|
if (watchers) {
|
|
watchers.slice().forEach(function (watcher) {
|
|
try { watcher(value, previous); } catch (error) {
|
|
console.error("[wrnexus] watcher for '" + name + "' failed", error);
|
|
}
|
|
});
|
|
}
|
|
anyStateListeners.forEach(function (listener) {
|
|
try { listener(name, value, previous); } catch (error) {
|
|
console.error("[wrnexus] state subscriber failed", error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function writeScope(
|
|
name,
|
|
value,
|
|
) {
|
|
if (!signals[name]) {
|
|
signals[name] =
|
|
signal(value);
|
|
|
|
notifyState(
|
|
name,
|
|
value,
|
|
undefined,
|
|
);
|
|
|
|
renderAll();
|
|
return;
|
|
}
|
|
|
|
var previous =
|
|
signals[name].get();
|
|
|
|
if (
|
|
Object.is(
|
|
previous,
|
|
value,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
signals[name].set(value);
|
|
|
|
notifyState(
|
|
name,
|
|
value,
|
|
previous,
|
|
);
|
|
}
|
|
|
|
function evalExpr(expr, locals) {
|
|
return evaluateExpression(expr, function (name) {
|
|
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
|
|
return locals[name];
|
|
}
|
|
return readScope(name);
|
|
});
|
|
}
|
|
|
|
function runStmt(
|
|
source,
|
|
locals,
|
|
) {
|
|
return batchUpdates(function () {
|
|
var statements =
|
|
splitStatements(source);
|
|
|
|
for (
|
|
var statementIndex = 0;
|
|
statementIndex <
|
|
statements.length;
|
|
statementIndex++
|
|
) {
|
|
var result = runStatement(
|
|
statements[statementIndex],
|
|
function (expression) {
|
|
return evalExpr(
|
|
expression,
|
|
locals,
|
|
);
|
|
},
|
|
function (name) {
|
|
if (
|
|
locals &&
|
|
Object.prototype
|
|
.hasOwnProperty.call(
|
|
locals,
|
|
name,
|
|
)
|
|
) {
|
|
return locals[name];
|
|
}
|
|
|
|
return peekScope(name);
|
|
},
|
|
function (name, value) {
|
|
if (
|
|
locals &&
|
|
Object.prototype
|
|
.hasOwnProperty.call(
|
|
locals,
|
|
name,
|
|
)
|
|
) {
|
|
locals[name] = value;
|
|
} else {
|
|
writeScope(name, value);
|
|
}
|
|
},
|
|
function (body) {
|
|
return runStmt(
|
|
body,
|
|
locals,
|
|
);
|
|
},
|
|
);
|
|
|
|
if (result.returned) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return {
|
|
returned: false,
|
|
value: undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
function installBehaviorFunctions(source) {
|
|
extractBehaviorFunctions(source || "").forEach(function (definition) {
|
|
// A generated browser module contains the real JavaScript implementation
|
|
// of client/shared functions. Keep that implementation when it has already
|
|
// been installed instead of replacing it with the small CSP-safe fallback
|
|
// interpreter. The fallback intentionally supports only a bounded subset of
|
|
// JavaScript and must never shadow the compiled function.
|
|
if (typeof behaviorFunctions[definition.name] === "function") return;
|
|
behaviorFunctions[
|
|
definition.name
|
|
] = function () {
|
|
var locals = {};
|
|
|
|
for (
|
|
var i = 0;
|
|
i < definition.args.length;
|
|
i++
|
|
) {
|
|
locals[definition.args[i]] =
|
|
arguments[i];
|
|
}
|
|
|
|
var result = runStmt(
|
|
definition.body,
|
|
locals,
|
|
);
|
|
|
|
return result.returned
|
|
? result.value
|
|
: undefined;
|
|
};
|
|
});
|
|
}
|
|
|
|
function installClientModule(module) {
|
|
if (!module) return;
|
|
var stateProxy = new Proxy({}, {
|
|
get: function (_target, property) { return readScope(String(property)); },
|
|
set: function (_target, property, value) { writeScope(String(property), value); return true; },
|
|
ownKeys: function () { return Object.keys(signals); },
|
|
getOwnPropertyDescriptor: function () { return { enumerable: true, configurable: true }; },
|
|
});
|
|
var context = {
|
|
state: stateProxy,
|
|
output: outputProxy,
|
|
server: serverProxy,
|
|
props: propsProxy,
|
|
refs: refsProxy,
|
|
};
|
|
var importedBindings = module.__wrnexusImportedBindings;
|
|
if (importedBindings && typeof importedBindings === "object") {
|
|
Object.keys(importedBindings).forEach(function (name) {
|
|
var binding = importedBindings[name];
|
|
moduleBindings[name] = binding;
|
|
if (binding && typeof binding.subscribe === "function") {
|
|
cleanupCallbacks.push(binding.subscribe(function () { renderAll(); }));
|
|
}
|
|
});
|
|
}
|
|
var functions = typeof module.bindClientScope === "function"
|
|
? module.bindClientScope(context)
|
|
: module.__wrnexusClientFunctions;
|
|
if (!functions || typeof functions !== "object") return;
|
|
Object.keys(functions).forEach(function (name) {
|
|
if (typeof functions[name] === "function") behaviorFunctions[name] = functions[name];
|
|
});
|
|
}
|
|
|
|
installClientModule(el.__wrnexusClientModule);
|
|
|
|
// A binding belongs to THIS scope only when el is the node's *owning*
|
|
// scope. Normally that is the nearest [data-scope] ancestor, so an outer
|
|
// scope never clobbers an inner one's values.
|
|
//
|
|
// Slot content is the exception. Children written between a component's
|
|
// open and close tags are authored
|
|
// in the PARENT's source but SSR splices them inside the child
|
|
// component's [data-scope] root, so the nearest-ancestor rule would hand
|
|
// them to the child -- where the page's state and functions do not exist,
|
|
// and the child's same-named state silently shadows them. The server
|
|
// wraps spliced slot content in [data-wrn-slot], which acts as an
|
|
// ownership boundary: crossing it means stepping *out* of the component
|
|
// that received the slot and continuing the search from its mount point.
|
|
// The loop repeats so slots nested through several components resolve to
|
|
// the scope that actually wrote the markup.
|
|
function ownerScope(node) {
|
|
var cursor = node.nodeType === 1 ? node : node.parentNode;
|
|
for (var guard = 0; cursor && cursor.closest && guard < 32; guard++) {
|
|
var found = cursor.closest(
|
|
"[data-scope], [data-wrn-scope], [data-wrn-slot]",
|
|
);
|
|
if (!found) return null;
|
|
if (!found.hasAttribute("data-wrn-slot")) return found;
|
|
// Step out of the component whose slot this content filled, then keep
|
|
// looking from just above that component's root.
|
|
var componentRoot =
|
|
found.parentNode && found.parentNode.closest
|
|
? found.parentNode.closest("[data-scope], [data-wrn-scope]")
|
|
: null;
|
|
cursor = componentRoot ? componentRoot.parentNode : found.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function owns(node) {
|
|
return ownerScope(node) === el;
|
|
}
|
|
|
|
// --- data-for list rendering -------------------------------------------
|
|
// Each [data-for="item in list"] element is a per-item template. Add
|
|
// data-key="item.id" or a key item.id suffix preserves DOM nodes when
|
|
// a list is reordered. Unkeyed loops retain the legacy full-rerender path.
|
|
function parseFor(value) {
|
|
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)(?:\s+key\s+([\s\S]+?))?\s*$/.exec(
|
|
value || "",
|
|
);
|
|
return m ? { item: m[1], index: m[2], list: m[3].trim(), key: m[4] && m[4].trim() } : null;
|
|
}
|
|
|
|
function unwrapForKey(value) {
|
|
var expression = String(value || "").trim();
|
|
if (expression.charAt(0) === "{" && expression.charAt(expression.length - 1) === "}") {
|
|
expression = expression.slice(1, -1).trim();
|
|
}
|
|
return expression;
|
|
}
|
|
|
|
function stableForKey(value) {
|
|
if (value === null) return "null:";
|
|
var type = typeof value;
|
|
if (type === "object") {
|
|
try { return "object:" + JSON.stringify(value); } catch (_) { return "object:" + String(value); }
|
|
}
|
|
return type + ":" + String(value);
|
|
}
|
|
function fillMustache(str, itemEval) {
|
|
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
|
var e = (d || s).trim();
|
|
try { return String(itemEval(e)); } catch (err) { return ""; }
|
|
});
|
|
}
|
|
function hydrateItem(
|
|
root,
|
|
locals,
|
|
) {
|
|
function localRead(name) {
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(
|
|
locals,
|
|
name,
|
|
)
|
|
) {
|
|
return locals[name];
|
|
}
|
|
|
|
return readScope(name);
|
|
}
|
|
|
|
function itemEval(expression) {
|
|
return evaluateExpression(
|
|
expression,
|
|
localRead,
|
|
);
|
|
}
|
|
|
|
function applyItemTemplate(
|
|
template,
|
|
) {
|
|
return String(template).replace(
|
|
/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g,
|
|
function (
|
|
_,
|
|
doubleExpression,
|
|
singleExpression,
|
|
) {
|
|
var expression = (
|
|
doubleExpression ||
|
|
singleExpression
|
|
).trim();
|
|
|
|
try {
|
|
var value =
|
|
itemEval(expression);
|
|
|
|
return value == null
|
|
? ""
|
|
: String(value);
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/*
|
|
* A nested [data-for] inside this item is a TEMPLATE for its own loop,
|
|
* not content of this one. Interpolating it here would bake the inner
|
|
* mustaches against the outer locals -- where the inner item name is
|
|
* undefined -- and the nested rows would render blank. Skip those
|
|
* subtrees; setupForLoop below takes them over with this item's locals
|
|
* in scope.
|
|
*/
|
|
function insideNestedLoop(node) {
|
|
var cursor = node;
|
|
while (cursor && cursor !== root) {
|
|
if (cursor.nodeType === 1 && cursor.hasAttribute("data-for")) return true;
|
|
cursor = cursor.parentNode;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
var elements = [root];
|
|
|
|
if (root.querySelectorAll) {
|
|
Array.prototype.push.apply(
|
|
elements,
|
|
root.querySelectorAll("*"),
|
|
);
|
|
}
|
|
|
|
elements.forEach(function (node) {
|
|
if (
|
|
!node ||
|
|
node.nodeType !== 1
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (node !== root && insideNestedLoop(node)) return;
|
|
|
|
var attributes =
|
|
Array.prototype.slice.call(
|
|
node.attributes,
|
|
);
|
|
|
|
attributes.forEach(function (
|
|
attribute,
|
|
) {
|
|
/*
|
|
* data-text must be handled using itemEval(),
|
|
* because item/index only exist in loop locals.
|
|
*/
|
|
if (
|
|
attribute.name === "data-text"
|
|
) {
|
|
try {
|
|
var textValue =
|
|
itemEval(attribute.value);
|
|
|
|
node.textContent =
|
|
textValue == null
|
|
? ""
|
|
: String(textValue);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] data-for text binding failed for '" +
|
|
attribute.value +
|
|
"'",
|
|
error,
|
|
);
|
|
|
|
node.textContent = "";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Conditional classes:
|
|
* data-wrn-class-*='["class","expression"]'
|
|
*/
|
|
if (
|
|
attribute.name.indexOf(
|
|
"data-wrn-class-",
|
|
) === 0
|
|
) {
|
|
var classBinding;
|
|
|
|
try {
|
|
classBinding = JSON.parse(
|
|
attribute.value,
|
|
);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!classBinding ||
|
|
classBinding.length !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var className =
|
|
classBinding[0];
|
|
|
|
var classExpression =
|
|
classBinding[1];
|
|
|
|
var classEnabled = false;
|
|
|
|
try {
|
|
classEnabled = !!itemEval(
|
|
classExpression,
|
|
);
|
|
} catch (_) {
|
|
classEnabled = false;
|
|
}
|
|
|
|
node.classList.toggle(
|
|
className,
|
|
classEnabled,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Reactive ordinary attributes:
|
|
* data-wrn-bind-*='["aria-expanded","{isOpen(index)}"]'
|
|
*/
|
|
if (
|
|
attribute.name.indexOf(
|
|
"data-wrn-bind-",
|
|
) === 0
|
|
) {
|
|
var binding;
|
|
|
|
try {
|
|
binding = JSON.parse(
|
|
attribute.value,
|
|
);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!binding ||
|
|
binding.length !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var attributeName =
|
|
binding[0];
|
|
|
|
var attributeTemplate =
|
|
binding[1];
|
|
|
|
/*
|
|
* Reactive, not resolved once. The expression can read component
|
|
* state the row never mentions -- a selection set, the current sort
|
|
* -- and the row must stay in step with it after it is built.
|
|
* Resolving at clone time froze every such attribute at whatever it
|
|
* was on first render.
|
|
*/
|
|
(function (bindNode, bindName, bindTemplate) {
|
|
var exact = /^\{([^{}]+)\}$/.exec(bindTemplate);
|
|
// Run immediately for the same reason as data-show above.
|
|
var runBind = reactive(function () {
|
|
var rawValue;
|
|
if (exact) {
|
|
try {
|
|
rawValue = itemEval(exact[1].trim());
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
} else {
|
|
rawValue = bindTemplate.replace(/\{([^{}]+)\}/g, function (_, expression) {
|
|
try {
|
|
var value = itemEval(expression.trim());
|
|
return value == null ? "" : String(value);
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
});
|
|
}
|
|
applyReactiveAttribute(bindNode, bindName, rawValue);
|
|
});
|
|
runBind();
|
|
})(node, attributeName, attributeTemplate);
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Event handlers inside data-for.
|
|
*/
|
|
if (
|
|
attribute.name.indexOf(
|
|
"data-on-",
|
|
) === 0
|
|
) {
|
|
var eventName =
|
|
attribute.name.slice(
|
|
"data-on-".length,
|
|
);
|
|
|
|
var statement =
|
|
attribute.value;
|
|
|
|
node.addEventListener(
|
|
eventName,
|
|
function (event) {
|
|
var eventLocals = {};
|
|
|
|
Object.keys(
|
|
locals,
|
|
).forEach(function (key) {
|
|
eventLocals[key] =
|
|
locals[key];
|
|
});
|
|
|
|
eventLocals.event = event;
|
|
eventLocals.$event = event;
|
|
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
|
|
|
try {
|
|
runStmt(
|
|
statement,
|
|
eventLocals,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] data-for handler error in '" +
|
|
statement +
|
|
"'",
|
|
error,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* data-show inside data-for.
|
|
*
|
|
* The page-level data-show pass runs once over the DOM at hydration,
|
|
* long before these clones exist, so a row's data-show was never
|
|
* evaluated: the attribute kept its literal expression text and the
|
|
* element stayed visible no matter what. That silently rendered
|
|
* every conditional part of every row -- an empty action button drawn
|
|
* as a stray outline, blank headings taking up space.
|
|
*
|
|
* Evaluated here against the row's own locals. A row is rebuilt when
|
|
* its item changes, so evaluating once per clone is correct and
|
|
* needs no separate subscription.
|
|
*/
|
|
/*
|
|
* data-html inside a loop row. The page-level scan happens once at
|
|
* hydration, long before these clones exist, so a row that renders
|
|
* markup would otherwise never have it applied.
|
|
*/
|
|
if (attribute.name === "data-html") {
|
|
(function (htmlNode, htmlExpr) {
|
|
var runHtml = reactive(function () {
|
|
var markup;
|
|
|
|
try {
|
|
markup = itemEval(htmlExpr);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
var next = markup == null ? "" : String(markup);
|
|
|
|
if (htmlNode.innerHTML !== next) {
|
|
htmlNode.innerHTML = next;
|
|
}
|
|
});
|
|
runHtml();
|
|
})(node, attribute.value);
|
|
|
|
return;
|
|
}
|
|
|
|
if (attribute.name === "data-show") {
|
|
// Reactive, not resolved once: the expression can read component
|
|
// state the row itself knows nothing about (a selection set, a
|
|
// sort key), and those must keep the row in step after it is
|
|
// built.
|
|
(function (showNode, showExpr) {
|
|
/*
|
|
* Run it now, do not wait to be swept. reactive() only
|
|
* registers; the initial renderAll picks registrations up, but a
|
|
* row rebuilt LATER (a re-sort, a filter) registers outside any
|
|
* sweep, and nothing would ever run it. The attribute then stays
|
|
* as the raw expression and the element defaults to visible --
|
|
* which is why sorting a column made a second copy of every
|
|
* header label appear.
|
|
*/
|
|
var runShow = reactive(function () {
|
|
var shown = true;
|
|
|
|
try {
|
|
shown = !!itemEval(showExpr);
|
|
} catch (_) {
|
|
// Unresolvable expression: leave the element visible rather
|
|
// than hiding content because of an authoring slip.
|
|
}
|
|
|
|
showNode.setAttribute(
|
|
"data-show",
|
|
shown ? "true" : "false",
|
|
);
|
|
|
|
showNode.style.display = shown ? "" : "none";
|
|
});
|
|
runShow();
|
|
})(node, attribute.value);
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Static attributes containing interpolation.
|
|
*/
|
|
if (
|
|
attribute.value &&
|
|
attribute.value.indexOf("{") !== -1
|
|
) {
|
|
node.setAttribute(
|
|
attribute.name,
|
|
applyItemTemplate(
|
|
attribute.value,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
/*
|
|
* Text interpolation inside loop content:
|
|
*
|
|
* <span>{item.title}</span>
|
|
*/
|
|
var walker =
|
|
document.createTreeWalker(
|
|
root,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
);
|
|
|
|
var textNode;
|
|
|
|
while (
|
|
(textNode = walker.nextNode())
|
|
) {
|
|
var template =
|
|
textNode.nodeValue || "";
|
|
|
|
if (
|
|
template.indexOf("{") === -1
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (insideNestedLoop(textNode)) continue;
|
|
|
|
textNode.nodeValue =
|
|
applyItemTemplate(template);
|
|
}
|
|
|
|
// Hand every nested loop its own renderer, with this item in scope.
|
|
if (root.querySelectorAll) {
|
|
Array.prototype.slice
|
|
.call(root.querySelectorAll("[data-for]"))
|
|
.forEach(function (nested) {
|
|
// Only the outermost nested templates: deeper ones are set up by
|
|
// their own parent when it renders.
|
|
var parentTemplate = nested.parentNode;
|
|
while (parentTemplate && parentTemplate !== root) {
|
|
if (
|
|
parentTemplate.nodeType === 1 &&
|
|
parentTemplate.hasAttribute("data-for")
|
|
) {
|
|
return;
|
|
}
|
|
parentTemplate = parentTemplate.parentNode;
|
|
}
|
|
setupForLoop(nested, locals);
|
|
});
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Set up one [data-for] template. Extracted from an inline forEach so it
|
|
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
|
* item, passing that item as outerLocals. Without that a table -- rows
|
|
* containing cells -- could not render at all, because the inner loop was
|
|
* never given a renderer and its template was left in the DOM unexpanded.
|
|
*
|
|
* outerLocals are the enclosing item bindings; they are visible to the
|
|
* list expression, the key, and everything the nested rows interpolate.
|
|
*/
|
|
function setupForLoop(tpl, outerLocals) {
|
|
var inherited = outerLocals || {};
|
|
|
|
function withInherited(extra) {
|
|
var merged = {};
|
|
Object.keys(inherited).forEach(function (name) {
|
|
merged[name] = inherited[name];
|
|
});
|
|
if (extra) {
|
|
Object.keys(extra).forEach(function (name) {
|
|
merged[name] = extra[name];
|
|
});
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function loopRead(name) {
|
|
if (Object.prototype.hasOwnProperty.call(inherited, name)) {
|
|
return inherited[name];
|
|
}
|
|
return readScope(name);
|
|
}
|
|
|
|
function loopEval(expression) {
|
|
return evaluateExpression(expression, loopRead);
|
|
}
|
|
|
|
/*
|
|
* A nested template is hydrated while its row is still detached from
|
|
* the document, so owns() cannot walk up to a [data-scope] ancestor
|
|
* and would reject it. Ownership is already settled in that case: the
|
|
* enclosing loop only renders rows it owns.
|
|
*/
|
|
if (!tpl.parentNode || (!outerLocals && !owns(tpl))) {
|
|
return;
|
|
}
|
|
|
|
var spec =
|
|
parseFor(
|
|
tpl.getAttribute(
|
|
"data-for",
|
|
),
|
|
);
|
|
|
|
if (!spec) {
|
|
return;
|
|
}
|
|
|
|
var template =
|
|
tpl.cloneNode(true);
|
|
|
|
template.removeAttribute(
|
|
"data-for",
|
|
);
|
|
|
|
var keyExpression =
|
|
spec.key ||
|
|
unwrapForKey(
|
|
tpl.getAttribute(
|
|
"data-key",
|
|
),
|
|
);
|
|
|
|
template.removeAttribute(
|
|
"data-key",
|
|
);
|
|
|
|
var parent =
|
|
tpl.parentNode;
|
|
|
|
var marker =
|
|
document.createComment(
|
|
"wrn-for",
|
|
);
|
|
|
|
parent.insertBefore(
|
|
marker,
|
|
tpl,
|
|
);
|
|
|
|
parent.removeChild(tpl);
|
|
|
|
var clones = [];
|
|
var keyedRecords = new Map();
|
|
|
|
/*
|
|
* reactive() only REGISTERS the effect; the initial renderAll() sweep
|
|
* runs the renderers array, and forEach fixes its range before the
|
|
* first callback -- so an effect registered while that sweep is
|
|
* already running is never executed. A nested loop registers at
|
|
* exactly that moment, from inside its parent row's render, which is
|
|
* why nested rows came out empty. Run it once by hand.
|
|
*/
|
|
var runLoop = reactive(function () {
|
|
var list =
|
|
loopEval(spec.list);
|
|
|
|
if (!Array.isArray(list)) {
|
|
console.error(
|
|
"[wrnexus] data-for expected an array for '" +
|
|
spec.list +
|
|
"', received",
|
|
list,
|
|
);
|
|
|
|
list = [];
|
|
}
|
|
|
|
if (!keyExpression) {
|
|
for (
|
|
var cloneIndex = 0;
|
|
cloneIndex <
|
|
clones.length;
|
|
cloneIndex++
|
|
) {
|
|
var existingClone =
|
|
clones[cloneIndex];
|
|
|
|
if (
|
|
existingClone.parentNode
|
|
) {
|
|
existingClone.parentNode
|
|
.removeChild(
|
|
existingClone,
|
|
);
|
|
}
|
|
}
|
|
|
|
clones = [];
|
|
|
|
var fragment =
|
|
document.createDocumentFragment();
|
|
|
|
for (
|
|
var itemIndex = 0;
|
|
itemIndex < list.length;
|
|
itemIndex++
|
|
) {
|
|
var clone =
|
|
template.cloneNode(true);
|
|
|
|
var locals = withInherited(null);
|
|
|
|
locals[spec.item] =
|
|
list[itemIndex];
|
|
|
|
if (spec.index) {
|
|
locals[spec.index] =
|
|
itemIndex;
|
|
}
|
|
|
|
hydrateItem(
|
|
clone,
|
|
locals,
|
|
);
|
|
|
|
fragment.appendChild(
|
|
clone,
|
|
);
|
|
|
|
clones.push(clone);
|
|
}
|
|
|
|
parent.insertBefore(
|
|
fragment,
|
|
marker.nextSibling,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
var nextRecords = new Map();
|
|
var orderedNodes = [];
|
|
|
|
for (
|
|
var keyedIndex = 0;
|
|
keyedIndex < list.length;
|
|
keyedIndex++
|
|
) {
|
|
var keyedItem = list[keyedIndex];
|
|
var keyedLocals = withInherited(null);
|
|
|
|
keyedLocals[spec.item] = keyedItem;
|
|
if (spec.index) keyedLocals[spec.index] = keyedIndex;
|
|
|
|
var rawKey;
|
|
try {
|
|
rawKey = evaluateExpression(
|
|
keyExpression,
|
|
function (name) {
|
|
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
|
|
? keyedLocals[name]
|
|
: loopRead(name);
|
|
},
|
|
);
|
|
} catch (error) {
|
|
reportDiagnostic(
|
|
"WRN-HYDRATE-KEY-001",
|
|
"Unable to evaluate data-for key '" + keyExpression + "'.",
|
|
el,
|
|
error,
|
|
);
|
|
rawKey = keyedIndex;
|
|
}
|
|
|
|
var normalizedKey = stableForKey(rawKey);
|
|
if (nextRecords.has(normalizedKey)) {
|
|
reportDiagnostic(
|
|
"WRN-HYDRATE-KEY-002",
|
|
"Duplicate data-for key '" + String(rawKey) + "'; falling back to its index.",
|
|
el,
|
|
{ key: rawKey, index: keyedIndex },
|
|
);
|
|
normalizedKey += ":index:" + keyedIndex;
|
|
}
|
|
|
|
var record = keyedRecords.get(normalizedKey);
|
|
if (
|
|
!record ||
|
|
record.item !== keyedItem ||
|
|
(spec.index && record.index !== keyedIndex)
|
|
) {
|
|
if (record && record.node.parentNode) {
|
|
record.node.parentNode.removeChild(record.node);
|
|
}
|
|
|
|
var keyedClone = template.cloneNode(true);
|
|
hydrateItem(keyedClone, keyedLocals);
|
|
record = {
|
|
node: keyedClone,
|
|
item: keyedItem,
|
|
index: keyedIndex,
|
|
};
|
|
}
|
|
|
|
nextRecords.set(normalizedKey, record);
|
|
orderedNodes.push(record.node);
|
|
}
|
|
|
|
keyedRecords.forEach(function (record, key) {
|
|
if (!nextRecords.has(key) && record.node.parentNode) {
|
|
record.node.parentNode.removeChild(record.node);
|
|
}
|
|
});
|
|
|
|
var keyedFragment = document.createDocumentFragment();
|
|
orderedNodes.forEach(function (node) {
|
|
keyedFragment.appendChild(node);
|
|
});
|
|
|
|
parent.insertBefore(
|
|
keyedFragment,
|
|
marker.nextSibling,
|
|
);
|
|
|
|
keyedRecords = nextRecords;
|
|
clones = orderedNodes;
|
|
});
|
|
|
|
if (outerLocals) runLoop();
|
|
|
|
}
|
|
|
|
Array.prototype.slice
|
|
.call(el.querySelectorAll("[data-for]"))
|
|
.forEach(function (tpl) {
|
|
// Only top-level templates here; nested ones are wired by the item
|
|
// that contains them, once it has values to give them.
|
|
if (tpl.parentNode && tpl.parentNode.closest && tpl.parentNode.closest("[data-for]")) {
|
|
return;
|
|
}
|
|
setupForLoop(tpl, null);
|
|
});
|
|
|
|
// data-text bindings. Server-rendered {#each} nodes recover their item/index
|
|
// values from the nearest data-wrn-loop-locals marker.
|
|
/*
|
|
* data-html="expr" -- render the value as markup instead of text.
|
|
*
|
|
* Deliberately a separate directive from data-text rather than an option
|
|
* on it: this is the one place the runtime stops escaping, so it should be
|
|
* impossible to reach by accident. Only ever point it at markup your own
|
|
* application produced. Anything derived from user input has to be
|
|
* sanitised first -- data-text is the safe default and stays that way.
|
|
*/
|
|
el.querySelectorAll("[data-html]").forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
var htmlExpr = node.getAttribute("data-html");
|
|
|
|
reactive(function () {
|
|
var value;
|
|
|
|
try {
|
|
value = evalExpr(htmlExpr, decodeLoopLocals(node));
|
|
} catch (error) {
|
|
reportDiagnostic("WRN-HTML-EXPR", "data-html expression failed.", node, error);
|
|
return;
|
|
}
|
|
|
|
var markup = value == null ? "" : String(value);
|
|
|
|
if (node.innerHTML !== markup) {
|
|
node.innerHTML = markup;
|
|
}
|
|
});
|
|
});
|
|
|
|
el.querySelectorAll("[data-text]").forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
var expr = node.getAttribute("data-text");
|
|
|
|
reactive(function () {
|
|
try {
|
|
var value = evalExpr(
|
|
expr,
|
|
decodeLoopLocals(node),
|
|
);
|
|
|
|
node.textContent =
|
|
value == null ? "" : String(value);
|
|
} catch (_) {
|
|
// Keep the server-rendered value when evaluation is unavailable.
|
|
}
|
|
});
|
|
});
|
|
|
|
// data-show="expr" — toggle visibility on truthiness. This directive is
|
|
// intentionally non-destructive because popovers, selects and remote data
|
|
// controls keep event wiring and state while closed. Use a compiled {#if}
|
|
// block when the inactive branch must not be rendered.
|
|
el.querySelectorAll("[data-show]").forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
var showExpr = node.getAttribute("data-show");
|
|
|
|
reactive(function () {
|
|
var visible = true;
|
|
|
|
try {
|
|
visible = !!evalExpr(
|
|
showExpr,
|
|
decodeLoopLocals(node),
|
|
);
|
|
} catch (_) {
|
|
// Keep visible when evaluation is unavailable.
|
|
}
|
|
|
|
node.setAttribute(
|
|
"data-show",
|
|
visible ? "true" : "false",
|
|
);
|
|
node.style.display = visible ? "" : "none";
|
|
});
|
|
});
|
|
|
|
// Conditional class bindings emitted as:
|
|
// data-wrn-class-*='["class-name","expression"]'
|
|
var classBindNodes = [el].concat(
|
|
Array.prototype.slice.call(el.querySelectorAll("*")),
|
|
);
|
|
|
|
classBindNodes.forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
|
|
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
|
|
|
var binding;
|
|
|
|
try {
|
|
binding = JSON.parse(marker.value);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
if (!binding || binding.length !== 2) return;
|
|
|
|
var className = binding[0];
|
|
var expression = binding[1];
|
|
|
|
reactive(function () {
|
|
var enabled = false;
|
|
|
|
try {
|
|
enabled = !!evalExpr(
|
|
expression,
|
|
decodeLoopLocals(node),
|
|
);
|
|
} catch (error) {
|
|
enabled = false;
|
|
}
|
|
|
|
node.classList.toggle(
|
|
className,
|
|
enabled,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Reactive ordinary attributes emitted by the compiler. Each marker stores
|
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
|
// state changes to update type, aria-*, class, href, and other attributes.
|
|
var bindNodes = [el].concat(
|
|
Array.prototype.slice.call(
|
|
el.querySelectorAll("*"),
|
|
),
|
|
);
|
|
|
|
bindNodes.forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
Array.prototype.slice
|
|
.call(node.attributes)
|
|
.forEach(function (marker) {
|
|
if (
|
|
marker.name.indexOf(
|
|
"data-wrn-bind-",
|
|
) !== 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var binding;
|
|
|
|
try {
|
|
binding = JSON.parse(
|
|
marker.value,
|
|
);
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!binding ||
|
|
binding.length !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var name = binding[0];
|
|
var template = binding[1];
|
|
|
|
reactive(function () {
|
|
var locals =
|
|
decodeLoopLocals(node);
|
|
|
|
var exact =
|
|
/^\{([^{}]+)\}$/.exec(
|
|
template,
|
|
);
|
|
|
|
var raw;
|
|
|
|
if (exact) {
|
|
try {
|
|
raw = evalExpr(
|
|
exact[1].trim(),
|
|
locals,
|
|
);
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
} else {
|
|
raw = template.replace(
|
|
/\{([^{}]+)\}/g,
|
|
function (
|
|
_,
|
|
expression,
|
|
) {
|
|
try {
|
|
var value =
|
|
evalExpr(
|
|
expression.trim(),
|
|
locals,
|
|
);
|
|
|
|
return value == null
|
|
? ""
|
|
: String(value);
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
applyReactiveAttribute(
|
|
node,
|
|
name,
|
|
raw,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// {{expr}} / {expr} interpolation in text nodes
|
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
|
var textNode;
|
|
while ((textNode = walker.nextNode())) {
|
|
var template = textNode.nodeValue;
|
|
if (template.indexOf("{") === -1) continue;
|
|
if (!owns(textNode)) continue;
|
|
(function (node, tpl) {
|
|
reactive(function () {
|
|
node.nodeValue = tpl.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, doubleExpr, singleExpr) {
|
|
var expr = doubleExpr || singleExpr;
|
|
try {
|
|
var value = evalExpr(
|
|
expr.trim(),
|
|
decodeLoopLocals(
|
|
node.parentNode,
|
|
),
|
|
);
|
|
|
|
return value == null
|
|
? ""
|
|
: String(value);
|
|
} catch (err) { return ""; }
|
|
});
|
|
});
|
|
})(textNode, template);
|
|
}
|
|
|
|
// Event handlers on elements, window, and document.
|
|
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
|
nodes.forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
|
if (attr.name.indexOf("data-on-") !== 0) return;
|
|
|
|
var rawName = attr.name.slice("data-on-".length);
|
|
var target = node;
|
|
var evt = rawName;
|
|
|
|
if (rawName.indexOf("window-") === 0) {
|
|
target = window;
|
|
evt = rawName.slice("window-".length);
|
|
} else if (rawName.indexOf("document-") === 0) {
|
|
target = document;
|
|
evt = rawName.slice("document-".length);
|
|
}
|
|
|
|
var stmt = attr.value;
|
|
var listener = function (event) {
|
|
var locals =
|
|
decodeLoopLocals(node);
|
|
|
|
locals.event = event;
|
|
locals.$event = event;
|
|
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
|
|
|
try {
|
|
runStmt(
|
|
stmt,
|
|
locals,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] handler error in '" +
|
|
stmt +
|
|
"'",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
var options =
|
|
target === window && (evt === "scroll" || evt === "touchstart" || evt === "touchmove")
|
|
? { passive: true }
|
|
: undefined;
|
|
|
|
if (target === componentEventTarget && declaredEvents.has(evt)) {
|
|
var directHandler = function (payload) {
|
|
var locals = decodeLoopLocals(node);
|
|
locals.payload = payload;
|
|
locals.event = undefined;
|
|
locals.$event = undefined;
|
|
return runStmt(stmt, locals);
|
|
};
|
|
(outputHandlers[evt] || (outputHandlers[evt] = new Set())).add(directHandler);
|
|
cleanupCallbacks.push(function () {
|
|
if (outputHandlers[evt]) outputHandlers[evt].delete(directHandler);
|
|
});
|
|
}
|
|
target.addEventListener(evt, listener, options);
|
|
cleanupCallbacks.push(function () {
|
|
target.removeEventListener(evt, listener, options);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Handlers a parent wrote on a component tag, e.g. a Modal tag carrying
|
|
// an @confirm="save()" attribute.
|
|
// The statement belongs to the PARENT's scope but the attribute rides
|
|
// through to the child's view root, so the child must not bind it: that
|
|
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
|
// two cases stay distinguishable, and this scope claims every one that
|
|
// sits on a component it directly mounts.
|
|
Array.prototype.slice
|
|
.call(el.querySelectorAll("[data-wrn-events]"))
|
|
.forEach(function (node) {
|
|
var componentRoot =
|
|
node.closest("[data-scope], [data-wrn-scope]");
|
|
if (!componentRoot || componentRoot === el) return;
|
|
// Only the scope that mounted this component owns its outputs.
|
|
if (
|
|
!componentRoot.parentNode ||
|
|
!componentRoot.parentNode.closest ||
|
|
ownerScope(componentRoot.parentNode) !== el
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var registry =
|
|
node.__wrnexusOutputHandlers ||
|
|
(node.__wrnexusOutputHandlers = {});
|
|
|
|
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
|
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
|
|
|
var outName = attr.name.slice("data-wrn-out-".length);
|
|
var outStmt = attr.value;
|
|
|
|
var outHandler = function (payload) {
|
|
var locals = decodeLoopLocals(componentRoot);
|
|
locals.payload = payload;
|
|
locals.event = undefined;
|
|
locals.$event = undefined;
|
|
try {
|
|
return runStmt(outStmt, locals);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] component output handler error in '" + outStmt + "'",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
(registry[outName] || (registry[outName] = new Set())).add(outHandler);
|
|
cleanupCallbacks.push(function () {
|
|
if (registry[outName]) registry[outName].delete(outHandler);
|
|
});
|
|
|
|
// A component tag can also carry plain DOM events such as click. Those
|
|
// are not declared outputs, so nothing calls the registry entry --
|
|
// listen for the bubbling event too. Declared outputs never reach
|
|
// here: invokeComponentOutput short-circuits once a handler exists.
|
|
var domListener = function (event) {
|
|
var locals = decodeLoopLocals(componentRoot);
|
|
locals.event = event;
|
|
locals.$event = event;
|
|
locals.payload =
|
|
event && Object.prototype.hasOwnProperty.call(event, "detail")
|
|
? event.detail
|
|
: undefined;
|
|
try {
|
|
runStmt(outStmt, locals);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] component handler error in '" + outStmt + "'",
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
node.addEventListener(outName, domListener);
|
|
cleanupCallbacks.push(function () {
|
|
node.removeEventListener(outName, domListener);
|
|
});
|
|
});
|
|
});
|
|
|
|
if (behavior) {
|
|
installBehaviorFunctions(behavior.functions);
|
|
var publicScopeApi = {
|
|
get: peekScope,
|
|
set: writeScope,
|
|
call: function (name) {
|
|
var fn = behaviorFunctions[name];
|
|
if (typeof fn !== "function") return undefined;
|
|
return fn.apply(null, Array.prototype.slice.call(arguments, 1));
|
|
},
|
|
};
|
|
el.__wrnexusScopeApi = publicScopeApi;
|
|
el.querySelectorAll("[data-wrn-select]").forEach(function (select) {
|
|
select.__wrnexusScopeApi = publicScopeApi;
|
|
});
|
|
|
|
(behavior.effects || []).forEach(function (source) {
|
|
if (typeof source !== "string" || !source.trim()) return;
|
|
reactive(function () {
|
|
try { runStmt(source); } catch (error) {
|
|
reportDiagnostic("WRN-EFFECT-ERROR", "Reactive effect failed.", el, error);
|
|
}
|
|
});
|
|
});
|
|
|
|
(behavior.watches || []).forEach(function (watch) {
|
|
if (!watch || typeof watch.state !== "string" || typeof watch.body !== "string") return;
|
|
if (!stateWatchers[watch.state]) stateWatchers[watch.state] = [];
|
|
stateWatchers[watch.state].push(function (value, previous) {
|
|
runStmt(watch.body, { value: value, previous: previous });
|
|
});
|
|
});
|
|
|
|
var updateSource =
|
|
behavior.lifecycle && typeof behavior.lifecycle.update === "string"
|
|
? behavior.lifecycle.update.trim()
|
|
: "";
|
|
|
|
if (updateSource) {
|
|
anyStateListeners.add(function () {
|
|
scheduleUpdateHook(el, function () {
|
|
if (!disposed) runStmt(updateSource);
|
|
});
|
|
});
|
|
}
|
|
|
|
var mountSource =
|
|
behavior.lifecycle && typeof behavior.lifecycle.mount === "string"
|
|
? behavior.lifecycle.mount.trim()
|
|
: "";
|
|
|
|
var unmountSource =
|
|
behavior.lifecycle && typeof behavior.lifecycle.unmount === "string"
|
|
? behavior.lifecycle.unmount.trim()
|
|
: "";
|
|
|
|
var instance = {
|
|
dispose: function () {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
cleanupCallbacks.splice(0).reverse().forEach(function (cleanup) {
|
|
try { cleanup(); } catch (_) { /* ignore cleanup errors */ }
|
|
});
|
|
pendingUpdateHooks.delete(el);
|
|
if (unmountSource) {
|
|
try { runStmt(unmountSource); } catch (error) {
|
|
console.error("[wrnexus] component unmount hook failed", error);
|
|
}
|
|
}
|
|
mountedBehaviorRoots.delete(el);
|
|
behaviorInstances.delete(el);
|
|
delete el.__wrnexusScopeApi;
|
|
el.querySelectorAll("[data-wrn-select]").forEach(function (select) {
|
|
delete select.__wrnexusScopeApi;
|
|
});
|
|
el.__wrnexusScope = false;
|
|
},
|
|
};
|
|
|
|
behaviorInstances.set(el, instance);
|
|
mountedBehaviorRoots.add(el);
|
|
|
|
if (mountSource) {
|
|
queueMicrotask(function () {
|
|
if (!disposed && el.isConnected) {
|
|
try { runStmt(mountSource); } catch (error) {
|
|
console.error("[wrnexus] component mount hook failed", error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
renderAll();
|
|
}
|
|
|
|
function disposeBehaviors(root) {
|
|
var elements = [];
|
|
if (root && root.nodeType === 1 && root.hasAttribute("data-wrn-behavior")) {
|
|
elements.push(root);
|
|
}
|
|
if (root && root.querySelectorAll) {
|
|
Array.prototype.push.apply(elements, root.querySelectorAll("[data-wrn-behavior]"));
|
|
}
|
|
elements.reverse().forEach(function (element) {
|
|
var instance = behaviorInstances.get(element);
|
|
if (instance) instance.dispose();
|
|
});
|
|
}
|
|
|
|
function ensureBehaviorObserver() {
|
|
if (behaviorObserver || typeof MutationObserver === "undefined") return;
|
|
behaviorObserver = new MutationObserver(function (records) {
|
|
records.forEach(function (record) {
|
|
Array.prototype.forEach.call(record.removedNodes, function (node) {
|
|
if (node && node.nodeType === 1) disposeBehaviors(node);
|
|
});
|
|
});
|
|
});
|
|
behaviorObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
}
|
|
|
|
function queueScopeHydration(element) {
|
|
if (!element || element.__wrnexusScope || element.__wrnexusHydrationQueued) return;
|
|
var runtime = element.getAttribute("data-wrn-runtime") || "universal";
|
|
var strategy = element.getAttribute("data-wrn-hydrate") || "load";
|
|
if (runtime === "server" || strategy === "none") return;
|
|
element.__wrnexusHydrationQueued = true;
|
|
|
|
function hydrate() {
|
|
if (element.__wrnexusScope || !element.isConnected) return;
|
|
var started = performance.now ? performance.now() : Date.now();
|
|
function complete(module) {
|
|
if (element.__wrnexusScope || !element.isConnected) return;
|
|
if (module) element.__wrnexusClientModule = module;
|
|
setupScope(element);
|
|
var ended = performance.now ? performance.now() : Date.now();
|
|
try {
|
|
element.dispatchEvent(new CustomEvent("wrnexus:hydrated", {
|
|
bubbles: true,
|
|
detail: {
|
|
id: element.getAttribute("data-wrn-hydration") || null,
|
|
strategy: strategy,
|
|
durationMs: Math.max(0, ended - started),
|
|
},
|
|
}));
|
|
} catch (_) {}
|
|
}
|
|
var moduleUrl = element.getAttribute("data-wrn-client-module");
|
|
if (!moduleUrl || moduleUrl === "__WRNEXUS_CLIENT_MODULE__") {
|
|
complete(null);
|
|
return;
|
|
}
|
|
loadClientModule(element).then(complete);
|
|
}
|
|
|
|
if (strategy === "load") {
|
|
hydrate();
|
|
return;
|
|
}
|
|
if (strategy === "idle") {
|
|
var idle = window.requestIdleCallback || function (callback) { return window.setTimeout(callback, 1); };
|
|
idle(hydrate);
|
|
return;
|
|
}
|
|
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
|
|
var observer = new IntersectionObserver(function (entries) {
|
|
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
observer.disconnect();
|
|
hydrate();
|
|
});
|
|
observer.observe(element);
|
|
return;
|
|
}
|
|
if (strategy === "interaction") {
|
|
var activate = function () {
|
|
element.removeEventListener("pointerdown", activate, true);
|
|
element.removeEventListener("keydown", activate, true);
|
|
element.removeEventListener("focusin", activate, true);
|
|
hydrate();
|
|
};
|
|
element.addEventListener("pointerdown", activate, true);
|
|
element.addEventListener("keydown", activate, true);
|
|
element.addEventListener("focusin", activate, true);
|
|
return;
|
|
}
|
|
if (strategy.indexOf("media:") === 0 && typeof window.matchMedia === "function") {
|
|
var query = strategy.slice(6);
|
|
var media = window.matchMedia(query);
|
|
if (media.matches) hydrate();
|
|
else {
|
|
var onChange = function (event) {
|
|
if (!event.matches) return;
|
|
if (media.removeEventListener) media.removeEventListener("change", onChange);
|
|
else media.removeListener(onChange);
|
|
hydrate();
|
|
};
|
|
if (media.addEventListener) media.addEventListener("change", onChange);
|
|
else media.addListener(onChange);
|
|
}
|
|
return;
|
|
}
|
|
|
|
reportDiagnostic(
|
|
"WRN-HYDRATE-STRATEGY",
|
|
"Unknown hydration strategy '" + strategy + "'. Falling back to load.",
|
|
element,
|
|
);
|
|
hydrate();
|
|
}
|
|
|
|
/*
|
|
* Keep anchored overlays inside the viewport.
|
|
*
|
|
* A popover, tooltip or menu is positioned purely in CSS, relative to its
|
|
* trigger. That is correct until the trigger sits near an edge: the panel
|
|
* then hangs off the side of the screen and is unreadable and unclickable.
|
|
* Every one of these components had that problem, so the nudge lives here
|
|
* once rather than five times.
|
|
*
|
|
* The offset is applied through the standalone translate property, NOT
|
|
* transform: several placements already use transform to centre themselves
|
|
* with translateX(-50%), and the two properties compose instead of
|
|
* overwriting each other. Measurement always happens with the offset
|
|
* cleared, so repositioning is idempotent -- re-running it on a panel that
|
|
* is already on screen changes nothing and cannot drift.
|
|
*/
|
|
var ANCHORED_SELECTOR = "[data-wrn-anchored]";
|
|
var ANCHOR_MARGIN = 8;
|
|
var anchoredScheduled = false;
|
|
// True while the clamp is writing, so its own style writes are not mistaken
|
|
// for a change that needs repositioning. Without this the observer and the
|
|
// clamp chase each other forever and the offset ends up cleared as often as
|
|
// it is applied.
|
|
var anchoredWriting = false;
|
|
|
|
function isRenderedElement(element) {
|
|
if (!element || !element.getBoundingClientRect) return false;
|
|
var rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
function clampAnchored(element) {
|
|
// Measure the untouched position; the offset is recomputed from scratch.
|
|
element.style.translate = "";
|
|
var arrow = element.querySelector("[data-wrn-anchor-arrow]");
|
|
if (arrow) arrow.style.translate = "";
|
|
if (!isRenderedElement(element)) return;
|
|
|
|
var rect = element.getBoundingClientRect();
|
|
var viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
|
|
/*
|
|
* Clamp only while the overlay's own trigger is on screen.
|
|
*
|
|
* Judging by the PANEL alone gets both cases wrong. An overlay whose
|
|
* trigger has scrolled away sits far outside the viewport quite
|
|
* legitimately, and dragging it back tore it off its trigger. But a menu
|
|
* that has only just opened can also start fully outside -- a top-placed
|
|
* dropdown near the top of a phone screen renders above y=0 -- and that
|
|
* one very much does need moving; skipping it left the menu invisible
|
|
* with no way to reach it. The trigger tells the two apart.
|
|
*/
|
|
var host = element.closest("[data-ui-component]") || element.parentElement;
|
|
var anchor = host ? host.getBoundingClientRect() : rect;
|
|
var anchorVisible =
|
|
anchor.bottom > 0 &&
|
|
anchor.top < viewportHeight &&
|
|
anchor.right > 0 &&
|
|
anchor.left < viewportWidth;
|
|
|
|
if (!anchorVisible) {
|
|
return;
|
|
}
|
|
|
|
var shiftX = 0;
|
|
var shiftY = 0;
|
|
|
|
if (rect.width < viewportWidth - ANCHOR_MARGIN * 2) {
|
|
if (rect.left < ANCHOR_MARGIN) shiftX = ANCHOR_MARGIN - rect.left;
|
|
else if (rect.right > viewportWidth - ANCHOR_MARGIN) {
|
|
shiftX = viewportWidth - ANCHOR_MARGIN - rect.right;
|
|
}
|
|
}
|
|
|
|
if (rect.height < viewportHeight - ANCHOR_MARGIN * 2) {
|
|
if (rect.top < ANCHOR_MARGIN) shiftY = ANCHOR_MARGIN - rect.top;
|
|
else if (rect.bottom > viewportHeight - ANCHOR_MARGIN) {
|
|
shiftY = viewportHeight - ANCHOR_MARGIN - rect.bottom;
|
|
}
|
|
}
|
|
|
|
if (!shiftX && !shiftY) return;
|
|
|
|
element.style.translate = shiftX + "px " + shiftY + "px";
|
|
|
|
/*
|
|
* The arrow lives inside the panel, so it travels with it and stops
|
|
* pointing at the trigger. Move it back by the same amount -- but keep it
|
|
* inside the panel, an arrow detached from its own bubble looks worse
|
|
* than one slightly off-centre.
|
|
*/
|
|
if (!arrow) return;
|
|
var arrowRect = arrow.getBoundingClientRect();
|
|
var limitX = Math.max(0, rect.width / 2 - arrowRect.width);
|
|
var limitY = Math.max(0, rect.height / 2 - arrowRect.height);
|
|
var arrowX = Math.max(-limitX, Math.min(limitX, -shiftX));
|
|
var arrowY = Math.max(-limitY, Math.min(limitY, -shiftY));
|
|
if (arrowX || arrowY) {
|
|
arrow.style.translate = arrowX + "px " + arrowY + "px";
|
|
}
|
|
}
|
|
|
|
function repositionAnchored(root) {
|
|
var host = root && root.querySelectorAll ? root : document;
|
|
anchoredWriting = true;
|
|
try {
|
|
Array.prototype.slice
|
|
.call(host.querySelectorAll(ANCHORED_SELECTOR))
|
|
.forEach(clampAnchored);
|
|
} finally {
|
|
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
|
// a background or non-compositing tab, and this flag gates every future
|
|
// reposition -- releasing it from rAF would latch it on and silently
|
|
// disable the whole feature for the life of the page.
|
|
window.setTimeout(function () {
|
|
anchoredWriting = false;
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
function scheduleAnchoredReposition() {
|
|
if (anchoredWriting || anchoredScheduled) return;
|
|
anchoredScheduled = true;
|
|
// setTimeout rather than requestAnimationFrame for the same reason: an
|
|
// overlay opened while the tab is in the background must still be placed
|
|
// correctly, and rAF simply never runs there.
|
|
window.setTimeout(function () {
|
|
anchoredScheduled = false;
|
|
repositionAnchored(document);
|
|
/*
|
|
* A second pass shortly after. The mutation that opens an overlay is
|
|
* usually the same one that makes it visible, so on the very next frame
|
|
* it can still measure as hidden (zero-sized) -- the clamp then skips it
|
|
* and, with no further mutation coming, nothing would ever reposition
|
|
* it. Re-running once the panel has actually been laid out fixes that,
|
|
* and because the clamp recomputes from a cleared offset the repeat is
|
|
* free of side effects.
|
|
*/
|
|
window.setTimeout(function () {
|
|
repositionAnchored(document);
|
|
}, 60);
|
|
});
|
|
}
|
|
|
|
function setupAnchoredOverlays() {
|
|
if (window.__wrnexusAnchoredBound) return;
|
|
window.__wrnexusAnchoredBound = true;
|
|
|
|
// Opening is expressed differently by each component (data-open on the
|
|
// root, data-show on the panel, a class), so watch for any attribute or
|
|
// structural change and re-measure on the next frame instead of trying to
|
|
// enumerate every signal.
|
|
if (typeof MutationObserver === "function") {
|
|
new MutationObserver(scheduleAnchoredReposition).observe(document.documentElement, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ["data-open", "data-show", "class", "data-placement"],
|
|
});
|
|
}
|
|
|
|
window.addEventListener("resize", scheduleAnchoredReposition);
|
|
window.addEventListener("scroll", scheduleAnchoredReposition, true);
|
|
}
|
|
|
|
/*
|
|
* Modal dialog behaviour: focus, focus restore, a Tab trap and a scroll lock.
|
|
*
|
|
* These live here rather than in Modal.wrn and Drawer.wrn because every one
|
|
* of them is the same code, and because a client function cannot hold the
|
|
* "element that had focus before we opened" across a close -- state written
|
|
* after an await or inside a callback is dropped.
|
|
*
|
|
* Fixing focus also repairs Escape. Both components bind @keydown on their
|
|
* own root, so the handler only ever runs when focus is inside the dialog;
|
|
* until something moved focus there, closeOnEscape did nothing at all.
|
|
*/
|
|
var DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]';
|
|
var FOCUSABLE_SELECTOR =
|
|
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled])," +
|
|
' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
var openDialogs = [];
|
|
var dialogRestoreFocus = null;
|
|
var dialogScrollLock = null;
|
|
|
|
/*
|
|
* Open/closed is expressed by the data-show marker these components already
|
|
* emit, not by measured size. Measuring looks more thorough but made the
|
|
* trap and the scroll lock impossible to cover: the test DOM reports every
|
|
* element as zero-sized, so a dialog never counted as open and none of this
|
|
* behaviour ran under test.
|
|
*/
|
|
function isDialogVisible(dialog) {
|
|
if (!dialog || !dialog.isConnected) return false;
|
|
if (dialog.hasAttribute("hidden")) return false;
|
|
if (dialog.closest('[data-show="false"]')) return false;
|
|
return true;
|
|
}
|
|
|
|
function focusableWithin(dialog) {
|
|
var found = [];
|
|
var candidates = dialog.querySelectorAll(FOCUSABLE_SELECTOR);
|
|
for (var index = 0; index < candidates.length; index += 1) {
|
|
var candidate = candidates[index];
|
|
// Same rule as the roving items: markers, not measurement.
|
|
if (candidate.hasAttribute("hidden")) continue;
|
|
if (candidate.closest('[data-show="false"]')) continue;
|
|
found.push(candidate);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function lockDialogScroll() {
|
|
if (dialogScrollLock) return;
|
|
var body = document.body;
|
|
if (!body) return;
|
|
dialogScrollLock = { overflow: body.style.overflow };
|
|
body.style.overflow = "hidden";
|
|
}
|
|
|
|
function unlockDialogScroll() {
|
|
if (!dialogScrollLock || !document.body) return;
|
|
document.body.style.overflow = dialogScrollLock.overflow;
|
|
dialogScrollLock = null;
|
|
}
|
|
|
|
function onDialogOpened(dialog) {
|
|
// Only the first dialog records the restore target; a dialog opened from
|
|
// inside another must not overwrite the page element we came from.
|
|
if (!openDialogs.length) {
|
|
var active = document.activeElement;
|
|
dialogRestoreFocus = active && active !== document.body ? active : null;
|
|
lockDialogScroll();
|
|
}
|
|
openDialogs.push(dialog);
|
|
|
|
// Prefer a real control so keyboard users land somewhere useful, and fall
|
|
// back to the panel itself, which carries tabindex="-1" for exactly this.
|
|
var targets = focusableWithin(dialog);
|
|
var target = targets.length ? targets[0] : dialog;
|
|
if (target && target.focus) target.focus();
|
|
}
|
|
|
|
function onDialogClosed(dialog) {
|
|
var at = openDialogs.indexOf(dialog);
|
|
if (at !== -1) openDialogs.splice(at, 1);
|
|
if (openDialogs.length) return;
|
|
unlockDialogScroll();
|
|
if (dialogRestoreFocus && dialogRestoreFocus.focus && dialogRestoreFocus.isConnected) {
|
|
dialogRestoreFocus.focus();
|
|
}
|
|
dialogRestoreFocus = null;
|
|
}
|
|
|
|
function syncDialogs() {
|
|
var dialogs = document.querySelectorAll(DIALOG_SELECTOR);
|
|
for (var index = 0; index < dialogs.length; index += 1) {
|
|
var dialog = dialogs[index];
|
|
var visible = isDialogVisible(dialog);
|
|
var tracked = openDialogs.indexOf(dialog) !== -1;
|
|
if (visible && !tracked) onDialogOpened(dialog);
|
|
else if (!visible && tracked) onDialogClosed(dialog);
|
|
}
|
|
// A dialog can be removed from the document outright rather than hidden.
|
|
for (var open = openDialogs.length - 1; open >= 0; open -= 1) {
|
|
if (!openDialogs[open].isConnected) onDialogClosed(openDialogs[open]);
|
|
}
|
|
}
|
|
|
|
function trapDialogTab(event) {
|
|
if (event.key !== "Tab" || !openDialogs.length) return;
|
|
var dialog = openDialogs[openDialogs.length - 1];
|
|
var targets = focusableWithin(dialog);
|
|
if (!targets.length) {
|
|
// Nothing to cycle through; keep focus on the panel rather than letting
|
|
// Tab walk out into the page sitting behind the backdrop.
|
|
event.preventDefault();
|
|
if (dialog.focus) dialog.focus();
|
|
return;
|
|
}
|
|
var first = targets[0];
|
|
var last = targets[targets.length - 1];
|
|
var active = document.activeElement;
|
|
if (event.shiftKey && (active === first || !dialog.contains(active))) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && active === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
|
|
function setupModalDialogs() {
|
|
if (window.__wrnexusDialogsBound) return;
|
|
window.__wrnexusDialogsBound = true;
|
|
|
|
if (typeof MutationObserver === "function") {
|
|
new MutationObserver(function () {
|
|
// Same deferral as the anchored clamp: the mutation that opens a
|
|
// dialog is the one that makes it visible, so it still measures as
|
|
// hidden until the panel has been laid out.
|
|
window.setTimeout(syncDialogs, 0);
|
|
}).observe(document.documentElement, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ["data-open", "data-show", "class", "style", "hidden"],
|
|
});
|
|
}
|
|
|
|
document.addEventListener("keydown", trapDialogTab, true);
|
|
syncDialogs();
|
|
}
|
|
|
|
/*
|
|
* Roving arrow-key focus, the ARIA pattern shared by tabs, menus, sidebars
|
|
* and steppers. A container marked data-wrn-roving owns its
|
|
* [data-wrn-roving-item] descendants: exactly one carries tabindex="0" so
|
|
* Tab reaches the group once, and the arrow keys move within it.
|
|
*
|
|
* This lives here rather than in each component because it is the same code
|
|
* five times over, and because focus bookkeeping cannot be held in component
|
|
* state -- a client function writing after it returns has that write dropped.
|
|
*/
|
|
var ROVING_SELECTOR = "[data-wrn-roving]";
|
|
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
|
|
|
|
/*
|
|
* Templates stringify these, so data-wrn-roving="" and
|
|
* data-wrn-roving-item="false" mean "not this time". A bare [attr] selector
|
|
* matches either, so the value must be checked.
|
|
*/
|
|
// A bare data-wrn-roving-item means yes; only an explicit "false" opts out.
|
|
function rovingItemOff(value) {
|
|
return value === null || value === "false";
|
|
}
|
|
|
|
/*
|
|
* The container is stricter: it must name an axis. An empty value is what a
|
|
* template emits for {cond ? "horizontal" : ""}, so empty means off rather
|
|
* than defaulting to horizontal.
|
|
*/
|
|
function rovingOrientation(container) {
|
|
var value = container.getAttribute("data-wrn-roving");
|
|
if (value === null || value === "" || value === "false") return "";
|
|
return value;
|
|
}
|
|
|
|
function rovingItems(container) {
|
|
var found = [];
|
|
var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR);
|
|
for (var index = 0; index < candidates.length; index += 1) {
|
|
var candidate = candidates[index];
|
|
if (rovingItemOff(candidate.getAttribute("data-wrn-roving-item"))) continue;
|
|
// A nested group owns its own items; do not steal them.
|
|
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
|
|
if (candidate.hasAttribute("disabled")) continue;
|
|
if (candidate.getAttribute("aria-disabled") === "true") continue;
|
|
// Markers, not measurement: the test DOM reports every element as
|
|
// zero-sized, which is what left the dialog trap uncovered.
|
|
if (candidate.hasAttribute("hidden")) continue;
|
|
if (candidate.closest('[data-show="false"]')) continue;
|
|
found.push(candidate);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function rovingActiveIndex(items) {
|
|
for (var index = 0; index < items.length; index += 1) {
|
|
var item = items[index];
|
|
if (item.getAttribute("aria-selected") === "true") return index;
|
|
var current = item.getAttribute("aria-current");
|
|
if (current === "page" || current === "step" || current === "true") return index;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function applyRovingTabindex(items, activeIndex) {
|
|
for (var index = 0; index < items.length; index += 1) {
|
|
items[index].setAttribute("tabindex", index === activeIndex ? "0" : "-1");
|
|
}
|
|
}
|
|
|
|
function syncRovingGroup(container) {
|
|
if (!rovingOrientation(container)) return;
|
|
var items = rovingItems(container);
|
|
if (!items.length) return;
|
|
applyRovingTabindex(items, rovingActiveIndex(items));
|
|
}
|
|
|
|
function syncRovingGroups() {
|
|
var groups = document.querySelectorAll(ROVING_SELECTOR);
|
|
for (var index = 0; index < groups.length; index += 1) syncRovingGroup(groups[index]);
|
|
}
|
|
|
|
function handleRovingKeydown(event) {
|
|
var target = event.target;
|
|
if (!target || !target.closest) return;
|
|
var item = target.closest(ROVING_ITEM_SELECTOR);
|
|
if (!item) return;
|
|
var container = item.closest(ROVING_SELECTOR);
|
|
if (!container) return;
|
|
|
|
var items = rovingItems(container);
|
|
var index = items.indexOf(item);
|
|
if (index === -1) return;
|
|
|
|
var orientation = rovingOrientation(container);
|
|
if (!orientation) return;
|
|
var horizontal = orientation === "horizontal" || orientation === "both";
|
|
var vertical = orientation === "vertical" || orientation === "both";
|
|
var key = event.key;
|
|
var next = -1;
|
|
|
|
if ((horizontal && key === "ArrowRight") || (vertical && key === "ArrowDown")) {
|
|
next = (index + 1) % items.length;
|
|
} else if ((horizontal && key === "ArrowLeft") || (vertical && key === "ArrowUp")) {
|
|
next = (index - 1 + items.length) % items.length;
|
|
} else if (key === "Home") {
|
|
next = 0;
|
|
} else if (key === "End") {
|
|
next = items.length - 1;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
applyRovingTabindex(items, next);
|
|
if (items[next].focus) items[next].focus();
|
|
}
|
|
|
|
function setupRovingFocus() {
|
|
if (window.__wrnexusRovingBound) return;
|
|
window.__wrnexusRovingBound = true;
|
|
|
|
document.addEventListener("keydown", handleRovingKeydown, true);
|
|
|
|
if (typeof MutationObserver === "function") {
|
|
new MutationObserver(function () {
|
|
window.setTimeout(syncRovingGroups, 0);
|
|
}).observe(document.documentElement, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ["aria-selected", "aria-current", "disabled", "aria-disabled", "hidden"],
|
|
});
|
|
}
|
|
|
|
syncRovingGroups();
|
|
}
|
|
|
|
function hydrateScopes(root) {
|
|
var host = root || document;
|
|
|
|
var scopeSelector =
|
|
"[data-scope], [data-wrn-scope]";
|
|
|
|
if (
|
|
host.nodeType === 1 &&
|
|
host.matches &&
|
|
host.matches(scopeSelector)
|
|
) {
|
|
queueScopeHydration(host);
|
|
}
|
|
|
|
if (host.querySelectorAll) {
|
|
host
|
|
.querySelectorAll(scopeSelector)
|
|
.forEach(queueScopeHydration);
|
|
}
|
|
|
|
ensureBehaviorObserver();
|
|
hydrateNavbarControllers(host);
|
|
hydratePreferenceControllers(host);
|
|
hydrateSidebarControllers(host);
|
|
hydrateDropdownControllers(host);
|
|
hydrateSelectControllers(host);
|
|
hydratePinInputControllers(host);
|
|
}
|
|
|
|
var navbarOutsideClickBound = false;
|
|
var preferenceOutsideClickBound = false;
|
|
var dropdownOutsideClickBound = false;
|
|
|
|
function hydrateSidebarControllers(root) {
|
|
var host = root || document;
|
|
var sidebars = [];
|
|
if (host.nodeType === 1 && host.matches && host.matches(".wire-sidebar-shell")) {
|
|
sidebars.push(host);
|
|
}
|
|
if (host.querySelectorAll) {
|
|
Array.prototype.push.apply(sidebars, host.querySelectorAll(".wire-sidebar-shell"));
|
|
}
|
|
sidebars.forEach(function (sidebar) {
|
|
var current = window.location.pathname.replace(/\/+$/, "") || "/";
|
|
var best = null;
|
|
var bestLength = -1;
|
|
sidebar.querySelectorAll(".wire-sidebar-items a[href]").forEach(function (link) {
|
|
var url;
|
|
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
|
|
var path = url.pathname.replace(/\/+$/, "") || "/";
|
|
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
|
|
link.classList.remove("is-active");
|
|
if (!matches || path.length <= bestLength) return;
|
|
best = link;
|
|
bestLength = path.length;
|
|
});
|
|
sidebar.querySelectorAll(".wire-sidebar-items a[aria-current='page']").forEach(function (link) {
|
|
if (link !== best) link.removeAttribute("aria-current");
|
|
});
|
|
sidebar.querySelectorAll(".wire-sidebar-group.has-active").forEach(function (group) {
|
|
group.classList.remove("has-active");
|
|
});
|
|
if (best) {
|
|
best.classList.add("is-active");
|
|
best.setAttribute("aria-current", "page");
|
|
var group = best.closest(".wire-sidebar-group");
|
|
if (group) {
|
|
group.setAttribute("open", "");
|
|
group.classList.add("has-active");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function hydrateDropdownControllers(root) {
|
|
var host = root || document;
|
|
var dropdowns = [];
|
|
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-dropdown]")) {
|
|
dropdowns.push(host);
|
|
}
|
|
if (host.querySelectorAll) {
|
|
Array.prototype.push.apply(dropdowns, host.querySelectorAll("[data-wrn-dropdown]"));
|
|
}
|
|
dropdowns.forEach(function (dropdown) {
|
|
if (dropdown.dataset.wrnDropdownBound === "true") return;
|
|
dropdown.dataset.wrnDropdownBound = "true";
|
|
dropdown.addEventListener("toggle", function () {
|
|
if (!dropdown.open) return;
|
|
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (candidate) {
|
|
if (candidate !== dropdown) candidate.removeAttribute("open");
|
|
});
|
|
});
|
|
});
|
|
if (!dropdownOutsideClickBound) {
|
|
dropdownOutsideClickBound = true;
|
|
document.addEventListener("pointerdown", function (event) {
|
|
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
|
|
if (!dropdown.contains(event.target)) dropdown.removeAttribute("open");
|
|
});
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Escape") return;
|
|
document.querySelectorAll("[data-wrn-dropdown][open]").forEach(function (dropdown) {
|
|
dropdown.removeAttribute("open");
|
|
var summary = dropdown.querySelector(":scope > summary");
|
|
if (summary) summary.focus();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function hydratePreferenceControllers(root) {
|
|
var host = root || document;
|
|
var switchers = [];
|
|
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-preferences]")) {
|
|
switchers.push(host);
|
|
}
|
|
if (host.querySelectorAll) {
|
|
Array.prototype.push.apply(
|
|
switchers,
|
|
host.querySelectorAll("[data-wrn-preferences]"),
|
|
);
|
|
}
|
|
switchers.forEach(function (switcher) {
|
|
if (switcher.dataset.wrnPreferencesBound === "true") return;
|
|
switcher.dataset.wrnPreferencesBound = "true";
|
|
switcher.addEventListener("toggle", function (event) {
|
|
var opened = event.target;
|
|
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
|
|
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
|
|
if (menu !== opened) menu.removeAttribute("open");
|
|
});
|
|
}, true);
|
|
});
|
|
if (!preferenceOutsideClickBound) {
|
|
preferenceOutsideClickBound = true;
|
|
document.addEventListener("pointerdown", function (event) {
|
|
document.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
|
|
if (switcher.contains(event.target)) return;
|
|
switcher.querySelectorAll(".wire-preferences__menu[open]").forEach(function (menu) {
|
|
menu.removeAttribute("open");
|
|
});
|
|
});
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Escape") return;
|
|
document.querySelectorAll("[data-wrn-preferences] .wire-preferences__menu[open]").forEach(function (menu) {
|
|
menu.removeAttribute("open");
|
|
var summary = menu.querySelector(":scope > summary");
|
|
if (summary) summary.focus();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function hydrateNavbarControllers(root) {
|
|
var host = root || document;
|
|
var navbars = [];
|
|
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-navbar]")) {
|
|
navbars.push(host);
|
|
}
|
|
if (host.querySelectorAll) {
|
|
Array.prototype.push.apply(
|
|
navbars,
|
|
host.querySelectorAll("[data-wrn-navbar]"),
|
|
);
|
|
}
|
|
|
|
navbars.forEach(function (navbar) {
|
|
var current = window.location.pathname.replace(/\/+$/, "") || "/";
|
|
var best = null;
|
|
var bestLength = -1;
|
|
navbar.querySelectorAll(".wire-navbar__menus a[href]").forEach(function (link) {
|
|
var url;
|
|
try { url = new URL(link.getAttribute("href"), window.location.origin); } catch (_) { return; }
|
|
if (url.origin !== window.location.origin) return;
|
|
var path = url.pathname.replace(/\/+$/, "") || "/";
|
|
var matches = path === current || (path !== "/" && current.indexOf(path + "/") === 0);
|
|
if (!matches || path.length <= bestLength) return;
|
|
best = link;
|
|
bestLength = path.length;
|
|
});
|
|
navbar.querySelectorAll(".wire-navbar__menus [aria-current='page']").forEach(function (item) {
|
|
item.removeAttribute("aria-current");
|
|
});
|
|
if (best) {
|
|
best.setAttribute("aria-current", "page");
|
|
var parentMenu = best.closest(".wire-navbar__dropdown");
|
|
var parentSummary = parentMenu && parentMenu.querySelector(":scope > summary");
|
|
if (parentSummary) parentSummary.setAttribute("aria-current", "page");
|
|
}
|
|
|
|
if (navbar.dataset.wrnNavbarBound === "true") return;
|
|
navbar.dataset.wrnNavbarBound = "true";
|
|
var hoverEnabled = navbar.getAttribute("data-open-on-hover") === "true";
|
|
var hoverCapable =
|
|
typeof window.matchMedia !== "function" ||
|
|
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
|
if (hoverEnabled && hoverCapable) {
|
|
navbar.querySelectorAll(".wire-navbar__dropdown").forEach(function (menu) {
|
|
menu.addEventListener("pointerenter", function () {
|
|
if (menu.__wrnNavbarCloseTimer) {
|
|
clearTimeout(menu.__wrnNavbarCloseTimer);
|
|
menu.__wrnNavbarCloseTimer = null;
|
|
}
|
|
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (candidate) {
|
|
if (candidate !== menu) candidate.removeAttribute("open");
|
|
});
|
|
menu.setAttribute("open", "");
|
|
});
|
|
menu.addEventListener("pointerleave", function () {
|
|
if (menu.__wrnNavbarCloseTimer) clearTimeout(menu.__wrnNavbarCloseTimer);
|
|
menu.__wrnNavbarCloseTimer = setTimeout(function () {
|
|
menu.removeAttribute("open");
|
|
menu.__wrnNavbarCloseTimer = null;
|
|
}, 240);
|
|
});
|
|
});
|
|
}
|
|
navbar.addEventListener("toggle", function (event) {
|
|
var opened = event.target;
|
|
if (!opened || opened.tagName !== "DETAILS" || !opened.open) return;
|
|
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
|
|
if (menu !== opened) menu.removeAttribute("open");
|
|
});
|
|
}, true);
|
|
});
|
|
|
|
if (!navbarOutsideClickBound) {
|
|
navbarOutsideClickBound = true;
|
|
document.addEventListener("pointerdown", function (event) {
|
|
document.querySelectorAll("[data-wrn-navbar]").forEach(function (navbar) {
|
|
if (navbar.contains(event.target)) return;
|
|
navbar.querySelectorAll(".wire-navbar__dropdown[open]").forEach(function (menu) {
|
|
menu.removeAttribute("open");
|
|
});
|
|
});
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Escape") return;
|
|
document.querySelectorAll("[data-wrn-navbar] .wire-navbar__dropdown[open]").forEach(function (menu) {
|
|
menu.removeAttribute("open");
|
|
var summary = menu.querySelector(":scope > summary");
|
|
if (summary) summary.focus();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
var selectOutsideClickBound = false;
|
|
|
|
function selectScopeApi(root) {
|
|
var scope = root;
|
|
while (scope) {
|
|
if (scope.__wrnexusScopeApi) return scope.__wrnexusScopeApi;
|
|
scope = scope.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function selectPayloadOptions(payload) {
|
|
if (Array.isArray(payload)) return payload;
|
|
if (!payload || typeof payload !== "object") return [];
|
|
if (Array.isArray(payload.options)) return payload.options;
|
|
if (Array.isArray(payload.items)) return payload.items;
|
|
if (Array.isArray(payload.results)) return payload.results;
|
|
if (Array.isArray(payload.data)) return payload.data;
|
|
return [];
|
|
}
|
|
|
|
function syncComboboxInput(root) {
|
|
if (!root || !root.hasAttribute("data-wrn-combobox")) return;
|
|
var input = root.querySelector(".wire-next__combobox-input");
|
|
var api = selectScopeApi(root);
|
|
if (!input || !api) return;
|
|
var value = api.call("inputText") || "";
|
|
input.value = String(value);
|
|
input.setAttribute("value", String(value));
|
|
}
|
|
|
|
function selectEventDetail(root, extra) {
|
|
var api = selectScopeApi(root);
|
|
var multiple = !!(api && api.get("multiple"));
|
|
var detail = {
|
|
component: root.hasAttribute("data-wrn-combobox") ? "ComboBox" : "AdvancedSelect",
|
|
query: api ? (api.get("query") || "") : "",
|
|
value: api ? (api.get("selectedValue") || "") : "",
|
|
values: api && Array.isArray(api.get("selectedValues")) ? api.get("selectedValues") : [],
|
|
multiple: multiple,
|
|
selectedOptions: api ? (api.call("selectedOptions") || []) : [],
|
|
};
|
|
if (extra && typeof extra === "object") {
|
|
Object.keys(extra).forEach(function (key) { detail[key] = extra[key]; });
|
|
}
|
|
return detail;
|
|
}
|
|
|
|
function emitSelectEvent(root, name, extra) {
|
|
try {
|
|
dispatchComponentEvent(root, name, selectEventDetail(root, extra));
|
|
} catch (_) {
|
|
// CustomEvent can be unavailable in minimal DOM environments.
|
|
}
|
|
}
|
|
|
|
function renderRemoteSelectOptions(root, options) {
|
|
var list = root.querySelector(".wire-next__select-list");
|
|
if (!list) return;
|
|
|
|
list.querySelectorAll(".wire-next__option-group, .wire-next__select-option, .wire-next__select-message")
|
|
.forEach(function (node) { node.remove(); });
|
|
|
|
options.forEach(function (option) {
|
|
if (!option || option.value == null) return;
|
|
var button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "wire-next__select-option";
|
|
button.setAttribute("role", "option");
|
|
button.setAttribute("data-option-value", String(option.value));
|
|
button.disabled = !!option.disabled;
|
|
|
|
if (option.icon) {
|
|
var icon = document.createElement("i");
|
|
icon.className = String(option.icon);
|
|
icon.setAttribute("aria-hidden", "true");
|
|
button.appendChild(icon);
|
|
} else if (option.avatar) {
|
|
var avatar = document.createElement("img");
|
|
avatar.src = String(option.avatar);
|
|
avatar.alt = "";
|
|
button.appendChild(avatar);
|
|
} else if (option.color) {
|
|
var dot = document.createElement("i");
|
|
dot.className = "wire-next__color-dot";
|
|
dot.style.setProperty("--option-color", String(option.color));
|
|
button.appendChild(dot);
|
|
}
|
|
|
|
var copy = document.createElement("span");
|
|
var label = document.createElement("strong");
|
|
label.textContent = String(option.label == null ? option.value : option.label);
|
|
copy.appendChild(label);
|
|
if (option.description) {
|
|
var description = document.createElement("small");
|
|
description.textContent = String(option.description);
|
|
copy.appendChild(description);
|
|
}
|
|
button.appendChild(copy);
|
|
|
|
var check = document.createElement("i");
|
|
check.className = "wire-next__check icon-[lucide--check]";
|
|
check.setAttribute("aria-hidden", "true");
|
|
check.style.display = "none";
|
|
button.appendChild(check);
|
|
|
|
button.addEventListener("click", function () {
|
|
var api = selectScopeApi(root);
|
|
if (!api || button.disabled) return;
|
|
api.call("chooseOption", option);
|
|
window.setTimeout(function () {
|
|
var selected = api.call("isSelected", option);
|
|
button.classList.toggle("wire-next__select-option--selected", !!selected);
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
check.style.display = selected ? "" : "none";
|
|
syncComboboxInput(root);
|
|
}, 0);
|
|
});
|
|
list.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function setupSelectController(root) {
|
|
if (!root || root.__wrnexusSelectController) return;
|
|
root.__wrnexusSelectController = { attempts: 0 };
|
|
|
|
var remote = root.getAttribute("data-remote") === "true";
|
|
var remoteUrl = root.getAttribute("data-remote-url") || "";
|
|
var input = root.querySelector(".wire-next__select-search input, .wire-next__combobox-input");
|
|
var list = root.querySelector(".wire-next__select-list");
|
|
var loadMore = root.querySelector("[data-wrn-select-load-more]");
|
|
var timer = 0;
|
|
var requestId = 0;
|
|
var loadedOptions = [];
|
|
var retainedSelections = [];
|
|
var currentPage = Number(root.getAttribute("data-page") || 1);
|
|
|
|
function setLoading(loading) {
|
|
root.setAttribute("data-loading", loading ? "true" : "false");
|
|
if (list) list.setAttribute("aria-busy", loading ? "true" : "false");
|
|
var api = selectScopeApi(root);
|
|
if (api) api.set("loading", loading);
|
|
}
|
|
|
|
function load(append, allowEmptyQuery) {
|
|
root.__wrnexusSelectController.attempts++;
|
|
if (!remote || !remoteUrl) return;
|
|
var api = selectScopeApi(root);
|
|
if (!api) return;
|
|
var query = input ? input.value : "";
|
|
var minimum = Number(api.get("minSearchLength") || 0);
|
|
if (!allowEmptyQuery && query.length < minimum) return;
|
|
|
|
var url;
|
|
try {
|
|
url = new URL(remoteUrl, window.location.href);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
var parameter = root.getAttribute("data-remote-query-param") || "q";
|
|
if (query) url.searchParams.set(parameter, query);
|
|
var nextPage = append ? currentPage + 1 : 1;
|
|
url.searchParams.set("page", String(nextPage));
|
|
|
|
var thisRequest = ++requestId;
|
|
setLoading(true);
|
|
fetch(url.toString(), { headers: { accept: "application/json" } })
|
|
.then(function (response) {
|
|
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
return response.json();
|
|
})
|
|
.then(function (payload) {
|
|
if (thisRequest !== requestId) return;
|
|
var nextOptions = selectPayloadOptions(payload);
|
|
loadedOptions = append ? loadedOptions.concat(nextOptions) : nextOptions;
|
|
currentPage = nextPage;
|
|
var selectedValues = api.get("selectedValues");
|
|
var selectedValue = api.get("selectedValue");
|
|
var selectedKeys = api.get("multiple")
|
|
? (Array.isArray(selectedValues) ? selectedValues : [])
|
|
: (selectedValue ? [selectedValue] : []);
|
|
var previousOptions = api.call("allOptions") || [];
|
|
retainedSelections = retainedSelections
|
|
.concat(previousOptions)
|
|
.concat(loadedOptions)
|
|
.filter(function (option, index, list) {
|
|
return option && selectedKeys.includes(option.value) &&
|
|
list.findIndex(function (item) { return item && item.value === option.value; }) === index;
|
|
});
|
|
var stateOptions = retainedSelections.concat(loadedOptions).filter(function (option, index, list) {
|
|
return option && list.findIndex(function (item) { return item && item.value === option.value; }) === index;
|
|
});
|
|
api.set("options", stateOptions);
|
|
api.set("groups", []);
|
|
api.set("page", currentPage);
|
|
api.set("hasMore", !!(payload && (payload.hasMore || payload.nextPage || payload.next)));
|
|
renderRemoteSelectOptions(root, loadedOptions);
|
|
emitSelectEvent(root, "load", {
|
|
options: nextOptions,
|
|
page: currentPage,
|
|
hasMore: !!(payload && (payload.hasMore || payload.nextPage || payload.next)),
|
|
append: !!append,
|
|
url: url.toString(),
|
|
payload: payload,
|
|
});
|
|
})
|
|
.catch(function (error) {
|
|
console.error("[wrnexus] advanced select remote request failed", error);
|
|
emitSelectEvent(root, "error", {
|
|
error: error,
|
|
message: error && error.message ? error.message : String(error),
|
|
url: url.toString(),
|
|
});
|
|
})
|
|
.then(function () {
|
|
if (thisRequest === requestId) setLoading(false);
|
|
});
|
|
}
|
|
|
|
if (input) {
|
|
input.addEventListener("input", function () {
|
|
emitSelectEvent(root, "search", { query: input.value });
|
|
if (!remote) return;
|
|
window.clearTimeout(timer);
|
|
var debounce = Number(root.getAttribute("data-remote-debounce") || 250);
|
|
timer = window.setTimeout(function () { load(false); }, debounce);
|
|
});
|
|
}
|
|
|
|
if (loadMore) {
|
|
loadMore.addEventListener("click", function () { load(true); });
|
|
}
|
|
|
|
if (list && root.getAttribute("data-infinite") === "true") {
|
|
list.addEventListener("scroll", function () {
|
|
if (list.scrollTop + list.clientHeight < list.scrollHeight - 24) return;
|
|
var api = selectScopeApi(root);
|
|
if (api && api.get("hasMore") && root.getAttribute("data-loading") !== "true") load(true);
|
|
});
|
|
}
|
|
|
|
root.addEventListener("click", function (event) {
|
|
if (!remote || !remoteUrl) return;
|
|
if (!event.target.closest(".wire-next__select-trigger")) return;
|
|
if (loadedOptions.length === 0) load(false);
|
|
});
|
|
root.__wrnexusSelectController.load = load;
|
|
|
|
var dropdown = root.querySelector(".wire-next__select-dropdown");
|
|
var lastOpen = !!(selectScopeApi(root) && selectScopeApi(root).get("open"));
|
|
function emitOpenStateIfChanged() {
|
|
var api = selectScopeApi(root);
|
|
var nextOpen = !!(api && api.get("open"));
|
|
if (nextOpen === lastOpen) return;
|
|
lastOpen = nextOpen;
|
|
emitSelectEvent(root, nextOpen ? "open" : "close");
|
|
}
|
|
var ObserverConstructor =
|
|
root.ownerDocument &&
|
|
root.ownerDocument.defaultView &&
|
|
root.ownerDocument.defaultView.MutationObserver
|
|
? root.ownerDocument.defaultView.MutationObserver
|
|
: (typeof MutationObserver !== "undefined" ? MutationObserver : null);
|
|
if (ObserverConstructor) {
|
|
new ObserverConstructor(function () {
|
|
emitOpenStateIfChanged();
|
|
}).observe(dropdown || root, {
|
|
attributes: true,
|
|
attributeFilter: dropdown ? ["data-show", "style"] : ["class"],
|
|
});
|
|
}
|
|
|
|
root.addEventListener("click", function (event) {
|
|
var optionButton = event.target.closest(".wire-next__select-option");
|
|
if (optionButton) {
|
|
window.setTimeout(function () {
|
|
var api = selectScopeApi(root);
|
|
var optionValue = optionButton.getAttribute("data-option-value");
|
|
var options = api ? (api.call("allOptions") || []) : [];
|
|
var option = options.find(function (item) {
|
|
return item && String(item.value) === String(optionValue);
|
|
});
|
|
emitSelectEvent(root, "select", { option: option || null });
|
|
emitSelectEvent(root, "change", { option: option || null, reason: "select" });
|
|
emitOpenStateIfChanged();
|
|
}, 0);
|
|
}
|
|
if (event.target.closest(".wire-next__clear-select")) {
|
|
window.setTimeout(function () {
|
|
emitSelectEvent(root, "clear");
|
|
emitSelectEvent(root, "change", { reason: "clear" });
|
|
}, 0);
|
|
}
|
|
}, true);
|
|
root.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Enter") return;
|
|
var before = selectEventDetail(root);
|
|
window.setTimeout(function () {
|
|
var after = selectEventDetail(root);
|
|
var beforeSelection = before.multiple ? JSON.stringify(before.values) : before.value;
|
|
var afterSelection = after.multiple ? JSON.stringify(after.values) : after.value;
|
|
if (afterSelection === beforeSelection) return;
|
|
syncComboboxInput(root);
|
|
emitSelectEvent(root, "select", { source: "keyboard" });
|
|
emitSelectEvent(root, "change", { reason: "select", source: "keyboard" });
|
|
emitOpenStateIfChanged();
|
|
}, 0);
|
|
}, true);
|
|
|
|
if (root.hasAttribute("data-wrn-combobox")) {
|
|
root.wrnexusCombobox = {
|
|
open: function () {
|
|
var api = selectScopeApi(root);
|
|
if (api) api.call("openDropdown");
|
|
emitOpenStateIfChanged();
|
|
},
|
|
close: function () {
|
|
var api = selectScopeApi(root);
|
|
if (api) api.call("closeDropdown");
|
|
emitOpenStateIfChanged();
|
|
},
|
|
clear: function () {
|
|
var api = selectScopeApi(root);
|
|
if (api) api.call("clearValue");
|
|
syncComboboxInput(root);
|
|
emitSelectEvent(root, "clear", { source: "method" });
|
|
emitSelectEvent(root, "change", { reason: "clear", source: "method" });
|
|
},
|
|
setValue: function (value) {
|
|
var api = selectScopeApi(root);
|
|
if (api) api.call("setValue", value);
|
|
syncComboboxInput(root);
|
|
emitSelectEvent(root, "change", { reason: "setValue", source: "method" });
|
|
},
|
|
reload: function () {
|
|
load(false, true);
|
|
},
|
|
};
|
|
root.addEventListener("click", function (event) {
|
|
if (!event.target.closest(".wire-next__select-option, .wire-next__clear-select")) return;
|
|
window.setTimeout(function () { syncComboboxInput(root); }, 0);
|
|
});
|
|
if (input) {
|
|
input.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter") {
|
|
window.setTimeout(function () { syncComboboxInput(root); }, 0);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (remote && remoteUrl && root.getAttribute("data-remote-auto-load") !== "false") {
|
|
load(false, true);
|
|
}
|
|
}
|
|
|
|
function hydrateSelectControllers(root) {
|
|
var host = root || document;
|
|
var selects = [];
|
|
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-select]")) selects.push(host);
|
|
if (host.querySelectorAll) {
|
|
Array.prototype.push.apply(selects, host.querySelectorAll("[data-wrn-select]"));
|
|
}
|
|
selects.forEach(setupSelectController);
|
|
|
|
if (!selectOutsideClickBound) {
|
|
selectOutsideClickBound = true;
|
|
document.addEventListener("pointerdown", function (event) {
|
|
document.querySelectorAll("[data-wrn-select].wire-next--open").forEach(function (select) {
|
|
if (select.contains(event.target)) return;
|
|
var api = selectScopeApi(select);
|
|
if (api) api.set("open", false);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function invokeComponentOutput(root, name, payload) {
|
|
if (!root || !name) return undefined;
|
|
var registry = root.__wrnexusOutputHandlers;
|
|
var handlers = registry && registry[name];
|
|
if (handlers && handlers.size) {
|
|
var values = [];
|
|
handlers.forEach(function (handler) { values.push(handler(payload)); });
|
|
return values.some(function (value) { return value && typeof value.then === "function"; })
|
|
? Promise.all(values)
|
|
: values[values.length - 1];
|
|
}
|
|
return dispatchComponentEvent(root, name, payload);
|
|
}
|
|
|
|
function callServerFunction(component, functionName, args) {
|
|
var csrf = document.querySelector('meta[name="wrnexus-csrf"]');
|
|
return fetch("/__wrnexus/rpc", {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-csrf-token": csrf ? csrf.getAttribute("content") || "" : "",
|
|
},
|
|
body: JSON.stringify({ component: component, function: functionName, args: args || [] }),
|
|
}).then(function (response) {
|
|
return response.json().catch(function () { return null; }).then(function (payload) {
|
|
if (!response.ok || !payload || !payload.ok) {
|
|
var error = new Error(payload && payload.error && payload.error.message || "Server function call failed");
|
|
error.code = payload && payload.error && payload.error.code || "WRN-RPC-FAILED";
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return payload.value;
|
|
});
|
|
});
|
|
}
|
|
|
|
function dispatchComponentEvent(root, name, detail) {
|
|
if (!root || !name) return null;
|
|
var EventConstructor =
|
|
root.ownerDocument &&
|
|
root.ownerDocument.defaultView &&
|
|
root.ownerDocument.defaultView.CustomEvent
|
|
? root.ownerDocument.defaultView.CustomEvent
|
|
: CustomEvent;
|
|
var event = new EventConstructor(String(name), {
|
|
bubbles: true,
|
|
detail: detail || {},
|
|
});
|
|
root.dispatchEvent(event);
|
|
// Compatibility for applications using the former prefixed contract.
|
|
root.dispatchEvent(new EventConstructor("wrnexus:" + String(name), {
|
|
bubbles: true,
|
|
detail: detail || {},
|
|
}));
|
|
return event;
|
|
}
|
|
|
|
function emitPinInputEvent(root, name, extra) {
|
|
var hidden = root.querySelector("[data-pin-value]");
|
|
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
|
var value = hidden ? hidden.value : "";
|
|
var detail = {
|
|
component: "PinInput",
|
|
value: value,
|
|
digits: cells.map(function (cell) { return cell.value; }),
|
|
complete: value.length === cells.length,
|
|
length: cells.length,
|
|
};
|
|
if (extra && typeof extra === "object") {
|
|
Object.keys(extra).forEach(function (key) { detail[key] = extra[key]; });
|
|
}
|
|
try {
|
|
dispatchComponentEvent(root, name, detail);
|
|
} catch (_) {
|
|
// CustomEvent can be unavailable in minimal DOM environments.
|
|
}
|
|
}
|
|
|
|
function setupPinInputController(root) {
|
|
if (!root || root.__wrnexusPinInputController) return;
|
|
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
|
var hidden = root.querySelector("[data-pin-value]");
|
|
var clearButton = root.querySelector("[data-pin-clear]");
|
|
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
|
var allowPaste = root.getAttribute("data-allow-paste") !== "false";
|
|
var autoSubmit = root.getAttribute("data-auto-submit") === "true";
|
|
var matcher;
|
|
var exactMatcher;
|
|
var NativeEventConstructor =
|
|
root.ownerDocument &&
|
|
root.ownerDocument.defaultView &&
|
|
root.ownerDocument.defaultView.Event
|
|
? root.ownerDocument.defaultView.Event
|
|
: Event;
|
|
|
|
try {
|
|
exactMatcher = new RegExp("^(?:" + patternSource + ")$");
|
|
matcher = new RegExp("^(?:" + patternSource + ")$", "i");
|
|
} catch (error) {
|
|
matcher = /$a/;
|
|
emitPinInputEvent(root, "error", {
|
|
error: error,
|
|
message: "Invalid PIN pattern: " + patternSource,
|
|
});
|
|
}
|
|
|
|
function acceptedCharacters(value) {
|
|
return Array.from(String(value || "")).map(function (character) {
|
|
matcher.lastIndex = 0;
|
|
if (!matcher.test(character)) return "";
|
|
exactMatcher.lastIndex = 0;
|
|
if (exactMatcher.test(character)) return character;
|
|
var upper = character.toUpperCase();
|
|
exactMatcher.lastIndex = 0;
|
|
if (exactMatcher.test(upper)) return upper;
|
|
var lower = character.toLowerCase();
|
|
exactMatcher.lastIndex = 0;
|
|
return exactMatcher.test(lower) ? lower : character;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function currentValue() {
|
|
return cells.map(function (cell) { return cell.value; }).join("");
|
|
}
|
|
|
|
function updateClearButton(value) {
|
|
if (!clearButton) return;
|
|
clearButton.style.display = value ? "" : "none";
|
|
}
|
|
|
|
function syncValue(reason, index) {
|
|
var previous = hidden ? hidden.value : "";
|
|
var value = currentValue();
|
|
if (hidden) {
|
|
hidden.value = value;
|
|
hidden.setAttribute("value", value);
|
|
hidden.dispatchEvent(new NativeEventConstructor("input", { bubbles: true }));
|
|
hidden.dispatchEvent(new NativeEventConstructor("change", { bubbles: true }));
|
|
}
|
|
root.setAttribute("data-value", value);
|
|
root.setAttribute("data-complete", value.length === cells.length ? "true" : "false");
|
|
updateClearButton(value);
|
|
emitPinInputEvent(root, "input", { reason: reason, index: index });
|
|
if (value !== previous) {
|
|
emitPinInputEvent(root, "change", { reason: reason, index: index });
|
|
}
|
|
if (value.length === cells.length && previous.length !== cells.length) {
|
|
emitPinInputEvent(root, "complete", { reason: reason, index: index });
|
|
if (autoSubmit) {
|
|
var form = root.closest("form");
|
|
if (form && typeof form.requestSubmit === "function") form.requestSubmit();
|
|
}
|
|
}
|
|
}
|
|
|
|
function setValue(value, reason) {
|
|
var characters = acceptedCharacters(value).slice(0, cells.length);
|
|
cells.forEach(function (cell, index) {
|
|
cell.value = characters[index] || "";
|
|
cell.setAttribute("value", cell.value);
|
|
});
|
|
syncValue(reason || "setValue", characters.length ? characters.length - 1 : -1);
|
|
}
|
|
|
|
function focusCell(index) {
|
|
var cell = cells[Math.max(0, Math.min(cells.length - 1, index))];
|
|
if (cell && !cell.disabled) {
|
|
cell.focus();
|
|
if (typeof cell.select === "function") cell.select();
|
|
}
|
|
}
|
|
|
|
cells.forEach(function (cell, index) {
|
|
cell.addEventListener("focus", function () {
|
|
if (typeof cell.select === "function") cell.select();
|
|
});
|
|
cell.addEventListener("input", function () {
|
|
var characters = acceptedCharacters(cell.value);
|
|
cell.value = characters.length ? characters[characters.length - 1] : "";
|
|
cell.setAttribute("value", cell.value);
|
|
syncValue("input", index);
|
|
if (cell.value && index < cells.length - 1) focusCell(index + 1);
|
|
});
|
|
cell.addEventListener("keydown", function (event) {
|
|
if (event.key === "Backspace" && !cell.value && index > 0) {
|
|
event.preventDefault();
|
|
cells[index - 1].value = "";
|
|
cells[index - 1].setAttribute("value", "");
|
|
syncValue("backspace", index - 1);
|
|
focusCell(index - 1);
|
|
} else if (event.key === "ArrowLeft" && index > 0) {
|
|
event.preventDefault();
|
|
focusCell(index - 1);
|
|
} else if (event.key === "ArrowRight" && index < cells.length - 1) {
|
|
event.preventDefault();
|
|
focusCell(index + 1);
|
|
} else if (event.key === "Home") {
|
|
event.preventDefault();
|
|
focusCell(0);
|
|
} else if (event.key === "End") {
|
|
event.preventDefault();
|
|
focusCell(cells.length - 1);
|
|
}
|
|
});
|
|
cell.addEventListener("paste", function (event) {
|
|
if (!allowPaste) return;
|
|
var clipboard = event.clipboardData;
|
|
if (!clipboard) return;
|
|
event.preventDefault();
|
|
var characters = acceptedCharacters(clipboard.getData("text")).slice(0, cells.length - index);
|
|
characters.forEach(function (character, offset) {
|
|
cells[index + offset].value = character;
|
|
cells[index + offset].setAttribute("value", character);
|
|
});
|
|
syncValue("paste", index);
|
|
emitPinInputEvent(root, "paste", { index: index, pasted: characters.join("") });
|
|
focusCell(Math.min(index + characters.length, cells.length - 1));
|
|
});
|
|
});
|
|
|
|
if (clearButton) {
|
|
clearButton.addEventListener("click", function () {
|
|
setValue("", "clear");
|
|
emitPinInputEvent(root, "clear");
|
|
focusCell(0);
|
|
});
|
|
}
|
|
|
|
root.wrnexusPinInput = {
|
|
focus: function (index) { focusCell(Number(index || 0)); },
|
|
clear: function () {
|
|
setValue("", "clear");
|
|
emitPinInputEvent(root, "clear", { source: "method" });
|
|
},
|
|
setValue: function (value) { setValue(value, "setValue"); },
|
|
getValue: function () { return currentValue(); },
|
|
};
|
|
root.__wrnexusPinInputController = root.wrnexusPinInput;
|
|
setValue(hidden ? hidden.value : root.getAttribute("data-value"), "initial");
|
|
}
|
|
|
|
function hydratePinInputControllers(root) {
|
|
var host = root || document;
|
|
var inputs = [];
|
|
if (host.nodeType === 1 && host.matches && host.matches("[data-wrn-pin-input]")) {
|
|
inputs.push(host);
|
|
}
|
|
if (host.querySelectorAll) {
|
|
Array.prototype.push.apply(inputs, host.querySelectorAll("[data-wrn-pin-input]"));
|
|
}
|
|
inputs.forEach(setupPinInputController);
|
|
}
|
|
|
|
function parseScopeDecl(decl) {
|
|
var initial = {};
|
|
splitTopLevel(decl, ",").forEach(function (part) {
|
|
var idx = findTopLevel(part, ":");
|
|
if (idx < 0) return;
|
|
var name = part.slice(0, idx).trim();
|
|
var expr = part.slice(idx + 1).trim();
|
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return;
|
|
try {
|
|
initial[name] = evaluateExpression(expr, function () { return undefined; });
|
|
} catch (e) {
|
|
console.error("[wrnexus] invalid data-scope value for '" + name + "'", e);
|
|
}
|
|
});
|
|
return initial;
|
|
}
|
|
|
|
function skipStatementWhitespace(
|
|
source,
|
|
index,
|
|
) {
|
|
while (
|
|
index < source.length &&
|
|
/\s/.test(source[index])
|
|
) {
|
|
index++;
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
function findClosingDelimiter(
|
|
source,
|
|
start,
|
|
opening,
|
|
closing,
|
|
) {
|
|
if (source[start] !== opening) {
|
|
return -1;
|
|
}
|
|
|
|
var depth = 1;
|
|
var quote = "";
|
|
var escaped = false;
|
|
|
|
for (
|
|
var index = start + 1;
|
|
index < source.length;
|
|
index++
|
|
) {
|
|
var character = source[index];
|
|
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (character === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (character === quote) {
|
|
quote = "";
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
character === '"' ||
|
|
character === "'" ||
|
|
character === "\`"
|
|
) {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
|
|
if (character === opening) {
|
|
depth++;
|
|
continue;
|
|
}
|
|
|
|
if (character === closing) {
|
|
depth--;
|
|
|
|
if (depth === 0) {
|
|
return index;
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
function parseIfStatement(source) {
|
|
source = String(source || "").trim();
|
|
|
|
if (
|
|
source.slice(0, 2) !== "if" ||
|
|
/[A-Za-z0-9_$]/.test(
|
|
source.charAt(2),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
var branches = [];
|
|
var index = 0;
|
|
var firstBranch = true;
|
|
|
|
while (index < source.length) {
|
|
index = skipStatementWhitespace(
|
|
source,
|
|
index,
|
|
);
|
|
|
|
var branchType;
|
|
|
|
if (
|
|
firstBranch &&
|
|
source.slice(index, index + 2) ===
|
|
"if" &&
|
|
!/[A-Za-z0-9_$]/.test(
|
|
source.charAt(index + 2),
|
|
)
|
|
) {
|
|
branchType = "if";
|
|
index += 2;
|
|
} else if (
|
|
source.slice(index, index + 7) ===
|
|
"else if" &&
|
|
!/[A-Za-z0-9_$]/.test(
|
|
source.charAt(index + 7),
|
|
)
|
|
) {
|
|
branchType = "else-if";
|
|
index += 7;
|
|
} else if (
|
|
source.slice(index, index + 4) ===
|
|
"else" &&
|
|
!/[A-Za-z0-9_$]/.test(
|
|
source.charAt(index + 4),
|
|
)
|
|
) {
|
|
branchType = "else";
|
|
index += 4;
|
|
} else {
|
|
break;
|
|
}
|
|
|
|
firstBranch = false;
|
|
|
|
index = skipStatementWhitespace(
|
|
source,
|
|
index,
|
|
);
|
|
|
|
var condition = null;
|
|
|
|
if (branchType !== "else") {
|
|
if (source.charAt(index) !== "(") {
|
|
throw new Error(
|
|
"Expected '(' after " +
|
|
(
|
|
branchType === "if"
|
|
? "if"
|
|
: "else if"
|
|
),
|
|
);
|
|
}
|
|
|
|
var conditionEnd =
|
|
findClosingDelimiter(
|
|
source,
|
|
index,
|
|
"(",
|
|
")",
|
|
);
|
|
|
|
if (conditionEnd < 0) {
|
|
throw new Error(
|
|
"Unclosed " +
|
|
(
|
|
branchType === "if"
|
|
? "if"
|
|
: "else if"
|
|
) +
|
|
" condition",
|
|
);
|
|
}
|
|
|
|
condition = source
|
|
.slice(
|
|
index + 1,
|
|
conditionEnd,
|
|
)
|
|
.trim();
|
|
|
|
index = skipStatementWhitespace(
|
|
source,
|
|
conditionEnd + 1,
|
|
);
|
|
}
|
|
|
|
if (source.charAt(index) !== "{") {
|
|
throw new Error(
|
|
"Expected '{' after " +
|
|
branchType,
|
|
);
|
|
}
|
|
|
|
var bodyEnd =
|
|
findClosingDelimiter(
|
|
source,
|
|
index,
|
|
"{",
|
|
"}",
|
|
);
|
|
|
|
if (bodyEnd < 0) {
|
|
throw new Error(
|
|
"Unclosed " +
|
|
branchType +
|
|
" body",
|
|
);
|
|
}
|
|
|
|
branches.push({
|
|
condition: condition,
|
|
body: source.slice(
|
|
index + 1,
|
|
bodyEnd,
|
|
),
|
|
});
|
|
|
|
index = skipStatementWhitespace(
|
|
source,
|
|
bodyEnd + 1,
|
|
);
|
|
|
|
if (
|
|
source.slice(index, index + 7) ===
|
|
"else if" ||
|
|
source.slice(index, index + 4) ===
|
|
"else"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return branches.length > 0
|
|
? branches
|
|
: null;
|
|
}
|
|
|
|
function runStatement(
|
|
stmt,
|
|
evalExpr,
|
|
read,
|
|
write,
|
|
runBlock,
|
|
) {
|
|
stmt = String(stmt || "").trim();
|
|
|
|
if (!stmt) {
|
|
return {
|
|
returned: false,
|
|
value: undefined,
|
|
};
|
|
}
|
|
|
|
var returnMatch =
|
|
/^return(?:\s+([\s\S]+))?$/.exec(
|
|
stmt,
|
|
);
|
|
|
|
if (returnMatch) {
|
|
return {
|
|
returned: true,
|
|
value: returnMatch[1]
|
|
? evalExpr(returnMatch[1])
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
var ifBranches =
|
|
parseIfStatement(stmt);
|
|
|
|
if (ifBranches) {
|
|
for (
|
|
var branchIndex = 0;
|
|
branchIndex < ifBranches.length;
|
|
branchIndex++
|
|
) {
|
|
var branch =
|
|
ifBranches[branchIndex];
|
|
|
|
if (
|
|
branch.condition === null ||
|
|
!!evalExpr(branch.condition)
|
|
) {
|
|
return runBlock(branch.body);
|
|
}
|
|
}
|
|
|
|
return {
|
|
returned: false,
|
|
value: undefined,
|
|
};
|
|
}
|
|
|
|
var increment = stmt.match(
|
|
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
|
);
|
|
|
|
if (increment) {
|
|
write(
|
|
increment[1],
|
|
Number(read(increment[1]) || 0) +
|
|
(increment[2] === "++"
|
|
? 1
|
|
: -1),
|
|
);
|
|
|
|
return {
|
|
returned: false,
|
|
value: undefined,
|
|
};
|
|
}
|
|
|
|
var assignment = stmt.match(
|
|
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+=|-=|\*=|\/=|%=|=)\s*([\s\S]+)$/,
|
|
);
|
|
|
|
if (!assignment) {
|
|
evalExpr(stmt);
|
|
|
|
return {
|
|
returned: false,
|
|
value: undefined,
|
|
};
|
|
}
|
|
|
|
var name = assignment[1];
|
|
var operator = assignment[2];
|
|
var nextValue =
|
|
evalExpr(assignment[3]);
|
|
var currentValue = read(name);
|
|
|
|
if (operator === "+=") {
|
|
nextValue =
|
|
currentValue + nextValue;
|
|
} else if (operator === "-=") {
|
|
nextValue =
|
|
Number(currentValue || 0) -
|
|
Number(nextValue || 0);
|
|
} else if (operator === "*=") {
|
|
nextValue =
|
|
Number(currentValue || 0) *
|
|
Number(nextValue || 0);
|
|
} else if (operator === "/=") {
|
|
nextValue =
|
|
Number(currentValue || 0) /
|
|
Number(nextValue || 0);
|
|
} else if (operator === "%=") {
|
|
nextValue =
|
|
Number(currentValue || 0) %
|
|
Number(nextValue || 0);
|
|
}
|
|
|
|
write(name, nextValue);
|
|
|
|
return {
|
|
returned: false,
|
|
value: nextValue,
|
|
};
|
|
}
|
|
|
|
function evaluateSpecialExpression(
|
|
expression,
|
|
read,
|
|
) {
|
|
var source = String(
|
|
expression || "",
|
|
).trim();
|
|
|
|
// Remove an optional trailing semicolon.
|
|
if (
|
|
source.charAt(
|
|
source.length - 1,
|
|
) === ";"
|
|
) {
|
|
source = source
|
|
.slice(0, -1)
|
|
.trim();
|
|
}
|
|
|
|
/*
|
|
* Array literals containing spreads:
|
|
*
|
|
* [...openIndexes, index]
|
|
* [first, ...otherItems]
|
|
*/
|
|
if (
|
|
source.charAt(0) === "[" &&
|
|
source.charAt(
|
|
source.length - 1,
|
|
) === "]" &&
|
|
source.indexOf("...") !== -1
|
|
) {
|
|
var arraySource = source.slice(
|
|
1,
|
|
-1,
|
|
);
|
|
|
|
var arrayParts = splitTopLevel(
|
|
arraySource,
|
|
",",
|
|
);
|
|
|
|
var arrayResult = [];
|
|
|
|
arrayParts.forEach(function (
|
|
part,
|
|
) {
|
|
var partSource =
|
|
part.trim();
|
|
|
|
if (!partSource) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
partSource.slice(0, 3) ===
|
|
"..."
|
|
) {
|
|
var spreadResult =
|
|
evaluateExpression(
|
|
partSource
|
|
.slice(3)
|
|
.trim(),
|
|
read,
|
|
);
|
|
|
|
if (
|
|
Array.isArray(
|
|
spreadResult,
|
|
)
|
|
) {
|
|
Array.prototype.push.apply(
|
|
arrayResult,
|
|
spreadResult,
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
arrayResult.push(
|
|
evaluateExpression(
|
|
partSource,
|
|
read,
|
|
),
|
|
);
|
|
});
|
|
|
|
return {
|
|
matched: true,
|
|
value: arrayResult,
|
|
};
|
|
}
|
|
|
|
/*
|
|
* First recognize the array method call without
|
|
* trying to parse the callback in the same regex.
|
|
*/
|
|
var methodMatch =
|
|
/^([\s\S]+)\.(filter|map|flatMap|some|every|find|findIndex)\(\s*([\s\S]*)\s*\)$/.exec(
|
|
source,
|
|
);
|
|
|
|
if (!methodMatch) {
|
|
return {
|
|
matched: false,
|
|
value: undefined,
|
|
};
|
|
}
|
|
|
|
var collectionSource =
|
|
methodMatch[1].trim();
|
|
|
|
var method =
|
|
methodMatch[2];
|
|
|
|
var callbackSource =
|
|
methodMatch[3].trim();
|
|
|
|
/*
|
|
* Supported callbacks:
|
|
*
|
|
* item => item.active
|
|
* (item) => item.active
|
|
* (item, index) => index > 0
|
|
*/
|
|
var callbackMatch =
|
|
/^(?:\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*)?\)|([A-Za-z_$][A-Za-z0-9_$]*))\s*=>\s*([\s\S]+)$/.exec(
|
|
callbackSource,
|
|
);
|
|
|
|
if (!callbackMatch) {
|
|
return {
|
|
matched: false,
|
|
value: undefined,
|
|
};
|
|
}
|
|
|
|
var itemName =
|
|
callbackMatch[1] ||
|
|
callbackMatch[3];
|
|
|
|
var indexName =
|
|
callbackMatch[2];
|
|
|
|
var callbackBody =
|
|
callbackMatch[4].trim();
|
|
|
|
/*
|
|
* Support expression bodies wrapped in parentheses:
|
|
*
|
|
* item => (
|
|
* item.active
|
|
* )
|
|
*/
|
|
if (
|
|
callbackBody.charAt(0) ===
|
|
"(" &&
|
|
callbackBody.charAt(
|
|
callbackBody.length - 1,
|
|
) === ")"
|
|
) {
|
|
callbackBody = callbackBody
|
|
.slice(1, -1)
|
|
.trim();
|
|
}
|
|
|
|
var collection =
|
|
evaluateExpression(
|
|
collectionSource,
|
|
read,
|
|
);
|
|
|
|
if (!Array.isArray(collection)) {
|
|
collection = [];
|
|
}
|
|
|
|
var callback = function (
|
|
itemValue,
|
|
itemIndex,
|
|
) {
|
|
return evaluateExpression(
|
|
callbackBody,
|
|
function (name) {
|
|
if (name === itemName) {
|
|
return itemValue;
|
|
}
|
|
|
|
if (
|
|
indexName &&
|
|
name === indexName
|
|
) {
|
|
return itemIndex;
|
|
}
|
|
|
|
return read(name);
|
|
},
|
|
);
|
|
};
|
|
|
|
var result;
|
|
|
|
if (method === "filter") {
|
|
result =
|
|
collection.filter(callback);
|
|
} else if (
|
|
method === "map"
|
|
) {
|
|
result =
|
|
collection.map(callback);
|
|
} else if (
|
|
method === "flatMap"
|
|
) {
|
|
result =
|
|
collection.flatMap(callback);
|
|
} else if (
|
|
method === "some"
|
|
) {
|
|
result =
|
|
collection.some(callback);
|
|
} else if (
|
|
method === "every"
|
|
) {
|
|
result =
|
|
collection.every(callback);
|
|
} else if (
|
|
method === "find"
|
|
) {
|
|
result =
|
|
collection.find(callback);
|
|
} else {
|
|
result =
|
|
collection.findIndex(
|
|
callback,
|
|
);
|
|
}
|
|
|
|
return {
|
|
matched: true,
|
|
value: result,
|
|
};
|
|
}
|
|
|
|
// A small, eval-free expression evaluator (so a strict CSP needs no
|
|
// 'unsafe-eval'). Supports: literals, identifiers, member access (a.b, a[b]),
|
|
// function/method calls, arrays, objects, arithmetic, comparison, equality,
|
|
// logical (&& ||), unary (! - +), and the ternary operator.
|
|
function evaluateExpression(
|
|
expr,
|
|
read,
|
|
) {
|
|
expr = String(
|
|
expr || "",
|
|
).trim();
|
|
|
|
var special =
|
|
evaluateSpecialExpression(
|
|
expr,
|
|
read,
|
|
);
|
|
|
|
if (special.matched) {
|
|
return special.value;
|
|
}
|
|
|
|
function evaluateTemplateLiteral(source) {
|
|
var template = source.slice(1, -1);
|
|
var rendered = "";
|
|
var cursor = 0;
|
|
|
|
while (cursor < template.length) {
|
|
if (template[cursor] === "\\") {
|
|
cursor++;
|
|
var escaped = template[cursor++];
|
|
rendered += escaped === "n" ? "\n" : escaped === "t" ? "\t" : escaped || "";
|
|
continue;
|
|
}
|
|
|
|
if (template[cursor] === "$" && template[cursor + 1] === "{") {
|
|
var expressionStart = cursor + 2;
|
|
var expressionEnd = expressionStart;
|
|
var depth = 1;
|
|
var quote = "";
|
|
|
|
for (; expressionEnd < template.length; expressionEnd++) {
|
|
var character = template[expressionEnd];
|
|
if (quote) {
|
|
if (character === "\\") expressionEnd++;
|
|
else if (character === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (character === '"' || character === "'") quote = character;
|
|
else if (character === "{") depth++;
|
|
else if (character === "}" && --depth === 0) break;
|
|
}
|
|
|
|
if (depth !== 0) throw new Error("Unclosed template interpolation in '" + source + "'");
|
|
rendered += String(evaluateExpression(template.slice(expressionStart, expressionEnd), read));
|
|
cursor = expressionEnd + 1;
|
|
continue;
|
|
}
|
|
|
|
rendered += template[cursor++];
|
|
}
|
|
|
|
return rendered;
|
|
}
|
|
|
|
// Template literals are evaluated without eval so they remain compatible
|
|
// with a strict CSP. Each interpolation uses the same bounded expression
|
|
// evaluator as every other reactive binding. They are accepted both as a
|
|
// complete expression and as a primary inside a larger expression.
|
|
if (
|
|
expr.length >= 2 &&
|
|
expr[0] === "\`" &&
|
|
expr[expr.length - 1] === "\`"
|
|
) {
|
|
return evaluateTemplateLiteral(expr);
|
|
}
|
|
|
|
var tokens =
|
|
tokenizeExpression(expr);
|
|
|
|
var index = 0;
|
|
function peek() { return tokens[index]; }
|
|
function next() { return tokens[index++]; }
|
|
/*
|
|
* Punctuation only. is/match are asked exclusively about operators and
|
|
* brackets, but they used to compare the token *value* alone -- so a
|
|
* string literal that happens to spell an operator was mistaken for one.
|
|
* That silently broke the most ordinary calls in the language:
|
|
* join('-'), split(','), replace('.', '') all died on "Unexpected token".
|
|
* Requiring an operator token keeps a quoted '-' a value.
|
|
*/
|
|
function is(v) {
|
|
var t = peek();
|
|
return !!t && (t.type === "op" || t.type === "operator") && t.value === v;
|
|
}
|
|
function match(v) { if (is(v)) { index++; return true; } return false; }
|
|
function expect(v) {
|
|
if (!match(v)) throw new Error("Expected '" + v + "' in '" + expr + "'");
|
|
}
|
|
|
|
function parsePrimary() {
|
|
var t = next();
|
|
if (!t) throw new Error("Unexpected end of expression in '" + expr + "'");
|
|
if (t.type === "number" || t.type === "string") return { value: t.value };
|
|
// A fresh RegExp per evaluation: a shared instance carries lastIndex
|
|
// between calls, so a /g pattern would return alternating results for
|
|
// the same input.
|
|
if (t.type === "regex") return { value: new RegExp(t.value.source, t.value.flags) };
|
|
if (t.type === "template") return { value: evaluateTemplateLiteral(t.value) };
|
|
if (t.type === "ident") {
|
|
if (t.value === "true") return { value: true };
|
|
if (t.value === "false") return { value: false };
|
|
if (t.value === "null") return { value: null };
|
|
if (t.value === "undefined") return { value: undefined };
|
|
return { value: read(t.value) };
|
|
}
|
|
if (t.value === "(") { var v = parseTernary(); expect(")"); return { value: v }; }
|
|
if (t.value === "[") {
|
|
var arr = [];
|
|
if (!is("]")) { arr.push(parseTernary()); while (match(",")) arr.push(parseTernary()); }
|
|
expect("]");
|
|
return { value: arr };
|
|
}
|
|
if (t.value === "{") {
|
|
var obj = {};
|
|
if (!is("}")) {
|
|
do {
|
|
var kt = next();
|
|
var key = kt.value;
|
|
expect(":");
|
|
obj[key] = parseTernary();
|
|
} while (match(","));
|
|
}
|
|
expect("}");
|
|
return { value: obj };
|
|
}
|
|
throw new Error("Unexpected token '" + t.value + "'");
|
|
}
|
|
|
|
function parsePostfix() {
|
|
var node = parsePrimary();
|
|
for (;;) {
|
|
if (match(".")) {
|
|
var prop = next().value;
|
|
node = { value: node.value == null ? undefined : node.value[prop], obj: node.value };
|
|
} else if (match("[")) {
|
|
var key = parseTernary();
|
|
expect("]");
|
|
node = { value: node.value == null ? undefined : node.value[key], obj: node.value };
|
|
} else if (is("(")) {
|
|
next();
|
|
var args = [];
|
|
if (!is(")")) { args.push(parseTernary()); while (match(",")) args.push(parseTernary()); }
|
|
expect(")");
|
|
var fn = node.value;
|
|
node = { value: typeof fn === "function" ? fn.apply(node.obj, args) : undefined };
|
|
} else break;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function parseUnary() {
|
|
if (match("!")) return !parseUnary();
|
|
if (match("-")) return -parseUnary();
|
|
if (match("+")) return +parseUnary();
|
|
return parsePostfix().value;
|
|
}
|
|
function parseMul() {
|
|
var l = parseUnary();
|
|
while (peek() && (is("*") || is("/") || is("%"))) {
|
|
var op = next().value, r = parseUnary();
|
|
l = op === "*" ? l * r : op === "/" ? l / r : l % r;
|
|
}
|
|
return l;
|
|
}
|
|
function parseAdd() {
|
|
var l = parseMul();
|
|
while (peek() && (is("+") || is("-"))) {
|
|
var op = next().value, r = parseMul();
|
|
l = op === "+" ? l + r : l - r;
|
|
}
|
|
return l;
|
|
}
|
|
function parseCmp() {
|
|
var l = parseAdd();
|
|
while (peek() && (is("<") || is(">") || is("<=") || is(">="))) {
|
|
var op = next().value, r = parseAdd();
|
|
l = op === "<" ? l < r : op === ">" ? l > r : op === "<=" ? l <= r : l >= r;
|
|
}
|
|
return l;
|
|
}
|
|
function parseEq() {
|
|
var l = parseCmp();
|
|
while (peek() && (is("==") || is("!=") || is("===") || is("!=="))) {
|
|
var op = next().value, r = parseCmp();
|
|
l = op === "==" ? l == r : op === "!=" ? l != r : op === "===" ? l === r : l !== r;
|
|
}
|
|
return l;
|
|
}
|
|
function parseAnd() {
|
|
var l = parseEq();
|
|
while (match("&&")) {
|
|
var r = parseEq();
|
|
l = l && r;
|
|
}
|
|
return l;
|
|
}
|
|
function parseOr() {
|
|
var l = parseAnd();
|
|
while (match("||")) {
|
|
var r = parseAnd();
|
|
l = l || r;
|
|
}
|
|
return l;
|
|
}
|
|
function parseTernary() {
|
|
var c = parseOr();
|
|
if (match("?")) { var a = parseTernary(); expect(":"); var b = parseTernary(); return c ? a : b; }
|
|
return c;
|
|
}
|
|
|
|
var value = parseTernary();
|
|
if (index < tokens.length) throw new Error("Unexpected token '" + tokens[index].value + "'");
|
|
return value;
|
|
}
|
|
|
|
function tokenizeExpression(input) {
|
|
var tokens = [];
|
|
var i = 0;
|
|
while (i < input.length) {
|
|
var ch = input[i];
|
|
if (/\s/.test(ch)) { i++; continue; }
|
|
|
|
/*
|
|
* Comments. Statement bodies handed to this engine -- a lifecycle mount
|
|
* hook, an inline handler -- are ordinary authored code and routinely
|
|
* contain them. Without this the leading slash fell through to the
|
|
* regex-literal branch and the whole body died with "Unclosed regular
|
|
* expression", naming nothing useful.
|
|
*/
|
|
if (ch === "/" && input[i + 1] === "/") {
|
|
var lineEnd = input.indexOf("\n", i + 2);
|
|
i = lineEnd === -1 ? input.length : lineEnd + 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "/" && input[i + 1] === "*") {
|
|
var blockEnd = input.indexOf("*/", i + 2);
|
|
i = blockEnd === -1 ? input.length : blockEnd + 2;
|
|
continue;
|
|
}
|
|
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(input[i + 1]))) {
|
|
var start = i++;
|
|
while (i < input.length && /[0-9.]/.test(input[i])) i++;
|
|
tokens.push({ type: "number", value: Number(input.slice(start, i)) });
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
var quote = ch, value = "";
|
|
i++;
|
|
while (i < input.length) {
|
|
ch = input[i++];
|
|
if (ch === quote) break;
|
|
if (ch === "\\") {
|
|
var esc = input[i++];
|
|
value += esc === "n" ? "\n" : esc === "t" ? "\t" : esc || "";
|
|
} else value += ch;
|
|
}
|
|
tokens.push({ type: "string", value: value });
|
|
continue;
|
|
}
|
|
if (ch === "\`") {
|
|
var templateStart = i++;
|
|
var templateDepth = 0;
|
|
var templateQuote = "";
|
|
while (i < input.length) {
|
|
ch = input[i++];
|
|
if (templateQuote) {
|
|
if (ch === "\\") i++;
|
|
else if (ch === templateQuote) templateQuote = "";
|
|
continue;
|
|
}
|
|
if (ch === "\\") {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === "\`" && templateDepth === 0) break;
|
|
if (ch === "$" && input[i] === "{") {
|
|
templateDepth++;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (templateDepth > 0) {
|
|
if (ch === '"' || ch === "'") templateQuote = ch;
|
|
else if (ch === "{") templateDepth++;
|
|
else if (ch === "}") templateDepth--;
|
|
}
|
|
}
|
|
if (input[i - 1] !== "\`") throw new Error("Unclosed template literal");
|
|
tokens.push({ type: "template", value: input.slice(templateStart, i) });
|
|
continue;
|
|
}
|
|
if (/[A-Za-z_$]/.test(ch)) {
|
|
var s = i++;
|
|
while (i < input.length && /[A-Za-z0-9_$]/.test(input[i])) i++;
|
|
tokens.push({ type: "ident", value: input.slice(s, i) });
|
|
continue;
|
|
}
|
|
/*
|
|
* Regex literal. Validating input is the single most common reason a
|
|
* client function reaches for one, and without this the leading '/' fell
|
|
* through to the division operator and the pattern body then died on
|
|
* "Unexpected character '^'" -- taking the whole handler with it.
|
|
*
|
|
* '/' is ambiguous: it starts a regex only where a *value* may begin,
|
|
* and is division after one. The preceding token decides -- after a
|
|
* value-producing token (number, string, identifier, or a closing
|
|
* bracket) it is division; anywhere else it opens a literal. This is
|
|
* the standard lexer disambiguation and is why plain division keeps working.
|
|
*/
|
|
if (ch === "/") {
|
|
var previous = tokens[tokens.length - 1];
|
|
var afterValue =
|
|
previous &&
|
|
(previous.type === "number" ||
|
|
previous.type === "string" ||
|
|
previous.type === "template" ||
|
|
(previous.type === "ident" &&
|
|
previous.value !== "return" &&
|
|
previous.value !== "typeof" &&
|
|
previous.value !== "in" &&
|
|
previous.value !== "of") ||
|
|
((previous.type === "op" || previous.type === "operator") &&
|
|
(previous.value === ")" || previous.value === "]" || previous.value === "}")));
|
|
|
|
if (!afterValue) {
|
|
var regexStart = i++;
|
|
var inClass = false;
|
|
var closed = false;
|
|
while (i < input.length) {
|
|
var rc = input[i];
|
|
if (rc === "\\") {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (rc === "[") inClass = true;
|
|
else if (rc === "]") inClass = false;
|
|
else if (rc === "/" && !inClass) {
|
|
closed = true;
|
|
i++;
|
|
break;
|
|
} else if (rc === "\n") break;
|
|
i++;
|
|
}
|
|
if (!closed) throw new Error("Unclosed regular expression");
|
|
var body = input.slice(regexStart + 1, i - 1);
|
|
var flagStart = i;
|
|
while (i < input.length && /[a-z]/.test(input[i])) i++;
|
|
tokens.push({
|
|
type: "regex",
|
|
value: new RegExp(body, input.slice(flagStart, i)),
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
var three = input.slice(i, i + 3);
|
|
|
|
if (
|
|
three === "===" ||
|
|
three === "!=="
|
|
) {
|
|
tokens.push({
|
|
type: "operator",
|
|
value: three,
|
|
});
|
|
|
|
i += 3;
|
|
continue;
|
|
}
|
|
|
|
var two = input.slice(i, i + 2);
|
|
|
|
if (
|
|
two === "==" ||
|
|
two === "!=" ||
|
|
two === ">=" ||
|
|
two === "<=" ||
|
|
two === "&&" ||
|
|
two === "||"
|
|
) {
|
|
tokens.push({
|
|
type: "operator",
|
|
value: two,
|
|
});
|
|
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if ("()+-*/%!<>.,?:[]{}".indexOf(ch) !== -1) { tokens.push({ type: "op", value: ch }); i++; continue; }
|
|
throw new Error("Unexpected character '" + ch + "'");
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function splitTopLevel(input, separator) {
|
|
var parts = [];
|
|
var start = 0;
|
|
var depth = 0;
|
|
var quote = "";
|
|
for (var i = 0; i < input.length; i++) {
|
|
var ch = input[i];
|
|
if (quote) {
|
|
if (ch === "\\") i++;
|
|
else if (ch === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
else if (ch === separator && depth === 0) {
|
|
parts.push(input.slice(start, i).trim());
|
|
start = i + 1;
|
|
}
|
|
}
|
|
parts.push(input.slice(start).trim());
|
|
return parts.filter(Boolean);
|
|
}
|
|
|
|
function findTopLevel(input, needle) {
|
|
var depth = 0;
|
|
var quote = "";
|
|
for (var i = 0; i < input.length; i++) {
|
|
var ch = input[i];
|
|
if (quote) {
|
|
if (ch === "\\") i++;
|
|
else if (ch === quote) quote = "";
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") quote = ch;
|
|
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
else if (ch === needle && depth === 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function safeCsrUrl(id) {
|
|
try {
|
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) return null;
|
|
return "/__wrnexus/csr?route=" + encodeURIComponent(location.pathname) + "&id=" + encodeURIComponent(id);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setupCsrFetch(el) {
|
|
if (el.__wrnexusCsrFetch) return;
|
|
el.__wrnexusCsrFetch = true;
|
|
|
|
var id = el.getAttribute("data-wrnexus-csr") || "";
|
|
var url = safeCsrUrl(id);
|
|
if (!url) {
|
|
console.error("[wrnexus] blocked unsafe CSR binding '" + id + "'");
|
|
return;
|
|
}
|
|
|
|
var headers = { "accept": "text/plain" };
|
|
var storage = localStorageSnapshotHeader();
|
|
if (storage) headers["x-wrnexus-local-storage"] = storage;
|
|
|
|
fetch(url, { headers: headers })
|
|
.then(function (res) {
|
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
return res.text();
|
|
})
|
|
.then(function (text) {
|
|
el.textContent = text;
|
|
})
|
|
.catch(function (err) {
|
|
console.error("[wrnexus] CSR fetch failed for binding '" + id + "'", err);
|
|
el.textContent = "Failed to load";
|
|
});
|
|
}
|
|
|
|
function hydrateCsrFetches(root) {
|
|
(root || document).querySelectorAll("[data-wrnexus-csr]").forEach(setupCsrFetch);
|
|
}
|
|
|
|
function localStorageSnapshotHeader() {
|
|
try {
|
|
if (!("localStorage" in window)) return "";
|
|
var values = {};
|
|
for (var i = 0; i < window.localStorage.length; i++) {
|
|
var key = window.localStorage.key(i);
|
|
if (!key) continue;
|
|
var value = window.localStorage.getItem(key);
|
|
if (typeof value === "string") values[key] = value;
|
|
}
|
|
var encoded = encodeURIComponent(JSON.stringify(values));
|
|
return encoded.length <= 12000 ? encoded : "";
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
var asyncLoads = new Map();
|
|
function asyncValueAt(value, path) {
|
|
var current = value;
|
|
var parts = String(path || "").split(".").filter(Boolean);
|
|
for (var i = 0; i < parts.length; i++) {
|
|
if (current == null || typeof current !== "object") return "";
|
|
current = current[parts[i]];
|
|
}
|
|
return current == null ? "" : current;
|
|
}
|
|
function interpolateAsync(root, model, prefix) {
|
|
function visit(node) {
|
|
if (node.nodeType === 3) {
|
|
node.textContent = String(node.textContent || "").replace(/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g, function (whole, path) {
|
|
var parts = path.split(".");
|
|
if (parts[0] === prefix) parts.shift();
|
|
if (prefix === "error" && parts[0] !== "message") return whole;
|
|
var value = asyncValueAt(model, parts.join("."));
|
|
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
});
|
|
}
|
|
Array.from(node.childNodes || []).forEach(visit);
|
|
}
|
|
visit(root);
|
|
root.querySelectorAll("*").forEach(function (node) {
|
|
Array.from(node.attributes || []).forEach(function (attribute) {
|
|
if (attribute.name.indexOf("data-wrn-async-") === 0) return;
|
|
if (attribute.value.indexOf("{") === -1) return;
|
|
var nextValue = String(attribute.value).replace(/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g, function (whole, path) {
|
|
var parts = path.split(".");
|
|
if (parts[0] === prefix) parts.shift();
|
|
if (prefix === "error" && parts[0] !== "message") return whole;
|
|
var value = asyncValueAt(model, parts.join("."));
|
|
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
});
|
|
if (/^(?:href|src|action|formaction)$/i.test(attribute.name)) {
|
|
nextValue = sanitizeReactiveUrl(nextValue);
|
|
}
|
|
node.setAttribute(attribute.name, nextValue);
|
|
});
|
|
});
|
|
root.querySelectorAll("[data-wrn-async-value]").forEach(function (node) {
|
|
var path = node.getAttribute("data-wrn-async-value") || "";
|
|
var value = asyncValueAt(model, path);
|
|
node.textContent = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
|
});
|
|
}
|
|
function showAsync(boundary, state, model) {
|
|
var template = boundary.querySelector("template[data-wrn-async-" + state + "]");
|
|
var content = boundary.querySelector("[data-wrn-async-content]");
|
|
if (!template || !content) return;
|
|
var fragment = template.content.cloneNode(true);
|
|
var alias = template.getAttribute("data-wrn-async-alias") ||
|
|
(state === "error" ? "error" : boundary.getAttribute("data-wrn-async"));
|
|
interpolateAsync(fragment, model, alias);
|
|
content.replaceChildren(fragment);
|
|
boundary.setAttribute("aria-busy", state === "loading" ? "true" : "false");
|
|
boundary.setAttribute("data-wrn-async-state", state);
|
|
}
|
|
function setupAsyncBoundary(boundary) {
|
|
if (boundary.__wrnexusAsync) return;
|
|
boundary.__wrnexusAsync = true;
|
|
if (boundary.getAttribute("data-wrn-async-resolved") === "true") {
|
|
boundary.setAttribute("data-wrn-async-state", "success");
|
|
boundary.setAttribute("aria-busy", "false");
|
|
return;
|
|
}
|
|
var name = boundary.getAttribute("data-wrn-async") || "";
|
|
if (!/^[A-Za-z_$][\w$]{0,63}$/.test(name)) return showAsync(boundary, "error", { message: "Invalid data source" });
|
|
var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&search=" + encodeURIComponent(location.search) + "&name=" + encodeURIComponent(name);
|
|
var attempts = Math.min(5, Math.max(0, Number(boundary.getAttribute("data-wrn-async-retries")) || 0));
|
|
var controller = new AbortController();
|
|
boundary.__wrnexusAsyncAbort = controller;
|
|
window.addEventListener("wrnexus:navigation-start", function () { controller.abort(); }, { once: true });
|
|
function request(remaining) {
|
|
var pending = asyncLoads.get(url);
|
|
if (!pending) {
|
|
pending = fetch(url, { headers: { accept: "application/json" }, signal: controller.signal }).then(function (response) {
|
|
if (!response.ok) throw new Error("Client load returned " + response.status);
|
|
return response.json();
|
|
}).finally(function () { asyncLoads.delete(url); });
|
|
asyncLoads.set(url, pending);
|
|
}
|
|
pending.then(function (result) {
|
|
showAsync(boundary, "success", result.data);
|
|
}).catch(function (error) {
|
|
if (controller.signal.aborted) return;
|
|
if (remaining > 0) return setTimeout(function () { request(remaining - 1); }, 100 * (attempts - remaining + 1));
|
|
showAsync(boundary, "error", { message: error && error.message ? error.message : "Loading failed" });
|
|
});
|
|
}
|
|
showAsync(boundary, "loading", {});
|
|
request(attempts);
|
|
}
|
|
function hydrateAsyncBoundaries(root) {
|
|
(root || document).querySelectorAll("[data-wrn-async]").forEach(setupAsyncBoundary);
|
|
}
|
|
|
|
var invalidationRefreshPending = false;
|
|
function asyncBoundaryTags(boundary) {
|
|
var raw = boundary.getAttribute("data-wrn-async-tags") || boundary.getAttribute("data-wrn-async") || "";
|
|
return raw.split(",").map(function (tag) { return tag.trim(); }).filter(Boolean);
|
|
}
|
|
function refreshAsyncBoundary(boundary) {
|
|
if (boundary.__wrnexusAsyncAbort) {
|
|
try { boundary.__wrnexusAsyncAbort.abort(); } catch (_) {}
|
|
}
|
|
boundary.__wrnexusAsync = false;
|
|
boundary.removeAttribute("data-wrn-async-resolved");
|
|
setupAsyncBoundary(boundary);
|
|
}
|
|
function handleCacheInvalidation(event) {
|
|
var detail = event && event.detail && typeof event.detail === "object" ? event.detail : {};
|
|
var tags = Array.isArray(detail.tags) ? detail.tags.map(String) : [];
|
|
if (!tags.length) return;
|
|
var matchedServerBoundary = false;
|
|
document.querySelectorAll("[data-wrn-async]").forEach(function (boundary) {
|
|
var matches = asyncBoundaryTags(boundary).some(function (tag) { return tags.indexOf(tag) !== -1; });
|
|
if (!matches) return;
|
|
if (boundary.getAttribute("data-wrn-async-resolved") === "true") {
|
|
matchedServerBoundary = true;
|
|
return;
|
|
}
|
|
refreshAsyncBoundary(boundary);
|
|
});
|
|
if (matchedServerBoundary && !invalidationRefreshPending) {
|
|
invalidationRefreshPending = true;
|
|
Promise.resolve().then(function () {
|
|
invalidationRefreshPending = false;
|
|
if (typeof window.__wrnexusRefresh === "function") window.__wrnexusRefresh();
|
|
else window.location.reload();
|
|
});
|
|
}
|
|
}
|
|
window.addEventListener("wrnexus:cache:invalidate", handleCacheInvalidation);
|
|
|
|
function mountClientRoots(root) {
|
|
(root || document).querySelectorAll("template[data-wrn-client-template]").forEach(function (template) {
|
|
var id = template.getAttribute("data-wrn-client-template");
|
|
var mount = null;
|
|
(root || document).querySelectorAll("[data-wrn-client-root]").forEach(function (candidate) {
|
|
if (!mount && candidate.getAttribute("data-wrn-client-root") === id) mount = candidate;
|
|
});
|
|
if (!mount || !template.content) return;
|
|
var fragment = template.content.cloneNode(true);
|
|
mount.replaceWith(fragment);
|
|
template.remove();
|
|
window.dispatchEvent(new window.CustomEvent("wrnexus:client-mounted", { detail: { id: id } }));
|
|
});
|
|
}
|
|
|
|
function hydrateDeclarativeUi(root) {
|
|
var host = root || document;
|
|
host.querySelectorAll("[data-wrn-portal]").forEach(function (portal) {
|
|
if (portal.__wrnPortalMounted) return;
|
|
var selector = portal.getAttribute("data-wrn-portal") || "body";
|
|
var target = null; try { target = document.querySelector(selector); } catch (_) {}
|
|
if (!target || target === portal || portal.contains(target)) return;
|
|
portal.__wrnPortalMounted = true;
|
|
var marker = document.createComment("wrnexus-portal");
|
|
portal.parentNode && portal.parentNode.insertBefore(marker, portal);
|
|
target.appendChild(portal);
|
|
});
|
|
host.querySelectorAll("[data-wrn-transition]").forEach(function (element) {
|
|
if (element.__wrnTransitionMounted) return;
|
|
element.__wrnTransitionMounted = true;
|
|
var name = element.getAttribute("data-wrn-transition") || "wrn-transition";
|
|
element.classList.add(name + "-enter", name + "-enter-active");
|
|
requestAnimationFrame(function () { element.classList.remove(name + "-enter"); element.classList.add(name + "-enter-to"); });
|
|
element.addEventListener("transitionend", function () { element.classList.remove(name + "-enter-active", name + "-enter-to"); }, { once: true });
|
|
});
|
|
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
|
if (element.__wrnDynamicMounted) return;
|
|
element.__wrnDynamicMounted = true;
|
|
var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
|
|
return candidate.hasAttribute("data-component-case");
|
|
}).map(function (candidate) {
|
|
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
|
element.insertBefore(marker, candidate);
|
|
return { candidate: candidate, marker: marker };
|
|
});
|
|
var update = function () {
|
|
var selected = element.getAttribute("data-wrn-dynamic-component") || "";
|
|
cases.forEach(function (record) {
|
|
var candidate = record.candidate;
|
|
var active = candidate.getAttribute("data-component-case") === selected;
|
|
candidate.hidden = false;
|
|
if (active && !candidate.isConnected && record.marker.parentNode) {
|
|
record.marker.parentNode.insertBefore(candidate, record.marker.nextSibling);
|
|
} else if (!active && candidate.parentNode) {
|
|
candidate.parentNode.removeChild(candidate);
|
|
}
|
|
});
|
|
window.dispatchEvent(new window.CustomEvent("wrnexus:dynamic-component", { detail: { component: selected } }));
|
|
};
|
|
update();
|
|
new MutationObserver(update).observe(element, { attributes: true, attributeFilter: ["data-wrn-dynamic-component"] });
|
|
});
|
|
}
|
|
|
|
window.__wrnexusMountClientRoots = mountClientRoots;
|
|
window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries;
|
|
setupAnchoredOverlays();
|
|
setupModalDialogs();
|
|
setupRovingFocus();
|
|
window.__wrnexusRepositionAnchored = repositionAnchored;
|
|
window.__wrnexusHydrateScopes = hydrateScopes;
|
|
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
|
|
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
|
|
window.__wrnexusDisposeBehaviors = disposeBehaviors;
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
mountClientRoots(document);
|
|
hydrateScopes(document);
|
|
hydrateCsrFetches(document);
|
|
hydrateAsyncBoundaries(document);
|
|
hydrateDeclarativeUi(document);
|
|
});
|
|
} else {
|
|
mountClientRoots(document);
|
|
hydrateScopes(document);
|
|
hydrateCsrFetches(document);
|
|
hydrateAsyncBoundaries(document);
|
|
hydrateDeclarativeUi(document);
|
|
}
|
|
})();
|
|
`.trim();
|