diff --git a/README.md b/README.md
index c4f6d8f..8b6b9d4 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,9 @@ hono build
# Show routes of your Hono app
hono routes
+# Generate static files from your Hono app
+hono ssg
+
# Show how to use Hono CLI, for coding agents
hono agent-context
```
@@ -34,6 +37,7 @@ hono agent-context
- `request [file]` - Send request to Hono app using `app.request()`
- `build [entry]` - Build your Hono app
- `routes [file]` - Show routes of your Hono app
+- `ssg [file]` - Generate static files from your Hono app
- `agent-context` - Show how to use Hono CLI, for coding agents
### `request`
@@ -219,6 +223,48 @@ hono routes [file] [options]
}
```
+### `ssg`
+
+Generate static files from your Hono app with the [SSG Helper](https://hono.dev/docs/helpers/ssg).
+
+```bash
+hono ssg [file] [options]
+```
+
+**Arguments:**
+
+- `file` - Path to the Hono app file (TypeScript/JSX supported, optional)
+
+**Options:**
+
+- `-o, --outdir
` - output directory (default: `static`)
+- `--include ` - generate only matching paths, `*` matches anything (can be used multiple times)
+- `--exclude ` - skip matching paths, `*` matches anything (can be used multiple times)
+- `--plain` - human-readable output instead of JSON
+- `-e, --external ` - Mark package as external (can be used multiple times)
+
+**Examples:**
+
+```bash
+# Generate everything to static/
+hono ssg
+
+# Skip API routes
+hono ssg --exclude '/api/*'
+```
+
+**Output:**
+
+```json
+{
+ "ok": true,
+ "data": {
+ "output": "static",
+ "files": ["static/index.html", "static/about.html"]
+ }
+}
+```
+
### `agent-context`
Show how to use Hono CLI, as Markdown for coding agents. The content is generated from the command definitions, so it always matches the installed version.
diff --git a/src/cli.ts b/src/cli.ts
index 6345125..0dc409a 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -6,6 +6,7 @@ import { agentContextCommand } from './commands/agent-context/index.js'
import { buildCommand } from './commands/build/index.js'
import { requestCommand } from './commands/request/index.js'
import { routesCommand } from './commands/routes/index.js'
+import { ssgCommand } from './commands/ssg/index.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
@@ -24,6 +25,7 @@ program
buildCommand(program)
requestCommand(program)
routesCommand(program)
+ssgCommand(program)
agentContextCommand(program)
program.parse()
diff --git a/src/commands/agent-context/document.ts b/src/commands/agent-context/document.ts
index a0b070a..b699938 100644
--- a/src/commands/agent-context/document.ts
+++ b/src/commands/agent-context/document.ts
@@ -4,11 +4,13 @@ import { bullets, codeBlock, section, steps } from '../../utils/markdown.js'
import { agentContext as buildContext } from '../build/index.js'
import { agentContext as requestContext } from '../request/index.js'
import { agentContext as routesContext } from '../routes/index.js'
+import { agentContext as ssgContext } from '../ssg/index.js'
const contexts: Record = {
routes: routesContext,
request: requestContext,
build: buildContext,
+ ssg: ssgContext,
}
const commandDoc = (command: Command, context?: CommandAgentContext): string => {
diff --git a/src/commands/ssg/index.test.ts b/src/commands/ssg/index.test.ts
new file mode 100644
index 0000000..9b7ccdd
--- /dev/null
+++ b/src/commands/ssg/index.test.ts
@@ -0,0 +1,165 @@
+import { Command } from 'commander'
+import { Hono } from 'hono'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+// Mock dependencies
+vi.mock('node:fs', () => ({
+ existsSync: vi.fn(),
+ realpathSync: vi.fn(),
+}))
+
+vi.mock('node:path', () => ({
+ resolve: vi.fn(),
+}))
+
+vi.mock('../../utils/build.js', () => ({
+ buildAndImportApp: vi.fn(),
+}))
+
+vi.mock('hono/ssg', () => ({
+ toSSG: vi.fn(),
+}))
+
+import { ssgCommand } from './index.js'
+
+describe('ssgCommand', () => {
+ let program: Command
+ const spyOnLog = () => vi.spyOn(console, 'log').mockImplementation(() => {})
+ let consoleLogSpy: ReturnType
+
+ const getMockModules = async () => ({
+ existsSync: vi.mocked((await import('node:fs')).existsSync),
+ realpathSync: vi.mocked((await import('node:fs')).realpathSync),
+ resolve: vi.mocked((await import('node:path')).resolve),
+ })
+ const getMockBuildAndImportApp = async () =>
+ vi.mocked((await import('../../utils/build.js')).buildAndImportApp)
+ const getMockToSSG = async () => vi.mocked((await import('hono/ssg')).toSSG)
+
+ let mockModules: Awaited>
+ let mockBuildAndImportApp: Awaited>
+ let mockToSSG: Awaited>
+
+ async function* createBuildIterator(app: Hono): AsyncGenerator {
+ yield app
+ }
+
+ const app = new Hono()
+
+ const setupBasicMocks = () => {
+ mockModules.existsSync.mockReturnValue(true)
+ mockModules.realpathSync.mockReturnValue('test-app.js')
+ mockModules.resolve.mockImplementation((cwd: string, path: string) => {
+ return `${cwd}/${path}`
+ })
+ mockBuildAndImportApp.mockReturnValue(createBuildIterator(app))
+ }
+
+ beforeEach(async () => {
+ program = new Command()
+ ssgCommand(program)
+ consoleLogSpy = spyOnLog()
+
+ mockModules = await getMockModules()
+ mockBuildAndImportApp = await getMockBuildAndImportApp()
+ mockToSSG = await getMockToSSG()
+
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ consoleLogSpy.mockRestore()
+ vi.restoreAllMocks()
+ })
+
+ it('should generate static files and print the JSON envelope', async () => {
+ setupBasicMocks()
+ mockToSSG.mockResolvedValue({
+ success: true,
+ files: ['static/index.html', 'static/about.html'],
+ })
+
+ await program.parseAsync(['node', 'test', 'ssg', 'test-app.js'])
+
+ const fsPromises = (await import('node:fs/promises')).default
+ expect(mockToSSG).toHaveBeenCalledWith(
+ app,
+ fsPromises,
+ expect.objectContaining({ dir: 'static' })
+ )
+ expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({
+ ok: true,
+ data: {
+ output: 'static',
+ files: ['static/index.html', 'static/about.html'],
+ },
+ })
+ })
+
+ it('should pass the output directory from -o', async () => {
+ setupBasicMocks()
+ mockToSSG.mockResolvedValue({ success: true, files: [] })
+
+ await program.parseAsync(['node', 'test', 'ssg', '-o', 'dist/static', 'test-app.js'])
+
+ const fsPromises = (await import('node:fs/promises')).default
+ expect(mockToSSG).toHaveBeenCalledWith(
+ app,
+ fsPromises,
+ expect.objectContaining({ dir: 'dist/static' })
+ )
+ })
+
+ it('should skip excluded paths via beforeRequestHook', async () => {
+ setupBasicMocks()
+ mockToSSG.mockResolvedValue({ success: true, files: [] })
+
+ await program.parseAsync(['node', 'test', 'ssg', '--exclude', '/api/*', 'test-app.js'])
+
+ const options = mockToSSG.mock.calls[0][2]
+ const hook = options?.beforeRequestHook
+ if (typeof hook !== 'function') {
+ throw new Error('beforeRequestHook must be a function')
+ }
+ const kept = new Request('http://localhost/about')
+ const skipped = new Request('http://localhost/api/data')
+ expect(await hook(kept)).toBe(kept)
+ expect(await hook(skipped)).toBe(false)
+ })
+
+ it('should print file names with --plain', async () => {
+ setupBasicMocks()
+ mockToSSG.mockResolvedValue({ success: true, files: ['static/index.html'] })
+
+ await program.parseAsync(['node', 'test', 'ssg', '--plain', 'test-app.js'])
+
+ expect(consoleLogSpy).toHaveBeenCalledWith('static/index.html')
+ })
+
+ it('should print a JSON error when toSSG fails', async () => {
+ setupBasicMocks()
+ mockToSSG.mockResolvedValue({ success: false, files: [], error: new Error('boom') })
+
+ await program.parseAsync(['node', 'test', 'ssg', 'test-app.js'])
+
+ const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0])
+ expect(parsed.ok).toBe(false)
+ expect(parsed.error.code).toBe('SSG_FAILED')
+ expect(parsed.error.message).toBe('boom')
+ expect(process.exitCode).toBe(1)
+ process.exitCode = undefined
+ })
+
+ it('should print a JSON error when the entry file is not found', async () => {
+ mockModules.existsSync.mockReturnValue(false)
+ mockModules.resolve.mockImplementation((cwd: string, path: string) => `${cwd}/${path}`)
+
+ await program.parseAsync(['node', 'test', 'ssg', 'missing.ts'])
+
+ const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0])
+ expect(parsed.ok).toBe(false)
+ expect(parsed.error.code).toBe('ENTRY_NOT_FOUND')
+ expect(process.exitCode).toBe(1)
+ process.exitCode = undefined
+ })
+})
diff --git a/src/commands/ssg/index.ts b/src/commands/ssg/index.ts
new file mode 100644
index 0000000..3841f17
--- /dev/null
+++ b/src/commands/ssg/index.ts
@@ -0,0 +1,83 @@
+import type { Command } from 'commander'
+import { toSSG } from 'hono/ssg'
+import fs from 'node:fs/promises'
+import type { CommandAgentContext } from '../../utils/agent-context.js'
+import { getBuildIterator } from '../../utils/load-app.js'
+import { CliError, handleErrors, printResult } from '../../utils/output.js'
+import { createRouteFilter } from './route-filter.js'
+
+export const agentContext: CommandAgentContext = {
+ output: '{ "output": "static", "files": ["static/index.html", "static/about.html"] }',
+ errors: ['ENTRY_NOT_FOUND', 'SSG_FAILED'],
+ examples: ['hono ssg', 'hono ssg -o dist/static src/app.ts', "hono ssg --exclude '/api/*'"],
+ notes: ['`--include` / `--exclude` select routes by path. `*` matches anything.'],
+}
+
+interface SsgOptions {
+ outdir: string
+ plain: boolean
+ include: string[]
+ exclude: string[]
+ external?: string[]
+}
+
+const collect = (value: string, previous: string[]): string[] =>
+ previous ? [...previous, value] : [value]
+
+export function ssgCommand(program: Command) {
+ program
+ .command('ssg')
+ .description('Generate static files from your Hono app')
+ .argument('[file]', 'Path to the Hono app file')
+ .option('-o, --outdir ', 'output directory', 'static')
+ .option('--plain', 'human-readable output instead of JSON', false)
+ .option(
+ '--include ',
+ 'generate only matching paths, `*` matches anything (can be used multiple times)',
+ collect,
+ [] as string[]
+ )
+ .option(
+ '--exclude ',
+ 'skip matching paths, `*` matches anything (can be used multiple times)',
+ collect,
+ [] as string[]
+ )
+ .option(
+ '-e, --external ',
+ 'Mark package as external (can be used multiple times)',
+ collect,
+ [] as string[]
+ )
+ .action(
+ handleErrors(async (file: string | undefined, options: SsgOptions) => {
+ const buildIterator = getBuildIterator(file, false, options.external || [])
+ const app = (await buildIterator.next()).value
+
+ const filter = createRouteFilter(options.include, options.exclude)
+ const result = await toSSG(app, fs, {
+ dir: options.outdir,
+ beforeRequestHook: (req) => (filter(new URL(req.url).pathname) ? req : false),
+ })
+
+ if (!result.success) {
+ throw new CliError(
+ 'SSG_FAILED',
+ result.error?.message ?? 'Failed to generate static files',
+ 'Check the routes with: hono routes'
+ )
+ }
+
+ const files = result.files ?? []
+
+ if (options.plain) {
+ for (const generated of files) {
+ console.log(generated)
+ }
+ return
+ }
+
+ printResult({ output: options.outdir, files })
+ })
+ )
+}
diff --git a/src/commands/ssg/route-filter.test.ts b/src/commands/ssg/route-filter.test.ts
new file mode 100644
index 0000000..cff1728
--- /dev/null
+++ b/src/commands/ssg/route-filter.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import { createRouteFilter } from './route-filter'
+
+describe('createRouteFilter', () => {
+ it('should pass everything with no patterns', () => {
+ const filter = createRouteFilter([], [])
+ expect(filter('/')).toBe(true)
+ expect(filter('/about')).toBe(true)
+ })
+
+ it('should exclude matching paths', () => {
+ const filter = createRouteFilter([], ['/api/*'])
+ expect(filter('/')).toBe(true)
+ expect(filter('/api/data')).toBe(false)
+ expect(filter('/api')).toBe(true)
+ })
+
+ it('should only pass included paths', () => {
+ const filter = createRouteFilter(['/blog/*'], [])
+ expect(filter('/blog/hello')).toBe(true)
+ expect(filter('/about')).toBe(false)
+ })
+
+ it('should apply exclude after include', () => {
+ const filter = createRouteFilter(['/blog/*'], ['/blog/draft-*'])
+ expect(filter('/blog/hello')).toBe(true)
+ expect(filter('/blog/draft-1')).toBe(false)
+ })
+
+ it('should not treat regex characters as special', () => {
+ const filter = createRouteFilter([], ['/a.b'])
+ expect(filter('/a.b')).toBe(false)
+ expect(filter('/axb')).toBe(true)
+ })
+})
diff --git a/src/commands/ssg/route-filter.ts b/src/commands/ssg/route-filter.ts
new file mode 100644
index 0000000..ec420c5
--- /dev/null
+++ b/src/commands/ssg/route-filter.ts
@@ -0,0 +1,21 @@
+// Path patterns for --include / --exclude. `*` matches anything.
+
+const toRegExp = (pattern: string): RegExp => {
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
+ return new RegExp(`^${escaped}$`)
+}
+
+export const createRouteFilter = (
+ include: string[],
+ exclude: string[]
+): ((pathname: string) => boolean) => {
+ const includeRegExps = include.map(toRegExp)
+ const excludeRegExps = exclude.map(toRegExp)
+
+ return (pathname) => {
+ if (includeRegExps.length > 0 && !includeRegExps.some((r) => r.test(pathname))) {
+ return false
+ }
+ return !excludeRegExps.some((r) => r.test(pathname))
+ }
+}