sprae
Version:
DOM microhydration
164 lines (135 loc) • 5.49 kB
JavaScript
import { use, effect, untracked } from "./signal.js";
import { store } from './store.js';
// polyfill
export const _dispose = (Symbol.dispose ||= Symbol("dispose"));
export const _state = Symbol("state"), _on = Symbol('on'), _off = Symbol('off')
// registered directives
export const directive = {}
/**
* Register a directive with a parsed expression and evaluator.
* @param {string} name - The name of the directive.
* @param {(el: Element, state: Object, expr: string, name: string) => (value: any) => void} create - A function to create the directive.
* @param {(expr: string) => (state: Object) => any} [p=parse] - Create evaluator from expression string.
*/
export const dir = (name, create, p = parse) => directive[name] = (el, expr, state, name, update, evaluate) => (
update = create(el, state, expr, name),
evaluate = p(expr, ':'+name),
() => update(evaluate(state))
)
/**
* Applies directives to an HTML element and manages its reactive state.
*
* @param {Element} [el=document.body] - The target HTML element to apply directives to.
* @param {Object} [values] - Initial values to populate the element's reactive state.
* @returns {Object} The reactive state object associated with the element.
*/
export const sprae = (el=document.body, values) => {
// repeated call can be caused by eg. :each with new objects with old keys
if (el[_state]) return Object.assign(el[_state], values)
// take over existing state instead of creating a clone
let state = store(values || {}), offs = [], fx = []
let init = (el, attrs = el.attributes) => {
// we iterate live collection (subsprae can init args)
if (attrs) for (let i = 0; i < attrs.length;) {
let { name, value } = attrs[i], update, dir
// if we have parts meaning there's attr needs to be spraed
if (name.startsWith(prefix)) {
el.removeAttribute(name);
// multiple attributes like :id:for=""
for (dir of name.slice(prefix.length).split(':')) {
update = (directive[dir] || directive.default)(el, value, state, dir)
// save & start effect
fx.push(update)
// FIXME: since effect can have async start, we can just use el[_on]
offs.push(effect(update))
// stop after :each, :if, :with etc.
if (el[_state] === null) return
}
} else i++
}
// :if and :each replace element with text node, which tweaks .children length, but .childNodes length persists
for (let child of el.childNodes) child.nodeType == 1 && init(child)
};
init(el);
// if element was spraed by inline :with instruction (meaning it has extended state) - skip, otherwise save _state
if (!(_state in el)) {
el[_state] = state
// on/off all effects
el[_off] = () => (offs.map(off => off()), offs = [])
el[_on] = () => offs = fx.map(f => effect(f))
// destroy
el[_dispose] = () => (el[_off](), el[_off] = el[_on] = el[_dispose] = el[_state] = null)
}
return state;
}
// configure signals/compile
// it's more compact than using sprae.signal = signal etc.
sprae.use = s => (
s.signal && use(s),
s.compile && (compile = s.compile),
s.prefix && (prefix = s.prefix)
)
/**
* Parses an expression into an evaluator function, caching the result for reuse.
*
* @param {string} expr - The expression to parse and compile into a function.
* @param {string} dir - The directive associated with the expression (used for error reporting).
* @returns {Function} The compiled evaluator function for the expression.
*/
export const parse = (expr, dir, fn) => {
if (fn = memo[expr = expr.trim()]) return fn
// static time errors
try { fn = compile(expr) }
catch (e) { err(e, dir, expr) }
// run time errors
return memo[expr] = s => {
try { return fn(s) }
catch(e) { err(e, dir, expr) }
}
}
const memo = {};
/**
* Branded sprae error with context about the directive and expression
*
* @param {Error} e - The original error object to enhance.
* @param {string} dir - The directive where the error occurred.
* @param {string} [expr=''] - The expression associated with the error, if any.
* @throws {Error} The enhanced error object with a formatted message.
*/
export const err = (e, dir = '', expr = '') => {
throw Object.assign(e, { message: `∴ ${e.message}\n\n${dir}${expr ? `="${expr}"\n\n` : ""}`, expr })
}
/**
* Compiles an expression into an evaluator function.
*
* @type {(expr: string) => Function}
*/
export let compile
/**
* Attributes prefix, by default ':'
*/
export let prefix = ':'
// instantiated <template> fragment holder, like persisting fragment but with minimal API surface
export const frag = (tpl) => {
if (!tpl.nodeType) return tpl // existing tpl
let content = tpl.content.cloneNode(true), // document fragment holder of content
attributes = [...tpl.attributes],
ref = document.createTextNode(''),
// ensure at least one node
childNodes = (content.append(ref), [...content.childNodes])
return {
// get parentNode() { return childNodes[0].parentNode },
childNodes,
content,
remove: () => content.append(...childNodes),
replaceWith(el) {
if (el === ref) return
ref.before(el)
content.append(...childNodes)
},
attributes,
removeAttribute(name) { attributes.splice(attributes.findIndex(a => a.name === name), 1) },
// setAttributeNode() { }
}
}
export default sprae