sprae 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.js CHANGED
@@ -1,50 +1,110 @@
1
- // autoinit
2
- const s = document.currentScript
3
- if (s && s.hasAttribute('init')) {
4
- sprae(document.documentElement)
5
- }
1
+ import sube, { observable } from 'sube';
6
2
 
3
+ let curEl, curDir;
7
4
  // sprae element: apply directives
8
- export default function sprae(el, scope) {
9
- scope ||= {}
5
+ export default function sprae(el, initScope) {
6
+ initScope ||= {};
10
7
 
11
- let params, updates=[]
8
+ let updates=[], // all spray directive updators
9
+ ready=false;
12
10
 
13
- // prepare directives
14
- for (let dir in directives) updates[dir] = directives[dir](el, scope)
11
+ const update = (values) => { updates.forEach(update => update(values)); };
15
12
 
16
- const update = (diff=scope) => {
17
- if (diff !== scope) Object.assign(scope, diff);
18
- for (let dir in updates) updates[dir].forEach(update => update(scope))
19
- }
13
+ // hook up observables (deeply, to include item.text etc)
14
+ // that's least evil compared to dlv/dset or proxies
15
+ // returns dynamic values snapshot
16
+ const rsube = (scope) => {
17
+ let values = {}
18
+ for (let k in scope) {
19
+ let v = scope[k];
20
+ if (observable(v = scope[k])) values[k] = null, registry.register(v, sube(v, v => (values[k] = v, ready && update(values))));
21
+ // FIXME: add []
22
+ else if (v?.constructor === Object) values[k] = rsube(v);
23
+ else values[k] = v;
24
+ }
25
+ return values;
26
+ };
27
+ const values = rsube(initScope);
28
+ ready = true;
29
+
30
+ // prepare directives - need to be after subscribing to values to get init state here
31
+ for (let name in directives) {
32
+ // updates[dir] = directives[dir](el)
33
+ const sel = `[${name.replace(':','\\:')}]`,
34
+ initDirective = directives[name]
35
+
36
+ // FIXME: possibly linear init of directives is better, who knows
37
+ const els = [...el.querySelectorAll(sel)];
38
+ if (el.matches?.(sel)) els.unshift(el);
39
+
40
+ let update
41
+ for (let el of els) if (update = initDirective(el, values)) updates.push(update);
42
+ };
43
+
44
+ update(values);
20
45
 
21
46
  // return update via destructuring of result to allow batch-update
22
- scope[Symbol.iterator] = function*(){ yield params; yield update; }
47
+ values[Symbol.iterator] = function*(){ yield proxy; yield (diff) => update(Object.assign(values, diff)); };
48
+
49
+ const proxy = new Proxy(values, {
50
+ set: (s, k, v) => (values[k]=v, update(values), 1),
51
+ deleteProperty: (s, k) => (values[k]=undefined, update(values), 1)
52
+ });
23
53
 
24
- return params = new Proxy(scope, {
25
- set: (s, k, v) => (scope[k]=v, update(), 1),
26
- deleteProperty: (s,k) => (delete scope[k], update(), 1)
27
- })
54
+ return proxy
28
55
  }
29
56
 
30
57
  // dict of directives
31
- const directives = {}, store = new WeakMap
58
+ const directives = {}
32
59
 
33
60
  // register a directive
34
- export const directive = (name, initializer) => (
35
- directives[name] = (container) => {
36
- const els = container.querySelectorAll(`[${name}]`)
37
- const updates = []
38
-
39
- // replace all shortcuts with inner templates
40
- for (el of els) {
41
- // FIXME: make sure no leak is introduced here
42
- if (!store.has(el)) {
43
- store.add(el)
44
- updates.push(initializer(el, el.getAttribute(name)))
45
- }
46
- }
61
+ export const directive = (name, initializer) => {
62
+ const className = name.replace(':','∴')
47
63
 
48
- return updates
64
+ // create initializer of a directive on an element
65
+ return directives[name] = (el, initValues) => {
66
+ if (el.classList.contains(className)) return
67
+ el.classList.add(className)
68
+ let expr = el.getAttribute(name)
69
+ el.removeAttribute(name)
70
+ return initializer(el, expr, initValues);
49
71
  }
50
- )
72
+ }
73
+
74
+ const registry = new FinalizationRegistry(unsub => unsub?.call?.())
75
+
76
+ let evaluatorMemo = {}
77
+
78
+ // borrowed from alpine: https://github.com/alpinejs/alpine/blob/main/packages/alpinejs/src/evaluator.js#L61
79
+ // it seems to be more robust than subscript
80
+ export function parseExpr(expression) {
81
+ if (evaluatorMemo[expression]) return evaluatorMemo[expression]
82
+
83
+ // Some expressions that are useful in Alpine are not valid as the right side of an expression.
84
+ // Here we'll detect if the expression isn't valid for an assignement and wrap it in a self-
85
+ // calling function so that we don't throw an error AND a "return" statement can b e used.
86
+ let rightSideSafeExpression = 0
87
+ // Support expressions starting with "if" statements like: "if (...) doSomething()"
88
+ || /^[\n\s]*if.*\(.*\)/.test(expression)
89
+ // Support expressions starting with "let/const" like: "let foo = 'bar'"
90
+ || /^(let|const)\s/.test(expression)
91
+ ? `(() => { ${expression} })()`
92
+ : expression
93
+
94
+ const safeFunction = () => {
95
+ try {
96
+ return new Function(['scope'], `let result; with (scope) { result = ${rightSideSafeExpression} }; return result;`)
97
+ } catch ( e ) {
98
+ return exprError(e, expression)
99
+ }
100
+ }
101
+
102
+ return evaluatorMemo[expression] = safeFunction()
103
+ }
104
+
105
+ export function exprError(error, expression) {
106
+ Object.assign( error, { expression } )
107
+ console.warn(`∴ ${error.message}\n\n${curDir}=${ expression ? `"${expression}"\n\n` : '' }`, curEl)
108
+ setTimeout(() => { throw error }, 0)
109
+ return Promise.resolve()
110
+ }
@@ -0,0 +1,10 @@
1
+ import { directive, parseExpr } from '../core.js'
2
+ import { prop } from 'element-props'
3
+
4
+ directive(':aria', (el, expr) => {
5
+ let evaluate = parseExpr(expr);
6
+ return (state) => {
7
+ let value = evaluate(state);
8
+ for (let key in value) prop(el, 'aria'+key[0].toUpperCase()+key.slice(1), value[key]);
9
+ }
10
+ })
@@ -0,0 +1,15 @@
1
+ // common directives just set/map value as is
2
+ import { directive, parseExpr } from '../core.js'
3
+ import {prop} from 'element-props'
4
+
5
+ common(`id`), common(`name`), common(`for`), common(`type`), common(`hidden`), common(`disabled`), common(`href`), common(`src`), common(`style`), common(`class`)
6
+
7
+ function common(name) {
8
+ directive(':'+name, (el,expr) => {
9
+ let evaluate = parseExpr(expr)
10
+ return state => {
11
+ let value = evaluate(state);
12
+ prop(el, name, value)
13
+ }
14
+ })
15
+ }
@@ -0,0 +1,10 @@
1
+ import { directive, parseExpr } from '../core.js'
2
+ import { prop } from 'element-props'
3
+
4
+ directive(':data', (el, expr) => {
5
+ let evaluate = parseExpr(expr);
6
+ return (state) => {
7
+ let value = evaluate(state);
8
+ for (let key in value) el.dataset[key] = value[key];
9
+ }
10
+ })
@@ -0,0 +1,53 @@
1
+ import sprae, { directive, exprError, parseExpr } from '../core.js'
2
+
3
+ directive(':each', (el, expr) => {
4
+ let each = parseForExpression(expr);
5
+ if (!each) return exprError(new Error, expr);
6
+
7
+ const getItems = parseExpr(each.items);
8
+
9
+ const holder = new Text
10
+ el.replaceWith(holder)
11
+
12
+ // FIXME: make sure no memory leak here
13
+ // FIXME: there can DOM swapper be used instead
14
+ let els = [];
15
+ return state => {
16
+ els.forEach(el => el.remove()); els = [];
17
+ let items = getItems(state);
18
+ if (typeof items === 'number') items = Array.from({length: items}, (item, i)=>i+1)
19
+ items?.forEach((item,i) => {
20
+ const scope = {...state};
21
+ scope[each.item] = item;
22
+ if (each.index) scope[each.index] = i;
23
+ let itemEl = el.cloneNode(true);
24
+ els.push(itemEl);
25
+ holder.before(itemEl);
26
+ sprae(itemEl, scope);
27
+ });
28
+ }
29
+ })
30
+
31
+ // This was taken AlpineJS, former VueJS 2.* core. Thanks Alpine & Vue!
32
+ function parseForExpression(expression) {
33
+ let forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
34
+ let stripParensRE = /^\s*\(|\)\s*$/g
35
+ let forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
36
+ let inMatch = expression.match(forAliasRE)
37
+
38
+ if (!inMatch) return
39
+
40
+ let res = {}
41
+ res.items = inMatch[2].trim()
42
+ let item = inMatch[1].replace(stripParensRE, '').trim()
43
+ let iteratorMatch = item.match(forIteratorRE)
44
+
45
+ if (iteratorMatch) {
46
+ res.item = item.replace(forIteratorRE, '').trim()
47
+ res.index = iteratorMatch[1].trim()
48
+ } else {
49
+ res.item = item
50
+ }
51
+
52
+ return res
53
+ }
@@ -0,0 +1,31 @@
1
+ import { directive, parseExpr } from '../core.js'
2
+
3
+ directive(':if', (el, expr) => {
4
+ let cur = el, els = [el], clauses = [parseExpr(expr)], holder = new Text
5
+
6
+ // collect clauses
7
+ while (cur = el.nextElementSibling) {
8
+ if (expr = cur.getAttribute(':else-if')) {
9
+ cur.removeAttribute(':else-if');
10
+ cur.classList.add('∴else-if')
11
+ cur.remove();
12
+ els.push(cur); clauses.push(parseExpr(expr));
13
+ continue
14
+ }
15
+ if (cur.hasAttribute(':else')) {
16
+ cur.removeAttribute(':else');
17
+ cur.classList.add('∴else')
18
+ cur.remove();
19
+ els.push(cur); clauses.push(() => 1);
20
+ }
21
+ break;
22
+ }
23
+
24
+ cur = els[0]
25
+
26
+ return state => {
27
+ let idx = clauses.findIndex(match => match(state));
28
+ if (idx >= 0) cur.replaceWith(cur = els[idx]);
29
+ else cur.replaceWith(cur = holder);
30
+ }
31
+ })
@@ -0,0 +1,11 @@
1
+ // order defines precedence
2
+ import './with.js'
3
+ import './each.js'
4
+ import './text.js'
5
+ import './if.js' // if must go last, after other directives are initialized, since it removes :else, :else-if from tree
6
+ import './common.js'
7
+ import './prop.js'
8
+ import './data.js'
9
+ import './aria.js'
10
+ import './value.js'
11
+ import './on.js'
@@ -0,0 +1,12 @@
1
+ import { directive, parseExpr } from '../core.js'
2
+
3
+ directive(':on', (el, expr) => {
4
+ let evaluate = parseExpr(expr);
5
+ let listeners = {}
6
+ return (state) => {
7
+ for (let evt in listeners) el.removeEventListener(evt, listeners[evt]);
8
+ listeners = evaluate(state);
9
+ for (let evt in listeners) el.addEventListener(evt, listeners[evt]);
10
+ }
11
+ })
12
+
@@ -0,0 +1,10 @@
1
+ import { directive, parseExpr } from '../core.js'
2
+ import { prop } from 'element-props'
3
+
4
+ directive(':prop', (el, expr) => {
5
+ let evaluate = parseExpr(expr);
6
+ return (state) => {
7
+ let value = evaluate(state);
8
+ for (let key in value) prop(el, key, value[key]);
9
+ }
10
+ })
@@ -0,0 +1,10 @@
1
+ import { directive, parseExpr } from '../core.js'
2
+
3
+ directive(':text', (el, expr) => {
4
+ let evaluate = parseExpr(expr);
5
+ return (state) => {
6
+ let value = evaluate(state);
7
+ el.textContent = value == null ? '' : value;
8
+ }
9
+ })
10
+
@@ -0,0 +1,26 @@
1
+ import { input, prop } from "element-props";
2
+ import { directive, parseExpr } from '../core.js';
3
+
4
+ // connect expr to element value
5
+ directive(':value', (el, expr) => {
6
+ let evaluateGet = parseExpr(expr);
7
+ let [get, set] = input(el);
8
+ let evaluateSet = parseSetter(expr);
9
+ let curState, onchange = e => evaluateSet(curState, get(el));
10
+ el.addEventListener('input', onchange);
11
+ el.addEventListener('change', onchange);
12
+ return (state) => {
13
+ let value = evaluateGet(curState = state);
14
+ prop(el, 'value', value)
15
+ set(value);
16
+ }
17
+ })
18
+
19
+ const memo = {}
20
+ function parseSetter(expr) {
21
+ if (memo[expr]) return memo[expr]
22
+ return memo[expr] = new Function(
23
+ ['scope', 'value'],
24
+ `with (scope) { ${expr} = value };`
25
+ )
26
+ }
@@ -0,0 +1,21 @@
1
+ import sprae, { directive, parseExpr } from '../core.js'
2
+
3
+ directive(':with', (el, expr, rootValues) => {
4
+ let evaluate = parseExpr(expr);
5
+
6
+ // it subsprays with shadowed values
7
+ // rootValues get updated by parent directives
8
+ // subscope doesn't contain reactive values
9
+ let subscope = Object.create(rootValues)
10
+
11
+ // FIXME: wonder if we better pass initial state rather than values snapshot, to let subtree subscribe to more complete set
12
+ // FIXME: likely initial set can be reactive itself then
13
+ Object.assign(subscope, evaluate(rootValues))
14
+ let [subvalues, subupdate] = sprae(el, subscope)
15
+
16
+ return (values) => {
17
+ let withValues = evaluate(values);
18
+ subupdate(withValues)
19
+ }
20
+ })
21
+
package/src/index.js CHANGED
@@ -1,2 +1,12 @@
1
- export { directives } from './dirs.js';
2
- export { default } from './core.js';
1
+ import sprae from './core.js';
2
+ import './directives/index.js';
3
+
4
+ export default sprae;
5
+ export * from './core.js';
6
+
7
+ // autoinit
8
+ // NOTE: abandoning for now, since requires a separate non-module JS entry, until use-case appears
9
+ // const s = document.currentScript
10
+ // if (s && s.hasAttribute('init')) {
11
+ // sprae(document.documentElement)
12
+ // }
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <meta charset=utf-8>
3
+ <title>Test</title>
4
+
5
+ <script async src="../node_modules/es-module-shims/dist/es-module-shims.js"></script>
6
+ <script type="importmap">
7
+ {
8
+ "imports": {
9
+ "sprae" : "../src/index.js",
10
+ "tst" : "../node_modules/tst/tst.js",
11
+ "wait-please" : "../node_modules/wait-please/index.js",
12
+ "value-ref" : "../node_modules/value-ref/value-ref.js",
13
+ "hyperf" : "../node_modules/hyperf/hyperf.js",
14
+ "sube" : "../node_modules/sube/sube.js",
15
+ "element-props" : "../node_modules/element-props/element-props.js",
16
+ "preact": "../node_modules/preact/dist/preact.mjs",
17
+ "preact/hooks": "../node_modules/preact/hooks/dist/hooks.mjs",
18
+ "@preact/signals": "../node_modules/@preact/signals/dist/signals.mjs",
19
+ "@preact/signals-core": "../node_modules/@preact/signals-core/dist/signals-core.mjs"
20
+ }
21
+ }
22
+ </script>
23
+
24
+ <script src="./test.js" type="module"></script>