@excom/kit-utils 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/form.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { isNumber } from "./common";
2
+ import * as pathval from "pathval";
3
+
4
+ export function parseFormInputValue(
5
+ root,
6
+ input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | undefined,
7
+ key,
8
+ value
9
+ ) {
10
+ if (input) {
11
+ if (["number", "range"].includes(input.type)) {
12
+ return Number(value);
13
+ }
14
+ if (input.type === "checkbox") {
15
+ if (typeof value === "string" && !["on", "off"].includes(value)) {
16
+ return Array.from(root.querySelectorAll(`[name="${key}"]:checked`)).map(
17
+ (input) => {
18
+ return (input as HTMLInputElement).value;
19
+ }
20
+ );
21
+ } else {
22
+ return (input as HTMLInputElement).checked;
23
+ }
24
+ }
25
+ if (input.type === "radio") {
26
+ const _input = root.querySelector(`[name="${key}"]:checked`);
27
+ return _input?.checked ? value : null;
28
+ }
29
+ if (input instanceof HTMLSelectElement && input.multiple) {
30
+ return Array.from(input.options)
31
+ .filter((option) => option.selected)
32
+ .map((option) => option.value);
33
+ }
34
+ return value;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
40
+
41
+ /**
42
+ * Serialize a form to a nested JSON object. Field names are paths
43
+ * (`address.city`, `tags[]`); values are typed from the control
44
+ * (`number` / `range` → number, checkbox → boolean or checked-values array,
45
+ * multi-select → array). Form-associated controls (`form="id"`) count like
46
+ * descendants, as they do for `FormData`.
47
+ *
48
+ * Controls are indexed once by `name` from `form.elements`, so a form with
49
+ * thousands of fields serializes in one pass (no per-field selector query).
50
+ */
51
+ export function formToJson(form: HTMLFormElement) {
52
+ const json = {};
53
+ const formData = new FormData(form);
54
+ /* name → controls, document order. `form.elements` also lists
55
+ * controls associated via `form=`, matching `FormData`. */
56
+ const byName = new Map<string, FormControl[]>();
57
+ for (const control of Array.from(form.elements) as FormControl[]) {
58
+ const name = control.getAttribute("name");
59
+ if (!name) continue;
60
+ const list = byName.get(name);
61
+ if (list) list.push(control);
62
+ else byName.set(name, [control]);
63
+ }
64
+ const formEntries: Array<[string, FormDataEntryValue | string]> = [
65
+ ...formData.entries(),
66
+ // include unchecked checkboxes
67
+ ...Array.from(
68
+ form.querySelectorAll<HTMLInputElement>(
69
+ "input[name][type='checkbox']:not([value]):not(:checked)"
70
+ )
71
+ ).map((input): [string, string] => [
72
+ input.name,
73
+ input.checked ? input.value : "off",
74
+ ]),
75
+ ];
76
+ const arrayPaths = {};
77
+ formEntries.forEach(([key, value]) => {
78
+ if (key?.includes("[]")) {
79
+ if (isNumber(arrayPaths[key])) {
80
+ arrayPaths[key] += 1;
81
+ } else {
82
+ arrayPaths[key] = 0;
83
+ }
84
+ const arrayKey = key.replace("[]", `[${arrayPaths[key]}]`);
85
+ const found = byName.get(key)?.find((control) => control.value === value);
86
+ const parsedValue = parseFormInputValue(form, found, key, value);
87
+ pathval.setPathValue(json, arrayKey, parsedValue);
88
+ } else if (key) {
89
+ const parsedValue = parseFormInputValue(
90
+ form,
91
+ byName.get(key)?.[0],
92
+ key,
93
+ value
94
+ );
95
+ pathval.setPathValue(json, key, parsedValue);
96
+ }
97
+ });
98
+ return json;
99
+ }
package/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./batch-manager";
2
+ export * from "./common";
3
+ export * from "./dom";
4
+ export * from "./fetching";
5
+ export * from "./form";
6
+ export * from "./loop-guard";
7
+ export * from "./property";
8
+ export * from "./queue-manager";
9
+ export * from "./url";
@@ -0,0 +1,40 @@
1
+ window.__DEPENDENCY_PROMISES__ = window.__DEPENDENCY_PROMISES__ || {};
2
+
3
+ type TDepGlobalName = string;
4
+ declare global {
5
+ interface Window {
6
+ __DEPENDENCY_PROMISES__: Record<TDepGlobalName, Promise<any> | undefined>;
7
+ }
8
+ }
9
+
10
+ /* Load an ESM module or a UMD script; return the export / global. */
11
+ export async function loadDependency<T = any>(
12
+ type: "esm" | "umd",
13
+ uri: string,
14
+ globalName?: TDepGlobalName
15
+ ): Promise<T> {
16
+ if (type === "esm") {
17
+ return import(/* @vite-ignore */ uri);
18
+ } else if (type === "umd" && globalName) {
19
+ if (window[globalName]) {
20
+ return window[globalName];
21
+ }
22
+ if (!window.__DEPENDENCY_PROMISES__[globalName]) {
23
+ window.__DEPENDENCY_PROMISES__[globalName] = new Promise(
24
+ (resolve, reject) => {
25
+ const script = document.createElement("script");
26
+ script.src = uri;
27
+ document.body.appendChild(script);
28
+ script.onload = () => resolve(window[globalName]);
29
+ script.onerror = () =>
30
+ reject(new Error(`Failed to load dependency: ${globalName}`));
31
+ }
32
+ );
33
+ }
34
+ return window.__DEPENDENCY_PROMISES__[globalName]!;
35
+ } else {
36
+ throw new Error(
37
+ `Invalid dependency load request: ${type} | ${uri} | ${globalName}`
38
+ );
39
+ }
40
+ }
package/loop-guard.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Cuts runaway write cycles after a fixed hop count. Shared by Quark,
3
+ * Neutron, and any other DOM writer. Depth is carried by address, so
4
+ * neither engine needs to know about the other.
5
+ *
6
+ * - **context**: a reaction (`run(inherited, fn)` at `max(current(), inherited)`).
7
+ * - **write**: `write(target, name, fn)` at `current() + 1`, stamped on
8
+ * `(target, name)` (attribute, property, binding, or `"content"`).
9
+ * - **trigger**: the next reaction inherits `depthOf(target, name)`.
10
+ *
11
+ * Stamps last this task + its microtasks. A later external write (input,
12
+ * timer, fetch, app JS) starts over at depth 0. Past `limit` the write is
13
+ * dropped and reported once; the document is left as-is.
14
+ *
15
+ * Unguarded writes (`setAttribute` in app JS) neither stamp nor drop.
16
+ * A loop that never hits `write()` is invisible; one that does is cut there.
17
+ */
18
+
19
+ export type LoopGuardTripKind = "depth" | "batch";
20
+
21
+ export interface LoopGuardTrip {
22
+ /**
23
+ * `"depth"`: a causal chain of writes exceeded `limit` hops (the write
24
+ * was dropped). `"batch"`: one `BatchManager` handler re-ran more than
25
+ * `limit` times inside a single synchronous flush (the queue was
26
+ * dropped).
27
+ */
28
+ kind: LoopGuardTripKind;
29
+ /** The element / object the dropped write targeted. */
30
+ target: object;
31
+ /** Attribute, property or binding name (`"content"` for child insertions). */
32
+ name: string;
33
+ /** Depth (or run count) that crossed the limit. */
34
+ depth: number;
35
+ limit: number;
36
+ /** Human-readable summary, also passed to the log function. */
37
+ message: string;
38
+ }
39
+
40
+ export type LoopGuardListener = (trip: LoopGuardTrip) => void;
41
+ export type LoopGuardLog = (message: string, trip: LoopGuardTrip) => void;
42
+
43
+ interface Stamp {
44
+ depth: number;
45
+ epoch: number;
46
+ }
47
+
48
+ const DEFAULT_LIMIT = 50;
49
+ const defaultLog: LoopGuardLog = (message) => console.error(message);
50
+
51
+ const STAMPS = new WeakMap<object, Map<string, Stamp>>();
52
+ const stack: number[] = [];
53
+ const listeners = new Set<LoopGuardListener>();
54
+ /** name → epoch of its last report; one report per name per task. */
55
+ const reported = new Map<string, number>();
56
+ let epoch = 1;
57
+ let bumpScheduled = false;
58
+ let limit = DEFAULT_LIMIT;
59
+ let log: LoopGuardLog = defaultLog;
60
+
61
+ /** Stamps expire at the next macrotask; one timer per task, only when something was stamped. */
62
+ const scheduleEpochBump = () => {
63
+ if (bumpScheduled) return;
64
+ bumpScheduled = true;
65
+ setTimeout(() => {
66
+ bumpScheduled = false;
67
+ epoch++;
68
+ }, 0);
69
+ };
70
+
71
+ const describeTarget = (target: object) =>
72
+ target instanceof Element
73
+ ? `<${target.localName}${target.id ? `#${target.id}` : ""}>`
74
+ : (target?.constructor?.name ?? typeof target);
75
+
76
+ export const LoopGuard = {
77
+ /** Maximum causal depth (and maximum handler re-runs per batch flush). */
78
+ get limit(): number {
79
+ return limit;
80
+ },
81
+
82
+ /** Adjust the limit and/or the log function (defaults: 50, `console.error`). */
83
+ configure(options: { limit?: number; log?: LoopGuardLog } = {}): void {
84
+ if (typeof options.limit === "number" && options.limit > 0) {
85
+ limit = Math.floor(options.limit);
86
+ }
87
+ if (options.log) log = options.log;
88
+ },
89
+
90
+ /** Depth of the innermost open context (0 outside any context). */
91
+ current(): number {
92
+ return stack.length ? stack[stack.length - 1] : 0;
93
+ },
94
+
95
+ /**
96
+ * Depth stamped on `(target, name)` by a guarded write earlier in this
97
+ * task, or 0 when nothing (or something in an earlier task) wrote it.
98
+ * Triggers call this to inherit the chain they continue.
99
+ */
100
+ depthOf(target: object, name: string): number {
101
+ const stamp = STAMPS.get(target)?.get(name);
102
+ return stamp && stamp.epoch === epoch ? stamp.depth : 0;
103
+ },
104
+
105
+ /**
106
+ * Open a context at `max(current(), inherited)` for the duration of `fn`.
107
+ * Pass a depth captured with `current()` to carry a context across an
108
+ * async boundary the engine itself introduces (a deferred paint, a
109
+ * `setTimeout(0)` effect).
110
+ */
111
+ run<T>(inherited: number, fn: () => T): T {
112
+ stack.push(Math.max(LoopGuard.current(), inherited));
113
+ try {
114
+ return fn();
115
+ } finally {
116
+ stack.pop();
117
+ }
118
+ },
119
+
120
+ /**
121
+ * Record that `(target, name)` was written at `depth` (default: the depth
122
+ * a `write()` would use) without applying a limit. For writers that want
123
+ * to be *visible* to the chain but never dropped.
124
+ */
125
+ stamp(target: object, name: string, depth = LoopGuard.current() + 1): void {
126
+ let names = STAMPS.get(target);
127
+ if (!names) STAMPS.set(target, (names = new Map()));
128
+ names.set(name, { depth, epoch });
129
+ scheduleEpochBump();
130
+ },
131
+
132
+ /**
133
+ * Perform a write as the next hop of the current chain: stamps
134
+ * `(target, name)` at `current() + 1`, then runs `fn` and returns its
135
+ * result. Past the limit the write is **not** performed, the trip is
136
+ * reported, and `false` is returned.
137
+ */
138
+ write<T>(target: object, name: string, fn: () => T): T | false {
139
+ const depth = LoopGuard.current() + 1;
140
+ if (depth > limit) {
141
+ LoopGuard.report({
142
+ kind: "depth",
143
+ target,
144
+ name,
145
+ depth,
146
+ limit,
147
+ message: `Loop guard: a chain of ${depth} dependent writes reached "${name}" on ${describeTarget(target)} — likely an infinite loop between rules, element effects or events; the write was dropped.`,
148
+ });
149
+ return false;
150
+ }
151
+ LoopGuard.stamp(target, name, depth);
152
+ return fn();
153
+ },
154
+
155
+ /**
156
+ * Report a trip: logs once per name per task and notifies listeners
157
+ * (every time). Engines use listeners to publish to DevTools.
158
+ */
159
+ report(trip: LoopGuardTrip): void {
160
+ if (reported.get(trip.name) !== epoch) {
161
+ reported.set(trip.name, epoch);
162
+ scheduleEpochBump();
163
+ log(trip.message, trip);
164
+ }
165
+ listeners.forEach((listener) => listener(trip));
166
+ },
167
+
168
+ /** Subscribe to trips; returns the unsubscribe function. */
169
+ onTrip(listener: LoopGuardListener): () => void {
170
+ listeners.add(listener);
171
+ return () => {
172
+ listeners.delete(listener);
173
+ };
174
+ },
175
+
176
+ /**
177
+ * Test helper: forget open contexts and stamps, restore the default
178
+ * limit and log. Listeners are kept (engines register theirs once).
179
+ */
180
+ reset(): void {
181
+ stack.length = 0;
182
+ reported.clear();
183
+ epoch++;
184
+ limit = DEFAULT_LIMIT;
185
+ log = defaultLog;
186
+ },
187
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@excom/kit-utils",
3
+ "version": "0.1.0",
4
+ "description": "kit-utils library",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=24.13.0"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "pathval": "^2.0.0"
12
+ },
13
+ "peerDependencies": {},
14
+ "devDependencies": {
15
+ "@excom/heft-rig": "^0.1.0"
16
+ },
17
+ "repository": {
18
+ "url": "excom-dev/nucleus",
19
+ "directory": "packages/kit-utils"
20
+ },
21
+ "homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/kit-utils/support/docs/README.md",
22
+ "bugs": "https://github.com/excom-dev/nucleus/issues",
23
+ "keywords": [
24
+ "kit-utils"
25
+ ],
26
+ "excom": {
27
+ "documented": false,
28
+ "packageType": "library"
29
+ },
30
+ "scripts": {
31
+ "build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
32
+ "build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
33
+ "format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
34
+ "test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
35
+ "coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs",
36
+ "dev": "node node_modules/@excom/heft-rig/scripts/vite-dev.mjs",
37
+ "preview": "node node_modules/@excom/heft-rig/scripts/vite-preview.mjs"
38
+ }
39
+ }
package/property.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Observable properties. Neutron defines them with
3
+ * `defineObservableProperty`; Quark subscribes with `observeProperty`
4
+ * (any element: Neutron, third-party, or native).
5
+ *
6
+ * One own accessor per (target, name), always outermost:
7
+ * - `observeProperty` wraps whatever is there (own accessor, data
8
+ * property, or inherited prototype accessor).
9
+ * - `defineObservableProperty` installs the *base* getter/setter. If a
10
+ * wrapper is already on, the base is swapped under it so subscriptions
11
+ * survive upgrade.
12
+ *
13
+ * Notify after the base setter, only on `!==`. In-place object mutation
14
+ * is not a write. Last subscriber off restores the base if the wrapper
15
+ * is still the own descriptor.
16
+ */
17
+
18
+ import { LoopGuard } from "./loop-guard";
19
+
20
+ export type PropertyObserver = (value: unknown, oldValue: unknown) => void;
21
+
22
+ export interface BaseDescriptor {
23
+ get?: (this: object) => unknown;
24
+ set?: (this: object, value: unknown) => void;
25
+ enumerable?: boolean;
26
+ }
27
+
28
+ interface Entry {
29
+ base: BaseDescriptor;
30
+ /**
31
+ * How to put the property back when the wrapper is removed: an own
32
+ * descriptor to redefine, `"absent"` when nothing existed before (the
33
+ * assigned value, if any, becomes a plain data property), or `null` for
34
+ * an inherited accessor (the own shadow is simply removed).
35
+ */
36
+ restore: PropertyDescriptor | "absent" | null;
37
+ subscribers: Set<PropertyObserver>;
38
+ get: () => unknown;
39
+ set: (value: unknown) => void;
40
+ }
41
+
42
+ const REGISTRY = new WeakMap<object, Map<string, Entry>>();
43
+
44
+ const entries = (target: object): Map<string, Entry> => {
45
+ let map = REGISTRY.get(target);
46
+ if (!map) REGISTRY.set(target, (map = new Map()));
47
+ return map;
48
+ };
49
+
50
+ const findDescriptor = (
51
+ target: object,
52
+ name: string
53
+ ): { descriptor: PropertyDescriptor; own: boolean } | null => {
54
+ const own = Object.getOwnPropertyDescriptor(target, name);
55
+ if (own) return { descriptor: own, own: true };
56
+ for (
57
+ let proto = Object.getPrototypeOf(target);
58
+ proto;
59
+ proto = Object.getPrototypeOf(proto)
60
+ ) {
61
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name);
62
+ if (descriptor) return { descriptor, own: false };
63
+ }
64
+ return null;
65
+ };
66
+
67
+ const isDataDescriptor = (d: PropertyDescriptor) =>
68
+ "value" in d || "writable" in d;
69
+
70
+ /** A base that stores in a closure slot (for data properties / absent). */
71
+ const slotBase = (value: unknown, writable: boolean): BaseDescriptor => {
72
+ let slot = value;
73
+ return {
74
+ get: () => slot,
75
+ set: writable
76
+ ? (next) => {
77
+ slot = next;
78
+ }
79
+ : undefined,
80
+ enumerable: true,
81
+ };
82
+ };
83
+
84
+ /** The wrapper's own descriptor, identity is how we recognize ourselves. */
85
+ const isWrapper = (target: object, name: string, entry: Entry) => {
86
+ const own = Object.getOwnPropertyDescriptor(target, name);
87
+ return !!own && own.get === entry.get && own.set === entry.set;
88
+ };
89
+
90
+ const install = (target: object, name: string): Entry | null => {
91
+ const found = findDescriptor(target, name);
92
+ let base: BaseDescriptor;
93
+ let restore: Entry["restore"];
94
+ if (!found) {
95
+ base = slotBase(undefined, true);
96
+ restore = "absent";
97
+ } else if (isDataDescriptor(found.descriptor)) {
98
+ if (found.descriptor.writable === false) return null;
99
+ base = slotBase(found.descriptor.value, true);
100
+ base.enumerable = found.descriptor.enumerable;
101
+ // an own data property is restored with its current value; an
102
+ // inherited one (rare) simply gets the own shadow removed
103
+ restore = found.own ? { ...found.descriptor } : null;
104
+ } else {
105
+ if (!found.descriptor.set) return null;
106
+ base = {
107
+ get: found.descriptor.get,
108
+ set: found.descriptor.set,
109
+ enumerable: found.descriptor.enumerable,
110
+ };
111
+ restore = found.own ? { ...found.descriptor } : null;
112
+ }
113
+ if (found?.own && found.descriptor.configurable === false) return null;
114
+ const entry: Entry = {
115
+ base,
116
+ restore,
117
+ subscribers: new Set(),
118
+ get() {
119
+ return entry.base.get?.call(target);
120
+ },
121
+ set(value: unknown) {
122
+ const old = entry.base.get?.call(target);
123
+ // an observed assignment is one causal hop; past the loop guard's
124
+ // limit it is dropped (the chain that led here is a runaway loop)
125
+ const applied = LoopGuard.write(target, name, () => {
126
+ entry.base.set?.call(target, value);
127
+ return true;
128
+ });
129
+ if (applied === false) return;
130
+ const next = entry.base.get?.call(target);
131
+ if (next !== old) {
132
+ entry.subscribers.forEach((subscriber) => subscriber(next, old));
133
+ }
134
+ },
135
+ };
136
+ Object.defineProperty(target, name, {
137
+ get: entry.get,
138
+ set: entry.set,
139
+ enumerable: base.enumerable ?? false,
140
+ configurable: true,
141
+ });
142
+ entries(target).set(name, entry);
143
+ return entry;
144
+ };
145
+
146
+ const uninstall = (target: object, name: string, entry: Entry) => {
147
+ entries(target).delete(name);
148
+ if (!isWrapper(target, name, entry)) return; // someone redefined it
149
+ const restore = entry.restore;
150
+ if (!restore || restore === "absent") {
151
+ const value = entry.base.get?.call(target);
152
+ delete (target as Record<string, unknown>)[name];
153
+ if (restore === "absent" && value !== undefined) {
154
+ (target as Record<string, unknown>)[name] = value;
155
+ }
156
+ return;
157
+ }
158
+ if (isDataDescriptor(restore)) {
159
+ Object.defineProperty(target, name, {
160
+ ...restore,
161
+ value: entry.base.get?.call(target),
162
+ });
163
+ } else {
164
+ Object.defineProperty(target, name, {
165
+ get: entry.base.get,
166
+ set: entry.base.set,
167
+ enumerable: restore.enumerable,
168
+ configurable: true,
169
+ });
170
+ }
171
+ };
172
+
173
+ /**
174
+ * Observe assignments to `target[name]`. Returns an unsubscribe function.
175
+ * Read-only properties (no setter / non-writable) cannot change by
176
+ * assignment, so nothing is installed and the returned function is a no-op.
177
+ */
178
+ export const observeProperty = (
179
+ target: object,
180
+ name: string,
181
+ observer: PropertyObserver
182
+ ): (() => void) => {
183
+ const entry = entries(target).get(name) ?? install(target, name);
184
+ if (!entry) return () => {};
185
+ entry.subscribers.add(observer);
186
+ return () => {
187
+ if (!entry.subscribers.delete(observer)) return;
188
+ if (entry.subscribers.size === 0) uninstall(target, name, entry);
189
+ };
190
+ };
191
+
192
+ /**
193
+ * Define the storage-owning accessor of `target[name]`. Keeps an installed
194
+ * observer wrapper on top (its base is swapped), so an element upgrading
195
+ * after a sheet subscribed keeps that subscription.
196
+ */
197
+ export const defineObservableProperty = (
198
+ target: object,
199
+ name: string,
200
+ descriptor: BaseDescriptor
201
+ ): void => {
202
+ const entry = entries(target).get(name);
203
+ if (entry && isWrapper(target, name, entry)) {
204
+ entry.base = descriptor;
205
+ // no slot to keep: the new base owns storage; nothing to restore to
206
+ // but the base itself
207
+ entry.restore = {
208
+ get: descriptor.get,
209
+ set: descriptor.set,
210
+ enumerable: descriptor.enumerable ?? false,
211
+ configurable: true,
212
+ };
213
+ return;
214
+ }
215
+ Object.defineProperty(target, name, {
216
+ get: descriptor.get,
217
+ set: descriptor.set,
218
+ enumerable: descriptor.enumerable ?? false,
219
+ configurable: true,
220
+ });
221
+ };
222
+
223
+ /** Whether `target[name]` currently has observers. */
224
+ export const isObservedProperty = (target: object, name: string): boolean =>
225
+ (REGISTRY.get(target)?.get(name)?.subscribers.size ?? 0) > 0;