2485 lines
53 KiB
Markdown
2485 lines
53 KiB
Markdown
# Hype + Forge
|
|
|
|
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.
|
|
|
|
**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.
|
|
|
|
## Table of Contents
|
|
|
|
**Setup**
|
|
- [Getting Started](#getting-started)
|
|
- [Project Structure](#project-structure)
|
|
|
|
**Hype — Server Framework**
|
|
- [Server Entry Point](#server-entry-point)
|
|
- [Routing](#routing)
|
|
- [Layouts](#layouts)
|
|
- [Pages](#pages)
|
|
- [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)
|
|
|
|
**Forge — Components**
|
|
- [Basic Components](#basic-components)
|
|
- [HTML Tags](#html-tags)
|
|
- [CSS Properties](#css-properties)
|
|
- [States](#states)
|
|
- [Variants](#variants)
|
|
- [Parts](#parts)
|
|
- [Custom Render](#custom-render)
|
|
- [Selectors](#selectors)
|
|
- [Scopes](#scopes)
|
|
- [Themes](#themes)
|
|
|
|
**Integration**
|
|
- [SSR with Forge](#ssr-with-forge)
|
|
- [SPA with Forge](#spa-with-forge)
|
|
- [Shared Components](#shared-components)
|
|
- [Choosing SSR vs SPA](#choosing-ssr-vs-spa)
|
|
|
|
**Reference**
|
|
- [Configuration](#configuration)
|
|
- [Environment Variables](#environment-variables)
|
|
- [Path Aliases](#path-aliases)
|
|
- [Utility Functions](#utility-functions)
|
|
|
|
---
|
|
|
|
# Setup
|
|
|
|
## Getting Started
|
|
|
|
```sh
|
|
bun add @because/hype @because/forge
|
|
|
|
mkdir -p src/server src/pages src/client src/shared src/css pub
|
|
```
|
|
|
|
Add scripts to `package.json`:
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"start": "bun run src/server/index.ts",
|
|
"dev": "bun run --hot src/server/index.ts"
|
|
}
|
|
}
|
|
```
|
|
|
|
Add the required `tsconfig.json`:
|
|
|
|
```json
|
|
{
|
|
"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,
|
|
"baseUrl": ".",
|
|
"paths": {
|
|
"$*": ["src/server/*"],
|
|
"#*": ["src/client/*"],
|
|
"@*": ["src/shared/*"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create a server and page:
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype()
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
export default () => (
|
|
<section>
|
|
<h1>Hello, world!</h1>
|
|
</section>
|
|
)
|
|
```
|
|
|
|
```sh
|
|
bun dev
|
|
```
|
|
|
|
You have a server-rendered page at `http://localhost:3000`.
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
.
|
|
├── package.json
|
|
├── tsconfig.json
|
|
├── pub/ # Static files (served as-is at /)
|
|
│ └── img/
|
|
│ └── logo.png # => /img/logo.png
|
|
├── src/
|
|
│ ├── server/
|
|
│ │ └── index.ts # Server entry point
|
|
│ ├── pages/
|
|
│ │ ├── index.tsx # => GET /
|
|
│ │ ├── about.tsx # => GET /about
|
|
│ │ └── _layout.tsx # Custom layout (optional)
|
|
│ ├── client/
|
|
│ │ └── main.ts # Client JS (auto-included by default layout)
|
|
│ ├── shared/
|
|
│ │ ├── components.tsx # Forge components (shared between SSR and SPA)
|
|
│ │ └── themes.tsx # Forge themes
|
|
│ └── css/
|
|
│ └── main.css # App CSS (auto-included by default layout)
|
|
```
|
|
|
|
- `src/pages/` — SSR pages, one file per route
|
|
- `src/client/` — Client-side TypeScript, transpiled and bundled on demand
|
|
- `src/shared/` — Isomorphic code, available to both server and client
|
|
- `src/css/` — Stylesheets
|
|
- `src/server/` — Server-only code
|
|
- `pub/` — Static assets served directly
|
|
|
|
---
|
|
|
|
# Hype — Server Framework
|
|
|
|
## Server Entry Point
|
|
|
|
Always export `app.defaults` as the default export:
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype()
|
|
|
|
export default app.defaults
|
|
// => { port, fetch, idleTimeout: 255 }
|
|
```
|
|
|
|
This triggers lazy route registration and auto-port selection. Bun picks it up as the server config when the file is the entry point.
|
|
|
|
---
|
|
|
|
## Routing
|
|
|
|
### File-based routing
|
|
|
|
Files in `src/pages/` are automatically mapped to GET routes:
|
|
|
|
| File | URL |
|
|
|------|-----|
|
|
| `src/pages/index.tsx` | `/` |
|
|
| `src/pages/about.tsx` | `/about` |
|
|
| `src/pages/contact.tsx` | `/contact` |
|
|
|
|
Files prefixed with `_` are private and won't be served (returns 404). This is used for layouts and other internal files.
|
|
|
|
### Custom routes
|
|
|
|
Since Hype extends Hono, you can define any route directly:
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype()
|
|
|
|
// JSON API
|
|
app.get('/api/users/:id', (c) => {
|
|
return c.json({ id: c.req.param('id'), name: 'Chris' })
|
|
})
|
|
|
|
// Form handling
|
|
app.post('/api/contact', async (c) => {
|
|
const body = await c.req.parseBody()
|
|
console.log('Message:', body.message)
|
|
return c.redirect('/thanks')
|
|
})
|
|
|
|
// Custom HTML
|
|
app.get('/custom', (c) => {
|
|
return c.html('<h1>Custom page</h1>')
|
|
})
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
Custom routes are defined before the file-based routes, so they take priority.
|
|
|
|
---
|
|
|
|
## Layouts
|
|
|
|
### Default layout
|
|
|
|
By default, Hype wraps every page in a simple HTML5 layout that includes:
|
|
|
|
- `<meta charset="utf-8">`
|
|
- `<meta name="viewport">` for responsive design
|
|
- `<meta name="color-scheme" content="light dark">` for automatic dark mode
|
|
- `<link>` to `/css/main.css` (your `src/css/main.css`)
|
|
- `<script>` loading `/client/main.ts` (your `src/client/main.ts`)
|
|
- Content wrapped in `<body><main>...</main></body>`
|
|
|
|
Optionally includes the CSS reset and/or Pico CSS based on your config.
|
|
|
|
### Custom layout
|
|
|
|
Create `src/pages/_layout.tsx` to replace the default layout:
|
|
|
|
```tsx
|
|
// src/pages/_layout.tsx
|
|
import type { FC } from 'hono/jsx'
|
|
|
|
const Layout: FC = ({ children, title, props }) => (
|
|
<html lang="en">
|
|
<head>
|
|
<title>{title ?? 'My App'}</title>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
|
|
<link href="/css/main.css" rel="stylesheet" />
|
|
<script src="/client/main.ts" type="module"></script>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
<a href="/about">About</a>
|
|
</nav>
|
|
<main>{children}</main>
|
|
<footer>Built with Hype</footer>
|
|
</body>
|
|
</html>
|
|
)
|
|
|
|
export default Layout
|
|
```
|
|
|
|
The layout receives:
|
|
- `children` — the rendered page content
|
|
- `title` — page title (defaults to `'hype'`)
|
|
- `props` — the `HypeProps` passed to the constructor (useful for conditional CSS)
|
|
|
|
### No layout
|
|
|
|
Disable the layout entirely for full control (used in SPA mode):
|
|
|
|
```ts
|
|
const app = new Hype({ layout: false })
|
|
```
|
|
|
|
---
|
|
|
|
## Pages
|
|
|
|
Pages are `.tsx` files in `src/pages/` that export a default function (or raw JSX).
|
|
|
|
### Function export (recommended)
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
export default ({ c, req }) => (
|
|
<section>
|
|
<h1>Home</h1>
|
|
</section>
|
|
)
|
|
```
|
|
|
|
### Static JSX export
|
|
|
|
```tsx
|
|
// src/pages/about.tsx
|
|
export default (
|
|
<section>
|
|
<h1>About</h1>
|
|
</section>
|
|
)
|
|
```
|
|
|
|
### Accessing the request
|
|
|
|
Page components receive `c` (the Hono context) and `req` (the Hono request) as props:
|
|
|
|
```tsx
|
|
// src/pages/greet.tsx
|
|
export default ({ req }) => (
|
|
<section>
|
|
<h1>Hello, {req.query('name') ?? 'stranger'}!</h1>
|
|
</section>
|
|
)
|
|
```
|
|
|
|
For the full Hono context:
|
|
|
|
```tsx
|
|
// src/pages/debug.tsx
|
|
export default ({ c, req }) => {
|
|
const ua = req.header('user-agent')
|
|
return (
|
|
<section>
|
|
<h1>Request Info</h1>
|
|
<p>URL: {req.url}</p>
|
|
<p>User-Agent: {ua}</p>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Async data in pages
|
|
|
|
Since pages render on the server, you can use top-level await:
|
|
|
|
```tsx
|
|
// src/pages/users.tsx
|
|
const users = await fetch('https://api.example.com/users').then(r => r.json())
|
|
|
|
export default () => (
|
|
<section>
|
|
<h1>Users</h1>
|
|
<ul>
|
|
{users.map(u => <li>{u.name}</li>)}
|
|
</ul>
|
|
</section>
|
|
)
|
|
```
|
|
|
|
Note: top-level data is fetched once at import time and cached. For per-request data, use the `c` context:
|
|
|
|
```tsx
|
|
// src/pages/profile.tsx
|
|
export default async ({ c, req }) => {
|
|
const userId = req.query('id')
|
|
const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json())
|
|
|
|
return (
|
|
<section>
|
|
<h1>{user.name}</h1>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Private pages
|
|
|
|
Prefix a file with `_` to prevent it from being served:
|
|
|
|
```
|
|
src/pages/_layout.tsx # not a route — used as the layout
|
|
src/pages/_helpers.tsx # not a route — internal helpers
|
|
src/pages/index.tsx # GET /
|
|
```
|
|
|
|
---
|
|
|
|
## Client-Side JavaScript
|
|
|
|
### Automatic transpilation
|
|
|
|
TypeScript files in `src/client/` and `src/shared/` are automatically transpiled and bundled by Bun when requested by the browser. The URL maps directly to the file path:
|
|
|
|
| File | URL |
|
|
|------|-----|
|
|
| `src/client/main.ts` | `/client/main.ts` |
|
|
| `src/client/app.tsx` | `/client/app.js` |
|
|
| `src/shared/utils.ts` | `/shared/utils.ts` |
|
|
|
|
You can request `.ts` or `.js` extensions — Hype resolves `.ts` and `.tsx` files automatically.
|
|
|
|
### Module imports
|
|
|
|
Client-side files can import from each other using relative paths:
|
|
|
|
```ts
|
|
// src/client/main.ts
|
|
import { initBurger } from './burger'
|
|
|
|
initBurger()
|
|
```
|
|
|
|
```ts
|
|
// src/client/burger.ts
|
|
export function initBurger() {
|
|
document.addEventListener('click', (ev) => {
|
|
const el = (ev?.target as HTMLElement).closest('.burger') as HTMLImageElement
|
|
if (!el) return
|
|
el.src = '/img/bite.png'
|
|
})
|
|
}
|
|
```
|
|
|
|
Imports are bundled — the full dependency graph is included in the output, so the browser only needs one request.
|
|
|
|
### The default layout auto-includes `main.ts`
|
|
|
|
When using the default layout, `src/client/main.ts` is automatically loaded as a module. Just create the file and it works.
|
|
|
|
---
|
|
|
|
## Styling
|
|
|
|
### External CSS
|
|
|
|
Put your styles in `src/css/main.css`. The default layout auto-includes it:
|
|
|
|
```css
|
|
/* src/css/main.css */
|
|
section {
|
|
max-width: 500px;
|
|
margin: 0 auto;
|
|
}
|
|
```
|
|
|
|
You can also serve additional CSS files from `src/css/`:
|
|
|
|
```html
|
|
<link href="/css/components.css" rel="stylesheet" />
|
|
```
|
|
|
|
### Pico CSS
|
|
|
|
Enable the bundled [Pico CSS](https://picocss.com) for classless styling:
|
|
|
|
```ts
|
|
const app = new Hype({ pico: true })
|
|
```
|
|
|
|
Or include it in a custom layout:
|
|
|
|
```html
|
|
<link href="/css/pico.css" rel="stylesheet" />
|
|
```
|
|
|
|
### CSS Reset
|
|
|
|
Enable the bundled CSS reset (Josh W. Comeau's reset):
|
|
|
|
```ts
|
|
const app = new Hype({ reset: true })
|
|
```
|
|
|
|
Or include it in a custom layout:
|
|
|
|
```html
|
|
<link href="/css/reset.css" rel="stylesheet" />
|
|
```
|
|
|
|
### Combining options
|
|
|
|
```ts
|
|
const app = new Hype({ pico: true, reset: true })
|
|
```
|
|
|
|
---
|
|
|
|
## Server-Sent Events (SSE)
|
|
|
|
Hype provides `app.sse()` for streaming data to the browser.
|
|
|
|
### Server
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype()
|
|
|
|
// Stream the current time every second
|
|
app.sse('/api/time', (send) => {
|
|
send({ time: Date.now() })
|
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
|
return () => clearInterval(interval) // cleanup on disconnect
|
|
})
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
The `send` function:
|
|
- Automatically `JSON.stringify`s objects; strings are sent as-is
|
|
- Accepts an optional second argument for named events: `send(data, 'eventName')`
|
|
|
|
The handler receives `(send, c)` where `c` is the Hono context:
|
|
|
|
```ts
|
|
app.sse('/api/user-events', (send, c) => {
|
|
const userId = c.req.query('userId')
|
|
// subscribe to user-specific events...
|
|
})
|
|
```
|
|
|
|
Return a cleanup function to handle client disconnection.
|
|
|
|
### Client
|
|
|
|
```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'
|
|
}
|
|
`}
|
|
</section>
|
|
)
|
|
```
|
|
|
|
### Named events
|
|
|
|
```ts
|
|
// Server
|
|
app.sse('/api/feed', (send) => {
|
|
send({ type: 'user_joined', name: 'Chris' }, 'activity')
|
|
send({ message: 'Hello everyone!' }, 'chat')
|
|
})
|
|
```
|
|
|
|
```js
|
|
// Client
|
|
const events = new EventSource('/api/feed')
|
|
|
|
events.addEventListener('activity', (e) => {
|
|
console.log('Activity:', JSON.parse(e.data))
|
|
})
|
|
|
|
events.addEventListener('chat', (e) => {
|
|
console.log('Chat:', JSON.parse(e.data))
|
|
})
|
|
```
|
|
|
|
### Test with curl
|
|
|
|
```sh
|
|
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:
|
|
|
|
```ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype()
|
|
|
|
// GET with params
|
|
app.get('/api/users/:id', (c) => {
|
|
return c.json({ id: c.req.param('id') })
|
|
})
|
|
|
|
// POST with body parsing
|
|
app.post('/api/users', async (c) => {
|
|
const body = await c.req.json()
|
|
return c.json({ created: true, name: body.name }, 201)
|
|
})
|
|
|
|
// Form submission
|
|
app.post('/contact', async (c) => {
|
|
const form = await c.req.parseBody()
|
|
console.log(form.email, form.message)
|
|
return c.redirect('/')
|
|
})
|
|
|
|
// Delete
|
|
app.delete('/api/users/:id', (c) => {
|
|
return c.json({ deleted: true })
|
|
})
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
### Redirect back
|
|
|
|
Use `redirectBack()` to redirect to the referrer:
|
|
|
|
```ts
|
|
import { Hype, redirectBack } from '@because/hype'
|
|
|
|
const app = new Hype()
|
|
|
|
app.post('/api/like', async (c) => {
|
|
// ... handle the like
|
|
return redirectBack(c, '/') // falls back to '/' if no referrer
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Sub-Routers
|
|
|
|
Use `Hype.router()` to create sub-routers without duplicate middleware:
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
import api from './api'
|
|
|
|
const app = new Hype()
|
|
app.route('/api', api)
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
```ts
|
|
// src/server/api.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const api = Hype.router()
|
|
|
|
api.get('/users', (c) => c.json([{ id: 1, name: 'Chris' }]))
|
|
api.get('/users/:id', (c) => c.json({ id: c.req.param('id') }))
|
|
api.post('/users', async (c) => {
|
|
const body = await c.req.json()
|
|
return c.json(body, 201)
|
|
})
|
|
|
|
export default api
|
|
```
|
|
|
|
`Hype.router()` creates a Hype instance that skips middleware registration (no duplicate logging, static file serving, etc.).
|
|
|
|
---
|
|
|
|
## Static Files
|
|
|
|
Files in `pub/` are served at the root URL:
|
|
|
|
```
|
|
pub/img/logo.png => /img/logo.png
|
|
pub/favicon.ico => /favicon.ico
|
|
pub/robots.txt => /robots.txt
|
|
```
|
|
|
|
Use them in pages:
|
|
|
|
```tsx
|
|
export default () => (
|
|
<section>
|
|
<img src="/img/logo.png" width="200" />
|
|
</section>
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
# Forge — Components
|
|
|
|
## Basic Components
|
|
|
|
Import `define` and start creating components. Each call to `define` generates real CSS classes and returns a JSX component.
|
|
|
|
```tsx
|
|
import { define } from 'forge'
|
|
|
|
const Box = define('Box', {
|
|
padding: 20,
|
|
background: '#111',
|
|
})
|
|
|
|
// Renders: <div class="Box">...</div>
|
|
<Box>Hello</Box>
|
|
```
|
|
|
|
### Named components
|
|
|
|
Pass a name as the first argument. The name becomes the CSS class.
|
|
|
|
```tsx
|
|
const Card = define('Card', {
|
|
padding: 20,
|
|
background: '#111',
|
|
borderRadius: 8,
|
|
})
|
|
|
|
// <div class="Card">...</div>
|
|
```
|
|
|
|
Names must be unique — defining the same name twice throws an error.
|
|
|
|
### Anonymous components
|
|
|
|
Omit the name and Forge generates one from the base tag: `Div`, `Button2`, `Anchor3`, etc.
|
|
|
|
```tsx
|
|
const Box = define({ display: 'flex', gap: 16 })
|
|
// class="Div"
|
|
|
|
const Link = define({ base: 'a', color: 'blue' })
|
|
// class="Anchor"
|
|
```
|
|
|
|
---
|
|
|
|
## HTML Tags
|
|
|
|
By default, components render as `<div>`. Use `base` to change the tag:
|
|
|
|
```tsx
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: 20,
|
|
cursor: 'pointer',
|
|
})
|
|
// <button class="Button">...</button>
|
|
|
|
const Heading = define('Heading', {
|
|
base: 'h1',
|
|
fontSize: 28,
|
|
})
|
|
// <h1 class="Heading">...</h1>
|
|
```
|
|
|
|
### Attribute shorthand
|
|
|
|
Set default attributes right in the base string:
|
|
|
|
```tsx
|
|
const Radio = define('Radio', {
|
|
base: 'input[type=radio]',
|
|
})
|
|
// <input type="radio" class="Radio" />
|
|
|
|
const Checkbox = define('Checkbox', {
|
|
base: 'input[type=checkbox]',
|
|
})
|
|
// <input type="checkbox" class="Checkbox" />
|
|
```
|
|
|
|
Props passed at usage time override base attributes.
|
|
|
|
---
|
|
|
|
## CSS Properties
|
|
|
|
Write CSS properties in camelCase. They compile to real CSS at definition time.
|
|
|
|
```tsx
|
|
const Card = define('Card', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 16,
|
|
padding: 20,
|
|
backgroundColor: '#111',
|
|
borderRadius: 8,
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
})
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```css
|
|
.Card {
|
|
background-color: #111;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
padding: 20px;
|
|
}
|
|
```
|
|
|
|
### Numeric values
|
|
|
|
Numbers are auto-converted to `px`:
|
|
|
|
```tsx
|
|
{ padding: 20 } // padding: 20px
|
|
{ margin: 0 } // margin: 0px
|
|
{ fontSize: 14 } // font-size: 14px
|
|
{ borderRadius: 8 } // border-radius: 8px
|
|
```
|
|
|
|
Except for unitless properties, which stay as plain numbers:
|
|
|
|
```tsx
|
|
{ opacity: 0.5 } // opacity: 0.5
|
|
{ zIndex: 10 } // z-index: 10
|
|
{ flex: 1 } // flex: 1
|
|
{ flexGrow: 2 } // flex-grow: 2
|
|
{ flexShrink: 0 } // flex-shrink: 0
|
|
{ fontWeight: 400 } // font-weight: 400
|
|
{ lineHeight: 1.6 } // line-height: 1.6
|
|
{ order: 3 } // order: 3
|
|
```
|
|
|
|
Strings are passed through as-is:
|
|
|
|
```tsx
|
|
{ padding: '12px 24px' } // padding: 12px 24px
|
|
{ border: '1px solid #222' } // border: 1px solid #222
|
|
{ margin: '0 auto' } // margin: 0 auto
|
|
```
|
|
|
|
### All supported properties
|
|
|
|
Every standard CSS property is supported in camelCase form: layout (`display`, `position`, `flexDirection`, `gridTemplateColumns`, etc.), box model (`margin`, `padding`, `width`, `height`, etc.), typography (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, etc.), visual (`background`, `border`, `boxShadow`, `opacity`, etc.), animation (`transition`, `animation`, `transform`, etc.), and SVG (`fill`, `stroke`, `strokeWidth`, etc.).
|
|
|
|
---
|
|
|
|
## States
|
|
|
|
Pseudo-selectors like `:hover`, `:focus`, `:active`, and `:disabled`.
|
|
|
|
```tsx
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: 20,
|
|
background: 'blue',
|
|
cursor: 'pointer',
|
|
|
|
states: {
|
|
':hover': { background: 'darkblue' },
|
|
':active': { transform: 'translateY(1px)' },
|
|
':focus': { outline: '2px solid white' },
|
|
':disabled': { opacity: 0.3, cursor: 'not-allowed' },
|
|
},
|
|
})
|
|
```
|
|
|
|
The colon is optional — `hover` and `:hover` are equivalent:
|
|
|
|
```tsx
|
|
states: {
|
|
hover: { background: 'darkblue' }, // same as ':hover'
|
|
}
|
|
```
|
|
|
|
### Complex pseudo-selectors
|
|
|
|
Use the full string for compound selectors:
|
|
|
|
```tsx
|
|
states: {
|
|
':not(:disabled):hover': { background: 'darkblue' },
|
|
':not(:disabled):active': { transform: 'translateY(1px)' },
|
|
}
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```css
|
|
.Button:not(:disabled):hover { background: darkblue; }
|
|
.Button:not(:disabled):active { transform: translateY(1px); }
|
|
```
|
|
|
|
---
|
|
|
|
## Variants
|
|
|
|
Variants are typed props that apply conditional CSS classes. They replace inline styles with a clean, declarative API.
|
|
|
|
### Boolean variants
|
|
|
|
A variant whose value is a style object. Activated by passing `true`.
|
|
|
|
```tsx
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: 20,
|
|
background: 'blue',
|
|
|
|
variants: {
|
|
disabled: {
|
|
opacity: 0.3,
|
|
cursor: 'not-allowed',
|
|
},
|
|
rounded: {
|
|
borderRadius: 999,
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<Button>Normal</Button>
|
|
<Button disabled>Disabled</Button>
|
|
<Button rounded>Pill</Button>
|
|
<Button disabled rounded>Both</Button>
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```css
|
|
.Button { padding: 20px; background: blue; }
|
|
.Button.disabled { cursor: not-allowed; opacity: 0.3; }
|
|
.Button.rounded { border-radius: 999px; }
|
|
```
|
|
|
|
HTML output for `<Button disabled rounded>`:
|
|
|
|
```html
|
|
<button class="Button disabled rounded">Both</button>
|
|
```
|
|
|
|
Variant props are consumed by Forge and **not** passed to the HTML element.
|
|
|
|
### Keyed variants
|
|
|
|
A variant whose value is an object of named options. Activated by passing a string.
|
|
|
|
```tsx
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: 16,
|
|
|
|
variants: {
|
|
intent: {
|
|
primary: { background: 'blue', color: 'white' },
|
|
secondary: { background: '#333', color: '#ccc' },
|
|
danger: { background: 'red', color: 'white' },
|
|
ghost: { background: 'transparent', color: 'gray' },
|
|
},
|
|
size: {
|
|
small: { padding: '8px 16px', fontSize: 12 },
|
|
large: { padding: '16px 32px', fontSize: 16 },
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<Button intent="primary">Save</Button>
|
|
<Button intent="danger" size="large">Delete Account</Button>
|
|
<Button intent="ghost" size="small">Cancel</Button>
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```css
|
|
.Button.intent-primary { background: blue; color: white; }
|
|
.Button.intent-danger { background: red; color: white; }
|
|
.Button.size-small { font-size: 12px; padding: 8px 16px; }
|
|
.Button.size-large { font-size: 16px; padding: 16px 32px; }
|
|
```
|
|
|
|
### Variants with states
|
|
|
|
Variants can include their own pseudo-selectors:
|
|
|
|
```tsx
|
|
variants: {
|
|
intent: {
|
|
danger: {
|
|
background: 'red',
|
|
states: {
|
|
':not(:disabled):hover': { background: '#cc0000' },
|
|
},
|
|
},
|
|
secondary: {
|
|
background: '#333',
|
|
states: {
|
|
':not(:disabled):hover': { borderColor: 'green' },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
### Combining multiple variants
|
|
|
|
Multiple keyed and boolean variants can be used together freely:
|
|
|
|
```tsx
|
|
<Button intent="primary" size="small">Small Primary</Button>
|
|
<Button intent="danger" size="large" disabled>Large Danger Disabled</Button>
|
|
```
|
|
|
|
---
|
|
|
|
## Parts
|
|
|
|
Parts are named sub-components within a component. They get their own CSS classes and can have their own base tags, states, and selectors.
|
|
|
|
```tsx
|
|
const Card = define('Card', {
|
|
padding: 20,
|
|
background: '#111',
|
|
|
|
parts: {
|
|
Header: {
|
|
base: 'h2',
|
|
fontSize: 24,
|
|
marginBottom: 12,
|
|
},
|
|
Body: {
|
|
fontSize: 14,
|
|
lineHeight: 1.6,
|
|
color: '#888',
|
|
},
|
|
Footer: {
|
|
base: 'footer',
|
|
marginTop: 16,
|
|
paddingTop: 12,
|
|
borderTop: '1px solid #333',
|
|
fontSize: 12,
|
|
},
|
|
},
|
|
|
|
render({ props, parts: { Root, Header, Body, Footer } }) {
|
|
return (
|
|
<Root>
|
|
<Header>{props.title}</Header>
|
|
<Body>{props.children}</Body>
|
|
<Footer>{props.footer}</Footer>
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<Card title="Welcome" footer="Last updated today">
|
|
This is the card body content.
|
|
</Card>
|
|
```
|
|
|
|
Generated CSS classes:
|
|
|
|
```css
|
|
.Card { padding: 20px; background: #111; }
|
|
.Card_Header { font-size: 24px; margin-bottom: 12px; }
|
|
.Card_Body { color: #888; font-size: 14px; line-height: 1.6; }
|
|
.Card_Footer { border-top: 1px solid #333; font-size: 12px; margin-top: 16px; padding-top: 12px; }
|
|
```
|
|
|
|
### Parts with states
|
|
|
|
Parts can have their own pseudo-selectors:
|
|
|
|
```tsx
|
|
parts: {
|
|
Tab: {
|
|
base: 'button',
|
|
color: '#888',
|
|
borderBottom: '1px solid transparent',
|
|
|
|
states: {
|
|
':hover': { color: '#fff' },
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
### Variants that affect parts
|
|
|
|
Variants can override styles on specific parts:
|
|
|
|
```tsx
|
|
const UserProfile = define('UserProfile', {
|
|
padding: 24,
|
|
|
|
parts: {
|
|
Avatar: { base: 'img', width: 64, height: 64, borderRadius: '50%' },
|
|
Name: { fontSize: 18 },
|
|
Bio: { fontSize: 14, color: '#888' },
|
|
},
|
|
|
|
variants: {
|
|
size: {
|
|
compact: {
|
|
padding: 16, // override root
|
|
parts: {
|
|
Avatar: { width: 48, height: 48 }, // override part
|
|
Name: { fontSize: 16 },
|
|
},
|
|
},
|
|
large: {
|
|
padding: 32,
|
|
parts: {
|
|
Avatar: { width: 96, height: 96 },
|
|
Name: { fontSize: 24 },
|
|
},
|
|
},
|
|
},
|
|
verified: {
|
|
parts: {
|
|
Avatar: { border: '2px solid gold' },
|
|
},
|
|
},
|
|
},
|
|
|
|
render({ props, parts: { Root, Avatar, Name, Bio } }) {
|
|
return (
|
|
<Root>
|
|
<Avatar src={props.avatarUrl} alt={props.name} />
|
|
<Name>{props.name}{props.verified && ' ✓'}</Name>
|
|
<Bio>{props.bio}</Bio>
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<UserProfile size="compact" name="Alex" ... />
|
|
<UserProfile size="large" verified name="Jordan" ... />
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```css
|
|
.UserProfile { padding: 24px; }
|
|
.UserProfile_Avatar { border-radius: 50%; height: 64px; width: 64px; }
|
|
.UserProfile.size-compact { padding: 16px; }
|
|
.UserProfile_Avatar.size-compact { height: 48px; width: 48px; }
|
|
.UserProfile_Avatar.verified { border: 2px solid gold; }
|
|
```
|
|
|
|
### Applying variants to parts in render
|
|
|
|
Inside `render()`, you can pass variant props directly to part components:
|
|
|
|
```tsx
|
|
const Tabs = define('Tabs', {
|
|
display: 'flex',
|
|
|
|
parts: {
|
|
Tab: {
|
|
base: 'button',
|
|
color: '#888',
|
|
borderBottom: '1px solid transparent',
|
|
},
|
|
},
|
|
|
|
variants: {
|
|
active: {
|
|
parts: {
|
|
Tab: {
|
|
color: 'green',
|
|
borderBottom: '1px solid green',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
render({ props, parts: { Root, Tab } }) {
|
|
return (
|
|
<Root>
|
|
{props.items?.map((item: any) => (
|
|
<Tab active={item.active}>{item.label}</Tab>
|
|
))}
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
The `active` prop on `<Tab>` adds the variant class to that specific tab instance. It is not passed through to the HTML.
|
|
|
|
---
|
|
|
|
## Custom Render
|
|
|
|
Override the default rendering with a `render` function. It receives `props` (everything passed to the component) and `parts` (component functions for Root and all named parts).
|
|
|
|
```tsx
|
|
const FormGroup = define('FormGroup', {
|
|
marginBottom: 24,
|
|
|
|
parts: {
|
|
Label: { base: 'label', display: 'block', fontSize: 14, marginBottom: 8 },
|
|
Helper: { fontSize: 12, color: '#888', marginTop: 6 },
|
|
Error: { fontSize: 12, color: 'red', marginTop: 6 },
|
|
},
|
|
|
|
render({ props, parts: { Root, Label, Helper, Error } }) {
|
|
return (
|
|
<Root>
|
|
{props.label && <Label>{props.label}</Label>}
|
|
{props.children}
|
|
{props.helper && <Helper>{props.helper}</Helper>}
|
|
{props.error && <Error>{props.error}</Error>}
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<FormGroup label="Email" helper="We'll never share your email">
|
|
<Input type="email" placeholder="you@example.com" />
|
|
</FormGroup>
|
|
|
|
<FormGroup label="Username" error="Username is already taken">
|
|
<Input status="error" value="admin" />
|
|
</FormGroup>
|
|
```
|
|
|
|
### Destructuring props
|
|
|
|
Destructure to separate custom props from HTML passthrough props:
|
|
|
|
```tsx
|
|
render({ props: { title, subtitle, ...rest }, parts: { Root, H2, P } }) {
|
|
return (
|
|
<Root {...rest}>
|
|
<H2>{title}</H2>
|
|
<P>{subtitle}</P>
|
|
</Root>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Without render
|
|
|
|
If no `render` is provided, the component renders its children into the root tag:
|
|
|
|
```tsx
|
|
const Box = define('Box', { padding: 20 })
|
|
|
|
// Equivalent to:
|
|
// render({ props, parts: { Root } }) {
|
|
// return <Root {...props}>{props.children}</Root>
|
|
// }
|
|
```
|
|
|
|
---
|
|
|
|
## Selectors
|
|
|
|
The `selectors` key lets you write custom CSS selectors. Use `&` for the current element and `@PartName` to reference other parts.
|
|
|
|
### Basic selectors
|
|
|
|
```tsx
|
|
const NavLink = define('NavLink', {
|
|
base: 'a',
|
|
color: '#888',
|
|
textDecoration: 'none',
|
|
|
|
selectors: {
|
|
'&:hover': { color: '#fff' },
|
|
'&[aria-current]': { color: '#fff', textDecoration: 'underline' },
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<NavLink href="/home" aria-current="page">Home</NavLink>
|
|
<NavLink href="/about">About</NavLink>
|
|
```
|
|
|
|
### Cross-part selectors
|
|
|
|
Reference other parts with `@PartName`. This is the mechanism that enables CSS-only interactive components.
|
|
|
|
```tsx
|
|
const Checkbox = define('Checkbox', {
|
|
parts: {
|
|
Input: {
|
|
base: 'input[type=checkbox]',
|
|
display: 'none',
|
|
},
|
|
Label: {
|
|
base: 'label',
|
|
padding: 10,
|
|
cursor: 'pointer',
|
|
color: 'gray',
|
|
|
|
selectors: {
|
|
// When the Input is checked, style this Label
|
|
'@Input:checked + &': {
|
|
color: 'green',
|
|
fontWeight: 'bold',
|
|
},
|
|
// When the Input is disabled, style this Label
|
|
'@Input:disabled + &': {
|
|
opacity: 0.5,
|
|
cursor: 'not-allowed',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
render({ props, parts: { Root, Input, Label } }) {
|
|
return (
|
|
<Root>
|
|
<Input id={props.id} checked={props.checked} disabled={props.disabled} />
|
|
<Label for={props.id}>{props.label}</Label>
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
The `@Input` in `@Input:checked + &` is replaced with `.Checkbox_Input`, and `&` is replaced with `.Checkbox_Label`, producing:
|
|
|
|
```css
|
|
.Checkbox_Input:checked + .Checkbox_Label {
|
|
color: green;
|
|
font-weight: bold;
|
|
}
|
|
```
|
|
|
|
### CSS-only tab switcher
|
|
|
|
Hidden radio inputs + sibling selectors = tabs without JavaScript:
|
|
|
|
```tsx
|
|
const TabSwitcher = define('TabSwitcher', {
|
|
parts: {
|
|
Input: {
|
|
base: 'input[type=radio]',
|
|
display: 'none',
|
|
},
|
|
TabBar: {
|
|
display: 'flex',
|
|
borderBottom: '1px solid #333',
|
|
},
|
|
TabLabel: {
|
|
base: 'label',
|
|
padding: '12px 24px',
|
|
color: '#888',
|
|
cursor: 'pointer',
|
|
|
|
states: {
|
|
':hover': { color: '#fff' },
|
|
},
|
|
|
|
selectors: {
|
|
'@Input:checked + &': {
|
|
color: 'green',
|
|
borderBottom: '1px solid green',
|
|
},
|
|
},
|
|
},
|
|
Content: {
|
|
display: 'none',
|
|
padding: 24,
|
|
|
|
selectors: {
|
|
// General sibling combinator: when a radio is checked,
|
|
// show the corresponding content panel
|
|
'@Input:checked ~ &': { display: 'block' },
|
|
},
|
|
},
|
|
},
|
|
|
|
render({ props, parts: { Root, Input, TabBar, TabLabel, Content } }) {
|
|
return (
|
|
<Root>
|
|
<TabBar>
|
|
{props.tabs?.map((tab: any, i: number) => (
|
|
<>
|
|
<Input id={`${props.name}-${tab.id}`} name={props.name} checked={i === 0} />
|
|
<TabLabel for={`${props.name}-${tab.id}`}>{tab.label}</TabLabel>
|
|
</>
|
|
))}
|
|
</TabBar>
|
|
{props.tabs?.map((tab: any) => (
|
|
<Content>{tab.content}</Content>
|
|
))}
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
<TabSwitcher
|
|
name="demo"
|
|
tabs={[
|
|
{ id: 'one', label: 'Tab 1', content: <p>First panel</p> },
|
|
{ id: 'two', label: 'Tab 2', content: <p>Second panel</p> },
|
|
{ id: 'three', label: 'Tab 3', content: <p>Third panel</p> },
|
|
]}
|
|
/>
|
|
```
|
|
|
|
### Selector reference
|
|
|
|
| Selector | Meaning |
|
|
|---|---|
|
|
| `&:hover` | This element on hover |
|
|
| `&[aria-current]` | This element when attribute is present |
|
|
| `@Input:checked + &` | This element when adjacent Input is checked |
|
|
| `@Input:checked ~ &` | This element when any preceding Input sibling is checked |
|
|
| `@Input:disabled + &` | This element when adjacent Input is disabled |
|
|
| `@Input:checked + &:hover` | This element on hover, but only when Input is checked |
|
|
|
|
---
|
|
|
|
## Scopes
|
|
|
|
When building a family of related components, `createScope` prefixes all names automatically:
|
|
|
|
```tsx
|
|
import { createScope } from 'forge'
|
|
|
|
const { define } = createScope('Button')
|
|
|
|
const Button = define('Root', { // CSS class: "Button" (Root is special)
|
|
base: 'button',
|
|
padding: 20,
|
|
})
|
|
|
|
const ButtonRow = define('Row', { // CSS class: "ButtonRow"
|
|
display: 'flex',
|
|
gap: 16,
|
|
})
|
|
|
|
const ButtonIcon = define('Icon', { // CSS class: "ButtonIcon"
|
|
width: 20,
|
|
height: 20,
|
|
})
|
|
```
|
|
|
|
`Root` is the special case — `define('Root', ...)` with scope `'Button'` produces class `Button`, not `ButtonRoot`.
|
|
|
|
---
|
|
|
|
## Themes
|
|
|
|
Built-in CSS custom properties with type safety.
|
|
|
|
### Define themes
|
|
|
|
Create a theme file with your tokens:
|
|
|
|
```tsx
|
|
// darkTheme.tsx
|
|
export default {
|
|
'colors-bg': '#0a0a0a',
|
|
'colors-bgElevated': '#111',
|
|
'colors-fg': '#00ff00',
|
|
'colors-fgMuted': '#888',
|
|
'colors-border': '#222',
|
|
'colors-accent': '#00ff00',
|
|
'colors-accentDim': '#008800',
|
|
|
|
'fonts-mono': "'Monaco', 'Menlo', monospace",
|
|
|
|
'spacing-sm': '12px',
|
|
'spacing-md': '16px',
|
|
'spacing-lg': '24px',
|
|
'spacing-xl': '32px',
|
|
|
|
'radius-sm': '4px',
|
|
'radius-md': '8px',
|
|
} as const
|
|
```
|
|
|
|
```tsx
|
|
// lightTheme.tsx
|
|
export default {
|
|
'colors-bg': '#f5f5f0',
|
|
'colors-bgElevated': '#fff',
|
|
'colors-fg': '#0a0a0a',
|
|
'colors-fgMuted': '#666',
|
|
'colors-border': '#ddd',
|
|
'colors-accent': '#0066cc',
|
|
'colors-accentDim': '#004499',
|
|
|
|
'fonts-mono': "'Monaco', 'Menlo', monospace",
|
|
|
|
'spacing-sm': '12px',
|
|
'spacing-md': '16px',
|
|
'spacing-lg': '24px',
|
|
'spacing-xl': '32px',
|
|
|
|
'radius-sm': '4px',
|
|
'radius-md': '8px',
|
|
} as const
|
|
```
|
|
|
|
### Register themes
|
|
|
|
```tsx
|
|
// themes.tsx
|
|
import { createThemes } from 'forge'
|
|
import darkTheme from './darkTheme'
|
|
import lightTheme from './lightTheme'
|
|
|
|
export const theme = createThemes({
|
|
dark: darkTheme,
|
|
light: lightTheme,
|
|
})
|
|
```
|
|
|
|
`createThemes` returns a typed function. It only accepts keys that exist in your theme objects.
|
|
|
|
### Use theme values
|
|
|
|
`theme('key')` returns `var(--theme-key)`:
|
|
|
|
```tsx
|
|
import { theme } from './themes'
|
|
|
|
const Card = define('Card', {
|
|
padding: theme('spacing-lg'), // var(--theme-spacing-lg)
|
|
background: theme('colors-bgElevated'), // var(--theme-colors-bgElevated)
|
|
color: theme('colors-fg'), // var(--theme-colors-fg)
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
})
|
|
```
|
|
|
|
### Switch themes
|
|
|
|
Themes are controlled by the `data-theme` attribute:
|
|
|
|
```tsx
|
|
// Set initial theme
|
|
<body data-theme="dark">
|
|
|
|
// Switch at runtime
|
|
document.body.setAttribute('data-theme', 'light')
|
|
```
|
|
|
|
Generated CSS:
|
|
|
|
```css
|
|
[data-theme="dark"] {
|
|
--theme-colors-bg: #0a0a0a;
|
|
--theme-colors-fg: #00ff00;
|
|
/* ... */
|
|
}
|
|
[data-theme="light"] {
|
|
--theme-colors-bg: #f5f5f0;
|
|
--theme-colors-fg: #0a0a0a;
|
|
/* ... */
|
|
}
|
|
```
|
|
|
|
### Other theme APIs
|
|
|
|
```tsx
|
|
// Register a single theme
|
|
import { createTheme } from 'forge'
|
|
createTheme('dark', { bgColor: '#000', fgColor: '#fff' })
|
|
|
|
// Extend existing themes with new tokens
|
|
import { extendThemes } from 'forge'
|
|
extendThemes({ dark: { 'colors-success': '#0f0' } })
|
|
|
|
// Untyped fallback for dynamic theme keys
|
|
import { themeVar } from 'forge'
|
|
themeVar('colors-bg') // 'var(--theme-colors-bg)'
|
|
```
|
|
|
|
---
|
|
|
|
# Integration
|
|
|
|
## SSR with Forge
|
|
|
|
In SSR mode, Forge components render on the server and `<Styles />` inlines all generated CSS into the page. Because Hype imports pages at startup, all `define()` calls run before any request — so `<Styles />` always has the complete CSS.
|
|
|
|
### Server
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype({ layout: false })
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
### Layout with `<Styles />`
|
|
|
|
```tsx
|
|
// src/pages/_layout.tsx
|
|
import type { FC } from 'hono/jsx'
|
|
import { Styles } from 'forge'
|
|
|
|
const Layout: FC = ({ children, title }) => (
|
|
<html lang="en">
|
|
<head>
|
|
<title>{title ?? 'My App'}</title>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta name="color-scheme" content="light dark" />
|
|
<Styles />
|
|
</head>
|
|
<body data-theme="dark">{children}</body>
|
|
</html>
|
|
)
|
|
|
|
export default Layout
|
|
```
|
|
|
|
### Shared components
|
|
|
|
```tsx
|
|
// src/shared/components.tsx
|
|
import { define } from 'forge'
|
|
|
|
export const Button = define('Button', {
|
|
base: 'button',
|
|
padding: '10px 20px',
|
|
background: '#111',
|
|
color: '#fff',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
|
|
states: {
|
|
hover: { background: '#333' },
|
|
},
|
|
|
|
variants: {
|
|
intent: {
|
|
primary: { background: 'blue' },
|
|
danger: { background: 'red' },
|
|
},
|
|
},
|
|
})
|
|
|
|
export const Card = define('Card', {
|
|
padding: 24,
|
|
background: '#111',
|
|
borderRadius: 8,
|
|
border: '1px solid #222',
|
|
|
|
parts: {
|
|
Title: { base: 'h2', fontSize: 20, marginBottom: 12 },
|
|
Body: { fontSize: 14, color: '#888', lineHeight: 1.6 },
|
|
},
|
|
|
|
render({ props, parts: { Root, Title, Body } }) {
|
|
return (
|
|
<Root>
|
|
<Title>{props.title}</Title>
|
|
<Body>{props.children}</Body>
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
```
|
|
|
|
### Pages
|
|
|
|
Use Forge components directly in Hype pages — they're just JSX:
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
import { Button, Card } from '@components'
|
|
|
|
export default () => (
|
|
<main>
|
|
<Card title="Welcome">
|
|
This page is server-rendered with Forge components.
|
|
</Card>
|
|
<Button intent="primary">Get Started</Button>
|
|
</main>
|
|
)
|
|
```
|
|
|
|
### Adding client interactivity to SSR
|
|
|
|
For light interactivity on SSR pages, use Hype's `fe()` helper 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>
|
|
</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>
|
|
)
|
|
```
|
|
|
|
### SSR with Forge + SSE
|
|
|
|
A server-rendered page with styled components that updates live:
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype({ layout: false })
|
|
|
|
let count = 0
|
|
|
|
app.sse('/api/counter', (send) => {
|
|
send({ count })
|
|
const interval = setInterval(() => send({ count: ++count }), 1000)
|
|
return () => clearInterval(interval)
|
|
})
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
import { js } from '@because/hype'
|
|
import { Card, Badge } from '@components'
|
|
|
|
export default () => (
|
|
<section>
|
|
<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
|
|
}
|
|
`}
|
|
</section>
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## SPA with Forge
|
|
|
|
In SPA mode, Hype serves a minimal HTML shell and Forge runs entirely in the browser. Forge auto-injects a `<style id="forge-styles">` tag into `document.head` — no `<Styles />` needed.
|
|
|
|
### Server
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype({ layout: false })
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
### HTML Shell
|
|
|
|
The page is just a mount point. No Forge imports here — everything runs client-side:
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
export default () => (
|
|
<html lang="en">
|
|
<head>
|
|
<title>My SPA</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 data-theme="dark">
|
|
<div id="root" />
|
|
<script src="/client/app.js" type="module" />
|
|
</body>
|
|
</html>
|
|
)
|
|
```
|
|
|
|
No `<Styles />`, no CSS link. Forge handles it in the browser.
|
|
|
|
### Client entry
|
|
|
|
```tsx
|
|
// src/client/app.tsx
|
|
import { render } from 'hono/jsx/dom'
|
|
import { useState } from 'hono/jsx/dom'
|
|
import { define } from 'forge'
|
|
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: '10px 20px',
|
|
background: 'blue',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
|
|
states: {
|
|
hover: { background: 'darkblue' },
|
|
},
|
|
})
|
|
|
|
const Card = define('Card', {
|
|
padding: 24,
|
|
background: '#111',
|
|
borderRadius: 8,
|
|
})
|
|
|
|
function App() {
|
|
const [count, setCount] = useState(0)
|
|
|
|
return (
|
|
<Card>
|
|
<h1>Count: {count}</h1>
|
|
<Button onClick={() => setCount(c => c + 1)}>Increment</Button>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
render(<App />, document.getElementById('root')!)
|
|
```
|
|
|
|
When `define()` runs in the browser, Forge immediately injects the CSS. Hype's auto-transpilation bundles everything — Forge included — into a single JS file served at `/client/app.js`.
|
|
|
|
### Extracting components
|
|
|
|
Keep components in separate files and import them. Hype's bundler resolves all imports:
|
|
|
|
```tsx
|
|
// src/client/components.tsx
|
|
import { define } from 'forge'
|
|
|
|
export const Button = define('Button', {
|
|
base: 'button',
|
|
padding: '10px 20px',
|
|
background: 'blue',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
})
|
|
|
|
export const Stack = define('Stack', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 16,
|
|
})
|
|
```
|
|
|
|
```tsx
|
|
// src/client/app.tsx
|
|
import { render } from 'hono/jsx/dom'
|
|
import { Button, Stack } from './components'
|
|
|
|
function App() {
|
|
return (
|
|
<Stack>
|
|
<h1>My App</h1>
|
|
<Button>Click me</Button>
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
render(<App />, document.getElementById('root')!)
|
|
```
|
|
|
|
### Passing server data to the SPA
|
|
|
|
Inject data from the server into the HTML shell, then read it client-side:
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
const config = {
|
|
apiUrl: process.env.API_URL ?? '/api',
|
|
version: '1.0.0',
|
|
}
|
|
|
|
export default () => (
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
</head>
|
|
<body data-theme="dark">
|
|
<div id="root" />
|
|
<script dangerouslySetInnerHTML={{
|
|
__html: `window.__CONFIG__ = ${JSON.stringify(config)};`
|
|
}} />
|
|
<script src="/client/app.js" type="module" />
|
|
</body>
|
|
</html>
|
|
)
|
|
```
|
|
|
|
```tsx
|
|
// src/client/app.tsx
|
|
const config = (window as any).__CONFIG__
|
|
```
|
|
|
|
Use `dangerouslySetInnerHTML` — Hono's JSX escapes `<script>` content by default.
|
|
|
|
### Cache-busting
|
|
|
|
Use the git hash to bust asset caches:
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
import { $ } from 'bun'
|
|
|
|
const GIT_HASH = await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'dev')
|
|
|
|
export default () => (
|
|
<html lang="en">
|
|
<head>
|
|
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
|
|
</head>
|
|
<body>
|
|
<div id="root" />
|
|
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
|
|
</body>
|
|
</html>
|
|
)
|
|
```
|
|
|
|
### SPA with API routes
|
|
|
|
Hype serves the shell and API; Forge renders the client:
|
|
|
|
```ts
|
|
// src/server/index.ts
|
|
import { Hype } from '@because/hype'
|
|
|
|
const app = new Hype({ layout: false })
|
|
|
|
const todos: { id: number; text: string }[] = []
|
|
let nextId = 1
|
|
|
|
app.get('/api/todos', (c) => c.json(todos))
|
|
|
|
app.post('/api/todos', async (c) => {
|
|
const { text } = await c.req.json()
|
|
const todo = { id: nextId++, text }
|
|
todos.push(todo)
|
|
return c.json(todo, 201)
|
|
})
|
|
|
|
app.delete('/api/todos/:id', (c) => {
|
|
const id = Number(c.req.param('id'))
|
|
const idx = todos.findIndex(t => t.id === id)
|
|
if (idx !== -1) todos.splice(idx, 1)
|
|
return c.json({ deleted: true })
|
|
})
|
|
|
|
export default app.defaults
|
|
```
|
|
|
|
```tsx
|
|
// src/client/app.tsx
|
|
import { render } from 'hono/jsx/dom'
|
|
import { useState, useEffect } from 'hono/jsx/dom'
|
|
import { define } from 'forge'
|
|
|
|
const Stack = define('Stack', {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 12,
|
|
padding: 24,
|
|
maxWidth: 500,
|
|
margin: '0 auto',
|
|
})
|
|
|
|
const TodoItem = define('TodoItem', {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: 12,
|
|
background: '#111',
|
|
borderRadius: 6,
|
|
})
|
|
|
|
const Button = define('Button', {
|
|
base: 'button',
|
|
padding: '8px 16px',
|
|
background: '#3b82f6',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
|
|
states: {
|
|
hover: { background: '#2563eb' },
|
|
},
|
|
|
|
variants: {
|
|
danger: {
|
|
background: '#ef4444',
|
|
states: { hover: { background: '#dc2626' } },
|
|
},
|
|
},
|
|
})
|
|
|
|
const Input = define('Input', {
|
|
base: 'input',
|
|
padding: 10,
|
|
background: '#111',
|
|
color: '#fff',
|
|
border: '1px solid #333',
|
|
borderRadius: 6,
|
|
flex: 1,
|
|
marginRight: 8,
|
|
})
|
|
|
|
function App() {
|
|
const [todos, setTodos] = useState<{ id: number; text: string }[]>([])
|
|
const [text, setText] = useState('')
|
|
|
|
useEffect(() => {
|
|
fetch('/api/todos').then(r => r.json()).then(setTodos)
|
|
}, [])
|
|
|
|
const add = async () => {
|
|
if (!text.trim()) return
|
|
const res = await fetch('/api/todos', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text }),
|
|
})
|
|
const todo = await res.json()
|
|
setTodos(prev => [...prev, todo])
|
|
setText('')
|
|
}
|
|
|
|
const remove = async (id: number) => {
|
|
await fetch(`/api/todos/${id}`, { method: 'DELETE' })
|
|
setTodos(prev => prev.filter(t => t.id !== id))
|
|
}
|
|
|
|
return (
|
|
<Stack>
|
|
<h1>Todos</h1>
|
|
<div style={{ display: 'flex' }}>
|
|
<Input
|
|
value={text}
|
|
onInput={(e: any) => setText(e.target.value)}
|
|
placeholder="What needs doing?"
|
|
/>
|
|
<Button onClick={add}>Add</Button>
|
|
</div>
|
|
{todos.map(todo => (
|
|
<TodoItem key={todo.id}>
|
|
<span>{todo.text}</span>
|
|
<Button danger onClick={() => remove(todo.id)}>Delete</Button>
|
|
</TodoItem>
|
|
))}
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
render(<App />, document.getElementById('root')!)
|
|
```
|
|
|
|
---
|
|
|
|
## Shared Components
|
|
|
|
When a component has no client-side hooks or event handlers, it works identically in both SSR pages and SPA code. Put these in `src/shared/`:
|
|
|
|
```tsx
|
|
// src/shared/components.tsx
|
|
import { define } from 'forge'
|
|
|
|
export const Badge = define('Badge', {
|
|
base: 'span',
|
|
display: 'inline-block',
|
|
padding: '4px 10px',
|
|
fontSize: 12,
|
|
borderRadius: 999,
|
|
background: '#3b82f6',
|
|
color: 'white',
|
|
|
|
variants: {
|
|
muted: {
|
|
background: '#333',
|
|
color: '#888',
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
Use it in an SSR page:
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
import { Badge } from '@components'
|
|
|
|
export default () => (
|
|
<section>
|
|
<h1>Status: <Badge>Active</Badge></h1>
|
|
</section>
|
|
)
|
|
```
|
|
|
|
Use the same component in a SPA:
|
|
|
|
```tsx
|
|
// src/client/app.tsx
|
|
import { Badge } from '@components'
|
|
|
|
function StatusBar() {
|
|
return <Badge muted>Draft</Badge>
|
|
}
|
|
```
|
|
|
|
Hype's bundler handles the import in both cases. On the server, `define()` registers CSS that `<Styles />` collects. In the browser, `define()` injects CSS into the document head.
|
|
|
|
---
|
|
|
|
## Choosing SSR vs SPA
|
|
|
|
| | 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 |
|
|
| **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 |
|
|
|
|
Both approaches use the same Forge components. The only difference is where `define()` runs (server vs browser) and how the CSS gets into the page (`<Styles />` vs auto-injection).
|
|
|
|
---
|
|
|
|
# Reference
|
|
|
|
## Configuration
|
|
|
|
### Constructor options
|
|
|
|
```ts
|
|
const app = new Hype({
|
|
pico: true, // Include Pico CSS (default: false)
|
|
reset: true, // Include CSS reset (default: false)
|
|
prettyHTML: true, // Pretty-print HTML in dev (default: true in dev, false in prod)
|
|
layout: true, // Wrap pages in layout (default: true)
|
|
logging: true, // HTTP request logging (default: true)
|
|
ok: true, // Add GET /ok healthcheck (default: false)
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Environment Variables
|
|
|
|
| Variable | Effect |
|
|
|----------|--------|
|
|
| `PORT` | Server port (default: `3000`) |
|
|
| `NODE_ENV` | Set to `production` to disable auto-port, HTML prettification, and enable transpile caching |
|
|
| `NO_AUTOPORT` | Disable auto-port selection even in dev |
|
|
|
|
In dev mode, if port 3000 is busy, Hype automatically tries 3001, 3002, etc. (up to 100 attempts).
|
|
|
|
---
|
|
|
|
## Path Aliases
|
|
|
|
The recommended `tsconfig.json` defines three path aliases:
|
|
|
|
| Alias | Maps to | Use for |
|
|
|-------|---------|---------|
|
|
| `$*` | `src/server/*` | Server-only imports |
|
|
| `#*` | `src/client/*` | Client-only imports |
|
|
| `@*` | `src/shared/*` | Isomorphic imports |
|
|
|
|
```ts
|
|
// In server code:
|
|
import { db } from '$db' // => src/server/db.ts
|
|
|
|
// In client code:
|
|
import { format } from '@utils' // => src/shared/utils.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Utility Functions
|
|
|
|
Hype exports a collection of helpers from `@because/hype` (or `@because/hype/utils`):
|
|
|
|
### Randomness
|
|
|
|
```ts
|
|
import { rand, randRange, randItem, randIndex, shuffle, weightedRand, randomId } from '@because/hype'
|
|
|
|
rand() // 1 or 2 (coin flip)
|
|
rand(6) // 1-6 (roll a die)
|
|
rand(20) // 1-20 (d20)
|
|
randRange(5, 10) // 5-10 inclusive
|
|
randItem(['a', 'b', 'c']) // random element
|
|
randIndex(['a', 'b', 'c']) // random index (0, 1, or 2)
|
|
shuffle([1, 2, 3, 4, 5]) // shuffled copy
|
|
weightedRand() // 1-10, lower numbers more likely
|
|
randomId() // e.g. "k7x2m1"
|
|
```
|
|
|
|
### Arrays
|
|
|
|
```ts
|
|
import { times, unique } from '@because/hype'
|
|
|
|
times(5) // [1, 2, 3, 4, 5]
|
|
unique([1, 1, 2, 2, 3, 3]) // [1, 2, 3]
|
|
```
|
|
|
|
### Colors
|
|
|
|
```ts
|
|
import { lightenColor, darkenColor } from '@because/hype'
|
|
|
|
lightenColor('#3498db', 0.5) // blend halfway to white
|
|
darkenColor('#3498db', 0.5) // blend halfway to black
|
|
```
|
|
|
|
### Strings
|
|
|
|
```ts
|
|
import { capitalize } from '@because/hype'
|
|
|
|
capitalize('hello') // "Hello"
|
|
```
|
|
|
|
### Dark mode detection (client-side)
|
|
|
|
```ts
|
|
import { isDarkMode } from '@because/hype'
|
|
|
|
if (isDarkMode()) {
|
|
// user prefers dark mode
|
|
}
|
|
```
|
|
|
|
### Transpilation
|
|
|
|
```ts
|
|
import { transpile } from '@because/hype'
|
|
|
|
const js = await transpile('./src/client/app.tsx')
|
|
// => bundled ESM JavaScript string
|
|
```
|
|
|
|
### Forge SSR helpers
|
|
|
|
```tsx
|
|
// Inline all Forge CSS as a <style> tag
|
|
import { Styles } from 'forge'
|
|
<Styles />
|
|
|
|
// Get raw CSS string (e.g. to write to a file)
|
|
import { stylesToCSS } from 'forge'
|
|
const css = stylesToCSS()
|
|
Bun.write('public/main.css', css)
|
|
```
|
|
|
|
### Forge HMR
|
|
|
|
Forge automatically clears and re-registers styles when modules are hot-reloaded via `import.meta.hot.dispose`. No setup needed.
|
|
|
|
---
|
|
|
|
## Hono Middleware
|
|
|
|
Any Hono middleware works with Hype:
|
|
|
|
```ts
|
|
import { Hype } from '@because/hype'
|
|
import { cors } from 'hono/cors'
|
|
import { basicAuth } from 'hono/basic-auth'
|
|
|
|
const app = new Hype()
|
|
|
|
// CORS for API routes
|
|
app.use('/api/*', cors())
|
|
|
|
// Basic auth for admin
|
|
app.use('/admin/*', basicAuth({
|
|
username: 'admin',
|
|
password: process.env.ADMIN_PASSWORD!,
|
|
}))
|
|
|
|
export default app.defaults
|
|
```
|