ai-props 2.1.3 → 2.3.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/.dev.vars +2 -0
- package/CHANGELOG.md +11 -0
- package/README.md +2 -0
- package/package.json +39 -13
- package/src/ai.ts +12 -31
- package/src/cascade.ts +795 -0
- package/src/client.ts +440 -0
- package/src/durable-cascade.ts +743 -0
- package/src/event-bridge.ts +478 -0
- package/src/generate.ts +14 -12
- package/src/hoc.ts +15 -19
- package/src/hono-jsx.ts +675 -0
- package/src/index.ts +30 -0
- package/src/mdx-types.ts +169 -0
- package/src/mdx-utils.ts +437 -0
- package/src/mdx.ts +1008 -0
- package/src/rpc.ts +614 -0
- package/src/streaming.ts +618 -0
- package/src/validate.ts +15 -29
- package/src/worker.ts +547 -0
- package/test/cascade.test.ts +338 -0
- package/test/durable-cascade.test.ts +319 -0
- package/test/event-bridge.test.ts +351 -0
- package/test/generate.test.ts +6 -16
- package/test/mdx.test.ts +817 -0
- package/test/worker/capnweb-rpc.test.ts +1084 -0
- package/test/worker/full-flow.integration.test.ts +1463 -0
- package/test/worker/hono-jsx.test.ts +1258 -0
- package/test/worker/mdx-parsing.test.ts +1148 -0
- package/test/worker/setup.ts +56 -0
- package/test/worker.test.ts +595 -0
- package/tsconfig.json +2 -1
- package/vitest.config.js +6 -0
- package/vitest.config.ts +15 -1
- package/vitest.workers.config.ts +58 -0
- package/wrangler.jsonc +27 -0
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
- package/dist/ai.d.ts +0 -125
- package/dist/ai.d.ts.map +0 -1
- package/dist/ai.js +0 -199
- package/dist/ai.js.map +0 -1
- package/dist/cache.d.ts +0 -66
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js +0 -183
- package/dist/cache.js.map +0 -1
- package/dist/generate.d.ts +0 -69
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js +0 -221
- package/dist/generate.js.map +0 -1
- package/dist/hoc.d.ts +0 -164
- package/dist/hoc.d.ts.map +0 -1
- package/dist/hoc.js +0 -236
- package/dist/hoc.js.map +0 -1
- package/dist/index.d.ts +0 -15
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -21
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts +0 -152
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -7
- package/dist/types.js.map +0 -1
- package/dist/validate.d.ts +0 -58
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -253
- package/dist/validate.js.map +0 -1
- package/src/ai.js +0 -198
- package/src/cache.js +0 -182
- package/src/generate.js +0 -220
- package/src/hoc.js +0 -235
- package/src/index.js +0 -20
- package/src/types.js +0 -6
- package/src/validate.js +0 -252
package/test/mdx.test.ts
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for MDX parsing and rendering with AI-generated props
|
|
3
|
+
*
|
|
4
|
+
* RED phase: These tests define the expected behavior for MDX integration.
|
|
5
|
+
* They will fail because the MDX parsing module does not exist yet.
|
|
6
|
+
*
|
|
7
|
+
* The MDX integration should:
|
|
8
|
+
* 1. Parse MDX content strings (with frontmatter)
|
|
9
|
+
* 2. Extract component prop schemas from MDX
|
|
10
|
+
* 3. Generate AI props for components found in MDX
|
|
11
|
+
* 4. Render MDX with injected props
|
|
12
|
+
* 5. Handle errors gracefully
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
// Mock the ai-functions generateObject (same pattern as other test files)
|
|
18
|
+
vi.mock('ai-functions', () => ({
|
|
19
|
+
generateObject: vi.fn().mockImplementation(async ({ schema }) => {
|
|
20
|
+
const mockData: Record<string, unknown> = {}
|
|
21
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
if (value.includes('(number)')) {
|
|
24
|
+
mockData[key] = 42
|
|
25
|
+
} else if (value.includes('(boolean)')) {
|
|
26
|
+
mockData[key] = true
|
|
27
|
+
} else {
|
|
28
|
+
mockData[key] = `generated-${key}`
|
|
29
|
+
}
|
|
30
|
+
} else if (Array.isArray(value)) {
|
|
31
|
+
mockData[key] = ['item1', 'item2']
|
|
32
|
+
} else if (typeof value === 'object') {
|
|
33
|
+
mockData[key] = { nested: 'value' }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { object: mockData }
|
|
37
|
+
}),
|
|
38
|
+
schema: vi.fn((s) => s),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
// These imports will fail since the mdx module does not exist yet
|
|
42
|
+
import {
|
|
43
|
+
parseMDX,
|
|
44
|
+
extractComponentSchemas,
|
|
45
|
+
renderMDXWithProps,
|
|
46
|
+
createMDXPropsGenerator,
|
|
47
|
+
compileMDX,
|
|
48
|
+
} from '../src/mdx.js'
|
|
49
|
+
import { resetConfig, clearCache } from '../src/index.js'
|
|
50
|
+
|
|
51
|
+
describe('MDX parsing', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
resetConfig()
|
|
54
|
+
clearCache()
|
|
55
|
+
vi.clearAllMocks()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('parseMDX()', () => {
|
|
59
|
+
it('parses simple MDX content string', () => {
|
|
60
|
+
const mdx = `# Hello World
|
|
61
|
+
|
|
62
|
+
This is a simple MDX document.`
|
|
63
|
+
|
|
64
|
+
const result = parseMDX(mdx)
|
|
65
|
+
|
|
66
|
+
expect(result).toBeDefined()
|
|
67
|
+
expect(result.content).toBeDefined()
|
|
68
|
+
expect(result.frontmatter).toEqual({})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('parses MDX content with frontmatter', () => {
|
|
72
|
+
const mdx = `---
|
|
73
|
+
title: My Page
|
|
74
|
+
description: A test page
|
|
75
|
+
author: Test Author
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
# {title}
|
|
79
|
+
|
|
80
|
+
{description}`
|
|
81
|
+
|
|
82
|
+
const result = parseMDX(mdx)
|
|
83
|
+
|
|
84
|
+
expect(result.frontmatter).toBeDefined()
|
|
85
|
+
expect(result.frontmatter.title).toBe('My Page')
|
|
86
|
+
expect(result.frontmatter.description).toBe('A test page')
|
|
87
|
+
expect(result.frontmatter.author).toBe('Test Author')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('parses MDX content with YAML frontmatter types', () => {
|
|
91
|
+
const mdx = `---
|
|
92
|
+
title: My Page
|
|
93
|
+
count: 5
|
|
94
|
+
published: true
|
|
95
|
+
tags:
|
|
96
|
+
- ai
|
|
97
|
+
- props
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
# Content`
|
|
101
|
+
|
|
102
|
+
const result = parseMDX(mdx)
|
|
103
|
+
|
|
104
|
+
expect(result.frontmatter.title).toBe('My Page')
|
|
105
|
+
expect(result.frontmatter.count).toBe(5)
|
|
106
|
+
expect(result.frontmatter.published).toBe(true)
|
|
107
|
+
expect(result.frontmatter.tags).toEqual(['ai', 'props'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('separates frontmatter from body content', () => {
|
|
111
|
+
const mdx = `---
|
|
112
|
+
title: Hello
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
# Body Content
|
|
116
|
+
|
|
117
|
+
Some paragraph text.`
|
|
118
|
+
|
|
119
|
+
const result = parseMDX(mdx)
|
|
120
|
+
|
|
121
|
+
expect(result.body).not.toContain('---')
|
|
122
|
+
expect(result.body).not.toContain('title: Hello')
|
|
123
|
+
expect(result.body).toContain('# Body Content')
|
|
124
|
+
expect(result.body).toContain('Some paragraph text.')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('handles MDX with no frontmatter', () => {
|
|
128
|
+
const mdx = `# No Frontmatter
|
|
129
|
+
|
|
130
|
+
Just regular content.`
|
|
131
|
+
|
|
132
|
+
const result = parseMDX(mdx)
|
|
133
|
+
|
|
134
|
+
expect(result.frontmatter).toEqual({})
|
|
135
|
+
expect(result.body).toContain('# No Frontmatter')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('handles empty MDX string', () => {
|
|
139
|
+
const result = parseMDX('')
|
|
140
|
+
|
|
141
|
+
expect(result).toBeDefined()
|
|
142
|
+
expect(result.frontmatter).toEqual({})
|
|
143
|
+
expect(result.body).toBe('')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('identifies JSX components in MDX', () => {
|
|
147
|
+
const mdx = `---
|
|
148
|
+
title: Page
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
# Hello
|
|
152
|
+
|
|
153
|
+
<Hero title="Welcome" />
|
|
154
|
+
|
|
155
|
+
<Card>
|
|
156
|
+
<CardBody>Some content</CardBody>
|
|
157
|
+
</Card>
|
|
158
|
+
|
|
159
|
+
<Footer />`
|
|
160
|
+
|
|
161
|
+
const result = parseMDX(mdx)
|
|
162
|
+
|
|
163
|
+
expect(result.components).toBeDefined()
|
|
164
|
+
expect(result.components).toContain('Hero')
|
|
165
|
+
expect(result.components).toContain('Card')
|
|
166
|
+
expect(result.components).toContain('CardBody')
|
|
167
|
+
expect(result.components).toContain('Footer')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('identifies components with props in MDX', () => {
|
|
171
|
+
const mdx = `<Button variant="primary" size="lg" disabled>Click Me</Button>
|
|
172
|
+
<Input placeholder="Enter text" type="email" />`
|
|
173
|
+
|
|
174
|
+
const result = parseMDX(mdx)
|
|
175
|
+
|
|
176
|
+
expect(result.componentProps).toBeDefined()
|
|
177
|
+
expect(result.componentProps.Button).toEqual({
|
|
178
|
+
variant: 'primary',
|
|
179
|
+
size: 'lg',
|
|
180
|
+
disabled: true,
|
|
181
|
+
})
|
|
182
|
+
expect(result.componentProps.Input).toEqual({
|
|
183
|
+
placeholder: 'Enter text',
|
|
184
|
+
type: 'email',
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('extractComponentSchemas()', () => {
|
|
190
|
+
it('extracts prop schemas from MDX component usage', () => {
|
|
191
|
+
const mdx = `<UserCard name="John" bio="A developer" avatar="/img.png" />`
|
|
192
|
+
|
|
193
|
+
const schemas = extractComponentSchemas(mdx)
|
|
194
|
+
|
|
195
|
+
expect(schemas).toBeDefined()
|
|
196
|
+
expect(schemas.UserCard).toBeDefined()
|
|
197
|
+
expect(Object.keys(schemas.UserCard)).toContain('name')
|
|
198
|
+
expect(Object.keys(schemas.UserCard)).toContain('bio')
|
|
199
|
+
expect(Object.keys(schemas.UserCard)).toContain('avatar')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('extracts schemas from multiple component instances', () => {
|
|
203
|
+
const mdx = `<Card title="First" />
|
|
204
|
+
<Card title="Second" description="With desc" />`
|
|
205
|
+
|
|
206
|
+
const schemas = extractComponentSchemas(mdx)
|
|
207
|
+
|
|
208
|
+
// Should merge schemas from multiple instances
|
|
209
|
+
expect(schemas.Card).toBeDefined()
|
|
210
|
+
expect(Object.keys(schemas.Card)).toContain('title')
|
|
211
|
+
expect(Object.keys(schemas.Card)).toContain('description')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('identifies components that need AI-generated props', () => {
|
|
215
|
+
const mdx = `<Hero />
|
|
216
|
+
<Card title="Provided" />
|
|
217
|
+
<Footer />`
|
|
218
|
+
|
|
219
|
+
const schemas = extractComponentSchemas(mdx)
|
|
220
|
+
|
|
221
|
+
// Components with no props should be flagged as needing generation
|
|
222
|
+
expect(schemas.Hero).toBeDefined()
|
|
223
|
+
expect(Object.keys(schemas.Hero)).toHaveLength(0)
|
|
224
|
+
expect(schemas.Footer).toBeDefined()
|
|
225
|
+
expect(Object.keys(schemas.Footer)).toHaveLength(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('handles components with expression props', () => {
|
|
229
|
+
const mdx = `<Widget count={42} active={true} data={{ key: 'value' }} />`
|
|
230
|
+
|
|
231
|
+
const schemas = extractComponentSchemas(mdx)
|
|
232
|
+
|
|
233
|
+
expect(schemas.Widget).toBeDefined()
|
|
234
|
+
expect(Object.keys(schemas.Widget)).toContain('count')
|
|
235
|
+
expect(Object.keys(schemas.Widget)).toContain('active')
|
|
236
|
+
expect(Object.keys(schemas.Widget)).toContain('data')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('returns empty object for MDX with no components', () => {
|
|
240
|
+
const mdx = `# Just Markdown
|
|
241
|
+
|
|
242
|
+
Regular paragraph text with **bold** and *italic*.`
|
|
243
|
+
|
|
244
|
+
const schemas = extractComponentSchemas(mdx)
|
|
245
|
+
|
|
246
|
+
expect(schemas).toEqual({})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('MDX props generation', () => {
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
resetConfig()
|
|
254
|
+
clearCache()
|
|
255
|
+
vi.clearAllMocks()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('createMDXPropsGenerator()', () => {
|
|
259
|
+
it('creates a props generator for MDX content', () => {
|
|
260
|
+
const generator = createMDXPropsGenerator({
|
|
261
|
+
schemas: {
|
|
262
|
+
Hero: {
|
|
263
|
+
title: 'Hero section title',
|
|
264
|
+
subtitle: 'Hero section subtitle',
|
|
265
|
+
},
|
|
266
|
+
Card: {
|
|
267
|
+
title: 'Card title',
|
|
268
|
+
description: 'Card description',
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(generator).toBeDefined()
|
|
274
|
+
expect(typeof generator.generate).toBe('function')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('generates props for components in MDX', async () => {
|
|
278
|
+
const generator = createMDXPropsGenerator({
|
|
279
|
+
schemas: {
|
|
280
|
+
Hero: {
|
|
281
|
+
title: 'Hero section title',
|
|
282
|
+
subtitle: 'Hero section subtitle',
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const mdx = `---
|
|
288
|
+
topic: AI Applications
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
<Hero />`
|
|
292
|
+
|
|
293
|
+
const result = await generator.generate(mdx)
|
|
294
|
+
|
|
295
|
+
expect(result).toBeDefined()
|
|
296
|
+
expect(result.Hero).toBeDefined()
|
|
297
|
+
expect(result.Hero.title).toBeDefined()
|
|
298
|
+
expect(result.Hero.subtitle).toBeDefined()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('uses frontmatter context for generation', async () => {
|
|
302
|
+
const generator = createMDXPropsGenerator({
|
|
303
|
+
schemas: {
|
|
304
|
+
Hero: {
|
|
305
|
+
title: 'A title relevant to the page topic',
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const mdx = `---
|
|
311
|
+
topic: Machine Learning
|
|
312
|
+
audience: developers
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
<Hero />`
|
|
316
|
+
|
|
317
|
+
const result = await generator.generate(mdx)
|
|
318
|
+
|
|
319
|
+
expect(result.Hero).toBeDefined()
|
|
320
|
+
expect(result.Hero.title).toBeDefined()
|
|
321
|
+
// The generator should have used frontmatter as context
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('preserves explicitly provided props', async () => {
|
|
325
|
+
const generator = createMDXPropsGenerator({
|
|
326
|
+
schemas: {
|
|
327
|
+
Card: {
|
|
328
|
+
title: 'Card title',
|
|
329
|
+
description: 'Card description',
|
|
330
|
+
image: 'Image URL',
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const mdx = `<Card title="My Explicit Title" />`
|
|
336
|
+
|
|
337
|
+
const result = await generator.generate(mdx)
|
|
338
|
+
|
|
339
|
+
// Explicit props should be preserved
|
|
340
|
+
expect(result.Card.title).toBe('My Explicit Title')
|
|
341
|
+
// Missing props should be generated
|
|
342
|
+
expect(result.Card.description).toBeDefined()
|
|
343
|
+
expect(result.Card.image).toBeDefined()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('generates props for multiple component instances', async () => {
|
|
347
|
+
const generator = createMDXPropsGenerator({
|
|
348
|
+
schemas: {
|
|
349
|
+
Card: {
|
|
350
|
+
title: 'Card title',
|
|
351
|
+
description: 'Card description',
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const mdx = `<Card />
|
|
357
|
+
<Card />
|
|
358
|
+
<Card />`
|
|
359
|
+
|
|
360
|
+
const result = await generator.generate(mdx)
|
|
361
|
+
|
|
362
|
+
// Should generate props for each instance
|
|
363
|
+
expect(result.Card).toBeDefined()
|
|
364
|
+
expect(Array.isArray(result.Card) || typeof result.Card === 'object').toBe(true)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('caches generated props per component', async () => {
|
|
368
|
+
const generator = createMDXPropsGenerator({
|
|
369
|
+
schemas: {
|
|
370
|
+
Hero: {
|
|
371
|
+
title: 'Hero title',
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
cache: true,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
const mdx = `<Hero />`
|
|
378
|
+
|
|
379
|
+
// First generation
|
|
380
|
+
const result1 = await generator.generate(mdx)
|
|
381
|
+
|
|
382
|
+
// Second generation (same content) should use cache
|
|
383
|
+
const result2 = await generator.generate(mdx)
|
|
384
|
+
|
|
385
|
+
expect(result1.Hero.title).toEqual(result2.Hero.title)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('handles async prop generation', async () => {
|
|
389
|
+
const generator = createMDXPropsGenerator({
|
|
390
|
+
schemas: {
|
|
391
|
+
AsyncWidget: {
|
|
392
|
+
data: 'Complex data structure',
|
|
393
|
+
status: 'Widget status',
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const mdx = `<AsyncWidget />`
|
|
399
|
+
|
|
400
|
+
const result = await generator.generate(mdx)
|
|
401
|
+
|
|
402
|
+
expect(result.AsyncWidget).toBeDefined()
|
|
403
|
+
expect(result.AsyncWidget.data).toBeDefined()
|
|
404
|
+
expect(result.AsyncWidget.status).toBeDefined()
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('passes custom model configuration', async () => {
|
|
408
|
+
const generator = createMDXPropsGenerator({
|
|
409
|
+
schemas: {
|
|
410
|
+
Hero: { title: 'Title' },
|
|
411
|
+
},
|
|
412
|
+
model: 'gpt-4',
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const mdx = `<Hero />`
|
|
416
|
+
|
|
417
|
+
const result = await generator.generate(mdx)
|
|
418
|
+
|
|
419
|
+
expect(result.Hero).toBeDefined()
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('MDX rendering with props', () => {
|
|
425
|
+
beforeEach(() => {
|
|
426
|
+
resetConfig()
|
|
427
|
+
clearCache()
|
|
428
|
+
vi.clearAllMocks()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
describe('renderMDXWithProps()', () => {
|
|
432
|
+
it('renders MDX string with injected props', async () => {
|
|
433
|
+
const mdx = `# Hello
|
|
434
|
+
|
|
435
|
+
<Hero title="Welcome" subtitle="To the future" />`
|
|
436
|
+
|
|
437
|
+
const result = await renderMDXWithProps(mdx, {
|
|
438
|
+
Hero: {
|
|
439
|
+
title: 'Welcome',
|
|
440
|
+
subtitle: 'To the future',
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
expect(result).toBeDefined()
|
|
445
|
+
expect(typeof result).toBe('string')
|
|
446
|
+
expect(result).toContain('Welcome')
|
|
447
|
+
expect(result).toContain('To the future')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('renders MDX with generated props for components missing props', async () => {
|
|
451
|
+
const mdx = `<Hero />`
|
|
452
|
+
|
|
453
|
+
const result = await renderMDXWithProps(mdx, {
|
|
454
|
+
Hero: {
|
|
455
|
+
title: 'Generated Title',
|
|
456
|
+
subtitle: 'Generated Subtitle',
|
|
457
|
+
},
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
expect(result).toContain('Generated Title')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('passes props to custom component renderers', async () => {
|
|
464
|
+
const mdx = `<Card title="Test" description="A card" />`
|
|
465
|
+
|
|
466
|
+
const components = {
|
|
467
|
+
Card: (props: Record<string, unknown>) => {
|
|
468
|
+
return `<div class="card"><h2>${props.title}</h2><p>${props.description}</p></div>`
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const result = await renderMDXWithProps(
|
|
473
|
+
mdx,
|
|
474
|
+
{ Card: { title: 'Test', description: 'A card' } },
|
|
475
|
+
{ components }
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
expect(result).toContain('Test')
|
|
479
|
+
expect(result).toContain('A card')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('preserves component tree structure', async () => {
|
|
483
|
+
const mdx = `<Layout>
|
|
484
|
+
<Header title="Page Title" />
|
|
485
|
+
<Main>
|
|
486
|
+
<Card title="Card 1" />
|
|
487
|
+
<Card title="Card 2" />
|
|
488
|
+
</Main>
|
|
489
|
+
<Footer />
|
|
490
|
+
</Layout>`
|
|
491
|
+
|
|
492
|
+
const props = {
|
|
493
|
+
Layout: {},
|
|
494
|
+
Header: { title: 'Page Title' },
|
|
495
|
+
Main: {},
|
|
496
|
+
Card: { title: 'Card Title' },
|
|
497
|
+
Footer: { copyright: '2026' },
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const result = await renderMDXWithProps(mdx, props)
|
|
501
|
+
|
|
502
|
+
expect(result).toBeDefined()
|
|
503
|
+
expect(typeof result).toBe('string')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('renders MDX with frontmatter variables', async () => {
|
|
507
|
+
const mdx = `---
|
|
508
|
+
title: Dynamic Page
|
|
509
|
+
author: AI
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
# {title}
|
|
513
|
+
|
|
514
|
+
Written by {author}.`
|
|
515
|
+
|
|
516
|
+
const result = await renderMDXWithProps(mdx, {})
|
|
517
|
+
|
|
518
|
+
expect(result).toContain('Dynamic Page')
|
|
519
|
+
expect(result).toContain('AI')
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('handles streaming render', async () => {
|
|
523
|
+
const mdx = `# Hello
|
|
524
|
+
|
|
525
|
+
<Hero title="Welcome" />
|
|
526
|
+
|
|
527
|
+
Some content after.`
|
|
528
|
+
|
|
529
|
+
const stream = await renderMDXWithProps(
|
|
530
|
+
mdx,
|
|
531
|
+
{
|
|
532
|
+
Hero: { title: 'Welcome' },
|
|
533
|
+
},
|
|
534
|
+
{ stream: true }
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
expect(stream).toBeDefined()
|
|
538
|
+
// Stream should be iterable or a ReadableStream
|
|
539
|
+
if (stream instanceof ReadableStream) {
|
|
540
|
+
const reader = stream.getReader()
|
|
541
|
+
const { value } = await reader.read()
|
|
542
|
+
expect(value).toBeDefined()
|
|
543
|
+
reader.releaseLock()
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('merges provided props with generated props', async () => {
|
|
548
|
+
const mdx = `<ProductCard name="Widget" />`
|
|
549
|
+
|
|
550
|
+
const result = await renderMDXWithProps(mdx, {
|
|
551
|
+
ProductCard: {
|
|
552
|
+
name: 'Widget',
|
|
553
|
+
price: 29.99,
|
|
554
|
+
description: 'A useful widget',
|
|
555
|
+
},
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
expect(result).toContain('Widget')
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe('compileMDX()', () => {
|
|
563
|
+
it('compiles MDX string to executable function', async () => {
|
|
564
|
+
const mdx = `# Hello World
|
|
565
|
+
|
|
566
|
+
This is content.`
|
|
567
|
+
|
|
568
|
+
const compiled = await compileMDX(mdx)
|
|
569
|
+
|
|
570
|
+
expect(compiled).toBeDefined()
|
|
571
|
+
expect(typeof compiled).toBe('function')
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('compiled function accepts props argument', async () => {
|
|
575
|
+
const mdx = `<Greeting name="World" />`
|
|
576
|
+
|
|
577
|
+
const compiled = await compileMDX(mdx)
|
|
578
|
+
const result = compiled({
|
|
579
|
+
Greeting: { name: 'World' },
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
expect(result).toBeDefined()
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('compiled function accepts component map', async () => {
|
|
586
|
+
const mdx = `<Custom value="test" />`
|
|
587
|
+
|
|
588
|
+
const compiled = await compileMDX(mdx, {
|
|
589
|
+
components: {
|
|
590
|
+
Custom: (props: Record<string, unknown>) => `Custom: ${props.value}`,
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
const result = compiled({ Custom: { value: 'test' } })
|
|
595
|
+
|
|
596
|
+
expect(result).toContain('test')
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('handles MDX with imports', async () => {
|
|
600
|
+
const mdx = `import { Button } from './components'
|
|
601
|
+
|
|
602
|
+
# Page
|
|
603
|
+
|
|
604
|
+
<Button variant="primary">Click</Button>`
|
|
605
|
+
|
|
606
|
+
// Should handle import statements without throwing
|
|
607
|
+
const compiled = await compileMDX(mdx)
|
|
608
|
+
|
|
609
|
+
expect(compiled).toBeDefined()
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('handles MDX with export statements', async () => {
|
|
613
|
+
const mdx = `export const metadata = { title: 'Test' }
|
|
614
|
+
|
|
615
|
+
# Page Content`
|
|
616
|
+
|
|
617
|
+
const compiled = await compileMDX(mdx)
|
|
618
|
+
|
|
619
|
+
expect(compiled).toBeDefined()
|
|
620
|
+
expect(compiled.metadata).toBeDefined()
|
|
621
|
+
expect(compiled.metadata.title).toBe('Test')
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
describe('MDX error handling', () => {
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
resetConfig()
|
|
629
|
+
clearCache()
|
|
630
|
+
vi.clearAllMocks()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('throws descriptive error for invalid MDX syntax', () => {
|
|
634
|
+
const invalidMDX = `<Unclosed
|
|
635
|
+
|
|
636
|
+
This has unclosed JSX.`
|
|
637
|
+
|
|
638
|
+
expect(() => parseMDX(invalidMDX)).toThrow()
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('throws for malformed frontmatter', () => {
|
|
642
|
+
const invalidMDX = `---
|
|
643
|
+
title: [invalid yaml
|
|
644
|
+
broken: {
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
# Content`
|
|
648
|
+
|
|
649
|
+
expect(() => parseMDX(invalidMDX)).toThrow()
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it('provides error location info for invalid MDX', () => {
|
|
653
|
+
const invalidMDX = `# Valid
|
|
654
|
+
<Valid />
|
|
655
|
+
<Invalid prop=>`
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
parseMDX(invalidMDX)
|
|
659
|
+
// Should not reach here
|
|
660
|
+
expect(true).toBe(false)
|
|
661
|
+
} catch (error) {
|
|
662
|
+
expect(error).toBeInstanceOf(Error)
|
|
663
|
+
const err = error as Error & { line?: number; column?: number }
|
|
664
|
+
// Error should contain location information
|
|
665
|
+
expect(err.message).toBeDefined()
|
|
666
|
+
expect(err.message.length).toBeGreaterThan(0)
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('handles MDX compilation errors gracefully', async () => {
|
|
671
|
+
const invalidMDX = `<>{(() => { throw new Error("runtime error") })()}</>`
|
|
672
|
+
|
|
673
|
+
await expect(compileMDX(invalidMDX)).rejects.toThrow()
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('handles missing component schemas gracefully in generator', async () => {
|
|
677
|
+
const generator = createMDXPropsGenerator({
|
|
678
|
+
schemas: {},
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
const mdx = `<UnknownComponent />`
|
|
682
|
+
|
|
683
|
+
// Should not throw, but should return empty or skip unknown components
|
|
684
|
+
const result = await generator.generate(mdx)
|
|
685
|
+
|
|
686
|
+
expect(result).toBeDefined()
|
|
687
|
+
expect(result.UnknownComponent).toBeUndefined()
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('handles render errors for invalid props', async () => {
|
|
691
|
+
const mdx = `<StrictComponent />`
|
|
692
|
+
|
|
693
|
+
// Rendering with invalid/missing required props
|
|
694
|
+
await expect(
|
|
695
|
+
renderMDXWithProps(mdx, {
|
|
696
|
+
StrictComponent: null as unknown as Record<string, unknown>,
|
|
697
|
+
})
|
|
698
|
+
).rejects.toThrow()
|
|
699
|
+
})
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
describe('MDX with AI props end-to-end', () => {
|
|
703
|
+
beforeEach(() => {
|
|
704
|
+
resetConfig()
|
|
705
|
+
clearCache()
|
|
706
|
+
vi.clearAllMocks()
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('parses MDX, generates props, and renders in one pipeline', async () => {
|
|
710
|
+
const mdx = `---
|
|
711
|
+
topic: Artificial Intelligence
|
|
712
|
+
audience: developers
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
# {topic} Guide
|
|
716
|
+
|
|
717
|
+
<Hero />
|
|
718
|
+
|
|
719
|
+
<Card />
|
|
720
|
+
|
|
721
|
+
<Footer />`
|
|
722
|
+
|
|
723
|
+
// Step 1: Parse
|
|
724
|
+
const parsed = parseMDX(mdx)
|
|
725
|
+
|
|
726
|
+
expect(parsed.frontmatter.topic).toBe('Artificial Intelligence')
|
|
727
|
+
expect(parsed.components).toContain('Hero')
|
|
728
|
+
expect(parsed.components).toContain('Card')
|
|
729
|
+
expect(parsed.components).toContain('Footer')
|
|
730
|
+
|
|
731
|
+
// Step 2: Create generator with schemas
|
|
732
|
+
const generator = createMDXPropsGenerator({
|
|
733
|
+
schemas: {
|
|
734
|
+
Hero: {
|
|
735
|
+
title: 'Hero title for the page topic',
|
|
736
|
+
subtitle: 'Hero subtitle',
|
|
737
|
+
},
|
|
738
|
+
Card: {
|
|
739
|
+
title: 'Card title',
|
|
740
|
+
description: 'Card description',
|
|
741
|
+
},
|
|
742
|
+
Footer: {
|
|
743
|
+
copyright: 'Copyright notice',
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
// Step 3: Generate props
|
|
749
|
+
const generatedProps = await generator.generate(mdx)
|
|
750
|
+
|
|
751
|
+
expect(generatedProps.Hero).toBeDefined()
|
|
752
|
+
expect(generatedProps.Card).toBeDefined()
|
|
753
|
+
expect(generatedProps.Footer).toBeDefined()
|
|
754
|
+
|
|
755
|
+
// Step 4: Render with generated props
|
|
756
|
+
const rendered = await renderMDXWithProps(mdx, generatedProps)
|
|
757
|
+
|
|
758
|
+
expect(rendered).toBeDefined()
|
|
759
|
+
expect(typeof rendered).toBe('string')
|
|
760
|
+
expect(rendered.length).toBeGreaterThan(0)
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('supports schema-less generation from component usage', async () => {
|
|
764
|
+
const mdx = `<ProductCard
|
|
765
|
+
name="AI Widget"
|
|
766
|
+
price={29.99}
|
|
767
|
+
category="technology"
|
|
768
|
+
/>`
|
|
769
|
+
|
|
770
|
+
// Extract schemas from actual component usage
|
|
771
|
+
const schemas = extractComponentSchemas(mdx)
|
|
772
|
+
|
|
773
|
+
expect(schemas.ProductCard).toBeDefined()
|
|
774
|
+
expect(Object.keys(schemas.ProductCard)).toContain('name')
|
|
775
|
+
expect(Object.keys(schemas.ProductCard)).toContain('price')
|
|
776
|
+
expect(Object.keys(schemas.ProductCard)).toContain('category')
|
|
777
|
+
|
|
778
|
+
// Use extracted schemas for generation
|
|
779
|
+
const generator = createMDXPropsGenerator({ schemas })
|
|
780
|
+
const props = await generator.generate(mdx)
|
|
781
|
+
|
|
782
|
+
expect(props.ProductCard).toBeDefined()
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it('handles MDX with mixed static and AI-generated content', async () => {
|
|
786
|
+
const mdx = `---
|
|
787
|
+
title: Product Page
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
# {title}
|
|
791
|
+
|
|
792
|
+
<StaticBanner text="Sale!" />
|
|
793
|
+
|
|
794
|
+
<DynamicRecommendation />
|
|
795
|
+
|
|
796
|
+
<StaticFooter year={2026} />`
|
|
797
|
+
|
|
798
|
+
const generator = createMDXPropsGenerator({
|
|
799
|
+
schemas: {
|
|
800
|
+
DynamicRecommendation: {
|
|
801
|
+
product: 'Recommended product name',
|
|
802
|
+
reason: 'Why this product is recommended',
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
const props = await generator.generate(mdx)
|
|
808
|
+
|
|
809
|
+
// Only DynamicRecommendation should have generated props
|
|
810
|
+
expect(props.DynamicRecommendation).toBeDefined()
|
|
811
|
+
expect(props.DynamicRecommendation.product).toBeDefined()
|
|
812
|
+
expect(props.DynamicRecommendation.reason).toBeDefined()
|
|
813
|
+
// Static components should not be in generated props
|
|
814
|
+
expect(props.StaticBanner).toBeUndefined()
|
|
815
|
+
expect(props.StaticFooter).toBeUndefined()
|
|
816
|
+
})
|
|
817
|
+
})
|