This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
stx is a fast, modern UI/templating framework that combines Laravel Blade-like syntax with Bun's performance. It's a monorepo containing multiple packages that work together to provide server-side rendering, component-based architecture, and a rich development experience.
This is a Bun workspace monorepo with packages in packages/:
packages/stx- Core framework with template processing enginepackages/bun-plugin- Bun plugin for.stxfile processingpackages/desktop- Native desktop application framework (NEW)packages/markdown- Markdown parsing with frontmatter supportpackages/sanitizer- HTML/XSS sanitizationpackages/iconify-core- Iconify integration corepackages/iconify-generator- Icon package generation CLIpackages/vscode- VS Code extension for.stxsyntaxpackages/devtools- Development toolingpackages/benchmarks- Performance benchmarks
External dependency: Craft (~/Code/craft) - Zig-based native webview framework for desktop/mobile apps
The core template processing is orchestrated by packages/stx/src/process.ts (~920 lines), which acts as a pipeline orchestrator delegating to extracted modules:
- Pre-processing: Comments removal, escaped directives
- Directive Processing: Sequential processing of directives in specific order:
- Stack directives (
@push,@prepend) - JavaScript/TypeScript execution (
@js,@ts) - Includes and layouts (
@include,@layout,@extends,@section) - Custom directives
- Components (via
component-renderer.tsandcomponent-registry.ts) - Async components (
@async) - Conditionals (
@if,@switch,@auth,@env) - Loops (
@foreach,@for) - Error boundaries (
@errorBoundary,@fallback,@enderrorBoundary) - Memoization (
@memo,v-memo) - Expressions (
{{ }},{!! !!}) — includes placeholder system for compile mode - i18n (
@translate) - Forms (
@csrf,@method,@error) - SEO directives (
@meta,@seo)
- Stack directives (
- Post-processing: Middleware, stack replacements, web component injection
| Module | Responsibility |
|---|---|
signal-processing.ts |
Signal detection, setup function wrapping |
runtime-injection.ts |
Signals/router/browser runtime injection |
component-processing.ts |
Component tag parsing (findComponentTags, parseMultilineAttributes) |
script-validation.ts |
Client script validation rules |
inline-assets.ts |
stx-inline asset resolution |
misc-directives.ts |
@json, @once, ref attrs, x-cloak |
signals.ts(~3075 lines) — runtime generation (template literal for client-side signals runtime)signals-api.ts(~550 lines) — TypeScript API (state, derived, effect, batch, lifecycle, type guards)
The Bun plugin (packages/bun-plugin/src/index.ts) registers loaders for:
.stxfiles - Processed as templates and exported as JavaScript modules.mdfiles - Parsed with frontmatter and exported withcontentanddataexports
# Build all packages
bun run build
# Build individual packages
cd packages/bun-plugin && bun run build
cd packages/stx && bun run buildThe build process:
- Builds CSS assets (
packages/stx/scripts/build-css.ts) - Compiles TypeScript using custom build scripts (
build.tsin each package) - Creates compiled CLI binaries for multiple platforms
# Run all tests
bun test
# Run tests for a specific package
cd packages/stx && bun test
# Run specific test file
bun test packages/stx/test/directives/conditionals.test.ts
# Run tests with coverage
cd packages/stx && bun test --coverage
# Run tests in watch mode
cd packages/stx && bun test --watchTests use Bun's built-in test runner with Happy DOM preloaded (configured in bunfig.toml). Test files follow the pattern *.test.ts and are located in each package's test/ directory.
# Lint all code
bun run lint
# Auto-fix linting issues
bun run lint:fixUses @stacksjs/eslint-config for consistent code style.
# Serve .stx files for development
bun packages/bun-plugin/dist/serve.js pages/ --port 8888
# Or using the CLI
stx-serve pages/ --port 3000Only <script server> runs on the server. All other script types run on the client:
| Tag | Execution | Purpose |
|---|---|---|
<script server> |
Server-side | Data fetching, variable extraction for templates |
<script> |
Client-side | Browser code, signals, composables |
<script client> |
Client-side | Same as bare <script> (explicit alias) |
<script type="module"> |
Client-side | ES module scripts |
This rule is enforced across ALL code paths: process.ts, includes.ts, render.ts, serve.ts, plugin.ts, streaming.ts, and build-views.ts. Previously, some paths used heuristics (checking for document/window/localStorage references) to guess whether a bare <script> was client or server — this caused crashes when browser-only code like localStorage.getItem() was executed server-side.
Templates execute in an isolated context. Variables are extracted from:
<script server>tags - Variables declared here are available to template expressions- Parent component/layout context
- Props passed to components
Important: The export keyword is optional in <script server> tags. All variable declarations (const, let, var) and function declarations are automatically made available to the template, whether exported or not.
<script server>
// Both styles work identically:
const title = 'Hello' // ✅ Works (auto-exported)
export const subtitle = 'World' // ✅ Works (explicitly exported)
function greet(name) { // ✅ Works (auto-exported)
return `Hello, ${name}!`
}
</script>
<h1>{{ title }}</h1>
<h2>{{ subtitle }}</h2>
<p>{{ greet('Alice') }}</p>See packages/stx/src/variable-extractor.ts extractVariables() and convertToCommonJS() for implementation details.
Custom directives are registered in packages/stx/src/config.ts as part of defaultConfig.customDirectives. Each directive needs:
name- without the@prefixhandler- function that processes the directivehasEndTag- whether it uses an end tag (e.g.,@directive...@enddirective)description- for documentation
Template caching is managed in packages/stx/src/caching.ts:
- Cache location:
.stx/cache(configurable viacachePath) - Cache invalidation based on file modification times and dependencies
- Disabled in development mode by default
Components use a centralized registry and unified renderer:
component-registry.ts—ComponentRegistrywith builtin registration and file resolutioncomponent-renderer.ts— unifiedprocessComponents()replacing the previous five separate functions- Builtins (in
packages/stx/src/builtins/):stx-link.ts—<StxLink>produces<a data-stx-link>directly (no custom element)stx-image.ts—<StxImage>produces<img>directlystx-loading-indicator.ts— loading indicator builtinindex.ts— barrel file +registerBuiltins()
- User components are
.stxfiles incomponentsDir, resolved recursively to prevent circular dependencies - Components can receive props and slots, and support scoped context
Props are categorized into three types:
static— plain string values (title="Hello")serverDynamic—:prop="expr"evaluated server-sideclientReactive—:prop="expr"preserved for signals runtime
<a href>= native full page reload (always)<StxLink to>= SPA navigation via router- Router intercepts clicks on
[data-stx-link]elements only - Fragment extraction includes body-level styles from
@push
The router detects layout changes and handles them without page reloads:
- Detection:
X-STX-Layoutresponse header and<meta name="stx-layout">tag - Same layout group: Fragment swap (fast, only
<main>content replaced) - Different layout group: Full body swap (entire
<body>replaced) - Layout groups:
'auth'(contains auth/guest layouts),'app'(everything else) - No page reloads — true SPA transitions across layout changes (like Vue/React)
- Prefetch cache stores layout info for instant layout change detection on hover
Components can emit custom events to parent templates:
defineEmits()returns anemit(event, payload)function inside<script>- Uses
CustomEventwithbubbles: truefor DOM propagation @eventattributes on component tags are forwarded to the component root element- Parent handles emitted events via
@event="handler"on component usage
@async(component: 'Name', timeout: 5000)directive loads components asynchronously/_stx/component/:nameendpoint serves individual components as HTML fragments- Supports loading/error/resolved states with configurable timeout and delay
- Scripts are re-executed and
stx:loadevent fired after component loads
stx uses three distinct prefixes:
| Prefix | Purpose | Examples |
|---|---|---|
: |
Structural directives (control flow) | :if, :show, :for, :key |
x- |
Attribute bindings & content | x-class, x-style, x-href, x-src, x-text, x-html, x-model, x-cloak |
@ |
Event listeners (as an attribute) | @click, @submit, @keydown.enter |
@if / v-if / :if / x-if are the same conditional in different syntax — interchangeable sugar, not separate lifecycles. v-if compiles to @if (vue-template.ts); x-if is the same as :if.
| Form | Relationship |
|---|---|
@if(cond) … @elseif … @else … @endif |
canonical Blade-style statement |
v-if / v-else-if / v-else |
Vue sugar → compiles to @if |
:if / :else-if / :else |
attribute form |
x-if / x-else-if / x-else |
Alpine sugar → same as :if |
Reactivity is decided by the data the condition reads, not the keyword. On a page that uses signals (<script client> with state()/derived()/…): a condition that reads a signal is promoted to the client runtime (bindIf/bindIfChain) and re-evaluates on change — including @if/@elseif/@else and v-if chains, which convertSignalDirectivesToAttributes (signal-processing.ts) rewrites into reactive @if/@else-if/@else attribute sibling-chains. A condition over server data ($user, env, a @foreach loop var) is evaluated once on the server (processConditionals). The decision is per-condition (conditionIsClientReactive): a condition is client-reactive iff it references a declared local signal OR a zero-arg getter call (loading(), cart.count() — signals/getters take no args; with-arg calls like formatDate(x) are treated as server helpers), AND it doesn't read a bare <script server> context variable — so a status chip inside a server @foreach (reads loop var b), a @if($user), and a chain that mixes a signal with a bare server var all stay server-side. A page with no signals renders every conditional once on the server.
Note @ is overloaded: a statement @if(...) is a directive, an attribute @click="…" is a client event listener. Full breakdown + gotchas: docs/guide/prefix-convention.md → "Conditionals: @if, v-if, :if are the same thing in different syntax".
— all client-side state lives in x-data is deprecated<script client> blocks using signals:
<script client>
const open = state(false)
const items = state(null)
onMount(async () => {
items.set(await fetch('/api').then(r => r.json()))
})
</script>
<button @click="open.set(!open())">Toggle</button>
<div :show="open">Content</div>
<div :for="item in items()" :key="item.id" class="card">
<p x-text="item.name"></p>
</div>- No
<template>wrappers — put:for/:ifdirectly on the element. stx strips<template>tags during server processing (SFC extraction), so<template :for>breaks. Use<div :for>instead. - All directives work:
:for,:if,:show,x-text,x-html,x-model,x-class,x-style,@click :forsupports parenthesized syntax:(item, index) in array- Avoid
>operator in attribute expressions (:if="count > 0") — use:if="count"or:if="count >= 1"instead. The>can be parsed as the HTML tag closer by regex-based processors.
Components support named slots using the Web Component-style slot="name" attribute:
slot="name"attribute on direct children (no<template>wrapper needed)- Self-closing elements supported
- Multiple children can target the same slot
<template #name>backward compatibility preserved
Web components are built from .stx templates (see packages/stx/src/web-components.ts):
- Configured in
stx.config.tsunderwebComponents - Generate custom element classes
- Support Shadow DOM, observed attributes, and lifecycle callbacks
Configuration is loaded from stx.config.ts or .config/stx.config.ts using bunfig.
Default config is in packages/stx/src/config.ts.
All directories default relative to where stx.config.ts lives. Only override what differs:
my-app/
pages/ ← default pagesDir
layouts/ ← default layoutsDir
components/ ← default componentsDir
partials/ ← default partialsDir
functions/ ← shared composables
stores/ ← state management
public/ ← default publicDir (static assets)
stx.config.ts
crosswind.config.ts ← auto-discovered next to stx.config.ts
Minimal config (all defaults):
export default {
app: { head: { title: 'My App' } },
}Stacks embedded (only override what differs):
export default {
root: 'resources', // shifts base directory
pagesDir: 'views', // pages/ → views/
}root- Base directory for all relative paths (default:.)pagesDir- Pages directory name (default:pages)componentsDir- Components directory (default:components)layoutsDir- Layouts directory (default:layouts)partialsDir- Partials directory (default:partials)storesDir- Stores directory (default:stores)publicDir- Static assets directory (default:public)css- Path to Crosswind config or inline CSS config (default: auto-discoverscrosswind.config.ts)envPrefix- Env var prefix for template exposure (default:STX_PUBLIC_)envFile- Path to .env file (Bun loads.envautomatically)plugins- Plugin/module array (npm packages, local paths, or[path, options]tuples)cache- Enable/disable template cachingdebug- Enable detailed error loggingcustomDirectives- Register custom directivesmiddleware- Pre/post-processing middlewarei18n- Internationalization settingswebComponents- Web component generation config
Plugins register components, functions, stores, pages, and middleware into an stx app:
// stx.config.ts
export default {
plugins: [
'@stacksjs/stx-auth', // npm package
'./plugins/analytics', // local plugin
['bun-queue/devtools', { port: 4400 }], // with options
],
}Plugin definition:
import { definePlugin } from 'stx'
export default definePlugin({
name: 'my-plugin',
components: './components', // auto-registered in host app
functions: './functions', // importable via @/functions/
stores: './stores', // auto-registered
pages: './pages', // merged into file-based routing
setup(options, stx) {
stx.addDirective({ name: 'auth', handler: authHandler, hasEndTag: true })
stx.addRoute('/api/auth/login', loginHandler)
},
})- Bun automatically loads
.envfiles — no stx config needed - Variables with
STX_PUBLIC_prefix (configurable viaenvPrefix) are exposed to templates via$env:
<p>API: {{ $env.STX_PUBLIC_API_URL }}</p>
@if($env.STX_PUBLIC_FEATURE_FLAG === 'true')
<div>New feature enabled</div>
@endifTypeScript path mappings are configured in tsconfig.json:
"paths": {
"stx": ["./packages/stx/src/index.ts"],
"@stacksjs/stx": ["./packages/stx/src/index.ts"],
"bun-plugin-stx": ["./packages/bun-plugin/src/index.ts"],
// ... other packages
}Use these imports consistently across the codebase.
# Generate changelog
bun run changelog
# Bump version and release (prompts for version)
bun run releaseThe release script:
- Generates
CHANGELOG.mdusinglogsmith - Prompts for version bump using
bumpx - Updates versions recursively across workspace packages
- Compiles binaries for all platforms
- Creates zip archives of binaries
Icons use Iconify with 200K+ icons:
# List available icon collections
bun stx iconify list
# Generate icon package
bun stx iconify generate <collection-name>Icon components are generated in packages/collections/ and used as <IconName size="24" /> in templates.
The @stacksjs/desktop package provides native desktop application support:
@stacksjs/desktop (TypeScript API)
↓
Craft (~/Code/craft - Zig webview implementation)
↓
Native APIs (WebKit/GTK/WebView2)
Note: The desktop package uses Craft for native webview rendering. Craft source lives at ~/Code/craft.
# Open native window with dev server
stx dev examples/homepage.stx --nativeThis internally calls openDevWindow() from the desktop package, which uses Craft to create a lightweight native window.
- Window Management: Create and control native windows
- System Tray: Build menubar applications
- Modals & Alerts: Native dialogs and notifications
- 35 UI Components: Documented component library
- Hot Reload: Development mode support
- 100% Test Coverage: 132 tests, 96.77% line coverage
packages/desktop/src/window.ts- Window management (fully implemented)packages/desktop/src/system-tray.ts- System tray with Craft bridge + web simulation (fully implemented)packages/desktop/src/modals.ts- Modal dialogs with native + web fallback (fully implemented)packages/desktop/src/alerts.ts- Toast notifications with native + web fallback (fully implemented)packages/desktop/src/components.ts- 35+ UI components with HTML rendering (fully implemented)packages/desktop/src/types.ts- Complete type definitionspackages/desktop/test/- Comprehensive test suitepackages/desktop/examples/- Working examples
The --native flag in stx dev is implemented via the dev-server module. dev-server.ts is now a 7-line re-export hub delegating to:
dev-server/serve-markdown.ts— markdown file servingdev-server/serve-file.ts— single.stxfile servingdev-server/serve-multi.ts— multi-file routingdev-server/serve-app.ts— full app serving with file-based routing
Native window integration example:
import { openDevWindow } from '@stacksjs/desktop'
async function openNativeWindow(port: number) {
return await openDevWindow(port, {
title: 'stx Development',
width: 1400,
height: 900,
darkMode: true,
hotReload: true,
})
}Run desktop package tests:
cd packages/desktop
bun test # Run all tests
bun test --coverage # With coverage reportAll desktop functionality is fully tested. The package uses Craft (~/Code/craft) for native rendering.
The framework has robust error handling (packages/stx/src/error-handling.ts):
StxRuntimeError- Enhanced errors with file path, line/column infoerrorLogger- Structured error loggingerrorRecovery- Fallback content generation in productiondevHelpers- Development-friendly error messages
When debugging, enable debug: true in config for detailed stack traces.
Performance tracking is available via packages/stx/src/performance-utils.ts:
performanceMonitor.timeAsync()- Measure async operations- Metrics tracked: template processing, directive execution, file I/O
- Enable with performance config options
The stx CLI (packages/stx/bin/cli.ts) provides:
# Initialize new project
stx init
# Generate documentation
stx docs [--format html|markdown|json] [--output dir]
# Icon management
stx iconify list
stx iconify generate <collection>
# Serve templates
stx serve <directory> [--port 3000]
# Diagnose framework-runtime resolution + staleness (pantry dist freshness,
# node_modules symlink, runtime length). Exits 1 if a layer is stale.
stx doctor [--json]-
Directive Processing Order Matters: Directives are processed sequentially. The order in
process.tsensures that includes/layouts are resolved before conditionals, which are resolved before expressions. -
Context Isolation: Each template execution gets an isolated VM context to prevent variable leakage and security issues. See
packages/stx/src/safe-evaluator.ts. -
Dependency Tracking: The build plugin tracks all template dependencies (includes, components, layouts) for proper cache invalidation.
-
Async Processing: Most directive handlers support async operations, allowing for file I/O, API calls, etc.
-
Middleware Timing: Middleware can run
beforeorafterdirective processing. Set thetimingfield appropriately. -
Component Resolution: Components are resolved via
ComponentRegistry— builtins first, thencomponentsDir, then current directory. Paths without extensions automatically append.stx. -
Bun-First APIs: The codebase uses Bun-native APIs where possible.
Bun.file().text()replacesfs.readFileSync,Bun.file().exists()replacesfs.existsSync, andBun.write()replacesfs.writeFileSyncin async contexts.node:pathis retained (no Bun alternative) andnode:fsis kept for directory operations.import process from 'node:process'has been removed across the codebase (global in Bun). -
Production Build: The placeholder system is wired into the expression processor for compile mode. The production server returns a handle with
stop()and proper 404 handling. Tested with real projects: bun-queue (12 pages, 169ms), 11ly (17 pages, 510ms). -
State Management (defineStore/useStore): Signals-based store system in the runtime.
defineStore('id', () => setup)(setup style, primary) anddefineStore('id', { state, getters, actions })(options style, backward compat).useStore('id')retrieves a store. Supports persistence via{ persist: true }or{ persist: { pick, storage, key } }. SSR hydration viawindow.__STX_STORE_STATE__. Stores survive SPA navigation (not cleaned up bycleanupContainer). -
Client Script Bundler:
client-script-bundler.tsprovideshasUserImports()andbundleClientScript(). UsesBun.buildto bundle client scripts that import from@/functions/...,./relativepaths, or npm packages. Features tree-shaking and content-hash caching. stx/stores/composables are marked as external. Opt-in: only triggers when user imports are detected. -
Route Manifest Generation:
.stx/routes.tsis generated on startup (like Nuxt's.nuxt/routes.mjs)..stx/route-types.d.tsprovides a TypeScript route map. Auto-filters components/layouts/partials. Generated by theRouterconstructor, so it works for all serve paths. -
Script Execution Rule: Only
<script server>runs on the server. Bare<script>and<script client>are both client-side. This is enforced in ALL code paths:process.ts,includes.ts,render.ts,serve.ts,plugin.ts,streaming.ts, andbuild-views.ts. Never use heuristics to guess — check for theserverattribute only. -
Error Boundaries:
@errorBoundary/@fallback/@enderrorBoundarydirectives provide template-level error catching.onErrorCaptured()composition API hook available for programmatic error handling. Client-side error catching supports retry. -
v-memo / @memo:
@memo="[dep1, dep2]"memoizes template subtrees. Runtime skips re-processing if dependency values are unchanged. Vue compatibility:v-memoalso works. -
Lazy Routes: Pages are server-rendered by architecture, with scripts loading per-page. SPA fragments only include the target page's scripts. Router prefetches on hover. Production build generates separate fragments per route.
-
Bun-style Dev Server Output: Dev server displays a pretty startup banner matching Bun's HTML dev server style. Interactive shortcuts:
o= open browser,q= quit. Route count and timing displayed on startup. -
Runtime Pre-Initialization Shim: Before the signals IIFE, a shim captures
onMount/onDestroycalls into temporary arrays (window.__stx_early_mounts,window.__stx_early_destroys). Once the real runtime initializes, it drains these queues. This prevents errors when partial scripts execute before the full runtime is ready. -
HTML Entity Decoding in Expressions:
evalAttrExprin the signals runtime decodes common HTML entities (&,<,>,",',') before evaluating expressions. This handles cases where browsers encode attribute values like:text="a > b"asa > b. -
Runtime Cache Behavior: The signals runtime is cached in memory via
getCachedSignalsRuntime()incaching.ts. In debug mode, the runtime is always regenerated (no caching) to prevent stale output during development. In production mode, it's cached for the lifetime of the process. -
Active Class Handling: Both
updateNav()andupdateActiveLinks()in the router handle space-separated class strings.activeClass="bg-indigo-500/10 text-indigo-400"is split and each class added/removed individually viaclassList. -
Alpine-style x-data Reactive Bridge:
reactive.tsprovides a bridge between Alpine-stylex-datasyntax and the signals runtime. The bridge parsesx-data, wraps state properties in signals viastx.state(), handlesinit()(including async), and registers scope intowindow.stx._scopes. The signals runtime then processes ALL directives (x-for,x-text,x-show,:bind,@click, etc.) within those scopes — the reactive bridge does NOT evaluate bindings itself.x-datatriggers signals runtime injection viahasSignalsSyntax. -
x- Directive Support in Signals Runtime*: The signals runtime handles Alpine-style directives alongside the native
@/:syntax:x-for,x-if,x-show,x-text,x-html,x-model,x-bind:attr,x-ref. Thex-forregex supports bothitem in listand Alpine's parenthesized(item, index) in listsyntax. All bind functions (bindShow,bindFor,bindIf,bindClass,bindStyle) usecreateAutoUnwrapProxyto auto-unwrap signals during evaluation. -
Signal Auto-Unwrap in All Evaluation Contexts: Every expression evaluation function uses
createAutoUnwrapProxyso that signal objects from x-data scopes are automatically unwrapped to their values. This ensuresx-show="mobileOpen"evaluates tofalse(the signal value) not truthy (the signal function). Event handlers (@click="mobileOpen = !mobileOpen") detect signals in scope and write back throughsignal.set(). -
CRITICAL: Never use
.replace('</body>', ...)for injection: Always uselastIndexOf('</body>')+ slice. The first</body>in the document may be inside a<script>tag's string content (e.g. the x-element or router runtime). Using.replace()matches the first occurrence and injects code into the middle of a script, breaking the page. This applies to ALL modules that inject before</body>: reactive bridge, x-element, events, hot-reload, animation, production-build, css-scoping, PWA, analytics, heatmap. Also never include</body>in comments inside generated<script>blocks. -
Runtime Minification ASI Fix: After
Bun.Transpilerminifies the signals runtime withminifyWhitespace: true, a post-processing step inserts semicolons at}var,}let,}const,}functionboundaries (→};var,};function). Browsers in strict mode reject these without semicolons when newlines are stripped, even though Bun's parser accepts them. -
SPA Fragment Script Extraction: When serving page fragments for SPA navigation,
serve.tsextracts ALLdata-stx-scopedscripts from the full page response (partial scope IIFEs, setup functions, reactive bridge initScope calls) and appends them to the fragment. The router re-executes these scripts after swapping content. A_latestSetup=nullclear script is prepended to prevent stale scope from the previous page. Previously, fragments only included__stx_setup_scripts, leaving partial scopes uninitialized on SPA navigation. -
Partial Signal Scripts Use Real APIs:
transformSignalScriptinincludes.tsdestructures directly fromwindow.stxinstead of using polyfill fallbacks. The old polyfills created signals without._isSignal, which broke auto-unwrap and effect tracking. The signals runtime is always available (injected in<head>before anydata-stx-scopedscript). -
Global mountCallbacks Flush After Scope Processing: The DOMContentLoaded handler flushes the global
mountCallbacksarray after processing all[data-stx-scope]elements. Previously,mountCallbackswas only flushed inside the[data-stx]loop (for setup function pages). Partial<script client>blocks that callonMount()push to the global array, so it must be flushed regardless of which code path registered the callbacks. -
Document Shell Comment Stripping:
hasDocumentShellstrips HTML comments (like<!-- stx-layout: ... -->) before checking if the output starts with<!DOCTYPE>or<html>. Without this, layout comments caused double<body>wrapping — the document shell wrapped the already-complete layout output because it didn't detect the existing document structure. -
Known Limitation: Server-Side Component Props in Loops: Component props (
:prop="expr") inside@foreachloops do not receive loop variables in their evaluation context. The component renderer creates an isolated context where loop variables likefeatureare not available. Workaround: inline the HTML directly in the@foreachloop instead of using a component. This affects server-side rendering only — client-side:forwith components works correctly. -
<template>Tag Stripping:bun-plugin/src/serve.tsstrips<template>wrapper tags from output (browsers don't render template content). Tags with reactive directives (x-for,x-if,@for,@if,:for,:if) are preserved for the client-side runtime. Prefer puttingx-for/x-ifdirectly on elements (<div x-for="item in items">) instead of using<template>wrappers to avoid stripping issues. -
Stacks App Conventions: Stacks apps use
resources/views/for stx pages (Mode 1: server-side). Config:root: 'resources',pagesDir: 'views',componentsDir: 'views/components',layoutsDir: 'views/layouts',partialsDir: 'views/partials'. Standalone SPA apps (training, bench-review, 11ly) usepages/at root (Mode 2: client-side with API). Both modes use the same stx engine — the mode is implicit from which directives and script types are used, not a config flag. -
CRITICAL: @click Signal Writeback Only for Direct Assignments: The
@clickhandler has a signal writeback mechanism for inline assignment expressions like@click="count = count + 1"or@click="open = !open". This MUST NOT run for function call expressions like@click="openModal()"— the function internally callssignal.set(), and the writeback would reset the signal to its pre-handler value (undoing the function's changes). The writeback is guarded byisDirectAssignmentcheck: only fires when the expression matches/^[a-zA-Z_$]\w*\s*=/(direct variable assignment). -
Directive Double-Bind Guards: All directive binding functions (
bindIf,bindShow,bindFor,bindModel, event handlers) have guards (el.__stx_if_bound,el.__stx_show_bound,el.__stx_for_bound,el.__stx_model_bound,el.__stx_evt_*) to prevent duplicate binding whenprocessElementis called multiple times on the same element (e.g., from:ifsubtree re-processing). -
bindIf Subtree Processing Deferred: When
:ifinserts an element and needs to process its children (bind:text,@click,:show, etc.), the processing is deferred viasetTimeout(0). This prevents child effects from accidentally subscribing to the parentbindIfeffect's tracked signals (which would cause cascading re-runs). ThechildrenProcessedflag ensures processing happens only once per element. -
Client-side useHead / useSeoMeta:
useHead({ title, meta, link, script, bodyAttrs, htmlAttrs })anduseSeoMeta({ title, description, ogTitle, ogImage, ... })are available in<script client>blocks. They updatedocument.title,<meta>tags, and<link>tags at runtime — works on both full page load and SPA navigation (scripts re-execute after fragment swap). -
Dev Server No-Cache: The dev server (
bun-plugin/src/serve.ts) does NOT cache processed templates or partials. Every request re-reads files from disk and re-processes. This ensures file changes are reflected immediately on browser refresh without restarting the server. Production caching is handled separately. -
Lazy Hydration (
stx-hydrate): DeferprocessElementfor a subtree until a trigger fires. Supported triggers:visible(IntersectionObserver, 50px rootMargin),idle(requestIdleCallback, 2000ms timeout),interaction(mouseenter/click/focusin/touchstart, once),media:<query>(matchMedia). Firesstx:hydratedCustomEvent onwindowwhen the subtree activates. Implementation insignals.tsdeferHydration()— runs before the mainprocessElementbody, short-circuits processing until the trigger. Elements withstx-hydratestill ship their HTML immediately (no fetch, unlike@async) — only the wire-up is deferred. Seedocs/features/lazy-hydration.md. -
Route Guard Middleware (SSG): SSG builds run route middleware before rendering each page. Auto-loads from
middleware/directory. Pages declare middleware viadefinePageMeta({ middleware: ['auth'] })in<script server>. Redirect → generates a static redirect page (<meta http-equiv="refresh">). Abort → skips the page. Global middleware viastx.config.tsrouteMiddleware.global. Seepackages/stx/src/ssg.tsmiddleware integration. -
Dual reactive implementations: stx ships TWO independent reactive systems —
packages/stx/src/signals-api.ts(module-import path; used by composables, stores at module level, SSR helpers, tests run viabun testin Node) and the runtime template literal generated insidepackages/stx/src/signals.ts(string-generated, injected into client pages, ownswindow.stx.{state, derived, …}). Both expose the same public surface (state,derived,effect,batch,untrack,peek,isSignal,isDerived,onMount,onDestroy) and must behave equivalently. The stx bundler rewrites<script client>imports so they destructure fromwindow.stx; unbundled code lands at signals-api.ts. Signals created in one impl are invisible to the other — astate()returned by signals-api.ts cannot be.set()-ed from inside a client page's<script>(and vice versa). Parity is pinned bytest/reactivity/dual-impl-parity.test.ts, which runs the same suite against both impls. When parity drifts, fix the lagging side — don't change only one. Seestacksjs/stx#1712for the architectural rationale; one drift example already caught + fixed:_isDerivedbrand on derived signals (the stx.d.ts type had wrongly declared_isSignal: truefor derived; corrected to match both impls). The same pattern extends to higher-level primitives that exist in both impls — currentlyuseCookieis covered bytest/signals/use-cookie-parity.test.ts(seestacksjs/stx#1710, which converted the composable from a Vue-styleCookieRefto a Signal so both paths return the same shape). If you add a new primitive that lives in bothsignals-api.ts/composables/AND inside the runtime template literal, add a parity test next to it.
- Lazy Hydration —
stx-hydrateattribute documentation with trigger types and usage examples - Deployment — Deployment guide for static sites and SSR apps
- Use pickier for linting — never use eslint directly
- Run
bunx --bun pickier .to lint,bunx --bun pickier . --fixto auto-fix - When fixing unused variable warnings, prefer
// eslint-disable-next-linecomments over prefixing with_
- Use stx for templating — use signals/composables in
<script>or<script client>tags - Use
<script server>for server-side data fetching — this is the ONLY script type that runs on the server - Bare
<script>and<script client>blocks run client-side. Browser APIs (localStorage,document,window) work here — but reach for an stx primitive first; see "Reach for stx primitives before vanilla JS" below for the cheat sheet - Use crosswind as the default CSS framework which enables standard Tailwind-like utility classes
- If you see an abundance of custom styling or utility classes in
<style>blocks, that's wrong — use Crosswind utility classes in the HTML instead. Custom CSS should be rare (only for things Tailwind can't express). :html=is the framework'sv-html/dangerouslySetInnerHTMLequivalent — opt-in raw HTML. It writes the bound value directly toinnerHTMLwith no escaping. Only use it for content you control (server-rendered markdown, sanitizer output). If the value could ever come from user input or an untrusted API, sanitize upstream (@stacksjs/sanitizer) or use:text=(auto-escapes). When in doubt,:text=.
Agents (this one included) repeatedly write code that the framework already gives them — reinventing a Toast component, hand-rolling addEventListener plumbing, hard-coding localStorage reads, plain <a href> for SPA-eligible links, plain <img> for local assets. Before adding any of those, check the four sources below.
Shipped with every stx app via node_modules/@stacksjs/components. Each .stx file's basename is the tag name (PascalCase) and tags are globally available in .stx files — no import needed. Run ls node_modules/@stacksjs/components/src/ui/ to refresh the inventory before you build anything UI-shaped. As of writing:
Accordion, Audio, Avatar, Badge, Breadcrumb, Button, Calendar, Card, Checkbox, Combobox, CommandPalette (+ CommandPaletteItem), Dialog (+ DialogBackdrop / DialogPanel / DialogTitle / DialogDescription), Drawer, Dropdown, Form, Heatmap, Image, EmailInput / NumberInput / PasswordInput / SearchInput / TextInput, Listbox, Login / Signup / TwoFactorChallenge (under ui/auth/), Navigator, Notification, Pagination, Payment, Popover, Portal, Progress, Radio (+ RadioGroup), Select, Sidebar, Skeleton, Spinner, Stepper, Storage, Switch, Table (+ TableHead / TableBody / TableRow / TableCell / TableHeader), Tabs, Teleport, Textarea, Tooltip, Transition, Video, VirtualList, VirtualTable. Plus standalones CodeBlock, Hero, Footer, Installation.
Before writing a Toast/Modal/Dropdown/Tabs/Pagination/Skeleton/etc. from scratch, look here. If you need an option the bundled component doesn't expose, extend it (or open an issue against @stacksjs/components) rather than inlining a one-off.
<StxLink> instead of <a href> for every internal link. A plain <a href> triggers a full page reload — losing stores, scroll position, and any client-side state. The router only intercepts [data-stx-link], which <StxLink> emits.
to="/path"(static) or:to="someSignal()"/x-to="`/judges/${id}/profile`"(reactive)prefetch— hover-prefetches the route HTMLactiveClass="bg-red-500 text-white"/exactActiveClass="…"— applied on route match (space-separated classes are supported)- Any other static attributes (
class,target,rel,aria-*) pass through to the underlying<a>
External links (http://, https://, mailto:, tel:) stay as <a href> — the router doesn't handle those.
<StxImage> instead of <img> for every local image. It lazy-loads by default, emits a responsive srcset, adds aspect-ratio to prevent layout shift, and optionally swaps to AVIF/WebP via <picture>.
src="/images/foo.png"+alt="…"— minimumwidth="800" height="600"— strongly recommended (setsaspect-ratio, prevents CLS)sizes="sm:100vw md:50vw lg:33vw"— auto-generatessrcsetacross 320 / 640 / 768 / 1024 / 1280 / 1536 / 1920pxformat="auto"orpicture— emits a<picture>element with AVIF + WebP sourcesplaceholder="blur"(default) — inline SVG blur placeholder while loading;placeholder="color"+placeholderColor="#…"for a solid filllazy={false}— opt out of lazy loading for above-the-fold heroespreload— emits a<link rel="preload">hintprovider="cloudinary"/"imgix"/"bunny"— CDN URL rewriting;qualityanddensities="1x 2x 3x"are also accepted
For genuinely dynamic URLs with no transforms, x-src / x-alt on a plain <img> is acceptable. Otherwise prefer <StxImage>.
<SafeImage> instead of a plain <img> for any URL that can 404 — external-CDN avatars, user-uploaded content, seed data pointing at links that may disappear. It wires a race-free inline onerror= handler (parsed synchronously with the element, so it catches cached 404s and offline first paint that a delegated listener misses) that swaps to a fallback image, with an optional class swap.
src+alt— minimum (also accepts:src/:altserver-dynamic bindings)fallback="/images/avatars/default.svg"— per-instance fallback (defaults to/images/safe-image-default.svg)className="… grayscale filter"+fallbackClassName="…"— swap the class string on error (e.g. drop agrayscale/blur-upload class that shouldn't apply to the placeholder)- Extra attrs (
loading,width,height,id, …) pass through to the<img> - The window-level error handler is registered once per page (idempotent across SPA nav); the loop guard prevents an infinite swap if the fallback itself 404s
Use <StxImage> for local assets you control (it has the responsive/CLS machinery); use <SafeImage> when the URL is untrusted and the failure mode is "broken image in a styled frame."
Every reactive primitive, composable, and lifecycle hook is attached to window.stx and callable as a bare identifier inside any <script> / <script client> block. Full surface (see packages/stx/src/signals.ts:3286):
- Reactivity:
state,derived,effect,batch,peek,untrack,isSignal,computed,watch,watchEffect,ref,reactive,useRef - Lifecycle:
onMount,onDestroy - Stores:
defineStore,useStore(also exposed aswindow.defineStore/window.useStore) - Routing:
navigate,goBack,goForward,useRoute,setRouteParams,useSearchParams - Data fetching:
useFetch(urlOrFn, opts),useQuery,useMutation— all return signal-shaped{ data, loading, error, ... } - Storage:
useLocalStorage(key, defaultValue)— reactive read/write, syncs across browser tabs via thestorageevent;useSessionStorage(key, defaultValue)— same shape forsessionStorage(filtered to its own storageArea);useCookie(name, opts?)— reactive string-valued cookie binding (supportspath/domain/maxAge/expires/sameSite/secure/encode/decode;.set('')deletes) - Component props (reactive):
useReactiveProp(name, defaultValue, opts?)— returns a signal that tracks a parent-driven:prop="signal()"attribute on the component's root element. Bridges parent clientReactive bindings into the component's local state via a MutationObserver. Default parse handles booleans (""/"true"→ true,"false"→ false), numbers, and strings; pass{ parse: (v) => …, }for JSON or custom types. One-way (parent → child); components still emit@input/@change/@closefor the reverse direction. See<Dialog>,<Switch>,<Checkbox>,<TextInput>for the pattern - Browser composables:
useEventListener(event, handler, { target?, passive?, capture?, once? }),useWebSocket,useClickOutside,useFocus,useColorMode,useDark,useInterval,useTimeout,useDebounce,useDebouncedValue,useThrottle,useToggle,useCounter,useAsync - Meta:
useHead,useSeoMeta,definePageMeta(client-side no-op; the real one runs at SSR/SSG) - UI primitives:
toast,modal,drawer,alert,confirm(imperative calls); plus the<Notification>/<Dialog>/<Drawer>template components from@stacksjs/components - Component API:
defineProps,withDefaults,defineEmits,defineExpose
State scope rule: if two pages or two components need to read or write the same data, it's a store (defineStore), not a component-local state(). Stores survive SPA navigation (they live on window.stx._stores — not cleaned up by cleanupContainer). Persist to localStorage with { persist: true } or { persist: { pick: ['items'], storage: 'localStorage', key: 'cart' } }. See "App Architecture" below for the broader logic / UI split.
| Don't write | Use instead | Why |
|---|---|---|
document.querySelector('#el') / getElementById |
const myRef = ref() + x-ref="myRef" on the element |
ref() is reactive and scope-aware; manual queries break on nested scopes, re-renders, and SPA nav |
el.addEventListener('click', handler) |
@click="handler($event)" in the markup |
Auto-removed on scope teardown; tracked by signals; works under SPA nav |
window.addEventListener('scroll', handler, { passive: true }) |
useEventListener('scroll', handler, { passive: true }) (defaults to window; pass { target: el } to bind elsewhere) |
Auto-cleanup on unmount; HMR-safe |
localStorage.getItem('foo') / setItem |
useLocalStorage('foo', defaultValue) for one signal, or defineStore(..., { persist: true }) for a whole store |
Reactive — writes propagate to every reader; syncs across tabs |
document.cookie.match(/(?:^|; )name=([^;]*)/) (read) / document.cookie = \name=value; path=/; ...`` (write) |
useCookie('name', { maxAge: …, sameSite: 'Lax' }) |
Reactive string signal; handles encode/decode, Max-Age=0 deletion on .set(''), and all standard cookie attributes |
window.location.href = '/x' / location.replace('/x') |
navigate('/x') (or navigate('/x', { replace: true })) |
SPA transition; preserves stores and scroll |
<a href="/internal"> |
<StxLink to="/internal"> |
SPA nav, hover-prefetch, active-class binding |
<img src="/local.png"> |
<StxImage src="/local.png" width=… height=…> |
Responsive srcset, lazy, blur placeholder, CLS-safe |
fetch('/api/x').then(r => r.json()) directly in a component |
A composable in functions/ that wraps useFetch('/api/x') and exposes { data, loading, error } |
Signal-shaped; loading / error already wired; composable is testable in isolation; reusable across pages |
| Toast / Modal / Tabs / Dropdown reinvented inline | <Notification>, <Dialog>, <Tabs>, <Dropdown> from @stacksjs/components (or imperative toast(...) / modal(...) on window.stx) |
Already styled, accessible, and battle-tested across other Stacks apps |
When vanilla IS the right answer: integrating third-party libraries that own the DOM (MediumEditor, Chart.js, Mapbox, Mermaid), one-shot reads from a non-reactive DOM when the library doesn't emit events (e.g., reading a contenteditable's innerHTML at submit because MediumEditor doesn't bubble input reliably — see ReviewForm.stx for the legitimate case), or feature detection ('IntersectionObserver' in window). The rule is: try the stx primitive first; drop to vanilla only with a reason you'd defend in code review. If you're reaching for querySelector because "it's just easier," that's the signal to use a signal.
When you hit a bug instead of a missing primitive: if the symptom looks like "the stx binding didn't fire" or "the directive doesn't propagate" — the bug is in stx, not your component. Don't paper over it with querySelector / addEventListener. File it (or fix it — the framework lives at ~/Documents/Projects/stx).
stx flips reactivity syntax between scripts and templates, and the failure mode is silent. This single rule is the most common stx bug source — read it once and the rest of the framework's reactivity follows.
| Where | Read a signal | Why |
|---|---|---|
Inside <script client> / <script setup> |
flag() — call the getter |
Signals are functions. flag alone is the function reference, not its value |
| Inside template attributes / interpolation | flag — bare name |
The template engine wraps the scope in an auto-unwrap proxy, so reading the bare name returns the current value |
<script client>
const flag = state(false)
// ✅ Script: call the getter to read the value
if (flag()) doStuff()
// ❌ Script: this is the getter function, always truthy
if (flag) doStuff()
</script>
<!-- ✅ Template: bare name; the proxy unwraps it -->
<div :if="flag">Visible when flag is true</div>
<!-- ❌ Template: this tries to CALL the unwrapped value (false), which throws
TypeError. Several directives swallow the error and just hide the element. -->
<div :if="flag()">…</div>The silent-failure mode: if you accidentally write :if="flag()" (or :show="!flag()"), the auto-unwrap proxy returns false (the unwrapped value) and you call false() → TypeError: flag is not a function. Most directive binders catch and suppress these errors during async init (a signal may not be ready on the first effect pass). The element silently stays hidden and you have no warning.
Exception — function references: when the template attribute is naming a function in scope (not a signal), call it the same way you would in script:
<script client>
function submit() { … }
</script>
<button @click="submit()">Save</button> <!-- ✅ call -->
<button @click="submit">Save</button> <!-- ✅ also works since #1695 — runtime invokes bare fn refs in event handlers -->If you're unsure whether a name is a signal or a function: check the <script client> block. state(...) / derived(...) produce signals (call to read in script, bare in template). function foo() / const foo = () => … produces functions (call in both places).
stx apps separate logic from UI. Components are presentation; everything else lives in composables or stores. This is the default — only deviate when a piece of code is genuinely single-use and trivial.
When writing or editing components, follow this layout:
| Concern | Lives in | Imported by |
|---|---|---|
| Network calls (fetch, GraphQL, websocket) | functions/ (composables) or stores/ |
Components, other composables |
| Persistent client state (auth, cart, theme, current filter) | stores/ (defineStore) |
Components |
| Domain logic (validation, formatting, transforms, business rules) | functions/ (composables) |
Components, stores |
| Browser API access (localStorage, IntersectionObserver, geolocation) | functions/ (composables) |
Components |
| Local ephemeral UI state (a single dropdown's open flag, hover state) | inline in the component's <script client> |
n/a |
The mapping is mechanical: if it has any of {persistence, reuse potential, side effects that aren't pure DOM, more than ~15 lines of logic}, factor it out.
Components import composables/stores. Composables/stores never import components. Anything in the table's right column flows to the left, not the other way.
<!-- pages/products/[id].stx -->
<script client>
// Compose: store for shared state, composable for the fetch.
const cart = useStore('cart')
const { product, loading, error } = useProduct(route.params.id)
function addToCart() {
cart.add(product())
}
</script>
<div :if="loading()">Loading…</div>
<div :if="error()" x-text="error().message"></div>
<article :if="product()">
<h1 x-text="product().name"></h1>
<button @click="addToCart()">Add to cart</button>
</article>// functions/useProduct.ts — composable owns the fetch + retry + caching
import { state, derived } from 'stx'
export function useProduct(id: string) {
const product = state(null)
const loading = state(true)
const error = state(null)
;(async () => {
try {
const res = await fetch(`/api/products/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
product.set(await res.json())
}
catch (e) {
error.set(e)
}
finally {
loading.set(false)
}
})()
return { product, loading, error }
}// stores/cart.ts — store owns the shared cross-page state
import { defineStore, state, derived } from 'stx'
export const useCart = defineStore('cart', () => {
const items = state([])
const total = derived(() => items().reduce((sum, x) => sum + x.price, 0))
function add(product) { items.set([...items(), product]) }
function remove(id) { items.set(items().filter(x => x.id !== id)) }
return { items, total, add, remove }
}, { persist: true })<!-- ❌ Component crammed with fetch + retry + cart state + business rules -->
<script client>
const product = state(null)
const loading = state(true)
const cart = state(JSON.parse(localStorage.getItem('cart') || '[]'))
;(async () => {
let attempt = 0
while (attempt < 3) {
try {
const res = await fetch(`/api/products/${id}`)
product.set(await res.json())
loading.set(false)
return
}
catch (e) {
attempt++
await new Promise(r => setTimeout(r, 1000 * attempt))
}
}
})()
function addToCart() {
const next = [...cart(), { ...product(), addedAt: Date.now() }]
cart.set(next)
localStorage.setItem('cart', JSON.stringify(next))
fetch('/api/cart/sync', { method: 'POST', body: JSON.stringify(next) })
}
</script>Three composables/stores got fused into one component. Now nothing's testable in isolation, the cart isn't shared across pages, the retry logic can't be reused for other endpoints, and the next person to touch this file has to read the whole thing to find the actual UI.
- Testability — composables are plain functions, trivially unit-testable. Components are templates, not.
- Reuse —
useProduct(id)works on every page that shows a product. Inline fetch logic doesn't. - State sharing — stores survive SPA navigation (see "Important Implementation Notes" item 9). Component-local state doesn't.
- Discoverability —
grep -r 'fetch(' functions/finds every API call.grep -r 'fetch(' .is noise. - Agent output quality — without this rule, agents (this one included) tend to cram fetch + state + handlers + UI into a single file because it's the shortest path to "something on the screen". The result compiles but is unmaintainable. Forcing the separation up front produces code that survives review.
- Single-purpose UI state with no domain coupling: a tooltip's hover flag, a modal's open boolean, a form input's focused state. Keep those in the component's
<script client>. - Trivial event handlers that just call a composable/store method:
@click="cart.add(product())"is fine, no need to wrap. - One-off layout-level calculations (e.g., computing CSS classes from props).
If you're unsure: factor it out. It's much cheaper to inline a small composable later than to disentangle a 200-line component.
- buddy-bot handles dependency updates — not renovatebot
- better-dx provides shared dev tooling as peer dependencies — do not install its peers (e.g.,
typescript,pickier,bun-plugin-dtsx) separately ifbetter-dxis already inpackage.json - If
better-dxis inpackage.json, ensurebunfig.tomlincludeslinker = "hoisted"