diff --git a/deno_dist/helper/ssg/ssg.ts b/deno_dist/helper/ssg/ssg.ts index 549e8171d..5e506436f 100644 --- a/deno_dist/helper/ssg/ssg.ts +++ b/deno_dist/helper/ssg/ssg.ts @@ -97,11 +97,66 @@ export type BeforeRequestHook = (req: Request) => Request | false | Promise Response | false | Promise export type AfterGenerateHook = (result: ToSSGResult) => void | Promise +export const combineBeforeRequestHooks = ( + hooks: BeforeRequestHook | BeforeRequestHook[] +): BeforeRequestHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (req: Request): Promise => { + let currentReq = req + for (const hook of hooks) { + const result = await hook(currentReq) + if (result === false) { + return false + } + if (result instanceof Request) { + currentReq = result + } + } + return currentReq + } +} + +export const combineAfterResponseHooks = ( + hooks: AfterResponseHook | AfterResponseHook[] +): AfterResponseHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (res: Response): Promise => { + let currentRes = res + for (const hook of hooks) { + const result = await hook(currentRes) + if (result === false) { + return false + } + if (result instanceof Response) { + currentRes = result + } + } + return currentRes + } +} + +export const combineAfterGenerateHooks = ( + hooks: AfterGenerateHook | AfterGenerateHook[] +): AfterGenerateHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (result: ToSSGResult): Promise => { + for (const hook of hooks) { + await hook(result) + } + } +} + export interface ToSSGOptions { dir?: string - beforeRequestHook?: BeforeRequestHook - afterResponseHook?: AfterResponseHook - afterGenerateHook?: AfterGenerateHook + beforeRequestHook?: BeforeRequestHook | BeforeRequestHook[] + afterResponseHook?: AfterResponseHook | AfterResponseHook[] + afterGenerateHook?: AfterGenerateHook | AfterGenerateHook[] concurrency?: number extensionMap?: Record } @@ -286,10 +341,16 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => { const outputDir = options?.dir ?? './static' const concurrency = options?.concurrency ?? DEFAULT_CONCURRENCY + const combinedBeforeRequestHook = combineBeforeRequestHooks( + options?.beforeRequestHook || ((req) => req) + ) + const combinedAfterResponseHook = combineAfterResponseHooks( + options?.afterResponseHook || ((req) => req) + ) const getInfoGen = fetchRoutesContent( app, - options?.beforeRequestHook, - options?.afterResponseHook, + combinedBeforeRequestHook, + combinedAfterResponseHook, concurrency ) for (const getInfo of getInfoGen) { @@ -319,6 +380,9 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => { const errorObj = error instanceof Error ? error : new Error(String(error)) result = { success: false, files: [], error: errorObj } } - await options?.afterGenerateHook?.(result) + if (options?.afterGenerateHook) { + const conbinedAfterGenerateHooks = combineAfterGenerateHooks(options?.afterGenerateHook) + await conbinedAfterGenerateHooks(result) + } return result } diff --git a/src/helper/ssg/ssg.test.tsx b/src/helper/ssg/ssg.test.tsx index 6f8bb819f..cceeacc86 100644 --- a/src/helper/ssg/ssg.test.tsx +++ b/src/helper/ssg/ssg.test.tsx @@ -17,6 +17,7 @@ import type { AfterResponseHook, AfterGenerateHook, FileSystemModule, + ToSSGResult, } from './ssg' const resolveRoutesContent = async (res: ReturnType) => { @@ -627,3 +628,165 @@ describe('disableSSG/onlySSG middlewares', () => { expect(res.status).toBe(404) }) }) + +describe('Request hooks - filterPathsBeforeRequestHook and denyPathsBeforeRequestHook', () => { + let app: Hono + let fsMock: FileSystemModule + + const filterPathsBeforeRequestHook = (allowedPaths: string | string[]): BeforeRequestHook => { + const baseURL = 'http://localhost' + return async (req: Request): Promise => { + const paths = Array.isArray(allowedPaths) ? allowedPaths : [allowedPaths] + const pathname = new URL(req.url, baseURL).pathname + + if (paths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return req + } + + return false + } + } + + const denyPathsBeforeRequestHook = (deniedPaths: string | string[]): BeforeRequestHook => { + const baseURL = 'http://localhost' + return async (req: Request): Promise => { + const paths = Array.isArray(deniedPaths) ? deniedPaths : [deniedPaths] + const pathname = new URL(req.url, baseURL).pathname + + if (!paths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return req + } + return false + } + } + + beforeEach(() => { + app = new Hono() + app.get('/allowed-path', (c) => c.html('Allowed Path Page')) + app.get('/denied-path', (c) => c.html('Denied Path Page')) + app.get('/other-path', (c) => c.html('Other Path Page')) + + fsMock = { + writeFile: vi.fn(() => Promise.resolve()), + mkdir: vi.fn(() => Promise.resolve()), + } + }) + + it('should only process requests for allowed paths with filterPathsBeforeRequestHook', async () => { + const allowedPathsHook = filterPathsBeforeRequestHook(['/allowed-path']) + + const result = await toSSG(app, fsMock, { + dir: './static', + beforeRequestHook: allowedPathsHook, + }) + + expect(result.files.some((file) => file.includes('allowed-path.html'))).toBe(true) + expect(result.files.some((file) => file.includes('other-path.html'))).toBe(false) + }) + + it('should deny requests for specified paths with denyPathsBeforeRequestHook', async () => { + const deniedPathsHook = denyPathsBeforeRequestHook(['/denied-path']) + + const result = await toSSG(app, fsMock, { dir: './static', beforeRequestHook: deniedPathsHook }) + + expect(result.files.some((file) => file.includes('denied-path.html'))).toBe(false) + + expect(result.files.some((file) => file.includes('allowed-path.html'))).toBe(true) + expect(result.files.some((file) => file.includes('other-path.html'))).toBe(true) + }) +}) + +describe('Combined Response hooks - modify response content', () => { + let app: Hono + let fsMock: FileSystemModule + + const prependContentAfterResponseHook = (prefix: string): AfterResponseHook => { + return async (res: Response): Promise => { + const originalText = await res.text() + return new Response(`${prefix}${originalText}`, { ...res }) + } + } + + const appendContentAfterResponseHook = (suffix: string): AfterResponseHook => { + return async (res: Response): Promise => { + const originalText = await res.text() + return new Response(`${originalText}${suffix}`, { ...res }) + } + } + + beforeEach(() => { + app = new Hono() + app.get('/content-path', (c) => c.text('Original Content')) + + fsMock = { + writeFile: vi.fn(() => Promise.resolve()), + mkdir: vi.fn(() => Promise.resolve()), + } + }) + + it('should modify response content with combined AfterResponseHooks', async () => { + const prefixHook = prependContentAfterResponseHook('Prefix-') + const suffixHook = appendContentAfterResponseHook('-Suffix') + + const combinedHook = [prefixHook, suffixHook] + + await toSSG(app, fsMock, { + dir: './static', + afterResponseHook: combinedHook, + }) + + // Assert that the response content is modified by both hooks + // This assumes you have a way to inspect the content of saved files or you need to mock/stub the Response text method correctly. + expect(fsMock.writeFile).toHaveBeenCalledWith( + 'static/content-path.txt', + 'Prefix-Original Content-Suffix' + ) + }) +}) + +describe('Combined Generate hooks - AfterGenerateHook', () => { + let app: Hono + let fsMock: FileSystemModule + + const logResultAfterGenerateHook = (): AfterGenerateHook => { + return async (result: ToSSGResult): Promise => { + console.log('Generation completed with status:', result.success) // Log the generation success + } + } + + const appendFilesAfterGenerateHook = (additionalFiles: string[]): AfterGenerateHook => { + return async (result: ToSSGResult): Promise => { + result.files = result.files.concat(additionalFiles) // Append additional files to the result + } + } + + beforeEach(() => { + app = new Hono() + app.get('/path', (c) => c.text('Page Content')) + + fsMock = { + writeFile: vi.fn(() => Promise.resolve()), + mkdir: vi.fn(() => Promise.resolve()), + } + }) + + it('should execute combined AfterGenerateHooks affecting the result', async () => { + const logHook = logResultAfterGenerateHook() + const appendHook = appendFilesAfterGenerateHook(['/extra/file1.html', '/extra/file2.html']) + + const combinedHook = [logHook, appendHook] + + const consoleSpy = vi.spyOn(console, 'log') + const result = await toSSG(app, fsMock, { + dir: './static', + afterGenerateHook: combinedHook, + }) + + // Check that the log function was called correctly + expect(consoleSpy).toHaveBeenCalledWith('Generation completed with status:', true) + + // Check that additional files were appended to the result + expect(result.files).toContain('/extra/file1.html') + expect(result.files).toContain('/extra/file2.html') + }) +}) diff --git a/src/helper/ssg/ssg.ts b/src/helper/ssg/ssg.ts index 3e49d82b1..4d1b622c6 100644 --- a/src/helper/ssg/ssg.ts +++ b/src/helper/ssg/ssg.ts @@ -97,11 +97,66 @@ export type BeforeRequestHook = (req: Request) => Request | false | Promise Response | false | Promise export type AfterGenerateHook = (result: ToSSGResult) => void | Promise +export const combineBeforeRequestHooks = ( + hooks: BeforeRequestHook | BeforeRequestHook[] +): BeforeRequestHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (req: Request): Promise => { + let currentReq = req + for (const hook of hooks) { + const result = await hook(currentReq) + if (result === false) { + return false + } + if (result instanceof Request) { + currentReq = result + } + } + return currentReq + } +} + +export const combineAfterResponseHooks = ( + hooks: AfterResponseHook | AfterResponseHook[] +): AfterResponseHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (res: Response): Promise => { + let currentRes = res + for (const hook of hooks) { + const result = await hook(currentRes) + if (result === false) { + return false + } + if (result instanceof Response) { + currentRes = result + } + } + return currentRes + } +} + +export const combineAfterGenerateHooks = ( + hooks: AfterGenerateHook | AfterGenerateHook[] +): AfterGenerateHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (result: ToSSGResult): Promise => { + for (const hook of hooks) { + await hook(result) + } + } +} + export interface ToSSGOptions { dir?: string - beforeRequestHook?: BeforeRequestHook - afterResponseHook?: AfterResponseHook - afterGenerateHook?: AfterGenerateHook + beforeRequestHook?: BeforeRequestHook | BeforeRequestHook[] + afterResponseHook?: AfterResponseHook | AfterResponseHook[] + afterGenerateHook?: AfterGenerateHook | AfterGenerateHook[] concurrency?: number extensionMap?: Record } @@ -286,10 +341,16 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => { const outputDir = options?.dir ?? './static' const concurrency = options?.concurrency ?? DEFAULT_CONCURRENCY + const combinedBeforeRequestHook = combineBeforeRequestHooks( + options?.beforeRequestHook || ((req) => req) + ) + const combinedAfterResponseHook = combineAfterResponseHooks( + options?.afterResponseHook || ((req) => req) + ) const getInfoGen = fetchRoutesContent( app, - options?.beforeRequestHook, - options?.afterResponseHook, + combinedBeforeRequestHook, + combinedAfterResponseHook, concurrency ) for (const getInfo of getInfoGen) { @@ -319,6 +380,9 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => { const errorObj = error instanceof Error ? error : new Error(String(error)) result = { success: false, files: [], error: errorObj } } - await options?.afterGenerateHook?.(result) + if (options?.afterGenerateHook) { + const conbinedAfterGenerateHooks = combineAfterGenerateHooks(options?.afterGenerateHook) + await conbinedAfterGenerateHooks(result) + } return result }