ps-spa
v0.8.1
Published
PureScript SPA library with file-based routing and a bundled CLI
Maintainers
Readme

ps-spa is a PureScript library for file-based SPAs, with a bundled CLI that recreates the elm-spa workflow:
- file-based routing from
src/Pages - a TEA-style page model with
static,sandbox,element, andadvancedpage kinds - route guards via
protect - generated route and page registries
- shared state handled by the runtime/app config
- zero npm dependencies for the CLI and code generator
- example apps that default to a Bun + Vite workflow (
bun install,bun run dev)
The most useful supporting docs are:
This repository currently contains:
- the PureScript library in
src/PsSpa - a zero-dependency Node CLI in
scripts/ps-spa.mjs - route/codegen tests in
tests-js - an Elm SPA feature analysis in
docs/elm-spa-analysis.md - generated example apps in
examples/basic,examples/tailwind, andexamples/excel
Repo Layout
There are two different layers in this repo:
src/PsSpais the framework coreexamples/basic/src/Main.purs,examples/basic/src/Pages, andexamples/basic/src/Generatedare generated app-level code
Pages and Generated are app-level folders, not framework-level folders. In consumer apps they should exist. In this repo they live only under examples/, so the framework source stays clean.
Framework Commands
node scripts/ps-spa.mjs new examples/my-app
node scripts/ps-spa.mjs --root examples/basic add /marketing/hero tailwind
node scripts/ps-spa.mjs --root examples/basic add /users/:name/posts/:id advanced
node scripts/ps-spa.mjs --root examples/basic gen
node scripts/ps-spa.mjs --root examples/basic verify
node scripts/ps-spa.mjs --root examples/basic doctor
npm run test:generated
npm run test:ps
npm run bench
npm run bench:verify
npm run bench:browser
npm run bench:browser:verify
node --test tests-js/**/*.test.mjs
spago buildRunning Examples
Each app in examples is its own project.
Run each example from its own directory, not from the repo root.
The default workflow is Bun-first.
Basic example:
cd examples/basic
bun install
bun run devTailwind example:
cd examples/tailwind
bun install
bun run devExcel grid example:
cd examples/excel
bun install
bun run devThe Excel example includes /spreadsheet, an interactive virtual spreadsheet demo with sticky row/column headers, editable cells that stay in page state after scrolling, and resizable visible columns.
If you prefer npm, npm install and npm run dev still work, but Bun is the default path the scaffold targets.
For framework development in this repo, install the local PureScript toolchain in the repo root instead of relying on globals:
bun install
bun run test:psWhat matches elm-spa today
examples/basic/src/Pages/Index.pursmaps to/examples/basic/src/Pages/NotFound.pursis reserved as the 404 page- nested folders produce nested routes
NameParam.pursstyle files produce dynamic path segments like:name- route constructors and
parsePath/toPathare generated intoexamples/basic/src/Generated/Route.purs parseRequestbuilds a request record with route, path, query, fragment, and rawhref- request helpers now include
queryParam,queryParams,queryInt,queryBoolean, andfragmentValue - page metadata and route-to-module wiring are generated into
examples/basic/src/Generated/Pages.purs examples/basic/src/Generated/App.pursis the default app entrypoint, so userland does not need to wireRuntime.startby handexamples/basic/src/Generated/Link.pursis the default typed navigation surface, so userland does not need raw route strings by default- the core page API models the four page kinds from Elm SPA
elementandadvancedtemplates exposesubscriptionsstubs out of the box- page modules receive a
Requestand can defineprotectguards genandaddmaintain generated smoke tests inexamples/basic/tests-generated/genandaddmaintain generated benchmark scenarios inexamples/basic/benchmarks-generated/genandaddmaintain browser benchmark assets inexamples/basic/public/benchnewscaffolds a full app root withspago.yaml,Main,App.Runtime,index.html, and browser benchmark pagesverifychecks for drift in generated PureScript, generated smoke tests, generated benchmarks, and Tailwind scaffold filesdoctorreports route counts plus missing/drifting generated artifacts
Because PureScript does not allow Elm-style trailing underscore module names, the generator uses a PureScript-native naming convention that preserves a 1:1 mapping between file path and module name:
src/Pages/Index.purs-> modulePages.Indexand route constructorIndexsrc/Pages/People/NameParam.purs-> modulePages.People.NameParamand route constructorPeopleNameParam
What is still missing
- richer PureScript-side request/form decoders for larger apps
- server-side rendering (SSR) to a string
Tailwind
Running node scripts/ps-spa.mjs --root examples/basic add /route tailwind does two things:
- generates a Tailwind-styled page module (using the HTML DSL below)
- scaffolds
styles/tailwind.css(with the@import "tailwindcss"directive), patchesvite.config.mjsto register the@tailwindcss/viteplugin, and addstailwindcss+@tailwindcss/viteto devDependencies
This is Tailwind v4 — there is no tailwind.config.cjs / postcss.config.cjs; the Vite plugin handles everything.
Generated apps also get a local package.json, so the normal way to run them is from inside that app directory:
cd examples/basic
bun install
bun run devor:
cd examples/tailwind
bun install
bun run devRuntime
The generated app can be bundled into public/app.js and mounted into #app using the minimal runtime in src/PsSpa/Runtime.purs.
Internal <a href="/somewhere"> links are intercepted by the SPA runtime, so route changes no longer require a full page reload.
Shared State and Auth
ps-spa new scaffolds three app-level modules alongside your pages:
src/App/Runtime.pursdefines the app-ownedCommand,Subscription,onCommand, andonSubscriptionhooks. Leave it empty for simple apps; extend it when a page needs custom effects or subscriptions.src/Shared.pursdefines theSharedrecord handed to every page andprotectguard. The default ships a singlecurrentUser :: Maybe Userfield; extend it with theme / feature flags / session token / etc.src/Auth.pursdefines theUsertype plusAuth.requireUser(a reusable protect guard) andAuth.optionalUser(a getter for views). Both helpers are row-polymorphic inshared, so adding fields toShareddoesn't break them.
Main.purs wires Shared.init through App.startWithShared, so shared is live from the first render. App.Runtime is the app-owned extension point for custom commands and subscriptions. Gate a page behind auth by replacing its default protect:
import Auth as Auth
import Generated.Route (Route(..))
protect = Auth.requireUser LoginTo flip shared.currentUser on login, an advanced page emits a fresh value via Effect.fromShared; the runtime swaps shared, re-renders, and the next protect call sees the new state. See Getting Started → Shared State and Auth for the full walkthrough.
These two modules are app-owned: gen and verify deliberately leave them alone, matching how Shared.elm and Auth.elm work in elm-spa.
Reliability
This is still not literally fail-proof. What it has now is a much stronger safety net:
- PureScript framework tests for the core request/effect/html/page APIs in
test/ - handwritten framework tests in tests-js
- generated per-page smoke tests in
examples/basic/tests-generated - real-world codegen and routing benchmarks in benchmarks
- generated per-page benchmark scenarios in
examples/basic/benchmarks-generated
npm run bench measures the runtime path in Node with a fake DOM harness.
npm run bench:browser starts a tiny local server, opens /bench/, and waits for the benchmark page to post back real browser results. The browser suite measures actual DOM render cost, rerender cost, and SPA navigation interception. npm run bench:browser:verify enforces thresholds from benchmarks/browser-thresholds.json.
Each benchmark run now also writes JSON history to benchmarks/history, and npm run bench:verify enforces the thresholds from benchmarks/thresholds.json.
Library Status
ps-spa is one repo with two publish surfaces:
- the PureScript library package defined by
spago.yaml - the npm package that also exposes the
ps-spaCLI
The CLI exists to scaffold and maintain apps around the library. The repo still ships the PureScript sources through the npm package today, but the package metadata is now aligned for a real library release as well. See docs/publishing.md.
HTML DSL
ps-spa ships three compatible layers for building views. New app pages should usually reach for the record-based DSL in src/PsSpa/Html/DSL.purs, which is the style every scaffold template now emits. Use the generated strict DSL in src/PsSpa/Html/DSL/Strict.purs when you want element-specific attribute checks across the full HTML element surface.
import PsSpa.Html.DSL (button, h1, main, p, text)
view =
main { className: "mx-auto max-w-3xl px-6 py-16" }
[ h1 { className: "text-4xl font-bold" }
[ text "Hello" ]
, p { className: "text-lg text-slate-600" }
[ text "Record attrs, type-safe, plays nice with the rest of PureScript." ]
, button
{ className: "rounded-full bg-slate-950 px-5 py-3 text-sm font-semibold text-white"
, onClick: Submit
, disabled: false
}
[ text "Click me" ]
]What's covered
Every standard HTML5 element — 90 container elements (
a,audio,blockquote,details,div,dialog,figure,form,header,iframe,main,math,nav,picture,progress,section,svg,table,template,video, ...) and 13 void elements (area,base,br,col,embed,hr,img,input,link,meta,source,track,wbr). The few PureScript keywords are escaped:data_,head_,map_.Most HTML attributes —
className,id,href,src,srcSet,sizes,alt,type_,value,placeholder,htmlFor,encType,httpEquiv,action,method,accept,acceptCharset,inputMode,pattern,formAction,formMethod,colSpan,rowSpan,tabIndex,width,height,min,max,step,maxLength,minLength, plus newer/resource/SVG attrs likefetchPriority,integrity,media,as_,viewBox,fill,stroke,d,cx,cy,r, … each backed by a typeclass instance that maps the record field to the canonical HTML attribute name.Boolean attributes —
disabled,checked,readOnly,required,autoFocus,hidden,open,controls,autoPlay,loop,muted,playsInline,async,defer,multiple,noValidate,formNoValidate,inert, … passtrueto emit,falseto omit.Full ARIA 1.2 —
role,ariaLabel,ariaLabelledBy,ariaDescribedBy,ariaControls,ariaCurrent,ariaLive,ariaHidden,ariaExpanded,ariaSelected,ariaPressed,ariaDisabled,ariaBusy,ariaModal,ariaInvalid,ariaHasPopup,ariaLevel,ariaSetSize,ariaPosInSet,ariaValueMin/Max/Now/Text,ariaRowCount/Index/Span,ariaColCount/Index/Span, … (string, boolean, and integer flavours).Microdata —
itemProp,itemId,itemRef,itemType,itemScope.Generic raw attrs,
data-*, andaria-*for the long tail:div { className: "panel" , attrs: [ kv "unknown-attr" "kept" ] , dataAttrs: [ kv "state" "open", kv "test-id" "main-panel" ] , ariaAttrs: [ kv "describedby" "panel-help" ] } [ ... ]Events —
onClick,onDoubleClick,onSubmit,onFocus,onBlur,onMouseEnter,onMouseLeave,onInput,onChange,onKeyDown,onKeyUp. Input-like handlers carry aString -> msg(the event target value or key name); others carry just the message:form { onSubmit: Submitted } [ input { type_: "text", onInput: \v -> Updated v, value: model.draft } , button { type_: "submit" } [ text "Save" ] ]For anything not in the list above, use record-style
events:div { className: "drop" , events: [ eventPreventDefault "dragover" \_ -> DragOver , event "drop" \event -> Dropped event ] } [ ... ]
Strict element DSL
The record DSL is intentionally ergonomic and permissive: any field with a ToAttribute instance can be used on any element. For places where the browser is pickier, import the strict sibling module:
import PsSpa.Html.DSL.Strict as S
view =
S.form [ S.action "/save", S.method "post", S.onSubmit Save ]
[ S.input
[ S.type_ "email"
, S.name "email"
, S.required true
, S.onInput EmailChanged
]
, S.button
[ S.type_ "submit"
, S.disabled false
]
[ S.text "Save" ]
]Strict attrs carry a phantom element type, so S.href works on S.a, S.area, S.base, and S.link; S.checked works on S.input; and S.action works on S.form. Invalid pairings fail during PureScript compilation. The strict module is generated from the HTML/SVG manifest and exposes every standard HTML helper plus common SVG helpers such as S.circle, S.rect, S.path, S.linearGradient, S.filter, S.feGaussianBlur, and S.svgText.
Value-level naming collisions are escaped in the strict module. The style, title, and pattern names remain attribute helpers, so the element helpers are S.style_, S.title_, S.svgStyle, S.svgTitle, and S.svgPattern. SVG elements that collide with HTML helpers are prefixed too: S.svgA, S.svgScript, S.svgText, S.svgUse, and S.svgView.
Use S.unsafeAttr "new-browser-attr" "value" for experimental attributes that are not modelled yet. It is intentionally named as an escape hatch.
Compatibility API
New pages should use the record DSL from PsSpa.Html.DSL:
import PsSpa.Html.DSL (div, kv, text)
div
{ attrs: [ kv "data-custom" "anything" ]
, onClick: Submit
}
[ text "Record DSL only." ]The original array-style API in src/PsSpa/Html.purs still exists for older pages, and Generated.Link.linkAttrs remains for compatibility. Prefer Link.link Index { className: "back" } [ text "Back" ] in new code.
Field → HTML attribute mapping
PureScript record fields can't be reserved keywords (class, type, for, data) and follow PS naming (camelCase), so some fields are renamed before reaching the DOM. Most renames just swap camelCase to kebab-case; a few are full keyword escapes:
| Record field | HTML attribute | Type | Notes |
| --------------------- | --------------------- | -------- | ---------------------------------- |
| className | class | String | class is reserved |
| htmlFor | for | String | for is reserved |
| type_ | type | String | type is reserved |
| data_ (element) | <data> | — | data is reserved |
| head_ (element) | <head> | — | shadows Prelude.head |
| map_ (element) | <map> | — | clarity |
| encType | enctype | String | |
| acceptCharset | accept-charset | String | |
| hrefLang | hreflang | String | |
| referrerPolicy | referrerpolicy | String | |
| srcSet | srcset | String | |
| srcLang | srclang | String | |
| crossOrigin | crossorigin | String | |
| httpEquiv | http-equiv | String | |
| tabIndex | tabindex | Int/Str | overloaded |
| colSpan / rowSpan | colspan / rowspan | Int | |
| spanCount | span | Int | <col span="">; avoids name clash |
| maxLength | maxlength | Int | |
| minLength | minlength | Int | |
| readOnly | readonly | Boolean | omitted when false |
| autoFocus | autofocus | Boolean | omitted when false |
| autoComplete | autocomplete | String | |
| autoCapitalize | autocapitalize | String | |
| noValidate | novalidate | Boolean | omitted when false |
| formNoValidate | formnovalidate | Boolean | |
| formAction | formaction | String | |
| formMethod | formmethod | String | |
| formEncType | formenctype | String | |
| formTarget | formtarget | String | |
| inputMode | inputmode | String | |
| autoPlay | autoplay | Boolean | omitted when false |
| playsInline | playsinline | Boolean | omitted when false |
| isMap | ismap | Boolean | omitted when false |
| contentEditable | contenteditable | Boolean | always emits ("true" / "false") |
| spellCheck | spellcheck | Boolean | always emits ("true" / "false") |
| accessKey | accesskey | String | |
| enterKeyHint | enterkeyhint | String | |
| itemScope | itemscope | Boolean | omitted when false |
| itemProp / itemId / itemRef / itemType | itemprop / itemid / itemref / itemtype | String | microdata |
| viewBox, fill, stroke, d, cx, cy, r, x, y | same / SVG names | String/Int | common SVG attrs |
| attrs | literal attributes | Array KeyValue | raw escape hatch |
| ariaLabel, ariaLabelledBy, … | aria-label, aria-labelledby, … | String/Bool/Int | full ARIA 1.2 set (see PsSpa.Html.DSL) |
| dataAttrs | many data-* | Array KeyValue | expands; one entry per attribute |
| ariaAttrs | many aria-* | Array KeyValue | expands |
| onClick | (event listener) | msg | dispatches msg on click |
| onInput / onChange / onKeyDown / onKeyUp | (event listener) | String -> msg | carries target.value or key.name |
| onSubmit / onFocus / onBlur / onDoubleClick / onMouseEnter / onMouseLeave | (event listener) | msg | onSubmit prevents the native form submit |
| events | many event listeners | Array (EventHandler msg) | raw event escape hatch |
Two rules for boolean attributes:
- Most HTML booleans (
disabled,checked,required, …) omit whenfalse. Passtrueto emit<button disabled="disabled">; passfalseto render nothing. - ARIA booleans (
ariaHidden,ariaExpanded,ariaSelected,ariaPressed,ariaDisabled,ariaBusy,ariaModal,ariaMultiLine,ariaMultiSelectable,ariaReadOnly,ariaRequired,ariaAtomic) pluscontentEditable,spellCheck,draggablealways emit with literal"true"or"false"— that's the spec.
Custom elements (web components)
For tags the DSL doesn't ship — e.g. <my-counter> — use the escape hatches:
element "my-counter" { className: "live" } [ text "8" ]
voidElement "my-spinner" {}Custom events
Need an event the DSL doesn't expose (scroll, wheel, pointerdown, dragover, touchstart, copy, …)? Use the events field:
import PsSpa.Event (targetValue)
div
{ className: "drop"
, events:
[ eventPreventDefault "dragover" (\e -> DragOver e)
, event "drop" (\e -> Dropped (targetValue e))
]
}
[ ... ]Both styles produce the same Html ADT, so they can sit inside each other.
Cookbook
Conditional rendering — use a helper that returns Maybe (Html msg) and flatten with Data.Array.catMaybes, or just a plain if:
import Data.Array (catMaybes)
import Data.Maybe (Maybe(..))
view model =
div { className: "page" }
(catMaybes
[ Just (h1 {} [ text "Dashboard" ])
, if model.loggedIn
then Just (button { onClick: Logout } [ text "Sign out" ])
else Nothing
, Just (section {} [ text "Content" ])
])Lists of items — plain map:
ul { className: "stack" }
(map (\todo -> li { className: "row" } [ text todo.label ]) model.todos)Keyed lists for reorder-safe rendering — when rows can move (drag-and-drop sort, virtualisation, reverse-chronological feeds), reach for keyed. Children are paired with stable string keys; the renderer matches by key across rerenders, so reordering moves the existing DOM nodes instead of mutating their contents in place. That preserves focus, scroll position, and listener identity per row.
import Data.Tuple (Tuple(..))
import PsSpa.Html.DSL (keyed, li, text)
view model =
keyed "ul" { className: "todos" }
(map (\todo -> Tuple todo.id (li { className: "row" } [ text todo.label ])) model.todos)PsSpa.Html.keyed is the equivalent array-style helper. Keys must be unique within a single keyed container; duplicates collapse onto a single DOM node.
Conditional className with multiple flags — Data.String.Common.joinWith:
import Data.Array (catMaybes)
import Data.String.Common (joinWith)
import Data.Maybe (Maybe(..))
classes :: Array (Maybe String) -> String
classes parts = joinWith " " (catMaybes parts)
button
{ className:
classes
[ Just "btn"
, if model.primary then Just "btn-primary" else Nothing
, if model.disabled then Just "opacity-50" else Nothing
]
, disabled: model.disabled
}
[ text "Submit" ]Submit a form without page reload — form { onSubmit: SaveForm } prevents the native submit and dispatches your message:
form { onSubmit: SaveForm }
[ … ]Focus an input on mount — declare a Command that runs the actual focus call via FFI:
data Command = FocusInput String -- elementId
init = { model: { … }, effect: [ FocusInput "search" ] }
-- in Main.purs onCommand:
onCommand cmd = case cmd of
FocusInput id -> focusById id -- FFI to document.getElementById(id).focus()See examples/basic/src/Pages/EffectsAndSubscriptions.purs for a runnable end-to-end example with commands + subscriptions.
What the DSL is not
So you know what you're trading off:
- No virtual DOM diffing. Until v0.5.x the renderer rebuilt the DOM tree on every state change; v0.5.2+ does positional diffing in place (preserves focus, faster on equal-shape rerenders). For lists where rows move around (drag-and-drop sort, virtualisation, reverse-chronological feeds), reach for
keyed/Html.keyedso the renderer matches children by stable key instead of by index. - No SSR. The renderer targets the browser DOM (
createElement,createElementNS, text nodes, listeners). Server-side rendering to a string is not supported. - No ref callbacks. You cannot get a handle to the underlying DOM element from inside the DSL. Reach for
Commands and FFI for things like "focus this input after mount". - No
dangerouslySetInnerHTML. All text goes throughcreateTextNode, so it's always escaped. Rendering markdown output requires using FFI to setinnerHTMLon a wrapper element via a custom Command. - The record DSL is not element-specific.
div { onSubmit: Foo }still compiles by design. UsePsSpa.Html.DSL.Strictfor opt-in element-specific checks across the generated HTML/SVG surface. - No CSS-in-JS / style objects.
style :: Stringonly — you write the literal CSS string. - No typed API for every obscure browser attribute. The strict DSL covers the standard HTML elements, common SVG elements, and high-value typed attrs. Use
attrsorS.unsafeAttrfor experimental or long-tail attributes.
Deep coverage tests
The DSL is covered by deep PureScript tests in test/Test/Main.purs: every element function, every attribute name mapping (className → class, htmlFor → for, encType → enctype, httpEquiv → http-equiv, srcSet → srcset, ...), boolean true/false behaviour, ARIA-bool always-emit semantics, integer/string overloads, event handler routing/options, generic raw/data/aria expansion, common SVG/resource attrs, generated strict HTML/SVG helpers, and deep nesting with custom Msg types. Runtime behavior is covered by tests-js/diff.test.mjs, including namespace creation, form property syncing, listener replacement, and link interception. Manifest coverage in tests-js/html-coverage.test.mjs pins the record DSL element/attribute surface, every generated strict helper, and the strict capability classes.
For a fresh checkout:
bun install
bun run browsers:install
bun run test:js
bun run test:ps
bun run release:checktest:js includes a real Chromium smoke test via Playwright. In sandboxed macOS environments, run that command where Chromium is allowed to launch.
