Compare commits
No commits in common. "8e45bd1d0cbcf7960166decf1bdb476dfdd10dcc" and "45139e519d4f83cfb60cbc4a6acec5b4bf92d326" have entirely different histories.
8e45bd1d0c
...
45139e519d
43
README.md
43
README.md
|
|
@ -17,6 +17,7 @@
|
||||||
- Static files served from `pub/`
|
- Static files served from `pub/`
|
||||||
- Page-based routing to `.tsx` files that export a `JSX` function in `./src/pages`
|
- 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`
|
- 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.
|
- Default, simple HTML5 layout with working frontend transpilation/bundling, or supply your own.
|
||||||
- Optional CSS reset.
|
- Optional CSS reset.
|
||||||
- Optional pico.css.
|
- Optional pico.css.
|
||||||
|
|
@ -114,6 +115,25 @@ CSS can be accessed via `/css/main.css`:
|
||||||
<link href="/css/main.css" rel="stylesheet" />
|
<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
|
### css reset
|
||||||
|
|
||||||
To use reset.css with the default layout, create `Hype` with `{ reset: true }`:
|
To use reset.css with the default layout, create `Hype` with `{ reset: true }`:
|
||||||
|
|
@ -145,6 +165,27 @@ import { initAbout } from "./about"
|
||||||
import utils from "./shared/utils"
|
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
|
### pub
|
||||||
|
|
||||||
Anything in `pub/` is served as-is. Simple stuff.
|
Anything in `pub/` is served as-is. Simple stuff.
|
||||||
|
|
@ -208,8 +249,10 @@ curl -N http://localhost:3000/api/time
|
||||||
`hype` includes helpful utils for your webapp:
|
`hype` includes helpful utils for your webapp:
|
||||||
|
|
||||||
- `capitalize(str: string): string` - Capitalizes a word
|
- `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
|
- `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
|
- `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
|
- `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
|
- `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`
|
- `randIndex<T>(list: T[]): number | undefined` - Get a random index from an array. `randIndex([5, 7, 9])` returns `0`, `1`, or `2`
|
||||||
|
|
|
||||||
204
docs/GUIDE.md
204
docs/GUIDE.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Guide to Writing Hype Apps
|
# 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, 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, inline CSS/JS helpers, 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.
|
Since `Hype extends Hono`, every Hono API (`get`, `post`, `use`, `on`, etc.) works out of the box.
|
||||||
|
|
||||||
|
|
@ -16,6 +16,8 @@ Since `Hype extends Hono`, every Hono API (`get`, `post`, `use`, `on`, etc.) wor
|
||||||
- [Client-Side JavaScript](#client-side-javascript)
|
- [Client-Side JavaScript](#client-side-javascript)
|
||||||
- [Styling](#styling)
|
- [Styling](#styling)
|
||||||
- [Server-Sent Events (SSE)](#server-sent-events-sse)
|
- [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)
|
- [Custom API Routes](#custom-api-routes)
|
||||||
- [Sub-Routers](#sub-routers)
|
- [Sub-Routers](#sub-routers)
|
||||||
- [Static Files](#static-files)
|
- [Static Files](#static-files)
|
||||||
|
|
@ -591,6 +593,32 @@ Or include it in a custom layout:
|
||||||
const app = new Hype({ pico: true, reset: true })
|
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)
|
## Server-Sent Events (SSE)
|
||||||
|
|
@ -634,31 +662,30 @@ Return a cleanup function to handle client disconnection.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/pages/sse.tsx
|
// src/pages/sse.tsx
|
||||||
|
import { js } from '@because/hype'
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<section>
|
<section>
|
||||||
<h1>SSE Demo</h1>
|
<h1>SSE Demo</h1>
|
||||||
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
|
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
|
||||||
|
|
||||||
<script src="/client/main.ts" type="module"></script>
|
{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>
|
</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
|
### Named events
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -690,6 +717,149 @@ 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
|
## Custom API Routes
|
||||||
|
|
||||||
Since Hype extends Hono, use any Hono routing method:
|
Since Hype extends Hono, use any Hono routing method:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
A complete guide to building web apps with Hype and 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, 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, 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.
|
**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,6 +20,8 @@ A complete guide to building web apps with Hype and Forge.
|
||||||
- [Client-Side JavaScript](#client-side-javascript)
|
- [Client-Side JavaScript](#client-side-javascript)
|
||||||
- [Styling](#styling)
|
- [Styling](#styling)
|
||||||
- [Server-Sent Events (SSE)](#server-sent-events-sse)
|
- [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)
|
- [Custom API Routes](#custom-api-routes)
|
||||||
- [Sub-Routers](#sub-routers)
|
- [Sub-Routers](#sub-routers)
|
||||||
- [Static Files](#static-files)
|
- [Static Files](#static-files)
|
||||||
|
|
@ -536,31 +538,30 @@ Return a cleanup function to handle client disconnection.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/pages/sse.tsx
|
// src/pages/sse.tsx
|
||||||
|
import { js } from '@because/hype'
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<section>
|
<section>
|
||||||
<h1>SSE Demo</h1>
|
<h1>SSE Demo</h1>
|
||||||
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
|
<div id="time" style="font-size: 2em; font-family: monospace;"></div>
|
||||||
|
|
||||||
<script src="/client/main.ts" type="module"></script>
|
{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>
|
</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
|
### Named events
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -592,6 +593,149 @@ 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
|
## Custom API Routes
|
||||||
|
|
||||||
Since Hype extends Hono, use any Hono routing method:
|
Since Hype extends Hono, use any Hono routing method:
|
||||||
|
|
@ -1708,25 +1852,43 @@ export default () => (
|
||||||
|
|
||||||
### Adding client interactivity to SSR
|
### Adding client interactivity to SSR
|
||||||
|
|
||||||
For interactivity on SSR pages, use a client-side script alongside Forge components:
|
For light interactivity on SSR pages, use Hype's `fe()` helper alongside Forge components:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/pages/index.tsx
|
// src/pages/index.tsx
|
||||||
|
import { fe } from '@because/hype'
|
||||||
import { Button } from '@components'
|
import { Button } from '@components'
|
||||||
|
|
||||||
|
const handleClick = fe(() => {
|
||||||
|
alert('Clicked!')
|
||||||
|
})
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<section>
|
<section>
|
||||||
<Button intent="primary" id="my-btn">Click me</Button>
|
<Button intent="primary" onclick={handleClick}>Click me</Button>
|
||||||
<script src="/client/main.ts" type="module"></script>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
Or use Hype's `js` tag for inline scripts:
|
||||||
// src/client/main.ts
|
|
||||||
document.getElementById('my-btn')?.addEventListener('click', () => {
|
```tsx
|
||||||
alert('Clicked!')
|
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
|
### SSR with Forge + SSE
|
||||||
|
|
@ -1752,6 +1914,7 @@ export default app.defaults
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/pages/index.tsx
|
// src/pages/index.tsx
|
||||||
|
import { js } from '@because/hype'
|
||||||
import { Card, Badge } from '@components'
|
import { Card, Badge } from '@components'
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
|
|
@ -1759,20 +1922,17 @@ export default () => (
|
||||||
<Card title="Live Counter">
|
<Card title="Live Counter">
|
||||||
Count: <Badge><span id="count">--</span></Badge>
|
Count: <Badge><span id="count">--</span></Badge>
|
||||||
</Card>
|
</Card>
|
||||||
<script src="/client/main.ts" type="module"></script>
|
{js`
|
||||||
|
const el = document.getElementById('count')!
|
||||||
|
const events = new EventSource('/api/counter')
|
||||||
|
events.onmessage = (e) => {
|
||||||
|
el.textContent = JSON.parse(e.data).count
|
||||||
|
}
|
||||||
|
`}
|
||||||
</section>
|
</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
|
## SPA with Forge
|
||||||
|
|
@ -2156,7 +2316,7 @@ Hype's bundler handles the import in both cases. On the server, `define()` regis
|
||||||
| | SSR | SPA |
|
| | SSR | SPA |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Styles** | `<Styles />` in the layout head | Auto-injected by Forge in browser |
|
| **Styles** | `<Styles />` in the layout head | Auto-injected by Forge in browser |
|
||||||
| **Interactivity** | Client scripts via `/client/main.ts` | Full `hono/jsx/dom` with hooks |
|
| **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 |
|
| **Routing** | File-based, one page per route | Client-side, single HTML shell |
|
||||||
| **Data** | Fetch in page components (`c`, `req`) | Fetch in `useEffect`, SSE, etc. |
|
| **Data** | Fetch in page components (`c`, `req`) | Fetch in `useEffect`, SSE, etc. |
|
||||||
| **Best for** | Content sites, dashboards, forms | Interactive apps, real-time UIs |
|
| **Best for** | Content sites, dashboards, forms | Interactive apps, real-time UIs |
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
|
import { fe } from '@because/hype'
|
||||||
|
|
||||||
|
const test = fe(() => {
|
||||||
|
alert('ding dong')
|
||||||
|
})
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<section>
|
<section>
|
||||||
<a href="#" onclick="alert('ding dong')">test</a>
|
<a href="#" onclick={test}>test</a>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { js } from '@because/hype'
|
||||||
|
|
||||||
export default () => (
|
export default () => (
|
||||||
<section>
|
<section>
|
||||||
<h1>SSE Demo</h1>
|
<h1>SSE Demo</h1>
|
||||||
|
|
@ -10,8 +12,7 @@ export default () => (
|
||||||
<a href="/">Back to Home</a>
|
<a href="/">Back to Home</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<script dangerouslySetInnerHTML={{
|
{js`
|
||||||
__html: `
|
|
||||||
const statusEl = document.getElementById('status')
|
const statusEl = document.getElementById('status')
|
||||||
const timeEl = document.getElementById('time')
|
const timeEl = document.getElementById('time')
|
||||||
const countEl = document.getElementById('count')
|
const countEl = document.getElementById('count')
|
||||||
|
|
@ -35,6 +36,6 @@ export default () => (
|
||||||
statusEl.textContent = 'Disconnected'
|
statusEl.textContent = 'Disconnected'
|
||||||
statusEl.style.color = 'red'
|
statusEl.style.color = 'red'
|
||||||
}
|
}
|
||||||
`}} />
|
`}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/hype",
|
"name": "@because/hype",
|
||||||
"version": "0.0.6",
|
"version": "0.0.5",
|
||||||
"module": "src/index.tsx",
|
"module": "src/index.tsx",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { join, resolve } from 'path'
|
import { join } from 'path'
|
||||||
import { render as formatHTML } from './lib/html-formatter'
|
import { render as formatHTML } from './lib/html-formatter'
|
||||||
import { type Context, Hono, type Schema, type Env } from 'hono'
|
import { type Context, Hono, type Schema, type Env } from 'hono'
|
||||||
import { serveStatic } from 'hono/bun'
|
import { serveStatic } from 'hono/bun'
|
||||||
|
|
@ -7,12 +7,14 @@ import color from 'kleur'
|
||||||
|
|
||||||
import { transpile } from './utils'
|
import { transpile } from './utils'
|
||||||
import defaultLayout from './layout'
|
import defaultLayout from './layout'
|
||||||
|
import { feFunctions, fnStorage } from './frontend'
|
||||||
|
|
||||||
const SHOW_HTTP_LOG = true
|
const SHOW_HTTP_LOG = true
|
||||||
const CSS_RESET = await Bun.file(join(import.meta.dir, '/css/reset.css')).text()
|
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()
|
const PICO_CSS = await Bun.file(join(import.meta.dir, '/css/pico.css')).text()
|
||||||
|
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
export { frontend, frontend as fe } from './frontend'
|
||||||
export type { Context } from 'hono'
|
export type { Context } from 'hono'
|
||||||
|
|
||||||
const pageCache = new Map()
|
const pageCache = new Map()
|
||||||
|
|
@ -144,6 +146,26 @@ export class Hype<
|
||||||
if (this.props.ok)
|
if (this.props.ok)
|
||||||
this.get('/ok', c => c.text('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
|
// css reset
|
||||||
this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))
|
this.get('/css/reset.css', async c => new Response(CSS_RESET, { headers: { 'Content-Type': 'text/css' } }))
|
||||||
|
|
||||||
|
|
@ -152,24 +174,22 @@ export class Hype<
|
||||||
|
|
||||||
// serve transpiled js
|
// serve transpiled js
|
||||||
this.on('GET', ['/client/:path{.+}', '/shared/:path{.+}'], async c => {
|
this.on('GET', ['/client/:path{.+}', '/shared/:path{.+}'], async c => {
|
||||||
const reqPath = resolve('./src/', c.req.path.slice(1))
|
let path = './src/' + c.req.path.replace('..', '.')
|
||||||
if (!reqPath.startsWith(resolve('./src/'))) return render404(c)
|
|
||||||
|
|
||||||
// strip known extension to get base path
|
// path must end in .js or .ts
|
||||||
const base = reqPath.replace(/\.(js|jsx|ts|tsx)$/, '')
|
if (!path.endsWith('.js') && !path.endsWith('.ts')) path += '.ts'
|
||||||
|
|
||||||
// try TS extensions first (needs transpilation)
|
const ts = path.replace('.js', '.ts')
|
||||||
for (const ext of ['.ts', '.tsx', '.jsx']) {
|
if (await Bun.file(ts).exists())
|
||||||
const file = base + ext
|
return new Response(await transpile(ts), { headers: { 'Content-Type': 'text/javascript' } })
|
||||||
if (await Bun.file(file).exists())
|
|
||||||
return new Response(await transpile(file), { headers: { 'Content-Type': 'text/javascript' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// try plain .js (serve raw)
|
else if (await Bun.file(ts + 'x').exists())
|
||||||
const jsFile = base + '.js'
|
return new Response(await transpile(ts + 'x'), { headers: { 'Content-Type': 'text/javascript' } })
|
||||||
if (await Bun.file(jsFile).exists())
|
|
||||||
return new Response(Bun.file(jsFile), { headers: { 'Content-Type': 'text/javascript' } })
|
|
||||||
|
|
||||||
|
else if (await Bun.file(path).exists())
|
||||||
|
return new Response(Bun.file(path), { headers: { 'Content-Type': 'text/javascript' } })
|
||||||
|
|
||||||
|
else
|
||||||
return render404(c)
|
return render404(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
import { type Context } from 'hono'
|
import { type Context } from 'hono'
|
||||||
import { stat } from 'fs/promises'
|
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
|
// lighten a hex color by blending with white. opacity 1 = original, 0 = white
|
||||||
export function lightenColor(hex: string, opacity: number): string {
|
export function lightenColor(hex: string, opacity: number): string {
|
||||||
// Remove # if present
|
// Remove # if present
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user