.phaze: Boundaries
---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: falserestricted: 'admin'---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 = falseexport 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 }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.
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:
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, 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:
---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, ctx }) => { 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 data declarations — either form works:
- Static placeholder data —
links: [{ href: '/', label: 'Home' }, …],DESCRIPTION_DATA: { …content map… }. Field-style compiles toconst NAME = valueat module scope. - Async data signals —
posts: 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).
---statestate: s<'s1' | 's2' | 's3'>('s1')CARD_ANIM: { 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 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.
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 … (setup statements) | 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 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 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 — 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.
---statedrafts: 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---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.
Edge-data boundaries
Section titled “Edge-data boundaries”---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.
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 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> </> )}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 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.' }---
---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.