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

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:

  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

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:

  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:

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/ — 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

// 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:

  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)

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 `![${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:

// 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)

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 `![${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:

// 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

  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:

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> → 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:

// 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:

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.

  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:

    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.