Skip to content
Open
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
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
},
}
}
48 changes: 48 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,53 @@ 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 robots noindex
expect(content).toContain('<meta name="robots" content="noindex" />')
// Should contain link anchor
expect(content).toContain('<a href="/new">Redirecting from')
// Should contain a body element that includes the anchor
expect(content).toMatch(/<body[^>]*>[\s\S]*<a href=\"\/new\">[\s\S]*<\/body>/)
})

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