Skip to content

.phaze

.phaze is an opt-in source file format that organizes a page or component’s source into named boundaries so a reader knows what to expect from each region of the file. It compiles to a normal .tsx module — every existing phaze-compile pass (/dsl, /match, /numeric, /list, /time, defer:, transition:, class:, bind:, etc.) applies inside the boundaries unchanged. The runtime contract is identical to writing the equivalent .tsx by hand.

The win is DX, not capability. .phaze doesn’t enable anything .tsx can’t already do — it makes the structure of a typical page (page-level metadata, server-side data, module-scope reactive state, component body) visible at a glance instead of dispersed across named exports and module-scope code.

.phaze files coexist with .tsx in the same src/pages/** tree. The choice is per-file.

A .phaze file has exactly one mental dispatch:

Has ---page?ModeWhat it is
YesPageA routed page — appears in src/pages/**, gets routing, caching, server-side data loading
NoComponentA reusable component — used anywhere; takes props

No ---comp label — absence of ---page is what marks a file as a component. The label asymmetry is deliberate: pages are the special case (one prop signature { data }, compiler-known); components are the default (any prop signature, user-defined).

Four optional named regions in this conventional order (when present), plus a trailing component body:

---page (opt-in — marks this file as a page)
…page-level contract + imports…
---data (opt-in — server-side data; pages only)
…loader body…
---state (opt-in — Local and @global state inventory)
…state declarations…
---props (opt-in — component input inventory; components only)
…prop declarations…
--- (REQUIRED — marks the trailing region boundary)
…trailing component body… (implicit for pages, explicit arrow for components)

Each boundary has a single semantic intent:

BoundarySemanticCompiles to
---page”What the page declares about itself.”Named exports: head, headers, revalidate, layout, prerender
---data”What the server does with this request before rendering.” (Pages only)export const loader = async (pageCtx) => …
---state”This component’s signal inventory — Local by default, @global to share across files.”const declarations: Local hoisted into the body; @global becomes export const in app.phaze (auto-imported elsewhere)
---props”What this component receives.” (Components only)Destructured arrow params + TypeScript type literal — synthesizes ({…}: {…}) =>
Trailing region”The component itself.”export default function Page({ data }) { … } (pages) or export default (props) => … (components)

Consecutive boundaries stack: a ---<label> line while a boundary is already open implicitly closes the previous boundary and opens the new one. The only fence that explicitly closes is the final bare ---, which transitions from the boundary block into the trailing region.

---page
…page content…
---data ← implicitly closes ---page
…data content…
---state ← implicitly closes ---data
…signals content…
--- ← REQUIRED — closes ---state AND opens the trailing region
…trailing region…

Why the final --- is required. Without it, the parser can’t tell where ---state ends and the trailing region begins — both contain JS statements that may include const X = s(…) declarations (Local in ---state, hoisted into the component body; per-render in the trailing region’s JSX expression). The bare --- is the one syntactic token that exits the boundary stack — a cheap visual cost for a real grammar guarantee.

The unstacked form is also valid. Each boundary closed by its own --- works identically:

---page
---
---data
---
---state
---
…trailing region…

Stacked is canonical; unstacked is supported for migration and reader preference. Linters can normalize to stacked.

Line shapeAction
---page / ---data / ---state / ---propsIf a boundary is open, close it. Open the new one.
--- (bare)If a boundary is open, close it. Transition to trailing region.
Anything elseAppend to the currently open region (boundary or trailing).

Holds the page’s declarative configuration — static values only. Each labeled statement maps 1:1 to one of the PageModule named exports:

---page
import { hours, days } from '@madenowhere/phaze/time'
import ProductLayout from '../../components/ProductLayout'
layout: ProductLayout
head: { title: 'Products — Neuralkit', description: 'Browse all products.' }
headers: { 'x-page': 'products' }
revalidate: { maxAge: hours(1), swr: days(1), tags: ['products'] }
prerender: false
---

Compiles to:

import { hours, days } from '@madenowhere/phaze/time'
import ProductLayout from '../../components/ProductLayout'
export const layout = ProductLayout
export const head = { title: 'Products — Neuralkit', description: 'Browse all products.' }
export const headers = { 'x-page': 'products' }
export const revalidate = { maxAge: hours(1), swr: days(1), tags: ['products'] }
export const prerender = false
  • Top-level values are static-shape only — object literals, primitives, imported references (components, helpers).

  • Function-valued top-level labels are anti-pattern. Anything that needs PageContext (env, params, cookies, request) belongs in ---data. Don’t write a head function that queries the DB — that’s the loader’s job, and duplicating the query in head causes parallel-DB-fetch redundancy. The shape to avoid:

    head: ({ params, env }) => env.DB.get(params.id).then(row => ({ title: row.name }))
  • Function-valued nested fields are fine — the top-level value is still a static object literal; the function inside is a config field, not a top-level role declaration:

    revalidate: { tags: ({ params }) => [`product:${params.id}`] }
  • Imports the role values close over live in this region (e.g. import { hours } for revalidate).

---page uses JavaScript labeled statement syntax — label: value. It’s valid JS (labels are essentially unused outside break label/continue label), so the region parses as a normal TS file. phaze-compile recognizes the labels inside ---page and emits each as export const NAME = value.

---data — server-side data loading (pages only)

Section titled “---data — server-side data loading (pages only)”

Holds the page’s loader — server-side, pageCtx-bearing, runs in parallel with head/headers. Two syntactic forms:

When you have multiple independent fetches:

---data
{/* Each labeled expression becomes one parallel fetch.
pageCtx ({ env, params, cookies, request }) is implicitly destructured. */}
products: env.DB.list()
featured: env.DB.list({ category: 'featured', limit: 3 })
user: env.SESSION.get(cookies.get('rid'))
---

Compiles to:

export const loader = async ({ env, params, cookies, request }) => {
const [products, featured, user] = await Promise.all([
env.DB.list(),
env.DB.list({ category: 'featured', limit: 3 }),
env.SESSION.get(cookies.get('rid')),
])
return { products, featured, user }
}

Field-style is strictly parallel — each field’s expression has no access to other fields. If a fetch depends on another, use the explicit form below.

Function-body form (single fetch or dependent fetches)

Section titled “Function-body form (single fetch or dependent fetches)”

When you need locals, intermediate values, or dependent fetches:

---data
const products = await env.DB.list()
return {
products,
recommendations: await env.AI.recommend(products.map(p => p.id)),
}
---

Compiles to:

export const loader = async ({ env, params, cookies, request }) => {
const products = await env.DB.list()
return {
products,
recommendations: await env.AI.recommend(products.map(p => p.id)),
}
}

phaze-compile uses a single rule: if the body contains a return statement OR any non-labeled top-level statement (const, let, if, etc.), it’s function-body form. Otherwise it’s field-style.

A file using both forms in one ---data block is a compile-time error — pick one.

In a component file (no ---page), ---data holds module-scope async data signals — typically s.async(actions.X(…)) calls that load shared data across all instances of the component. It compiles to module-scope const declarations like ---state does. The semantic distinction (“loaded from the world” vs “UI state”) is for the reader; the compile output is the same.

---state — the component’s state inventory

Section titled “---state — the component’s state inventory”

Holds every signal, store, computed, and setup statement the component owns — the at-a-glance answer to “what state does this component have?” Each declaration uses the colon-form name: expression (the const keyword is implied by placement — the boundary IS the declarator).

---state distinguishes two lifetimes: Local (the default — one copy per component mount, declared inside the body) and Global — cross-file shared state declared in src/app.phaze. Local declarations need no marker; global declarations are prefixed with the @global decorator. Outside app.phaze, the @global keyword has a second role: it acts as a reference marker that documents this file’s consumption of a cross-file global (the auto-import handles the actual binding — see below).

---state
state: s<'s1' | 's2' | 's3'>('s1')
ANIM_STATES: { s1: { y: 0 }, s2: { y: -8 } }
@global selectedPost: signal('landing')
sig: propSelectedPost || selectedPost
if (sig?.current() === 'landing') {
sig.set({ genres: ['devops', 'ai', 'llm'] })
}
---

Compiles to:

// Global (module) scope — @global promoted
const selectedPost = signal('landing')
export default ({ selectedPost: propSelectedPost }: Props) => {
// Local scope (component body) — undecorated decls hoisted here
const state = s<'s1' | 's2' | 's3'>('s1')
const ANIM_STATES = { s1: { y: 0 }, s2: { y: -8 } }
const sig = propSelectedPost || selectedPost
if (sig?.current() === 'landing') {
sig.set({ genres: ['devops', 'ai', 'llm'] })
}
return (
// …trailing JSX…
)
}

The reader scans ---state once and learns the whole component’s state surface: three Local signals, one Global (module) signal flagged @global, one setup statement. The @global marker is the only thing distinguishing lifetimes — visually consistent column, lifetime obvious from the decorator slot.

Line shapeLifetimeEmit
<id>: <expr>Localconst <id> = <expr> inside the component body
@global <id>: <expr> (in app.phaze)Global — declaration siteexport const <id> = <expr> at module scope; registered in the project-wide registry for auto-import
@global <id>: <expr> (in any other file)Compile error (Q5)“Global signal ‘<id>’ must be declared in app.phaze.”
@global <id> (no value, anywhere)Reference markerNo emit. Documents that this file consumes <id> from app.phaze. The auto-import injects import { <id> } from '<rel>.phaze' at build/edit time.
@global <id>: <multi-line expr> (brace/paren-tracked)Global — declaration siteSingle export const covering the whole expression (app.phaze only)
Multi-line <id>: <expr> (no @global)LocalSingle const inside the component body
import … / function … / if … / for … / const … / let …Local (setup)Passthrough verbatim, inside the body
// / /* */ comments, blank linespreserved in placePassthrough verbatim

@global is the v1 decorator. The slot is extensible — future annotations stack on the same line without changing the parser:

---state
@global @persist('localStorage', 'cart') cart: signal<Item[]>([])
@global @route('?filter') filter: routeSignal('filter')

Planned annotations: @persist(adapter, key), @route(query), @export. Unknown decorators in v1 are silently ignored; a phase 2b improvement will surface them as “Unknown state decorator ‘@xyz’” diagnostics.

The app.phaze-first model — auto-export + auto-import

Section titled “The app.phaze-first model — auto-export + auto-import”

There is one canonical home for cross-file shared state: src/app.phaze. Every @global X : value declaration there emits as export const, and a project-wide GlobalRegistry (maintained at build time by phaze-compile and at edit time by phaze-language-tools) tracks every declared name.

Anywhere else in the project — .tsx or .phaze — a bare reference to a registered name triggers an auto-injected import { X } from '<rel>/app.phaze' at the top of the file. The consumer writes no import statement; the compiler does it.

// src/app.phaze — the canonical home
---state
@global selectedPost : signal<'landing' | Post>('landing')
@global scrollSource : signal<'touchpad' | 'mousewheel' | 'unknown'>('unknown')
---
// app shell JSX
// src/components/Card.phaze — a consumer
---state
@global selectedPost // optional reference marker — documents the dependency
visible : s(false)
---
watch(visible() && selectedPost.set(post)) // bare reference; auto-imported

Both build (phaze-compile’s Vite plugin) and edit (phaze-language-tools’ Volar plugin) inject the same import statement; TypeScript sees the resolved binding in both environments.

Strict mode is the default — @global X : value in any file other than app.phaze surfaces a compile error (Q5: “Global signal ‘X’ must be declared in app.phaze.”). The plugin option distributedGlobals: true relaxes this for projects that want their globals spread across multiple declaring files (registry conflicts on duplicate names still error). The option appPhazePath (default 'src/app.phaze') overrides the canonical location.

When still to use a separate store.ts — if you need a shared module that’s NOT a .phaze file (e.g. shared between non-phaze tooling), the explicit-import path stays available. For pure phaze projects, app.phaze replaces store.ts entirely — one fewer file, zero import statements at consumer sites.

Typed declarations — the explicit-const escape hatch

Section titled “Typed declarations — the explicit-const escape hatch”

The colon-form does not support name: Type = expr (the : already separates label/expression). For typed declarations, write the explicit const form — passthrough handles it, and the declaration stays in instance scope:

---state
const items: Item[] = [] // explicit-const for typed (Local)
filterMode: s('all' | 'new') // colon-form for inferred (Local)
@global selectedPost: signal('landing') // Global (module) via @global decorator
---

For typed Global (module-scope) declarations, put them above ---page (or above the component’s trailing arrow) — @global only works with the colon-form.

Always use the DSL form. Always.

✅ DSL form (source)❌ Lower-level (compiled output — don’t write by hand)
const sig = s(initial)const sig = signal(initial)
const a = c(expr)const a = computed(() => expr)
watch(expr)effect(() => expr)
phaze(expr) (JSX child)() => expr
const data = s.async(fetcher())const data = signal.async(() => fetcher())
is(sig, 'X') (in JSX)sig() === 'X'
not(sig, 'X') (in JSX)sig() !== 'X'

The DSL macros auto-thunk at compile time. Writing () => by hand inside s.async(…) / c(…) / watch(…) / phaze(…) defeats the entire subpath — that arrow IS the compiled output.

The canonical import shape for any .phaze file using state machines + async:

import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'
import { is, not } from '@madenowhere/phaze/match' // free-function form

Why free-function is/not and not the method form? Method form (step.is('X')) requires s from /match, which would collide with s from /dsl (which has .async auto-thunking). Free-function form lets you use ONE s (from /dsl) for everything.

---props — the component input inventory

Section titled “---props — the component input inventory”

Component-mode files declare their props via ---props. The fence eliminates the type Props = {…} declaration AND the explicit ({…}: Props) => arrow at the tail — the inventory IS the type.

---props
post : Post // required, typed
className? : string = '' // optional, typed, with default
children? : any // optional, untyped
status : 'active' | 'inactive' // union type
config? : { api: string; retries: number } = { // multi-line type + default
api: '/api',
retries: 3,
}

emits the synthesized arrow params + type literal:

({ post, className = '', children, status, config = { api: '/api', retries: 3 } }: {
post: Post
className?: string
children?: any
status: 'active' | 'inactive'
config?: { api: string; retries: number }
}) => { /* state setup; trailing body; return JSX */ }

The component-mode trailing is implicit when ---props is present — no (props) => <jsx> tail arrow. The trailing is just statements + final JSX (the params come from the inventory).

LineMeaningEmit
<name>: <type>required, typed<name>: <type> in the type literal; <name> in the destructure
<name>?: <type>optional, typed<name>?: <type> in the type literal; <name> in the destructure
<name>?: <type> = <default>optional, typed, with default<name>?: <type> in the type; <name> = <default> in the destructure
<name>required, untyped (TS infers any)<name>: any in the type; <name> in the destructure
<name>?optional, untyped<name>?: any in the type; <name> in the destructure
<name> = <default>required, untyped, with default<name>: any in the type; <name> = <default> in the destructure

Multi-line types and defaults track brace/paren depth — same machinery as ---state’s value tracking — so complex inline types stay one declaration.

A ---page file with ---props surfaces a diagnostic error: pages get their inputs via { data } from the loader, not via call-site props. Use ---data to type the loader’s return shape instead.

After the last fenced region comes the component body. The shape depends on the mode:

The trailing region IS the function body, implicitly. data (the loader’s return value) is implicit. No arrow, no return keyword. The compiler synthesizes both.

{/* Statements declared here are per-instance scope — run on every mount. */}
const state = s<'s1' | 's2' | 's3'>('s1')
const ANIM_STATES = {
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 },
},
}
{/* The final JSX expression IS the implicit return. */}
<>
<title>{`${data.products.length} products`}</title>
<section use:spring={ANIM_STATES[state()]}>…</section>
</>

Compiles to:

export default function Page({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
const state = s<'s1' | 's2' | 's3'>('s1')
const ANIM_STATES = { /* … */ }
return (
<>
{/* <title> stripped — lifted to a synthesized head export */}
<section use:spring={ANIM_STATES[state()]}>…</section>
</>
)
}

The trailing region is an explicit arrow function. Props are destructured at the arrow’s parameter — there’s no implicit prop (because the prop signature is yours, not the compiler’s).

({ title, children }: { title: string; children?: any }) => {
const state = s<'s1' | 's2' | 's3'>('s1')
const ANIM_STATES = { /* … */ }
return (
<article use:spring={ANIM_STATES[state()]} class:active={is(state, 's3')}>
<h3>{title}</h3>
{children}
</article>
)
}

The arrow can be expression-body for trivial components:

({ items }: { items: Item[] }) =>
<ul>{items.map(item => <li>{item.name}</li>)}</ul>

…or block-body when per-instance state is needed (the common case).

Dynamic page title — <title> in the component body

Section titled “Dynamic page title — <title> in the component body”

To set a title from loader data without duplicating the query in head:, write a <title> element with a data expression directly in the component body. phaze-compile lifts it to a synthesized head export that derives from data:

---page
{/* `head` declares only the static defaults. */}
head: { description: 'Browse all products.' }
---
---data
products: env.DB.list()
---
<>
<title>{`${data.products.length} products — Neuralkit`}</title>
<ul>{data.products.map(p => <li>{p.name}</li>)}</ul>
</>

Compiles to:

export const head = ({ data }) => ({
description: 'Browse all products.', // from ---page
title: `${data.products.length} products — Neuralkit`, // lifted from <title>
})
head.__needsData = true // runtime marker
export const loader = async ({ env }) => ({ products: await env.DB.list() })
export default function Page({ data }) {
return (
<>
{/* <title> stripped from the body */}
<ul>{data.products.map(p => <li>{p.name}</li>)}</ul>
</>
)
}

Same rule applies to <meta name="description"> and other compile-recognized head tags. Single source of truth (the loader’s data); zero duplicate queries; head streams as parallel as it can given its dependencies.

  • Trades: the head/loader streaming parallelism for the dynamic-title case. Stage 1 of streaming SSR (the <!doctype><head>… chunk) waits for the loader to resolve because the title is derived from data.
  • Gives: zero duplication, declarative title alongside the body, no separate DB query.
  • Net cost: ~0 ms — the parallelism it gave up was head doing its own I/O, not head reading already-computed data. Reading data is sub-millisecond work after loader resolves.

Static title in ---page (no <title> in body) keeps full parallelism — head streams as soon as the static object is read.

.phaze exposes phaze-cloudflare’s existing three-layer chrome composition without inventing new machinery:

LayerWherePersists acrossSlots
App shellsrc/app.phaze (no ---page, just an arrow)Every navigationchildren
Section layoutlayout: X label in ---pageSame-section navschildren
Per-page layout<Layout> wrapped in the trailing region’s JSXNothing — just the page’s own compositionchildren + <Fragment slot="X"> named slots

The three nest:

App shell ← persistent across all nav
└─ Section layout (from `layout:`) ← persistent within same-section nav
└─ Per-page <Layout> wrap ← per-page composition with named slots
├─ <Fragment slot="header">
├─ <Fragment slot="sidebar">
└─ default content

.phaze doesn’t need to invent slot composition — phaze-compile’s existing extractSlots pass handles <Fragment slot="X"> lifting at the inline <Layout>…</Layout> call site in the trailing region.

Every signal has exactly one of two lifetimes:

  • Local — one copy per component mount; disposed on unmount.
  • Global (module) — one copy per page load; shared across every mount of this component (and every other importer if exported).

The decision flow:

  1. Does each component instance need its own copy?
    • YesLocal. Declare in ---state without a decorator. Compiles to const X = … inside the component body.
    • No → continue.
  2. Is THIS component the only consumer (it owns the signal, even if shared across its own mounts)?
    • YesGlobal (module), inline. Declare in ---state with @global. Compiles to const X = … at module scope, above the component.
    • No → continue.
  3. Do multiple unrelated components read or write this signal?
    • YesGlobal (module), shared file. Declare in src/store.ts (or another shared module) and import it into each consumer.

Where each kind of declaration lives:

DeclarationRegionLifetimeMarker
ImportsTop of fileModule load
Inline subcomponents, lookup tables, helpersTop of file (above ---page)Module load
state: s('s1') (per-mount state machine)---stateLocalnone
ANIM_STATES: { … } (per-mount constant alongside state)---stateLocalnone
if (sig.current() === 'X') sig.set(…) (mount-time setup)---stateLocalnone — passthrough
@global selectedPost: signal('landing') (this component’s shared selection)---stateGlobal (module)@global
const items: Item[] = [] (typed Local decl)---stateLocalnone — explicit-const passthrough
const products = s.async(actions.X()) (typed Global decl)Above ---pageGlobal (module)— — colon-form doesn’t cover types
Cross-component shared signalssrc/store.tsGlobal (module), project-wide— — explicit imports per consumer
Effects, refs, use:-directives bound to local stateTrailing regionLocal (live alongside the JSX they touch)

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 ANIM_STATES = {
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={ANIM_STATES[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:actions'
---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 ANIM_STATES = {
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={ANIM_STATES[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>
)
}

Each entry below names the anti-pattern, why it’s wrong, and the canonical replacement.

// ❌ Wrong — head queries the DB; duplicates the loader's work
head: ({ params, env }) => env.DB.get(params.id).then(row => ({ title: row.name }))

---page is for static config only. Anything that needs PageContext belongs in ---data. For dynamic titles, write <title> in the component body — the compiler lifts it to a synthesized head that derives from data.

// ❌ Wrong — the `() =>` is the compiled output
const data = signal.async(() => fetcher())
const doubled = c(() => count() * 2)
watch(() => log(count()))
// ✅ Right — the compiler adds the arrow
const data = s.async(fetcher())
const doubled = c(count() * 2)
watch(log(count()))

s.async / c / watch / phaze from /dsl auto-thunk their argument. Writing () => defeats the entire subpath.

// ❌ Wrong — re-introduces the boilerplate the format drops
({ data }) => {
const state = s('s1')
return <>…</>
}
// ✅ Right — page trailing region IS the function body
const state = s('s1')
<></>

The page’s trailing region is statements + a JSX expression. The compiler wraps it.

Method-form predicates that collide with s.async

Section titled “Method-form predicates that collide with s.async”
// ❌ Wrong — `s` from `/match` has `.is`/`.not` but blocks `s.async` auto-thunking
import { s } from '@madenowhere/phaze/match'
state.is('X')
const data = s.async(fetcher()) // NOT auto-thunked; needs explicit () =>
// ✅ Right — single `s` source, free-function predicates
import { s } from '@madenowhere/phaze/dsl'
import { is, not } from '@madenowhere/phaze/match'
is(state, 'X')
const data = s.async(fetcher()) // auto-thunked

ANIM_STATES lifted to @global or above ---page

Section titled “ANIM_STATES lifted to @global or above ---page”

The canonical phaze idiom keeps lookup tables Local — colocated with the state signal they drive. Lifting ANIM_STATES to @global (or above ---page) makes the reader scroll across boundaries to find the state→pose mapping. Put it right under the state signal in ---state, both Local:

---state
state: s<'s1' | 's2' | 's3'>('s1')
ANIM_STATES: { s1: { y: 0 }, s2: { y: -8 }, s3: { y: 0 } }
---

That’s one Local block, two consecutive lines, lifetime obvious.

This project uses verbatimModuleSyntax: false in tsconfig.json — type-only names elide from plain import automatically. Writing import type { X } from '…' is redundant and conflicts with the project’s commit 43125e8 convention.

Field-style fields can’t reference each other — they’re strictly parallel:

// ❌ Wrong — `related` can't reference `products`
---data
products: env.DB.list()
related: env.DB.list({ category: products[0]?.category }) // products is undefined here
---
// ✅ Right — switch to function-body form
---data
const products = await env.DB.list()
return { products, related: await env.DB.list({ category: products[0]?.category }) }
---

.phaze is a source-syntax layer. Nothing at the runtime layer changes:

  • The PageModule contract is unchanged — the compiler emits export const head/loader/headers/revalidate/layout exports verbatim.
  • Phaze’s reactivity model is unchanged — signals are signals, computeds are computeds, the SSR/hydrate path is the same.
  • The phaze-cloudflare router is unchanged — currentRoute() is the same signal; the swap mechanism is the same.
  • The action surface is unchanged — actions.X(input) per-call-site fetch arrow inlining is the same.
  • Bundle cost is zero at the framework level — .phaze is build-time only.

The same is true of every compile-strip subpath (/dsl, /match, /numeric, /list, /time’s duration helpers) — all of them work inside .phaze boundaries verbatim because the compiler runs them after lowering .phaze to .tsx.

.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.

FileExtensionMode
Page (---page present).phaze under src/pages/**Page
Component (no ---page).phaze anywhereComponent
Page that doesn’t want .phaze sugar.tsx under src/pages/**Plain PageModule, no boundaries
Component that doesn’t want .phaze sugar.tsx anywherePlain function component

.phaze and .tsx coexist — the choice is per-file. A project can adopt .phaze gradually, file-by-file.

  • Phaze Router — the runtime contract .phaze compiles to
  • Page Anatomy — the five PageModule exports that ---page and ---data produce
  • DSL & directives — the compile-strip helpers (s, c, watch, phaze, s.async) .phaze files should use throughout
  • Phaze + Cloudflare — the adapter that hosts .phaze pages