Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ node_modules/
.gitignore
.nvmrc
index.html
index.js
importmap.json
importmap.yaml
*.config.js
Expand Down
89 changes: 1 addition & 88 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
115 changes: 101 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -27,3 +29,88 @@ A template repo for npm packages
- [Code of Conduct](./.github/CODE_OF_CONDUCT.md)
- [Contributing](./.github/CONTRIBUTING.md)
<!-- - [Security Policy](./.github/SECURITY.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
```

### `<script type="importmap">`
```html
<script type="importmap">
"imports": {
"@shgysk8zer0/signals": "https://unpkg.com/@shgysk8zer0/signals@0.0.3/signals.js,
"@aegisjsproject/iota": "https://unpkg.com/@aegisjsproject/iota/iota.js"
}
</script>
```
## 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`
<h1>Hello, ${$name}!</h1>
<form id="container">
<input
type="text"
${$value}
${onInput}="${$name.handleEvent}"
/>
<button type="button" command="--dispose" commandfor="root">Dispose</button>
</form>
`);

// 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 `<!--ref-->` 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.
41 changes: 41 additions & 0 deletions attr.js
Original file line number Diff line number Diff line change
@@ -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);
1 change: 0 additions & 1 deletion consts.js

This file was deleted.

95 changes: 95 additions & 0 deletions disposable.js
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 2 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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'] });
Loading
Loading