",
20 | "license": "MIT",
21 | "devDependencies": {
22 | "babel-cli": "^6.26.0",
23 | "babel-plugin-transform-class-properties": "^6.24.1",
24 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
25 | "babel-preset-env": "^1.6.1",
26 | "babel-preset-react": "^6.24.1",
27 | "chai": "^4.1.2",
28 | "enzyme": "^3.3.0",
29 | "enzyme-adapter-react-16": "^1.1.1",
30 | "jsdom": "^11.6.2",
31 | "mocha": "^5.0.1",
32 | "prop-types": "^15.6.0",
33 | "react": "^16.2.0",
34 | "react-addons-test-utils": "^15.6.2",
35 | "react-dom": "^16.2.0",
36 | "sinon": "^6.3.4",
37 | "sinon-chai": "^3.2.0"
38 | },
39 | "peerDependencies": {
40 | "react": "^16.2.0",
41 | "prop-types": "^15.6.0"
42 | },
43 | "dependencies": {
44 | "pathname-router": "0.0.5"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/ButtonLink.js:
--------------------------------------------------------------------------------
1 | import {Link} from './Link';
2 |
3 | export default function ButtonLink(props){
4 | return {props.children}
5 | }
--------------------------------------------------------------------------------
/src/Link.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 |
4 | class Link extends Component {
5 |
6 | static contextTypes = {
7 | redirectTo: PropTypes.func.isRequired,
8 | location: PropTypes.object.isRequired,
9 | };
10 |
11 | static propTypes = {
12 | Component: PropTypes.node.isRequired,
13 | to: PropTypes.oneOfType([
14 | PropTypes.string,
15 | PropTypes.object,
16 | ]),
17 | href: PropTypes.string,
18 | path: PropTypes.string,
19 | onClick: PropTypes.func,
20 | externalLink: PropTypes.bool,
21 | };
22 |
23 | static defaultProps = {
24 | Component: 'a',
25 | externalLink: false,
26 | };
27 |
28 | constructor(props) {
29 | super(props);
30 | this.onClick = this.onClick.bind(this);
31 | }
32 |
33 | isSameOrigin(){
34 | const { href } = this.props
35 | return !href.match(/^https?:\/\//) || href.startsWith(window.location.origin)
36 | }
37 |
38 | onClick(event){
39 | if (this.props.onClick){
40 | this.props.onClick(event)
41 | }
42 |
43 | if (event.defaultPrevented) return
44 |
45 | if (!this.props.externalLink && !event.ctrlKey && !event.metaKey && !event.shiftKey && this.isSameOrigin()){
46 | event.preventDefault()
47 | this.context.redirectTo(this.props.href, !!this.props.replace)
48 | }
49 | }
50 |
51 | render(){
52 | const props = Object.assign({}, this.props)
53 | const Component = props.Component
54 | delete props.Component
55 | delete props.externalLink
56 | props.href = props.href || ''
57 | props.onClick = this.onClick
58 | return { this.link = node }} {...props}>{props.children}
59 | }
60 | }
61 |
62 | export default Link
63 |
--------------------------------------------------------------------------------
/src/Location.js:
--------------------------------------------------------------------------------
1 | import querystring from 'querystring'
2 |
3 | export default class Location {
4 | constructor({pathname, query, search, hash}){
5 | this.pathname = pathname
6 | this.query = typeof search === 'string'
7 | ? searchToObject(search)
8 | : query || {}
9 | this.hash = hash === "" ? null : hash
10 | }
11 |
12 | toString(){
13 | let href = this.pathname
14 | let query = objectToSearch(this.query)
15 | if (query) href += '?'+query
16 | return href
17 | }
18 |
19 | update({pathname, query, hash}){
20 | return new Location({
21 | pathname: pathname || this.pathname,
22 | query: query || this.query,
23 | hash: hash || this.hash,
24 | })
25 | }
26 |
27 | hrefFor(location) {
28 | return this.update(location).toString()
29 | }
30 | }
31 |
32 |
33 | const searchToObject = (search) => {
34 | return querystring.parse((search || '').replace(/^\?/, ''))
35 | }
36 |
37 | const objectToSearch = (object) => {
38 | return querystring.stringify(object)
39 | }
40 |
--------------------------------------------------------------------------------
/src/Router.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import PathnameRouter from 'pathname-router'
4 | import Location from './Location'
5 |
6 | export default class SimpleReactRouter extends Component {
7 |
8 | static childContextTypes = {
9 | location: PropTypes.object.isRequired,
10 | redirectTo: PropTypes.func.isRequired,
11 | }
12 |
13 | constructor(props){
14 | super(props)
15 | if (process.env.NODE_ENV === 'development'){
16 | if (this.routes && this.getRoutes)
17 | throw new Error('you cannot define both routes() and getRoutes()')
18 | if (!this.routes && !this.getRoutes)
19 | throw new Error('you must define either routes() or getRoutes()')
20 | }
21 |
22 | this.router = new Router({
23 | component: this,
24 | staticRoutes: !!this.routes,
25 | getRoutes: this.routes || this.getRoutes
26 | })
27 | }
28 |
29 | componentWillUnmount(){
30 | this.router.unmount()
31 | }
32 |
33 | componentWillReceiveProps(nextProps){
34 | this.router.update(nextProps)
35 | }
36 |
37 | getChildContext() {
38 | return {
39 | location: this.router.location,
40 | redirectTo: this.router.redirectTo,
41 | }
42 | }
43 |
44 | render(){
45 | const { router } = this
46 | if (!router.location.params){
47 | return React.createElement('span', null, 'No Route Found')
48 | }
49 | const { Component } = router.location.params
50 | const props = Object.assign({}, this.props)
51 | props.location = router.location
52 | props.router = router;
53 | return React.createElement(Component, props)
54 | }
55 | }
56 |
57 |
58 |
59 | class Router {
60 | constructor({component, staticRoutes, getRoutes}){
61 | this.component = component
62 | this.resolve = staticRoutes ?
63 | staticResolver(getRoutes) :
64 | dynamicResolver(getRoutes)
65 | this.rerender = this.rerender.bind(this)
66 | this.redirectTo = this.redirectTo.bind(this)
67 | addEventListener('popstate', this.rerender)
68 | this.update(component.props)
69 | }
70 |
71 | update(props=this.component.props){
72 | this.location = new Location(window.location)
73 | this.location.params = this.resolve(this.location, props)
74 | this.component.location = this.location
75 | }
76 |
77 | rerender(){
78 | this.update()
79 | this.component.forceUpdate()
80 | }
81 |
82 | redirectTo(href, replace){
83 | if (replace){
84 | window.history.replaceState(null, document.title, href)
85 | }else{
86 | window.history.pushState(null, document.title, href)
87 | }
88 | this.rerender()
89 | }
90 |
91 | unmount(){
92 | removeEventListener('popstate', this.rerender)
93 | }
94 | }
95 |
96 | const staticResolver = (mapper) => {
97 | const router = instantiatePathnameRouter(mapper)
98 | return (location) => router.resolve(location.pathname)
99 | }
100 |
101 | const dynamicResolver = (mapper) => {
102 | return (location, props) =>
103 | instantiatePathnameRouter(mapper, props).resolve(location.pathname)
104 | }
105 |
106 | const instantiatePathnameRouter = (mapper, props) => {
107 | const router = new PathnameRouter
108 | const map = (path, Component, params={}) =>
109 | router.map(path, {Component, ...params})
110 | mapper.call(null, map, props)
111 | return router
112 | }
113 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Router from './Router'
2 | import Link from './Link'
3 | import withRedirectTo from './withRedirectTo';
4 |
5 | export {
6 | Router,
7 | Link,
8 | withRedirectTo,
9 | }
10 |
11 | export default Router
12 |
--------------------------------------------------------------------------------
/src/withRedirectTo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const withRedirectTo = Component => {
4 | const WithRedirectTo = (props, context) => (
5 |
6 | );
7 |
8 | WithRedirectTo.contextTypes = { redirectTo: () => null };
9 | return WithRedirectTo;
10 | };
11 |
12 | export default withRedirectTo;
13 |
--------------------------------------------------------------------------------
/test/.setup.js:
--------------------------------------------------------------------------------
1 | require('babel-register')();
2 |
3 | const { JSDOM } = require('jsdom');
4 | const jsdom = new JSDOM('');
5 | const { window } = jsdom;
6 |
7 | global.jsdom = jsdom;
8 | global.window = window;
9 | global.document = window.document;
10 | global.navigator = {
11 | userAgent: 'node.js',
12 | };
13 |
14 | Object.getOwnPropertyNames(window)
15 | .filter(prop => typeof global[prop] === 'undefined')
16 | .forEach(prop => {
17 | // if (prop === 'location') return
18 | Object.defineProperties(global, {
19 | [prop]: Object.getOwnPropertyDescriptor(window, prop)
20 | });
21 | })
22 |
23 | documentRef = document;
24 |
--------------------------------------------------------------------------------
/test/simple-react-router-test.js:
--------------------------------------------------------------------------------
1 | import URL from 'url'
2 | import querystring from 'querystring'
3 | import chai from 'chai'
4 | import sinon from 'sinon'
5 | chai.use(require("sinon-chai"))
6 | const { expect } = chai
7 | import React from 'react'
8 | import ReactDOM from 'react-dom'
9 | import Enzyme, { mount, shallow, render} from 'enzyme';
10 | import Adapter from 'enzyme-adapter-react-16'
11 | Enzyme.configure({ adapter: new Adapter() });
12 |
13 |
14 | import SimpleReactRouter from '../src/Router'
15 | import Link from '../src/Link'
16 |
17 | const only = (block) => context.only('', block)
18 |
19 | const setPath = (path) => {
20 | let { pathname, search, hash } = URL.parse(path)
21 | const url = "https://www.example.com"+path
22 | jsdom.reconfigure({url})
23 | }
24 |
25 | let subject = null
26 |
27 | const setSubject = (subjectGetter) => {
28 | beforeEach(() => {
29 | subject = subjectGetter()
30 | })
31 | }
32 |
33 | const itShouldBeAReactComponent = (component) => {
34 | it('should be a React Component', () => {
35 | expect(component).to.be.a('function')
36 | })
37 | }
38 |
39 | const whenAt = (path, block) => {
40 | const url = URL.parse(path)
41 | context(`when at ${path}`, () => {
42 | beforeEach(() => {
43 | setPath(path)
44 | })
45 | block()
46 | })
47 | }
48 |
49 | const itShouldRouteTo = (expectedRoute) => {
50 | it(`should route to ${JSON.stringify(expectedRoute)}`, () => {
51 | const { location } = mount(subject).instance().router
52 | expect(location).to.eql(expectedRoute)
53 | })
54 | }
55 |
56 | const itShouldRender = (Component) => {
57 | it(`should render <${Component.name} />`, () => {
58 | const mountedRouter = mount(subject)
59 | const props = Object.assign({}, mountedRouter.instance().props)
60 | props.location = mountedRouter.instance().router.location
61 | const expectedHTML = render().html()
62 | expect(render(subject).html()).to.eql(expectedHTML)
63 | })
64 | }
65 |
66 |
67 |
68 |
69 | const NotFound = (props) => NotFound {props.location.params.path}
70 | const HomePage = (props) => HomePage
71 | const SignupPage = (props) => SignupPage
72 | const LoginPage = (props) => LoginPage
73 | const LogoutPage = (props) => LogoutPage
74 | const PostIndexPage = (props) => PostIndexPage
75 | const NewPostPage = (props) => NewPostPage
76 | const PostShowPage = (props) => PostShowPage
77 | const PostEditPage = (props) => PostEditPage
78 |
79 | class StaticRouter extends SimpleReactRouter {
80 | routes(map){
81 | map('/', HomePage)
82 | map('/signup', SignupPage)
83 | map('/login', LoginPage)
84 | map('/logout', LogoutPage)
85 | map('/posts', PostIndexPage)
86 | map('/posts/new', NewPostPage)
87 | map('/posts/:postId', PostShowPage)
88 | map('/posts/:postId/edit', PostEditPage)
89 | map('/:path*', NotFound) // catchall route
90 | }
91 | }
92 |
93 |
94 | describe('StaticRouter', () => {
95 |
96 | setSubject(() => )
97 |
98 | itShouldBeAReactComponent(StaticRouter)
99 |
100 | whenAt('/', () => {
101 | itShouldRouteTo({
102 | pathname: '/',
103 | query: {},
104 | hash: null,
105 | params: {
106 | Component: HomePage,
107 | },
108 | })
109 |
110 | itShouldRender(HomePage)
111 |
112 | })
113 |
114 | whenAt('/signup', () => {
115 |
116 | itShouldRouteTo({
117 | pathname: '/signup',
118 | query: {},
119 | hash: null,
120 | params: {
121 | Component: SignupPage,
122 | },
123 | })
124 |
125 | itShouldRender(SignupPage)
126 |
127 | })
128 |
129 | whenAt('/login?return=/about#pricing', () => {
130 |
131 | itShouldRouteTo({
132 | pathname: '/login',
133 | query: {
134 | "return": "/about"
135 | },
136 | hash: "#pricing",
137 | params: {
138 | Component: LoginPage,
139 | },
140 | })
141 |
142 | itShouldRender(LoginPage)
143 |
144 | })
145 |
146 | whenAt('/posts/42', () => {
147 |
148 | itShouldRouteTo({
149 | pathname: '/posts/42',
150 | query: {},
151 | hash: null,
152 | params: {
153 | Component: PostShowPage,
154 | postId: "42",
155 | },
156 | })
157 |
158 | itShouldRender(PostShowPage)
159 |
160 | })
161 |
162 | whenAt('/posts/88/edit', () => {
163 |
164 | itShouldRouteTo({
165 | pathname: '/posts/88/edit',
166 | query: {},
167 | hash: null,
168 | params: {
169 | Component: PostEditPage,
170 | postId: "88",
171 | },
172 | })
173 |
174 | itShouldRender(PostEditPage)
175 |
176 | })
177 |
178 | whenAt('/some/unknown/path', () => {
179 |
180 | itShouldRouteTo({
181 | pathname: '/some/unknown/path',
182 | query: {},
183 | hash: null,
184 | params: {
185 | Component: NotFound,
186 | path: 'some/unknown/path',
187 | },
188 | })
189 |
190 | itShouldRender(NotFound)
191 |
192 | })
193 |
194 |
195 | })
196 |
197 |
198 |
199 |
200 |
201 |
202 | const LoggedInHomePage = (props) => LoggedInHomePage
203 | const LoggedOutHomePage = (props) => LoggedOutHomePage
204 |
205 | class DynamicRouter extends SimpleReactRouter {
206 | getRoutes(map, props){
207 | const { loggedIn } = props
208 | if (loggedIn){
209 | map('/', LoggedInHomePage)
210 | map('/logout', LogoutPage)
211 | map('/posts/new', NewPostPage)
212 | map('/posts/:postId/edit', PostEditPage)
213 | } else {
214 | map('/', LoggedOutHomePage)
215 | map('/signup', SignupPage)
216 | map('/login', LoginPage)
217 | }
218 | map('/posts', PostIndexPage)
219 | map('/posts/:postId', PostShowPage)
220 | map('/:path*', NotFound) // catchall route
221 | }
222 | }
223 |
224 |
225 | describe('DynamicRouter', () => {
226 |
227 | context('when not logged in', () => {
228 | setSubject(() => )
229 |
230 | whenAt('/', () => {
231 |
232 | itShouldRouteTo({
233 | pathname: '/',
234 | query: {},
235 | hash: null,
236 | params: {
237 | Component: LoggedOutHomePage,
238 | },
239 | })
240 |
241 | itShouldRender(LoggedOutHomePage)
242 |
243 | })
244 |
245 | whenAt('/signup', () => {
246 |
247 | itShouldRouteTo({
248 | pathname: '/signup',
249 | query: {},
250 | hash: null,
251 | params: {
252 | Component: SignupPage,
253 | },
254 | })
255 |
256 | itShouldRender(SignupPage)
257 |
258 | })
259 |
260 | whenAt('/login', () => {
261 |
262 | itShouldRouteTo({
263 | pathname: '/login',
264 | query: {},
265 | hash: null,
266 | params: {
267 | Component: LoginPage,
268 | },
269 | })
270 |
271 | itShouldRender(LoginPage)
272 |
273 | })
274 |
275 | whenAt('/posts', () => {
276 |
277 | itShouldRouteTo({
278 | pathname: '/posts',
279 | query: {},
280 | hash: null,
281 | params: {
282 | Component: PostIndexPage,
283 | },
284 | })
285 |
286 | itShouldRender(PostIndexPage)
287 |
288 | })
289 |
290 | whenAt('/posts/88/edit', () => {
291 |
292 | itShouldRouteTo({
293 | pathname: '/posts/88/edit',
294 | query: {},
295 | hash: null,
296 | params: {
297 | Component: NotFound,
298 | path: 'posts/88/edit'
299 | },
300 | })
301 |
302 | itShouldRender(NotFound)
303 |
304 | })
305 |
306 |
307 | whenAt('/posts/42', () => {
308 |
309 | itShouldRouteTo({
310 | pathname: '/posts/42',
311 | query: {},
312 | hash: null,
313 | params: {
314 | Component: PostShowPage,
315 | postId: "42",
316 | },
317 | })
318 |
319 | itShouldRender(PostShowPage)
320 |
321 | })
322 |
323 |
324 | whenAt('/some/unknown/path', () => {
325 |
326 | itShouldRouteTo({
327 | pathname: '/some/unknown/path',
328 | query: {},
329 | hash: null,
330 | params: {
331 | Component: NotFound,
332 | path: 'some/unknown/path',
333 | },
334 | })
335 |
336 | itShouldRender(NotFound)
337 |
338 | })
339 |
340 | })
341 |
342 | context('when logged in', () => {
343 | setSubject(() => )
344 |
345 | whenAt('/', () => {
346 |
347 | itShouldRouteTo({
348 | pathname: '/',
349 | query: {},
350 | hash: null,
351 | params: {
352 | Component: LoggedInHomePage,
353 | },
354 | })
355 |
356 | itShouldRender(LoggedInHomePage)
357 |
358 | })
359 |
360 | whenAt('/signup', () => {
361 |
362 | itShouldRouteTo({
363 | pathname: '/signup',
364 | query: {},
365 | hash: null,
366 | params: {
367 | Component: NotFound,
368 | path: 'signup',
369 | }
370 | })
371 |
372 | itShouldRender(NotFound)
373 |
374 | })
375 |
376 | whenAt('/login', () => {
377 |
378 | itShouldRouteTo({
379 | pathname: '/login',
380 | query: {},
381 | hash: null,
382 | params: {
383 | Component: NotFound,
384 | path: 'login'
385 | },
386 | })
387 |
388 | itShouldRender(NotFound)
389 |
390 | })
391 |
392 | whenAt('/posts', () => {
393 |
394 | itShouldRouteTo({
395 | pathname: '/posts',
396 | query: {},
397 | hash: null,
398 | params: {
399 | Component: PostIndexPage,
400 | },
401 | })
402 |
403 | itShouldRender(PostIndexPage)
404 |
405 | })
406 |
407 | whenAt('/posts/88/edit', () => {
408 |
409 | itShouldRouteTo({
410 | pathname: '/posts/88/edit',
411 | query: {},
412 | hash: null,
413 | params: {
414 | Component: PostEditPage,
415 | postId: '88'
416 | },
417 | })
418 |
419 | itShouldRender(PostEditPage)
420 |
421 | })
422 |
423 |
424 | whenAt('/posts/42', () => {
425 |
426 | itShouldRouteTo({
427 | pathname: '/posts/42',
428 | query: {},
429 | hash: null,
430 | params: {
431 | Component: PostShowPage,
432 | postId: "42",
433 | },
434 | })
435 |
436 | itShouldRender(PostShowPage)
437 |
438 | })
439 |
440 |
441 | whenAt('/some/unknown/path', () => {
442 |
443 | itShouldRouteTo({
444 | pathname: '/some/unknown/path',
445 | query: {},
446 | hash: null,
447 | params: {
448 | Component: NotFound,
449 | path: 'some/unknown/path',
450 | },
451 | })
452 |
453 | itShouldRender(NotFound)
454 |
455 | })
456 | })
457 |
458 | })
459 |
460 | describe('Location', () => {
461 |
462 | let location
463 | beforeEach(() => {
464 | setPath('/posts/23/edit?order=desc')
465 | const mountPoint = mount()
466 | location = mountPoint.instance().router.location
467 | })
468 |
469 | it('should', ()=> {
470 | expect(location).to.eql({
471 | pathname: '/posts/23/edit',
472 | query: {
473 | "order": "desc",
474 | },
475 | hash: null,
476 | params: {
477 | Component: PostEditPage,
478 | postId: '23'
479 | },
480 | })
481 |
482 | expect(location.toString()).to.eql('/posts/23/edit?order=desc')
483 |
484 | expect(location.hrefFor({})).to.eql('/posts/23/edit?order=desc')
485 |
486 | expect(location.hrefFor({
487 | pathname: '/foo/bar'
488 | })).to.eql('/foo/bar?order=desc')
489 |
490 | expect(location.hrefFor({
491 | pathname: '/foo/bar',
492 | query: {order: 'asc'},
493 | })).to.eql('/foo/bar?order=asc')
494 |
495 | })
496 |
497 | })
498 |
499 | describe('Link', () => {
500 |
501 | class LinkTestRouter extends SimpleReactRouter {
502 | routes(map){
503 | map('/a', () => )
504 | map('/b', () => )
505 | map('/c', () => )
506 | map('/d', () => )
507 | map('/e', () => )
508 | }
509 | }
510 |
511 | let replaceStateSpy, pushStateSpy
512 |
513 | const theLinkShouldUseTheRouter = () => {
514 | it(`should use the router`, () => {
515 | const link = mount(subject).find('a')
516 | link.simulate('click')
517 | expect(replaceStateSpy).to.have.not.been.called
518 | expect(pushStateSpy).to.have.been.calledWith(null, '', link.props().href)
519 | })
520 | }
521 |
522 | const theLinkShouldNotPreventDefault = () => {
523 | it(`should use the router`, () => {
524 | mount(subject).find('a').simulate('click')
525 | expect(pushStateSpy).to.have.not.been.called
526 | expect(replaceStateSpy).to.have.not.been.called
527 | })
528 | }
529 |
530 |
531 | beforeEach(function(){
532 | replaceStateSpy = sinon.spy(window.history, 'replaceState')
533 | pushStateSpy = sinon.spy(window.history, 'pushState')
534 | })
535 |
536 | afterEach(function(){
537 | replaceStateSpy.restore()
538 | pushStateSpy.restore()
539 | })
540 |
541 | setSubject(() => )
542 |
543 | whenAt('/a', function(){
544 | theLinkShouldUseTheRouter()
545 | })
546 |
547 | whenAt('/b', function(){
548 | theLinkShouldUseTheRouter()
549 | })
550 |
551 | whenAt('/c', function(){
552 | theLinkShouldNotPreventDefault()
553 | })
554 |
555 | whenAt('/d', function(){
556 | theLinkShouldNotPreventDefault()
557 | })
558 |
559 | whenAt('/e', function(){
560 | it('should be able to be rendered as a