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.

47 changes: 47 additions & 0 deletions src/helper/ssg/plugins.ts
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 default plugin is an object, but the redirect plugin is a function. Should we standardize it to functions for consistency?

Copy link
Member

Choose a reason for hiding this comment

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

Ahh, I think function is better because the user can pass options like redirectPlugin({ optionA }) if needed.

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { html } from '../html'
import type { SSGPlugin } from './ssg'

const generateRedirectHtml = (from: string, to: string) => {
// prettier-ignore
const content = html`<!DOCTYPE html>
<title>Redirecting to: ${to}</title>
Copy link
Member

Choose a reason for hiding this comment

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

It's a rare case if passing the script into to, but I think you should escape it. How about html?

import { html } from '../html'

And you can esapce to with ${html`${to}`}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I refactored the code to use the html helper and added test cases related to escaping. Double escaping might not be necessary.

Also, it could be useful if the html helper had an option to get a minified string. If you cannot think of many use cases for it, it may not be needed.

<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 content.toString().replace(/\n/g, '')
}

/**
* Redirect plugin for Hono SSG.
*
* Generates HTML redirect pages for HTTP 301 and 302 responses.
* When used with `defaultPlugin`, place `redirectPlugin` before it, because `defaultPlugin` skips non-200 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)
Copy link
Member

Choose a reason for hiding this comment

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

You don't specify the from parameter. Is it expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I forgot to implement it.

The Response object in afterResponseHook does not contain information about the original redirect source, so we probably need to prepare a custom header like x-hono-ssg-route to inject the request path.

I also thought of another option, but this approach might break when running in parallel. (For production use, we would need to use a FIFO queue, but in this PoC I implemented it in a straightforward way.)

export const redirectPlugin = (): SSGPlugin => {
  const requestedUrls: string[] = []
  return {
    beforeRequestHook: (req) => {
      requestedUrls.push(new URL(req.url).pathname)
      return req
    },
    afterResponseHook: (res) => {
      if (res.status === 301 || res.status === 302) {
        const location = res.headers.get('Location')
        if (!location) {
          return false
        }
        const from = requestedUrls.shift()
        if (!from) {
          return false
        }
        const body = generateRedirectHtml(from, location)
        return new Response(body, {
          status: 200,
          headers: { 'Content-Type': 'text/html; charset=utf-8' },
        })
      }
      return res
    },
  }
}

Copy link
Member

Choose a reason for hiding this comment

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

How about removing from from this PR?

At the very least, the user knows where it comes from because they access the URL directly. And I think the information about from is not so important.

return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
return res
},
}
}
110 changes: 110 additions & 0 deletions src/helper/ssg/ssg.test.tsx
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Build-in plugins test should be separated to plugins.test.tsx?

Copy link
Member

Choose a reason for hiding this comment

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

The ssg.test.tsx became fat. So separating it is a good idea.

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,79 @@ 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()] })
Copy link
Member

Choose a reason for hiding this comment

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

What do you think of a situation where you use it with defaultPlugin? It works well if you put the redirecPlugin above the defaultPlugin. So, I think it's good to add a description about it on our website.

Copy link
Contributor Author

@3w36zj6 3w36zj6 Jan 25, 2026

Choose a reason for hiding this comment

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

I also think updating the documentation is essential.

Additionally, it might be helpful to add name: string and requires?: string[] to the plugin, so that we can validate the plugin order. What do you think? (I think dependency validation itself is an interesting idea, but we need to carefully discuss the interface and validation methods.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added TSDoc and tests for plugin ordering.

Copy link
Member

Choose a reason for hiding this comment

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

I think dependency validation itself is an interesting idea, but we need to carefully discuss the interface and validation methods.

Yes. Let's discuss another place.


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 escape Location header values when generating redirect HTML', 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 maliciousLocation = '/new"> <script>alert(1)</script>'
const app = new Hono()
app.get(
'/evil',
(c) => new Response(null, { status: 301, headers: { Location: maliciousLocation } })
)

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

const content = writtenFiles['static/evil.html']
expect(content).toBeDefined()
expect(content).not.toContain('<script>alert(1)</script>')
expect(content).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
expect(content).toContain('&quot;')
})

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 Expand Up @@ -906,6 +980,42 @@ describe('SSG Plugin System', () => {
expect(fsMock.writeFile).toHaveBeenCalledWith('static/blog.html', '<h1>Blog - Modified</h1>')
})

it('redirectPlugin before defaultPlugin generates redirect HTML', async () => {
const writtenFiles: Record<string, string> = {}
const fsMockLocal: 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, fsMockLocal, { dir: './static', plugins: [redirectPlugin(), defaultPlugin] })
expect(writtenFiles['static/old.html']).toBeDefined()
})

it('redirectPlugin after defaultPlugin does not generate redirect HTML', async () => {
const writtenFiles: Record<string, string> = {}
const fsMockLocal: 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, fsMockLocal, { dir: './static', plugins: [defaultPlugin, redirectPlugin()] })
expect(writtenFiles['static/old.html']).toBeUndefined()
})

it('should correctly apply plugins with afterGenerateHook', async () => {
const additionalFiles = ['sitemap.xml', 'robots.txt']
const plugin: SSGPlugin = {
Expand Down
Loading