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:
| API | Description |
|---|---|
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:
| Argument | Description |
|---|---|
edit | { kind: 'html', payload: ... } where payload is whatever your iframe posted via stoaEdit() |
content | Current 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:
| Direction | Shape | When |
|---|---|---|
| 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). Userender()oronEdit()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.