# 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 = ( component?: ComponentWithChildren, options?: RendererOptions | ((c: Context) => 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(, { 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: '', false omits it stream?: boolean | Record // 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 }) => ( {title}{children} ))) // Inner layout app2.use('*', jsxRenderer(({ children, Layout, title }) => (
{children}
))) ``` The `Layout` component resolves to the parent's renderer function, enabling composition. ### Renderer Data Flow ``` c.render(, { 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 | 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., ``, `<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.