Editable HTML

Build renderer plugins with custom HTML UI that writes back to the source file, with a stable iframe that preserves state across edits.

editableHtml is the escape hatch for renderers whose UI isn't a table — forms, kanban boards, calendars, graphs, drag-and-drop surfaces, custom visualizations. The plugin author owns 100% of the UI (any HTML, CSS, or JavaScript in a sandboxed iframe) and posts edit events back to the host.

Stable iframe

The iframe stays mounted across content edits. CRDT mutations flow into the iframe via postMessage rather than re-loading the document. In-flight DOM state — focus, scroll position, half-typed input, drag gestures — survives a round-trip.

For this to work, your render() must return a content-agnostic shell. Don't bake file values into HTML attributes. Paint everything via window.hydrate(content).

Iframe-side API

The host injects a bootstrap script after your content. It exposes:

APIDescription
window.stoaEdit(payload)Post an edit event back to the host. The payload is whatever shape you want — your onEdit() receives it as edit.payload.
window.hydrate(content)You define this. Called by the bootstrap whenever fresh content arrives — once at iframe load and again on every CRDT change. Receives the file content as a string.

Plugin-side contract

// @stoa-plugin
// @name Server Config Editor
// @type renderer
// @icon Settings
// @filetype server.json

module.exports = {
  render(content, context) {
    return {
      type: 'editableHtml',
      content: `
        <style>
          body { font-family: system-ui; padding: 16px; margin: 0; }
          label { display: block; margin: 8px 0; font-size: 13px; }
          input, select { padding: 4px 6px; font-size: 14px; }
          button { background: #0070f3; color: white; border: 0;
                   padding: 8px 16px; border-radius: 4px; cursor: pointer; }
        </style>
        <form id="cfg">
          <label>Hostname: <input name="host"></label>
          <label>Port: <input name="port" type="number"></label>
          <label>Region:
            <select name="region">
              <option value="us-east">us-east</option>
              <option value="us-west">us-west</option>
              <option value="eu">eu</option>
            </select>
          </label>
          <label><input type="checkbox" name="tls"> TLS enabled</label>
          <button type="submit">Save</button>
        </form>
        <script>
          window.hydrate = function(content) {
            var data
            try { data = JSON.parse(content) } catch (e) { return }
            var f = document.getElementById('cfg')
            f.host.value = data.host || ''
            f.port.value = data.port || ''
            f.region.value = data.region || 'us-east'
            f.tls.checked = !!data.tls
          }

          document.getElementById('cfg').addEventListener('submit', function(e) {
            e.preventDefault()
            var fd = new FormData(e.target)
            window.stoaEdit({
              host: fd.get('host'),
              port: parseInt(fd.get('port'), 10),
              region: fd.get('region'),
              tls: fd.get('tls') === 'on'
            })
          })
        </script>
      `
    }
  },

  onEdit(edit, content, context) {
    if (edit.kind !== 'html') return { content, error: 'unexpected edit kind' }
    var data
    try { data = JSON.parse(content) } catch (e) { data = {} }
    Object.assign(data, edit.payload)
    return { content: JSON.stringify(data, null, 2) }
  }
}

onEdit for editableHtml

The onEdit handler receives:

ArgumentDescription
edit{ kind: 'html', payload: ... } where payload is whatever your iframe posted via stoaEdit()
contentCurrent file content from the CRDT
context{ filePath, fileContent }

Return { content: 'new file bytes' } to commit, or { content: '...', error: 'message' } to surface a validation error.

Message protocol

For debugging — you don't need to know this to write a plugin:

DirectionShapeWhen
Host to iframe{ __stoaIframeId, __stoaContent }On iframe load and on every fileContent change
Iframe to host{ __stoaIframeId, __stoaEdit: true, payload }When stoaEdit(payload) is called
Iframe to host{ __stoaIframeId, __stoaIframeHeight }Height reporting (auto-injected)

Constraints

  • Trust boundary: The iframe runs with sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox". Payload validation is your job.
  • No fetch() from inside the iframe (yet). Use render() or onEdit() for network requests and pass data through.
  • Plugin source edits remount the iframe. When the plugin file changes, the shell string changes, and any in-flight iframe state is lost. This is intentional for live-editing the plugin itself.