Skip to content

Commit

Permalink
feat: support hono/jsx client, add e2e, add an example (#10)
Browse files Browse the repository at this point in the history
* feat: support hono/jsx client, add e2e, add an example

* install playwright on CI
  • Loading branch information
yusukebe committed Jan 18, 2024
1 parent 27e844f commit db25d98
Show file tree
Hide file tree
Showing 30 changed files with 448 additions and 64 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ jobs:
with:
node-version: 20.x
- run: yarn install --frozen-lockfile
- run: npx playwright install --with-deps
- run: yarn build
- run: yarn test
2 changes: 2 additions & 0 deletions examples/basic/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
yarn.lock
.yarn/*
3 changes: 3 additions & 0 deletions examples/basic/app/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createClient } from 'honox/client'

createClient()
16 changes: 16 additions & 0 deletions examples/basic/app/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// eslint-disable-next-line node/no-extraneous-import
import 'hono'

type Head = {
title?: string
}

declare module 'hono' {
interface Env {
Variables: {}
Bindings: {}
}
interface ContextRenderer {
(content: string | Promise<string>, head?: Head): Response | Promise<Response>
}
}
11 changes: 11 additions & 0 deletions examples/basic/app/islands/counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useState } from 'hono/jsx'

export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
24 changes: 24 additions & 0 deletions examples/basic/app/routes/_renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { jsxRenderer } from 'hono/jsx-renderer'

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 ? (
<script type='module' src='/static/client.js'></script>
) : (
<script type='module' src='/app/client.ts'></script>
)}
</head>
<body>{children}</body>
</html>
)
},
{
docType: true,
}
)
15 changes: 15 additions & 0 deletions examples/basic/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'

export default createRoute((c) => {
const name = c.req.query('name') ?? 'Hono'
return c.render(
<div>
<h1>Hello, {name}!</h1>
<Counter />
</div>,
{
title: name,
}
)
})
11 changes: 11 additions & 0 deletions examples/basic/app/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { serveStatic } from 'hono/cloudflare-pages'
import { showRoutes } from 'hono/dev'
import { createApp } from 'honox/server'

const app = createApp({
init: (app) => app.get('/static/*', serveStatic()),
})

showRoutes(app)

export default app
21 changes: 21 additions & 0 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "basic",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && vite build --mode client",
"preview": "wrangler pages dev ./dist",
"deploy": "$npm_execpath build && wrangler pages deploy ./dist"
},
"private": true,
"dependencies": {
"honox": "^0.0.0"
},
"devDependencies": {
"vite": "^5.0.10",
"wrangler": "^3.22.1"
},
"resolutions": {
"honox": "portal:../.."
}
}
17 changes: 17 additions & 0 deletions examples/basic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"lib": [
"esnext"
],
"types": [
"vite/client"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
}
33 changes: 33 additions & 0 deletions examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import honox from 'honox/vite'
import { defineConfig } from '../../node_modules/vite'

export default defineConfig(({ mode }) => {
if (mode === 'client') {
return {
build: {
rollupOptions: {
input: ['./app/client.ts'],
output: {
entryFileNames: 'static/client.js',
chunkFileNames: 'static/assets/[name]-[hash].js',
assetFileNames: 'static/assets/[name].[ext]',
},
},
emptyOutDir: false,
copyPublicDir: false,
},
}
} else {
return {
build: {
rollupOptions: {
output: {
entryFileNames: '_worker.js',
},
},
minify: true,
},
plugins: [honox()],
}
}
})
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
"main": "dist/index.js",
"type": "module",
"scripts": {
"test": "yarn typecheck && yarn test:unit && yarn test:integration",
"test": "yarn typecheck && yarn test:unit && yarn test:integration && yarn test:e2e",
"test:unit": "vitest --run test/unit",
"test:integration": "yarn test:integration:api && yarn test:integration:hono-jsx",
"test:integration:hono-jsx": "vitest run -c ./test/hono-jsx/vitest.config.ts ./test/hono-jsx/integration.test.ts",
"test:integration:api": "vitest run -c ./test/api/vitest.config.ts ./test/api/integration.test.ts",
"test:e2e": "playwright test -c ./test/hono-jsx/playwright.config.ts ./test/hono-jsx/e2e.test.ts",
"typecheck": "tsc --noEmit",
"build": "tsup && publint",
"watch": "tsup --watch",
Expand Down Expand Up @@ -91,6 +92,7 @@
},
"devDependencies": {
"@hono/eslint-config": "^0.0.3",
"@playwright/test": "^1.41.0",
"@types/babel__generator": "^7",
"@types/babel__traverse": "^7",
"@types/node": "^20.10.5",
Expand All @@ -108,4 +110,4 @@
"engines": {
"node": ">=18.14.1"
}
}
}
17 changes: 10 additions & 7 deletions src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { jsx as jsxFn } from 'hono/jsx'
import { render } from 'hono/jsx/dom'
import { COMPONENT_NAME, DATA_SERIALIZED_PROPS } from '../constants.js'
import type { CreateElement, Hydrate } from '../types.js'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FileCallback = () => Promise<{ default: Promise<any> }>

export type ClientOptions = {
hydrate: Hydrate
createElement: CreateElement
hydrate?: Hydrate
createElement?: CreateElement
ISLAND_FILES?: Record<string, () => Promise<unknown>>
island_root?: string
}

export const createClient = async (options: ClientOptions) => {
const FILES = options.ISLAND_FILES ?? import.meta.glob('/app/islands/**/[a-zA-Z0-9[-]+.(tsx|ts)')
const root = options.island_root ?? '/app/islands/'
export const createClient = async (options?: ClientOptions) => {
const FILES = options?.ISLAND_FILES ?? import.meta.glob('/app/islands/**/[a-zA-Z0-9[-]+.(tsx|ts)')
const root = options?.island_root ?? '/app/islands/'

const hydrateComponent = async () => {
const filePromises = Object.keys(FILES).map(async (filePath) => {
Expand All @@ -28,10 +30,11 @@ export const createClient = async (options: ClientOptions) => {
const serializedProps = element.attributes.getNamedItem(DATA_SERIALIZED_PROPS)?.value
const props = JSON.parse(serializedProps ?? '{}') as Record<string, unknown>

const hydrate = options.hydrate
const createElement = options.createElement
const hydrate = options?.hydrate ?? render
const createElement = options?.createElement ?? jsxFn

const newElem = await createElement(Component, props)
// @ts-expect-error default `render` cause a type error
await hydrate(newElem, element)
})
await Promise.all(elementPromises)
Expand Down
102 changes: 56 additions & 46 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
groupByDirectory,
listByDirectory,
pathToDirectoryPath,
sortDirectoriesByDepth,
} from '../utils/file.js'

const NOTFOUND_FILENAME = '_404.tsx'
Expand Down Expand Up @@ -76,67 +77,76 @@ export const createApp = <E extends Env>(options?: ServerOptions<E>): Hono<E> =>
import.meta.glob<RouteFile | AppFile>('/app/routes/**/[a-z0-9[-][a-z0-9[_-]*.(ts|tsx|mdx)', {
eager: true,
})
const routesMap = groupByDirectory(ROUTES_FILE)
const routesMap = sortDirectoriesByDepth(groupByDirectory(ROUTES_FILE))

for (const [dir, content] of Object.entries(routesMap)) {
const subApp = new Hono()
for (const map of routesMap) {
for (const [dir, content] of Object.entries(map)) {
const subApp = new Hono()

// Renderer
let rendererFiles = rendererList[dir]
// Renderer
let rendererFiles = rendererList[dir]

if (rendererFiles) {
applyRenderer(rendererFiles[0])
}
if (rendererFiles) {
applyRenderer(rendererFiles[0])
}

if (!rendererFiles) {
const dirPaths = dir.split('/')
const getRendererPaths = (paths: string[]) => {
rendererFiles = rendererList[paths.join('/')]
if (!rendererFiles) {
paths.pop()
if (paths.length) {
getRendererPaths(paths)
if (!rendererFiles) {
const dirPaths = dir.split('/')
const getRendererPaths = (paths: string[]) => {
rendererFiles = rendererList[paths.join('/')]
if (!rendererFiles) {
paths.pop()
if (paths.length) {
getRendererPaths(paths)
}
}
return rendererFiles
}
rendererFiles = getRendererPaths(dirPaths)
if (rendererFiles) {
applyRenderer(rendererFiles[0])
}
return rendererFiles
}
rendererFiles = getRendererPaths(dirPaths)
if (rendererFiles) {
applyRenderer(rendererFiles[0])
}
}

// Root path
let rootPath = dir.replace(rootRegExp, '')
rootPath = filePathToPath(rootPath)
// Root path
let rootPath = dir.replace(rootRegExp, '')
rootPath = filePathToPath(rootPath)

for (const [filename, route] of Object.entries(content)) {
const routeDefault = route.default
const path = filePathToPath(filename)
for (const [filename, route] of Object.entries(content)) {
const routeDefault = route.default
const path = filePathToPath(filename)

// Instance of Hono
if (routeDefault && 'fetch' in routeDefault) {
subApp.route(path, routeDefault)
}
// Instance of Hono
if (routeDefault && 'fetch' in routeDefault) {
subApp.route(path, routeDefault)
}

// export const POST = factory.createHandlers(...)
for (const m of METHODS) {
const handlers = (route as Record<string, H[]>)[m]
if (handlers) {
subApp.on(m, path, ...handlers)
}
}

// export const POST = factory.createHandlers(...)
for (const m of METHODS) {
const handlers = (route as Record<string, H[]>)[m]
if (handlers) {
subApp.on(m, path, ...handlers)
// export default factory.createHandlers(...)
if (routeDefault && Array.isArray(routeDefault)) {
subApp.get(path, ...(routeDefault as H[]))
}
}

// export default factory.createHandlers(...)
if (routeDefault && Array.isArray(routeDefault)) {
subApp.get(path, ...(routeDefault as H[]))
// export default function Helle() {}
if (typeof routeDefault === 'function') {
subApp.get(path, (c) => {
return c.render(routeDefault())
})
}
}
// Not Found
applyNotFound(subApp, dir, notFoundMap)
// Error
applyError(subApp, dir, errorMap)
app.route(rootPath, subApp)
}
// Not Found
applyNotFound(subApp, dir, notFoundMap)
// Error
applyError(subApp, dir, errorMap)
app.route(rootPath, subApp)
}

return app
Expand Down
14 changes: 14 additions & 0 deletions src/utils/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ export const groupByDirectory = <T = unknown>(files: Record<string, T>) => {
return organizedFiles
}

export const sortDirectoriesByDepth = <T>(directories: Record<string, T>) => {
const sortedKeys = Object.keys(directories).sort((a, b) => {
const depthA = a.split('/').length
const depthB = b.split('/').length
return depthB - depthA
})

const sortedDirectories: Record<string, T>[] = sortedKeys.map((key) => {
return { [key]: directories[key] }
})

return sortedDirectories
}

/*
/app/routes/_renderer.tsx
/app/routes/blog/_renderer.tsx
Expand Down
Loading

0 comments on commit db25d98

Please sign in to comment.