[wip] SPA shell mode #1
45
README.md
45
README.md
|
|
@ -60,6 +60,51 @@ The `req` JSX prop will be given the `Hono` request:
|
||||||
|
|
||||||
To put a file in `./src/pages` but prevent it from being server, preface it with `_`.
|
To put a file in `./src/pages` but prevent it from being server, preface it with `_`.
|
||||||
|
|
||||||
|
### SPA Shell Mode
|
||||||
|
|
||||||
|
If `src/client/index.tsx` (or `src/client/index.ts`) exists, hype automatically serves a minimal HTML shell at `/` - no `src/pages/index.tsx` needed.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
client/
|
||||||
|
index.tsx <- This triggers SPA shell mode!
|
||||||
|
server/
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The shell HTML served at `/`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>App</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/client/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Your client code mounts to `#app`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/client/index.tsx
|
||||||
|
import { render } from 'hono/jsx/dom'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <h1>Hello from the SPA!</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app')!)
|
||||||
|
```
|
||||||
|
|
||||||
|
See `examples/spa-shell` for a complete example.
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
`hype` will wrap everything in a simple default layout unless `./src/pages/_layout.tsx` exists,
|
`hype` will wrap everything in a simple default layout unless `./src/pages/_layout.tsx` exists,
|
||||||
|
|
|
||||||
44
examples/spa-shell/README.md
Normal file
44
examples/spa-shell/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# SPA Shell Mode Example
|
||||||
|
|
||||||
|
This example demonstrates the SPA shell mode feature. When `src/client/index.tsx` (or `.ts`) exists, hype automatically serves a minimal HTML shell at `/` - no `src/pages/index.tsx` needed.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
client/
|
||||||
|
index.tsx <- This triggers SPA shell mode!
|
||||||
|
server/
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit http://localhost:3000 to see the SPA in action.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
When hype detects `src/client/index.tsx` or `src/client/index.ts`, it serves this HTML at `/`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>App</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/client/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Your client code mounts to `#app` and takes over from there.
|
||||||
10
examples/spa-shell/package.json
Normal file
10
examples/spa-shell/package.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "spa-shell-example",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/server/index.ts",
|
||||||
|
"dev": "bun run --hot src/server/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hype": "link:../.."
|
||||||
|
}
|
||||||
|
}
|
||||||
21
examples/spa-shell/src/client/index.tsx
Normal file
21
examples/spa-shell/src/client/index.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { render } from 'hono/jsx/dom'
|
||||||
|
import { useState } from 'hono/jsx/dom'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>SPA Shell Mode</h1>
|
||||||
|
<p>This page is served from a minimal HTML shell - no <code>src/pages/index.tsx</code> needed!</p>
|
||||||
|
<h2>Count: {count}</h2>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setCount(c => c + 1)}>+</button>
|
||||||
|
{' '}
|
||||||
|
<button onClick={() => setCount(c => c && c - 1)}>-</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app')!)
|
||||||
8
examples/spa-shell/src/server/index.ts
Normal file
8
examples/spa-shell/src/server/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Hype } from 'hype'
|
||||||
|
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
// Add your API routes here
|
||||||
|
app.get('/api/hello', (c) => c.json({ message: 'Hello from the API!' }))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
29
examples/spa-shell/tsconfig.json
Normal file
29
examples/spa-shell/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": ["src/server/*"],
|
||||||
|
"#*": ["src/client/*"],
|
||||||
|
"@*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,22 @@ export type { Context } from 'hono'
|
||||||
|
|
||||||
const pageCache = new Map()
|
const pageCache = new Map()
|
||||||
|
|
||||||
|
function generateShellHtml(title = 'App', mountId = 'app', clientEntry = '/client/index.js'): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>${title}</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="${mountId}"></div>
|
||||||
|
<script type="module" src="${clientEntry}"></script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
export type HypeProps = {
|
export type HypeProps = {
|
||||||
pico?: boolean
|
pico?: boolean
|
||||||
reset?: boolean
|
reset?: boolean
|
||||||
|
|
@ -68,7 +84,7 @@ export class Hype<
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
registerRoutes() {
|
async registerRoutes() {
|
||||||
if (this.routesRegistered) return
|
if (this.routesRegistered) return
|
||||||
this.routesRegistered = true
|
this.routesRegistered = true
|
||||||
|
|
||||||
|
|
@ -167,6 +183,15 @@ export class Hype<
|
||||||
return render404(c)
|
return render404(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SPA auto-detection: serve shell if src/client/index.tsx exists
|
||||||
|
const clientIndexPath = join(process.env.PWD ?? '.', 'src/client/index.tsx')
|
||||||
|
const clientIndexTsPath = join(process.env.PWD ?? '.', 'src/client/index.ts')
|
||||||
|
const hasSpaClient = await Bun.file(clientIndexPath).exists() || await Bun.file(clientIndexTsPath).exists()
|
||||||
|
|
||||||
|
if (hasSpaClient) {
|
||||||
|
this.get('/', (c) => c.html(generateShellHtml()))
|
||||||
|
}
|
||||||
|
|
||||||
// file based routing
|
// file based routing
|
||||||
this.on('GET', ['/', '/:page'], async c => {
|
this.on('GET', ['/', '/:page'], async c => {
|
||||||
const pageName = (c.req.param('page') ?? 'index').replace('.', '')
|
const pageName = (c.req.param('page') ?? 'index').replace('.', '')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user