@potch/munifw 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/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # munifw
2
+
3
+ A signal-driven framework for small applications
4
+
5
+ [current file sizes](./sizes.md)
6
+
7
+ # API
8
+
9
+ ## `event(watchers?) => [emitEvent, onEvent]`
10
+
11
+ Create an event bus. The bus consists of two methods, an `emit` and an `on` method that notify and register a set of `watchers`. Calls to `on` return a `teardown` function used to remove the listener.
12
+
13
+ ### Arguments
14
+
15
+ - `watchers` - optional `Set` containing `callback` functions that will be called by `emitEvent`
16
+
17
+ ### Returns
18
+
19
+ `Array` containing two methods:
20
+
21
+ - `emitEvent(data?)` - calls all `callback` functions with the provided optional `data`.
22
+ - `onEvent(callback) => teardown` - adds `callback` to the `watchers` set. Returns a `teardown` function to remove `callback` from `watchers` set
23
+
24
+ ### Example
25
+
26
+ ```js
27
+ function clock() {
28
+ const start = Date.now();
29
+ const [onUpdate, emitUpdate] = event();
30
+ setInterval(() => {
31
+ emitUpdate(start - Date.now());
32
+ }, 1000);
33
+ return emitUpdate;
34
+ }
35
+
36
+ const onClock = clock();
37
+ const stopClockLogs = onClock((time) => {
38
+ console.log(`time elapsed: ${time}ms`);
39
+ if (time > 30 * 1000) {
40
+ stopClockLogs();
41
+ }
42
+ });
43
+ ```
44
+
45
+ ## `signal(initialValue, equality?) => <signal>`
46
+
47
+ Reactive state primitive built on top of `event`. Setting `.value` updates all `computed` values and runs any `effects` that depend on `<signal>.value`
48
+
49
+ ### Arguments
50
+
51
+ - `initialValue` - initial value of the signal's `value` property.
52
+ - `equality: (a, b) => boolean` - optional function used to compare subsequent values of `<signal>`.value. Signals only update if `equality` returns false (indicating a change). Default equality check is `===`.
53
+
54
+ ### Returns
55
+
56
+ A `<signal>` interface with the following properties:
57
+
58
+ - `.watch(fn) => teardown` - add `fn` as a watcher to be notified when the state of the signal changes. Returns a `teardown` function that removes the watcher when called.
59
+ - `.value`- state of the signal. backed by a pair of getters and setters such that setting `.value` triggers an update and accessing `.value` inside an `effect` automatically registers a watcher.
60
+ - `.touch()` manually trigger all watchers
61
+ - `.peek()` returns current `.value` without registering a watcher within `computed` or `effect`
62
+
63
+ ### Example
64
+
65
+ ```js
66
+ const s = signal(1);
67
+
68
+ s.watch(() => console.log("value is", s.value));
69
+
70
+ s.value = 1; // watcher will not run, value is the same
71
+ s.value = 2; // watcher function will log
72
+
73
+ const person = signal({ name: "bob", age: 29 }, (a, b) => a.name === b.name);
74
+
75
+ person.watch(() => console.log("new person!", person.value.name));
76
+
77
+ person.value = { name: "bob", age: 30 }; // no log
78
+ person.value = { name: "alice", age: 30 }; // log new person!
79
+
80
+ person.value.name = "pat"; // no log bc value wasn't changed
81
+ person.touch(); // triggers log
82
+ ```
83
+
84
+ ## `effect(fn, ...explicitDependencies) => teardown`
85
+
86
+ A reactive effect that runs whenever any of its dependencies change.
87
+
88
+ ### Arguments
89
+
90
+ - `fn` - the body of the effect, using any number of `signal` or `computed` values to perform other work. The callback is called once immediately on the creation of the effect and any time any depnendencies (signal or computed) are changed. References to `<signal>.value` or `<computed>.value` encountered during this initial run of the callback will automatically be detected as dependencies of the effect and will re-run the effect whenever they change. `fn` is called with a special `innerEffect` argument that, if used instead of global `effect`, will ensure that effects created inside of `fn` will be destroyed when the outer effect `teardown` is called.
91
+ - `explicitDependencies` - Dependencies that are not encountered on the initial callback run can be passed positionally as `explicitDependencies` to ensure they are registered.
92
+
93
+ ### Returns
94
+
95
+ A `teardown` function that can be called to "destroy" the effect and stop watching all its dependencies.
96
+
97
+ ## `computed(fn, ...explicitDependencies) => <computed>`
98
+
99
+ A derived reactive value, internally the composition of a `signal` and an `effect`. `.value` updates when any signal/computed referenced in `fn` by `.value` changes. Takes the same arguments as `effect`.
100
+
101
+ ### Returns
102
+
103
+ A `<computed>` interface, which is the same as `<signal>` but with an additional `.teardown()` method which is a reference to the teardown provided by the internal `effect`.
104
+
105
+ ### Example
106
+
107
+ ```js
108
+ const a = signal(1);
109
+ const b = signal(2);
110
+
111
+ const c = computed(() => a.value + b.value);
112
+
113
+ console.log(c.value); // 3
114
+
115
+ a.value = 3;
116
+
117
+ console.log(c.value); // 5
118
+ ```
119
+
120
+ ## `dom(tag, props?, ...children) => HTMLElement`
121
+
122
+ Create HTML elements, hyperscript compatible. Effects created during prop setting are captured and reported to `onEvent`.
123
+
124
+ ### Arguments
125
+
126
+ - `tag` - can be a string tag name or a function that returns an HTMLElement
127
+ - `props` - optional object of properties/attributes. Attached to the element using `assign` and `setProp`. If `props` is not an object it's treated as the first chid.
128
+ - `children` - positional arguments, appended to created element.
129
+
130
+ ### Returns
131
+
132
+ Created `HTMLElement`.
133
+
134
+ ## `on(el, type, handler) => teardown`
135
+
136
+ Create DOM event listeners using `addEventListener`, returns a teardown that calls `removeEventListener`
137
+
138
+ ## `mount(fn, ...explicitDependencies) => HTMLElement?`
139
+
140
+ ## `assign(a, b) => a`
141
+
142
+ Deep-recursing version of `Object.assign`. If `a` is an `HTMLElement`, uses `setProp`.
143
+
144
+ ## `setProp(el, key, value) => void`
145
+
146
+ Set a single prop on an element. Props values can be signals/computeds, which will be bound to the element and automatically updated. If the special prop `ref` is a signal, that signal's value will be set to the element. Functions, objects, and existing props of the element are set directly on the element. Other values are treated as attributes and set using `setAttribute` or `removeAttribute`
147
+
148
+ ## `onEffect(fn) => teardown`
149
+
150
+ A global event bus that is called whenever any framework method creates an effect. Listeners are called with the teardown associated with that effect. Used for ensuring internal effects can be tracked and cleaned up.
151
+
152
+ ## `using(cb, fn)`
153
+
154
+ Runs `fn` and automatically calls `cb` after `fn` returns.
155
+
156
+ ## `collect(onEvent, fn, collection?)`
157
+
158
+ Uses the provided event bus and `using` to "collect" any arguments passed to events emitted when `fn` is run.
159
+
160
+ ### Example
161
+
162
+ ```js
163
+ const [emit, on] = event();
164
+ const teardowns = collect(on, () => {
165
+ for (let i = 1; i <= 4; i++) {
166
+ emit(i);
167
+ }
168
+ });
169
+ console.log(teardowns); // [1, 2, 3, 4]
170
+ ```
package/build.sh ADDED
@@ -0,0 +1,70 @@
1
+ #!/bin/zsh
2
+
3
+ OUTPUT="./dist"
4
+ COLSIZE=7
5
+ TABLETEMP=$(mktemp)
6
+ TERMTEMP=$(mktemp)
7
+
8
+ function len() {
9
+ # strip escape chars
10
+ TEXT_ESC=$(echo $1 | sed $'s,\x1b\\[[0-9;]*[a-zA-Z],,g')
11
+ echo $TEXT_ESC | wc -c | grep -Eo "\d+"
12
+ }
13
+
14
+ function pad() {
15
+ TEXT=$1
16
+ LEN=$2
17
+ RALIGN=$3
18
+ TEXT_LEN=$(len $TEXT)
19
+ REMAIN=$(printf ' %.0s' {0..$(($LEN - $TEXT_LEN))})
20
+ if [[ -n $RALIGN ]] then;
21
+ echo "$REMAIN$TEXT"
22
+ else
23
+ echo "$TEXT$REMAIN"
24
+ fi
25
+ }
26
+
27
+ function max() {
28
+ echo $(( $1 > $2 ? $1 : $2))
29
+ }
30
+
31
+ function leftright() {
32
+ TEXT_LEN=$(len "$2")
33
+ echo "$(pad $1 $(($COLUMNS - $TEXT_LEN)))$2"
34
+ }
35
+
36
+ function sizeof() {
37
+ echo "$(cat $1 | wc -c | grep -Eo "\d+")"
38
+ }
39
+
40
+ function build() {
41
+ MINFILE=`basename $1 | sed 's/\(.*\.\)js/\1min.js/'`
42
+ cat $1 | terser -c "booleans_as_integers=true,passes=2" \
43
+ -m "reserved=['_','$','\$$','on','dom']" \
44
+ --module \
45
+ --ecma 2020 > "$OUTPUT/$MINFILE"
46
+ cat "$OUTPUT/$MINFILE" | gzip > "$OUTPUT/$MINFILE.gz"
47
+ MINSIZE=$(sizeof $OUTPUT/$MINFILE)
48
+ ZIPSIZE=$(sizeof $OUTPUT/$MINFILE.gz)
49
+ leftright "\e[37;1m$1\e[0m" "$(pad $(sizeof $1) $COLSIZE 1)$(pad "\e[33m$MINSIZE\e[0m" $COLSIZE 1)$(pad "\e[33m$ZIPSIZE\e[0m" $COLSIZE 1)" >> "$TERMTEMP"
50
+ echo "$(pad $1 20) | $(pad $(sizeof $1) 9 1) | $(pad $MINSIZE 9 1) | $(pad $ZIPSIZE 6 1)" >> "$TABLETEMP"
51
+ }
52
+
53
+ echo "\nbuilding...\n"
54
+
55
+ leftright "file" "$(pad "orig" $COLSIZE 1)$(pad "min" $COLSIZE 1)$(pad "gz" $COLSIZE 1)"
56
+ echo $(printf '-%.0s' {0..$(($COLUMNS - 1))})
57
+ for file in $(ls src/*.js)
58
+ do
59
+ build "$file" &
60
+ done
61
+ wait
62
+ cat "$TERMTEMP" | sort
63
+
64
+ echo "# file sizes\n" > sizes.md
65
+ echo "$(pad "file" 20) | $(pad "original" 9 1) | $(pad "minified" 9 1) | $(pad "gzip" 6 1)" >> sizes.md
66
+ echo "$(pad ":---" 20) | $(pad "---:" 9 1) | $(pad "---:" 9 1) | $(pad "---:" 6 1) " >> sizes.md
67
+ cat "$TABLETEMP" | sort >> sizes.md
68
+
69
+ echo "\ndone!"
70
+ exit 0
@@ -0,0 +1 @@
1
+ const t="value",e=t=>"object"==typeof t,n=(t,e)=>[...t].map(e);export const using=(t,e)=>(e(),t());export const collect=(t,e,n=[])=>(using(t(t=>n.push(t)),e),n);export const event=(t=new Set)=>[(...e)=>n(t,t=>t(...e)),e=>(t.add(e),()=>t.delete(e))];const o=[];export const signal=(e,n=(t,e)=>t===e)=>{const[c,s]=event();return{set[t](t){n(e,t)||(e=t,c())},touch(){c()},get[t](){return o[0]&&o[0].add(this),e},peek:()=>e,watch:s}};export const[emitEffect,onEffect]=event();export const effect=(t,...e)=>{let c=0;o.push(new Set(e));const s=()=>{c||(c=1,t((...t)=>emitEffect(effect(...t))),c=0)};s();const r=n(o.at(-1),t=>t.watch(s));return o.pop(),()=>n(r,t=>t())};export const computed=(e,...n)=>{const o=signal();return o.teardown=effect(()=>o[t]=e(o[t]),...n),o};export const setProp=(n,o,c)=>{"ref"==o&&t in c?c[t]=n:e(c)&&t in c?emitEffect(effect(()=>setProp(n,o,c[t]))):"function"==typeof c||e(c)||o in n?n[o]&&e(n[o])?assign(n[o],c):n[o]=null===c?"":c:null!==c&&(0!=c||o.startsWith("data-")||o.startsWith("aria-"))?n.setAttribute(o,c):n.removeAttribute(o)};export const assign=(t,n)=>(n&&Object.entries(n).forEach(([n,o])=>{t.nodeType?setProp(t,n,o):e(t[n])&&e(o)?assign(t[n],o):t[n]=o}),t);export const dom=(t,n,...o)=>{let c;return"function"==typeof t?c=mount(()=>t(n,o)):(c=document.createElement(t),n&&e(n)&&!n.nodeType?assign(c,n):n&&o.unshift(n),c.append(...o.flat(1))),c};export const mount=(t,...e)=>{const n=[];let o;return emitEffect(effect(()=>{for(;n.length;)try{n.pop()()}catch(t){}collect(onEffect,()=>{const e=t();o&&e&&o.replaceWith(e),o=e},n)},...e)),o};export const on=(t,...e)=>(t.addEventListener(...e),()=>t.removeEventListener(...e));
Binary file
@@ -0,0 +1 @@
1
+ const e=/^(area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/,t="attributes",r="parentNode",i="childNodes",n=Object,s=(e,t)=>e[i].map(t),o={append(...e){e.map(e=>{e=e?.nodeType?e:a(3,{textContent:""+e}),this[i].push(e),e[r]=this})},replaceWith(e){const t=this[r];t&&(t[i]=s(t,t=>t==this?e:t),e[r]=t)},replaceChildren(...e){s(this,e=>e.remove()),this.append(...e)},setAttribute(e,r){this[t][e]=r},getAttribute(e){return this[t][e]},removeAttribute(e){delete this[t][e]},remove(){const e=this,t=e[r];t&&(t[i]=t[i].filter(t=>t!=e),e[r]=null)},get outerHTML(){const r=this,{tagName:i,nodeType:s}=r;return 1==s?"<"+i+n.entries(r[t]).map(e=>` ${e[0]}="${e[1]}"`).join("")+">"+r.innerHTML+(e.test(i)?"":`</${i}>`):3==s?r.textContent:""},get innerHTML(){return s(this,e=>e.outerHTML).join("")}},a=(e,t)=>n.assign(n.create(o),{nodeType:e},t);export default{createElement:e=>a(1,{tagName:e.toLowerCase(),[t]:{},[i]:[]}),find:(e,r)=>{let n,s=[e];do{if(n=s.pop(),n?.[t]?.id==r)return n;n&&s.push(...n[i])}while(s.length)}};
Binary file
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@potch/munifw",
3
+ "version": "1.0.0",
4
+ "description": "A signal-driven framework for small applications",
5
+ "main": "src/munifw.js",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "start": "npx onchange -ik 'src/*.js' 'test/*.test.js' build.sh -- npm run dev",
9
+ "build": "./build.sh",
10
+ "dev": "vitest run && ./build.sh"
11
+ },
12
+ "author": "Potch",
13
+ "license": "ISC",
14
+ "devDependencies": {
15
+ "onchange": "^7.1.0",
16
+ "terser": "^5.29.1",
17
+ "vitest": "^2.1.6"
18
+ }
19
+ }
package/sizes.md ADDED
@@ -0,0 +1,6 @@
1
+ # file sizes
2
+
3
+ file | original | minified | gzip
4
+ :--- | ---: | ---: | ---:
5
+ src/munifw.js | 5425 | 1662 | 823
6
+ src/ssr.js | 2260 | 1027 | 611
package/src/munifw.js ADDED
@@ -0,0 +1,205 @@
1
+ // @potch/minifw/fw.js without so much code golfing
2
+
3
+ // prop to monitor for changes
4
+ const val = "value";
5
+ const isObj = (o) => typeof o === "object";
6
+ const map = (a, fn) => [...a].map(fn);
7
+ // utility to run a function and ensure cleanup after
8
+ export const using = (cb, fn) => (fn(), cb());
9
+ // collect event teardowns within the scope of a function
10
+ export const collect = (onEvent, fn, collection = []) => (
11
+ using(
12
+ onEvent((o) => collection.push(o)),
13
+ fn
14
+ ),
15
+ collection
16
+ );
17
+
18
+ // event primitive, returns [emit, watch] fns
19
+ export const event = (watchers = new Set()) => [
20
+ (...args) => map(watchers, (fn) => fn(...args)),
21
+ (fn) => {
22
+ watchers.add(fn);
23
+ return () => watchers.delete(fn);
24
+ },
25
+ ];
26
+
27
+ // used for computed/effect bookkeeping
28
+ const context = [];
29
+
30
+ // reactive value primitive, notifies when .value changes
31
+ export const signal = (value, eq = (a, b) => a === b) => {
32
+ // create event bus
33
+ const [emit, watch] = event();
34
+ return {
35
+ set [val](v) {
36
+ if (!eq(value, v)) {
37
+ value = v;
38
+ emit();
39
+ }
40
+ },
41
+ // manually trigger a value update
42
+ touch() {
43
+ emit();
44
+ },
45
+ get [val]() {
46
+ // if we're in an effect callback, register as a dep
47
+ if (context[0]) {
48
+ context[0].add(this);
49
+ }
50
+ return value;
51
+ },
52
+ peek: () => value,
53
+ watch,
54
+ };
55
+ };
56
+
57
+ // global event bus for effects
58
+ export const [emitEffect, onEffect] = event();
59
+
60
+ // pure side effect
61
+ // runs when any signal referenced in `fn` by `.value` changes
62
+ // use signal.peek in effects to avoid dependency tracking
63
+ export const effect = (fn, ...explicitDependencies) => {
64
+ let inUpdate = false;
65
+
66
+ context.push(new Set(explicitDependencies));
67
+
68
+ const update = () => {
69
+ // prevents effects effecting themselves
70
+ if (!inUpdate) {
71
+ inUpdate = true;
72
+ fn((...args) => emitEffect(effect(...args)));
73
+ inUpdate = false;
74
+ }
75
+ };
76
+
77
+ update();
78
+
79
+ const teardown = map(context.at(-1), (dependency) =>
80
+ dependency.watch(update)
81
+ );
82
+
83
+ context.pop();
84
+
85
+ return () => map(teardown, (fn) => fn());
86
+ };
87
+
88
+ // derived reactive value, composition of a signal and an effect
89
+ // auto updates when any signal referenced in `fn` by `.value` changes
90
+ export const computed = (fn, ...explicitDependencies) => {
91
+ const out = signal();
92
+ out.teardown = effect(
93
+ () => (out[val] = fn(out[val])),
94
+ ...explicitDependencies
95
+ );
96
+ return out;
97
+ };
98
+
99
+ // credit to @developit/preact for logic here
100
+ export const setProp = (el, key, value) => {
101
+ if (key == "ref" && val in value) {
102
+ // if key is "ref" and value is signal-like, treat value as signal and set el as value
103
+ value[val] = el;
104
+ } else if (isObj(value) && val in value) {
105
+ // if value is signal-like, mount an effect to update prop
106
+ emitEffect(effect(() => setProp(el, key, value[val])));
107
+ } else if (typeof value === "function" || isObj(value) || key in el) {
108
+ // is value a function, object, or is the key an extant prop?
109
+ // situations where we always set prop
110
+ // if el[key] is object, deep merge it, else set it.
111
+ if (el[key] && isObj(el[key])) {
112
+ assign(el[key], value);
113
+ } else {
114
+ el[key] = value === null ? "" : value;
115
+ }
116
+ } else {
117
+ // treat as attribute
118
+ // set if value not null and either not false or prop is `data-` or `aria-`
119
+ if (
120
+ value !== null &&
121
+ (value !== false || key.startsWith("data-") || key.startsWith("aria-"))
122
+ ) {
123
+ el.setAttribute(key, value);
124
+ } else {
125
+ el.removeAttribute(key);
126
+ }
127
+ }
128
+ };
129
+
130
+ // deep merge assignment
131
+ export const assign = (a, b) => {
132
+ if (b) {
133
+ Object.entries(b).forEach(([key, value]) => {
134
+ if (a.nodeType) {
135
+ // check if a is an HTMLElement (duck type for SSR)
136
+ setProp(a, key, value);
137
+ } else if (isObj(a[key]) && isObj(value)) {
138
+ // if both current and new value are objects, recursively deep merge
139
+ assign(a[key], value);
140
+ } else {
141
+ a[key] = value;
142
+ }
143
+ });
144
+ }
145
+ return a;
146
+ };
147
+
148
+ // create DOM, hyperscript compatible
149
+ export const dom = (tag, props, ...children) => {
150
+ let el;
151
+ if (typeof tag === "function") {
152
+ el = mount(() => tag(props, children));
153
+ } else {
154
+ el = document.createElement(tag);
155
+ // allow optional props syntax
156
+ if (props && isObj(props) && !props.nodeType) {
157
+ assign(el, props);
158
+ } else {
159
+ if (props) {
160
+ children.unshift(props);
161
+ }
162
+ }
163
+ el.append(...children.flat(1));
164
+ }
165
+ return el;
166
+ };
167
+
168
+ // a "computed" value that can mount and update in DOM
169
+ export const mount = (fn, ...explicitDependencies) => {
170
+ // track effects for disconnect
171
+ const teardowns = [];
172
+ let currentEl;
173
+ emitEffect(
174
+ effect(() => {
175
+ // disconnect existing effects
176
+ while (teardowns.length) {
177
+ try {
178
+ teardowns.pop()();
179
+ } catch (e) {}
180
+ }
181
+ // track any effects created when generating mounted DOM
182
+ collect(
183
+ onEffect,
184
+ () => {
185
+ const newEl = fn();
186
+ if (currentEl) {
187
+ // swap or remove new element
188
+ if (newEl) {
189
+ currentEl.replaceWith(newEl);
190
+ }
191
+ }
192
+ currentEl = newEl;
193
+ },
194
+ teardowns
195
+ );
196
+ }, ...explicitDependencies)
197
+ );
198
+ return currentEl;
199
+ };
200
+
201
+ // dom event listeners, returns a callback to un-listen
202
+ export const on = (target, ...args) => {
203
+ target.addEventListener(...args);
204
+ return () => target.removeEventListener(...args);
205
+ };
package/src/ssr.js ADDED
@@ -0,0 +1,99 @@
1
+ // fake enough DOM for the `dom()` function to work
2
+ // not even a little bit standards compliant
3
+
4
+ const voids =
5
+ /^(area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/;
6
+
7
+ const attributes = "attributes";
8
+ const pn = "parentNode";
9
+ const cn = "childNodes";
10
+ const obj = Object;
11
+ const mapCn = (el, fn) => el[cn].map(fn);
12
+
13
+ const nodeMock = {
14
+ append(...nodes) {
15
+ nodes.map((n) => {
16
+ n = n?.nodeType ? n : _node(3, { textContent: "" + n });
17
+ this[cn].push(n);
18
+ n[pn] = this;
19
+ });
20
+ },
21
+ replaceWith(node) {
22
+ const parentNode = this[pn];
23
+ if (parentNode) {
24
+ parentNode[cn] = mapCn(parentNode, (n) => (n == this ? node : n));
25
+ node[pn] = parentNode;
26
+ }
27
+ },
28
+ replaceChildren(...nodes) {
29
+ mapCn(this, (n) => n.remove());
30
+ this.append(...nodes);
31
+ },
32
+ setAttribute(a, v) {
33
+ this[attributes][a] = v;
34
+ },
35
+ getAttribute(a) {
36
+ return this[attributes][a];
37
+ },
38
+ removeAttribute(a) {
39
+ delete this[attributes][a];
40
+ },
41
+ remove() {
42
+ const self = this;
43
+ const parentNode = self[pn];
44
+ if (parentNode) {
45
+ parentNode[cn] = parentNode[cn].filter((n) => n != self);
46
+ self[pn] = null;
47
+ }
48
+ },
49
+ get outerHTML() {
50
+ const self = this;
51
+ const { tagName, nodeType } = self;
52
+ if (nodeType == 1) {
53
+ return (
54
+ "<" +
55
+ tagName +
56
+ obj
57
+ .entries(self[attributes])
58
+ .map((a) => ` ${a[0]}="${a[1]}"`)
59
+ .join("") +
60
+ ">" +
61
+ self.innerHTML +
62
+ (voids.test(tagName) ? "" : `</${tagName}>`)
63
+ );
64
+ }
65
+ if (nodeType == 3) {
66
+ return self.textContent;
67
+ }
68
+ return "";
69
+ },
70
+ get innerHTML() {
71
+ return mapCn(this, (n) => n.outerHTML).join("");
72
+ },
73
+ };
74
+
75
+ const _node = (nodeType, props) =>
76
+ obj.assign(obj.create(nodeMock), { nodeType }, props);
77
+
78
+ const createElement = (tagName) =>
79
+ _node(1, {
80
+ tagName: tagName.toLowerCase(),
81
+ [attributes]: {},
82
+ [cn]: [],
83
+ });
84
+
85
+ export default {
86
+ createElement,
87
+ // used by ssr instead of getElementById
88
+ find: (el, id) => {
89
+ let current,
90
+ stack = [el];
91
+ do {
92
+ current = stack.pop();
93
+ if (current?.[attributes]?.id == id) return current;
94
+ if (current) {
95
+ stack.push(...current[cn]);
96
+ }
97
+ } while (stack.length);
98
+ },
99
+ };
@@ -0,0 +1,292 @@
1
+ import { describe, expect } from "vitest";
2
+ import {
3
+ assign,
4
+ event,
5
+ signal,
6
+ computed,
7
+ effect,
8
+ dom,
9
+ onEffect,
10
+ using,
11
+ collect,
12
+ } from "../src/munifw.js";
13
+
14
+ describe("fw", () => {
15
+ describe("assign", () => {
16
+ it("shallow", () => {
17
+ const o = assign({ a: 1 }, { b: 2 });
18
+ expect(o).toStrictEqual({ a: 1, b: 2 });
19
+ expect(assign({ a: 1 }, null)).toStrictEqual({ a: 1 });
20
+ });
21
+ it("deep", () => {
22
+ const o = assign({ a: { deep: 1 } }, { b: 2, a: { deeper: 2 } });
23
+ expect(o).toStrictEqual({ a: { deep: 1, deeper: 2 }, b: 2 });
24
+ });
25
+ });
26
+
27
+ describe("event", () => {
28
+ let watch, emit;
29
+
30
+ beforeEach(() => {
31
+ [emit, watch] = event();
32
+ });
33
+
34
+ it("emits", () => {
35
+ const fn = vi.fn();
36
+ watch(fn);
37
+ emit(1);
38
+ expect(fn).toHaveBeenCalledWith(1);
39
+ });
40
+
41
+ it("maps emit", () => {
42
+ const fn = vi.fn();
43
+ watch((a) => a + 1);
44
+ watch((a) => a + 2);
45
+ const result = emit(1);
46
+ expect(result).toStrictEqual([2, 3]);
47
+ });
48
+
49
+ it("unwatches", () => {
50
+ const fn = vi.fn();
51
+ const unwatch = watch(fn);
52
+ emit(1);
53
+ expect(fn).toHaveBeenCalled();
54
+ unwatch();
55
+ expect(fn).toHaveBeenCalledTimes(1);
56
+ });
57
+ });
58
+
59
+ describe("signal", () => {
60
+ let s;
61
+
62
+ beforeEach(() => {
63
+ s = signal(1);
64
+ });
65
+
66
+ it("can get value", () => {
67
+ expect(s.value).toBe(1);
68
+ });
69
+ it("can update value", () => {
70
+ expect(s.value).toBe(1);
71
+ s.value = 2;
72
+ expect(s.value).toBe(2);
73
+ });
74
+ it("watches", () => {
75
+ const fn = vi.fn();
76
+ s.watch(fn);
77
+ s.value = 2;
78
+ expect(fn).toHaveBeenCalled();
79
+ });
80
+ it("peeks", () => {
81
+ expect(s.peek()).toBe(1);
82
+ });
83
+ it("un-watches", () => {
84
+ const fn = vi.fn();
85
+ const unwatch = s.watch(fn);
86
+ unwatch();
87
+ s.value = 2;
88
+ expect(fn).not.toHaveBeenCalled();
89
+ });
90
+ it("compares", () => {
91
+ const fn = vi.fn();
92
+ const s = signal({ a: 1 }, (a, b) => a.a === b.a);
93
+ s.watch(fn);
94
+ s.value = { a: 2 };
95
+ expect(fn).toHaveBeenCalled();
96
+ s.value = { a: 2, b: 1 };
97
+ expect(fn).toHaveBeenCalledTimes(1);
98
+ });
99
+ });
100
+
101
+ describe("effect", () => {
102
+ let s1, s2;
103
+ beforeEach(() => {
104
+ s1 = signal(1);
105
+ s2 = signal(2);
106
+ });
107
+ it("runs once", () => {
108
+ const fn = vi.fn();
109
+ effect(() => fn(s1.value + s2.value));
110
+ expect(fn).toHaveBeenCalledWith(3);
111
+ });
112
+ it("runs on change", () => {
113
+ const fn = vi.fn();
114
+ effect(() => fn(s1.value + s2.value));
115
+ expect(fn).toHaveBeenCalledWith(3);
116
+ s1.value = 3;
117
+ expect(fn).toHaveBeenCalledWith(5);
118
+ });
119
+ it("peeks", () => {
120
+ const fn = vi.fn();
121
+ effect(() => fn(s1.peek() + s2.value));
122
+ expect(fn).toHaveBeenCalledWith(3);
123
+ s1.value = 3;
124
+ expect(fn).toHaveBeenCalledTimes(1);
125
+ s2.value = 3;
126
+ expect(fn).toHaveBeenCalledTimes(2);
127
+ });
128
+ it("un-watches", () => {
129
+ const fn = vi.fn();
130
+ const unwatch = effect(() => fn(s1.value + s2.value));
131
+ expect(fn).toHaveBeenCalledWith(3);
132
+ s1.value = 3;
133
+ expect(fn).toHaveBeenCalledWith(5);
134
+ unwatch();
135
+ s1.value = 6;
136
+ expect(fn).toHaveBeenCalledTimes(2);
137
+ });
138
+ it("cycle protects", () => {
139
+ const fn = vi.fn();
140
+ effect(() => {
141
+ s1.value++;
142
+ fn(s1.value + s2.value);
143
+ });
144
+ s2.value++;
145
+ expect(fn).toHaveBeenCalledTimes(2);
146
+ });
147
+ it("independent nested effects", () => {
148
+ const s1 = signal(1);
149
+ const s2 = signal(2);
150
+ const fn1 = vi.fn();
151
+ const fn2 = vi.fn();
152
+
153
+ effect(() => {
154
+ fn1();
155
+ effect(() => {
156
+ fn2();
157
+ }, s2);
158
+ }, s1);
159
+
160
+ s2.value = 3;
161
+ expect(fn1).toHaveBeenCalledTimes(1);
162
+ expect(fn2).toHaveBeenCalledTimes(2);
163
+ });
164
+ it("inner effects", () => {
165
+ const fn = vi.fn();
166
+ const s2 = signal(1);
167
+ const innerEffects = collect(onEffect, () =>
168
+ effect((effect) => {
169
+ effect(fn, s2);
170
+ })
171
+ );
172
+ s2.value = 2;
173
+ while (innerEffects.length) innerEffects.pop()();
174
+ s2.value = 3;
175
+ expect(fn).toHaveBeenCalledTimes(2);
176
+ });
177
+ });
178
+
179
+ describe("computed", () => {
180
+ let s1, s2, c;
181
+ beforeEach(() => {
182
+ s1 = signal(1);
183
+ s2 = signal(2);
184
+ c = computed(() => s1.value + s2.value);
185
+ });
186
+ it("has value", () => {
187
+ expect(c.value).toBe(3);
188
+ });
189
+ it("updates value", () => {
190
+ s1.value = 3;
191
+ expect(c.value).toBe(5);
192
+ });
193
+ it("works with effects", () => {
194
+ const fn = vi.fn();
195
+ const s3 = signal(4);
196
+ effect(() => {
197
+ fn(c.value + s3.value);
198
+ });
199
+ expect(fn).toHaveBeenCalledWith(7);
200
+ });
201
+ it("watches", () => {
202
+ const fn = vi.fn();
203
+ c.watch(fn);
204
+ s1.value = 2;
205
+ expect(fn).toHaveBeenCalled();
206
+ });
207
+ it("peeks", () => {
208
+ expect(c.peek()).toBe(3);
209
+ });
210
+ it("un-watches", () => {
211
+ const fn = vi.fn();
212
+ const unwatch = c.watch(fn);
213
+ unwatch();
214
+ s1.value = 2;
215
+ expect(fn).not.toHaveBeenCalled();
216
+ });
217
+ });
218
+
219
+ const child = dom("foo");
220
+
221
+ describe("dom", () => {
222
+ it("creates", () => {
223
+ const el = dom("div", {}, 2, 3, 4);
224
+ expect(document.createElement).toHaveBeenCalledWith("div");
225
+ expect(el.append).toHaveBeenCalledWith(2, 3, 4);
226
+ });
227
+ it("creates with optional props", () => {
228
+ const el = dom("div");
229
+ expect(document.createElement).toHaveBeenCalledWith("div");
230
+ });
231
+ it("creates with optional props and children", () => {
232
+ const el = dom("div", "hello", child);
233
+ expect(document.createElement).toHaveBeenCalledWith("div");
234
+ expect(el.append).toHaveBeenCalledWith("hello", child);
235
+ });
236
+ it("sets attributes", () => {
237
+ const fn = vi.fn();
238
+ const s = signal();
239
+ const el = dom("test", { "data-test": 1, a: fn, ref: s, o: { a: 1 } });
240
+ expect(document.createElement).toHaveBeenCalledWith("test");
241
+ expect(el.setAttribute).toHaveBeenCalledTimes(1);
242
+ expect(el.setAttribute).toHaveBeenCalledWith("data-test", 1);
243
+ expect(el.a).toBe(fn);
244
+ expect(s.value).toBe(el);
245
+ expect(el.o).toStrictEqual({ a: 1 });
246
+ });
247
+ it("binds signals", () => {
248
+ const s = signal("foo");
249
+ const el = dom("test", { id: s });
250
+ expect(document.createElement).toHaveBeenCalledWith("test");
251
+ expect(el.setAttribute).toHaveBeenCalledTimes(1);
252
+ expect(el.setAttribute).toHaveBeenCalledWith("id", "foo");
253
+ s.value = "bar";
254
+ expect(el.setAttribute).toHaveBeenCalledTimes(2);
255
+ expect(el.setAttribute).toHaveBeenCalledWith("id", "bar");
256
+ s.value = false;
257
+ expect(el.removeAttribute).toHaveBeenCalledTimes(1);
258
+ expect(el.removeAttribute).toHaveBeenCalledWith("id");
259
+ });
260
+ it("captures effects", () => {
261
+ const fn = vi.fn();
262
+ const s = signal("foo");
263
+ const s2 = signal("bar");
264
+ onEffect(fn);
265
+ const el = dom("test", { id: s, name: s2 });
266
+ expect(fn).toHaveBeenCalledTimes(2);
267
+ });
268
+ });
269
+
270
+ describe("utils", () => {
271
+ it("using", () => {
272
+ const fn = vi.fn();
273
+ const cb = vi.fn();
274
+ using(cb, () => {
275
+ fn();
276
+ });
277
+ expect(fn).toHaveBeenCalled();
278
+ expect(cb).toHaveBeenCalled();
279
+ });
280
+ it("collect", () => {
281
+ const [emit, on] = event();
282
+ const collected = collect(on, () => {
283
+ for (let i = 1; i <= 3; i++) {
284
+ emit(i);
285
+ }
286
+ });
287
+ expect(collected).toStrictEqual([1, 2, 3]);
288
+ emit(4);
289
+ expect(collected).toStrictEqual([1, 2, 3]);
290
+ });
291
+ });
292
+ });
package/test/setup.js ADDED
@@ -0,0 +1,13 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockDoc = {
4
+ createElement: vi.fn((tagName) => ({
5
+ append: vi.fn(),
6
+ setAttribute: vi.fn(),
7
+ removeAttribute: vi.fn(),
8
+ nodeType: 1,
9
+ tagName,
10
+ })),
11
+ };
12
+
13
+ vi.stubGlobal("document", mockDoc);
@@ -0,0 +1,64 @@
1
+ import ssr from "../src/ssr.js";
2
+ vi.stubGlobal("document", ssr);
3
+ import { dom } from "../src/munifw.js";
4
+
5
+ describe("createElement", () => {
6
+ it("makes tags", () => {
7
+ const el = ssr.createElement("div");
8
+ expect(el.tagName).toBe("div");
9
+ });
10
+ it("makes text", () => {
11
+ const el = ssr.createElement("div");
12
+ el.append("foo");
13
+ const text = el.childNodes[0];
14
+ expect(text.nodeType).toBe(3);
15
+ expect(text.textContent).toBe("foo");
16
+ });
17
+ it("works with muni", () => {
18
+ const spy = vi.spyOn(ssr, "createElement");
19
+ const el = dom("div");
20
+ expect(spy).toHaveBeenCalled();
21
+ expect(el.tagName).toBe("div");
22
+ expect(el.nodeType).toBe(1);
23
+ });
24
+ });
25
+
26
+ describe("nodeMock", () => {
27
+ it("attributes", () => {
28
+ const el = ssr.createElement("div");
29
+ el.setAttribute("foo", "bar");
30
+ expect(el.getAttribute("foo")).toBe("bar");
31
+ el.removeAttribute("foo");
32
+ expect(el.getAttribute("foo")).toBeUndefined();
33
+ });
34
+ it("children", () => {
35
+ const el = ssr.createElement("div");
36
+ const h1 = ssr.createElement("h1");
37
+ el.append(h1);
38
+ expect(el.childNodes[0]).toBe(h1);
39
+ expect(h1.parentNode).toBe(el);
40
+ const h2 = ssr.createElement("h2");
41
+ el.replaceChildren(h2);
42
+ expect(el.childNodes.length).toBe(1);
43
+ expect(el.childNodes[0]).toBe(h2);
44
+ expect(h1.parentNode).toBeNull();
45
+ el.append(h1);
46
+ expect(el.childNodes.length).toBe(2);
47
+ h2.remove();
48
+ expect(el.childNodes[0]).toBe(h1);
49
+ h1.replaceWith(h2);
50
+ expect(el.childNodes[0]).toBe(h2);
51
+ });
52
+ it("serializes", () => {
53
+ const el = ssr.createElement("div");
54
+ const a = ssr.createElement("a");
55
+ a.setAttribute("href", "foo");
56
+ a.append("bar");
57
+ el.append(a);
58
+ el.append(ssr.createElement("img"));
59
+ el.append(ssr.createElement("b"));
60
+ expect(el.outerHTML).toBe('<div><a href="foo">bar</a><img><b></b></div>');
61
+ expect(el.innerHTML).toBe('<a href="foo">bar</a><img><b></b>');
62
+ expect(a.innerHTML).toBe("bar");
63
+ });
64
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig, mergeConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ setupFiles: ["test/setup.js"],
7
+ },
8
+ });