Skip to content

sntran/signet.js

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Signet.js

A tiny, signal-driven directive engine for HTML. Zero framework footprint. ~15KB (bundled).

Philosophy

  1. HTML-First & Purity — Interactivity is defined via js- prefixed directives. After binding, all js- attributes are stripped from the DOM for zero framework footprint and better accessibility.
  2. Signal-Driven — Uses @preact/signals-core for fine-grained reactivity.
  3. Just Enough — Only the essential directive engine. No virtual DOM, no compiler, no build step.

Quick Start

Without JavaScript (CDN)

Load Signet as a classic <script> with defer and init. All elements with js-scope are automatically discovered and mounted — no JavaScript required:

<script src="https://unpkg.com/signet.js" defer init></script>

<div js-scope="{ theme: 'light' }">
  <p js-text="'Theme: ' + theme"></p>
  <button js-on:click="theme = theme === 'light' ? 'dark' : 'light'">Toggle</button>

  <!-- Nested scope inherits `theme` from the parent -->
  <div js-scope="{ count: 0 }">
    <p js-text="'Theme: ' + theme + ' | Count: ' + count"></p>
    <button js-on:click="count++">+1</button>
  </div>
</div>

The defer attribute ensures the DOM is parsed before the script runs. The init attribute triggers auto-discovery of all top-level [js-scope] elements. Nested js-scope elements inherit parent scope properties via the prototype chain — the inner scope above can read theme without redeclaring it.

With JavaScript (ESM)

<div id="app">
  <h1 js-text="greeting"></h1>
  <input js-model="name" />
  <p js-show="name.length > 0" js-text="'Hello, ' + name + '!'"></p>
  <button js-on:click="count++">
    Clicked: <span js-text="count"></span>
  </button>
</div>

<script type="module">
  import Signet from './src/signet.js';

  Signet(document.getElementById('app'), () => ({
    greeting: 'Welcome to Signet.js',
    name: '',
    count: 0,
  }));
</script>

Installation

npm install signet.js

Or use directly via ESM import.

CSP-Safe (Default)

import Signet, { createApp } from 'signet.js';

Uses a recursive descent parser — no eval() or new Function(). Safe under strict Content-Security-Policy headers.

Unsafe-Eval (Smaller Bundle)

import Signet, { createApp } from 'signet.js/unsafe';

Uses new Function() for expression evaluation. Smaller bundle via tree-shaking (the parser is excluded), but requires unsafe-eval in your CSP.

API

Signet(rootEl, dataFn?)

Mount a reactive scope onto a root element.

  • rootEl — The root DOM element to bind.
  • dataFn — Optional function returning the initial data object. Plain values are wrapped in signal(), getters become computed(), functions are kept as-is.

Returns { store, directive, unmount, scope }.

createApp(dataOrFn)

Builder-style API (petite-vue compatible). Accepts a function or plain object.

import { createApp } from 'signet.js';

createApp({
  count: 0,
  get double() { return this.count * 2; },
})
  .store({ theme: 'dark' })
  .directive('log', ({ exp }) => console.log(exp))
  .mount('#app');

Returns { store, directive, mount } — chainable until .mount(el | selector).

.store(obj)

Register global state accessible from any scope. Properties are merged into the prototype chain so local scope values take precedence.

const app = Signet(root, () => ({ name: 'local' }));
app.store({ theme: 'dark', apiUrl: '/api' });
// theme and apiUrl are now available in all expressions

.directive(name, fn)

Register a custom directive (js-{name}).

app.directive('tooltip', ({ el, exp, arg, modifiers, scope, effect }) => {
  const dispose = effect(() => {
    el.title = /* evaluate expression */;
  });
  return dispose; // cleanup function (optional)
});

Context object:

Property Type Description
el Element The DOM element
exp string The expression string
arg string | null The argument (e.g., click in js-on:click)
modifiers string[] Dot-separated modifiers (e.g., ['prevent', 'stop'])
scope object The reactive scope
effect function The effect function from @preact/signals-core

.unmount()

Tear down all effects and cleanup functions recursively.

Directives

js-scope="{ ... }"

Define an inline reactive scope. Nested scopes inherit parent properties via the prototype chain.

<div js-scope="{ count: 0 }">
  <span js-text="count"></span>
  <button js-on:click="count++">+1</button>
</div>

js-text="expr"

Set el.textContent reactively.

<span js-text="count"></span>
<span js-text="'Total: ' + (count * price)"></span>

js-on:[event].modifiers="expr"

Attach event listener. $event is available in the expression.

Modifiers:

  • .preventevent.preventDefault()
  • .stopevent.stopPropagation()
  • .self — Only fire if event.target === el
  • .once — Auto-remove after first trigger
<button js-on:click="count++">+1</button>
<form js-on:submit.prevent="handleSubmit($event)">...</form>
<div js-on:click.self="closeModal()">...</div>

js-show="expr"

Toggle display: none based on expression truthiness.

<p js-show="isVisible">Now you see me</p>

js-if="expr"

Conditional rendering. Uses comment anchors for DOM position tracking.

<div js-if="isLoggedIn">Welcome back!</div>

js-for="item in list"

List rendering. Each item gets its own scope with item and $index signals.

<ul>
  <li js-for="todo in todos" js-text="todo"></li>
</ul>

js-model="prop"

Two-way binding for form elements (input, checkbox, radio, select).

<input js-model="name" />
<input type="checkbox" js-model="agreed" />
<input type="radio" name="size" value="sm" js-model="size" />

js-bind:[attr]="expr"

Reactively set any attribute. Removes attribute if value is null or false.

<a js-bind:href="url">Link</a>
<input js-bind:disabled="isLoading" />
<div js-bind:class="isActive ? 'active' : ''"></div>

js-html="expr"

Set el.innerHTML reactively. Use only with trusted data.

<div js-html="richContent"></div>

js-ref="name"

Assign element to a signal. Cleared to null on unmount.

<input js-ref="inputEl" />
<!-- inputEl is the <input> DOM element (auto-unwrapped) -->

js-once="expr"

One-time evaluation — no reactive tracking.

<span js-once="Date.now()"></span>

js-cloak

Removed after mounting. Pair with CSS: [js-cloak] { display: none; }

<div js-cloak>Content hidden until Signet.js mounts</div>

Computed Properties

Object getters are automatically wrapped in computed(). Inside a getter, this auto-unwraps signals — write this.count, not this.count.value:

Signet(root, () => ({
  count: 0,
  get double() { return this.count * 2; },
  get isEven() { return this.count % 2 === 0; },
}));

Scope Helpers

The following are available in all expressions:

  • $signal(value) — Create a new signal
  • $computed(fn) — Create a new computed signal
  • $batch(fn) — Batch multiple signal updates

Development

# Run tests (176 tests, 24 suites)
npm test

# Run property-based tests (22 tests, fast-check)
node --test test/pbt.test.js

# Run benchmarks
npm run bench

Testing Stack

  • Runner: Node.js native test runner (node:test)
  • DOM: linkedom
  • PBT: fast-check — fuzzes the parser with random strings, verifies idempotency, whitespace invariance, nesting integrity, and the no-crash safety invariant
  • Target: 100% coverage

License

MIT

About

A tiny, signal-driven directive engine for HTML. Zero framework footprint.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors