├── icon.png ├── .babelrc ├── rekishi.png ├── package.json ├── LICENSE ├── .gitignore ├── rollup.js ├── CONTRIBUTING.md ├── dist ├── rekishi.js └── rekishi.es5.js ├── README.md └── src └── rekishi.js /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robb0wen/rekishi/HEAD/icon.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /rekishi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robb0wen/rekishi/HEAD/rekishi.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rekishi", 3 | "version": "0.2.2", 4 | "description": "Rekishi helps you respond to your browser's history", 5 | "main": "dist/rekishi.js", 6 | "module": "src/rekishi.js", 7 | "icon": "icon.png", 8 | "scripts": { 9 | "build": "node rollup.js", 10 | "build:es5": "node rollup.js --es5" 11 | }, 12 | "keywords": [ 13 | "history", 14 | "routing", 15 | "transitions", 16 | "animation", 17 | "management" 18 | ], 19 | "author": { 20 | "name": "Robb Owen", 21 | "email": "hello@robbowen.digital", 22 | "url": "https://robbowen.digital" 23 | }, 24 | "homepage": "https://github.com/robb0wen/rekishi#readme", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "@babel/core": "^7.9.0", 28 | "@babel/preset-env": "^7.9.0", 29 | "@rollup/plugin-babel": "^5.0.0", 30 | "@rollup/plugin-commonjs": "^11.1.0", 31 | "rollup-plugin-terser": "^7.0.0", 32 | "rollup": "^2.10.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Robb Owen 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. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /rollup.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const rollup = require('rollup'); 3 | const { babel } = require('@rollup/plugin-babel'); 4 | const { terser } = require('rollup-plugin-terser'); 5 | 6 | const useES5 = process.argv.includes('--es5'); 7 | 8 | const plugins = useES5 9 | ? [ 10 | babel({ 11 | exclude: 'node_modules/**', 12 | babelHelpers: 'bundled' 13 | }), 14 | terser({ 15 | output: { 16 | comments: function(node, comment) { 17 | var text = comment.value; 18 | var type = comment.type; 19 | if (type == "comment2") { 20 | // multiline comment 21 | return /@preserve|@license|@cc_on/i.test(text); 22 | } 23 | } 24 | } 25 | }) 26 | ] 27 | : [ 28 | terser({ 29 | output: { 30 | comments: function(node, comment) { 31 | var text = comment.value; 32 | var type = comment.type; 33 | if (type == "comment2") { 34 | // multiline comment 35 | return /@preserve|@license|@cc_on/i.test(text); 36 | } 37 | } 38 | } 39 | }) 40 | ]; 41 | 42 | const filename = useES5 ? 'rekishi.es5.js' : 'rekishi.js'; 43 | 44 | async function build() { 45 | const bundle = await rollup.rollup({ 46 | input: path.join(__dirname,'/src/rekishi.js'), 47 | plugins 48 | }); 49 | 50 | await bundle.write({ 51 | format: 'umd', 52 | name: 'rekishi', 53 | file: path.join(__dirname, `/dist/${filename}`) 54 | }); 55 | }; 56 | 57 | build(); -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Rekishi 2 | So, you'd like to contribute to Rekishi? Awesome! 3 | 4 | However, before you make your changes, please get in touch to make the project owner aware of what you're proposing. The best way to do this is to raise an issue clearly detailing the change(s) you wish to make. 5 | 6 | When suggesting any changes, please be aware of the code of conduct for this project: 7 | 8 | ## Code of conduct 9 | 10 | ### Our Pledge 11 | 12 | In the interest of fostering an open and welcoming environment, we as 13 | contributors and maintainers pledge to making participation in our project and 14 | our community a harassment-free experience for everyone, regardless of age, body 15 | size, disability, ethnicity, sex characteristics, gender identity and expression, 16 | level of experience, education, socio-economic status, nationality, personal 17 | appearance, race, religion, or sexual identity and orientation. 18 | 19 | ### Our Standards 20 | 21 | Examples of behavior that contributes to creating a positive environment 22 | include: 23 | 24 | * Using welcoming and inclusive language 25 | * Being respectful of differing viewpoints and experiences 26 | * Gracefully accepting constructive criticism 27 | * Focusing on what is best for the community 28 | * Showing empathy towards other community members 29 | 30 | Examples of unacceptable behavior by participants include: 31 | 32 | * The use of sexualized language or imagery and unwelcome sexual attention or 33 | advances 34 | * Trolling, insulting/derogatory comments, and personal or political attacks 35 | * Public or private harassment 36 | * Publishing others' private information, such as a physical or electronic 37 | address, without explicit permission 38 | * Other conduct which could reasonably be considered inappropriate in a 39 | professional setting 40 | 41 | ### Our Responsibilities 42 | 43 | Project maintainers are responsible for clarifying the standards of acceptable 44 | behavior and are expected to take appropriate and fair corrective action in 45 | response to any instances of unacceptable behavior. 46 | 47 | Project maintainers have the right and responsibility to remove, edit, or 48 | reject comments, commits, code, wiki edits, issues, and other contributions 49 | that are not aligned to this Code of Conduct, or to ban temporarily or 50 | permanently any contributor for other behaviors that they deem inappropriate, 51 | threatening, offensive, or harmful. 52 | 53 | ### Scope 54 | 55 | This Code of Conduct applies within all project spaces, and it also applies when 56 | an individual is representing the project or its community in public spaces. 57 | Examples of representing a project or community include using an official 58 | project e-mail address, posting via an official social media account, or acting 59 | as an appointed representative at an online or offline event. Representation of 60 | a project may be further defined and clarified by project maintainers. 61 | 62 | ### Enforcement 63 | 64 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 65 | reported by contacting the project team at hello@robbowen.digital. All 66 | complaints will be reviewed and investigated and will result in a response that 67 | is deemed necessary and appropriate to the circumstances. The project team is 68 | obligated to maintain confidentiality with regard to the reporter of an incident. 69 | Further details of specific enforcement policies may be posted separately. 70 | 71 | Project maintainers who do not follow or enforce the Code of Conduct in good 72 | faith may face temporary or permanent repercussions as determined by other 73 | members of the project's leadership. 74 | 75 | ### Attribution 76 | 77 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 78 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 79 | 80 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /dist/rekishi.js: -------------------------------------------------------------------------------- 1 | !function(t,a){"object"==typeof exports&&"undefined"!=typeof module?a(exports):"function"==typeof define&&define.amd?define(["exports"],a):a((t=t||self).rekishi={})}(this,(function(t){"use strict";const a=t=>"/"==t?t:t.replace(/\/$/g,""),e=t=>{if("string"!=typeof t||!t)throw new Error("Value passed must be a string");let e;e="/"==t.substr(0,1)?t:"?"==t.substr(0,1)?`${window.location.pathname.replace(/\/$/,"")}/${t}`:"#"==t.substr(0,1)?window.location.pathname+window.location.search+t:`${window.location.pathname.replace(/\/$/,"")}/${t}`;let[s,h]=e.split("#"),[i,r]=s.split("?");const n=r?r.split("&").reduce((t,a)=>{const[e,s]=a.split("=");return{...t,[e]:s}},{}):{};return{path:a(i),hash:h?"#"+h:null,params:n}},s=t=>{try{let a="*"!=t.substr(-1),e=t.replace(/(\*)|(\/)$/g,"");return a?new RegExp(`^${e}(\\/)?$`):new RegExp("^"+e)}catch(t){throw new Error("Path is not in the correct format")}};t.REKISHI_HASH="HASH",t.REKISHI_HASHPARAMS="HASHPARAMS",t.REKISHI_NOCHANGE="NOCHANGE",t.REKISHI_PARAMS="PARAMS",t.REKISHI_POP="POP",t.REKISHI_PUSH="PUSH",t.Rekishi=class{constructor(t){const a={initPathData:{},registeredPaths:[],scrollRestoration:"manual",...t};history.scrollRestoration=a.scrollRestoration,this._callbacks=[],this._prevURL={},this._currentURL={},this._pathData={},this._registeredPaths=[],this.push=this.push.bind(this),this.registerPath=this.registerPath.bind(this),this.getCurrentState=this.getCurrentState.bind(this),this.watch=this.watch.bind(this),this.unwatch=this.unwatch.bind(this),this._handlePop=this._handlePop.bind(this),this._updatePathData=this._updatePathData.bind(this),this._updatePosition=this._updatePosition.bind(this),this._didHashChange=this._didHashChange.bind(this),this._mergeRegisteredData=this._mergeRegisteredData.bind(this),a.registeredPaths.length&&a.registeredPaths.forEach(t=>{const{path:a,data:e}=t;this.registerPath(a,e)});const s=`${window.location.pathname}${window.location.hash}${window.location.search}`,{path:h,hash:i,params:r}=e(s);this._updatePosition(h,i,r),this._updatePathData(h,a.initPathData),window.addEventListener("popstate",this._handlePop)}_updatePosition(t,e,s){if(this._prevURL=this._currentURL,this._currentURL={path:a(t),hash:e,params:s},this._prevURL.path==this._currentURL.path){if(this._didHashChange()&&!this._didParamsChange())return"HASH";if(!this._didHashChange()&&this._didParamsChange())return"PARAMS";if(this._didHashChange()&&this._didParamsChange())return"HASHPARAMS";if(!this._didHashChange()&&!this._didParamsChange())return"NOCHANGE"}}_updatePathData(t,e){const s=a(t);return this._pathData={...this._pathData,[s]:e},this._pathData}_mergeRegisteredData(t){return{...this._registeredPaths.reduce((a,e)=>e.regex.test(t)?{...a,...e.data}:a,{}),...this._pathData[t]}}push(t,a){const{path:s,hash:h,params:i}=e(t);this._updatePathData(s,a||{});let r=this._updatePosition(s,h,i);r=r||"PUSH",history.pushState(null,null,t),this._callbacks.forEach(t=>t({...this.getCurrentState(),action:r}))}_handlePop(t){const a=`${window.location.pathname}${window.location.search}${window.location.hash}`,{path:s,hash:h,params:i}=e(a);let r=this._updatePosition(s,h,i);r=r||"POP",this._callbacks.forEach(t=>t({...this.getCurrentState(),action:r}))}_didHashChange(){return this._currentURL.path==this._prevURL.path&&this._currentURL.hash!=this._prevURL.hash}_didParamsChange(){return this._currentURL.path==this._prevURL.path&&(t=this._currentURL.params,a=this._prevURL.params,!(Object.keys(t).every(e=>e in a&&t[e]===a[e])&&Object.keys(a).every(e=>e in t&&t[e]===a[e])));var t,a}getCurrentState(){return{incoming:{path:this._currentURL.path,hash:this._currentURL.hash,params:this._currentURL.params,data:this._mergeRegisteredData(this._currentURL.path)},outgoing:{path:this._prevURL.path,hash:this._prevURL.hash,params:this._prevURL.params,data:this._mergeRegisteredData(this._prevURL.path)}}}registerPath(t,a){return this._registeredPaths=[...this._registeredPaths,{path:t,regex:s(t),data:a}],this._registeredPaths}watch(t){if("function"!=typeof t)throw new Error("Value passed to Watch is not a function");this._callbacks.push(t)}unwatch(t){if("function"!=typeof t)throw new Error("The value passed to unwatch is not a function");this._callbacks=this._callbacks.filter(a=>a!==t)}},Object.defineProperty(t,"__esModule",{value:!0})})); 2 | -------------------------------------------------------------------------------- /dist/rekishi.es5.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).rekishi={})}(this,(function(t){"use strict";function e(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,a=new Array(e);r 8 | 9 | ## What is Rekishi? 10 | 11 | Rekishi is a minimal wrapper for the History API that provides additional pub/sub functionality. 12 | 13 | I became frustrated that, whilst you can assign some state, the history API doesn't give access to any information about _outgoing_ URLs. If you need to create dynamic transitions between different pages or different types of content, then Rekishi can help. 14 | 15 | Beyond watching for URL changes, Rekishi also allows you to associate data with incoming paths - that means that, for example, your front-end code can know ahead of time that `/blog/finding-the-best-coffee-in-tokyo` is a going to be a blog post. 16 | 17 | 18 | ## What Rekishi isn't 19 | 20 | Rekishi is not a clientside router, in the traditional sense. It isn't really designed for use with a framework either - There are far better and more mature libraries available for that. 21 | 22 | Rekishi is concerned only with watching and responding to changes in browser history. What you do with that information is up to you and your application. 23 | 24 | ## Why "Rekishi"? 25 | 26 | At this point, most of the more obvious names for History libs have been taken. Rekishi (歴史 | れきし) means history in Japanese. Why pick a Japanese word? well, part of my own personal history was spent living in Tokyo, so it felt appropriate. 27 | 28 | # Installation 29 | Npm is the preferred method. Open your project directory in your command line or terminal, then run: 30 | 31 | ``` 32 | npm install rekishi 33 | ``` 34 | 35 | # Standard usage 36 | 37 | ## Import Rekishi 38 | 39 | First of all, you will need to import Rekishi and any actions that you require: 40 | ``` javascript 41 | import { 42 | Rekishi, 43 | REKISHI_POP, 44 | REKISHI_PUSH, 45 | REKISHI_HASH, 46 | REKISHI_PARAMS, 47 | REKISHI_HASHPARAMS, 48 | REKISHI_NOCHANGE 49 | } from 'rekishi'; 50 | ``` 51 | 52 | ## Initialise Rekishi 53 | 54 | Next you will need to initialise an instance of Rekishi: 55 | ``` javascript 56 | const options = { 57 | registeredRoutes: [ 58 | { 59 | path: '/blog/*', 60 | data: { 61 | type: 'post' 62 | } 63 | } 64 | ], 65 | initPathData: { 66 | type: 'page', 67 | initial: true, 68 | foo: 'bar' 69 | }, 70 | scrollRestoration: 'manual' 71 | }; 72 | 73 | const rekishi = new Rekishi(options); 74 | ``` 75 | 76 | The constructor options object currently takes the following properties: 77 | 78 | ### _registeredPaths_ 79 | 80 | This property takes an array of path objects. These objects contain a path glob, and a data object, in the following structure: 81 | 82 | ``` javascript 83 | { 84 | path: String, 85 | data: Object 86 | } 87 | ``` 88 | 89 | Each path object in this array is assigned to Rekishi on initialisation, and that default data will be passed automatically to your handler function. 90 | 91 | 92 | 93 | The `path` property is a string representation of a relative url pathname. It uses a simple matching format: 94 | 95 | `/path/` would match exactly to `/path` or `/path/` (trailing slash is optional) 96 | `/path/*` would match to any url under `/path/`, such as `/path/to/a/post` 97 | 98 | The paths defined in `registeredPaths` stack, so the order is significant. If you wanted to pass data to all posts, but single out a specific subpage you could do: 99 | 100 | ``` javascript 101 | [ 102 | { 103 | path: '/posts/*', 104 | data: { 105 | type: 'post' 106 | } 107 | }, 108 | { 109 | path: '/posts/riding-the-shinkansen', 110 | data: { 111 | type: 'post', 112 | featured: true 113 | } 114 | } 115 | ] 116 | ``` 117 | 118 | __Note:__ Rekishi is not designed to map data to specific domains, hashes or query parameters. Any paths in a format such as `http://mywebsite.com/blog?id=1` will be ignored. 119 | 120 | ### _initPathData_ 121 | This property allows you to pass and store a data object _specific to the page that initialises Rekishi_. 122 | 123 | For example, this property allows you to override the default registered path data for users entering the site directly on a URL. 124 | 125 | Consider this example: 126 | 127 | ``` javascript 128 | const rekishi = new Rekishi({ 129 | registeredPaths: [ 130 | { 131 | path: '/profile', 132 | type: 'modal' 133 | } 134 | ], 135 | initPathData: { 136 | type: 'page' 137 | } 138 | }); 139 | ``` 140 | 141 | By default, when the history changes to `/profile`, your application would consider that path to have a type of `modal`. Using the above configuration, when a user enters the site directly on the `/profile` url, that type will be overwritten with `page`. 142 | 143 | This can be useful if you are creating page transitions that need to vary depending on a particular data property. 144 | 145 | ### scrollRestoration 146 | Modifies the history API scroll position. Defaults to `manual` (full controll over scroll position between routes). Alternatively, `auto` will leave scroll position up to the browser. For more see: https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration 147 | 148 | ## Handler functions 149 | 150 | Now that Rekishi is initialised, you can define a function to respond to any changes in history: 151 | 152 | ``` javascript 153 | const handleRouteChange = ({ incoming, outgoing, action }) => { 154 | switch (action) { 155 | case REKISHI_POP: 156 | // browser history has changed 157 | // do something based on incoming and outgoing data 158 | break; 159 | 160 | case REKISHI_PUSH: 161 | // a new link has been followed 162 | break; 163 | 164 | case REKISHI_HASH: 165 | case REKISHI_HASHPARAMS: 166 | // the hash has changed, so handle it 167 | break; 168 | 169 | case REKISHI_PARAMS: 170 | case REKISHI_HASHPARAMS: 171 | // the query has changed, so handle it 172 | break; 173 | 174 | case REKISHI_NOCHANGE: 175 | // nothing changed 176 | break; 177 | } 178 | }; 179 | ``` 180 | 181 | Handler functions have access to the current state object: 182 | 183 | ``` javascript 184 | { 185 | incoming: { 186 | path: String, 187 | hash: String, 188 | params: Object, 189 | data: Object 190 | }, 191 | outgoing: { 192 | path: String, 193 | hash: String, 194 | params: Object, 195 | data: Object 196 | }, 197 | action: REKISHI_PUSH || REKISHI_POP || REKISHI__HASH || REKISHI_PARAMS || REKISHI_HASHPARAMS || REKISHI_NOCHANGE 198 | } 199 | ``` 200 | The actions available to you are: 201 | 202 | * __REKISHI_PUSH__ - a new path was visited and pushed to history 203 | * __REKISHI_POP__ - A previous path was visited when the user travelled forward or back in their browser's history 204 | * __REKISHI_HASH__ - the path hasn't changed, but the hash fragment has 205 | * __REKISHI_PARAMS__ - the path hasn't changed, but the query parameters have 206 | * __REKISHI_HASHPARAMS__ - the path hasn't changed, but both the hash and the query parameters have 207 | * __REKISHI_NOCHANGE__ - the path, hash fragment and query parameters are unchanged 208 | 209 | Together with the incoming and outgoing objects, you can use these actions to choreograph page changes, animations or transitions however you want. 210 | 211 | For example, if you wanted a transition between a page and modal content, you might write a handler function that looks like this: 212 | 213 | ``` javascript 214 | const handleRouteChange = ({ incoming, outgoing, action }) => { 215 | switch (action) { 216 | // on either history, or new link events... 217 | case REKISHI_POP: 218 | case REKISHI_PUSH: 219 | 220 | if (incoming.data.type == 'modal' && outgoing.data.type == 'page') { 221 | // AJAX the content from incoming.path 222 | // set up your modal and inject AJAX content 223 | } 224 | 225 | if (incoming.data.type == 'page' && outgoing.data.type == 'modal') { 226 | //close your modal and remove the content 227 | } 228 | 229 | break; 230 | } 231 | }; 232 | ``` 233 | 234 | Similarly, responding to a change in hash fragment could look like this: 235 | 236 | ``` javascript 237 | const handleRouteChange = ({ incoming, outgoing, action }) => { 238 | switch (action) { 239 | case REKISHI_HASH: 240 | case REKISHI_HASHPARAMS: 241 | // find an element that matches the hash 242 | // if it exists, scroll to its position 243 | 244 | break; 245 | } 246 | }; 247 | ``` 248 | 249 | ## Subscribing to handlers 250 | 251 | Once you have written a handler function, you can tell Rekishi to call that function whenever there are any changes in history. You can set Rekishi to watch as many handler functions as you need. 252 | 253 | ``` javascript 254 | rekishi.watch(handleRouteChange); 255 | ``` 256 | 257 | ## Pushing new paths 258 | For most situations, you will need your application to be able to visit new URLs. You can pass a new URL, and any optional data, to Rekishi with the `push` method. 259 | 260 | ``` javascript 261 | rekishi.push(url, data); 262 | ``` 263 | In this case, `url` is a relative internal path that can include a hash or query params, but not a domain (Rekishi shouldn't be listening to external URLs). 264 | 265 | For example you might want to bind internal links to Rekishi in the following way: 266 | 267 | ``` javascript 268 | const internalLinks = [...root.querySelectorAll('a[href^="/"], a[href^="#"], a[href^="?"]')]; 269 | 270 | interalLinks.forEach(link => { 271 | 272 | link.addEventListener('click', event => { 273 | // prevent the link from following 274 | event.preventDefault(); 275 | 276 | // capture the url and any additional data from the markup 277 | const href = link.getAttribute('href'); 278 | const data = { 279 | foo: link.dataset.foo, 280 | bar: link.dataset.bar 281 | }; 282 | 283 | // push the url and any optional custom data to Rekishi 284 | rekishi.push(href, data); 285 | }); 286 | 287 | }); 288 | ``` 289 | Any data passed with the `push` method will be merged with registered path data. This means that properties passed in the `push` data object will override the defaults associated with a particular path. 290 | 291 | 292 | # Optional methods 293 | 294 | ## Unsubscribing to changes 295 | When you're ready to stop watching for changes, you can pass the handler function into the `unwatch` method. 296 | 297 | ``` javascript 298 | rekishi.unwatch(handleRouteChange); 299 | ``` 300 | 301 | ## Registering additional paths 302 | Sometimes you might need to register additional path information on-the-fly. You can do this by calling the `registerPath` method: 303 | 304 | ``` javascript 305 | rekishi.registerPath('/contact', { type: "modal" }); 306 | ``` -------------------------------------------------------------------------------- /src/rekishi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Exported constants for Rekishi's action types. Can be imported into code to ensure correct naming 3 | */ 4 | export const REKISHI_POP = "POP"; 5 | export const REKISHI_PUSH = "PUSH"; 6 | export const REKISHI_HASH = "HASH"; 7 | export const REKISHI_PARAMS = "PARAMS"; 8 | export const REKISHI_HASHPARAMS = "HASHPARAMS"; 9 | export const REKISHI_NOCHANGE = "NOCHANGE"; 10 | 11 | /** 12 | * Helper to shallow compare objects 13 | * @param {Object} a 14 | * @param {Object} b 15 | */ 16 | const paramsAreEqual = (a, b) => 17 | Object.keys(a).every(key => (key in b) && a[key] === b[key]) && 18 | Object.keys(b).every(key => (key in a) && a[key] === b[key]); 19 | 20 | 21 | /** 22 | * Sanitise incoming paths 23 | * @param {String} url 24 | */ 25 | const stripTrailingSlash = url => url == '/' ? url : url.replace(/\/$/g, ''); 26 | 27 | /** 28 | * Processes a url string to extract path, hash and params 29 | * @param {String} url The url as a string without the domain but with hash and params 30 | */ 31 | const processUrl = url => { 32 | if (typeof url !== "string" || !url) { 33 | throw new Error('Value passed must be a string'); 34 | } 35 | 36 | // if this was a relative url, prefix the current path 37 | let prefixedUrl; 38 | if (url.substr(0,1) == "/") { 39 | // the url is fine as is 40 | prefixedUrl = url; 41 | } else if (url.substr(0,1) == "?") { 42 | // the url is a relative wit query 43 | prefixedUrl = `${window.location.pathname.replace(/\/$/, '')}/${url}`; 44 | } else if (url.substr(0,1) == "#") { 45 | // the url is a hash only 46 | prefixedUrl = window.location.pathname+window.location.search+url; 47 | } else { 48 | // the url is a relative path 49 | prefixedUrl = `${window.location.pathname.replace(/\/$/, '')}/${url}`; 50 | } 51 | 52 | // extract the hash 53 | let [urlWithParams, hash] = prefixedUrl.split('#'); 54 | 55 | // grab the query string 56 | let [path, queryString] = urlWithParams.split('?'); 57 | 58 | const params = queryString 59 | ? queryString.split('&').reduce((acc, param) => { 60 | const [key, value] = param.split('='); 61 | return { 62 | ...acc, 63 | [key]: value 64 | }; 65 | }, {}) 66 | : {}; 67 | 68 | return { 69 | path: stripTrailingSlash(path), 70 | hash: hash ? `#${hash}` : null, 71 | params 72 | }; 73 | }; 74 | 75 | /** 76 | * Convert simple path glob into a regex 77 | * @param {String} path a path without domain. Will match exactly unless a * is placed at the end of the url i.e. /posts/*. Trailing slash is optional. 78 | */ 79 | const getPathRegex = path => { 80 | try { 81 | let preciseMatch = path.substr(-1) != "*"; 82 | let reg = path.replace(/(\*)|(\/)$/g, ''); 83 | 84 | if (preciseMatch) { 85 | return new RegExp(`^${reg}(\\\/)?$`); 86 | } else { 87 | return new RegExp(`^${reg}`); 88 | } 89 | } catch (err) { 90 | throw new Error('Path is not in the correct format'); 91 | } 92 | }; 93 | 94 | 95 | // Main Rekishi class 96 | export class Rekishi { 97 | 98 | /** 99 | * internal constructor for Rekishi class 100 | * @param {Object} options The options object. Will merge with, and override, internal defaults 101 | */ 102 | constructor(options) { 103 | // set default options 104 | const defaultOptions = { 105 | // data to be passed to the initial URL (overrides registered Path Data) 106 | initPathData: {}, 107 | // an array of registered path regex and associated data 108 | registeredPaths: [], 109 | // by default give the user control over scroll position 110 | scrollRestoration: 'manual', 111 | }; 112 | 113 | // merge user options with defaults to override 114 | const mergedOptions = {...defaultOptions, ...options}; 115 | 116 | // set the history scroll mode 117 | history.scrollRestoration = mergedOptions.scrollRestoration; 118 | 119 | // initialise the internal watched function queue 120 | this._callbacks = []; 121 | 122 | // init internal objects 123 | this._prevURL = {}; 124 | this._currentURL = {}; 125 | this._pathData = {}; 126 | this._registeredPaths = []; 127 | 128 | // external methods 129 | this.push = this.push.bind(this); 130 | this.registerPath = this.registerPath.bind(this); 131 | this.getCurrentState = this.getCurrentState.bind(this); 132 | this.watch = this.watch.bind(this); 133 | this.unwatch = this.unwatch.bind(this); 134 | 135 | // internal methods 136 | this._handlePop = this._handlePop.bind(this); 137 | this._updatePathData = this._updatePathData.bind(this); 138 | this._updatePosition = this._updatePosition.bind(this); 139 | this._didHashChange = this._didHashChange.bind(this); 140 | this._mergeRegisteredData = this._mergeRegisteredData.bind(this); 141 | 142 | // register any paths passed in options 143 | if (mergedOptions.registeredPaths.length) { 144 | mergedOptions.registeredPaths.forEach(pathToRegister => { 145 | const { path, data } = pathToRegister; 146 | this.registerPath(path, data); 147 | }) 148 | } 149 | 150 | // set init page date state 151 | const initUrl = `${window.location.pathname}${window.location.hash}${window.location.search}`; 152 | const { path, hash, params } = processUrl(initUrl); 153 | this._updatePosition(path, hash, params); 154 | this._updatePathData(path, mergedOptions.initPathData); 155 | 156 | // bind the pop listener 157 | window.addEventListener('popstate', this._handlePop); 158 | } 159 | 160 | /** 161 | * Update internal values for previous and current paths 162 | * @param {String} path The incoming path without domain, hash or search params 163 | * @param {String} hash the incoming hash with leading # 164 | */ 165 | _updatePosition(path, hash, params) { 166 | // update the outgoing url 167 | this._prevURL = this._currentURL; 168 | 169 | // store the incoming 170 | this._currentURL = { 171 | path: stripTrailingSlash(path), 172 | hash, 173 | params 174 | }; 175 | 176 | // determine action if paths match 177 | if (this._prevURL.path == this._currentURL.path) { 178 | if (this._didHashChange() && !this._didParamsChange()) { 179 | return REKISHI_HASH; 180 | } 181 | 182 | if (!this._didHashChange() && this._didParamsChange()) { 183 | return REKISHI_PARAMS; 184 | } 185 | 186 | if (this._didHashChange() && this._didParamsChange()) { 187 | return REKISHI_HASHPARAMS; 188 | } 189 | 190 | if (!this._didHashChange() && !this._didParamsChange()) { 191 | return REKISHI_NOCHANGE; 192 | } 193 | } 194 | 195 | return; 196 | } 197 | 198 | /** 199 | * Updates the data store with new values 200 | * @param {String} path the parh without domain to assign the data to 201 | * @param {Object} data the updated data object to insert into the store 202 | */ 203 | _updatePathData(path, data) { 204 | // strip any slashes for consistency 205 | const pathKey = stripTrailingSlash(path); 206 | this._pathData = { 207 | ...this._pathData, 208 | [pathKey]: data 209 | }; 210 | return this._pathData; 211 | } 212 | 213 | /** 214 | * Get path data from internal store, and merge with regex based internal defaults 215 | * @param {String} path the path without domain to get data for 216 | */ 217 | _mergeRegisteredData(path) { 218 | const newData = this._registeredPaths.reduce((acc, curr) => { 219 | if (curr.regex.test(path)) { 220 | return { 221 | ...acc, 222 | ...curr.data 223 | } 224 | } else { 225 | return acc; 226 | } 227 | }, {}); 228 | 229 | return { 230 | ...newData, 231 | ...this._pathData[path] 232 | } 233 | } 234 | 235 | /** 236 | * Push a new url to history with some accompanying data 237 | * @param {String} url The new pathname without domain, but with hash and params 238 | * @param {Object} data The data object to be associated with url 239 | */ 240 | push(url, data) { 241 | // update some things 242 | const { path, hash, params } = processUrl(url); 243 | 244 | this._updatePathData(path, data || {}); 245 | let action = this._updatePosition(path, hash, params); 246 | // if no specific action returned, set to PUSH 247 | action = !action ? REKISHI_PUSH : action; 248 | 249 | // update history 250 | history.pushState(null, null, url); 251 | 252 | // run callbacks 253 | this._callbacks.forEach(cb => cb({ ...this.getCurrentState(), action })); 254 | } 255 | 256 | /** 257 | * Method to bind to internal history API popstate event 258 | */ 259 | _handlePop(e) { 260 | const poppedUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; 261 | const { path, hash, params } = processUrl(poppedUrl); 262 | 263 | let action = this._updatePosition(path, hash, params); 264 | // if no specific action returned, set to POP 265 | action = !action ? REKISHI_POP : action; 266 | 267 | // run callbacks 268 | this._callbacks.forEach(cb => cb({ ...this.getCurrentState(), action })); 269 | } 270 | 271 | /** 272 | * simple check to see if the hash has changed 273 | */ 274 | _didHashChange() { 275 | return this._currentURL.path == this._prevURL.path && this._currentURL.hash != this._prevURL.hash; 276 | } 277 | 278 | /** 279 | * simple check to see if the params have changed 280 | */ 281 | _didParamsChange() { 282 | return this._currentURL.path == this._prevURL.path && !paramsAreEqual(this._currentURL.params, this._prevURL.params); 283 | } 284 | 285 | /** 286 | * Returns a copy of the current and previous urls, with data, formatted for public use 287 | */ 288 | getCurrentState() { 289 | return { 290 | incoming: { 291 | path: this._currentURL.path, 292 | hash: this._currentURL.hash, 293 | params: this._currentURL.params, 294 | data: this._mergeRegisteredData(this._currentURL.path) 295 | }, 296 | outgoing: { 297 | path: this._prevURL.path, 298 | hash: this._prevURL.hash, 299 | params: this._prevURL.params, 300 | data: this._mergeRegisteredData(this._prevURL.path) 301 | } 302 | } 303 | } 304 | 305 | /** 306 | * Map default data to be associated with a simple path blog 307 | * @param {String} path a path without domain. Will match exactly unless a * is placed at the end of the url i.e. /posts/*. Trailing slash is optional. 308 | * @param {Object} data The data object to be associated with url 309 | */ 310 | registerPath(path, data) { 311 | this._registeredPaths = [ 312 | ...this._registeredPaths, 313 | { 314 | path, 315 | regex: getPathRegex(path), 316 | data 317 | } 318 | ]; 319 | 320 | return this._registeredPaths; 321 | } 322 | 323 | /** 324 | * Subscribes a function to the 'watched functions' list. 325 | * Watched functions will be automatically called on history update 326 | * @param {Function} callback The function to call on update 327 | */ 328 | watch(callback) { 329 | if (typeof callback !== 'function') { 330 | throw new Error('Value passed to Watch is not a function'); 331 | } 332 | 333 | // push the callback to the queue to ensure it runs on future updates 334 | this._callbacks.push(callback); 335 | } 336 | 337 | /** 338 | * Unsubscribe a function from the 'watched functions' list 339 | * @param {Function} callback The function to be removed 340 | */ 341 | unwatch(callback) { 342 | if (typeof callback !== 'function') { 343 | throw new Error('The value passed to unwatch is not a function'); 344 | } 345 | 346 | // remove the callback from the list 347 | this._callbacks = this._callbacks.filter(cb => cb !== callback); 348 | } 349 | } --------------------------------------------------------------------------------