ai-props 2.1.1 → 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 +24 -0
- package/README.md +131 -118
- package/package.json +30 -4
- 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 -5
- 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
|
@@ -0,0 +1,1148 @@
|
|
|
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
|
+
* in a real Cloudflare Workers environment using @cloudflare/vitest-pool-workers.
|
|
6
|
+
*
|
|
7
|
+
* NO MOCKS - all tests run against real Workers runtime and AI Gateway bindings.
|
|
8
|
+
*
|
|
9
|
+
* The MDX integration should:
|
|
10
|
+
* 1. Parse MDX content strings (with frontmatter)
|
|
11
|
+
* 2. Extract component prop schemas from MDX
|
|
12
|
+
* 3. Generate AI props for components found in MDX
|
|
13
|
+
* 4. Render MDX with injected props
|
|
14
|
+
* 5. Support custom component renderers
|
|
15
|
+
* 6. Support streaming MDX rendering
|
|
16
|
+
* 7. Handle errors gracefully
|
|
17
|
+
*
|
|
18
|
+
* RED phase tests:
|
|
19
|
+
* - Some tests PASS because basic mdx.ts implementation exists
|
|
20
|
+
* - AI generation tests FAIL because API keys are not configured in test env
|
|
21
|
+
* - Some parsing tests FAIL because edge cases need implementation
|
|
22
|
+
* - Streaming tests may need implementation refinement
|
|
23
|
+
*
|
|
24
|
+
* Bead: aip-nw0g
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
30
|
+
import { env } from 'cloudflare:test'
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// MDX integration imports
|
|
34
|
+
// Some functions exist, but tests may fail due to:
|
|
35
|
+
// 1. Missing AI Gateway bindings/API keys
|
|
36
|
+
// 2. Edge cases in parsing that need implementation
|
|
37
|
+
// 3. Streaming implementation refinements needed
|
|
38
|
+
// ============================================================================
|
|
39
|
+
import {
|
|
40
|
+
parseMDX,
|
|
41
|
+
extractComponentSchemas,
|
|
42
|
+
renderMDXWithProps,
|
|
43
|
+
createMDXPropsGenerator,
|
|
44
|
+
compileMDX,
|
|
45
|
+
streamMDXWithProps,
|
|
46
|
+
} from '../../src/mdx.js'
|
|
47
|
+
|
|
48
|
+
// Import existing ai-props modules to verify integration
|
|
49
|
+
import { PropsServiceCore } from '../../src/worker.js'
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Type definitions for expected MDX integration interfaces
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Result of parsing an MDX string
|
|
57
|
+
*/
|
|
58
|
+
interface ParsedMDX {
|
|
59
|
+
/** Raw MDX body content (without frontmatter) */
|
|
60
|
+
body: string
|
|
61
|
+
/** Full content including frontmatter */
|
|
62
|
+
content: string
|
|
63
|
+
/** Parsed frontmatter key-value pairs */
|
|
64
|
+
frontmatter: Record<string, unknown>
|
|
65
|
+
/** List of JSX component names found in the MDX */
|
|
66
|
+
components: string[]
|
|
67
|
+
/** Map of component names to their detected props */
|
|
68
|
+
componentProps: Record<string, Record<string, unknown>>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Component schema map extracted from MDX
|
|
73
|
+
*/
|
|
74
|
+
type ComponentSchemaMap = Record<string, Record<string, unknown>>
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Options for rendering MDX with props
|
|
78
|
+
*/
|
|
79
|
+
interface RenderMDXOptions {
|
|
80
|
+
/** Custom component renderers */
|
|
81
|
+
components?: Record<string, (props: Record<string, unknown>) => string>
|
|
82
|
+
/** Whether to return a ReadableStream instead of a string */
|
|
83
|
+
stream?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Options for creating an MDX props generator
|
|
88
|
+
*/
|
|
89
|
+
interface MDXPropsGeneratorOptions {
|
|
90
|
+
/** Map of component names to their prop schemas */
|
|
91
|
+
schemas: ComponentSchemaMap
|
|
92
|
+
/** Whether to cache generated props */
|
|
93
|
+
cache?: boolean
|
|
94
|
+
/** AI model to use for generation */
|
|
95
|
+
model?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// 1. MDX Parsing Tests
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
describe('MDX parsing', () => {
|
|
103
|
+
describe('parseMDX()', () => {
|
|
104
|
+
it('parses simple MDX content string', () => {
|
|
105
|
+
const mdx = `# Hello World
|
|
106
|
+
|
|
107
|
+
This is a simple MDX document.`
|
|
108
|
+
|
|
109
|
+
const result = parseMDX(mdx)
|
|
110
|
+
|
|
111
|
+
expect(result).toBeDefined()
|
|
112
|
+
expect(result.content).toBeDefined()
|
|
113
|
+
expect(result.frontmatter).toEqual({})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('parses MDX content with frontmatter', () => {
|
|
117
|
+
const mdx = `---
|
|
118
|
+
title: My Page
|
|
119
|
+
description: A test page
|
|
120
|
+
author: Test Author
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
# {title}
|
|
124
|
+
|
|
125
|
+
{description}`
|
|
126
|
+
|
|
127
|
+
const result = parseMDX(mdx)
|
|
128
|
+
|
|
129
|
+
expect(result.frontmatter).toBeDefined()
|
|
130
|
+
expect(result.frontmatter.title).toBe('My Page')
|
|
131
|
+
expect(result.frontmatter.description).toBe('A test page')
|
|
132
|
+
expect(result.frontmatter.author).toBe('Test Author')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('parses MDX content with YAML frontmatter types', () => {
|
|
136
|
+
const mdx = `---
|
|
137
|
+
title: My Page
|
|
138
|
+
count: 5
|
|
139
|
+
published: true
|
|
140
|
+
tags:
|
|
141
|
+
- ai
|
|
142
|
+
- props
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
# Content`
|
|
146
|
+
|
|
147
|
+
const result = parseMDX(mdx)
|
|
148
|
+
|
|
149
|
+
expect(result.frontmatter.title).toBe('My Page')
|
|
150
|
+
expect(result.frontmatter.count).toBe(5)
|
|
151
|
+
expect(result.frontmatter.published).toBe(true)
|
|
152
|
+
expect(result.frontmatter.tags).toEqual(['ai', 'props'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('separates frontmatter from body content', () => {
|
|
156
|
+
const mdx = `---
|
|
157
|
+
title: Hello
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
# Body Content
|
|
161
|
+
|
|
162
|
+
Some paragraph text.`
|
|
163
|
+
|
|
164
|
+
const result = parseMDX(mdx)
|
|
165
|
+
|
|
166
|
+
expect(result.body).not.toContain('---')
|
|
167
|
+
expect(result.body).not.toContain('title: Hello')
|
|
168
|
+
expect(result.body).toContain('# Body Content')
|
|
169
|
+
expect(result.body).toContain('Some paragraph text.')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('handles MDX with no frontmatter', () => {
|
|
173
|
+
const mdx = `# No Frontmatter
|
|
174
|
+
|
|
175
|
+
Just regular content.`
|
|
176
|
+
|
|
177
|
+
const result = parseMDX(mdx)
|
|
178
|
+
|
|
179
|
+
expect(result.frontmatter).toEqual({})
|
|
180
|
+
expect(result.body).toContain('# No Frontmatter')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('handles empty MDX string', () => {
|
|
184
|
+
const result = parseMDX('')
|
|
185
|
+
|
|
186
|
+
expect(result).toBeDefined()
|
|
187
|
+
expect(result.frontmatter).toEqual({})
|
|
188
|
+
expect(result.body).toBe('')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('identifies JSX components in MDX', () => {
|
|
192
|
+
const mdx = `---
|
|
193
|
+
title: Page
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
# Hello
|
|
197
|
+
|
|
198
|
+
<Hero title="Welcome" />
|
|
199
|
+
|
|
200
|
+
<Card>
|
|
201
|
+
<CardBody>Some content</CardBody>
|
|
202
|
+
</Card>
|
|
203
|
+
|
|
204
|
+
<Footer />`
|
|
205
|
+
|
|
206
|
+
const result = parseMDX(mdx)
|
|
207
|
+
|
|
208
|
+
expect(result.components).toBeDefined()
|
|
209
|
+
expect(result.components).toContain('Hero')
|
|
210
|
+
expect(result.components).toContain('Card')
|
|
211
|
+
expect(result.components).toContain('CardBody')
|
|
212
|
+
expect(result.components).toContain('Footer')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('identifies components with props in MDX', () => {
|
|
216
|
+
const mdx = `<Button variant="primary" size="lg" disabled>Click Me</Button>
|
|
217
|
+
<Input placeholder="Enter text" type="email" />`
|
|
218
|
+
|
|
219
|
+
const result = parseMDX(mdx)
|
|
220
|
+
|
|
221
|
+
expect(result.componentProps).toBeDefined()
|
|
222
|
+
expect(result.componentProps.Button).toEqual({
|
|
223
|
+
variant: 'primary',
|
|
224
|
+
size: 'lg',
|
|
225
|
+
disabled: true,
|
|
226
|
+
})
|
|
227
|
+
expect(result.componentProps.Input).toEqual({
|
|
228
|
+
placeholder: 'Enter text',
|
|
229
|
+
type: 'email',
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('does not include lowercase HTML elements as components', () => {
|
|
234
|
+
const mdx = `<div class="wrapper">
|
|
235
|
+
<p>Paragraph text</p>
|
|
236
|
+
<CustomComponent title="test" />
|
|
237
|
+
</div>`
|
|
238
|
+
|
|
239
|
+
const result = parseMDX(mdx)
|
|
240
|
+
|
|
241
|
+
expect(result.components).toContain('CustomComponent')
|
|
242
|
+
expect(result.components).not.toContain('div')
|
|
243
|
+
expect(result.components).not.toContain('p')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('handles frontmatter with prop schemas defined', () => {
|
|
247
|
+
const mdx = `---
|
|
248
|
+
title: Product Page
|
|
249
|
+
$schema:
|
|
250
|
+
Hero:
|
|
251
|
+
title: Hero heading text
|
|
252
|
+
subtitle: Hero subheading
|
|
253
|
+
Card:
|
|
254
|
+
title: Card title
|
|
255
|
+
description: Card description
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
<Hero />
|
|
259
|
+
<Card />`
|
|
260
|
+
|
|
261
|
+
const result = parseMDX(mdx)
|
|
262
|
+
|
|
263
|
+
expect(result.frontmatter.$schema).toBeDefined()
|
|
264
|
+
const schema = result.frontmatter.$schema as Record<string, unknown>
|
|
265
|
+
expect(schema.Hero).toBeDefined()
|
|
266
|
+
expect(schema.Card).toBeDefined()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('extractComponentSchemas()', () => {
|
|
271
|
+
it('extracts prop schemas from MDX component usage', () => {
|
|
272
|
+
const mdx = `<UserCard name="John" bio="A developer" avatar="/img.png" />`
|
|
273
|
+
|
|
274
|
+
const schemas = extractComponentSchemas(mdx)
|
|
275
|
+
|
|
276
|
+
expect(schemas).toBeDefined()
|
|
277
|
+
expect(schemas.UserCard).toBeDefined()
|
|
278
|
+
expect(Object.keys(schemas.UserCard)).toContain('name')
|
|
279
|
+
expect(Object.keys(schemas.UserCard)).toContain('bio')
|
|
280
|
+
expect(Object.keys(schemas.UserCard)).toContain('avatar')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('extracts schemas from multiple component instances', () => {
|
|
284
|
+
const mdx = `<Card title="First" />
|
|
285
|
+
<Card title="Second" description="With desc" />`
|
|
286
|
+
|
|
287
|
+
const schemas = extractComponentSchemas(mdx)
|
|
288
|
+
|
|
289
|
+
// Should merge schemas from multiple instances
|
|
290
|
+
expect(schemas.Card).toBeDefined()
|
|
291
|
+
expect(Object.keys(schemas.Card)).toContain('title')
|
|
292
|
+
expect(Object.keys(schemas.Card)).toContain('description')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('identifies components that need AI-generated props', () => {
|
|
296
|
+
const mdx = `<Hero />
|
|
297
|
+
<Card title="Provided" />
|
|
298
|
+
<Footer />`
|
|
299
|
+
|
|
300
|
+
const schemas = extractComponentSchemas(mdx)
|
|
301
|
+
|
|
302
|
+
// Components with no props should be flagged as needing generation
|
|
303
|
+
expect(schemas.Hero).toBeDefined()
|
|
304
|
+
expect(Object.keys(schemas.Hero)).toHaveLength(0)
|
|
305
|
+
expect(schemas.Footer).toBeDefined()
|
|
306
|
+
expect(Object.keys(schemas.Footer)).toHaveLength(0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('handles components with expression props', () => {
|
|
310
|
+
const mdx = `<Widget count={42} active={true} data={{ key: 'value' }} />`
|
|
311
|
+
|
|
312
|
+
const schemas = extractComponentSchemas(mdx)
|
|
313
|
+
|
|
314
|
+
expect(schemas.Widget).toBeDefined()
|
|
315
|
+
expect(Object.keys(schemas.Widget)).toContain('count')
|
|
316
|
+
expect(Object.keys(schemas.Widget)).toContain('active')
|
|
317
|
+
expect(Object.keys(schemas.Widget)).toContain('data')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('returns empty object for MDX with no components', () => {
|
|
321
|
+
const mdx = `# Just Markdown
|
|
322
|
+
|
|
323
|
+
Regular paragraph text with **bold** and *italic*.`
|
|
324
|
+
|
|
325
|
+
const schemas = extractComponentSchemas(mdx)
|
|
326
|
+
|
|
327
|
+
expect(schemas).toEqual({})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('excludes lowercase HTML elements from schemas', () => {
|
|
331
|
+
const mdx = `<div><span>text</span></div>
|
|
332
|
+
<MyComponent prop="value" />`
|
|
333
|
+
|
|
334
|
+
const schemas = extractComponentSchemas(mdx)
|
|
335
|
+
|
|
336
|
+
expect(schemas).not.toHaveProperty('div')
|
|
337
|
+
expect(schemas).not.toHaveProperty('span')
|
|
338
|
+
expect(schemas.MyComponent).toBeDefined()
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
// ============================================================================
|
|
344
|
+
// 2. MDX Props Generation Tests
|
|
345
|
+
// ============================================================================
|
|
346
|
+
|
|
347
|
+
describe('MDX props generation', () => {
|
|
348
|
+
describe('createMDXPropsGenerator()', () => {
|
|
349
|
+
it('creates a props generator for MDX content', () => {
|
|
350
|
+
const generator = createMDXPropsGenerator({
|
|
351
|
+
schemas: {
|
|
352
|
+
Hero: {
|
|
353
|
+
title: 'Hero section title',
|
|
354
|
+
subtitle: 'Hero section subtitle',
|
|
355
|
+
},
|
|
356
|
+
Card: {
|
|
357
|
+
title: 'Card title',
|
|
358
|
+
description: 'Card description',
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
expect(generator).toBeDefined()
|
|
364
|
+
expect(typeof generator.generate).toBe('function')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('generates props for components in MDX using real AI', async () => {
|
|
368
|
+
const generator = createMDXPropsGenerator({
|
|
369
|
+
schemas: {
|
|
370
|
+
Hero: {
|
|
371
|
+
title: 'Hero section title',
|
|
372
|
+
subtitle: 'Hero section subtitle',
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
const mdx = `---
|
|
378
|
+
topic: AI Applications
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
<Hero />`
|
|
382
|
+
|
|
383
|
+
const result = await generator.generate(mdx)
|
|
384
|
+
|
|
385
|
+
expect(result).toBeDefined()
|
|
386
|
+
expect(result.Hero).toBeDefined()
|
|
387
|
+
expect(result.Hero.title).toBeDefined()
|
|
388
|
+
expect(typeof result.Hero.title).toBe('string')
|
|
389
|
+
expect(result.Hero.subtitle).toBeDefined()
|
|
390
|
+
expect(typeof result.Hero.subtitle).toBe('string')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('uses frontmatter context for generation', async () => {
|
|
394
|
+
const generator = createMDXPropsGenerator({
|
|
395
|
+
schemas: {
|
|
396
|
+
Hero: {
|
|
397
|
+
title: 'A title relevant to the page topic',
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
const mdx = `---
|
|
403
|
+
topic: Machine Learning
|
|
404
|
+
audience: developers
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
<Hero />`
|
|
408
|
+
|
|
409
|
+
const result = await generator.generate(mdx)
|
|
410
|
+
|
|
411
|
+
expect(result.Hero).toBeDefined()
|
|
412
|
+
expect(result.Hero.title).toBeDefined()
|
|
413
|
+
expect(typeof result.Hero.title).toBe('string')
|
|
414
|
+
// The generated title should be contextually relevant
|
|
415
|
+
// (we cannot assert exact content, but it should be non-empty)
|
|
416
|
+
expect((result.Hero.title as string).length).toBeGreaterThan(0)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('preserves explicitly provided props', async () => {
|
|
420
|
+
const generator = createMDXPropsGenerator({
|
|
421
|
+
schemas: {
|
|
422
|
+
Card: {
|
|
423
|
+
title: 'Card title',
|
|
424
|
+
description: 'Card description',
|
|
425
|
+
image: 'Image URL',
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
const mdx = `<Card title="My Explicit Title" />`
|
|
431
|
+
|
|
432
|
+
const result = await generator.generate(mdx)
|
|
433
|
+
|
|
434
|
+
// Explicit props should be preserved
|
|
435
|
+
expect(result.Card.title).toBe('My Explicit Title')
|
|
436
|
+
// Missing props should be generated
|
|
437
|
+
expect(result.Card.description).toBeDefined()
|
|
438
|
+
expect(result.Card.image).toBeDefined()
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('only generates props for schemas that were provided', async () => {
|
|
442
|
+
const generator = createMDXPropsGenerator({
|
|
443
|
+
schemas: {
|
|
444
|
+
DynamicRecommendation: {
|
|
445
|
+
product: 'Recommended product name',
|
|
446
|
+
reason: 'Why this product is recommended',
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const mdx = `<StaticBanner text="Sale!" />
|
|
452
|
+
<DynamicRecommendation />
|
|
453
|
+
<StaticFooter year={2026} />`
|
|
454
|
+
|
|
455
|
+
const result = await generator.generate(mdx)
|
|
456
|
+
|
|
457
|
+
// Only DynamicRecommendation should have generated props
|
|
458
|
+
expect(result.DynamicRecommendation).toBeDefined()
|
|
459
|
+
expect(result.DynamicRecommendation.product).toBeDefined()
|
|
460
|
+
expect(result.DynamicRecommendation.reason).toBeDefined()
|
|
461
|
+
// Static components should not be in generated props
|
|
462
|
+
expect(result.StaticBanner).toBeUndefined()
|
|
463
|
+
expect(result.StaticFooter).toBeUndefined()
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('caches generated props per component', async () => {
|
|
467
|
+
const generator = createMDXPropsGenerator({
|
|
468
|
+
schemas: {
|
|
469
|
+
Hero: {
|
|
470
|
+
title: 'Hero title',
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
cache: true,
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
const mdx = `<Hero />`
|
|
477
|
+
|
|
478
|
+
// First generation
|
|
479
|
+
const result1 = await generator.generate(mdx)
|
|
480
|
+
|
|
481
|
+
// Second generation (same content) should use cache
|
|
482
|
+
const result2 = await generator.generate(mdx)
|
|
483
|
+
|
|
484
|
+
expect(result1.Hero.title).toEqual(result2.Hero.title)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('handles async prop generation', async () => {
|
|
488
|
+
const generator = createMDXPropsGenerator({
|
|
489
|
+
schemas: {
|
|
490
|
+
AsyncWidget: {
|
|
491
|
+
data: 'Complex data structure description',
|
|
492
|
+
status: 'Widget status text',
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
const mdx = `<AsyncWidget />`
|
|
498
|
+
|
|
499
|
+
const result = await generator.generate(mdx)
|
|
500
|
+
|
|
501
|
+
expect(result.AsyncWidget).toBeDefined()
|
|
502
|
+
expect(result.AsyncWidget.data).toBeDefined()
|
|
503
|
+
expect(result.AsyncWidget.status).toBeDefined()
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('passes custom model configuration', async () => {
|
|
507
|
+
const generator = createMDXPropsGenerator({
|
|
508
|
+
schemas: {
|
|
509
|
+
Hero: { title: 'Title' },
|
|
510
|
+
},
|
|
511
|
+
// Use full model ID to avoid alias resolution issues in bundled environments
|
|
512
|
+
model: 'openai/gpt-4o',
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
const mdx = `<Hero />`
|
|
516
|
+
|
|
517
|
+
const result = await generator.generate(mdx)
|
|
518
|
+
|
|
519
|
+
expect(result.Hero).toBeDefined()
|
|
520
|
+
expect(result.Hero.title).toBeDefined()
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('handles missing component schemas gracefully', async () => {
|
|
524
|
+
const generator = createMDXPropsGenerator({
|
|
525
|
+
schemas: {},
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
const mdx = `<UnknownComponent />`
|
|
529
|
+
|
|
530
|
+
// Should not throw, but should return empty or skip unknown components
|
|
531
|
+
const result = await generator.generate(mdx)
|
|
532
|
+
|
|
533
|
+
expect(result).toBeDefined()
|
|
534
|
+
expect(result.UnknownComponent).toBeUndefined()
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
// ============================================================================
|
|
540
|
+
// 3. MDX Rendering with Props Tests
|
|
541
|
+
// ============================================================================
|
|
542
|
+
|
|
543
|
+
describe('MDX rendering with props', () => {
|
|
544
|
+
describe('renderMDXWithProps()', () => {
|
|
545
|
+
it('renders MDX string with injected props', async () => {
|
|
546
|
+
const mdx = `# Hello
|
|
547
|
+
|
|
548
|
+
<Hero title="Welcome" subtitle="To the future" />`
|
|
549
|
+
|
|
550
|
+
const result = await renderMDXWithProps(mdx, {
|
|
551
|
+
Hero: {
|
|
552
|
+
title: 'Welcome',
|
|
553
|
+
subtitle: 'To the future',
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
expect(result).toBeDefined()
|
|
558
|
+
expect(typeof result).toBe('string')
|
|
559
|
+
expect(result).toContain('Welcome')
|
|
560
|
+
expect(result).toContain('To the future')
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('renders MDX with generated props for components missing props', async () => {
|
|
564
|
+
const mdx = `<Hero />`
|
|
565
|
+
|
|
566
|
+
const result = await renderMDXWithProps(mdx, {
|
|
567
|
+
Hero: {
|
|
568
|
+
title: 'Generated Title',
|
|
569
|
+
subtitle: 'Generated Subtitle',
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
expect(result).toContain('Generated Title')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('passes props to custom component renderers', async () => {
|
|
577
|
+
const mdx = `<Card title="Test" description="A card" />`
|
|
578
|
+
|
|
579
|
+
const components = {
|
|
580
|
+
Card: (props: Record<string, unknown>) => {
|
|
581
|
+
return `<div class="card"><h2>${props.title}</h2><p>${props.description}</p></div>`
|
|
582
|
+
},
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const result = await renderMDXWithProps(
|
|
586
|
+
mdx,
|
|
587
|
+
{ Card: { title: 'Test', description: 'A card' } },
|
|
588
|
+
{ components }
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
expect(result).toContain('Test')
|
|
592
|
+
expect(result).toContain('A card')
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('preserves component tree structure', async () => {
|
|
596
|
+
const mdx = `<Layout>
|
|
597
|
+
<Header title="Page Title" />
|
|
598
|
+
<Main>
|
|
599
|
+
<Card title="Card 1" />
|
|
600
|
+
<Card title="Card 2" />
|
|
601
|
+
</Main>
|
|
602
|
+
<Footer />
|
|
603
|
+
</Layout>`
|
|
604
|
+
|
|
605
|
+
const props = {
|
|
606
|
+
Layout: {},
|
|
607
|
+
Header: { title: 'Page Title' },
|
|
608
|
+
Main: {},
|
|
609
|
+
Card: { title: 'Card Title' },
|
|
610
|
+
Footer: { copyright: '2026' },
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const result = await renderMDXWithProps(mdx, props)
|
|
614
|
+
|
|
615
|
+
expect(result).toBeDefined()
|
|
616
|
+
expect(typeof result).toBe('string')
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it('renders MDX with frontmatter variables interpolated', async () => {
|
|
620
|
+
const mdx = `---
|
|
621
|
+
title: Dynamic Page
|
|
622
|
+
author: AI
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
# {title}
|
|
626
|
+
|
|
627
|
+
Written by {author}.`
|
|
628
|
+
|
|
629
|
+
const result = await renderMDXWithProps(mdx, {})
|
|
630
|
+
|
|
631
|
+
expect(result).toContain('Dynamic Page')
|
|
632
|
+
expect(result).toContain('AI')
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('merges provided props with generated props', async () => {
|
|
636
|
+
const mdx = `<ProductCard name="Widget" />`
|
|
637
|
+
|
|
638
|
+
const result = await renderMDXWithProps(mdx, {
|
|
639
|
+
ProductCard: {
|
|
640
|
+
name: 'Widget',
|
|
641
|
+
price: 29.99,
|
|
642
|
+
description: 'A useful widget',
|
|
643
|
+
},
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
expect(result).toContain('Widget')
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('handles render errors for invalid props', async () => {
|
|
650
|
+
const mdx = `<StrictComponent />`
|
|
651
|
+
|
|
652
|
+
// Rendering with null props should throw
|
|
653
|
+
await expect(
|
|
654
|
+
renderMDXWithProps(mdx, {
|
|
655
|
+
StrictComponent: null as unknown as Record<string, unknown>,
|
|
656
|
+
})
|
|
657
|
+
).rejects.toThrow()
|
|
658
|
+
})
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
describe('streamMDXWithProps()', () => {
|
|
662
|
+
it('returns a ReadableStream for MDX content', async () => {
|
|
663
|
+
const mdx = `# Hello
|
|
664
|
+
|
|
665
|
+
<Hero title="Welcome" />
|
|
666
|
+
|
|
667
|
+
Some content after.`
|
|
668
|
+
|
|
669
|
+
const stream = await streamMDXWithProps(mdx, {
|
|
670
|
+
Hero: { title: 'Welcome' },
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
expect(stream).toBeDefined()
|
|
674
|
+
expect(stream).toBeInstanceOf(ReadableStream)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('stream contains the rendered content', async () => {
|
|
678
|
+
const mdx = `# Streaming Test
|
|
679
|
+
|
|
680
|
+
<Card title="Stream Card" description="Streamed content" />`
|
|
681
|
+
|
|
682
|
+
const stream = await streamMDXWithProps(mdx, {
|
|
683
|
+
Card: { title: 'Stream Card', description: 'Streamed content' },
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
const reader = stream.getReader()
|
|
687
|
+
const decoder = new TextDecoder()
|
|
688
|
+
let content = ''
|
|
689
|
+
|
|
690
|
+
while (true) {
|
|
691
|
+
const { done, value } = await reader.read()
|
|
692
|
+
if (done) break
|
|
693
|
+
content += decoder.decode(value, { stream: true })
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
expect(content.length).toBeGreaterThan(0)
|
|
697
|
+
expect(content).toContain('Stream Card')
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('streams chunks progressively', async () => {
|
|
701
|
+
const mdx = `# Part 1
|
|
702
|
+
|
|
703
|
+
<Section1 title="First Section" />
|
|
704
|
+
|
|
705
|
+
# Part 2
|
|
706
|
+
|
|
707
|
+
<Section2 title="Second Section" />
|
|
708
|
+
|
|
709
|
+
# Part 3
|
|
710
|
+
|
|
711
|
+
<Section3 title="Third Section" />`
|
|
712
|
+
|
|
713
|
+
const stream = await streamMDXWithProps(mdx, {
|
|
714
|
+
Section1: { title: 'First Section' },
|
|
715
|
+
Section2: { title: 'Second Section' },
|
|
716
|
+
Section3: { title: 'Third Section' },
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
const reader = stream.getReader()
|
|
720
|
+
const chunks: Uint8Array[] = []
|
|
721
|
+
|
|
722
|
+
while (true) {
|
|
723
|
+
const { done, value } = await reader.read()
|
|
724
|
+
if (done) break
|
|
725
|
+
chunks.push(value)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Should have received multiple chunks
|
|
729
|
+
expect(chunks.length).toBeGreaterThan(0)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('supports custom component renderers in streaming mode', async () => {
|
|
733
|
+
const mdx = `<Badge label="New" color="green" />`
|
|
734
|
+
|
|
735
|
+
const stream = await streamMDXWithProps(
|
|
736
|
+
mdx,
|
|
737
|
+
{ Badge: { label: 'New', color: 'green' } },
|
|
738
|
+
{
|
|
739
|
+
components: {
|
|
740
|
+
Badge: (props: Record<string, unknown>) =>
|
|
741
|
+
`<span class="badge badge-${props.color}">${props.label}</span>`,
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
const reader = stream.getReader()
|
|
747
|
+
const decoder = new TextDecoder()
|
|
748
|
+
let content = ''
|
|
749
|
+
|
|
750
|
+
while (true) {
|
|
751
|
+
const { done, value } = await reader.read()
|
|
752
|
+
if (done) break
|
|
753
|
+
content += decoder.decode(value, { stream: true })
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
expect(content).toContain('New')
|
|
757
|
+
})
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
describe('compileMDX()', () => {
|
|
761
|
+
it('compiles MDX string to executable function', async () => {
|
|
762
|
+
const mdx = `# Hello World
|
|
763
|
+
|
|
764
|
+
This is content.`
|
|
765
|
+
|
|
766
|
+
const compiled = await compileMDX(mdx)
|
|
767
|
+
|
|
768
|
+
expect(compiled).toBeDefined()
|
|
769
|
+
expect(typeof compiled).toBe('function')
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('compiled function accepts props argument', async () => {
|
|
773
|
+
const mdx = `<Greeting name="World" />`
|
|
774
|
+
|
|
775
|
+
const compiled = await compileMDX(mdx)
|
|
776
|
+
const result = compiled({
|
|
777
|
+
Greeting: { name: 'World' },
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
expect(result).toBeDefined()
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('compiled function accepts component map', async () => {
|
|
784
|
+
const mdx = `<Custom value="test" />`
|
|
785
|
+
|
|
786
|
+
const compiled = await compileMDX(mdx, {
|
|
787
|
+
components: {
|
|
788
|
+
Custom: (props: Record<string, unknown>) => `Custom: ${props.value}`,
|
|
789
|
+
},
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
const result = compiled({ Custom: { value: 'test' } })
|
|
793
|
+
|
|
794
|
+
expect(result).toContain('test')
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('handles MDX with import statements', async () => {
|
|
798
|
+
const mdx = `import { Button } from './components'
|
|
799
|
+
|
|
800
|
+
# Page
|
|
801
|
+
|
|
802
|
+
<Button variant="primary">Click</Button>`
|
|
803
|
+
|
|
804
|
+
// Should handle import statements without throwing
|
|
805
|
+
const compiled = await compileMDX(mdx)
|
|
806
|
+
|
|
807
|
+
expect(compiled).toBeDefined()
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('handles MDX with export statements', async () => {
|
|
811
|
+
const mdx = `export const metadata = { title: 'Test' }
|
|
812
|
+
|
|
813
|
+
# Page Content`
|
|
814
|
+
|
|
815
|
+
const compiled = await compileMDX(mdx)
|
|
816
|
+
|
|
817
|
+
expect(compiled).toBeDefined()
|
|
818
|
+
expect(compiled.metadata).toBeDefined()
|
|
819
|
+
expect(compiled.metadata.title).toBe('Test')
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
// ============================================================================
|
|
825
|
+
// 4. MDX Error Handling Tests
|
|
826
|
+
// ============================================================================
|
|
827
|
+
|
|
828
|
+
describe('MDX error handling', () => {
|
|
829
|
+
it('throws descriptive error for invalid MDX syntax', () => {
|
|
830
|
+
const invalidMDX = `<Unclosed
|
|
831
|
+
|
|
832
|
+
This has unclosed JSX.`
|
|
833
|
+
|
|
834
|
+
expect(() => parseMDX(invalidMDX)).toThrow()
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
it('throws for malformed frontmatter', () => {
|
|
838
|
+
const invalidMDX = `---
|
|
839
|
+
title: [invalid yaml
|
|
840
|
+
broken: {
|
|
841
|
+
---
|
|
842
|
+
|
|
843
|
+
# Content`
|
|
844
|
+
|
|
845
|
+
expect(() => parseMDX(invalidMDX)).toThrow()
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
it('provides error location info for invalid MDX', () => {
|
|
849
|
+
const invalidMDX = `# Valid
|
|
850
|
+
<Valid />
|
|
851
|
+
<Invalid prop=>`
|
|
852
|
+
|
|
853
|
+
try {
|
|
854
|
+
parseMDX(invalidMDX)
|
|
855
|
+
// Should not reach here
|
|
856
|
+
expect(true).toBe(false)
|
|
857
|
+
} catch (error) {
|
|
858
|
+
expect(error).toBeInstanceOf(Error)
|
|
859
|
+
const err = error as Error & { line?: number; column?: number }
|
|
860
|
+
// Error should contain location information
|
|
861
|
+
expect(err.message).toBeDefined()
|
|
862
|
+
expect(err.message.length).toBeGreaterThan(0)
|
|
863
|
+
}
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('handles MDX compilation errors gracefully', async () => {
|
|
867
|
+
const invalidMDX = `<>{(() => { throw new Error("runtime error") })()}</>`
|
|
868
|
+
|
|
869
|
+
await expect(compileMDX(invalidMDX)).rejects.toThrow()
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('handles render errors for null component props', async () => {
|
|
873
|
+
const mdx = `<BrokenComponent />`
|
|
874
|
+
|
|
875
|
+
await expect(
|
|
876
|
+
renderMDXWithProps(mdx, {
|
|
877
|
+
BrokenComponent: null as unknown as Record<string, unknown>,
|
|
878
|
+
})
|
|
879
|
+
).rejects.toThrow()
|
|
880
|
+
})
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
// ============================================================================
|
|
884
|
+
// 5. MDX with AI Props End-to-End Tests (Real AI Gateway)
|
|
885
|
+
// ============================================================================
|
|
886
|
+
|
|
887
|
+
describe('MDX with AI props end-to-end', () => {
|
|
888
|
+
it('parses MDX, generates props, and renders in one pipeline', async () => {
|
|
889
|
+
const mdx = `---
|
|
890
|
+
topic: Artificial Intelligence
|
|
891
|
+
audience: developers
|
|
892
|
+
---
|
|
893
|
+
|
|
894
|
+
# {topic} Guide
|
|
895
|
+
|
|
896
|
+
<Hero />
|
|
897
|
+
|
|
898
|
+
<Card />
|
|
899
|
+
|
|
900
|
+
<Footer />`
|
|
901
|
+
|
|
902
|
+
// Step 1: Parse
|
|
903
|
+
const parsed = parseMDX(mdx)
|
|
904
|
+
|
|
905
|
+
expect(parsed.frontmatter.topic).toBe('Artificial Intelligence')
|
|
906
|
+
expect(parsed.components).toContain('Hero')
|
|
907
|
+
expect(parsed.components).toContain('Card')
|
|
908
|
+
expect(parsed.components).toContain('Footer')
|
|
909
|
+
|
|
910
|
+
// Step 2: Create generator with schemas
|
|
911
|
+
const generator = createMDXPropsGenerator({
|
|
912
|
+
schemas: {
|
|
913
|
+
Hero: {
|
|
914
|
+
title: 'Hero title for the page topic',
|
|
915
|
+
subtitle: 'Hero subtitle',
|
|
916
|
+
},
|
|
917
|
+
Card: {
|
|
918
|
+
title: 'Card title',
|
|
919
|
+
description: 'Card description',
|
|
920
|
+
},
|
|
921
|
+
Footer: {
|
|
922
|
+
copyright: 'Copyright notice',
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
// Step 3: Generate props using real AI
|
|
928
|
+
const generatedProps = await generator.generate(mdx)
|
|
929
|
+
|
|
930
|
+
expect(generatedProps.Hero).toBeDefined()
|
|
931
|
+
expect(generatedProps.Hero.title).toBeDefined()
|
|
932
|
+
expect(typeof generatedProps.Hero.title).toBe('string')
|
|
933
|
+
expect(generatedProps.Card).toBeDefined()
|
|
934
|
+
expect(generatedProps.Card.title).toBeDefined()
|
|
935
|
+
expect(generatedProps.Footer).toBeDefined()
|
|
936
|
+
expect(generatedProps.Footer.copyright).toBeDefined()
|
|
937
|
+
|
|
938
|
+
// Step 4: Render with generated props
|
|
939
|
+
const rendered = await renderMDXWithProps(mdx, generatedProps)
|
|
940
|
+
|
|
941
|
+
expect(rendered).toBeDefined()
|
|
942
|
+
expect(typeof rendered).toBe('string')
|
|
943
|
+
expect(rendered.length).toBeGreaterThan(0)
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
it('supports schema-less generation from component usage', async () => {
|
|
947
|
+
const mdx = `<ProductCard
|
|
948
|
+
name="AI Widget"
|
|
949
|
+
price={29.99}
|
|
950
|
+
category="technology"
|
|
951
|
+
/>`
|
|
952
|
+
|
|
953
|
+
// Extract schemas from actual component usage
|
|
954
|
+
const schemas = extractComponentSchemas(mdx)
|
|
955
|
+
|
|
956
|
+
expect(schemas.ProductCard).toBeDefined()
|
|
957
|
+
expect(Object.keys(schemas.ProductCard)).toContain('name')
|
|
958
|
+
expect(Object.keys(schemas.ProductCard)).toContain('price')
|
|
959
|
+
expect(Object.keys(schemas.ProductCard)).toContain('category')
|
|
960
|
+
|
|
961
|
+
// Use extracted schemas for generation
|
|
962
|
+
const generator = createMDXPropsGenerator({ schemas })
|
|
963
|
+
const props = await generator.generate(mdx)
|
|
964
|
+
|
|
965
|
+
expect(props.ProductCard).toBeDefined()
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
it('handles MDX with mixed static and AI-generated content', async () => {
|
|
969
|
+
const mdx = `---
|
|
970
|
+
title: Product Page
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
# {title}
|
|
974
|
+
|
|
975
|
+
<StaticBanner text="Sale!" />
|
|
976
|
+
|
|
977
|
+
<DynamicRecommendation />
|
|
978
|
+
|
|
979
|
+
<StaticFooter year={2026} />`
|
|
980
|
+
|
|
981
|
+
const generator = createMDXPropsGenerator({
|
|
982
|
+
schemas: {
|
|
983
|
+
DynamicRecommendation: {
|
|
984
|
+
product: 'Recommended product name',
|
|
985
|
+
reason: 'Why this product is recommended',
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
const props = await generator.generate(mdx)
|
|
991
|
+
|
|
992
|
+
// Only DynamicRecommendation should have generated props
|
|
993
|
+
expect(props.DynamicRecommendation).toBeDefined()
|
|
994
|
+
expect(props.DynamicRecommendation.product).toBeDefined()
|
|
995
|
+
expect(typeof props.DynamicRecommendation.product).toBe('string')
|
|
996
|
+
expect(props.DynamicRecommendation.reason).toBeDefined()
|
|
997
|
+
expect(typeof props.DynamicRecommendation.reason).toBe('string')
|
|
998
|
+
// Static components should not be in generated props
|
|
999
|
+
expect(props.StaticBanner).toBeUndefined()
|
|
1000
|
+
expect(props.StaticFooter).toBeUndefined()
|
|
1001
|
+
})
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
// ============================================================================
|
|
1005
|
+
// 6. Integration with PropsServiceCore (Worker RPC)
|
|
1006
|
+
// ============================================================================
|
|
1007
|
+
|
|
1008
|
+
describe('MDX integration with PropsServiceCore', () => {
|
|
1009
|
+
it('PropsServiceCore can generate props for MDX component schemas', async () => {
|
|
1010
|
+
const service = new PropsServiceCore()
|
|
1011
|
+
|
|
1012
|
+
// Parse MDX to get component schemas
|
|
1013
|
+
const mdx = `<UserProfile />
|
|
1014
|
+
<ActivityFeed />`
|
|
1015
|
+
|
|
1016
|
+
const schemas = extractComponentSchemas(mdx)
|
|
1017
|
+
|
|
1018
|
+
// Use PropsServiceCore to generate props for each component
|
|
1019
|
+
for (const [componentName, schema] of Object.entries(schemas)) {
|
|
1020
|
+
if (Object.keys(schema).length > 0) {
|
|
1021
|
+
const result = await service.generate({
|
|
1022
|
+
schema: schema as Record<string, string>,
|
|
1023
|
+
})
|
|
1024
|
+
expect(result.props).toBeDefined()
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
it('validates generated MDX props against component schemas', async () => {
|
|
1030
|
+
const service = new PropsServiceCore()
|
|
1031
|
+
|
|
1032
|
+
const schema = {
|
|
1033
|
+
title: 'Hero title text',
|
|
1034
|
+
subtitle: 'Hero subtitle text',
|
|
1035
|
+
ctaText: 'Call to action button text',
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const result = await service.generate({ schema })
|
|
1039
|
+
|
|
1040
|
+
// Validate the generated props
|
|
1041
|
+
const validation = service.validate(result.props, schema)
|
|
1042
|
+
expect(validation.valid).toBe(true)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
it('merges partial MDX props with AI-generated ones via service', async () => {
|
|
1046
|
+
const service = new PropsServiceCore()
|
|
1047
|
+
|
|
1048
|
+
const schema = {
|
|
1049
|
+
title: 'Card title',
|
|
1050
|
+
description: 'Card description',
|
|
1051
|
+
image: 'Image URL',
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Simulate partial props from MDX attribute parsing
|
|
1055
|
+
const partialProps = { title: 'Explicit Title' }
|
|
1056
|
+
|
|
1057
|
+
const merged = await service.mergeWithGenerated(schema, partialProps)
|
|
1058
|
+
|
|
1059
|
+
expect(merged.title).toBe('Explicit Title')
|
|
1060
|
+
expect(merged.description).toBeDefined()
|
|
1061
|
+
expect(merged.image).toBeDefined()
|
|
1062
|
+
})
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
// ============================================================================
|
|
1066
|
+
// 7. Real AI Gateway Integration Tests
|
|
1067
|
+
// ============================================================================
|
|
1068
|
+
|
|
1069
|
+
describe('Real AI Gateway integration for MDX', () => {
|
|
1070
|
+
it('generates contextual props from MDX content using AI', async () => {
|
|
1071
|
+
const generator = createMDXPropsGenerator({
|
|
1072
|
+
schemas: {
|
|
1073
|
+
Hero: {
|
|
1074
|
+
headline: 'An engaging headline about the topic',
|
|
1075
|
+
subheadline: 'A supporting subheadline',
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
const mdx = `---
|
|
1081
|
+
topic: Cloud Computing
|
|
1082
|
+
industry: Technology
|
|
1083
|
+
---
|
|
1084
|
+
|
|
1085
|
+
<Hero />`
|
|
1086
|
+
|
|
1087
|
+
const result = await generator.generate(mdx)
|
|
1088
|
+
|
|
1089
|
+
expect(result.Hero).toBeDefined()
|
|
1090
|
+
expect(result.Hero.headline).toBeDefined()
|
|
1091
|
+
expect(typeof result.Hero.headline).toBe('string')
|
|
1092
|
+
expect((result.Hero.headline as string).length).toBeGreaterThan(0)
|
|
1093
|
+
expect(result.Hero.subheadline).toBeDefined()
|
|
1094
|
+
expect(typeof result.Hero.subheadline).toBe('string')
|
|
1095
|
+
expect((result.Hero.subheadline as string).length).toBeGreaterThan(0)
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('uses cached responses for repeated renders', async () => {
|
|
1099
|
+
const generator = createMDXPropsGenerator({
|
|
1100
|
+
schemas: {
|
|
1101
|
+
Card: {
|
|
1102
|
+
title: 'Card title',
|
|
1103
|
+
body: 'Card body text',
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
cache: true,
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
const mdx = `<Card />`
|
|
1110
|
+
|
|
1111
|
+
// First call - real AI
|
|
1112
|
+
const first = await generator.generate(mdx)
|
|
1113
|
+
expect(first.Card.title).toBeDefined()
|
|
1114
|
+
|
|
1115
|
+
// Second call - should be cached
|
|
1116
|
+
const second = await generator.generate(mdx)
|
|
1117
|
+
expect(second.Card.title).toBe(first.Card.title)
|
|
1118
|
+
expect(second.Card.body).toBe(first.Card.body)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('generates different props for different frontmatter contexts', async () => {
|
|
1122
|
+
const generator = createMDXPropsGenerator({
|
|
1123
|
+
schemas: {
|
|
1124
|
+
Hero: {
|
|
1125
|
+
title: 'A title matching the page topic',
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
const mdxTech = `---
|
|
1131
|
+
topic: Machine Learning
|
|
1132
|
+
---
|
|
1133
|
+
<Hero />`
|
|
1134
|
+
|
|
1135
|
+
const mdxArt = `---
|
|
1136
|
+
topic: Renaissance Art
|
|
1137
|
+
---
|
|
1138
|
+
<Hero />`
|
|
1139
|
+
|
|
1140
|
+
const techResult = await generator.generate(mdxTech)
|
|
1141
|
+
const artResult = await generator.generate(mdxArt)
|
|
1142
|
+
|
|
1143
|
+
expect(techResult.Hero.title).toBeDefined()
|
|
1144
|
+
expect(artResult.Hero.title).toBeDefined()
|
|
1145
|
+
// Different topics should produce different titles
|
|
1146
|
+
// (we cannot guarantee this 100%, but it's the expected behavior)
|
|
1147
|
+
})
|
|
1148
|
+
})
|