2591 lines
59 KiB
TypeScript
2591 lines
59 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;
|
|
|
|
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;
|
|
}
|
|
|
|
function splitStatements(input) {
|
|
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 === "'" || ch === "\`") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
else if (
|
|
(
|
|
ch === ";" ||
|
|
ch === "\n" ||
|
|
ch === "\r"
|
|
) &&
|
|
depth === 0
|
|
) {
|
|
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 applyReactiveAttribute(node, name, value) {
|
|
var lowerName = String(name || "").toLowerCase();
|
|
|
|
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 (value === false || value == null) {
|
|
node.removeAttribute(name);
|
|
} else {
|
|
node.setAttribute(
|
|
name,
|
|
value === true ? "" : String(value),
|
|
);
|
|
}
|
|
}
|
|
|
|
function setupScope(el) {
|
|
if (el.__wrnexusScope) return;
|
|
|
|
el.__wrnexusScope = true;
|
|
el.__wrnexusHydrated = true;
|
|
|
|
var encodedScope =
|
|
el.getAttribute(
|
|
"data-wrn-scope",
|
|
);
|
|
|
|
var initial;
|
|
|
|
if (encodedScope) {
|
|
try {
|
|
initial =
|
|
decodeScopePayload(
|
|
encodedScope,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] failed to decode scope payload",
|
|
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 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 currentRenderer = null;
|
|
|
|
function reactive(fn) {
|
|
var running = false;
|
|
|
|
function run() {
|
|
if (running) {
|
|
return;
|
|
}
|
|
|
|
running = true;
|
|
|
|
var previousRenderer =
|
|
currentRenderer;
|
|
|
|
currentRenderer = run;
|
|
|
|
try {
|
|
fn();
|
|
} finally {
|
|
currentRenderer =
|
|
previousRenderer;
|
|
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
renderers.push(run);
|
|
|
|
return run;
|
|
}
|
|
function renderAll() { renderers.forEach(function (f) { f(); }); }
|
|
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 stateWatchers = {};
|
|
var anyStateListeners = new Set();
|
|
var cleanupCallbacks = [];
|
|
var disposed = false;
|
|
|
|
function readGlobal(name) {
|
|
if (name === "window") return window;
|
|
if (name === "document") return document;
|
|
if (name === "console") return console;
|
|
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 === "setTimeout") return window.setTimeout.bind(window);
|
|
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
|
if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
|
|
if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
|
|
return undefined;
|
|
}
|
|
|
|
function readScope(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 (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,
|
|
) {
|
|
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) {
|
|
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;
|
|
};
|
|
});
|
|
}
|
|
|
|
// A binding belongs to THIS scope only when el is the node's nearest
|
|
// [data-scope] ancestor. Otherwise a nested scope owns it and we skip it,
|
|
// so an outer scope never clobbers an inner one's values.
|
|
function owns(node) {
|
|
var host = node.nodeType === 1 ? node : node.parentNode;
|
|
return (
|
|
!!host &&
|
|
host.closest &&
|
|
host.closest(
|
|
"[data-scope], [data-wrn-scope]",
|
|
) === el
|
|
);
|
|
}
|
|
|
|
// --- data-for list rendering -------------------------------------------
|
|
// Each [data-for="item in list"] element is a per-item template. On any
|
|
// change to the list (or a dependency an item reads), the list re-renders.
|
|
function parseFor(value) {
|
|
var m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
|
|
value || "",
|
|
);
|
|
return m ? { item: m[1], index: m[2], list: m[3] } : null;
|
|
}
|
|
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 "";
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
var elements = [root];
|
|
|
|
if (root.querySelectorAll) {
|
|
Array.prototype.push.apply(
|
|
elements,
|
|
root.querySelectorAll("*"),
|
|
);
|
|
}
|
|
|
|
elements.forEach(function (node) {
|
|
if (
|
|
!node ||
|
|
node.nodeType !== 1
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var attributes =
|
|
Array.prototype.slice.call(
|
|
node.attributes,
|
|
);
|
|
|
|
attributes.forEach(function (
|
|
attribute,
|
|
) {
|
|
/*
|
|
* data-text must be handled using itemEval(),
|
|
* because item/index only exist in loop locals.
|
|
*/
|
|
if (
|
|
attribute.name === "data-text"
|
|
) {
|
|
try {
|
|
var textValue =
|
|
itemEval(attribute.value);
|
|
|
|
node.textContent =
|
|
textValue == null
|
|
? ""
|
|
: String(textValue);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] data-for text binding failed for '" +
|
|
attribute.value +
|
|
"'",
|
|
error,
|
|
);
|
|
|
|
node.textContent = "";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Conditional classes:
|
|
* data-wrn-class-*='["class","expression"]'
|
|
*/
|
|
if (
|
|
attribute.name.indexOf(
|
|
"data-wrn-class-",
|
|
) === 0
|
|
) {
|
|
var classBinding;
|
|
|
|
try {
|
|
classBinding = JSON.parse(
|
|
attribute.value,
|
|
);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!classBinding ||
|
|
classBinding.length !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var className =
|
|
classBinding[0];
|
|
|
|
var classExpression =
|
|
classBinding[1];
|
|
|
|
var classEnabled = false;
|
|
|
|
try {
|
|
classEnabled = !!itemEval(
|
|
classExpression,
|
|
);
|
|
} catch (_) {
|
|
classEnabled = false;
|
|
}
|
|
|
|
node.classList.toggle(
|
|
className,
|
|
classEnabled,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Reactive ordinary attributes:
|
|
* data-wrn-bind-*='["aria-expanded","{isOpen(index)}"]'
|
|
*/
|
|
if (
|
|
attribute.name.indexOf(
|
|
"data-wrn-bind-",
|
|
) === 0
|
|
) {
|
|
var binding;
|
|
|
|
try {
|
|
binding = JSON.parse(
|
|
attribute.value,
|
|
);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!binding ||
|
|
binding.length !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var attributeName =
|
|
binding[0];
|
|
|
|
var attributeTemplate =
|
|
binding[1];
|
|
|
|
var exact =
|
|
/^\{([^{}]+)\}$/.exec(
|
|
attributeTemplate,
|
|
);
|
|
|
|
var rawValue;
|
|
|
|
if (exact) {
|
|
try {
|
|
rawValue = itemEval(
|
|
exact[1].trim(),
|
|
);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
} else {
|
|
rawValue =
|
|
attributeTemplate.replace(
|
|
/\{([^{}]+)\}/g,
|
|
function (
|
|
_,
|
|
expression,
|
|
) {
|
|
try {
|
|
var value =
|
|
itemEval(
|
|
expression.trim(),
|
|
);
|
|
|
|
return value == null
|
|
? ""
|
|
: String(value);
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
applyReactiveAttribute(
|
|
node,
|
|
attributeName,
|
|
rawValue,
|
|
);
|
|
|
|
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;
|
|
|
|
try {
|
|
runStmt(
|
|
statement,
|
|
eventLocals,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"[wrnexus] data-for handler error in '" +
|
|
statement +
|
|
"'",
|
|
error,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
textNode.nodeValue =
|
|
applyItemTemplate(template);
|
|
}
|
|
}
|
|
|
|
Array.prototype.slice
|
|
.call(
|
|
el.querySelectorAll(
|
|
"[data-for]",
|
|
),
|
|
)
|
|
.forEach(function (tpl) {
|
|
if (
|
|
!tpl.parentNode ||
|
|
!owns(tpl)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var spec =
|
|
parseFor(
|
|
tpl.getAttribute(
|
|
"data-for",
|
|
),
|
|
);
|
|
|
|
if (!spec) {
|
|
return;
|
|
}
|
|
|
|
var template =
|
|
tpl.cloneNode(true);
|
|
|
|
template.removeAttribute(
|
|
"data-for",
|
|
);
|
|
|
|
var parent =
|
|
tpl.parentNode;
|
|
|
|
var marker =
|
|
document.createComment(
|
|
"wrn-for",
|
|
);
|
|
|
|
parent.insertBefore(
|
|
marker,
|
|
tpl,
|
|
);
|
|
|
|
parent.removeChild(tpl);
|
|
|
|
var clones = [];
|
|
|
|
reactive(function () {
|
|
var list =
|
|
evalExpr(spec.list);
|
|
|
|
if (!Array.isArray(list)) {
|
|
console.error(
|
|
"[wrnexus] data-for expected an array for '" +
|
|
spec.list +
|
|
"', received",
|
|
list,
|
|
);
|
|
|
|
list = [];
|
|
}
|
|
|
|
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 = {};
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|
|
|
|
// data-text bindings. Server-rendered {#each} nodes recover their item/index
|
|
// values from the nearest data-wrn-loop-locals marker.
|
|
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.
|
|
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.style.display = visible ? "" : "none";
|
|
});
|
|
});
|
|
|
|
// Conditional class bindings emitted as:
|
|
// data-wrn-class-*='["class-name","expression"]'
|
|
var classBindNodes = [el].concat(
|
|
Array.prototype.slice.call(el.querySelectorAll("*")),
|
|
);
|
|
|
|
classBindNodes.forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
|
|
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
|
|
|
var binding;
|
|
|
|
try {
|
|
binding = JSON.parse(marker.value);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
|
|
if (!binding || binding.length !== 2) return;
|
|
|
|
var className = binding[0];
|
|
var expression = binding[1];
|
|
|
|
reactive(function () {
|
|
var enabled = false;
|
|
|
|
try {
|
|
enabled = !!evalExpr(
|
|
expression,
|
|
decodeLoopLocals(node),
|
|
);
|
|
} catch (error) {
|
|
enabled = false;
|
|
}
|
|
|
|
node.classList.toggle(
|
|
className,
|
|
enabled,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Reactive ordinary attributes emitted by the compiler. Each marker stores
|
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
|
// state changes to update type, aria-*, class, href, and other attributes.
|
|
var bindNodes = [el].concat(
|
|
Array.prototype.slice.call(
|
|
el.querySelectorAll("*"),
|
|
),
|
|
);
|
|
|
|
bindNodes.forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
|
|
Array.prototype.slice
|
|
.call(node.attributes)
|
|
.forEach(function (marker) {
|
|
if (
|
|
marker.name.indexOf(
|
|
"data-wrn-bind-",
|
|
) !== 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var binding;
|
|
|
|
try {
|
|
binding = JSON.parse(
|
|
marker.value,
|
|
);
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!binding ||
|
|
binding.length !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
var name = binding[0];
|
|
var template = binding[1];
|
|
|
|
reactive(function () {
|
|
var locals =
|
|
decodeLoopLocals(node);
|
|
|
|
var exact =
|
|
/^\{([^{}]+)\}$/.exec(
|
|
template,
|
|
);
|
|
|
|
var raw;
|
|
|
|
if (exact) {
|
|
try {
|
|
raw = evalExpr(
|
|
exact[1].trim(),
|
|
locals,
|
|
);
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
} else {
|
|
raw = template.replace(
|
|
/\{([^{}]+)\}/g,
|
|
function (
|
|
_,
|
|
expression,
|
|
) {
|
|
try {
|
|
var value =
|
|
evalExpr(
|
|
expression.trim(),
|
|
locals,
|
|
);
|
|
|
|
return value == null
|
|
? ""
|
|
: String(value);
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
applyReactiveAttribute(
|
|
node,
|
|
name,
|
|
raw,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// {{expr}} / {expr} interpolation in text nodes
|
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
|
var textNode;
|
|
while ((textNode = walker.nextNode())) {
|
|
var template = textNode.nodeValue;
|
|
if (template.indexOf("{") === -1) continue;
|
|
if (!owns(textNode)) continue;
|
|
(function (node, tpl) {
|
|
reactive(function () {
|
|
node.nodeValue = tpl.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, doubleExpr, singleExpr) {
|
|
var expr = doubleExpr || singleExpr;
|
|
try {
|
|
var value = evalExpr(
|
|
expr.trim(),
|
|
decodeLoopLocals(
|
|
node.parentNode,
|
|
),
|
|
);
|
|
|
|
return value == null
|
|
? ""
|
|
: String(value);
|
|
} catch (err) { return ""; }
|
|
});
|
|
});
|
|
})(textNode, template);
|
|
}
|
|
|
|
// Event handlers on elements, window, and document.
|
|
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
|
nodes.forEach(function (node) {
|
|
if (!owns(node)) return;
|
|
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
|
if (attr.name.indexOf("data-on-") !== 0) return;
|
|
|
|
var rawName = attr.name.slice("data-on-".length);
|
|
var target = node;
|
|
var evt = rawName;
|
|
|
|
if (rawName.indexOf("window-") === 0) {
|
|
target = window;
|
|
evt = rawName.slice("window-".length);
|
|
} else if (rawName.indexOf("document-") === 0) {
|
|
target = document;
|
|
evt = rawName.slice("document-".length);
|
|
}
|
|
|
|
var stmt = attr.value;
|
|
var listener = function (event) {
|
|
var locals =
|
|
decodeLoopLocals(node);
|
|
|
|
locals.event = event;
|
|
locals.$event = event;
|
|
|
|
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;
|
|
|
|
target.addEventListener(evt, listener, options);
|
|
cleanupCallbacks.push(function () {
|
|
target.removeEventListener(evt, listener, options);
|
|
});
|
|
});
|
|
});
|
|
|
|
var behavior = parseBehavior(el);
|
|
if (behavior) {
|
|
installBehaviorFunctions(behavior.functions);
|
|
|
|
(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);
|
|
el.__wrnexusScope = false;
|
|
},
|
|
};
|
|
|
|
behaviorInstances.set(el, instance);
|
|
mountedBehaviorRoots.add(el);
|
|
|
|
if (mountSource) {
|
|
queueMicrotask(function () {
|
|
if (!disposed && el.isConnected) {
|
|
try { runStmt(mountSource); } catch (error) {
|
|
console.error("[wrnexus] component mount hook failed", error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
renderAll();
|
|
}
|
|
|
|
function disposeBehaviors(root) {
|
|
var elements = [];
|
|
if (root && root.nodeType === 1 && root.hasAttribute("data-wrn-behavior")) {
|
|
elements.push(root);
|
|
}
|
|
if (root && root.querySelectorAll) {
|
|
Array.prototype.push.apply(elements, root.querySelectorAll("[data-wrn-behavior]"));
|
|
}
|
|
elements.reverse().forEach(function (element) {
|
|
var instance = behaviorInstances.get(element);
|
|
if (instance) instance.dispose();
|
|
});
|
|
}
|
|
|
|
function ensureBehaviorObserver() {
|
|
if (behaviorObserver || typeof MutationObserver === "undefined") return;
|
|
behaviorObserver = new MutationObserver(function (records) {
|
|
records.forEach(function (record) {
|
|
Array.prototype.forEach.call(record.removedNodes, function (node) {
|
|
if (node && node.nodeType === 1) disposeBehaviors(node);
|
|
});
|
|
});
|
|
});
|
|
behaviorObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
}
|
|
|
|
function hydrateScopes(root) {
|
|
var host = root || document;
|
|
|
|
var scopeSelector =
|
|
"[data-scope], [data-wrn-scope]";
|
|
|
|
if (
|
|
host.nodeType === 1 &&
|
|
host.matches &&
|
|
host.matches(scopeSelector)
|
|
) {
|
|
setupScope(host);
|
|
}
|
|
|
|
if (host.querySelectorAll) {
|
|
host
|
|
.querySelectorAll(scopeSelector)
|
|
.forEach(setupScope);
|
|
}
|
|
|
|
ensureBehaviorObserver();
|
|
}
|
|
|
|
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|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 === "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;
|
|
}
|
|
|
|
var tokens =
|
|
tokenizeExpression(expr);
|
|
|
|
var index = 0;
|
|
function peek() { return tokens[index]; }
|
|
function next() { return tokens[index++]; }
|
|
function is(v) { return peek() && peek().value === v; }
|
|
function match(v) { if (is(v)) { index++; return true; } return false; }
|
|
function expect(v) { if (!match(v)) throw new Error("Expected '" + v + "'"); }
|
|
|
|
function parsePrimary() {
|
|
var t = next();
|
|
if (!t) throw new Error("Unexpected end of expression");
|
|
if (t.type === "number" || t.type === "string") return { value: 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("&&")) l = l && parseEq();
|
|
return l;
|
|
}
|
|
function parseOr() {
|
|
var l = parseAnd();
|
|
while (match("||")) l = l || parseAnd();
|
|
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; }
|
|
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 (/[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;
|
|
}
|
|
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 "";
|
|
}
|
|
}
|
|
|
|
window.__wrnexusHydrateScopes = hydrateScopes;
|
|
window.__wrnexusHydrateCsrFetches = hydrateCsrFetches;
|
|
window.__wrnexusDisposeBehaviors = disposeBehaviors;
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
hydrateScopes(document);
|
|
hydrateCsrFetches(document);
|
|
});
|
|
} else {
|
|
hydrateScopes(document);
|
|
hydrateCsrFetches(document);
|
|
}
|
|
})();
|
|
`.trim();
|