Skip to content

About

A lightweight, reactive HTML library for building progressive web applications with server-side rendering and server-sent events

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

49 Commits

Folders and files

Repository files navigation

🦇 batsignal.js

batsignal.js is an alternative to htmx and datastar that provides many of the same features, but focuses on plug-and-play interactions instead of requiring x- or data- attributes for common cases. Pairing batsignal.js with pico.css makes HTML clean and easy to work with.

This project is especially suitable for backend developers who do not want to maintain a large full-stack Node.js framework, but still want to add interactivity to server-rendered pages without writing much JavaScript. Full-stack and frontend developers can also benefit from batsignal.js's simplicity and hackability, adapting it to their needs without extra bloat.

Quick start

Place the selected scripts immediately before the closing </body> tag.

<!-- Idiomorph is a required dependency -->
<script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.min.js"></script>
<!-- Development: main can contain breaking changes -->
<script src="https://cdn.jsdelivr.net/gh/jhthorsen/batsignal.js@main/batsignal.js"></script>
<!-- Or pin a revision until versioned releases are available:
<script src="https://cdn.jsdelivr.net/gh/jhthorsen/batsignal.js@be29cca/batsignal.js"></script>
-->

Idiomorph is a JavaScript library for morphing one DOM tree to another. It makes replacing nodes slower, but it makes the user experience much better: For example, text selection and focused input are remembered.

With batsignal.js loaded, this button is interactive and the same-origin link is fetched without a full reload; an HTML response patches the current document:

<button on:load on:click="alert('Hello')">Hello</button>
<a href="/user/profile">View profile</a>

Event handlers

Plugins

Add a plugin as an inert <script> containing an object expression. The script element itself is the plugin. Batsignal evaluates each script once, calls optional setup(script, C) once when it first discovers the plugin, passing the plugin <script> element and the shared helper registry. Plugins can add helpers to C in setup(); batsignal includes those helpers in the ctx passed to plugin init() hooks and attribute handlers. It calls optional init(el, ctx) for each newly initialized element with on:load, before binding that element's on:* handlers. ctx has the same helpers available to attribute handlers. Inside both hooks, this refers to the plugin object.

<script type="text/plain" data-batsignal-plugin="widget">
({
  setup(script) {
    console.log('Loaded plugin:', script.dataset.batsignalPlugin)
  },
  init(el, ctx) {
    if (el.matches('[data-widget]')) ctx.listen(el, 'click', handleWidgetClick)
  },
})
</script>

Either method may be omitted. init also runs for new elements added by HTML patches:

Place plugin scripts before batsignal to run them during the initial page initialization. Batsignal has no public init method. A plugin script added later is picked up the next time Batsignal initializes elements, such as after an HTML patch. Each script tag is an independent plugin; its data-batsignal-plugin value is a label. Plugins can add shared helpers to C from setup():

<script type="text/plain" data-batsignal-plugin="clipboard">
({
  setup(script, C) {
    C.copy = text => navigator.clipboard.writeText(text)
  },
})
</script>
<button on:load on:click="ctx.copy('Copied text')">Copy</button>

The added helpers are available to every plugin init() hook and attribute handler. If plugins assign the same helper name, the later setup assignment replaces the earlier one. Core context helpers such as fetch and listen take precedence over plugin helpers. Removing a script tag means it won't run for elements initialized afterward.

Listen for events with the on:<event> syntax. In addition to standard events such as click and submit, batsignal.js provides the special events listed below.

on:load

An element must have an on:load attribute to be initialized. This applies to on:value and every other on:* handler too. A blank on:load is enough:

<button on:load on:click="alert('Hello')">Hello</button>

on:load runs once for each DOM node when batsignal initializes it. It also runs for new elements after a response patches the page, but not again when Idiomorph retains an existing element during a morph swap.

on:destroy

on:destroy runs immediately before batsignal removes or replaces the element during an HTML patch. Use it to clean up resources that are not managed by batsignal, such as timers or third-party widgets. It does not run when other code removes the element from the DOM.

<div on:load="el.timer = setInterval(refresh, 1000)" on:destroy="clearInterval(el.timer)">
  Refreshing…
</div>

Once initialized, on:<event> adds an event listener to the element. The handler has el (the element) and evt (the event) in scope.

<input on:load on:input="console.log(el.value)">
<button on:load on:click="el.disabled = true">Disable me</button>

on:value

on:value runs immediately, then on input for text inputs and textareas, or on change for selects, checkboxes, and radio buttons. It also listens for a custom value event on the element. A defined event.detail is assigned to el.value before the handler runs.

<input on:load on:value="store.name = el.value">

<script>
  window.store = new Proxy({}, {
    set(object, key, value) {
      document.querySelector('#name').textContent = value;
      return Reflect.set(object, key, value);
    },
  });
</script>
<output id="name"></output>

Handler bindings

Attribute handlers receive el (the element), evt (the event), and ctx. ctx contains helpers bound to the element:

Helper Behavior
ctx.fetch(url, options) Fetches url for el and handles the response as described below.
ctx.listen(target, name, callback, options) Adds a listener using the element lifecycle signal by default.
ctx.dispatch(target, name, detail, options) Dispatches a CustomEvent on target.
ctx.init(root) Initializes root and its descendants that have on:load.
ctx.signal Aborts when batsignal removes or replaces el.

For example:

<button on:load on:click="ctx.dispatch(document, 'saved', 'Done')">
  Save
</button>

<output on:load="ctx.listen(document, 'saved', evt => {el.textContent = evt.detail})"></output>

<button on:load on:click="$('.status').textContent = 'Updated'">
  <span class="status">Waiting</span>
</button>

Use ctx.init() to initialize elements created in JavaScript. Append the root before calling it so on:load handlers run with the element in its final DOM context:

const child = document.createElement('button')
child.setAttribute('on:load', "el.textContent = 'Ready'")
document.body.append(child)
ctx.init(child)

$() is exposed on window (when that name is not already in use) and works like document.querySelector():

<button on:load on:click="$('#status').textContent = 'Updated'">Update</button>
<output id="status"></output>

Requests and responses

Links and forms targeting the same origin are intercepted. A form without an action submits to the current page, as it would without batsignal.js. Cross-origin forms use normal browser submission. Links use GET; forms use their declared method. data-history controls the history update:

<a href="/account">Push a history entry</a>
<a href="/account" data-history="replaceState">Replace the current entry</a>
<a href="/account" data-history="none">Do not update history</a>

Submitting controls can override a form's action, method, or enctype with the standard formaction, formmethod, and formenctype attributes. Forms or submitting controls with a target use normal browser submission. Forms with method="dialog" retain their native dialog behavior.

Modified or non-primary link clicks, links or forms with a target, links with a download attribute, and cross-origin links use normal browser navigation.

Requests run concurrently by default. Set navigation: true to abort an earlier pending navigation request before starting a browser-style navigation: fetch('/account', {navigation: true}). Response handling depends on Content-Type:

Content-Type Event
text/html sse-patch-elements with {data, url}
a type containing json sse-message with {data, url}; data is response text, not parsed JSON
text/event-stream (parameters allowed) sse-<event> with {data, url} for each SSE message, or sse-message when no event is specified
anything else sse-unknown with {response, url}

SSE connections reconnect after a read failure, but not after a graceful stream close, matching EventSource. The default delay is 3000 ms; a server-sent retry: field replaces it. A received id: is sent as Last-Event-ID on the next connection.

fetch() dispatches a bubbling fetch event on the target before making the request with {options, headers, url}, after receiving a response with {response}, and on an error with {error, options, url}. To retry a safe request, add an on:fetch handler to the element that started it. This example retries a failed request at most three times, with a three-second delay:

<a href="/account" on:load on:fetch="
  if (evt.detail.response) { el.retryCount = 0; return }
  if (!evt.detail.error || evt.detail.error.name == 'AbortError' || !el.isConnected) return
  if ((el.retryCount ?? 0) >= 3) return
  el.retryCount = (el.retryCount ?? 0) + 1
  setTimeout(() => el.isConnected && ctx.fetch(evt.detail.url, evt.detail.options), 3000)
">
  View account
</a>

HTML patches

A full HTML document replaces the body (unless the response contains data-swap elements). A fragment updates elements by id, or can explicitly target an element with data-swap:

<!-- Returned by the server -->
<div data-swap="replaceWith:#messages">
  <p>New message</p>
</div>

<!-- Also supported: replaceWith:#messages and morph:#messages -->

When a swap target does not exist, or its requested mode is unsupported, batsignal logs a warning and ignores that response element.

Fragments are parsed through a <template>, so partials can contain elements that require a specific parent context, such as a table row:

<tr data-swap="append:#orders tbody">
  <td>New order</td>
</tr>

morph uses the required window.Idiomorph dependency. The following modes control a response element without inserting it:

<!-- Discard the returned element without changing the current DOM. -->
<div data-swap="ignore"></div>

<!-- Retain the current element with the same id. -->
<div id="editor" data-swap="keep"></div>

<!-- Remove the selected current element. -->
<template data-swap="remove:#messages"></template>

For a full-document response, keep moves the current element into the incoming document. For a fragment response, it discards the incoming element.

Headers

To add headers to every batsignal request, the meta tag content is inserted inside an object literal. Use object entries, without outer braces:

<meta name="fetch-headers" content='"X-Request-ID": "example"'>

The content is evaluated as JavaScript. Only serve trusted documents.

GitHub Pages

This repository's index.html is a client-side demo and can be served directly from GitHub Pages. Its overall functionality test exercises links, GET forms, and HTML partial patches using static files in tests/. Enable Pages from the repository root in Settings → Pages.

Security

Attribute values and fetch-headers are compiled with new Function(). Never put untrusted input into either location. Batsignal does not sanitize HTML received from the server; sanitize user-generated content before sending it in an HTML response.

License

MIT. See LICENSE.

About

A lightweight, reactive HTML library for building progressive web applications with server-side rendering and server-sent events

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages