From b256fc7eb5dcebf460c9cf7837795ffe8b169838 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Wed, 29 Apr 2026 15:11:46 +0000 Subject: [PATCH] import research docs from prior conversation and scattered sources --- docs/research/README.md | 52 + docs/research/agent-hud-architecture.md | 675 +++++++++ docs/research/hono-jsx-ssr-llm-hud.md | 1077 +++++++++++++++ docs/research/prior-poc-source-reference.md | 119 ++ .../signals-ujsx-reactive-pipeline.md | 372 +++++ docs/research/typebox-module-type-registry.md | 113 ++ docs/research/typebox-module-valuepointer.md | 101 ++ docs/research/ujsx-v2-typebox-rewrite.md | 554 ++++++++ .../unist-ecosystem-jsx-to-markdown.md | 1211 +++++++++++++++++ 9 files changed, 4274 insertions(+) create mode 100644 docs/research/README.md create mode 100644 docs/research/agent-hud-architecture.md create mode 100644 docs/research/hono-jsx-ssr-llm-hud.md create mode 100644 docs/research/prior-poc-source-reference.md create mode 100644 docs/research/signals-ujsx-reactive-pipeline.md create mode 100644 docs/research/typebox-module-type-registry.md create mode 100644 docs/research/typebox-module-valuepointer.md create mode 100644 docs/research/ujsx-v2-typebox-rewrite.md create mode 100644 docs/research/unist-ecosystem-jsx-to-markdown.md diff --git a/docs/research/README.md b/docs/research/README.md new file mode 100644 index 0000000..34b2348 --- /dev/null +++ b/docs/research/README.md @@ -0,0 +1,52 @@ +# UJSX Research Index + +All research documents for the `@alkdev/ujsx` rewrite. This project is a universal JSX IR that treats JSX as an intermediate representation for multi-target rendering (markdown primary, graph/HTML/etc. future). + +## Architecture & Design + +| Doc | Description | +|-----|-------------| +| [agent-hud-architecture.md](./agent-hud-architecture.md) | Full HUD architecture: event log, adaptive density, cache-aware system prompt, plugin integration | +| [ujsx-v2-typebox-rewrite.md](./ujsx-v2-typebox-rewrite.md) | The rewrite plan: TypeBox-schema-driven UJSX, bi-directional transforms, JSX syntax, HTML-agnostic core | +| [signals-ujsx-reactive-pipeline.md](./signals-ujsx-reactive-pipeline.md) | Preact Signals integration: `computed()` components, reactive transform pipeline, signals+TypeBox interaction | + +## Ecosystem & Feasibility + +| Doc | Description | +|-----|-------------| +| [unist-ecosystem-jsx-to-markdown.md](./unist-ecosystem-jsx-to-markdown.md) | hast/mdast/syntax-tree ecosystem, pipeline feasibility, element mappings, alternative approaches | +| [hono-jsx-ssr-llm-hud.md](./hono-jsx-ssr-llm-hud.md) | Hono JSXNode internals, SSR pipeline, custom walker feasibility for markdown rendering | + +## TypeBox Patterns + +| Doc | Description | +|-----|-------------| +| [typebox-module-type-registry.md](./typebox-module-type-registry.md) | `Type.Module` as type registry, `Import` mechanics, runtime schema access, implications for UJSX | +| [typebox-module-valuepointer.md](./typebox-module-valuepointer.md) | `TModule` + `ValuePointer` API, `ComputeModuleProperties`, Function types, practical patterns | + +## Prior Art & Source Reference + +| Doc | Description | +|-----|-------------| +| [prior-poc-source-reference.md](./prior-poc-source-reference.md) | Code from `/workspace/aui/ujsx` POC: what to preserve, what to remove/rewrite, TypeBox research examples | + +## External Resources + +| Location | Description | +|----------|-------------| +| `/workspace/aui/ujsx/` | Prior POC: core types, h() factory, HostConfig, Graphology host, TransformRegistry, StreamingTransformer | +| `/workspace/aui/SUMMARY.md` | POC summary with architecture, strengths, gaps, recommendations | +| `/workspace/research/typebox_research/ujsx/` | TypeBox Module examples: `unist.ts` (unist schema), `ujsx.ts` (UJSX schema draft) | +| `/workspace/@alkdev/typebox/` | TypeBox fork source: Module, ValuePointer, Value system | +| `/workspace/signals/packages/core/` | Preact Signals core: signal, computed, effect, batch, createModel | +| `/workspace/conversations/research/` | Original research docs from conversation session | + +## Key Decisions from Prior Discussion + +1. **TypeBox Module IS the type registry** — no separate registry needed. `ValuePointer.Get/Set` for runtime access, `Module.Import` for resolved schemas. +2. **Signals for reactivity** — `computed()` wrapping component functions so unchanged inputs skip re-evaluation. NOT in the registry, but in the transformation process. +3. **Bi-directional transforms** — same registry, `direction` parameter. UJSX↔mdast, UJSX↔hast. Rules match on TypeBox schemas, not string tags. +4. **HTML-agnostic core** — no onClick, className, aria-* in UniversalProps. Hosts handle platform specifics. +5. **Actual JSX syntax** — `jsxImportSource: "@alkdev/ujsx"` with jsx-runtime exports. +6. **HostConfig preserved** — same reconciler pattern from POC, but mount-only → full reconciliation. +7. **Platform agnostic** — no Deno/Node-specific APIs in core. Published as `@alkdev/ujsx`. \ No newline at end of file diff --git a/docs/research/agent-hud-architecture.md b/docs/research/agent-hud-architecture.md new file mode 100644 index 0000000..5ddea32 --- /dev/null +++ b/docs/research/agent-hud-architecture.md @@ -0,0 +1,675 @@ +# Agent HUD: A Context Management Architecture for LLM Agents + +## Research → Design Synthesis + +This document synthesizes findings from six research areas into a concrete architecture for an agent HUD system that replaces ad-hoc compaction with structured, cache-aware context management. + +**See also**: [`ujsx-v2-typebox-rewrite.md`](./ujsx-v2-typebox-rewrite.md) — The UJSX v2 rewrite plan, replacing the existing POC at `/workspace/aui` with a TypeBox-schema-driven universal JSX IR that supports actual JSX syntax, bi-directional transforms, and component schemas as tool schemas. + +--- + +## The Problem + +Current LLM agent interfaces have a fundamental flaw: **context is managed as a monolithic conversation log that grows until it must be violently compacted**. This creates several pathologies: + +1. **Information cliff**: Compaction replaces rich conversation with a lossy summary. Everything before the compaction boundary is gone — the agent can't even know what it lost. + +2. **Reactive, not proactive**: OpenCode's compaction fires at ~92% context usage. There's no budgeting — system prompt, tool definitions, and conversation history compete for the same fixed space with no allocation strategy. + +3. **Cognitive waste**: The agent sees the full conversation log every turn, including messages that are no longer relevant. Early messages about setup, resolved errors, and abandoned approaches consume tokens without providing value. + +4. **Cognitive strain**: The agent has no ambient awareness of its own state. It must explicitly call tools to check context usage, search history, or understand where it is in a task. Each tool call costs a turn and consumes context. + +5. **No provider-cache alignment**: OpenCode's 2-part system prompt split (`llm.ts:115-126`) is the only cache-aware structure. The rest of the context — messages, tool results, the full conversation — has no cache segmentation. + +The key insight: **the "agent frame" — what the LLM sees in a single turn — should be treated as a composition problem, not an append-only log problem.** + +--- + +## The Leverage Point + +OpenCode's `llm.ts:115-126` reveals the critical pattern: + +```typescript +// rejoin to maintain 2-part structure for caching if header unchanged +if (system.length > 2 && system[0] === header) { + const rest = system.slice(1) + system.length = 0 + system.push(header, rest.join("\n")) +} +``` + +This splits the system prompt into: +- **Part 0** (cached, stable): Agent prompt, provider prompt — changes rarely, benefits from prompt caching +- **Part 1** (dynamic, re-cached each call): Environment, skills, instructions — changes often + +The plugin system pushes to `output.system[]` which gets merged into Part 1. The open-memory plugin injects context status (~50 tokens) into this dynamic part. + +**This 2-part pattern generalizes.** We can extend it to a multi-part context composition with different cache characteristics: + +| Part | Content | Changes | Cache Value | +|------|---------|---------|-------------| +| 0 | Agent identity, core instructions | Rarely | Highest (static across turns) | +| 1 | HUD static layer (task, key decisions) | On agent action | High (stable within a task phase) | +| 2 | HUD dynamic layer (notes, recent events) | Every turn | Medium (re-cached each call) | +| 3 | Conversation messages | Every turn | Low (grows monotonically) | + +--- + +## Architecture: The Agent HUD + +### Core Concept + +The HUD is a **structured markdown document** that the agent sees at the top of every turn, injected via the `experimental.chat.system.transform` plugin hook. Unlike compaction (which discards information), the HUD is: +- **Composed**: Built from components, not a flat log +- **Bidirectional**: The agent can update it via tools +- **Cache-aware**: Structured so static parts benefit from provider caching +- **Adaptive**: Automatically adjusts density based on context pressure + +### The "Agent Frame" Model + +Instead of treating the conversation as an append-only log, think of each agent turn as a **frame** composed of: + +``` +┌─────────────────────────────────────┐ +│ System Prompt Part 0 (cached) │ ← Agent identity, core instructions +│ [never changes within a session] │ +├─────────────────────────────────────┤ +│ System Prompt Part 1 (re-cached) │ ← HUD rendered to markdown +│ ┌─────────────────────────────────┐ │ +│ │ # Task │ │ ← Static HUD (changes on action) +│ │ Implement auth module │ │ +│ │ │ │ +│ │ ## Context │ │ ← Adaptive density +│ │ 67% used (134k/200k tokens) │ │ ← Context status +│ │ Trend: growing rapidly │ │ +│ │ │ │ +│ │ ## Key Decisions │ │ ← Agent-maintained state +│ │ • Using JWT over sessions │ │ +│ │ • bcrypt for password hashing │ │ +│ │ │ │ +│ │ ## Active Files │ │ ← Derived from tool calls +│ │ • src/auth/mod.ts (editing) │ │ +│ │ • src/auth/jwt.ts (referenced) │ │ +│ │ │ │ +│ │ ## Next Steps │ │ ← Agent's own plan +│ │ 1. Add refresh token rotation │ │ +│ │ 2. Write auth middleware │ │ +│ │ 3. Add tests │ │ +│ │ │ │ +│ │ ## Notes │ │ ← Agent's scratchpad +│ │ • DB schema: users, sessions │ │ +│ │ • Rate limit: 100/min default │ │ +│ └─────────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ Conversation Messages │ ← Standard message history +│ [filtered by compaction boundary] │ +└─────────────────────────────────────┘ +``` + +### The Append-Only Event Log + +Underlying the HUD is an **append-only event log** — similar to Yjs/CRDT event streams but simpler because we're single-author (one agent per session). This is the "source of truth" that the HUD renders from. + +```typescript +interface HudEvent { + id: string // UUID + type: HudEventType // discriminated union + timestamp: number // Unix ms + sessionId: string // opencode session ID + turn: number // which agent turn + payload: unknown // type-specific data +} + +type HudEventType = + | "task.set" // Agent sets the current task description + | "task.update" // Agent refines the task + | "decision.record" // Agent records a key decision + | "note.add" // Agent adds a note + | "note.update" // Agent updates a note + | "note.remove" // Agent removes a note + | "file.open" // Agent reads a file (derived from tool calls) + | "file.edit" // Agent edits a file (derived from tool calls) + | "file.close" // File falls out of recent context + | "step.complete" // Agent marks a step complete + | "step.add" // Agent adds a next step + | "step.reorder" // Agent reorders steps + | "error.encountered" // Agent encounters an error + | "error.resolved" // Agent resolves an error + | "blocker.add" // Agent identifies a blocker + | "blocker.remove" // Agent removes a blocker + | "context.snapshot" // Context window usage snapshot (auto-generated) + | "compact.before" // Pre-compaction state snapshot + | "compact.after" // Post-compaction state + summary +``` + +The event log is persisted and append-only. It replaces the "conversation log as only state" model with "conversation log as one input to the HUD, alongside structured state." + +### Rendering Pipeline: UJSX → MDAST → Markdown + +The HUD uses the **UJSX v2** universal JSX IR (see [`ujsx-v2-typebox-rewrite.md`](./ujsx-v2-typebox-rewrite.md)) built on TypeBox schemas. The existing POC at `/workspace/aui` already implements UJSX → mdast → markdown with a `TransformRegistry` and `mdast-util-to-markdown`. The v2 rewrite adds: + +- Actual JSX syntax (via `jsxImportSource: "@ade/ujsx"`) +- TypeBox schema-driven node types (schemas ARE types, schemas ARE tool parameter schemas) +- Bi-directional transforms (UJSX ↔ mdast, UJSX ↔ hast, etc.) +- HTML-agnostic core (no `onClick`, `className` in universal props) + +``` +HUD Components (.tsx with JSX syntax) + │ + ▼ +h() factory / JSX transform → UElement tree (TypeBox-schema-validated) + │ + ▼ TransformRegistry (direction: 'ujsx->mdast') + │ Rules match on TypeBox schemas, not string tags + │ +mdast tree + │ + ▼ mdast-util-to-markdown + mdast-util-gfm + │ +Markdown String + │ + ▼ Injected via experimental.chat.system.transform + │ +System Prompt Part 1 +``` + +**Why UJSX (not Hono JSXNode, not hast→mdast)?** + +1. **Schema-driven**: TypeBox schemas serve triple duty — TypeScript types, runtime validation, and tool parameter schemas. Component props = tool input schemas. Zero duplication. +2. **Bi-directional**: Rules convert both UJSX→mdast AND mdast→UJSX. Parse existing markdown (notes, AGENTS.md) back into the IR. +3. **HTML-agnostic**: No `onClick`, `className`, `aria-*` in the core. The IR isn't pretending to be HTML. +4. **HostConfig preserved**: The same component tree renders to graphology, markdown, or future targets. +5. **Actual JSX syntax**: With `jsxImportSource`, write `` not `h('TaskSection', { task: state.task, density: 'compact' })`. + +### HUD Component Architecture + +```tsx +// The root HUD component +function HUD({ state, contextInfo }: { state: HudState, contextInfo: ContextInfo }) { + const density = getDensity(contextInfo.percentage) + + return ( + + + + {density !== 'minimal' && } + {density !== 'minimal' && } + + {density === 'full' && } + {contextInfo.percentage > 85 && } + + ) +} + +// Adaptive density: renders differently based on context pressure +type Density = 'full' | 'compact' | 'minimal' + +function getDensity(percentage: number): Density { + if (percentage < 70) return 'full' + if (percentage < 85) return 'compact' + return 'minimal' +} + +// Example adaptive component +function DecisionsList({ decisions, density }: { decisions: Decision[], density: Density }) { + if (density === 'compact') { + return {`## Decisions (${decisions.length})\n` + decisions.map(d => `- ${d.summary}`).join('\n')} + } + return ( +
+ {decisions.map(d => )} +
+ ) +} +``` + +### Cache-Aware System Prompt Structure + +The key extension of OpenCode's 2-part pattern: + +``` +System Message 0 (cached, stable): + - Agent identity prompt + - Provider-specific prompt + — Never changes within a session + +System Message 1 (re-cached each call): + - HUD static layer (task, decisions, next steps) + - HUD dynamic layer (context bar, notes, active files) + - Environment info, skills, instructions + — Changes based on agent actions and context pressure +``` + +The **static layer** of the HUD should change only when the agent explicitly updates it (task change, decision recorded, step completed). The **dynamic layer** changes every turn (context percentage, recent files, notes). + +For Anthropic's `cache_control`, we mark: +- System message 0 → `cacheControl: { type: "ephemeral" }` (already done by opencode) +- System message 1 → `cacheControl: { type: "ephemeral" }` (already done by opencode) + +The savings come from keeping message 0 stable (0.1x cost on cache hits) and making message 1 as small as possible while still providing full situational awareness. + +### HUD State and Event Log Storage + +The event log uses the same SQLite database opencode already has, with a new table: + +```sql +CREATE TABLE hud_event ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES session(id), + type TEXT NOT NULL, -- HudEventType + turn INTEGER NOT NULL, -- agent turn number + timestamp INTEGER NOT NULL, -- Unix ms + payload TEXT NOT NULL, -- JSON + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX idx_hud_event_session ON hud_event(session_id, timestamp); +CREATE INDEX idx_hud_event_type ON hud_event(session_id, type); +``` + +The **HUD state** is derived from the event log by projectors (similar to opencode's existing event sourcing pattern): + +```typescript +interface HudState { + task: { description: string; updatedAt: number } | null + decisions: Array<{ id: string; summary: string; details: string; recordedAt: number }> + notes: Array<{ id: string; content: string; updatedAt: number }> + activeFiles: Array<{ path: string; lastAccessed: number; status: 'reading' | 'editing' | 'referenced' }> + nextSteps: Array<{ id: string; description: string; completed: boolean; order: number }> + blockers: Array<{ id: string; description: string; resolved: boolean }> + errors: Array<{ id: string; message: string; resolved: boolean; resolvedAt?: number }> +} +``` + +Projectors are pure functions: `(state, event) => newState`. They're deterministic and idempotent — the state can always be rebuilt from the event log. + +Some events are **derived** rather than agent-authored: +- `file.open`, `file.edit`, `file.close`: Intercepted from opencode's `tool.execute.before/after` hook by watching for Read, Write, Edit tools +- `context.snapshot`: Auto-generated from `SessionProcessor` events tracking token usage + +### HUD Tools (Agent-Facing) + +The agent interacts with the HUD through two tools (following the router pattern from open-memory): + +**`hud`** (router tool — reduces context bloat from tool definitions): + +```typescript +hud(input: { + tool: string, // operation name + args?: object // operation arguments +}) +``` + +Operations: +- `task.get` / `task.set` / `task.update` — Manage current task +- `decisions.list` / `decisions.record` / `decisions.remove` — Key decisions log +- `notes.list` / `notes.add` / `notes.update` / `notes.remove` — Scratchpad +- `steps.list` / `steps.add` / `steps.complete` / `steps.reorder` — Next steps / plan +- `blockers.list` / `blockers.add` / `blockers.remove` — Blockers +- `snapshot` — Full HUD state +- `history` — Recent event log (for understanding what changed) + +**`hud_compact`** (mutation tool — separate to prevent accidental use): + +```typescript +hud_compact() // Triggers both HUD compaction AND opencode compaction +``` + +This is distinct from `memory_compact` because: +1. It snapshots HUD state to the event log before compaction +2. After compaction, the stable HUD layer carries forward — the agent doesn't lose its task, decisions, or notes +3. It can trigger compaction at a natural breakpoint (when the agent says "next steps updated, good time to compact") + +### Plugin Integration (opencode) + +The HUD is implemented as an opencode plugin, using these hooks: + +```typescript +const HUDPlugin: Plugin = async (ctx) => { + const stateManager = new HudStateManager(ctx) // Manages event log + projectors + const renderer = new HudRenderer() // JSX component → markdown + + return { + tool: { hud: createHudTool(ctx, stateManager), hud_compact: createHudCompactTool(ctx, stateManager) }, + + "experimental.chat.system.transform": async (input, output) => { + // Render HUD and inject as system prompt + const state = stateManager.getState(input.sessionID) + const contextInfo = getContextInfo(input.sessionID) + const markdown = renderer.render(HUD, { state, contextInfo }) + output.system.push(markdown) + }, + + "experimental.session.compacting": async (input, output) => { + // Before compaction: snapshot HUD state to event log + stateManager.recordEvent(input.sessionID, { type: "compact.before", payload: stateManager.getState(input.sessionID) }) + // Replace compaction prompt with self-continuity + HUD-aware prompt + output.prompt = getCompactionPrompt(stateManager.getState(input.sessionID)) + }, + + "event": async ({ event }) => { + // Derive file events from tool calls + if (event.type === "tool.execute.after") { + stateManager.maybeRecordFileEvent(event) + } + // Track context snapshots + if (event.type === "message.updated") { + stateManager.maybeRecordContextSnapshot(event) + } + }, + + "tool.execute.before": async (input, output) => { + // Track file access from tool calls + stateManager.trackToolCall(input) + }, + } +} +``` + +### Rendering Pipeline Implementation + +```typescript +// core/renderer.ts + +import type { FC } from "hono/jsx" + +interface RenderContext { + density: Density + state: HudState + contextInfo: ContextInfo +} + +class HudRenderer { + // Custom walker over Hono JSXNode tree + render(component: FC, props: Record): string { + const node = component(props) + return this.walkNode(node) + } + + private walkNode(node: any): string { + if (typeof node === "string" || typeof node === "number") { + return String(node) + } + if (node === null || node === undefined || node === false) { + return "" + } + if (node instanceof Promise) { + throw new Error("Async components not supported in HUD rendering") + } + + // JSXFragmentNode — just concatenate children + if (node.tag === null || node.tag === Symbol.for("hono.fragment")) { + return (node.children as any[]).map(c => this.walkNode(c)).join("\n") + } + + // Function component — call it and walk the result + if (typeof node.tag === "function") { + const result = node.tag({ ...node.props, children: node.children }) + return this.walkNode(result) + } + + // Intrinsic element — map HTML tag to markdown + return this.renderElement(node.tag, node.props, node.children) + } + + private renderElement(tag: string, props: any, children: any[]): string { + const childContent = (children || []).map(c => this.walkNode(c)).join("\n") + + switch (tag) { + case "h1": return `# ${childContent}` + case "h2": return `## ${childContent}` + case "h3": return `### ${childContent}` + case "strong": case "b": return `**${childContent}**` + case "em": case "i": return `*${childContent}*` + case "code": return props.lang + ? `\`\`\`${props.lang}\n${childContent}\n\`\`\`` + : `\`${childContent}\`` + case "ul": return childContent + case "li": return `- ${childContent}` + case "ol": return childContent // handled by parent + case "p": return childContent + case "div": case "section": case "article": case "main": case "header": + case "footer": case "nav": case "aside": case "span": + return childContent // container — just pass through content + case "a": return `[${childContent}](${props.href})` + case "blockquote": return childContent.split("\n").map(l => `> ${l}`).join("\n") + case "hr": return "---" + case "br": return "\n" + case "pre": return childContent // content already formatted by + case "table": return this.renderTable(props, children) + default: + // Unknown/custom tags: use data-md attribute or just render content + if (props?.["data-md"]) { + // Custom markdown rendering hint + return this.renderCustomMd(props["data-md"], props, childContent) + } + return childContent + } + } +} +``` + +### Adaptive Density in Practice + +The key insight from open-memory's context status thresholding: **information density should be proportional to context pressure.** + +``` +Context < 70% (GREEN / "full"): + ┌────────────────────────────────────┐ + │ # Task │ + │ Implement user authentication │ + │ │ + │ ## Context: 45% (90k/200k) stable │ + │ │ + │ ## Decisions (3) │ + │ • Using JWT over sessions │ + │ • bcrypt for password hashing │ + │ • Rate limiting: 100/min default │ + │ │ + │ ## Active Files │ + │ • src/auth/mod.ts (editing) │ + │ • src/auth/jwt.ts (referenced) │ + │ • src/db/schema.ts (referenced) │ + │ │ + │ ## Next Steps │ + │ 1. ~~Add refresh token rotation~~ │ + │ 2. Write auth middleware │ + │ 3. Add tests │ + │ │ + │ ## Notes │ + │ • DB schema: users, sessions │ + │ • Env vars: JWT_SECRET, DB_URL │ + └────────────────────────────────────┘ + ~300-500 tokens + +Context 70-85% (YELLOW / "compact"): + ┌────────────────────────────────────┐ + │ Task: Implement auth │ 72% (144k) ↑ │ + │ Decisions: JWT, bcrypt, 100/min │ + │ Steps: ~~rotation~~, middleware, │ + │ tests │ + │ Files: auth/mod.ts, auth/jwt.ts │ + │ Notes: DB schema users/sessions │ + └────────────────────────────────────┘ + ~100-150 tokens + +Context 85-92% (RED / "minimal"): + ┌────────────────────────────────────┐ + │ Auth impl │ 89% │ ⚠ compact soon │ + │ JWT+bcrypt, step: middleware │ + └────────────────────────────────────┘ + ~30-50 tokens +``` + +This adaptive compression means the agent always has situational awareness, but the cost scales inversely with available context. + +### Compaction That Preserves State + +The critical difference from current compaction: **the HUD state survives compaction.** + +Current flow: +``` +[conversation] → COMPACT → [summary replaces everything] → agent loses context +``` + +HUD-aware flow: +``` +[event log] → project to HUD state → render HUD → [inject into system prompt] +[conversation] → COMPACT → [summary replaces conversation, but HUD persisted state carries forward] +``` + +Before compaction: +1. Agent records `compact.before` event with full HUD state +2. HUD state is persisted to the event log (already append-only) +3. Compaction prompt includes: "Your HUD state: {rendered HUD}. This will survive compaction." + +After compaction: +1. `filterCompacted` drops pre-compaction messages +2. But the system prompt still contains the fully rendered HUD +3. The agent sees its task, decisions, notes, and next steps — not just a narrative summary + +The compaction summary becomes a **complement** to the HUD, not a **replacement** for lost context. The summary handles conversational continuity ("we were discussing..."), while the HUD provides structured persistent state. + +### Comparison: Current vs HUD Architecture + +| Aspect | Current (Compaction) | HUD Architecture | +|--------|---------------------|------------------| +| State management | Monolithic conversation log | Structured event log + HUD projection | +| Context loss | All-or-nothing compaction cliff | Adaptive density that preserves key state | +| Agent awareness | Must call memory tool | Ambient via system prompt injection | +| Cache optimization | 2-part system prompt only | Multi-part with stable HUD layer | +| Recovery from compaction | LLM-generated summary (lossy) | Structured HUD state (deterministic) | +| Cognitive load | Full conversation always visible | Relevant state always visible, details on demand | +| Token cost at 50% | Full conversation (~100k tokens of history) | Conversation + HUD (~100k + ~300 tokens) | +| Token cost at 90% | Conversation (truncated by compaction, then grows again) | Conversation + compact HUD (truncated + ~50 tokens) | + +--- + +## Implementation Plan + +### Phase 1: Minimal Viable HUD (Plugin) + +1. **Event log table**: Add `hud_event` table to opencode's SQLite database (via plugin) +2. **Basic event types**: `task.set`, `task.update`, `decision.record`, `note.add`, `step.add`, `step.complete` +3. **State projector**: Pure functions that fold events into `HudState` +4. **Simple renderer**: Template-based markdown rendering (no JSX yet) +5. **Plugin hooks**: `system.transform` for injection, `event` for derivation, `session.compacting` for pre-compaction snapshot +6. **Two tools**: `hud` (router) and `hud_compact` + +### Phase 2: JSX Rendering Pipeline + +1. **Hono JSXNode walking**: Custom ` HudRenderer` that walks JSXNode trees directly to markdown +2. **HUD components**: ``, ``, ``, ``, ``, `` +3. **Adaptive density**: Components that render differently based on `density` prop +4. **Test rendering**: Snapshot tests comparing component output to expected markdown + +### Phase 3: Cache Optimization + +1. **HUD layer splitting**: Separate HUD into static layer (task, decisions, next steps) and dynamic layer (context bar, notes, active files) +2. **Static layer diffing**: Only push to `output.system[]` when static content changes, reducing cache misses +3. **Dynamic layer minimal updates**: Track what changed since last render, include only deltas +4. **Measure cache hit rates**: Instrument provider token usage to verify caching improvements + +### Phase 4: Advanced Features + +1. **Derived events**: File tracking from tool calls, context snapshots from message events +2. **Search integration**: `hud.search` operation using FTS5 instead of LIKE +3. **Cross-session HUD**: Persist HUD state across sessions, enable resuming tasks +4. **QuickJS scripting**: Use toolEnv's envProxy pattern to let agents customize their HUD components +5. **Multi-agent HUD**: When `coord.spawn` creates sub-agents, share HUD state via parent session events + +--- + +## Key Design Decisions + +### Why event log over direct state mutation? + +1. **Auditability**: Every state change has a trace. You can reconstruct the HUD at any point in time. +2. **Compaction resilience**: Events survive compaction because they're in a separate table, not the conversation message stream. +3. **No conflict resolution needed**: Unlike CRDTs, we're single-author (one agent per session). The event log is append-only with no merge conflicts. +4. **Projection flexibility**: Different projectors can derive different views from the same events. The HUD is one projection; a task progress tracker could be another. + +### Why JSX over Handlebars/templates? + +1. **Component composition**: JSX supports arbitrary nesting and composition. `` can wrap `` with different rendering logic. +2. **Conditional rendering**: `{density === 'full' && }` is cleaner than `{{#if density.full}}...{{/if}}`. +3. **Type safety**: Components are typed functions. Props, state, density — all compile-time checked. +4. **Developer familiarity**: React-like patterns are widely understood. +5. **Direct JSXNode walking avoids HTML roundtrip**: No `renderToStaticMarkup → parse → hast → mdast → markdown` needed. + +### Why system prompt injection instead of a dedicated message role? + +1. **Cache alignment**: OpenCode already manages the system prompt as a cache-aware structure. Injecting into `output.system[]` gives us immediate cache optimization. +2. **No protocol change**: We don't need to change opencode's core messaging protocol. The plugin hook is sufficient. +3. **Survives compaction**: System prompt is always included (it's never compacted). The HUD is always visible. +4. **Provider compatibility**: System prompts work with every LLM provider. A custom message role might not. + +### Why router pattern (1 tool) over separate tools? + +From open-memory's research: each tool definition adds ~1-2k tokens to the system prompt. With 10+ HUD operations, that's 10-20k tokens of overhead. A single `hud` router tool costs ~2k tokens regardless of how many operations it supports. The `help` operation provides inline documentation. + +--- + +## File Structure (Proposed) + +``` +packages/hud-plugin/ + src/ + index.ts # Plugin entry point + state/ + types.ts # HudState, HudEvent types + projector.ts # Event → State projectors + store.ts # SQLite read/write for hud_event + renderer/ + components/ + HUD.tsx # Root HUD component + TaskSection.tsx # Task display + ContextBar.tsx # Context percentage bar + DecisionsList.tsx # Key decisions + NotesSection.tsx # Scratchpad + NextSteps.tsx # Plan/next steps + ActiveFiles.tsx # Currently active files + WarningBanner.tsx # Context pressure warning + renderer.ts # JSXNode → markdown walker + density.ts # Adaptive density logic + tools/ + hud.ts # hud router tool definition + hud_compact.ts # hud_compact tool definition + operations/ # Individual operations + task.ts + decisions.ts + notes.ts + steps.ts + files.ts + snapshot.ts + history.ts + hooks/ + system-transform.ts # experimental.chat.system.transform + compacting.ts # experimental.session.compacting + event.ts # event derivation (file tracking, context snapshots) + context/ + tracker.ts # Context window tracking (from open-memory) + thresholds.ts # Density thresholds +``` + +--- + +## Risks and Open Questions + +1. **Token overhead at low context usage**: Even "minimal" HUD adds ~30-50 tokens. Is 0% context worth the overhead? Probably yes — the ROI is in the 70%+ range where the HUD prevents catastrophic compaction. At 0%, the full HUD costs ~300-500 tokens, which is ~0.25% of a 200k context. The payoff is avoiding a compaction event that loses the entire conversation. + +2. **Agent compliance**: Will agents consistently use `hud` tools to update their state? The current approach relies on the agent choosing to call `hud` tools. Alternatives: + - **Auto-derivation**: More events derived automatically from tool calls (file tracking is automatic, but decisions/notes require agent action) + - **AGENTS.md prompt**: Include HUD tool usage in instructions + - **Conventional prompting**: "Always update your HUD after completing a step or making a decision" in the compaction/system prompt + +3. **Stale state**: If the agent doesn't update the HUD before compaction, the HUD might be stale. Mitigation: auto-snapshot HUD state before compaction in the `session.compacting` hook. + +4. **JSX dependency weight**: Hono's JSX runtime adds a dependency. Alternative: use a lightweight custom JSX transform that doesn't need Hono. The renderer only needs `JSXNode` types and the walker — not the full Hono framework. + +5. **Multi-agent sessions**: When sub-agents are spawned, should they share HUD state? The event log is per-session, but parent-child relationships in opencode could enable HUD state inheritance. + +6. **Search vs. structured state**: Should agents search conversation history (like `memory({tool: "search"})`) or maintain structured state (like `hud({tool: "decisions.record"})`)? Both. The HUD is for state the agent actively maintains. Search is for recovering information the agent didn't think to record. They complement each other. + +7. **Event log growth**: The `hud_event` table will grow. Needs a cleanup strategy — perhaps tied to compaction events (archive events older than the last compaction point). \ No newline at end of file diff --git a/docs/research/hono-jsx-ssr-llm-hud.md b/docs/research/hono-jsx-ssr-llm-hud.md new file mode 100644 index 0000000..e010818 --- /dev/null +++ b/docs/research/hono-jsx-ssr-llm-hud.md @@ -0,0 +1,1077 @@ +# 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. diff --git a/docs/research/prior-poc-source-reference.md b/docs/research/prior-poc-source-reference.md new file mode 100644 index 0000000..0377afa --- /dev/null +++ b/docs/research/prior-poc-source-reference.md @@ -0,0 +1,119 @@ +# Prior UJSX POC: Source Code Reference + +Source: `/workspace/aui/ujsx/` (Deno/JSR package `@ade/ujsx`) +Summary: `/workspace/aui/SUMMARY.md` + +## What to Preserve + +### Transform Registry (`transform/registry.ts`) + +Priority-based transformation with continuation-passing style: + +```typescript +interface TransformRule<T, U, A> { + name: string; + match: (node: T) => boolean; + transform: (node: T, ctx: TransformContext<A>, next: TransformFn<T, U, A>) => U; + priority?: number; +} +``` + +Key pattern: `next` continuation allows recursive child transforms. Rules sorted by priority (higher = first). V2 extends this with `direction` and `schema` fields. + +### HostConfig + Reconciler (`host/config.ts`) + +React-reconciler-inspired host adapter: + +```typescript +interface HostConfig<TTag, Instance, RootCtx> { + name: string; + createRootContext(container, options?): RootCtx; + createInstance(tag, props, ctx, parent?): Instance; + createTextInstance(text, ctx, parent?): Instance; + appendChild(parent, child, ctx): void; + insertBefore?(parent, child, before, ctx): void; + removeChild?(parent, child, ctx): void; + prepareUpdate?(instance, tag, prevProps, nextProps, ctx): unknown | null; + commitUpdate?(instance, payload, tag, prevProps, nextProps, ctx): void; +} +``` + +The `createRoot()` reconciler is mount-only (MVP). V2 needs full reconciliation with key-based diffing. + +### Graphology Host (`host/graphology.ts`) + +Dirty bitmask pattern on graph nodes: + +```typescript +const DIRTY = { Props: 1<<0, Content: 1<<1, Structure: 1<<2 } as const; +``` + +Edges use `parent->child#order` keys. Version tracking on nodes. Issue: `createInstance` has commented-out append logic, `prepareUpdate` uses `JSON.stringify` diffing. + +### Streaming Transformer (`streaming/transformer.ts`) + +Chunk-based async iterable processor. V2 preserves this but flush should pass real ancestor context instead of empty arrays. + +## What to Remove/Rewrite + +### `UniversalProps` (`core/types.ts`) + +HTML-specific props that don't belong in a universal IR: +- `onClick`, `onSubmit`, `onInput`, `onChange` (event handlers) +- `className`, `class` (HTML-specific) +- `data-*`, `aria-*` template keys +- `__html` (dangerouslySetInnerHTML) + +V2 replaces with plain `Record<string, unknown>`. + +### `genId()` (`core/jsx.ts`) + +```typescript +function genId(): string { + return `e_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} +``` + +Non-deterministic. V2 uses counter-based or injectable IDs. + +### Metadata Injection (`core/jsx.ts`) + +Every element gets `{ timestamp: new Date(), id: genId() }`. This is overhead without clear use in a universal IR. Move to host-specific concerns if needed. + +## TypeBox Research Examples + +Source: `/workspace/research/typebox_research/ujsx/` + +### `unist.ts` - Unist schema as TypeBox Module + +```typescript +export const Unist = Type.Module({ + Data: Type.Object({},{additionalProperties: Type.Unknown()}), + Point: Type.Object({ line: Type.Number(), column: Type.Number(), ... }), + Position: Type.Object({ start: Type.Ref('Point'), end: Type.Ref("Point") }), + Node: Type.Object({ type: Type.String(), data: Type.Optional(...), position: Type.Optional(...) }), + Literal: Type.Composite([Type.Ref("Node"), Type.Object({ value: Type.Unknown() })]), + Parent: Type.Composite([Type.Ref("Node"), Type.Object({ children: Type.Array(Type.Ref("Node")) })]), +}) +``` + +### `ujsx.ts` - UJSX schema as TypeBox Module + +```typescript +export const UJSX = Type.Module({ + ElementMetadata: Type.Object({ id: Type.Optional(Type.String()), timestamp: Type.Optional(Type.Date()) }, { additionalProperties: Type.Unknown() }), + Children: Type.Union([Type.Ref("UniversalNode"), Type.Array(Type.Ref("UniversalNode"))]), + PropValue: Type.Union([Type.String(), Type.Number(), ..., Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Void())]), + UniversalProps: Type.Object({ id: Type.Optional(Type.String()), children: Type.Optional(Type.Ref("Children")) }, { additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) }), + RootElement: Type.Object({ type: Type.Literal("root"), props: Type.Intersect([...]), children: Type.Array(Type.Ref("UniversalNode")), ... }), + UniversalElement: Type.Object({ type: Type.Union([Type.String(), Type.Function([Type.Ref("UniversalProps")], Type.Ref("UniversalNode"))]), props: Type.Ref("UniversalProps"), children: Type.Array(Type.Ref("UniversalNode")), ... }), +}) +``` + +Key insight: `Type.Module` creates a `TModule` whose `$defs` is a live `string → TSchema` map. Access via `ValuePointer.Get(UJSX, "$defs/Children")`. Add at runtime via `ValuePointer.Set()`. Import resolved schemas via `UJSX.Import("Element")`. + +Note: `ElementMetadata` is defined twice in the research file (duplicate). V2 should clean this up and likely drop metadata from the core schema entirely. + +### ts2typebox + +CLI tool `ts2typebox` can convert TypeScript types to TypeBox defs but has issues with complex types (JSDoc comments, linked references). Useful for basic types, unreliable for complex ones like unist/mdast type definitions. \ No newline at end of file diff --git a/docs/research/signals-ujsx-reactive-pipeline.md b/docs/research/signals-ujsx-reactive-pipeline.md new file mode 100644 index 0000000..75ce875 --- /dev/null +++ b/docs/research/signals-ujsx-reactive-pipeline.md @@ -0,0 +1,372 @@ +# Signals + UJSX: Reactive Transform Pipeline + +## The Core Idea + +Preact Signals' `computed` provides exactly what UJSX needs for efficient rendering: a component whose output is automatically cached and only recomputed when its input signals change. + +In the context of an agent HUD: +- HUD **state** lives in `signal()` values (task description, decisions, notes, context %) +- HUD **components** are `computed()` signals that read their input signals and produce UElements +- The **root render** is a `computed()` that traverses the component tree and produces markdown +- Changing one signal (e.g., context percentage from 70% to 85%) automatically marks only the downstream components as outdated — not the entire tree + +This is fundamentally different from the current approach where the entire HUD is re-rendered from scratch every turn. + +## How Signals Work (from source) + +Key behaviors from `/workspace/signals/packages/core/src/index.ts`: + +### `signal(value)` — The Reactive Primitive +```typescript +const count = signal(0) +count.value // read (tracks deps if in computed/effect) +count.value = 1 // write (notifies dependents, batches updates) +count.peek() // read WITHOUT tracking (untracked) +``` + +### `computed(fn)` — Derived, cached computation +```typescript +const doubled = computed(() => count.value * 2) +``` +- Lazily evaluated: fn only runs when `.value` is read AND deps have changed +- Cached: if deps haven't changed, returns cached value without running fn +- Version tracking: uses `_version` numbers on signals instead of storing values +- Dependency graph: automatically tracks which signals were read during evaluation + +### `effect(fn)` — Side effects +```typescript +effect(() => { + console.log(doubled.value) // re-runs when count changes +}) +``` +- Returns a dispose function +- fn can return a cleanup function +- Batches: multiple signal writes within `batch()` only trigger one effect run + +### `batch(fn)` — Batching +```typescript +batch(() => { + a.value = 1 // doesn't trigger effects yet + b.value = 2 // still batched +}) // effects run once after batch completes +``` + +### `createModel(factory)` — Model pattern +```typescript +const Counter = createModel((initial: number) => ({ + count: signal(initial), + increment: action(() => { Counter.count.value++ }), + doubled: computed(() => Counter.count.value * 2), +})) +const c = new Counter(0) +c.count.value // 0 +c.increment() // batched, untracked write +c.doubled.value // 2 +c[Symbol.dispose]() // cleans up all effects +``` + +### Key Implementation Details + +1. **Version numbers, not value storage**: Computed signals store `_version` per dependency node, not values. When checking if recomputation is needed, it compares version numbers. This is memory-efficient. + +2. **Global version counter**: `globalVersion` increments when ANY signal changes. Computed signals track `_globalVersion` — if global hasn't changed since last evaluation, skip entirely (fast path). + +3. **Lazy evaluation**: Computed values are NOT computed until someone reads `.value`. No eager re-computation. + +4. **Batch snapshots**: When writing multiple signals in a batch, `_batchSnapshotVersion` tracks which signals were written. On batch end, `reconcileBatchSnapshots()` restores version numbers for signals that were written but ended up with the same value (avoiding spurious notifications). + +5. **Auto-dispose**: Effects created within `createModel` are captured and disposed when the model's `[Symbol.dispose]()` is called. + +6. **Untracked reads**: `signal.peek()` reads without tracking. `untracked(fn)` runs fn without tracking. `action(fn)` wraps fn in batch+untracked. + +## Application to UJSX Transform Pipeline + +### The Problem + +In the HUD, every agent turn re-renders the entire HUD from the root. If the task description hasn't changed, the task section still re-renders. If context went from 67% to 68%, the entire tree is walked even though only `ContextBar` changed. + +With signals: +- Each component whose input hasn't changed **skips re-evaluation entirely** +- Only the `computed` signals that depend on changed `signal` values re-compute +- The render pipeline walks the tree, but `computed.value` returns cached results for unchanged components + +### Proposed Architecture + +```typescript +import { signal, computed, batch, effect } from "@preact/signals-core" + +// --- State Layer (signals) --- + +const hudState = createModel(() => ({ + task: signal<{ description: string; updatedAt: number } | null>(null), + contextPercent: signal(0), + contextStatus: computed(() => { + const p = hudState.contextPercent.value + if (p < 70) return 'green' as const + if (p < 85) return 'yellow' as const + return 'red' as const + }), + decisions: signal<Array<{ id: string; summary: string }>>([]), + notes: signal<Array<{ id: string; content: string }>>([]), + nextSteps: signal<Array<{ id: string; description: string; completed: boolean }>>([]), + activeFiles: signal<Array<{ path: string; status: string }>>([]), + density: computed(() => { + const p = hudState.contextPercent.value + if (p < 70) return 'full' as const + if (p < 85) return 'compact' as const + return 'minimal' as const + }), +})) + +// --- Component Layer (computed signals producing UElements) --- + +function TaskSection() { + return computed(() => { + const task = hudState.task.value + const density = hudState.density.value + if (!task) return null + if (density === 'minimal') { + return h('p', null, task.description) + } + return h('div', null, + h('h2', null, 'Task'), + h('p', null, task.description), + ) + }) +} + +function ContextBar() { + return computed(() => { + const percent = hudState.contextPercent.value + const status = hudState.contextStatus.value + const density = hudState.density.value + if (density === 'minimal') { + return h('p', null, `${Math.round(percent)}% ${status === 'red' ? '⚠' : ''}`) + } + return h('div', null, + h('h2', null, 'Context'), + h('p', null, `${Math.round(percent)}% used (${status})`), + ) + }) +} + +function DecisionsList() { + return computed(() => { + const density = hudState.density.value + const decisions = hudState.decisions.value + if (density === 'minimal') return null + if (density === 'compact') { + return h('p', null, `Decisions (${decisions.length})`) + } + return h('div', null, + h('h2', null, 'Decisions'), + h('ul', null, ...decisions.map(d => h('li', null, d.summary))), + ) + }) +} + +// --- Root Render (computed signal) --- + +const hudRender = computed(() => { + const task = TaskSection() + const ctx = ContextBar() + const decisions = DecisionsList() + + const children = [task, ctx, decisions].filter(Boolean) + return createRoot('hud', ...children) +}) + +// --- Markdown Output (effect, or manual) --- + +// Option A: Reactive — auto-renders when any signal changes +const dispose = effect(() => { + const tree = hudRender.value // only recomputes what changed + const markdown = renderMarkdown(tree) + sendToSystemPrompt(markdown) +}) + +// Option B: Manual — called when we know we need a new render +function renderHUD() { + return hudRender.value // triggers only necessary computed() evaluations +} + +// --- Updates are batched --- + +function updateHUD(event: HudEvent) { + batch(() => { + switch (event.type) { + case 'task.set': + hudState.task.value = { description: event.description, updatedAt: Date.now() } + break + case 'context.snapshot': + hudState.contextPercent.value = event.percent + break + case 'decision.record': + hudState.decisions.value = [...hudState.decisions.value, event.decision] + break + } + }) + // Only one effect run after the batch, even if multiple signals changed +} +``` + +### What This Buys Us + +1. **Skip unchanged components**: If `task` signal hasn't changed, `TaskSection()` returns the cached UElement without calling the component function. + +2. **Density changes propagate efficiently**: Changing `contextPercent` from 67% to 68% (green→green) doesn't trigger `ContextBar` re-render because the computed `contextStatus` still returns 'green'. From 69%→71% (green→yellow) DOES trigger `ContextBar` and anything that reads `density`. + +3. **Batched updates**: A multi-field update (task + decisions + context) in one `batch()` only triggers one render pass. + +4. **No global dirty tracking needed**: The existing POC's `dirty` bitmask on graphology nodes is a manual approximation. Signals do this automatically and correctly. + +5. **Separation of state and rendering**: State is in `signal()` values. Rendering is `computed()` derivations. Effects wire rendering to side effects (injecting into system prompt). + +6. **`peek()` for untracked reads**: During transformation (walking the tree to produce markdown), we can `peek()` at values without subscribing — useful when the transform doesn't need to be reactive. + +7. **Disposal**: `hudState[Symbol.dispose]()` cleans up all effects created within the model. Clean teardown when a session ends. + +### What This Does NOT Buy Us (Yet) + +1. **Structural changes still need full walk**: Adding/removing a list item still requires re-walking that part of the tree. Signals help with value changes, not structural changes. For structural changes, we'd need a keyed list diff (like React's reconciliation). + +2. **The transform (UJSX → mdast → markdown) is always full**: Even if only one component re-computes, the entire tree still gets walked to produce markdown. To skip unchanged subtrees in the transform, we'd need to memoize transform results per node (using `computed` at the transform level too). + +3. **No framework dependency yet**: We're using signals-core standalone, not via Preact/React integration. The Preact adapter (`@preact/signals`) adds `useSignal()` hook, React adapter (`@preact/signals-react`) adds `useSignalValue()`. We don't need either — we use `signal`/`computed` directly. + +### Computed Transforms (Memoization at Every Level) + +The real power: make the transform pipeline itself reactive: + +```typescript +// State signal +const taskText = signal("Implement auth module") + +// Component produces a UElement (computed — cached) +const taskElement = computed(() => + h('h2', null, taskText.value) +) + +// Transform produces mdast (computed — cached) +const taskMdast = computed(() => + transformToMdast(taskElement.value, { direction: 'ujsx->mdast' }) +) + +// Serialize to markdown (computed — cached) +const taskMarkdown = computed(() => + toMarkdown(taskMdast.value) +) + +// Only runs when taskText changes: taskElement → taskMdast → taskMarkdown +// If taskText hasn't changed, reading taskMarkdown.value returns cached string +``` + +This means the ENTIRE pipeline is memoized. If you change `contextPercent` from 67% to 68% (green→green, no density change), `taskMarkdown.value` returns the cached string instantly. Only `contextBarMarkdown.value` recomputes. + +### The Final Composition + +```typescript +// Each section is its own computed pipeline +const taskMd = computed(() => renderSection(TaskSection(), 'ujsx->mdast')) +const ctxMd = computed(() => renderSection(ContextBar(), 'ujsx->mdast')) +const decisionsMd = computed(() => renderSection(DecisionsList(), 'ujsx->mdast')) + +// Root markdown is computed from sections +const fullMarkdown = computed(() => + [taskMd.value, ctxMd.value, decisionsMd.value] + .filter(Boolean) + .join('\n\n') +) + +// Only the sections whose signals changed recompute their markdown +// fullMarkdown recomputes its string join (cheap) but doesn't re-walk unchanged sections +``` + +### Integration with opencode Plugin + +The `effect()` that injects into the system prompt would be: + +```typescript +const dispose = effect(() => { + const markdown = fullMarkdown.value + // This runs whenever any upstream signal changes + // But only the changed computed() evaluations actually ran + systemPromptInjector.push(markdown) +}) +``` + +Or more pragmatically, since opencode calls the plugin hook on every LLM turn: + +```typescript +// In the plugin's system.transform hook: +"experimental.chat.system.transform": async (input, output) => { + // Just read .value — it's cached, no work if unchanged + const markdown = fullMarkdown.value + output.system.push(markdown) +} +``` + +This is the simplest integration: no effect, no subscription. Just read `.value` when asked. If nothing changed, it's O(1). If something changed, only the minimal recomputation happens. + +### Signals + TypeBox Module Interaction + +The signals hold runtime state. The TypeBox Module defines the schema for that state. + +```typescript +import { Type, Static } from "@alkdev/typebox" + +const HudStateModule = Type.Module({ + Task: Type.Object({ + description: Type.String(), + updatedAt: Type.Number(), + }), + Decision: Type.Object({ + id: Type.String(), + summary: Type.String(), + details: Type.Optional(Type.String()), + }), + ContextInfo: Type.Object({ + percent: Type.Number(), + status: Type.Union([Type.Literal('green'), Type.Literal('yellow'), Type.Literal('red')]), + model: Type.String(), + }), +}) + +// Import for validation +const Task = HudStateModule.Import("Task") +const Decision = HudStateModule.Import("Decision") + +// Validate before setting signal values +function updateHUD(event: HudEvent) { + batch(() => { + if (event.type === 'task.set') { + if (Value.Check(Task, event.task)) { + hudState.task.value = event.task + } + } + // ... + }) +} +``` + +The TypeBox Module validates incoming events. The signals hold the validated state. The `computed` components derive UElements from the state. The transform pipeline serializes to markdown. + +### File Structure Addition + +``` +@alkdev/ujsx/ + core/ + schema.ts # TypeBox Module for UNode/UElement/URoot + h.ts # h(), createRoot(), Fragment factory + context.ts # Signal-based context (density, target) + signals.ts # createHudModel() — signals for HUD state + host/ + config.ts # HostConfig interface (preserved) + markdown.ts # Markdown host (new) + transform/ + registry.ts # Enhanced TransformRegistry with direction + schema + rule.ts # Rule type with TypeBox schema + ... +``` + +The signals integration is an **addition** to the TypeBox-driven rewrite, not a replacement. The TypeBox Module defines the shape. The signals hold the runtime values. The computed signals produce the rendered output. \ No newline at end of file diff --git a/docs/research/typebox-module-type-registry.md b/docs/research/typebox-module-type-registry.md new file mode 100644 index 0000000..bdca816 --- /dev/null +++ b/docs/research/typebox-module-type-registry.md @@ -0,0 +1,113 @@ +# TypeBox Module Research: UJSX Schema Implications + +## Key Finding: `Type.Module` IS the Type Registry + +`Type.Module` creates a `TModule` object whose `$defs` property is a live `string → TSchema` map. This is exactly the "type registry" pattern needed for UJSX. + +### Core Operations + +```typescript +const UJSX = Type.Module({ + Props: Type.Object({ id: Type.Optional(Type.String()) }, { additionalProperties: Type.Unknown() }), + Element: Type.Object({ + type: Type.String(), + props: Type.Ref("Props"), + children: Type.Array(Type.Ref("Node")), + }), + Node: Type.Union([Type.String(), Type.Number(), Type.Null(), Type.Ref("Element")]), +}) +``` + +#### Reading schemas by name +```typescript +ValuePointer.Get(UJSX, "$defs/Props") // → TSchema with $id: "Props" +ValuePointer.Get(UJSX, "$defs/Element") // → TSchema with $id: "Element" +``` + +#### Adding schemas at runtime +```typescript +ValuePointer.Set(UJSX, "$defs/Heading", Type.Object({ + type: Type.Literal("heading"), + depth: Type.Number(), + children: Type.Array(Type.Ref("Node")), +})) +// Now UJSX.$defs.Heading exists and can be referenced by Type.Ref("Heading") +``` + +#### Importing (resolves $ref, creates self-contained schema for validation) +```typescript +const Element = UJSX.Import("Element") +// Element = { [Kind]: 'Import', $defs: { Props: {...}, Element: {...}, Node: {...} }, $ref: "Element" } +Value.Check(Element, someData) // Works! All $refs resolved via inlined $defs +``` + +#### Static type inference +```typescript +type UE = Static<typeof Element> +// UE = { type: string; props: { id?: string; [k: string]: unknown }; children: UE[] } +``` + +### How `Import` Works (from source) + +The `Import` method on `TModule` (module.ts:70-72): + +```typescript +public Import<Key extends keyof ComputedModuleProperties>(key: Key, options?: SchemaOptions): TImport<ComputedModuleProperties, Key> { + const $defs = { ...this.$defs, [key]: CreateType(this.$defs[key], options) } + return CreateType({ [Kind]: 'Import', $defs, $ref: key }) as never +} +``` + +It creates a `TImport` schema with: +- `[Kind]: 'Import'` — uniquely identifies this as a module import +- `$defs` — ALL module definitions copied in (so $refs resolve) +- `$ref: key` — which definition to use as the root + +### How `ComputeModuleProperties` Works (from compute.ts) + +The constructor calls `ComputeModuleProperties` which walks every property and: + +1. Resolves `Type.Ref("X")` → dereferences to the actual type at `moduleProperties[X]` +2. Recursively processes: `Type.Array(Type.Ref("Node"))` → `Type.Array(ResolvedNode)` +3. Handles all TypeBox combinators: Object, Union, Intersect, Function, Record, etc. +4. Adds `$id` to each definition via `WithIdentifiers` + +This means you can use `Type.Ref()` inside `Type.Module()` and they get resolved to direct references. The $ref JSON pointer stays in the output for JSON Schema compatibility, but TypeBox's internal resolution handles them. + +### `TInferFromModuleKey` — Type-Level Inference + +The `infer.ts` file provides `TInferFromModuleKey<ModuleProperties, Key>` which is the type-level path of resolving refs. This is what makes `Static<typeof ImportedElement>` produce a proper recursive type: + +```typescript +type UE = Static<typeof Element> +// Resolves to: +// { type: string; props: { id?: string; [k: string]: unknown }; children: (string | number | null | UE)[] } +``` + +This is a **recursive type** inferred from the module. No `Type.Recursive` needed because the Module's type-level inference handles cycles through `TRef`. + +### Function Types in Modules + +`Type.Function([...params], returnType)` creates a JavaScript-extended schema type: + +```typescript +ComponentFn: Type.Function([Type.Ref("Props")], Type.Ref("Node")) +``` + +This produces a JSON Schema with `{ type: "Function", parameters: [...], returns: {...} }` which is NOT standard JSON Schema but IS valid TypeBox. `Value.Check` validates that the value is a function at runtime. This means we can represent component functions in the schema. + +### Implications for UJSX v2 + +1. **No separate type registry needed.** `TModule` IS the registry. It's a map of `string → TSchema` with computed resolution and `$id` assignment built in. + +2. **Schemas ARE types AND ARE tool parameter schemas.** One definition creates the TypeScript type, the runtime validator, and the JSON Schema for tool definitions. + +3. **Bi-directional transforms can be schema-driven.** Transform rules can match on `Value.Check(ruleSchema, node)` instead of string tag matching. + +4. **Node definitions can be added at runtime.** `ValuePointer.Set(module, "$defs/CustomNode", ...)` or simply defining new entries in the Module. This means plugins can extend the IR. + +5. **Function components are first-class in the schema.** `Type.Function([Type.Ref("Props")], Type.Ref("Node"))` validates that `type` is a function at runtime. For serialization, we'd resolve function components to string tags before going to mdast/other targets. + +6. **`Import` creates self-contained schemas.** Perfect for sending to tool definitions or validation contexts where the full module isn't available. + +7. **The `ComponentFn` pattern means we can separate runtime and serializable representations.** At runtime, `type` can be a function. Before serialization (JSON, mdast), we resolve function components to string identifiers and emit `{ type: "Component", componentId: "TaskSection", ... }`. \ No newline at end of file diff --git a/docs/research/typebox-module-valuepointer.md b/docs/research/typebox-module-valuepointer.md new file mode 100644 index 0000000..9e4d65b --- /dev/null +++ b/docs/research/typebox-module-valuepointer.md @@ -0,0 +1,101 @@ +# TypeBox Module & ValuePointer: Core Patterns for UJSX + +Source: `/workspace/@alkdev/typebox` (npm: `@alkdev/typebox`) + +## TModule: The Schema Registry + +`Type.Module()` creates a `TModule` whose `$defs` property is a live `string → TSchema` map. This IS the type registry UJSX needs — no separate registry required. + +### Core Operations + +```typescript +const UJSX = Type.Module({ + Props: Type.Object({ id: Type.Optional(Type.String()) }, { additionalProperties: Type.Unknown() }), + Element: Type.Object({ + type: Type.String(), + props: Type.Ref("Props"), + children: Type.Array(Type.Ref("Node")), + }), + Node: Type.Union([Type.String(), Type.Number(), Type.Null(), Type.Ref("Element")]), +}) +``` + +### Read schemas by name + +```typescript +import { ValuePointer } from "@alkdev/typebox/value"; +ValuePointer.Get(UJSX, "$defs/Props") // TSchema with $id: "Props" +ValuePointer.Get(UJSX, "$defs/Element") // TSchema with $id: "Element" +``` + +### Add schemas at runtime + +```typescript +ValuePointer.Set(UJSX, "$defs/Heading", Type.Object({ + type: Type.Literal("heading"), + depth: Type.Number(), + children: Type.Array(Type.Ref("Node")), +})) +// Now UJSX.$defs.Heading exists and Type.Ref("Heading") resolves +``` + +### Import: Resolved schemas for validation + +```typescript +const Element = UJSX.Import("Element") +// Creates TImport with: { [Kind]: 'Import', $defs: { all module defs inlined }, $ref: "Element" } +Value.Check(Element, someData) // Works! All $refs resolved via inlined $defs +``` + +### Static type inference + +```typescript +type UE = Static<typeof Element> +// UE = { type: string; props: { id?: string; [k: string]: unknown }; children: UE[] } +``` + +The Module's type-level inference handles cycles through `TRef` — no `Type.Recursive` needed. + +## How Import Works (from source) + +Module source: `/workspace/@alkdev/typebox/src/type/module/index.ts` + +```typescript +public Import<Key>(key: Key, options?: SchemaOptions): TImport { + const $defs = { ...this.$defs, [key]: CreateType(this.$defs[key], options) } + return CreateType({ [Kind]: 'Import', $defs, $ref: key }) +} +``` + +Creates a `TImport` schema with: +- `[Kind]: 'Import'` — uniquely identifies as module import +- `$defs` — ALL module definitions copied in (so $refs resolve) +- `$ref: key` — which definition to use as root + +## ComputeModuleProperties + +Called during Module construction. Walks every property and: +1. Resolves `Type.Ref("X")` → dereferences to actual type at `moduleProperties[X]` +2. Recursively processes: `Type.Array(Type.Ref("Node"))` → `Type.Array(ResolvedNode)` +3. Handles all TypeBox combinators: Object, Union, Intersect, Function, Record, etc. +4. Adds `$id` to each definition via `WithIdentifiers` + +## Function Types in Modules + +```typescript +Type.Function([Type.Ref("Props")], Type.Ref("Node")) +``` + +Creates a JavaScript-extended schema type `{ type: "Function", parameters: [...], returns: {...} }`. Not standard JSON Schema but valid TypeBox. `Value.Check` validates that the value is a function at runtime. + +This means component functions CAN be represented in the schema. For serialization, resolve function components to string identifiers before going to mdast/other targets. + +## Implications for UJSX v2 + +1. **No separate type registry needed** — `TModule` IS the registry +2. **Schemas ARE types AND tool parameter schemas** — one definition, triple duty +3. **Bi-directional transforms can be schema-driven** — `Value.Check(ruleSchema, node)` instead of string matching +4. **Node definitions can be added at runtime** — plugins can extend the IR via `ValuePointer.Set()` +5. **`Import` creates self-contained schemas** — perfect for tool definitions or validation contexts +6. **Function components are first-class in schema** — separate runtime vs serializable representations +7. **No `Type.Recursive` needed in Module** — cycles handle through `TRef` + type-level inference \ No newline at end of file diff --git a/docs/research/ujsx-v2-typebox-rewrite.md b/docs/research/ujsx-v2-typebox-rewrite.md new file mode 100644 index 0000000..9b59796 --- /dev/null +++ b/docs/research/ujsx-v2-typebox-rewrite.md @@ -0,0 +1,554 @@ +# UJSX v2: TypeBox-Schema-Driven Universal JSX IR + +## What Exists: The Current POC + +The `/workspace/aui` codebase has two packages: + +- **`@ade/ujsx`** — Core universal JSX IR: `h()` factory, `UniversalElement`/`RootElement` types, `HostConfig` reconciler, `TransformRegistry`, `StreamingTransformer` +- **`@ade/aui`** — Markdown consumer: `jsxToMdast` transform rules, `renderMarkdown()` via mdast-util-to-markdown + GFM + +The architecture is already right at a high level: +``` +JSX (h calls) → UniversalNode tree → Host/Transform → Output +``` + +## What's Wrong + +1. **No actual JSX syntax** — Uses `h()` calls instead of `<Component />` syntax. Needs a JSX transform config (like Hono's `tsconfig.json` jsxFactory) so you can write real JSX. + +2. **HTML baggage** — `UniversalProps` has `onClick`, `onSubmit`, `onInput`, `onChange`, `className`, `aria-*`, `data-*` — all HTML-specific. For a universal IR targeting markdown (and eventually other formats), these are noise. + +3. **No schema-driven types** — Types are hand-written interfaces. The whole point of using TypeBox is that the IR *is* a schema — nodes can be validated at runtime, composed generically, and converted to JSON Schema for tool definitions. + +4. **Non-deterministic IDs** — `genId()` uses `Math.random()`. Needs to be injectable/deterministic. + +5. **Separate props + children** — `UniversalElement` has both `props` and `children`. This is the React/HTML model where `children` is a special prop. For a universal IR, children is just a prop like any other. Simplify. + +6. **No Context system** — React's Context/Provider pattern is essential for passing density/target/host config down the tree without prop drilling. + +7. **Fragment not integrated** — Exists but reconciler and transforms don't handle it specially. + +8. **Missing markdown nodes** — No blockquote, link, image, hr, table support. + +## The Rewrite: TypeBox-Schema-Driven UJSX + +### Core Principle + +**The UniversalElement IS a TypeBox schema at runtime.** Every node in the tree is both a valid TypeScript value AND a valid JSON Schema value. This means: + +- Runtime validation: `Value.Check(UElementSchema, node)` +- Tool schema generation: Convert any component's props schema to JSON Schema for agent tool definitions +- Generic composition: `Type.Intersect()`, `Type.Partial()`, `Type.Omit()` work on node schemas +- Bi-directional transforms: Rules match on schema shape, not string tags + +### TypeBox AST Schema + +```typescript +import { Type, Static, TSchema } from "@alkdev/typebox" + +// The recursive node schema — the heart of the system +const UNodeSchema = Type.Recursive((This) => + Type.Union([ + Type.Null(), + Type.String(), + Type.Number(), + Type.Boolean(), + Type.Object({ + type: Type.Union([Type.String(), Type.Function([Type.Object({}), This], This)]), + props: Type.Record(Type.String(), Type.Any()), + children: Type.Array(This), + }), + ]), + { $id: "UNode" } +) + +type UNode = Static<typeof UNodeSchema> + +// Refined: element is the object branch +const UElementSchema = Type.Object({ + type: Type.Union([Type.String(), Type.Function([Type.Object({}), UNodeSchema], UNodeSchema)]), + props: Type.Record(Type.String(), Type.Any()), + children: Type.Array(UNodeSchema), +}) + +type UElement = Static<typeof UElementSchema> +``` + +Wait — `Type.Function` in TypeBox creates a JavaScript extended schema type, not a JSON Schema one. For the IR's purposes, we want function components to be part of the *runtime* TypeScript type but NOT part of the serializable schema. The schema (what gets validated and what tools see) should only have string types. + +Better approach: separate the runtime type from the serializable schema: + +```typescript +// Serializable schema — what validation and tool definitions see +const USerializableElementSchema = Type.Recursive((This) => + Type.Object({ + type: Type.String(), // Always a string after component resolution + props: Type.Record(Type.String(), Type.Any()), + children: Type.Array(Type.Union([ + Type.String(), + Type.Number(), + Type.Null(), + This, + ])), + }), + { $id: "UElement" } +) + +// Runtime TypeScript type — includes function components +type UType = string | ComponentFn +type UNode = UElement | string | number | null +type UElement = { + type: UType + props: Record<string, unknown> + children: UNode[] +} +``` + +Actually, let me think about this differently. The key insight from the user's message is **bi-directional transforms**. The current `TransformRegistry` goes one direction (UJSX → mdast). The universal IR concept means rules should go both ways: + +- UJSX → mdast (for markdown rendering) +- mdast → UJSX (for parsing markdown back into the IR) +- UJSX → hast (for HTML rendering) +- hast → UJSX (for parsing HTML) +- Eventually: UJSX → Graphology, UJSX → terminal ANSI, etc. + +This is where TypeBox schemas really shine: **each node type's schema IS the match criterion**. Instead of string-based matching (`n.type === 'h1'`), rules match on schema shape: + +```typescript +// Current: string-based matching +jsxToMdast.register({ + name: 'heading', + match: (n) => isUniversalElement(n) && typeof n.type === 'string' && /^h[1-6]$/.test(n.type), + transform: ... +}) + +// TypeBox-driven: schema-based matching +const HeadingSchema = Type.Object({ + type: Type.String({ pattern: '^h[1-6]$' }), + props: Type.Object({ level: Type.Number().optional() }), + children: Type.Array(UNodeSchema), +}) + +rule({ + name: 'heading', + schema: HeadingSchema, + direction: 'ujsx->mdast', + transform: (node, ctx, next) => ({ + type: 'heading', + depth: Number(node.type.slice(1)), + children: transformChildren(node, ctx, next), + }), +}) +``` + +This is more verbose but gives us: +1. `Value.Check(HeadingSchema, node)` — validate before transforming +2. The schema can be registered as a tool parameter schema +3. Bi-directional: a `mdast->ujsx` rule for heading uses the same schema +4. `Type.Intersect()` to compose schemas for compound elements + +### Simplified Element Model + +Remove HTML-specific stuff. The `props` becomes a plain `Record<string, unknown>` with no HTML-centric defaults. Event handlers (`onClick`, etc.) and HTML attributes (`className`, `aria-*`) are gone from the core — they belong in an HTML host extension, not the universal IR. + +```typescript +// core/schema.ts +import { Type, Static } from "@alkdev/typebox" + +// Primitives +const UPrimitive = Type.Union([Type.String(), Type.Number(), Type.Null()]) + +// Element — the universal IR node +const UElement = Type.Recursive((This) => + Type.Object({ + type: Type.String(), + props: Type.Record(Type.String(), Type.Any()), + children: Type.Array(Type.Union([UPrimitive, This])), + }, { $id: "UElement" }), +) + +// Root — session/frame container +const URoot = Type.Object({ + type: Type.Literal("root"), + props: Type.Object({ + id: Type.Optional(Type.String()), + target: Type.Optional(Type.String()), + }), + children: Type.Array(Type.Union([UPrimitive, UElement])), +}, { $id: "URoot" }) + +// Node = everything a position in the tree can hold +const UNode = Type.Union([UPrimitive, UElement, URoot], { $id: "UNode" }) + +type UElement = Static<typeof UElement> +type URoot = Static<typeof URoot> +type UNode = Static<typeof UNode> +``` + +### The `h()` Factory (Cleaned Up) + +```typescript +// core/h.ts +import type { UNode, UElement, URoot } from './schema.ts' + +let _idCounter = 0 + +export function h( + type: string, + props?: Record<string, unknown> | null, + ...children: UNode[] +): UElement { + return { + type, + props: props ?? {}, + children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[], + } +} + +export function createRoot(id?: string, ...children: UNode[]): URoot { + return { + type: 'root', + props: { id }, + children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[], + } +} + +// Alias for JSX transform compatibility +export const jsx = h +export const jsxs = h +export const jsxDEV = h + +// Fragment: children pass-through +export function Fragment(props: { children?: UNode[] }): UNode[] { + return (props.children ?? []).flat(Infinity).filter(c => c != null && c !== false) as UNode[] +} +``` + +Key changes from current: +- **No metadata injection** — `timestamp` and `id` on every element was overhead without clear use. If needed, it belongs in the host, not the IR. +- **No `className`/event handlers** — Universal props, not HTML props. +- **Deterministic by default** — No `Math.random()`. + +### JSX Transform Config + +To support actual JSX syntax (`<TaskSection task={state.task} />`), you need a `tsconfig.json` or `deno.json` with: + +```json +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@ade/ujsx" + } +} +``` + +And an `jsx-runtime` module that exports `jsx`, `jsxs`, `jsxDEV`, `Fragment`. This is what Hono does (`hono/jsx/jsx-runtime`). + +### Host System (Preserved, Simplified) + +The `HostConfig` interface is good. Keep it but remove the HTML-specific assumptions: + +```typescript +// host/config.ts +export interface HostConfig<TTag extends string, Instance, RootCtx> { + name: string + createRootContext(container: unknown, options?: Record<string, unknown>): RootCtx + finalizeRoot?(ctx: RootCtx): void + createInstance(tag: TTag, props: Record<string, unknown>, ctx: RootCtx, parent?: Instance): Instance + createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance + appendChild(parent: Instance, child: Instance, ctx: RootCtx): void + insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void + removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void + prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null + commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void +} +``` + +No changes needed. The host is host-agnostic already. The graphology host stays. A markdown host can be added: + +```typescript +// host/markdown.ts +export function createMarkdownHost(): HostConfig<string, MdastNode, MdastRoot> { + // Maps universal tags to mdast node creation + // Uses the same rules as the TransformRegistry but via HostConfig +} +``` + +### TypeBox-Driven Transform Rules + +The key evolution of the `TransformRegistry`. Rules are now schema-aware and bi-directional: + +```typescript +// transform/rule.ts +import { Type, TSchema, Value } from "@alkdev/typebox" + +export type Direction = 'ujsx->mdast' | 'mdast->ujsx' | 'ujsx->hast' | 'hast->ujsx' + +export interface TransformRule<TInput, TOutput, TAncestor> { + name: string + direction: Direction + schema: TSchema // The TypeBox schema this rule matches against + match: (node: TInput) => boolean // Runtime match (can use schema or custom logic) + transform: (node: TInput, ctx: TransformContext<TAncestor>, next: TransformFn<TInput, TOutput, TAncestor>) => TOutput + priority?: number +} + +export class TransformRegistry<TInput, TOutput, TAncestor> { + private rules: TransformRule<TInput, TOutput, TAncestor>[] = [] + + register(rule: TransformRule<TInput, TOutput, TAncestor>) { + this.rules.push(rule) + this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + } + + transform(node: TInput, ctx: TransformContext<TAncestor>, direction?: Direction): TOutput { + const rule = direction + ? this.rules.find(r => r.direction === direction && r.match(node)) + : this.rules.find(r => r.match(node)) + if (!rule) throw new Error(`No rule for node`) + return rule.transform(node, ctx, (n, c) => this.transform(n, c, direction)) + } +} +``` + +With schema-based matching, we can also do: + +```typescript +// Validate a node against a rule's schema before transforming +function matchesSchema(schema: TSchema, node: unknown): boolean { + return Value.Check(schema, node) +} +``` + +### Bi-Directional Markdown Rules + +```typescript +// markdown/rules.ts +import { Type } from "@alkdev/typebox" + +const HeadingUSchema = Type.Object({ + type: Type.String({ pattern: '^h[1-6]$' }), + props: Type.Record(Type.String(), Type.Any()), + children: Type.Array(Type.Any()), +}) + +const HeadingMdastSchema = Type.Object({ + type: Type.Literal('heading'), + depth: Type.Number(), + children: Type.Array(Type.Any()), +}) + +// UJSX → mdast +registry.register({ + name: 'heading-ujsx-to-mdast', + direction: 'ujsx->mdast', + schema: HeadingUSchema, + match: (n) => isUElement(n) && /^h[1-6]$/.test(String(n.type)), + transform: (n, ctx, next) => ({ + type: 'heading', + depth: Number(String(n.type).slice(1)), + children: transformChildren(n, ctx, next), + }), +}) + +// mdast → UJSX +registry.register({ + name: 'heading-mdast-to-ujsx', + direction: 'mdast->ujsx', + schema: HeadingMdastSchema, + match: (n) => n.type === 'heading', + transform: (n, ctx, next) => ({ + type: `h${n.depth}`, + props: {}, + children: (n.children || []).map((c, i) => next(c, childCtx(n, ctx, i))), + }), +}) +``` + +This is the bi-directional concept. Same registry, same `transform()` call, different `direction` parameter. The schemas provide the contract for each direction. + +### Component Schema = Tool Schema + +The real payoff of TypeBox-driven schemas: any component's props schema is automatically a tool parameter schema. + +```typescript +// A HUD component defined with TypeBox +const TaskSectionProps = Type.Object({ + task: Type.Object({ + description: Type.String(), + updatedAt: Type.Number(), + }), + density: Type.Union([Type.Literal('full'), Type.Literal('compact'), Type.Literal('minimal')]), +}) + +// The component +function TaskSection(props: Static<typeof TaskSectionProps>): UElement { + if (props.density === 'minimal') { + return h('p', null, props.task.description) + } + return h('div', null, + h('h2', null, 'Task'), + h('p', null, props.task.description), + ) +} + +// TaskSectionProps IS the schema for an agent tool that sets the task: +// hud({ tool: "task.set", args: { description: "...", updatedAt: Date.now() } }) +// The tool's inputSchema = TaskSectionProps +``` + +No duplication. The component's type IS the tool's schema. `Value.Check(TaskSectionProps, input)` validates both. + +### Context System + +Needed for passing density/target config down the tree without prop drilling: + +```typescript +// core/context.ts + +export interface ContextValue { + density: 'full' | 'compact' | 'minimal' + target: string // 'markdown' | 'graph' | 'html' | etc. + metadata: Record<string, unknown> +} + +// Simple context implementation (no React dependency) +type ContextListener = (value: ContextValue) => void + +export class Context { + private value: ContextValue + private listeners: Set<ContextListener> = new Set() + + constructor(initial: ContextValue) { + this.value = initial + } + + get(): ContextValue { return this.value } + + set(partial: Partial<ContextValue>) { + this.value = { ...this.value, ...partial } + for (const fn of this.listeners) fn(this.value) + } + + subscribe(fn: ContextListener): () => void { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } +} +``` + +This is lightweight and doesn't need React. Components receive context via the `host` or `transform` path. + +### File Structure (Proposed Rewrite) + +``` +ujsx/ + mod.ts # Re-exports + deno.json # JSX transform config + core/ + schema.ts # TypeBox schemas for UNode, UElement, URoot + h.ts # h() factory, createRoot(), Fragment + context.ts # Context system for density/target + jsx-runtime.ts # JSX transform runtime exports + host/ + config.ts # HostConfig interface + createRoot reconciler (preserved) + graphology.ts # Graphology host (preserved, fix commented-out append) + markdown.ts # NEW: Markdown host using HostConfig pattern + transform/ + registry.ts # Enhanced TransformRegistry with direction + schema + rule.ts # TransformRule type with TSchema + streaming/ + transformer.ts # Chunk-based async transformer (preserved) + +aui/ + mod.ts + deno.json + markdown/ + render.ts # renderMarkdown() (preserved, uses new registry) + rules.ts # Bi-directional rules with TypeBox schemas + types.ts # mdast type re-exports + components/ # NEW: Pre-built markdown-targeted components + container.ts # <Container> + context-bar.ts # <ContextBar> with density awareness + heading.ts # <Heading level={1-6}> + list.ts # <List>, <ListItem>, <TaskList> + code.ts # <Code>, <CodeBlock> + blockquote.ts # NEW: <Blockquote> + link.ts # NEW: <Link> + table.ts # NEW: <Table> + hr.ts # NEW: <Divider> + hud/ # NEW: HUD-specific components + HUD.tsx # Root HUD component + TaskSection.tsx # Task display + DecisionsList.tsx # Key decisions + NotesSection.tsx # Scratchpad + NextSteps.tsx # Plan/next steps + ActiveFiles.tsx # Currently active files + WarningBanner.tsx # Context pressure warning +``` + +### tsconfig for JSX Syntax + +```json +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@ade/ujsx" + } +} +``` + +With this, you write: + +```tsx +import { h } from "@ade/ujsx" + +function TaskSection({ task, density }: TaskSectionProps) { + if (density === 'minimal') { + return <p>{task.description}</p> + } + return ( + <div> + <h2>Task</h2> + <p>{task.description}</p> + </div> + ) +} +``` + +Instead of: + +```typescript +function TaskSection({ task, density }: TaskSectionProps) { + if (density === 'minimal') { + return h('p', null, task.description) + } + return h('div', null, + h('h2', null, 'Task'), + h('p', null, task.description), + ) +} +``` + +### Migration Steps + +1. **Replace types.ts with schema.ts** — TypeBox schemas for UNode/UElement/URoot. Export both schemas and `Static<>` types. +2. **Clean up h.ts** — Remove metadata injection, HTML-specific props, `Math.random()`. Add `jsx-runtime.ts` export. +3. **Add deno.json jsxImportSource** — Enable JSX syntax. +4. **Strip UniversalProps** — Replace with plain `Record<string, unknown>`. No `onClick`, `className`, etc. +5. **Upgrade TransformRegistry** — Add `direction` and `schema` fields to rules. +6. **Add missing markdown rules** — blockquote, link, image, hr, table. +7. **Add Context** — Simple context system for density/target. +8. **Fix graphology host** — Uncomment append logic, replace `JSON.stringify` diffing. +9. **Add Markdown host** — `createMarkdownHost()` using HostConfig. +10. **Add bi-directional rules** — mdast → ujsx direction for each element. +11. **Build HUD components** — The `aui/hud/` directory with density-aware components. + +### What This Enables for the Agent HUD + +With the cleaned-up UJSX: +- Components ARE schemas — any HUD component's props schema is automatically a tool parameter schema +- Bi-directional transforms mean we can parse markdown INTO the IR (parse existing agent notes, markdown files, AGENTS.md) +- The host system means the same HUD component tree could render to markdown for LLM consumption OR to a graph for visualization OR to HTML for a web dashboard +- TypeBox validation means we can validate HUD events against component schemas before accepting them +- The Context system carries density/target through the component tree — clean and no prop drilling \ No newline at end of file diff --git a/docs/research/unist-ecosystem-jsx-to-markdown.md b/docs/research/unist-ecosystem-jsx-to-markdown.md new file mode 100644 index 0000000..c4946f7 --- /dev/null +++ b/docs/research/unist-ecosystem-jsx-to-markdown.md @@ -0,0 +1,1211 @@ +# Unist Ecosystem Research: JSX → Markdown Pipeline for LLM Consumption + +**Date**: 2026-04-28 +**Topic**: Feasibility of JSX components → hast → mdast → markdown pipeline using the Unist/syntax-tree ecosystem + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [unist: The Universal Foundation](#2-unist-the-universal-foundation) +3. [hast: Hypertext Abstract Syntax Tree](#3-hast-hypertext-abstract-syntax-tree) +4. [mdast: Markdown Abstract Syntax Tree](#4-mdast-markdown-abstract-syntax-tree) +5. [hast-util-to-mdast: The Key Transform](#5-hast-util-to-mdast-the-key-transform) +6. [mdast-util-to-markdown: Serialization to Markdown](#6-mdast-util-to-markdown-serialization-to-markdown) +7. [remark/rehype Ecosystem](#7-remarkrehype-ecosystem) +8. [unist-util-visit and Related Utilities](#8-unist-util-visit-and-related-utilities) +9. [TypeScript Type Definitions](#9-typescript-type-definitions) +10. [Pipeline Feasibility Assessment](#10-pipeline-feasibility-assessment) +11. [Alternative Approaches](#11-alternative-approaches) +12. [Recommended Architecture](#12-recommended-architecture) +13. [Appendix: Element-to-Markdown Mapping Table](#13-appendix-element-to-markdown-mapping-table) + +--- + +## 1. Executive Summary + +The JSX → hast → mdast → markdown pipeline is **feasible and well-supported** by mature, well-typed libraries in the unist/syntax-tree ecosystem. The core transformation chain is: + +``` +JSX Component Tree → hast (HTML AST) → mdast (Markdown AST) → markdown string + │ │ │ │ + React rendering hast-util-from-html hast-util-to-mdast mdast-util-to-markdown + or react-dom/ or manual hast (v10.1.2) (v2.1.2) + server rendering construction +``` + +**Key finding**: The hardest step is not hast→mdast→markdown (which is solved by existing, mature libraries), but rather **JSX → hast** and handling **custom components** that have no direct HTML/markdown equivalent. The ecosystem provides excellent tooling for standard HTML elements but requires a custom strategy for framework-specific components. + +**Verdict**: Use the existing unist ecosystem libraries for the hast→mdast→markdown steps. Build a custom JSX→hast adapter layer that handles React component rendering and custom element mapping. + +--- + +## 2. unist: The Universal Foundation + +**Repository**: https://github.com/syntax-tree/unist +**Current version**: 3.0.0 +**License**: CC-BY-4.0 + +unist is the abstract base specification that hast, mdast, xast, and nlcst all implement. It defines the minimal node interface that all syntax tree nodes share. + +### Core Node Interface + +```typescript +interface Node { + type: string // Non-empty string identifying the node variant + data?: Data // Ecosystem-specific metadata + position?: Position // Source location info +} + +interface Parent <: Node { + children: [Node] // Child nodes +} + +interface Literal <: Node { + value: any // Node's value +} + +interface Position { + start: Point + end: Point +} + +interface Point { + line: number // 1-indexed + column: number // 1-indexed + offset?: number // 0-indexed +} +``` + +### Design Principles + +- All values must be JSON-serializable (no functions, undefined, symbols) +- Trees can survive `JSON.parse(JSON.stringify(tree))` roundtrips +- `data` field is reserved for ecosystem use; specifications never define fields on it +- `position` must be absent on generated nodes + +### Why This Matters for Our Pipeline + +The JSON-serializability constraint means the AST is inherently portable and can be passed between contexts (server/client, different frameworks). The `data` field provides an escape hatch for custom metadata that custom component handlers can use. + +--- + +## 3. hast: Hypertext Abstract Syntax Tree + +**Repository**: https://github.com/syntax-tree/hast +**Spec version**: 2.4.0 +**Type definitions**: `@types/hast` +**Stars**: 892 + +hast represents HTML (and embedded SVG/MathML) as an abstract syntax tree. It extends unist. + +### Node Types + +| Node Type | Extends | Description | Key Fields | +|-----------|---------|-------------|------------| +| **`Root`** | Parent | Document root | `children` | +| **`Element`** | Parent | HTML element | `tagName`, `properties`, `children`, `content?` | +| **`Text`** | Literal | Text content | `value` | +| **`Comment`** | Literal | HTML comment | `value` | +| **`Doctype`** | Node | Document type declaration | (none beyond unist Node) | + +### Element Interface (the workhorse) + +```typescript +interface Element <: Parent { + type: 'element' + tagName: string // e.g., 'div', 'span', 'custom-card' + properties: Properties // HTML attributes mapped to DOM properties + content?: Root // Only for <template> elements + children: [Comment | Element | Text] +} +``` + +### Properties System + +hast uses DOM-style property names, not HTML attribute names: + +| HTML Attribute | hast Property | +|----------------|---------------| +| `class` | `className` (array: `['foo', 'bar']`) | +| `for` | `htmlFor` | +| `data-*` | `data*` (camelCase) | +| `aria-*` | `aria*` (camelCase) | +| `tabindex` | `tabIndex` | +| `colspan` | `colSpan` | + +Property values: +- Boolean attributes: `true`/`false` +- Numeric attributes: `number` +- Space-separated: `string[]` (e.g., `className: ['foo', 'bar']`) +- All other: `string` + +### Example: hast tree for HTML + +HTML: +```html +<a href="https://alpha.com" class="bravo" download>Link</a> +``` + +hast: +```json +{ + "type": "element", + "tagName": "a", + "properties": { + "href": "https://alpha.com", + "className": ["bravo"], + "download": true + }, + "children": [{"type": "text", "value": "Link"}] +} +``` + +### Key Utilities for hast Construction + +- **`hastscript`** (v9.0.1) — `h()` function to create hast trees, like React's `createElement`. Supports JSX via automatic runtime (`@jsxImportSource hastscript`). +- **`hast-util-from-html`** — Parse HTML string to hast +- **`hast-util-from-dom`** — Convert browser DOM nodes to hast +- **`hast-util-to-html`** — Serialize hast to HTML string +- **`hast-util-to-jsx-runtime`** (v2.3.6) — Convert hast to React/Preact/Solid/Svelte/Vue (the *reverse* direction of what we need) +- **`hast-util-select`** — CSS selector queries on hast trees (`querySelector`, etc.) + +### hastscript JSX Support + +This is significant: hastscript supports using JSX syntax to directly create hast trees: + +```jsx +/** @jsxImportSource hastscript */ +const tree = ( + <div class="foo" id="some-id"> + <span>some text</span> + <input type="text" value="foo" /> + </div> +) +``` + +This produces a **hast tree**, not a React element. This is a potential alternative entry point for our pipeline. + +--- + +## 4. mdast: Markdown Abstract Syntax Tree + +**Repository**: https://github.com/syntax-tree/mdast +**Spec version**: 5.0.0 +**Type definitions**: `@types/mdast` +**Stars**: 1.4k + +mdast represents markdown as an abstract syntax tree. It extends unist. + +### Core Node Types (CommonMark) + +| Node Type | Category | Description | Key Fields | +|-----------|----------|-------------|------------| +| **`Root`** | — | Document root | `children` | +| **`Paragraph`** | Content | Text paragraph | `children: [PhrasingContent]` | +| **`Heading`** | Flow | Section heading | `depth: 1-6`, `children: [PhrasingContent]` | +| **`Blockquote`** | Flow | Quoted section | `children: [FlowContent]` | +| **`List`** | Flow | Ordered/unordered list | `ordered`, `start`, `spread`, `children: [ListItem]` | +| **`ListItem`** | ListContent | List item | `spread`, `checked?`, `children: [FlowContent]` | +| **`Code`** | Flow | Fenced/indented code block | `value`, `lang?`, `meta?` | +| **`ThematicBreak`** | Flow | Horizontal rule `---` | (none) | +| **`Html`** | Flow/Phrasing | Raw HTML in markdown | `value` | +| **`Definition`** | Content | Link/image reference def | `identifier`, `label`, `url`, `title` | +| **`Text`** | Phrasing | Plain text | `value` | +| **`Emphasis`** | Phrasing | Italic `*text*` | `children: [PhrasingContent]` | +| **`Strong`** | Phrasing | Bold `**text**` | `children: [PhrasingContent]` | +| **`InlineCode`** | Phrasing | Inline code `` `code` `` | `value` | +| **`Break`** | Phrasing | Hard line break | (none) | +| **`Link`** | Phrasing | Hyperlink | `url`, `title?`, `children: [PhrasingContent]` | +| **`LinkReference`** | Phrasing | Link by reference | `identifier`, `label`, `referenceType` | +| **`Image`** | Phrasing | Image | `url`, `title?`, `alt?` | +| **`ImageReference`** | Phrasing | Image by reference | `identifier`, `label`, `referenceType`, `alt?` | + +### GFM Extension Nodes + +| Node Type | Description | Key Fields | +|-----------|-------------|------------| +| **`Delete`** | Strikethrough `~~text~~` | `children: [PhrasingContent]` | +| **`Table`** | Table | `align?: [alignType]`, `children: [TableRow]` | +| **`TableRow`** | Table row | `children: [TableCell]` | +| **`TableCell`** | Table cell | `children: [PhrasingContent]` | +| **`FootnoteDefinition`** | Footnote def | `identifier`, `label`, `children: [FlowContent]` | +| **`FootnoteReference`** | Footnote ref | `identifier`, `label` | + +### Content Model Hierarchy + +``` +MdastContent = FlowContent | ListContent | PhrasingContent + +FlowContent = Blockquote | Code | Heading | Html | List | ThematicBreak | Paragraph +ListContent = ListItem +PhrasingContent = Break | Emphasis | Html | Image | ImageReference | InlineCode + | Link | LinkReference | Strong | Text + + GFM: Delete | FootnoteReference +``` + +This hierarchy is critical: hast-util-to-mdast must map HTML's content model (which doesn't have this distinction) into mdast's strict flow/phrasing content model. + +### Mixin Types + +```typescript +interface Resource { + url: string + title?: string +} + +interface Alternative { + alt?: string +} + +interface Association { + identifier: string + label?: string +} + +interface Reference { + referenceType: 'shortcut' | 'collapsed' | 'full' +} +``` + +--- + +## 5. hast-util-to-mdast: The Key Transform + +**Repository**: https://github.com/syntax-tree/hast-util-to-mdast +**Current version**: 10.1.2 +**License**: MIT +**Stars**: 43 + +This is the critical library in the pipeline — it converts hast (HTML AST) into mdast (Markdown AST). + +### API + +```typescript +import { toMdast } from 'hast-util-to-mdast' + +const mdastTree = toMdast(hastTree, options?) +``` + +### Options + +```typescript +interface Options { + newlines?: boolean // Keep line endings when collapsing whitespace (default: false) + checked?: string // Value for checked checkbox (default: '[x]') + unchecked?: string // Value for unchecked checkbox (default: '[ ]') + quotes?: string[] // Quote characters for <q> nesting (default: ['"']) + document?: boolean // Whether tree is a complete document (default: auto-detect) + handlers?: Record<string, Handle> // Custom element handlers + nodeHandlers?: Record<string, NodeHandle> // Custom node type handlers +} +``` + +### Custom Handlers + +This is the **extensibility mechanism** most relevant to our use case: + +```typescript +type Handle = ( + state: State, + element: Element, + parent: HastParent +) => Array<MdastNode> | MdastNode | undefined + +type NodeHandle = ( + state: State, + node: any, + parent: HastParent +) => Array<MdastNode> | MdastNode | undefined +``` + +The `handlers` option maps HTML tag names to custom conversion functions. Custom handlers are **merged** into the defaults, so you can override specific tags without reimplementing everything. + +The `nodeHandlers` option maps hast node types (like `'text'`, `'comment'`) to custom handlers. + +### State Object + +Passed to all handlers: + +```typescript +interface State { + patch: (from: HastNode, to: MdastNode) => undefined // Copy positional info + one: (node: HastNode, parent?: HastParent) => MdastNode // Transform single node + all: (parent: HastParent) => Array<MdastContent> // Transform children + toFlow: (nodes: Array<MdastContent>) => Array<MdastFlowContent> // Promote to flow content + resolve: (url: string | null | undefined) => string // Resolve URLs + options: Options + elementById: Map<string, Element> + handlers: Record<string, Handle> + nodeHandlers: Record<string, NodeHandle> + inTable: boolean // Whether we're inside a table + qNesting: number // <q> nesting depth +} +``` + +### How It Handles Different Element Categories + +#### Inline Elements (Phrasing Content) + +| HTML Element | mdast Node | Notes | +|-------------|------------|-------| +| `<strong>`, `<b>` | `strong` | Children processed recursively | +| `<em>`, `<i>` | `emphasis` | Children processed recursively | +| `<code>` | `inlineCode` | Value extracted from text child | +| `<a href="...">` | `link` | `url` from `href`, `title` from `title` attr | +| `<br>` | `break` | Hard line break | +| `<del>`, `<s>`, `<strike>` | `delete` (GFM) | Strikethrough | +| `<q>` | `text` with quotes | Uses `quotes` option for nesting | +| `<img>` | `image` | `url` from `src`, `alt` from `alt` attr | +| `<sub>`, `<sup>`, `<mark>`, etc. | Text content only | Non-semantic in markdown; children extracted | +| `<input type="checkbox">` | `text` | Uses `checked`/`unchecked` options | + +#### Block Elements (Flow Content) + +| HTML Element | mdast Node | Notes | +|-------------|------------|-------| +| `<h1>`–`<h6>` | `heading` (depth 1-6) | | +| `<p>` | `paragraph` | | +| `<blockquote>` | `blockquote` | | +| `<ul>` | `list` (ordered: false) | | +| `<ol>` | `list` (ordered: true, start) | | +| `<li>` | `listItem` | GFM: `checked` for task lists | +| `<pre><code>` | `code` | `lang` from class (`language-js`), `meta` from data attributes | +| `<table>` | `table` (GFM) | With `align` from `align` attribute | +| `<tr>` | `tableRow` | | +| `<td>`/`<th>` | `tableCell` | | +| `<hr>` | `thematicBreak` | | +| `<dl>`, `<dt>`, `<dd>` | Paragraphs | No markdown equivalent; downgraded | + +#### Special Behaviors + +- **`<template>`**: Content is processed from the `content` field +- **`<noscript>`**: Children processed as if scripting is disabled +- **`<svg>`**, **`<math>`**: **Ignored** by default (no markdown equivalent) +- **`<video>`**, **`<audio>`**, **`<iframe>`**: Converted to **links** to the source +- **`<form>`** elements: Processed for their text content +- **Implicit paragraphs**: The algorithm correctly handles HTML's implicit paragraph model (e.g., text + heading inside a container gets proper paragraph wrapping) + +#### `data-mdast` Attribute + +Elements with `data-mdast="ignore"` are excluded from output: + +```html +<p><strong>Important</strong> and <em data-mdast="ignore">ignored</em>.</p> +``` +→ `**Important** and .` + +### Algorithm + +The algorithm is described as "very powerful" and handles all HTML elements including ancient and obscure ones. It is particularly good at: + +1. **Implicit/explicit paragraph handling**: Correctly wraps loose text in paragraphs when adjacent to block elements +2. **Whitespace collapsing**: Collapses inter-element whitespace to single spaces (configurable with `newlines`) +3. **Content model enforcement**: Ensures phrasing content doesn't end up in flow contexts (auto-wraps in paragraphs) +4. **GFM output**: Tables produce GFM `table` nodes; `<del>`/`<s>`/`<strike>` produce `delete` nodes + +### Custom Handler Example: Preserving SVG as Raw HTML + +```typescript +import { toHtml } from 'hast-util-to-html' + +const mdast = toMdast(hast, { + handlers: { + svg(state, node) { + const result = { type: 'html', value: toHtml(node, { space: 'svg' }) } + state.patch(node, result) + return result + } + } +}) +``` + +This pattern — converting an unhandled element to an mdast `html` node — is the standard escape hatch for elements that don't map cleanly to markdown. + +--- + +## 6. mdast-util-to-markdown: Serialization to Markdown + +**Repository**: https://github.com/syntax-tree/mdast-util-to-markdown +**Current version**: 2.1.2 +**License**: MIT +**Stars**: 139 + +This library serializes an mdast tree back to a markdown string. + +### API + +```typescript +import { toMarkdown } from 'mdast-util-to-markdown' + +const markdown = toMarkdown(mdastTree, options?) +``` + +### Key Options + +```typescript +interface Options { + // List formatting + bullet?: '*' | '+' | '-' // Unordered list marker (default: '*') + bulletOther?: '*' | '+' | '-' // Fallback list marker (default: '-') + bulletOrdered?: '.' | ')' // Ordered list marker (default: '.') + listItemIndent?: 'mixed' | 'one' | 'tab' // List item indentation (default: 'one') + incrementListMarker?: boolean // Increment ordered list numbers (default: true) + + // Heading formatting + closeAtx?: boolean // Close ATX headings with trailing #s (default: false) + setext?: boolean // Use setext headings when possible (default: false) + + // Emphasis/strong markers + emphasis?: '*' | '_' // Emphasis marker (default: '*') + strong?: '*' | '_' // Strong marker (default: '*') + + // Code blocks + fence?: '`' | '~' // Fenced code marker (default: '`') + fences?: boolean // Always use fenced code (default: true) + + // Links + resourceLink?: boolean // Always use resource links (default: false) + quote?: '"' | "'" // Title quote character (default: '"') + + // Thematic breaks + rule?: '*' | '-' | '_' // Thematic break marker (default: '*') + ruleRepetition?: number // Number of markers (default: 3, min: 3) + ruleSpaces?: boolean // Spaces between markers (default: false) + + // Definitions + tightDefinitions?: boolean // No blank lines between definitions (default: false) + + // Extensibility + handlers?: Handlers // Custom node type handlers + join?: Array<Join> // Custom block-joining behavior + unsafe?: Array<Unsafe> // Characters that need escaping in contexts + extensions?: Array<Options> // Extension options (e.g., GFM) +} +``` + +### GFM Support + +GFM output is achieved by using the `mdast-util-gfm` extension: + +```typescript +import { toMarkdown } from 'mdast-util-to-markdown' +import { gfmToMarkdown } from 'mdast-util-gfm' + +const markdown = toMarkdown(tree, { + extensions: [gfmToMarkdown()] +}) +``` + +This adds support for: +- Tables (`| col1 | col2 |`) +- Strikethrough (`~~text~~`) +- Task lists (`- [x] item`) +- Autolink literals +- Footnotes + +### Safety / Escaping + +The library carefully escapes characters that would be interpreted as markdown syntax: + +```typescript +// Character that would break markdown is properly escaped: +Input mdast: { type: 'text', value: '- a\nb !' } +Output: \- a\nb \! +``` + +This is handled via the `Unsafe` type system, which specifies which characters are dangerous in which constructs. + +### Custom Handlers + +```typescript +type Handle = (node, parent, state, info) => string + +type Handlers = Record<Node['type'], Handle> +``` + +Custom handlers can be provided for any node type, including custom/extension node types. + +--- + +## 7. remark/rehype Ecosystem + +### Architecture + +``` +unified (core processor) + ├── remark (markdown) ─── mdast ─── remarkParse / remarkStringify + ├── rehype (HTML) ────── hast ───── rehypeParse / rehypeStringify + └── retext (NLP) ────── nlcst ───── retextEnglish / retextContent +``` + +### Cross-Ecosystem Plugins + +| Plugin | Direction | Description | +|--------|-----------|-------------| +| `remark-rehype` | mdast → hast | Markdown to HTML (the common direction) | +| `rehype-remark` | hast → mdast | HTML to Markdown (our direction!) | +| `remark-retext` | mdast → nlcst | Markdown to NLP | +| `rehype-retext` | hast → nlcst | HTML to NLP | + +### rehype-remark (v10.0.1) + +**Repository**: https://github.com/rehypejs/rehype-remark +**Stars**: 99 + +This is the higher-level wrapper around `hast-util-to-mdast`. It operates as a unified plugin: + +```typescript +import { unified } from 'unified' +import rehypeParse from 'rehype-parse' +import rehypeRemark from 'rehype-remark' +import remarkStringify from 'remark-stringify' + +const file = await unified() + .use(rehypeParse) // HTML → hast + .use(rehypeRemark) // hast → mdast (uses hast-util-to-mdast internally) + .use(remarkStringify) // mdast → markdown (uses mdast-util-to-markdown internally) + .process(htmlString) + +console.log(String(file)) +``` + +**Recommendation**: For our JSX→markdown pipeline, we should use the lower-level utilities (`hast-util-to-mdast` + `mdast-util-to-markdown`) directly rather than the unified processor chain. This avoids the overhead of the unified pipeline and gives us more control. + +However, `rehype-remark` plus remark plugins could be useful if we want to add post-processing transformations (e.g., `remark-gfm` for explicit GFM support). + +### Available remark Plugins (for post-processing) + +- `remark-gfm` — GFM syntax support +- `remark-frontmatter` — YAML/TOML frontmatter +- `remark-mdx` — MDX syntax support +- `remark-lint` — Markdown linting +- `remark-toc` — Table of contents generation +- `remark-comment-config` — Configure remark from HTML comments +- 150+ total plugins + +--- + +## 8. unist-util-visit and Related Utilities + +**Repository**: https://github.com/syntax-tree/unist-util-visit +**Current version**: 5.1.0 +**License**: MIT + +### Core Traversal: unist-util-visit + +```typescript +import { visit, CONTINUE, EXIT, SKIP } from 'unist-util-visit' + +visit(tree, 'heading', (node, index, parent) => { + // Return CONTINUE (default), EXIT, SKIP, or a new index + if (node.depth === 1) return EXIT + if (node.depth === 2) return SKIP // Don't visit children + return [SKIP, 5] // Skip children, continue from index 5 +}) +``` + +### Complete Utility Landscape + +| Utility | Purpose | Stars | +|---------|---------|-------| +| `unist-util-visit` | Walk tree depth-first | 346 | +| `unist-util-visit-parents` | Walk with parent stack | — | +| `unist-util-is` | Check if node matches test | — | +| `unist-util-filter` | Create filtered tree | — | +| `unist-util-map` | Create mapped tree | — | +| `unist-util-remove` | Remove nodes from tree | — | +| `unist-util-select` | CSS-like selectors on trees | — | +| `unist-util-find-after` | Find node after another | — | +| `unist-util-find-and-replace` | Find/replace text in tree | — | +| `unist-builder` | Create trees programmatically | — | + +### hast-specific Utilities + +| Utility | Purpose | +|---------|---------| +| `hast-util-is-element` | Check if node is (a specific) element | +| `hast-util-select` | querySelector/querySelectorAll on hast | +| `hast-util-find-and-replace` | Text find/replace in hast | +| `hast-util-classnames` | Merge class names | +| `hast-util-to-string` | Get textContent | +| `hast-util-to-text` | Get innerText | +| `hast-util-phrasing` | Check if node is phrasing content | +| `hast-util-heading` | Check if node is heading content | +| `hast-util-embedded` | Check if node is embedded content | +| `hast-util-sanitize` | Sanitize tree (XSS prevention) | + +### mdast-specific Utilities + +| Utility | Purpose | +|---------|---------| +| `mdast-util-to-string` | Get plain text content | +| `mdast-util-definitions` | Find definition nodes | +| `mdast-util-heading-range` | Use headings as ranges | +| `mdast-util-toc` | Generate TOC | +| `mdast-util-phrasing` | Check if node is phrasing content | +| `mdast-util-gfm` | GFM parse/serialize | +| `mdast-util-gfm-table` | GFM tables specifically | +| `mdast-util-directive` | Generic directives | + +--- + +## 9. TypeScript Type Definitions + +### Availability + +| Package | Types | Source | +|---------|-------|--------| +| `@types/unist` | Built-in | DefinitelyTyped | +| `@types/hast` | Built-in | DefinitelyTyped | +| `@types/mdast` | Built-in | DefinitelyTyped | +| `hast-util-to-mdast` | **Shipped with package** | Included TypeScript types + `index.d.ts` | +| `mdast-util-to-markdown` | **Shipped with package** | Included TypeScript types + `index.d.ts` | +| `unist-util-visit` | **Shipped with package** | Included TypeScript types + `index.d.ts` | +| `hastscript` | **Shipped with package** | Included TypeScript types | +| `hast-util-to-jsx-runtime` | **Shipped with package** | Included TypeScript types | +| `rehype-remark` | **Shipped with package** | Included TypeScript types | +| `unified` | **Shipped with package** | Included TypeScript types | + +### Type Quality + +**Excellent**. All packages in the syntax-tree and unified ecosystems are written in TypeScript or ship hand-written `.d.ts` files. The type definitions are comprehensive and well-maintained. Key observations: + +1. **Hast types are well-defined**: `Element`, `Text`, `Comment`, `Root`, `Properties`, `PropertyValue` are all properly typed +2. **Mdast types are well-defined**: All node types with their specific fields, plus content model types like `FlowContent`, `PhrasingContent` +3. **Handler types are exported**: `Handle`, `NodeHandle`, `Options`, `State` from hast-util-to-mdast +4. **Serializer types are exported**: `Handle`, `Handlers`, `Options`, `Unsafe`, `Join` from mdast-util-to-markdown +5. **Generic node types**: The TypeScript types support discriminated unions on `node.type` + +### Type Usage Example + +```typescript +import type { Element, Root, Text } from 'hast' +import type { Root as MdastRoot, Heading, Code } from 'mdast' +import type { Handle, Options as ToMdastOptions } from 'hast-util-to-mdast' +import type { Options as ToMarkdownOptions } from 'mdast-util-to-markdown' +``` + +--- + +## 10. Pipeline Feasibility Assessment + +### The Full Pipeline + +``` +┌─────────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ JSX Components │ ──▶ │ hast │ ──▶ │ mdast │ ──▶ │ markdown │ +│ (React tree) │ │ (HTML AST)│ │ (MD AST) │ │ (string) │ +└─────────────────┘ └──────────┘ └──────────┘ └──────────┘ + │ │ │ │ + STEP 1: STEP 2: STEP 3: STEP 4: + JSX → hast hast → mdast mdast → md md string + (the solved (well-solved) + problem) +``` + +### Step-by-Step Assessment + +#### Step 1: JSX → hast (THE HARDEST STEP) + +**Challenge**: React components are not HTML. They have: +- Custom component elements (`<Card>`, `<UserAvatar>`) that don't map to HTML tags +- Props that aren't HTML attributes +- Rendering logic (conditionals, loops, state) +- Event handlers that are meaningless in markdown + +**Approaches**: + +**Approach A: Render to HTML string, then parse to hast** +```typescript +import { renderToStaticMarkup } from 'react-dom/server' +import { fromHtml } from 'hast-util-from-html' + +const html = renderToStaticMarkup(<MyComponent />) +const hast = fromHtml(html, { fragment: true }) +``` +- Pros: Complete rendering support, handles all React features +- Cons: Loss of custom element information, all components flatten to HTML + +**Approach B: Use React's internal fiber tree to build hast directly** +- Pros: More control, can preserve custom element boundaries +- Cons: Relies on React internals, fragile + +**Approach C: Use hastscript JSX runtime** +```jsx +/** @jsxImportSource hastscript */ +const tree = <div class="card"><h2>Title</h2><p>Body</p></div> +``` +- Pros: Direct hast construction, type-safe +- Cons: Can't use React components (no rendering), only raw elements + +**Recommendation**: **Approach A** for the base pipeline, with **custom component registry** for handling non-HTML elements (see below). + +#### Step 2: hast → mdast (SOLVED by hast-util-to-mdast) + +- This step is fully solved by `hast-util-to-mdast` v10.1.2 +- Handles all standard HTML elements +- Custom handlers for non-standard elements +- Proper paragraph wrapping, whitespace handling, content model enforcement +- GFM support (tables, strikethrough, task lists) + +**Edge cases to watch**: +- Nested inline elements: `<strong><em>bold italic</em></strong>` → `***bold italic***` +- Mixed content: `<div>Text <h2>Heading</h2> More text</div>` → Proper paragraph wrapping +- Deeply nested lists +- Tables with merged cells (not supported in GFM) +- `<br>` inside `<p>`: Produces `break` nodes in phrasing content + +#### Step 3: mdast → markdown (SOLVED by mdast-util-to-markdown) + +- Fully solved by `mdast-util-to-markdown` v2.1.2 +- Proper character escaping +- GFM output with `mdast-util-gfm` extension +- Configurable markers, indentation, heading styles + +### Edge Cases and Failure Modes + +#### Custom Components (The #1 Challenge) + +A React component like `<DataField label="Name" value="Alice" />` renders to HTML like `<div class="data-field"><span class="label">Name</span><span class="value">Alice</span></div>`. But the markdown output would just be "Name Alice" without the structure. + +**Solution**: Register custom handlers that use the component's semantic intent: + +```typescript +// Option 1: Use CSS class-based detection +handlers: { + // Custom class→markdown mapping + 'data-field': (state, node) => { + if (node.properties.className?.includes('data-field')) { + const label = findTextByClass(node, 'label') + const value = findTextByClass(node, 'value') + return { type: 'paragraph', children: [ + { type: 'strong', children: [{ type: 'text', value: label }] }, + { type: 'text', value: `: ${value}` } + ]} + } + } +} +``` + +```typescript +// Option 2: Use data attributes for semantic hints +// Render with: <div data-md-type="field" data-md-label="Name">Alice</div> +handlers: { + div: (state, node) => { + const mdType = node.properties.dataMdType + if (mdType === 'field') { + // Custom conversion + } + return defaultHandlers.div(state, node, parent) + } +} +``` + +```typescript +// Option 3: Pre-transform the hast tree before passing to toMdast +// Walk the hast tree, replace custom structures with semantic mdast nodes +visit(hast, 'element', (node) => { + if (node.properties.className?.includes('callout')) { + // Transform the element's structure to something that maps cleanly + } +}) +``` + +#### SVG/Math Content + +- **Default behavior**: Ignored (content lost) +- **Workaround 1**: Convert to mdast `html` node (preserves as raw HTML in markdown) +- **Workaround 2**: Render to an image and use mdast `image` node +- **For LLM consumption**: Image alt text is likely more useful than raw SVG markup + +#### Forms and Interactive Elements + +- `<input>`, `<select>`, `<textarea>`: Processed for their text content +- Checkboxes become `[x]`/`[ ]` in GFM task lists +- Other form elements: Downgraded to text content + +#### CSS-Dependent Layout + +- Tables rendered via CSS grid/flexbox (not `<table>` elements) won't produce GFM tables +- Tab components rendered as `<div>` stacks won't produce meaningful markdown +- **Workaround**: Use semantic HTML (`<table>`, `<details>`, etc.) in the component's render output when markdown output is needed + +#### Content That Has No Markdown Equivalent + +| HTML Construct | Default Behavior | Better Alternative | +|---------------|-----------------|-------------------| +| `<details>/<summary>` | Text content only | Use mdast `html` node or custom directive | +| `<dialog>` | Ignored | Pre-process to extract content | +| `<meter>`, `<progress>` | Text content | Convert to descriptive text | +| `<ruby>`, `<rt>` | Text content | Custom handler for pronunciation annotation | +| `<iframe>` | Link to src | Custom handler for embed description | +| `<video>`, `<audio>` | Link to src | Custom handler for media description | + +#### Whitespace and Formatting + +- Multiple spaces in HTML collapse to single spaces by default +- `<pre>` whitespace is preserved in `code` nodes +- `newlines: true` option preserves line breaks during whitespace collapsing +- Indentation in `<pre>` blocks may need careful handling + +#### Roundtrip Fidelity + +Not all markdown constructs survive HTML roundtripping: +- Reference-style links (`[text][id]`) become direct links `[text](url)` +- Setext headings become ATX headings (configurable) +- Tight vs. loose lists may change +- Multiple markdown syntaxes collapse to one (e.g., both `*` and `_` for emphasis become `*`) + +**For LLM consumption, this is acceptable** — the goal is readable markdown, not perfect roundtripping. + +--- + +## 11. Alternative Approaches + +### Existing Libraries: React/JSX → Markdown + +There is **no mature, well-maintained library** that directly converts React component trees to markdown. The approaches that exist are simpler or serve different purposes: + +#### 1. react-markdown + remark (the reverse direction) +**React Markdown** renders markdown as React components. This is the **opposite** of what we need. Not relevant. + +#### 2. html-to-markdown (turndown) +**Repository**: https://github.com/mixmark-io/turndown +A widely-used HTML-to-markdown converter (not based on unist). It works on HTML strings, not ASTs. + +- **Pros**: Simple API, well-tested, many plugins, configurable rules +- **Cons**: Not AST-based, no TypeScript AST types, no GFM table support out-of-box, less extensible than unist ecosystem +- **Comparison**: `hast-util-to-mdast` is more principled (proper AST, content model enforcement, better extensibility) + +#### 3. react-to-markdown (hypothetical) +No prominent library exists under this name or concept on npm. Search terms "react to markdown", "jsx to mdast", "component to markdown" yield no direct results. + +#### 4. mdast-util-from-adf +Converts Atlassian Document Format to mdast. Demonstrates the pattern of converting external formats to mdast, but for a different source format. + +#### 5. hast-util-to-portable-text +Converts hast to Sanity's Portable Text format. Another example of hast → alternative format, but not markdown. + +#### 6. Custom JSX → mdast builders +One could build React components that directly produce mdast nodes using `mdast-builder` or manual construction: + +```typescript +function MarkdownHeading({ depth, children }) { + return { type: 'heading', depth, children } +} +``` + +- **Pros**: Direct control, no intermediate HTML +- **Cons**: Can't reuse existing React component ecosystem, dual rendering needed + +#### 7. React Server Components + hast +If using RSC, the output could be intercepted and converted to hast before HTML serialization. This is speculative and would require custom infrastructure. + +### Comparison Table + +| Approach | Maturity | Type Safety | Extensibility | Custom Components | GFM Support | +|----------|----------|-------------|---------------|-------------------|-------------| +| hast-util-to-mdast | **High** (v10) | Excellent | Excellent (handlers) | Needs custom layer | Built-in | +| turndown | High | Poor | Good (rules) | Needs custom rules | Plugin | +| Custom JSX→mdast | Low | Manual | Full control | Full control | Manual | +| unified/rehype-remark | High | Excellent | Excellent (plugins) | Needs custom layer | Via remark-gfm | + +**Recommended**: Use `hast-util-to-mdast` + `mdast-util-to-markdown` with custom JSX→hast adapter. + +--- + +## 12. Recommended Architecture + +### Overview + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ JSX → Markdown Pipeline │ +│ │ +│ ┌──────────────┐ ┌───────────────┐ ┌─────────┐ ┌───────────┐ │ +│ │ JSX Renderer │──▶│ hast Builder │──▶│ hast → │──▶│ mdast → │ │ +│ │ (React SSR) │ │ (fromHtml + │ │ mdast │ │ markdown │ │ +│ │ │ │ pre-process) │ │ │ │ (GFM) │ │ +│ └──────────────┘ └───────────────┘ └─────────┘ └───────────┘ │ +│ │ │ │ │ │ +│ ┌──────────────┐ ┌───────────────┐ │ │ │ +│ │ Component │ │ Custom │ │ │ │ +│ │ Registry │ │ Handlers │ │ │ │ +│ │ (markdown │ │ (data-md- │ │ │ │ +│ │ hints) │ │ attributes) │ │ │ │ +│ └──────────────┘ └───────────────┘ │ │ │ +│ │ │ │ +│ ┌────────┘ │ │ +│ │ │ │ +│ ┌──────────────┐ ┌──────────┘ │ +│ │ Post-process │ │ Output │ +│ │ (unist-util) │ │ (markdown string) │ +│ └──────────────┘ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### Implementation Design + +#### Phase 1: Core Pipeline + +```typescript +// renderToMarkdown.ts + +import { renderToStaticMarkup } from 'react-dom/server' +import { fromHtml } from 'hast-util-from-html' +import { toMdast } from 'hast-util-to-mdast' +import { toMarkdown } from 'mdast-util-to-markdown' +import { gfmToMarkdown } from 'mdast-util-gfm' +import type { Element, Root as HastRoot } from 'hast' +import type { Handle } from 'hast-util-to-mdast' + +interface RenderOptions { + /** Custom hast → mdast handlers */ + handlers?: Record<string, Handle> + /** Markdown serialization options */ + markdownOptions?: MarkdownOptions + /** Whether to produce GFM output (default: true) */ + gfm?: boolean + /** Pre-processing hook for the hast tree */ + preProcess?: (hast: HastRoot) => HastRoot + /** Post-processing hook for the mdast tree */ + postProcess?: (mdast: MdastRoot) => MdastRoot +} + +function renderToMarkdown(element: React.ReactElement, options?: RenderOptions): string { + // Step 1: Render JSX to HTML + const html = renderToStaticMarkup(element) + + // Step 2: Parse HTML to hast + let hast = fromHtml(html, { fragment: true }) + + // Step 3: Pre-process hast (optional: semantic annotation, cleanup) + if (options?.preProcess) { + hast = options.preProcess(hast) + } + + // Step 4: Convert hast to mdast + const mdast = toMdast(hast, { + handlers: { + ...defaultCustomHandlers, + ...options?.handlers, + }, + document: false, + }) + + // Step 5: Post-process mdast (optional: custom transforms) + if (options?.postProcess) { + mdast = options.postProcess(mdast) + } + + // Step 6: Serialize mdast to markdown + const extensions = options?.gfm !== false ? [gfmToMarkdown()] : [] + const markdown = toMarkdown(mdast, { + ...options?.markdownOptions, + extensions, + }) + + return markdown +} +``` + +#### Phase 2: Component Registry / Semantic Annotations + +The key innovation is a **data attribute convention** that components use to hint markdown semantics: + +```tsx +// Component renders with markdown hints +function InfoCallout({ title, children }) { + return ( + <div data-md="callout" data-md-callout-type="info"> + <strong data-md="callout-title">{title}</strong> + <div data-md="callout-content">{children}</div> + </div> + ) +} +``` + +Handler: +```typescript +const calloutHandler: Handle = (state, node) => { + const calloutType = node.properties.dataMdCalloutType || 'note' + const title = findChildByDataMd(node, 'callout-title') + const content = findChildByDataMd(node, 'callout-content') + + return { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + { type: 'strong', children: [{ type: 'text', value: calloutType.toUpperCase() }] }, + { type: 'text', value: ': ' }, + ...state.all(content), + ] + } + ] + } +} +``` + +Registry: +```typescript +const defaultCustomHandlers: Record<string, Handle> = { + // Map data-md attributes to handlers + div: (state, node, parent) => { + const mdType = node.properties.dataMd + switch (mdType) { + case 'callout': return calloutHandler(state, node) + case 'admonition': return admonitionHandler(state, node) + // ... extensible + default: return undefined // Fall through to default handler + } + } +} +``` + +#### Phase 3: Advanced - Dual-Mode Components + +For components that need to render differently in "markdown mode" vs "visual mode": + +```tsx +interface MarkdownAwareProps { + /** When defined, used for markdown output instead of the visual render */ + markdownContent?: string | React.ReactNode +} + +function DataTable({ data, markdownContent }: MarkdownAwareProps & DataTableProps) { + // Visual mode: rich table with sorting, pagination + // Markdown mode: simple GFM table + return ( + <div className="data-table" data-md="table" data-md-raw={markdownContent}> + {/* ... rich visual table ... */} + </div> + ) +} +``` + +#### Phase 4: Pre-Render Hooks + +For components that need full control over their markdown output: + +```typescript +interface MarkdownRenderer { + /** If returned, used directly as markdown instead of converting the hast */ + toMarkdown?(): string | MdastNode +} + +// In the pre-process step: +function preProcess(hast: HastRoot): HastRoot { + visit(hast, 'element', (node) => { + // Check if this element's component provided a markdown hint + const markdownDirect = node.properties.dataMdRaw + if (markdownDirect) { + // Replace the element with a text node containing the markdown + // (this will be parsed separately or stored as raw markdown) + } + }) + return hast +} +``` + +### Package Dependencies + +```json +{ + "dependencies": { + "hast-util-from-html": "^2.0.0", + "hast-util-to-mdast": "^10.0.0", + "mdast-util-to-markdown": "^2.0.0", + "mdast-util-gfm": "^3.0.0", + "unist-util-visit": "^5.0.0", + "hast-util-select": "^6.0.0", + "hast-util-to-string": "^3.0.0" + }, + "devDependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} +``` + +Estimated total bundle size: ~50-80KB (tree-shaken, minified) + +--- + +## 13. Appendix: Element-to-Markdown Mapping Table + +### Complete Mapping for Standard HTML Elements + +| HTML Element | mdast Output | Markdown | Notes | +|-------------|-------------|----------|-------| +| `<h1>` | `heading` (depth: 1) | `# ` | | +| `<h2>` | `heading` (depth: 2) | `## ` | | +| `<h3>` | `heading` (depth: 3) | `### ` | | +| `<h4>` | `heading` (depth: 4) | `#### ` | | +| `<h5>` | `heading` (depth: 5) | `##### ` | | +| `<h6>` | `heading` (depth: 6) | `###### ` | | +| `<p>` | `paragraph` | (blank line delimited) | | +| `<strong>`, `<b>` | `strong` | `**text**` | | +| `<em>`, `<i>` | `emphasis` | `*text*` | | +| `<del>`, `<s>`, `<strike>` | `delete` (GFM) | `~~text~~` | | +| `<code>` | `inlineCode` | `` `code` `` | | +| `<pre><code>` | `code` | ````\ncode\n```` | `lang` from `class="language-xxx"` | +| `<a href>` | `link` | `[text](url "title")` | | +| `<img>` | `image` | `![alt](src "title")` | | +| `<ul>` | `list` (ordered: false) | `- item` | | +| `<ol>` | `list` (ordered: true) | `1. item` | `start` attribute respected | +| `<li>` | `listItem` | (list item) | GFM: `checked` for task lists | +| `<blockquote>` | `blockquote` | `> text` | | +| `<hr>` | `thematicBreak` | `---` | | +| `<table>` | `table` (GFM) | `\| col \| col \|` | | +| `<tr>` | `tableRow` | | | +| `<th>`, `<td>` | `tableCell` | | `align` from `align` attr | +| `<br>` | `break` | (hard break) | Two spaces + newline or `\` | +| `<q>` | `text` with quotes | ("quoted") | Uses `quotes` option for nesting | +| `<input type="checkbox">` | `text` | `[x]` or `[ ]` | GFM task list item support | +| `<abbr>` | `text` | (just text) | Title lost | +| `<sup>` | `text` | (just text) | Superscript lost | +| `<sub>` | `text` | (just text) | Subscript lost | +| `<mark>` | `text` | (just text) | Highlight lost | +| `<small>` | `text` | (just text) | Small text lost | +| `<details>` | Children only | (content extracted) | No markdown equivalent | +| `<summary>` | `text` | (just text) | Collapsed with details | +| `<dl>`, `<dt>`, `<dd>` | paragraphs | (text only) | No definition list in markdown | +| `<figure>` | Children only | (content extracted) | | +| `<figcaption>` | paragraph/text | (text) | | +| `<video>`, `<audio>` | `link` | `[src](url)` | Downgraded to link | +| `<iframe>` | `link` | `[src](url)` | Downgraded to link | +| `<svg>` | (ignored) | (nothing) | Use custom handler to preserve | +| `<math>` | (ignored) | (nothing) | Use custom handler for LaTeX output | +| `<script>` | (ignored) | (nothing) | | +| `<style>` | (ignored) | (nothing) | | +| `<div>`, `<section>`, `<article>` | Children extracted | (children only) | Container elements unwrapped | +| `<span>`, `<time>` | Children extracted | (children only) | Inline containers unwrapped | + +--- + +## Summary of Key Findings + +1. **The hast→mdast→markdown chain is production-ready**: `hast-util-to-mdast` (v10.1.2) and `mdast-util-to-markdown` (v2.1.2) are mature, well-tested, well-typed libraries that handle the full HTML spec and produce clean GFM markdown. + +2. **The gap is JSX→hast**: There is no off-the-shelf solution for converting React component trees directly to hast. The recommended approach is React server-side rendering (to HTML) followed by `hast-util-from-html` parsing. + +3. **Custom components need a strategy**: Standard HTML elements map well, but custom React components lose their semantics through HTML rendering. A `data-md` attribute convention + custom handler registry is the recommended solution. + +4. **TypeScript support is excellent**: All packages in the ecosystem ship with proper TypeScript types. The discriminated union on `node.type` makes pattern matching clean and type-safe. + +5. **GFM is fully supported**: Tables, strikethrough, task lists, and autolink literals are all handled. + +6. **The unist utility ecosystem is rich**: `unist-util-visit`, `hast-util-select`, and dozens of other utilities provide all the building blocks needed for pre/post-processing. + +7. **For LLM consumption specifically**: The markdown output quality from this pipeline is already excellent for standard HTML. The main investment should go into the component annotation system and custom handlers for domain-specific components. + +--- + +*Research completed 2026-04-28. All version numbers and repository states reflect the latest available at time of research.*