From 176775c0337052d94c07ebae69c2613264b39e38 Mon Sep 17 00:00:00 2001 From: Chris Zuber Date: Thu, 12 Mar 2026 14:50:35 -0700 Subject: [PATCH] Initial Release --- .npmignore | 1 + CHANGELOG.md | 89 +------------------- README.md | 115 ++++++++++++++++++++++---- attr.js | 41 ++++++++++ consts.js | 1 - disposable.js | 95 ++++++++++++++++++++++ eslint.config.js | 4 +- http.config.js | 12 +-- index.html | 7 +- index.js | 56 ++++++++++++- index.test.js | 8 -- iota.js | 9 +++ package-lock.json | 172 ++++++++++++++++++++++++--------------- package.json | 35 ++++---- refs.js | 34 ++++++++ registry.js | 46 +++++++++++ rollup.config.js | 20 ++--- text.js | 17 ++++ watcher.js | 201 ++++++++++++++++++++++++++++++++++++++++++++++ 19 files changed, 749 insertions(+), 214 deletions(-) create mode 100644 attr.js delete mode 100644 consts.js create mode 100644 disposable.js delete mode 100644 index.test.js create mode 100644 iota.js create mode 100644 refs.js create mode 100644 registry.js create mode 100644 text.js create mode 100644 watcher.js diff --git a/.npmignore b/.npmignore index eb76fa7..037ba2f 100644 --- a/.npmignore +++ b/.npmignore @@ -8,6 +8,7 @@ node_modules/ .gitignore .nvmrc index.html +index.js importmap.json importmap.yaml *.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2296da8..303b729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,93 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- Add `@aegisjsproject/dev-server` - -### Changed -- Update CSP, template, etc - -## [v1.1.3] - 2025-11-21 - -### Added -- Add `npm start` script - -### Changed -- Update npm publishing - -## [v1.1.2] - 2025-05-01 - -### Changed -- Use `eslint` & `rollup` directly instead of by other packages -- Update node version via `.npmrc` -- Update Node CI workflow -- Install & use `@shgysk8zer0/eslint-config` -- Add support for `node --test`, including ignoring tests for publishing -- Update ESLint & super-linter -- Switch to more basic Rollup config -- Update `exports` and `main` accordingly - -### Fixed -- Fix missed renaming in README - -## Removed -- Remove old ESLint config files - -## [v1.1.1] - 2023-09-24 - -### Added -- Add `unpkg` to `package.json` -- Add badges in README - -### Changed -- Update `exports` to `package.json` to handle wider variety - -### Fixed -- Fix typo in `fix:js` script - -### [v1.1.0] - 2023-07-03 - -### Changed -- Update to node 20 -- Update npm publishing GH Action - -## [v1.0.5] - 2023-07-02 - -### Added -- Add `funding` - -### Changed -- Updated GitHub Actions workflows -- Update versioning & lock-file scripts -- Update `.npmignore` & `.gitignore` - -## [v1.0.4] - 2023-06-08 - -### Added -- Install `@shgysk8zer0/npm-utils` -- Add `exports` to package config - -### Removed -- Uninstall `rollup`, `eslint` - -### Changed -- Use `getConfig()` from `@shgysk8zer0/js-utils/rollup` for rollup config - -## [v1.0.3] - 2023-06-01 - -### Fixed -- Revert to old Release Action, now with permissions & link to changelog - -## [v1.0.2] - 2023-06-01 - -### Fixed -- Fix `changelog-entry` to match `[$version]` instead of `$version` - -## [v1.0.1] - 2023-05-31 - -### Fixed -- Update GitHub Release workflow to use [Auto Release](https://github.com/marketplace/actions/auto-release) - -## [v1.0.0] - 2023-05-31 +## [v1.0.0] - 2026-03-11 Initial Release diff --git a/README.md b/README.md index d7fbe2f..dd63e84 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# npm-template +# @aegisjsproject/iota -A template repo for npm packages +A zero-build, fine-grained reactivity library leveraging the TC39 Signals proposal. -[![CodeQL](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml) -![Node CI](https://github.com/shgysk8zer0/npm-template/workflows/Node%20CI/badge.svg) -![Lint Code Base](https://github.com/shgysk8zer0/npm-template/workflows/Lint%20Code%20Base/badge.svg) +Iota delivers the microscopic DOM mutation performance of compiled frameworks (like Solid or Svelte) entirely at runtime. It bypasses the Virtual DOM by surgically targeting individual `Text` and `Attr` nodes, utilizing Explicit Resource Management (`DisposableStack`) for deterministic memory cleanup. -[![GitHub license](https://img.shields.io/github/license/shgysk8zer0/npm-template.svg)](https://github.com/shgysk8zer0/npm-template/blob/master/LICENSE) -[![GitHub last commit](https://img.shields.io/github/last-commit/shgysk8zer0/npm-template.svg)](https://github.com/shgysk8zer0/npm-template/commits/master) -[![GitHub release](https://img.shields.io/github/release/shgysk8zer0/npm-template?logo=github)](https://github.com/shgysk8zer0/npm-template/releases) +[![CodeQL](https://github.com/AegisJSProject/iota/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/AegisJSProject/iota/actions/workflows/codeql-analysis.yml) +![Node CI](https://github.com/AegisJSProject/iota/workflows/Node%20CI/badge.svg) +![Lint Code Base](https://github.com/AegisJSProject/iota/workflows/Lint%20Code%20Base/badge.svg) + +[![GitHub license](https://img.shields.io/github/license/AegisJSProject/iota.svg)](https://github.com/AegisJSProject/iota/blob/master/LICENSE) +[![GitHub last commit](https://img.shields.io/github/last-commit/AegisJSProject/iota.svg)](https://github.com/AegisJSProject/iota/commits/master) +[![GitHub release](https://img.shields.io/github/release/AegisJSProject/iota?logo=github)](https://github.com/AegisJSProject/iota/releases) [![GitHub Sponsors](https://img.shields.io/github/sponsors/shgysk8zer0?logo=github)](https://github.com/sponsors/shgysk8zer0) -[![npm](https://img.shields.io/npm/v/@shgysk8zer0/npm-template)](https://www.npmjs.com/package/@shgysk8zer0/npm-template) -![node-current](https://img.shields.io/node/v/@shgysk8zer0/npm-template) -![npm bundle size gzipped](https://img.shields.io/bundlephobia/minzip/@shgysk8zer0/npm-template) -[![npm](https://img.shields.io/npm/dw/@shgysk8zer0/npm-template?logo=npm)](https://www.npmjs.com/package/@shgysk8zer0/npm-template) +[![npm](https://img.shields.io/npm/v/@aegisjsproject/iota)](https://www.npmjs.com/package/@aegisjsproject/iota) +![node-current](https://img.shields.io/node/v/@aegisjsproject/iota) +![npm bundle size gzipped](https://img.shields.io/bundlephobia/minzip/@aegisjsproject/iota) +[![npm](https://img.shields.io/npm/dw/@aegisjsproject/iota?logo=npm)](https://www.npmjs.com/package/@aegisjsproject/iota) [![GitHub followers](https://img.shields.io/github/followers/shgysk8zer0.svg?style=social)](https://github.com/shgysk8zer0) -![GitHub forks](https://img.shields.io/github/forks/shgysk8zer0/npm-template.svg?style=social) -![GitHub stars](https://img.shields.io/github/stars/shgysk8zer0/npm-template.svg?style=social) +![GitHub forks](https://img.shields.io/github/forks/AegisJSProject/iota.svg?style=social) +![GitHub stars](https://img.shields.io/github/stars/AegisJSProject/iota.svg?style=social) [![Twitter Follow](https://img.shields.io/twitter/follow/shgysk8zer0.svg?style=social)](https://twitter.com/shgysk8zer0) [![Donate using Liberapay](https://img.shields.io/liberapay/receives/shgysk8zer0.svg?logo=liberapay)](https://liberapay.com/shgysk8zer0/donate "Donate using Liberapay") @@ -27,3 +29,88 @@ A template repo for npm packages - [Code of Conduct](./.github/CODE_OF_CONDUCT.md) - [Contributing](./.github/CONTRIBUTING.md) + +## Features + +* **Zero-Build:** Runs natively in the browser. No compilers, no bundlers required. +* **Micro-Updates:** Mutates specific `Text` and `Attr` nodes. Re-renders are physically impossible. +* **Deterministic Cleanup:** Built-in memory management via `Symbol.dispose` and `DisposableStack` prevents zombie listeners. +* **Security by Default:** Bypasses `innerHTML` during reactive updates. By assigning directly to `Node.textContent` and `Attr.value`, markup injection is neutralized by the platform. +* **Web Component Native:** Designed to cleanly hydrate Shadow DOMs and standard elements alike. + +## Installation + +### NPM +```bash +npm install @aegisjsproject/iota +``` + +### ` +``` +## Usage + +Iota uses string placeholders (HTML comments and data-attributes) to position signals in your markup, which are then hydrated via `$observe()`. + +```javascript +import { $text, $attr, $observe } from '@aegisjsproject/iota'; +import { html } from '@aegisjsproject/core/parsers/html.js'; +import { onInput, observeEvents } from '@aegisjsproject/callback-registry'; +import { sanitizer as sanitizerConfig } from '@aegisjsproject/sanitizer/config/base.js'; + +// Manage lifecycle natively +const stack = new DisposableStack(); + +// Initialize signals and bind them to the stack +const $name = stack.use($text('World')); +const $value = stack.use($attr('value', () => $name.get())); + +document.body.append(html` +

Hello, ${$name}!

+
+ + +
+`); + +// Hydrate the DOM (replaces placeholders with live Text/Attr nodes) +$observe(); +observeEvents(); + +// Tie disposal to a DOM event (e.g., Invoker Commands or unmount lifecycle) +document.addEventListener('command', ({ command }) => { + if (command === '--dispose') stack.dispose(); +}); +``` + +## API Reference + +### Primitives +* **`$text(value | computeFn)`**: Creates a `TextState` or `TextComputed` signal. Returns an HTML comment placeholder `` when cast to a string. +* **`$attr(name, value | computeFn)`**: Creates an `AttrState` or `AttrComputed` signal. Returns a data-attribute placeholder `data-attr-signal="ref"` when cast to a string. +* **`$signal(value)`**: Creates a base `DisposableState`. Includes a `.handleEvent(e)` method that automatically updates the signal value from form inputs. +* **`$computed(fn)`**: Creates a base `DisposableComputed` signal. + +### Observers & Hydration +* **`$observe(target = document.body, { stack, signal, base } = {})`**: Walks the target DOM node, locates signal placeholders, and replaces them with live, reactive `Text` and `Attr` nodes. +* **`observeTextSignalRefs(...)`**: Hydrates only text nodes. +* **`observeAttrSignalRefs(...)`**: Hydrates only attribute nodes. + +### Watchers +* **`$watch(signal, callback)`**: Manually subscribe to a signal. +* **`$unwatch(signal)`**: Stop tracking a specific signal. +* **`unwatchSignalCallback(signal, callback)`**: Remove a specific callback from a signal. + +### Registry & Internal +* **`RegistryKey`**: An extended `String` representing the signal's internal ID. Implements `[Symbol.dispose]` to automatically unwatch and unregister the associated signal when the stack clears. +* **`registerSignal(ref, signal)`** / **`unregisterSignal(ref)`**: Manages the global Map of active signals used during hydration. diff --git a/attr.js b/attr.js new file mode 100644 index 0000000..8f84b26 --- /dev/null +++ b/attr.js @@ -0,0 +1,41 @@ +import { DisposableComputed, DisposableState } from './disposable.js'; + +export const SIGNAL_DATA_ATTR = 'data-attr-signal'; + +export class AttrState extends DisposableState { + #name; + + constructor(name, value, config) { + super(value, config); + this.#name = name; + } + + get name() { + return this.#name; + } + + toString() { + return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + } +} + +export class AttrComputed extends DisposableComputed { + #name; + + constructor(name, callback, config) { + super(callback, config); + this.#name = name; + } + + get name() { + return this.#name; + } + + toString() { + return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + } +} + +export const $attr = (name, val, config) => typeof val === 'function' + ? new AttrComputed(name, val, config) + : new AttrState(name, val, config); diff --git a/consts.js b/consts.js deleted file mode 100644 index fc3a79b..0000000 --- a/consts.js +++ /dev/null @@ -1 +0,0 @@ -export const MESSAGE = 'This is a template for npm projects.'; diff --git a/disposable.js b/disposable.js new file mode 100644 index 0000000..ffd379c --- /dev/null +++ b/disposable.js @@ -0,0 +1,95 @@ +import { Signal } from '@shgysk8zer0/signals'; +import { getRef } from './refs.js'; +import { registerSignal, unregisterSignal } from './registry.js'; + +export class DisposableState extends Signal.State { + #ref = getRef('__signal_ref'); + + constructor(value, { + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + equals = Object.is, + } = {}) { + super(value, { + equals: (a, b) => this.#ref.disposed ? true : equals(a, b), + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + }); + + registerSignal(this.#ref, this); + this.#ref.defer(() => unregisterSignal(this.#ref)); + this.handleEvent = this.handleEvent.bind(this); + } + + handleEvent(event) { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) { + this.set(event.target.value); + } + } + + get ref() { + return this.#ref; + } + + get disposed() { + return this.#ref.disposed; + } + + [Symbol.dispose]() { + if (! this.#ref.disposed) { + this.#ref[Symbol.dispose](); + + Signal.subtle.introspectSinks(this).forEach(source => { + if (source instanceof Signal.subtle.Watcher) { + source.unwatch(this); + } + }); + } + } +} + +export class DisposableComputed extends Signal.Computed { + #ref = getRef('__signal_ref'); + + constructor(callback, { + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + equals = Object.is, + } = {}) { + super(callback, { + equals: (a, b) => this.#ref.disposed ? true : equals(a, b), + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + }); + + registerSignal(this.#ref, this); + this.#ref.defer(() => unregisterSignal(this.#ref)); + } + + get ref() { + return this.#ref; + } + + get disposed() { + return this.#ref.disposed; + } + + dispose() { + this[Symbol.dispose](); + } + + [Symbol.dispose]() { + if (! this.#ref.disposed) { + this.#ref[Symbol.dispose](); + + Signal.subtle.introspectSinks(this).forEach(source => { + if (source instanceof Signal.subtle.Watcher) { + source.unwatch(this); + } + }); + } + } +} + +export const $signal = (initial, config) => new DisposableState(initial, config); +export const $computed = (callback, config) => new DisposableComputed(callback, config); diff --git a/eslint.config.js b/eslint.config.js index e567f3e..4ff7308 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,3 @@ -import { node } from '@shgysk8zer0/eslint-config'; +import { browser } from '@shgysk8zer0/eslint-config'; -export default node({ files: ['**/*.js'], ignores: ['**/*.min.js', '**/*.cjs', '**/*.mjs'] }); +export default browser({ files: ['**/*.js'], ignores: ['**/*.min.js', '**/*.cjs', '**/*.mjs'] }); diff --git a/http.config.js b/http.config.js index 43ca2ec..84610c1 100644 --- a/http.config.js +++ b/http.config.js @@ -1,12 +1,15 @@ -import { imports } from '@shgysk8zer0/importmap'; -import { checkCacheItem, setCacheItem } from '@aegisjsproject/http-utils/cache.js'; -import { addTrustedTypePolicy, addScriptSrc, useDefaultCSP } from '@aegisjsproject/http-utils/csp.js'; +import { imports, importmap } from '@shgysk8zer0/importmap'; +import { addTrustedTypePolicy, addScriptSrc, addStyleSrc, useDefaultCSP } from '@aegisjsproject/http-utils/csp.js'; addScriptSrc( 'https://unpkg.com/@aegisjsproject/', 'https://unpkg.com/@shgysk8zer0/', ); +addStyleSrc( + importmap.resolve('@aegisjsproject/styles/css/'), +); + addTrustedTypePolicy('aegis-sanitizer#html'); export default { @@ -14,10 +17,10 @@ export default { '/': '@aegisjsproject/dev-server', '/favicon.svg': '@aegisjsproject/dev-server/favicon', }, + port: 8023, open: true, requestPreprocessors: [ '@aegisjsproject/http-utils/request-id.js', - checkCacheItem, ], responsePostprocessors: [ '@aegisjsproject/http-utils/compression.js', @@ -28,6 +31,5 @@ export default { response.headers.append('Link', `<${imports['@shgysk8zer0/polyfills']}>; rel="preload"; as="script"; fetchpriority="high"; crossorigin="anonymous"; referrerpolicy="no-referrer"`); } }, - setCacheItem, ], }; diff --git a/index.html b/index.html index 014936b..3910122 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + NPM Template @@ -8,6 +8,7 @@ +
-
+
Request
 					{{ REQUEST }}
 				
-
+
Context
 					{{ CONTEXT }}
diff --git a/index.js b/index.js
index dce5927..e2054cf 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,53 @@
-import { MESSAGE } from 'npm-template/consts.js';
-console.log(import.meta);
-console.info(MESSAGE);
+import { $text, $attr, $observe } from '@aegisjsproject/iota';
+import { createHTMLParser } from '@aegisjsproject/core/parsers/html.js';
+import { onInput, observeEvents, createCallback } from '@aegisjsproject/callback-registry';
+import { sanitizer as sanitizerConfig } from '@aegisjsproject/sanitizer/config/base.js';
+// import pkg from '/package.json' with { type: 'json' }; // Wrong mime-type causes error
+import properties from '@aegisjsproject/styles/css/properties.css' with { type: 'css' };
+import theme from '@aegisjsproject/styles/css/theme.css' with { type: 'css' };
+import misc from '@aegisjsproject/styles/css/misc.css' with { type: 'css' };
+import forms from '@aegisjsproject/styles/css/forms.css' with { type: 'css' };
+import btn from '@aegisjsproject/styles/css/button.css' with { type: 'css' };
+
+// document.title = pkg.name;
+document.adoptedStyleSheets = [properties, theme, misc, forms, btn];
+
+// Need to force-allow comments for text signals
+const html = createHTMLParser({
+	...sanitizerConfig,
+	comments: true,
+});
+
+const stack = new DisposableStack();
+const controller = stack.adopt(new AbortController(), controller => controller.abort());
+const $name = stack.use($text('Silly person'));
+const $value = stack.use($attr('value', () => $name.get()));
+
+document.getElementById('main').prepend(html`

Hello, ${$name}!

+
+
+ + +
+
+ + +
+
+`); + +document.documentElement.addEventListener('command', ({ source, command }) => { + if (command === '--dispose') { + stack.dispose(); + source.disabled = true; + } +}, { signal: controller.signal }); + +stack.defer(() => { + for (const el of document.forms.container.elements) { + el.disabled = true; + } +}); + +$observe(); +observeEvents(); diff --git a/index.test.js b/index.test.js deleted file mode 100644 index 1afaf94..0000000 --- a/index.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { test, describe } from 'node:test'; -import assert from 'node:assert'; -import { MESSAGE } from './consts.js'; -const signal = AbortSignal.timeout(300); - -describe('An example test', () => { - test('Message is a string', { signal }, () => assert.equal(typeof MESSAGE, 'string', 'Message should be a string.')); -}); diff --git a/iota.js b/iota.js new file mode 100644 index 0000000..9710304 --- /dev/null +++ b/iota.js @@ -0,0 +1,9 @@ +export { getRef, RegistryKey, $ref } from './refs.js'; +export { registerSignal, getSignalFromRef, hasSignalRef, unregisterSignal } from './registry.js'; +export { DisposableComputed, DisposableState, $computed, $signal } from './disposable.js'; +export { + watchSignal, unwatchSignal, unwatchSignalCallback, observeAttrSignalRefs, observeSignalRefs, + observeTextSignalRefs, $watch, $unwatch, $observe, +} from './watcher.js'; +export { TextComputed, TextState, $text } from './text.js'; +export { AttrComputed, AttrState, $attr } from './attr.js'; diff --git a/package-lock.json b/package-lock.json index 29c5bc9..758c7b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,9 @@ "@rollup/plugin-terser": "^1.0.0", "@shgysk8zer0/eslint-config": "^1.0.7", "@shgysk8zer0/http-server": "^1.1.1", - "@shgysk8zer0/importmap": "^1.7.11", - "eslint": "^10.0.2", + "@shgysk8zer0/importmap": "^1.8.1", + "@shgysk8zer0/signals": "^0.0.3", + "eslint": "^10.0.3", "rollup": "^4.59.0" }, "engines": { @@ -174,14 +175,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -200,10 +202,11 @@ } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -232,21 +235,23 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { @@ -941,11 +946,32 @@ "@aegisjsproject/trusted-types": "^1.0.2" } }, + "node_modules/@shgysk8zer0/signals": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@shgysk8zer0/signals/-/signals-0.0.3.tgz", + "integrity": "sha512-mUSWKyZILwAoVIhwjM/QGVYfihEZgepO6TUV2BtOlHLqXkwhUMirEMDNOER/mkc+JnhpVDa0luiqbxpo+ZVG0g==", + "dev": true, + "funding": [ + { + "type": "librepay", + "url": "https://liberapay.com/shgysk8zer0" + }, + { + "type": "github", + "url": "https://github.com/sponsors/shgysk8zer0" + } + ], + "license": "MIT", + "engines": { + "node": ">=24.10.0" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -971,6 +997,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1014,15 +1041,17 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, + "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -1062,6 +1091,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1102,17 +1132,19 @@ } }, "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", + "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1121,7 +1153,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", @@ -1134,7 +1166,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1157,10 +1189,11 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", @@ -1220,6 +1253,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1512,6 +1546,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, @@ -1526,7 +1561,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -1658,6 +1694,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -1905,14 +1942,14 @@ } }, "@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "requires": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" } }, "@eslint/config-helpers": { @@ -1925,9 +1962,9 @@ } }, "@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.15" @@ -1941,18 +1978,18 @@ "requires": {} }, "@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true }, "@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "requires": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, @@ -2338,6 +2375,12 @@ "@aegisjsproject/trusted-types": "^1.0.2" } }, + "@shgysk8zer0/signals": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@shgysk8zer0/signals/-/signals-0.0.3.tgz", + "integrity": "sha512-mUSWKyZILwAoVIhwjM/QGVYfihEZgepO6TUV2BtOlHLqXkwhUMirEMDNOER/mkc+JnhpVDa0luiqbxpo+ZVG0g==", + "dev": true + }, "@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2366,7 +2409,8 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -2400,9 +2444,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -2459,17 +2503,18 @@ "dev": true }, "eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", + "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2478,7 +2523,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", @@ -2491,15 +2536,15 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" } }, "eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "requires": { "@types/esrecurse": "^4.3.1", @@ -2859,6 +2904,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "peer": true, "requires": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", diff --git a/package.json b/package.json index da81520..f2a6ed8 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { - "name": "npm-template", - "version": "1.1.3", - "description": "A template repo for npm packages", - "keywords": [], + "name": "@aegisjsproject/iota", + "version": "1.0.0", + "description": "A Signals-based reactivity library", + "keywords": [ + "signals", + "tc39-signals", + "reactivity" + ], "type": "module", - "main": "./index.cjs", - "module": "./index.js", - "unpkg": "./index.min.js", + "main": "./iota.cjs", + "module": "./iota.js", + "unpkg": "./iota.min.js", "exports": { ".": { - "import": "./index.js", - "require": "./index.cjs" + "import": "./iota.js", + "require": "./iota.cjs" }, "./*.js": { "import": "./*.js", @@ -52,7 +56,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/shgysk8zer0/npm-template.git" + "url": "git+https://github.com/AegisJSProject/iota.git" }, "author": "Chris Zuber ", "license": "MIT", @@ -67,17 +71,20 @@ } ], "bugs": { - "url": "https://github.com/shgysk8zer0/npm-template/issues" + "url": "https://github.com/AegisJSProject/iota/issues" + }, + "homepage": "https://github.com/AegisJSProject/iota#readme", + "dependencies": { + "@shgysk8zer0/signals": "^0.0.3" }, - "homepage": "https://github.com/shgysk8zer0/npm-template#readme", "devDependencies": { "@aegisjsproject/dev-server": "^1.0.6", "@aegisjsproject/http-utils": "^1.0.4", "@rollup/plugin-terser": "^1.0.0", "@shgysk8zer0/eslint-config": "^1.0.7", "@shgysk8zer0/http-server": "^1.1.1", - "@shgysk8zer0/importmap": "^1.7.11", - "eslint": "^10.0.2", + "@shgysk8zer0/importmap": "^1.8.1", + "eslint": "^10.0.3", "rollup": "^4.59.0" } } diff --git a/refs.js b/refs.js new file mode 100644 index 0000000..f64df0a --- /dev/null +++ b/refs.js @@ -0,0 +1,34 @@ +let n = 0; + +export class RegistryKey extends String { + #stack = new DisposableStack(); + + get disposed() { + return this.#stack.diposed; + } + + adopt(what, onDiposed) { + return this.#stack.adopt(what, onDiposed); + } + + defer(onDiposed) { + return this.#stack.defer(onDiposed); + } + + use(what) { + return this.#stack.use(what); + } + + [Symbol.dispose]() { + this.#stack.dispose(); + } +} + +/** + * + * @param {string} [prefix="ref-"] + * @param {number} [suffix=Date.now()] + * @returns {RegistryKey} + */ +export const getRef = (prefix = 'ref', suffix = Date.now()) => new RegistryKey(`${prefix}-${n++}-${suffix.toString(16)}`); +export const $ref = getRef; diff --git a/registry.js b/registry.js new file mode 100644 index 0000000..9a788c2 --- /dev/null +++ b/registry.js @@ -0,0 +1,46 @@ +import { Signal } from '@shgysk8zer0/signals'; + +/** + * @type {Map} + */ +const signalReg = new Map(); + +/** + * + * @param {string|String} ref + * @param {Signal.Computed|Signal.State} signal + */ +export function registerSignal(ref, signal) { + if (! (signal instanceof Signal.State || signal instanceof Signal.Computed)) { + throw new TypeError('Signal must be a `Signal.State` or `Signal.Computed`.'); + } else if (signalReg.has(ref)) { + throw new TypeError(`${ref} is already registered.`); + } else if (typeof ref === 'string') { + signalReg.set(ref, signal); + } else if (ref instanceof String) { + signalReg.set(ref.toString(), signal); + } else { + throw new TypeError('Invalid registry key.'); + } +} + +/** + * + * @param {string|String} ref + * @returns {boolean} + */ +export const unregisterSignal = ref => ref instanceof String ? signalReg.delete(ref.toString()) : signalReg.delete(ref); + +/** + * + * @param {string|String} ref + * @returns {Signal.State|undefined} + */ +export const getSignalFromRef = ref => ref instanceof String ? signalReg.get(ref.toString()) : signalReg.get(ref); + +/** + * + * @param {string|String} ref + * @returns {boolean} + */ +export const hasSignalRef = ref => ref instanceof String ? signalReg.has(ref.toString()) : signalReg.has(ref); diff --git a/rollup.config.js b/rollup.config.js index 00e10f0..87010f5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,23 +1,17 @@ import terser from '@rollup/plugin-terser'; +import nodeResolve from '@rollup/plugin-node-resolve'; export default [{ - input: 'index.js', + input: 'iota.js', + plugins: [nodeResolve()], + external: ['@shgysk8zer0/signals'], output: [{ - file: 'index.cjs', + file: 'iota.cjs', format: 'cjs', }, { - file: 'index.min.js', - format: 'iife', + file: 'iota.min.js', + format: 'module', plugins: [terser()], sourcemap: true, - }, { - file: 'index.mjs', - format: 'module', }], -}, { - input: 'consts.js', - output: { - file: 'consts.cjs', - format: 'cjs', - } }]; diff --git a/text.js b/text.js new file mode 100644 index 0000000..660ab5c --- /dev/null +++ b/text.js @@ -0,0 +1,17 @@ +import { DisposableComputed, DisposableState } from './disposable.js'; + +export class TextState extends DisposableState { + toString() { + return ``; + } +} + +export class TextComputed extends DisposableComputed { + toString() { + return ``; + } +} + +export const $text = (val, config) => typeof val === 'function' + ? new TextComputed(val, config) + : new TextState(val, config); diff --git a/watcher.js b/watcher.js new file mode 100644 index 0000000..b73a790 --- /dev/null +++ b/watcher.js @@ -0,0 +1,201 @@ +import { Signal } from '@shgysk8zer0/signals'; +import { hasSignalRef, getSignalFromRef } from './registry.js'; +import { SIGNAL_DATA_ATTR } from './attr.js'; + +const noop = () => undefined; +/** + * @type {boolean} + */ +let updating = false; + +/** + * @typedef {Object} AsyncDisposableStackInterface + * @property {boolean} disposed + * @property {() => Promise} disposeAsync + * @property {(value: T) => T} use + * @property {(value: T, onDispose: (val: T) => void) => T} adopt + * @property {(onDispose: () => void) => void} defer + * @property {() => AsyncDisposableStackInterface} move + */ + +/** + * @template T + * @typedef {Signal.State|Signal.Computed} AnySignal + */ + +/** + * @typedef {Object} SignalObserverParams + * @property {AsyncDisposableStackInterface} stack + * @property {AbortSignal} signal + * @property {number} timeStamp + */ + +/** + * @template T + * @callback SignalObserverCallback + * @param {T} value + * @param {SignalObserverParams} params + * @returns {void} + */ + +/** + * @template T + * @type {WeakMap, Set>>} + */ +const registry = new WeakMap(); + +/** + * @type {Signal.subtle.Watcher} + */ +const watcher = new Signal.subtle.Watcher(function() { + if (! updating) { + updating = true; + + queueMicrotask(async () => { + /** + * @type {AsyncDisposableStackInterface} + */ + const stack = new AsyncDisposableStack(); + const { signal } = stack.adopt(new AbortController(), controller => controller.abort()); + const config = Object.freeze({ stack, signal, timeStamp: performance.now() }); + + await Promise.allSettled(watcher.getPending().flatMap(src => { + const val = src.get(); + + return Array.from( + registry.get(src), + (callback = noop) => Promise.try(callback, val, config) + .finally(() => registry.has(src) && watcher.watch(src)) + ); + })); + + stack.disposeAsync() + .catch(globalThis.reportError?.bind(globalThis)) + .finally(() => updating = false); + }); + } +}); + +/** + * Calls `callback` when `signal` is updated. + * + * @template T + * @param {AnySignal} signal + * @param {SignalObserverCallback} callback + */ +export function watchSignal(signal, callback) { + if (! (signal instanceof Signal.State || signal instanceof Signal.Computed)) { + throw new TypeError('Signal must be a `Signal.State` or `Signal.Computed.'); + } else if (typeof callback !== 'function') { + throw new TypeError('Callback must be a function.'); + } else if (registry.has(signal)) { + registry.get(signal).add(callback); + } else { + registry.set(signal, new Set([callback])); + watcher.watch(signal); + } +} + +export const $watch = watchSignal; + +/** + * Unregister `signal` from the signal registry + * + * @template T + * @param {AnySignal} signal + * @returns {boolean} True if the signal was successfully removed/unwatched + */ +export function unwatchSignal(signal) { + const result = registry.delete(signal); + watcher.unwatch(signal); + return result; +} + +export const $unwatch = unwatchSignal; + +/** + * Unregisters a callback assosciate with a `Signal.State` or `Signal.Computed` + * + * @template T + * @param {AnySignal} signal + * @param {SignalObserverCallback} callback + * @returns {boolean} Whether or not the callback was registered and was removed + */ +export function unwatchSignalCallback(signal, callback) { + if (registry.has(signal)) { + const callbacks = registry.get(signal); + const removed = callbacks.delete(callback); + + if (callbacks.size === 0) { + registry.delete(signal); + watcher.unwatch(signal); + } + + return removed; + } else { + return false; + } +} + + +export function observeTextSignalRefs(root = document.body, { stack, signal, base = document } = {}) { + if (typeof root === 'string') { + return observeTextSignalRefs(base.getElementById(root), { stack, signal }); + } else { + const it = root.ownerDocument.createNodeIterator( + root, + NodeFilter.SHOW_COMMENT, + comment => hasSignalRef(comment.textContent.trim()) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + ); + + let comment; + + while ((comment = it.nextNode())) { + const sig = getSignalFromRef(comment.textContent.trim()); + const textNode = root.ownerDocument.createTextNode(sig.get()); + comment.parentElement.replaceChild(textNode, comment); + watchSignal(sig, text => textNode.textContent = text); + stack?.defer?.(sig[Symbol.dispose]?.bind?.(sig)); + signal?.addEventListener?.('abort',sig[Symbol.dispose]?.bind?.(sig), { once: true }); + } + + return root; + } +} + +export function observeAttrSignalRefs(root = document.body, { stack, signal, base = document } = {}) { + if (typeof root === 'string') { + return observeAttrSignalRefs(base.getElementById(root), { stack, signal }); + } else { + const els = root.querySelectorAll(`[${SIGNAL_DATA_ATTR}]`); + + for (const el of els) { + const key = el.dataset.attrSignal; + + if (hasSignalRef(key)) { + const sig = getSignalFromRef(key); + const attr = root.ownerDocument.createAttribute(sig.name); + attr.value = sig.get(); + el.setAttributeNode(attr); + el.removeAttribute(SIGNAL_DATA_ATTR); + watchSignal(sig, newVal => attr.value = newVal); + stack?.defer?.(sig[Symbol.dispose]?.bind?.(sig)); + signal?.addEventListener?.('abort', sig[Symbol.dispose]?.bind?.(sig), { once: true }); + } + } + } + + return root; +} + +export function observeSignalRefs(root = document.body, { stack, signal, base = document } = {}) { + if (typeof root === 'string') { + return observeSignalRefs(base.getElementById(root), { stack, signal }); + } else { + observeTextSignalRefs(root, { stack, signal }); + observeAttrSignalRefs(root, { stack, signal }); + return root; + } +} + +export const $observe = observeSignalRefs;