Skip to content

Commit

Permalink
feat: introduce <HasIslands> (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
yusukebe authored Feb 1, 2024
1 parent 297eaf6 commit a54466f
Show file tree
Hide file tree
Showing 14 changed files with 171 additions and 40 deletions.
Binary file modified bun.lockb
Binary file not shown.
11 changes: 3 additions & 8 deletions examples/basic/app/global.d.ts
Original file line number Diff line number Diff line change
@@ -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<string>, head?: Head): Response | Promise<Response>
(content: string | Promise<string>, props?: Props): Response | Promise<Response>
}
}
40 changes: 19 additions & 21 deletions examples/basic/app/routes/_renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import { jsxRenderer } from 'hono/jsx-renderer'
import { HAS_ISLANDS } from 'honox/server'

export default jsxRenderer(
({ children, title }) => {
return (
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
{title ? <title>{title}</title> : <></>}
{import.meta.env.PROD ? (
export default jsxRenderer(({ children, title }) => {
return (
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
{title ? <title>{title}</title> : <></>}
{import.meta.env.PROD ? (
<HAS_ISLANDS>
<script type='module' src='/static/client.js'></script>
) : (
<script type='module' src='/app/client.ts'></script>
)}
</head>
<body>{children}</body>
</html>
)
},
{
docType: true,
}
)
</HAS_ISLANDS>
) : (
<script type='module' src='/app/client.ts'></script>
)}
</head>
<body>{children}</body>
</html>
)
})
Binary file modified examples/basic/bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.9.6"
}
}
}
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions src/server/components.tsx
Original file line number Diff line number Diff line change
@@ -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 : <></>}</>
}
1 change: 1 addition & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createApp } from './server.js'
export type { ServerOptions } from './server.js'
export { HasIslands } from './components.js'
21 changes: 20 additions & 1 deletion src/server/server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<E extends Env = Env> = (app: Hono<E>) => void

export type ServerOptions<E extends Env = Env> = {
Expand Down Expand Up @@ -114,6 +123,13 @@ export const createApp = <E extends Env>(options?: ServerOptions<E>): Hono<E> =>
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)

Expand All @@ -126,17 +142,20 @@ export const createApp = <E extends Env>(options?: ServerOptions<E>): Hono<E> =>
for (const m of METHODS) {
const handlers = (route as Record<string, H[]>)[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)
})
Expand Down
3 changes: 3 additions & 0 deletions src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -38,6 +39,8 @@ function honox(options?: HonoXOptions): PluginOption[] {
plugins.push(islandComponents())
}

plugins.push(injectImportingIslands())

return [
{
name: 'honox-vite-config',
Expand Down
60 changes: 60 additions & 0 deletions src/vite/inject-importing-islands.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
},
}
}
5 changes: 4 additions & 1 deletion test/hono-jsx/app/routes/_renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { jsxRenderer } from 'hono/jsx-renderer'
import { HasIslands } from '../../../../src/server'

export default jsxRenderer(({ children, title }) => {
return (
<html>
<head>
<title>{title}</title>
<script type='module' src='/app/client.ts'></script>
<HasIslands>
<script type='module' src='/app/client.ts'></script>
</HasIslands>
</head>
<body>{children}</body>
</html>
Expand Down
54 changes: 49 additions & 5 deletions test/hono-jsx/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -27,13 +28,33 @@ 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',
handler: expect.any(Function),
},
{
path: '/about/:name',
method: 'POST',
handler: expect.any(Function),
},
{
path: '/interaction',
method: 'GET',
handler: expect.any(Function),
},
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -127,15 +162,15 @@ describe('Basic', () => {
const res = await app.request('/')
expect(res.status).toBe(200)
expect(await res.text()).toBe(
'<html><head><title>This is a title</title><script type="module" src="/app/client.ts"></script></head><body><h1>Hello</h1></body></html>'
'<html><head><title>This is a title</title></head><body><h1>Hello</h1></body></html>'
)
})

it('Should return 404 response - /foo', async () => {
const res = await app.request('/foo')
expect(res.status).toBe(404)
expect(await res.text()).toBe(
'<html><head><title>Not Found</title><script type="module" src="/app/client.ts"></script></head><body><h1>Not Found</h1></body></html>'
'<html><head><title>Not Found</title></head><body><h1>Not Found</h1></body></html>'
)
})

Expand All @@ -144,7 +179,7 @@ describe('Basic', () => {
expect(res.status).toBe(200)
// hono/jsx escape a single quote to &#39;
expect(await res.text()).toBe(
'<html><head><title>me</title><script type="module" src="/app/client.ts"></script></head><body><p>It&#39;s me</p><b>My name is me</b></body></html>'
'<html><head><title>me</title></head><body><p>It&#39;s me</p><b>My name is me</b></body></html>'
)
})

Expand All @@ -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 &#39;
expect(await res.text()).toBe(
'<html><head><title></title><script type="module" src="/app/client.ts"></script></head><body><honox-island component-name="Counter.tsx" data-serialized-props="{&quot;initial&quot;:5}"><div><p>Count: 5</p><button onClick="() =&gt; setCount(count + 1)">Increment</button></div></honox-island></body></html>'
)
})

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(
'<html><head><title>Internal Server Error</title><script type="module" src="/app/client.ts"></script></head><body><h1>Custom Error Message: Foo</h1></body></html>'
'<html><head><title>Internal Server Error</title></head><body><h1>Custom Error Message: Foo</h1></body></html>'
)
})
})
Expand Down
5 changes: 2 additions & 3 deletions test/hono-jsx/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
Expand Down

0 comments on commit a54466f

Please sign in to comment.