40 KiB
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:
export const jsxRenderer = <E extends Env = Env>(
component?: ComponentWithChildren,
options?: RendererOptions | ((c: Context<E>) => RendererOptions)
): MiddlewareHandler
Key behavior:
- Sets a renderer on the context via
c.setRenderer(createRenderer(c, Layout, component, options)) - Sets a layout on the context via
c.setLayout((props) => component({ ...props, Layout }, c)) - After
next(), routes can callc.render(<Component />, { title: 'foo' })which invokes the stored renderer - The renderer wraps content in a
RequestContext.Providerso components can access the Hono request/context viauseRequestContext()
API Options
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:
// 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:
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):
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:
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:
- If
tagis a function →new JSXFunctionNode(tag, props, children) - If
tagis an intrinsic element with special behavior (e.g.,<title>,<script>,<meta>,<link>,<style>,<form>,<input>,<button>) →new JSXFunctionNode(intrinsicElementComponent, props, children) - If
tagis'svg'or'head'→ creates a namespace context wrapper - Otherwise →
new JSXNode(tag, props, children)(plain HTML element)
Key Insight: JSXNodes are Accessible Before Serialization
When you write:
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:
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/— onlyuseState,useReducer,useCallback,useMemo,useId,useRef, anduseSyncExternalStorehave 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
// 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)
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:
- Opens the tag:
buffer[0] += '<${tag}' - Serializes attributes (with escaping via
escapeToBuffer) - Handles special props:
style(object→string),dangerouslySetInnerHTML, boolean attributes - Handles void elements (
<br/>,<img/>, etc.) - Renders children recursively via
childrenToStringToBuffer(children, buffer) - Closes the tag:
buffer[0] += '</${tag}>'
JSXFunctionNode.toStringToBuffer() for function components (base.ts:248-289)
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)
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)
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
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
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:
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:
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:
JSXNode {
tag: 'div',
props: { class: 'x' },
children: [
JSXNode {
tag: 'p',
props: {},
children: ['Hello']
}
]
}
You can walk this tree without calling .toString() at all:
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
JSXFunctionNodevsJSXNodevsJSXFragmentNodeneed different handling- Special intrinsic elements (title, script, etc.) are wrapped in
JSXFunctionNode - Context system works via side-effects (push/pop on the
context.valuesarray) duringtoString()/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:
// 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:
- Full HTML serialization (wasteful if we're going to convert away from HTML)
- Dependency on the unified/rehype/remark ecosystem
- Parsing overhead
- 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
// 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:
// 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:
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
c.setRenderer()— The primary hook. Set a custom renderer that walks JSXNodes instead of converting to HTMLc.setLayout()/c.getLayout()— For nested layout composition in markdown outputjsxRenderermiddleware — Reference implementation showing how to set up the rendering pipelineJSXNodeclass — The tree structure accessible before.toString()serializationJSXFunctionNodeclass — The subclass for function components; callingthis.tag(props)evaluates componentsJSXFragmentNodeclass — The subclass for fragments; just concatenates children- Hono
Context— Available viaRequestContext.Providerfor injecting request data into components
Async Component Handling
Since Hono supports async components natively:
const AsyncData: FC = async () => {
const data = await fetchData()
return <div>{data}</div>
}
Our markdown renderer must handle Promise<JSXNode> returns:
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>→ CallsintrinsicElementTags.titlewhich 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:
// 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:
- Walk JSXNode tree → produce hast tree
- Use
hast-util-to-mdastto convert hast→mdast - Use
mdast-util-to-markdownto produce markdown string
This gives us all the remark/rehype ecosystem benefits (GFM tables, footnotes, etc.) but adds significant dependency weight.
JSXNode → hast conversion:
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:
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
-
Create
mdRenderermiddleware analogous tojsxRendererthat:- 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()
- Calls
-
Create
renderJSXNodeToMd()walker that:- Recursively walks
JSXNodetrees - 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
- Recursively walks
-
Build an LLM HUD component library using standard Hono JSX:
Thinking→ renders to<blockquote>→ converted to> ...in markdownCodeBlock→ renders to<pre data-language="...">→ converted to fenced code blocksToolResult→ renders to<section>→ converted to structured markdown sectionsStatusBadge→ renders to<span data-status="...">→ converted to emoji/status text
-
Content negotiation middleware that detects
Accept: text/markdownand routes to the markdown renderer: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' }) }) -
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.