Skip to content
Merged
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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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`
Expand Down Expand Up @@ -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 <dir>` - output directory (default: `static`)
- `--include <path>` - generate only matching paths, `*` matches anything (can be used multiple times)
- `--exclude <path>` - skip matching paths, `*` matches anything (can be used multiple times)
- `--plain` - human-readable output instead of JSON
- `-e, --external <package>` - 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.
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -24,6 +25,7 @@ program
buildCommand(program)
requestCommand(program)
routesCommand(program)
ssgCommand(program)
agentContextCommand(program)

program.parse()
2 changes: 2 additions & 0 deletions src/commands/agent-context/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CommandAgentContext> = {
routes: routesContext,
request: requestContext,
build: buildContext,
ssg: ssgContext,
}

const commandDoc = (command: Command, context?: CommandAgentContext): string => {
Expand Down
165 changes: 165 additions & 0 deletions src/commands/ssg/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOnLog>

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<ReturnType<typeof getMockModules>>
let mockBuildAndImportApp: Awaited<ReturnType<typeof getMockBuildAndImportApp>>
let mockToSSG: Awaited<ReturnType<typeof getMockToSSG>>

async function* createBuildIterator(app: Hono): AsyncGenerator<Hono> {
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
})
})
83 changes: 83 additions & 0 deletions src/commands/ssg/index.ts
Original file line number Diff line number Diff line change
@@ -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 <dir>', 'output directory', 'static')
.option('--plain', 'human-readable output instead of JSON', false)
.option(
'--include <path>',
'generate only matching paths, `*` matches anything (can be used multiple times)',
collect,
[] as string[]
)
.option(
'--exclude <path>',
'skip matching paths, `*` matches anything (can be used multiple times)',
collect,
[] as string[]
)
.option(
'-e, --external <package>',
'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 })
})
)
}
35 changes: 35 additions & 0 deletions src/commands/ssg/route-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading