Files
ujsx/docs/research/hono-jsx-ssr-llm-hud.md

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 `![${jsxNode.props.alt || ''}](${jsxNode.props.src || ''})`
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 `![${node.props.alt || ''}](${node.props.src || ''})`
// 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.