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 Vite3. phaze-vite ← island HMR + chunking helpers4. 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.
The package’s two surfaces
Section titled “The package’s two surfaces”@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 path | What it returns | Used by |
|---|---|---|
@madenowhere/phaze-compile | The Babel plugin (default export) | Raw Babel pipelines, custom Rollup babel passes, jest’s babel-jest, non-Vite consumers |
@madenowhere/phaze-compile/vite | A Vite plugin object that wraps the Babel plugin | Vite / 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:
| Visitor | Transforms implemented |
|---|---|
Program.enter | Per-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.exit | Drops 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. |
ImportDeclaration | Walks 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. |
CallExpression | The 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. |
JSXElement | Four 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. |
JSXFragment | Same 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:
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).- File-extension filter — only
.tsx/.jsxfiles get transformed..ts/.js/.astro/.csspass through untouched (thetransformhook returnsnullfor non-matches, signaling “this plugin doesn’t transform this file”). - Babel parser plugins — the parser is configured for both JSX and TypeScript syntax so it can handle
.tsxfiles 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:
| Component | Path | Role |
|---|---|---|
| Parser | parser.ts | State 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. |
| Emit | emit.ts | Walks 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). |
| Sourcemap | mapper.ts | Thin 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.
---props synthesis
Section titled “---props synthesis”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:
---propspost : PostclassName? : 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:
-
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’sonTransformhook 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. -
Babel’s parser handles TS + JSX natively. Configure
parserOpts: { plugins: ['jsx', 'typescript'] }and Babel parses.tsxfiles 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. -
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.
Where Babel sits in the build pipeline
Section titled “Where Babel sits in the build pipeline”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.
Why each tool is in this slot
Section titled “Why each tool is in this slot”| Tool | Strength | Why it’s used here |
|---|---|---|
| Babel | Mature 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. |
| esbuild | Go-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. |
| Rollup | Best-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.tsas 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.
What a transform looks like in code
Section titled “What a transform looks like in code”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.
Where to go from here
Section titled “Where to go from here”- /phaze-compiler/ — the Reference catalog of every transform, with source/compiled side-by-side.
- DSL & directives — the user-facing documentation for every namespace and DSL primitive the compiler recognizes.
babel-plugin.tsin phaze-compiler (source) — the full plugin, end-to-end.