Introduction
A production-ready way to let React engineers and low-code designers work on the same screens—without hand-translating code.
The two worlds & the pain they create
Role | Native format | Friction without a bridge |
---|---|---|
Designer / BA | Form DSL (indentation-driven) | Needs a dev to “implement” every tweak |
React dev | JSX + hooks | Refactors break DSL docs, merge conflicts |
A single source of truth is clearly missing.
A first look at the Form DSL
PERSON_FORM:
PARAMETERS:
mode: "edit" | "view" = "view"
STATE person FROM DATASOURCE rest:/api/people/:id
LAYOUT:
- Row:
- TextField label:"First name" value:@person.firstName
- TextField label:"Last name" value:@person.lastName
VIEW_LOGIC:
IF @person.isEmployee IS TRUE
SHOW Badge "Employee"
Now imagine the JSX you would like to see after round-tripping:
function PersonForm({ mode = "view", person }) {
return (
<>
<Row>
<TextField label="First name" value={person.firstName} />
<TextField label="Last name" value={person.lastName} />
</Row>
{person.isEmployee && <Badge>Employee</Badge>}
</>
);
}
The Codec-Provider
(Bridge + Strategy) pattern
Form DSL text ──┐
│ parse() serialize()
▼ ▲
DSL-Codec ───────── AST ───────── JSX-Codec
▲ ▼
│ serialize() │ parse()
JSX file ────┘ └───
- Ast — an in-memory canonical tree.
- AstCodec — a pluggable “coder/decoder” that knows one concrete format.
interface AstCodec {
readonly format: "jsx" | "dsl" | string;
parse(text: string): Ast; // text ➜ canonical AST
serialize(ast: Ast): string; // AST ➜ text
}
Reference implementation (TypeScript)
Core wrapper
export class Ast {
constructor(public root: AstNode) {}
static from(codec: AstCodec, text: string) {
return codec.parse(text);
}
to(codec: AstCodec) {
return codec.serialize(this);
}
}
JSX codec
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import * as t from "@babel/types";
export const jsxCodec: AstCodec = {
format: "jsx",
parse(src) {
const file = parse(src, { sourceType: "module", plugins: ["jsx"] });
return new Ast(file);
},
serialize(ast) {
return generate(ast.root as t.File, { retainLines: true }).code;
},
};
Form DSL codec
(DSL grammar lives in its own module; no JSON shortcuts.)
import { parseDsl, printDsl } from "./dsl/grammar";
import { toCanonicalAst, fromCanonicalAst } from "./dsl/mappers";
export const formDslCodec: AstCodec = {
format: "dsl",
parse(text) {
const cst = parseDsl(text); // holds whitespace & comments
const jsxTree = toCanonicalAst(cst); // DSL ➜ JSX-flavoured AST
return new Ast(jsxTree);
},
serialize(ast) {
const cst = fromCanonicalAst(ast.root); // canonical ➜ DSL CST
return printDsl(cst); // pretty-printer restores style
},
};
Error handling &
unsupported features
- Rich diagnostics – both codecs throw
CodecError
containingline
,column
, and a snippet so IDEs can underline mistakes. - Graceful gaps – if the DSL references a widget with no JSX equivalent, the codec injects:
{/* TODO: Unsupported widget "Timeline" */}
so the round-trip never fails silently.
Scaling to real-world projects
Concern | Recommended tactic |
---|---|
Large files | Cache ASTs by file hash; re-parse only on change |
Watch mode | Keep both codecs hot; push incremental edits via WebSocket |
CI drift check | codec:lint runs DSL → AST → DSL and fails on diff |
Versioned DSL | Add VERSION: 2 header; supply upgrade_v1_to_v2(ast) transform |
Inline JS safety | Parse with Babel, then AST-walk to ban import() , eval , etc. |
Run it locally via commandline
# 1. export JSX to DSL
node tools/codec-export.js src/PersonForm.jsx > PersonForm.form.dsl
# 2. edit the DSL in your IDE
# 3. import DSL back to JSX
node tools/codec-import.js PersonForm.form.dsl
Watch the component update without losing hooks, comments, or formatting.
Take-aways
- Codec-Provider = one interface, many formats; the canonical AST stays pure.
- Designers keep expressive DSL text; engineers keep ergonomic JSX.
- CI, caching, and explicit error paths make the bridge production-grade.