@magic-spells/puzzle
v0.9.0
Published
SPA-first JavaScript framework — single-file .pzl components, reactive data() components, Go + esbuild compiler
Maintainers
Readme
Puzzle
A SPA-first JavaScript framework with single-file components, reactive data binding, and Go-based compilation.
▶ Live demo — the music example app built with Puzzle.
- ~200 ms production builds — compile, bundle, Tailwind, and minify, end to end (todos example, Apple Silicon)
- Small apps, honestly measured — a minimal app is 23.0 KB gzip with the router, store, and validation included, not just a view layer; the complete todos example ships at 27.0 KB gzip. Regenerated every release by
npm run measure:size - Zero JavaScript toolchain — the CLI is one prebuilt Go binary; no Babel, no bundler config, no postinstall scripts
Quick start
Install the puzzle CLI once (a prebuilt Go binary — no toolchain needed):
npm install -g @magic-spells/puzzleThen scaffold and run a new app:
puzzle init my-app
cd my-app
npm install
puzzle dev # develop with live reload
puzzle build # build for productionOr add Puzzle to an existing project — one dev dependency gives you both the client runtime and the CLI:
npm install -D @magic-spells/puzzleStatus: 0.9.0 — the current release. The browser runtime, Go compiler, static generator, and CLI are implemented and covered by Go, Vitest/jsdom, type, package, example, and browser-focused checks.
Puzzle is pre-1.0, so 0.x minor bumps may carry breaking changes — caret ranges do not cross them. See CHANGELOG.md before upgrading.
constellation/doc/DOC-SPEC.md is the canonical, frozen v1 contract — it indexes the numbered sections (§1–§67) across six domain cards, and they are the source of truth for exactly what ships; if anything here conflicts with it, the spec wins.
Features
- Single-file components (
.pzl) with template + scripts + styles — optional TypeScript (<script lang="ts">), scoped styles (<style scoped>), skeletons, comments, slots, and refs - Snippets —
<Snippet fits="row" user>…</Snippet>hands a component a template it can stamp repeatedly with its own data; parameters are declared as bare attributes, and a snippet forwards through a wrapper's<Children/> - Component families — related components import as one unit and invoke with dot notation (
<Frame.Wrapper>), scaffolded bypuzzle generate component Frame --family Wrapper,Content - Reactive data with automatic view updates
- Two-way form binding with no directive —
value={ draft }andchecked={ todo.completed }read and write; the compiler synthesizes the handler, so there is nobind:prefix and no mirror handler to maintain - Model/store architecture with adapters, relationships, schema validation, persistence, and read/write server sync — opt-in via the
@magic-spells/puzzle/adaptersubpath (local-only apps ship none of it) - Server data with no loading code —
findOne/findManyinside a view'sdata()fetch whatever the store is missing and settle before the view commits, so a committednullalways means "does not exist", never "still loading" - Template expressions that are JavaScript —
{ truncate(title.toLowerCase(), 40) },{#for t in todos.filter(t => !t.done)}: one closed grammar and method table, parsed once and shared with Magic Spells Sites, with display functions called like any function - Translations —
{ t('cart.title') }with oneapp/locales/<tag>.jsonper language: nested files, CLDR plurals throughIntl.PluralRules, missing keys filled from the default at build time, and only the active locale's hashed file downloaded;this.ctx.i18n.setLocale('es')switches in place. Apps withouti18nconfigured ship none of it - Multilingual static sites —
i18n.routing: 'prefix'gives every non-default language its own URLs (/es/about) and its own prerendered pages, withhreflangalternates, asitemap.xmloncesiteis set, a first-visit language redirect,<html dir>for right-to-left locales and translated page titles - Raw template blocks —
{#raw}…{/raw}turns off template-expression parsing so JSON, JavaScript, CSS, and syntax examples with literal braces compile as-is (HTML inside still renders normally) - Nested routing with view slots — path routing by default, hash/memory via
hashRouter()/memoryRouter()from@magic-spells/puzzle/router-modes; scroll restoration; base paths; anchors; mode-agnostic path-shaped hrefs via the built-inlinkfunction - Prerendered
:paramroutes —staticPathslists a route's pages at build time (/blog/:slugbecomes one page per post, per language), as an array, a function, or a model name - Runtime component selection —
<Component is={ cards[embed.type] }>picks one of the constructors the file imports; no registry, no runtime module lookup - Hooks for tests and tools —
router.push()resolves aNavigationResultinstead of rejecting,router.viewslists every page (with an optionalagentdescription per route),store.observe()watches records from outside a view, andawait app.idle()waits for the app to go quiet - On-demand route views —
view: lazy(() => import('./views/Admin.pzl'))in the route table downloads a view or layout the first time a navigation needs it, guards first - Virtual DOM with efficient diffing, pk-aware list keying, and
{#for}rows and static markup cached between renders - Built-in view & component animations (Web Animations API), including visibility-triggered enters and app lifecycle hooks
- Route transitions: sequential by default; overlapping cross-fades and shared-element morphs (experimental — see below)
- App-level error handling — one compiled
errorViewreplaces a failed view in place with{ error, info, retry }; theonErrorhook funnels every framework-contained error - Go-based compiler for fast builds and state-preserving live reload (store and JSON-safe local view state survive edits)
- Type-checked templates —
puzzle checkruns the app's own TypeScript over every.pzl, so a typo in{ user.nmae }is a type error reported at its real line and column - SPA-first output with two optional prerender modes —
output: 'hybrid'(prerendered pages the SPA takes over) andoutput: 'static'(true static pages, no router orapp.js); no request-time SSR server or hydration layer - Puzzle Pieces component library — ready-made
.pzlcomponents installed withpuzzle add piece <name>(browse the catalog)
Experimental: overlapping route transitions (
transitionMode: 'overlap') and shared-element morph transitions (@magic-spells/puzzle/morph) work and are tested individually, but their interaction matrix with other opt-in features (nested reused layouts, hash/base-path routing, anchors) has had less real-world mileage than the core. They're safe to try — just expect rougher edges there, and prefer the default sequential transitions where stability matters most.
Installation
Install Puzzle as a dev dependency. This one package gives you both the client
runtime (import { PuzzleView } from '@magic-spells/puzzle') and the puzzle CLI:
npm install -D @magic-spells/puzzleThe CLI is a prebuilt Go binary delivered through per-platform optional
dependencies (no compiler toolchain, no postinstall step). Prebuilt binaries ship
for macOS (arm64, x64), Linux (x64, arm64), and Windows (x64); npm
downloads only the one matching your machine. Windows-on-ARM runs the x64 binary
under emulation. Once installed, puzzle is on your PATH for npm scripts:
// package.json
{
"scripts": {
"dev": "puzzle dev",
"build": "puzzle build"
}
}Scaffold a new project
With the CLI installed globally (npm install -g @magic-spells/puzzle):
puzzle init my-app
cd my-app
npm install
npm run devThe generated app depends on @magic-spells/puzzle locally, so collaborators
who clone it only need npm install — no global CLI required.
For a TypeScript app, answer yes to the TypeScript prompt or pass the flag:
puzzle init my-app --typescript
cd my-app
npm install
npm run check # type-check with puzzle check
npm run devEvery component is <script lang="ts"> with typed data(), props, events and
lifecycle hooks, the modules are .ts — the build entry app/app.ts,
routes.ts, the todos template's models — and package.json adds
typescript plus a check script. The build starts from app/app.ts when it
exists, otherwise app/app.js, and refuses an app that has both. It strips
types without checking them; npm run check is the type check, under the
strict tsconfig.json the scaffold writes. In an app with that
tsconfig.json, puzzle generate writes TypeScript too: <script lang="ts">
stubs, app/models/<name>.ts, and an index.ts family barrel.
Other platforms, or building from source
The prebuilt binaries cover macOS, Linux, and Windows. On any other platform — or
if you prefer to build the CLI yourself — build it from a checkout with Go, then
put the puzzle binary on your PATH:
git clone https://github.com/magic-spells/puzzle
cd puzzle/packages/puzzle/compiler && go build -o puzzle ./cmd/puzzleOn Windows use -o puzzle.exe.
Project Structure
my-puzzle-app/
├── app/
│ ├── app.js # App initialization
│ ├── routes.js # Route definitions
│ ├── models/ # Optional models and adapters
│ ├── views/ # Routed .pzl views
│ ├── components/ # Reusable .pzl components
│ ├── layouts/ # Route layouts with <Slot/>
│ ├── assets/ # Source assets, including {#svg} files
│ ├── styles/ # Tailwind entry + global CSS
│ └── public/ # Static assets + index.html
├── puzzle.config.js # Compiler, styles, and output config
└── package.jsonTemplate Syntax
Basic Interpolation
<p>{ user.name }</p>
<h1>{ capitalize(title) }</h1>
<p>{ items.length } items, { currency(price * quantity) }</p>
<p>{ nickname ?? name } · { tags.join(', ') }</p>
<ul>{#for t in todos.filter(t => !t.done)}<li>{ t.title }</li>{/for}</ul>A template expression is JavaScript from a closed table, so it means the same
thing when PuzzleKit compiles it to JavaScript and when Magic Spells Sites
evaluates it in Go. The grammar: literals, template literals, arrays and
objects; a.b, a?.b, a[i]; the operators with JavaScript precedence
(+ - * / %, comparisons, == != === !==, && || !, ??, ?:); arrow
functions as call arguments (items.map(i => i.name)); and three kinds of
call — a display function by name (currency(price)), a method from the
table on a string, array or number (name.trim(), items.filter(…),
n.toFixed(2)), and the globals Math.*, Number, String, Boolean,
parseInt, parseFloat, isNaN, isFinite, encodeURIComponent,
decodeURIComponent, encodeURI, decodeURI, Array.isArray and
Object.keys/values/entries. The count is .length. No method mutates
(toSorted and toReversed, not sort and reverse), and anything outside
the table — new Date(), Date.now(), JSON.stringify(x), **, bitwise
operators, spread, regex literals, and window, document or globalThis
read as a value — is a compile error at its own line and column that names
the replacement. Member reads are
guarded: { user.address.city } prints nothing when address is missing.
A template never reaches the view instance: this is a compile error in every
template expression, @event handler arguments included. Every value a
template shows comes through data(), and a handler reaches the view through
its own name (@click={ save(items.length - 1) } calls the view's save).
An @event value is always such a call (or the handler's bare name); inside
its arguments event is the DOM event and a chain rooted at it is
unrestricted — @input={ rename(event.target.value) }. Work on the event
itself, such as event.preventDefault(), goes inside the handler method or
through the :prevent modifier.
A literal brace is escaped with a backslash — \{ and \} — anywhere an
expression could appear, attribute values included:
<input pattern="[0-9]\{5\}" />
<p>Use \{ name \} to interpolate.</p>For a whole block of literal braces (JSON, CSS, a syntax example) use
{#raw}…{/raw} instead — but {#raw} is not allowed inside an attribute
value, so the escape is the only way out there.
Comments
{## Single-line hash comments ##} {## Multi-line hash comments work too These
are useful for documentation ##}
<div class="user-card">
{## TODO: Add user avatar ##}
<h2>{ user.name }</h2>
{#comment}
<div class="temporarily-disabled">
<p>{ user.bio }</p>
</div>
{/comment}
</div>Control Flow
<!-- Conditionals with {:else if} chaining -->
{#if loggedIn}
<p>Welcome back!</p>
{:else if loading}
<p>Loading...</p>
{:else}
<p>Please log in</p>
{/if}
<!-- Loops -->
{#for item in items}
<li>{ item.name }</li>
{/for}
<!-- Multi-branch; a {:when} takes comma-separated values, matching any of them -->
{#case status}
{:when 'loading', 'paused'}
<LoadingSpinner />
{:when 'error', 'no-connection'}
<ErrorMessage />
{:else}
<SuccessContent />
{/case}
<!-- Inverted conditional -->
{#unless user.isAdmin}
<p>Access denied</p>
{/unless}See constellation/doc/DOC-TEMPLATE-SYNTAX.md for the full grammar.
Events & Binding
<!-- Event handlers -->
<button @click={ handleClick }>Click me</button>
<form @submit={ handleForm(event) }>
<!-- Two-way: reads searchQuery AND writes the user's edit back. No handler. -->
<input value={ searchQuery } />
<select value={ selectedOption }></select>
</form>
<!-- A one-member path writes the record itself, through validated update() -->
<input type="checkbox" checked={ todo.completed } />
<!-- Event modifiers: prevent / stop / once / passive / capture + key filters, and they stack -->
<input @keydown:enter={ handleSubmit } @keydown:escape:prevent={ cancelEdit } />
<button @click:once={ claimReward }>Claim</button>
<div @wheel:passive={ onScroll }>…</div>Two-way binding (D147): value= and checked= on a plain <input>, <textarea>, or <select> bind in both directions when the expression is a bare identifier or a one-member path — the compiler synthesizes the write-back handler. A bare identifier writes local state; a path writes the record through validated update(). Opt out with your own @input/@change, a non-path expression (value={ x ?? '' }), or a static readonly. Handlers on other events (@blur, @keydown:enter) coexist with the bind.
Event modifiers (prevent, stop, once, and key filters like :enter/:escape) stack; the canonical order is key-gate → once-spend → preventDefault → stopPropagation → handler. passive and capture set the native listener options (D187); passive with prevent is a compile error.
Dynamic URL attributes (href={ url }, src="{ path }", …) never carry javascript: or vbscript:, and data: only in image positions: a blocked value leaves the attribute off, in the browser and in prerendered HTML alike, with a warning in development (D186). Static attributes you write yourself are left as written. See constellation/doc/DOC-SPEC.md §5.
Slots & Nested Routing
<!-- Layout slot: the routed view renders at <Slot/> -->
<div class="user-layout">
<nav><!-- user navigation --></nav>
<Slot />
<!-- routed view renders here -->
</div>
<!-- Named component slots: static slot="name" on a direct child -->
<Card>
<h2 slot="header">Card Title</h2>
<p>Card content</p>
<!-- no slot attr → default slot -->
</Card>Named slots. The child declares regions with <Slot name="header"/>, and the call site routes a direct child into one with a static slot="header" attribute (stripped from the rendered output). An unfilled marker renders its fallback body — the content between paired marker tags — or nothing when self-closing; routed views fill the default marker only. See constellation/doc/DOC-SPEC.md §24.
Reusable components declare default child content with <Children/>, and a paired marker's body is the fallback shown when the caller supplies nothing:
<article class="card">
<Children/>
<footer><Slot name="footer"><button>OK</button></Slot></footer>
</article>Display Functions
A display function transforms a value for display without modifying the underlying data. It is called like any function, and calls nest:
{ truncate(capitalize(post.body.trim()), 140) }
<!-- trim, capitalize, then cut to 140 characters -->
{ date(post.publishedAt, 'long') }
<!-- September 24, 2026 -->What a JavaScript method or Math global already says is not a function: use
.toUpperCase(), .toLowerCase(), .trim(), .replaceAll(a, b),
.join(', '), Math.floor(), Math.ceil() and Math.abs(). Lists are
shaped with array methods (items.filter(…), .toSorted(…), .at(-1)) or in
data(); counting, arithmetic and fallbacks are .length, the operators and
??.
The library is the standard set — the same names, arguments and meaning
in PuzzleKit and in Sites — plus the browser-only link and timeago. An
app registers its own through the formatters config map and calls them the
same way ({ specialFormat(product.title) }); one that reuses a standard name
wins, with a development warning. An unregistered name never crashes a render —
the value passes through unchanged and a single console.error names the
offender (and, for a name JavaScript covers, the method to write instead).
Text
{ capitalize(name) }
<!-- First character uppercase, the rest untouched: iPhone → IPhone -->
{ truncate(content, 100) }
<!-- At most 100 characters, the … included -->
{ pluralize(count, 'comment') }
<!-- 1 comment / 3 comments; irregular: pluralize(n, 'person', 'people') -->
{ strip_html(post.excerpt) }
<!-- Tags removed -->Numbers
{ currency(price, '$', 2) }
<!-- $1,219.99 -->
{ percentage(progress) }
<!-- 75.4 → 75% (the number as written) -->
{ number_with_delimiter(count) }
<!-- 1,234,567 in the viewer's locale; number_with_delimiter(n, ',') forces one -->
{ compact_number(followers) }
<!-- 1.2K, 45K, 3.4M -->
{ round(rating, 1) }
<!-- 4.3 — half away from zero, a number -->Values
{ json(obj) }
<!-- JSON with sorted keys -->Markup Functions
Every interpolation is text unless its outermost call is one of these two, and both are safe by construction:
{ raw(post.bodyHtml) }
<!-- Real HTML, always through an allowlist sanitizer -->
{ newline_to_br(comment.text) }
<!-- Escaped text with a real <br> per line break -->raw keeps document markup, links and images, class and id (DOMPurify's
defaults; no id on <img> and none starting with __), and
target="_blank" on links (always with rel="noopener noreferrer"; any other
target is dropped). It removes <script> (with its contents), <style>,
<iframe>, <object>, <embed>, <svg>, forms, every on* handler,
style, name, and any URL that is not relative or http(s) (links also keep
mailto: and tel:). Because class and id survive, sanitized content can
use your app's CSS and name its elements — for untrusted user HTML that is a
UI-overlay and naming risk (a fixed inset-0 block over your page, an id
that shadows an undefined global), not code execution. A markup function must
be the outermost call of a text interpolation — inside another call, in an
attribute or a prop it is a compile error — so an app function can never
inject markup. Apps that never use either function ship none of this code.
Dates
date, time and datetime take the presets short, medium, long and
iso, in the viewer's locale and time zone. With no preset, date is medium,
time is short, and datetime is the medium date with the short time. A
literal preset the standard functions do not know (time(at, 'shrot')) is a
compile-time warning (an app may register its own time), and at run time
an unknown preset renders the default.
{ date(createdAt) }
<!-- Sep 24, 2026 -->
{ date(createdAt, 'long') }
<!-- September 24, 2026 -->
{ time(createdAt) }
<!-- 3:04 PM -->
{ datetime(updatedAt) }
<!-- Sep 24, 2026, 3:04 PM -->
{ datetime(updatedAt, 'iso') }
<!-- 2026-09-24T15:04:05-04:00 -->
{ timeago(publishedAt) }
<!-- 2 hours ago -->Translations
With i18n: { locales: ['en', 'es'], defaultLocale: 'en' } in
puzzle.config.js and one app/locales/<tag>.json per locale:
{
"cart": {
"title": "Your cart",
"items": { "one": "{count} item", "other": "{count} items" }
},
"greeting": "Hello, {name}!"
}{ t('cart.title') }
<!-- Your cart -->
{ t('greeting', { name: user.name }) }
<!-- Hello, Ada! — fills {name} -->
{ t('cart.items', { count: cart.count }) }
<!-- 3 items — a numeric `count` picks the plural form -->A missing key prints the key itself. Dates and numbers (date,
number_with_delimiter, compact_number, …) follow the active locale. Switch
languages with
this.ctx.i18n.setLocale('es'): the new file loads first, then the page
rebuilds in place and the choice is remembered. See examples/i18n.
Add routing: 'prefix' to the i18n block and every non-default locale gets
its own URLs under its tag (/es/about; the default stays unprefixed), with one
prerendered page per locale in static and hybrid output. link() adds the
active prefix for you, and this.ctx.i18n.locales is a list of
{ locale, label, href, active } entries a language switcher can render as is
(the language-switcher and language-menu pieces do).
Single-File Components
<!-- Button.pzl -->
<puzzle-view>
<button
class="btn { variantClass } { sizeClass }"
@click={ handleClick }
disabled={ disabled }>
{#if icon}
<Icon name={ icon } />
{/if}
<Children />
</button>
</puzzle-view>
<script>
import { PuzzleView } from '@magic-spells/puzzle';
import Icon from './Icon.pzl';
export default class Button extends PuzzleView {
data(params, props) {
const variant = props.variant || 'default'
const size = props.size || 'medium'
const icon = props.icon || null
const disabled = !!props.disabled
return {
variant,
size,
icon,
disabled,
variantClass: `btn--${variant}`,
sizeClass: `btn--${size}`
}
}
// `click` is a callback prop: a parent writes <Button @click={ handler }>
// and the compiler hands the child a function on this.props.click. There is
// no this.$emit — the child gates the event, the parent's function does the work.
events = {
handleClick: (event) => {
const { disabled } = this.getData()
const { click } = this.props
if (!disabled && typeof click === 'function') {
click(event)
}
}
}
}
</script>
<style>
.btn { padding: 0.75rem 1.5rem; border: 1px solid transparent; border-radius:
0.5rem; cursor: pointer; transition: all 0.2s; } .btn--primary { background:
var(--primary-color); color: white; } .btn--medium { font-size: 1rem; }
</style>Waiting for a render
await this.renderTick() resolves once every pending store flush and scheduled render has landed, including renders those schedule (D188):
this.setData({ editing: true });
await this.renderTick();
this.refs.editor.focus();It forces nothing and does not wait for async data(), requests or navigation; app.idle() waits for the whole app to go quiet.
Imports
Component imports live in <script> and can be relative or use @, the
built-in alias for your app/ directory — no configuration, works from any
depth:
import Icon from './Icon.pzl'; // relative
import Icon from '@/components/Icon.pzl'; // app/components/Icon.pzlComponent families
Related components can import as one unit and invoke with dot notation. Group
them in a directory with a plain JS barrel — .pzl stays one class per file:
app/components/Frame/
Frame.pzl Wrapper.pzl Content.pzl index.js// index.js
import Frame from './Frame.pzl';
import Wrapper from './Wrapper.pzl';
import Content from './Content.pzl';
export { Frame, Wrapper, Content };
export default Object.assign(Frame, { Wrapper, Content });<puzzle-view>
<Frame>
<Frame.Wrapper>
<Frame.Content>…</Frame.Content>
</Frame.Wrapper>
</Frame>
</puzzle-view>
<script>
import { PuzzleView } from '@magic-spells/puzzle';
import Frame from '@/components/Frame';
export default class Page extends PuzzleView {}
</script>A dotted tag is an ordinary member expression resolved against module scope —
no registry, no compiler magic. puzzle generate component Frame --family
Wrapper,Content scaffolds the whole directory, barrel included.
Monorepo
The framework lives at packages/puzzle in the magic-spells/puzzle
repository; the repo root is a private shell whose scripts delegate here.
packages/ holds everything that releases in lockstep with it:
packages/puzzle-lang— the Puzzle template language (lexer, section splitter, AST, expression parser, positioned errors) as its own Go module, which the compiler importspackages/puzzle-pieces— the official component library (published to npm as@magic-spells/puzzle-pieces)packages/puzzle-devtools— the Chrome DevTools extension (ships as an extension zip, not npm)packages/puzzle-eslintandpackages/puzzle-prettier— the.pzllint and format plugins
Editor grammars live in their own repos — see Syntax Highlighting below.
Puzzle Pieces
Puzzle Pieces is the official component library for
Puzzle — ready-made .pzl components (and their styles) you can drop into any
app. It lives in this monorepo and releases in lockstep with the framework:
puzzle add piece <name>Pieces are fetched from the @magic-spells/puzzle-pieces npm package,
version-matched to your CLI — puzzle 0.8.x pulls the newest pieces 0.8.x — so a
piece is always authored for the compiler installing it. --pieces-version
pins an exact release; --registry accepts npm:pkg[@version], a local
directory, or an http(s) URL.
A piece that is a component family installs path-preserving: its members land in
their own directory under the manifest's target
(app/components/ui/NavigationMenu/Item.pzl), barrel included, and
pieces.lock records each member by its full app-relative path.
Preview every piece in the live catalog at magicspells.io/puzzle-pieces.
Syntax Highlighting
Editor extensions provide full .pzl highlighting — native HTML, JavaScript/TypeScript, and CSS per section, plus Puzzle's template expressions, directives, and event bindings:
- puzzle-vscode - Visual Studio Code extension with snippets and completions
- puzzle-sublime - Sublime Text 4 syntax package
- puzzle-zed - Zed extension (tree-sitter grammar)
Install instructions are in each repository's README.
Documentation
- User Guide - Complete guide to building Puzzle applications
- Component Reference - Complete .pzl component documentation
- Data Layer - Models, adapters, and store management
- Build Process - Compiler and build system details
AI project memory
This repository uses Constellation MCP
as long-term project memory for AI-assisted development. The cards in
constellation/ preserve decisions, features, data structures, component and
file relationships, flows, and plans so future AI conversations can recover the
full project context and build better plans without re-deriving earlier work.
CLI Commands
# Development server with live reload
puzzle dev --port 3000
# Production build (default)
puzzle build
# True static pages (no router, no app.js; per-page mount module)
puzzle build --static
# Prerendered pages plus the SPA bundle the router takes over
puzzle build --hybrid
# Type-check .pzl scripts and template expressions with the app's own TypeScript
puzzle check
# Serve an existing build the way a production host will
puzzle preview
puzzle preview ./my-app --port 4000
# Per-phase timing tables for builds and dev rebuilds
puzzle build --profile-build
puzzle dev --profile-build
# Upgrade the installed CLI, or only check what is available
puzzle upgrade
puzzle upgrade --check
# Install the Puzzle agent skill for your coding tools, or refresh it later
puzzle add skills
puzzle upgrade skillsEvery command above is built and verified today. puzzle build compiles .pzl files and produces a working bundle; puzzle dev watches app/, rebuilds on change, and delivers full-page live reload over SSE (the reload client is injected into index.html at serve time). Both run the declared style pipeline automatically — styles: { use: ['tailwindcss'] } in puzzle.config.js (tailwindcss-only in v1) — so Tailwind output is included in the served/built stylesheet.
By default a build emits one dist/app.js, and a dynamic import() is inlined
into it. Set build: { splitting: true } in puzzle.config.js and each dynamic
import() becomes a lazy chunk under dist/chunks/ that the browser fetches
only when that code path runs — the way to keep a heavy on-demand dependency (a
chart library, a diagram renderer, an editor) off the first page load.
Production split builds name the entry app-<hash>.js, the stylesheet
styles-<hash>.css, and any CSS imported by the entry app-<hash>.css. The build
rewrites the shell's references and adds modulepreload links for the entry's
transitive static-import chunks; lazy chunks stay on demand. esbuild's ESM
splitting has no chunk-loader runtime, so it adds no runtime bytes. The build's
size banner also names your largest dependencies and, in production builds,
flags any single one over 200 KB (the threshold assumes minified bytes, so dev
builds never warn).
build.hashNames defaults to build.splitting. Set
build: { splitting: true, hashNames: false } to keep app.js and styles.css
while retaining preloads, or build: { hashNames: true } to hash without
splitting. Development builds always keep fixed names and add no preloads.
Files copied from public/ are never renamed. A hashed SPA or hybrid build
requires the shell to reference app.js as /app.js, ./app.js or app.js;
use hashNames: false when another page needs the fixed URL.
output: 'hybrid' uses the same entry and stylesheet names as the SPA, with
prerendered HTML under dist/<path>/index.html. output: 'static' ships no
app entry: each page loads dist/_puzzle/<slug>.js, or
dist/_puzzle/<slug>-<hash>.js when hashing is on, and preloads its shared
dist/_puzzle/chunks/. A static build with no splitting or hashNames key
keeps fixed page-module names and styles.css, with preloads in production.
Splitting adds one deployment rule: chunk filenames are content-hashed, so a
new deploy writes new ones, and a tab still holding the previous entry will
ask for the chunk names that entry graph was built with. Keep the previous
dist/chunks/ files available for a support window after a deploy, or deploy
immutably (each build to its own directory, with the old one still served). If a
lazy chunk does 404, the failure is contained the way any other pre-commit route
failure is: nothing commits, the page the user is on stays put, and the loader
rejection reports through onError as the navigation phase. A rejected import
is never cached, so the next attempt loads again — and once the client reloads,
it asks for the chunk names the current build wrote.
On an interactive terminal, build and dev also mention newer Puzzle releases.
The notice is printed from a cached answer and never waits on the network —
no build pays registry latency, ever. When that answer is more than an hour old
the command starts a detached background process to refresh it and exits without
waiting, so a release published since your last check is mentioned on the run
after the refresh lands rather than on the run that fetched it. A failed refresh
is not retried for fifteen minutes, so an unreachable registry costs one
short-lived process every quarter hour rather than one per command. Set
PUZZLE_NO_UPDATE_CHECK=1 to disable it; the check is skipped automatically
when CI is set or output is not a terminal.
puzzle preview serves a build you already produced, with no watcher, no live
reload, and no dev.proxy — the artifact is checked exactly as it sits on disk.
Its optional argument is the project directory (its dist/ is found for
you), not the output directory.
It serves per the resolved output mode: an SPA gets history-API fallback, hybrid
serves the prerendered page first and the shell otherwise, and static gets clean
URLs and a real 404.html rather than the shell — a folder's own 404.html
first, so a miss under /es/ gets dist/es/404.html. It defaults to port 4000 so it
runs alongside puzzle dev.
puzzle upgrade updates the CLI you are running, resolved from the executable's
own install context rather than the current directory — bumping a project's
@magic-spells/puzzle dependency is npm's job, not the CLI's.
puzzle upgrade --check only reports the current and latest versions.
Agent skill
puzzle add skills installs the Puzzle agent skill — how to write .pzl files,
routing, the data layer, static output — into every Claude Code, Codex, and
Cursor config directory it finds (~/.claude, ~/.codex, ~/.cursor). On an
interactive terminal you pick the targets from a checklist; scripts install to
all of them.
The skill is compiled into the CLI binary, so it always matches the version that wrote it. Each install records which version that was, so re-running the command is how you refresh it:
- An install matching your current CLI is skipped as up to date.
- An older one asks before it is replaced. Declining leaves it alone but still installs anywhere that has no skill yet.
- On a non-interactive terminal an older install is refused rather than asked
about, and needs
--overwrite. - A symlinked install (a dev checkout linked into your config dir) is reported
and left alone unless you pass
--overwrite.
puzzle upgrade skills refreshes only the installs you already have, and
puzzle upgrade offers the same refresh automatically after it installs a new
version.
puzzle check type-checks every .pzl file with the TypeScript the app itself
has installed — Puzzle never installs one for you. A lang="ts" script is
checked as written; every template expression is re-emitted as typed statements,
so a typo in { user.nmae } is a type error reported at its real .pzl line and
column. A plain-JavaScript component gets its template expressions checked and
its script body left alone.
The full CLI surface (see constellation/doc/DOC-SPEC.md §13): init, generate, add, check, doctor, and info join dev and build.
# Scaffold a project; omitting the name prompts only in an interactive terminal
puzzle init my-app --template todos
puzzle init my-app --template todos --typescript
# Generate a stub (component, view, layout, or model) — TypeScript stubs in an
# app with a tsconfig.json
puzzle generate component UserCard --path components/ui/
# Wire up Tailwind, install a piece or a palette (see Puzzle Pieces above), or run diagnostics
puzzle add tailwind
puzzle add piece <name>
puzzle add theme <name>
puzzle add skills
puzzle doctorLicense
Puzzle is released under the MIT License.
