1078 lines
40 KiB
Markdown
1078 lines
40 KiB
Markdown
# Hono JSX Rendering & SSR: Deep Research for LLM HUD
|
|
|
|
## Executive Summary
|
|
|
|
Hono's JSX system is a lightweight, server-first JSX implementation that renders directly to HTML strings via a custom virtual node tree (`JSXNode`). Critically, the **virtual tree is accessible before serialization** — the `JSXNode` class holds the tag, props, and children, and only serializes to HTML when `.toString()` is called. This creates a key interception point for repurposing JSX → markdown pipelines.
|
|
|
|
---
|
|
|
|
## 1. JSX Renderer Middleware (`src/middleware/jsx-renderer/`)
|
|
|
|
### Source Location
|
|
`/workspace/hono/src/middleware/jsx-renderer/index.ts`
|
|
|
|
### How It Works
|
|
|
|
The `jsxRenderer` middleware sets up a rendering pipeline on the Hono `Context` object:
|
|
|
|
```typescript
|
|
export const jsxRenderer = <E extends Env = Env>(
|
|
component?: ComponentWithChildren,
|
|
options?: RendererOptions | ((c: Context<E>) => RendererOptions)
|
|
): MiddlewareHandler
|
|
```
|
|
|
|
**Key behavior:**
|
|
|
|
1. **Sets a renderer** on the context via `c.setRenderer(createRenderer(c, Layout, component, options))`
|
|
2. **Sets a layout** on the context via `c.setLayout((props) => component({ ...props, Layout }, c))`
|
|
3. **After `next()`**, routes can call `c.render(<Component />, { title: 'foo' })` which invokes the stored renderer
|
|
4. The renderer wraps content in a `RequestContext.Provider` so components can access the Hono request/context via `useRequestContext()`
|
|
|
|
### API Options
|
|
|
|
```typescript
|
|
type RendererOptions = {
|
|
docType?: boolean | string // default: '<!DOCTYPE html>', false omits it
|
|
stream?: boolean | Record<string, string> // streaming response with chunked transfer
|
|
}
|
|
```
|
|
|
|
### Layout Nesting
|
|
|
|
The `Layout` prop allows nested layouts. Each route-level middleware can define a layout that wraps the parent layout:
|
|
|
|
```tsx
|
|
// Outer layout
|
|
app.use('*', jsxRenderer(({ children, Layout, title }) => (
|
|
<Layout title={title}>
|
|
<html><head><title>{title}</title></head><body>{children}</body></html>
|
|
</Layout>
|
|
)))
|
|
|
|
// Inner layout
|
|
app2.use('*', jsxRenderer(({ children, Layout, title }) => (
|
|
<Layout title={title}>
|
|
<div class="nested">{children}</div>
|
|
</Layout>
|
|
)))
|
|
```
|
|
|
|
The `Layout` component resolves to the parent's renderer function, enabling composition.
|
|
|
|
### Renderer Data Flow
|
|
|
|
```
|
|
c.render(<Content/>, { title: 'X' })
|
|
→ createRenderer(c, Layout, component, options)(children, props)
|
|
→ if component exists: jsx(component, { Layout, ...props }, children)
|
|
→ wraps in RequestContext.Provider: jsx(RequestContext.Provider, { value: c }, ...)
|
|
→ prepends docType string
|
|
→ if stream: c.body(renderToReadableStream(body)) with chunked headers
|
|
→ else: c.html(body) // standard HTML response
|
|
```
|
|
|
|
---
|
|
|
|
## 2. JSX Components in Hono (`src/jsx/`)
|
|
|
|
### Core Data Structures
|
|
|
|
**`JSXNode` (base.ts:130-244)** — The fundamental virtual node:
|
|
|
|
```typescript
|
|
export class JSXNode implements HtmlEscaped {
|
|
tag: string | Function // HTML tag name OR component function
|
|
props: Props // Properties object
|
|
key?: string // React-style key
|
|
children: Child[] // Child nodes
|
|
isEscaped: true // Marks as HTML-escaped string
|
|
localContexts?: LocalContexts // Context values for SSR
|
|
}
|
|
```
|
|
|
|
**`JSXFunctionNode` (base.ts:247-289)** — Extends `JSXNode` for function components. Overrides `toStringToBuffer` to call `this.tag(props)` and recursively render the result.
|
|
|
|
**`JSXFragmentNode` (base.ts:291-294)** — Extends `JSXNode` for fragments. Simply concatenates children without wrapping tags.
|
|
|
|
**`Child` type (base.ts:121-129)**:
|
|
```typescript
|
|
export type Child =
|
|
| string | Promise<string> | number | JSXNode
|
|
| null | undefined | boolean | Child[]
|
|
```
|
|
|
|
### How JSX Gets Transformed
|
|
|
|
The `jsx()` function (base.ts:297-313) is the entry point:
|
|
|
|
```typescript
|
|
export const jsx = (
|
|
tag: string | Function,
|
|
props: Props | null,
|
|
...children: (string | number | HtmlEscapedString)[]
|
|
): JSXNode => {
|
|
// ... props normalization
|
|
return jsxFn(tag, props, children)
|
|
}
|
|
```
|
|
|
|
`jsxFn()` (base.ts:316-351) determines node type:
|
|
1. If `tag` is a function → `new JSXFunctionNode(tag, props, children)`
|
|
2. If `tag` is an intrinsic element with special behavior (e.g., `<title>`, `<script>`, `<meta>`, `<link>`, `<style>`, `<form>`, `<input>`, `<button>`) → `new JSXFunctionNode(intrinsicElementComponent, props, children)`
|
|
3. If `tag` is `'svg'` or `'head'` → creates a namespace context wrapper
|
|
4. Otherwise → `new JSXNode(tag, props, children)` (plain HTML element)
|
|
|
|
### Key Insight: JSXNodes are Accessible Before Serialization
|
|
|
|
When you write:
|
|
|
|
```tsx
|
|
const node = <div class="container"><h1>Hello</h1></div>
|
|
```
|
|
|
|
The result is a `JSXNode` instance with:
|
|
- `node.tag === 'div'`
|
|
- `node.props === { class: 'container' }`
|
|
- `node.children === [<JSXNode tag='h1' props={} children=['Hello']>]`
|
|
|
|
**The tree is Materialized Before toString()** — this is the critical interception point.
|
|
|
|
### Component Model
|
|
|
|
Function components are plain functions that receive props and return `JSXNode | string | Promise<JSXNode> | HtmlEscapedString`:
|
|
|
|
```typescript
|
|
export type FC<P = Props> = {
|
|
(props: P): HtmlEscapedString | Promise<HtmlEscapedString> | null
|
|
defaultProps?: Partial<P>
|
|
displayName?: string
|
|
}
|
|
```
|
|
|
|
When rendered, `JSXFunctionNode.toStringToBuffer()` calls `this.tag(props)` and recursively renders the result.
|
|
|
|
### Hooks (SSR Support)
|
|
|
|
Hooks (`useState`, `useEffect`, etc.) have split implementations:
|
|
- **Server-side**: The hooks in `src/jsx/hooks/` — only `useState`, `useReducer`, `useCallback`, `useMemo`, `useId`, `useRef`, and `useSyncExternalStore` have meaningful SSR behavior
|
|
- **Client-side (DOM)**: The hooks in `src/jsx/dom/hooks/` — full React-like behavior for browser rehydration
|
|
|
|
For SSR, `useState` returns the initial value, `useMemo` caches computation, and `useId` generates deterministic IDs.
|
|
|
|
### Context System
|
|
|
|
```typescript
|
|
// src/jsx/context.ts
|
|
export const createContext = <T>(defaultValue: T): Context<T> => {
|
|
const values = [defaultValue]
|
|
const context = ((props): HtmlEscapedString | Promise<HtmlEscapedString> => {
|
|
values.push(props.value)
|
|
// ... renders children with value on stack, then pops
|
|
}) as Context<T>
|
|
context.values = values
|
|
context.Provider = context
|
|
globalContexts.push(context as Context<unknown>)
|
|
return context
|
|
}
|
|
|
|
export const useContext = <T>(context: Context<T>): T => {
|
|
return context.values.at(-1) as T
|
|
}
|
|
```
|
|
|
|
Context works by maintaining a stack of values. The Provider pushes a new value, renders children, then pops. This is critical for passing request-scoped data through the component tree.
|
|
|
|
---
|
|
|
|
## 3. SSR Pattern: From JSX to Output
|
|
|
|
### The Rendering Pipeline
|
|
|
|
```
|
|
JSX Syntax
|
|
│
|
|
▼ (JSX transform / runtime)
|
|
JSXNode tree (tag, props, children)
|
|
│
|
|
▼ (.toString() or .toStringToBuffer())
|
|
StringBufferWithCallbacks ['chunk1', Promise<string>, 'chunk2', ...]
|
|
│
|
|
▼ (stringBufferToString / resolveCallback)
|
|
HtmlEscapedString (string with {isEscaped: true, callbacks?: [...]})
|
|
|
|
|
|
For streaming:
|
|
│
|
|
▼ (renderToReadableStream)
|
|
ReadableStream<Uint8Array>
|
|
```
|
|
|
|
### `JSXNode.toString()` (base.ts:153-170)
|
|
|
|
```typescript
|
|
toString(): string | Promise<string> {
|
|
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
|
|
this.localContexts?.forEach(([context, value]) => {
|
|
context.values.push(value)
|
|
})
|
|
try {
|
|
this.toStringToBuffer(buffer)
|
|
} finally {
|
|
this.localContexts?.forEach(([context]) => {
|
|
context.values.pop()
|
|
})
|
|
}
|
|
return buffer.length === 1
|
|
? 'callbacks' in buffer
|
|
? resolveCallbackSync(raw(buffer[0], buffer.callbacks)).toString()
|
|
: buffer[0]
|
|
: stringBufferToString(buffer, buffer.callbacks)
|
|
}
|
|
```
|
|
|
|
### `JSXNode.toStringToBuffer()` for intrinsic elements (base.ts:172-244)
|
|
|
|
This is the HTML serialization method. It:
|
|
1. Opens the tag: `buffer[0] += '<${tag}'`
|
|
2. Serializes attributes (with escaping via `escapeToBuffer`)
|
|
3. Handles special props: `style` (object→string), `dangerouslySetInnerHTML`, boolean attributes
|
|
4. Handles void elements (`<br/>`, `<img/>`, etc.)
|
|
5. Renders children recursively via `childrenToStringToBuffer(children, buffer)`
|
|
6. Closes the tag: `buffer[0] += '</${tag}>'`
|
|
|
|
### `JSXFunctionNode.toStringToBuffer()` for function components (base.ts:248-289)
|
|
|
|
```typescript
|
|
toStringToBuffer(buffer: StringBufferWithCallbacks): void {
|
|
const { children } = this
|
|
const props = { ...this.props }
|
|
if (children.length) {
|
|
props.children = children.length === 1 ? children[0] : children
|
|
}
|
|
const res = (this.tag as Function).call(null, props)
|
|
// ... dispatches based on res type:
|
|
// - null/undefined: skip
|
|
// - Promise: buffer.unshift('', promise)
|
|
// - JSXNode: res.toStringToBuffer(buffer) // recurse
|
|
// - HtmlEscapedString: buffer += res
|
|
// - string: escapeToBuffer(res, buffer)
|
|
}
|
|
```
|
|
|
|
### `renderToString` (dom/server.ts:21-30)
|
|
|
|
```typescript
|
|
const renderToString = (element: Child, options: RenderToStringOptions = {}): string => {
|
|
const res = element?.toString() ?? ''
|
|
if (typeof res !== 'string') {
|
|
throw new Error('Async component is not supported in renderToString')
|
|
}
|
|
return res
|
|
}
|
|
```
|
|
|
|
It simply calls `.toString()` on the element. If there are async components, it throws.
|
|
|
|
### HtmlEscapedString Protocol (src/utils/html.ts)
|
|
|
|
```typescript
|
|
export type HtmlEscaped = {
|
|
isEscaped: true
|
|
callbacks?: HtmlEscapedCallback[]
|
|
}
|
|
export type HtmlEscapedString = string & HtmlEscaped
|
|
```
|
|
|
|
Strings are wrapped with `{ isEscaped: true }` to mark them as already-HTML-escaped (no need to re-escape). The `callbacks` array enables lazy resolution for streaming/Suspense — each callback can return a `Promise<string>` that resolves when async content is ready.
|
|
|
|
### `raw()` helper
|
|
|
|
```typescript
|
|
export const raw = (value: unknown, callbacks?: HtmlEscapedCallback[]): HtmlEscapedString => {
|
|
const escapedString = new String(value) as HtmlEscapedString
|
|
escapedString.isEscaped = true
|
|
escapedString.callbacks = callbacks
|
|
return escapedString
|
|
}
|
|
```
|
|
|
|
Creates an `HtmlEscapedString` that bypasses escaping (for pre-escaped content).
|
|
|
|
---
|
|
|
|
## 4. The `html` Tagged Template Literal
|
|
|
|
### Source: `src/helper/html/index.ts`
|
|
|
|
```typescript
|
|
export const html = (
|
|
strings: TemplateStringsArray,
|
|
...values: unknown[]
|
|
): HtmlEscapedString | Promise<HtmlEscapedString> => {
|
|
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
|
|
for (let i = 0, len = strings.length - 1; i < len; i++) {
|
|
buffer[0] += strings[i]
|
|
const children = Array.isArray(values[i])
|
|
? (values[i] as Array<unknown>).flat(Infinity)
|
|
: [values[i]]
|
|
for (let i = 0, len = children.length; i < len; i++) {
|
|
const child = children[i] as any
|
|
if (typeof child === 'string') {
|
|
escapeToBuffer(child, buffer)
|
|
} else if (typeof child === 'number') {
|
|
buffer[0] += child
|
|
} else if (typeof child === 'object' && (child as HtmlEscaped).isEscaped) {
|
|
// JSXNodes or HtmlEscapedStrings (already escaped)
|
|
if ((child as HtmlEscapedString).callbacks) {
|
|
buffer.unshift('', child)
|
|
} else {
|
|
const tmp = child.toString()
|
|
if (tmp instanceof Promise) {
|
|
buffer.unshift('', tmp)
|
|
} else {
|
|
buffer[0] += tmp
|
|
}
|
|
}
|
|
} else if (child instanceof Promise) {
|
|
buffer.unshift('', child)
|
|
} else {
|
|
escapeToBuffer(child.toString(), buffer)
|
|
}
|
|
}
|
|
}
|
|
buffer[0] += strings.at(-1)
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**Key insight**: The `html` tagged template can interoperate with JSXNodes. When you write:
|
|
|
|
```tsx
|
|
html`<div>${someJsxNode}</div>`
|
|
```
|
|
|
|
It detects the `isEscaped` property on JSXNodes and calls `.toString()` on them, producing the same HTML output. This means `html` templates and JSX are composable.
|
|
|
|
**Could this be an alternative for markdown?** Potentially — you could write:
|
|
|
|
```typescript
|
|
html`## ${title}\n\n${content}\n`
|
|
```
|
|
|
|
But this doesn't give you the component composition model. JSX is the way to get composable, reusable UI primitives.
|
|
|
|
---
|
|
|
|
## 5. Relevant Middleware Ecosystem
|
|
|
|
| Middleware | Path | Relevance |
|
|
|---|---|---|
|
|
| **jsx-renderer** | `src/middleware/jsx-renderer/` | Core SSR rendering pipeline |
|
|
| **compress** | `src/middleware/compress/` | Response compression (gzip/deflate) |
|
|
| **cache** | `src/middleware/cache/` | HTTP caching headers |
|
|
| **etag** | `src/middleware/etag/` | ETag generation for caching |
|
|
| **pretty-json** | `src/middleware/pretty-json/` | JSON formatting (pattern for custom formatting) |
|
|
| **context-storage** | `src/middleware/context-storage/` | Async context storage (could carry renderer config) |
|
|
| **streaming** | N/A (built into jsx-renderer) | Streaming via `renderToReadableStream` |
|
|
| **content-type** | N/A (auto via c.html, c.text, etc.) | Content-Type headers |
|
|
|
|
The most relevant pattern is how `jsxRenderer` hooks into the context via `c.setRenderer()` / `c.setLayout()` / `c.render()`. This is the pattern we'd emulate for a markdown renderer.
|
|
|
|
---
|
|
|
|
## 6. Feasibility: JSX → hast → mdast → Markdown Pipeline
|
|
|
|
### The Core Idea
|
|
|
|
Instead of the standard pipeline:
|
|
```
|
|
JSX → JSXNode tree → .toString() → HTML string
|
|
```
|
|
|
|
We want:
|
|
```
|
|
JSX → JSXNode tree → Custom walker → hast/mdast → markdown string
|
|
```
|
|
|
|
### Approach A: Intercept at `JSXNode.toStringToBuffer()` (MOST PROMISING)
|
|
|
|
**The `JSXNode` tree is fully materialized before serialization.** When you write `<div class="x"><p>Hello</p></div>`, the result is:
|
|
|
|
```javascript
|
|
JSXNode {
|
|
tag: 'div',
|
|
props: { class: 'x' },
|
|
children: [
|
|
JSXNode {
|
|
tag: 'p',
|
|
props: {},
|
|
children: ['Hello']
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
You can walk this tree without calling `.toString()` at all:
|
|
|
|
```typescript
|
|
function jsxNodeToMarkdown(node: Child): string {
|
|
if (node == null || typeof node === 'boolean') return ''
|
|
if (typeof node === 'string') return node
|
|
if (typeof node === 'number') return String(node)
|
|
if (Array.isArray(node)) return node.map(jsxNodeToMarkdown).join('')
|
|
|
|
// node is JSXNode
|
|
const jsxNode = node as JSXNode
|
|
|
|
// Function component — evaluate it first
|
|
if (typeof jsxNode.tag === 'function') {
|
|
// Call the component to get the rendered result
|
|
// NOTE: For async components, this gets more complex
|
|
const props = { ...jsxNode.props }
|
|
if (jsxNode.children.length) {
|
|
props.children = jsxNode.children.length === 1
|
|
? jsxNode.children[0]
|
|
: jsxNode.children
|
|
}
|
|
const result = (jsxNode.tag as Function)(props)
|
|
return jsxNodeToMarkdown(result)
|
|
}
|
|
|
|
// Intrinsic element — map to markdown
|
|
const tag = jsxNode.tag as string
|
|
const children = jsxNode.children.map(jsxNodeToMarkdown).join('')
|
|
|
|
switch (tag) {
|
|
case 'h1': return `# ${children}\n\n`
|
|
case 'h2': return `## ${children}\n\n`
|
|
case 'h3': return `### ${children}\n\n`
|
|
case 'p': return `${children}\n\n`
|
|
case 'strong': case 'b': return `**${children}**`
|
|
case 'em': case 'i': return `*${children}*`
|
|
case 'code': return `\`${children}\``
|
|
case 'pre': return `\`\`\`\n${children}\n\`\`\`\n`
|
|
case 'ul': return `${children}\n`
|
|
case 'ol': return `${children}\n`
|
|
case 'li': return `- ${children}\n`
|
|
case 'a': return `[${children}](${jsxNode.props.href || ''})`
|
|
case 'img': return ``
|
|
case 'br': return '\n'
|
|
case 'hr': return '---\n\n'
|
|
case 'div': case 'section': case 'article': case 'main': case 'span':
|
|
return children // non-semantic containers pass through
|
|
default:
|
|
return children // fallback: strip unknown tags
|
|
}
|
|
}
|
|
```
|
|
|
|
**Advantages:**
|
|
- Direct access to the structured tree (tag, props, children)
|
|
- No need to parse HTML back into an AST
|
|
- Component composition still works perfectly
|
|
- Hooks that don't depend on DOM (useState initial, useMemo, useContext) still work
|
|
- Can handle async components with `Promise.all`
|
|
|
|
**Challenges:**
|
|
- Async components return Promises — need async walker
|
|
- `JSXFunctionNode` vs `JSXNode` vs `JSXFragmentNode` need different handling
|
|
- Special intrinsic elements (title, script, etc.) are wrapped in `JSXFunctionNode`
|
|
- Context system works via side-effects (push/pop on the `context.values` array) during `toString()`/`toStringToBuffer()` — for our walker, we'd need to handle context providers differently
|
|
|
|
### Approach B: Intercept at `toStringToBuffer()` Level
|
|
|
|
Override or wrap `toStringToBuffer` to build an AST instead of an HTML string buffer:
|
|
|
|
```typescript
|
|
// Create a custom buffer type that builds a hast tree instead of strings
|
|
interface HastBuffer {
|
|
type: 'element'
|
|
tagName: string
|
|
properties: Record<string, unknown>
|
|
children: HastNode[]
|
|
// ...
|
|
}
|
|
```
|
|
|
|
This is more invasive but gives cleaner AST output. However, it requires restructuring the entire rendering pipeline since `toStringToBuffer` is deeply coupled to the string buffer protocol.
|
|
|
|
### Approach C: Render to HTML, then parse back via rehype/remark
|
|
|
|
```
|
|
JSX → JSXNode.toString() → HTML string → rehype-parse → hast → rehype-remark → mdast → remark-stringify → markdown
|
|
```
|
|
|
|
This is the simplest approach but involves:
|
|
1. Full HTML serialization (wasteful if we're going to convert away from HTML)
|
|
2. Dependency on the unified/rehype/remark ecosystem
|
|
3. Parsing overhead
|
|
4. Loss of semantic information that was in the JSXNode tree but not in the HTML (e.g., component boundaries, custom props)
|
|
|
|
### Recommended: Approach A (Direct JSXNode Walking)
|
|
|
|
The JSXNode tree IS an AST. It's not a full hast/html AST (no position info, slightly different structure), but it has tag names, attributes, and children — everything needed to produce markdown. The mapping is:
|
|
|
|
| JSXNode field | hast equivalent |
|
|
|---|---|
|
|
| `tag` | `tagName` |
|
|
| `props` | `properties` |
|
|
| `children` (string items) | `value` (text nodes) |
|
|
| `children` (JSXNode items) | `children` (element nodes) |
|
|
|
|
---
|
|
|
|
## 7. Custom Renderer Architecture: "LLM HUD Renderer"
|
|
|
|
### Proposed Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────┐
|
|
│ Hono Application │
|
|
│ │
|
|
│ app.get('/chat/:id', mdRenderer(), (c) => { │
|
|
│ return c.render(<ChatHUD messages={...} />) │
|
|
│ }) │
|
|
│ │
|
|
│ mdRenderer = jsxRenderer analog that sets up │
|
|
│ a markdown renderer instead of HTML renderer │
|
|
└──────────┬──────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────┐
|
|
│ mdRenderer Middleware │
|
|
│ │
|
|
│ 1. Sets c.setRenderer((jsxNode, props) => { │
|
|
│ const md = renderJSXToMarkdown(jsxNode) │
|
|
│ return c.text(md, 200, { │
|
|
│ 'Content-Type': 'text/markdown' │
|
|
│ }) │
|
|
│ }) │
|
|
│ 2. Provides a markdown layout wrapper │
|
|
│ 3. Sets content-type to text/markdown │
|
|
└──────────┬──────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────┐
|
|
│ renderJSXToMarkdown(jsxNode) │
|
|
│ │
|
|
│ 1. Walk the JSXNode tree recursively │
|
|
│ 2. For function components: call tag(props) │
|
|
│ to get rendered JSXNode, then recurse │
|
|
│ 3. For intrinsic elements: map tag→markdown │
|
|
│ 4. For text nodes: pass through (with escaping) │
|
|
│ 5. For async components: await and recurse │
|
|
│ │
|
|
│ Returns: markdown string │
|
|
└─────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Implementation Sketch
|
|
|
|
```typescript
|
|
// md-renderer.ts
|
|
|
|
import { jsxRenderer, useRequestContext } from 'hono/jsx-renderer'
|
|
import { createContext, useContext } from 'hono/jsx'
|
|
import type { Child, JSXNode, FC, PropsWithChildren } from 'hono/jsx'
|
|
import type { Context } from 'hono'
|
|
|
|
// ─── Markdown Rendering Engine ───────────────────────────────
|
|
|
|
interface MdRenderOptions {
|
|
/** Whether to include frontmatter in output */
|
|
frontmatter?: Record<string, string>
|
|
/** Custom tag-to-markdown mappings */
|
|
tagMap?: Record<string, (children: string, props: Record<string, unknown>) => string>
|
|
}
|
|
|
|
async function renderChildToMd(child: Child, options: MdRenderOptions): Promise<string> {
|
|
if (child == null || typeof child === 'boolean') return ''
|
|
if (typeof child === 'string') return child
|
|
if (typeof child === 'number') return String(child)
|
|
if (child instanceof Promise) {
|
|
const resolved = await child
|
|
return renderChildToMd(resolved, options)
|
|
}
|
|
if (Array.isArray(child)) {
|
|
const parts = await Promise.all(child.map(c => renderChildToMd(c, options)))
|
|
return parts.join('')
|
|
}
|
|
// It's a JSXNode-like object
|
|
return renderJSXNodeToMd(child as JSXNode, options)
|
|
}
|
|
|
|
async function renderJSXNodeToMd(node: JSXNode, options: MdRenderOptions): Promise<string> {
|
|
const tag = node.tag
|
|
|
|
// Handle function components
|
|
if (typeof tag === 'function') {
|
|
const props = { ...node.props }
|
|
if (node.children.length) {
|
|
props.children = node.children.length === 1
|
|
? node.children[0]
|
|
: node.children
|
|
}
|
|
const result = await tag(props)
|
|
return renderChildToMd(result, options)
|
|
}
|
|
|
|
// Handle fragment-like nodes (tag === '')
|
|
if (tag === '') {
|
|
const parts = await Promise.all(
|
|
node.children.map(c => renderChildToMd(c, options))
|
|
)
|
|
return parts.join('')
|
|
}
|
|
|
|
// Intrinsic element — recurse into children first
|
|
const childStrings = await Promise.all(
|
|
node.children.map(c => renderChildToMd(c, options))
|
|
)
|
|
const children = childStrings.join('')
|
|
|
|
// Check custom mappings first
|
|
if (options.tagMap?.[tag as string]) {
|
|
return options.tagMap[tag as string](children, node.props)
|
|
}
|
|
|
|
// Default HTML→Markdown mapping
|
|
switch (tag as string) {
|
|
// Headings
|
|
case 'h1': return `# ${children.trim()}\n\n`
|
|
case 'h2': return `## ${children.trim()}\n\n`
|
|
case 'h3': return `### ${children.trim()}\n\n`
|
|
case 'h4': return `#### ${children.trim()}\n\n`
|
|
case 'h5': return `##### ${children.trim()}\n\n`
|
|
case 'h6': return `###### ${children.trim()}\n\n`
|
|
|
|
// Block elements
|
|
case 'p': return `${children.trim()}\n\n`
|
|
case 'blockquote': return children.split('\n').map(l => `> ${l}`).join('\n') + '\n\n'
|
|
case 'pre': {
|
|
const lang = node.props['data-language'] || node.props.className?.toString().replace('language-', '') || ''
|
|
return `\`\`\`${lang}\n${children}\n\`\`\`\n\n`
|
|
}
|
|
case 'code': return `\`${children}\``
|
|
|
|
// Inline elements
|
|
case 'strong': case 'b': return `**${children}**`
|
|
case 'em': case 'i': return `*${children}*`
|
|
case 'del': case 's': return `~~${children}~~`
|
|
case 'a': return `[${children}](${node.props.href || ''})`
|
|
case 'img': return ``
|
|
|
|
// Lists
|
|
case 'ul': return `${children}\n`
|
|
case 'ol': return `${children}\n`
|
|
case 'li': return `- ${children.trim()}\n`
|
|
|
|
// Table elements
|
|
case 'table': return `${children}\n`
|
|
case 'thead': return children
|
|
case 'tbody': return children
|
|
case 'tr': return `| ${children} |\n`
|
|
case 'th': return children
|
|
case 'td': return children
|
|
|
|
// Utility
|
|
case 'br': return '\n'
|
|
case 'hr': return '---\n\n'
|
|
|
|
// Non-semantic containers (pass through)
|
|
case 'div': case 'section': case 'article':
|
|
case 'main': case 'span': case 'header':
|
|
case 'footer': case 'nav': case 'aside':
|
|
return children
|
|
|
|
// HTML boilerplate (pass through children)
|
|
case 'html': case 'body': case 'head':
|
|
return children
|
|
|
|
default:
|
|
return children // Unknown tags: strip and pass through
|
|
}
|
|
}
|
|
|
|
// ─── Middleware ─────────────────────────────────────────────
|
|
|
|
export const mdRenderer = <E extends Env>(
|
|
component?: FC<PropsWithChildren<{ Layout: FC }>>,
|
|
options?: MdRenderOptions | ((c: Context<E>) => MdRenderOptions)
|
|
) => {
|
|
return async (c: Context, next: Next) => {
|
|
const opts = typeof options === 'function' ? options(c) : options
|
|
|
|
c.setRenderer((jsxNode: JSXNode, props: Record<string, unknown>) => {
|
|
// Wrap in component if provided (layout support)
|
|
const tree = component
|
|
? jsx(component, { Layout: Fragment, ...props }, jsxNode as any)
|
|
: jsxNode
|
|
|
|
return renderJSXNodeToMd(tree as JSXNode, opts || {}).then(md => {
|
|
// Optionally wrap in frontmatter
|
|
if (opts?.frontmatter) {
|
|
const fm = '---\n' +
|
|
Object.entries(opts.frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n') +
|
|
'\n---\n\n'
|
|
md = fm + md
|
|
}
|
|
return c.text(md, 200, { 'Content-Type': 'text/markdown; charset=UTF-8' })
|
|
})
|
|
})
|
|
await next()
|
|
}
|
|
}
|
|
|
|
// ─── LLM Output Components ────────────────────────────────
|
|
|
|
// These are reusable JSX components for building LLM-facing markdown HUDs
|
|
|
|
export const CodeBlock: FC<{ language?: string; title?: string }> = ({
|
|
children, language, title
|
|
}) => (
|
|
<pre data-language={language} data-title={title}>{children}</pre>
|
|
)
|
|
|
|
export const Thinking: FC<{ effort?: string }> = ({ children, effort }) => (
|
|
<blockquote data-type="thinking" data-effort={effort}>{children}</blockquote>
|
|
)
|
|
|
|
export const ToolResult: FC<{ name: string; success: boolean }> = ({
|
|
children, name, success
|
|
}) => (
|
|
<section data-type="tool-result" data-tool={name} data-success={String(success)}>
|
|
{children}
|
|
</section>
|
|
)
|
|
|
|
export const StatusBadge: FC<{ status: string; label?: string }> = ({
|
|
status, label
|
|
}) => (
|
|
<span data-status={status}>{label || status}</span>
|
|
)
|
|
```
|
|
|
|
### Context Handling for Custom Renderers
|
|
|
|
The context system in Hono JSX works by pushing/popping values during toString(). For a markdown renderer, we need context support too. Two options:
|
|
|
|
**Option 1 — Manually handle context providers:**
|
|
|
|
```typescript
|
|
// When walking the JSXNode tree and encountering a Context.Provider,
|
|
// push the value onto the context stack before recursing into children,
|
|
// then pop it afterward.
|
|
async function renderChildToMd(child: Child, options: MdRenderOptions, contextStack: Map<Context<unknown>, unknown>): Promise<string> {
|
|
// ...
|
|
if (typeof tag === 'function' && tag === SomeContext.Provider) {
|
|
const prevValue = SomeContext.values.at(-1)
|
|
SomeContext.values.push(node.props.value)
|
|
try {
|
|
const result = await renderChildToMd(node.children, options, contextStack)
|
|
return result
|
|
} finally {
|
|
SomeContext.values.pop()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Option 2 — Leverage the existing `RequestContext` pattern:**
|
|
|
|
The `jsx-renderer` middleware already provides `useRequestContext()` via `RequestContext.Provider`. We can reuse this to make the Hono `Context` available in our markdown components:
|
|
|
|
```tsx
|
|
app.use('/api/*', mdRenderer())
|
|
|
|
app.get('/api/status', (c) => {
|
|
return c.render(<StatusDashboard />)
|
|
})
|
|
|
|
const StatusDashboard: FC = () => {
|
|
const c = useRequestContext()
|
|
const status = c.get('currentStatus')
|
|
return (
|
|
<div>
|
|
<h2>System Status</h2>
|
|
<p>Current status: {status}</p>
|
|
</div>
|
|
)
|
|
})
|
|
```
|
|
|
|
### Key Integration Points
|
|
|
|
1. **`c.setRenderer()`** — The primary hook. Set a custom renderer that walks JSXNodes instead of converting to HTML
|
|
2. **`c.setLayout()`** / **`c.getLayout()`** — For nested layout composition in markdown output
|
|
3. **`jsxRenderer` middleware** — Reference implementation showing how to set up the rendering pipeline
|
|
4. **`JSXNode` class** — The tree structure accessible before `.toString()` serialization
|
|
5. **`JSXFunctionNode` class** — The subclass for function components; calling `this.tag(props)` evaluates components
|
|
6. **`JSXFragmentNode` class** — The subclass for fragments; just concatenates children
|
|
7. **Hono `Context`** — Available via `RequestContext.Provider` for injecting request data into components
|
|
|
|
### Async Component Handling
|
|
|
|
Since Hono supports async components natively:
|
|
|
|
```tsx
|
|
const AsyncData: FC = async () => {
|
|
const data = await fetchData()
|
|
return <div>{data}</div>
|
|
}
|
|
```
|
|
|
|
Our markdown renderer must handle `Promise<JSXNode>` returns:
|
|
|
|
```typescript
|
|
async function renderJSXNodeToMd(node: JSXNode, options: MdRenderOptions): Promise<string> {
|
|
if (typeof tag === 'function') {
|
|
const result = await tag(props) // May return Promise
|
|
if (result instanceof Promise) {
|
|
const resolved = await result
|
|
return renderChildToMd(resolved, options)
|
|
}
|
|
return renderChildToMd(result, options)
|
|
}
|
|
// ...
|
|
}
|
|
```
|
|
|
|
The JSXNode tree may contain `Promise<string>` in children (from `HtmlEscapedString` with async callbacks). These need `await` resolution.
|
|
|
|
### What About Special Intrinsic Elements?
|
|
|
|
Hono wraps certain tags in function component wrappers:
|
|
|
|
- `<title>` → Calls `intrinsicElementTags.title` which handles document metadata
|
|
- `<script>` → Handles hoisting to `<head>`
|
|
- `<style>` → Handles precedence-based insertion
|
|
- `<link>` → Handles stylesheet deduplication
|
|
- `<meta>` → Handles metadata deduplication
|
|
- `<form>`, `<input>`, `<button>` → Handle action/PERMALINK
|
|
|
|
For markdown rendering, these should all pass through or be stripped. The markdown renderer would need to detect these function-tag wrappers and handle them appropriately:
|
|
|
|
```typescript
|
|
// For markdown, we don't need document metadata handling
|
|
// Just render the children of title/script/etc.
|
|
if (typeof tag === 'function' && tag === intrinsicElementTags.title) {
|
|
// Extract just the text content
|
|
const children = await Promise.all(node.children.map(c => renderChildToMd(c, options)))
|
|
return children.join('')
|
|
}
|
|
```
|
|
|
|
Actually, since `intrinsicElementTags.title` etc. return JSXNodes when called (they're component functions), our walker already handles this automatically — it calls the function component and recurses.
|
|
|
|
---
|
|
|
|
## 8. Data Flow Summary: JSX to Markdown
|
|
|
|
```
|
|
User Code Hono Internals Custom Renderer
|
|
───────── ────────────── ────────────────
|
|
|
|
<ChatHUD>
|
|
│
|
|
▼ jsx()
|
|
JSXNode {
|
|
tag: ChatHUD,
|
|
props: {...},
|
|
children: []
|
|
}
|
|
│
|
|
▼ ChatHUD(props)
|
|
evaluates to
|
|
JSXNode {
|
|
tag: 'div',
|
|
props: {class:'hud'},
|
|
children:
|
|
[JSXNode {tag:'h2',...},
|
|
JSXNode {tag:'p',...}]
|
|
}
|
|
│
|
|
│ ┌──────────────────────┼─────────────────────┐
|
|
│ │ Standard Path │ LLM HUD Path │
|
|
│ │ │ │
|
|
│ ▼ .toString() │ ▼ renderJSXNodeToMd()│
|
|
│ StringBuffer │ Recursive walk │
|
|
│ │ │ │ │
|
|
│ ▼ escapeToBuffer() │ ▼ tag→md mapping │
|
|
│ '<div class="hud">' │ '# ...\n\n...' │
|
|
│ <h2>Title</h2> │ │
|
|
│ <p>Content</p> │ │
|
|
│ </div> │ │
|
|
│ │ │ │ │
|
|
│ ▼ c.html(result) │ ▼ c.text(md, { │
|
|
│ │ │ 'Content-Type': │
|
|
│ ▼ Response │ 'text/markdown' │
|
|
│ Content-Type: │ }) │
|
|
│ text/html │ │
|
|
│ │ ▼ Response │
|
|
│ │ Content-Type: │
|
|
│ │ text/markdown │
|
|
└─────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Alternative: hast/mdast Pipeline (Full AST)
|
|
|
|
If we want proper hast→mdast conversion (rather than direct JSXNode→markdown), we could:
|
|
|
|
1. **Walk JSXNode tree → produce hast tree**
|
|
2. **Use `hast-util-to-mdast` to convert hast→mdast**
|
|
3. **Use `mdast-util-to-markdown` to produce markdown string**
|
|
|
|
This gives us all the remark/rehype ecosystem benefits (GFM tables, footnotes, etc.) but adds significant dependency weight.
|
|
|
|
**JSXNode → hast conversion:**
|
|
|
|
```typescript
|
|
import type { Element, Text, Root, ElementContent } from 'hast'
|
|
|
|
function jsxNodeToHast(node: Child): ElementContent[] | undefined {
|
|
if (node == null || typeof node === 'boolean') return undefined
|
|
if (typeof node === 'string') return [{ type: 'text', value: node }]
|
|
if (typeof node === 'number') return [{ type: 'text', value: String(node) }]
|
|
if (Array.isArray(node)) return node.flatMap(jsxNodeToHast).filter(Boolean) as ElementContent[]
|
|
|
|
const jsxNode = node as JSXNode
|
|
|
|
if (typeof jsxNode.tag === 'function') {
|
|
// Evaluate function component
|
|
const props = { ...jsxNode.props }
|
|
if (jsxNode.children.length) {
|
|
props.children = jsxNode.children.length === 1 ? jsxNode.children[0] : jsxNode.children
|
|
}
|
|
const result = jsxNode.tag(props)
|
|
return jsxNodeToHast(result) ?? undefined
|
|
}
|
|
|
|
if (jsxNode.tag === '') {
|
|
// Fragment
|
|
return jsxNode.children.flatMap(jsxNodeToHast).filter(Boolean) as ElementContent[]
|
|
}
|
|
|
|
// Intrinsic element → hast Element
|
|
const properties: Record<string, unknown> = {}
|
|
for (const [key, value] of Object.entries(jsxNode.props)) {
|
|
if (key === 'children') continue
|
|
if (key === 'className') properties.className = value
|
|
else if (key === 'htmlFor') properties.htmlFor = value
|
|
else properties[key] = value
|
|
}
|
|
|
|
const children = jsxNode.children.flatMap(jsxNodeToHast).filter(Boolean) as ElementContent[]
|
|
|
|
return [{
|
|
type: 'element',
|
|
tagName: jsxNode.tag as string,
|
|
properties,
|
|
children
|
|
}]
|
|
}
|
|
```
|
|
|
|
**Then:**
|
|
|
|
```typescript
|
|
import { toMdast } from 'hast-util-to-mdast'
|
|
import { toMarkdown } from 'mdast-util-to-markdown'
|
|
|
|
function renderToMarkdown(jsxNode: JSXNode): string {
|
|
const hast = jsxNodeToHast(jsxNode)
|
|
const mdast = toMdast({ type: 'root', children: hast })
|
|
return toMarkdown(mdast)
|
|
}
|
|
```
|
|
|
|
**Trade-offs:**
|
|
|
|
| Approach | Pros | Cons |
|
|
|---|---|---|
|
|
| Direct JSXNode→MD | Zero dependencies, fast, simple | Must implement all tag mappings manually |
|
|
| JSXNode→hast→mdast→MD | Full GFM support, extensible, standards-based | Heavy dependency tree (~50 packages from unified ecosystem) |
|
|
| JSXNode→HTML→rehype→MD | Simplest to implement, no JSX walking | Wasteful roundtrip, loses semantic info |
|
|
|
|
**Recommendation**: Start with direct JSXNode→MD for simplicity and zero dependencies. Add hast/mdast pipeline later if GFM tables, footnotes, or other advanced markdown features are needed.
|
|
|
|
---
|
|
|
|
## 10. Key Files Reference
|
|
|
|
| File | Purpose |
|
|
|---|---|
|
|
| `/workspace/hono/src/jsx/base.ts` | Core `JSXNode`, `JSXFunctionNode`, `JSXFragmentNode`, `jsx()`, `jsxFn()`, `Fragment` |
|
|
| `/workspace/hono/src/jsx/jsx-dev-runtime.ts` | `jsxDEV()` — JSX transform entry point |
|
|
| `/workspace/hono/src/jsx/streaming.ts` | `Suspense`, `renderToReadableStream()` |
|
|
| `/workspace/hono/src/jsx/context.ts` | `createContext()`, `useContext()` |
|
|
| `/workspace/hono/src/jsx/components.ts` | `ErrorBoundary`, `childrenToString()` |
|
|
| `/workspace/hono/src/jsx/children.ts` | `Children` utility (map, forEach, count, only, toArray) |
|
|
| `/workspace/hono/src/jsx/intrinsic-element/components.ts` | Special element handlers (title, script, style, link, meta, form) |
|
|
| `/workspace/hono/src/jsx/intrinsic-element/common.ts` | `domRenderers` map, deduplication helpers |
|
|
| `/workspace/hono/src/jsx/types.ts` | Type exports (`Child`, `JSXNode`, `FC`, etc.) |
|
|
| `/workspace/hono/src/utils/html.ts` | `HtmlEscapedString`, `raw()`, `escapeToBuffer()`, `stringBufferToString()`, `resolveCallback()` |
|
|
| `/workspace/hono/src/helper/html/index.ts` | `html` tagged template literal |
|
|
| `/workspace/hono/src/jsx/dom/server.ts` | `renderToString()`, `renderToReadableStream()` (React-compatible API) |
|
|
| `/workspace/hono/src/jsx/dom/render.ts` | DOM renderer (client-side hydration) |
|
|
| `/workspace/hono/src/jsx/hooks/index.ts` | All hooks (useState, useEffect, etc.) — DOM-oriented |
|
|
| `/workspace/hono/src/middleware/jsx-renderer/index.ts` | `jsxRenderer` middleware, `useRequestContext()`, `RequestContext` |
|
|
| `/workspace/hono/src/context.ts` | `Context` class with `setRenderer()`, `setLayout()`, `getLayout()`, `render()` |
|
|
|
|
---
|
|
|
|
## 11. Conclusion & Architecture Recommendation
|
|
|
|
### The Core Insight
|
|
|
|
**Hono's JSXNode tree is an accessible AST that exists before HTML serialization.** The `.toString()` / `.toStringToBuffer()` methods are the serialization layer. By walking the tree before calling these methods, we can produce any output format — including markdown.
|
|
|
|
### Recommended Architecture
|
|
|
|
1. **Create `mdRenderer` middleware** analogous to `jsxRenderer` that:
|
|
- Calls `c.setRenderer()` with a custom renderer function
|
|
- The custom renderer walks the JSXNode tree using `renderJSXNodeToMd()`
|
|
- Returns `c.text(markdown, 200, {'Content-Type': 'text/markdown; charset=UTF-8'})`
|
|
- Supports layout composition via `c.setLayout()` / `c.getLayout()`
|
|
|
|
2. **Create `renderJSXNodeToMd()` walker** that:
|
|
- Recursively walks `JSXNode` trees
|
|
- Calls function components to evaluate them (with async support)
|
|
- Maps HTML tags to markdown syntax
|
|
- Handles fragments by concatenating children
|
|
- Preserves semantic meaning from component structure
|
|
|
|
3. **Build an LLM HUD component library** using standard Hono JSX:
|
|
- `Thinking` → renders to `<blockquote>` → converted to `> ...` in markdown
|
|
- `CodeBlock` → renders to `<pre data-language="...">` → converted to fenced code blocks
|
|
- `ToolResult` → renders to `<section>` → converted to structured markdown sections
|
|
- `StatusBadge` → renders to `<span data-status="...">` → converted to emoji/status text
|
|
|
|
4. **Content negotiation** middleware that detects `Accept: text/markdown` and routes to the markdown renderer:
|
|
|
|
```typescript
|
|
app.get('/api/status', async (c) => {
|
|
const acceptsMarkdown = c.req.header('Accept')?.includes('text/markdown')
|
|
if (acceptsMarkdown) {
|
|
return mdRender(<StatusDashboard data={...} />)
|
|
}
|
|
return c.json({ status: 'ok' })
|
|
})
|
|
```
|
|
|
|
5. **Future: hast/mdast pipeline** for full GFM support, if the direct-walker approach proves insufficient for complex table/footnote rendering.
|
|
|
|
### Risk Assessment
|
|
|
|
| Risk | Severity | Mitigation |
|
|
|---|---|---|
|
|
| Async component handling | Medium | Must `await` component results; walker must be async throughout |
|
|
| Context providers need push/pop | Low | Can handle manually in walker or skip RequestContext for markdown |
|
|
| Special intrinsic elements (title, form) | Low | They evaluate to JSXNodes; walker recurses naturally |
|
|
| Loss of semantic info in tag→md mapping | Medium | Use `data-*` attributes for custom markdown semantics |
|
|
| `HtmlEscapedString` protocol | Medium | Must detect `isEscaped` flag and handle `Promise` children |
|
|
|
|
The approach is highly feasible. Hono's architecture — with `JSXNode` as a first-class tree structure and `c.setRenderer()` as the output hook — makes this a natural extension rather than a hack.
|