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)) + } +}