kerfjs 4.1.1 → 4.2.0-beta.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/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ - **`trustedRaw(html)`** (main barrel) — the intention-revealing, lint-exempt escape hatch for injecting a **server-trusted dynamic** value (a CSRF token, a trusted `<script src>`, a server-issued id). Identical to `raw()` at runtime, but because it isn't named `raw`, the `kerfjs/no-raw-with-dynamic-arg` rule leaves it alone — replacing scattered `eslint-disable` comments with one explicit call. Not a sanitizer; only pass values you control.
10
+ - **`renderDocument(node, options?)`** (main barrel) — a tiny SSR helper that prepends the doctype to a rendered document, so server routes stop reinventing `"<!DOCTYPE html>" + page.toString()`. Takes a `SafeHtml` or string; optional `{ doctype }` (default `'html'`). Pure string work, no DOM dependency.
11
+ - **New `kerfjs/list` subpath** — `bindList(parent, source, options)`, a keyed list distinct from `each()`: each row is individually `mount()`ed, so a signal a row reads updates just that row (fine-grained, no full-list pass), and it can **virtualize** the viewport (`virtualize: { rowHeight }` renders only visible rows, padding keeps `scrollHeight` honest). `source` is a `signal<readonly T[]>` or an `arraySignal<T>` — a non-virtualized `arraySignal` source applies its structural patches **granularly** (O(patches)), everything else uses a keyed diff (transparent optimization). Reach for it for surgical per-row updates or long/windowed lists; `each()` stays the default for item-owned-state lists rendered to HTML strings. Optional and tree-shakeable.
12
+ - **New `kerfjs/async` subpath** — `resource<T>()` models async state (`{ status, data, error, progress }`) with the stale-response guard built in. You write the fetch (Node `fetch` for SSR, browser `fetch` client-side); `.run(fetcher)` drives `idle` → `running` → `completed`/`failed` and drops out-of-order responses (only the latest run resolves the state). It never rejects — a failure lands in `value.error` — keeps previous data across a re-run (stale-while-revalidate), and supports opt-in progress via a callback the fetcher receives. `value` is a tracking read. Signals only (no render core); tiny.
13
+ - **New `kerfjs/scope` subpath** — tie disposers to a DOM element's lifetime, so append-heavy UIs stop leaking detached-but-subscribed effects/listeners. `disposeScope(el)` returns a WeakMap-keyed, accumulating scope whose `add(disposer)` (plus convenience `mount` / `effect` / `delegate` wrappers that register their own disposer) collects teardown; `dispose()` runs it all best-effort and idempotently. `disposeSubtree(root)` sweeps a subtree before removal; `observeRemovals(root)` installs one `MutationObserver` that auto-disposes on removal. No module-level mutable state. Optional and tree-shakeable.
14
+ - **New `kerfjs/overlay` subpath** — the blessed modal/overlay + dismiss manager that every real kerf app hand-rolls. `overlay(content, options?)` appends a wrapper, `mount()`s content inside it (owning the disposal), wires dismissals (Escape / backdrop / outside-click, with `outsideIgnore`), a focus trap (`role="dialog"` / `aria-modal`, Tab wrap-around, restore-focus-on-close), and returns `{ el, close(result?), result }`. `confirm(message, options?)` is a promise-based `window.confirm` replacement (that global is a no-op in Tauri webviews); `toast(content, options?)` is an auto-dismissing notification. Structural only — kerf ships no CSS. Optional and tree-shakeable; shares the core with the main barrel via code-splitting.
15
+ - **New `kerfjs/actions` subpath** — the blessed delegated action-table helper. `action(value)` returns a `data-action` `AttrSpec` (a thin specialization of `attr()`); `delegateActions(root, eventType, table, options?)` wires a whole table of `data-action` handlers with one delegated listener (built on `delegate()`) and returns a disposer. Formalizes the most-reinvented idiom in real kerf apps — one `attr('data-action', …)` table as the single source of truth for both the JSX attribute and the delegate dispatch. Optional and tree-shakeable; adds nothing to the main barrel.
16
+
9
17
  ## [4.1.1] - 2026-08-14
10
18
 
11
19
 
@@ -0,0 +1,72 @@
1
+ import { A as AttrSpec } from './attrSelector-Cmu2ZoGO.js';
2
+ import { D as DelegateOptions } from './delegate-CL9VTZFb.js';
3
+
4
+ /**
5
+ * `kerfjs/actions` — the delegated action-table helper.
6
+ *
7
+ * The most-reinvented idiom across real kerf apps: one table of `data-action`
8
+ * attribute specs used as the single source of truth for BOTH the JSX attribute
9
+ * and the delegate selector, plus a hand-rolled `switch (dataset.action)`
10
+ * dispatcher. This subpath blesses it as two thin helpers over the existing
11
+ * `attr()` + `delegate()` — it does NOT replace them.
12
+ *
13
+ * import { action, delegateActions } from 'kerfjs/actions';
14
+ *
15
+ * const A = {
16
+ * select: action('select-file'),
17
+ * remove: action('remove-file'),
18
+ * };
19
+ *
20
+ * // JSX — spread the attr (rename-safe; no hardcoded attribute name):
21
+ * // <button {...A.select.attrs} data-id={id}>…</button>
22
+ *
23
+ * // Wire the whole table with ONE delegated listener; returns a disposer:
24
+ * const dispose = delegateActions(root, 'click', {
25
+ * [A.select.value]: (_e, el) => selectFile(el.getAttribute('data-id')),
26
+ * [A.remove.value]: (_e, el) => removeFile(el.getAttribute('data-id')),
27
+ * });
28
+ *
29
+ * Contract: `delegateActions` returns a `() => void` disposer and holds no
30
+ * per-instance state — the same shape as `delegate()`, which it builds on (so
31
+ * it inherits the single-listener dispatch and the capture auto-promotion for
32
+ * well-known non-bubbling event types). One event type per call, mirroring
33
+ * `delegate()`; collect the disposers for a root that needs several.
34
+ */
35
+
36
+ /**
37
+ * A handler in a {@link delegateActions} table. Receives the DOM event and the
38
+ * matched element (walk-up `closest()` match by default) — the same shape as a
39
+ * `delegate()` handler.
40
+ */
41
+ type ActionHandler<E extends Element = Element> = (event: Event, el: E) => void;
42
+ /**
43
+ * `action(value)` — an {@link AttrSpec} on `data-action`. A thin specialization
44
+ * of `attr('data-action', value)`: spread its `.attrs` in JSX and use its
45
+ * `.value` as the handler-table key, so the action name lives in exactly one
46
+ * place and can't drift between the markup and the dispatcher.
47
+ */
48
+ declare function action<V extends string>(value: V): AttrSpec<'data-action', V>;
49
+ /** Options for {@link delegateActions}. Extends {@link DelegateOptions}. */
50
+ interface DelegateActionsOptions extends DelegateOptions {
51
+ /**
52
+ * The attribute the table keys on. Default `'data-action'`. Override it only
53
+ * if you also author the specs with `attr(yourName, …)` instead of `action()`.
54
+ */
55
+ attr?: string;
56
+ }
57
+ /**
58
+ * Wire a whole table of action handlers with ONE delegated listener.
59
+ *
60
+ * On `eventType`, the nearest element carrying the action attribute (walk-up
61
+ * `closest()` by default; pass `{ match: 'direct' }` for an exact-element match)
62
+ * is looked up in `table` by its attribute value, and the matching handler
63
+ * runs. An element whose action is absent from the table is ignored — the same
64
+ * behavior as a `switch (dataset.action)` with no matching `case`.
65
+ *
66
+ * Returns a `() => void` disposer. One event type per call (the smallest
67
+ * surface, mirroring `delegate()`); collect the disposers when a root needs
68
+ * several event types.
69
+ */
70
+ declare function delegateActions<E extends Element = Element>(root: HTMLElement, eventType: string, table: Readonly<Record<string, ActionHandler<E>>>, options?: DelegateActionsOptions): () => void;
71
+
72
+ export { type ActionHandler, type DelegateActionsOptions, action, delegateActions };
@@ -0,0 +1,26 @@
1
+ import { attr } from './chunk-U32TFTGZ.js';
2
+ import { delegate } from './chunk-KEZTD6H4.js';
3
+ import './chunk-VVDJLWMP.js';
4
+
5
+ // src/actions.ts
6
+ var DEFAULT_ACTION_ATTR = "data-action";
7
+ function action(value) {
8
+ return attr(DEFAULT_ACTION_ATTR, value);
9
+ }
10
+ function delegateActions(root, eventType, table, options) {
11
+ const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;
12
+ return delegate(
13
+ root,
14
+ eventType,
15
+ `[${attrName}]`,
16
+ (event, el) => {
17
+ const handler = table[el.getAttribute(attrName)];
18
+ if (handler !== void 0) handler(event, el);
19
+ },
20
+ options
21
+ );
22
+ }
23
+
24
+ export { action, delegateActions };
25
+ //# sourceMappingURL=actions.js.map
26
+ //# sourceMappingURL=actions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/actions.ts"],"names":[],"mappings":";;;;;AAmCA,IAAM,mBAAA,GAAsB,aAAA;AAerB,SAAS,OAAyB,KAAA,EAAsC;AAC7E,EAAA,OAAO,IAAA,CAAK,qBAAqB,KAAK,CAAA;AACxC;AAwBO,SAAS,eAAA,CACd,IAAA,EACA,SAAA,EACA,KAAA,EACA,OAAA,EACY;AACZ,EAAA,MAAM,QAAA,GAAW,SAAS,IAAA,IAAQ,mBAAA;AAClC,EAAA,OAAO,QAAA;AAAA,IACL,IAAA;AAAA,IACA,SAAA;AAAA,IACA,IAAI,QAAQ,CAAA,CAAA,CAAA;AAAA,IACZ,CAAC,OAAO,EAAA,KAAO;AAEb,MAAA,MAAM,OAAA,GAAU,KAAA,CAAM,EAAA,CAAG,YAAA,CAAa,QAAQ,CAAW,CAAA;AACzD,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AAAA,IAC9C,CAAA;AAAA,IACA;AAAA,GACF;AACF","file":"actions.js","sourcesContent":["/**\n * `kerfjs/actions` — the delegated action-table helper.\n *\n * The most-reinvented idiom across real kerf apps: one table of `data-action`\n * attribute specs used as the single source of truth for BOTH the JSX attribute\n * and the delegate selector, plus a hand-rolled `switch (dataset.action)`\n * dispatcher. This subpath blesses it as two thin helpers over the existing\n * `attr()` + `delegate()` — it does NOT replace them.\n *\n * import { action, delegateActions } from 'kerfjs/actions';\n *\n * const A = {\n * select: action('select-file'),\n * remove: action('remove-file'),\n * };\n *\n * // JSX — spread the attr (rename-safe; no hardcoded attribute name):\n * // <button {...A.select.attrs} data-id={id}>…</button>\n *\n * // Wire the whole table with ONE delegated listener; returns a disposer:\n * const dispose = delegateActions(root, 'click', {\n * [A.select.value]: (_e, el) => selectFile(el.getAttribute('data-id')),\n * [A.remove.value]: (_e, el) => removeFile(el.getAttribute('data-id')),\n * });\n *\n * Contract: `delegateActions` returns a `() => void` disposer and holds no\n * per-instance state — the same shape as `delegate()`, which it builds on (so\n * it inherits the single-listener dispatch and the capture auto-promotion for\n * well-known non-bubbling event types). One event type per call, mirroring\n * `delegate()`; collect the disposers for a root that needs several.\n */\nimport { attr, type AttrSpec } from './attrSelector.js';\nimport { delegate, type DelegateOptions } from './delegate.js';\n\n/** The attribute an action table keys on by default. */\nconst DEFAULT_ACTION_ATTR = 'data-action';\n\n/**\n * A handler in a {@link delegateActions} table. Receives the DOM event and the\n * matched element (walk-up `closest()` match by default) — the same shape as a\n * `delegate()` handler.\n */\nexport type ActionHandler<E extends Element = Element> = (event: Event, el: E) => void;\n\n/**\n * `action(value)` — an {@link AttrSpec} on `data-action`. A thin specialization\n * of `attr('data-action', value)`: spread its `.attrs` in JSX and use its\n * `.value` as the handler-table key, so the action name lives in exactly one\n * place and can't drift between the markup and the dispatcher.\n */\nexport function action<V extends string>(value: V): AttrSpec<'data-action', V> {\n return attr(DEFAULT_ACTION_ATTR, value);\n}\n\n/** Options for {@link delegateActions}. Extends {@link DelegateOptions}. */\nexport interface DelegateActionsOptions extends DelegateOptions {\n /**\n * The attribute the table keys on. Default `'data-action'`. Override it only\n * if you also author the specs with `attr(yourName, …)` instead of `action()`.\n */\n attr?: string;\n}\n\n/**\n * Wire a whole table of action handlers with ONE delegated listener.\n *\n * On `eventType`, the nearest element carrying the action attribute (walk-up\n * `closest()` by default; pass `{ match: 'direct' }` for an exact-element match)\n * is looked up in `table` by its attribute value, and the matching handler\n * runs. An element whose action is absent from the table is ignored — the same\n * behavior as a `switch (dataset.action)` with no matching `case`.\n *\n * Returns a `() => void` disposer. One event type per call (the smallest\n * surface, mirroring `delegate()`); collect the disposers when a root needs\n * several event types.\n */\nexport function delegateActions<E extends Element = Element>(\n root: HTMLElement,\n eventType: string,\n table: Readonly<Record<string, ActionHandler<E>>>,\n options?: DelegateActionsOptions,\n): () => void {\n const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;\n return delegate<E>(\n root,\n eventType,\n `[${attrName}]`,\n (event, el) => {\n // `el` matched `[${attrName}]`, so the attribute is always present.\n const handler = table[el.getAttribute(attrName) as string];\n if (handler !== undefined) handler(event, el);\n },\n options,\n );\n}\n"]}
@@ -1,107 +1,6 @@
1
- import { bumpItemVersion } from './chunk-QIP723L4.js';
2
- import { signal } from './chunk-3APBEVHF.js';
1
+ export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-MRYM3O3V.js';
2
+ import './chunk-QIP723L4.js';
3
+ import './chunk-3APBEVHF.js';
3
4
  import './chunk-VVDJLWMP.js';
4
-
5
- // src/array-signal.ts
6
- var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
7
- var ArraySignal = class {
8
- _items;
9
- _version;
10
- _patches;
11
- // Branded so `isArraySignal()` recognizes instances from any copy of this module.
12
- [ARRAY_SIGNAL_BRAND] = true;
13
- constructor(initial = []) {
14
- this._items = [...initial];
15
- this._version = signal(0);
16
- this._patches = [];
17
- }
18
- /** Read-only snapshot. Reads inside an effect/computed register a dependency. */
19
- get value() {
20
- void this._version.value;
21
- return this._items;
22
- }
23
- /**
24
- * Replace the item at `index` with `fn(currentItem)`. Emits one `update`
25
- * patch. Both styles work: returning a fresh object (idiomatic) invalidates
26
- * the row by identity, and mutating `item` in place and returning it works
27
- * too — a per-item content version (KF-418) makes the same-ref change visible
28
- * to every consumer's row memo.
29
- */
30
- update(index, fn) {
31
- if (index < 0 || index >= this._items.length) {
32
- throw new Error(
33
- `arraySignal.update: index ${index} out of bounds [0, ${this._items.length}).`
34
- );
35
- }
36
- const next = fn(this._items[index]);
37
- this._items[index] = next;
38
- this._patches.push({ type: "update", index, item: next });
39
- bumpItemVersion(next);
40
- this._version.value++;
41
- }
42
- /** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */
43
- insert(index, item) {
44
- if (index < 0 || index > this._items.length) {
45
- throw new Error(
46
- `arraySignal.insert: index ${index} out of bounds [0, ${this._items.length}].`
47
- );
48
- }
49
- this._items.splice(index, 0, item);
50
- this._patches.push({ type: "insert", index, item });
51
- this._version.value++;
52
- }
53
- /** Append `item` at the end. Sugar for `insert(items.length, item)`. */
54
- push(item) {
55
- this.insert(this._items.length, item);
56
- }
57
- /** Remove and return the item at `index`. Emits one `remove` patch. */
58
- remove(index) {
59
- if (index < 0 || index >= this._items.length) {
60
- throw new Error(
61
- `arraySignal.remove: index ${index} out of bounds [0, ${this._items.length}).`
62
- );
63
- }
64
- const [removed] = this._items.splice(index, 1);
65
- this._patches.push({ type: "remove", index });
66
- this._version.value++;
67
- return removed;
68
- }
69
- /** Move the item at `from` to position `to`. Emits one `move` patch (no-op when from === to). */
70
- move(from, to) {
71
- if (from === to) return;
72
- if (from < 0 || from >= this._items.length || to < 0 || to >= this._items.length) {
73
- throw new Error(
74
- `arraySignal.move: indices out of bounds (from=${from}, to=${to}, length=${this._items.length}).`
75
- );
76
- }
77
- const [item] = this._items.splice(from, 1);
78
- this._items.splice(to, 0, item);
79
- this._patches.push({ type: "move", from, to });
80
- this._version.value++;
81
- }
82
- /** Replace every item. Emits one `replace` patch — the granular reconciler falls back to a full keyed diff for this case. */
83
- replace(items) {
84
- this._items = [...items];
85
- this._patches.push({ type: "replace", items: this._items });
86
- this._version.value++;
87
- }
88
- /**
89
- * @internal Used by `each()` when binding this signal to a list. Returns
90
- * the queue of granular patches issued since the previous call, then
91
- * clears the queue. Best paired with a single binding — a second consumer
92
- * in the same render gets an empty array (which forces the snapshot
93
- * fall-back path, which is correct but slower).
94
- */
95
- _consumePatches() {
96
- const out = this._patches;
97
- this._patches = [];
98
- return out;
99
- }
100
- };
101
- function arraySignal(initial = []) {
102
- return new ArraySignal(initial);
103
- }
104
-
105
- export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal };
106
5
  //# sourceMappingURL=array-signal.js.map
107
6
  //# sourceMappingURL=array-signal.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/array-signal.ts"],"names":[],"mappings":"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","file":"array-signal.js","sourcesContent":["/**\n * `arraySignal(initial)` — granular collection signal.\n *\n * A keyed-list-friendly variant of `signal()` that emits typed patch events\n * for every mutation (update / insert / remove / move / replace). When such\n * a signal is bound to `each(...)` inside a `mount()`, the keyed list\n * reconciler applies just the patches against the live DOM — no per-item\n * iteration, no `classifyItems` Map build, no LIS pass over unchanged rows.\n *\n * const rows = arraySignal<Row>([]);\n *\n * rows.update(42, (r) => ({ ...r, label: 'changed' })); // 1 update event\n * rows.insert(0, { id: 'x', ... }); // 1 insert event\n * rows.remove(7); // 1 remove event\n * rows.move(3, 0); // 1 move event\n * rows.replace([...]); // falls back to snapshot reconcile\n *\n * Read-side semantics match a regular signal: `arraySig.value` is a\n * snapshot, and reads inside `effect()` / `computed()` register as\n * dependencies, so derived values keep working.\n */\n\nimport { bumpItemVersion } from './item-version.js';\nimport type { Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\n/** A single granular mutation event. */\nexport type ArrayPatch<T> =\n | { type: 'update'; index: number; item: T }\n | { type: 'insert'; index: number; item: T }\n | { type: 'remove'; index: number }\n | { type: 'move'; from: number; to: number }\n | { type: 'replace'; items: readonly T[] };\n\n/**\n * Cross-bundle brand for `ArraySignal` instances. `each()` and the\n * granular reconciler check for this brand instead of `instanceof\n * ArraySignal`, so the main `kerfjs` barrel can detect arraySignal\n * inputs without importing the class at runtime — the class lives\n * only in the `kerfjs/array-signal` subpath, so apps that don't need\n * granular collections shed ~1 KB.\n *\n * Same `Symbol.for(...)`-based pattern as `SafeHtml` (KF-14): cross-\n * bundle-safe, zero-cost runtime check.\n */\nexport const ARRAY_SIGNAL_BRAND = Symbol.for('kerfjs.ArraySignal');\n\nexport class ArraySignal<T> {\n private _items: T[];\n private _version: Signal<number>;\n private _patches: ArrayPatch<T>[];\n // Branded so `isArraySignal()` recognizes instances from any copy of this module.\n readonly [ARRAY_SIGNAL_BRAND] = true as const;\n\n constructor(initial: readonly T[] = []) {\n this._items = [...initial];\n this._version = signal(0);\n this._patches = [];\n }\n\n /** Read-only snapshot. Reads inside an effect/computed register a dependency. */\n get value(): readonly T[] {\n // Touch the version signal so signals-core treats reads as tracked.\n void this._version.value;\n return this._items;\n }\n\n /**\n * Replace the item at `index` with `fn(currentItem)`. Emits one `update`\n * patch. Both styles work: returning a fresh object (idiomatic) invalidates\n * the row by identity, and mutating `item` in place and returning it works\n * too — a per-item content version (KF-418) makes the same-ref change visible\n * to every consumer's row memo.\n */\n update(index: number, fn: (item: T) => T): void {\n if (index < 0 || index >= this._items.length) {\n throw new Error(\n `arraySignal.update: index ${index} out of bounds [0, ${this._items.length}).`,\n );\n }\n const next = fn(this._items[index]);\n this._items[index] = next;\n this._patches.push({ type: 'update', index, item: next });\n // KF-418: a same-ref update (fn mutates and returns the same object) is\n // invisible to the row memo, which is keyed on object identity. Bump the\n // item's content version so every consumer — this list, another list over\n // this signal, a second mount, a plain-array filter() view — re-renders it.\n // Non-object items (an arraySignal<number> used as a plain signal) are\n // skipped by bumpItemVersion — they can't be each() rows (KF-419).\n bumpItemVersion(next);\n this._version.value++;\n }\n\n /** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */\n insert(index: number, item: T): void {\n if (index < 0 || index > this._items.length) {\n throw new Error(\n `arraySignal.insert: index ${index} out of bounds [0, ${this._items.length}].`,\n );\n }\n this._items.splice(index, 0, item);\n this._patches.push({ type: 'insert', index, item });\n this._version.value++;\n }\n\n /** Append `item` at the end. Sugar for `insert(items.length, item)`. */\n push(item: T): void {\n this.insert(this._items.length, item);\n }\n\n /** Remove and return the item at `index`. Emits one `remove` patch. */\n remove(index: number): T {\n if (index < 0 || index >= this._items.length) {\n throw new Error(\n `arraySignal.remove: index ${index} out of bounds [0, ${this._items.length}).`,\n );\n }\n const [removed] = this._items.splice(index, 1);\n this._patches.push({ type: 'remove', index });\n this._version.value++;\n return removed;\n }\n\n /** Move the item at `from` to position `to`. Emits one `move` patch (no-op when from === to). */\n move(from: number, to: number): void {\n if (from === to) return;\n if (from < 0 || from >= this._items.length || to < 0 || to >= this._items.length) {\n throw new Error(\n `arraySignal.move: indices out of bounds (from=${from}, to=${to}, length=${this._items.length}).`,\n );\n }\n const [item] = this._items.splice(from, 1);\n this._items.splice(to, 0, item);\n this._patches.push({ type: 'move', from, to });\n this._version.value++;\n }\n\n /** Replace every item. Emits one `replace` patch — the granular reconciler falls back to a full keyed diff for this case. */\n replace(items: readonly T[]): void {\n this._items = [...items];\n this._patches.push({ type: 'replace', items: this._items });\n this._version.value++;\n }\n\n /**\n * @internal Used by `each()` when binding this signal to a list. Returns\n * the queue of granular patches issued since the previous call, then\n * clears the queue. Best paired with a single binding — a second consumer\n * in the same render gets an empty array (which forces the snapshot\n * fall-back path, which is correct but slower).\n */\n _consumePatches(): ArrayPatch<T>[] {\n const out = this._patches;\n this._patches = [];\n return out;\n }\n}\n\n/** Construct an array signal seeded with `initial`. */\nexport function arraySignal<T>(initial: readonly T[] = []): ArraySignal<T> {\n return new ArraySignal(initial);\n}\n"]}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"array-signal.js"}
@@ -0,0 +1,41 @@
1
+ /** The lifecycle status of a {@link Resource}. */
2
+ type ResourceStatus = 'idle' | 'running' | 'completed' | 'failed';
3
+ /** Optional progress for a long-running fetch (uploads, chunked work). */
4
+ interface ResourceProgress {
5
+ completed: number;
6
+ total: number;
7
+ }
8
+ /** The reactive state a {@link Resource} exposes. */
9
+ interface ResourceState<T> {
10
+ status: ResourceStatus;
11
+ /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */
12
+ data: T | undefined;
13
+ /** The rejection from the most recent failed run. */
14
+ error: unknown;
15
+ /** Latest reported progress while running, or `undefined`. */
16
+ progress: ResourceProgress | undefined;
17
+ }
18
+ /**
19
+ * The fetcher passed to {@link Resource.run}. You own the transport. It receives
20
+ * a `report(completed, total)` callback for optional progress — ignore it if you
21
+ * don't need progress (a plain `() => Promise<T>` is assignable here).
22
+ */
23
+ type ResourceFetcher<T> = (report: (completed: number, total: number) => void) => Promise<T>;
24
+ /** An async-state container. Its `value` is a tracking read; drive UI off `value.status`. */
25
+ interface Resource<T> {
26
+ /** Tracking read of the current {@link ResourceState}. */
27
+ readonly value: ResourceState<T>;
28
+ /**
29
+ * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding
30
+ * against stale responses (only the latest run resolves the state). Never
31
+ * rejects — a failure lands in `value.error`; resolves with the data (or
32
+ * `undefined` on failure) for callers who want to await it.
33
+ */
34
+ run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;
35
+ /** Reset to `idle` (clearing data/error/progress) and invalidate any in-flight run. */
36
+ reset(): void;
37
+ }
38
+ /** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */
39
+ declare function resource<T>(): Resource<T>;
40
+
41
+ export { type Resource, type ResourceFetcher, type ResourceProgress, type ResourceState, type ResourceStatus, resource };
package/dist/async.js ADDED
@@ -0,0 +1,52 @@
1
+ import { signal } from './chunk-3APBEVHF.js';
2
+ import './chunk-VVDJLWMP.js';
3
+
4
+ // src/async.ts
5
+ var IDLE = () => ({
6
+ status: "idle",
7
+ data: void 0,
8
+ error: void 0,
9
+ progress: void 0
10
+ });
11
+ function resource() {
12
+ const state = signal(IDLE());
13
+ let generation = 0;
14
+ function run(fetcher) {
15
+ const gen = ++generation;
16
+ state.value = { ...state.value, status: "running", error: void 0, progress: void 0 };
17
+ const report = (completed, total) => {
18
+ if (gen === generation) {
19
+ state.value = { ...state.value, progress: { completed, total } };
20
+ }
21
+ };
22
+ return fetcher(report).then(
23
+ (data) => {
24
+ if (gen === generation) {
25
+ state.value = { status: "completed", data, error: void 0, progress: void 0 };
26
+ }
27
+ return data;
28
+ },
29
+ (error) => {
30
+ if (gen === generation) {
31
+ state.value = { ...state.value, status: "failed", error, progress: void 0 };
32
+ }
33
+ return void 0;
34
+ }
35
+ );
36
+ }
37
+ function reset() {
38
+ generation++;
39
+ state.value = IDLE();
40
+ }
41
+ return {
42
+ get value() {
43
+ return state.value;
44
+ },
45
+ run,
46
+ reset
47
+ };
48
+ }
49
+
50
+ export { resource };
51
+ //# sourceMappingURL=async.js.map
52
+ //# sourceMappingURL=async.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/async.ts"],"names":[],"mappings":";;;;AA+DA,IAAM,OAAO,OAA4B;AAAA,EACvC,MAAA,EAAQ,MAAA;AAAA,EACR,IAAA,EAAM,MAAA;AAAA,EACN,KAAA,EAAO,MAAA;AAAA,EACP,QAAA,EAAU;AACZ,CAAA,CAAA;AAGO,SAAS,QAAA,GAA2B;AACzC,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAyB,IAAA,EAAS,CAAA;AAEhD,EAAA,IAAI,UAAA,GAAa,CAAA;AAEjB,EAAA,SAAS,IAAI,OAAA,EAAqD;AAChE,IAAA,MAAM,MAAM,EAAE,UAAA;AACd,IAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,GAAG,KAAA,CAAM,KAAA,EAAO,QAAQ,SAAA,EAAW,KAAA,EAAO,MAAA,EAAW,QAAA,EAAU,MAAA,EAAU;AAEzF,IAAA,MAAM,MAAA,GAAS,CAAC,SAAA,EAAmB,KAAA,KAAwB;AACzD,MAAA,IAAI,QAAQ,UAAA,EAAY;AACtB,QAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,GAAG,KAAA,CAAM,OAAO,QAAA,EAAU,EAAE,SAAA,EAAW,KAAA,EAAM,EAAE;AAAA,MACjE;AAAA,IACF,CAAA;AAEA,IAAA,OAAO,OAAA,CAAQ,MAAM,CAAA,CAAE,IAAA;AAAA,MACrB,CAAC,IAAA,KAAS;AACR,QAAA,IAAI,QAAQ,UAAA,EAAY;AACtB,UAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,MAAA,EAAQ,WAAA,EAAa,MAAM,KAAA,EAAO,MAAA,EAAW,UAAU,MAAA,EAAU;AAAA,QACnF;AACA,QAAA,OAAO,IAAA;AAAA,MACT,CAAA;AAAA,MACA,CAAC,KAAA,KAAmB;AAClB,QAAA,IAAI,QAAQ,UAAA,EAAY;AACtB,UAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,GAAG,KAAA,CAAM,OAAO,MAAA,EAAQ,QAAA,EAAU,KAAA,EAAO,QAAA,EAAU,MAAA,EAAU;AAAA,QAC/E;AACA,QAAA,OAAO,MAAA;AAAA,MACT;AAAA,KACF;AAAA,EACF;AAEA,EAAA,SAAS,KAAA,GAAc;AACrB,IAAA,UAAA,EAAA;AACA,IAAA,KAAA,CAAM,QAAQ,IAAA,EAAQ;AAAA,EACxB;AAEA,EAAA,OAAO;AAAA,IACL,IAAI,KAAA,GAAQ;AACV,MAAA,OAAO,KAAA,CAAM,KAAA;AAAA,IACf,CAAA;AAAA,IACA,GAAA;AAAA,IACA;AAAA,GACF;AACF","file":"async.js","sourcesContent":["/**\n * `kerfjs/async` — model async state, with the stale-response guard built in.\n *\n * Every real kerf app reproduces the same shape — `{ status, data, error }` —\n * for loading/error UI, each paired with a hand-rolled generation counter so a\n * slow response can't overwrite a newer one. This subpath blesses exactly that,\n * and no more: you still write the fetch (Node `fetch` for SSR, browser `fetch`\n * client-side), and `.run()` owns the status transitions plus the stale guard.\n *\n * import { resource } from 'kerfjs/async';\n *\n * const users = resource<User[]>();\n * users.run(() => fetch('/api/users').then((r) => r.json()));\n * // render off users.value.status: 'idle' | 'running' | 'completed' | 'failed'\n *\n * Only the LATEST run may resolve the state, so out-of-order responses are\n * dropped automatically. Optional progress: declare the `report` parameter on\n * your fetcher and call it (e.g. from an upload's progress events).\n */\nimport { signal } from './reactive.js';\n\n/** The lifecycle status of a {@link Resource}. */\nexport type ResourceStatus = 'idle' | 'running' | 'completed' | 'failed';\n\n/** Optional progress for a long-running fetch (uploads, chunked work). */\nexport interface ResourceProgress {\n completed: number;\n total: number;\n}\n\n/** The reactive state a {@link Resource} exposes. */\nexport interface ResourceState<T> {\n status: ResourceStatus;\n /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */\n data: T | undefined;\n /** The rejection from the most recent failed run. */\n error: unknown;\n /** Latest reported progress while running, or `undefined`. */\n progress: ResourceProgress | undefined;\n}\n\n/**\n * The fetcher passed to {@link Resource.run}. You own the transport. It receives\n * a `report(completed, total)` callback for optional progress — ignore it if you\n * don't need progress (a plain `() => Promise<T>` is assignable here).\n */\nexport type ResourceFetcher<T> = (report: (completed: number, total: number) => void) => Promise<T>;\n\n/** An async-state container. Its `value` is a tracking read; drive UI off `value.status`. */\nexport interface Resource<T> {\n /** Tracking read of the current {@link ResourceState}. */\n readonly value: ResourceState<T>;\n /**\n * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding\n * against stale responses (only the latest run resolves the state). Never\n * rejects — a failure lands in `value.error`; resolves with the data (or\n * `undefined` on failure) for callers who want to await it.\n */\n run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /** Reset to `idle` (clearing data/error/progress) and invalidate any in-flight run. */\n reset(): void;\n}\n\nconst IDLE = <T>(): ResourceState<T> => ({\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n});\n\n/** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */\nexport function resource<T>(): Resource<T> {\n const state = signal<ResourceState<T>>(IDLE<T>());\n // Per-resource run counter (closure-local, not module state) — the stale guard.\n let generation = 0;\n\n function run(fetcher: ResourceFetcher<T>): Promise<T | undefined> {\n const gen = ++generation;\n state.value = { ...state.value, status: 'running', error: undefined, progress: undefined };\n\n const report = (completed: number, total: number): void => {\n if (gen === generation) {\n state.value = { ...state.value, progress: { completed, total } };\n }\n };\n\n return fetcher(report).then(\n (data) => {\n if (gen === generation) {\n state.value = { status: 'completed', data, error: undefined, progress: undefined };\n }\n return data;\n },\n (error: unknown) => {\n if (gen === generation) {\n state.value = { ...state.value, status: 'failed', error, progress: undefined };\n }\n return undefined;\n },\n );\n }\n\n function reset(): void {\n generation++; // invalidate any in-flight run\n state.value = IDLE<T>();\n }\n\n return {\n get value() {\n return state.value;\n },\n run,\n reset,\n };\n}\n"]}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `attr(name, value)` — create a pre-computed attribute descriptor (static form).
3
+ * `attr(name)` — create a per-render factory for dynamic attribute values (dynamic form).
4
+ *
5
+ * **Static form** — best for fixed action names, filter keys, role values, etc.
6
+ * Escapes once at module-load time; produces a full {@link AttrSpec} with
7
+ * `.name`, `.value`, `.selector`, and `.attrs`.
8
+ *
9
+ * const ACTIONS = {
10
+ * toggle: attr('data-action', 'toggle'),
11
+ * remove: attr('data-action', 'remove'),
12
+ * } as const satisfies Record<string, AttrSpec<'data-action'>>;
13
+ *
14
+ * // In JSX — spread .attrs (rename-safe; no hardcoded attribute name):
15
+ * <button {...ACTIONS.toggle.attrs}>Toggle</button>
16
+ *
17
+ * // In delegate — use the pre-computed selector:
18
+ * delegate(root, 'click', ACTIONS.toggle.selector, handler);
19
+ *
20
+ * **Dynamic form** — best for per-row data like `data-id`, where the value
21
+ * changes per item but the attribute name is constant.
22
+ * The name is validated and pre-escaped at definition time; calling the
23
+ * returned factory is cheap (it just freezes a one-key object — the value is
24
+ * escaped later by the JSX attribute renderer when the result is spread).
25
+ *
26
+ * const ITEM = { id: attr('data-id') } as const;
27
+ *
28
+ * // In JSX — call the factory inline:
29
+ * <li {...ITEM.id(String(item.id))}>…</li>
30
+ *
31
+ * For ad-hoc compound selectors, concatenate `.selector` strings:
32
+ *
33
+ * delegate(root, 'click',
34
+ * ACTIONS.toggle.selector + attr('data-id', id).selector,
35
+ * handler);
36
+ *
37
+ * Escaping:
38
+ * - Attribute name: escaped as a CSS identifier via `cssEscapeIdent`, which is
39
+ * an SSR-safe (no `CSS.escape`) adaptation of the Mathias Bynens polyfill
40
+ * (https://github.com/mathiasbynens/CSS.escape, MIT licensed — see the
41
+ * Acknowledgements section of LICENSE). Handles
42
+ * control chars, leading digits, non-ASCII, and CSS metacharacters.
43
+ * - Attribute value: embedded in double quotes as a CSS string. Backslashes and
44
+ * double-quote characters are backslash-escaped; control characters are
45
+ * hex-escaped per CSS Syntax Level 3 §3.4.
46
+ *
47
+ * Throws on an empty attribute name (not a valid CSS identifier).
48
+ */
49
+ /** Descriptor created by the static {@link attr} overload. */
50
+ interface AttrSpec<N extends string = string, V extends string = string> {
51
+ /** The raw attribute name passed to `attr()`. */
52
+ readonly name: N;
53
+ /** The raw attribute value passed to `attr()`. */
54
+ readonly value: V;
55
+ /** Pre-computed `[name="value"]` CSS selector string, safe to pass to `delegate()`. */
56
+ readonly selector: string;
57
+ /** Spreadable JSX object — `{ [name]: value }` — keeps the attribute name out of JSX literals. */
58
+ readonly attrs: {
59
+ readonly [K in N]: V;
60
+ };
61
+ }
62
+ /**
63
+ * Static overload — pre-computes the full descriptor at definition time.
64
+ * Returns an {@link AttrSpec} with `.name`, `.value`, `.selector`, and `.attrs`.
65
+ */
66
+ declare function attr<N extends string, V extends string>(name: N, value: V): AttrSpec<N, V>;
67
+ /**
68
+ * Dynamic overload — pre-validates and pre-escapes the attribute name, returns a
69
+ * factory that accepts a per-render value and produces a frozen spreadable object.
70
+ * Use for per-row attributes like `data-id` where the value changes per item.
71
+ * The optional `V` generic constrains which values the factory accepts:
72
+ * `attr<'data-id', 'a'|'b'>('data-id')` → `(value: 'a'|'b') => { 'data-id': 'a'|'b' }`.
73
+ * Leaving both generics off infers `N` from the argument and defaults `V` to `string`.
74
+ */
75
+ declare function attr<N extends string, V extends string = string>(name: N): (value: V) => {
76
+ readonly [K in N]: V;
77
+ };
78
+
79
+ export { type AttrSpec as A, attr as a };