Tell LLMs how to write Markdown, then render it as generative UI.
npm install @moeki0/gengen
define ──> g.prompt() ──> LLM ──> Markdown ──> <Gengen> ──> UI
^ |
└────────── same definition drives both sides ────────────┘
One schema definition produces the system prompt that tells the LLM what to write, and the React component that renders what it wrote. No format drift. No manual syncing.
// schemas/card.ts
import { g } from '@moeki0/gengen'
export const card = g.block('card')
.describe('A summary card with a title and bullet points.')
.schema({
title: g.text(),
points: g.list(),
})import { g } from '@moeki0/gengen'
import { card } from '@/schemas/card'
const systemPrompt = g.prompt([card])
// → "A summary card with a title and bullet points.
//
// - write title as a plain text paragraph
// - write points as a bullet list (- item)"import { Gengen } from '@moeki0/gengen/react'
import { card } from '@/schemas/card'
const CardView = card.component(({ title, points }) => (
<div className="card">
<h2>{title}</h2>
<ul>{points.map((p, i) => <li key={i}>{p}</li>)}</ul>
</div>
))
export default function Page() {
return <Gengen markdown={llmOutput} renderers={[CardView]} />
}g.route() works anywhere --- Node, Deno, TUI, terminal.
import { g } from '@moeki0/gengen'
for (const block of g.route(markdown, renderers)) {
if (block.renderer) {
const props = g.parseSchema(block.markdown, block.renderer.schema)
// render however you want
}
}See samples/agent-sdk-tui for a full Ink-based terminal UI using gengen.
import { g } from '@moeki0/gengen' // server-safe, no React
import { Gengen } from '@moeki0/gengen/react' // React component
import { useGengenContext, useInlineText } from '@moeki0/gengen/react' // hooks (inside renderers)The default export (@moeki0/gengen) has zero React dependency and is safe for server components, edge functions, and non-React runtimes.
Used inside .schema({}). Each key becomes a prop on the rendered component, with full type inference.
const article = g.block('article')
.schema({
title: g.heading(), // string
body: g.text(), // string
code: g.codeblock('ts'), // string
items: g.list(), // string[]
quote: g.blockquote(), // string
data: g.table(), // { headers: string[]; rows: string[][] }
active: g.bool(), // boolean
})| Part | Markdown the LLM writes | Prop type |
|---|---|---|
g.text() |
paragraph | string |
g.list() |
- item |
string[] |
g.codeblock(lang?) |
```ts ``` |
string |
g.heading(level?) |
## ... |
string |
g.blockquote() |
> ... |
string |
g.table() |
| col | ... | |
{ headers: string[]; rows: string[][] } |
g.bool() |
true / false |
boolean |
All parts except g.inline() support .optional(), which marks the field as may-be-absent. The LLM may omit the block, and the prop type becomes T | undefined.
const card = g.block('card')
.schema({
title: g.heading(2),
summary: g.text(),
code: g.codeblock('ts').optional(), // may be absent → string | undefined
tags: g.list().optional(), // may be absent → string[] | undefined
})Lists support constraints that both validate LLM output and parse structured data.
// Basic constraints
g.list().min(3) // at least 3 items
g.list().optional() // block may be absent
// Parse structured items
g.list().all(
g.split(': ', g.str('name'), g.number('score'))
)
// "- Alice: 95" → { name: "Alice", score: 95 }[]
// Format constraints
g.list().all(g.url()) // URLs only
g.list().all(g.image()) // image URLs only
// Extract marked items (labeled constraint)
g.list().some(
g.endsWith('★').is('answer')
)
// picks the ★-marked item as the "answer" prop
// Require at least one match (unlabeled constraint)
g.list().some(g.matches(/\d{4}/)) // at least one item must matchg.heading(2) // specific level (##)
g.heading([2, 3]) // multiple levels
g.heading(3).content('quiz') // must match text (case-insensitive)
g.heading([2, 3]).content(/^(quiz|クイズ)$/i) // regex match
g.heading(2).optional() // heading may be absentEmbed multiple fields in a single heading line.
const section = g.heading(2)
.split(': ', 'title')
.split(' | ', 'color', g.hex())
.split(' | ', 'span', g.gridSpan())The LLM writes headings like:
## 1789: Revolution | #1a1a1a | 2x1You parse them:
const { intro, sections } = section.parse(markdown)
sections[0].text // '1789'
sections[0].title // 'Revolution'
sections[0].color // '#1a1a1a'
sections[0].span // { col: 2, row: 1 }Built-in types for .split():
| Type | Example | Result |
|---|---|---|
g.hex() |
#1a1a1a |
string (HEX color) |
g.gridSpan() |
2x1 |
{ col: 2, row: 1 } |
g.oneOf('a', 'b') |
a |
constrained string |
g.str(name) |
any text | string |
g.number(name) |
3.14 |
number |
g.integer(name) |
42 |
number (integer) |
g.yearStr(name) |
1789 |
string (year-like) |
| (default) | any text | string |
Define markers that the LLM can use within prose text.
const deepdive = g.inline('deepdive', {
marker: ['[[', ']]'],
description: 'A term the reader can click to explore deeper.',
component: ({ text }) => (
<button onClick={() => handleDeepDive(text)}>{text}</button>
),
})Inline schemas work alongside block schemas in both prompt generation and rendering:
g.prompt([card, deepdive])
// → "...
// **Inline markers** — use these within prose text:
// - `[[term]]` — A term the reader can click to explore deeper."
<Gengen markdown={md} renderers={[CardView, deepdive]} />Use useInlineText() inside block renderers to process inline markers in parsed string props:
import { useInlineText } from '@moeki0/gengen/react'
function CalloutRenderer({ note }: { note: string }) {
const inlineText = useInlineText()
return <blockquote>{inlineText(note)}</blockquote>
}Control the narrative structure of the LLM's output. Flow is a prompt-generation hint --- it tells the LLM what order to write in, but g.route() does not enforce this order. Routing is always specificity-based schema matching.
const documentFlow = g.flow([
g.prose('Set the scene with a brief introduction'),
card,
g.loop([
g.prose('Continue the narrative'),
g.pick(timeline, stats),
]),
])
const systemPrompt = g.prompt(documentFlow)
// → "Structure your response following this flow:
// 1. A prose paragraph: Set the scene with a brief introduction
// 2. A **card** block (write as: ### card heading, then content below) — ...
// 3. Repeat the following as needed:
// 4. A prose paragraph: Continue the narrative
// 5. One of: **timeline**, **stats**
// ..."| Function | Description |
|---|---|
g.flow(nodes) |
Define a document structure |
g.prose(hint?) |
A prose paragraph (optional hint for the LLM) |
g.loop(nodes) |
Repeat the child nodes as needed |
g.pick(...schemas) |
LLM picks one of the given block types |
Builder for creating schema definitions. Chain .describe(), .schema(), and .component().
// Builder style (recommended)
const diff = g.block('diff')
.describe('A before/after code diff.')
.schema({
before: g.codeblock('ts'),
after: g.codeblock('ts'),
})
// Schema only (server-safe, no component)
g.prompt([diff])
// With component (client-side)
const DiffRenderer = diff.component(DiffView)Also supports an object-style overload for concise one-shot definitions:
const diff = g.block('diff', {
schema: { content: g.codeblock('diff') },
component: DiffView,
description: 'A unified diff.',
})Generate a system prompt string from schemas, inline schemas, or a flow.
g.prompt([card, diff]) // flat list of schemas
g.prompt([card, deepdive]) // block + inline schemas
g.prompt(documentFlow) // flow structureReact component that routes each Markdown block to the matching renderer. Unmatched blocks render as styled prose.
<Gengen
markdown={md}
renderers={[CardView, DiffRenderer, deepdive]}
fallback={CustomFallback} // optional custom fallback component
context={{ onAction: handleAction }}
/>Route without React. Returns an array of { renderer, markdown } blocks.
const blocks = g.route(markdown, renderers)
for (const block of blocks) {
if (block.renderer) {
const props = g.parseSchema(block.markdown, block.renderer.schema)
// render with any framework or TUI
} else {
// unmatched prose
console.log(block.markdown)
}
}Extract typed props from a Markdown block using a schema. Returns InferSchema<S>.
Returns true if the markdown matches the schema.
Debug why a schema doesn't match. Returns { field, reason }[] (empty = matches).
const errors = g.diagnose(markdown, mySchema.schema)
// → [{ field: 'items', reason: 'expected at least 3 items, got 1' }]Access the context prop from inside a renderer component.
import { useGengenContext } from '@moeki0/gengen/react'
function TopicButton({ topic }: { topic: string }) {
const { onAction } = useGengenContext<{ onAction: (a: Action) => void }>()
return (
<button onClick={() => onAction({ type: 'navigate', payload: topic })}>
{topic}
</button>
)
}Process inline markers within block renderer components. Block renderers receive parsed strings, not raw Markdown --- use this hook to render [[term]]-style markers.
import { useInlineText } from '@moeki0/gengen/react'
function NoteRenderer({ note }: { note: string }) {
const inlineText = useInlineText()
return <p>{inlineText(note)}</p>
}gengen uses a multi-pass algorithm to match Markdown blocks to renderers:
- Named headings --- If a heading matches a renderer name or
contentMatch, everything until the next named heading becomes one block - Grouping --- Remaining nodes are grouped by type (code blocks, lists, tables are isolated; adjacent lists merge for multi-list schemas)
- Schema matching --- Each group is tested against all renderers. When multiple match, the one with the highest specificity wins (more constraints = higher score)
- Merge-forward --- Unmatched non-paragraph groups attempt to merge with the next group
- Default fallback --- Adjacent unmatched blocks are merged and rendered as styled prose
This means renderers are greedy-matched by specificity, so you can define both a generic list renderer and a specific timeline renderer (with a year-format constraint) and the right one will match.
Schema definitions carry full type information through to component props.
const quiz = g.block('quiz')
.schema({
question: g.text(),
choices: g.list().all(g.split(': ', g.str('label'), g.str('text'))),
answer: g.list().some(g.endsWith('★').is('answer')),
})
// Component receives fully inferred props:
const QuizView = quiz.component(({ question, choices, answer }) => {
// question: string
// choices: { label: string; text: string }[]
// answer: string
...
})To create a gengen skill for Claude Code:
/skill-creator https://github.com/moeki0/gengen/blob/main/README.md
MIT