@radishland/runtime 0.0.1
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/LICENCE +20 -0
- package/README.md +3 -0
- package/client/config.d.ts +12 -0
- package/client/config.js +12 -0
- package/client/handler-registry.d.ts +25 -0
- package/client/handler-registry.js +211 -0
- package/client/handlers.d.ts +1 -0
- package/client/handlers.js +136 -0
- package/client/index.d.ts +3 -0
- package/client/index.js +3 -0
- package/client/reactivity.d.ts +25 -0
- package/client/reactivity.js +132 -0
- package/client/utils.d.ts +10 -0
- package/client/utils.js +66 -0
- package/package.json +23 -0
package/LICENCE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Frédéric Crozatier
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
package/client/config.js
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
import type { AutonomousCustomElement, EffectCallback, EffectOptions } from "./types.d.ts";
|
2
|
+
/**
|
3
|
+
* A Scoped Handler Registry implements the logic for handling effect requests.
|
4
|
+
*
|
5
|
+
* Extend this class by adding new methods in your subclass to implement your own effect handlers
|
6
|
+
*/
|
7
|
+
export declare class HandlerRegistry extends HTMLElement implements AutonomousCustomElement {
|
8
|
+
#private;
|
9
|
+
[key: string]: any;
|
10
|
+
/**
|
11
|
+
* References the handler's `AbortController`.
|
12
|
+
*
|
13
|
+
* The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
|
14
|
+
*/
|
15
|
+
abortController: AbortController;
|
16
|
+
constructor();
|
17
|
+
/**
|
18
|
+
* Creates an effect that is automatically cleaned up when the component is disconnected
|
19
|
+
*
|
20
|
+
* An optional AbortSignal can be provided to abort the effect prematurely
|
21
|
+
*/
|
22
|
+
$effect(callback: EffectCallback, options?: EffectOptions): void;
|
23
|
+
connectedCallback(): void;
|
24
|
+
disconnectedCallback(): void;
|
25
|
+
}
|
@@ -0,0 +1,211 @@
|
|
1
|
+
import { booleanAttributes } from "./utils.js";
|
2
|
+
import { bindingConfig } from "./config.js";
|
3
|
+
import { $effect, getValue, isComputed, isState } from "./reactivity.js";
|
4
|
+
/**
|
5
|
+
* A Scoped Handler Registry implements the logic for handling effect requests.
|
6
|
+
*
|
7
|
+
* Extend this class by adding new methods in your subclass to implement your own effect handlers
|
8
|
+
*/
|
9
|
+
export class HandlerRegistry extends HTMLElement {
|
10
|
+
#cleanup = [];
|
11
|
+
/**
|
12
|
+
* References the handler's `AbortController`.
|
13
|
+
*
|
14
|
+
* The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
|
15
|
+
*/
|
16
|
+
abortController;
|
17
|
+
constructor() {
|
18
|
+
super();
|
19
|
+
this.abortController = new AbortController();
|
20
|
+
}
|
21
|
+
/**
|
22
|
+
* Creates an effect that is automatically cleaned up when the component is disconnected
|
23
|
+
*
|
24
|
+
* An optional AbortSignal can be provided to abort the effect prematurely
|
25
|
+
*/
|
26
|
+
$effect(callback, options) {
|
27
|
+
const signals = [this.abortController.signal];
|
28
|
+
if (options?.signal)
|
29
|
+
signals.push(options.signal);
|
30
|
+
$effect(callback, { ...options, signal: AbortSignal.any(signals) });
|
31
|
+
}
|
32
|
+
#get(identifier) {
|
33
|
+
return this[identifier];
|
34
|
+
}
|
35
|
+
#handleOn(e) {
|
36
|
+
if (e instanceof CustomEvent) {
|
37
|
+
const { handler, type } = e.detail;
|
38
|
+
if (handler in this && typeof this.#get(handler) === "function") {
|
39
|
+
e.target?.addEventListener(type, this.#get(handler).bind(this));
|
40
|
+
e.stopPropagation();
|
41
|
+
}
|
42
|
+
}
|
43
|
+
}
|
44
|
+
#handleClass(e) {
|
45
|
+
const target = e.target;
|
46
|
+
if (e instanceof CustomEvent && target) {
|
47
|
+
const { identifier } = e.detail;
|
48
|
+
if (identifier in this) {
|
49
|
+
this.$effect(() => {
|
50
|
+
const classList = getValue(this.#get(identifier));
|
51
|
+
if (classList && typeof classList === "object") {
|
52
|
+
for (const [k, v] of Object.entries(classList)) {
|
53
|
+
const force = !!getValue(v);
|
54
|
+
for (const className of k.split(" ")) {
|
55
|
+
// @ts-ignore target is an HTMLElement
|
56
|
+
target.classList.toggle(className, force);
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
});
|
61
|
+
e.stopPropagation();
|
62
|
+
}
|
63
|
+
}
|
64
|
+
}
|
65
|
+
#handleUse(e) {
|
66
|
+
if (e instanceof CustomEvent) {
|
67
|
+
const { hook } = e.detail;
|
68
|
+
if (hook in this && typeof this.#get(hook) === "function") {
|
69
|
+
const cleanup = this.#get(hook).bind(this)(e.target);
|
70
|
+
if (typeof cleanup === "function") {
|
71
|
+
this.#cleanup.push(cleanup);
|
72
|
+
}
|
73
|
+
e.stopPropagation();
|
74
|
+
}
|
75
|
+
}
|
76
|
+
}
|
77
|
+
#handleAttr(e) {
|
78
|
+
if (e instanceof CustomEvent) {
|
79
|
+
const { identifier, attribute } = e.detail;
|
80
|
+
const target = e.target;
|
81
|
+
if (identifier in this && target instanceof HTMLElement &&
|
82
|
+
attribute in target) {
|
83
|
+
const ref = this.#get(identifier);
|
84
|
+
const setAttr = () => {
|
85
|
+
const value = getValue(ref);
|
86
|
+
if (booleanAttributes.includes(attribute)) {
|
87
|
+
value
|
88
|
+
? target.setAttribute(attribute, "")
|
89
|
+
: target.removeAttribute(attribute);
|
90
|
+
}
|
91
|
+
else {
|
92
|
+
target.setAttribute(attribute, `${value}`);
|
93
|
+
}
|
94
|
+
};
|
95
|
+
if (isState(ref) || isComputed(ref)) {
|
96
|
+
this.$effect(() => setAttr());
|
97
|
+
}
|
98
|
+
else {
|
99
|
+
setAttr();
|
100
|
+
}
|
101
|
+
e.stopPropagation();
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
#handleProp(e) {
|
106
|
+
if (e instanceof CustomEvent) {
|
107
|
+
const { identifier, property } = e.detail;
|
108
|
+
const target = e.target;
|
109
|
+
if (identifier in this && target && property in target) {
|
110
|
+
const ref = this.#get(identifier);
|
111
|
+
const setProp = () => {
|
112
|
+
const value = getValue(ref);
|
113
|
+
// @ts-ignore property is in target
|
114
|
+
target[property] = value;
|
115
|
+
};
|
116
|
+
if (isState(ref) || isComputed(ref)) {
|
117
|
+
this.$effect(() => setProp());
|
118
|
+
}
|
119
|
+
else {
|
120
|
+
setProp();
|
121
|
+
}
|
122
|
+
e.stopPropagation();
|
123
|
+
}
|
124
|
+
}
|
125
|
+
}
|
126
|
+
#handleText(e) {
|
127
|
+
if (e instanceof CustomEvent) {
|
128
|
+
const target = e.target;
|
129
|
+
const { identifier } = e.detail;
|
130
|
+
if (identifier in this && target instanceof HTMLElement) {
|
131
|
+
const ref = this.#get(identifier);
|
132
|
+
const setTextContent = () => {
|
133
|
+
const value = getValue(ref);
|
134
|
+
target.textContent = `${value}`;
|
135
|
+
};
|
136
|
+
if (isState(ref) || isComputed(ref)) {
|
137
|
+
this.$effect(() => setTextContent());
|
138
|
+
}
|
139
|
+
else {
|
140
|
+
setTextContent();
|
141
|
+
}
|
142
|
+
e.stopPropagation();
|
143
|
+
}
|
144
|
+
}
|
145
|
+
}
|
146
|
+
#handleHTML(e) {
|
147
|
+
if (e instanceof CustomEvent) {
|
148
|
+
const { identifier } = e.detail;
|
149
|
+
const target = e.target;
|
150
|
+
if (identifier in this && target instanceof HTMLElement) {
|
151
|
+
const ref = this.#get(identifier);
|
152
|
+
const setInnerHTML = () => {
|
153
|
+
const value = getValue(ref);
|
154
|
+
target.innerHTML = `${value}`;
|
155
|
+
};
|
156
|
+
if (isState(ref) || isComputed(ref)) {
|
157
|
+
this.$effect(() => setInnerHTML());
|
158
|
+
}
|
159
|
+
else {
|
160
|
+
setInnerHTML();
|
161
|
+
}
|
162
|
+
e.stopPropagation();
|
163
|
+
}
|
164
|
+
}
|
165
|
+
}
|
166
|
+
#handleBind(e) {
|
167
|
+
if (e instanceof CustomEvent) {
|
168
|
+
const { identifier, property } = e.detail;
|
169
|
+
const target = e.target;
|
170
|
+
if (identifier in this && target instanceof HTMLElement &&
|
171
|
+
property in target) {
|
172
|
+
const state = this.#get(identifier);
|
173
|
+
if (isState(state)) {
|
174
|
+
// @ts-ignore property is in target
|
175
|
+
state.value = target[property];
|
176
|
+
// Add change listener
|
177
|
+
target.addEventListener(bindingConfig[property].event, () => {
|
178
|
+
// @ts-ignore property is in target
|
179
|
+
state.value = target[property];
|
180
|
+
});
|
181
|
+
// Sync
|
182
|
+
this.$effect(() => {
|
183
|
+
// @ts-ignore property is in target
|
184
|
+
target[property] = state.value;
|
185
|
+
});
|
186
|
+
}
|
187
|
+
e.stopPropagation();
|
188
|
+
}
|
189
|
+
}
|
190
|
+
}
|
191
|
+
connectedCallback() {
|
192
|
+
const { signal } = this.abortController;
|
193
|
+
this.addEventListener("@attr-request", this.#handleAttr, { signal });
|
194
|
+
this.addEventListener("@class-request", this.#handleClass, { signal });
|
195
|
+
this.addEventListener("@on-request", this.#handleOn, { signal });
|
196
|
+
this.addEventListener("@use-request", this.#handleUse, { signal });
|
197
|
+
this.addEventListener("@prop-request", this.#handleProp, { signal });
|
198
|
+
this.addEventListener("@html-request", this.#handleHTML, { signal });
|
199
|
+
this.addEventListener("@text-request", this.#handleText, { signal });
|
200
|
+
this.addEventListener("@bind-request", this.#handleBind, { signal });
|
201
|
+
}
|
202
|
+
disconnectedCallback() {
|
203
|
+
this.abortController.abort();
|
204
|
+
for (const cleanup of this.#cleanup) {
|
205
|
+
cleanup();
|
206
|
+
}
|
207
|
+
}
|
208
|
+
}
|
209
|
+
if (window && !customElements.get("handler-registry")) {
|
210
|
+
customElements.define("handler-registry", HandlerRegistry);
|
211
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
export {};
|
@@ -0,0 +1,136 @@
|
|
1
|
+
import { bindingConfig } from "./config.js";
|
2
|
+
import { spaces_sep_by_comma } from "./utils.js";
|
3
|
+
const bindingsQueryString = Object.keys(bindingConfig).map((property) => `[\\@bind\\:${property}]`).join(",");
|
4
|
+
setTimeout(() => {
|
5
|
+
customElements?.whenDefined("handler-registry").then(() => {
|
6
|
+
document.querySelectorAll(`[\\@on],[\\@use],[\\@attr],[\\@attr\\|client],[\\@prop],${bindingsQueryString},[\\@text],[\\@html]`)
|
7
|
+
.forEach((entry) => {
|
8
|
+
const events = entry.getAttribute("@on")?.trim()
|
9
|
+
?.split(spaces_sep_by_comma);
|
10
|
+
if (events) {
|
11
|
+
for (const event of events) {
|
12
|
+
const [type, handler] = event.split(":");
|
13
|
+
const onRequest = new CustomEvent("@on-request", {
|
14
|
+
bubbles: true,
|
15
|
+
cancelable: true,
|
16
|
+
composed: true,
|
17
|
+
detail: {
|
18
|
+
type,
|
19
|
+
handler: handler || type,
|
20
|
+
},
|
21
|
+
});
|
22
|
+
entry.dispatchEvent(onRequest);
|
23
|
+
}
|
24
|
+
}
|
25
|
+
const hooks = entry.getAttribute("@use")?.trim()
|
26
|
+
?.split(spaces_sep_by_comma);
|
27
|
+
if (hooks) {
|
28
|
+
for (const hook of hooks) {
|
29
|
+
const useRequest = new CustomEvent("@use-request", {
|
30
|
+
bubbles: true,
|
31
|
+
cancelable: true,
|
32
|
+
composed: true,
|
33
|
+
detail: {
|
34
|
+
hook,
|
35
|
+
},
|
36
|
+
});
|
37
|
+
entry.dispatchEvent(useRequest);
|
38
|
+
}
|
39
|
+
}
|
40
|
+
const props = (entry.getAttribute("@prop"))?.trim()
|
41
|
+
.split(spaces_sep_by_comma);
|
42
|
+
if (props) {
|
43
|
+
for (const prop of props) {
|
44
|
+
const [key, value] = prop.split(":");
|
45
|
+
const propRequest = new CustomEvent("@prop-request", {
|
46
|
+
bubbles: true,
|
47
|
+
cancelable: true,
|
48
|
+
composed: true,
|
49
|
+
detail: {
|
50
|
+
property: key,
|
51
|
+
identifier: value || key,
|
52
|
+
},
|
53
|
+
});
|
54
|
+
entry.dispatchEvent(propRequest);
|
55
|
+
}
|
56
|
+
}
|
57
|
+
const text = entry.hasAttribute("@text");
|
58
|
+
if (text) {
|
59
|
+
const identifier = entry.getAttribute("@text") || "text";
|
60
|
+
const textRequest = new CustomEvent("@text-request", {
|
61
|
+
bubbles: true,
|
62
|
+
cancelable: true,
|
63
|
+
composed: true,
|
64
|
+
detail: {
|
65
|
+
identifier,
|
66
|
+
},
|
67
|
+
});
|
68
|
+
entry.dispatchEvent(textRequest);
|
69
|
+
}
|
70
|
+
const html = entry.hasAttribute("@html");
|
71
|
+
if (html) {
|
72
|
+
const identifier = entry.getAttribute("@html") || "html";
|
73
|
+
const htmlRequest = new CustomEvent("@html-request", {
|
74
|
+
bubbles: true,
|
75
|
+
cancelable: true,
|
76
|
+
composed: true,
|
77
|
+
detail: {
|
78
|
+
identifier,
|
79
|
+
},
|
80
|
+
});
|
81
|
+
entry.dispatchEvent(htmlRequest);
|
82
|
+
}
|
83
|
+
const classList = entry.hasAttribute("@class");
|
84
|
+
if (classList) {
|
85
|
+
const identifier = entry.getAttribute("@class") || "class";
|
86
|
+
const classRequest = new CustomEvent("@class-request", {
|
87
|
+
bubbles: true,
|
88
|
+
cancelable: true,
|
89
|
+
composed: true,
|
90
|
+
detail: {
|
91
|
+
identifier,
|
92
|
+
},
|
93
|
+
});
|
94
|
+
entry.dispatchEvent(classRequest);
|
95
|
+
}
|
96
|
+
const attributes = [
|
97
|
+
...(entry.getAttribute("@attr"))?.trim()
|
98
|
+
.split(spaces_sep_by_comma) ?? [],
|
99
|
+
...(entry.getAttribute("@attr|client"))?.trim()
|
100
|
+
.split(spaces_sep_by_comma) ?? [],
|
101
|
+
];
|
102
|
+
if (attributes.length > 0) {
|
103
|
+
for (const attribute of attributes) {
|
104
|
+
const [key, value] = attribute.split(":");
|
105
|
+
const attrRequest = new CustomEvent("@attr-request", {
|
106
|
+
bubbles: true,
|
107
|
+
cancelable: true,
|
108
|
+
composed: true,
|
109
|
+
detail: {
|
110
|
+
attribute: key,
|
111
|
+
identifier: value || key,
|
112
|
+
},
|
113
|
+
});
|
114
|
+
entry.dispatchEvent(attrRequest);
|
115
|
+
}
|
116
|
+
}
|
117
|
+
for (const property of Object.keys(bindingConfig)) {
|
118
|
+
if (entry.hasAttribute(`@bind:${property}`)) {
|
119
|
+
const identifier = entry.getAttribute(`@bind:${property}`)?.trim() ||
|
120
|
+
property;
|
121
|
+
const bindRequest = new CustomEvent("@bind-request", {
|
122
|
+
bubbles: true,
|
123
|
+
cancelable: true,
|
124
|
+
composed: true,
|
125
|
+
detail: {
|
126
|
+
property,
|
127
|
+
identifier,
|
128
|
+
handled: false,
|
129
|
+
},
|
130
|
+
});
|
131
|
+
entry.dispatchEvent(bindRequest);
|
132
|
+
}
|
133
|
+
}
|
134
|
+
});
|
135
|
+
});
|
136
|
+
}, 100);
|
package/client/index.js
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
import { Signal } from "signal-polyfill";
|
2
|
+
import type { Destructor, EffectCallback, EffectOptions, ReactivityOptions } from "./types.d.ts";
|
3
|
+
export declare class ReactiveValue<T> extends Signal.State<T> {
|
4
|
+
private get;
|
5
|
+
private set;
|
6
|
+
get value(): T;
|
7
|
+
set value(newValue: T);
|
8
|
+
}
|
9
|
+
export declare class ReactiveComputation<T> extends Signal.Computed<T> {
|
10
|
+
private get;
|
11
|
+
get value(): T;
|
12
|
+
}
|
13
|
+
export declare const $object: <T extends Record<PropertyKey, any>>(init: T, options?: ReactivityOptions) => T;
|
14
|
+
export declare const $array: <T extends ArrayLike<any>>(init: T, options?: ReactivityOptions) => T;
|
15
|
+
export declare const isState: (s: unknown) => s is InstanceType<typeof ReactiveValue>;
|
16
|
+
export declare const isComputed: (s: unknown) => s is InstanceType<typeof ReactiveComputation>;
|
17
|
+
export declare const getValue: (signal: unknown) => unknown;
|
18
|
+
export declare const $state: <T>(initialValue: T, options?: Signal.Options<T | undefined>) => ReactiveValue<T>;
|
19
|
+
export declare const $computed: <T>(computation: () => T, options?: Signal.Options<T>) => ReactiveComputation<T>;
|
20
|
+
/**
|
21
|
+
* Create an unowned effect that must be cleanup up manually
|
22
|
+
*
|
23
|
+
* Accept an AbortSignal to abort the effect
|
24
|
+
*/
|
25
|
+
export declare const $effect: (cb: EffectCallback, options?: EffectOptions) => Destructor;
|
@@ -0,0 +1,132 @@
|
|
1
|
+
import { Signal } from "signal-polyfill";
|
2
|
+
// @ts-ignore we're hiding get and set
|
3
|
+
export class ReactiveValue extends Signal.State {
|
4
|
+
// @ts-ignore see above
|
5
|
+
get;
|
6
|
+
// @ts-ignore see above
|
7
|
+
set;
|
8
|
+
get value() {
|
9
|
+
return super.get();
|
10
|
+
}
|
11
|
+
set value(newValue) {
|
12
|
+
super.set(newValue);
|
13
|
+
}
|
14
|
+
}
|
15
|
+
// @ts-ignore we're hiding get and set
|
16
|
+
export class ReactiveComputation extends Signal.Computed {
|
17
|
+
// @ts-ignore see above
|
18
|
+
get;
|
19
|
+
get value() {
|
20
|
+
return super.get();
|
21
|
+
}
|
22
|
+
}
|
23
|
+
const maybeReactiveObjectType = (thing, options) => {
|
24
|
+
if (typeof thing === "object") {
|
25
|
+
if (Array.isArray(thing)) {
|
26
|
+
return $array(thing, options);
|
27
|
+
}
|
28
|
+
else if (thing) {
|
29
|
+
return $object(thing, options);
|
30
|
+
}
|
31
|
+
}
|
32
|
+
return thing;
|
33
|
+
};
|
34
|
+
export const $object = (init, options = { deep: false }) => {
|
35
|
+
if (options.deep === true) {
|
36
|
+
for (const [key, value] of Object.entries(init)) {
|
37
|
+
init[key] = maybeReactiveObjectType(value, options);
|
38
|
+
}
|
39
|
+
}
|
40
|
+
const state = new Signal.State(init);
|
41
|
+
const proxy = new Proxy(init, {
|
42
|
+
get(_target, p, _receiver) {
|
43
|
+
return state.get()[p];
|
44
|
+
},
|
45
|
+
set(_target, p, newValue, _receiver) {
|
46
|
+
state.set({
|
47
|
+
...state.get(),
|
48
|
+
[p]: maybeReactiveObjectType(newValue, options),
|
49
|
+
});
|
50
|
+
return true;
|
51
|
+
},
|
52
|
+
});
|
53
|
+
return proxy;
|
54
|
+
};
|
55
|
+
export const $array = (init, options = { deep: false }) => {
|
56
|
+
if (options.deep) {
|
57
|
+
for (const [key, value] of Object.entries(init)) {
|
58
|
+
init[key] = maybeReactiveObjectType(value, options);
|
59
|
+
}
|
60
|
+
}
|
61
|
+
const state = new Signal.State(init);
|
62
|
+
const proxy = new Proxy(init, {
|
63
|
+
get(_target, p, _receiver) {
|
64
|
+
// @ts-ignore state has p
|
65
|
+
return state.get()[p];
|
66
|
+
},
|
67
|
+
set(_target, p, newValue, _receiver) {
|
68
|
+
state.set({
|
69
|
+
...state.get(),
|
70
|
+
[p]: maybeReactiveObjectType(newValue, options),
|
71
|
+
});
|
72
|
+
return true;
|
73
|
+
},
|
74
|
+
});
|
75
|
+
return proxy;
|
76
|
+
};
|
77
|
+
export const isState = (s) => {
|
78
|
+
return Signal.isState(s);
|
79
|
+
};
|
80
|
+
export const isComputed = (s) => {
|
81
|
+
return Signal.isComputed(s);
|
82
|
+
};
|
83
|
+
export const getValue = (signal) => {
|
84
|
+
if (isState(signal) || isComputed(signal)) {
|
85
|
+
return signal.value;
|
86
|
+
}
|
87
|
+
return signal;
|
88
|
+
};
|
89
|
+
export const $state = (initialValue, options) => {
|
90
|
+
return new ReactiveValue(initialValue, options);
|
91
|
+
};
|
92
|
+
export const $computed = (computation, options) => {
|
93
|
+
return new ReactiveComputation(computation, options);
|
94
|
+
};
|
95
|
+
let pending = false;
|
96
|
+
const watcher = new Signal.subtle.Watcher(() => {
|
97
|
+
if (!pending) {
|
98
|
+
pending = true;
|
99
|
+
queueMicrotask(() => {
|
100
|
+
pending = false;
|
101
|
+
for (const s of watcher.getPending())
|
102
|
+
s.get();
|
103
|
+
watcher.watch();
|
104
|
+
});
|
105
|
+
}
|
106
|
+
});
|
107
|
+
/**
|
108
|
+
* Create an unowned effect that must be cleanup up manually
|
109
|
+
*
|
110
|
+
* Accept an AbortSignal to abort the effect
|
111
|
+
*/
|
112
|
+
export const $effect = (cb, options) => {
|
113
|
+
if (options?.signal?.aborted)
|
114
|
+
return () => { };
|
115
|
+
let destroy;
|
116
|
+
const c = new Signal.Computed(() => {
|
117
|
+
destroy?.();
|
118
|
+
destroy = cb() ?? undefined;
|
119
|
+
});
|
120
|
+
watcher.watch(c);
|
121
|
+
c.get();
|
122
|
+
let cleaned = false;
|
123
|
+
const cleanup = () => {
|
124
|
+
if (cleaned)
|
125
|
+
return;
|
126
|
+
destroy?.();
|
127
|
+
watcher.unwatch(c);
|
128
|
+
cleaned = true;
|
129
|
+
};
|
130
|
+
options?.signal.addEventListener("abort", cleanup);
|
131
|
+
return cleanup;
|
132
|
+
};
|
@@ -0,0 +1,10 @@
|
|
1
|
+
export declare const spaces_sep_by_comma: RegExp;
|
2
|
+
/**
|
3
|
+
* Idempotent string conversion to kebab-case
|
4
|
+
*/
|
5
|
+
export declare const toKebabCase: (str: string) => string;
|
6
|
+
/**
|
7
|
+
* Idempotent string conversion to PascalCase
|
8
|
+
*/
|
9
|
+
export declare const toPascalCase: (str: string) => string;
|
10
|
+
export declare const booleanAttributes: string[];
|
package/client/utils.js
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
const is_upper = /[A-Z]/;
|
2
|
+
export const spaces_sep_by_comma = /\s*,\s*/;
|
3
|
+
/**
|
4
|
+
* Idempotent string conversion to kebab-case
|
5
|
+
*/
|
6
|
+
export const toKebabCase = (str) => {
|
7
|
+
let kebab = "";
|
8
|
+
for (let index = 0; index < str.length; index++) {
|
9
|
+
const char = str[index];
|
10
|
+
if (index !== 0 && is_upper.test(char)) {
|
11
|
+
kebab += `-${char.toLowerCase()}`;
|
12
|
+
}
|
13
|
+
else {
|
14
|
+
kebab += char.toLowerCase();
|
15
|
+
}
|
16
|
+
}
|
17
|
+
return kebab;
|
18
|
+
};
|
19
|
+
/**
|
20
|
+
* Idempotent string conversion to PascalCase
|
21
|
+
*/
|
22
|
+
export const toPascalCase = (str) => {
|
23
|
+
let pascal = "";
|
24
|
+
let toUpper = true;
|
25
|
+
for (let index = 0; index < str.length; index++) {
|
26
|
+
const char = str[index];
|
27
|
+
if (char === "-") {
|
28
|
+
toUpper = true;
|
29
|
+
continue;
|
30
|
+
}
|
31
|
+
if (toUpper) {
|
32
|
+
pascal += char.toUpperCase();
|
33
|
+
toUpper = false;
|
34
|
+
}
|
35
|
+
else {
|
36
|
+
pascal += char.toLowerCase();
|
37
|
+
}
|
38
|
+
}
|
39
|
+
return pascal;
|
40
|
+
};
|
41
|
+
export const booleanAttributes = [
|
42
|
+
"allowfullscreen", // on <iframe>
|
43
|
+
"async", // on <script>
|
44
|
+
"autofocus", // on <button>, <input>, <select>, <textarea>
|
45
|
+
"autoplay", // on <audio>, <video>
|
46
|
+
"checked", // on <input type="checkbox">, <input type="radio">
|
47
|
+
"controls", // on <audio>, <video>
|
48
|
+
"default", // on <track>
|
49
|
+
"defer", // on <script>
|
50
|
+
"disabled", // on form elements like <button>, <fieldset>, <input>, <optgroup>, <option>,<select>, <textarea>
|
51
|
+
"formnovalidate", // on <button>, <input type="submit">
|
52
|
+
"hidden", // global
|
53
|
+
"inert", // global
|
54
|
+
"ismap", // on <img>
|
55
|
+
"itemscope", // global; part of microdata
|
56
|
+
"loop", // on <audio>, <video>
|
57
|
+
"multiple", // on <input type="file">, <select>
|
58
|
+
"muted", // on <audio>, <video>
|
59
|
+
"nomodule", // on <script>
|
60
|
+
"novalidate", // on <form>
|
61
|
+
"open", // on <details>
|
62
|
+
"readonly", // on <input>, <textarea>
|
63
|
+
"required", // on <input>, <select>, <textarea>
|
64
|
+
"reversed", // on <ol>
|
65
|
+
"selected", // on <option>
|
66
|
+
];
|
package/package.json
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
{
|
2
|
+
"name": "@radishland/runtime",
|
3
|
+
"version": "0.0.1",
|
4
|
+
"type": "module",
|
5
|
+
"description": "The Radish runtime",
|
6
|
+
"author": "Frédéric Crozatier",
|
7
|
+
"license": "MIT",
|
8
|
+
"scripts": {
|
9
|
+
"build": "tsc",
|
10
|
+
"prepublishOnly": "pnpm build"
|
11
|
+
},
|
12
|
+
"main": "./client/index.js",
|
13
|
+
"exports": {
|
14
|
+
".": "./client/index.js"
|
15
|
+
},
|
16
|
+
"files": ["./client", "README.md", "LICENCE"],
|
17
|
+
"dependencies": {
|
18
|
+
"signal-polyfill": "^0.2.2"
|
19
|
+
},
|
20
|
+
"devDependencies": {
|
21
|
+
"typescript": "^5.7.3"
|
22
|
+
}
|
23
|
+
}
|