Skip to content

.phaze: Boundaries

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
restricted: 'admin'
---
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
export const restricted = 'admin'
  • 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.

restricted — the one label with a bare form

Section titled “restricted — the one label with a bare form”

Every other ---page label needs a value; restricted also accepts a bare form. Bare restricted (no :) emits export const restricted = true; any value form passes through unchanged. That’s the whole fence mechanic — what the values mean (roles, predicates, sessions, the 401/403/404 outcomes) is the Authorization reference’s job, not the format layer’s.

---data — server-side data loading + placeholder data

Section titled “---data — server-side data loading + placeholder data”

In page mode (---page present) holds the page’s loader — server-side, pageCtx-bearing, runs in parallel with head/headers. In component mode (no ---page) holds module-scope data declarations — static placeholder data, async data signals, or anything else you want at module scope. Two syntactic forms cover both:

When you have multiple independent fetches:

---data
{/* Each labeled expression becomes one parallel fetch.
pageCtx ({ env, params, cookies, request, ctx }) is implicitly destructured.
`ctx` is the Cloudflare Workers ExecutionContext — use `ctx.waitUntil(promise)`
for fire-and-forget background work that should outlive the response. */}
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, ctx }) => {
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, ctx }) => {
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 data declarations — either form works:

  • Static placeholder datalinks: [{ href: '/', label: 'Home' }, …], DESCRIPTION_DATA: { …content map… }. Field-style compiles to const NAME = value at module scope.
  • Async data signalsposts: s.async(actions.getPosts()), users: s.async(fetch('/api/users').then(r => r.json())). Same field-style emit; the value is just a reactive primitive.

Both compile to module-scope const declarations the same way ---state’s @global lines do. The semantic distinction (“loaded from the world” vs “UI state”) is for the reader; the compile output is uniform. Field-style is the canonical form; function-body form is the escape hatch for dependent statements or local computations.

There’s no pageCtx in component mode — env/params/cookies/request/ctx are server-only handler-scope concerns and components are universal (run on both server and client).

---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')
CARD_ANIM: { 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 CARD_ANIM = { 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 … (setup statements)Local (setup)Passthrough verbatim, inside the body
// / /* */ comments, blank linespreserved in placePassthrough verbatim

@global is the v1 decorator. The slot is extensible — future annotations would stack on the same line without changing the parser. 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 — via the factory’s type parameter

Section titled “Typed declarations — via the factory’s type parameter”

The colon-form’s : separates label from expression, so there’s no place for name: Type = expr syntax inside the fence. Typed declarations go through the factory’s type parameter — s<T>(initial), c<T>(expr), store<T>(initial). No explicit const keyword needed; the fence IS the declarator.

---state
drafts: s<Item[]>([]) // typed via factory <T>
filterMode: s<'all' | 'new'>('all') // union via factory <T>
config: store<Config>({ api: '/' }) // typed store
@global selectedPost: signal('landing') // Global (module) via @global
---

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.

---platform and ---cloudflare are a separate edge-data subsystem: they list bare field names that compile to instance-scope edgeSignals resolved from Cloudflare’s per-request cf object, not to exports / loaders / state like the four boundaries above. They have their own page — see Edge data.

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 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 },
},
}
{/* The final JSX expression IS the implicit return. */}
<>
<title>{`${data.products.length} products`}</title>
<section use:spring={SECTION_ANIM[state()]}></section>
</>
Compiles to
export default function Page({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
const state = s<'s1' | 's2' | 's3'>('s1')
const SECTION_ANIM = { /* … */ }
return (
<>
{/* <title> stripped — lifted to a synthesized head export */}
<section use:spring={SECTION_ANIM[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 CARD_ANIM = { /* … */ }
return (
<article use:spring={CARD_ANIM[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.