├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── index.d.ts ├── index.js ├── stores.js └── utils.js └── ssr.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,css,html,svelte}] 8 | charset = utf-8 9 | indent_style = tab 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | max_line_length = 100 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | node_modules 4 | *.log 5 | .idea/ 6 | .sessions/ 7 | .vscode/ 8 | dist -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # svelte-pathfinder changelog 2 | 3 | # 4.7.1 4 | * add support for svelte 4 5 | 6 | # 4.7.0 7 | * support of custom RegExp pattern to `pattern` store-function. 8 | * support relative `query` links using `click` helpers. 9 | 10 | # 4.6.1 11 | * fix empty state prefixing. 12 | 13 | # 4.6.0 14 | * (minor breaking change): revert prefixing to `query` and `fragment` stores after discussion with the community. 15 | * improve types. 16 | 17 | # 4.5.1 18 | * fix SubmitEvent type. 19 | * actualize README. 20 | 21 | # 4.5.0 22 | * (minor breaking change): `basePath` preference re-named to `base`. 23 | * (minor breaking change): `query.toString()` now returns url-encoded string *NOT* prefixed by `?` symbol. 24 | * add support of full URL fallback in `back` helper instead of only `pathname`. 25 | * add support of URL instance instead of only string in `goto`, `back` and `redirect` helpers. 26 | * better support of hash-only links within `click` helper. 27 | * new preferences `scroll`, `anchor` and `focus` (disabled by default) for auto-management of corresponding things. Please, read more in README. 28 | * introducing `hook` method of `path`, `query` and `fragment` stores to get more control before it update and possible skip this update instantly, before actual router state change. Please, check an examples. 29 | * new `breakHooks` preference to get control the behavior of hooks execution - should it be stopped immediately after first fail (by default) or all hooks should be performed in any case. 30 | 31 | # 4.1.2 32 | * add missed files. 33 | 34 | # 4.1.1 35 | * fix back helper bug using href="#" navigation. 36 | 37 | # 4.1.0 38 | * better SSR support. 39 | * extended support for special links in `click` helper (eg. blob:, data:, etc.) 40 | * code refactoring and formating. 41 | * fix multiple `rel` attribute values. 42 | * add LICENSE file. 43 | * fix `back` helper bug. 44 | 45 | # 4.0.0 46 | * (breaking change): Now `path` store contains just an array of path segments and doesn't contain `params` field anymore. Please, check examples of `path` store and `paramable` custom store constructor to manipulate parameters. 47 | * (breaking change): Now `query` store contains just an object of query string parameters and doesn't contain `params` field anymore. Please, check examples of `query` store. 48 | * (breaking change): Now `pattern` store directly returns read-only `params` object if pattern was matched or `null` if not. Please, check examples in README. 49 | * New `paramable` custom store constructor is provided to create separate `params` stores for each of possible path patterns to manipulate path parameters in ultimate flexible manner. 50 | 51 | #### Migration guide: 52 | ![migration](https://user-images.githubusercontent.com/4378873/221018183-678c16fc-e940-43b4-ab4e-413fa77af881.png) 53 | 54 | 55 | # 3.4.0 56 | * Make updating of `query` and `fragment` parts of URL state in the next tick to prevent unnecessary executions of listeners when transitioning between paths. 57 | 58 | # 3.3.0 59 | * Check that if `convertTypes=true` and the number after conversion is not equal to an initial value, we need to cancel conversion and keep it a string, because it seems to be too large a number. 60 | 61 | # 3.2.3 62 | * Move redirection to next tick. 63 | 64 | # 3.2.2 65 | * Fix types. 66 | 67 | # 3.2.1 68 | * Fix `click` helper issue. 69 | 70 | # 3.2.0 71 | * SVG support improved. 72 | * rel=external support to prevent some link handling. 73 | * `redirect()` function added. 74 | 75 | # 3.1.3 76 | * Fix for duplicate records in browser history on each page load (#11). 77 | 78 | # 3.1.2 79 | * Fix absolute URLs in `goto`. 80 | * Few improvements. 81 | 82 | # 3.1.1 83 | * Update README. 84 | * Switch to `hashbang` routing automatically if initial path contain file name with extension. 85 | * Switch to `hashbang` routing automatically if `History API` not available. 86 | 87 | # 3.1.0 88 | * Support of `hashbang` routing automatically for `file://` protocol and manually via `prefs`. 89 | * Support of working in subdirectory using `basePath` preference in `prefs`. 90 | 91 | # 3.0.6 92 | * Improve typings. 93 | 94 | # 3.0.5 95 | * Fix typings 96 | 97 | # 3.0.4 98 | * Minor fix in `package.json`. 99 | 100 | # 3.0.2 101 | * Add special fields to `package.json` for CDNs. 102 | * Add babel and prettier configs to `package.json`. 103 | 104 | # 3.0.1 105 | 106 | * Fix typings 107 | * Fix README 108 | 109 | # 3.0.0 110 | 111 | * (breacking change): Now `pattern()` is not a part of `path` store, but separate derived store related to `path`. Please, check an examples in README. 112 | * (breacking change): Now all parsed parameters of `path` and `query` stores collected in `params` property of that objects. Please, check an examples in README. 113 | * Re-write back to plain Javascript, but with typings support. 114 | * Fix for issue #10 in original repo. 115 | * Official SSR support. Examples coming soon! 116 | 117 | 118 | # 2.2.1 119 | 120 | * Move build output to a dedicated dist folder 121 | 122 | # 2.2.0 123 | 124 | * Use single export instead of multiple. 125 | * Fix `prefs` export which was missed in PR. 126 | 127 | # 2.1.2 128 | 129 | * Fix boolean values convertation. 130 | 131 | # 2.1.1 132 | 133 | * Minor fix of new query array param formating. 134 | 135 | # 2.1.0 136 | 137 | * Improve query string parsing 138 | * Support different formats of query string array processing, eg. `?foo[]=1&foo[]=2` (default) and NEW alternative way `?foo=1,2`. 139 | * Support arrays in `path` eg. param`/:variants` in example `/foo|bar|baz` will be parsed as array `$path.variants === ['foo', 'bar', 'baz']`. 140 | 141 | # 2.0.0 142 | 143 | * Re-write to Typescript (first edition). 144 | * Add `svelte` to devDeps to get typings. 145 | * Improve query string parsing, better support for nesting objects. 146 | * Now configurable from user-land! 147 | * Jest setup (tests coming soon). 148 | 149 | # 1.1.0 150 | 151 | * Fix URL() base. 152 | * Fix deps. 153 | * Code formatting via Prettier. 154 | 155 | ## 1.0.0 156 | 157 | * First release 158 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Svelte Tools 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiny, state-based, advanced router for SvelteJS. 2 | 3 | [![NPM version](https://img.shields.io/npm/v/svelte-pathfinder.svg?style=flat)](https://www.npmjs.com/package/svelte-pathfinder) [![NPM downloads](https://img.shields.io/npm/dm/svelte-pathfinder.svg?style=flat)](https://www.npmjs.com/package/svelte-pathfinder) 4 | 5 | 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. 6 | 7 | ## 💡 Features 8 | 9 | - Zero-config! 10 | - Just another global state. Ultimate freedom how to apply this state to your app! 11 | - Juggling of different parts of URL (path/query/hash) effective and granularly. 12 | - Automatic parsing of the `query` params, optional parsing `path` params. 13 | - Helpers to work with navigation, links, and even html forms. 14 | 15 | ![preview](https://user-images.githubusercontent.com/4378873/220714832-e1aefab3-7cfa-4f4a-9a02-3e280257388c.png) 16 | 17 | ## 📦 Install 18 | 19 | ```bash 20 | npm i svelte-pathfinder --save-dev 21 | ``` 22 | 23 | ```bash 24 | yarn add svelte-pathfinder 25 | ``` 26 | 27 | CDN: [UNPKG](https://unpkg.com/svelte-pathfinder/) | [jsDelivr](https://cdn.jsdelivr.net/npm/svelte-pathfinder/) (available as `window.Pathfinder`) 28 | 29 | ```html 30 | 31 | 32 | 33 | 34 | 35 | ``` 36 | 37 | ## 📌 URL schema 38 | 39 | `/path`?`query`#`fragment` 40 | 41 | ## 🤖 API 42 | 43 | ### Stores 44 | 45 | `path` - represents path segments of the URL as an array. 46 | 47 | ```javascript 48 | path: Writable<[]> 49 | ``` 50 | 51 | `query` - represents query params of the URL as an object. 52 | 53 | ```javascript 54 | query: Writable<{}> 55 | ``` 56 | 57 | `fragment` - represents fragment (hash) string of URL. 58 | 59 | ```javascript 60 | fragment: Writable 61 | ``` 62 | 63 | `state` - represents state object associated with the new history entry created by pushState(). 64 | 65 | ```javascript 66 | state: Writable<{}> 67 | ``` 68 | 69 | `url` - represents full URL string. 70 | 71 | ```javascript 72 | url: Readable 73 | ``` 74 | 75 | `pattern` - function to match path patterns and return read-only `params` object or `null`. 76 | 77 | ```javascript 78 | pattern: Readable<(pattern?: string | RegExp, options?: ParseParamsOptions) => T | null> 79 | ``` 80 | 81 | `paramable` - constructor of custom `params` stores to parse path patterns and manipulate path parameters. 82 | 83 | ```javascript 84 | paramable: (pattern?: string, options?: ParseParamsOptions): Writable; 85 | ``` 86 | 87 | ### Helpers 88 | 89 | `goto` - perform navigation to the next router state by URL. 90 | 91 | ```javascript 92 | goto(url: string | URL, state?: {}); 93 | ``` 94 | 95 | `back` - perform navigation to the previous router state. 96 | 97 | ```javascript 98 | back(url?: string | URL) 99 | ``` 100 | 101 | `redirect` - update current url without new history record. 102 | 103 | ```javascript 104 | redirect(url: string | URL, state?: {}) 105 | ``` 106 | 107 | `click` - handle click event from the link and perform navigation to its targets. 108 | 109 | ```javascript 110 | click(event: MouseEvent) 111 | ``` 112 | 113 | `submit` - handle submit event from the GET-form and perform navigation using its inputs. 114 | 115 | ```javascript 116 | submit(event: SubmitEvent) 117 | ``` 118 | 119 | ### Configuration 120 | 121 | - `prefs` - preferences object 122 | - `sideEffect` - manually disable/enable History API usage (changing URL) (default: auto). 123 | - `hashbang` - manually activate hashbang-routing. 124 | - `base` - set base path if web app is located within a nested basepath. 125 | - `convertTypes` - disable converting types when parsing query/path parameters (default: true). 126 | - `nesting` - number of levels when pasring nested objects in query parameters (default: 3). 127 | - `array.format` - format for arrays in query parameters (possible values: 'bracket' (default), 'separator'). 128 | - `array.separator` - if format is `separator` this field give you speficy which separator you want to use (default: ','). 129 | - `breakHooks` - whether or not hooks execution should be stopped immediately after first fail or all hooks should be performed in any case (default: true). 130 | - `anchor` - whether or not router should respect fragment (hash) value as an anchor if DOM element with appropriate id attribute is found (default: false). 131 | - `scroll` - whether or not router should restore scroll position upon the navigation (default: false). 132 | - `focus` - whether or not router should restore last focus on DOM element upon the navigation (default: false). 133 | 134 | To change the preferences, just import and change them somewhere on top of your code: 135 | 136 | ```javascript 137 | import { prefs } from 'svelte-pathfinder'; 138 | 139 | prefs.scroll = true; 140 | prefs.convertTypes = false; 141 | prefs.array.format = 'separator'; 142 | ``` 143 | 144 | #### ParseParamsOptions 145 | 146 | 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: 147 | 148 | - `loose` - should the pattern match paths that are longer than the pattern itself? (default: false) 149 | - `blank` - should blank params object (with undeifned values) returned if pattern not match? (default: false) 150 | - `sensitive` - should be pattern case sensitive? (default: false) 151 | - `decode` - you can provide you own function to decode URL parameters. (default: decodeURIComponent will be used) 152 | 153 | ## 🕹 Usage 154 | 155 | ### Changing markup related to the router state 156 | 157 | ```svelte 158 | {#if params = $pattern('/products/:id')} 159 | 160 | {:else if params = $pattern('/products')} 161 | 162 | {:else} 163 | 164 | {/if} 165 | 166 | 167 | 168 | 169 | 170 | 174 | ``` 175 | 176 | #### Changing logic related to the router state 177 | 178 | ```svelte 179 | {#if page} 180 | 181 | {/if} 182 | 183 | 195 | ``` 196 | 197 | 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Backreferences#using_named_groups). For example, you need to make sure that path includes numeric identifier in second segment of the path: 198 | 199 | ```svelte 200 | {#if params = $pattern(/^\/todos\/(?\d+)/)} 201 | 202 | {:else if params = $pattern('/todos/:action')} 203 | 204 | {:else if $pattern('/todos')} 205 | 206 | {/if} 207 | 208 | 212 | ``` 213 | 214 | > ⚠️ 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. 215 | 216 | ### Performing updates of router state with optional side-effect to URL 217 | 218 | ```svelte 219 | 229 | ``` 230 | 231 | #### Directly bind & assign values to stores 232 | 233 | ```svelte 234 | 235 | ... 236 | 237 | ... 238 | $path[1] = product.id}> 239 | {product.title} 240 | 241 | ``` 242 | 243 | ### Creating stores to manipulating path parameters 244 | 245 | ```javascript 246 | // ./stores/params.js 247 | 248 | import { paramable } from 'svelte-pathfinder'; 249 | 250 | export const productPageParams = paramable('/products/:category/:productId?'); 251 | ... 252 | ``` 253 | 254 | ```svelte 255 | 261 | ... 262 | {#each products as product} 263 | $params.productId = product.id}> 264 | {product.title} 265 | 266 | {/each} 267 | 268 | 271 | ``` 272 | 273 | ### Use with the other stores 274 | 275 | ```javascript 276 | import { derived } from 'svelte/store'; 277 | import asyncable from 'svelte-asyncable'; 278 | import { path, query } from 'svelte-pathfinder'; 279 | 280 | import { productPageParams } from './store/params'; 281 | 282 | // with regular derived store 283 | 284 | export const productData = derived(productPageParams, ($params, set) => { 285 | if ($params.productId}) { 286 | fetch(`/api/products/${$params.productId}`) 287 | .then(res => res.json()) 288 | .then(set); 289 | } 290 | }, {}); 291 | 292 | // with svelte-asyncable 293 | 294 | export const productsList = asyncable(async $query => { 295 | const res = await fetch(`/api/products${$query}`) 296 | return res.json(); 297 | }, undefined, [ query ]); 298 | 299 | ``` 300 | 301 | ### Using helper `click` 302 | 303 | Auto-handling all links in the application. 304 | 305 | ```svelte 306 | 307 | 308 | 309 | 310 | 315 | 316 | 317 | 318 | 332 | 333 | 336 | ``` 337 | 338 | ### Using helpers `goto` and `back` 339 | 340 | ```svelte 341 | 342 | 343 | 344 | 347 | ``` 348 | 349 | ### Using helper `submit` 350 | 351 | ```svelte 352 | 353 |
354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 367 | 368 | 369 | 370 | 371 | 372 |
373 | 374 | 377 | ``` 378 | 379 | ### Using hooks 380 | 381 | 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). 382 | 383 | 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. 384 | 385 | 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: 386 | 387 | ```javascript 388 | onDestroy(path.hook(($path, $currentPath, name) => { 389 | 390 | if ($path[0] === 'my' && ! isAuthorized()) return false; // skip router state update 391 | 392 | // do some additional side-effect 393 | 394 | console.log(`Hook of ${name} performed`); // output: Hook of path performed 395 | })); 396 | ``` 397 | 398 | > ⚠️ 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: 399 | 400 | ```javascript 401 | onDestroy(query.hook(($query, $currentQuery, name) => { 402 | if ($query === null) { 403 | console.log(`Hook for ${name} is just added`); 404 | } 405 | })); 406 | ``` 407 | 408 | 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. 409 | 410 | ## 🔖 SSR support (highly experimental) 411 | 412 | ```javascript 413 | require('svelte/register'); 414 | 415 | const express = require('express'); 416 | 417 | const app = express(); 418 | ... 419 | /* any other routes */ 420 | ... 421 | app.get('*', (req, res) => { 422 | const router = require('svelte-pathfinder/ssr')(); 423 | const App = require('./App.svelte').default; 424 | 425 | router.goto(req.url); 426 | 427 | const { html, head, css } = App.render({ router }); 428 | 429 | res.send(` 430 | 431 | 432 | 433 | ${head} 434 | ${css} 435 | 436 | 437 | ${html} 438 | 439 | 440 | `); 441 | }); 442 | ``` 443 | 444 | > ⚠️ 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: 445 | 446 | ```svelte 447 | 448 | 449 | 455 | ``` 456 | 457 | ```svelte 458 | 459 | 468 | ``` 469 | 470 | ## 🚩 Assumptions 471 | 472 | ### Optional side-effect (changing browser history and URL) 473 | 474 | Router will automatically perform `pushState` to browser History API and listening `popstate` event if the following conditions are valid: 475 | 476 | * router works in browser and global objects are available (window & history). 477 | * router works in browser which is support `pushState/popstate`. 478 | * router works in top-level window and has no parent window (eg. iframe, frame, object, window.open). 479 | 480 | If any condition is not applicable, the router will work properly but without side-effect (changing URL). 481 | 482 | ### `hashbang` routing (`#!`) 483 | 484 | Router will automatically switch to `hashbang` routing in the following conditions: 485 | 486 | * `History API` is not available. 487 | * web app has launched under `file:` protocol. 488 | * initial path contain exact file name with extension. 489 | 490 | ## © License 491 | 492 | MIT © [PaulMaly](https://github.com/PaulMaly) 493 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-pathfinder", 3 | "version": "4.8.1", 4 | "description": "Tiny, state-based, advanced router for SvelteJS.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "cdn": "dist/pathfinder.min.js", 8 | "unpkg": "dist/pathfinder.min.js", 9 | "svelte": "src/index.js", 10 | "exports": { 11 | ".": { 12 | "svelte": "./src/index.js" 13 | } 14 | }, 15 | "types": "src/index.d.ts", 16 | "files": [ 17 | "dist", 18 | "src", 19 | "ssr.js", 20 | "CHANGELOG.md" 21 | ], 22 | "engines": { 23 | "node": ">=16.0.0" 24 | }, 25 | "scripts": { 26 | "build": "npm run format && npm run lint && npm run clean && rollup -c", 27 | "prepublishOnly": "npm run build && npm run size", 28 | "format": "prettier --write src", 29 | "lint": "eslint src", 30 | "lint:fix": "eslint src --fix", 31 | "test": "jest src", 32 | "size": "size-limit", 33 | "clean": "rm -rf ./dist" 34 | }, 35 | "babel": { 36 | "presets": [ 37 | [ 38 | "@babel/preset-env" 39 | ] 40 | ] 41 | }, 42 | "browserslist": [ 43 | "extends browserslist-config-google" 44 | ], 45 | "size-limit": [ 46 | { 47 | "name": "UMD output", 48 | "limit": "6.5 KB", 49 | "path": "./dist/index.js" 50 | }, 51 | { 52 | "name": "ESM output", 53 | "limit": "6.5 KB", 54 | "path": "./dist/index.mjs" 55 | }, 56 | { 57 | "name": "UMD output (minified)", 58 | "limit": "4.5 KB", 59 | "path": "./dist/index.min.js" 60 | }, 61 | { 62 | "name": "ESM output (minified)", 63 | "limit": "4.5 KB", 64 | "path": "./dist/index.min.mjs" 65 | }, 66 | { 67 | "name": "IIFE bundle", 68 | "limit": "8.5 KB", 69 | "path": "./dist/pathfinder.js" 70 | }, 71 | { 72 | "name": "ESM bundle", 73 | "limit": "8.5 KB", 74 | "path": "./dist/pathfinder.mjs" 75 | }, 76 | { 77 | "name": "IIFE bundle (minified)", 78 | "limit": "5.5 KB", 79 | "path": "./dist/pathfinder.min.js" 80 | }, 81 | { 82 | "name": "ESM bundle (minified)", 83 | "limit": "5.5 KB", 84 | "path": "./dist/pathfinder.min.mjs" 85 | } 86 | ], 87 | "prettier": { 88 | "tabWidth": 4, 89 | "semi": true, 90 | "singleQuote": true 91 | }, 92 | "eslintConfig": { 93 | "extends": [ 94 | "eslint:recommended", 95 | "prettier" 96 | ], 97 | "parserOptions": { 98 | "ecmaVersion": 2019, 99 | "sourceType": "module" 100 | }, 101 | "env": { 102 | "es6": true, 103 | "browser": true 104 | } 105 | }, 106 | "repository": { 107 | "type": "git", 108 | "url": "git+https://github.com/sveltetools/svelte-pathfinder.git" 109 | }, 110 | "keywords": [ 111 | "svelte", 112 | "svelte store" 113 | ], 114 | "author": "PaulMaly", 115 | "license": "MIT", 116 | "bugs": { 117 | "url": "https://github.com/sveltetools/svelte-pathfinder/issues" 118 | }, 119 | "homepage": "https://github.com/sveltetools/svelte-pathfinder#readme", 120 | "devDependencies": { 121 | "@babel/core": "^7.22.9", 122 | "@babel/preset-env": "^7.22.9", 123 | "@rollup/plugin-babel": "^6.0.3", 124 | "@rollup/plugin-commonjs": "^25.0.3", 125 | "@rollup/plugin-node-resolve": "^15.1.0", 126 | "@rollup/plugin-terser": "^0.4.3", 127 | "@size-limit/preset-app": "^8.2.6", 128 | "browserslist-config-google": "^3.0.1", 129 | "core-js": "^3.32.0", 130 | "eslint": "^8.46.0", 131 | "eslint-config-prettier": "^9.0.0", 132 | "jest": "^29.6.2", 133 | "prettier": "^3.0.1", 134 | "rollup": "^3.27.2", 135 | "rollup-plugin-sourcemaps": "^0.6.3", 136 | "size-limit": "^8.2.6", 137 | "svelte": "^4.1.2" 138 | }, 139 | "peerDependencies": { 140 | "svelte": ">=3 <5" 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import babel from '@rollup/plugin-babel'; 4 | import terser from '@rollup/plugin-terser'; 5 | import sourceMaps from 'rollup-plugin-sourcemaps'; 6 | import { readFileSync } from 'node:fs'; 7 | 8 | const pkg = JSON.parse(readFileSync('./package.json')); 9 | 10 | const extensions = ['.js', '.mjs']; 11 | 12 | const pkgname = pkg.name.replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3'); 13 | const clsname = pkgname.replace(/-\w/g, (m) => m[1].toUpperCase()); 14 | 15 | const external = [ 16 | ...Object.keys(pkg.dependencies || {}), 17 | ...Object.keys(pkg.peerDependencies || {}), 18 | ]; 19 | 20 | const appendMin = (file) => file.replace(/(\.[\w\d_-]+)$/i, '.min$1'); 21 | const replaceFilename = (file, name) => 22 | file.replace(/(.*)\/.*(\.[\w\d_-]+)/i, `$1/${name}$2`); 23 | 24 | const pkgMain = replaceFilename(pkg.main, pkgname); 25 | const pkgModule = replaceFilename(pkg.module, pkgname); 26 | 27 | const input = 'src/index.js'; 28 | 29 | const files = { 30 | npm: { 31 | esm: { file: pkg.module, format: 'esm' }, 32 | umd: { file: pkg.main, format: 'umd', name: clsname }, 33 | esmMin: { 34 | file: appendMin(pkg.module), 35 | format: 'esm', 36 | sourcemap: true, 37 | }, 38 | umdMin: { 39 | file: appendMin(pkg.main), 40 | format: 'umd', 41 | sourcemap: true, 42 | name: clsname, 43 | }, 44 | }, 45 | cdn: { 46 | esm: { file: pkgModule, format: 'esm' }, 47 | iife: { file: pkgMain, format: 'iife', name: clsname }, 48 | esmMin: { 49 | file: appendMin(pkgModule), 50 | format: 'esm', 51 | sourcemap: true, 52 | }, 53 | iifeMin: { 54 | file: appendMin(pkgMain), 55 | format: 'iife', 56 | sourcemap: true, 57 | name: clsname, 58 | }, 59 | }, 60 | }; 61 | 62 | const terserConfig = { 63 | format: { 64 | comments: false, 65 | }, 66 | }; 67 | 68 | const cdnPlugins = [ 69 | resolve({ browser: true, extensions }), 70 | commonjs({ include: 'node_modules/**', extensions }), 71 | ]; 72 | 73 | export default [ 74 | { 75 | // esm && umd bundles for npm/yarn 76 | input, 77 | output: [files.npm.esm, files.npm.umd], 78 | external, 79 | }, 80 | { 81 | // esm && umd bundles for npm/yarn (min) 82 | input, 83 | output: [files.npm.esmMin, files.npm.umdMin], 84 | external, 85 | plugins: [sourceMaps(), terser(terserConfig)], 86 | }, 87 | { 88 | // esm bundle for cdn/unpkg 89 | input, 90 | output: files.cdn.esm, 91 | plugins: cdnPlugins, 92 | }, 93 | { 94 | // iife bundle for cdn/unpkg 95 | input, 96 | output: files.cdn.iife, 97 | plugins: [...cdnPlugins, babel({ babelHelpers: 'bundled' })], 98 | }, 99 | { 100 | // esm bundle from cdn/unpkg (min) 101 | input, 102 | output: files.cdn.esmMin, 103 | plugins: [...cdnPlugins, sourceMaps(), terser(terserConfig)], 104 | }, 105 | { 106 | // iife bundle for cdn/unpkg (min) 107 | input, 108 | output: files.cdn.iifeMin, 109 | plugins: [ 110 | ...cdnPlugins, 111 | sourceMaps(), 112 | babel({ babelHelpers: 'bundled' }), 113 | terser(terserConfig), 114 | ], 115 | }, 116 | ]; 117 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Writable, Readable, Updater, Unsubscriber } from 'svelte/store'; 2 | 3 | export type ParsableStoreNames = 'path' | 'query' | 'fragment'; 4 | 5 | export type Hook = ( 6 | value: T | null, 7 | currValue: T, 8 | storeName: ParsableStoreNames 9 | ) => boolean | undefined; 10 | 11 | export interface Parsable extends Readable { 12 | set(this: void, value: T | string): void; 13 | update(this: void, updater: Updater): void; 14 | hook(fn: Hook): Unsubscriber; 15 | } 16 | 17 | export interface SubmitEvent extends Event { 18 | submitter: HTMLElement | null; 19 | } 20 | 21 | export interface ParseParamsOptions { 22 | loose?: boolean; 23 | sensitive?: boolean; 24 | blank?: boolean; 25 | decode?: typeof decodeURIComponent; 26 | } 27 | 28 | export interface Prefs { 29 | array: { 30 | separator: string; 31 | format: 'bracket' | 'separator'; 32 | }; 33 | convertTypes: boolean; 34 | breakHooks: boolean; 35 | hashbang: boolean; 36 | anchor: boolean | ScrollIntoViewOptions; 37 | scroll: boolean | ScrollIntoViewOptions; 38 | focus: boolean; 39 | nesting: number; 40 | sideEffect: boolean; 41 | base: string; 42 | } 43 | 44 | export type Param = string | boolean | number | {} | [] | null | undefined; 45 | 46 | export type Params = Record; 47 | export type State = Record; 48 | 49 | export declare const prefs: Prefs; 50 | export declare const path: Parsable; 51 | export declare const query: Parsable; 52 | export declare const fragment: Parsable; 53 | export declare const state: Writable; 54 | export declare const url: Readable; 55 | export declare const pattern: Readable< 56 | ( 57 | pattern?: string | RegExp, 58 | options?: ParseParamsOptions 59 | ) => T | null 60 | >; 61 | 62 | export declare function goto(url?: string | URL, data?: State): void; 63 | export declare function redirect(url?: string | URL, data?: State): void; 64 | export declare function back(url?: string | URL): void; 65 | export declare function click(e: MouseEvent): void; 66 | export declare function submit(e: SubmitEvent): void; 67 | 68 | export declare function paramable( 69 | pattern?: string, 70 | options?: ParseParamsOptions 71 | ): Writable; 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { tick } from 'svelte'; 2 | import { derived, writable } from 'svelte/store'; 3 | 4 | import { 5 | prependPrefix, 6 | specialLinks, 7 | hasPushState, 8 | useHashbang, 9 | parseParams, 10 | getLocation, 11 | isSubWindow, 12 | listenEvent, 13 | hasProcess, 14 | sideEffect, 15 | getShortURL, 16 | getFullURL, 17 | setScroll, 18 | setFocus, 19 | getPath, 20 | closest, 21 | prefs, 22 | isBtn, 23 | isObj, 24 | } from './utils'; 25 | 26 | import { pathable, queryable, fragmentable, createParamStore } from './stores'; 27 | 28 | const pathname = getPath(); 29 | const { search, hash } = getLocation(); 30 | 31 | let init = true; 32 | let popstate = false; 33 | let replace = false; 34 | let len = 0; 35 | 36 | const path = pathable(pathname, before); 37 | 38 | const query = queryable(search, before); 39 | 40 | const fragment = fragmentable(hash, before); 41 | 42 | const state = writable({}); 43 | 44 | const url = derived( 45 | [path, query, fragment], 46 | ([$path, $query, $fragment], set) => { 47 | let skip = false; 48 | tick().then(() => { 49 | if (skip) return; 50 | set($path + $query + $fragment); 51 | }); 52 | 53 | return () => (skip = true); 54 | }, 55 | pathname + search + hash 56 | ); 57 | 58 | const pattern = derived(path, ($path) => parseParams.bind(null, $path.toString())); 59 | 60 | function before() { 61 | if (!prefs.scroll && !prefs.focus) return; 62 | state.update(($state = {}) => { 63 | prefs.scroll && 64 | ($state._scroll = { 65 | top: window.pageYOffset, 66 | left: window.pageXOffset, 67 | }); 68 | prefs.focus && ($state._focus = document.activeElement.id); 69 | return $state; 70 | }); 71 | } 72 | 73 | function after(url, state) { 74 | const anchor = url.indexOf('#') >= 0 ? url.slice(url.indexOf('#')) : ''; 75 | const activeElement = document.activeElement; 76 | !isObj(state) && (state = {}); 77 | tick() 78 | .then(() => setFocus(state._focus, activeElement)) 79 | .then(() => setScroll(state._scroll, anchor)); 80 | } 81 | 82 | if (sideEffect || isSubWindow) { 83 | const cleanup = new Set(); 84 | 85 | cleanup.add( 86 | url.subscribe(($url) => { 87 | if (!init && !popstate && prefs.sideEffect) { 88 | if (hasPushState) { 89 | history[replace ? 'replaceState' : 'pushState']({}, null, getFullURL($url)); 90 | } else { 91 | location.hash = getFullURL($url); 92 | } 93 | } 94 | !popstate && after($url); 95 | !replace && len++; 96 | init = replace = popstate = false; 97 | }) 98 | ); 99 | 100 | if (hasPushState) { 101 | cleanup.add( 102 | state.subscribe(($state) => { 103 | if (init || !prefs.sideEffect) return; 104 | history.replaceState( 105 | $state, 106 | null, 107 | location.pathname + location.search + location.hash 108 | ); 109 | }) 110 | ); 111 | cleanup.add( 112 | listenEvent('popstate', (e) => { 113 | popstate = true; 114 | goto(location.href, e.state); 115 | after(getShortURL(location.href), e.state); 116 | }) 117 | ); 118 | } else { 119 | cleanup.add( 120 | listenEvent('hashchange', () => { 121 | popstate = true; 122 | if (!prefs.hashbang && !useHashbang) return fragment.set(location.hash); 123 | goto(location.hash); 124 | after(getShortURL(location.hash)); 125 | }) 126 | ); 127 | } 128 | cleanup.add( 129 | listenEvent( 130 | 'beforeunload', 131 | () => { 132 | cleanup.forEach((off) => off()); 133 | cleanup.clear(); 134 | }, 135 | true 136 | ) 137 | ); 138 | } 139 | 140 | function goto(url = '', data = {}) { 141 | const { pathname, search, hash } = 142 | url instanceof URL ? url : new URL(getShortURL(url), 'file:'); 143 | 144 | path.set(pathname); 145 | query.set(search); 146 | fragment.set(hash); 147 | 148 | tick().then(() => state.set(data || {})); 149 | } 150 | 151 | function back(url) { 152 | if (len > 0 && sideEffect && prefs.sideEffect) { 153 | history.back(); 154 | len--; 155 | } else { 156 | tick().then(() => goto(url)); 157 | } 158 | } 159 | 160 | function redirect(url, data) { 161 | tick().then(() => { 162 | replace = true; 163 | goto(url, data); 164 | }); 165 | } 166 | 167 | function click(e) { 168 | if ( 169 | !e.target || 170 | e.ctrlKey || 171 | e.metaKey || 172 | e.altKey || 173 | e.shiftKey || 174 | e.button || 175 | e.which !== 1 || 176 | e.defaultPrevented 177 | ) 178 | return; 179 | 180 | const a = closest(e.target, 'a'); 181 | 182 | if ( 183 | !a || 184 | a.target || 185 | a.hasAttribute('download') || 186 | (a.hasAttribute('rel') && a.getAttribute('rel').includes('external')) 187 | ) 188 | return; 189 | 190 | const href = a.getAttribute('href'); 191 | const url = a.href; 192 | if ( 193 | !href || 194 | url.indexOf(location.origin) !== 0 || 195 | specialLinks.test(href) || 196 | (!prefs.hashbang && !useHashbang && href.startsWith('#')) 197 | ) 198 | return; 199 | 200 | e.preventDefault(); 201 | goto(url, Object.assign({}, a.dataset)); 202 | } 203 | 204 | function submit(e) { 205 | if (!e.target || e.defaultPrevented) return; 206 | 207 | const form = e.target; 208 | const btn = e.submitter || (isBtn(document.activeElement) && document.activeElement); 209 | 210 | let action = form.action; 211 | let method = form.method; 212 | let target = form.target; 213 | 214 | if (btn) { 215 | btn.hasAttribute('formaction') && (action = btn.formAction); 216 | btn.hasAttribute('formmethod') && (method = btn.formMethod); 217 | btn.hasAttribute('formtarget') && (target = btn.formTarget); 218 | } 219 | 220 | if (method && method.toLowerCase() !== 'get') return; 221 | if (target && target.toLowerCase() !== '_self') return; 222 | 223 | const { pathname, hash } = new URL(action); 224 | const search = []; 225 | const state = {}; 226 | 227 | const elements = form.elements; 228 | const len = elements.length; 229 | 230 | for (let i = 0; i < len; i++) { 231 | const element = elements[i]; 232 | if (!element.name || element.disabled) continue; 233 | if (['checkbox', 'radio'].includes(element.type) && !element.checked) { 234 | continue; 235 | } 236 | if (isBtn(element) && element !== btn) { 237 | continue; 238 | } 239 | if (element.type === 'hidden') { 240 | state[element.name] = element.value; 241 | continue; 242 | } 243 | search.push(`${element.name}=${element.value}`); 244 | } 245 | 246 | let url = prependPrefix(`${pathname}?${search.join('&')}${hash}`); 247 | 248 | if (hasProcess && url.match(/^\/[a-zA-Z]:\//)) { 249 | url = url.replace(/^\/[a-zA-Z]:\//, '/'); 250 | } 251 | 252 | e.preventDefault(); 253 | goto(url, state); 254 | } 255 | 256 | export const paramable = createParamStore(path); 257 | 258 | export { redirect, fragment, pattern, submit, click, prefs, state, query, path, back, goto, url }; 259 | -------------------------------------------------------------------------------- /src/stores.js: -------------------------------------------------------------------------------- 1 | import { writable, get } from 'svelte/store'; 2 | 3 | import { 4 | stringifyQuery, 5 | normalizeHash, 6 | prependPrefix, 7 | hookLauncher, 8 | injectParams, 9 | parseParams, 10 | parseQuery, 11 | shallowCopy, 12 | trimPrefix, 13 | isFn, 14 | } from './utils'; 15 | 16 | export const pathable = createParsableStore(function path($path = '') { 17 | if (typeof $path === 'string') $path = trimPrefix($path, '/').split('/'); 18 | return !Object.prototype.hasOwnProperty.call($path, 'toString') 19 | ? Object.defineProperty($path, 'toString', { 20 | value() { 21 | return prependPrefix(this.join('/')); 22 | }, 23 | configurable: false, 24 | writable: false, 25 | }) 26 | : $path; 27 | }); 28 | 29 | export const queryable = createParsableStore(function query($query = '') { 30 | if (typeof $query === 'string') $query = parseQuery($query); 31 | return !Object.prototype.hasOwnProperty.call($query, 'toString') 32 | ? Object.defineProperty($query, 'toString', { 33 | value() { 34 | return prependPrefix(stringifyQuery(this), '?', true); 35 | }, 36 | configurable: false, 37 | writable: false, 38 | }) 39 | : $query; 40 | }); 41 | 42 | export const fragmentable = createParsableStore(function fragment($fragment = '') { 43 | return prependPrefix(normalizeHash($fragment), '#', true); 44 | }); 45 | 46 | export function createParamStore(path) { 47 | return (pattern, options = {}) => { 48 | if (pattern instanceof RegExp) 49 | throw new Error('Paramable does not support RegExp patterns.'); 50 | 51 | let params; 52 | 53 | pattern = pattern.replace(/\/$/, ''); 54 | 55 | const { subscribe } = writable({}, (set) => { 56 | return path.subscribe(($path) => { 57 | params = parseParams($path.toString(), pattern, { blank: true, ...options }); 58 | set(shallowCopy(params)); 59 | }); 60 | }); 61 | 62 | function set(value = {}) { 63 | if (Object.entries(params).some(([key, val]) => val !== value[key])) { 64 | path.update(($path) => { 65 | const tail = options.loose 66 | ? prependPrefix($path.slice(pattern.split('/').length - 1).join('/')) 67 | : ''; 68 | return injectParams(pattern + tail, value); 69 | }); 70 | } 71 | } 72 | 73 | return { 74 | get() { 75 | return get(this); 76 | }, 77 | update(fn) { 78 | set(fn(this.get())); 79 | }, 80 | subscribe, 81 | set, 82 | }; 83 | }; 84 | } 85 | 86 | function createParsableStore(parse) { 87 | return (value, cbx) => { 88 | let serialized = value && value.toString(); 89 | 90 | !Array.isArray(cbx) && (cbx = [cbx]); 91 | 92 | const hooks = new Set(cbx); 93 | const runHooks = hookLauncher(hooks); 94 | 95 | const { subscribe, set } = writable((value = parse(value)), () => () => hooks.clear()); 96 | 97 | function update(val) { 98 | val = parse(val); 99 | if (val.toString() !== serialized && runHooks(val, value, parse.name) !== false) { 100 | serialized = val.toString(); 101 | value = val; 102 | set(value); 103 | } 104 | } 105 | 106 | runHooks(null, value, parse.name); 107 | 108 | return { 109 | subscribe, 110 | update(fn) { 111 | update(fn(get(this))); 112 | }, 113 | set(value) { 114 | update(value); 115 | }, 116 | hook(cb) { 117 | if (isFn(cb)) { 118 | hooks.add(cb); 119 | cb(null, value, parse.name); 120 | } 121 | return () => hooks.delete(cb); 122 | }, 123 | }; 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const specialLinks = 2 | /^((mailto:)|(tel:)|(sms:)|(data:)|(blob:)|(javascript:)|(ftp(s?):\/\/)|(file:\/\/))/; 3 | export const hasLocation = typeof location !== 'undefined'; 4 | export const hasProcess = typeof process !== 'undefined'; 5 | export const hasHistory = typeof history !== 'undefined'; 6 | export const hasPushState = hasHistory && isFn(history.pushState); 7 | export const hasWindow = typeof window !== 'undefined'; 8 | export const isSubWindow = hasWindow && window !== window.parent; 9 | export const isFileScheme = 10 | hasLocation && (location.protocol === 'file:' || /[-_\w]+[.][\w]+$/i.test(location.pathname)); 11 | export const sideEffect = hasWindow && hasHistory && hasLocation && !isSubWindow; 12 | 13 | export const useHashbang = !hasPushState || isFileScheme; 14 | 15 | const hashbang = '#!'; 16 | 17 | export const prefs = { 18 | array: { 19 | separator: ',', 20 | format: 'bracket', 21 | }, 22 | convertTypes: true, 23 | breakHooks: true, 24 | hashbang: false, 25 | anchor: false, 26 | scroll: false, 27 | focus: false, 28 | nesting: 3, 29 | sideEffect, 30 | base: '', 31 | }; 32 | 33 | export function getPath() { 34 | const pathname = getLocation().pathname; 35 | if (!pathname) return; 36 | 37 | const base = getBase(); 38 | const path = trimPrefix(pathname, base); 39 | 40 | return prependPrefix(path); 41 | } 42 | 43 | export function getLocation() { 44 | if (!hasLocation) return {}; 45 | 46 | if (prefs.hashbang || useHashbang) { 47 | const hash = location.hash; 48 | return new URL( 49 | hash.indexOf(hashbang) === 0 ? hash.substring(2) : hash.substring(1), 50 | 'file:' 51 | ); 52 | } 53 | 54 | return location; 55 | } 56 | 57 | export function getBase() { 58 | if (prefs.base) return prefs.base; 59 | if (hasLocation && (prefs.hashbang || useHashbang)) return location.pathname; 60 | return '/'; 61 | } 62 | 63 | export function getFullURL(url) { 64 | (prefs.hashbang || useHashbang) && (url = hashbang + url); 65 | const base = getBase(); 66 | return (base[base.length - 1] === '/' ? base.substring(0, base.length - 1) : base) + url; 67 | } 68 | 69 | export function getShortURL(url) { 70 | url = trimPrefix(url, getLocation().origin); 71 | 72 | const base = getBase(); 73 | url = trimPrefix(url, base); 74 | 75 | (prefs.hashbang || useHashbang) && (url = trimPrefix(url, hashbang)); 76 | return prependPrefix(url); 77 | } 78 | 79 | export function isBtn(el) { 80 | const tagName = el.tagName.toLowerCase(); 81 | const type = el.type && el.type.toLowerCase(); 82 | return ( 83 | tagName === 'button' || 84 | (tagName === 'input' && ['button', 'submit', 'image'].includes(type)) 85 | ); 86 | } 87 | 88 | export function closest(el, tagName) { 89 | while (el && el.nodeName.toLowerCase() !== tagName) el = el.parentNode; 90 | return !el || el.nodeName.toLowerCase() !== tagName ? null : el; 91 | } 92 | 93 | export function setScroll(scroll, hash = '') { 94 | const anchor = trimPrefix(normalizeHash(hash), '#'); 95 | if (scroll && prefs.scroll) { 96 | const opts = isObj(prefs.scroll) ? { ...prefs.scroll, ...scroll } : scroll; 97 | const { top = 0, left = 0 } = scroll; 98 | const { scrollHeight, scrollWidth } = document.documentElement; 99 | 100 | if (top <= scrollHeight && left <= scrollWidth) return scrollTo(opts); 101 | 102 | const cancel = observeResize((entries) => { 103 | if (!entries[0]) return cancel(); 104 | if ( 105 | (!top || entries[0].contentRect.height >= top) && 106 | (!left || entries[0].contentRect.width >= left) 107 | ) { 108 | cancel(); 109 | scrollTo(opts); 110 | } 111 | }, document.documentElement); 112 | } else if (anchor && prefs.anchor) { 113 | const opts = isObj(prefs.anchor) ? prefs.anchor : {}; 114 | const el = document.getElementById(anchor); 115 | 116 | if (el) return scrollTo(opts, el); 117 | 118 | const cancel = observeDom(() => { 119 | const el = document.getElementById(anchor); 120 | if (el) { 121 | cancel(); 122 | scrollTo(opts, el); 123 | } 124 | }); 125 | } else if (prefs.scroll) { 126 | scrollTo(); 127 | } 128 | } 129 | 130 | export function setFocus(keepFocusId, activeElement) { 131 | if (!prefs.focus) return; 132 | 133 | setTimeout(() => { 134 | const autofocus = focus(); 135 | if (autofocus) return autofocus.focus(); 136 | const cancel = observeDom(() => { 137 | const autofocus = focus(); 138 | if (autofocus) { 139 | cancel(); 140 | autofocus.focus(); 141 | } 142 | }); 143 | 144 | const body = document.body; 145 | const tabindex = body.getAttribute('tabindex'); 146 | 147 | body.tabIndex = -1; 148 | body.focus({ preventScroll: true }); 149 | 150 | if (tabindex !== null) { 151 | body.setAttribute('tabindex', tabindex); 152 | } else { 153 | body.removeAttribute('tabindex'); 154 | } 155 | 156 | getSelection().removeAllRanges(); 157 | }); 158 | 159 | function focus() { 160 | if (keepFocusId) { 161 | return document.getElementById(keepFocusId); 162 | } else if ( 163 | document.activeElement !== activeElement && 164 | document.activeElement !== document.body 165 | ) { 166 | return document.activeElement; 167 | } else { 168 | return document.querySelector('[autofocus]'); 169 | } 170 | } 171 | } 172 | 173 | export function parseQuery(str = '', { decode = decodeURIComponent } = {}) { 174 | return str 175 | ? str 176 | .replace('?', '') 177 | .replace(/\+/g, ' ') 178 | .split('&') 179 | .filter(Boolean) 180 | .reduce((obj, p) => { 181 | let [key, val] = p.split(/=(.*)/, 2); 182 | key = decode(key || ''); 183 | val = decode(val || ''); 184 | 185 | let o = parseKeys(key, val); 186 | obj = Object.keys(o).reduce((obj, key) => { 187 | const val = prefs.convertTypes ? convertType(o[key]) : o[key]; 188 | if (obj[key]) { 189 | Array.isArray(obj[key]) 190 | ? (obj[key] = obj[key].concat(val)) 191 | : Object.assign(obj[key], val); 192 | } else { 193 | obj[key] = val; 194 | } 195 | return obj; 196 | }, obj); 197 | 198 | return obj; 199 | }, {}) 200 | : {}; 201 | } 202 | 203 | export function stringifyQuery(obj = {}, { encode = encodeURIComponent } = {}) { 204 | return Object.keys(obj) 205 | .reduce((a, k) => { 206 | if (Object.prototype.hasOwnProperty.call(obj, k) && isNaN(parseInt(k, 10))) { 207 | if (Array.isArray(obj[k])) { 208 | if (prefs.array.format === 'separator') { 209 | a.push(`${k}=${obj[k].join(prefs.array.separator)}`); 210 | } else { 211 | obj[k].forEach((v) => a.push(`${k}[]=${encode(v)}`)); 212 | } 213 | } else if (isObj(obj[k])) { 214 | let o = parseKeys(k, obj[k]); 215 | a.push(stringifyObj(o)); 216 | } else { 217 | a.push(`${k}=${encode(obj[k])}`); 218 | } 219 | } 220 | return a; 221 | }, []) 222 | .join('&'); 223 | } 224 | 225 | export function injectParams(pattern, params, { encode = encodeURIComponent } = {}) { 226 | return pattern.replace(/(\/|^)([:*][^/]*?)(\?)?(?=[/.]|$)/g, (param, _, key) => { 227 | param = params[key === '*' ? 'wild' : key.substring(1)]; 228 | return param ? `/${encode(param)}` : ''; 229 | }); 230 | } 231 | 232 | export function parseParams( 233 | path = '', 234 | pattern = '*', 235 | { loose = false, sensitive = false, blank = false, decode = decodeURIComponent } = {} 236 | ) { 237 | const blanks = {}; 238 | const rgx = 239 | pattern instanceof RegExp 240 | ? pattern 241 | : pattern.split('/').reduce((rgx, seg, i, { length }) => { 242 | if (seg) { 243 | const pfx = seg[0]; 244 | if (pfx === '*') { 245 | blanks['wild'] = undefined; 246 | rgx += '/(?.*)'; 247 | } else if (pfx === ':') { 248 | const opt = seg.indexOf('?', 1); 249 | const ext = seg.indexOf('.', 1); 250 | const isOpt = !!~opt; 251 | const isExt = !!~ext; 252 | 253 | const key = seg.substring(1, isOpt ? opt : isExt ? ext : seg.length); 254 | blanks[key] = undefined; 255 | 256 | rgx += 257 | isOpt && !isExt ? `(?:/(?<${key}>[^/]+?))?` : `/(?<${key}>[^/]+?)`; 258 | if (isExt) rgx += `${isOpt ? '?' : ''}\\${seg.substring(ext)}`; 259 | } else { 260 | rgx += `/${seg}`; 261 | } 262 | } 263 | 264 | if (i === length - 1) { 265 | rgx += loose ? '(?:$|/)' : '/?$'; 266 | } 267 | 268 | return rgx; 269 | }, '^'); 270 | 271 | const flags = sensitive ? '' : 'i'; 272 | const matches = new RegExp(rgx, flags).exec(path); 273 | 274 | return matches 275 | ? Object.entries(matches.groups || {}).reduce((params, [key, val]) => { 276 | const value = decode(val); 277 | params[key] = prefs.convertTypes ? convertType(value) : value; 278 | return params; 279 | }, {}) 280 | : blank 281 | ? blanks 282 | : null; 283 | } 284 | 285 | export function normalizeHash(fragment, { decode = decodeURIComponent } = {}) { 286 | return decode(fragment); 287 | } 288 | 289 | export function prependPrefix(str, pfx = '/', strict = false) { 290 | str += ''; 291 | return !str && strict ? str : str.indexOf(pfx) !== 0 ? pfx + str : str; 292 | } 293 | 294 | export function trimPrefix(str, pfx) { 295 | return (str + '').indexOf(pfx) === 0 ? str.substring(pfx.length) : str; 296 | } 297 | 298 | export function isObj(obj) { 299 | return !Array.isArray(obj) && typeof obj === 'object' && obj !== null; 300 | } 301 | 302 | export function isFn(fn) { 303 | return typeof fn === 'function'; 304 | } 305 | 306 | export function shallowCopy(value) { 307 | if (typeof value !== 'object' || value === null) return value; 308 | return Object.create(Object.getPrototypeOf(value), Object.getOwnPropertyDescriptors(value)); 309 | } 310 | 311 | export function hookLauncher(hooks) { 312 | return (...args) => { 313 | const arr = [...hooks]; 314 | return !(prefs.breakHooks 315 | ? arr.some((cb) => cb(...args) === false) 316 | : arr.reduce((stop, cb) => cb(...args) === false || stop, false)); 317 | }; 318 | } 319 | 320 | export function listenEvent(...args) { 321 | window.addEventListener(...args); 322 | return () => window.removeEventListener(...args); 323 | } 324 | 325 | function scrollTo({ top = 0, left = 0, ...opts } = {}, el) { 326 | if (el) { 327 | document.documentElement.scrollIntoView 328 | ? el.scrollIntoView({ behavior: 'smooth', ...opts }) 329 | : window.scrollTo({ top: el.offsetTop - top, behavior: 'smooth', ...opts }); 330 | } else { 331 | window.scrollTo({ top, left, behavior: 'smooth', ...opts }); 332 | } 333 | } 334 | 335 | function observeResize(cb, el, t = 5000) { 336 | const observer = new ResizeObserver(cb); 337 | observer.observe(el); 338 | const off = () => observer.unobserve(el); 339 | setTimeout(off, t); 340 | return off; 341 | } 342 | 343 | function observeDom(cb, t = 5000) { 344 | const observer = new MutationObserver(cb); 345 | observer.observe(document.body, { 346 | childList: true, 347 | subtree: true, 348 | }); 349 | const off = () => observer.disconnect(); 350 | setTimeout(off, t); 351 | return off; 352 | } 353 | 354 | function convertType(val) { 355 | if (Array.isArray(val)) { 356 | val[val.length - 1] = convertType(val[val.length - 1]); 357 | return val; 358 | } else if (typeof val === 'object') { 359 | return Object.entries(val).reduce((obj, [k, v]) => { 360 | obj[k] = convertType(v); 361 | return obj; 362 | }, {}); 363 | } 364 | 365 | if (val === 'true' || val === 'false') { 366 | return val === 'true'; 367 | } else if (val === 'null') { 368 | return null; 369 | } else if (val === 'undefined') { 370 | return undefined; 371 | } else if (val !== '' && !isNaN(Number(val)) && Number(val).toString() === val) { 372 | return Number(val); 373 | } else if (prefs.array.format === 'separator' && typeof val === 'string') { 374 | const arr = val.split(prefs.array.separator); 375 | return arr.length > 1 ? arr : val; 376 | } 377 | return val; 378 | } 379 | 380 | function parseKeys(key, val) { 381 | const brackets = /(\[[^[\]]*])/, 382 | child = /(\[[^[\]]*])/g; 383 | 384 | let seg = brackets.exec(key), 385 | parent = seg ? key.slice(0, seg.index) : key, 386 | keys = []; 387 | 388 | parent && keys.push(parent); 389 | 390 | let i = 0; 391 | while ((seg = child.exec(key)) && i < prefs.nesting) { 392 | i++; 393 | keys.push(seg[1]); 394 | } 395 | 396 | seg && keys.push(`[${key.slice(seg.index)}]`); 397 | 398 | return parseObj(keys, val); 399 | } 400 | 401 | function parseObj(chain, val) { 402 | let leaf = val; 403 | 404 | for (let i = chain.length - 1; i >= 0; --i) { 405 | let root = chain[i], 406 | obj; 407 | 408 | if (root === '[]') { 409 | obj = [].concat(leaf); 410 | } else { 411 | obj = {}; 412 | const key = 413 | root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' 414 | ? root.slice(1, -1) 415 | : root, 416 | j = parseInt(key, 10); 417 | if (!isNaN(j) && root !== key && String(j) === key && j >= 0) { 418 | obj = []; 419 | obj[j] = prefs.convertTypes ? convertType(leaf) : leaf; 420 | } else { 421 | obj[key] = leaf; 422 | } 423 | } 424 | leaf = obj; 425 | } 426 | 427 | return leaf; 428 | } 429 | 430 | function stringifyObj(obj = {}, nesting = '') { 431 | return Object.entries(obj) 432 | .map(([key, val]) => { 433 | if (typeof val === 'object') { 434 | return stringifyObj(val, nesting ? `${nesting}[${key}]` : key); 435 | } else { 436 | return `${nesting}[${key}]=${val}`; 437 | } 438 | }) 439 | .join('&'); 440 | } 441 | -------------------------------------------------------------------------------- /ssr.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package.json'); 2 | 3 | module.exports = function () { 4 | try { 5 | const mod = require.resolve(`../${pkg.name}`); 6 | 7 | delete require.cache[mod]; 8 | Object.keys(module.constructor._pathCache).forEach((cacheKey) => { 9 | if (cacheKey.indexOf(mod) > -1) { 10 | delete module.constructor._pathCache[cacheKey]; 11 | } 12 | }); 13 | 14 | return require(mod); 15 | } catch (err) { 16 | throw new Error("Decaching wasn't performed."); 17 | } 18 | } 19 | --------------------------------------------------------------------------------