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/package.json +12 -8
- package/plan.md +6 -0
- package/r&d.md +63 -3
- package/readme.md +79 -65
- package/sprae.js +317 -0
- package/sprae.min.js +1 -0
- package/src/core.js +95 -35
- package/src/directives/aria.js +10 -0
- package/src/directives/common.js +15 -0
- package/src/directives/data.js +10 -0
- package/src/directives/each.js +53 -0
- package/src/directives/if.js +31 -0
- package/src/directives/index.js +11 -0
- package/src/directives/on.js +12 -0
- package/src/directives/prop.js +10 -0
- package/src/directives/text.js +10 -0
- package/src/directives/value.js +26 -0
- package/src/directives/with.js +21 -0
- package/src/index.js +12 -2
- package/test/index.html +24 -0
- package/test/test.js +303 -0
- package/src/dirs.js +0 -43
- package/src/eval.js +0 -33
- package/test.js +0 -84
package/src/core.js
CHANGED
|
@@ -1,50 +1,110 @@
|
|
|
1
|
-
|
|
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,
|
|
9
|
-
|
|
5
|
+
export default function sprae(el, initScope) {
|
|
6
|
+
initScope ||= {};
|
|
10
7
|
|
|
11
|
-
let
|
|
8
|
+
let updates=[], // all spray directive updators
|
|
9
|
+
ready=false;
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
for (let dir in directives) updates[dir] = directives[dir](el, scope)
|
|
11
|
+
const update = (values) => { updates.forEach(update => update(values)); };
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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 = {}
|
|
58
|
+
const directives = {}
|
|
32
59
|
|
|
33
60
|
// register a directive
|
|
34
|
-
export const directive = (name, initializer) =>
|
|
35
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
2
|
-
|
|
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
|
+
// }
|
package/test/index.html
ADDED
|
@@ -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>
|