Wadi as a DSL-software framework
How one declaration per concept is projected onto every surface, and how the domain-neutral parts were extracted into a reusable kernel.
Part of the Wadi documentation, the advanced tier, for developers extending the language itself. If you only want to design houses, the authoring guide is all you need.
Companion to the parametric method. That document explains the parametric layers (variables, formulas, the resolver, the configurator). This document explains the componentization framework: how a new concept (a "primitive") is defined once and shows up in the schema, the forms, the docs, and the DSL, and how the domain-neutral parts were extracted into a reusable kernel. Read this for the approach; read that for the parametric machinery underneath.
0. The one-paragraph version
Wadi lets you design a house from a single declarative file and get a live 3D model, dimensioned 2D plans, elevations, roof drawings, a property-panel editor, and a typed text DSL, all from the same source of truth. The mechanism behind this is that all of those surfaces are generated from one declaration per concept. A concept (a wall, a beam, a spiral staircase) declares its fields once (as data) and its capabilities once (as code), and the framework projects those onto every surface. Adding a new object type is about 2 files, with no edits scattered across renderers. The domain-neutral half of this machinery is extracted into a kernel that a different domain (a PCB tool, a solar-farm planner) can reuse verbatim. Wadi is the reference instance of that framework.
1. DSL-based software
Most low-code and model-driven tools hard-wire a fixed set of concepts into a
monolith: the schema knows about walls, the 3D renderer knows about walls, the
forms know about walls, and the file format knows about walls. Adding a pillar
means touching all four in lockstep, by hand. The coupling sets the product's
ceiling.
The alternative Wadi pursues:
A concept is a single declaration. Every surface is a projection of it.
If that holds, then:
- Adding a concept is additive and local. You write the declaration and its capabilities in one place; the schema, form, docs, and DSL fall out.
- The surfaces cannot drift. They are generated from the same source, and a parity harness proves it byte-for-byte on every change.
- The engine is separable from the domain. The projection machinery mentions no house, so it can be lifted out and pointed at another domain.
This is what "DSL-based software" means here: a framework for building domain-specific-language software, where the domain is data and the engine is reusable.
2. The central mechanism: one declaration → many projections
A primitive is defined by two things:
fields(data). The list of properties the concept has, each with a kind (coord,extent,nonneg,int,text,flag,enum), plus a doc string and unit. This is the whole shape of the concept.- capabilities (code). How the concept turns into pixels: a 3D renderer, a
2D footprint, an optional decomposition (
expand), a layer, an add-menu default.
From the one fields declaration, four surfaces are generated:
┌───────────────────────────────────────┐
│ fields (one declaration) │
│ [{name:"radius", kind:"extent", …}] │
└───────────────────────────────────────┘
│ │ │ │
fieldsToZodSource fieldToFormControl fieldsToDocRows descriptor
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌──────┐ ┌─────────┐ ┌───────────────┐
│ SCHEMA │ │ FORM │ │ DOCS │ │ DSL (grammar │
│ (typed │ │(Auto-│ │ (data- │ │ +positional │
│ Zod, │ │ Form)│ │ model) │ │ +validation │
│ in the │ │ │ │ │ │ +completion) │
│ union) │ │ │ │ │ │ │
└────────┘ └──────┘ └─────────┘ └───────────────┘
- Schema. A codegen step (
gen-primitives) reads every primitive'sfieldsand emits typed Zod source into a generated file, which the config union imports. Soz.infergives precise types, with no dynamic/anyschema. (The runtime builder is kept only for a parity test.) - Form.
fieldToFormControlmaps each field's kind to a widget (measure/text/select/flag) with defaults (min bound from the constraint, label from the humanized name, optionality fromrequired). A primitive gets a working property-panel form fromfieldsalone. - Docs.
fieldsToDocRows(and the doc comments the schema codegen emits) feed the human-readable data-model reference, so the docs an authoring agent reads cannot drift from the schema. - DSL. The
fieldsbecome a descriptor the text DSL consumes: positional arguments map to the field order, and unknown-field and arg-count validation and editor completion are derived from it.
The capabilities are consulted by a registry: the 3D scene, the 2D plan, the property panel, the add-menu, and the layer system each ask the registry "who handles this type?" first, and fall back to the legacy per-type switch for not-yet-migrated types. A new type is one registry entry, nothing scattered.
The structural-conventions linter is another such surface. It is now a declarative
per-constraint registry (editor/src/lint/constraints/, currently C1-C10 plus the
spiral's SP1), each rule a self-contained module (check + doc + fixtures) built on a
spatial query layer (editor/src/model/, a @flatten-js/core adapter). structural.ts
is a thin loop over allConstraints(), and conventions.md is generated from those
modules, so the linter, its docs, and per-primitive rules (NodeDefinition.constraints)
all fall out of one declaration each, the same as the four surfaces above.
The two-tier structure that keeps this open: field kinds are presets (Tier 2) composed from a small closed set of atoms plus constraints (Tier 1). Adding a kind is data, not an engine release, the same way Zod, JSON-Schema, and protobuf treat their type vocabularies.
3. The architecture
┌──────────────────────────────────────────────────────────────┐
│ @dslkit/kernel (domain-NEUTRAL) │
│ fieldSchema : fields → {schema-source, docs, form-control} │
│ stageRunner : pure toposort-fold over a stage DAG │
│ (mentions no house / three / react / langium / zod) │
└──────────────────────────────────────────────────────────────┘
▲ consumed via thin re-export shims
┌─────────────────────┴────────────────────────────────────────┐
│ WADI (the domain) │
│ │
│ schema/fields/* ─ the primitive declarations (data) │
│ registry/nodes/* ─ the primitive capabilities (code) │
│ registry ─ type → {schema, fields, render3D, │
│ planFootprint, expand, layer, default} │
│ pipeline/compose ─ the compositor as a Stage DAG │
│ wadi-dsl/ ─ the text DSL: grammar + compile + emit + │
│ validate + complete (fed by descriptors)│
└───────────────────────────────────────────────────────────────┘
3.1 Kernel vs domain (and the guardrail)
The kernel (kernel/) is the domain-neutral engine: the field-projection
machinery and the stage runner. It imports nothing outside itself and node
builtins, not even zod. The Wadi domain consumes it through thin export * shims
(editor/src/registry/fieldSchema.ts, editor/src/pipeline/stageRunner.ts), so no
consumer changed when the code moved out. A dependency-direction guardrail test
(kernelBoundary.test.ts) fails CI if any kernel file ever imports domain code,
so the boundary cannot silently rot. This makes "the engine is separable"
a checked claim rather than an aspiration.
(The one runtime zod builder stays on the Wadi side, so the kernel needs no zod dependency and the app keeps a single zod instance in its schema union.)
3.2 Two registries
- Primitive registry: type →
NodeDefinition(fields,schema,render3D,planFootprint,expand,layerRole,makeDefault,constraints). The socket a new object plugs into. Dispatchers consult-first, fall-back, so migration never breaks the app mid-flight. - Stage registry: the compositor is a DAG of pure
stages (resolve → expand → edges → perimeter → dimensions → wall-trim →
roof-derive → draw).
orderStagestoposorts them;runStagesfolds them over a shared context. This is a build-not-adopt decision: it is one page of code, not Airflow. It is synchronous, pure, byte-parity-preserving dataflow that runs on every edit.
The finding that shaped this: 3D and expansion are clean per-object (registry-routable), but 2D plan/elevation are pipeline/compositor-structured (type-major passes plus cross-object dimensioning and wall-trim). So primitives own their per-object capabilities and independent 2D fragments; the domain owns the 2D compositor. The boundary follows the structure of the problem, not a tidy diagram.
3.3 The two-track grammar
A parser generator produces a static grammar, so you cannot register a bespoke rule per contributed primitive at runtime. The resolution is two tracks:
- A generic core rule (
ObjectDecl) parses any primitive by name:type name? (positional, args) { key value … }. Semantics come from the descriptor'sfields. A contributed primitive needs zero grammar edits and no code-gen of the parser. This is the framework's real grammar. - Optional bespoke sugar: the hand-authored ergonomic rules (
wall north { window … }) stay, as sugar over the generic core. A promotion path lets a contributed primitive graduate from generic syntax to bespoke sugar without ever breaking existing files (both syntaxes remain valid).
The layering is visible live: author a not-yet-registered type and the grammar accepts it (zero parse errors) while the strict schema gates it. That is the design: the front-end parses any name; the schema registers the real ones.
4. Adding a primitive in ~2 files: the spiral_staircase case
A helical staircase, spiral_staircase, is a new object type defined in about two
files.
File 1, the declaration (editor/src/schema/fields/spiralStaircase.ts):
export const spiralStaircaseFields: FieldSpec[] = [
{ name: "name", kind: "text", required: false, doc: "Label" },
{ name: "x", kind: "coord", doc: "Centre X", unit: "project units" },
{ name: "y", kind: "coord", doc: "Centre Y", unit: "project units" },
{ name: "radius", kind: "extent", doc: "Outer radius", unit: "project units" },
{ name: "total_height", kind: "extent", doc: "Total rise", unit: "project units" },
{ name: "turns", kind: "extent", required: false, doc: "Revolutions" },
{ name: "steps", kind: "int", required: false, doc: "Number of treads" },
// …tread_thickness, pole_radius, z_offset
];
With the declaration registered in the manifest and the codegen run, the type is in the typed schema union, has a property-panel form, appears in the data-model docs, and is a DSL citizen, with no other edits. It reads in two forms.
The generic form uses named parameters in a block:
spiral_staircase "Stair" { radius 45 total_height 110 turns 1.75 }
A primitive can also carry bespoke sugar (§3.3): a second file, a grammar rule plus a
compile/emit pair, that lets it read like the built-in primitives, with at (x, y)
placement and named clauses and no braces:
spiral_staircase "Stair" at (120, 120) radius 45 total_height 110 turns 1.75
Both forms compile to the same object, and the fields declaration drives the schema,
form, and docs either way; only the surface syntax differs. A bespoke rule claims the
type keyword, so for a type that has one the generic form yields to it. The clause
keywords (radius, turns, …) stay usable as bare field keys on any generic
primitive: a soft-keyword token builder (wadi-token-builder.ts) makes every
field-marker keyword also lex as an identifier. The soft set is derived from the
grammar (field markers minus a small, stable set of object and statement leaders), so
no grammar edit is needed per primitive, and a primitive is still about two files.
File 2, the capabilities (editor/src/registry/nodes/spiralStaircase.tsx):
export const spiralStaircaseNode: NodeDefinition = {
type: "spiral_staircase",
label: "Spiral staircase",
addable: true,
layerRole: "structure",
fields: spiralStaircaseFields, // → AutoForm, no bespoke form needed
makeDefault: (cfg, existing) => ({ … }),// add-menu default
render3D: (obj, ctx) => ({ … }), // helix of treads + central pole (lazy)
planFootprint: (obj) => ({ … }), // 2D footprint
};
Register the node, and the 3D scene, 2D plan, add-menu, and layers all pick it up.
File 3 (optional), a per-primitive structural constraint. A primitive can also ship
its own rule by setting NodeDefinition.constraints?: Constraint[], which the
structural-conventions linter merges into allConstraints(). The spiral staircase
does exactly this: editor/src/registry/nodes/spiralStaircase.constraints.ts supplies
SP1, so the primitive carries its own validation alongside the shared C1-C10 rules.
In the WDL playground, spiral_staircase "Stair" at (120, 120) radius 45 total_height 110 turns 1.75 renders as wooden treads
winding a central pole inside a room, and the editor's completion widget lists
radius / total_height / turns / steps / tread_thickness / pole_radius / z_offset.
Two files, with every projection and capability derived from them.
4.1 The steps, end to end
The two files above are the fields declaration and the capabilities node. In practice you also register each in a manifest and run the schema codegen. A new type today lives in the repository, so adding one is a branch and a pull request. The full sequence:
- Declare the fields. Create
editor/src/schema/fields/<type>.tsexporting aFieldSpec[], one entry per property (name,kind,doc, optionalunit,required). This is the whole shape of the type. - Add it to the fields manifest. One line in
PRIMITIVE_FIELD_DECLS, ineditor/src/schema/fields/index.ts. - Run the schema codegen.
npm --prefix editor run gen-primitives. It reads the manifest and rewriteseditor/src/schema/generated/objects.generated.ts(a generated file, not hand-edited).gen-primitives:checkfails CI if it is stale. - Add the type to the schema union. In
editor/src/schema/houseConfig.ts, import the generated const and add it to thez.discriminatedUnion("type", [ … ]). This is the one hand-edit to a shared file. - Write the capabilities node. Create
editor/src/registry/nodes/<type>.tsxexporting aNodeDefinition:type,label,addable,layerRole,fields(reuse the declaration from step 1, so the property-panel form is generated),makeDefault(the add-menu default), and the render capabilities the type needs (render3D,planFootprint, and optionallyexpand,drawPlan,drawElevation). - Register the node. Add
registerNode(<type>Node)to the list ineditor/src/registry/registry.ts. - Add the 3D component, if any. If
render3Ddraws geometry, add the React-Three component it imports (for exampleeditor/src/three/<Type>.tsx). - The DSL needs nothing for the generic form.
<type> "name" { field value … }parses and compiles with no grammar change. Bespoke sugar (<type> "name" at (x, y) …) is optional; it needs a grammar rule plus a compile function inwadi-dsl, followed bynpm --prefix wadi-dsl run langium:generate.
Verify with the parity gate and the tests: npm --prefix editor run parity-render
(must stay 6/6) and npm --prefix editor test.
So it is two files of real content (steps 1 and 5) plus three build-time registration and codegen touches (steps 2, 3, 4, 6). All of it is base-code, edited and shipped in a build. Removing that constraint, so a type can be added without touching the base code, is the job of a plugin loader, which is not built yet.
5. Build discipline
Two disciplines keep the framework correct as it changes, and let a change stay additive and reversible rather than a rewrite.
- A parity gate. A self-referential golden snapshot (expand plus combined plans plus
elevations plus the merged roof spec, hashed per config over 6 real configs) must
stay 6/6 byte-identical.
npm run parity-renderis the gate. A refactor that must not change output is checked mechanically rather than by eye. - Consult-first, fall-back dispatch. Every dispatcher (3D, 2D, expand, add-menu, layers) asks the registry first and falls back to the legacy per-type switch, so a partially-migrated system is still a working system.
Together they mean a new primitive, a new stage, or a moved module is added without a rewrite, and the gate confirms nothing else moved.
6. Retargeting to a new domain
Because the kernel mentions no house, a second domain reuses it verbatim and rewrites only the domain layer. The mapping (worked out in full for a parametric solar-PV farm in the parametric method, §13):
| Keep verbatim (kernel + method) | Rewrite (domain) |
|---|---|
field engine: fields → {schema, docs, form} |
the primitive catalogue (panel, inverter, trench…) |
| stage-DAG runner | the compositor stages + the coordinate/units conventions |
generic ObjectDecl grammar + descriptor-driven compile/validate/complete |
the bespoke sugar rules (optional) |
| parametric engine + resolver (variables/points/grids/formulas) | the views (3D, plan, elevation, bill-of-materials) |
| capability + view registry + generic dispatch | the capability vocabulary + concrete renderers |
The procedure is: enumerate the domain's primitives and their fields; write their
capabilities against the registry; keep the parametric layer and the DSL front-end
as-is. The engine is the constant; the domain is the variable.
7. Capabilities and open directions
What the framework provides:
- One
fieldsdeclaration projects to the schema, form, docs, and DSL, all four, non-drifting. - A new primitive is about 2 files and renders live (
spiral_staircase). - A domain-neutral kernel with an enforced dependency-direction boundary.
- A two-track grammar where any primitive parses generically and the schema gates it.
- The compositor is a pure, ordered, byte-parity-preserving stage DAG.
Open, by choice:
- Promoting the kernel from a guarded boundary to a separately-published npm package (needs workspace hoisting to keep a single zod instance).
- Migrating the remaining compositor-coupled types (wall/room) fully to
fieldsfor their schema/form/docs while keeping their 2D render in the compositor. - A domain-agnostic promotion codegen (generic syntax → bespoke sugar).
8. Map of the code
| Path | Role |
|---|---|
kernel/ |
domain-neutral engine (field projections + stage runner) + boundary guardrail |
editor/src/schema/fields/* |
primitive declarations (fields) |
editor/src/registry/ |
the primitive registry, node definitions, and the field-engine shim |
editor/src/pipeline/ |
the compositor: stage runner shim + compose layer |
editor/scripts/gen-primitives.mjs |
fields → generated typed schema codegen |
editor/scripts/parity-render.mjs |
the 6/6 byte-parity gate |
wadi-dsl/ |
the text DSL: grammar, compile, decompile, validate, complete |
wadi-dsl/src/generator/descriptors.ts |
the seam feeding fields to the DSL |
06-the-method.md |
the parametric-layer methodology (variables/formulas/resolver) |
../plans/primitive-componentization.md |
the working design doc for this framework |
Summary
Wadi is a parametric house designer. Underneath it is a framework where a concept is declared once and the schema, the forms, the docs, and the language are projected from that declaration, on a domain-neutral kernel that can be pointed at another domain. Adding a new kind of object is about two files, and it shows up across all the surfaces from that one declaration.