Remove css, js, and fe() inline helpers; update docs and examples to use client scripts

This commit is contained in:
Chris Wanstrath 2026-02-20 16:18:24 -08:00
parent 1333deb5e9
commit 78eef47f46
8 changed files with 58 additions and 524 deletions

View File

@ -17,7 +17,6 @@
- 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.
- Optional CSS reset.
- Optional pico.css.
@ -115,25 +114,6 @@ 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 }`:
@ -165,27 +145,6 @@ 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.
@ -249,10 +208,8 @@ curl -N http://localhost:3000/api/time
`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`

View File

@ -1,6 +1,6 @@
# Guide to Writing Hype Apps
Hype is a thin, opinionated wrapper around [Hono](https://hono.dev) for fast prototyping with Bun. It gives you file-based routing, automatic TypeScript transpilation, SSE, inline CSS/JS helpers, and a default HTML5 layout — all without a build step.
Hype is a thin, opinionated wrapper around [Hono](https://hono.dev) for fast prototyping with Bun. It gives you file-based routing, automatic TypeScript transpilation, SSE, and a default HTML5 layout — all without a build step.
Since `Hype extends Hono`, every Hono API (`get`, `post`, `use`, `on`, etc.) works out of the box.
@ -16,8 +16,6 @@ Since `Hype extends Hono`, every Hono API (`get`, `post`, `use`, `on`, etc.) wor
- [Client-Side JavaScript](#client-side-javascript)
- [Styling](#styling)
- [Server-Sent Events (SSE)](#server-sent-events-sse)
- [The `fe()` Helper](#the-fe-helper)
- [Inline `css` and `js` Tags](#inline-css-and-js-tags)
- [Custom API Routes](#custom-api-routes)
- [Sub-Routers](#sub-routers)
- [Static Files](#static-files)
@ -593,32 +591,6 @@ Or include it in a custom layout:
const app = new Hype({ pico: true, reset: true })
```
### Inline CSS
Use the `css` template tag for scoped inline styles in any page:
```tsx
import { css } from '@because/hype'
export default () => (
<section>
{css`
.hero {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 4rem 2rem;
text-align: center;
}
`}
<div class="hero">
<h1>Welcome</h1>
</div>
</section>
)
```
This renders a `<style>` tag inline. Install the `vscode-styled-components` extension for syntax highlighting.
---
## Server-Sent Events (SSE)
@ -662,30 +634,31 @@ Return a cleanup function to handle client disconnection.
```tsx
// src/pages/sse.tsx
import { js } from '@because/hype'
export default () => (
<section>
<h1>SSE Demo</h1>
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
{js`
const timeEl = document.getElementById('time')
const events = new EventSource('/api/time')
events.onmessage = (e) => {
const data = JSON.parse(e.data)
timeEl.textContent = new Date(data.time).toLocaleTimeString()
}
events.onerror = () => {
timeEl.textContent = 'Disconnected'
}
`}
<script src="/client/main.ts" type="module"></script>
</section>
)
```
```ts
// src/client/main.ts
const timeEl = document.getElementById('time')
const events = new EventSource('/api/time')
events.onmessage = (e) => {
const data = JSON.parse(e.data)
timeEl!.textContent = new Date(data.time).toLocaleTimeString()
}
events.onerror = () => {
timeEl!.textContent = 'Disconnected'
}
```
### Named events
```ts
@ -717,149 +690,6 @@ curl -N http://localhost:3000/api/time
---
## The `fe()` Helper
`fe()` (short for "frontend") lets you define JavaScript functions on the server and use them as inline event handlers in SSR pages. The function bodies are automatically extracted and injected as a `<script>` before `</body>`.
### Basic usage
```tsx
import { fe } from '@because/hype'
const showAlert = fe(() => {
alert('ding dong')
})
export default () => (
<section>
<button onclick={showAlert}>Click me</button>
</section>
)
```
`fe()` returns a string like `"frontendFn0()"` which works as an `onclick` attribute value. The actual function is injected once in a `<script>` tag at the end of the page.
### Passing arguments
Pass server data to frontend functions:
```tsx
import { fe } from '@because/hype'
const greet = (name: string) => fe((args) => {
alert(`Hello, ${args.name}!`)
}, { name })
export default () => (
<section>
<button onclick={greet('Chris')}>Greet Chris</button>
<button onclick={greet('Alex')}>Greet Alex</button>
</section>
)
```
Arguments are JSON-serialized and passed at call time. The function body is deduplicated — if the same function is used with different arguments, only one copy of the function is injected.
### Deduplication
`fe()` deduplicates by function body. If you call `fe()` with the same function multiple times, it only injects one copy:
```tsx
import { fe } from '@because/hype'
const handleClick = fe(() => {
console.log('clicked!')
})
export default () => (
<section>
{/* Both buttons share the same injected function */}
<button onclick={handleClick}>Button 1</button>
<button onclick={handleClick}>Button 2</button>
</section>
)
```
---
## Inline `css` and `js` Tags
### `css` template tag
Returns a `<style>` element. Use it anywhere in your JSX:
```tsx
import { css } from '@because/hype'
export default () => (
<section>
{css`
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
}
`}
<div class="card">
<h2>Card Title</h2>
<p>Card content</p>
</div>
</section>
)
```
### `js` template tag
Returns a `<script>` element. The content is **transpiled from TypeScript** on the server using Bun's transpiler, so you can write TypeScript directly:
```tsx
import { js } from '@because/hype'
export default () => (
<section>
<div id="output"></div>
{js`
interface TimeData {
formatted: string
timestamp: number
}
function formatTime(ts: number): TimeData {
return {
formatted: new Date(ts).toLocaleTimeString(),
timestamp: ts
}
}
const el = document.getElementById('output')!
setInterval(() => {
el.textContent = formatTime(Date.now()).formatted
}, 1000)
`}
</section>
)
```
### Interpolation
Both tags support template literal interpolation:
```tsx
const primaryColor = '#3498db'
export default () => (
<section>
{css`
h1 { color: ${primaryColor}; }
`}
<h1>Styled heading</h1>
</section>
)
```
---
## Custom API Routes
Since Hype extends Hono, use any Hono routing method:

View File

@ -2,7 +2,7 @@
A complete guide to building web apps with Hype and Forge.
**Hype** is a thin wrapper around [Hono](https://hono.dev) for building web apps with Bun. It provides file-based routing, automatic TypeScript transpilation, SSE, inline CSS/JS helpers, and a default HTML5 layout — all without a build step. Since `Hype extends Hono`, every Hono API works out of the box.
**Hype** is a thin wrapper around [Hono](https://hono.dev) for building web apps with Bun. It provides file-based routing, automatic TypeScript transpilation, SSE, and a default HTML5 layout — all without a build step. Since `Hype extends Hono`, every Hono API works out of the box.
**Forge** is a component library that generates real CSS from JSX definitions. Each `define()` call produces a typed component with scoped CSS classes. It works on both server and client — on the server it collects CSS for `<Styles />` to inline, in the browser it auto-injects a `<style>` tag.
@ -20,8 +20,6 @@ A complete guide to building web apps with Hype and Forge.
- [Client-Side JavaScript](#client-side-javascript)
- [Styling](#styling)
- [Server-Sent Events (SSE)](#server-sent-events-sse)
- [The `fe()` Helper](#the-fe-helper)
- [Inline `css` and `js` Tags](#inline-css-and-js-tags)
- [Custom API Routes](#custom-api-routes)
- [Sub-Routers](#sub-routers)
- [Static Files](#static-files)
@ -538,30 +536,31 @@ Return a cleanup function to handle client disconnection.
```tsx
// src/pages/sse.tsx
import { js } from '@because/hype'
export default () => (
<section>
<h1>SSE Demo</h1>
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
{js`
const timeEl = document.getElementById('time')
const events = new EventSource('/api/time')
events.onmessage = (e) => {
const data = JSON.parse(e.data)
timeEl.textContent = new Date(data.time).toLocaleTimeString()
}
events.onerror = () => {
timeEl.textContent = 'Disconnected'
}
`}
<script src="/client/main.ts" type="module"></script>
</section>
)
```
```ts
// src/client/main.ts
const timeEl = document.getElementById('time')
const events = new EventSource('/api/time')
events.onmessage = (e) => {
const data = JSON.parse(e.data)
timeEl!.textContent = new Date(data.time).toLocaleTimeString()
}
events.onerror = () => {
timeEl!.textContent = 'Disconnected'
}
```
### Named events
```ts
@ -593,149 +592,6 @@ curl -N http://localhost:3000/api/time
---
## The `fe()` Helper
`fe()` (short for "frontend") lets you define JavaScript functions on the server and use them as inline event handlers in SSR pages. The function bodies are automatically extracted and injected as a `<script>` before `</body>`.
### Basic usage
```tsx
import { fe } from '@because/hype'
const showAlert = fe(() => {
alert('ding dong')
})
export default () => (
<section>
<button onclick={showAlert}>Click me</button>
</section>
)
```
`fe()` returns a string like `"frontendFn0()"` which works as an `onclick` attribute value. The actual function is injected once in a `<script>` tag at the end of the page.
### Passing arguments
Pass server data to frontend functions:
```tsx
import { fe } from '@because/hype'
const greet = (name: string) => fe((args) => {
alert(`Hello, ${args.name}!`)
}, { name })
export default () => (
<section>
<button onclick={greet('Chris')}>Greet Chris</button>
<button onclick={greet('Alex')}>Greet Alex</button>
</section>
)
```
Arguments are JSON-serialized and passed at call time. The function body is deduplicated — if the same function is used with different arguments, only one copy of the function is injected.
### Deduplication
`fe()` deduplicates by function body. If you call `fe()` with the same function multiple times, it only injects one copy:
```tsx
import { fe } from '@because/hype'
const handleClick = fe(() => {
console.log('clicked!')
})
export default () => (
<section>
{/* Both buttons share the same injected function */}
<button onclick={handleClick}>Button 1</button>
<button onclick={handleClick}>Button 2</button>
</section>
)
```
---
## Inline `css` and `js` Tags
### `css` template tag
Returns a `<style>` element. Use it anywhere in your JSX:
```tsx
import { css } from '@because/hype'
export default () => (
<section>
{css`
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 1rem;
}
`}
<div class="card">
<h2>Card Title</h2>
<p>Card content</p>
</div>
</section>
)
```
### `js` template tag
Returns a `<script>` element. The content is **transpiled from TypeScript** on the server using Bun's transpiler, so you can write TypeScript directly:
```tsx
import { js } from '@because/hype'
export default () => (
<section>
<div id="output"></div>
{js`
interface TimeData {
formatted: string
timestamp: number
}
function formatTime(ts: number): TimeData {
return {
formatted: new Date(ts).toLocaleTimeString(),
timestamp: ts
}
}
const el = document.getElementById('output')!
setInterval(() => {
el.textContent = formatTime(Date.now()).formatted
}, 1000)
`}
</section>
)
```
### Interpolation
Both tags support template literal interpolation:
```tsx
const primaryColor = '#3498db'
export default () => (
<section>
{css`
h1 { color: ${primaryColor}; }
`}
<h1>Styled heading</h1>
</section>
)
```
---
## Custom API Routes
Since Hype extends Hono, use any Hono routing method:
@ -1852,43 +1708,25 @@ export default () => (
### Adding client interactivity to SSR
For light interactivity on SSR pages, use Hype's `fe()` helper alongside Forge components:
For interactivity on SSR pages, use a client-side script alongside Forge components:
```tsx
// src/pages/index.tsx
import { fe } from '@because/hype'
import { Button } from '@components'
const handleClick = fe(() => {
alert('Clicked!')
})
export default () => (
<section>
<Button intent="primary" onclick={handleClick}>Click me</Button>
<Button intent="primary" id="my-btn">Click me</Button>
<script src="/client/main.ts" type="module"></script>
</section>
)
```
Or use Hype's `js` tag for inline scripts:
```tsx
import { js } from '@because/hype'
import { Card } from '@components'
export default () => (
<section>
<Card title="Live Clock">
<span id="clock">--:--:--</span>
</Card>
{js`
setInterval(() => {
document.getElementById('clock')!.textContent =
new Date().toLocaleTimeString()
}, 1000)
`}
</section>
)
```ts
// src/client/main.ts
document.getElementById('my-btn')?.addEventListener('click', () => {
alert('Clicked!')
})
```
### SSR with Forge + SSE
@ -1914,7 +1752,6 @@ export default app.defaults
```tsx
// src/pages/index.tsx
import { js } from '@because/hype'
import { Card, Badge } from '@components'
export default () => (
@ -1922,17 +1759,20 @@ export default () => (
<Card title="Live Counter">
Count: <Badge><span id="count">--</span></Badge>
</Card>
{js`
const el = document.getElementById('count')!
const events = new EventSource('/api/counter')
events.onmessage = (e) => {
el.textContent = JSON.parse(e.data).count
}
`}
<script src="/client/main.ts" type="module"></script>
</section>
)
```
```ts
// src/client/main.ts
const el = document.getElementById('count')!
const events = new EventSource('/api/counter')
events.onmessage = (e) => {
el.textContent = JSON.parse(e.data).count
}
```
---
## SPA with Forge
@ -2316,7 +2156,7 @@ Hype's bundler handles the import in both cases. On the server, `define()` regis
| | SSR | SPA |
|---|---|---|
| **Styles** | `<Styles />` in the layout head | Auto-injected by Forge in browser |
| **Interactivity** | `fe()`, `js` tag, or link to client scripts | Full `hono/jsx/dom` with hooks |
| **Interactivity** | Client scripts via `/client/main.ts` | Full `hono/jsx/dom` with hooks |
| **Routing** | File-based, one page per route | Client-side, single HTML shell |
| **Data** | Fetch in page components (`c`, `req`) | Fetch in `useEffect`, SSE, etc. |
| **Best for** | Content sites, dashboards, forms | Interactive apps, real-time UIs |

View File

@ -1,11 +1,5 @@
import { fe } from '@because/hype'
const test = fe(() => {
alert('ding dong')
})
export default () => (
<section>
<a href="#" onclick={test}>test</a>
<a href="#" onclick="alert('ding dong')">test</a>
</section>
)

View File

@ -1,5 +1,3 @@
import { js } from '@because/hype'
export default () => (
<section>
<h1>SSE Demo</h1>
@ -12,7 +10,8 @@ export default () => (
<a href="/">Back to Home</a>
</p>
{js`
<script dangerouslySetInnerHTML={{
__html: `
const statusEl = document.getElementById('status')
const timeEl = document.getElementById('time')
const countEl = document.getElementById('count')
@ -36,6 +35,6 @@ export default () => (
statusEl.textContent = 'Disconnected'
statusEl.style.color = 'red'
}
`}
`}} />
</section>
)

View File

@ -1,48 +0,0 @@
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

View File

@ -7,14 +7,12 @@ 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 { frontend, frontend as fe } from './frontend'
export type { Context } from 'hono'
const pageCache = new Map()
@ -146,26 +144,6 @@ export class Hype<
if (this.props.ok)
this.get('/ok', c => c.text('ok'))
// serve frontend js
this.use('*', async (c, next) => {
await fnStorage.run({ fns: new Map(), counter: 0 }, async () => {
await next()
const contentType = c.res.headers.get('content-type')
if (!contentType?.includes('text/html')) return
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>`)
const headers = new Headers(c.res.headers)
headers.delete('content-length')
c.res = new Response(newHtml, { status: c.res.status, headers })
})
})
// css reset
this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))

View File

@ -1,22 +1,6 @@
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