@theseus.run/jsx-md 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +323 -0
- package/package.json +48 -0
- package/src/_render-registry.ts +16 -0
- package/src/context.ts +44 -0
- package/src/escape.ts +36 -0
- package/src/index.ts +29 -0
- package/src/jsx-dev-runtime.ts +4 -0
- package/src/jsx-runtime.ts +94 -0
- package/src/primitives.tsx +383 -0
- package/src/render.ts +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# `@theseus.run/jsx-md`
|
|
2
|
+
|
|
3
|
+
Built to manage agent instructions across multiple harnesses — [Copilot](https://github.com/features/copilot), [OpenCode](https://opencode.ai), and others. The content wasn't the problem. The management was.
|
|
4
|
+
|
|
5
|
+
Agent instruction files grow. They develop conditional sections — capabilities that only apply to one harness, traits you're toggling between first-person and third-person to test what produces better behavior. A shared fragment between two agents becomes a copy-paste. A conditional becomes a ternary inside a template literal:
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const instructions = `
|
|
9
|
+
You are a code reviewer.
|
|
10
|
+
|
|
11
|
+
${harness === 'opencode' ? '<!-- use task() for subtasks -->' : ''}
|
|
12
|
+
|
|
13
|
+
## Traits
|
|
14
|
+
${firstPerson
|
|
15
|
+
? '- I always verify output before claiming done.'
|
|
16
|
+
: '- The agent must verify output before claiming done.'}
|
|
17
|
+
`
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No syntax highlighting for the Markdown inside the string. No types on the structure. A variant is a new file. Refactoring is grep-and-pray.
|
|
21
|
+
|
|
22
|
+
Nested lists make it worse. Markdown requires exact indentation — two spaces per level. Template strings force you to hardcode that spacing:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
const instructions = `
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
- Outer rule
|
|
29
|
+
- Nested rule: two hardcoded spaces
|
|
30
|
+
- Deeper: four hardcoded spaces
|
|
31
|
+
${condition ? ' - Conditional nested item' : ''}
|
|
32
|
+
`
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A false conditional leaves a blank bullet. The indentation is load-bearing and invisible. `DepthContext` in `@theseus.run/jsx-md` tracks nesting depth automatically — you write `<Ul>` inside `<Li>` and the renderer handles the spaces.
|
|
36
|
+
|
|
37
|
+
This isn't an edge case. [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) — 39.8k stars, serious harness work — stores instructions the same way:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
export const PROMETHEUS_HIGH_ACCURACY_MODE = `# PHASE 3: PLAN GENERATION
|
|
41
|
+
## High Accuracy Mode - MANDATORY LOOP
|
|
42
|
+
\`\`\`typescript
|
|
43
|
+
while (true) {
|
|
44
|
+
const result = task(subagent_type="momus", ...)
|
|
45
|
+
if (result.verdict === "OKAY") break
|
|
46
|
+
}
|
|
47
|
+
\`\`\`
|
|
48
|
+
...` // 62 more lines of escaped template string
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The escaped backticks are the tell — not a bad solution, it's the only solution the format offers. No composability, no reuse, no tooling support.
|
|
52
|
+
|
|
53
|
+
The web dev world solved "structured text with conditionals and composable fragments" ten years ago. JSX is a transform spec, not a React dependency — `jsxImportSource` lets you point it at any runtime. `@theseus.run/jsx-md` is a runtime that outputs Markdown:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
const ReviewerInstructions = ({ harness, firstPerson }: Props) => (
|
|
57
|
+
<>
|
|
58
|
+
<P>You are a code reviewer.</P>
|
|
59
|
+
{harness === 'opencode' && (
|
|
60
|
+
<HtmlComment>use task() for subtasks</HtmlComment>
|
|
61
|
+
)}
|
|
62
|
+
<H2>Traits</H2>
|
|
63
|
+
<Ul>
|
|
64
|
+
<Li>
|
|
65
|
+
{firstPerson
|
|
66
|
+
? 'I always verify output before claiming done.'
|
|
67
|
+
: 'The agent must verify output before claiming done.'}
|
|
68
|
+
</Li>
|
|
69
|
+
</Ul>
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
render(<ReviewerInstructions harness="opencode" firstPerson={true} />)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
No escaped backticks. Syntax highlighting in your editor. Conditionals are JSX expressions. Variants are props. Shared fragments are components.
|
|
77
|
+
|
|
78
|
+
`render()` returns a plain string — no virtual DOM, no hydration, no React runtime anywhere in the chain.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bun add @theseus.run/jsx-md
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then in `tsconfig.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"compilerOptions": {
|
|
93
|
+
"jsx": "react-jsx",
|
|
94
|
+
"jsxImportSource": "@theseus.run/jsx-md"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## What you're getting
|
|
102
|
+
|
|
103
|
+
**Zero runtime dependencies.** The package ships TypeScript source with no third-party imports. Nothing gets added to your bundle.
|
|
104
|
+
|
|
105
|
+
**Bun-first.** Ships TypeScript source directly, no compiled output. Works transparently in Bun: install, add two tsconfig lines, write TSX. The trade-off is real: vanilla Node.js without a bundler won't run `.ts` files from `node_modules`. If your stack is Vite, tsup, or esbuild, those handle it. If you're running bare Node, v0.1.0 doesn't have a solution for you yet.
|
|
106
|
+
|
|
107
|
+
**JSX without React.** When you write `<H2>Title</H2>`, the transform calls `jsx(H2, { children: "Title" })`. `H2` is a plain function — takes props, returns a string. No virtual DOM, no reconciler, no fiber, no hooks. `render()` walks the VNode tree synchronously and concatenates. The output is deterministic: same input, same string, every time. You can test it with `expect(render(<MyPrompt />)).toMatchSnapshot()`.
|
|
108
|
+
|
|
109
|
+
**TypeScript-first.** All components and their props are typed. `render()` accepts `VNode`, returns `string`. Wrong usage is a compile error, not a runtime surprise.
|
|
110
|
+
|
|
111
|
+
**String children are verbatim.** `render()` passes raw string values through without escaping. `<P>{"<b>text</b>"}</P>` outputs `<b>text</b>` — no transformation. For LLM prompts this is the right default; Markdown renderers handle the rest.
|
|
112
|
+
|
|
113
|
+
**XML-structured prompts, zero config.** Any lowercase JSX tag renders as an XML block — `<context>`, `<instructions>`, `<example index={1}>`. Anthropic's prompt engineering guide recommends this structure for Claude agents. Attributes are typed and serialized automatically; empty tags self-close. No imports, no registration — it's built into the JSX intrinsics catch-all.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Usage
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
// system-prompt.tsx
|
|
121
|
+
import { render, H2, P, Ul, Li, Bold, Code } from "@theseus.run/jsx-md";
|
|
122
|
+
|
|
123
|
+
const prompt = render(
|
|
124
|
+
<>
|
|
125
|
+
<H2>Role</H2>
|
|
126
|
+
<P>
|
|
127
|
+
You are a precise code reviewer. Your job is to find bugs, not suggest
|
|
128
|
+
style changes.
|
|
129
|
+
</P>
|
|
130
|
+
|
|
131
|
+
<H2>Rules</H2>
|
|
132
|
+
<Ul>
|
|
133
|
+
<Li>
|
|
134
|
+
Flag <Bold>P0</Bold> issues immediately — do not bury them.
|
|
135
|
+
</Li>
|
|
136
|
+
<Li>
|
|
137
|
+
Use <Code>inline code</Code> when referencing identifiers.
|
|
138
|
+
</Li>
|
|
139
|
+
<Li>One finding per comment. No compound observations.</Li>
|
|
140
|
+
</Ul>
|
|
141
|
+
|
|
142
|
+
<H2>Output format</H2>
|
|
143
|
+
<P>
|
|
144
|
+
Respond with a structured list. Each item: severity, location, finding.
|
|
145
|
+
</P>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
console.log(prompt);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Output:
|
|
153
|
+
|
|
154
|
+
```markdown
|
|
155
|
+
## Role
|
|
156
|
+
|
|
157
|
+
You are a precise code reviewer. Your job is to find bugs, not suggest style changes.
|
|
158
|
+
|
|
159
|
+
## Rules
|
|
160
|
+
|
|
161
|
+
- Flag **P0** issues immediately — do not bury them.
|
|
162
|
+
- Use `inline code` when referencing identifiers.
|
|
163
|
+
- One finding per comment. No compound observations.
|
|
164
|
+
|
|
165
|
+
## Output format
|
|
166
|
+
|
|
167
|
+
Respond with a structured list. Each item: severity, location, finding.
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Structured agent instructions
|
|
173
|
+
|
|
174
|
+
Anthropic's prompt engineering guide recommends XML tags to structure Claude prompts — wrapping each content type in its own tag reduces misinterpretation. `<instructions>`, `<context>`, `<examples>`, `<document index="n">` are the documented patterns.
|
|
175
|
+
|
|
176
|
+
Any lowercase JSX tag is an XML intrinsic in `@theseus.run/jsx-md`. No imports, no registration:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
const ReviewerPrompt = ({ repo, examples }: Props) => (
|
|
180
|
+
<>
|
|
181
|
+
<context>
|
|
182
|
+
<P>Repository: {repo}. Language: TypeScript. Package manager: bun.</P>
|
|
183
|
+
</context>
|
|
184
|
+
|
|
185
|
+
<instructions>
|
|
186
|
+
<H2>Role</H2>
|
|
187
|
+
<P>You are a precise code reviewer. Find bugs, not style issues.</P>
|
|
188
|
+
|
|
189
|
+
<H2>Rules</H2>
|
|
190
|
+
<Ul>
|
|
191
|
+
<Li>Flag <Bold>P0</Bold> issues first — do not bury them.</Li>
|
|
192
|
+
<Li>One finding per comment. No compound observations.</Li>
|
|
193
|
+
<Li>Use <Code>inline code</Code> when referencing identifiers.</Li>
|
|
194
|
+
</Ul>
|
|
195
|
+
</instructions>
|
|
196
|
+
|
|
197
|
+
{examples.length > 0 && (
|
|
198
|
+
<examples>
|
|
199
|
+
{examples.map((ex, i) => (
|
|
200
|
+
<example index={i + 1}>
|
|
201
|
+
<Md>{ex}</Md>
|
|
202
|
+
</example>
|
|
203
|
+
))}
|
|
204
|
+
</examples>
|
|
205
|
+
)}
|
|
206
|
+
</>
|
|
207
|
+
)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Output:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
<context>
|
|
214
|
+
Repository: cockpit. Language: TypeScript. Package manager: bun.
|
|
215
|
+
</context>
|
|
216
|
+
|
|
217
|
+
<instructions>
|
|
218
|
+
## Role
|
|
219
|
+
|
|
220
|
+
You are a precise code reviewer. Find bugs, not style issues.
|
|
221
|
+
|
|
222
|
+
## Rules
|
|
223
|
+
|
|
224
|
+
- Flag **P0** issues first — do not bury them.
|
|
225
|
+
- One finding per comment. No compound observations.
|
|
226
|
+
- Use `inline code` when referencing identifiers.
|
|
227
|
+
</instructions>
|
|
228
|
+
|
|
229
|
+
<examples>
|
|
230
|
+
<example index="1">
|
|
231
|
+
... your example content ...
|
|
232
|
+
</example>
|
|
233
|
+
</examples>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Attributes are typed (`index={1}` → `index="1"`), boolean `true` attrs render bare, `false`/`null`/`undefined` are omitted. Empty tags self-close: `<tag />`.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Primitives
|
|
241
|
+
|
|
242
|
+
| Component | Markdown output |
|
|
243
|
+
|-----------|----------------|
|
|
244
|
+
| `H1`–`H6` | `#`–`######` headings |
|
|
245
|
+
| `P` | Paragraph (blank line separated) |
|
|
246
|
+
| `Hr` | `---` horizontal rule |
|
|
247
|
+
| `Codeblock` | Fenced code block with optional `lang` prop |
|
|
248
|
+
| `Blockquote` | `>` blockquote |
|
|
249
|
+
| `Ul` | Unordered list |
|
|
250
|
+
| `Ol` | Ordered list |
|
|
251
|
+
| `Li` | List item (supports nesting via `Ul`/`Ol` inside `Li`) |
|
|
252
|
+
| `TaskList` | Task list container |
|
|
253
|
+
| `Task` | `- [ ]` / `- [x]` task item (`done` prop) |
|
|
254
|
+
| `Table` | Markdown table |
|
|
255
|
+
| `Tr` | Table row |
|
|
256
|
+
| `Th` | Table header cell |
|
|
257
|
+
| `Td` | Table data cell |
|
|
258
|
+
| `Bold` | `**bold**` |
|
|
259
|
+
| `Code` | `` `inline code` `` |
|
|
260
|
+
| `Italic` | `*italic*` |
|
|
261
|
+
| `Strikethrough` | `~~strikethrough~~` |
|
|
262
|
+
| `Link` | `[text](url)` |
|
|
263
|
+
| `Img` | `` |
|
|
264
|
+
| `Md` | Raw markdown passthrough (escape hatch for complex inline combinations) |
|
|
265
|
+
| `HtmlComment` | `<!-- comment -->` (invisible to most renderers; useful for LLM-only instructions) |
|
|
266
|
+
| `Details` | `<details><summary>` collapsible block |
|
|
267
|
+
| `Callout` | GitHub-style admonition with `type` prop: `note`, `tip`, `important`, `warning`, `caution` |
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Roadmap
|
|
272
|
+
|
|
273
|
+
**v0.1.0** — current. TypeScript source, Bun-first. Markdown primitives + XML intrinsic elements.
|
|
274
|
+
|
|
275
|
+
**v0.2.0** — two additions:
|
|
276
|
+
|
|
277
|
+
- _Context API (public)._ `DepthContext` already uses an internal context system for list nesting. v0.2.0 exposes this as a first-class public API. The pattern is React-familiar but synchronous and string-based.
|
|
278
|
+
|
|
279
|
+
Should fix prop-drilling. A prompt tree with conditional sections per harness ends up threading `harness` through every component:
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
// v0.1.0 — prop-drilling
|
|
283
|
+
const Prompt = ({ harness }: Props) => (
|
|
284
|
+
<>
|
|
285
|
+
<Instructions harness={harness} />
|
|
286
|
+
<Rules harness={harness} />
|
|
287
|
+
<Examples harness={harness} />
|
|
288
|
+
</>
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
With the v0.2.0 context API, set it once at the root and read it anywhere:
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
// v0.2.0 — context
|
|
296
|
+
const HarnessContext = createContext<'opencode' | 'copilot'>('copilot')
|
|
297
|
+
|
|
298
|
+
const prompt = render(
|
|
299
|
+
<HarnessContext.Provider value="opencode">
|
|
300
|
+
<Prompt />
|
|
301
|
+
</HarnessContext.Provider>
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
// In any component, at any depth — no prop threading:
|
|
305
|
+
const Instructions = () => {
|
|
306
|
+
const harness = useContext(HarnessContext)
|
|
307
|
+
return harness === 'opencode'
|
|
308
|
+
? <P>Use task() for subtasks.</P>
|
|
309
|
+
: null
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- _Node.js compiled output._ Ships a compiled `dist/` alongside the TypeScript source. Unblocks bare Node.js without a bundler.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## License
|
|
318
|
+
|
|
319
|
+
MIT — see [LICENSE](../../LICENSE).
|
|
320
|
+
|
|
321
|
+
Built by [Roman Dubinin](https://romanonthego.com).
|
|
322
|
+
|
|
323
|
+
Part of [Theseus](https://theseus.run).
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theseus.run/jsx-md",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JSX/TSX renderer for Markdown. Write agent prompts and LLM instructions as typed, composable components. Zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Roman Dubinin <romanonthego@gmail.com> (https://romanonthego.com)",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/theseus-run/theseus.git",
|
|
11
|
+
"directory": "packages/jsx-md"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/theseus-run/theseus/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://theseus.run",
|
|
17
|
+
"keywords": ["jsx", "tsx", "markdown", "llm", "agents", "prompts", "jsx-runtime"],
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "bun test"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src/index.ts",
|
|
29
|
+
"src/render.ts",
|
|
30
|
+
"src/primitives.tsx",
|
|
31
|
+
"src/jsx-runtime.ts",
|
|
32
|
+
"src/jsx-dev-runtime.ts",
|
|
33
|
+
"src/context.ts",
|
|
34
|
+
"src/escape.ts",
|
|
35
|
+
"src/_render-registry.ts"
|
|
36
|
+
],
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"types": "./src/index.ts",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": "./src/index.ts",
|
|
41
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
42
|
+
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/bun": "latest",
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type RenderFn = (node: unknown) => string;
|
|
2
|
+
|
|
3
|
+
let _render: RenderFn | null = null;
|
|
4
|
+
|
|
5
|
+
export function registerRender(fn: RenderFn): void {
|
|
6
|
+
_render = fn;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function callRender(node: unknown): string {
|
|
10
|
+
if (!_render) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"jsx-md: render not initialized. Ensure 'render' is imported from '@theseus.run/jsx-md' before Fragment is called.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return _render(node);
|
|
16
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal synchronous context API for jsx-md.
|
|
3
|
+
*
|
|
4
|
+
* Uses a module-level stack map so context values are available synchronously
|
|
5
|
+
* during the render() traversal. Not safe for async or concurrent rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface Context<T> {
|
|
9
|
+
readonly _id: symbol;
|
|
10
|
+
readonly _default: T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Process-wide singleton. Not safe for concurrent render() calls in the same
|
|
15
|
+
* process (e.g. simultaneous Bun HTTP requests). For agent prompt generation
|
|
16
|
+
* this is almost always a non-issue — renders are sequential.
|
|
17
|
+
*/
|
|
18
|
+
const stack = new Map<symbol, unknown[]>();
|
|
19
|
+
|
|
20
|
+
export function createContext<T>(defaultValue: T): Context<T> {
|
|
21
|
+
return { _id: Symbol(), _default: defaultValue };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useContext<T>(ctx: Context<T>): T {
|
|
25
|
+
const s = stack.get(ctx._id);
|
|
26
|
+
if (!s || s.length === 0) {
|
|
27
|
+
return ctx._default;
|
|
28
|
+
}
|
|
29
|
+
return s[s.length - 1] as T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function withContext<T>(ctx: Context<T>, value: T, fn: () => string): string {
|
|
33
|
+
let s = stack.get(ctx._id);
|
|
34
|
+
if (!s) {
|
|
35
|
+
s = [];
|
|
36
|
+
stack.set(ctx._id, s);
|
|
37
|
+
}
|
|
38
|
+
s.push(value);
|
|
39
|
+
try {
|
|
40
|
+
return fn();
|
|
41
|
+
} finally {
|
|
42
|
+
s.pop();
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/escape.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes `&`, `"`, `<`, `>` — for use in XML/HTML attribute values.
|
|
3
|
+
* Escapes for double-quoted HTML/XML attribute values. Single quotes are not
|
|
4
|
+
* escaped — do not use this in single-quoted attribute contexts.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeHtmlAttr(s: string): string {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Escapes `&`, `<`, `>` — for use in HTML/XML text content (not attributes). */
|
|
15
|
+
export function escapeHtmlContent(s: string): string {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Percent-encodes `(` and `)` in a URL to prevent premature link termination in
|
|
24
|
+
* Markdown link syntax `[text](url)`.
|
|
25
|
+
*/
|
|
26
|
+
export function encodeLinkUrl(url: string): string {
|
|
27
|
+
return url.replace(/[()]/g, (c) => encodeURIComponent(c));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Percent-encodes `[` and `]` in text used as Markdown link label (image alt).
|
|
32
|
+
* Prevents premature bracket closure in ``.
|
|
33
|
+
*/
|
|
34
|
+
export function encodeLinkLabel(text: string): string {
|
|
35
|
+
return text.replace(/[[\]]/g, (c) => encodeURIComponent(c));
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Runtime exports
|
|
2
|
+
export { jsx, jsxs, Fragment } from './jsx-runtime.ts';
|
|
3
|
+
export type { JSX, VNode, VNodeElement } from './jsx-runtime.ts';
|
|
4
|
+
|
|
5
|
+
// Render
|
|
6
|
+
export { render } from './render.ts';
|
|
7
|
+
|
|
8
|
+
// Context API
|
|
9
|
+
export { createContext, useContext, withContext } from './context.ts';
|
|
10
|
+
export type { Context } from './context.ts';
|
|
11
|
+
|
|
12
|
+
// Primitive components
|
|
13
|
+
export {
|
|
14
|
+
H1, H2, H3, H4, H5, H6,
|
|
15
|
+
P,
|
|
16
|
+
Hr,
|
|
17
|
+
Codeblock,
|
|
18
|
+
Blockquote,
|
|
19
|
+
Li, Ul, Ol,
|
|
20
|
+
Bold, Code, Italic, Strikethrough, Link, Img,
|
|
21
|
+
Md,
|
|
22
|
+
Table, Tr, Th, Td,
|
|
23
|
+
TaskList, Task,
|
|
24
|
+
Callout,
|
|
25
|
+
HtmlComment,
|
|
26
|
+
Details,
|
|
27
|
+
} from './primitives.tsx';
|
|
28
|
+
|
|
29
|
+
export type { CalloutType } from './primitives.tsx';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Dev mode — re-export everything from jsx-runtime, aliasing jsx as jsxDEV.
|
|
2
|
+
// Bun resolves ./jsx-dev-runtime in development builds and calls jsxDEV.
|
|
3
|
+
export { jsx as jsxDEV, jsxs, Fragment } from './jsx-runtime.ts';
|
|
4
|
+
export type { VNode, VNodeElement } from './jsx-runtime.ts';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom JSX-to-VNode runtime for agent markdown generation.
|
|
3
|
+
*
|
|
4
|
+
* Bun's JSX compilation with `jsxImportSource: "@theseus.run/jsx-md"` resolves
|
|
5
|
+
* `jsx` and `jsxs` from `@theseus.run/jsx-md/jsx-runtime`. The factory builds
|
|
6
|
+
* a VNode tree — no evaluation at construction time.
|
|
7
|
+
*
|
|
8
|
+
* Call `render(node)` from `./render.ts` to produce the final markdown string.
|
|
9
|
+
* All markdown primitives live in primitives.tsx as named components.
|
|
10
|
+
*
|
|
11
|
+
* Fragment circular-dep resolution: Fragment needs render() to flatten children,
|
|
12
|
+
* but render.ts imports types from this file. We use _render-registry.ts as a
|
|
13
|
+
* shared side-channel: render.ts calls registerRender(render) on module init,
|
|
14
|
+
* Fragment defers to callRender(). Any entry point that imports render will
|
|
15
|
+
* register it before rendering starts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { callRender } from './_render-registry.ts';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// VNode types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type VNodeElement = {
|
|
25
|
+
readonly type: Component | string;
|
|
26
|
+
readonly props: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type VNode =
|
|
30
|
+
| null
|
|
31
|
+
| undefined
|
|
32
|
+
| boolean
|
|
33
|
+
| string
|
|
34
|
+
| number
|
|
35
|
+
| VNodeElement
|
|
36
|
+
| ReadonlyArray<VNode>;
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
type Component<P = any> = (props: P) => string;
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// JSX namespace
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
46
|
+
export namespace JSX {
|
|
47
|
+
/**
|
|
48
|
+
* JSX.Element is VNode (not just VNodeElement) so that string-returning
|
|
49
|
+
* components pass TypeScript's component return-type check. The jsx() factory
|
|
50
|
+
* always produces VNodeElement at runtime; the wider union here is only needed
|
|
51
|
+
* to satisfy TypeScript's component-validity check.
|
|
52
|
+
*/
|
|
53
|
+
export type Element = VNode;
|
|
54
|
+
|
|
55
|
+
// Catch-all — allows arbitrary lowercase XML tags as intrinsic elements.
|
|
56
|
+
export interface IntrinsicElements {
|
|
57
|
+
[tag: string]: { children?: VNode; [attr: string]: unknown };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ElementChildrenAttribute {
|
|
61
|
+
children: VNode;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Render registration (avoids circular dep with render.ts)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
// Registration and dispatch are handled by _render-registry.ts.
|
|
70
|
+
// render.ts calls registerRender(render) on module init.
|
|
71
|
+
// Fragment calls callRender() to evaluate children.
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// JSX factory
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* JSX factory — called by Bun's compiled JSX. Builds VNode tree; no evaluation.
|
|
79
|
+
*
|
|
80
|
+
* `type` is a function component or a string tag name. String tags are rendered
|
|
81
|
+
* as XML blocks by render.ts: `<tag attrs>\ncontent\n</tag>\n` (or self-closing
|
|
82
|
+
* when the inner content is empty).
|
|
83
|
+
*/
|
|
84
|
+
export function jsx(type: Component | string, props: Record<string, unknown>): VNodeElement {
|
|
85
|
+
return { type, props };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** jsxs — same as jsx, used when there are multiple children (children is array) */
|
|
89
|
+
export const jsxs = jsx;
|
|
90
|
+
|
|
91
|
+
/** Fragment — renders children. Defers to _render-registry to avoid circular imports. */
|
|
92
|
+
export function Fragment({ children }: { children?: VNode }): string {
|
|
93
|
+
return callRender(children ?? null);
|
|
94
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown primitives as named TSX components.
|
|
3
|
+
*
|
|
4
|
+
* Components covering all markdown formatting needs:
|
|
5
|
+
* Block: H1, H2, H3, H4, H5, H6, P, Hr, Codeblock, Blockquote
|
|
6
|
+
* List: Ol, Ul, Li, TaskList, Task
|
|
7
|
+
* Table: Table, Tr, Th, Td
|
|
8
|
+
* Inline: Bold, Code, Italic, Strikethrough, Link, Img
|
|
9
|
+
* Raw: Md
|
|
10
|
+
* Other: Callout, HtmlComment, Details
|
|
11
|
+
*
|
|
12
|
+
* Each is a plain function (props) => string. No React. No DOM.
|
|
13
|
+
*
|
|
14
|
+
* Children are passed as VNode (unevaluated). Each component calls
|
|
15
|
+
* render(children) to produce the final string. This enables context
|
|
16
|
+
* propagation — Ul/Ol wrap render in a withContext(DepthContext, depth+1)
|
|
17
|
+
* call so nested Li components can compute their indentation level.
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* AUTHORING RULES
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
*
|
|
23
|
+
* 1. Prefer components over inline markdown strings.
|
|
24
|
+
*
|
|
25
|
+
* **text** → <Bold>text</Bold>
|
|
26
|
+
* `text` → <Code>text</Code>
|
|
27
|
+
* *text* → <Italic>text</Italic>
|
|
28
|
+
* [label](url) → <Link href="url">label</Link>
|
|
29
|
+
*
|
|
30
|
+
* 2. Bare JSX text for plain prose. {'...'} only when the string contains
|
|
31
|
+
* a character JSX cannot represent as bare text:
|
|
32
|
+
*
|
|
33
|
+
* Requires {'...'}: backtick { } < > '
|
|
34
|
+
* Bare text is fine: letters, spaces, digits, . , : ; ! ? — – - ( ) [ ] " & * + = | / \
|
|
35
|
+
*
|
|
36
|
+
* // WRONG — unnecessary wrapper
|
|
37
|
+
* <Li><Bold>P1</Bold>{': Guard clauses over nesting.'}</Li>
|
|
38
|
+
*
|
|
39
|
+
* // CORRECT — bare text
|
|
40
|
+
* <Li><Bold>P1</Bold>: Guard clauses over nesting.</Li>
|
|
41
|
+
*
|
|
42
|
+
* // CORRECT — must wrap, contains single quote
|
|
43
|
+
* <Li><Bold>P1</Bold>{": Dead code: delete, don't comment out."}</Li>
|
|
44
|
+
*
|
|
45
|
+
* // CORRECT — must wrap, contains backtick
|
|
46
|
+
* <P>{'Format: `ROI≈X.X`'}</P>
|
|
47
|
+
*
|
|
48
|
+
* 3. Md is an escape hatch for genuinely undecomposable strings — typically
|
|
49
|
+
* 4+ inline code spans mixed with bold and prose. Use sparingly.
|
|
50
|
+
*
|
|
51
|
+
* // Legitimate Md use — 6 code spans, decomposition adds noise
|
|
52
|
+
* <Li><Md>{'**P0**: Type = `TKey`. Not `TFuncKey`, `string`, or `<Trans>`.'}</Md></Li>
|
|
53
|
+
*
|
|
54
|
+
* // Not legitimate — decompose instead
|
|
55
|
+
* <Li><Md>{'**P1**: Guard clauses over nesting.'}</Md></Li>
|
|
56
|
+
*
|
|
57
|
+
* 4. Nested lists are supported via nested Ul inside Li children.
|
|
58
|
+
* DepthContext tracks the nesting level and computes indentation automatically.
|
|
59
|
+
*
|
|
60
|
+
* <Ul>
|
|
61
|
+
* <Li>top-level item
|
|
62
|
+
* <Ul><Li>sub-item one</Li><Li>sub-item two</Li></Ul>
|
|
63
|
+
* </Li>
|
|
64
|
+
* </Ul>
|
|
65
|
+
*
|
|
66
|
+
* 5. Ol must be at document root (depth 0). Nesting Ol anywhere inside a Ul —
|
|
67
|
+
* including inside a Li — throws at runtime because depth > 0.
|
|
68
|
+
* Ol is for flat, top-level numbered sequences only.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/* @jsxImportSource @theseus.run/jsx-md */
|
|
72
|
+
|
|
73
|
+
import type { VNode } from './jsx-runtime.ts';
|
|
74
|
+
import { render } from './render.ts';
|
|
75
|
+
import { createContext, useContext, withContext } from './context.ts';
|
|
76
|
+
import { escapeHtmlContent, encodeLinkUrl, encodeLinkLabel } from './escape.ts';
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// DepthContext — tracks list nesting level for Li indentation
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Tracks the current list nesting depth. 0 = outside any list. */
|
|
83
|
+
const DepthContext = createContext(0);
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// OlContext — signals that Li is inside an Ol (uses sentinel marker)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Signals that the current Li is being rendered inside an Ol.
|
|
91
|
+
* Li emits a sentinel prefix (\x01) instead of "- " when this is true,
|
|
92
|
+
* preventing Ol's post-processor from matching literal "- " content.
|
|
93
|
+
*/
|
|
94
|
+
const OlContext = createContext(false);
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Block elements — trailing \n\n
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
interface BlockProps {
|
|
101
|
+
children?: VNode;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function H1({ children }: BlockProps): string {
|
|
105
|
+
return `# ${render(children ?? null)}\n\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function H2({ children }: BlockProps): string {
|
|
109
|
+
return `## ${render(children ?? null)}\n\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function H3({ children }: BlockProps): string {
|
|
113
|
+
return `### ${render(children ?? null)}\n\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function H4({ children }: BlockProps): string {
|
|
117
|
+
return `#### ${render(children ?? null)}\n\n`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function H5({ children }: BlockProps): string {
|
|
121
|
+
return `##### ${render(children ?? null)}\n\n`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function H6({ children }: BlockProps): string {
|
|
125
|
+
return `###### ${render(children ?? null)}\n\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function P({ children }: BlockProps): string {
|
|
129
|
+
return `${render(children ?? null)}\n\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function Hr(): string {
|
|
133
|
+
return '---\n\n';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface CodeblockProps {
|
|
137
|
+
lang?: string;
|
|
138
|
+
children?: VNode;
|
|
139
|
+
indent?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function Codeblock({ lang = '', children, indent = 0 }: CodeblockProps): string {
|
|
143
|
+
const prefix = ' '.repeat(indent);
|
|
144
|
+
const rawLines = render(children ?? null).split('\n');
|
|
145
|
+
// Drop trailing empty entries produced by a trailing \n in content to prevent
|
|
146
|
+
// a spurious indented blank line appearing before the closing fence.
|
|
147
|
+
while (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') {
|
|
148
|
+
rawLines.pop();
|
|
149
|
+
}
|
|
150
|
+
const body = rawLines.map((line) => prefix + line).join('\n');
|
|
151
|
+
return `\`\`\`${lang}\n${body}\n\`\`\`\n\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Blockquote — prefixes every content line with `> `.
|
|
156
|
+
* Trailing blank lines are stripped before prefixing so the output ends
|
|
157
|
+
* cleanly with `\n\n` rather than `> \n> \n\n`.
|
|
158
|
+
* Empty lines within the content get a bare `>` (no trailing space) to
|
|
159
|
+
* avoid invisible trailing whitespace in the rendered output.
|
|
160
|
+
* Nested blockquotes compose naturally: the inner renders `> text\n\n`,
|
|
161
|
+
* trimEnd strips the trailing newlines, then the outer prefixes each line
|
|
162
|
+
* with `> `, producing `> > text`.
|
|
163
|
+
*/
|
|
164
|
+
export function Blockquote({ children }: BlockProps): string {
|
|
165
|
+
const content = render(children ?? null).trimEnd();
|
|
166
|
+
const lines = content.split('\n').map((line) => (line === '' ? '>' : `> ${line}`)).join('\n');
|
|
167
|
+
return `${lines}\n\n`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// List elements
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export function Ul({ children }: BlockProps): string {
|
|
175
|
+
const depth = useContext(DepthContext);
|
|
176
|
+
// Reset OlContext so Li items inside a Ul nested within Ol emit "- " not the Ol sentinel
|
|
177
|
+
const rendered = withContext(OlContext, false, () =>
|
|
178
|
+
withContext(DepthContext, depth + 1, () => render(children ?? null)),
|
|
179
|
+
);
|
|
180
|
+
// Add trailing newline only at the outermost list level
|
|
181
|
+
return depth === 0 ? `${rendered}\n` : rendered;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Ordered list — auto-numbers Li children at the current depth level.
|
|
186
|
+
*
|
|
187
|
+
* Ul/Ol increment the depth before rendering children, so Li items know
|
|
188
|
+
* their indentation. When OlContext is true, Li emits a sentinel prefix
|
|
189
|
+
* (\x01) instead of "- ", which Ol replaces with numbered prefixes. This
|
|
190
|
+
* prevents false matches on Li content that literally begins with "- ".
|
|
191
|
+
*
|
|
192
|
+
* Constraint: Ol must be at document root (depth 0). Nesting Ol anywhere
|
|
193
|
+
* inside a Ul — including inside a Li — throws at runtime because depth > 0.
|
|
194
|
+
* Ol is for flat, top-level numbered sequences only.
|
|
195
|
+
*/
|
|
196
|
+
export function Ol({ children }: BlockProps): string {
|
|
197
|
+
const depth = useContext(DepthContext);
|
|
198
|
+
if (depth > 0) {
|
|
199
|
+
throw new Error('Ol cannot be used inside any list container (Ul, Ol, or TaskList) — depth must be 0.');
|
|
200
|
+
}
|
|
201
|
+
const MARKER = '\x01';
|
|
202
|
+
const rendered = withContext(OlContext, true, () =>
|
|
203
|
+
withContext(DepthContext, depth + 1, () => render(children ?? null)),
|
|
204
|
+
);
|
|
205
|
+
let counter = 0;
|
|
206
|
+
return rendered.replace(/^\x01/gm, () => `${++counter}. `) + '\n';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function Li({ children }: BlockProps): string {
|
|
210
|
+
const depth = useContext(DepthContext);
|
|
211
|
+
const isInOl = useContext(OlContext);
|
|
212
|
+
// depth is already incremented by the enclosing Ul/Ol, so depth 1 = top-level.
|
|
213
|
+
// Math.max guard: safe when Li is used outside Ul/Ol (depth=0).
|
|
214
|
+
const indent = ' '.repeat(Math.max(0, depth - 1));
|
|
215
|
+
const inner = render(children ?? null).trimEnd();
|
|
216
|
+
if (isInOl) {
|
|
217
|
+
// \x01 is the sentinel Ol replaces with a number — prevents "- " content from being matched
|
|
218
|
+
return `${indent}\x01${inner}\n`;
|
|
219
|
+
}
|
|
220
|
+
return `${indent}- ${inner}\n`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Table elements
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Th and Td are semantically identical in GFM — position determines header
|
|
229
|
+
* styling, not cell type. Both return ` content |` (trailing pipe delimiter).
|
|
230
|
+
* Tr prepends the leading `|` to form a complete row line.
|
|
231
|
+
*/
|
|
232
|
+
export function Th({ children }: { children?: VNode }): string {
|
|
233
|
+
return ` ${render(children ?? null)} |`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function Td({ children }: { children?: VNode }): string {
|
|
237
|
+
return ` ${render(children ?? null)} |`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function Tr({ children }: { children?: VNode }): string {
|
|
241
|
+
return `|${render(children ?? null)}\n`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Table — renders Tr children, then injects a GFM separator row after the
|
|
246
|
+
* first row (the header). Column count is derived from the first row's pipe
|
|
247
|
+
* count so no column metadata needs to be threaded through context.
|
|
248
|
+
*/
|
|
249
|
+
export function Table({ children }: { children?: VNode }): string {
|
|
250
|
+
const rendered = render(children ?? null);
|
|
251
|
+
const lines = rendered.split('\n').filter((l) => l.trim().length > 0);
|
|
252
|
+
if (lines.length === 0) {
|
|
253
|
+
return '';
|
|
254
|
+
}
|
|
255
|
+
const headerLine = lines[0]!;
|
|
256
|
+
const colCount = headerLine.split('|').filter((s) => s.trim().length > 0).length;
|
|
257
|
+
const separator = '| ' + Array(colCount).fill('---').join(' | ') + ' |';
|
|
258
|
+
const bodyLines = lines.slice(1);
|
|
259
|
+
return [headerLine, separator, ...bodyLines].join('\n') + '\n\n';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Inline elements — no trailing whitespace
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
interface InlineProps {
|
|
267
|
+
children?: VNode;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function Bold({ children }: InlineProps): string {
|
|
271
|
+
return `**${render(children ?? null)}**`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function Code({ children }: InlineProps): string {
|
|
275
|
+
return `\`${render(children ?? null)}\``;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function Italic({ children }: InlineProps): string {
|
|
279
|
+
return `*${render(children ?? null)}*`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function Strikethrough({ children }: InlineProps): string {
|
|
283
|
+
return `~~${render(children ?? null)}~~`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface LinkProps {
|
|
287
|
+
href: string;
|
|
288
|
+
children?: VNode;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function Link({ href, children }: LinkProps): string {
|
|
292
|
+
return `[${render(children ?? null)}](${encodeLinkUrl(href)})`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface ImgProps {
|
|
296
|
+
src: string;
|
|
297
|
+
alt?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function Img({ src, alt = '' }: ImgProps): string {
|
|
301
|
+
return `})`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Raw passthrough — escape hatch only, see AUTHORING RULES above
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
export function Md({ children }: BlockProps): string {
|
|
309
|
+
return render(children ?? null);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// TaskList + Task — GFM task list items
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
export function TaskList({ children }: { children?: VNode }): string {
|
|
317
|
+
const depth = useContext(DepthContext);
|
|
318
|
+
const rendered = withContext(DepthContext, depth + 1, () => render(children ?? null));
|
|
319
|
+
// Mirror Ul: trailing \n only at outermost task list level
|
|
320
|
+
return depth === 0 ? `${rendered}\n` : rendered;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function Task({ children, done }: { children?: VNode; done?: boolean }): string {
|
|
324
|
+
const depth = useContext(DepthContext);
|
|
325
|
+
// Math.max guard matches Li's defensive pattern — safe when Task is used outside TaskList (depth=0)
|
|
326
|
+
const indent = ' '.repeat(Math.max(0, depth - 1));
|
|
327
|
+
const prefix = done ? '[x]' : '[ ]';
|
|
328
|
+
// NOTE: nested TaskList inside a Task appends first inner item inline (same known
|
|
329
|
+
// limitation as Li with nested Ul — structural fix requires block-aware rendering)
|
|
330
|
+
const inner = render(children ?? null).trimEnd();
|
|
331
|
+
return `${indent}- ${prefix} ${inner}\n`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Callout — GitHub-flavored alert blockquote
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
export type CalloutType = 'note' | 'tip' | 'important' | 'warning' | 'caution';
|
|
339
|
+
|
|
340
|
+
export function Callout({
|
|
341
|
+
children,
|
|
342
|
+
type,
|
|
343
|
+
}: {
|
|
344
|
+
children?: VNode;
|
|
345
|
+
type: CalloutType;
|
|
346
|
+
}): string {
|
|
347
|
+
const inner = render(children ?? null).trimEnd();
|
|
348
|
+
const lines = inner.split('\n').map((line) => (line === '' ? '>' : `> ${line}`)).join('\n');
|
|
349
|
+
return `> [!${type.toUpperCase()}]\n${lines}\n\n`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// HtmlComment — renders <!-- content --> (single-line or multi-line)
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
export function HtmlComment({ children }: { children?: VNode }): string {
|
|
357
|
+
const inner = render(children ?? null).trimEnd();
|
|
358
|
+
// Use .trim() only for the empty-check: whitespace-only content → <!-- -->
|
|
359
|
+
if (!inner.trim()) {
|
|
360
|
+
return `<!-- -->\n`;
|
|
361
|
+
}
|
|
362
|
+
if (inner.includes('\n')) {
|
|
363
|
+
return `<!--\n${inner}\n-->\n`;
|
|
364
|
+
}
|
|
365
|
+
return `<!-- ${inner} -->\n`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Details — GitHub collapsible section
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
export function Details({
|
|
373
|
+
children,
|
|
374
|
+
summary,
|
|
375
|
+
}: {
|
|
376
|
+
children?: VNode;
|
|
377
|
+
summary: string;
|
|
378
|
+
}): string {
|
|
379
|
+
// trimEnd() required: GitHub needs a blank line before </details> to render body as markdown.
|
|
380
|
+
// The \n\n in the template provides that; trimEnd prevents double-blank-lines.
|
|
381
|
+
const body = render(children ?? null).trimEnd();
|
|
382
|
+
return `<details>\n<summary>${escapeHtmlContent(summary)}</summary>\n\n${body}\n\n</details>\n`;
|
|
383
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render() — converts a VNode tree to a markdown string.
|
|
3
|
+
*
|
|
4
|
+
* This is the top-down evaluation pass. Components in the tree are called
|
|
5
|
+
* here, not at JSX construction time. Children are passed as raw VNode values
|
|
6
|
+
* to each component so they can wrap rendering in context (e.g. DepthContext).
|
|
7
|
+
*
|
|
8
|
+
* Registers itself with the render registry (via _render-registry.ts) on module
|
|
9
|
+
* init to provide Fragment with a render reference without a circular import.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { registerRender } from './_render-registry.ts';
|
|
13
|
+
import { escapeHtmlAttr } from './escape.ts';
|
|
14
|
+
import { type VNode, type VNodeElement } from './jsx-runtime.ts';
|
|
15
|
+
|
|
16
|
+
function isVNodeElement(node: VNode): node is VNodeElement {
|
|
17
|
+
return typeof node === 'object' && node !== null && !Array.isArray(node);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function render(node: VNode): string {
|
|
21
|
+
if (node === null || node === undefined || node === false || node === true) {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
if (typeof node === 'string') {
|
|
25
|
+
return node;
|
|
26
|
+
}
|
|
27
|
+
if (typeof node === 'number') {
|
|
28
|
+
return String(node);
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(node)) {
|
|
31
|
+
return (node as ReadonlyArray<VNode>).map(render).join('');
|
|
32
|
+
}
|
|
33
|
+
// VNodeElement — dispatch on type
|
|
34
|
+
if (!isVNodeElement(node)) {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
const el = node; // narrowed to VNodeElement
|
|
38
|
+
|
|
39
|
+
// String type → render as an XML block tag
|
|
40
|
+
if (typeof el.type === 'string') {
|
|
41
|
+
const { children, ...attrs } = el.props;
|
|
42
|
+
const attrStr = Object.entries(attrs)
|
|
43
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== false)
|
|
44
|
+
.map(([k, v]) => (v === true ? ` ${k}` : ` ${k}="${escapeHtmlAttr(String(v))}"`))
|
|
45
|
+
.join('');
|
|
46
|
+
const inner = render(children as VNode ?? null);
|
|
47
|
+
if (inner === '') {
|
|
48
|
+
return `<${el.type}${attrStr} />\n`;
|
|
49
|
+
}
|
|
50
|
+
return `<${el.type}${attrStr}>\n${inner.trimEnd()}\n</${el.type}>\n`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Function component — call with its props (children still as VNode)
|
|
54
|
+
return el.type(el.props);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Register render with Fragment immediately on module init.
|
|
58
|
+
// Any entry point that imports render will trigger this before rendering starts.
|
|
59
|
+
// Cast is safe: callRender passes only VNode values to render at runtime.
|
|
60
|
+
registerRender(render as (node: unknown) => string);
|