diff --git a/bun.lockb b/bun.lockb index b003832..dfb7fda 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/examples/basic/app/global.d.ts b/examples/basic/app/global.d.ts index e9536ef..216b135 100644 --- a/examples/basic/app/global.d.ts +++ b/examples/basic/app/global.d.ts @@ -1,16 +1,11 @@ -// eslint-disable-next-line node/no-extraneous-import -import 'hono' +import type {} from 'hono' -type Head = { +type Props = { title?: string } declare module 'hono' { - interface Env { - Variables: {} - Bindings: {} - } interface ContextRenderer { - (content: string | Promise, head?: Head): Response | Promise + (content: string | Promise, props?: Props): Response | Promise } } diff --git a/examples/basic/app/routes/_renderer.tsx b/examples/basic/app/routes/_renderer.tsx index df3dff7..29635d7 100644 --- a/examples/basic/app/routes/_renderer.tsx +++ b/examples/basic/app/routes/_renderer.tsx @@ -1,24 +1,22 @@ import { jsxRenderer } from 'hono/jsx-renderer' +import { HAS_ISLANDS } from 'honox/server' -export default jsxRenderer( - ({ children, title }) => { - return ( - - - - - {title ? {title} : <>} - {import.meta.env.PROD ? ( +export default jsxRenderer(({ children, title }) => { + return ( + + + + + {title ? {title} : <>} + {import.meta.env.PROD ? ( + - ) : ( - - )} - - {children} - - ) - }, - { - docType: true, - } -) + + ) : ( + + )} + + {children} + + ) +}) diff --git a/examples/basic/bun.lockb b/examples/basic/bun.lockb index fbd4567..36ec9f5 100755 Binary files a/examples/basic/bun.lockb and b/examples/basic/bun.lockb differ diff --git a/package.json b/package.json index f80583a..77f4414 100644 --- a/package.json +++ b/package.json @@ -117,4 +117,4 @@ "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.9.6" } -} \ No newline at end of file +} diff --git a/src/constants.ts b/src/constants.ts index 6c9e790..8cf2797 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,2 +1,3 @@ export const COMPONENT_NAME = 'component-name' export const DATA_SERIALIZED_PROPS = 'data-serialized-props' +export const IMPORTING_ISLANDS_ID = '__importing_islands' as const diff --git a/src/server/components.tsx b/src/server/components.tsx new file mode 100644 index 0000000..1d6983a --- /dev/null +++ b/src/server/components.tsx @@ -0,0 +1,8 @@ +import type { FC } from 'hono/jsx' +import { useRequestContext } from 'hono/jsx-renderer' +import { IMPORTING_ISLANDS_ID } from '../constants.js' + +export const HasIslands: FC = ({ children }) => { + const c = useRequestContext() + return <>{c.get(IMPORTING_ISLANDS_ID) ? children : <>} +} diff --git a/src/server/index.ts b/src/server/index.ts index cc14490..a6be94c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,2 +1,3 @@ export { createApp } from './server.js' export type { ServerOptions } from './server.js' +export { HasIslands } from './components.js' diff --git a/src/server/server.ts b/src/server/server.ts index 296e0f9..793dd35 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Hono } from 'hono' import type { Env, NotFoundHandler, ErrorHandler, MiddlewareHandler } from 'hono' +import { createMiddleware } from 'hono/factory' import type { H } from 'hono/types' +import { IMPORTING_ISLANDS_ID } from '../constants.js' import { filePathToPath, groupByDirectory, @@ -15,12 +17,19 @@ const ERROR_FILENAME = '_error.tsx' const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] as const type AppFile = { default: Hono } + +type InnerMeta = { + [key in typeof IMPORTING_ISLANDS_ID]?: boolean +} + type RouteFile = { default?: Function -} & { [M in (typeof METHODS)[number]]?: H[] } +} & { [M in (typeof METHODS)[number]]?: H[] } & InnerMeta + type RendererFile = { default: MiddlewareHandler } type NotFoundFile = { default: NotFoundHandler } type ErrorFile = { default: ErrorHandler } + type InitFunction = (app: Hono) => void export type ServerOptions = { @@ -114,6 +123,13 @@ export const createApp = (options?: ServerOptions): Hono => rootPath = filePathToPath(rootPath) for (const [filename, route] of Object.entries(content)) { + // @ts-expect-error route[IMPORTING_ISLANDS_ID] is not typed + const importingIslands = route[IMPORTING_ISLANDS_ID] as boolean + const setInnerMeta = createMiddleware(async function setInnerMeta(c, next) { + c.set(IMPORTING_ISLANDS_ID as any, importingIslands) + await next() + }) + const routeDefault = route.default const path = filePathToPath(filename) @@ -126,17 +142,20 @@ export const createApp = (options?: ServerOptions): Hono => for (const m of METHODS) { const handlers = (route as Record)[m] if (handlers) { + subApp.on(m, path, setInnerMeta) subApp.on(m, path, ...handlers) } } // export default factory.createHandlers(...) if (routeDefault && Array.isArray(routeDefault)) { + subApp.get(path, setInnerMeta) subApp.get(path, ...(routeDefault as H[])) } // export default function Helle() {} if (typeof routeDefault === 'function') { + subApp.get(path, setInnerMeta) subApp.get(path, (c) => { return c.render(routeDefault(), route as any) }) diff --git a/src/vite/index.ts b/src/vite/index.ts index 925022a..1eb5c2b 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -2,6 +2,7 @@ import path from 'path' import devServer, { defaultOptions as devServerDefaultOptions } from '@hono/vite-dev-server' import type { DevServerOptions } from '@hono/vite-dev-server' import type { PluginOption } from 'vite' +import { injectImportingIslands } from './inject-importing-islands.js' import { islandComponents } from './island-components.js' type HonoXOptions = { @@ -38,6 +39,8 @@ function honox(options?: HonoXOptions): PluginOption[] { plugins.push(islandComponents()) } + plugins.push(injectImportingIslands()) + return [ { name: 'honox-vite-config', diff --git a/src/vite/inject-importing-islands.ts b/src/vite/inject-importing-islands.ts new file mode 100644 index 0000000..02bae86 --- /dev/null +++ b/src/vite/inject-importing-islands.ts @@ -0,0 +1,60 @@ +import _generate from '@babel/generator' +import { parse } from '@babel/parser' +import _traverse from '@babel/traverse' +import type { Plugin } from 'vite' +import { IMPORTING_ISLANDS_ID } from '../constants.js' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const traverse = (_traverse.default as typeof _traverse) ?? _traverse +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const generate = (_generate.default as typeof _generate) ?? _generate + +export function injectImportingIslands(): Plugin { + return { + name: 'inject-importing-islands', + transform(code, id) { + if (id.endsWith('.tsx') || id.endsWith('.jsx')) { + let hasIslandsImport = false + const ast = parse(code, { + sourceType: 'module', + plugins: ['jsx'], + }) + + traverse(ast, { + ImportDeclaration(path) { + // We have to make a note that `../components/islands/foo.tsx` is also a target. + if (path.node.source.value.includes('islands/')) { + hasIslandsImport = true + } + }, + }) + + if (hasIslandsImport) { + const hasIslandsNode = { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: IMPORTING_ISLANDS_ID }, + init: { type: 'BooleanLiteral', value: true }, + }, + ], + kind: 'const', + }, + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ast.program.body.push(hasIslandsNode as any) + } + + const output = generate(ast, {}, code) + return { + code: output.code, + map: output.map, + } + } + }, + } +} diff --git a/test/hono-jsx/app/routes/_renderer.tsx b/test/hono-jsx/app/routes/_renderer.tsx index ba8020b..067565b 100644 --- a/test/hono-jsx/app/routes/_renderer.tsx +++ b/test/hono-jsx/app/routes/_renderer.tsx @@ -1,11 +1,14 @@ import { jsxRenderer } from 'hono/jsx-renderer' +import { HasIslands } from '../../../../src/server' export default jsxRenderer(({ children, title }) => { return ( {title} - + + + {children} diff --git a/test/hono-jsx/integration.test.ts b/test/hono-jsx/integration.test.ts index b75a7d8..af2ffab 100644 --- a/test/hono-jsx/integration.test.ts +++ b/test/hono-jsx/integration.test.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { poweredBy } from 'hono/powered-by' +import { describe, it, expect, vi } from 'vitest' import { createApp } from '../../src/server' describe('Basic', () => { @@ -27,6 +28,21 @@ describe('Basic', () => { method: 'GET', handler: expect.any(Function), }, + { + path: '/about/:name/address', + method: 'GET', + handler: expect.any(Function), + }, + { + path: '/about/:name', + method: 'GET', + handler: expect.any(Function), + }, + { + path: '/about/:name', + method: 'GET', + handler: expect.any(Function), + }, { path: '/about/:name', method: 'POST', @@ -34,6 +50,11 @@ describe('Basic', () => { }, { path: '/about/:name', + method: 'POST', + handler: expect.any(Function), + }, + { + path: '/interaction', method: 'GET', handler: expect.any(Function), }, @@ -43,8 +64,16 @@ describe('Basic', () => { handler: expect.any(Function), }, { path: '/api', method: 'POST', handler: expect.any(Function) }, + { path: '/api', method: 'POST', handler: expect.any(Function) }, + { path: '/api', method: 'GET', handler: expect.any(Function) }, { path: '/api', method: 'GET', handler: expect.any(Function) }, { path: '/', method: 'GET', handler: expect.any(Function) }, + { path: '/', method: 'GET', handler: expect.any(Function) }, + { + path: '/post', + method: 'GET', + handler: expect.any(Function), + }, { path: '/post', method: 'GET', @@ -55,8 +84,14 @@ describe('Basic', () => { method: 'GET', handler: expect.any(Function), }, + { + path: '/throw_error', + method: 'GET', + handler: expect.any(Function), + }, ] - expect(app.routes).toEqual(routes) + expect(app.routes).toHaveLength(routes.length) + expect(app.routes).toEqual(expect.arrayContaining(routes)) }) it('Should return 200 response - / with a Powered By header', async () => { @@ -127,7 +162,7 @@ describe('Basic', () => { const res = await app.request('/') expect(res.status).toBe(200) expect(await res.text()).toBe( - 'This is a title

Hello

' + 'This is a title

Hello

' ) }) @@ -135,7 +170,7 @@ describe('Basic', () => { const res = await app.request('/foo') expect(res.status).toBe(404) expect(await res.text()).toBe( - 'Not Found

Not Found

' + 'Not Found

Not Found

' ) }) @@ -144,7 +179,7 @@ describe('Basic', () => { expect(res.status).toBe(200) // hono/jsx escape a single quote to ' expect(await res.text()).toBe( - 'me

It's me

My name is me' + 'me

It's me

My name is me' ) }) @@ -157,11 +192,20 @@ describe('Basic', () => { ) }) + it('Should return 200 response /interaction', async () => { + const res = await app.request('/interaction') + expect(res.status).toBe(200) + // hono/jsx escape a single quote to ' + expect(await res.text()).toBe( + '

Count: 5

' + ) + }) + it('Should return 500 response /throw_error', async () => { const res = await app.request('/throw_error') expect(res.status).toBe(500) expect(await res.text()).toBe( - 'Internal Server Error

Custom Error Message: Foo

' + 'Internal Server Error

Custom Error Message: Foo

' ) }) }) diff --git a/test/hono-jsx/vitest.config.ts b/test/hono-jsx/vitest.config.ts index 0903e10..f220ecb 100644 --- a/test/hono-jsx/vitest.config.ts +++ b/test/hono-jsx/vitest.config.ts @@ -1,13 +1,12 @@ import mdx from '@mdx-js/rollup' import { defineConfig } from 'vitest/config' +import { injectImportingIslands } from '../../src/vite/inject-importing-islands' import { islandComponents } from '../../src/vite/island-components' export default defineConfig({ - test: { - globals: true, - }, plugins: [ islandComponents(), + injectImportingIslands(), mdx({ jsxImportSource: 'hono/jsx', }),