@dolanske/pantry
v1.0.0
Published
Front-end framework combining the UI components of Cascade and routing of Crumbs.
Maintainers
Readme
PANTRY
npm i @dolanske/pantryCompletely 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).defineRouteris replaced bycreateApp.
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
setupruns again, so state declared inside starts fresh, and the new$params/$dataare 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 buildChanges 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 routefallbackis rendered by Crumbs when the loader throws and receives the route props with$data: null. errorFallbackis also an option ofcreateAppand renders for unmatched paths and unhandled loader errors, receiving$errorand$route.- Route props gained
$queryand$route.PropTypeis deprecated in favour ofRouteProps. - Routes accept
meta,titleanddefault.app.run()returns the promise of the initial navigation. Linkuses Crumbs'<a link>handling, accepts a reactivehrefand anactiveClassoption. Argument order isLink(href, children, options).useRoute()exposes the current route as a shallow ref.- Pantry re-exports Cascade,
@vue/reactivityand the Crumbs runtime API. Apps import from@dolanske/pantryonly and no longer install the three packages themselves. - Fixed: the fallback view was mounted with an invalid selector and never rendered.
