Skip to content

Commit f23e97b

Browse files
authored
feat(trailing-slash): add skip option (#4862)
1 parent 1aa32fb commit f23e97b

File tree

2 files changed

+80
-2
lines changed

2 files changed

+80
-2
lines changed

src/middleware/trailing-slash/index.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,63 @@ describe('Resolve trailing slash', () => {
255255
})
256256
})
257257

258+
describe('appendTrailingSlash middleware with skip option', () => {
259+
const hasExtension = (path: string): boolean => /\.\w+$/.test(path.split('/').at(-1) ?? '')
260+
const app = new Hono({ strict: true })
261+
app.use('*', appendTrailingSlash({ skip: hasExtension }))
262+
263+
app.get('/page/', (c) => c.text('ok'))
264+
265+
it('should not redirect paths with file extensions', async () => {
266+
const resp = await app.request('/foo.html')
267+
268+
expect(resp.status).toBe(404)
269+
})
270+
271+
it('should redirect paths without file extensions', async () => {
272+
const resp = await app.request('/page')
273+
274+
expect(resp.status).toBe(301)
275+
const loc = new URL(resp.headers.get('location')!)
276+
expect(loc.pathname).toBe('/page/')
277+
})
278+
})
279+
280+
describe('appendTrailingSlash middleware with alwaysRedirect and skip options', () => {
281+
const hasExtension = (path: string): boolean => /\.\w+$/.test(path.split('/').at(-1) ?? '')
282+
const app = new Hono()
283+
app.use('*', appendTrailingSlash({ alwaysRedirect: true, skip: hasExtension }))
284+
285+
app.get('/', (c) => c.text('ok'))
286+
app.get('/my-path/*', (c) => c.text('wildcard'))
287+
288+
it('should redirect paths without extensions', async () => {
289+
const resp = await app.request('/my-path/something')
290+
const loc = new URL(resp.headers.get('location')!)
291+
292+
expect(resp.status).toBe(301)
293+
expect(loc.pathname).toBe('/my-path/something/')
294+
})
295+
296+
it('should not redirect paths with file extensions', async () => {
297+
const resp = await app.request('/my-path/style.css')
298+
299+
expect(resp.status).toBe(200)
300+
})
301+
302+
it('should not redirect paths with extensions like .html', async () => {
303+
const resp = await app.request('/my-path/page.html')
304+
305+
expect(resp.status).toBe(200)
306+
})
307+
308+
it('should handle HEAD request for paths with extensions', async () => {
309+
const resp = await app.request('/my-path/script.js', { method: 'HEAD' })
310+
311+
expect(resp.status).toBe(200)
312+
})
313+
})
314+
258315
describe('appendTrailingSlash middleware with alwaysRedirect option', () => {
259316
const app = new Hono()
260317
app.use('*', appendTrailingSlash({ alwaysRedirect: true }))

src/middleware/trailing-slash/index.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ type AppendTrailingSlashOptions = {
8282
* @default false
8383
*/
8484
alwaysRedirect?: boolean
85+
/**
86+
* A function that determines whether to skip the redirect for a given path.
87+
* If the function returns `true`, the redirect will be skipped.
88+
* @param path - The request path
89+
* @returns `true` to skip the redirect
90+
*
91+
* @example
92+
* ```ts
93+
* // Skip redirect for paths with file extensions
94+
* app.use(appendTrailingSlash({
95+
* alwaysRedirect: true,
96+
* skip: (path) => /\.\w+$/.test(path),
97+
* }))
98+
* ```
99+
*/
100+
skip?: (path: string) => boolean
85101
}
86102

87103
/**
@@ -112,7 +128,11 @@ type AppendTrailingSlashOptions = {
112128
export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): MiddlewareHandler => {
113129
return async function appendTrailingSlash(c, next) {
114130
if (options?.alwaysRedirect) {
115-
if ((c.req.method === 'GET' || c.req.method === 'HEAD') && c.req.path.at(-1) !== '/') {
131+
if (
132+
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
133+
c.req.path.at(-1) !== '/' &&
134+
!options.skip?.(c.req.path)
135+
) {
116136
const url = new URL(c.req.url)
117137
url.pathname += '/'
118138

@@ -126,7 +146,8 @@ export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): Middl
126146
!options?.alwaysRedirect &&
127147
c.res.status === 404 &&
128148
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
129-
c.req.path.at(-1) !== '/'
149+
c.req.path.at(-1) !== '/' &&
150+
!options?.skip?.(c.req.path)
130151
) {
131152
const url = new URL(c.req.url)
132153
url.pathname += '/'

0 commit comments

Comments
 (0)