Bindings in Markup (enableDeclarativeBinding)
Markup that cannot carry a script — a model's output, a saved view, a
server-rendered fragment, a doc page under a strict CSP — can still be live.
enableDeclarativeBinding() watches a root and turns data-tosi-*
attributes into ordinary bind() and on() calls. Nothing is evaluated: an
attribute's value is a state path, checked against what you granted.
It is opt-in and closed by default. With no grants nothing binds; it is
not in tosijs/core or the <script> build, and an app that never imports it
does not carry it.
<label>Name <input data-tosi-bind-value="markup.name"></label>
<p>Hello, <b data-tosi-text="markup.name"></b>!</p>
<button data-tosi-on-click="markup.shout" data-tosi-attr-aria-label="markup.name">
Shout
</button>
import { tosi, enableDeclarativeBinding } from 'tosijs'
const { markup } = tosi({
markup: {
name: 'Ada',
shout() {
markup.name.value = markup.name.value.toUpperCase()
},
},
})
const { disable } = enableDeclarativeBinding({
read: [markup],
write: [markup.name],
actions: [markup.shout],
root: preview,
})
The attributes
| attribute | binds |
|---|---|
data-tosi-text="path" |
the value → text content |
data-tosi-value="path" |
the value → value, one way |
data-tosi-bind-value="path" |
value, two way (needs a write grant) |
data-tosi-attr-aria-label="path" |
the value → the attribute (null, undefined and false remove it; true sets it empty) |
data-tosi-prop-row-count="path" |
the value → the rowCount property (kebab → camel) |
data-tosi-on-click="path" |
the event calls the function at the path |
A handler is called the way on(element, 'click', 'app.save') calls it:
with the event, resolved when the event fires, so replacing the function in
state takes effect. It takes no arguments from markup; put what it needs in
ordinary data-* attributes and read event.target.dataset.
Bound data-tosi-* attributes are removed once bound, so an element is
hydrated once. A refused one is left in place and warned about once, with the
reason.
Grants
enableDeclarativeBinding({
read: ['app'], // paths (or proxies) markup may show
write: ['app.form'], // paths a two-way binding may write
actions: ['app.save'], // functions markup may call
root: document.body, // where to look (the default)
})
Each grant is a list of paths or proxies, matched by whole segment (app
allows app.x and app[0], not appEvil), or a (path) => boolean. These
are the agent surface's three grants (roots, write, actions), so one
manifest can drive both.
Paths are plain: names, dots and [n] indices. Id-paths and anything else are
refused.
Showing is not acting. Binding a value into href, src, action,
a component's property or anything else that is not known to be inert turns
the value into behaviour: a link, a request, a document. Those targets need
the path in read and in actions, which reads as "the value at this
path is trusted to act". So a path granted only read (user-authored text,
say) can be shown but cannot be made into a link by injected markup. The
inert targets are text, value, checked, disabled, hidden, title,
class, role, alt, placeholder, aria-* and data-*; an unknown
target fails closed. on* attributes and properties, innerHTML,
outerHTML and srcdoc are refused whatever the grants.
Only gesture events. data-tosi-on-* accepts click, dblclick,
input, change, submit, reset, keydown and keyup: events a person
causes. load, error, focus and the like fire with no gesture, so
markup could call a function just by being inserted.
Not closable here: read is disclosure to whoever wrote the markup. A
bound field inside <form action="https://elsewhere"> sends its value there.
A form-action CSP is the control for that.
disable() stops watching. Bindings already made stay, as with bind().
hydrate(element) binds a fragment you are about to insert, which avoids a
moment where the raw attribute is live.
Verified in each real browser engine:
import { tosi, updates, enableDeclarativeBinding } from 'tosijs'
test('markup binds, writes back, calls, and refuses an ungranted href', async () => {
const { declTest } = tosi({
declTest: { name: 'Ada', link: 'https://example.com/', clicks: 0, hit() {
declTest.clicks.value += 1
} },
})
await updates()
const host = document.createElement('div')
preview.append(host)
const binding = enableDeclarativeBinding({
read: [declTest],
write: [declTest.name],
actions: [declTest.hit],
root: host,
})
// inserted after enabling: the observer hydrates it
host.innerHTML =
'<input data-tosi-bind-value="declTest.name">' +
'<span data-tosi-text="declTest.name"></span>' +
'<button data-tosi-on-click="declTest.hit">hit</button>' +
'<a data-tosi-attr-href="declTest.link">link</a>'
await new Promise((resolve) => setTimeout(resolve, 0))
await updates()
const input = host.querySelector('input')
expect(input.value).toBe('Ada')
input.value = 'Grace'
input.dispatchEvent(new Event('input', { bubbles: true }))
await updates()
expect(host.querySelector('span').textContent).toBe('Grace')
host.querySelector('button').click()
expect(declTest.clicks.value).toBe(1)
// href acts on its value: read alone is not enough
expect(host.querySelector('a').hasAttribute('href')).toBe(false)
binding.disable()
})