@granularjs/core 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.
Files changed (70) hide show
  1. package/README.md +576 -0
  2. package/dist/granular.min.js +2 -0
  3. package/dist/granular.min.js.map +7 -0
  4. package/package.json +54 -0
  5. package/src/core/bootstrap.js +63 -0
  6. package/src/core/collections/observable-array.js +204 -0
  7. package/src/core/component/function-component.js +82 -0
  8. package/src/core/context.js +172 -0
  9. package/src/core/dom/dom.js +25 -0
  10. package/src/core/dom/element.js +725 -0
  11. package/src/core/dom/error-boundary.js +111 -0
  12. package/src/core/dom/input-format.js +82 -0
  13. package/src/core/dom/list.js +185 -0
  14. package/src/core/dom/portal.js +57 -0
  15. package/src/core/dom/tags.js +182 -0
  16. package/src/core/dom/virtual-list.js +242 -0
  17. package/src/core/dom/when.js +138 -0
  18. package/src/core/events/event-hub.js +97 -0
  19. package/src/core/forms/form.js +127 -0
  20. package/src/core/internal/symbols.js +5 -0
  21. package/src/core/network/websocket.js +165 -0
  22. package/src/core/query/query-client.js +529 -0
  23. package/src/core/reactivity/after-flush.js +20 -0
  24. package/src/core/reactivity/computed.js +51 -0
  25. package/src/core/reactivity/concat.js +89 -0
  26. package/src/core/reactivity/dirty-host.js +162 -0
  27. package/src/core/reactivity/observe.js +421 -0
  28. package/src/core/reactivity/persist.js +180 -0
  29. package/src/core/reactivity/resolve.js +8 -0
  30. package/src/core/reactivity/signal.js +97 -0
  31. package/src/core/reactivity/state.js +294 -0
  32. package/src/core/renderable/render-string.js +51 -0
  33. package/src/core/renderable/renderable.js +21 -0
  34. package/src/core/renderable/renderer.js +66 -0
  35. package/src/core/router/router.js +865 -0
  36. package/src/core/runtime.js +28 -0
  37. package/src/index.js +42 -0
  38. package/types/core/bootstrap.d.ts +11 -0
  39. package/types/core/collections/observable-array.d.ts +25 -0
  40. package/types/core/component/function-component.d.ts +14 -0
  41. package/types/core/context.d.ts +29 -0
  42. package/types/core/dom/dom.d.ts +13 -0
  43. package/types/core/dom/element.d.ts +10 -0
  44. package/types/core/dom/error-boundary.d.ts +8 -0
  45. package/types/core/dom/input-format.d.ts +6 -0
  46. package/types/core/dom/list.d.ts +8 -0
  47. package/types/core/dom/portal.d.ts +8 -0
  48. package/types/core/dom/tags.d.ts +114 -0
  49. package/types/core/dom/virtual-list.d.ts +8 -0
  50. package/types/core/dom/when.d.ts +13 -0
  51. package/types/core/events/event-hub.d.ts +48 -0
  52. package/types/core/forms/form.d.ts +9 -0
  53. package/types/core/internal/symbols.d.ts +4 -0
  54. package/types/core/network/websocket.d.ts +18 -0
  55. package/types/core/query/query-client.d.ts +73 -0
  56. package/types/core/reactivity/after-flush.d.ts +4 -0
  57. package/types/core/reactivity/computed.d.ts +1 -0
  58. package/types/core/reactivity/concat.d.ts +1 -0
  59. package/types/core/reactivity/dirty-host.d.ts +42 -0
  60. package/types/core/reactivity/observe.d.ts +10 -0
  61. package/types/core/reactivity/persist.d.ts +1 -0
  62. package/types/core/reactivity/resolve.d.ts +1 -0
  63. package/types/core/reactivity/signal.d.ts +11 -0
  64. package/types/core/reactivity/state.d.ts +14 -0
  65. package/types/core/renderable/render-string.d.ts +2 -0
  66. package/types/core/renderable/renderable.d.ts +15 -0
  67. package/types/core/renderable/renderer.d.ts +38 -0
  68. package/types/core/router/router.d.ts +57 -0
  69. package/types/core/runtime.d.ts +26 -0
  70. package/types/index.d.ts +2 -0
@@ -0,0 +1,294 @@
1
+ import { signal, setSignal, readSignal, subscribeSignal } from './signal.js';
2
+
3
+ const STATE = Symbol('zb.state');
4
+ const STATE_META = Symbol('zb.state.meta');
5
+
6
+ function isObject(value) {
7
+ return value !== null && typeof value === 'object';
8
+ }
9
+
10
+ function splitPath(path) {
11
+ if (!path) return [];
12
+ if (Array.isArray(path)) return path;
13
+ return String(path)
14
+ .split('.')
15
+ .map((p) => p.trim())
16
+ .filter(Boolean);
17
+ }
18
+
19
+ function getAtPath(obj, path) {
20
+ let cur = obj;
21
+ for (const key of path) {
22
+ if (!cur) return undefined;
23
+ cur = cur[key];
24
+ }
25
+ return cur;
26
+ }
27
+
28
+ function mergeDefaults(base, next) {
29
+ if (!isObject(base) || !isObject(next)) return isObject(next) ? { ...next } : next;
30
+ const out = { ...base };
31
+ for (const key of Object.keys(next)) {
32
+ const baseValue = base[key];
33
+ const nextValue = next[key];
34
+ if (isObject(baseValue) && isObject(nextValue)) {
35
+ out[key] = mergeDefaults(baseValue, nextValue);
36
+ continue;
37
+ }
38
+ out[key] = nextValue;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function normalizeWhen(when) {
44
+ if (typeof when === 'function') return when;
45
+ if (when === 'nullish') return (value) => value == null;
46
+ return (value) => value === undefined;
47
+ }
48
+
49
+ function resolveValue(adapter, path, root) {
50
+ const currentRoot = root === undefined ? adapter.get() : root;
51
+ const value = getAtPath(currentRoot, path);
52
+ const defaults = adapter.defaults;
53
+ if (!defaults) return value;
54
+ const shouldDefault = adapter.defaultsWhen(value);
55
+ if (!shouldDefault) return value;
56
+ const fallback = getAtPath(defaults, path);
57
+ if (fallback === undefined) return value;
58
+ if (typeof fallback === 'function') {
59
+ return fallback({ value, path, root: currentRoot });
60
+ }
61
+ return fallback;
62
+ }
63
+
64
+ function setAtPath(obj, path, value) {
65
+ if (!path.length) return value;
66
+ const root = Array.isArray(obj) ? obj.slice() : { ...(obj || {}) };
67
+ let cur = root;
68
+ for (let i = 0; i < path.length - 1; i++) {
69
+ const key = path[i];
70
+ const next = cur[key];
71
+ const cloned = Array.isArray(next) ? next.slice() : { ...(next || {}) };
72
+ cur[key] = cloned;
73
+ cur = cloned;
74
+ }
75
+ cur[path[path.length - 1]] = value;
76
+ return root;
77
+ }
78
+
79
+ function createSetterProxy(adapter, basePath) {
80
+ return new Proxy(
81
+ {},
82
+ {
83
+ get(_t, prop) {
84
+ if (prop === 'increment') {
85
+ return () => {
86
+ const current = getAtPath(adapter.get(), basePath);
87
+ const next = (Number(current) || 0) + 1;
88
+ adapter.set(setAtPath(adapter.get(), basePath, next));
89
+ };
90
+ }
91
+ if (prop === 'decrement') {
92
+ return () => {
93
+ const current = getAtPath(adapter.get(), basePath);
94
+ const next = (Number(current) || 0) - 1;
95
+ adapter.set(setAtPath(adapter.get(), basePath, next));
96
+ };
97
+ }
98
+ if (prop === 'mutate') {
99
+ return (...args) => adapter.mutate?.(...args);
100
+ }
101
+ if (typeof prop === 'string') {
102
+ return createSetterProxy(adapter, basePath.concat(prop));
103
+ }
104
+ return undefined;
105
+ },
106
+ set(_t, prop, value) {
107
+ const path = basePath.concat(String(prop));
108
+ adapter.set(setAtPath(adapter.get(), path, value));
109
+ return true;
110
+ },
111
+ }
112
+ );
113
+ }
114
+
115
+ function createStateProxy(adapter, path = []) {
116
+ const meta = { adapter, path };
117
+ return new Proxy(
118
+ {},
119
+ {
120
+ get(_t, prop) {
121
+ if (prop === STATE) return true;
122
+ if (prop === STATE_META) return meta;
123
+ if (prop === 'get') {
124
+ return (p) => {
125
+ if (p === undefined) return adapter.get();
126
+ return resolveValue(adapter, path.concat(splitPath(p)));
127
+ };
128
+ }
129
+ if (prop === 'set') {
130
+ return (...args) => {
131
+ if (args.length === 0) return createSetterProxy(adapter, path);
132
+ if (args.length === 1) {
133
+ return adapter.set(setAtPath(adapter.get(), path, args[0]));
134
+ }
135
+ const [p, v] = args;
136
+ if (typeof p === 'string') {
137
+ return adapter.set(setAtPath(adapter.get(), path.concat(splitPath(p)), v));
138
+ }
139
+ return adapter.set(setAtPath(adapter.get(), path, p));
140
+ };
141
+ }
142
+ if (prop === 'subscribe') {
143
+ return (fn) => adapter.subscribe(fn);
144
+ }
145
+ if (prop === 'before') {
146
+ return adapter.before;
147
+ }
148
+ if (prop === 'mutate') {
149
+ return (...args) => adapter.mutate?.(...args);
150
+ }
151
+ if (prop === Symbol.toPrimitive) return () => resolveValue(adapter, path);
152
+ if (prop === 'valueOf') return () => resolveValue(adapter, path);
153
+ if (prop === 'toString') return () => String(resolveValue(adapter, path));
154
+
155
+ const current = resolveValue(adapter, path);
156
+ if (Array.isArray(current) && prop === 'map') {
157
+ return (fn) => {
158
+ const out = current.map(fn);
159
+ Object.defineProperty(out, STATE_META, { value: { adapter, path, mapFn: fn } });
160
+ return out;
161
+ };
162
+ }
163
+
164
+ if (isObject(current) && typeof prop === 'string') {
165
+ return createStateProxy(adapter, path.concat(prop));
166
+ }
167
+ return undefined;
168
+ },
169
+ set(_t, prop, value) {
170
+ if (typeof prop === 'string') {
171
+ throw new Error(`Direct mutation is not allowed. Use .set().${prop} = value or .set("${path.concat(prop).join('.')}", value).`);
172
+ }
173
+ return false;
174
+ },
175
+ }
176
+ );
177
+ }
178
+
179
+ export function state(initial) {
180
+ const rootSignal = signal(initial);
181
+ const adapter = {
182
+ kind: 'state',
183
+ get: () => readSignal(rootSignal),
184
+ set: (next) => setSignal(rootSignal, next, true),
185
+ subscribe: (fn) => subscribeSignal(rootSignal, fn),
186
+ before: rootSignal.before,
187
+ mutate: (optimistic, mutation, options = {}) => mutateAdapter(adapter, optimistic, mutation, options),
188
+ };
189
+ return createStateFromAdapter(adapter);
190
+ }
191
+
192
+ export function createStateFromAdapter(adapter) {
193
+ const proxy = createStateProxy(adapter, []);
194
+ Object.defineProperty(proxy, STATE, { value: true });
195
+ return proxy;
196
+ }
197
+
198
+ function cloneForSnapshot(value, options) {
199
+ if (typeof options.clone === 'function') return options.clone(value);
200
+ return value;
201
+ }
202
+
203
+ export async function mutateAdapter(adapter, optimistic, mutation, options = {}) {
204
+ if (typeof optimistic !== 'function' || typeof mutation !== 'function') {
205
+ throw new Error('mutate(optimistic, mutation, options?): invalid arguments');
206
+ }
207
+ const prev = cloneForSnapshot(adapter.get(), options);
208
+ optimistic();
209
+ try {
210
+ const result = await mutation();
211
+ return result;
212
+ } catch (err) {
213
+ if (typeof options.rollback === 'function') {
214
+ options.rollback(err, prev);
215
+ } else {
216
+ adapter.set(prev);
217
+ }
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ export function isState(value) {
223
+ return !!value && value[STATE] === true;
224
+ }
225
+
226
+ export function isComputed(value) {
227
+ const meta = value?.[STATE_META];
228
+ return !!meta && meta.adapter?.kind === 'computed';
229
+ }
230
+
231
+ export function isStatePath(value) {
232
+ return !!value && value[STATE_META];
233
+ }
234
+
235
+ export function readState(value) {
236
+ const meta = value?.[STATE_META];
237
+ if (!meta) return undefined;
238
+ return resolveValue(meta.adapter, meta.path);
239
+ }
240
+
241
+ export function readStateFromRoot(value, root) {
242
+ const meta = value?.[STATE_META];
243
+ if (!meta) return undefined;
244
+ return resolveValue(meta.adapter, meta.path, root);
245
+ }
246
+
247
+ export function subscribeState(value, fn) {
248
+ const meta = value?.[STATE_META];
249
+ if (!meta) return null;
250
+ return meta.adapter.subscribe((nextRoot, prevRoot) => {
251
+ const next = resolveValue(meta.adapter, meta.path, nextRoot);
252
+ const prev = resolveValue(meta.adapter, meta.path, prevRoot);
253
+ if (next === prev) return;
254
+ fn(next, prev);
255
+ });
256
+ }
257
+
258
+ export function readStateMeta(meta) {
259
+ if (!meta) return undefined;
260
+ return resolveValue(meta.adapter, meta.path);
261
+ }
262
+
263
+ export function subscribeStateMeta(meta, fn) {
264
+ if (!meta) return null;
265
+ return meta.adapter.subscribe((nextRoot, prevRoot) => {
266
+ const next = resolveValue(meta.adapter, meta.path, nextRoot);
267
+ const prev = resolveValue(meta.adapter, meta.path, prevRoot);
268
+ if (next === prev) return;
269
+ fn(next, prev);
270
+ });
271
+ }
272
+
273
+ export function setStateValue(value, next) {
274
+ const meta = value?.[STATE_META];
275
+ if (!meta) return;
276
+ return meta.adapter.set(setAtPath(meta.adapter.get(), meta.path, next));
277
+ }
278
+
279
+ export function getMappedMeta(value) {
280
+ const meta = value?.[STATE_META];
281
+ if (!meta || !meta.mapFn) return null;
282
+ return meta;
283
+ }
284
+
285
+ export function withDefaults(target, defaults, options = {}) {
286
+ const meta = target?.[STATE_META];
287
+ if (!meta) {
288
+ throw new Error('withDefaults(target, defaults, options?): target must be a state or state path');
289
+ }
290
+ const adapter = meta.adapter;
291
+ adapter.defaultsWhen = options.when === undefined ? (adapter.defaultsWhen ?? normalizeWhen()) : normalizeWhen(options.when);
292
+ adapter.defaults = adapter.defaults ? mergeDefaults(adapter.defaults, defaults) : defaults;
293
+ return target;
294
+ }
@@ -0,0 +1,51 @@
1
+ import { Renderable } from './renderable.js';
2
+ import { Renderer } from './renderer.js';
3
+ import { ElementNode } from '../dom/element.js';
4
+ import { isSignal, readSignal } from '../reactivity/signal.js';
5
+ import { isState, isStatePath, readState } from '../reactivity/state.js';
6
+
7
+ function escapeHtml(value) {
8
+ return String(value)
9
+ .replace(/&/g, '&amp;')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&#39;');
14
+ }
15
+
16
+ function renderValue(value, render) {
17
+ if (value == null || value === false) return '';
18
+ if (Array.isArray(value)) return value.map((v) => render(v)).join('');
19
+ if (isSignal(value)) return render(readSignal(value));
20
+ if (isState(value) || isStatePath(value)) return render(readState(value));
21
+ if (value instanceof Renderable && typeof value.renderToString === 'function') {
22
+ return value.renderToString(render);
23
+ }
24
+ if (value instanceof ElementNode) {
25
+ return value.renderToString(render);
26
+ }
27
+ if (Renderer.isDomNode(value)) {
28
+ return value.outerHTML || '';
29
+ }
30
+ return escapeHtml(Renderer.toText(value));
31
+ }
32
+
33
+ export function renderToString(value) {
34
+ const render = (v) => renderValue(v, render);
35
+ render.escape = escapeHtml;
36
+ return render(value);
37
+ }
38
+
39
+ export function hydrate(target, value) {
40
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
41
+ if (!el) throw new Error('hydrate(target): target not found');
42
+ while (el.firstChild) el.removeChild(el.firstChild);
43
+ const values = Renderer.normalize(value);
44
+ for (const r of values) {
45
+ if (Renderer.isRenderable(r)) {
46
+ r.mountInto(el, null);
47
+ } else if (Renderer.isDomNode(r)) {
48
+ el.appendChild(r);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Base contract for objects that can be mounted/unmounted by the core renderer.
3
+ */
4
+ export class Renderable {
5
+ /**
6
+ * Mounts the instance into the DOM.
7
+ * @param {Node} parent
8
+ * @param {Node|null} beforeNode
9
+ */
10
+ mountInto() {
11
+ throw new Error('Renderable.mountInto() must be implemented');
12
+ }
13
+
14
+ /**
15
+ * Unmounts and releases DOM/resources owned by the instance.
16
+ */
17
+ unmount() {
18
+ throw new Error('Renderable.unmount() must be implemented');
19
+ }
20
+ }
21
+
@@ -0,0 +1,66 @@
1
+ import { Renderable } from './renderable.js';
2
+
3
+ /**
4
+ * Core rendering rules for "values".
5
+ * This is intentionally separate from the Renderable contract.
6
+ */
7
+ export class Renderer {
8
+ /**
9
+ * @param {unknown} value
10
+ * @returns {value is Node}
11
+ */
12
+ static isDomNode(value) {
13
+ return !!value && typeof value === 'object' && typeof value.nodeType === 'number';
14
+ }
15
+
16
+ /**
17
+ * @param {unknown} value
18
+ * @returns {value is Renderable}
19
+ */
20
+ static isRenderable(value) {
21
+ return value instanceof Renderable;
22
+ }
23
+
24
+ /**
25
+ * Converts a non-renderable value into string for text rendering.
26
+ * @param {unknown} value
27
+ * @returns {string}
28
+ */
29
+ static toText(value) {
30
+ if (value == null || value === false) return '';
31
+ if (typeof value === 'string') return value;
32
+ if (typeof value === 'number') return String(value);
33
+ if (typeof value === 'bigint') return String(value);
34
+ if (typeof value === 'boolean') return value ? 'true' : '';
35
+ try {
36
+ return String(value);
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Normalizes a value into a flat list of renderables:
44
+ * - Renderable instances
45
+ * - DOM Nodes
46
+ * - TextNodes created from primitives/objects
47
+ *
48
+ * @param {unknown} value
49
+ * @returns {(Renderable|Node)[]}
50
+ */
51
+ static normalize(value) {
52
+ if (value == null || value === false) return [];
53
+ if (Array.isArray(value)) return value.flatMap((v) => Renderer.normalize(v));
54
+ if (Renderer.isRenderable(value) || Renderer.isDomNode(value)) return /** @type {(Renderable|Node)[]} */ ([value]);
55
+ return [document.createTextNode(Renderer.toText(value))];
56
+ }
57
+
58
+ /**
59
+ * Unmounts a renderable value if applicable.
60
+ * @param {unknown} value
61
+ */
62
+ static unmount(value) {
63
+ if (Renderer.isRenderable(value)) value.unmount();
64
+ }
65
+ }
66
+