Skip to content

Resolve relative paths #4122

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions modules/Link.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { PropTypes } from 'react'
import { resolveLocation } from './LocationUtils'

class Link extends React.Component {
static defaultProps = {
Expand Down Expand Up @@ -56,8 +57,8 @@ class Link extends React.Component {
event.preventDefault()

const { router } = this.context
const { to, replace } = this.props

const { replace } = this.props
const to = this.absoluteLocation()
if (replace) {
router.replaceWith(to)
} else {
Expand All @@ -66,8 +67,21 @@ class Link extends React.Component {
}
}

absoluteLocation = () => {
const { router } = this.context
let { to } = this.props
let base
// use the context.router's match if it exists
if (router.match) {
const matchState = router.match.getState()
base = matchState && matchState.match.pathname
}
return resolveLocation(to, base)
}

getIsActive() {
const { to, isActive } = this.props
const { isActive } = this.props
const to = this.absoluteLocation()

return isActive(
this.context.router.getState().location,
Expand All @@ -79,7 +93,7 @@ class Link extends React.Component {
render() {
const { isActive } = this.state
const {
to,
to: undefTo, // eslint-disable-line
style, activeStyle,
className, activeClassName,
activeOnlyWhenExact, // eslint-disable-line
Expand All @@ -88,6 +102,8 @@ class Link extends React.Component {
...rest
} = this.props

const to = this.absoluteLocation()

return (
<a
{...rest}
Expand Down
111 changes: 111 additions & 0 deletions modules/LocationUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,114 @@ export const createRouterPath = (input, stringifyQuery) => {
)
})
}

export const resolveLocation = (location, base) => {
if (!isRelative(location)) {
return location
}

if (typeof location === 'string') {
return resolve(location, base)
} else {
location.pathname = resolve(location.pathname, base)
return location
}
}

const isRelative = (location) => {
if (typeof location === 'string') {
return location.charAt(0) !== '/'
} else {
const { pathname, query, search, hash } = location
// If there is no pathname, the location should still be
// considered relative if it has a query, search, or hash
// (but not an empty query)
if (!pathname) {
return (query && Object.keys(query).length) || !!search || !!hash
}
return pathname.charAt(0) !== '/'
}
}

// works similarly, but not exactly the same as RFC 1808
// https://tools.ietf.org/html/rfc1808#section-4
// base is the base URL and path is the URL to resolve.
// this differentiates from the RFC because it treats
// the url pattern "foo/bar" the same as "foo/bar/" where the
// relative path will be joined after "bar"
const resolve = (path, base = '') => {
if (path === undefined) {
return base
} else if (base === '') {
return '/' + path
}

// RFC 1808 drops the last base segment (step 6) and joins off
// of its parent. This does not happen in here because the last
// segment of the base because we want to join off of the last
// segment. If the base ends in a forward slash, strip it so that
// we join off of the segment before that instead.
if (base[base.length-1] === '/') {
base = base.slice(0,-1)
}

const baseSegments = splitSegments(base)
const { pathname, extra } = removeExtra(path)
// don't need to resolve if there is no pathname
if (pathname === '') {
return base + extra
}

// filter out all ./ segments (step 6.a)
const relativeSegments = splitSegments(pathname).filter(s => s !== '.')
return joinSegments([...baseSegments, ...relativeSegments]) + extra
}

// remove any '//' from the path because this doesn't mean anything
// and will interfere in replacement
const splitSegments = (path) => path.replace('//','/').split('/')

// if the original location was a string (not a descriptor), then the search
// or hash can still be attached to the path, so remove it before resolving
const removeExtra = (path) => {
const hashIndex = path.indexOf('#')
const searchIndex = path.indexOf('?')
let splitIndex
if (searchIndex >= 0 && hashIndex >= 0) {
splitIndex = Math.min(searchIndex, hashIndex)
} else if (searchIndex >=0) {
splitIndex = searchIndex
} else if (hashIndex >= 0) {
splitIndex = hashIndex
}

return {
pathname: splitIndex !== undefined ? path.slice(0, splitIndex) : path,
extra: splitIndex !== undefined ? path.slice(splitIndex) : ''
}
}

const joinSegments = (segments) => {
// (step 6.c)
const output = []
const length = segments.length
for (let i=0; i<length; i++) {
const curr = segments[i]
if (curr !== '..') {
output.push(curr)
continue
}
// remove <segment>/.. but not ../.. (or /.., but the only empty
// string segment should be the root segment). To do this,
// we want to verify that the last kept item is not a '..' or a ''.
// If it is a '..', that means that we already failed to match
// (['', 'foo', '..'] will never occur in the output array)
const last = output[output.length-1]
if (last !== '..' && last !== '') {
output.pop()
} else {
output.push('..')
}
}
return output.join('/')
}
10 changes: 9 additions & 1 deletion modules/Redirect.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { PropTypes } from 'react'
import { resolveLocation } from './LocationUtils'

class Redirect extends React.Component {
static defaultProps = {
Expand Down Expand Up @@ -34,7 +35,14 @@ class Redirect extends React.Component {

redirect() {
const { router } = this.context
const { to, push } = this.props
let { to } = this.props
const { push } = this.props
let base
if (router.match) {
const matchState = router.match.getState()
base = matchState && matchState.match.pathname
}
to = resolveLocation(to, base)
const navigate = push ? router.transitionTo : router.replaceWith
navigate(to)
}
Expand Down
1 change: 1 addition & 0 deletions modules/__tests__/HashRouter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('HashRouter', () => {
const div = document.createElement('div')

afterEach(() => {
history.replaceState(null, document.title, '#')
unmountComponentAtNode(div)
})

Expand Down
48 changes: 48 additions & 0 deletions modules/__tests__/Link-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from '../Link'
import MemoryRouter from '../MemoryRouter'
import StaticRouter from '../StaticRouter'
import { render, unmountComponentAtNode } from 'react-dom'
import Match from '../Match'
import { Simulate } from 'react-addons-test-utils'

const { click } = Simulate
Expand All @@ -29,6 +30,40 @@ describe('Link', () => {
expect(div.querySelector('a').getAttribute('href')).toEqual('/foo')
})

describe('relative path', () => {
it('works with search/hash pathnames', () => {
const pathname = 'test?this/../isfine'
const div = document.createElement('div')
render(<LinkInContext {...requiredProps} to={pathname}/>, div)
expect(div.querySelector('a').getAttribute('href')).toEqual(`/${pathname}`)
})

describe('with context.match', () => {
const BASE = '/a/b'
const LinkInMatch = ({pattern = BASE, ...props}) => (
<MemoryRouter initialEntries={[ pattern ]}>
<Match pattern={pattern} render={() => (
<Link {...props}/>
)} />
</MemoryRouter>
)

it('resolves using parent pathname', () => {
const div = document.createElement('div')
render(<LinkInMatch to="foo"/>, div)
expect(div.querySelector('a').getAttribute('href')).toEqual('/a/b/foo')
})
})

describe('without context.match', () => {
it('resolves using root', () => {
const div = document.createElement('div')
render(<LinkInContext {...requiredProps} to="foo"/>, div)
expect(div.querySelector('a').getAttribute('href')).toEqual('/foo')
})
})
})

describe('with context.router', () => {
it('uses router.createHref to build the href', () => {
const CONTEXT_HREF = 'CONTEXT_HREF'
Expand Down Expand Up @@ -223,6 +258,19 @@ describe('Link', () => {
const a = div.querySelector('a')
expect(a.className).toEqual('active')
})

it('works with relative links', () => {
const div = document.createElement('div')
render((
<LinkInContext
to='foo'
location={{ pathname: '/foo' }}
activeClassName="active"
/>
), div)
const a = div.querySelector('a')
expect(a.className).toEqual('active')
})
})
})

Expand Down
111 changes: 110 additions & 1 deletion modules/__tests__/LocationUtils-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import expect from 'expect'
import { parse, stringify } from 'query-string'
import { createRouterLocation, createRouterPath } from '../LocationUtils'
import {
createRouterLocation,
createRouterPath,
resolveLocation
} from '../LocationUtils'

describe('LocationUtils', () => {
describe('createRouterLocation', () => {
Expand Down Expand Up @@ -195,4 +199,109 @@ describe('LocationUtils', () => {
})
})
})

describe('resolveLocation', () => {
const BASE = '/a/b'

describe('string location', () => {
it('returns path if absolute', () => {
const path = '/foo'
expect(resolveLocation(path)).toBe(path)
})
})

describe('object location', () => {
it('does not affect the pathname if absolute', () => {
const descriptor = { pathname: '/foo' }
expect(resolveLocation(descriptor).pathname).toBe(descriptor.pathname)
})

describe('no pathname', () => {
it('resolves to base if there is a query', () => {
const descriptor = {
query: {a: 'b'}
}
expect(resolveLocation(descriptor, BASE).pathname).toBe(BASE)
})

it('ignores empty query when determining if relative', () => {
const descriptor = {
query: {}
}
expect(resolveLocation(descriptor, BASE).pathname).toBe(undefined)
})

it('resolves to base if there is a query', () => {
const descriptor = {
search: '?a=b'
}
expect(resolveLocation(descriptor, BASE).pathname).toBe(BASE)
})

it('resolves to base if there is a hash', () => {
const descriptor = {
hash: '#foo'
}
expect(resolveLocation(descriptor, BASE).pathname).toBe(BASE)
})
})
})

it('removes unnecessary segments', () => {
const input = 'foo//../bar'
expect(resolveLocation(input, BASE)).toEqual('/a/b/bar')
})

describe('rfc1808', () => {

// https://tools.ietf.org/html/rfc1808#section-5.1
it('passes normal examples', () => {
const cases = [
{ input: 'g', output: '/a/b/g' },
{ input: './g', output: '/a/b/g' },
{ input: 'g/', output: '/a/b/g/' },
{ input: '/g', output: '/g' },
{ input: '?y', output: '/a/b?y' },
{ input: 'g?y', output: '/a/b/g?y' },
{ input: 'g?y/./x', output: '/a/b/g?y/./x' },
{ input: '#s', output: '/a/b#s' },
{ input: 'g#s', output: '/a/b/g#s' },
{ input: 'g#s/./x', output: '/a/b/g#s/./x' },
{ input: 'g?y#s', output: '/a/b/g?y#s' },
{ input: '.', output: '/a/b' },
{ input: './', output: '/a/b/' },
{ input: '..', output: '/a' },
{ input: '../', output: '/a/' },
{ input: '../g', output: '/a/g' },
{ input: '../..', output: '' },
{ input: '../../', output: '/' },
{ input: '../../g', output: '/g' }
]
cases.forEach(test => {
expect(resolveLocation(test.input, BASE)).toBe(test.output)
})
})

// https://tools.ietf.org/html/rfc1808#section-5.2
it('passes abnormal examples', () => {
const cases = [
{ input: '../../g', output: '/g' },
{ input: '../../../g', output: '/../g' },
{ input: '/./g', output: '/./g' },
{ input: '/../g', output: '/../g' },
{ input: 'g.', output: '/a/b/g.' },
{ input: '.g', output: '/a/b/.g' },
{ input: 'g..', output: '/a/b/g..' },
{ input: '..g', output: '/a/b/..g' },
{ input: './../g', output: '/a/g' },
{ input: './g/.', output: '/a/b/g' },
{ input: 'g/./h', output: '/a/b/g/h' },
{ input: 'g/../h', output: '/a/b/h' }
]
cases.forEach(test => {
expect(resolveLocation(test.input, BASE)).toBe(test.output)
})
})
})
})
})
Loading