Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f96acbd17e |
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -109,13 +109,3 @@ bun --hot ./index.ts
|
|||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
This package exports raw `.tsx` source as the runtime entry (Bun handles it fine), but uses hand-written `.d.ts` files in `dist/` for the `types` export condition. This prevents consumers' `tsc` from type-checking our Hono JSX internals, which would fail if they use a different JSX runtime.
|
||||
|
||||
- When adding or changing exported functions/types, update the corresponding `.d.ts` file in `dist/` by hand.
|
||||
- Do NOT add a build step to generate declarations. The `.d.ts` files are small and manually maintained.
|
||||
- `dist/index.d.ts` — main exports (`Hype` class, `HypeProps`, `SSEHandler`, re-exports from utils/layout)
|
||||
- `dist/utils.d.ts` — utility function signatures
|
||||
- `dist/layout.d.ts` — `Layout` and `ReloadScript` FC declarations
|
||||
|
|
|
|||
114
README.md
114
README.md
|
|
@ -13,12 +13,11 @@
|
|||
`hype` wraps `hono` with useful features for fast prototyping:
|
||||
|
||||
- HTTP logging to the console (disable with `NO_HTTP_LOG` env variable)
|
||||
- Auto-port selection in dev mode: if the port is busy, tries the next one (disable with `NO_AUTOPORT` env variable)
|
||||
- Static files served from `pub/`
|
||||
- Page-based routing to `.tsx` files that export a `JSX` function in `./src/pages`
|
||||
- Transpile `.ts` files in `src/client/blah.ts` via `website.com/client/blah.ts`
|
||||
- Helpers like `css` and `js` template tags.
|
||||
- Default, simple HTML5 layout with working frontend transpilation/bundling, or supply your own.
|
||||
- Live reload in development mode — pages automatically refresh when the server restarts.
|
||||
- Optional CSS reset.
|
||||
- Optional pico.css.
|
||||
|
||||
|
|
@ -61,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 `_`.
|
||||
|
||||
### 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
|
||||
|
||||
`hype` will wrap everything in a simple default layout unless `./src/pages/_layout.tsx` exists,
|
||||
|
|
@ -115,6 +159,25 @@ CSS can be accessed via `/css/main.css`:
|
|||
<link href="/css/main.css" rel="stylesheet" />
|
||||
```
|
||||
|
||||
Or written inline using the `css` template tag:
|
||||
|
||||
```tsx
|
||||
import { css } from "hype"
|
||||
|
||||
export default () => (
|
||||
<div>
|
||||
{css`
|
||||
* {
|
||||
color: red;
|
||||
}
|
||||
`}
|
||||
<h1>Hello</h1>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
Install the `vscode-styled-components` VSCode extension to highlight inline `css` tags!
|
||||
|
||||
### css reset
|
||||
|
||||
To use reset.css with the default layout, create `Hype` with `{ reset: true }`:
|
||||
|
|
@ -146,6 +209,27 @@ import { initAbout } from "./about"
|
|||
import utils from "./shared/utils"
|
||||
```
|
||||
|
||||
Or written inline as transpiled typescript using the `js` template tag:
|
||||
|
||||
```tsx
|
||||
import { js } from "hype"
|
||||
|
||||
export default () => (
|
||||
<div>
|
||||
{js`
|
||||
window.onload = () => alert(welcomeMsg(Date.now()))
|
||||
|
||||
function welcomeMsg(time: number): string {
|
||||
return "Welcome to my website!"
|
||||
}
|
||||
`}
|
||||
<h1>Hello!</h1>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
Install the `vscode-styled-components` VSCode extension to highlight inline `js` tags!
|
||||
|
||||
### pub
|
||||
|
||||
Anything in `pub/` is served as-is. Simple stuff.
|
||||
|
|
@ -204,37 +288,15 @@ Test with curl:
|
|||
curl -N http://localhost:3000/api/time
|
||||
```
|
||||
|
||||
### Live Reload
|
||||
|
||||
In development mode (`NODE_ENV !== 'production'`), `hype` automatically injects a live reload script into pages using the default layout. When the server restarts (e.g. via `bun --hot`), the browser will automatically reload the page.
|
||||
|
||||
If you're using a custom layout, you can add live reload by importing and rendering the `ReloadScript` component:
|
||||
|
||||
```tsx
|
||||
import { ReloadScript } from "hype"
|
||||
|
||||
export default ({ children, title }: any) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title ?? "hype"}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
<ReloadScript />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
```
|
||||
|
||||
`ReloadScript` only renders in development mode — it's a no-op in production.
|
||||
|
||||
### utils
|
||||
|
||||
`hype` includes helpful utils for your webapp:
|
||||
|
||||
- `capitalize(str: string): string` - Capitalizes a word
|
||||
- `css` Template Tag - Lets you inline CSS in your TSX. Returns a `<style>` tag
|
||||
- `darkenColor(hex: string, opacity: number): string` - Darken a hex color by blending with black. opacity 1 = original, 0 = black
|
||||
- `isDarkMode(): boolean` - Check if the user prefers dark mode
|
||||
- `js` Template Tag - Lets you inline JavaScript in your TSX. Transpiles and returns a `<script>` tag
|
||||
- `lightenColor(hex: string, opacity: number): string` - Lighten a hex color by blending with white. opacity 1 = original, 0 = white
|
||||
- `rand(end = 2, startAtZero = false): number` - Generate random integer. `rand(2)` flips a coin, `rand(6)` rolls a die, `rand(20)` rolls d20
|
||||
- `randIndex<T>(list: T[]): number | undefined` - Get a random index from an array. `randIndex([5, 7, 9])` returns `0`, `1`, or `2`
|
||||
|
|
|
|||
19
bun.lock
19
bun.lock
|
|
@ -3,7 +3,7 @@
|
|||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@because/hype",
|
||||
"name": "hype",
|
||||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"kleur": "^4.1.5",
|
||||
|
|
@ -11,19 +11,24 @@
|
|||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
"@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
"hono": ["hono@4.11.1", "", {}, "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1055
docs/GUIDE.md
1055
docs/GUIDE.md
File diff suppressed because it is too large
Load Diff
2324
docs/HYPE+FORGE.md
2324
docs/HYPE+FORGE.md
File diff suppressed because it is too large
Load Diff
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "hype-live-reload-example",
|
||||
"module": "src/index.tsx",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@because/hype": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/server/index.ts",
|
||||
"dev": "bun run --hot src/server/index.ts"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
console.log('Live reload is active in dev mode — no setup required.')
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
section {
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f0f0f0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
code {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export default () => (
|
||||
<section>
|
||||
<h1>Live Reload Demo</h1>
|
||||
<p>Run this with <code>bun run dev</code>, then edit this file.</p>
|
||||
<p>The browser will automatically refresh — no manual reload needed.</p>
|
||||
<p class="hint">OMG! Try changing this text and saving!</p>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Hype } from '@because/hype'
|
||||
|
||||
const app = new Hype({})
|
||||
|
||||
export default app.defaults
|
||||
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
|
||||
|
|
@ -1,36 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"$*": ["src/server/*"],
|
||||
"#*": ["src/client/*"],
|
||||
"@*": ["src/shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
registry=https://npm.nose.space
|
||||
35
examples/spa/bun.lock
Normal file
35
examples/spa/bun.lock
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "hype-spa-example",
|
||||
"dependencies": {
|
||||
"hype": "git+https://git.nose.space/defunkt/hype",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
|
||||
|
||||
"hype": ["hype@git+https://git.nose.space/defunkt/hype#33d228c9ac6a01fad570e0ac2ba836a100dde623", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "33d228c9ac6a01fad570e0ac2ba836a100dde623"],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@
|
|||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@because/hype": "*"
|
||||
"hype": "git+https://git.nose.space/defunkt/hype"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/server/index.ts",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Hype } from '@because/hype'
|
||||
import { Hype } from 'hype'
|
||||
|
||||
const app = new Hype({ layout: false })
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
registry=https://npm.nose.space
|
||||
35
examples/ssr/bun.lock
Normal file
35
examples/ssr/bun.lock
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "hype-ssr-example",
|
||||
"dependencies": {
|
||||
"hype": "git+https://git.nose.space/defunkt/hype",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
|
||||
|
||||
"hype": ["hype@git+https://git.nose.space/defunkt/hype#33d228c9ac6a01fad570e0ac2ba836a100dde623", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "33d228c9ac6a01fad570e0ac2ba836a100dde623"],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@
|
|||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@because/hype": "*"
|
||||
"hype": "git+https://git.nose.space/defunkt/hype"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/server/index.ts",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { fe } from 'hype'
|
||||
|
||||
const test = fe(() => {
|
||||
alert('ding dong')
|
||||
})
|
||||
|
||||
export default () => (
|
||||
<section>
|
||||
<a href="#" onclick="alert('ding dong')">test</a>
|
||||
<a href="#" onclick={test}>test</a>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { js } from 'hype'
|
||||
|
||||
export default () => (
|
||||
<section>
|
||||
<h1>SSE Demo</h1>
|
||||
|
|
@ -10,8 +12,7 @@ export default () => (
|
|||
<a href="/">Back to Home</a>
|
||||
</p>
|
||||
|
||||
<script dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
{js`
|
||||
const statusEl = document.getElementById('status')
|
||||
const timeEl = document.getElementById('time')
|
||||
const countEl = document.getElementById('count')
|
||||
|
|
@ -35,6 +36,6 @@ export default () => (
|
|||
statusEl.textContent = 'Disconnected'
|
||||
statusEl.style.color = 'red'
|
||||
}
|
||||
`}} />
|
||||
`}
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Hype } from '@because/hype'
|
||||
import { Hype } from 'hype'
|
||||
|
||||
const app = new Hype({})
|
||||
|
||||
|
|
|
|||
26
package.json
26
package.json
|
|
@ -1,31 +1,17 @@
|
|||
{
|
||||
"name": "@because/hype",
|
||||
"version": "0.0.14",
|
||||
"name": "hype",
|
||||
"module": "src/index.tsx",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.tsx"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/utils.d.ts",
|
||||
"default": "./src/utils.tsx"
|
||||
}
|
||||
".": "./src/index.tsx",
|
||||
"./utils": "./src/utils.tsx"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src/css",
|
||||
"src/frontend.ts",
|
||||
"src/index.tsx",
|
||||
"src/layout.tsx",
|
||||
"src/lib",
|
||||
"src/utils.tsx",
|
||||
"README.md"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"kleur": "^4.1.5"
|
||||
|
|
|
|||
48
src/frontend.ts
Normal file
48
src/frontend.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
|
||||
export const fnStorage = new AsyncLocalStorage<{
|
||||
fns: Map<string, string>
|
||||
counter: number
|
||||
}>()
|
||||
|
||||
export function fe<T extends Record<string, unknown>>(
|
||||
fn: (args?: T) => void,
|
||||
args?: T
|
||||
): string {
|
||||
const store = fnStorage.getStore()
|
||||
if (!store) {
|
||||
// Fallback to IIFE if outside request context
|
||||
return args
|
||||
? `(${fn.toString()})(${JSON.stringify(args)})`
|
||||
: `(${fn.toString()})()`
|
||||
}
|
||||
|
||||
const fnStr = fn.toString()
|
||||
|
||||
// Dedupe by function body
|
||||
for (const [name, body] of store.fns)
|
||||
if (body === fnStr)
|
||||
return args ? `${name}(${JSON.stringify(args)})` : `${name}()`
|
||||
|
||||
const name = `frontendFn${store.counter++}`
|
||||
store.fns.set(name, fnStr)
|
||||
|
||||
return args ? `${name}(${JSON.stringify(args)})` : `${name}()`
|
||||
}
|
||||
|
||||
export function feFunctions(): string[] {
|
||||
const store = fnStorage.getStore()
|
||||
if (!store?.fns.size) return []
|
||||
|
||||
return [...store.fns.entries()].map(([name, body]) => {
|
||||
// Handle arrow functions vs regular functions
|
||||
if (body.startsWith('(') || body.startsWith('async ('))
|
||||
return `const ${name} = ${body};`
|
||||
|
||||
// Named function - rename it
|
||||
return body.replace(/^(async\s+)?function\s*\w*/, `$1function ${name}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Keep for backwards compat
|
||||
export const frontend = fe
|
||||
159
src/index.tsx
159
src/index.tsx
|
|
@ -1,4 +1,4 @@
|
|||
import { join, resolve } from 'path'
|
||||
import { join } from 'path'
|
||||
import { render as formatHTML } from './lib/html-formatter'
|
||||
import { type Context, Hono, type Schema, type Env } from 'hono'
|
||||
import { serveStatic } from 'hono/bun'
|
||||
|
|
@ -7,28 +7,41 @@ import color from 'kleur'
|
|||
|
||||
import { transpile } from './utils'
|
||||
import defaultLayout from './layout'
|
||||
import { feFunctions, fnStorage } from './frontend'
|
||||
|
||||
const SHOW_HTTP_LOG = true
|
||||
const CSS_RESET = await Bun.file(join(import.meta.dir, '/css/reset.css')).text()
|
||||
const PICO_CSS = await Bun.file(join(import.meta.dir, '/css/pico.css')).text()
|
||||
|
||||
export * from './utils'
|
||||
export { ReloadScript } from './layout'
|
||||
export { frontend, frontend as fe } from './frontend'
|
||||
export type { Context } from 'hono'
|
||||
|
||||
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 = {
|
||||
pico?: boolean
|
||||
reset?: boolean
|
||||
prettyHTML?: boolean
|
||||
layout?: boolean
|
||||
logging?: boolean
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
type InternalProps = HypeProps & { _isRouter?: boolean }
|
||||
|
||||
export type SSEHandler<E extends Env> = (
|
||||
send: (data: unknown, event?: string) => Promise<void>,
|
||||
c: Context<E>
|
||||
|
|
@ -40,20 +53,12 @@ export class Hype<
|
|||
BasePath extends string = '/'
|
||||
> extends Hono<E, S, BasePath> {
|
||||
props: HypeProps
|
||||
middlewareRegistered = false
|
||||
routesRegistered = false
|
||||
|
||||
constructor(props?: InternalProps & ConstructorParameters<typeof Hono<E, S, BasePath>>[0]) {
|
||||
constructor(props?: HypeProps & ConstructorParameters<typeof Hono<E, S, BasePath>>[0]) {
|
||||
super(props)
|
||||
|
||||
this.props = props ?? {}
|
||||
if (!props?._isRouter) {
|
||||
this.registerMiddleware()
|
||||
}
|
||||
}
|
||||
|
||||
static router<E extends Env = Env>(): Hype<E> {
|
||||
return new Hype<E>({ _isRouter: true })
|
||||
}
|
||||
|
||||
sse(path: string, handler: SSEHandler<E>) {
|
||||
|
|
@ -79,9 +84,9 @@ export class Hype<
|
|||
})
|
||||
}
|
||||
|
||||
registerMiddleware() {
|
||||
if (this.middlewareRegistered) return
|
||||
this.middlewareRegistered = true
|
||||
async registerRoutes() {
|
||||
if (this.routesRegistered) return
|
||||
this.routesRegistered = true
|
||||
|
||||
// static assets in pub/
|
||||
this.use('/*', serveStatic({ root: './pub' }))
|
||||
|
|
@ -90,25 +95,21 @@ export class Hype<
|
|||
this.use('/css/*', serveStatic({ root: './src' }))
|
||||
|
||||
// console logging
|
||||
if (this.props.logging !== false) {
|
||||
this.use('*', async (c, next) => {
|
||||
if (!SHOW_HTTP_LOG) return await next()
|
||||
this.use('*', async (c, next) => {
|
||||
if (!SHOW_HTTP_LOG) return await next()
|
||||
|
||||
const start = Date.now()
|
||||
await next()
|
||||
const end = Date.now()
|
||||
const fn = c.res.status < 400 ? color.green : c.res.status < 500 ? color.yellow : color.red
|
||||
const method = c.req.method === 'GET' ? color.cyan(c.req.method) : color.magenta(c.req.method)
|
||||
console.log(fn(`${c.res.status}`), `${color.bold(method)} ${c.req.url} (${end - start}ms)`)
|
||||
})
|
||||
}
|
||||
const start = Date.now()
|
||||
await next()
|
||||
const end = Date.now()
|
||||
const fn = c.res.status < 400 ? color.green : c.res.status < 500 ? color.yellow : color.red
|
||||
const method = c.req.method === 'GET' ? color.cyan(c.req.method) : color.magenta(c.req.method)
|
||||
console.log(fn(`${c.res.status}`), `${color.bold(method)} ${c.req.url} (${end - start}ms)`)
|
||||
})
|
||||
|
||||
// exception handler
|
||||
this.onError((err, c) => {
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
if (isDev) console.error(err)
|
||||
|
||||
return c.html(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -132,26 +133,28 @@ export class Hype<
|
|||
const res = c.res.clone()
|
||||
const html = await res.text()
|
||||
const formatted = formatHTML(html)
|
||||
const headers = new Headers(c.res.headers)
|
||||
headers.delete('content-length')
|
||||
c.res = new Response(formatted, { status: c.res.status, headers })
|
||||
c.res = new Response(formatted, c.res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
registerRoutes() {
|
||||
if (this.routesRegistered) return
|
||||
this.routesRegistered = true
|
||||
// serve frontend js
|
||||
this.use('*', async (c, next) => {
|
||||
await fnStorage.run({ fns: new Map(), counter: 0 }, async () => {
|
||||
await next()
|
||||
|
||||
// live reload endpoint (dev mode only)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.sse('/__hype/reload', () => {})
|
||||
this.get('/__hype/ping', c => c.text('ok'))
|
||||
}
|
||||
const contentType = c.res.headers.get('content-type')
|
||||
if (!contentType?.includes('text/html')) return
|
||||
|
||||
// healthcheck
|
||||
if (this.props.ok)
|
||||
this.get('/ok', c => c.text('ok'))
|
||||
const fns = feFunctions()
|
||||
if (!fns.length) return
|
||||
|
||||
const res = c.res.clone()
|
||||
const html = await res.text()
|
||||
const newHtml = html.replace('</body>', `<script>${fns.join('\n')}</script></body>`)
|
||||
|
||||
c.res = new Response(newHtml, c.res)
|
||||
})
|
||||
})
|
||||
|
||||
// css reset
|
||||
this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))
|
||||
|
|
@ -161,45 +164,48 @@ export class Hype<
|
|||
|
||||
// serve transpiled js
|
||||
this.on('GET', ['/client/:path{.+}', '/shared/:path{.+}'], async c => {
|
||||
const reqPath = resolve('./src/', c.req.path.slice(1))
|
||||
if (!reqPath.startsWith(resolve('./src/'))) return render404(c)
|
||||
let path = './src/' + c.req.path.replace('..', '.')
|
||||
|
||||
// strip known extension to get base path
|
||||
const base = reqPath.replace(/\.(js|jsx|ts|tsx)$/, '')
|
||||
// path must end in .js or .ts
|
||||
if (!path.endsWith('.js') && !path.endsWith('.ts')) path += '.ts'
|
||||
|
||||
// try TS extensions first (needs transpilation)
|
||||
for (const ext of ['.ts', '.tsx', '.jsx']) {
|
||||
const file = base + ext
|
||||
if (await Bun.file(file).exists())
|
||||
return new Response(await transpile(file), { headers: { 'Content-Type': 'text/javascript' } })
|
||||
}
|
||||
const ts = path.replace('.js', '.ts')
|
||||
if (await Bun.file(ts).exists())
|
||||
return new Response(await transpile(ts), { headers: { 'Content-Type': 'text/javascript' } })
|
||||
|
||||
// try plain .js (serve raw)
|
||||
const jsFile = base + '.js'
|
||||
if (await Bun.file(jsFile).exists())
|
||||
return new Response(Bun.file(jsFile), { headers: { 'Content-Type': 'text/javascript' } })
|
||||
else if (await Bun.file(ts + 'x').exists())
|
||||
return new Response(await transpile(ts + 'x'), { headers: { 'Content-Type': 'text/javascript' } })
|
||||
|
||||
return render404(c)
|
||||
else if (await Bun.file(path).exists())
|
||||
return new Response(Bun.file(path), { headers: { 'Content-Type': 'text/javascript' } })
|
||||
|
||||
else
|
||||
return render404(c)
|
||||
})
|
||||
|
||||
// file based routing
|
||||
this.on('GET', ['/', '/:page{.+}'], async c => {
|
||||
const pageName = (c.req.param('page') ?? 'index').replace(/\.\./g, '')
|
||||
if (pageName.split('/').some(s => s.startsWith('_'))) 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()
|
||||
|
||||
// try pageName.tsx, then pageName/index.tsx
|
||||
const base = join(process.env.PWD ?? process.cwd(), './src/pages')
|
||||
let path = join(base, `${pageName}.tsx`)
|
||||
if (!(await Bun.file(path).exists())) {
|
||||
path = join(base, `${pageName}/index.tsx`)
|
||||
if (!(await Bun.file(path).exists()))
|
||||
return render404(c)
|
||||
}
|
||||
if (hasSpaClient) {
|
||||
this.get('/', (c) => c.html(generateShellHtml()))
|
||||
}
|
||||
|
||||
// file based routing
|
||||
this.on('GET', ['/', '/:page'], async c => {
|
||||
const pageName = (c.req.param('page') ?? 'index').replace('.', '')
|
||||
if (pageName.startsWith('_')) return render404(c)
|
||||
|
||||
const path = join(process.env.PWD ?? '.', `./src/pages/${pageName}.tsx`)
|
||||
|
||||
if (!(await Bun.file(path).exists()))
|
||||
return render404(c)
|
||||
|
||||
let Layout = defaultLayout
|
||||
const layoutPath = join(process.env.PWD ?? process.cwd(), `./src/pages/_layout.tsx`)
|
||||
const layoutPath = join(process.env.PWD ?? '.', `./src/pages/_layout.tsx`)
|
||||
if (await Bun.file(layoutPath).exists()) {
|
||||
Layout = pageCache.get(layoutPath)
|
||||
let Layout = pageCache.get(layoutPath)
|
||||
if (!Layout) {
|
||||
Layout = (await import(layoutPath + `?t=${Date.now()}`)).default
|
||||
pageCache.set(layoutPath, Layout)
|
||||
|
|
@ -236,8 +242,6 @@ export class Hype<
|
|||
|
||||
// find an available port starting from the given port
|
||||
function findAvailablePort(startPort: number, maxAttempts = 100): number {
|
||||
if (process.env.NO_AUTOPORT) return startPort
|
||||
|
||||
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
||||
try {
|
||||
const server = Bun.serve({ port, fetch: () => new Response() })
|
||||
|
|
@ -253,4 +257,3 @@ function findAvailablePort(startPort: number, maxAttempts = 100): number {
|
|||
function render404(c: Context) {
|
||||
return c.text('File not found', 404)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { type FC } from 'hono/jsx'
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
const Layout: FC = ({ children, title, props }) =>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -19,13 +17,7 @@ const Layout: FC = ({ children, title, props }) =>
|
|||
<main>
|
||||
{children}
|
||||
</main>
|
||||
{isDev && <ReloadScript />}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
const RELOAD_SCRIPT = '{let c=false;const e=new EventSource("/__hype/reload");e.onopen=()=>{if(c)location.reload();c=true};e.onerror=()=>{e.close();let d=50;setTimeout(function r(){fetch("/__hype/ping").then(()=>location.reload()).catch(()=>{d=Math.min(d*1.5,2000);setTimeout(r,d)})},d)}}'
|
||||
|
||||
export const ReloadScript: FC = () =>
|
||||
isDev ? <script dangerouslySetInnerHTML={{ __html: RELOAD_SCRIPT }} /> : null
|
||||
|
||||
export default Layout
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ const minify = function(el) {
|
|||
.replace(/>\s+</g, '><')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s>/g, '>')
|
||||
.replace(/>\s/g, '>')
|
||||
.replace(/\s</g, '<')
|
||||
.replace(/class=["']\s/g, function(match) {
|
||||
return match.replace(/\s/g, '');
|
||||
})
|
||||
|
|
@ -54,25 +56,7 @@ const minify = function(el) {
|
|||
|
||||
const convert = {
|
||||
comment: [],
|
||||
line: [],
|
||||
script: []
|
||||
};
|
||||
|
||||
const preserveScripts = function (el) {
|
||||
convert.script = [];
|
||||
el = el.replace(/(<script[^>]*>)([\s\S]*?)(<\/script>)/gi, function (match, open, content, close) {
|
||||
if (!content.trim()) return match;
|
||||
convert.script.push(content);
|
||||
return open + '[#s# : ' + (convert.script.length - 1) + ' : #s#]' + close;
|
||||
});
|
||||
return el;
|
||||
};
|
||||
|
||||
const restoreScripts = function (el) {
|
||||
convert.script.forEach(function (content, index) {
|
||||
el = el.replace('[#s# : ' + index + ' : #s#]', content);
|
||||
});
|
||||
return el;
|
||||
line: []
|
||||
};
|
||||
|
||||
const comment = function (el) {
|
||||
|
|
@ -143,13 +127,11 @@ const tidy = function (el) {
|
|||
|
||||
const render = function (el, opt) {
|
||||
el = closing(el);
|
||||
el = preserveScripts(el);
|
||||
el = comment(el);
|
||||
el = entity(el);
|
||||
el = minify(el);
|
||||
el = line(el);
|
||||
el = tidy(el);
|
||||
el = restoreScripts(el);
|
||||
|
||||
return el;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import { test, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { Hype } from "../index.tsx"
|
||||
import { join } from "path"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
|
||||
// PWD is set to testRoot, so pages resolve at testRoot/src/pages/
|
||||
const testRoot = join(process.cwd(), "src/tests/_test_root")
|
||||
const pagesDir = join(testRoot, "src/pages")
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(join(pagesDir, "editor"), { recursive: true })
|
||||
mkdirSync(join(pagesDir, "deep/nested"), { recursive: true })
|
||||
|
||||
writeFileSync(join(pagesDir, "index.tsx"), `export default <h1>Home</h1>`)
|
||||
writeFileSync(join(pagesDir, "about.tsx"), `export default <h1>About</h1>`)
|
||||
writeFileSync(join(pagesDir, "editor/spell.tsx"), `export default <h1>Spell Editor</h1>`)
|
||||
writeFileSync(join(pagesDir, "deep/nested/page.tsx"), `export default <h1>Deep Page</h1>`)
|
||||
writeFileSync(join(pagesDir, "editor/index.tsx"), `export default <h1>Editor Index</h1>`)
|
||||
writeFileSync(join(pagesDir, "_private.tsx"), `export default <h1>Private</h1>`)
|
||||
writeFileSync(join(pagesDir, "editor/_secret.tsx"), `export default <h1>Secret</h1>`)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("single-segment page route", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/about`)
|
||||
const text = await res.text()
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toContain("About")
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("nested page route (editor/spell)", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/editor/spell`)
|
||||
const text = await res.text()
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toContain("Spell Editor")
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("deeply nested page route", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/deep/nested/page`)
|
||||
const text = await res.text()
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toContain("Deep Page")
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("subdirectory index.tsx fallback", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/editor`)
|
||||
const text = await res.text()
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toContain("Editor Index")
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("underscore-prefixed files return 404", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/_private`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("underscore-prefixed files in subdirectories return 404", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/editor/_secret`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("nonexistent page returns 404", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/does/not/exist`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
||||
test("path traversal is blocked", async () => {
|
||||
const origPwd = process.env.PWD
|
||||
process.env.PWD = testRoot
|
||||
const app = new Hype({ layout: false, logging: false, prettyHTML: false })
|
||||
const server = Bun.serve({ port: 0, fetch: app.defaults.fetch })
|
||||
|
||||
const res = await fetch(`http://localhost:${server.port}/../../../etc/passwd`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
server.stop(true)
|
||||
process.env.PWD = origPwd
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from "bun:test"
|
||||
import { Hype } from "../index.tsx"
|
||||
import { Hype } from "./index.tsx"
|
||||
|
||||
test("defaults returns next available port in dev mode", () => {
|
||||
// Start a server on port 4000 to block it
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
import { type Context } from 'hono'
|
||||
import { stat } from 'fs/promises'
|
||||
|
||||
// template literal tag for inline CSS. returns a <style> tag
|
||||
export function css(strings: TemplateStringsArray, ...values: any[]) {
|
||||
return <style dangerouslySetInnerHTML={{
|
||||
__html: String.raw({ raw: strings }, ...values)
|
||||
}} />
|
||||
}
|
||||
|
||||
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
||||
|
||||
// template literal tag for inline JS. transpiles and returns a <script> tag
|
||||
export function js(strings: TemplateStringsArray, ...values: any[]) {
|
||||
return <script dangerouslySetInnerHTML={{
|
||||
__html: transpiler.transformSync(String.raw({ raw: strings }, ...values))
|
||||
}} />
|
||||
}
|
||||
|
||||
// lighten a hex color by blending with white. opacity 1 = original, 0 = white
|
||||
export function lightenColor(hex: string, opacity: number): string {
|
||||
// Remove # if present
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user