未验证 提交 54d991e6 编写于 作者: J Jan Potoms 提交者: GitHub

fix basepath trailing slash (#15200)

Fixes the link rewriting part of https://github.com/vercel/next.js/issues/15194
上级 1fe612e8
import { UrlObject } from 'url'
/** /**
* Removes the trailing slash of a path if there is one. Preserves the root path `/`. * Removes the trailing slash of a path if there is one. Preserves the root path `/`.
*/ */
...@@ -11,7 +9,7 @@ export function removePathTrailingSlash(path: string): string { ...@@ -11,7 +9,7 @@ export function removePathTrailingSlash(path: string): string {
* Normalizes the trailing slash of a path according to the `trailingSlash` option * Normalizes the trailing slash of a path according to the `trailingSlash` option
* in `next.config.js`. * in `next.config.js`.
*/ */
const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH export const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH
? (path: string): string => { ? (path: string): string => {
if (/\.[^/]+\/?$/.test(path)) { if (/\.[^/]+\/?$/.test(path)) {
return removePathTrailingSlash(path) return removePathTrailingSlash(path)
...@@ -22,18 +20,3 @@ const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH ...@@ -22,18 +20,3 @@ const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH
} }
} }
: removePathTrailingSlash : removePathTrailingSlash
/**
* Normalizes the trailing slash of the path of a parsed url. Non-destructive.
*/
export function normalizeTrailingSlash(url: URL): URL
export function normalizeTrailingSlash(url: UrlObject): UrlObject
export function normalizeTrailingSlash(url: UrlObject | URL): UrlObject | URL {
const normalizedPath =
url.pathname && normalizePathTrailingSlash(url.pathname)
return url.pathname === normalizedPath
? url
: url instanceof URL
? Object.assign(new URL(url.href), { pathname: normalizedPath })
: Object.assign({}, url, { pathname: normalizedPath })
}
...@@ -18,14 +18,18 @@ import { getRouteRegex } from './utils/route-regex' ...@@ -18,14 +18,18 @@ import { getRouteRegex } from './utils/route-regex'
import { searchParamsToUrlQuery } from './utils/search-params-to-url-query' import { searchParamsToUrlQuery } from './utils/search-params-to-url-query'
import { parseRelativeUrl } from './utils/parse-relative-url' import { parseRelativeUrl } from './utils/parse-relative-url'
import { import {
normalizeTrailingSlash,
removePathTrailingSlash, removePathTrailingSlash,
normalizePathTrailingSlash,
} from '../../../client/normalize-trailing-slash' } from '../../../client/normalize-trailing-slash'
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || '' const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
export function addBasePath(path: string): string { export function addBasePath(path: string): string {
return basePath ? (path === '/' ? basePath : basePath + path) : path return basePath
? path === '/'
? normalizePathTrailingSlash(basePath)
: basePath + path
: path
} }
export function delBasePath(path: string): string { export function delBasePath(path: string): string {
...@@ -47,7 +51,8 @@ export function resolveHref(currentPath: string, href: Url): string { ...@@ -47,7 +51,8 @@ export function resolveHref(currentPath: string, href: Url): string {
const base = new URL(currentPath, 'http://n') const base = new URL(currentPath, 'http://n')
const urlAsString = const urlAsString =
typeof href === 'string' ? href : formatWithValidation(href) typeof href === 'string' ? href : formatWithValidation(href)
const finalUrl = normalizeTrailingSlash(new URL(urlAsString, base)) const finalUrl = new URL(urlAsString, base)
finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname)
// if the origin didn't change, it means we received a relative href // if the origin didn't change, it means we received a relative href
return finalUrl.origin === base.origin return finalUrl.origin === base.origin
? finalUrl.href.slice(finalUrl.origin.length) ? finalUrl.href.slice(finalUrl.origin.length)
......
...@@ -2,4 +2,5 @@ module.exports = { ...@@ -2,4 +2,5 @@ module.exports = {
experimental: { experimental: {
// <placeholder> // <placeholder>
}, },
// basePath: '/docs',
} }
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
launchApp, launchApp,
nextBuild, nextBuild,
nextStart, nextStart,
File,
} from 'next-test-utils' } from 'next-test-utils'
import { join } from 'path' import { join } from 'path'
...@@ -20,7 +21,7 @@ jest.setTimeout(1000 * 60 * 2) ...@@ -20,7 +21,7 @@ jest.setTimeout(1000 * 60 * 2)
let app let app
let appPort let appPort
const appDir = join(__dirname, '../') const appDir = join(__dirname, '../')
const nextConfig = join(appDir, 'next.config.js') const nextConfig = new File(join(appDir, 'next.config.js'))
function testShouldRedirect(expectations) { function testShouldRedirect(expectations) {
it.each(expectations)( it.each(expectations)(
...@@ -70,8 +71,8 @@ function testShouldResolve(expectations) { ...@@ -70,8 +71,8 @@ function testShouldResolve(expectations) {
function testLinkShouldRewriteTo(expectations) { function testLinkShouldRewriteTo(expectations) {
it.each(expectations)( it.each(expectations)(
'%s should have href %s', '%s should have href %s',
async (href, expectedHref) => { async (linkPage, expectedHref) => {
const content = await renderViaHTTP(appPort, `/linker?href=${href}`) const content = await renderViaHTTP(appPort, linkPage)
const $ = cheerio.load(content) const $ = cheerio.load(content)
expect($('#link').attr('href')).toBe(expectedHref) expect($('#link').attr('href')).toBe(expectedHref)
} }
...@@ -79,10 +80,10 @@ function testLinkShouldRewriteTo(expectations) { ...@@ -79,10 +80,10 @@ function testLinkShouldRewriteTo(expectations) {
it.each(expectations)( it.each(expectations)(
'%s should navigate to %s', '%s should navigate to %s',
async (href, expectedHref) => { async (linkPage, expectedHref) => {
let browser let browser
try { try {
browser = await webdriver(appPort, `/linker?href=${href}`) browser = await webdriver(appPort, linkPage)
await browser.elementByCss('#link').click() await browser.elementByCss('#link').click()
await browser.waitForElementByCss('#hydration-marker') await browser.waitForElementByCss('#hydration-marker')
...@@ -97,10 +98,10 @@ function testLinkShouldRewriteTo(expectations) { ...@@ -97,10 +98,10 @@ function testLinkShouldRewriteTo(expectations) {
it.each(expectations)( it.each(expectations)(
'%s should push route to %s', '%s should push route to %s',
async (href, expectedHref) => { async (linkPage, expectedHref) => {
let browser let browser
try { try {
browser = await webdriver(appPort, `/linker?href=${href}`) browser = await webdriver(appPort, linkPage)
await browser.elementByCss('#route-pusher').click() await browser.elementByCss('#route-pusher').click()
await browser.waitForElementByCss('#hydration-marker') await browser.waitForElementByCss('#hydration-marker')
...@@ -134,13 +135,13 @@ function testWithoutTrailingSlash() { ...@@ -134,13 +135,13 @@ function testWithoutTrailingSlash() {
]) ])
testLinkShouldRewriteTo([ testLinkShouldRewriteTo([
['/', '/'], ['/linker?href=/', '/'],
['/about', '/about'], ['/linker?href=/about', '/about'],
['/about/', '/about'], ['/linker?href=/about/', '/about'],
['/about?hello=world', '/about?hello=world'], ['/linker?href=/about?hello=world', '/about?hello=world'],
['/about/?hello=world', '/about?hello=world'], ['/linker?href=/about/?hello=world', '/about?hello=world'],
['/catch-all/hello/', '/catch-all/hello'], ['/linker?href=/catch-all/hello/', '/catch-all/hello'],
['/catch-all/hello.world/', '/catch-all/hello.world'], ['/linker?href=/catch-all/hello.world/', '/catch-all/hello.world'],
]) ])
} }
...@@ -164,30 +165,25 @@ function testWithTrailingSlash() { ...@@ -164,30 +165,25 @@ function testWithTrailingSlash() {
]) ])
testLinkShouldRewriteTo([ testLinkShouldRewriteTo([
['/', '/'], ['/linker?href=/', '/'],
['/about', '/about/'], ['/linker?href=/about', '/about/'],
['/about/', '/about/'], ['/linker?href=/about/', '/about/'],
['/about?hello=world', '/about/?hello=world'], ['/linker?href=/about?hello=world', '/about/?hello=world'],
['/about/?hello=world', '/about/?hello=world'], ['/linker?href=/about/?hello=world', '/about/?hello=world'],
['/catch-all/hello/', '/catch-all/hello/'], ['/linker?href=/catch-all/hello/', '/catch-all/hello/'],
['/catch-all/hello.world/', '/catch-all/hello.world'], ['/linker?href=/catch-all/hello.world/', '/catch-all/hello.world'],
]) ])
} }
describe('Trailing slashes', () => { describe('Trailing slashes', () => {
describe('dev mode, trailingSlash: false', () => { describe('dev mode, trailingSlash: false', () => {
let origNextConfig
beforeAll(async () => { beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8') nextConfig.replace('// <placeholder>', 'trailingSlash: false')
await fs.writeFile(
nextConfig,
origNextConfig.replace('// <placeholder>', 'trailingSlash: false')
)
appPort = await findPort() appPort = await findPort()
app = await launchApp(appDir, appPort) app = await launchApp(appDir, appPort)
}) })
afterAll(async () => { afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig) nextConfig.restore()
await killApp(app) await killApp(app)
}) })
...@@ -195,18 +191,13 @@ describe('Trailing slashes', () => { ...@@ -195,18 +191,13 @@ describe('Trailing slashes', () => {
}) })
describe('dev mode, trailingSlash: true', () => { describe('dev mode, trailingSlash: true', () => {
let origNextConfig
beforeAll(async () => { beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8') nextConfig.replace('// <placeholder>', 'trailingSlash: true')
await fs.writeFile(
nextConfig,
origNextConfig.replace('// <placeholder>', 'trailingSlash: true')
)
appPort = await findPort() appPort = await findPort()
app = await launchApp(appDir, appPort) app = await launchApp(appDir, appPort)
}) })
afterAll(async () => { afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig) nextConfig.restore()
await killApp(app) await killApp(app)
}) })
...@@ -214,20 +205,14 @@ describe('Trailing slashes', () => { ...@@ -214,20 +205,14 @@ describe('Trailing slashes', () => {
}) })
describe('production mode, trailingSlash: false', () => { describe('production mode, trailingSlash: false', () => {
let origNextConfig
beforeAll(async () => { beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8') nextConfig.replace('// <placeholder>', 'trailingSlash: false')
await fs.writeFile(
nextConfig,
origNextConfig.replace('// <placeholder>', 'trailingSlash: false')
)
await nextBuild(appDir) await nextBuild(appDir)
appPort = await findPort() appPort = await findPort()
app = await nextStart(appDir, appPort) app = await nextStart(appDir, appPort)
}) })
afterAll(async () => { afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig) nextConfig.restore()
await killApp(app) await killApp(app)
}) })
...@@ -252,20 +237,14 @@ describe('Trailing slashes', () => { ...@@ -252,20 +237,14 @@ describe('Trailing slashes', () => {
}) })
describe('production mode, trailingSlash: true', () => { describe('production mode, trailingSlash: true', () => {
let origNextConfig
beforeAll(async () => { beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8') nextConfig.replace('// <placeholder>', 'trailingSlash: true')
await fs.writeFile(
nextConfig,
origNextConfig.replace('// <placeholder>', 'trailingSlash: true')
)
await nextBuild(appDir) await nextBuild(appDir)
appPort = await findPort() appPort = await findPort()
app = await nextStart(appDir, appPort) app = await nextStart(appDir, appPort)
}) })
afterAll(async () => { afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig) nextConfig.restore()
await killApp(app) await killApp(app)
}) })
...@@ -295,20 +274,14 @@ describe('Trailing slashes', () => { ...@@ -295,20 +274,14 @@ describe('Trailing slashes', () => {
}) })
describe('dev mode, with basepath, trailingSlash: true', () => { describe('dev mode, with basepath, trailingSlash: true', () => {
let origNextConfig
beforeAll(async () => { beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8') nextConfig.replace('// <placeholder>', 'trailingSlash: true')
await fs.writeFile( nextConfig.replace('// basePath:', 'basePath:')
nextConfig,
origNextConfig
.replace('// <placeholder>', 'trailingSlash: true')
.replace('// basePath:', 'basePath:')
)
appPort = await findPort() appPort = await findPort()
app = await launchApp(appDir, appPort) app = await launchApp(appDir, appPort)
}) })
afterAll(async () => { afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig) nextConfig.restore()
await killApp(app) await killApp(app)
}) })
...@@ -318,25 +291,23 @@ describe('Trailing slashes', () => { ...@@ -318,25 +291,23 @@ describe('Trailing slashes', () => {
['/docs/catch-all/hello/world', '/docs/catch-all/hello/world/'], ['/docs/catch-all/hello/world', '/docs/catch-all/hello/world/'],
['/docs/catch-all/hello.world/', '/docs/catch-all/hello.world'], ['/docs/catch-all/hello.world/', '/docs/catch-all/hello.world'],
]) ])
testLinkShouldRewriteTo([
['/docs/linker?href=/about', '/docs/about/'],
['/docs/linker?href=/', '/docs/'],
])
}) })
describe('production mode, with basepath, trailingSlash: true', () => { describe('production mode, with basepath, trailingSlash: true', () => {
let origNextConfig
beforeAll(async () => { beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8') nextConfig.replace('// <placeholder>', 'trailingSlash: true')
await fs.writeFile( nextConfig.replace('// basePath:', 'basePath:')
nextConfig,
origNextConfig
.replace('// <placeholder>', 'trailingSlash: true')
.replace('// basePath:', 'basePath:')
)
await nextBuild(appDir) await nextBuild(appDir)
appPort = await findPort() appPort = await findPort()
app = await nextStart(appDir, appPort) app = await nextStart(appDir, appPort)
}) })
afterAll(async () => { afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig) nextConfig.restore()
await killApp(app) await killApp(app)
}) })
...@@ -346,5 +317,10 @@ describe('Trailing slashes', () => { ...@@ -346,5 +317,10 @@ describe('Trailing slashes', () => {
['/docs/catch-all/hello/world', '/docs/catch-all/hello/world/'], ['/docs/catch-all/hello/world', '/docs/catch-all/hello/world/'],
['/docs/catch-all/hello.world/', '/docs/catch-all/hello.world'], ['/docs/catch-all/hello.world/', '/docs/catch-all/hello.world'],
]) ])
testLinkShouldRewriteTo([
['/docs/linker?href=/about', '/docs/about/'],
['/docs/linker?href=/', '/docs/'],
])
}) })
}) })
...@@ -393,25 +393,24 @@ export class File { ...@@ -393,25 +393,24 @@ export class File {
} }
replace(pattern, newValue) { replace(pattern, newValue) {
const currentContent = readFileSync(this.path, 'utf8')
if (pattern instanceof RegExp) { if (pattern instanceof RegExp) {
if (!pattern.test(this.originalContent)) { if (!pattern.test(currentContent)) {
throw new Error( throw new Error(
`Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${ `Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${currentContent}`
this.originalContent
}`
) )
} }
} else if (typeof pattern === 'string') { } else if (typeof pattern === 'string') {
if (!this.originalContent.includes(pattern)) { if (!currentContent.includes(pattern)) {
throw new Error( throw new Error(
`Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${this.originalContent}` `Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${currentContent}`
) )
} }
} else { } else {
throw new Error(`Unknown replacement attempt type: ${pattern}`) throw new Error(`Unknown replacement attempt type: ${pattern}`)
} }
const newContent = this.originalContent.replace(pattern, newValue) const newContent = currentContent.replace(pattern, newValue)
this.write(newContent) this.write(newContent)
} }
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册