Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions src/helper/ssg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export {
disableSSG,
onlySSG,
} from './middleware'
export { redirectPlugin } from './plugins'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since built-in plugins should only handle lightweight ones, I decided to manage multiple plugins in a single plugins.ts file. This should make updating exports slightly more convenient.

41 changes: 41 additions & 0 deletions src/helper/ssg/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { SSGPlugin } from './ssg'

const generateRedirectHtml = (from: string, to: string) => {
const html = `<!DOCTYPE html>
<title>Redirecting to: ${to}</title>
<meta http-equiv="refresh" content="0;url=${to}" />
<meta name="robots" content="noindex" />
<link rel="canonical" href="${to}" />
<body>
<a href="${to}">Redirecting from <code>${from}</code> to <code>${to}</code></a>
</body>`
return html.replace(/\n/g, '')
}

/**
* Redirect plugin for Hono SSG.
*
* Generates HTML redirect pages for HTTP 301 and 302 responses.
*
* @returns A SSGPlugin that generates HTML redirect pages.
*
* @experimental
* `redirectPlugin` is an experimental feature.
* The API might be changed.
*/
export const redirectPlugin = (): SSGPlugin => {
return {
afterResponseHook: (res) => {
if (res.status === 301 || res.status === 302) {
const location = res.headers.get('Location')
if (!location) return false
const html = generateRedirectHtml('', location)
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
return res
},
}
}
44 changes: 44 additions & 0 deletions src/helper/ssg/ssg.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
onlySSG,
ssgParams,
} from './middleware'
import { redirectPlugin } from './plugins'
import {
defaultExtensionMap,
fetchRoutesContent,
Expand Down Expand Up @@ -254,6 +255,49 @@ describe('toSSG function', () => {
)
})

it('should generate redirect HTML for 301/302 route responses using plugin', async () => {
const writtenFiles: Record<string, string> = {}
const fsMock: FileSystemModule = {
writeFile: (path, data) => {
writtenFiles[path] = typeof data === 'string' ? data : data.toString()
return Promise.resolve()
},
mkdir: vi.fn(() => Promise.resolve()),
}
const app = new Hono()
app.get('/old', (c) => c.redirect('/new'))
app.get('/new', (c) => c.html('New Page'))

await toSSG(app, fsMock, { dir: './static', plugins: [redirectPlugin()] })

expect(writtenFiles['static/old.html']).toBeDefined()
const content = writtenFiles['static/old.html']
// Should contain meta refresh
expect(content).toContain('meta http-equiv="refresh" content="0;url=/new"')
// Should contain canonical
expect(content).toContain('rel="canonical" href="/new"')
// Should contain link anchor
expect(content).toContain('<a href="/new">Redirecting from')
})

it('should skip generating a redirect HTML when 301/302 has no Location header', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The specification mandates that redirect responses must include a Location header 1. Current implementations simply bypass generating the file if no Location header is present, though options include displaying a warning or throwing an error could be considered.

Footnotes

  1. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Location

const writtenFiles: Record<string, string> = {}
const fsMock: FileSystemModule = {
writeFile: (path, data) => {
writtenFiles[path] = typeof data === 'string' ? data : data.toString()
return Promise.resolve()
},
mkdir: vi.fn(() => Promise.resolve()),
}
const app = new Hono()
// Return a 301 without Location header
app.get('/bad', (c) => new Response(null, { status: 301 }))

await toSSG(app, fsMock, { dir: './static', plugins: [redirectPlugin()] })

expect(writtenFiles['static/bad.html']).toBeUndefined()
})

it('should handle asynchronous beforeRequestHook correctly', async () => {
const beforeRequestHook: BeforeRequestHook = async (req) => {
await new Promise((resolve) => setTimeout(resolve, 10))
Expand Down
Loading