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()
})