,

Bridging JSX and a Text-Based Form DSL with the Codec-Provider Pattern

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

RoleNative formatFriction without a bridge
Designer / BAForm DSL (indentation-driven)Needs a dev to “implement” every tweak
React devJSX + hooksRefactors 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 containing line, 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

ConcernRecommended tactic
Large filesCache ASTs by file hash; re-parse only on change
Watch modeKeep both codecs hot; push incremental edits via WebSocket
CI drift checkcodec:lint runs DSL → AST → DSL and fails on diff
Versioned DSLAdd VERSION: 2 header; supply upgrade_v1_to_v2(ast) transform
Inline JS safetyParse 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

  1. Codec-Provider = one interface, many formats; the canonical AST stays pure.
  2. Designers keep expressive DSL text; engineers keep ergonomic JSX.
  3. CI, caching, and explicit error paths make the bridge production-grade.