sprae 0.0.0 → 0.1.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 +6 -5
- package/readme.md +43 -62
- package/src/core.js +85 -25
- package/src/directives/each.js +56 -0
- package/src/directives/hidden.js +14 -0
- package/src/directives/if.js +34 -0
- package/src/directives/index.js +5 -0
- package/src/directives/text.js +13 -0
- package/src/index.js +2 -2
- package/test/index.html +24 -0
- package/test/test.js +183 -0
- package/src/dirs.js +0 -43
- package/src/eval.js +0 -33
- package/test.js +0 -84
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprae",
|
|
3
3
|
"description": "Reactive directives with expressions for DOM microtemplating.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"main": "sprae.js",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"sube": "^2.2.1",
|
|
8
7
|
"element-props": "^2.0.4",
|
|
9
8
|
"htm": "^3.1.1",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
9
|
+
"preact": "^10.11.2",
|
|
10
|
+
"sube": "^2.2.1",
|
|
11
|
+
"swapdom": "^1.1.1"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@preact/signals": "^1.1.2",
|
|
15
|
+
"es-module-shims": "^1.6.2",
|
|
15
16
|
"hyperf": "^1.4.0",
|
|
16
17
|
"tst": "^7.1.1",
|
|
17
18
|
"value-ref": "^2.1.0",
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
},
|
|
23
24
|
"repository": {
|
|
24
25
|
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/dy/
|
|
26
|
+
"url": "git+https://github.com/dy/sprae.git"
|
|
26
27
|
},
|
|
27
28
|
"keywords": [
|
|
28
29
|
"preact",
|
package/readme.md
CHANGED
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
# ∴ spræ
|
|
2
2
|
|
|
3
|
-
Reactive directives for DOM microtemplating: moisturize tree without trasplanting to JSX
|
|
3
|
+
> Reactive directives for DOM microtemplating: moisturize tree without trasplanting to JSX.
|
|
4
|
+
|
|
4
5
|
A lightweight essential alternative to [alpine](https://github.com/alpinejs/alpine), [petite-vue](https://github.com/vuejs/petite-vue) and [templize](https://github.com/dy/templize) with better ergonomics[*](#justification).
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
## Usage
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
<script src="./sprae.js" defer init></script>
|
|
11
|
-
|
|
12
|
-
<div :scope="{ count: 0 }">
|
|
13
|
-
<span :text="count">
|
|
14
|
-
<button :on="{ click: e => count++ }">inc</button>
|
|
15
|
-
</div>
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
* `:scope` marks regions on the tree that should be controlled by sprae.
|
|
19
|
-
* `init` attribute tells sprae to automatically initialize all elements that have `:scope`.
|
|
20
|
-
* any attribute starting with `:` is considered a sprae directive.
|
|
21
|
-
|
|
22
|
-
## Manual init
|
|
23
|
-
|
|
24
|
-
The more direct case is initializing sprae via JS.
|
|
10
|
+
Sprae enables directives as attributes starting with `:`.
|
|
25
11
|
|
|
26
12
|
```html
|
|
27
13
|
<div id="element" :if="user">
|
|
@@ -44,66 +30,62 @@ The more direct case is initializing sprae via JS.
|
|
|
44
30
|
* `update` can be used for bulk-updating multiple props.
|
|
45
31
|
* `data` is the initial state to render the template. It can include reactive values, see [reactivity](#reactivity).
|
|
46
32
|
|
|
33
|
+
<details>
|
|
34
|
+
<summary><h3>Autoinit</h3></summary>
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* `:scope="data"` – autoinit subtree.
|
|
51
|
-
* `:if="condition"`, `:else-if="condition"`, `:else` - controls flow of elements.
|
|
52
|
-
* `:each="item, idx? in list"` - map list to instances of an element.
|
|
53
|
-
* `:text="value"` - set text content of an element.
|
|
54
|
-
* `:value="value"` – bind value to input or textarea.
|
|
55
|
-
* `:id`, `:name`, `:for`, `:type`, `:hidden`, `:disabled`, `:href`, `:src` – common attributes setters.
|
|
56
|
-
* `:class="[ foo, 'bar' ]"` – set element class from an array, object or a string.
|
|
57
|
-
* `:style="{ top:1, position:'absolute' }"` – set element style from a string or an object.
|
|
58
|
-
* `:prop="{ alt:'foo', title:'bar' }"` – set any attribute / property.
|
|
59
|
-
* `:on="{ click:e=>{}, touch:e=>{} }"` – add event listeners.
|
|
60
|
-
* `:data="{ foo:1, bar:2 }"` – set [data-*](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*) attributes.
|
|
61
|
-
* `:aria="{ role:'progressbar' }"` – set [aria-role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) attributes.
|
|
62
|
-
* `:item="{ id: 1 }"` – set [item*](https://developer.mozilla.org/en-US/docs/Web/HTML/Microdata) microdata attribute.
|
|
63
|
-
|
|
64
|
-
<!--
|
|
65
|
-
### Loops
|
|
66
|
-
|
|
67
|
-
Iterating over set of items can be done with `:each` directive:
|
|
36
|
+
Sprae can be used without build step or JS, autoinitializing HTML:
|
|
68
37
|
|
|
69
38
|
```html
|
|
70
|
-
<
|
|
71
|
-
<li :each="item, index in items" :id="'item-' + item.id" :data="{value:item.value}" :text="item.label"></li>
|
|
72
|
-
</ul>
|
|
39
|
+
<script src="./sprae.js" defer init></script>
|
|
73
40
|
|
|
74
|
-
<
|
|
75
|
-
<
|
|
76
|
-
<
|
|
41
|
+
<div :scope="{ count: 0 }">
|
|
42
|
+
<span :text="count">
|
|
43
|
+
<button :on="{ click: e => count++ }">inc</button>
|
|
44
|
+
</div>
|
|
77
45
|
```
|
|
78
46
|
|
|
79
|
-
|
|
47
|
+
* `:scope` marks regions on the tree that should be controlled by sprae.
|
|
48
|
+
* `init` attribute tells sprae to automatically initialize all elements that have `:scope`.
|
|
80
49
|
|
|
81
|
-
|
|
50
|
+
</details>
|
|
82
51
|
|
|
83
|
-
```html
|
|
84
|
-
<span :if="status == 0">Inactive</span>
|
|
85
|
-
<span :else-if="status == 1">Active</span>
|
|
86
|
-
<span :else>Finished</span>
|
|
87
|
-
```
|
|
88
|
-
-->
|
|
89
52
|
|
|
90
|
-
|
|
53
|
+
## Directives
|
|
54
|
+
|
|
55
|
+
* [x] `:if="condition"`, `:else-if="condition"`, `:else` - controls flow of elements.
|
|
56
|
+
* [x] `:each="item, i? in list"`,`:each="i in number"` - create multiple instances of element by mapping list or number.
|
|
57
|
+
* [x] `:text="value"` - set text content of an element.
|
|
58
|
+
* [ ] `:value="value"` – bind value to input or textarea.
|
|
59
|
+
* [ ]`:id`, `:name`, `:for`, `:type`, `:hidden`, `:disabled`, `:href`, `:src` – common attributes setters.
|
|
60
|
+
* [ ] `:class="[ foo, 'bar' ]"` – set element class from an array, object or a string.
|
|
61
|
+
* [ ] `:style="{ top:1, position:'absolute' }"` – set element style from a string or an object.
|
|
62
|
+
* [ ] `:prop="{ alt:'foo', title:'bar' }"` – set any attribute / property.
|
|
63
|
+
* [ ] `:on="{ click:e=>{}, touch:e=>{} }"` – add event listeners.
|
|
64
|
+
* [ ] `:data="{ foo:1, bar:2 }"` – set [data-*](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*) attributes.
|
|
65
|
+
* [ ] `:aria="{ role:'progressbar' }"` – set [aria-role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) attributes.
|
|
66
|
+
* [ ] `:item="{ id: 1 }"` – set [item*](https://developer.mozilla.org/en-US/docs/Web/HTML/Microdata) microdata attribute.
|
|
67
|
+
* [ ] `:scope="data"` – autoinit subtree.
|
|
68
|
+
|
|
69
|
+
<details>
|
|
70
|
+
<summary><h3>Adding directives</h3></summary>
|
|
91
71
|
|
|
92
72
|
Directives can be added by registering them via `directive(name, initializer)`:
|
|
93
73
|
|
|
94
74
|
```js
|
|
95
|
-
import init, { directive } from 'sprae'
|
|
75
|
+
import init, { directive } from 'sprae';
|
|
96
76
|
|
|
97
|
-
directive(':html', (el
|
|
77
|
+
directive(':html', (el) => {
|
|
98
78
|
// ...initialize here
|
|
99
|
-
const evaluate = parseExpression(
|
|
79
|
+
const evaluate = parseExpression(el.getAttribute(':html'));
|
|
100
80
|
return (state) => {
|
|
101
81
|
// ...update here
|
|
102
|
-
el.innerHTML = evaluate(state)
|
|
82
|
+
el.innerHTML = evaluate(state);
|
|
103
83
|
}
|
|
104
|
-
})
|
|
84
|
+
});
|
|
105
85
|
```
|
|
106
86
|
|
|
87
|
+
</details>
|
|
88
|
+
|
|
107
89
|
|
|
108
90
|
## Reactivity
|
|
109
91
|
|
|
@@ -128,8 +110,8 @@ Update happens when any value changes:
|
|
|
128
110
|
|
|
129
111
|
// <div id="done">...</div>
|
|
130
112
|
|
|
131
|
-
const loading = signal(true),
|
|
132
|
-
|
|
113
|
+
const loading = signal(true), result = signal(false);
|
|
114
|
+
|
|
133
115
|
spores(done, { loading, result })
|
|
134
116
|
|
|
135
117
|
// <div id="done">loading</div>
|
|
@@ -144,11 +126,10 @@ Update happens when any value changes:
|
|
|
144
126
|
Note: observers don't require disposal, since they're connected in weak fashion. Once element is disposed, observables are disconnected.
|
|
145
127
|
|
|
146
128
|
|
|
147
|
-
|
|
148
129
|
## Justification
|
|
149
130
|
|
|
150
|
-
* [Template-parts](https://github.com/dy/template-parts) / [templize](https://github.com/dy/templize) is progressive, but is stuck with native HTML quirks (parsing table
|
|
151
|
-
* [Alpine](https://github.com/alpinejs/alpine) / [vue](https://github.com/vuejs/petite-vue) / [lit](https://github.com/lit/lit/tree/main/packages/lit-html) escapes native HTML quirks, but the syntax is a bit scattered: `:attr`, `v-*`,`x-*`, `@evt`, `{{}}` can be expressed with single convention. Besides, functionality is too broad and can be reduced to essence.
|
|
131
|
+
* [Template-parts](https://github.com/dy/template-parts) / [templize](https://github.com/dy/templize) is progressive, but is stuck with native HTML quirks ([parsing table](https://github.com/github/template-parts/issues/24), [svg attributes](https://github.com/github/template-parts/issues/25), [liquid syntax](https://shopify.github.io/liquid/tags/template/#raw) conflict etc). Also ergonomics of `attr="{{}}"` is inferior to `:attr=""` since it creates flash of uninitialized values.
|
|
132
|
+
* [Alpine](https://github.com/alpinejs/alpine) / [vue](https://github.com/vuejs/petite-vue) / [lit](https://github.com/lit/lit/tree/main/packages/lit-html) escapes native HTML quirks, but the syntax is a bit scattered: `:attr`, `v-*`,`x-*`, `@evt`, `{{}}` can be expressed with single convention. Besides, functionality is too broad and can be reduced to essence. Also they tend to [self-encapsulate](https://github.com/alpinejs/alpine/discussions/3223), making interop hard.
|
|
152
133
|
* [preact](https://ghub.io/preact) with HTML as JSX is a nice way to wire JS to templates, but it doesn't really support reactive fields (needs render call). Also migrating HTML to JS is an extreme with unwanted side-effects.
|
|
153
134
|
|
|
154
135
|
_Sprae_ takes elegant syntax convention of _alpine_ and method of _templize_ to connect any reactive values (like [@preact/signals](https://ghub.io/@preact/signals) or observables) to static HTML.
|
package/src/core.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import sube, { observable } from 'sube';
|
|
2
|
+
|
|
1
3
|
// autoinit
|
|
2
4
|
const s = document.currentScript
|
|
3
5
|
if (s && s.hasAttribute('init')) {
|
|
@@ -5,46 +7,104 @@ if (s && s.hasAttribute('init')) {
|
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
// sprae element: apply directives
|
|
8
|
-
export default function sprae(el,
|
|
9
|
-
|
|
10
|
+
export default function sprae(el, initScope) {
|
|
11
|
+
initScope ||= {};
|
|
10
12
|
|
|
11
|
-
let
|
|
13
|
+
let updates=[], // all spray directive updators
|
|
14
|
+
ready=false;
|
|
12
15
|
|
|
13
16
|
// prepare directives
|
|
14
|
-
for (let dir in directives) updates[dir] = directives[dir](el
|
|
17
|
+
for (let dir in directives) updates[dir] = directives[dir](el);
|
|
15
18
|
|
|
16
|
-
const update = (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const update = (values) => { for (let dir in updates) updates[dir].forEach(update => update(values)); };
|
|
20
|
+
|
|
21
|
+
// hook up observables (deeply, to include item.text etc)
|
|
22
|
+
// that's least evil compared to dlv/dset or proxies
|
|
23
|
+
// returns dynamic values snapshot
|
|
24
|
+
const rsube = (scope) => {
|
|
25
|
+
let values = {}
|
|
26
|
+
for (let k in scope) {
|
|
27
|
+
let v = scope[k];
|
|
28
|
+
if (observable(v = scope[k])) registry.register(v, sube(v, v => (values[k] = v, ready && update(values))));
|
|
29
|
+
// FIXME: add []
|
|
30
|
+
else if (v?.constructor === Object) values[k] = rsube(v);
|
|
31
|
+
else values[k] = v;
|
|
32
|
+
}
|
|
33
|
+
return values;
|
|
34
|
+
};
|
|
35
|
+
const values = rsube(initScope);
|
|
36
|
+
update(values);
|
|
37
|
+
ready = true;
|
|
20
38
|
|
|
21
39
|
// return update via destructuring of result to allow batch-update
|
|
22
|
-
|
|
40
|
+
values[Symbol.iterator] = function*(){ yield proxy; yield (diff) => update(Object.assign(values, diff)); };
|
|
41
|
+
|
|
42
|
+
const proxy = new Proxy(values, {
|
|
43
|
+
set: (s, k, v) => (values[k]=v, update(values), 1),
|
|
44
|
+
deleteProperty: (s, k) => (values[k]=undefined, update(values), 1)
|
|
45
|
+
});
|
|
23
46
|
|
|
24
|
-
return
|
|
25
|
-
set: (s, k, v) => (scope[k]=v, update(), 1),
|
|
26
|
-
deleteProperty: (s,k) => (delete scope[k], update(), 1)
|
|
27
|
-
})
|
|
47
|
+
return proxy
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
// dict of directives
|
|
31
|
-
const directives = {}, store = new
|
|
51
|
+
const directives = {}, store = new WeakSet
|
|
32
52
|
|
|
33
53
|
// register a directive
|
|
34
|
-
export const directive = (name, initializer) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
export const directive = (name, initializer) => {
|
|
55
|
+
const attr = `\\:${name}`, sel = `[${attr}]`
|
|
56
|
+
|
|
57
|
+
return directives[name] = (container) => {
|
|
58
|
+
const els = [...container.querySelectorAll(sel)];
|
|
59
|
+
if (container.matches(sel)) els.unshift(container);
|
|
60
|
+
|
|
61
|
+
const updates = [];
|
|
38
62
|
|
|
39
63
|
// replace all shortcuts with inner templates
|
|
40
|
-
for (el of els) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
store.add(el)
|
|
44
|
-
updates.push(initializer(el, el.getAttribute(name)))
|
|
45
|
-
}
|
|
64
|
+
for (let el of els) {
|
|
65
|
+
if (!el.classList.contains(`∴${name}`))
|
|
66
|
+
el.classList.add(`∴${name}`), updates.push(initializer(el));
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
return updates
|
|
49
70
|
}
|
|
50
|
-
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const registry = new FinalizationRegistry(unsub => unsub?.call?.())
|
|
74
|
+
|
|
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, el) {
|
|
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, el)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return evaluatorMemo[expression] = safeFunction()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function exprError(error, expression, el) {
|
|
106
|
+
Object.assign( error, { el, expression } )
|
|
107
|
+
console.warn(`∴ Expression Error: ${error.message}\n\n${ expression ? 'Expression: \"' + expression + '\"\n\n' : '' }`, el)
|
|
108
|
+
setTimeout(() => { throw error }, 0)
|
|
109
|
+
return Promise.resolve()
|
|
110
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sprae, { directive, exprError, parseExpr } from '../core.js'
|
|
2
|
+
|
|
3
|
+
directive('each', (el) => {
|
|
4
|
+
let expr = el.getAttribute(':each');
|
|
5
|
+
el.removeAttribute(':each');
|
|
6
|
+
|
|
7
|
+
let each = parseForExpression(expr);
|
|
8
|
+
if (!each) return exprError(new Error, expr, el);
|
|
9
|
+
|
|
10
|
+
const getItems = parseExpr(each.items);
|
|
11
|
+
|
|
12
|
+
const holder = new Text
|
|
13
|
+
el.replaceWith(holder)
|
|
14
|
+
|
|
15
|
+
// FIXME: make sure no memory leak here
|
|
16
|
+
// FIXME: there can DOM swapper be used instead
|
|
17
|
+
let els = [];
|
|
18
|
+
return state => {
|
|
19
|
+
els.forEach(el => el.remove()); els = [];
|
|
20
|
+
let items = getItems(state);
|
|
21
|
+
if (typeof items === 'number') items = Array.from({length: items}, (item, i)=>i+1)
|
|
22
|
+
items?.forEach((item,i) => {
|
|
23
|
+
const scope = {...state};
|
|
24
|
+
scope[each.item] = item;
|
|
25
|
+
if (each.index) scope[each.index] = i;
|
|
26
|
+
let itemEl = el.cloneNode(true);
|
|
27
|
+
els.push(itemEl);
|
|
28
|
+
holder.before(itemEl);
|
|
29
|
+
sprae(itemEl, scope);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// This was taken AlpineJS, former VueJS 2.* core. Thanks Alpine & Vue!
|
|
35
|
+
function parseForExpression(expression) {
|
|
36
|
+
let forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
|
|
37
|
+
let stripParensRE = /^\s*\(|\)\s*$/g
|
|
38
|
+
let forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
|
|
39
|
+
let inMatch = expression.match(forAliasRE)
|
|
40
|
+
|
|
41
|
+
if (!inMatch) return
|
|
42
|
+
|
|
43
|
+
let res = {}
|
|
44
|
+
res.items = inMatch[2].trim()
|
|
45
|
+
let item = inMatch[1].replace(stripParensRE, '').trim()
|
|
46
|
+
let iteratorMatch = item.match(forIteratorRE)
|
|
47
|
+
|
|
48
|
+
if (iteratorMatch) {
|
|
49
|
+
res.item = item.replace(forIteratorRE, '').trim()
|
|
50
|
+
res.index = iteratorMatch[1].trim()
|
|
51
|
+
} else {
|
|
52
|
+
res.item = item
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return res
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { directive, parseExpr } from '../core.js'
|
|
2
|
+
|
|
3
|
+
// hidden attribute directive example
|
|
4
|
+
directive('hidden', (el) => {
|
|
5
|
+
let expr = el.getAttribute(':hidden');
|
|
6
|
+
el.removeAttribute(':hidden');
|
|
7
|
+
let evaluate = parseExpr(expr);
|
|
8
|
+
return (state) => {
|
|
9
|
+
let value = evaluate(state);
|
|
10
|
+
if (value || value === '') el.setAttribute('hidden', '')
|
|
11
|
+
else el.removeAttribute('hidden')
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { directive, parseExpr } from '../core.js'
|
|
2
|
+
|
|
3
|
+
directive('if', (el) => {
|
|
4
|
+
let expr = el.getAttribute(':if');
|
|
5
|
+
el.removeAttribute(':if');
|
|
6
|
+
|
|
7
|
+
let cur = el, els = [el], clauses = [parseExpr(expr)], holder = new Text
|
|
8
|
+
|
|
9
|
+
// collect clauses
|
|
10
|
+
while (cur = el.nextElementSibling) {
|
|
11
|
+
if (expr = cur.getAttribute(':else-if')) {
|
|
12
|
+
cur.removeAttribute(':else-if');
|
|
13
|
+
cur.classList.add('∴else-if')
|
|
14
|
+
cur.remove();
|
|
15
|
+
els.push(cur); clauses.push(parseExpr(expr));
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
if (cur.hasAttribute(':else')) {
|
|
19
|
+
cur.removeAttribute(':else');
|
|
20
|
+
cur.classList.add('∴else')
|
|
21
|
+
cur.remove();
|
|
22
|
+
els.push(cur); clauses.push(() => 1);
|
|
23
|
+
}
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cur = els[0]
|
|
28
|
+
|
|
29
|
+
return state => {
|
|
30
|
+
let idx = clauses.findIndex(match => match(state));
|
|
31
|
+
if (idx >= 0) cur.replaceWith(cur = els[idx]);
|
|
32
|
+
else cur.replaceWith(cur = holder);
|
|
33
|
+
}
|
|
34
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { directive, parseExpr } from '../core.js'
|
|
2
|
+
|
|
3
|
+
// hidden attribute directive example
|
|
4
|
+
directive('text', (el) => {
|
|
5
|
+
let expr = el.getAttribute(':text');
|
|
6
|
+
el.removeAttribute(':text');
|
|
7
|
+
let evaluate = parseExpr(expr);
|
|
8
|
+
return (state) => {
|
|
9
|
+
let value = evaluate(state);
|
|
10
|
+
el.textContent = value == null ? '' : value;
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
package/src/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export { default, directive } from './core.js';
|
|
2
|
+
import './directives/index.js';
|
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
|
+
"preact": "../node_modules/preact/dist/preact.mjs",
|
|
16
|
+
"preact/hooks": "../node_modules/preact/hooks/dist/hooks.mjs",
|
|
17
|
+
"@preact/signals": "../node_modules/@preact/signals/dist/signals.mjs",
|
|
18
|
+
"@preact/signals-core": "../node_modules/@preact/signals-core/dist/signals-core.mjs"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
<script src="./test.js" type="module"></script>
|
package/test/test.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import v from 'value-ref'
|
|
2
|
+
import { signal } from '@preact/signals'
|
|
3
|
+
import test, {is, any, throws} from 'tst'
|
|
4
|
+
import {tick, time} from 'wait-please'
|
|
5
|
+
import sprae from 'sprae'
|
|
6
|
+
import h from 'hyperf'
|
|
7
|
+
|
|
8
|
+
test('hidden: core', async () => {
|
|
9
|
+
let el = h`<div :hidden="hidden"></div>`
|
|
10
|
+
let params = sprae(el, {hidden:true})
|
|
11
|
+
is(el.outerHTML, `<div class="∴hidden" hidden=""></div>`)
|
|
12
|
+
params.hidden = false
|
|
13
|
+
is(el.outerHTML, `<div class="∴hidden"></div>`)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('hidden: reactive', async () => {
|
|
17
|
+
const hidden = signal(true)
|
|
18
|
+
let el = h`<div :hidden="hidden"></div>`
|
|
19
|
+
sprae(el, {hidden})
|
|
20
|
+
is(el.outerHTML, `<div class="∴hidden" hidden=""></div>`)
|
|
21
|
+
hidden.value = false
|
|
22
|
+
is(el.outerHTML, `<div class="∴hidden"></div>`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('text: core', async () => {
|
|
26
|
+
let el = h`<div :text="text"></div>`
|
|
27
|
+
let params = sprae(el, {text:'abc'})
|
|
28
|
+
is(el.outerHTML, `<div class="∴text">abc</div>`)
|
|
29
|
+
params.text = null
|
|
30
|
+
is(el.outerHTML, `<div class="∴text"></div>`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('conditions: base', async () => {
|
|
34
|
+
let el = h`<p>
|
|
35
|
+
<span :if="a==1">a</span>
|
|
36
|
+
<span :else-if="a==2">b</span>
|
|
37
|
+
<span :else >c</span>
|
|
38
|
+
</p>`
|
|
39
|
+
|
|
40
|
+
const params = sprae(el, { a: 1 })
|
|
41
|
+
|
|
42
|
+
is(el.innerHTML, '<span class="∴if">a</span>')
|
|
43
|
+
params.a = 2
|
|
44
|
+
is(el.innerHTML, '<span class="∴else-if">b</span>')
|
|
45
|
+
params.a = 3
|
|
46
|
+
is(el.innerHTML, '<span class="∴else">c</span>')
|
|
47
|
+
|
|
48
|
+
delete params.a
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('conditions: short with insertions', async () => {
|
|
52
|
+
let el = h`<p>
|
|
53
|
+
<span :if="a==1" :text="'1:'+a"></span>
|
|
54
|
+
<span :else-if="a==2" :text="'2:'+a"></span>
|
|
55
|
+
<span :else :text="a"></span>
|
|
56
|
+
</p>`
|
|
57
|
+
|
|
58
|
+
const params = sprae(el, { a: 1 })
|
|
59
|
+
|
|
60
|
+
is(el.innerHTML, '<span class="∴text ∴if">1:1</span>')
|
|
61
|
+
params.a = 2
|
|
62
|
+
is(el.innerHTML, '<span class="∴text ∴else-if">2:2</span>')
|
|
63
|
+
params.a = 3
|
|
64
|
+
is(el.innerHTML, '<span class="∴text ∴else">3</span>')
|
|
65
|
+
params.a = 4
|
|
66
|
+
is(el.innerHTML, '<span class="∴text ∴else">4</span>')
|
|
67
|
+
|
|
68
|
+
params.a = 1
|
|
69
|
+
is(el.innerHTML, '<span class="∴text ∴if">1:1</span>')
|
|
70
|
+
params.a = 4
|
|
71
|
+
is(el.innerHTML, '<span class="∴text ∴else">4</span>')
|
|
72
|
+
|
|
73
|
+
delete params.a
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('conditions: reactive values', async () => {
|
|
77
|
+
let el = h`<p>
|
|
78
|
+
<span :if="a==1" :text="'1:'+a"></span>
|
|
79
|
+
<span :else-if="a==2" :text="'2:'+a"></span>
|
|
80
|
+
<span :else :text="a"></span>
|
|
81
|
+
</p>`
|
|
82
|
+
|
|
83
|
+
const a = signal(1)
|
|
84
|
+
sprae(el, { a })
|
|
85
|
+
|
|
86
|
+
is(el.innerHTML, '<span class="∴text ∴if">1:1</span>')
|
|
87
|
+
a.value = 2
|
|
88
|
+
is(el.innerHTML, '<span class="∴text ∴else-if">2:2</span>')
|
|
89
|
+
a.value = 3
|
|
90
|
+
is(el.innerHTML, '<span class="∴text ∴else">3</span>')
|
|
91
|
+
a.value = 4
|
|
92
|
+
is(el.innerHTML, '<span class="∴text ∴else">4</span>')
|
|
93
|
+
|
|
94
|
+
a.value = 1
|
|
95
|
+
is(el.innerHTML, '<span class="∴text ∴if">1:1</span>')
|
|
96
|
+
a.value = 4
|
|
97
|
+
is(el.innerHTML, '<span class="∴text ∴else">4</span>')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
test('each: base', async () => {
|
|
102
|
+
// FIXME: in some conspicuous reason jsdom fails to update text nodes somehow
|
|
103
|
+
let el = h`<p>
|
|
104
|
+
<span :each="a in b" :text="a"></span>
|
|
105
|
+
</p>`
|
|
106
|
+
|
|
107
|
+
const params = sprae(el, { b: [] })
|
|
108
|
+
|
|
109
|
+
is(el.innerHTML, '')
|
|
110
|
+
params.b = [1,2]
|
|
111
|
+
is(el.innerHTML, '<span class="∴each ∴text">1</span><span class="∴each ∴text">2</span>')
|
|
112
|
+
params.b = []
|
|
113
|
+
is(el.innerHTML, '')
|
|
114
|
+
delete params.b
|
|
115
|
+
is(el.innerHTML, '')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('each: reactive values', async () => {
|
|
119
|
+
let el = h`<p>
|
|
120
|
+
<span :each="a in b" :text="a"></span>
|
|
121
|
+
</p>`
|
|
122
|
+
|
|
123
|
+
const b = signal([])
|
|
124
|
+
const params = sprae(el, { b })
|
|
125
|
+
|
|
126
|
+
is(el.innerHTML, '')
|
|
127
|
+
b.value = [1,2]
|
|
128
|
+
is(el.innerHTML, '<span class="∴each ∴text">1</span><span class="∴each ∴text">2</span>')
|
|
129
|
+
b.value = []
|
|
130
|
+
is(el.innerHTML, '')
|
|
131
|
+
delete params.b
|
|
132
|
+
is(el.innerHTML, '')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('each: loop with condition', async () => {
|
|
136
|
+
let el = h`<p>
|
|
137
|
+
<span :each="a in b" :text="a" :if="c"></span>
|
|
138
|
+
</p>`
|
|
139
|
+
|
|
140
|
+
const params = sprae(el, { b: [1,2], c:false })
|
|
141
|
+
|
|
142
|
+
is(el.innerHTML, '')
|
|
143
|
+
params.c = true
|
|
144
|
+
is(el.innerHTML, '<span class="∴each ∴text ∴if">1</span><span class="∴each ∴text ∴if">2</span>')
|
|
145
|
+
params.b = [1]
|
|
146
|
+
is(el.innerHTML, '<span class="∴each ∴text ∴if">1</span>')
|
|
147
|
+
delete params.b
|
|
148
|
+
is(el.innerHTML, '')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('each: loop within condition', async () => {
|
|
152
|
+
let el = h`<p>
|
|
153
|
+
<x :if="a==1"><y :each="i in a" :text="i"></y></x>
|
|
154
|
+
<x :else-if="a==2"><y :each="i in a" :text="-i"></y></x>
|
|
155
|
+
</p>`
|
|
156
|
+
|
|
157
|
+
const params = sprae(el, { a: 1 })
|
|
158
|
+
|
|
159
|
+
is(el.innerHTML, '<x class="∴if"><y class="∴each ∴text">1</y></x>')
|
|
160
|
+
params.a = 2
|
|
161
|
+
is(el.innerHTML, '<x class="∴else-if"><y class="∴each ∴text">-1</y><y class="∴each ∴text">-2</y></x>')
|
|
162
|
+
params.a = 0
|
|
163
|
+
is(el.innerHTML, '')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('each: condition within loop', async () => {
|
|
167
|
+
let el = h`<p>
|
|
168
|
+
<x :each="a in b">
|
|
169
|
+
<y :if="a==1" :text="'1:'+a"></y>
|
|
170
|
+
<y :else-if="a==2" :text="'2:'+a"></y>
|
|
171
|
+
<y :else :text="a"></y>
|
|
172
|
+
</x>
|
|
173
|
+
</p>`
|
|
174
|
+
|
|
175
|
+
const params = sprae(el, { b: [1,2,3] })
|
|
176
|
+
|
|
177
|
+
is(el.innerHTML, '<x class="∴each"><y class="∴text ∴if">1:1</y></x><x class="∴each"><y class="∴text ∴else-if">2:2</y></x><x class="∴each"><y class="∴text ∴else">3</y></x>')
|
|
178
|
+
params.b = [2]
|
|
179
|
+
is(el.innerHTML, '<x class="∴each"><y class="∴text ∴else-if">2:2</y></x>')
|
|
180
|
+
delete params.b
|
|
181
|
+
is(el.innerHTML, '')
|
|
182
|
+
})
|
|
183
|
+
|
package/src/dirs.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { directive } from './core'
|
|
2
|
-
import { parseExpr } from './eval'
|
|
3
|
-
|
|
4
|
-
// hidden attribute directive example
|
|
5
|
-
directive(':hidden', (el, expr) => {
|
|
6
|
-
let evaluate = parseExpr(expr)
|
|
7
|
-
return (state) => {
|
|
8
|
-
let value = evaluate(state)
|
|
9
|
-
prop(el, 'hidden', value)
|
|
10
|
-
}
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// directive(':if', (el, expr, stash) => {
|
|
15
|
-
// stasj
|
|
16
|
-
// let stash = {els: [el], clauses:[parse(expr)], holder:new Text}, current = el
|
|
17
|
-
// ifStash.set(el, stash)
|
|
18
|
-
// return state => {
|
|
19
|
-
// let idx = stash.clauses.findIndex(match => match(state))
|
|
20
|
-
// if (idx >= 0) current.replaceWith(current = els[idx])
|
|
21
|
-
// else current.replaceWith(current = holder)
|
|
22
|
-
// }
|
|
23
|
-
// })
|
|
24
|
-
// directive(':else-if', (el, expr) => {
|
|
25
|
-
// const ifNode = prev('text-with-if')
|
|
26
|
-
// const stash = ifStash.get(ifNode)
|
|
27
|
-
// stash.els.push(el)
|
|
28
|
-
// stash.els.
|
|
29
|
-
// return
|
|
30
|
-
// })
|
|
31
|
-
// directive(':else', (el, expr) => {
|
|
32
|
-
|
|
33
|
-
// })
|
|
34
|
-
|
|
35
|
-
// directive(':each', (instance, part) => {
|
|
36
|
-
// let evalLoop = part.eval
|
|
37
|
-
// part.eval = state => {
|
|
38
|
-
// let [itemId, items] = evalLoop(state), list=[]
|
|
39
|
-
// // FIXME: cache els instead of recreating. Causes difficulties tracking el children
|
|
40
|
-
// for (let item of items) list.push(new TemplateInstance(part.template, {...state,[itemId]:item}, processor))
|
|
41
|
-
// return list
|
|
42
|
-
// }
|
|
43
|
-
// })
|
package/src/eval.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
let evaluatorMemo = {}
|
|
2
|
-
|
|
3
|
-
// borrowed from alpine: https://github.com/alpinejs/alpine/blob/main/packages/alpinejs/src/evaluator.js#L61
|
|
4
|
-
// it seems to be more robust than subscript
|
|
5
|
-
export function parseExpr(expression, el) {
|
|
6
|
-
if (evaluatorMemo[expression]) return evaluatorMemo[expression]
|
|
7
|
-
|
|
8
|
-
let AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
|
|
9
|
-
|
|
10
|
-
// Some expressions that are useful in Alpine are not valid as the right side of an expression.
|
|
11
|
-
// Here we'll detect if the expression isn't valid for an assignement and wrap it in a self-
|
|
12
|
-
// calling function so that we don't throw an error AND a "return" statement can b e used.
|
|
13
|
-
let rightSideSafeExpression = 0
|
|
14
|
-
// Support expressions starting with "if" statements like: "if (...) doSomething()"
|
|
15
|
-
|| /^[\n\s]*if.*\(.*\)/.test(expression)
|
|
16
|
-
// Support expressions starting with "let/const" like: "let foo = 'bar'"
|
|
17
|
-
|| /^(let|const)\s/.test(expression)
|
|
18
|
-
? `(() => { ${expression} })()`
|
|
19
|
-
: expression
|
|
20
|
-
|
|
21
|
-
const safeAsyncFunction = () => {
|
|
22
|
-
try {
|
|
23
|
-
return new AsyncFunction(['scope'], `let result; with (scope) { result = ${rightSideSafeExpression} }; return result;`)
|
|
24
|
-
} catch ( error ) {
|
|
25
|
-
Object.assign( error, { el, expression } )
|
|
26
|
-
console.warn(`∴ Expression Error: ${error.message}\n\n${ expression ? 'Expression: \"' + expression + '\"\n\n' : '' }`, el)
|
|
27
|
-
setTimeout(() => { throw error }, 0)
|
|
28
|
-
return Promise.resolve()
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return evaluatorMemo[expression] = safeAsyncFunction()
|
|
33
|
-
}
|
package/test.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import v from 'value-ref'
|
|
2
|
-
import { signal } from '@preact/signals'
|
|
3
|
-
import test, {is, throws} from 'tst'
|
|
4
|
-
import {tick, time} from 'wait-please'
|
|
5
|
-
import sprae from '../sprae.js'
|
|
6
|
-
import h from 'hyperf'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
test.only('hidden', async () => {
|
|
10
|
-
let el = `<div :hidden="hidden"></div>`
|
|
11
|
-
let params = sprae
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
test('conditions: short', async () => {
|
|
15
|
-
let el = h`<p>
|
|
16
|
-
<span :if="a==1">a</span>
|
|
17
|
-
<span :else-if="a==2">b</span>
|
|
18
|
-
<span :else >c</span>
|
|
19
|
-
</p>`
|
|
20
|
-
|
|
21
|
-
const params = sprae(el, { a: 1 })
|
|
22
|
-
|
|
23
|
-
is(el.innerHTML, '<span>a</span>')
|
|
24
|
-
params.a = 2
|
|
25
|
-
is(el.innerHTML, '<span>b</span>')
|
|
26
|
-
params.a = 3
|
|
27
|
-
is(el.innerHTML, '<span>c</span>')
|
|
28
|
-
|
|
29
|
-
delete params.a
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('conditions: short with insertions', async () => {
|
|
33
|
-
let el = h`<p>
|
|
34
|
-
<span :if="a==1" :text="['1:',a]"></span>
|
|
35
|
-
<span :else-if="a==2" :text="['2:',a]"></span>
|
|
36
|
-
<span :else :text="a"></span>
|
|
37
|
-
</p>`
|
|
38
|
-
|
|
39
|
-
const params = templize(el, { a: 1 }, exprProcessor)
|
|
40
|
-
|
|
41
|
-
is(el.innerHTML, '<span>1:1</span>')
|
|
42
|
-
params.a = 2
|
|
43
|
-
is(el.innerHTML, '<span>2:2</span>')
|
|
44
|
-
params.a = 3
|
|
45
|
-
is(el.innerHTML, '<span>3</span>')
|
|
46
|
-
params.a = 4
|
|
47
|
-
is(el.innerHTML, '<span>4</span>')
|
|
48
|
-
|
|
49
|
-
params.a = 1
|
|
50
|
-
is(el.innerHTML, '<span>1:1</span>')
|
|
51
|
-
params.a = 4
|
|
52
|
-
is(el.innerHTML, '<span>4</span>')
|
|
53
|
-
|
|
54
|
-
delete params.a
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
test.todo('conditions: reactive values', async () => {
|
|
58
|
-
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
test('loops: short', async () => {
|
|
63
|
-
// FIXME: in some conspicuous reason jsdom fails to update text nodes somehow
|
|
64
|
-
let el = h`<p>
|
|
65
|
-
<span :each="item in items" :text="item"></span>
|
|
66
|
-
</p>`
|
|
67
|
-
|
|
68
|
-
const params = sprae(el, { items: [] })
|
|
69
|
-
|
|
70
|
-
is(el.innerHTML, '')
|
|
71
|
-
params.items = [1,2]
|
|
72
|
-
is(el.innerHTML, '<span>1</span><span>2</span>')
|
|
73
|
-
params.items = []
|
|
74
|
-
is(el.innerHTML, '')
|
|
75
|
-
|
|
76
|
-
delete params.items
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test.todo('loops: reactive values', async () => {
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test.todo('loops: condition within loop')
|
|
83
|
-
|
|
84
|
-
test.todo('loops: loop within condition')
|