Three ways to add something ledge does not do.
A block template when the markup is the only unusual thing. A WebAssembly plugin when the block needs real computation, or an inspector of its own. A field type when the seventeen Universal Editor widgets and ledge’s own sheet picker cannot express the input you need. All three are ordinary project files, committed beside your blocks — there is no registry and nothing to install.
Nothing here is a hosted extension point. A plugin is a file at plugins/<name>/<name>.wasm, discovered at startup and hot-reloaded when it changes: claims are swapped, the plugin’s cache is cleared, and the editor is told to re-fetch its panel bundles. The version number baked into a plugin’s manifest is treated as a floor, not a match — the server loads anything built against an older contract and refuses only a plugin that needs a newer host, with a warning rather than a failed startup.
Plugins
A renderer is a function
A plugin is a WebAssembly component, not a plain module, loaded into a sandboxed wasmtime runtime on the server. You annotate functions and call one macro; the SDK emits the manifest and the render export. A renderer receives an already-decoded block — its name, the port that asked, the page path, its fields and its items — and returns HTML or an error. The code sample below is rendered by exactly this, on this page, right now:
plugin-src/code-sample/src/lib.rsuse std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use ledge_plugin_sdk::eds_html::escape;
use ledge_plugin_sdk::prelude::*;
/// A fingerprint of this render's inputs, for the KV cache key. Distinguishes
/// every distinct `(language, filename, code)` triple — including two
/// different `code-sample` blocks on the same page.
fn fingerprint(language: &str, filename: &str, code: &str) -> u64 {
let mut hasher = DefaultHasher::new();
language.hash(&mut hasher);
filename.hash(&mut hasher);
code.hash(&mut hasher);
hasher.finish()
}
/// Owns the delivery HTML for the `code-sample` block.
#[ledge::renderer("code-sample")]
fn code_sample(block: Block) -> Result<Html, RenderError> {
let fields = block.fields();
let language = fields.str("language").unwrap_or("");
let filename = fields.str("filename").unwrap_or("");
let code = fields.str("code").unwrap_or("");
let key = format!(
"code-sample:{}:{:016x}",
block.page_path(),
fingerprint(language, filename, code)
);
let dep = SourceDep::Page(block.page_path().to_owned());
// The fingerprint above already makes an edit to this block produce a
// different key on its own. `dep` is a second, independent guarantee on
// top of that: the KV is also cleared on any edit to the containing
// page, even one the fingerprint didn't cover.
cache::memoize(&key, std::slice::from_ref(&dep), || {
Ok(render_code_sample(parse_language(language), filename, code))
})
}
/// Claims the in-browser inspector for the same block.
#[ledge::panel("code-sample")]
fn code_sample_panel() {}
ledge_plugin_sdk::plugin!();
Escaping is the plugin’s job, and that is deliberate rather than an oversight. ledge’s sanitiser is model-aware: it leaves a text or textarea field verbatim and escapes it at its own render seam instead, because cleaning those was corrupting any field holding code — <script> deleted outright, Result<Html, RenderError> stripped of its type parameters, a stray ampersand double-escaped permanently by the next save. A plugin has no component models to consult and so cannot make that call for you. This one escapes every token it emits, including the ones it classified as ordinary text, because a code sample is attacker-shaped by nature.
What a plugin emits is otherwise indistinguishable from a native block’s output: the SDK re-exports the same HTML primitives the server’s own cell renderer calls, so an image a plugin renders through them participates in publish-time rendition pre-generation exactly as a native one does. On delivery and published, plugin HTML is emitted verbatim with zero instrumentation. On authoring it is wrapped in one block-level handle, and the block is edited as a whole through its panel — its inner fields are not click-to-edit.
A renderer is pure by default and read-only where it is not. It may read a page, a sheet, a query index, media metadata, the list of pages and a committed data file, plus its own key-value cache — and nothing else. There is no network, no disk, no clock, no randomness. It runs under a wall-clock budget of 250 ms and a memory ceiling of 64 MiB by default, and a trap, a timeout or an error degrades to the standard render path: the block renders exactly as it would with no plugin at all. A plugin never returns a 500 and never hangs a request. Failures are classified — trap, timeout, out of memory, bad input, contract mismatch — and logged with the elapsed time against the budget, so “degraded” is never the whole diagnosis.
An inspector panel mounts only for a block whose content is one set of fields. A repeating block or a container never gets one — and nothing is logged when that happens, so the symptom is simply that the panel is absent, which is what authors actually report. For a repeating block, claim a widget on one of its fields instead: a widget addresses a single field and is not gated on the owning block’s shape. Panel bundles are also served from a hardcoded /plugins/ URL, so a project that moves its plugin directory keeps working renderers and 404s its panels.
Block templates
When only the markup is unusual
Drop a blocks/<name>/<name>.html beside that block’s CSS and JavaScript and it owns the block’s markup. It is minijinja, with the block’s fields in scope and a sheet() global that projects the first tab of an author sheet into columns and rows. A missing or unreadable sheet renders the empty view rather than an error — and the read is recorded anyway, so the render is evicted the moment that sheet appears.
Because the template owns the whole element, the renderer cannot instrument it from outside. You place {{ aue_block() }} on the root and {{ aue_item(loop.index0) }} on each repeated item, and forgetting is the one mistake that costs you selectability in the editor — so under instrumentation only, the renderer appends a comment saying precisely that. On delivery all of it expands to nothing.
The build-profile table on the front page is one of these: a template, a sheet, and a JavaScript file whose whole body is a comment explaining that it is empty on purpose. EDS imports a block’s module unconditionally, so a block with no client behaviour still needs the file to exist.
Field types
The inspector is a dispatch table
Every field the inspector draws comes from the component string in your model, resolved in exactly one table. All seventeen Adobe Universal Editor component types have a widget, and ledge adds one of its own — sheet, a picker over the project’s author sheets whose stored value is a plain path, so it renders and instruments identically to a text field. This block’s own model is three of them:
component-models.json{
"id": "code-sample",
"fields": [
{ "component": "select", "name": "language", "label": "Language",
"options": [
{ "name": "Rust", "value": "rust" },
{ "name": "TOML", "value": "toml" }
] },
{ "component": "text", "name": "filename", "label": "Filename" },
{ "component": "textarea", "name": "code", "label": "Code" }
]
}
Adding a type is a widget function, an arm in that table, and — the step that actually matters — an arm in the renderer, because a value the renderer does not know how to flatten renders as an empty cell and delivery quietly stops being byte-identical. Constraints are enforced twice: as native HTML attributes for browser feedback, and again at commit time, where a failed pattern blocks the write rather than storing a bad value.
Conditional visibility reads a sibling field and shows or hides the row live, with no round-trip. Its grammar is deliberately a subset — field === 'value' or field == "value" — and anything else parses as no condition at all, which means the field is always visible. That is a documented fallback rather than a silent failure, and it is the safe direction to fail in.
The models themselves are readable, and editable, at /_/models: a component rail, a field list, and a field form that shows the catalogue of what each type allows. Editing writes to the file that actually wins in the merged config, one operation per control change. It is editable only when the code store is the local filesystem — a deployment serving its code from git stays a read-only viewer, because that store cannot be written to.
A repeating container field renders as one row per entry, and those rows carry no instrumentation in authoring or delivery. That is structural, not an omission: there is no pointer form for a container entry’s field, and the live-preview mapping drops the entry index, so a per-row handle would resolve every entry to the first one. Entries are added, removed and reordered from the container widget in the properties rail instead. A non-repeating container has no render path at all — it renders as one empty cell.
Keep reading
Two more pieces of the same system
The sheets a template or a plugin reads, the forms the server renders, and the generated files EDS blocks expect to fetch.
Why a plugin’s HTML and a template’s HTML have to be indistinguishable from a native block’s, and what pins that down.
Waitlist
One mail, when there is a binary.
No newsletter, no drip sequence, no passing your address to anyone. One message when ledge is downloadable, and that is the entire promise.