.phaze
What .phaze is
Section titled “What .phaze is”.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.
The two modes
Section titled “The two modes”A .phaze file has exactly one mental dispatch:
Has ---page? | Mode | What it is |
|---|---|---|
| Yes | Page | A routed page — appears in src/pages/**, gets routing, caching, server-side data loading |
| No | Component | A 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).
The named boundaries
Section titled “The named boundaries”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:
| Boundary | Semantic | Compiles 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) |
Boundary stacking — the canonical form
Section titled “Boundary stacking — the canonical form”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.
Parser rule
Section titled “Parser rule”| Line shape | Action |
|---|---|
---page / ---data / ---state / ---props | If a boundary is open, close it. Open the new one. |
--- (bare) | If a boundary is open, close it. Transition to trailing region. |
| Anything else | Append to the currently open region (boundary or trailing). |
---page — the static page contract
Section titled “---page — the static page contract”Holds the page’s declarative configuration — static values only. Each labeled statement maps 1:1 to one of the PageModule named exports:
---pageimport { hours, days } from '@madenowhere/phaze/time'import ProductLayout from '../../components/ProductLayout'
layout: ProductLayouthead: { 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 = ProductLayoutexport 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 }forrevalidate).
The labeled-statement syntax
Section titled “The labeled-statement syntax”---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:
Field-style (multi-fetch parallel sugar)
Section titled “Field-style (multi-fetch parallel sugar)”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:
---dataconst 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)), }}Discriminating between the two forms
Section titled “Discriminating between the two forms”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.
Non-page mode
Section titled “Non-page mode”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).
---statestate: s<'s1' | 's2' | 's3'>('s1')ANIM_STATES: { s1: { y: 0 }, s2: { y: -8 } }@global selectedPost: signal('landing')sig: propSelectedPost || selectedPostif (sig?.current() === 'landing') { sig.set({ genres: ['devops', 'ai', 'llm'] })}---Compiles to:
// Global (module) scope — @global promotedconst 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.
The line shapes
Section titled “The line shapes”| Line shape | Lifetime | Emit |
|---|---|---|
<id>: <expr> | Local | const <id> = <expr> inside the component body |
@global <id>: <expr> (in app.phaze) | Global — declaration site | export 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 marker | No 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 site | Single export const covering the whole expression (app.phaze only) |
Multi-line <id>: <expr> (no @global) | Local | Single const inside the component body |
import … / function … / if … / for … / const … / let … | Local (setup) | Passthrough verbatim, inside the body |
// / /* */ comments, blank lines | preserved in place | Passthrough verbatim |
The decorator slot
Section titled “The decorator slot”@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 dependencyvisible : s(false)
---watch(visible() && selectedPost.set(post)) // bare reference; auto-importedBoth 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:
---stateconst 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.
DSL rules (mandatory)
Section titled “DSL rules (mandatory)”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.
Imports for ---state
Section titled “Imports for ---state”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 formWhy 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.
---propspost : Post // required, typedclassName? : string = '' // optional, typed, with defaultchildren? : any // optional, untypedstatus : 'active' | 'inactive' // union typeconfig? : { 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).
Line shapes
Section titled “Line shapes”| Line | Meaning | Emit |
|---|---|---|
<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.
Page mode rejects ---props
Section titled “Page mode rejects ---props”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.
Trailing region — the component body
Section titled “Trailing region — the component body”After the last fenced region comes the component body. The shape depends on the mode:
Page mode (with ---page)
Section titled “Page mode (with ---page)”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> </> )}Component mode (no ---page)
Section titled “Component mode (no ---page)”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.' }---
---dataproducts: 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.
What this trades for what it gives
Section titled “What this trades for what it gives”- 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 fromdata. - 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
datais sub-millisecond work afterloaderresolves.
Static title in ---page (no <title> in body) keeps full parallelism — head streams as soon as the static object is read.
Layouts and slots — the three layers
Section titled “Layouts and slots — the three layers”.phaze exposes phaze-cloudflare’s existing three-layer chrome composition without inventing new machinery:
| Layer | Where | Persists across | Slots |
|---|---|---|---|
| App shell | src/app.phaze (no ---page, just an arrow) | Every navigation | children |
| Section layout | layout: X label in ---page | Same-section navs | children |
| Per-page layout | <Layout> wrapped in the trailing region’s JSX | Nothing — just the page’s own composition | children + <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.
State placement — the decision tree
Section titled “State placement — the decision tree”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:
- Does each component instance need its own copy?
- Yes → Local. Declare in
---statewithout a decorator. Compiles toconst X = …inside the component body. - No → continue.
- Yes → Local. Declare in
- Is THIS component the only consumer (it owns the signal, even if shared across its own mounts)?
- Yes → Global (module), inline. Declare in
---statewith@global. Compiles toconst X = …at module scope, above the component. - No → continue.
- Yes → Global (module), inline. Declare in
- Do multiple unrelated components read or write this signal?
- Yes → Global (module), shared file. Declare in
src/store.ts(or another shared module) andimportit into each consumer.
- Yes → Global (module), shared file. Declare in
Where each kind of declaration lives:
| Declaration | Region | Lifetime | Marker |
|---|---|---|---|
| Imports | Top of file | Module load | — |
| Inline subcomponents, lookup tables, helpers | Top of file (above ---page) | Module load | — |
state: s('s1') (per-mount state machine) | ---state | Local | none |
ANIM_STATES: { … } (per-mount constant alongside state) | ---state | Local | none |
if (sig.current() === 'X') sig.set(…) (mount-time setup) | ---state | Local | none — passthrough |
@global selectedPost: signal('landing') (this component’s shared selection) | ---state | Global (module) | @global |
const items: Item[] = [] (typed Local decl) | ---state | Local | none — explicit-const passthrough |
const products = s.async(actions.X()) (typed Global decl) | Above ---page | Global (module) | — — colon-form doesn’t cover types |
| Cross-component shared signals | src/store.ts | Global (module), project-wide | — — explicit imports per consumer |
Effects, refs, use:-directives bound to local state | Trailing region | Local (live alongside the JSX they touch) | — |
Full example — page
Section titled “Full example — page”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).
---pageimport { hours, days } from '@madenowhere/phaze/time'import ProductLayout from '../../components/ProductLayout'
layout: ProductLayouthead: { description: 'Browse all products in our catalog.' }revalidate: { maxAge: hours(1), swr: days(1), tags: ['products'] }---dataproducts: env.DB.list()featured: env.DB.list({ category: 'featured', limit: 3 })---stateimport { 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></>Full example — component (non-page)
Section titled “Full example — component (non-page)”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> )}Anti-patterns
Section titled “Anti-patterns”Each entry below names the anti-pattern, why it’s wrong, and the canonical replacement.
Function with PageContext in ---page
Section titled “Function with PageContext in ---page”// ❌ Wrong — head queries the DB; duplicates the loader's workhead: ({ 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.
Hand-written thunks in DSL macros
Section titled “Hand-written thunks in DSL macros”// ❌ Wrong — the `() =>` is the compiled outputconst data = signal.async(() => fetcher())const doubled = c(() => count() * 2)watch(() => log(count()))// ✅ Right — the compiler adds the arrowconst 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.
Arrow + return in a page trailing region
Section titled “Arrow + return in a page trailing region”// ❌ Wrong — re-introduces the boilerplate the format drops({ data }) => { const state = s('s1') return <>…</>}// ✅ Right — page trailing region IS the function bodyconst 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-thunkingimport { s } from '@madenowhere/phaze/match'state.is('X')const data = s.async(fetcher()) // NOT auto-thunked; needs explicit () =>// ✅ Right — single `s` source, free-function predicatesimport { s } from '@madenowhere/phaze/dsl'import { is, not } from '@madenowhere/phaze/match'is(state, 'X')const data = s.async(fetcher()) // auto-thunkedANIM_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:
---statestate: 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.
import type in this project
Section titled “import type in this project”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.
Dependent field-style ---data
Section titled “Dependent field-style ---data”Field-style fields can’t reference each other — they’re strictly parallel:
// ❌ Wrong — `related` can't reference `products`---dataproducts: env.DB.list()related: env.DB.list({ category: products[0]?.category }) // products is undefined here---// ✅ Right — switch to function-body form---dataconst products = await env.DB.list()return { products, related: await env.DB.list({ category: products[0]?.category }) }---What the format does NOT change
Section titled “What the format does NOT change”.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/layoutexports 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 —
.phazeis 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.
Compile pipeline
Section titled “Compile pipeline”.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 bundlesArchitectural 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.
Tooling — host adapter wiring
Section titled “Tooling — host adapter wiring”.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()` hookreturn { 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.
TypeScript LSP
Section titled “TypeScript LSP”.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.
File extension on the filesystem
Section titled “File extension on the filesystem”| File | Extension | Mode |
|---|---|---|
Page (---page present) | .phaze under src/pages/** | Page |
Component (no ---page) | .phaze anywhere | Component |
Page that doesn’t want .phaze sugar | .tsx under src/pages/** | Plain PageModule, no boundaries |
Component that doesn’t want .phaze sugar | .tsx anywhere | Plain function component |
.phaze and .tsx coexist — the choice is per-file. A project can adopt .phaze gradually, file-by-file.
See also
Section titled “See also”Phaze Router— the runtime contract.phazecompiles toPage Anatomy— the five PageModule exports that---pageand---dataproduceDSL & directives— the compile-strip helpers (s,c,watch,phaze,s.async).phazefiles should use throughoutPhaze + Cloudflare— the adapter that hosts.phazepages