Skip to content

2. phaze-compile

1. phaze-tsplugin ← editor (TS Language Service)
2. phaze-compile ← build-time AST rewriting ← you are here
├── babel-plugin.ts ← the actual Babel plugin (all the visitors live here)
└── vite-plugin.ts ← thin wrapper that adapts it for Vite
3. phaze-vite ← island HMR + chunking helpers
4. phaze-astro ← Astro integration (island model)
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)

phaze-compile is the engine of phaze’s “compile-time ergonomics” thesis. Every feature that’s been added to make Phaze code shorter than the equivalent React or generic-signals code — c(expr) instead of c(() => expr), <For for:todo={todos}> instead of <For each={() => todos()} getKey={…}>, inc(count) instead of count.set(count() + 1), <input bind:value={name}/> instead of the manual value+onInput pair — is implemented as an AST rewrite in this package.

For the full per-transform catalog with side-by-side source/compiled examples, see the Phaze Compiler reference and DSL & directives. This page is the why and the how — how the package is organized, why it uses Babel, and what the two source files (babel-plugin.ts and vite-plugin.ts in phaze-compiler) each do.

@madenowhere/phaze-compile exposes two entry points from the same codebase:

@madenowhere/phaze-compile/
├── src/
│ ├── babel-plugin.ts ← THE ENGINE — Babel plugin with the AST visitors
│ ├── vite-plugin.ts ← THE ADAPTER — Vite plugin that wraps the Babel one
│ └── index.ts ← entry: re-exports the Babel plugin as default
└── package.json (exports):
"." → ./dist/index.js (the Babel plugin)
"./vite" → ./dist/vite-plugin.js (the Vite plugin wrapper)
Import pathWhat it returnsUsed by
@madenowhere/phaze-compileThe Babel plugin (default export)Raw Babel pipelines, custom Rollup babel passes, jest’s babel-jest, non-Vite consumers
@madenowhere/phaze-compile/viteA Vite plugin object that wraps the Babel pluginVite / Astro consumers (and phaze-astro imports this internally)

Two surfaces, one set of transforms. The Vite wrapper is ~50 lines — its job is to receive Vite’s (code, id) transform callback, recognize .tsx/.jsx files by extension, run babel.transformSync() with the Babel plugin loaded, and return the transformed code.

babel-plugin.ts in phaze-compiler — the engine

Section titled “babel-plugin.ts in phaze-compiler — the engine”

A Babel plugin is, at its core, a { visitor: { ... } } object. Each visitor key is an AST node type (CallExpression, JSXElement, ImportDeclaration, …) and each value is a function that fires when Babel’s depth-first traversal encounters that node type. The function can mutate the node, replace it, read scope information, or trigger downstream rewrites by mutating sibling AST.

babel-plugin.ts in phaze-compiler defines visitors that implement every compile-time Phaze transform:

VisitorTransforms implemented
Program.enterPer-file state reset — dslLocals, numericLocals, timeLocals, matchFreeLocals, matchFactoryLocals, listLocals, needsRuntimeImport. Babel reuses plugin instances across files in batch mode, so a per-file reset is required.
Program.exitDrops the /numeric, /match, /list import declarations (per-specifier) when every binding’s references were rewritten. Auto-injects effect / listen imports from @madenowhere/phaze when class: / bind: namespace rewrites referenced them.
ImportDeclarationWalks each from '@madenowhere/phaze/...' import and records bindings (c, watch, phaze, s from /dsl; inc, dec, add, sub from /numeric; interval, timeout from /time; is, not, signal/s from /match; remove, push, prepend, replace, patch, matches from /list) in the per-file state maps. The CallExpression visitor reads those maps to decide which calls to rewrite.
CallExpressionThe DSL macros (c(expr)/watch(expr)/phaze(expr)/s.async(expr) auto-thunks), the /numeric inline rewrites (inc(sig)sig.set(sig() + 1)), the /match rewrites (is(sig, val)sig() === val, method-form step.is(val)step() === val), the /list rewrites (remove(sig, { id })sig.set(sig().filter(_t => !(_t.id === id))), plus push/prepend/replace/patch/matches), the /time second-arg auto-thunks (interval(delay, expr)interval(delay, () => expr)). All in one branch-per-shape visitor.
JSXElementFour responsibilities: (a) rewrite namespace attributes (phaze:, on:, for:), (b) extract post-creation operations (use:, class:, bind:) and emit the IIFE that runs them, (c) wrap component children in thunks (<Catch><App/></Catch><Catch>{() => <App/>}</Catch>), (d) <For> key-lift + inversion — hoists inner key={…} to getKey={(p) => …}, then rewrites <For for:item={items}>…</For> (no phaze/getKey) to {() => items().map((item) => …)} and drops the For import. The phaze opt-in leaves the runtime For shape.
JSXFragmentSame expression-children-wrap rule as JSX host elements.

The visitors share per-file state through a PluginPass-shaped object. The Program-enter/exit visitors initialize and finalize that state; the other visitors read and write it.

The plugin is ~800 lines. Most of it is dispatch logic and the JSXElement post-creation-op extractor; the actual rewrites are short. The Babel plugin API has been stable for years and the visitor pattern is well-understood — adding a new transform usually means adding one branch to the right visitor + a test case.

vite-plugin.ts in phaze-compiler — the adapter

Section titled “vite-plugin.ts in phaze-compiler — the adapter”

The Vite plugin is intentionally tiny (~50 lines):

export default function phazeVitePlugin(): VitePlugin {
return {
name: '@madenowhere/phaze-compile',
enforce: 'pre',
transform(code: string, id: string) {
const path = id.split('?')[0] ?? id
if (!/\.(tsx|jsx)$/.test(path)) return null
const out = transformSync(code, {
plugins: [phazeCompile],
parserOpts: { plugins: ['jsx', 'typescript'] },
filename: path,
sourceMaps: true,
})
return { code: out?.code ?? '', map: out?.map ?? undefined }
},
}
}

Three things going on:

  1. enforce: 'pre' — runs before esbuild’s JSX-to-jsx() transform. By the time esbuild processes the file, all JSX namespace attributes have already been lowered to plain attributes (or to the post-creation IIFE).
  2. File-extension filter — only .tsx/.jsx files get transformed. .ts/.js/.astro/.css pass through untouched (the transform hook returns null for non-matches, signaling “this plugin doesn’t transform this file”).
  3. Babel parser plugins — the parser is configured for both JSX and TypeScript syntax so it can handle .tsx files in one pass.

That’s it. The Vite plugin doesn’t implement any of the transforms; it just decides which files to feed to the Babel plugin and hands the result back to Vite.

.phaze format support — parser + emit + registry + auto-import

Section titled “.phaze format support — parser + emit + registry + auto-import”

phaze-compile’s phaze-format subpath (packages/compile/src/phaze-format/) provides the structural .phaze.tsx pipeline that complements the Babel-level AST rewrites:

ComponentPathRole
Parserparser.tsState machine TOP / IN_BOUNDARY / BETWEEN / TRAILING. Recognises four named fences (---page / ---data / ---state / ---props) plus the bare --- body-exit. Tolerates trailing whitespace + // line comment on every fence line.
Emitemit.tsWalks the ParseResult, produces synthetic .tsx source + a v3 sourcemap. Per-boundary routines: processStateBoundary (Local vs @global split + Q5 enforcement), processPropsBoundary (destructure + type literal synthesis), emitComponentTrailing (routes by propsInfo / explicit-arrow / implicit), emitImplicitArrow (shared body for both ---props-driven and bare implicit paths).
Sourcemapmapper.tsThin wrapper over @jridgewell/gen-mapping with three primitives (push / skip / blank). The Vite plugin chains the emit map through Babel via inputSourceMap so the final source map reaches .phaze positions in one hop.

The Vite plugin’s transform hook handles both .tsx and .phaze extensions — for .phaze it runs phazeFormatTransform first, then feeds the synthesized .tsx into the Babel pass chain. Build-time errors land at .phaze source positions, not the synthesized intermediate.

The @global registry — project-wide shared state

Section titled “The @global registry — project-wide shared state”

A GlobalRegistry (in packages/compile/src/global-registry.ts, exported as @madenowhere/phaze-compile/global-registry) tracks every @global X : value declaration across the project. The Vite plugin populates it at buildStart via a sync filesystem scan of .phaze files (skips node_modules / dist / .git / .cache / .vite / .phaze / .wrangler), updates it per-transform, and invalidates consumers on HMR when a global’s declaration set changes.

Plugin options control the policy:

phazeVitePlugin({
// (strict mode is the silent default — only `src/app.phaze` may declare
// `@global X : value`; any other file gets a Q5 compile error.)
// Both options are escape hatches; comment them out for the default behavior.
appPhazePath: 'src/shell.phaze', // override the conventional `src/app.phaze`
distributedGlobals: true, // allow `@global` declarations in any .phaze file
})

The babel-plugin’s Program.exit visitor consults the registry to inject auto-imports. For every unresolved ReferencedIdentifier whose name matches a registered global, the visitor prepends import { X } from '<rel>.phaze' at the top of the file. Scope analysis uses ip.scope.hasBinding(name) (the inner-most scope at the reference site), so locals and explicit imports correctly shadow registry entries — standard JS scope rules win.

processPropsBoundary parses each prop line (<name>[?]: <type> [= <default>]) into a destructured parameter list AND a TypeScript type literal. The synthesized arrow becomes the component’s signature:

---props
post : Post
className? : string = ''
({ post, className = '' }: { post: Post; className?: string }) =>

Multi-line types/defaults track brace/paren depth — same machinery as ---state’s value tracking. When ---props is present, the trailing region is forced implicit form (no (params) => tail arrow needed).

Page mode rejects ---props with a diagnostic — pages get inputs via { data } from the loader, not call-site props.

Why Babel (not esbuild, swc, or a TypeScript transformer)

Section titled “Why Babel (not esbuild, swc, or a TypeScript transformer)”

Three reasons, in order:

  1. Babel’s plugin API is the standard for arbitrary AST rewriting. Walking the AST via the visitor pattern, swapping nodes, tracking scope, querying bindings via path.scope.getBinding(name) — all first-class. esbuild’s onTransform hook returns source text and gets source text; it doesn’t expose an AST to user transforms. swc has a Rust-level plugin API that requires writing transforms in Rust (or using a slow JS bridge), which is a much higher friction surface than Babel’s TypeScript/JS plugins.

  2. Babel’s parser handles TS + JSX natively. Configure parserOpts: { plugins: ['jsx', 'typescript'] } and Babel parses .tsx files in one pass — no separate TypeScript step needed. The output AST keeps JSX nodes intact, which is critical for phaze-compile because the JSX is what subsequent transforms (esbuild’s JSX-to-jsx()) consume.

  3. phaze-compile doesn’t need to be the JSX-to-call transformer. phaze-compile runs at enforce: 'pre' and emits JSX as output — esbuild then does the JSX-to-jsx() pass. So the responsibility split is: Babel handles the phaze-specific AST rewrites, esbuild handles the fast JSX-call lowering. Babel doesn’t have to be fast at JSX-call generation (esbuild is much faster at that); Babel just has to be expressive enough for the AST transforms.

The choice of Babel for this layer is purely an implementation detail of phaze-compile. The Phaze runtime contract — what jsx() calls look like, what the JSX runtime expects — is bundler-agnostic. A future phaze-compile-rust written as a swc Rust plugin could replace babel-plugin.ts without any user-visible change.

phaze-compile is one stage of a three-stage pipeline that runs over every .tsx file on its way from source to bundle. Babel (phaze-compile), esbuild, and Rollup each have a role; they’re not alternatives but a stack:

Your .tsx file
┌───────────────────────────────────────────────────────────────┐
│ STAGE 1 — Babel (phaze-compile's babel-plugin.ts) │
│ ──────────────────────────────────────────────────── │
│ AST rewriting via the visitor API. Implements every │
│ phaze-specific transform: │
│ • c(expr) → c(() => expr) (DSL auto-thunks) │
│ • watch(expr) → effect(() => expr) │
│ • s.async(expr) → s.async(() => expr) │
│ • inc(count) → count.set(count() + 1) (/numeric inline) │
│ • is(step,'a') → step() === 'a' (/match inline) │
│ • remove(t,{id}) → t.set(t().filter(_t=>!(_t.id===id))) │
│ • matches({id}) → _t => _t.id === id (/list matches) │
│ • on:event={…} → onEvent={…} (JSX namespaces) │
│ • use:NAME={v} → IIFE post-creation call │
│ • <For for:t> → {() => t().map(...)} (inversion, 0 B) │
│ • <For for:t phaze> → <For each={…} getKey={…}> │
│ • interval(s,n,fn) → interval(() => {s();return n}, fn) │
│ │
│ OUTPUT: JSX still intact, plus the phaze-specific rewrites. │
└───────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ STAGE 2 — esbuild (Vite's transformer) │
│ ──────────────────────────────────────────────────── │
│ JSX-to-jsx() lowering. Converts every `<Foo bar={1}>` to │
│ `jsx(Foo, { bar: 1 })`. Also does TS-strip, minify (in │
│ prod), and constant-folds `import.meta.env.DEV`. │
│ │
│ OUTPUT: plain ES2022 JS, no JSX left. │
└───────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ STAGE 3 — Rollup (Vite's bundler, prod builds only) │
│ ──────────────────────────────────────────────────── │
│ Tree-shake + chunk + emit final .js files. Reads │
│ phazeChunks()'s manualChunks decisions, deduplicates │
│ modules across the dep graph, drops unused exports, splits │
│ into phaze / phaze-directives / phaze-actions / │
│ component chunks. │
│ │
│ OUTPUT: the final bundled .js files (host-specific path). │
└───────────────────────────────────────────────────────────────┘

Stage 3’s output path depends on the host: dist/_astro/*.js under Astro, or — under phaze-cloudflare’s dual-environment build — dist/client/assets/*.js (browser bundle + manifest) plus a single self-contained dist/server/index.js worker.

ToolStrengthWhy it’s used here
BabelMature plugin/visitor API. Can traverse the AST, mutate nodes, query scope (path.scope.getBinding), preserve JSX nodes through the transform.phaze-compile needs all of this for the namespace rewrites + macros + scope-aware /numeric tracking. esbuild has no equivalent public visitor API; SWC’s is Rust-only.
esbuildGo-based, ~10-100× faster than Babel at the JSX-to-call lowering.Vite uses it for the high-volume, schema-stable transforms (JSX, TS-strip, minify). phaze-compile leaves JSX intact so esbuild can do its fast lowering pass downstream.
RollupBest-in-class tree-shaking (more aggressive than esbuild’s), manualChunks API for chunk layout, deep cross-module dependency analysis.Production builds need all of this. esbuild’s tree-shake is decent but not as aggressive; esbuild has no chunking system that matches manualChunks.

The split is what makes phaze fast at build time AND aggressive at tree-shake: Babel handles the small set of phaze-specific transforms (slow but expressive AST API), esbuild handles the large volume of generic JSX/TS transforms (fast at the boring stuff), Rollup handles the final assembly (smartest at deciding what ships where).

Could phaze switch to all-esbuild or all-SWC?

Section titled “Could phaze switch to all-esbuild or all-SWC?”

Theoretically yes, but neither pays off:

  • All esbuild would require esbuild to add a public visitor API for arbitrary transforms (it doesn’t have one — been requested since 2020).
  • All SWC would require rewriting babel-plugin.ts as a Rust plugin (massive effort) or using SWC’s JS bridge (slower than Babel for the kind of work phaze-compile does).

Today the Babel-as-AST-engine choice has zero practical downside — JSX-to-call lowering (the speed-critical step) still goes through esbuild; Babel only runs on the small set of phaze patterns. The combined pipeline is fast enough that no app I’ve seen complains about build time.

Where size.mjs diverges from the production stack

Section titled “Where size.mjs diverges from the production stack”

A maintainer-only detail worth knowing: phaze core’s scripts/size.mjs uses esbuild alone with plain bundling — no Babel pre-pass, no Rollup post-pass, no chunking. That’s why its per-module-attribution numbers are diagnostic approximations, not the real production output. For the canonical real-app bundle sizes — phaze, phaze-directives, etc. — use the sizeReport() plugin from @madenowhere/vite-plugin-phaze in your consumer app’s vite.plugins[]. It measures what comes out of stage 3 (Rollup’s actual chunks) — that’s the honest in-app number.

Walking through the c(expr)c(() => expr) auto-thunk as a representative example. The CallExpression visitor sees the call, checks if the callee is a DSL-traced binding, and rewrites the argument:

CallExpression(path, state) {
const callee = path.node.callee
if (callee.type !== 'Identifier') return
// Look up which DSL primitive this local name refers to.
// state.dslLocals was populated by the ImportDeclaration visitor.
const dslKind = state.dslLocals?.get(callee.name)
if (!dslKind) return // not a DSL call, bail
if (dslKind === 's') return // s() is a plain signal alias — no thunk
const arg = path.node.arguments[0]
if (!arg) return
// Skip if the user already wrote an arrow — idempotent.
if (
arg.type === 'ArrowFunctionExpression' ||
arg.type === 'FunctionExpression' ||
arg.type === 'SpreadElement'
) return
// Wrap the argument in `() => arg`.
const arrow = types.arrowFunctionExpression([], arg)
path.node.arguments[0] = arrow
},

Every transform in babel-plugin.ts follows roughly this shape: detect a syntactic pattern via the visitor + state lookup, mutate the AST in-place, return. Idempotence (skipping already-transformed shapes) is enforced via the early-return guards.