Coder Social home page Coder Social logo

svelte-pathfinder's Introduction

Tiny, state-based, advanced router for SvelteJS.

NPM version NPM downloads

A completely different approach of routing. State-based router suggests that routing is just another global state and History API changes are just an optional side-effects of this state.

๐Ÿ’ก Features

  • Zero-config!
  • Just another global state. Ultimate freedom how to apply this state to your app!
  • Juggling of different parts of URL (path/query/hash) effective and granularly.
  • Automatic parsing of the query params, optional parsing path params.
  • Helpers to work with navigation, links, and even html forms.

preview

๐Ÿ“ฆ Install

npm i svelte-pathfinder --save-dev
yarn add svelte-pathfinder

CDN: UNPKG | jsDelivr (available as window.Pathfinder)

<script src="https://unpkg.com/svelte-pathfinder/dist/pathfinder.min.js"></script>

<!-- OR in modern browsers -->

<script type="module" src="https://unpkg.com/svelte-pathfinder/dist/pathfinder.min.mjs"></script>

๐Ÿ“Œ URL schema

/path?query#fragment

๐Ÿค– API

Stores

path - represents path segments of the URL as an array.

path: Writable<[]>

query - represents query params of the URL as an object.

query: Writable<{}>

fragment - represents fragment (hash) string of URL.

fragment: Writable<string>

state - represents state object associated with the new history entry created by pushState().

state: Writable<{}>

url - represents full URL string.

url: Readable<string>

pattern - function to match path patterns and return read-only params object or null.

pattern: Readable<<T extends {}>(pattern?: string | RegExp, options?: ParseParamsOptions) => T | null>

paramable - constructor of custom params stores to parse path patterns and manipulate path parameters.

paramable: <T extends {}>(pattern?: string, options?: ParseParamsOptions): Writable<T>;

Helpers

goto - perform navigation to the next router state by URL.

goto(url: string | URL, state?: {});

back - perform navigation to the previous router state.

back(url?: string | URL)

redirect - update current url without new history record.

redirect(url: string | URL, state?: {})

click - handle click event from the link and perform navigation to its targets.

click(event: MouseEvent)

submit - handle submit event from the GET-form and perform navigation using its inputs.

submit(event: SubmitEvent)

Configuration

  • prefs - preferences object
    • sideEffect - manually disable/enable History API usage (changing URL) (default: auto).
    • hashbang - manually activate hashbang-routing.
    • base - set base path if web app is located within a nested basepath.
    • convertTypes - disable converting types when parsing query/path parameters (default: true).
    • nesting - number of levels when pasring nested objects in query parameters (default: 3).
    • array.format - format for arrays in query parameters (possible values: 'bracket' (default), 'separator').
    • array.separator - if format is separator this field give you speficy which separator you want to use (default: ',').
    • breakHooks - whether or not hooks execution should be stopped immediately after first fail or all hooks should be performed in any case (default: true).
    • anchor - whether or not router should respect fragment (hash) value as an anchor if DOM element with appropriate id attribute is found (default: false).
    • scroll - whether or not router should restore scroll position upon the navigation (default: false).
    • focus - whether or not router should restore last focus on DOM element upon the navigation (default: false).

To change the preferences, just import and change them somewhere on top of your code:

import { prefs } from 'svelte-pathfinder';

prefs.scroll = true;
prefs.convertTypes = false;
prefs.array.format = 'separator';

ParseParamsOptions

Usually, you don't need to use second argument in $pattern() function and paramable store constructor, but in some cases its options can be useful for your goals:

  • loose - should the pattern match paths that are longer than the pattern itself? (default: false)
  • blank - should blank params object (with undeifned values) returned if pattern not match? (default: false)
  • sensitive - should be pattern case sensitive? (default: false)
  • decode - you can provide you own function to decode URL parameters. (default: decodeURIComponent will be used)

๐Ÿ•น Usage

Changing markup related to the router state

{#if params = $pattern('/products/:id')} <!-- eg. /products/1 -->
    <ProductView productId={params.id} />
{:else if params = $pattern('/products')} <!-- eg. /products?page=2&q=Apple -->
    <ProductsList page={$query.page} search={$query.q} />
{:else}
    <NotFound path={$path} />
{/if}

<Modal open={$fragment === '#login'}>
    <LoginForm />
</Modal>

<script>
    import { path, query, fragment, pattern } from 'svelte-pathfinder';
    let params;
</script>

Changing logic related to the router state

{#if page}
    <svelte:component this={page.component} {params} />
{/if}

<script>
    ...
    import { path, pattern } from 'svelte-pathfinder';
    import routes from './routes.js'; // [{ pattern: '/products', component: ProductsList }, ...]
    ...
    let params;
    $: page = routes.find((route) => params = $pattern(route.pattern)) || null; // match path pattens and get parsed params
    ...
    $: if ($path[0] === 'admin' && ! isAuthorized) { // check any specific segment of the path
        $path = '/forbidden'; // re-write whole path
    }
</script>

Also, you able to use custom RegExp pattern instead of regular path-to-regexp pattern in $pattern() function. In this case, if you want to interprent some part of this RegExp pattern as an path parameter you need to use RegExp's named groups. For example, you need to make sure that path includes numeric identifier in second segment of the path:

{#if params = $pattern(/^\/todos\/(?<id>\d+)/)} <!-- will be matched only if second segment is a number -->
    <TodoDetail id={params.id} />
{:else if params = $pattern('/todos/:action')} <!-- will be matched if second segment is NOT a number but any other value -->
    <TodoAction action={params.action} />
{:else if $pattern('/todos')}
    <TodoList  />
{/if}

<script>
    import { pattern } from 'svelte-pathfinder';
    let params;
</script>

โš ๏ธ Note: paramable stores still not support custom RegExp patterns. If you'll try to pass RegExp to paramable constructor it'll cause exception. Maybe similar support will be added in the future.

Performing updates of router state with optional side-effect to URL

<script>
    ...
    $query.page = 10; // set ?page=10 without changing or loose other query params
    ...
    $path[1] = 4; // set second segment of the path, e.g. /products/4
    ...
    $fragment = 'login'; // set url hash to #login 
    ...
    $state = { restoreOnBack: 'something' }; // set history record related state object
</script>

Directly bind & assign values to stores

<input bind:value={$query.q} placeholder="Search product...">
...
<button on:click={() => $fragment = 'login'}>Login</button>
...
<a href="/products/{product.id}" on:click|preventDefault={e => $path[1] = product.id}>
    {product.title}
</a>

Creating stores to manipulating path parameters

// ./stores/params.js

import { paramable } from 'svelte-pathfinder';

export const productPageParams = paramable('/products/:category/:productId?');
...
<select bind:value={$params.category}>
    <option value="all">All</option>
    {#each categories as category}
        <option value={category.slug}>{category.name}</option>
    {/each}
</select>
...
{#each products as product} 
    <a href="/products/{product.id}" on:click|preventDefault={e => $params.productId = product.id}>
        {product.title}
    </a>
{/each}

<script>
    import { productPageParams as params } from './store/params';
</script>

Use with the other stores

import { derived } from 'svelte/store';
import asyncable from 'svelte-asyncable';
import { path, query } from 'svelte-pathfinder';

 import { productPageParams } from './store/params';

// with regular derived store

export const productData = derived(productPageParams, ($params, set) => {
    if ($params.productId}) {
        fetch(`/api/products/${$params.productId}`)
            .then(res => res.json())
            .then(set);
    }
}, {});

// with svelte-asyncable

export const productsList = asyncable(async $query => {
    const res = await fetch(`/api/products${$query}`)
    return res.json();
}, undefined, [ query ]);

Using helper click

Auto-handling all links in the application.

<svelte:window on:click={click} />

<!-- links below will be handled by `click` helper -->

<nav class="navigate">
    <a href="/">Home</a>
    <a href="/products">Products</a>
    <a href="/about">About</a>
</nav>

<!-- links below will be EXCLUDED from the navigation -->

<nav class="not-navigate">
    <a href="http://google.com">External links</a>
    <a href="/shortlink/2hkjhrfwgsd" rel="external">Links with external rel</a>
    <a href="/products" target="_blank">Any links with target</a>
    <a href="/" target="_self">Any links with target even to _self</a>
    <a href="/path/prices.zip" download>Download links</a>  
    <a href="mailto:[email protected]">Mailto links</a>
    <a href="tel:+432423535">Tel links</a>
    <a href="/cart" on:click|preventDefault|stopPropagation={doSomething}>
        Just stop click event bubbling
    </a>
    <a href="javascript:void(0)">Old style JS links</a>
    <a href="#hashOnly">Hash-only links</a>
</nav>

<script>
    import { click } from 'svelte-pathfinder';
</script>

Using helpers goto and back

<button on:click={() => back()}>Back</button> 
<button on:click={() => goto('/cart?tab=overview')}>Open cart</button>

<script>
    import { goto, back } from 'svelte-pathfinder';
</script>

Using helper submit

<!-- handle GET-forms -->
<form on:submit={submit} action="/products" method="GET">
    <!-- all hidden fields will be propagated to $state by name attributes -->

    <input type="hidden" name="uid" value={$state.uid}>

    <!-- all visible fields will be propagated to $query by name attributes -->

    <input name="q" value={$query.q} placeholder="Title...">

    <select name="option" value={$query.option}>
        <option>1</option>
        <option>2</option>
        <option>3</option>
    </select>

    <!-- even pushed submit button will be propagated to $query by name attribute -->

    <button name="type" value="quick">Quick search</button>
    <button name="type" value="fulltext">Fulltext search</button>
</form>

<script>
    import { submit, query, state } from 'svelte-pathfinder';
</script>

Using hooks

Hook is a callback function which will be executed before actual router state update to perform some side-effects. Callback function receives 3 arguments: (1) upcoming store value, (2) current store value and (3) name of the store (simple 'path', 'query' or 'fragment' string).

If callback function return explicit false, specific router state update and subsequent navigation will be skipped. It can be useful for some kind of guards for specific router states.

Hooks applicable only for major stores such as path, query or fragment. Hook can be added using hook method of particular store which is returns cancel function:

onDestroy(path.hook(($path, $currentPath, name) => {

    if ($path[0] === 'my' && ! isAuthorized()) return false; // skip router state update

    // do some additional side-effect

    console.log(`Hook of ${name} performed`); // output: Hook of path performed
}));

โš ๏ธ Note: hooks are not pre-defined thing that's why it matters when and in what order the hooks are created in your app. When the hook is just added, the callback function will be executed instantly with upcoming value (first argument) equals null which means not actual state update, but that the hook was just cocked. You able to use this fact in some way:

onDestroy(query.hook(($query, $currentQuery, name) => {
    if ($query === null) {
        console.log(`Hook for ${name} is just added`);
    }   
}));

Multiple hooks can be added to each particular store and they will be executed in order of adding hooks to the store. By default, if some hook returns explicit false value, execution process will be stoped immediately which is more optimized. If you need to perform all hooks in any case, just use prefs.breakHooks = false. In this case, all hooks will be executed but state still won't be updated and navigation won't be performed.

๐Ÿ”– SSR support (highly experimental)

require('svelte/register');

const express = require('express');

const app = express();
...
/* any other routes */
...
app.get('*', (req, res) => {
    const router = require('svelte-pathfinder/ssr')();
    const App = require('./App.svelte').default;

    router.goto(req.url);

    const { html, head, css } = App.render({ router });

    res.send(`
        <!DOCTYPE html>
        <html>
            <head>
                ${head}
                ${css}
            </head>
            <body>
                ${html}
            </body>
        </html>
    `);
});

โš ๏ธ Note: you can't use pathfinder stores by just directly import it in SSR rendered applications (just like the other Svelte stores), because, in-fact, they're global to the entire server instance. To avoid it, just pass pathfinder instance to root component via props and use it in all components of application. For example using context:

<!-- App.svelte -->
<svelte:window on:click={router.click}/>
<script>
    import { setContext } from 'svelte';

    export let router;
    setContext('router', router);
</script>
<!-- Nested.svelte -->
<script>
    import { getContext } from 'svelte';

    const { path } = getContext('router');

    function gotoSomething() {
        $path = '/something';
    }
</script>

๐Ÿšฉ Assumptions

Optional side-effect (changing browser history and URL)

Router will automatically perform pushState to browser History API and listening popstate event if the following conditions are valid:

  • router works in browser and global objects are available (window & history).
  • router works in browser which is support pushState/popstate.
  • router works in top-level window and has no parent window (eg. iframe, frame, object, window.open).

If any condition is not applicable, the router will work properly but without side-effect (changing URL).

hashbang routing (#!)

Router will automatically switch to hashbang routing in the following conditions:

  • History API is not available.
  • web app has launched under file: protocol.
  • initial path contain exact file name with extension.

ยฉ License

MIT ยฉ PaulMaly

svelte-pathfinder's People

Contributors

blokhin avatar dependabot[bot] avatar dxpm avatar greenheart avatar paulmaly avatar samal-rasmussen avatar valexr avatar vfilatov avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

svelte-pathfinder's Issues

Linking to path with query parameter triggers two history changes

Current Behavior

From path "/"
<a href={${path}?=${query}}>Link with Query

Then from "{path}"
function back() results in url /${path}
pressing browser back button results in the same behavior

Desired Behavior

<a href={${path}?=${query}}>Link with Query
would result in one history change, back button would return to "/" not "{path}"

Why page reloads?

Do I completely misunderstand the intention of routers? Why is your router reloading the pages? Isn't the purpose of a router in an SPA suppose to NOT reload the page???? How is state to persist with this design?

$query store prototype

Because a $query store have String prototype there may be property name conflicts.

import { query } from 'svelte-pathfinder'

console.log($query.search) // function String.search by default
$query.search = 'test'
console.log($query.search) // string 'test'

Maybe need to rewrite a $query store to an object with a toString() function that returns '?one=1&second=2&...' string?

Nested routes

Thank you for this library.

This routing approach is completely different from what I'm used to.

Is it possible to implement nested routes (or layout route) with pathfinder?

Something similar to

const routes = [
  {
    path: '/*',
    component: AppShell,
    children: [
      {
        path: 'user',
        component: User,
      }
    ],
  }
]

/user would match both / and user and user would render in a <slot /> inside AppShell

"unload" event is deprecated

Hello,

First off great work on the router, it's very handy and some of its features were impossible to find in others, love it!

Recently on a project checking the Lighthouse report, I noticed the score from "best practises" went from 100 to 78 because it detected some deprecated API usage. After a bit of digging I realized it was coming from svelte-pathfinder usage of the unload event, which seems outdated: https://developer.mozilla.org/en-US/docs/Web/API/Window/unload_event

Would it be possible to work around it somehow?
Cheers.

Redirect & browser history navbutton

How to reason with the navigation with the history buttons of the browser..?
To obey the conditions of the router ๐Ÿ˜

<script>
  if(!authed) goto('/auth')
  
  //  $authed -> exclude /auth
  // !$authed -> only /auth
</script>

{#if !authed}
  <Auth />
{:else}
  <OtherRoutes />
{/if}

If you enter the address in the line, it works ok, just like the page reload ... and the history buttons live their own life ๐Ÿ˜ continuing to change the link in the address bar, although the router shows what is needed ...

Example repo

Warning after Vite 5 update

After update to Vite v5.0 there are warning in the console:

[vite-plugin-svelte] WARNING: The following packages have a svelte field in their package.json but no exports condition for svelte.

[email protected]

Please see https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/faq.md#missing-exports-condition for details.

Everything builds and works just fine.

Possible to preserve paths on page refresh?

I'm noticing that whether I use prefs.hashbang = true; or not, when I'm on a route that isn't the root and I refresh the page, places where I use $pattern have become empty. Is there any way to make this persistent?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.