@dolanske/pantry

Front-end framework combining the UI components of Cascade and routing of Crumbs.
434 1
Install
npm install @dolanske/pantry
Latest Version:1.0.0
License:GPL-3.0
Last Updated:Sep 13, 2026
Links: GitHub  ·  npm
Maintainer: dolanske

PANTRY

npm i @dolanske/pantry

Completely homegrown framework, utilizing my own libraries Cascade for UI components and Crumbs for client side routing. Oh the joy of creating things.

Cascade

Is a simple library to write reusable UI components using nothing but raw will and render functions. The idea behind such library has been on my mind for almost a year, so it's lovely to finally see it happen.

A simple example of a reusable piece of UI written in Cascade.

import { reusable } from '@dolanske/cascade'
import { ref } from '@vue/reactivity'

const Counter = reusable<{ startingCount: number }>('button', (ctx, props) => {
  const count = ref(props.startingCount)

  ctx.text(() => `Clicked ${count.value} times`)
  ctx.click(() => count.value++)
})

Crumbs

I wish I had a SPA router utilizing native browser history API? Kid named SPA router utilizing native browser history API: hold my beer.

Crumbs is a simple client side routing library, working with raw HTML files imported as strings. Here's an example

import { defineRouter } from '@dolanske/crumbs'

import errorFallback from './routes/errorFallback.html?raw'
import main from './routes/main.html?raw'
import user from './routes/user.html?raw'

const routes = {
  '/': main,
  '/about': '<span>About Us</span>',
  '/user/:id': {
    html: user,
    // In case loader throws, you can provide a fallback route to render instead
    fallback: errorFallback,
    async loader({ id }) {
      return fetch(`https://swapi.dev/api/people/${id}`).then(r => r.json())
    },
  },
}

defineRouter(routes).run('#app')

Both together = Pantry

To explain it in the simplest terms, Pantry uses the routing mechanism of Crumbs, but instead of rendering HTML files, it renders the UI components provided by Cascade. And that's it. There's nothing else to it!

Pantry is the only package your app needs to install and import from. It pins compatible versions of Cascade, Crumbs and @vue/reactivity and re-exports them:

  • everything Cascade exports (div, reusable, nextTick, ...)
  • the reactivity primitives (ref, computed, watch, reactive, ...)
  • the Crumbs runtime API (navigate, getRoute, findRoute, onNavigation, onRouteResolve, onRouteError, getRouterRoot, getRouterConfig). defineRouter is replaced by createApp.
import type { RouteProps } from '@dolanske/pantry'
import { createApp, div, h1, Link, p, pre, reusable } from '@dolanske/pantry'

interface Person {
  name: string
}

const PersonView = reusable<RouteProps<Person>>('div', (ctx, props) => {
  ctx.nest(
    // Every route view receives these props
    // props.$data   - resolved loader data, `null` when there is none
    // props.$params - dynamic path parameters, e.g. `/people/:id` -> { id: '3' }
    // props.$query  - search parameters
    // props.$route  - the full resolved route (hash, props, meta, title, ...)
    h1(() => props.$data.name),
    pre(JSON.stringify(props.$params, null, 2)),
    Link('/', 'Go back'),
  )
})

const app = createApp({
  '/': div(
    h1('HOME'),
    Link('/about', 'About us'),
    Link(`/people/${getRandomNumberInRange(1, 10)}`, 'Random person'),
  ),
  '/about': div(
    h1('About us'),
    p('We are a community of {big number} and constantly growing!'),
    Link('/', 'Go back'),
  ),
  '/people/:id': {
    component: PersonView,
    title: 'Person',
    async loader({ id }) {
      return fetch(`https://swapi.dev/api/people/${id}`).then(r => r.json())
    },
    // Rendered instead of `component` when the loader throws
    fallback: p('Whoops, something went wrong :/'),
  }
}, {
  // Rendered when no route matches or a loader throws without a fallback
  errorFallback: p('Page not found'),
})

app.run('#app')

API

createApp(routes, options?)

type View<Props> = Component<Props> | (() => Component<Props>)

interface Route<Props> {
  component: View<Props>
  // Rendered instead of `component` when the loader throws. Receives the same props with `$data` set to `null`
  fallback?: View<Props>
  // Resolved before the route renders, the result is available as `$data`
  loader?: (params: Record<string, string>) => Promise<any>
  // Sets `document.title`
  title?: string
  // Rendered when the current URL matches no route on startup
  default?: boolean
  // Free form data, available as `$route.meta` and in `onNavigation` guards
  meta?: Record<string, any>
}

type Routes = Record<string, Route | View>

interface AppOptions {
  errorFallback?: View<ErrorProps>
}

A view is either a component instance or a function returning one, such as a component created with reusable().

  • A component instance is destroyed when you leave the route and mounted again when you come back. Its setup runs again, so state declared inside starts fresh, and the new $params / $data are passed in.
  • A factory creates a brand new instance on every visit. Prefer this for anything with state.

The returned app has three methods:

// Starts routing inside the element matching the selector. Resolves once the initial route rendered
await app.run('#app')
// Stops routing and destroys the rendered view
app.stop()
// Same as the `errorFallback` option
app.errorFallback(view)

Route paths, matching rules, <a link> handling, guards and the navigation options are all Crumbs features, see its documentation.

Route props

type RouteProps<Data = any, Extra extends object = object> = Extra & {
  $data: Data
  $params: Record<string, string>
  $query: Record<string, string>
  $route: ResolvedRoute
}

// The error fallback receives
interface ErrorProps {
  $error: unknown
  // `null` when no route matched the path
  $route: SerializedRoute | null
}

Use RouteProps as the props type of a view. Add your own props as the second type argument when you also pass props manually.

Link(href, children?, options?)

Anchor which navigates through the router instead of reloading the page. href can be a ref or getter.

interface LinkOptions extends NavigateOptions {
  // Class added while the link's path matches the current route. `/users` is
  // also active on `/users/10`, set `exact` to only match the whole path
  activeClass?: string
  exact?: boolean
}

Link('/users', 'Users', { activeClass: 'active' })
Link(() => `/users/${id.value}`, [icon, 'Profile'], { query: { tab: 'posts' } })

Plain links get the link attribute and are handled by Crumbs, which leaves modified clicks, other targets and external links to the browser. Links with navigation options (query, hash, props, replace) call navigate() on click.

useRoute()

Reactive reference to the currently rendered route, null before the first route rendered and after app.stop().

const route = useRoute()

nav(
  span(() => route.value?.title ?? ''),
  Link('/', 'Home', { activeClass: 'active', exact: true }),
)

Development

npm run dev     # playground in `src/test.ts`
npm test        # vitest + happy-dom
npm run lint
npm run build

Changes in 0.5

Pantry now targets Cascade 3 and Crumbs 2.

  • Views can be factories (reusable() components or any () => Component). Instance views are re-mounted instead of cloned, Component.clone() no longer exists in Cascade.
  • Routes render into a persistent boundary element per route, which Crumbs 2 accepts as html. The route fallback is rendered by Crumbs when the loader throws and receives the route props with $data: null.
  • errorFallback is also an option of createApp and renders for unmatched paths and unhandled loader errors, receiving $error and $route.
  • Route props gained $query and $route. PropType is deprecated in favour of RouteProps.
  • Routes accept meta, title and default. app.run() returns the promise of the initial navigation.
  • Link uses Crumbs' <a link> handling, accepts a reactive href and an activeClass option. Argument order is Link(href, children, options).
  • useRoute() exposes the current route as a shallow ref.
  • Pantry re-exports Cascade, @vue/reactivity and the Crumbs runtime API. Apps import from @dolanske/pantry only and no longer install the three packages themselves.
  • Fixed: the fallback view was mounted with an invalid selector and never rendered.

Related Packages

laravel-nuxt

Build a SPA with Laravel and Nuxt

4,089 146
@vunojs/core

Core runtime and typed configuration for Vuno applications.

923
@ergonode/vuems

A simple mechanism to transform a monolithic Vue application into an application...

6,417 79
create-easy-cms

Add Easy CMS to a Nuxt or Next.js project

14,568 2
nuxt-interpolation

Directive for binding every link to catch the click event, and if it's a relativ...

13,801 44

Version History

Version Released License
1.0.0 GPL-3.0
0.4.4 GPL-3.0
0.4.3 GPL-3.0
0.4.2 GPL-3.0