├── test ├── index.js └── index.html ├── cjs ├── package.json └── index.js ├── .gitignore ├── .npmignore ├── rollup ├── new.config.js └── babel.config.js ├── LICENSE ├── package.json ├── README.md ├── esm └── index.js ├── new.js ├── min.js └── index.js /test/index.js: -------------------------------------------------------------------------------- 1 | require('../cjs'); -------------------------------------------------------------------------------- /cjs/package.json: -------------------------------------------------------------------------------- 1 | {"type":"commonjs"} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | rollup/ 4 | test/ 5 | package-lock.json 6 | .travis.yml 7 | -------------------------------------------------------------------------------- /rollup/new.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import {terser} from 'rollup-plugin-terser'; 4 | 5 | export default { 6 | input: './esm/index.js', 7 | plugins: [ 8 | resolve(), 9 | commonjs(), 10 | terser() 11 | ], 12 | output: { 13 | exports: 'named', 14 | file: './new.js', 15 | format: 'iife', 16 | name: 'ARoute' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /rollup/babel.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | 5 | export default { 6 | input: './esm/index.js', 7 | plugins: [ 8 | resolve(), 9 | commonjs(), 10 | babel({presets: ['@babel/preset-env']}) 11 | ], 12 | output: { 13 | exports: 'named', 14 | file: './index.js', 15 | format: 'iife', 16 | name: 'ARoute' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2019, Andrea Giammarchi, @WebReflection 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 14 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a-route", 3 | "version": "1.1.1", 4 | "description": "Express like routing as Custom Element or standalone", 5 | "main": "cjs/index.js", 6 | "scripts": { 7 | "build": "npm run cjs && npm run rollup:new && npm run rollup:babel && npm run min && npm run size", 8 | "cjs": "ascjs esm cjs", 9 | "rollup:new": "rollup --config rollup/new.config.js", 10 | "rollup:babel": "rollup --config rollup/babel.config.js && drop-babel-typeof index.js", 11 | "min": "terser index.js --comments=/^!/ -c -m -o min.js", 12 | "size": "cat index.js | wc -c;cat min.js | wc -c;gzip -c9 min.js | wc -c;gzip -c9 new.js | wc -c" 13 | }, 14 | "keywords": [ 15 | "routing", 16 | "route", 17 | "client" 18 | ], 19 | "author": "Andrea Giammarchi", 20 | "license": "ISC", 21 | "devDependencies": { 22 | "@babel/core": "^7.11.6", 23 | "@babel/preset-env": "^7.11.5", 24 | "ascjs": "^4.0.1", 25 | "drop-babel-typeof": "^1.0.3", 26 | "rollup": "^2.27.0", 27 | "rollup-plugin-babel": "^4.4.0", 28 | "rollup-plugin-commonjs": "^10.1.0", 29 | "rollup-plugin-node-resolve": "^5.2.0", 30 | "rollup-plugin-terser": "^7.0.2", 31 | "terser": "^5.3.1" 32 | }, 33 | "module": "esm/index.js", 34 | "unpkg": "min.js", 35 | "dependencies": { 36 | "path-to-regexp": "^3.1.0" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/WebReflection/a-route.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/WebReflection/a-route/issues" 44 | }, 45 | "homepage": "https://github.com/WebReflection/a-route#readme" 46 | } 47 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 14 | 15 | 39 | 40 | 41 | test query 42 | test OK 43 | test 404 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # a-route 2 | 3 | **Social Media Photo by [Jakub Gorajek](https://unsplash.com/@cinegeek) on [Unsplash](https://unsplash.com/)** 4 | 5 | Express like routing, as Custom Element or standalone, inspired by [page.js](https://visionmedia.github.io/page.js/). 6 | 7 | 8 | ### app API 9 | 10 | * `app.get(path:string|RegExp, cb:Function[, cb2, ...]):app` to subscribe one or more callbacks for the specified route 11 | * `app.delete(path:string|RegExp, cb:Function[, cb2, ...]):app` to unsubscribe one or more callbacks for the specified route 12 | * `app.navigate(path:string[, operation:string = 'push']):void` to navigate to the first matching route for the given path. By default, it pushes to the history but it could `replace`, if the second parameter is the _replace_ string, or `ignore`. 13 | * `app.param(path:string|RegExp):app` to subscribe to a specific parameter regardless of the route 14 | * `app.use(path:string|RegExp):app` to subscribe a callback for a specific mount point or all of them 15 | 16 | 17 | ### Example 18 | 19 | The following is a basic example, also [available live](https://webreflection.github.io/a-route/test/?). 20 | 21 | ```html 22 | 23 | 24 | 25 | test query 26 | 27 | 29 | test OK 30 | 31 | 32 | test 404 33 | ``` 34 | 35 | ```js 36 | // import {app} from 'a-route'; 37 | // const {app} = require('a-route'); 38 | const {app} = ARoute; 39 | 40 | // define routes 41 | app 42 | .get('/test/?query=:query', function (ctx) { 43 | console.log(ctx); 44 | /* 45 | { 46 | "path": "/test/?query=value", 47 | "params": { 48 | "query": "value" 49 | } 50 | } 51 | */ 52 | }) 53 | .get('/test/:status', function (ctx) { 54 | console.log(ctx); 55 | /* 56 | { 57 | "path": "/test/OK", 58 | "params": { 59 | "status": "OK" 60 | } 61 | } 62 | */ 63 | }); 64 | 65 | // intercept all unregistered calls 66 | app.get('*', 67 | function (ctx, next) { 68 | console.log(ctx); 69 | /* 70 | { 71 | "path": "/whatever" 72 | } 73 | */ 74 | next(); 75 | }, 76 | // will receive the ctx object too 77 | console.error 78 | ); 79 | ``` 80 | -------------------------------------------------------------------------------- /esm/index.js: -------------------------------------------------------------------------------- 1 | import pathToRegexp from 'path-to-regexp'; 2 | 3 | const {create, freeze, keys} = Object; 4 | 5 | const Class = customElements.get('a-route'); 6 | 7 | const app = Class ? Class.app : freeze({ 8 | 9 | _: freeze({ 10 | params: create(null), 11 | paths: create(null) 12 | }), 13 | 14 | get(path) { 15 | for (let 16 | {paths} = app._, 17 | keys = [], 18 | all = path === '*', 19 | re = all ? '*' : asPath2RegExp(path, keys), 20 | info = paths[re] || (paths[re] = { 21 | keys, 22 | re, 23 | cb: [] 24 | }), 25 | i = 1, {length} = arguments; 26 | i < length; i++ 27 | ) { 28 | info.cb.push(arguments[i]); 29 | } 30 | return app; 31 | }, 32 | 33 | delete(path) { 34 | for (let 35 | all = path === '*', 36 | re = all ? '*' : asPath2RegExp(path, []), 37 | info = app._.paths[re], 38 | i = 1, {length} = arguments; 39 | i < length; i++ 40 | ) { 41 | const cb = arguments[i]; 42 | const index = info ? info.cb.lastIndexOf(cb) : -1; 43 | if (-1 < index) 44 | info.cb.splice(index, 1); 45 | } 46 | return app; 47 | }, 48 | 49 | navigate(path, operation) { 50 | navigate( 51 | path, 52 | !operation || operation === 'push' ? 1 : ( 53 | operation === 'replace' ? -1 : 0 54 | ) 55 | ); 56 | }, 57 | 58 | param(name, cb) { 59 | for (let 60 | {params} = app._, 61 | names = [].concat(name), 62 | i = 0, {length} = names; 63 | i < length; i++ 64 | ) { 65 | params[names[i]] = cb; 66 | } 67 | return app; 68 | }, 69 | 70 | use(mount, cb) { 71 | if (typeof mount === 'function') { 72 | cb = mount; 73 | mount = '(.*)'; 74 | } 75 | for (let 76 | paths = [].concat(mount), 77 | i = 0, {length} = paths; 78 | i < length; i++ 79 | ) { 80 | app.get(paths[i], cb); 81 | } 82 | return app; 83 | } 84 | }); 85 | 86 | const isModifiedEvent = ({metaKey, altKey, ctrlKey, shiftKey}) => 87 | !!(metaKey || altKey || ctrlKey || shiftKey); 88 | 89 | const ARoute = Class || class ARoute extends HTMLAnchorElement { 90 | static get app() { return app; } 91 | connectedCallback() { this.addEventListener('click', this); } 92 | disconnectedCallback() { this.removeEventListener('click', this); } 93 | handleEvent(event) { 94 | // Let the browser handle modified click events (ctrl-click etc.) 95 | if (isModifiedEvent(event)) 96 | return; 97 | 98 | event.preventDefault(); 99 | if (this.hasAttribute('no-propagation')) 100 | event.stopPropagation(); 101 | const {pathname, search, hash} = new URL(this.href); 102 | const path = pathname + search + hash; 103 | navigate(path, this.hasAttribute('replace') ? -1 : 1); 104 | } 105 | } 106 | 107 | if (!Class) { 108 | customElements.define('a-route', ARoute, {extends: 'a'}); 109 | addEventListener('popstate', function () { 110 | const {pathname, search, hash} = location; 111 | navigate(pathname + search + hash, 0); 112 | }); 113 | } 114 | 115 | export {app, ARoute}; 116 | 117 | function asPath2RegExp(path, keys) { 118 | if (typeof path !== 'string') { 119 | path = path.toString(); 120 | path = path.slice(1, path.lastIndexOf('/')); 121 | } 122 | return pathToRegexp(path, keys); 123 | } 124 | 125 | function byKey(key) { 126 | return key in this; 127 | } 128 | 129 | function callNext(ctx, cbs) { 130 | const invoked = []; 131 | (function next() { 132 | const cb = cbs.shift(); 133 | if (cb) { 134 | if (invoked.lastIndexOf(cb) < 0) { 135 | invoked.push(cb); 136 | cb(ctx, next); 137 | } else { 138 | next(); 139 | } 140 | } 141 | }()); 142 | } 143 | 144 | function createParams(match, keys) { 145 | const params = create(null); 146 | for (let i = 1, {length} = match; i < length; i++) { 147 | if (match[i] != null) 148 | params[keys[i - 1].name] = match[i]; 149 | } 150 | return params; 151 | } 152 | 153 | function navigate(path, operation) { 154 | const {params, paths} = app._; 155 | if (operation < 0) 156 | history.replaceState(location.href, document.title, path); 157 | else if (operation) 158 | history.pushState(location.href, document.title, path); 159 | for (let key in paths) { 160 | if (key === '*') 161 | continue; 162 | const info = paths[key]; 163 | const match = info.re.exec(path); 164 | if (match) { 165 | const ctx = { 166 | path, 167 | params: createParams(match, info.keys) 168 | }; 169 | const all = keys(ctx.params).filter(byKey, params); 170 | return (function param() { 171 | if (all.length) { 172 | const key = all.shift(); 173 | params[key](ctx, param, ctx.params[key]); 174 | } 175 | else 176 | callNext(ctx, info.cb.slice(0)); 177 | }()); 178 | } 179 | } 180 | if ('*' in paths) 181 | callNext({path}, paths['*'].cb.slice(0)); 182 | } 183 | -------------------------------------------------------------------------------- /cjs/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const pathToRegexp = (m => m.__esModule ? /* istanbul ignore next */ m.default : /* istanbul ignore next */ m)(require('path-to-regexp')); 3 | 4 | const {create, freeze, keys} = Object; 5 | 6 | const Class = customElements.get('a-route'); 7 | 8 | const app = Class ? Class.app : freeze({ 9 | 10 | _: freeze({ 11 | params: create(null), 12 | paths: create(null) 13 | }), 14 | 15 | get(path) { 16 | for (let 17 | {paths} = app._, 18 | keys = [], 19 | all = path === '*', 20 | re = all ? '*' : asPath2RegExp(path, keys), 21 | info = paths[re] || (paths[re] = { 22 | keys, 23 | re, 24 | cb: [] 25 | }), 26 | i = 1, {length} = arguments; 27 | i < length; i++ 28 | ) { 29 | info.cb.push(arguments[i]); 30 | } 31 | return app; 32 | }, 33 | 34 | delete(path) { 35 | for (let 36 | all = path === '*', 37 | re = all ? '*' : asPath2RegExp(path, []), 38 | info = app._.paths[re], 39 | i = 1, {length} = arguments; 40 | i < length; i++ 41 | ) { 42 | const cb = arguments[i]; 43 | const index = info ? info.cb.lastIndexOf(cb) : -1; 44 | if (-1 < index) 45 | info.cb.splice(index, 1); 46 | } 47 | return app; 48 | }, 49 | 50 | navigate(path, operation) { 51 | navigate( 52 | path, 53 | !operation || operation === 'push' ? 1 : ( 54 | operation === 'replace' ? -1 : 0 55 | ) 56 | ); 57 | }, 58 | 59 | param(name, cb) { 60 | for (let 61 | {params} = app._, 62 | names = [].concat(name), 63 | i = 0, {length} = names; 64 | i < length; i++ 65 | ) { 66 | params[names[i]] = cb; 67 | } 68 | return app; 69 | }, 70 | 71 | use(mount, cb) { 72 | if (typeof mount === 'function') { 73 | cb = mount; 74 | mount = '(.*)'; 75 | } 76 | for (let 77 | paths = [].concat(mount), 78 | i = 0, {length} = paths; 79 | i < length; i++ 80 | ) { 81 | app.get(paths[i], cb); 82 | } 83 | return app; 84 | } 85 | }); 86 | 87 | const isModifiedEvent = ({metaKey, altKey, ctrlKey, shiftKey}) => 88 | !!(metaKey || altKey || ctrlKey || shiftKey); 89 | 90 | const ARoute = Class || class ARoute extends HTMLAnchorElement { 91 | static get app() { return app; } 92 | connectedCallback() { this.addEventListener('click', this); } 93 | disconnectedCallback() { this.removeEventListener('click', this); } 94 | handleEvent(event) { 95 | // Let the browser handle modified click events (ctrl-click etc.) 96 | if (isModifiedEvent(event)) 97 | return; 98 | 99 | event.preventDefault(); 100 | if (this.hasAttribute('no-propagation')) 101 | event.stopPropagation(); 102 | const {pathname, search, hash} = new URL(this.href); 103 | const path = pathname + search + hash; 104 | navigate(path, this.hasAttribute('replace') ? -1 : 1); 105 | } 106 | } 107 | 108 | if (!Class) { 109 | customElements.define('a-route', ARoute, {extends: 'a'}); 110 | addEventListener('popstate', function () { 111 | const {pathname, search, hash} = location; 112 | navigate(pathname + search + hash, 0); 113 | }); 114 | } 115 | 116 | exports.app = app; 117 | exports.ARoute = ARoute; 118 | 119 | function asPath2RegExp(path, keys) { 120 | if (typeof path !== 'string') { 121 | path = path.toString(); 122 | path = path.slice(1, path.lastIndexOf('/')); 123 | } 124 | return pathToRegexp(path, keys); 125 | } 126 | 127 | function byKey(key) { 128 | return key in this; 129 | } 130 | 131 | function callNext(ctx, cbs) { 132 | const invoked = []; 133 | (function next() { 134 | const cb = cbs.shift(); 135 | if (cb) { 136 | if (invoked.lastIndexOf(cb) < 0) { 137 | invoked.push(cb); 138 | cb(ctx, next); 139 | } else { 140 | next(); 141 | } 142 | } 143 | }()); 144 | } 145 | 146 | function createParams(match, keys) { 147 | const params = create(null); 148 | for (let i = 1, {length} = match; i < length; i++) { 149 | if (match[i] != null) 150 | params[keys[i - 1].name] = match[i]; 151 | } 152 | return params; 153 | } 154 | 155 | function navigate(path, operation) { 156 | const {params, paths} = app._; 157 | if (operation < 0) 158 | history.replaceState(location.href, document.title, path); 159 | else if (operation) 160 | history.pushState(location.href, document.title, path); 161 | for (let key in paths) { 162 | if (key === '*') 163 | continue; 164 | const info = paths[key]; 165 | const match = info.re.exec(path); 166 | if (match) { 167 | const ctx = { 168 | path, 169 | params: createParams(match, info.keys) 170 | }; 171 | const all = keys(ctx.params).filter(byKey, params); 172 | return (function param() { 173 | if (all.length) { 174 | const key = all.shift(); 175 | params[key](ctx, param, ctx.params[key]); 176 | } 177 | else 178 | callNext(ctx, info.cb.slice(0)); 179 | }()); 180 | } 181 | } 182 | if ('*' in paths) 183 | callNext({path}, paths['*'].cb.slice(0)); 184 | } 185 | -------------------------------------------------------------------------------- /new.js: -------------------------------------------------------------------------------- 1 | var ARoute=function(e){"use strict";var t=d,n=function(e,t){var n=[];return l(d(e,n,t),n)},r=l,a=p,o=function(e,t){return u(p(e,t),t)},i=u,c=m,s=new RegExp(["(\\\\.)","(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?"].join("|"),"g");function p(e,t){for(var n,r=[],a=0,o=0,i="",c=t&&t.delimiter||"/",p=t&&t.whitelist||void 0,l=!1;null!==(n=s.exec(e));){var u=n[0],g=n[1],m=n.index;if(i+=e.slice(o,m),o=m+u.length,g)i+=g[1],l=!0;else{var d="",v=n[2],x=n[3],y=n[4],E=n[5];if(!l&&i.length){var b=i.length-1,w=i[b];(!p||p.indexOf(w)>-1)&&(d=w,i=i.slice(0,b))}i&&(r.push(i),i="",l=!1);var R="+"===E||"*"===E,A="?"===E||"*"===E,k=x||y,T=d||c;r.push({name:v||a++,prefix:d,delimiter:T,optional:A,repeat:R,pattern:k?h(k):"[^"+f(T===c?T:T+c)+"]+?"})}}return(i||o