npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ps-spa

v0.8.1

Published

PureScript SPA library with file-based routing and a bundled CLI

Readme

ps-spa logo

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, and advanced page 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:

Repo Layout

There are two different layers in this repo:

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 build

Running 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 dev

Tailwind example:

cd examples/tailwind
bun install
bun run dev

Excel grid example:

cd examples/excel
bun install
bun run dev

The 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:ps

What matches elm-spa today

  • examples/basic/src/Pages/Index.purs maps to /
  • examples/basic/src/Pages/NotFound.purs is reserved as the 404 page
  • nested folders produce nested routes
  • NameParam.purs style files produce dynamic path segments like :name
  • route constructors and parsePath / toPath are generated into examples/basic/src/Generated/Route.purs
  • parseRequest builds a request record with route, path, query, fragment, and raw href
  • request helpers now include queryParam, queryParams, queryInt, queryBoolean, and fragmentValue
  • page metadata and route-to-module wiring are generated into examples/basic/src/Generated/Pages.purs
  • examples/basic/src/Generated/App.purs is the default app entrypoint, so userland does not need to wire Runtime.start by hand
  • examples/basic/src/Generated/Link.purs is 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
  • element and advanced templates expose subscriptions stubs out of the box
  • page modules receive a Request and can define protect guards
  • gen and add maintain generated smoke tests in examples/basic/tests-generated/
  • gen and add maintain generated benchmark scenarios in examples/basic/benchmarks-generated/
  • gen and add maintain browser benchmark assets in examples/basic/public/bench
  • new scaffolds a full app root with spago.yaml, Main, App.Runtime, index.html, and browser benchmark pages
  • verify checks for drift in generated PureScript, generated smoke tests, generated benchmarks, and Tailwind scaffold files
  • doctor reports 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 -> module Pages.Index and route constructor Index
  • src/Pages/People/NameParam.purs -> module Pages.People.NameParam and route constructor PeopleNameParam

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), patches vite.config.mjs to register the @tailwindcss/vite plugin, and adds tailwindcss + @tailwindcss/vite to 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 dev

or:

cd examples/tailwind
bun install
bun run dev

Runtime

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.purs defines the app-owned Command, Subscription, onCommand, and onSubscription hooks. Leave it empty for simple apps; extend it when a page needs custom effects or subscriptions.
  • src/Shared.purs defines the Shared record handed to every page and protect guard. The default ships a single currentUser :: Maybe User field; extend it with theme / feature flags / session token / etc.
  • src/Auth.purs defines the User type plus Auth.requireUser (a reusable protect guard) and Auth.optionalUser (a getter for views). Both helpers are row-polymorphic in shared, so adding fields to Shared doesn'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 Login

To 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:

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-spa CLI

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 attributesclassName, 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 like fetchPriority, 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 attributesdisabled, checked, readOnly, required, autoFocus, hidden, open, controls, autoPlay, loop, muted, playsInline, async, defer, multiple, noValidate, formNoValidate, inert, … pass true to emit, false to omit.

  • Full ARIA 1.2role, 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).

  • MicrodataitemProp, itemId, itemRef, itemType, itemScope.

  • Generic raw attrs, data-*, and aria-* 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" ]
      }
      [ ... ]
  • EventsonClick, onDoubleClick, onSubmit, onFocus, onBlur, onMouseEnter, onMouseLeave, onInput, onChange, onKeyDown, onKeyUp. Input-like handlers carry a String -> 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 when false. Pass true to emit <button disabled="disabled">; pass false to render nothing.
  • ARIA booleans (ariaHidden, ariaExpanded, ariaSelected, ariaPressed, ariaDisabled, ariaBusy, ariaModal, ariaMultiLine, ariaMultiSelectable, ariaReadOnly, ariaRequired, ariaAtomic) plus contentEditable, spellCheck, draggable always 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 flagsData.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 reloadform { 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.keyed so 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 through createTextNode, so it's always escaped. Rendering markdown output requires using FFI to set innerHTML on a wrapper element via a custom Command.
  • The record DSL is not element-specific. div { onSubmit: Foo } still compiles by design. Use PsSpa.Html.DSL.Strict for opt-in element-specific checks across the generated HTML/SVG surface.
  • No CSS-in-JS / style objects. style :: String only — 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 attrs or S.unsafeAttr for 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:check

test:js includes a real Chromium smoke test via Playwright. In sandboxed macOS environments, run that command where Chromium is allowed to launch.