├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── setup-jest.js └── src ├── CoreLink.jsx ├── CoreLink.test.jsx ├── RelativeLink.jsx ├── RelativeLink.test.jsx ├── RelativeNavLink.jsx └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-2"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "jest": true 5 | }, 6 | "parser": "babel-eslint" 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore lib 2 | lib 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # Commenting this out is preferred by some people, see 27 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 28 | node_modules 29 | 30 | # Users Environment Variables 31 | .lock-wscript 32 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore Src 2 | src 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # Commenting this out is preferred by some people, see 27 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 28 | node_modules 29 | 30 | # Users Environment Variables 31 | .lock-wscript 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | script: npm run posttest 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2017 Donavon West 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 | # react-router-relative-link 2 | [![Build Status](https://travis-ci.org/donavon/react-router-relative-link.svg?branch=master)](https://travis-ci.org/donavon/react-router-relative-link) 3 | 4 | TL;DR 5 | 6 | * A wrapper around react-router's `` and `` that allows relative paths. 7 | * **Now supports `react-router-dom` version 4!** 8 | 9 | ## Install 10 | ``` 11 | > npm i react-router-relative-link --save 12 | ``` 13 | 14 | ## Usage 15 | To use `react-router-relative-link`, simply `import` it (ES6) as `Link` in place of `react-router-dom` 16 | then dot and dot-dot to your heart's content. 17 | 18 | So in your code, replace this: 19 | ```js 20 | import { Link } from "react-router-dom"; 21 | ``` 22 | with the following and you're good to go! 23 | ```js 24 | import { Link } from "react-router-relative-link"; 25 | ``` 26 | 27 | Here is a real world example. Notice that you don't need to know that you are at the base base `/zoo`, just like everywhere else in web land. 28 | 29 | ```js 30 | import { Link } from "react-router-relative-link"; 31 | 32 | export default class MyZoo extends React.Component { 33 | render() { 34 | return ( 35 |

Welcome to the Lions Den at /zoo/lions

36 | Back to the Zoo Entrance 37 | Visit the Giraffes 38 | Visit the Monkeys 39 | Visit the Mountain Lions 40 | ); 41 | } 42 | } 43 | ``` 44 | 45 | `react-router-relative-link` support passing `to` as a string or as an object with a `pathname` property, just like `react-router`. 46 | 47 | It also works with both `Link` and with `NavLink`. 48 | 49 | ### Does it Work? 50 | 51 | Of course it does, and I have the tests to prove it! 52 | See the [test results](https://travis-ci.org/donavon/react-router-relative-link?branch=master) here. 53 | 54 | You can also see it running live on 55 | [this CodeSandbox](https://codesandbox.io/s/pkpw96w4nq). 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.0.2", 3 | "name": "react-router-relative-link", 4 | "description": "A wrapper around react-router's Link that allows relative paths.", 5 | "keywords": [ 6 | "relative", 7 | "path", 8 | "pathname", 9 | "router", 10 | "react-router", 11 | "react-router-relative-link", 12 | "react", 13 | "react-component" 14 | ], 15 | "main": "./lib/index.js", 16 | "scripts": { 17 | "prepublish": "npm run lint && npm run _build && npm run _test", 18 | "build": "npm run lint && npm run _build", 19 | "_build": "babel src --out-dir lib", 20 | "test": "npm run lint && npm run _test", 21 | "posttest": "cowsay Your tests all passed!", 22 | "_test": "jest", 23 | "test:watch": "jest --watch", 24 | "lint": "eslint src" 25 | }, 26 | "author": { 27 | "name": "Donavon West", 28 | "email": "github@donavon.com", 29 | "url": "http://donavon.com" 30 | }, 31 | "license": "MIT", 32 | "peerDependencies": { 33 | "react": "^15.4.2 || ^16.0.0", 34 | "react-router-dom": "^4.1.2" 35 | }, 36 | "dependencies": { 37 | "prop-types": "^15.5.10", 38 | "resolve-pathname": "^2.1.0" 39 | }, 40 | "devDependencies": { 41 | "babel-cli": "^6.24.1", 42 | "babel-eslint": "^8.0.3", 43 | "babel-preset-es2015": "^6.24.1", 44 | "babel-preset-react": "^6.24.1", 45 | "babel-preset-stage-2": "^6.24.1", 46 | "coveralls": "^3.0.0", 47 | "cowsay": "^1.1.9", 48 | "enzyme": "^3.2.0", 49 | "enzyme-adapter-react-16": "^1.1.0", 50 | "eslint": "^4.13.1", 51 | "eslint-config-airbnb": "^16.1.0", 52 | "eslint-plugin-import": "^2.3.0", 53 | "eslint-plugin-jsx-a11y": "^6.0.3", 54 | "eslint-plugin-react": "^7.1.0", 55 | "jest": "^21.3.0-beta.15", 56 | "react": "^16.2.0", 57 | "react-dom": "^16.2.0", 58 | "react-router-dom": "^4.2.2", 59 | "react-test-renderer": "^16.2.0" 60 | }, 61 | "jest": { 62 | "setupTestFrameworkScriptFile": "/setup-jest.js" 63 | }, 64 | "repository": { 65 | "type": "git", 66 | "url": "git://github.com/donavon/react-router-relative-link.git" 67 | }, 68 | "bugs": { 69 | "url": "https://github.com/donavon/react-router-relative-link/issues" 70 | }, 71 | "homepage": "https://github.com/donavon/react-router-relative-link" 72 | } 73 | -------------------------------------------------------------------------------- /setup-jest.js: -------------------------------------------------------------------------------- 1 | import { configure, mount } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | 6 | global.mount = mount; 7 | -------------------------------------------------------------------------------- /src/CoreLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import resolvePathname from 'resolve-pathname'; 4 | 5 | const ensureLeadingAndTrailingSlashes = path => path.replace(/^\/?/, '/').replace(/\/?$/, '/'); 6 | const removeTrailingSlash = path => path.replace(/\/$/, '') || '/'; 7 | const removeQuery = path => path.split('?')[0]; 8 | const resolvePathnameNoTrailingSlash = (path, currentPath) => removeTrailingSlash(resolvePathname(`${path}`, currentPath)); 9 | 10 | const extractCurrentPath = currentPath => ( 11 | ensureLeadingAndTrailingSlashes(removeQuery(currentPath)) 12 | ); 13 | 14 | const CoreLink = ({ BaseComponent, match, to: relativeTo, staticContext, ...others }) => { 15 | const currentPath = extractCurrentPath(match.url); 16 | const to = typeof relativeTo === 'object' 17 | ? { 18 | ...relativeTo, 19 | pathname: resolvePathnameNoTrailingSlash(relativeTo.pathname, currentPath), 20 | } 21 | : resolvePathnameNoTrailingSlash(relativeTo, currentPath); 22 | 23 | return ; 24 | }; 25 | 26 | CoreLink.propTypes = { 27 | BaseComponent: PropTypes.func.isRequired, 28 | staticContext: PropTypes.object, 29 | match: PropTypes.shape({ 30 | url: PropTypes.string, 31 | }).isRequired, 32 | to: PropTypes.oneOfType([ 33 | PropTypes.string, 34 | PropTypes.shape({ 35 | pathname: PropTypes.string, 36 | search: PropTypes.string, 37 | hash: PropTypes.string, 38 | }), 39 | ]).isRequired, 40 | }; 41 | 42 | export default CoreLink; 43 | -------------------------------------------------------------------------------- /src/CoreLink.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import CoreLink from './CoreLink'; 4 | 5 | const noop = () => null; 6 | 7 | const Component = props => ( 8 | 9 | ); 10 | 11 | describe('CoreLink', () => { 12 | describe('when the current path is "/zoo"', () => { 13 | const match = { url: '/zoo' }; 14 | let baseLinkSpy; 15 | beforeEach(() => { 16 | baseLinkSpy = jest.fn().mockImplementation(noop); 17 | }); 18 | afterEach(() => { 19 | baseLinkSpy.mockRestore(); 20 | }); 21 | it('should call the base Link once when rendered', () => { 22 | mount(); 23 | expect(baseLinkSpy).toHaveBeenCalledTimes(1); 24 | }); 25 | it('should not pass "match" to the base Link', () => { 26 | mount(); 27 | const props = baseLinkSpy.mock.calls[0][0]; 28 | expect(props.match).toBe(undefined); 29 | }); 30 | it('resolves to "/zoo/lions" when given "lions"', () => { 31 | mount(); 32 | const props = baseLinkSpy.mock.calls[0][0]; 33 | expect(props.to).toBe('/zoo/lions'); 34 | }); 35 | it('resolves to "/zoo/lions" when given "./lions"', () => { 36 | mount(); 37 | const props = baseLinkSpy.mock.calls[0][0]; 38 | expect(props.to).toBe('/zoo/lions'); 39 | }); 40 | it('resolves to "/" when given "/"', () => { 41 | mount(); 42 | const props = baseLinkSpy.mock.calls[0][0]; 43 | expect(props.to).toBe('/'); 44 | }); 45 | it('resolves to "/bar" when given "/bar"', () => { 46 | mount(); 47 | const props = baseLinkSpy.mock.calls[0][0]; 48 | expect(props.to).toBe('/bar'); 49 | }); 50 | }); 51 | 52 | describe('when the current path is "/zoo/lions"', () => { 53 | const match = { url: '/zoo/lions' }; 54 | let baseLinkSpy; 55 | beforeEach(() => { 56 | baseLinkSpy = jest.fn().mockImplementation(noop); 57 | }); 58 | afterEach(() => { 59 | baseLinkSpy.mockRestore(); 60 | }); 61 | it('resolves to "/zoo/giraffes" when given "../giraffes"', () => { 62 | mount(); 63 | const props = baseLinkSpy.mock.calls[0][0]; 64 | expect(props.to).toBe('/zoo/giraffes'); 65 | }); 66 | it('resolves to "/zoo/lions/mountain" when given "../giraffes"', () => { 67 | mount(); 68 | const props = baseLinkSpy.mock.calls[0][0]; 69 | expect(props.to).toBe('/zoo/lions/mountain'); 70 | }); 71 | it('resolves to "/zoo" when given ".."', () => { 72 | mount(); 73 | const props = baseLinkSpy.mock.calls[0][0]; 74 | expect(props.to).toBe('/zoo'); 75 | }); 76 | it('resolves to "/zoo/lions" when given "."', () => { 77 | mount(); 78 | const props = baseLinkSpy.mock.calls[0][0]; 79 | expect(props.to).toBe('/zoo/lions'); 80 | }); 81 | }); 82 | 83 | describe('when the current path is "/"', () => { 84 | const match = { url: '/' }; 85 | let baseLinkSpy; 86 | beforeEach(() => { 87 | baseLinkSpy = jest.fn().mockImplementation(noop); 88 | }); 89 | afterEach(() => { 90 | baseLinkSpy.mockRestore(); 91 | }); 92 | it('resolves to "/zoo" when given "zoo"', () => { 93 | mount(); 94 | const props = baseLinkSpy.mock.calls[0][0]; 95 | expect(props.to).toBe('/zoo'); 96 | }); 97 | it('resolves to "/zoo" when given "/zoo"', () => { 98 | mount(); 99 | const props = baseLinkSpy.mock.calls[0][0]; 100 | expect(props.to).toBe('/zoo'); 101 | }); 102 | it('resolves to "/zoo" when given "/zoo/"', () => { 103 | mount(); 104 | const props = baseLinkSpy.mock.calls[0][0]; 105 | expect(props.to).toBe('/zoo'); 106 | }); 107 | it('resolves to "/zoo" when given "zoo/"', () => { 108 | mount(); 109 | const props = baseLinkSpy.mock.calls[0][0]; 110 | expect(props.to).toBe('/zoo'); 111 | }); 112 | }); 113 | 114 | describe('when changing props', () => { 115 | const match = { url: '/foo' }; 116 | let baseLinkSpy; 117 | beforeEach(() => { 118 | baseLinkSpy = jest.fn().mockImplementation(noop); 119 | }); 120 | afterEach(() => { 121 | baseLinkSpy.mockRestore(); 122 | }); 123 | it('respects the new value of "match"', () => { 124 | const wrapper = mount(); 125 | wrapper.setProps({ match: { url: '/baz' }, to: 'bar' }); 126 | const props = baseLinkSpy.mock.calls[1][0]; 127 | expect(props.to).toBe('/baz/bar'); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/RelativeLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, withRouter } from 'react-router-dom'; 3 | import CoreLink from './CoreLink'; 4 | 5 | const RelativeLink = props => ( 6 | 7 | ); 8 | 9 | export default withRouter(RelativeLink); 10 | -------------------------------------------------------------------------------- /src/RelativeLink.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StaticRouter } from 'react-router-dom'; 3 | import RelativeLink from './RelativeLink'; 4 | 5 | describe('RelativeLink', () => { 6 | it('should not cause errors', () => { 7 | const spy = jest.spyOn(global.console, 'error'); 8 | const wrapper = mount( 9 | 10 | 11 | 12 | ); 13 | 14 | expect(spy.mock.calls.length).toBe(0); 15 | spy.mockRestore(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/RelativeNavLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink, withRouter } from 'react-router-dom'; 3 | import CoreLink from './CoreLink'; 4 | 5 | const RelativeNavLink = props => ( 6 | 7 | ); 8 | 9 | export default withRouter(RelativeNavLink); 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Link from './RelativeLink'; 2 | import NavLink from './RelativeNavLink'; 3 | 4 | export { Link, NavLink }; 5 | --------------------------------------------------------------------------------