commit 2704eb81807633ad369ea29ffba5cb9c790c0434 Author: Timothy Farrell Date: Fri Jan 20 23:20:34 2017 -0600 Bring in the router diff --git a/packages/router/README.md b/packages/router/README.md new file mode 100644 index 0000000..e42f057 --- /dev/null +++ b/packages/router/README.md @@ -0,0 +1,48 @@ +Usage: + +Router(rootURL, routeSpec, onUnmatched): + +rootURL (string) the url prefix of all paths. + +routeSpec (object) + +onUnmatched (optional function(api, badPath, lastGoodRoute)) Called whenever an unmatched route is +navigated. Parameters are: api (Router instance) badpath (string) lastGoodRoute (object) a route +descriptor object for the last good route. + +returns an api object: goto(path) - match the path to a route and navigate there href(pathName, +vars) - build a path based on the name and supplied vars listen() - listen to window.onhashchange +for route changes current() - get the current route descriptor object + +Example: + +```js +const router = Router( + '#', + { + home: { + path: '/', + onenter: (r, route) => { + console.log('At application home'); + }, + onexit: (r, route, newRoute) => { + console.log(`Exiting from ${route.path} to ${newRoute.path}`); + } + }, + article: { + path: '/article/:id', + vars: { id: /[a-f0-9]{6,40}/ }, + onenter: (r, route) => { + console.log('Opening Article', route.vars.id); + }, + onexit: (r, route, newRoute) => { + console.log('Closing Article', route.vars.id); + } + } + }, + (r, path, lastGoodRoute) => { + alert(`Unmatched route "${path}"`); + r.goto(lastGoodRoute.path || 'home'); + } +); +``` diff --git a/packages/router/package.json b/packages/router/package.json new file mode 100644 index 0000000..596f277 --- /dev/null +++ b/packages/router/package.json @@ -0,0 +1,42 @@ +{ + "name": "router", + "version": "1.0.0", + "description": "A slim and unopinionated router", + "main": "lib/index.js", + "jsnext:main": "src/index.js", + "files": ["dist", "lib", "src"], + "scripts": { + "lint": "eslint src", + "clean": "rimraf dist lib", + "build:lib": "NODE_ENV=production babel src --presets=\"stage-0,es2015\" --out-dir lib", + "build:umd": "npm run build:lib && NODE_ENV=production rollup -c", + "build:umd:min": + "npm run build:umd && uglifyjs -m --screw-ie8 -c -o dist/router.min.js dist/router.js", + "build:umd:gzip": "npm run build:umd:min && gzip -c9 dist/router.min.js > dist/router.min.js.gz", + "build": "npm run build:umd:gzip && ls -l dist/", + "prepublish": "npm run clean && npm run build" + }, + "repository": { + "type": "git", + "url": "https://gitlab.com/explorigin/router.git" + }, + "keywords": ["javascript"], + "author": "Timothy Farrell (https://github.com/explorigin)", + "license": "Apache-2.0", + "bugs": { + "url": "https://gitlab.com/explorigin/router/issues" + }, + "homepage": "https://gitlab.com/explorigin/router", + "devDependencies": { + "babel-cli": "6.18.0", + "babel-core": "6.21.0", + "babel-eslint": "7.1.1", + "babel-preset-es2015": "6.18.0", + "babel-preset-es2015-rollup": "3.0.0", + "babel-preset-stage-0": "6.16.0", + "eslint": "3.2.0", + "rimraf": "2.5.4", + "rollup-plugin-json": "2.1.0", + "uglifyjs": "2.4.10" + } +} diff --git a/packages/router/rollup.config.js b/packages/router/rollup.config.js new file mode 100644 index 0000000..02d2d72 --- /dev/null +++ b/packages/router/rollup.config.js @@ -0,0 +1,10 @@ +import json from 'rollup-plugin-json'; +import babel from 'rollup-plugin-babel'; + +export default { + entry: 'src/index.js', + format: 'umd', + moduleName: 'Router', + plugins: [json(), babel(babelConfig)], + dest: 'dist/router.js' +}; diff --git a/packages/router/src/index.js b/packages/router/src/index.js new file mode 100644 index 0000000..f09571f --- /dev/null +++ b/packages/router/src/index.js @@ -0,0 +1,140 @@ +import { isFunction, isUndefined, Null, ObjectKeys } from 'trimkit'; + +function nop() {} + +export function Router( + baseUrl: string, + routes: RouteSpecType, + unmatched: UnmatchedFunctionType +): RouterApiType { + let listening = false; + let currentRoute = Null; + let reEnterHook = Null; + + const VARMATCH_RE = /:([^\/]+)/g; + const ROUTEELEMENT_RE = /^[^\/]+$/; + + const routeMatcher = ObjectKeys(routes).map(name => { + const route = routes[name]; + const reg = route.path.replace(VARMATCH_RE, (m, varName) => { + const varDef = route.vars || {}; + const regExStr = '' + (varDef[varName] || /[^\/]+/); + return '(' + regExStr.substring(1, regExStr.lastIndexOf('/')) + ')'; + }); + + return { + matcher: new RegExp(`^${baseUrl}${reg}$`), + name, + ...route + }; + }); + + function goto(urlOrName: string, vars?: RouteVarsType) { + const url = urlOrName.startsWith(baseUrl) ? urlOrName : href(urlOrName, vars); + + if (listening && _location() !== url) { + // If we're not there make the change and exit. + location.hash = url; + return; + } + + if (currentRoute && currentRoute.path === url) { + // This is only supposed to happen when recovering from a bad path. + return; + } + + let routeVars = {}; + + const routeMatch = routeMatcher.find(({ matcher, path }) => { + const match = url.match(matcher); + if (!match) { + return false; + } + + if (path.indexOf(':') !== -1) { + match.shift(); + path.replace(VARMATCH_RE, (_, varName) => { + // We're abusing RegExp.replace here. + routeVars[varName] = match.shift(); + }); + } + + return true; + }); + + if (routeMatch) { + let newRoute = { + name: routeMatch.name, + vars: routeVars, + path: url + }; + if (currentRoute && currentRoute.name === newRoute.name && isFunction(reEnterHook)) { + reEnterHook(newRoute); + currentRoute = newRoute; + } else { + let onexit = currentRoute && currentRoute.name ? routes[currentRoute.name].onexit || nop : nop; + Promise.resolve(onexit(api, currentRoute, newRoute)).then(() => { + reEnterHook = routes[routeMatch.name].onenter(api, newRoute); + currentRoute = newRoute; + }); + } + } else if (unmatched) { + unmatched(api, url, currentRoute); + } else { + if (currentRoute && listening) { + location.hash = currentRoute.path; + return; + } + throw new Error(`No route for "${url}"`); + } + } + + function href(routeName: string, vars: RouteVarsType = {}): string { + const route = routes[routeName]; + if (!route) { + throw new Error(`Invalid route ${routeName}.`); + } + + let path = '' + route.path; + + if (route.vars) { + path = path.replace(VARMATCH_RE, (_, varName) => { + let value = vars[varName]; + if ((route.vars[varName] || ROUTEELEMENT_RE).test(value)) { + value = isUndefined(value) ? '' : '' + value; + return value; + } + + throw new Error(`Invalid value for route ${path} var ${varName}: ${value}.`); + }); + } + return `${baseUrl}${path}`; + } + + function _location(): string { + return location.hash; + } + + function listen(initialRoute: string): void { + if (listening) { + return; + } + + self.addEventListener('hashchange', () => goto(_location()), false); + listening = true; + goto(_location() || initialRoute); + } + + function current(): RouteInfoType | null { + return currentRoute; + } + + const api = { + goto, + href, + listen, + current + }; + + return api; +}