Skip to main content

Writing a generator

A generator module is a WASM module with a small, boring string ABI: a JSON request in, a fragment of markup out. You don't write that ABI by hand — xsvg-plugin is the SDK, and xsvg-schema derives both an element's machine-readable description and the code that reads its attributes from one declaration.

Declare, then implement​

use xsvg_plugin::{ElementSpec, Params, Plugin, Request};

#[derive(Params)]
#[element(name = "arrow", doc = "An outlined thick arrow.")]
struct Arrow {
#[param(default = 0.0, geometry)] x1: f64,
#[param(default = 0.0, geometry)] y1: f64,
#[param(default = 16.0, min = 0.0, max = 200.0)] thickness: f64,
#[param(min = 0.0, max = 400.0, doc = "defaults to 2.5x thickness")] head_width: Option<f64>,
heads: Heads, // an enum contributes its own spellings
#[param(color, default = "currentColor")] fill: String,
}

struct Clipart;

impl Plugin for Clipart {
const NS: &'static str = "https://example.com/xsvg-clipart";
const NAME: &'static str = "clipart";
const VERSION: &'static str = "0.1.0";

fn elements() -> &'static [ElementSpec] {
&[Arrow::SPEC] // the derived description
}

fn generate(tag: &str, req: &Request) -> Result<String, String> {
match tag {
"arrow" => arrow(&Arrow::from_attrs(req), req),
other => Err(format!("<{other}> is not a clipart element")),
}
}
}

xsvg_plugin::export_plugin!(Clipart); // the whole ABI: alloc, describe, generate

Field names map to attributes by kebab-casing: head_width reads head-width. Values arrive as strings with theme var() tokens already resolved, and a nonsense value falls back to the declared default rather than to NaN.

The manifest can't drift​

The published manifest is generated from the declarations, so it cannot disagree with what the module actually reads: an attribute with no field cannot be read, and a field nothing reads is a dead_code warning. (The hand-written manifest this replaced had already lost two working aliases within an hour.) Hosts read it to build UI without instantiating anything — reading a schema runs no generator.

The compiler describes its own elements through the same declarations, exposed as describe(), so a host merges built-ins and plugin manifests into one lookup and treats both tiers identically. All eleven x: elements are declared, and none has a parameter its emitter doesn't read.

What a declaration carries​

On #[param(…)]Meaning for a host
default, min, max, steptyped control with its range — a bounded number gets a slider
colora colour control
doc = "…"the control's tooltip
geometrythis is position or extent — it belongs to canvas handles, not a number field, so the property panel omits it
read_onlyshow the value, disable editing, and say why
superseded_by = "x2, y2"inert while those are present — the control is disabled and names what overrides it
ns = "…"the attribute lives in another namespace (e.g. x:border-width), and a host must write it with the prefix that document binds

geometry is a fact, not a policy: an editor with handles reads the same flag to know what its handles own, undeclared means editable, and a dimension that shapes the drawing without placing it (stroke-width, bulge, pad) stays editable.

Leaves and containers​

Declaring children = "container" on the element makes the compiler lower and measure the element's children first and hand them over as { svg, bbox } in document order — so the module can size itself to what it wraps, then splice the child fragments verbatim inside its own geometry. Splicing them unmodified preserves inspector round-tripping for that content.

input = "optional" | "required" asks for an in="#id" reference, pre-resolved to a path string.

What a module can't do​

The sandbox is the point. No WASI, no network, no DOM, no document access, no host callbacks — a module is a pure function from request to markup, and it cannot re-lower a child, introspect child source, define new paint servers, or add attributes to elements it doesn't own. Output is re-parsed by the compiler, so it stays inside the static-SVG subset whatever the module returns.

Purity is enforced rather than promised: the browser host drops every instance between compiles, so retained state is unobservable, and the native hosts bound each call with wasmi fuel.

Editing generator elements​

Because every element — built-in or loaded — describes itself, the interactive viewer's inspector offers typed controls for whatever a pinned element declares: defaults, ranges, tooltips, disabled controls with a reason. A bounded parameter's slider commits as it moves, which is practical only because an edit re-emits the elements it can have changed and patches them into the live drawing instead of recompiling everything. See Playground & preview.

Hosts building their own panel reach the same schemas through compileXsvg's onPlugins callback.

Publishing​

Ship the .wasm next to the documents that import it, or serve it and pin the bytes with integrity="sha256-…". Declare a namespace URI you control (a domain, or a urn:) — the https://xsvg.visioncortex.org/ space is reserved for official extensions. The module's declared ns must match the <x:import ns> that binds it.

Official extensions live in the xsvg repo under crates/ext/ and are published per release; clipart is the worked example to read.

See also​