Skip to content

.phaze: Examples & pipeline

Uses the canonical stacked form (no closing --- between boundaries — each ---<label> implicitly closes the previous; one bare --- at the end transitions to the trailing region).

---page
import { hours, days } from '@madenowhere/phaze/time'
import ProductLayout from '../../components/ProductLayout'
layout: ProductLayout
head: { description: 'Browse all products in our catalog.' }
revalidate: { maxAge: hours(1), swr: days(1), tags: ['products'] }
---data
products: env.DB.list()
featured: env.DB.list({ category: 'featured', limit: 3 })
---state
import { s, c } from '@madenowhere/phaze/dsl'
import { is, not } from '@madenowhere/phaze/match'
import { withRevalidate } from '@madenowhere/phaze/revalidate'
const filterMode = s<'all' | 'new' | 'sale'>('all')
const activeUsers = s.async(fetch('/api/active-users').then(r => r.json()))
withRevalidate(activeUsers, 30)
---
const state = s<'s1' | 's2' | 's3'>('s1')
const SECTION_ANIM = {
s1: { y: 0, opacity: 1 },
s2: { y: -8, opacity: 0.6 },
s3: { y: 0, opacity: 1 },
springs: {
y: { stiffness: 500, damping: 100, mass: 3 },
opacity: { stiffness: 300, damping: 60 },
},
}
<>
<title>{`${data.products.length} products — Neuralkit`}</title>
<header class="page-header">
<h1>Products</h1>
<small>{activeUsers.value()?.count ?? ''} shoppers online</small>
</header>
<nav class="filter-bar">
<button on:click={filterMode.set('all')} class:active={is(filterMode, 'all')}>All</button>
<button on:click={filterMode.set('new')} class:active={is(filterMode, 'new')}>New</button>
<button on:click={filterMode.set('sale')} class:active={is(filterMode, 'sale')}>Sale</button>
</nav>
<section
use:spring={SECTION_ANIM[state()]}
on:mouseenter={state.set('s2')}
on:mouseleave={state.set('s3')}
class:loaded={not(state, 's1')}
>
{data.products
.filter(p => is(filterMode, 'all') || p.category === filterMode())
.map(p => (
<article>
<h3>{p.name}</h3>
<span>${p.price}</span>
</article>
))}
</section>
</>
import type { JSXChild } from '@madenowhere/phaze'
import { s } from '@madenowhere/phaze/dsl'
import { is, not } from '@madenowhere/phaze/match'
import { spring } from '@madenowhere/photon/phaze'
import { actions } from 'phaze:transport'
---data
{/* Module-scope — shared across every Card instance on the page. */}
const commentCounts = s.async(actions.getAllCommentCounts())
---state
{/* Module-scope UI state — toggled once, every Card sees it. */}
const focusMode = s<'all' | 'unread'>('all')
---
({ post }: { post: { id: string; title: string; excerpt: string } }) => {
const state = s<'s1' | 's2' | 's3'>('s1')
const CARD_ANIM = {
s1: { y: 0, opacity: 0.6, scale: 1 },
s2: { y: -4, opacity: 1, scale: 1.02 },
s3: { y: 0, opacity: 1, scale: 0.98 },
springs: {
y: { stiffness: 500, damping: 100, mass: 3 },
opacity: { stiffness: 300, damping: 60 },
scale: { stiffness: 400, damping: 80 },
},
}
return (
<article
use:spring={CARD_ANIM[state()]}
on:mouseenter={state.set('s2')}
on:mouseleave={state.set('s1')}
on:mousedown={state.set('s3')}
on:mouseup={state.set('s2')}
class="card"
class:hovered={not(state, 's1')}
class:active={is(state, 's3')}
class:focused={is(focusMode, 'unread')}
>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
<small>{commentCounts.value()?.[post.id] ?? 0} comments</small>
</article>
)
}
.phaze source
phaze-compile pre-processor ← state machine: extracts boundaries
│ (---page, ---data, ---state + trailing)
synthetic .tsx ← multi-export shape, JSX trailing function
phaze-compile babel passes ← DSL strip, match/numeric/list/time strip,
│ extractSlots, staticSubtreeHoist, defer:,
│ transition:, class:, bind:, on:event,
│ <title> lift to head, all unchanged
emitted JSX ← same as hand-written .tsx output
esbuild JSX transform ← same downstream pipeline
Rollup chunking ← phazeChunks() rules apply unchanged
shipped bundles

Architectural moat: the pre-processor emits a synthetic .tsx and hands it to the existing babel pass chain. Every present and future phaze-compile pass works inside .phaze automatically — no duplication, no maintenance fork.

.phaze is not a runtime concept; it’s wired into the build by the host adapter (@madenowhere/phaze-cloudflare, future @madenowhere/phaze-*). End users don’t configure anything — installing the adapter is enough. This section documents what the adapter does so authors of new adapters can mirror it, and so users debugging a misconfigured project know what to look for.

Three integration points, all in the host adapter’s Vite plugin:

1. Load hook — claim ownership of .phaze paths. Rollup’s default loader errors out on unknown extensions before any transform hook runs. The adapter’s load hook reads .phaze files off disk and returns the source as-is. (phaze-compile itself stays file-system-agnostic — it only owns transform, not load. Host adapters already have Node-side fs machinery for routing, so that’s where the read lives.)

async load(id) {
const path = id.split('?')[0] ?? id
if (/\.phaze$/.test(path)) {
try { return readFileSync(path, 'utf8') } catch { return null }
}
// …other virtual modules
}

2. esbuild filter expansion — recognize .phaze for the JSX→jsx() pass. phaze-compile’s transform produces JSX-shaped output (component children hoisted to thunks; the JSX itself preserved for esbuild to lower). Vite’s built-in vite:esbuild plugin handles that lowering downstream, but its default filter (/\.(m?ts|[jt]sx)$/) skips .phaze. The adapter expands the filter via config.esbuild.include.

3. loader: 'tsx' pin — required when the filter includes a non-standard extension. This is the gotcha. Vite’s transformWithEsbuild derives the loader from path.extname(filename).slice(1) when options.loader is unset — for foo.phaze that yields the literal string 'phaze', which esbuild rejects with Invalid loader value: 'phaze'. Pinning the loader forces every filter-matched file through the tsx loader (a superset of ts; correct for both .tsx and .phaze outputs).

// inside the adapter's vite plugin `config()` hook
return {
esbuild: {
jsx: 'automatic',
jsxImportSource: '@madenowhere/phaze',
include: /\.(m?ts|[jt]sx|phaze)$/,
loader: 'tsx', // ← the pin
},
// …
}

This pattern is the upstream-canonical Vite 7 recipe for custom file extensions (vitejs/vite#7321) — the same shape Astro uses to wire .astro, Vue uses for .vue, and Svelte uses for .svelte. It’s documented here (rather than left as a discovery problem) because the failure mode is opaque: a user adding .phaze support to a custom build, or an adapter author copying only the first two integration points, hits the Invalid loader value: 'phaze' error with no obvious connection to esbuild’s per-file loader inference.

Adapters that spawn a transient Vite server (e.g. for prerender) must restate the same esbuild block on that server too — the second instance doesn’t inherit the main plugin’s config. phaze-cloudflare’s runPrerender does this; future adapters following the same pattern should mirror it.

.phaze editor support uses Volar — the same framework Vue, Astro, and MDX use for custom-format LSP integration. The Volar adapter runs the same .phaze → .tsx pre-processor used by the build, exposes the synthetic .tsx to the TypeScript language server, and maps positions back to .phaze source via a sourcemap.

This gives .phaze files the full TypeScript LSP feature set for free — completion, hover, find-references, rename, codemods, refactors — without phaze maintaining its own language server. The pattern is well-trodden; the adapter is ~200–400 LoC of glue.