Let's Write a Datastar Plugin!
Let’s build a datastar plugin. All datastar functionality is implemented as plugins. The new RC6 release included a revised plugin API, so we’ll use this. The API is not (yet) documented, but we can still use it to build our plugin. The plan is to update this document when I spot any mistakes, so take all of this with a pinch of salt.
Data Bind Attr
Our new plugin will combine the functionality of two of the core plugins:
data-bind and data-attr. The data-bind plugin sets up a two-way binding
between an element’s value and a datastar signal. When used on an input element,
and changes made by the user will be reflected in the signal, and changes to the
signal will be reflected in the element’s value.
The data-attr plugin sets up a one-way binding between an element’s attribute
and a datastar signal. When used on an element, the defined signal’s value will
be reflected in the defined attribute.
For our data-bind-attr plugin, we want a two-way binding between an arbitrary
attribute and a datastar signal. When used on an element, the defined signal’s
value will be reflected in the defined attribute and any changes to the
attribute will be reflected in the signal.
Why is this useful? Web components. Most web component libraries can define properties which are populated from attribute values. Changes to the properties will also update the attribute value. So our plugin will let us drive web components via datastar signals but also allow the web component to update the signal.
The Plugin API
Let’s have a look at some existing plugins to get an idea of how the plugin API
works. We’ll use the data-attr plugin as an example.
import { attribute } from '@engine'
import { effect } from '@engine/signals'
attribute({
name: 'attr',
requirement: { value: 'must' },
returnsValue: true,
apply({ el, key, rx }) {
...
Here we see that we’re importing attribute. Datastar has attribute, action and
watcher plugins. Attributes are put on elements in data-* attributes. Actions
let us do things within Datastar expressions, like issue network requests or
copying text to clipboard. And watcher plugins will react to events.
We then see various config settings being passed to attribute, along with the
apply function. Let’s grep all the attribute config options and see what we can use.
namerequirementreturnsValueargNames
name
Simple enough: the name of the plugin.
argNames
All plugins are passed an el argument that represents the element the plugin
has been used on. The argNames parameter contains the names of additional
arguments made available to the plugin. For example, with the data-on plugin,
argNames includes evt which represents the event object. The plugin can then
use evt.target etc.
requirement
The requirement argument is one of the following values, and applies to the
key and/or value defined on the attribute. A key is the string that comes
after the colon in the data-* attribute. E.g. in data-on:click, then click
is the key. The value is the value of the attribute. E.g. in
data-text="$foo", the value is $foo. The requirement argument takes one of
the following values:
allowed- The attribute must be present on the element.must- The attribute must be present on the element.denied- The attribute is optional.exclusive- The attribute must not be present on the element.
The argument can also take an object with optional key and value properties.
One or both of these properties must be present. Both key and value can be
set as must, denied, or exclusive. must means that a value must be
present. denied means no value is allowed. And exclusive means that if a key
is set then no value is allowed, or vice versa.
If the requirement is defined as:
requirement: {
key: 'denied'
}
Datastar will throw an error if a key is supplied.
requirement: {
key: 'exclusive'
}
Datastar will throw an error if a value is supplied (or if no key is defined).
requirement: {
key: 'denied'
}
Datastar will throw an error if a key is supplied.
Rather than defining the requirement argument as an object, you can also define it as a string. For example:
requirement: 'denied'
In this case, the requirement will apply to both the key and value
properties. So denied means neither is allowed, must means both are
required, and exclusive means only one is allowed.
returnsValue
Finally we have returnsValue which is a boolean indicating whether the plugin
returns a value or not. At a high level, setting it to false means that the
plugin will be performing some action or calculations, but datastar does not
need to get involved after the action is complete. More importantly, it also
means that the attribute’s value does not need to be treated as reactive.
If you want to use a reactive expression in the attribute’s value, then you need
to set returnsValue to true. This in turn will mean that the context value
passed to your apply function will have an rx property that can be used to
get the value of the expression.
The apply function
The apply function is where you implement the logic of your plugin. It takes a context object as its argument, which contains information about the attribute being processed. The context object has the following properties:
el: HTMLOrSVG // The element the attribute is onmods: Modifiers // Map of modifier names to their tagsrawKey: string // The full raw key (e.g., “bind-attr__debounce.5s”)evt: Event | undefined // Event object (if called from an event handler)error: ErrorFn // Error reporting functionkey: string | undefined // The part after the colon (may be undefined)value: string | undefined // The attribute valuerx: (…args: any[]) => T | undefined // Reactive function that executes the expression
Let’s see how the data-attr implements its apply function:
apply({ el, key, rx }) {
Here we see that it’s using element, the key and the rx function. The key
is used to get the name of the attribute that we want to sync with a value. The
rx function is used to execute the expression and get the value to sync with
the attribute.
Next, it defines a syncAttr function that will be used to update the element’s
attribute value:
const syncAttr = (key: string, val: any) => {
if (val === '' || val === true) {
el.setAttribute(key, '')
} else if (val === false || val == null) {
el.removeAttribute(key)
} else if (typeof val === 'string') {
el.setAttribute(key, val)
} else {
el.setAttribute(key, JSON.stringify(val))
}
}
The function uses the el element to update the attribute’s value, based on the
type of the value passed to the function.
const update = key
? () => {
observer.disconnect()
const val = rx() as string
syncAttr(key, val)
observer.observe(el, {
attributeFilter: [key],
})
}
: () => {
observer.disconnect()
const obj = rx() as Record<string, any>
const attributeFilter = Object.keys(obj)
for (const key of attributeFilter) {
syncAttr(key, obj[key])
}
observer.observe(el, {
attributeFilter,
})
}
Next an update function is defined depending on whether the user set a key. If
a key was set, then it will be the attribute to update and the return value of
rx() will be the value to set. If no key was set, then the value will be an
object where the keys are attribute names, and the values are the attribute
values.
The main thing to note here is that the first thing done will be to disconnect the observer. Doing so will ensure that any updates to the element do not trigger further updates and get us stuck in a loop. Once the updates have been applied, the observer is restarted and set to only watch the attributes that have been defined.
const observer = new MutationObserver(update)
const cleanup = effect(update)
return () => {
observer.disconnect()
cleanup()
}
Then we create the MutationObserver that will watch our element. We then
create a cleanup function that will do a final sync when the element is
removed from the DOM.
data-bind
The data-attr pluign will reflect signal changes into the defined attribues.
What we now need is the reverse: changes to the attributes need to be reflected
into our signals.
Something similar to this is done in the data-bind plugin, so let’s see how it
does it:
apply({ el, key, mods, value, error }) {
const signalName = key != null ? modifyCasing(key, mods) : value
We see here that the function uses the value, error and mods properties of
the context passed to it. It then gets the signal name from the key or the value
and applies case modifications as needed.
let get = (el: any, type: string) =>
type === 'number' ? +el.value : el.value
let set = (value: any) => {
;(el as HTMLInputElement).value = `${value}`
}
The next part defines a getter and a setter function. It does a simple conversion to numbers if needed, otherwise just treats the value as a string.
Next it does some checks for what type of element it’s on. The getter and setter are updated to new functions as appropriate. (The bulk of the code is removed below for brevity.)
if (el instanceof HTMLInputElement) {
switch (el.type) {
case 'range':
case 'number':
...
case 'checkbox':
...
case 'radio':
...
case 'file': {
...
}
}
} else if (el instanceof HTMLSelectElement) {
...
} else if (el instanceof HTMLTextAreaElement) {
// default case
} else {
// web component
get = (el: Element) =>
'value' in el ? el.value : el.getAttribute('value')
set = (value: any) => {
if ('value' in el) {
el.value = value
} else {
el.setAttribute('value', value)
}
}
}
Next the plugin deals with the signal’s value. The key points are using
getPath to get the current value of the signal, and mergePaths to ensure
that the signal is present. The getPath function is passed a string
representing the signal name. E.g. getPath('foo') will get the value of the
foo signal. getPath('foo.bar') will get the value of the nested signal bar
({ foo: bar }).
mergePaths takes an array of paths and values to merge into the signal store.
E.g. mergePaths([['foo.bar', 'quz'], ['foo.baz', 'qum']]) will merge the values
'quz' and 'qum' into the signal store at paths 'foo.bar' and 'foo.baz',
respectively. mergePaths also takes an optional ifMissing parameter, which
defaults to false. If ifMissing is true, mergePaths will create any
missing paths in the signal store. If ifMissing is false, mergePaths will
also delete any paths that are set to null. Essentially, ifMissing: true means
“only merge in values for properties that don’t already exist” - it skips
overwriting anything that’s already there.
const initialValue = getPath(signalName)
const type = typeof initialValue
let path = signalName
if (
Array.isArray(initialValue) &&
!(el instanceof HTMLSelectElement && el.multiple)
) {
const signalNameKebab = key ? key : value!
const inputs = document.querySelectorAll(
...
) as NodeListOf<HTMLInputElement>
const paths: Paths = []
let i = 0
for (const input of inputs) {
paths.push([`${path}.${i}`, get(input, 'none')])
if (el === input) {
break
}
i++
}
mergePaths(paths, { ifMissing: true })
path = `${path}.${i}`
} else {
mergePaths([[path, get(el, type)]], {
ifMissing: true,
})
}
Finally, the plugin creates a syncSignal function that gets the value of the
element and updates the signal. The function is added to the input and
change events, and a cleanup function is returned that will update the signal
with the final value of the element, as well as removing the event listeners.
const syncSignal = () => {
const signalValue = getPath(path)
if (signalValue != null) {
const value = get(el, typeof signalValue)
if (value !== empty) {
mergePaths([[path, value]])
}
}
}
el.addEventListener('input', syncSignal)
el.addEventListener('change', syncSignal)
const cleanup = effect(() => {
set(getPath(path))
})
return () => {
cleanup()
el.removeEventListener('input', syncSignal)
el.removeEventListener('change', syncSignal)
}
Implementing our plugin
With all these pieces, we should be able to implement our own bind-attr
plugin. It should be used as follows:
data-bind-attr:attributeName="signal-name-or-path"
For our usage, we’ll want the key to be the attribute name and the value to be
a signal name or path. We set the requirement to 'must' as we want both a
key and a value. In our case, we don’t set returnsValue to true because we
won’t be using the rx() function. Rather, we’ll be accessing the signals
directly with getPath() and mergePaths().
Ideally, we’d then use modifyCasing to convert the key using the standard
casing modifiers. Sadly, modifyCasing is not part of the public plugin API, so
we can’t do this for now.
Our plugin will also need the following pieces of code:
- A simple getter function for getting the attribute value.
- Code to make sure that the signal exists, and create it if it doesn’t.
- A
syncSignalfunction that reads the value of the attribute and updates the signal value. - A
syncAttrfunction that does the opposite and gets the signal value and updates the attribute’s value. - An
updatefunction that is used to update the signal value when the attribute value changes by handling our observer’s subscription. - A
MutationObserverthat watches the attributes for changes. - Code to use
effectto register our plugin for signal updates and to runsyncAttrwhen they occur. - A cleanup function that performs a final sync of the attributes as well as disconnecting the observer.
Here’s the full code:
import { attribute } from '@engine'
import { effect, getPath, mergePaths } from '@engine/signals'
import { modifyCasing } from '@utils/text'
const empty = Symbol('empty')
attribute({
name: 'bind-attr',
requirement: 'must',
apply({ el, key, mods, value, error }) {
// Ideally we would apply case modifications.
// Sadly, `modifyCasing` is not part of the public API.
// const attributeName = modifyCasing(key, mods)
const attributeName = key
// Simple getter
let get = (el: any, type: string) =>
type === 'number' ? +el.getAttribute(attributeName) : el.getAttribute(attributeName)
// Ensure signal exists and has a value.
const signalName = value
const initialValue = getPath(signalName) ?? ''
mergePaths([[signalName, initialValue]], {
ifMissing: true,
})
// Sync signal value with updated attribute value
const syncSignal = () => {
const signalValue = getPath(signalName)
if (signalValue != null) {
const value = get(el, typeof signalValue)
if (value !== empty) {
mergePaths([[signalName, value]])
}
}
}
// Sync attribute with updated signal value
const syncAttr = () => {
const val = getPath(signalName)
if (val === '' || val === true) {
el.setAttribute(attributeName, '')
} else if (val === false || val == null) {
el.removeAttribute(attributeName)
} else if (typeof val === 'string') {
el.setAttribute(attributeName, val)
} else {
el.setAttribute(attributeName, JSON.stringify(val))
}
}
// Update function to handle observer's observing.
const update = () => {
observer.disconnect()
syncSignal()
observer.observe(el, {
attributeFilter: [attributeName],
})
}
// Create the observer, cleanup function, and register our plugin for signal
// updates.
const observer = new MutationObserver(update)
observer.observe(el, {
attributeFilter:[attributeName],
})
const cleanup = effect(syncAttr)
// Return our cleanup function and disconnect the observer.
return () => {
observer.disconnect()
cleanup()
}
}})
Adding our plugin to our application
Our plugin is currently written in Typescript and is importing functions etc as if it was part of the main datastar bundle. What we need to do is convert our typescript to javascript and then import the plugin and register with datastar.
We can use esbuild to convert the typescript to javascript:
npx esbuild <path to bindAttr.ts>
We can then create a basic HTML file to test our plugin:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>DataStar bind-attr Example</title>
<script type="importmap">
{
"imports": {
"datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js",
}
}
</script>
<script type="module" src="/bindAttr.js"></script>
</head>
<body>
<input type="range" data-bind="count" min="0" max="100" step="1" value="50" /><span data-text="$count"></span>
<div data-bind-attr:count="count"></div>
</body>
</html>
Use Caddy or your favorite web server to serve the HTML and JS file, and then use devtools to inspect the element’s attribute as you adjust the range slider.
Note - if you also include datastar in its own script tag, then you may well cause the browser to hang.
Final thoughts
The plugin API is nicely written and simple to use. The existing plugins are a
great set of examples that should give you an idea of what’s possible -
something like @clip at one end, and the whole of Rocket at the other.