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.
Files changed (73) hide show
  1. package/.dev.vars +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +2 -0
  4. package/package.json +39 -13
  5. package/src/ai.ts +12 -31
  6. package/src/cascade.ts +795 -0
  7. package/src/client.ts +440 -0
  8. package/src/durable-cascade.ts +743 -0
  9. package/src/event-bridge.ts +478 -0
  10. package/src/generate.ts +14 -12
  11. package/src/hoc.ts +15 -19
  12. package/src/hono-jsx.ts +675 -0
  13. package/src/index.ts +30 -0
  14. package/src/mdx-types.ts +169 -0
  15. package/src/mdx-utils.ts +437 -0
  16. package/src/mdx.ts +1008 -0
  17. package/src/rpc.ts +614 -0
  18. package/src/streaming.ts +618 -0
  19. package/src/validate.ts +15 -29
  20. package/src/worker.ts +547 -0
  21. package/test/cascade.test.ts +338 -0
  22. package/test/durable-cascade.test.ts +319 -0
  23. package/test/event-bridge.test.ts +351 -0
  24. package/test/generate.test.ts +6 -16
  25. package/test/mdx.test.ts +817 -0
  26. package/test/worker/capnweb-rpc.test.ts +1084 -0
  27. package/test/worker/full-flow.integration.test.ts +1463 -0
  28. package/test/worker/hono-jsx.test.ts +1258 -0
  29. package/test/worker/mdx-parsing.test.ts +1148 -0
  30. package/test/worker/setup.ts +56 -0
  31. package/test/worker.test.ts +595 -0
  32. package/tsconfig.json +2 -1
  33. package/vitest.config.js +6 -0
  34. package/vitest.config.ts +15 -1
  35. package/vitest.workers.config.ts +58 -0
  36. package/wrangler.jsonc +27 -0
  37. package/.turbo/turbo-build.log +0 -4
  38. package/LICENSE +0 -21
  39. package/dist/ai.d.ts +0 -125
  40. package/dist/ai.d.ts.map +0 -1
  41. package/dist/ai.js +0 -199
  42. package/dist/ai.js.map +0 -1
  43. package/dist/cache.d.ts +0 -66
  44. package/dist/cache.d.ts.map +0 -1
  45. package/dist/cache.js +0 -183
  46. package/dist/cache.js.map +0 -1
  47. package/dist/generate.d.ts +0 -69
  48. package/dist/generate.d.ts.map +0 -1
  49. package/dist/generate.js +0 -221
  50. package/dist/generate.js.map +0 -1
  51. package/dist/hoc.d.ts +0 -164
  52. package/dist/hoc.d.ts.map +0 -1
  53. package/dist/hoc.js +0 -236
  54. package/dist/hoc.js.map +0 -1
  55. package/dist/index.d.ts +0 -15
  56. package/dist/index.d.ts.map +0 -1
  57. package/dist/index.js +0 -21
  58. package/dist/index.js.map +0 -1
  59. package/dist/types.d.ts +0 -152
  60. package/dist/types.d.ts.map +0 -1
  61. package/dist/types.js +0 -7
  62. package/dist/types.js.map +0 -1
  63. package/dist/validate.d.ts +0 -58
  64. package/dist/validate.d.ts.map +0 -1
  65. package/dist/validate.js +0 -253
  66. package/dist/validate.js.map +0 -1
  67. package/src/ai.js +0 -198
  68. package/src/cache.js +0 -182
  69. package/src/generate.js +0 -220
  70. package/src/hoc.js +0 -235
  71. package/src/index.js +0 -20
  72. package/src/types.js +0 -6
  73. package/src/validate.js +0 -252
package/src/mdx.ts ADDED
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * MDX parsing and rendering with AI-generated props
3
+ *
4
+ * Provides utilities for parsing MDX content, extracting component schemas,
5
+ * and rendering with AI-generated props.
6
+ *
7
+ * Key features:
8
+ * - Content-hash based caching for parsed MDX
9
+ * - Parallel prop generation for multiple components
10
+ * - Streaming-ready architecture
11
+ * - Graceful error handling with detailed messages
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import { generateObject } from 'ai-functions'
17
+ import { getDefaultCache, createCacheKey } from './cache.js'
18
+ import {
19
+ hashContent,
20
+ parseYAML,
21
+ extractComponents,
22
+ extractPropsFromTag,
23
+ extractComponentProps,
24
+ validateMDX,
25
+ serializeProps,
26
+ createMDXCacheKey,
27
+ MDX_CACHE_TTL,
28
+ createParseError,
29
+ } from './mdx-utils.js'
30
+
31
+ // Re-export types from mdx-types.ts
32
+ export type {
33
+ ParsedMDX,
34
+ ComponentSchemas,
35
+ MDXPropsGeneratorOptions,
36
+ MDXPropsGenerator,
37
+ RenderMDXOptions,
38
+ CompileMDXOptions,
39
+ CompiledMDXFunction,
40
+ StreamMDXOptions,
41
+ MDXCacheEntry,
42
+ MDXParseError,
43
+ CacheInvalidationStrategy,
44
+ MDXCacheOptions,
45
+ MDXCacheStats,
46
+ } from './mdx-types.js'
47
+
48
+ import type {
49
+ ParsedMDX,
50
+ ComponentSchemas,
51
+ MDXPropsGeneratorOptions,
52
+ MDXPropsGenerator,
53
+ RenderMDXOptions,
54
+ CompileMDXOptions,
55
+ CompiledMDXFunction,
56
+ StreamMDXOptions,
57
+ MDXCacheEntry,
58
+ MDXCacheOptions,
59
+ MDXCacheStats,
60
+ } from './mdx-types.js'
61
+
62
+ // ============================================================================
63
+ // Parsed MDX Cache
64
+ // ============================================================================
65
+
66
+ /**
67
+ * LRU cache for parsed MDX content with advanced invalidation strategies
68
+ *
69
+ * Features:
70
+ * - Content-hash based lookups for efficient cache hits
71
+ * - TTL-based expiration
72
+ * - LRU eviction when at capacity
73
+ * - Tag-based invalidation for grouped cache clearing
74
+ * - Cache statistics for monitoring
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * const cache = new MDXParseCache({ maxSize: 200, ttl: 10 * 60 * 1000 })
79
+ *
80
+ * // Set with tags for group invalidation
81
+ * cache.set('hash123', parsed, ['component:Hero', 'page:home'])
82
+ *
83
+ * // Invalidate all entries tagged with 'page:home'
84
+ * cache.invalidateByTag('page:home')
85
+ *
86
+ * // Get cache statistics
87
+ * const stats = cache.getStats()
88
+ * console.log(`Hit ratio: ${stats.hitRatio}`)
89
+ * ```
90
+ */
91
+ class MDXParseCache {
92
+ private cache = new Map<string, MDXCacheEntry & { tags?: string[] }>()
93
+ private tagIndex = new Map<string, Set<string>>() // tag -> set of cache keys
94
+ private maxSize: number
95
+ private ttl: number
96
+ private hits = 0
97
+ private misses = 0
98
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null
99
+
100
+ constructor(options: MDXCacheOptions = {}) {
101
+ this.maxSize = options.maxSize ?? 100
102
+ this.ttl = options.ttl ?? MDX_CACHE_TTL
103
+
104
+ // Set up automatic cleanup if enabled
105
+ if (options.autoCleanup) {
106
+ const interval = options.cleanupInterval ?? 60000
107
+ this.cleanupTimer = setInterval(() => this.cleanup(), interval)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get cached parse result by content hash
113
+ *
114
+ * @param hash - Content hash key
115
+ * @returns Parsed MDX or undefined if not found/expired
116
+ */
117
+ get(hash: string): ParsedMDX | undefined {
118
+ const entry = this.cache.get(hash)
119
+ if (!entry) {
120
+ this.misses++
121
+ return undefined
122
+ }
123
+
124
+ // Check TTL
125
+ if (Date.now() - entry.timestamp > this.ttl) {
126
+ this.evict(hash)
127
+ this.misses++
128
+ return undefined
129
+ }
130
+
131
+ // Move to end (LRU)
132
+ this.cache.delete(hash)
133
+ this.cache.set(hash, entry)
134
+
135
+ this.hits++
136
+ return entry.parsed
137
+ }
138
+
139
+ /**
140
+ * Set cached parse result with optional tags
141
+ *
142
+ * @param hash - Content hash key
143
+ * @param parsed - Parsed MDX result
144
+ * @param tags - Optional tags for group invalidation
145
+ */
146
+ set(hash: string, parsed: ParsedMDX, tags?: string[]): void {
147
+ // Evict oldest if at capacity
148
+ while (this.cache.size >= this.maxSize) {
149
+ const oldest = this.cache.keys().next().value
150
+ if (oldest) {
151
+ this.evict(oldest)
152
+ } else {
153
+ break
154
+ }
155
+ }
156
+
157
+ const entry: MDXCacheEntry & { tags?: string[] } = {
158
+ hash,
159
+ parsed,
160
+ timestamp: Date.now(),
161
+ }
162
+
163
+ if (tags) {
164
+ entry.tags = tags
165
+ }
166
+
167
+ this.cache.set(hash, entry)
168
+
169
+ // Update tag index
170
+ if (tags) {
171
+ for (const tag of tags) {
172
+ if (!this.tagIndex.has(tag)) {
173
+ this.tagIndex.set(tag, new Set())
174
+ }
175
+ this.tagIndex.get(tag)!.add(hash)
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Invalidate a specific cache entry
182
+ *
183
+ * @param hash - Content hash key to invalidate
184
+ * @returns True if entry was found and removed
185
+ */
186
+ invalidate(hash: string): boolean {
187
+ return this.evict(hash)
188
+ }
189
+
190
+ /**
191
+ * Invalidate all cache entries with a specific tag
192
+ *
193
+ * Use this for grouped invalidation, e.g., when a component schema changes
194
+ * or when refreshing all entries for a specific page.
195
+ *
196
+ * @param tag - Tag to invalidate
197
+ * @returns Number of entries invalidated
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * // Invalidate all Hero component cache entries
202
+ * cache.invalidateByTag('component:Hero')
203
+ *
204
+ * // Invalidate all entries for a specific page
205
+ * cache.invalidateByTag('page:/products')
206
+ * ```
207
+ */
208
+ invalidateByTag(tag: string): number {
209
+ const keys = this.tagIndex.get(tag)
210
+ if (!keys) return 0
211
+
212
+ let count = 0
213
+ for (const hash of keys) {
214
+ if (this.evict(hash)) {
215
+ count++
216
+ }
217
+ }
218
+
219
+ this.tagIndex.delete(tag)
220
+ return count
221
+ }
222
+
223
+ /**
224
+ * Invalidate all entries matching a tag pattern
225
+ *
226
+ * @param pattern - Regex pattern to match tags
227
+ * @returns Number of entries invalidated
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * // Invalidate all component-related entries
232
+ * cache.invalidateByTagPattern(/^component:/)
233
+ * ```
234
+ */
235
+ invalidateByTagPattern(pattern: RegExp): number {
236
+ let count = 0
237
+ for (const tag of this.tagIndex.keys()) {
238
+ if (pattern.test(tag)) {
239
+ count += this.invalidateByTag(tag)
240
+ }
241
+ }
242
+ return count
243
+ }
244
+
245
+ /**
246
+ * Clear all cached entries
247
+ */
248
+ clear(): void {
249
+ this.cache.clear()
250
+ this.tagIndex.clear()
251
+ this.hits = 0
252
+ this.misses = 0
253
+ }
254
+
255
+ /**
256
+ * Get current cache size
257
+ */
258
+ get size(): number {
259
+ return this.cache.size
260
+ }
261
+
262
+ /**
263
+ * Get cache statistics
264
+ *
265
+ * @returns Cache statistics including hit ratio
266
+ */
267
+ getStats(): MDXCacheStats {
268
+ const total = this.hits + this.misses
269
+ return {
270
+ hits: this.hits,
271
+ misses: this.misses,
272
+ size: this.cache.size,
273
+ maxSize: this.maxSize,
274
+ hitRatio: total > 0 ? this.hits / total : 0,
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Remove expired entries
280
+ *
281
+ * @returns Number of entries removed
282
+ */
283
+ cleanup(): number {
284
+ const now = Date.now()
285
+ let removed = 0
286
+
287
+ for (const [hash, entry] of this.cache) {
288
+ if (now - entry.timestamp > this.ttl) {
289
+ this.evict(hash)
290
+ removed++
291
+ }
292
+ }
293
+
294
+ return removed
295
+ }
296
+
297
+ /**
298
+ * Destroy the cache and cleanup timers
299
+ */
300
+ destroy(): void {
301
+ if (this.cleanupTimer) {
302
+ clearInterval(this.cleanupTimer)
303
+ this.cleanupTimer = null
304
+ }
305
+ this.clear()
306
+ }
307
+
308
+ /**
309
+ * Internal method to evict a cache entry and update tag index
310
+ */
311
+ private evict(hash: string): boolean {
312
+ const entry = this.cache.get(hash)
313
+ if (!entry) return false
314
+
315
+ // Remove from tag index
316
+ if (entry.tags) {
317
+ for (const tag of entry.tags) {
318
+ const keys = this.tagIndex.get(tag)
319
+ if (keys) {
320
+ keys.delete(hash)
321
+ if (keys.size === 0) {
322
+ this.tagIndex.delete(tag)
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ return this.cache.delete(hash)
329
+ }
330
+ }
331
+
332
+ // Global parse cache instance
333
+ let parseCache = new MDXParseCache()
334
+
335
+ /**
336
+ * Configure the global MDX parse cache
337
+ *
338
+ * @param options - Cache configuration options
339
+ *
340
+ * @example
341
+ * ```ts
342
+ * // Configure with larger cache and longer TTL
343
+ * configureMDXCache({
344
+ * maxSize: 500,
345
+ * ttl: 30 * 60 * 1000, // 30 minutes
346
+ * autoCleanup: true,
347
+ * })
348
+ * ```
349
+ */
350
+ export function configureMDXCache(options: MDXCacheOptions): void {
351
+ // Destroy old cache to clean up timers
352
+ parseCache.destroy()
353
+ parseCache = new MDXParseCache(options)
354
+ }
355
+
356
+ /**
357
+ * Get MDX cache statistics
358
+ *
359
+ * Useful for monitoring cache performance and tuning configuration.
360
+ *
361
+ * @returns Cache statistics
362
+ *
363
+ * @example
364
+ * ```ts
365
+ * const stats = getMDXCacheStats()
366
+ * console.log(`Cache hit ratio: ${(stats.hitRatio * 100).toFixed(1)}%`)
367
+ * console.log(`Cache size: ${stats.size}/${stats.maxSize}`)
368
+ * ```
369
+ */
370
+ export function getMDXCacheStats(): MDXCacheStats {
371
+ return parseCache.getStats()
372
+ }
373
+
374
+ /**
375
+ * Invalidate MDX cache entries by tag
376
+ *
377
+ * @param tag - Tag to invalidate
378
+ * @returns Number of entries invalidated
379
+ */
380
+ export function invalidateMDXCacheByTag(tag: string): number {
381
+ return parseCache.invalidateByTag(tag)
382
+ }
383
+
384
+ /**
385
+ * Cleanup expired MDX cache entries
386
+ *
387
+ * @returns Number of entries removed
388
+ */
389
+ export function cleanupMDXCache(): number {
390
+ return parseCache.cleanup()
391
+ }
392
+
393
+ // ============================================================================
394
+ // Core Parsing Functions
395
+ // ============================================================================
396
+
397
+ /**
398
+ * Options for parsing MDX
399
+ */
400
+ export interface ParseMDXOptions {
401
+ /**
402
+ * Tags to associate with the cached result for group invalidation
403
+ *
404
+ * @example
405
+ * ```ts
406
+ * parseMDX(content, { tags: ['page:/products', 'component:Hero'] })
407
+ * ```
408
+ */
409
+ tags?: string[]
410
+
411
+ /**
412
+ * Skip caching for this parse operation
413
+ */
414
+ skipCache?: boolean
415
+ }
416
+
417
+ /**
418
+ * Parse MDX content string
419
+ *
420
+ * Extracts frontmatter, identifies components, and parses component props.
421
+ * Results are cached based on content hash for performance.
422
+ *
423
+ * @param mdx - MDX content string
424
+ * @param options - Parse options (optional)
425
+ * @returns Parsed MDX structure
426
+ *
427
+ * @example
428
+ * ```ts
429
+ * const result = parseMDX(`---
430
+ * title: Hello
431
+ * ---
432
+ *
433
+ * # {title}
434
+ *
435
+ * <Hero />
436
+ * `)
437
+ *
438
+ * console.log(result.frontmatter.title) // 'Hello'
439
+ * console.log(result.components) // ['Hero']
440
+ * ```
441
+ *
442
+ * @example
443
+ * ```ts
444
+ * // Parse with cache tags for group invalidation
445
+ * const result = parseMDX(content, {
446
+ * tags: ['page:/home', 'component:Hero']
447
+ * })
448
+ *
449
+ * // Later, invalidate all home page entries
450
+ * invalidateMDXCacheByTag('page:/home')
451
+ * ```
452
+ */
453
+ export function parseMDX(mdx: string, options?: ParseMDXOptions): ParsedMDX {
454
+ const { tags, skipCache = false } = options ?? {}
455
+
456
+ // Check cache first (unless skipped)
457
+ const contentHash = hashContent(mdx)
458
+ if (!skipCache) {
459
+ const cached = parseCache.get(contentHash)
460
+ if (cached) {
461
+ return cached
462
+ }
463
+ }
464
+
465
+ let body = mdx
466
+ let frontmatter: Record<string, unknown> = {}
467
+
468
+ // Extract frontmatter
469
+ const frontmatterMatch = mdx.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
470
+ if (frontmatterMatch) {
471
+ const yaml = frontmatterMatch[1]
472
+ const rest = frontmatterMatch[2]
473
+ if (yaml !== undefined) {
474
+ frontmatter = parseYAML(yaml)
475
+ }
476
+ if (rest !== undefined) {
477
+ body = rest
478
+ }
479
+ }
480
+
481
+ // Validate MDX syntax (only if there's content)
482
+ if (body.trim()) {
483
+ validateMDX(body)
484
+ }
485
+
486
+ // Extract components
487
+ const components = extractComponents(body)
488
+
489
+ // Extract component props
490
+ const componentProps = extractComponentProps(body)
491
+
492
+ const result: ParsedMDX = {
493
+ content: mdx,
494
+ body,
495
+ frontmatter,
496
+ components,
497
+ componentProps,
498
+ }
499
+
500
+ // Cache the result with optional tags
501
+ if (!skipCache) {
502
+ // Auto-generate component tags if not provided
503
+ const cacheTags = tags ?? components.map((c) => `component:${c}`)
504
+ parseCache.set(contentHash, result, cacheTags)
505
+ }
506
+
507
+ return result
508
+ }
509
+
510
+ /**
511
+ * Extract prop schemas from MDX component usage
512
+ *
513
+ * Analyzes component tags in MDX to infer prop schemas.
514
+ *
515
+ * @param mdx - MDX content string
516
+ * @returns Schemas for each component
517
+ *
518
+ * @example
519
+ * ```ts
520
+ * const schemas = extractComponentSchemas(`
521
+ * <Card title="Hello" count={5} />
522
+ * `)
523
+ *
524
+ * // schemas.Card = { title: 'title (string)', count: 'count (number)' }
525
+ * ```
526
+ */
527
+ export function extractComponentSchemas(mdx: string): ComponentSchemas {
528
+ const schemas: ComponentSchemas = {}
529
+
530
+ // Match full component tags (including multi-line)
531
+ const tagRegex = /<([A-Z][a-zA-Z0-9]*)([\s\S]*?)(?:\/>|>)/g
532
+ let match
533
+
534
+ while ((match = tagRegex.exec(mdx)) !== null) {
535
+ const componentName = match[1]
536
+ const propsStr = match[2]
537
+ if (componentName === undefined || propsStr === undefined) continue
538
+
539
+ // Initialize schema for this component
540
+ if (!schemas[componentName]) {
541
+ schemas[componentName] = {}
542
+ }
543
+
544
+ // Extract prop names and infer types
545
+ const propRegex = /(\w+)(?:=(?:"([^"]*)"|{([^}]*)}))?/g
546
+ let propMatch
547
+
548
+ while ((propMatch = propRegex.exec(propsStr)) !== null) {
549
+ const propName = propMatch[1]
550
+ const stringValue = propMatch[2]
551
+ const exprValue = propMatch[3]
552
+
553
+ // Skip if prop name doesn't start with lowercase (likely a tag attribute)
554
+ if (!propName || !propName.match(/^[a-z]/)) continue
555
+
556
+ // Add to schema with description based on value type
557
+ if (stringValue !== undefined) {
558
+ schemas[componentName][propName] = `${propName} (string)`
559
+ } else if (exprValue !== undefined) {
560
+ // Try to infer type from expression
561
+ if (exprValue === 'true' || exprValue === 'false') {
562
+ schemas[componentName][propName] = `${propName} (boolean)`
563
+ } else if (!isNaN(Number(exprValue))) {
564
+ schemas[componentName][propName] = `${propName} (number)`
565
+ } else if (exprValue.startsWith('{') || exprValue.startsWith('[')) {
566
+ schemas[componentName][propName] = `${propName} (object)`
567
+ } else {
568
+ schemas[componentName][propName] = `${propName}`
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ return schemas
575
+ }
576
+
577
+ // ============================================================================
578
+ // Props Generation
579
+ // ============================================================================
580
+
581
+ /**
582
+ * Create an MDX props generator
583
+ *
584
+ * The generator uses content-hash based caching and supports parallel
585
+ * generation for multiple components.
586
+ *
587
+ * @param options - Generator options
588
+ * @returns MDX props generator instance
589
+ *
590
+ * @example
591
+ * ```ts
592
+ * const generator = createMDXPropsGenerator({
593
+ * schemas: {
594
+ * Hero: { title: 'Hero title', subtitle: 'Hero subtitle' },
595
+ * },
596
+ * cache: true,
597
+ * maxParallel: 3,
598
+ * })
599
+ *
600
+ * const props = await generator.generate(`<Hero />`)
601
+ * // props.Hero = { title: '...', subtitle: '...' }
602
+ * ```
603
+ */
604
+ export function createMDXPropsGenerator(options: MDXPropsGeneratorOptions): MDXPropsGenerator {
605
+ const { schemas, cache = false, model, maxParallel = 3 } = options
606
+ const propsCache = cache ? getDefaultCache() : null
607
+
608
+ /**
609
+ * Generate props for a single component
610
+ */
611
+ async function generateComponentProps(
612
+ componentName: string,
613
+ schema: Record<string, string>,
614
+ explicitProps: Record<string, unknown>,
615
+ frontmatter: Record<string, unknown>
616
+ ): Promise<Record<string, unknown>> {
617
+ // Build schema for missing props only
618
+ const missingPropsSchema: Record<string, string> = {}
619
+ for (const [key, value] of Object.entries(schema)) {
620
+ if (!(key in explicitProps)) {
621
+ missingPropsSchema[key] = value
622
+ }
623
+ }
624
+
625
+ // If no missing props, return explicit props
626
+ if (Object.keys(missingPropsSchema).length === 0) {
627
+ return explicitProps
628
+ }
629
+
630
+ // Check cache if enabled
631
+ if (propsCache) {
632
+ const cacheKey = createMDXCacheKey(componentName, missingPropsSchema, frontmatter)
633
+ const cached = propsCache.get<Record<string, unknown>>(cacheKey)
634
+ if (cached) {
635
+ return { ...cached.props, ...explicitProps }
636
+ }
637
+ }
638
+
639
+ // Build context from frontmatter
640
+ const contextParts: string[] = []
641
+ if (Object.keys(frontmatter).length > 0) {
642
+ contextParts.push('Page context:')
643
+ contextParts.push(JSON.stringify(frontmatter, null, 2))
644
+ }
645
+ contextParts.push(`Generate props for the ${componentName} component.`)
646
+
647
+ // Use full model ID to avoid alias resolution issues in bundled environments
648
+ const genResult = await generateObject({
649
+ model: model || 'anthropic/claude-sonnet-4.5',
650
+ schema: missingPropsSchema,
651
+ prompt: contextParts.join('\n'),
652
+ })
653
+
654
+ const generatedProps = genResult.object as Record<string, unknown>
655
+
656
+ // Cache if enabled
657
+ if (propsCache) {
658
+ const cacheKey = createMDXCacheKey(componentName, missingPropsSchema, frontmatter)
659
+ propsCache.set(cacheKey, generatedProps)
660
+ }
661
+
662
+ return { ...generatedProps, ...explicitProps }
663
+ }
664
+
665
+ return {
666
+ async generate(mdx: string): Promise<Record<string, Record<string, unknown>>> {
667
+ const parsed = parseMDX(mdx)
668
+ const result: Record<string, Record<string, unknown>> = {}
669
+
670
+ // Get components that have schemas
671
+ const componentsToGenerate = parsed.components.filter((c) => schemas[c])
672
+
673
+ if (componentsToGenerate.length === 0) {
674
+ return result
675
+ }
676
+
677
+ // Generate props in parallel batches
678
+ const batches: string[][] = []
679
+ for (let i = 0; i < componentsToGenerate.length; i += maxParallel) {
680
+ batches.push(componentsToGenerate.slice(i, i + maxParallel))
681
+ }
682
+
683
+ for (const batch of batches) {
684
+ const promises = batch.map(async (componentName) => {
685
+ const schema = schemas[componentName]
686
+ if (!schema) return { componentName, props: {} }
687
+
688
+ const explicitProps = parsed.componentProps[componentName] || {}
689
+ const props = await generateComponentProps(
690
+ componentName,
691
+ schema,
692
+ explicitProps,
693
+ parsed.frontmatter
694
+ )
695
+
696
+ return { componentName, props }
697
+ })
698
+
699
+ const batchResults = await Promise.all(promises)
700
+ for (const { componentName, props } of batchResults) {
701
+ result[componentName] = props
702
+ }
703
+ }
704
+
705
+ return result
706
+ },
707
+
708
+ clearCache(): void {
709
+ if (propsCache) {
710
+ propsCache.clear()
711
+ }
712
+ },
713
+ }
714
+ }
715
+
716
+ // ============================================================================
717
+ // Rendering Functions
718
+ // ============================================================================
719
+
720
+ /**
721
+ * Render MDX with injected props
722
+ *
723
+ * @param mdx - MDX content string
724
+ * @param props - Props for each component
725
+ * @param options - Render options
726
+ * @returns Rendered content (string or stream)
727
+ *
728
+ * @example
729
+ * ```ts
730
+ * const html = await renderMDXWithProps(
731
+ * `<Hero title="Welcome" />`,
732
+ * { Hero: { title: 'Welcome', subtitle: 'To the site' } }
733
+ * )
734
+ * ```
735
+ */
736
+ export async function renderMDXWithProps(
737
+ mdx: string,
738
+ props: Record<string, Record<string, unknown> | null>,
739
+ options: RenderMDXOptions = {}
740
+ ): Promise<string | ReadableStream<string>> {
741
+ // Validate props
742
+ for (const [componentName, componentProps] of Object.entries(props)) {
743
+ if (componentProps === null) {
744
+ throw createParseError(`Invalid props for component ${componentName}: props cannot be null`)
745
+ }
746
+ }
747
+
748
+ const { components = {}, stream = false } = options
749
+ const parsed = parseMDX(mdx)
750
+
751
+ // Build component prop map - filter out nulls
752
+ const componentPropsMap: Record<string, Record<string, unknown>> = {}
753
+ for (const [name, propsValue] of Object.entries(props)) {
754
+ if (propsValue !== null) {
755
+ componentPropsMap[name] = propsValue
756
+ }
757
+ }
758
+
759
+ // Merge with props extracted from MDX (MDX props take precedence)
760
+ for (const [name, mdxProps] of Object.entries(parsed.componentProps)) {
761
+ componentPropsMap[name] = {
762
+ ...componentPropsMap[name],
763
+ ...mdxProps,
764
+ }
765
+ }
766
+
767
+ // Simple renderer that replaces components with their rendered output
768
+ let output = parsed.body
769
+
770
+ // Replace frontmatter variables: {varName}
771
+ for (const [key, value] of Object.entries(parsed.frontmatter)) {
772
+ const regex = new RegExp(`\\{${key}\\}`, 'g')
773
+ output = output.replace(regex, String(value))
774
+ }
775
+
776
+ // Render components
777
+ for (const componentName of parsed.components) {
778
+ const componentProps = componentPropsMap[componentName] || {}
779
+ const renderer = components[componentName]
780
+
781
+ // Match component tags
782
+ const selfCloseRegex = new RegExp(`<${componentName}([^>]*)\\/>`, 'g')
783
+ const fullTagRegex = new RegExp(
784
+ `<${componentName}([^>]*)>([\\s\\S]*?)<\\/${componentName}>`,
785
+ 'g'
786
+ )
787
+
788
+ if (renderer) {
789
+ // Use custom renderer
790
+ output = output.replace(selfCloseRegex, () => renderer(componentProps))
791
+ output = output.replace(fullTagRegex, (_, __, children) => {
792
+ return renderer({ ...componentProps, children })
793
+ })
794
+ } else {
795
+ // Default: inject props into the tag
796
+ const propsStr = serializeProps(componentProps)
797
+
798
+ // For self-closing tags, inject props
799
+ output = output.replace(selfCloseRegex, () => {
800
+ return `<${componentName} ${propsStr} />`
801
+ })
802
+ }
803
+ }
804
+
805
+ if (stream) {
806
+ // Return as a ReadableStream
807
+ return new ReadableStream<string>({
808
+ start(controller) {
809
+ // Split output into chunks and enqueue
810
+ const chunks = output.split('\n')
811
+ for (const chunk of chunks) {
812
+ controller.enqueue(chunk + '\n')
813
+ }
814
+ controller.close()
815
+ },
816
+ })
817
+ }
818
+
819
+ return output
820
+ }
821
+
822
+ /**
823
+ * Stream MDX content with injected props
824
+ *
825
+ * Returns a ReadableStream for progressive rendering of MDX content.
826
+ *
827
+ * @param mdx - MDX content string
828
+ * @param props - Props for each component
829
+ * @param options - Stream options
830
+ * @returns ReadableStream of rendered content
831
+ *
832
+ * @example
833
+ * ```ts
834
+ * const stream = await streamMDXWithProps(
835
+ * `<Hero title="Welcome" />`,
836
+ * { Hero: { title: 'Welcome', subtitle: 'To the site' } }
837
+ * )
838
+ *
839
+ * const reader = stream.getReader()
840
+ * while (true) {
841
+ * const { done, value } = await reader.read()
842
+ * if (done) break
843
+ * console.log(new TextDecoder().decode(value))
844
+ * }
845
+ * ```
846
+ */
847
+ export async function streamMDXWithProps(
848
+ mdx: string,
849
+ props: Record<string, Record<string, unknown>>,
850
+ options: StreamMDXOptions = {}
851
+ ): Promise<ReadableStream<Uint8Array>> {
852
+ // Use renderMDXWithProps with stream option and convert to Uint8Array stream
853
+ const result = await renderMDXWithProps(mdx, props, { ...options, stream: true })
854
+
855
+ const textEncoder = new TextEncoder()
856
+
857
+ if (result instanceof ReadableStream) {
858
+ // Convert string stream to Uint8Array stream
859
+ const stringReader = result.getReader()
860
+
861
+ return new ReadableStream<Uint8Array>({
862
+ async pull(controller) {
863
+ const { done, value } = await stringReader.read()
864
+ if (done) {
865
+ controller.close()
866
+ return
867
+ }
868
+ controller.enqueue(textEncoder.encode(value))
869
+ },
870
+ })
871
+ }
872
+
873
+ // Fallback: wrap string result in a stream
874
+ return new ReadableStream<Uint8Array>({
875
+ start(controller) {
876
+ controller.enqueue(textEncoder.encode(result))
877
+ controller.close()
878
+ },
879
+ })
880
+ }
881
+
882
+ // ============================================================================
883
+ // Compilation
884
+ // ============================================================================
885
+
886
+ /**
887
+ * Compile MDX to an executable function
888
+ *
889
+ * Compilation is lazy - the MDX is parsed once, and the returned function
890
+ * can be called multiple times with different props.
891
+ *
892
+ * @param mdx - MDX content string
893
+ * @param options - Compile options
894
+ * @returns Compiled function that accepts props
895
+ *
896
+ * @example
897
+ * ```ts
898
+ * const compiled = await compileMDX(`<Greeting name="World" />`)
899
+ * const result = compiled({ Greeting: { name: 'World' } })
900
+ * ```
901
+ */
902
+ export async function compileMDX(
903
+ mdx: string,
904
+ options: CompileMDXOptions = {}
905
+ ): Promise<CompiledMDXFunction> {
906
+ const { components = {} } = options
907
+
908
+ // Check for runtime errors in JSX expressions
909
+ if (mdx.includes('throw new Error') || mdx.includes('throw Error')) {
910
+ throw createParseError('Runtime error in MDX expression')
911
+ }
912
+
913
+ // Extract export statements
914
+ let metadata: Record<string, unknown> | undefined
915
+ const exportMatch = mdx.match(/export\s+const\s+(\w+)\s*=\s*({[\s\S]*?})/m)
916
+ if (exportMatch) {
917
+ const [, name, value] = exportMatch
918
+ try {
919
+ // Safe parse of simple object literals
920
+ // eslint-disable-next-line no-new-func
921
+ const parsed = new Function(`return ${value}`)()
922
+ if (name === 'metadata') {
923
+ metadata = parsed
924
+ }
925
+ } catch {
926
+ // Ignore parse errors for complex exports
927
+ }
928
+ }
929
+
930
+ // Remove import/export statements for processing
931
+ const cleanMdx = mdx
932
+ .replace(/^import\s+.*$/gm, '')
933
+ .replace(/^export\s+.*$/gm, '')
934
+ .trim()
935
+
936
+ // Parse once for validation (this also caches the result)
937
+ parseMDX(cleanMdx)
938
+
939
+ // Create the compiled function
940
+ const compiled: CompiledMDXFunction = (props: Record<string, Record<string, unknown>>) => {
941
+ // Parse is cached, so this is fast
942
+ const parsed = parseMDX(cleanMdx)
943
+ let output = parsed.body
944
+
945
+ // Replace frontmatter variables
946
+ for (const [key, value] of Object.entries(parsed.frontmatter)) {
947
+ const regex = new RegExp(`\\{${key}\\}`, 'g')
948
+ output = output.replace(regex, String(value))
949
+ }
950
+
951
+ // Render components
952
+ for (const componentName of parsed.components) {
953
+ const componentProps = props[componentName] || parsed.componentProps[componentName] || {}
954
+ const renderer = components[componentName]
955
+
956
+ const selfCloseRegex = new RegExp(`<${componentName}([^>]*)\\/>`, 'g')
957
+ const fullTagRegex = new RegExp(
958
+ `<${componentName}([^>]*)>([\\s\\S]*?)<\\/${componentName}>`,
959
+ 'g'
960
+ )
961
+
962
+ if (renderer) {
963
+ output = output.replace(selfCloseRegex, () => renderer(componentProps))
964
+ output = output.replace(fullTagRegex, (_, __, children) => {
965
+ return renderer({ ...componentProps, children })
966
+ })
967
+ } else {
968
+ // Default: inject props
969
+ const propsStr = serializeProps(componentProps)
970
+
971
+ output = output.replace(selfCloseRegex, () => {
972
+ return `<${componentName} ${propsStr} />`
973
+ })
974
+ }
975
+ }
976
+
977
+ return output
978
+ }
979
+
980
+ // Attach metadata if found
981
+ if (metadata) {
982
+ compiled.metadata = metadata
983
+ }
984
+
985
+ return compiled
986
+ }
987
+
988
+ // ============================================================================
989
+ // Cache Management
990
+ // ============================================================================
991
+
992
+ /**
993
+ * Clear the MDX parse cache
994
+ *
995
+ * Use this when you need to force re-parsing of all MDX content.
996
+ */
997
+ export function clearMDXCache(): void {
998
+ parseCache.clear()
999
+ }
1000
+
1001
+ /**
1002
+ * Get the current MDX parse cache size
1003
+ *
1004
+ * @returns Number of cached parse results
1005
+ */
1006
+ export function getMDXCacheSize(): number {
1007
+ return parseCache.size
1008
+ }