@mr_hugo/boredom
Version:
The LLM-First JavaScript Framework
1,389 lines (1,218 loc) • 41.6 kB
JavaScript
/**
* boreDOM Lite runtime (v0.28.1)
*/
(() => {
if (
window.__BOREDOM_RUNTIME__ &&
typeof window.__BOREDOM_RUNTIME__.autoInitFromScript === "function"
) {
window.__BOREDOM_RUNTIME__.autoInitFromScript(document.currentScript);
return;
}
const CONSTANTS = {
Attributes: {
COMPONENT: "data-component",
STATE: "data-state",
APP: "data-app",
ROOT: "data-root",
LIST: "data-list",
ITEM_TEMPLATE: "data-item",
LIST_KEY: "data-list-key",
LIST_ONCE: "data-list-once",
LIST_STATIC: "data-list-static",
TEXT: "data-text",
SHOW: "data-show",
VALUE: "data-value",
CHECKED: "data-checked",
CLASS: "data-class",
REF: "data-ref",
DISPATCH: "data-dispatch",
ARG_PREFIX: "data-arg-",
ATTR_PREFIX: "data-attr-",
},
Events: [
"click",
"dblclick",
"input",
"change",
"dragstart",
"dragover",
"drop",
"dragend",
"pointerdown",
"pointermove",
"pointerup",
"pointerout",
"keydown",
"keyup",
"focus",
"blur",
],
};
const DEFAULT_APP_ID = "default";
const COMPONENT_NODE_SELECTOR = [
"style[data-component]",
"script[data-component]",
"template[data-component]",
].join(",");
const appRegistry = new Map();
const pendingScriptModules = new Map();
const fnCache = new Map();
const setterCache = new Map();
const expressionParseCache = new Map();
const invalidValueSetterWarnings = new Set();
const LEGACY_PENDING_SCRIPT_MODULES_KEY = "__BOREDOM_PENDING_SCRIPTS__";
const normalizeAppId = (value) => {
if (typeof value !== "string") return DEFAULT_APP_ID;
const trimmed = value.trim();
return trimmed || DEFAULT_APP_ID;
};
const normalizeOptionalAppId = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed || null;
};
const getLegacyPendingScriptModules = () => {
const store = window[LEGACY_PENDING_SCRIPT_MODULES_KEY];
if (!store || typeof store !== "object") return null;
return store;
};
const isNodeWithinRoot = (node, root) => {
if (!node || !root) return false;
if (root === document || root.nodeType === Node.DOCUMENT_NODE) return true;
if (node === root) return true;
return typeof root.contains === "function" ? root.contains(node) : false;
};
const resolveRootNode = (rootOption) => {
if (!rootOption) return document;
if (
rootOption === document ||
rootOption === document.documentElement ||
rootOption === document.body
) {
return rootOption;
}
if (typeof rootOption === "string") {
const resolved = document.querySelector(rootOption);
if (resolved) return resolved;
console.warn(`[BOREDOM] Root selector not found: ${rootOption}. Falling back to document.`);
return document;
}
if (rootOption && typeof rootOption === "object" && rootOption.nodeType) {
return rootOption;
}
return document;
};
const safeParseJson = (rawText) => {
if (!rawText || !rawText.trim()) return {};
try {
return JSON.parse(rawText);
} catch (err) {
console.error(
"[BOREDOM:ERROR]",
JSON.stringify({
component: "runtime",
message: err.message,
stack: err.stack,
context: { source: "state_parse" },
}),
);
return {};
}
};
const resolveStateElement = (app, stateSelector) => {
if (!stateSelector || typeof stateSelector !== "string") return null;
if (app.root && typeof app.root.querySelector === "function") {
const inRoot = app.root.querySelector(stateSelector);
if (inRoot) return inRoot;
}
return document.querySelector(stateSelector);
};
const flushUpdates = (app) => {
if (!app) return;
app.updatesScheduled = false;
const queue = Array.from(app.pendingUpdates);
app.pendingUpdates.clear();
queue.forEach((component) => {
if (!component || component.isConnected === false) return;
component._update();
});
};
const scheduleComponentUpdate = (component) => {
const app = component && component.__boreApp;
if (!component || !app) return;
app.pendingUpdates.add(component);
if (!app.updatesScheduled) {
app.updatesScheduled = true;
queueMicrotask(() => flushUpdates(app));
}
};
const scheduleGlobalUpdate = (app) => {
if (!app) return;
app.activeComponents.forEach((component) => scheduleComponentUpdate(component));
};
const createReactiveState = (target, callback, cache = new WeakMap()) => {
if (typeof target !== "object" || target === null) return target;
if (cache.has(target)) return cache.get(target);
const proxy = new Proxy(target, {
set(obj, prop, value) {
const oldValue = obj[prop];
if (Object.is(oldValue, value)) return true;
obj[prop] = value;
callback();
return true;
},
get(obj, prop) {
return createReactiveState(obj[prop], callback, cache);
},
deleteProperty(obj, prop) {
const hadKey = Object.prototype.hasOwnProperty.call(obj, prop);
if (!hadKey) return true;
delete obj[prop];
callback();
return true;
},
});
cache.set(target, proxy);
return proxy;
};
const initGlobalState = (app, stateSelector) => {
const stateElement = resolveStateElement(app, stateSelector);
const initialState = stateElement ? safeParseJson(stateElement.textContent || "") : {};
app.globalState = createReactiveState(initialState, () => scheduleGlobalUpdate(app));
app.stateElement = stateElement;
return stateElement;
};
const flattenScope = (scope) => {
const flat = {};
for (const key in scope) {
flat[key] = scope[key];
}
return flat;
};
const evaluate = (expr, scope) => {
try {
const flat = flattenScope(scope);
const keys = Object.keys(flat);
const values = Object.values(flat);
const cacheKey = `${expr}|${keys.join(",")}`;
let fn = fnCache.get(cacheKey);
if (!fn) {
fn = new Function(...keys, `return ${expr}`);
fnCache.set(cacheKey, fn);
}
return fn(...values);
} catch (_e) {
return undefined;
}
};
const isParsableExpression = (expr) => {
if (expr == null) return false;
const source = String(expr).trim();
if (!source) return false;
if (expressionParseCache.has(source)) {
return expressionParseCache.get(source);
}
try {
new Function(`return (${source});`);
expressionParseCache.set(source, true);
return true;
} catch (_err) {
expressionParseCache.set(source, false);
return false;
}
};
const compileSetter = (expr, scope) => {
if (!scope || typeof scope !== "object") return null;
const source = String(expr || "").trim();
if (!source) return null;
const flat = flattenScope(scope);
const keys = Object.keys(flat);
const cacheKey = `${source}|${keys.join(",")}`;
if (setterCache.has(cacheKey)) {
return setterCache.get(cacheKey);
}
try {
const setter = new Function(...keys, "__boreValue", `${source} = __boreValue;`);
setterCache.set(cacheKey, setter);
return setter;
} catch (_err) {
setterCache.set(cacheKey, null);
return null;
}
};
const createComponentContext = (component) => {
const app = component.__boreApp;
return {
state: app ? app.globalState : {},
local: component.localState,
refs: component.refs,
};
};
const withItemContext = (context, item, index, alias = "item", indexAlias = null) => {
const next = Object.create(context);
next.item = item;
next.index = index;
if (alias && alias !== "item" && alias !== "index") {
next[alias] = item;
}
if (indexAlias && indexAlias !== "item" && indexAlias !== "index") {
next[indexAlias] = index;
}
return next;
};
const withEventContext = (context, event, dispatcher, args) => {
const next = Object.create(context);
next.e = { event, dispatcher, args };
return next;
};
const createInitContext = (component) => ({
on: (name, fn) => registerAction(component.eventHandlers, name, fn),
onMount: (fn) => registerHook(component.mountHooks, fn),
onUpdate: (fn) => registerHook(component.updateHooks, fn),
onCleanup: (fn) => registerHook(component.cleanupHooks, fn),
self: component,
...createComponentContext(component),
});
const getDispatchAttribute = (eventName) =>
eventName === "click"
? "dispatch"
: `dispatch${eventName[0].toUpperCase()}${eventName.slice(1)}`;
const shouldUseCapture = (eventName) => ["focus", "blur"].includes(eventName);
const getElementsInRoot = (root) => {
const elements = root.querySelectorAll ? Array.from(root.querySelectorAll("*")) : [];
if (root && root.nodeType === Node.ELEMENT_NODE) {
elements.unshift(root);
}
return elements;
};
const registerHook = (hooks, fn) => {
hooks.push(fn);
return () => {
const index = hooks.indexOf(fn);
if (index >= 0) hooks.splice(index, 1);
};
};
const registerAction = (handlersMap, name, fn) => {
if (!handlersMap.has(name)) {
handlersMap.set(name, []);
}
const handlers = handlersMap.get(name);
handlers.push(fn);
return () => {
const index = handlers.indexOf(fn);
if (index >= 0) handlers.splice(index, 1);
};
};
const runHooks = (component, hooks, context, source) => {
hooks.forEach((hook) => {
try {
hook(context);
} catch (err) {
console.error(
`[BOREDOM:ERROR]`,
JSON.stringify({
component: component.tagName.toLowerCase(),
message: err.message,
stack: err.stack,
context: { source },
}),
);
}
});
};
const isComponentHost = (el) => !!(el && el.__boreHost);
const findHost = (el) => {
let cur = el;
while (cur) {
if (isComponentHost(cur)) return cur;
cur = cur.parentElement;
}
return null;
};
const isElementInComponentScope = (el, component) => {
const host = findHost(el);
return !host || host === component;
};
const hasListContext = (el) =>
!!el &&
Object.prototype.hasOwnProperty.call(el, "__boreListContext") &&
!!el.__boreListContext;
const getListContextChain = (el, component) => {
const chain = [];
let cur = el;
while (cur && cur !== component) {
if (hasListContext(cur)) {
chain.push(cur.__boreListContext);
}
if (isComponentHost(cur) && cur !== component) return null;
cur = cur.parentElement;
}
if (cur && hasListContext(cur)) {
chain.push(cur.__boreListContext);
}
return chain.length ? chain : null;
};
const withListContext = (context, chain) => {
if (!chain || !chain.length) return context;
const next = Object.create(context);
const nearest = chain[0];
next.item = nearest.item;
next.index = nearest.index;
chain.forEach((entry) => {
if (!entry || !entry.alias) return;
if (!Object.prototype.hasOwnProperty.call(next, entry.alias)) {
next[entry.alias] = entry.item;
}
if (entry.indexAlias && !Object.prototype.hasOwnProperty.call(next, entry.indexAlias)) {
next[entry.indexAlias] = entry.index;
}
});
return next;
};
const isElementInListItem = (el, component) => !!getListContextChain(el, component);
const toCamel = (value) =>
value.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase());
const collectArgs = (dispatcher, context) => {
const args = {};
if (!dispatcher || !dispatcher.attributes) return args;
Array.from(dispatcher.attributes).forEach((attr) => {
if (attr.name.startsWith(CONSTANTS.Attributes.ARG_PREFIX)) {
const rawName = attr.name.slice(CONSTANTS.Attributes.ARG_PREFIX.length);
const key = toCamel(rawName);
args[key] = evaluate(attr.value, context);
}
});
return args;
};
const findDispatcher = (component, event, actionType) => {
const path = event.composedPath ? event.composedPath() : [];
if (path.length) {
for (const node of path) {
if (!node || !(node instanceof Element)) continue;
if (node === component) {
if (node.dataset && node.dataset[actionType]) return node;
return null;
}
if (isComponentHost(node) && node !== component) return null;
if (node.dataset && node.dataset[actionType]) return node;
}
return null;
}
let cur = event.target;
while (cur && cur !== component) {
if (isComponentHost(cur) && cur !== component) return null;
if (cur.dataset && cur.dataset[actionType]) return cur;
cur = cur.parentElement;
}
if (cur === component && cur.dataset && cur.dataset[actionType]) return cur;
return null;
};
const runActionHandlers = (component, actionName, dispatcher, event) => {
const handlers = component.eventHandlers.get(actionName);
if (!handlers || !handlers.length) return;
const baseContext = component._createContext();
const listContextChain = getListContextChain(dispatcher, component);
const context = withListContext(baseContext, listContextChain);
const args = collectArgs(dispatcher, context);
const selfContext = Object.create(context);
selfContext.self = component;
const eventContext = withEventContext(
selfContext,
event,
dispatcher,
args,
);
handlers.forEach((handler) => {
try {
handler(eventContext);
} catch (err) {
console.error(
`[BOREDOM:ERROR]`,
JSON.stringify({
component: component.tagName.toLowerCase(),
message: err.message,
stack: err.stack,
context: { action: actionName },
}),
);
}
});
};
const dispatchComponentEvent = (component, event, actionType) => {
const dispatcher = findDispatcher(component, event, actionType);
if (!dispatcher) return;
const actionName = dispatcher.dataset[actionType];
if (!actionName) return;
runActionHandlers(component, actionName, dispatcher, event);
};
const parseDataClassPair = (pair) => {
const source = String(pair || "").trim();
if (!source) return null;
let idx = source.indexOf(":");
while (idx !== -1) {
const cls = source.slice(0, idx).trim();
const expr = source.slice(idx + 1).trim();
if (cls && expr && isParsableExpression(expr)) {
return { cls, expr };
}
idx = source.indexOf(":", idx + 1);
}
return null;
};
const isCheckableInput = (el) => {
if (!el || typeof el.type !== "string") return false;
const type = el.type.toLowerCase();
return type === "checkbox" || type === "radio";
};
const readWritableValue = (el) => {
if (!el) return undefined;
if (isCheckableInput(el) && "checked" in el) return !!el.checked;
if ("value" in el) {
const type = (el.type || "").toLowerCase();
if (type === "number" || type === "range") {
return el.value === "" ? null : Number(el.value);
}
return el.value;
}
return undefined;
};
const getValueBindingEventName = (el) => {
if (!el) return "input";
const tagName = (el.tagName || "").toLowerCase();
if (isCheckableInput(el) || tagName === "select") return "change";
return "input";
};
const warnInvalidValueSetter = (el, expr, err) => {
const host = findHost(el);
const componentName = host
? host.tagName.toLowerCase()
: el && el.tagName
? el.tagName.toLowerCase()
: "unknown";
const key = `${componentName}|${expr}`;
if (invalidValueSetterWarnings.has(key)) return;
invalidValueSetterWarnings.add(key);
console.warn(
`[BOREDOM:WARN]`,
JSON.stringify({
component: componentName,
message: `data-value is not assignable: "${expr}"`,
stack: err && err.stack ? err.stack : undefined,
context: { source: "data_value_setter" },
}),
);
};
const ensureValueWriteback = (el, raw, ctx) => {
if (!el) return;
const eventName = getValueBindingEventName(el);
const existing = el.__boreValueBinding;
if (existing && (existing.expr !== raw || existing.eventName !== eventName)) {
el.removeEventListener(existing.eventName, existing.listener);
el.__boreValueBinding = null;
}
if (!el.__boreValueBinding) {
const listener = () => {
const binding = el.__boreValueBinding;
if (!binding || !binding.scope) return;
const setter = compileSetter(binding.expr, binding.scope);
if (!setter) {
warnInvalidValueSetter(el, binding.expr);
return;
}
try {
const flat = flattenScope(binding.scope);
const values = Object.values(flat);
setter(...values, readWritableValue(el));
} catch (err) {
warnInvalidValueSetter(el, binding.expr, err);
}
};
el.__boreValueBinding = {
expr: raw,
eventName,
listener,
scope: ctx,
};
el.addEventListener(eventName, listener);
} else {
el.__boreValueBinding.scope = ctx;
}
};
const Directives = {
text: (el, raw, ctx) => {
const val = evaluate(raw, ctx);
const nextText = val !== undefined && val !== null ? String(val) : "";
if (el.textContent !== nextText) {
el.textContent = nextText;
}
},
show: (el, raw, ctx) => {
const nextDisplay = evaluate(raw, ctx) ? "" : "none";
if (el.style.display !== nextDisplay) {
el.style.display = nextDisplay;
}
},
value: (el, raw, ctx) => {
const val = evaluate(raw, ctx);
if (isCheckableInput(el) && "checked" in el) {
const nextChecked = !!val;
if (el.checked !== nextChecked) {
el.checked = nextChecked;
}
} else if ("value" in el) {
const nextValue = val !== undefined && val !== null ? String(val) : "";
if (el.value !== nextValue) {
el.value = nextValue;
}
}
ensureValueWriteback(el, raw, ctx);
},
checked: (el, raw, ctx) => {
if (!("checked" in el)) return;
const nextChecked = !!evaluate(raw, ctx);
if (el.checked !== nextChecked) {
el.checked = nextChecked;
}
},
class: (el, raw, ctx) => {
const pairs = raw
.split(";")
.map((part) => part.trim())
.filter(Boolean);
pairs.forEach((pair) => {
const parsed = parseDataClassPair(pair);
if (!parsed) return;
el.classList.toggle(parsed.cls, !!evaluate(parsed.expr, ctx));
});
},
ref: (el, raw, ctx) => {
if (ctx.refs) ctx.refs[raw] = el;
},
};
const applyAttrBindings = (el, ctx) => {
if (!el || !el.attributes) return;
Array.from(el.attributes).forEach((attr) => {
if (!attr.name.startsWith(CONSTANTS.Attributes.ATTR_PREFIX)) return;
const rawName = attr.name.slice(CONSTANTS.Attributes.ATTR_PREFIX.length);
if (!rawName) return;
const val = evaluate(attr.value, ctx);
if (val === false || val === null || val === undefined) {
if (el.hasAttribute(rawName)) {
el.removeAttribute(rawName);
}
} else {
const nextAttr = String(val);
if (el.getAttribute(rawName) !== nextAttr) {
el.setAttribute(rawName, nextAttr);
}
}
});
};
const hasSingleTemplateRoot = (template) => {
const nodes = Array.from(template.content.childNodes);
const elementNodes = nodes.filter((node) => node.nodeType === Node.ELEMENT_NODE);
const nonEmptyText = nodes.filter(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "",
);
return elementNodes.length === 1 && nonEmptyText.length === 0;
};
const parseListBinding = (rawListExpr) => {
const raw = String(rawListExpr || "").trim();
const aliasMatch = raw.match(
/^(?:([A-Za-z_$][\w$]*)|\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\))\s+(?:in|of)\s+([\s\S]+)$/
);
if (aliasMatch) {
return {
alias: aliasMatch[1] || aliasMatch[2],
indexAlias: aliasMatch[3] || null,
itemsExpr: aliasMatch[4].trim(),
};
}
return {
alias: "item",
indexAlias: null,
itemsExpr: raw,
};
};
const markListItemRoots = (fragment, listContext) => {
const roots = Array.from(fragment.childNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
);
roots.forEach((node) => {
node.__boreListContext = listContext;
});
return roots;
};
const updateListItemContext = (node, listContext) => {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
node.__boreListContext = listContext;
};
const renderListNaive = (listEl, template, items, context, alias, indexAlias, component) => {
Array.from(listEl.children).forEach((child) => {
if (child !== template) child.remove();
});
items.forEach((item, index) => {
const fragment = template.content.cloneNode(true);
const itemContext = withItemContext(context, item, index, alias, indexAlias);
const listContext = { alias, indexAlias, item, index };
markListItemRoots(fragment, listContext);
processListBindings(fragment, itemContext, component, { includeNested: true });
processAttributeBindings(
fragment,
itemContext,
component,
{
includeListItems: true,
listContext,
},
);
listEl.appendChild(fragment);
});
};
const renderListKeyed = (listEl, template, items, context, keyExpr, alias, indexAlias, component) => {
const meta =
listEl.__boreList ||
(listEl.__boreList = { rendered: false, keyMap: new Map() });
const keyMap = meta.keyMap || new Map();
const nextKeys = new Set();
const nodesInOrder = [];
items.forEach((item, index) => {
const itemContext = withItemContext(context, item, index, alias, indexAlias);
const key = evaluate(keyExpr, itemContext);
const resolvedKey = key !== undefined && key !== null ? key : index;
nextKeys.add(resolvedKey);
let node = keyMap.get(resolvedKey);
if (!node) {
const fragment = template.content.cloneNode(true);
const listContext = { alias, indexAlias, item, index };
const roots = markListItemRoots(fragment, listContext);
processListBindings(fragment, itemContext, component, { includeNested: true });
processAttributeBindings(
fragment,
itemContext,
component,
{
includeListItems: true,
listContext,
},
);
node = roots[0] || fragment.firstElementChild;
if (!node) return;
listEl.appendChild(fragment);
} else {
const listContext = { alias, indexAlias, item, index };
updateListItemContext(node, listContext);
processListBindings(node, itemContext, component, { includeNested: true });
processAttributeBindings(
node,
itemContext,
component,
{
includeListItems: true,
listContext,
},
);
}
keyMap.set(resolvedKey, node);
nodesInOrder.push(node);
});
keyMap.forEach((node, key) => {
if (!nextKeys.has(key)) {
if (node && node.parentNode === listEl) node.remove();
keyMap.delete(key);
}
});
// Reorder with minimal DOM moves to preserve focus/caret on active inputs.
let nextSibling = template.nextSibling;
nodesInOrder.forEach((node) => {
if (!node || node.parentNode !== listEl) return;
if (node === nextSibling) {
nextSibling = nextSibling ? nextSibling.nextSibling : null;
return;
}
listEl.insertBefore(node, nextSibling);
});
meta.keyMap = keyMap;
};
const processListBindings = (root, context, component, options = {}) => {
if (!root || typeof root.querySelectorAll !== "function") return;
const includeNested = options.includeNested === true;
const lists = root.querySelectorAll(`[${CONSTANTS.Attributes.LIST}]`);
lists.forEach((listEl) => {
if (!isElementInComponentScope(listEl, component)) return;
if (!includeNested && isElementInListItem(listEl, component)) return;
const parsedList = parseListBinding(listEl.getAttribute(CONSTANTS.Attributes.LIST));
const listContextChain = getListContextChain(listEl, component);
const scopedContext = withListContext(context, listContextChain);
const evaluatedItems = evaluate(parsedList.itemsExpr, scopedContext);
const items = Array.isArray(evaluatedItems) ? evaluatedItems : [];
const template = listEl.querySelector(
`template[${CONSTANTS.Attributes.ITEM_TEMPLATE}]`,
);
const listOnce =
listEl.hasAttribute(CONSTANTS.Attributes.LIST_ONCE) ||
listEl.hasAttribute(CONSTANTS.Attributes.LIST_STATIC);
const keyExpr = listEl.getAttribute(CONSTANTS.Attributes.LIST_KEY);
if (!template) return;
const meta =
listEl.__boreList ||
(listEl.__boreList = { rendered: false, keyMap: new Map() });
if (listOnce && meta.rendered) return;
if (keyExpr && hasSingleTemplateRoot(template)) {
renderListKeyed(
listEl,
template,
items,
scopedContext,
keyExpr,
parsedList.alias,
parsedList.indexAlias,
component,
);
} else {
renderListNaive(
listEl,
template,
items,
scopedContext,
parsedList.alias,
parsedList.indexAlias,
component,
);
meta.keyMap = new Map();
}
meta.rendered = true;
});
};
const processAttributeBindings = (root, context, component, options = {}) => {
const includeListItems = options.includeListItems === true;
const listContext = options.listContext || null;
const useStaticCache =
!includeListItems &&
root === component &&
Array.isArray(component._boundElements);
const elements = useStaticCache ? component._boundElements : getElementsInRoot(root);
elements.forEach((el) => {
if (!isElementInComponentScope(el, component)) return;
if (!includeListItems && isElementInListItem(el, component)) return;
if (includeListItems && listContext) {
const chain = getListContextChain(el, component);
if (!chain || chain[0] !== listContext) return;
}
applyAttrBindings(el, context);
Object.keys(el.dataset).forEach((key) => {
const rawValue = el.dataset[key];
if (Directives[key]) {
Directives[key](el, rawValue, context);
}
});
});
};
const processBindings = (root, context, component) => {
processListBindings(root, context, component);
processAttributeBindings(root, context, component);
};
const resolveAppForComponent = (component) => {
if (component.__boreApp && appRegistry.has(component.__boreApp.appId)) {
return component.__boreApp;
}
const componentName = component.tagName.toLowerCase();
let fallback = null;
for (const app of appRegistry.values()) {
if (!app.componentTemplates.has(componentName)) continue;
if (isNodeWithinRoot(component, app.root)) return app;
if (!fallback) fallback = app;
}
if (fallback) return fallback;
for (const app of appRegistry.values()) {
if (isNodeWithinRoot(component, app.root)) return app;
}
return appRegistry.get(DEFAULT_APP_ID) || null;
};
class ReactiveComponent extends HTMLElement {
constructor() {
super();
this.__boreHost = true;
this.localState = createReactiveState({}, () => scheduleComponentUpdate(this));
this.refs = {};
this.eventHandlers = new Map();
this.mountHooks = [];
this.updateHooks = [];
this.cleanupHooks = [];
this._initialized = false;
this._eventDelegationReady = false;
this._hydrated = false;
this._boundElements = null;
}
_update() {
try {
const context = this._createContext();
processBindings(this, context, this);
runHooks(this, this.updateHooks, context, "update_hook");
} catch (err) {
console.error(
`[BOREDOM:ERROR]`,
JSON.stringify({
component: this.tagName.toLowerCase(),
message: err.message,
stack: err.stack,
context: { source: "_update" },
}),
);
}
}
_createContext() {
return createComponentContext(this);
}
_setupEventDelegation() {
if (this._eventDelegationReady) return;
this._eventDelegationReady = true;
CONSTANTS.Events.forEach((event) => {
const actionType = getDispatchAttribute(event);
const useCapture = shouldUseCapture(event);
this.addEventListener(
event,
(e) => dispatchComponentEvent(this, e, actionType),
{ capture: useCapture },
);
});
}
_hydrateTemplate() {
if (this._hydrated) return;
this._hydrated = true;
const app = this.__boreApp;
if (!app) return;
const name = this.tagName.toLowerCase();
const template = app.componentTemplates.get(name);
if (!template) return;
this.innerHTML = "";
this.appendChild(template.content.cloneNode(true));
this._boundElements = getElementsInRoot(this);
}
async connectedCallback() {
const app = resolveAppForComponent(this);
if (!app) {
console.warn(`[BOREDOM] No runtime app found for component <${this.tagName.toLowerCase()}>`);
return;
}
this.__boreApp = app;
app.activeComponents.add(this);
this._hydrateTemplate();
this._setupEventDelegation();
await this._loadScriptLogic();
this._update();
runHooks(this, this.mountHooks, this._createContext(), "mount_hook");
}
disconnectedCallback() {
const app = this.__boreApp;
if (app) {
app.activeComponents.delete(this);
}
const hooks = [...this.cleanupHooks].reverse();
runHooks(this, hooks, this._createContext(), "cleanup_hook");
}
async _loadScriptLogic() {
if (this._initialized) return;
const app = this.__boreApp;
const componentName = this.tagName.toLowerCase();
if (app && app.loadedScripts[componentName]) {
try {
const module = await app.loadedScripts[componentName];
if (module && module.default) {
const initFn = module.default;
initFn(createInitContext(this));
}
} catch (err) {
console.error(
`[BOREDOM:ERROR]`,
JSON.stringify({
component: componentName,
message: err.message,
stack: err.stack,
context: { source: "script_load" },
}),
);
}
}
this._initialized = true;
}
}
const defineReactiveElement = (name) => {
if (!name || customElements.get(name)) return;
const BaseReactiveComponent = window.ReactiveComponent || ReactiveComponent;
customElements.define(name, class extends BaseReactiveComponent { });
};
const normalizePendingComponentEntry = (entry) => {
if (typeof entry === "string") {
return { name: entry, appId: null };
}
if (
entry &&
typeof entry === "object" &&
typeof entry.name === "string"
) {
return {
name: entry.name,
appId: typeof entry.appId === "string" ? entry.appId : null,
};
}
return null;
};
const flushPendingComponentQueue = (app) => {
if (!Array.isArray(window.__pendingComponents)) return;
const remaining = [];
window.__pendingComponents.forEach((entry) => {
const normalized = normalizePendingComponentEntry(entry);
if (!normalized || !normalized.name.trim()) return;
if (normalized.appId && normalizeAppId(normalized.appId) !== app.appId) {
remaining.push(entry);
return;
}
defineReactiveElement(normalized.name);
});
window.__pendingComponents = remaining;
};
const registerScriptModule = (name, modulePromise, options = {}) => {
if (!name || !modulePromise) return;
const appId = normalizeAppId(options.appId);
const app = appRegistry.get(appId);
if (app) {
app.loadedScripts[name] = modulePromise;
if (appId === DEFAULT_APP_ID) {
window.loadedScripts = app.loadedScripts;
}
return;
}
const pending = pendingScriptModules.get(appId) || {};
pending[name] = modulePromise;
pendingScriptModules.set(appId, pending);
if (appId === DEFAULT_APP_ID) {
window.loadedScripts = window.loadedScripts || {};
window.loadedScripts[name] = modulePromise;
}
};
const applyPendingScriptModules = (app) => {
const legacyPending = getLegacyPendingScriptModules();
if (legacyPending && legacyPending[app.appId] && typeof legacyPending[app.appId] === "object") {
Object.assign(app.loadedScripts, legacyPending[app.appId]);
delete legacyPending[app.appId];
}
if (app.appId === DEFAULT_APP_ID && window.loadedScripts) {
Object.assign(app.loadedScripts, window.loadedScripts);
window.loadedScripts = app.loadedScripts;
}
const pending = pendingScriptModules.get(app.appId);
if (pending) {
Object.assign(app.loadedScripts, pending);
pendingScriptModules.delete(app.appId);
}
};
const applyComponentStyle = (app, name, cssText) => {
if (!name || !cssText || !cssText.trim()) return;
document.querySelectorAll("style[data-component]").forEach((node) => {
const nodeAppId = normalizeAppId(node.getAttribute(CONSTANTS.Attributes.APP));
if (node.dataset.component === name && nodeAppId === app.appId) {
node.remove();
}
});
const style = document.createElement("style");
style.setAttribute(CONSTANTS.Attributes.COMPONENT, name);
style.setAttribute(CONSTANTS.Attributes.APP, app.appId);
style.textContent = cssText;
document.head.appendChild(style);
};
const toSourceUrlSegment = (value, fallback) => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
if (!trimmed) return fallback;
const normalized = trimmed.replace(/[^a-zA-Z0-9._-]/g, "_");
return normalized || fallback;
};
const registerScriptText = (app, name, scriptText) => {
const appSegment = toSourceUrlSegment(app?.appId, "default");
const componentSegment = toSourceUrlSegment(name, "component");
const sourceUrl = `boredom://${appSegment}/${componentSegment}.js`;
const blobText = `${scriptText}\n//# sourceURL=${sourceUrl}`;
const blob = new Blob([blobText], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
const modulePromise = import(url).then((m) => {
URL.revokeObjectURL(url);
return m;
});
registerScriptModule(name, modulePromise, { appId: app.appId });
};
const includeUnscopedNodeForApp = (app) => {
if (app.appId === DEFAULT_APP_ID) return true;
if (app.root === document || app.root.nodeType === Node.DOCUMENT_NODE) {
return false;
}
return true;
};
const shouldIncludeNodeForApp = (node, app) => {
const nodeAppId = normalizeOptionalAppId(node.getAttribute(CONSTANTS.Attributes.APP));
if (!nodeAppId) {
if (!includeUnscopedNodeForApp(app)) return false;
return isNodeWithinRoot(node, app.root);
}
return normalizeAppId(nodeAppId) === app.appId;
};
const collectNodesFromRoot = (root) => {
const nodes = [];
if (root && typeof root.querySelectorAll === "function") {
nodes.push(...root.querySelectorAll(COMPONENT_NODE_SELECTOR));
}
if (
root &&
root.nodeType === Node.ELEMENT_NODE &&
typeof root.matches === "function" &&
root.matches(COMPONENT_NODE_SELECTOR)
) {
nodes.unshift(root);
}
return nodes;
};
const collectComponentNodes = (app) => {
const uniqueNodes = new Set();
collectNodesFromRoot(app.root).forEach((node) => {
if (shouldIncludeNodeForApp(node, app)) {
uniqueNodes.add(node);
}
});
if (app.appId !== DEFAULT_APP_ID) {
const scopedSelector = [
`style[data-component][${CONSTANTS.Attributes.APP}="${app.appId}"]`,
`script[data-component][${CONSTANTS.Attributes.APP}="${app.appId}"]`,
`template[data-component][${CONSTANTS.Attributes.APP}="${app.appId}"]`,
].join(",");
document
.querySelectorAll(scopedSelector)
.forEach((node) => uniqueNodes.add(node));
}
return Array.from(uniqueNodes);
};
const registerComponentNode = (app, node) => {
const name = node.dataset.component;
if (!name) return;
switch (node.tagName) {
case "STYLE":
applyComponentStyle(app, name, node.textContent || "");
node.remove();
break;
case "SCRIPT":
registerScriptText(app, name, node.textContent || "");
node.remove();
break;
case "TEMPLATE":
app.componentTemplates.set(name, node);
defineReactiveElement(name);
node.remove();
break;
default:
break;
}
};
const registerComponents = (app, componentNodes) => {
const priority = { STYLE: 0, SCRIPT: 1, TEMPLATE: 2 };
const orderedNodes = [...componentNodes].sort((a, b) => {
const left = priority[a.tagName] ?? 99;
const right = priority[b.tagName] ?? 99;
return left - right;
});
orderedNodes.forEach((node) => registerComponentNode(app, node));
};
const exposeDevTools = (app) => {
const queryInRoot = (selector) => {
if (!selector || typeof selector !== "string") return null;
if (app.root && typeof app.root.querySelector === "function") {
const found = app.root.querySelector(selector);
if (found) return found;
}
return document.querySelector(selector);
};
const api = {
appId: app.appId,
root: app.root,
getState: () => JSON.parse(JSON.stringify(app.globalState)),
inspect: (el) => ({
local: el?.localState,
refs: el?.refs,
state: app.globalState,
}),
query: (selector) => queryInRoot(selector),
reset: () => {
if (!app.stateElement) return;
const newState = safeParseJson(app.stateElement.textContent || "{}");
Object.keys(app.globalState).forEach((key) => delete app.globalState[key]);
Object.assign(app.globalState, newState);
},
};
window.__BOREDOM_APPS__ = window.__BOREDOM_APPS__ || {};
window.__BOREDOM_APPS__[app.appId] = api;
if (app.appId === DEFAULT_APP_ID || !window.__BOREDOM__) {
window.__BOREDOM__ = api;
window.__RESET_APP__ = api.reset;
}
return api;
};
const createApp = (options = {}) => {
const appId = normalizeAppId(options.appId);
if (appRegistry.has(appId)) {
const existing = appRegistry.get(appId);
registerComponents(existing, collectComponentNodes(existing));
flushPendingComponentQueue(existing);
return existing.devtools;
}
const app = {
appId,
root: resolveRootNode(options.root),
stateSelector:
typeof options.stateSelector === "string" && options.stateSelector.trim()
? options.stateSelector
: "#initial-state",
stateElement: null,
globalState: {},
activeComponents: new Set(),
componentTemplates: new Map(),
loadedScripts: {},
pendingUpdates: new Set(),
updatesScheduled: false,
devtools: null,
};
appRegistry.set(appId, app);
initGlobalState(app, app.stateSelector);
applyPendingScriptModules(app);
registerComponents(app, collectComponentNodes(app));
flushPendingComponentQueue(app);
const devtools = exposeDevTools(app);
app.devtools = devtools;
return devtools;
};
const autoInitFromScript = (script) => {
if (!script) return null;
const stateSelector = script.dataset.state || "#initial-state";
const appId = normalizeAppId(script.dataset.app);
const rootSelector = script.dataset.root || null;
return createApp({
appId,
root: rootSelector,
stateSelector,
});
};
const getApp = (appId = DEFAULT_APP_ID) => {
const normalizedAppId = normalizeAppId(appId);
return (window.__BOREDOM_APPS__ && window.__BOREDOM_APPS__[normalizedAppId]) || null;
};
const runtimeApi = {
version: "0.28.1",
createApp,
autoInitFromScript,
getApp,
listApps: () => Array.from(appRegistry.keys()),
defineReactiveElement,
registerScriptModule,
};
window.__BOREDOM_RUNTIME__ = runtimeApi;
window.ReactiveComponent = ReactiveComponent;
autoInitFromScript(document.currentScript);
})();