Menu
Public repository / 8b66e18ce8cb51ca3e4124f89fd1429a1d4792f3
AkurAI-Mail / src/framework/vendor/signals.js
Latest change c3ef99b092caa55fb6a3eed9d3e776734cc99e97 - OLI-241: framework migration plan; shortcuts slice onto AkurAI-Framework signals runtime by Ólafur Búi Ólafsson
// @ts-nocheck
// VENDORED from AkurAI-Framework@d506ed5 site/frontend/signals.js. Only change: top-level document access guarded (gap G1 in docs/framework-migration.md).
// signals.js — a tiny reactivity runtime for AkurAI/Framework.
//
// Native ES module. No bundler, no transpiler, no dependencies — it runs as-is
// in any modern browser. It enhances server-rendered HTML: the page renders
// fully without it, and this script wires declarative directives to reactive
// state once it loads. Win by subtraction.
//
// Public API (also exposed as `window.Signals`):
// signal(value) -> accessor; accessor() reads (tracked), accessor(next) writes.
// effect(fn) -> runs fn now and re-runs whenever a signal it read changes.
//
// Directives (scanned from the DOM, scoped per [data-scope] root):
// data-text="expr" textContent = expr (reactive)
// data-show="expr" toggle display by truthiness (reactive)
// data-on:EVENT="stmt" run stmt on EVENT ($event available)
// data-model="name" two-way bind an input to signal `name`
// data-for="item in list" render a <template> once per array item
//
// Expressions evaluate against a per-scope state object via `new Function` +
// `with`. That is `eval`-class power: only ever feed it author-trusted markup,
// never user input. See /docs/reactivity for the security note.
// ── reactive core ───────────────────────────────────────────────────────────
let activeEffect = null;
// A reactive cell. Call with no args to read (subscribing the running effect);
// call with one arg to write (notifying subscribers, but only on a real change).
function signal(initial) {
let value = initial;
const subscribers = new Set();
function accessor(...args) {
if (args.length === 0) {
if (activeEffect) {
subscribers.add(activeEffect);
activeEffect.deps.push(subscribers);
}
return value;
}
const next = args[0];
if (!Object.is(next, value)) {
value = next;
// Iterate a copy: an effect may re-subscribe while we notify.
for (const eff of [...subscribers]) eff();
}
return value;
}
return accessor;
}
// Run `fn`, tracking every signal it reads. Re-runs on any of their changes.
// Dependencies are recomputed each run, so stale ones are dropped automatically.
function effect(fn) {
function runner() {
cleanup(runner);
const previous = activeEffect;
activeEffect = runner;
try {
fn();
} catch (err) {
console.warn("[signals] effect error:", err);
} finally {
activeEffect = previous;
}
}
runner.deps = [];
runner();
return runner;
}
function cleanup(runner) {
for (const subs of runner.deps) subs.delete(runner);
runner.deps.length = 0;
}
// ── expression evaluation ────────────────────────────────────────────────────
const exprCache = new Map();
const stmtCache = new Map();
function compile(src, isStatement) {
const cache = isStatement ? stmtCache : exprCache;
let fn = cache.get(src);
if (!fn) {
const body = isStatement
? `with($scope){ ${src} }`
: `with($scope){ return (${src}); }`;
// new Function runs in non-strict mode, so `with` is permitted here.
fn = new Function("$scope", body);
cache.set(src, fn);
}
return fn;
}
// Evaluate `src` against a scope, optionally with an event in flight.
function evaluate(scope, src, isStatement, event) {
scope.setEvent(event ?? null);
try {
return compile(src, isStatement)(scope.proxy);
} catch (err) {
console.warn(`[signals] failed to evaluate \`${src}\`:`, err);
return undefined;
} finally {
scope.setEvent(null);
}
}
// ── scopes ───────────────────────────────────────────────────────────────────
//
// A scope wraps a set of named signals behind a Proxy. Inside `with(proxy)`,
// reading a name returns (and tracks) its signal; assigning a name writes it.
// `has` returns true for every key so `with` captures all identifiers and they
// never leak to the global scope; unknown reads fall back to globals (Math,
// JSON, …) or lazily create a reactive cell.
function createScope(initial) {
const signals = Object.create(null);
let currentEvent = null;
function ensure(key, value) {
if (!signals[key]) signals[key] = signal(value);
return signals[key];
}
for (const key in initial) ensure(key, initial[key]);
const proxy = new Proxy(Object.create(null), {
has() {
return true;
},
get(_target, key) {
if (key === Symbol.unscopables) return undefined;
if (key === "$event" || key === "event") return currentEvent;
if (key in signals) return signals[key]();
if (key in globalThis) return globalThis[key];
return ensure(key, undefined)(); // tolerate forward references
},
set(_target, key, value) {
ensure(key, value)(value);
return true;
},
});
return { proxy, setEvent: (e) => (currentEvent = e), ensure };
}
// A child scope (used by data-for) layers non-reactive locals over a parent.
function createChildScope(parent, locals) {
let currentEvent = null;
const proxy = new Proxy(Object.create(null), {
has() {
return true;
},
get(_target, key) {
if (key === Symbol.unscopables) return undefined;
if (key === "$event" || key === "event") return currentEvent;
if (key in locals) return locals[key];
return parent.proxy[key]; // reactive reads + global fallback via parent
},
set(_target, key, value) {
if (key in locals) {
locals[key] = value;
return true;
}
parent.proxy[key] = value;
return true;
},
});
return { proxy, setEvent: (e) => (currentEvent = e) };
}
// ── directive binding ────────────────────────────────────────────────────────
function bindTree(el, scope) {
// data-for owns its subtree (a <template>); set it up and stop descending.
if (el.hasAttribute && el.hasAttribute("data-for")) {
bindFor(el, scope);
return;
}
bindDirectives(el, scope);
for (const child of Array.from(el.children)) {
// Independent nested widgets are initialised separately by init().
if (child.hasAttribute("data-scope")) continue;
bindTree(child, scope);
}
}
function bindDirectives(el, scope) {
if (el.hasAttribute("data-text")) {
const src = el.getAttribute("data-text");
effect(() => {
el.textContent = toText(evaluate(scope, src, false));
});
}
if (el.hasAttribute("data-show")) {
const src = el.getAttribute("data-show");
const original = el.style.display === "none" ? "" : el.style.display;
effect(() => {
el.style.display = evaluate(scope, src, false) ? original : "none";
});
}
if (el.hasAttribute("data-model")) {
bindModel(el, scope, el.getAttribute("data-model"));
}
// Event handlers: data-on:click, data-on:submit, data-on:input, …
for (const attr of Array.from(el.attributes)) {
if (!attr.name.startsWith("data-on:")) continue;
const eventName = attr.name.slice("data-on:".length);
const stmt = attr.value;
el.addEventListener(eventName, (event) => {
evaluate(scope, stmt, true, event);
});
}
}
function bindModel(el, scope, name) {
scope.ensure(name, el.type === "checkbox" ? el.checked : el.value);
const isCheckbox = el.type === "checkbox";
const eventName = isCheckbox || el.tagName === "SELECT" ? "change" : "input";
// DOM follows the signal.
effect(() => {
const value = scope.proxy[name];
if (isCheckbox) {
el.checked = !!value;
} else if (el.value !== String(value ?? "")) {
el.value = value ?? "";
}
});
// Signal follows the DOM.
el.addEventListener(eventName, () => {
scope.proxy[name] = isCheckbox ? el.checked : el.value;
});
}
function bindFor(tpl, scope) {
const raw = tpl.getAttribute("data-for");
// "item in list" or "(item, index) in list"
const match = raw.match(
/^\s*\(?\s*([\w$]+)\s*(?:,\s*([\w$]+)\s*)?\)?\s+in\s+(.+)$/
);
if (!match) {
console.warn(`[signals] unparseable data-for: \`${raw}\``);
return;
}
const [, itemName, indexName, listSrc] = match;
const anchor = document.createComment("for");
tpl.parentNode.insertBefore(anchor, tpl.nextSibling);
let rendered = [];
effect(() => {
let list = evaluate(scope, listSrc, false);
if (list == null) list = [];
if (!Array.isArray(list)) list = Array.from(list);
for (const node of rendered) node.remove();
rendered = [];
const frag = document.createDocumentFragment();
list.forEach((item, index) => {
const locals = Object.create(null);
locals[itemName] = item;
if (indexName) locals[indexName] = index;
const child = createChildScope(scope, locals);
for (const node of Array.from(cloneTemplate(tpl).childNodes)) {
if (node.nodeType === Node.ELEMENT_NODE) bindTree(node, child);
frag.appendChild(node);
rendered.push(node);
}
});
anchor.parentNode.insertBefore(frag, anchor.nextSibling);
});
}
function cloneTemplate(tpl) {
if (tpl.content) return tpl.content.cloneNode(true); // <template>
const clone = tpl.cloneNode(true); // any element used as a template
clone.removeAttribute("data-for");
const frag = document.createDocumentFragment();
frag.appendChild(clone);
return frag;
}
function toText(value) {
return value == null ? "" : String(value);
}
function parseInitial(root) {
const raw = root.getAttribute("data-state") || root.getAttribute("data-scope");
if (!raw || !raw.trim().startsWith("{")) return {};
try {
return JSON.parse(raw);
} catch (err) {
console.warn("[signals] invalid data-state JSON:", err);
return {};
}
}
// ── auto-init ────────────────────────────────────────────────────────────────
function init() {
const roots = document.querySelectorAll("[data-scope]");
if (!roots.length) return; // no widgets on this page — do nothing
roots.forEach((root) => {
if (root.__signalsBound) return;
root.__signalsBound = true;
bindTree(root, createScope(parseInitial(root)));
});
}
if (typeof document === "undefined") {
// non-DOM host (tests, SSR): skip auto-init
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// One namespaced global; nothing else leaks.
const Signals = { signal, effect, init, version: "1.0.0" };
if (typeof window !== "undefined") window.Signals = Signals;
export { signal, effect, init };
export default Signals;