5758 lines
181 KiB
TypeScript
5758 lines
181 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.
|
|
*
|
|
* wrnToast is the stable name. toast is the ergonomic alias and defers to
|
|
* an application that already defines one.
|
|
*/
|
|
window.wrnToast = 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.
|
|
}
|
|
}
|
|
|
|
/*__WRNEXUS_DEV_START__*/
|
|
var developmentWarnings = Object.create(null);
|
|
function warnOnce(code, message, element, detail) {
|
|
var key = code + "\\n" + message;
|
|
if (developmentWarnings[key]) return;
|
|
developmentWarnings[key] = true;
|
|
var payload = {
|
|
code: code,
|
|
message: message,
|
|
hydrationId: element && element.getAttribute ? element.getAttribute("data-wrn-hydration") : null,
|
|
detail: detail || null,
|
|
};
|
|
console.warn("[" + code + "] " + message, detail || "");
|
|
try {
|
|
window.dispatchEvent(new CustomEvent("wrnexus:diagnostic", { detail: payload }));
|
|
} catch (_) {
|
|
// CustomEvent can be unavailable in minimal DOM test environments.
|
|
}
|
|
}
|
|
|
|
function warnMissingBindingFunction(statement, resolve, element) {
|
|
var match = /^\s*([A-Za-z_$][\w$]*)\s*\(/.exec(statement || "");
|
|
if (!match || typeof resolve(match[1]) === "function") return;
|
|
warnOnce(
|
|
"WRN-DEV-BINDING-MISSING",
|
|
"Component binding calls '" + match[1] + "', but that function does not exist in the parent scope.",
|
|
element,
|
|
{ binding: statement, functionName: match[1] },
|
|
);
|
|
}
|
|
|
|
function warnMissingThemeTokens() {
|
|
var referenced = Object.create(null);
|
|
var declared = Object.create(null);
|
|
function collect(rules) {
|
|
Array.prototype.forEach.call(rules || [], function (rule) {
|
|
var cssText = rule.cssText || "";
|
|
var match;
|
|
var pattern = /var\(\s*(--wrn-[A-Za-z0-9_-]+)/g;
|
|
while ((match = pattern.exec(cssText))) referenced[match[1]] = true;
|
|
if (rule.style) {
|
|
Array.prototype.forEach.call(rule.style, function (property) {
|
|
if (String(property).indexOf("--wrn-") === 0) declared[property] = true;
|
|
});
|
|
}
|
|
try {
|
|
if (rule.cssRules) collect(rule.cssRules);
|
|
} catch (_) {
|
|
// Cross-origin and disabled stylesheets can deny CSSOM access.
|
|
}
|
|
});
|
|
}
|
|
Array.prototype.forEach.call(document.styleSheets || [], function (sheet) {
|
|
try { collect(sheet.cssRules); } catch (_) {}
|
|
});
|
|
Array.prototype.forEach.call(document.querySelectorAll("[style]") || [], function (element) {
|
|
Array.prototype.forEach.call(element.style || [], function (property) {
|
|
if (String(property).indexOf("--wrn-") === 0) declared[property] = true;
|
|
});
|
|
});
|
|
var rendered = window.getComputedStyle(document.documentElement);
|
|
Object.keys(referenced).forEach(function (token) {
|
|
if (declared[token] || rendered.getPropertyValue(token).trim()) return;
|
|
warnOnce(
|
|
"WRN-DEV-THEME-TOKEN-MISSING",
|
|
"Theme token '" + token + "' is referenced by rendered CSS but is not defined.",
|
|
document.documentElement,
|
|
{ token: token },
|
|
);
|
|
});
|
|
}
|
|
/*__WRNEXUS_DEV_END__*/
|
|
|
|
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) {
|
|
el.removeAttribute("data-scope");
|
|
el.removeAttribute("data-wrn-scope");
|
|
el.removeAttribute("data-wrn-behavior");
|
|
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 componentRpcIdentity = el.getAttribute("data-wrn-component") ||
|
|
el.getAttribute("data-wrn-hydration") ||
|
|
componentEventTarget.getAttribute("data-wrn-component") ||
|
|
componentEventTarget.getAttribute("data-wrn-hydration") ||
|
|
"component";
|
|
var componentRpcName = String(componentRpcIdentity).split(":", 1)[0] || "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 isScopeRoot(node) {
|
|
return !!(
|
|
node &&
|
|
node.nodeType === 1 &&
|
|
(node.__wrnexusScope ||
|
|
node.hasAttribute("data-scope") ||
|
|
node.hasAttribute("data-wrn-scope"))
|
|
);
|
|
}
|
|
|
|
function closestScope(node) {
|
|
for (var current = node; current; current = current.parentNode) {
|
|
if (isScopeRoot(current)) return current;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function closestScopeOrSlot(node) {
|
|
for (var current = node; current; current = current.parentNode) {
|
|
if (
|
|
current.nodeType === 1 &&
|
|
(current.hasAttribute("data-wrn-slot") || isScopeRoot(current))
|
|
) {
|
|
return current;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ownerScope(node) {
|
|
var cursor = node.nodeType === 1 ? node : node.parentNode;
|
|
for (var guard = 0; cursor && guard < 32; guard++) {
|
|
var found = closestScopeOrSlot(cursor);
|
|
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 = closestScope(found.parentNode);
|
|
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
|
|
) {
|
|
node.removeAttribute(attribute.name);
|
|
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 connected 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 integration 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;
|
|
}
|
|
|
|
node.removeAttribute(marker.name);
|
|
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;
|
|
// Text in these is data, not a template: a JSON sample sitting in a
|
|
// textarea would otherwise be read as mustaches and eaten.
|
|
var owner = textNode.parentNode;
|
|
var ownerName = owner ? owner.nodeName : "";
|
|
if (ownerName === "TEXTAREA" || ownerName === "SCRIPT" || ownerName === "STYLE") 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) {
|
|
// An unbound output falls back to a same-named CustomEvent for
|
|
// external consumers. Do not feed that synthetic event back into
|
|
// the component's own declarative DOM handler (for example,
|
|
// @click="output.click()"), which would recurse indefinitely.
|
|
if (event && event.__wrnexusComponentOutput) return;
|
|
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 = closestScope(node);
|
|
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 {
|
|
/*__WRNEXUS_DEV_START__*/
|
|
warnMissingBindingFunction(outStmt, function (name) { return peekScope(name); }, componentRoot);
|
|
/*__WRNEXUS_DEV_END__*/
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Prop expressions belong to the parent that mounted the component. The
|
|
// server forwards these markers onto the rendered child root; evaluate
|
|
// them here and write changes into the child's prop signals.
|
|
Array.prototype.slice
|
|
.call(el.querySelectorAll("*"))
|
|
.filter(isScopeRoot)
|
|
.forEach(function (node) {
|
|
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
|
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
|
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
|
var binding;
|
|
try { binding = JSON.parse(attr.value); } catch (_) { return; }
|
|
if (!binding || binding.length !== 2) return;
|
|
var propName = binding[0];
|
|
var template = binding[1];
|
|
reactive(function () {
|
|
var exact = /^\{([^{}]+)\}$/.exec(template);
|
|
var value;
|
|
try {
|
|
value = exact
|
|
? evalExpr(exact[1].trim(), decodeLoopLocals(node))
|
|
: template.replace(/\{([^{}]+)\}/g, function (_, expression) {
|
|
var part = evalExpr(expression.trim(), decodeLoopLocals(node));
|
|
return part == null ? "" : String(part);
|
|
});
|
|
} catch (_) { return; }
|
|
var apply = function () {
|
|
if (node.__wrnexusScopeApi) node.__wrnexusScopeApi.set(propName, value);
|
|
};
|
|
if (node.__wrnexusScopeApi) apply();
|
|
else queueMicrotask(apply);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Every hydrated scope exposes state writes. Prop-only components need the
|
|
// same API even when they have no behavior block.
|
|
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;
|
|
});
|
|
|
|
if (behavior) {
|
|
installBehaviorFunctions(behavior.functions);
|
|
|
|
(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();
|
|
|
|
// Hydration metadata is an SSR transport format, not application DOM.
|
|
// All values needed after setup now live in closures, WeakMaps, or private
|
|
// element properties, so keep the live DOM limited to actual web markup.
|
|
el.removeAttribute("data-scope");
|
|
el.removeAttribute("data-wrn-scope");
|
|
el.removeAttribute("data-wrn-behavior");
|
|
}
|
|
|
|
function disposeBehaviors(root) {
|
|
var elements = [];
|
|
mountedBehaviorRoots.forEach(function (element) {
|
|
if (
|
|
element === root ||
|
|
(root && root.nodeType === 1 && root.contains && root.contains(element))
|
|
) {
|
|
elements.push(element);
|
|
}
|
|
});
|
|
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.
|
|
*/
|
|
/*__WRNEXUS_CONTROLLERS_PRIMARY_START__*/
|
|
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);
|
|
});
|
|
}
|
|
|
|
/*
|
|
* One document observer, several subscribers. The filter stays explicit:
|
|
* observing every attribute would see the tabindex the roving code writes
|
|
* and loop on its own output.
|
|
*/
|
|
var documentWatchers = [];
|
|
|
|
function watchDocument(handler) {
|
|
documentWatchers.push(handler);
|
|
}
|
|
|
|
function startDocumentWatch() {
|
|
if (window.__wrnexusDocWatchBound || typeof MutationObserver !== "function") return;
|
|
window.__wrnexusDocWatchBound = true;
|
|
new MutationObserver(function () {
|
|
for (var index = 0; index < documentWatchers.length; index += 1) documentWatchers[index]();
|
|
}).observe(document.documentElement, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
// prettier-ignore
|
|
attributeFilter: ["data-open","data-show","class","style","hidden",
|
|
"data-placement","aria-selected","aria-current","disabled","aria-disabled"],
|
|
});
|
|
}
|
|
|
|
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.
|
|
watchDocument(scheduleAnchoredReposition);
|
|
|
|
window.addEventListener("resize", scheduleAnchoredReposition);
|
|
window.addEventListener("scroll", scheduleAnchoredReposition, true);
|
|
}
|
|
|
|
/*
|
|
* Modal dialog focus, focus restore, Tab trap and scroll lock. Shared by
|
|
* Modal and Drawer, and here rather than in them because a client function
|
|
* cannot hold the previously focused element across a close.
|
|
*
|
|
* Fixing focus also repairs Escape: both bind @keydown on their own root,
|
|
* so until focus moved inside, closeOnEscape did nothing.
|
|
*/
|
|
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;
|
|
|
|
// data-show, not measured size: the test DOM reports everything as
|
|
// zero-sized, which is what left the trap uncovered.
|
|
function isDialogVisible(dialog) {
|
|
if (!dialog || !dialog.isConnected) return false;
|
|
if (dialog.hasAttribute("hidden")) return false;
|
|
if (dialog.closest('[data-show="false"]')) return false;
|
|
/*
|
|
* data-open is the signal Modal and Drawer actually publish, and it is the
|
|
* only one Drawer can publish: its panel animates open, so it cannot be
|
|
* toggled with data-show, which sets display:none. Treating every dialog
|
|
* without a data-show="false" ancestor as open meant a closed Drawer held
|
|
* the body scroll lock forever and the page could not be scrolled.
|
|
*/
|
|
var owner = dialog.closest("[data-open]");
|
|
if (owner) return owner.getAttribute("data-open") === "true";
|
|
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];
|
|
// 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; the panel carries tabindex="-1" as a fallback.
|
|
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; keep focus on the panel rather than the page behind.
|
|
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;
|
|
|
|
// Deferred: the mutation that opens a dialog is the one that makes it
|
|
// visible, so it is not laid out yet when the record arrives.
|
|
watchDocument(function () {
|
|
window.setTimeout(syncDialogs, 0);
|
|
});
|
|
|
|
document.addEventListener("keydown", trapDialogTab, true);
|
|
syncDialogs();
|
|
}
|
|
|
|
/*
|
|
* Roving arrow-key focus. A container marked data-wrn-roving owns its
|
|
* [data-wrn-roving-item] descendants: one carries tabindex="0" so Tab
|
|
* reaches the group once, and the arrows move within it. Here rather than
|
|
* in five components because focus bookkeeping cannot live in component
|
|
* state.
|
|
*/
|
|
var ROVING_SELECTOR = "[data-wrn-roving]";
|
|
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
|
|
|
|
// Templates stringify these: "" and "false" mean opted out, and a bare
|
|
// [attr] selector matches either, so the value must be checked.
|
|
// Bare means yes; only an explicit "false" opts out.
|
|
function rovingItemOff(value) {
|
|
return value === null || value === "false";
|
|
}
|
|
|
|
// The container must name an axis; empty is what {cond ? "x" : ""} emits.
|
|
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 (see isDialogVisible).
|
|
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);
|
|
|
|
watchDocument(function () {
|
|
window.setTimeout(syncRovingGroups, 0);
|
|
});
|
|
|
|
syncRovingGroups();
|
|
}
|
|
|
|
// Scrollspy. The marker goes onto the links, not into state: an observer
|
|
// callback fires after the client function returned, so that write is lost.
|
|
function applyScrollspyCurrent(nav, href) {
|
|
var links = nav.querySelectorAll('a[href^="#"]');
|
|
var changed = false;
|
|
var label = "";
|
|
for (var index = 0; index < links.length; index += 1) {
|
|
var link = links[index];
|
|
var current = link.getAttribute("href") === href;
|
|
if (current) label = (link.textContent || "").trim();
|
|
if ((link.getAttribute("data-active") === "true") !== current) changed = true;
|
|
link.setAttribute("data-active", current ? "true" : "false");
|
|
link.setAttribute("aria-current", current ? "location" : "false");
|
|
}
|
|
if (!changed) return;
|
|
// Named for the component output so a parent @change binding receives it.
|
|
nav.dispatchEvent(new CustomEvent("change", { detail: { href: href, label: label } }));
|
|
}
|
|
|
|
function bindScrollspy(nav) {
|
|
if (nav.__wrnScrollspyBound) return;
|
|
nav.__wrnScrollspyBound = true;
|
|
|
|
nav.addEventListener("click", function (event) {
|
|
var t = event.target;
|
|
var link = t && t.closest ? t.closest('a[href^="#"]') : null;
|
|
if (link && nav.contains(link)) applyScrollspyCurrent(nav, link.getAttribute("href"));
|
|
});
|
|
|
|
if (typeof IntersectionObserver === "undefined") return;
|
|
|
|
var links = nav.querySelectorAll('a[href^="#"]');
|
|
var targets = [];
|
|
for (var index = 0; index < links.length; index += 1) {
|
|
var href = links[index].getAttribute("href");
|
|
var section = document.getElementById(href.slice(1));
|
|
if (section) targets.push({ section: section, href: href });
|
|
}
|
|
if (!targets.length) return;
|
|
|
|
var visible = {};
|
|
var observer = new IntersectionObserver(
|
|
function (entries) {
|
|
for (var entryIndex = 0; entryIndex < entries.length; entryIndex += 1) {
|
|
visible[entries[entryIndex].target.id] = entries[entryIndex].isIntersecting;
|
|
}
|
|
// First visible section in document order wins, so up and down
|
|
// settle on the same link.
|
|
for (var pick = 0; pick < targets.length; pick += 1) {
|
|
if (visible[targets[pick].section.id]) {
|
|
applyScrollspyCurrent(nav, targets[pick].href);
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
// Biased to the upper third: the current section is the one being read.
|
|
{ rootMargin: "-80px 0px -55% 0px" },
|
|
);
|
|
for (var watch = 0; watch < targets.length; watch += 1) observer.observe(targets[watch].section);
|
|
}
|
|
|
|
function setupScrollspy() {
|
|
if (window.__wrnexusScrollspyBound) return;
|
|
window.__wrnexusScrollspyBound = true;
|
|
var bindAll = function () {
|
|
var navs = document.querySelectorAll("[data-wrn-scrollspy]");
|
|
for (var index = 0; index < navs.length; index += 1) bindScrollspy(navs[index]);
|
|
};
|
|
bindAll();
|
|
watchDocument(function () {
|
|
window.setTimeout(bindAll, 0);
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Resizable split panes.
|
|
*
|
|
* The resolved size is written onto the container as a --wrn-split custom
|
|
* property and the component styles from it. Pointer moves fire far too
|
|
* often to route through a client function, and a state write made inside a
|
|
* pointermove callback is dropped, so the DOM holds the answer.
|
|
*/
|
|
var SPLITTER_SELECTOR = "[data-wrn-splitter]";
|
|
|
|
function splitterNumber(element, name, fallback) {
|
|
var raw = Number(element.getAttribute(name));
|
|
return isFinite(raw) && raw !== 0 ? raw : fallback;
|
|
}
|
|
|
|
function splitterBounds(root) {
|
|
var min = splitterNumber(root, "data-wrn-splitter-min", 10);
|
|
return { min: min, max: 100 - min };
|
|
}
|
|
|
|
function applySplit(root, handle, size) {
|
|
var bounds = splitterBounds(root);
|
|
var next = Math.min(bounds.max, Math.max(bounds.min, size));
|
|
next = Math.round(next * 100) / 100;
|
|
root.style.setProperty("--wrn-split", next + "%");
|
|
if (handle) {
|
|
handle.setAttribute("aria-valuenow", String(next));
|
|
handle.setAttribute("aria-valuemin", String(bounds.min));
|
|
handle.setAttribute("aria-valuemax", String(bounds.max));
|
|
}
|
|
/*
|
|
* Deliberately not named "resize". That collides with the native event, and
|
|
* the component output binding never ran for it. The component listens for
|
|
* this and re-emits its own declared output.
|
|
*/
|
|
root.dispatchEvent(
|
|
new CustomEvent("wrnexus:splitter:resize", { detail: { size: next } }),
|
|
);
|
|
return next;
|
|
}
|
|
|
|
function currentSplit(root, handle) {
|
|
var fromHandle = Number(handle && handle.getAttribute("aria-valuenow"));
|
|
if (isFinite(fromHandle) && fromHandle) return fromHandle;
|
|
var raw = String(root.style.getPropertyValue("--wrn-split") || "").replace("%", "");
|
|
var parsed = Number(raw);
|
|
return isFinite(parsed) && parsed ? parsed : 50;
|
|
}
|
|
|
|
function handleSplitterKeydown(event) {
|
|
var target = event.target;
|
|
if (!target || !target.closest) return;
|
|
var handle = target.closest("[data-wrn-splitter-handle]");
|
|
if (!handle) return;
|
|
var root = handle.closest(SPLITTER_SELECTOR);
|
|
if (!root) return;
|
|
|
|
var vertical = root.getAttribute("data-wrn-splitter") === "vertical";
|
|
var step = splitterNumber(root, "data-wrn-splitter-step", 5);
|
|
var bounds = splitterBounds(root);
|
|
var size = currentSplit(root, handle);
|
|
var key = event.key;
|
|
var next = size;
|
|
|
|
if (key === (vertical ? "ArrowDown" : "ArrowRight")) next = size + step;
|
|
else if (key === (vertical ? "ArrowUp" : "ArrowLeft")) next = size - step;
|
|
else if (key === "Home") next = bounds.min;
|
|
else if (key === "End") next = bounds.max;
|
|
else return;
|
|
|
|
event.preventDefault();
|
|
applySplit(root, handle, next);
|
|
}
|
|
|
|
function setupSplitters() {
|
|
if (window.__wrnexusSplitterBound) return;
|
|
window.__wrnexusSplitterBound = true;
|
|
|
|
document.addEventListener("keydown", handleSplitterKeydown, true);
|
|
|
|
var dragging = null;
|
|
|
|
document.addEventListener("pointerdown", function (event) {
|
|
var target = event.target;
|
|
var handle = target && target.closest ? target.closest("[data-wrn-splitter-handle]") : null;
|
|
if (!handle) return;
|
|
var root = handle.closest(SPLITTER_SELECTOR);
|
|
if (!root) return;
|
|
dragging = { root: root, handle: handle };
|
|
root.setAttribute("data-wrn-splitter-dragging", "true");
|
|
if (handle.setPointerCapture && event.pointerId !== undefined) {
|
|
handle.setPointerCapture(event.pointerId);
|
|
}
|
|
event.preventDefault();
|
|
});
|
|
|
|
document.addEventListener("pointermove", function (event) {
|
|
if (!dragging) return;
|
|
var rect = dragging.root.getBoundingClientRect();
|
|
var vertical = dragging.root.getAttribute("data-wrn-splitter") === "vertical";
|
|
var span = vertical ? rect.height : rect.width;
|
|
if (!span) return;
|
|
var offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
|
|
applySplit(dragging.root, dragging.handle, (offset / span) * 100);
|
|
});
|
|
|
|
var endDrag = function () {
|
|
if (!dragging) return;
|
|
dragging.root.removeAttribute("data-wrn-splitter-dragging");
|
|
dragging = null;
|
|
};
|
|
document.addEventListener("pointerup", endDrag);
|
|
document.addEventListener("pointercancel", endDrag);
|
|
}
|
|
|
|
/*__WRNEXUS_CONTROLLERS_PRIMARY_END__*/
|
|
|
|
function hydrateScopes(root) {
|
|
var host = root || document;
|
|
|
|
// The document shell can contain server-resolved attributes such as
|
|
// lang={language} and data-theme={theme}. It has no component scope and
|
|
// therefore no client-side binding to retain; consume its compiler markers
|
|
// separately from component hydration.
|
|
if (host === document || host === document.documentElement) {
|
|
Array.prototype.slice
|
|
.call(document.documentElement.attributes)
|
|
.forEach(function (attribute) {
|
|
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
|
document.documentElement.removeAttribute(attribute.name);
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
hydrateSelectControllers(host);
|
|
hydratePinInputControllers(host);
|
|
/*__WRNEXUS_DEV_START__*/
|
|
warnMissingThemeTokens();
|
|
/*__WRNEXUS_DEV_END__*/
|
|
}
|
|
|
|
/*__WRNEXUS_CONTROLLERS_UI_START__*/
|
|
var navbarOutsideClickBound = false;
|
|
var preferenceOutsideClickBound = false;
|
|
|
|
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(".wrn-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(".wrn-preferences__menu[open]").forEach(function (menu) {
|
|
menu.removeAttribute("open");
|
|
});
|
|
});
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Escape") return;
|
|
document.querySelectorAll("[data-wrn-preferences] .wrn-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(".wrn-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(".wrn-navbar__menus [aria-current='page']").forEach(function (item) {
|
|
item.removeAttribute("aria-current");
|
|
});
|
|
if (best) {
|
|
best.setAttribute("aria-current", "page");
|
|
var parentMenu = best.closest(".wrn-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(".wrn-navbar__dropdown").forEach(function (menu) {
|
|
menu.addEventListener("pointerenter", function () {
|
|
if (menu.__wrnNavbarCloseTimer) {
|
|
clearTimeout(menu.__wrnNavbarCloseTimer);
|
|
menu.__wrnNavbarCloseTimer = null;
|
|
}
|
|
navbar.querySelectorAll(".wrn-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(".wrn-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(".wrn-navbar__dropdown[open]").forEach(function (menu) {
|
|
menu.removeAttribute("open");
|
|
});
|
|
});
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Escape") return;
|
|
document.querySelectorAll("[data-wrn-navbar] .wrn-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(".wrn-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(".wrn-next__select-list");
|
|
if (!list) return;
|
|
|
|
list.querySelectorAll(".wrn-next__option-group, .wrn-next__select-option, .wrn-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 = "wrn-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 = "wrn-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 = "wrn-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("wrn-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(".wrn-next__select-search input, .wrn-next__combobox-input");
|
|
var list = root.querySelector(".wrn-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(".wrn-next__select-trigger")) return;
|
|
if (loadedOptions.length === 0) load(false);
|
|
});
|
|
root.__wrnexusSelectController.load = load;
|
|
|
|
var dropdown = root.querySelector(".wrn-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(".wrn-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(".wrn-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(".wrn-next__select-option, .wrn-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].wrn-next--open").forEach(function (select) {
|
|
if (select.contains(event.target)) return;
|
|
var api = selectScopeApi(select);
|
|
if (api) api.set("open", false);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/*__WRNEXUS_CONTROLLERS_UI_END__*/
|
|
|
|
function invokeComponentOutput(root, name, payload) {
|
|
if (!root || !name) return undefined;
|
|
var registry = root.__wrnexusOutputHandlers;
|
|
var handlers = registry && registry[name];
|
|
/*
|
|
* A parent writes @sizeChange, but HTML lowercases attribute names, so the
|
|
* handler is registered under "sizechange" while the component emits
|
|
* "sizeChange". Without this the lookup misses, the call falls through to
|
|
* dispatchComponentEvent, and the binding is never invoked -- silently.
|
|
* Every camelCase output in the library was undeliverable because of it.
|
|
*/
|
|
if ((!handlers || !handlers.size) && registry) {
|
|
var lower = String(name).toLowerCase();
|
|
if (lower !== name) handlers = registry[lower];
|
|
if (!handlers || !handlers.size) {
|
|
for (var key in registry) {
|
|
if (key.toLowerCase() === lower && registry[key] && registry[key].size) {
|
|
handlers = registry[key];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 csrfMeta = document.querySelector('meta[name="wrnexus-csrf"]');
|
|
var csrfMatch = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || "");
|
|
var csrf = csrfMeta
|
|
? csrfMeta.getAttribute("content") || ""
|
|
: csrfMatch
|
|
? decodeURIComponent(csrfMatch[1])
|
|
: "";
|
|
return fetch("/__wrnexus/rpc", {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-csrf-token": csrf,
|
|
},
|
|
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 || {},
|
|
});
|
|
event.__wrnexusComponentOutput = true;
|
|
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.
|
|
}
|
|
}
|
|
|
|
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
|
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);
|
|
}
|
|
|
|
/*__WRNEXUS_CONTROLLERS_PIN_END__*/
|
|
|
|
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();
|
|
setupScrollspy();
|
|
setupSplitters();
|
|
startDocumentWatch();
|
|
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();
|