@blogmcp/mdx-render-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +32 -0
  2. package/dist/MdxContent.d.ts +29 -0
  3. package/dist/MdxContent.js +51 -0
  4. package/dist/index.d.ts +19 -0
  5. package/dist/index.js +43 -0
  6. package/dist/marked.d.ts +34 -0
  7. package/dist/marked.js +15 -0
  8. package/dist/registry.d.ts +33 -0
  9. package/dist/registry.js +88 -0
  10. package/dist/render/alert/Alert.d.ts +13 -0
  11. package/dist/render/alert/Alert.js +24 -0
  12. package/dist/render/audioplayer/AudioPlayerDefault.d.ts +6 -0
  13. package/dist/render/audioplayer/AudioPlayerDefault.js +75 -0
  14. package/dist/render/badge/Badge.d.ts +11 -0
  15. package/dist/render/badge/Badge.js +12 -0
  16. package/dist/render/button/Button.d.ts +14 -0
  17. package/dist/render/button/Button.js +20 -0
  18. package/dist/render/callout/CalloutDefault.d.ts +11 -0
  19. package/dist/render/callout/CalloutDefault.js +19 -0
  20. package/dist/render/callout/CalloutMinimal.d.ts +10 -0
  21. package/dist/render/callout/CalloutMinimal.js +16 -0
  22. package/dist/render/card/Card.d.ts +9 -0
  23. package/dist/render/card/Card.js +17 -0
  24. package/dist/render/codeblock/CodeBlockDefault.d.ts +14 -0
  25. package/dist/render/codeblock/CodeBlockDefault.js +56 -0
  26. package/dist/render/codeblock/language-labels.d.ts +8 -0
  27. package/dist/render/codeblock/language-labels.js +42 -0
  28. package/dist/render/gallery/GalleryGrid.d.ts +11 -0
  29. package/dist/render/gallery/GalleryGrid.js +106 -0
  30. package/dist/render/gallery/GalleryLightbox.d.ts +18 -0
  31. package/dist/render/gallery/GalleryLightbox.js +154 -0
  32. package/dist/render/gallery/gallery.d.ts +29 -0
  33. package/dist/render/gallery/gallery.js +56 -0
  34. package/dist/render/icon/Icon.d.ts +11 -0
  35. package/dist/render/icon/Icon.js +18 -0
  36. package/dist/render/index.d.ts +25 -0
  37. package/dist/render/index.js +26 -0
  38. package/dist/render/tooltip/Tooltip.d.ts +13 -0
  39. package/dist/render/tooltip/Tooltip.js +15 -0
  40. package/dist/render/youtube/YouTubeDefault.d.ts +11 -0
  41. package/dist/render/youtube/YouTubeDefault.js +53 -0
  42. package/dist/render/youtube/youtube.d.ts +8 -0
  43. package/dist/render/youtube/youtube.js +13 -0
  44. package/dist/server-render-map.d.ts +54 -0
  45. package/dist/server-render-map.js +56 -0
  46. package/package.json +77 -0
  47. package/src/MdxContent.tsx +105 -0
  48. package/src/index.ts +37 -0
  49. package/src/marked.ts +49 -0
  50. package/src/registry.ts +131 -0
  51. package/src/render/alert/Alert.tsx +32 -0
  52. package/src/render/audioplayer/AudioPlayerDefault.tsx +106 -0
  53. package/src/render/badge/Badge.tsx +24 -0
  54. package/src/render/button/Button.tsx +49 -0
  55. package/src/render/callout/CalloutDefault.tsx +34 -0
  56. package/src/render/callout/CalloutMinimal.tsx +25 -0
  57. package/src/render/card/Card.tsx +21 -0
  58. package/src/render/codeblock/CodeBlockDefault.tsx +82 -0
  59. package/src/render/codeblock/language-labels.ts +46 -0
  60. package/src/render/gallery/GalleryGrid.tsx +143 -0
  61. package/src/render/gallery/GalleryLightbox.tsx +201 -0
  62. package/src/render/gallery/gallery.ts +90 -0
  63. package/src/render/icon/Icon.tsx +25 -0
  64. package/src/render/index.ts +26 -0
  65. package/src/render/tooltip/Tooltip.tsx +38 -0
  66. package/src/render/youtube/YouTubeDefault.tsx +58 -0
  67. package/src/render/youtube/youtube.ts +17 -0
  68. package/src/server-render-map.ts +120 -0
@@ -0,0 +1,201 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, type TouchEvent as ReactTouchEvent, type MouseEvent as ReactMouseEvent } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+
6
+ export interface GalleryLightboxProps {
7
+ isOpen: boolean
8
+ currentIndex: number
9
+ imageCount: number
10
+ getImageSrc: (index: number) => string
11
+ getImageAlt: (index: number) => string
12
+ getImageDescription: (index: number) => string | undefined
13
+ onClose: () => void
14
+ onNavigate: (index: number) => void
15
+ }
16
+
17
+ /**
18
+ * Fullscreen image viewer with keyboard + touch navigation. Client island —
19
+ * mirrors Vue GalleryLightbox.vue: portalled to <body>, dark backdrop,
20
+ * close/prev/next buttons, image counter, Escape + ArrowLeft/Right keys,
21
+ * click-backdrop-to-close, and horizontal-swipe navigation. Markup, classes,
22
+ * and `aria-label`s match the Vue source.
23
+ */
24
+ export default function GalleryLightbox({
25
+ isOpen,
26
+ currentIndex,
27
+ imageCount,
28
+ getImageSrc,
29
+ getImageAlt,
30
+ getImageDescription,
31
+ onClose,
32
+ onNavigate,
33
+ }: GalleryLightboxProps) {
34
+ const hasPrev = currentIndex > 0
35
+ const hasNext = currentIndex < imageCount - 1
36
+ const showNav = imageCount > 1
37
+
38
+ // Keyboard navigation — registered while open, mirrors Vue's document listener.
39
+ useEffect(() => {
40
+ if (!isOpen) return
41
+ function onKeydown(e: KeyboardEvent) {
42
+ if (e.key === 'Escape') {
43
+ onClose()
44
+ } else if (e.key === 'ArrowLeft' && currentIndex > 0) {
45
+ onNavigate(currentIndex - 1)
46
+ } else if (e.key === 'ArrowRight' && currentIndex < imageCount - 1) {
47
+ onNavigate(currentIndex + 1)
48
+ }
49
+ }
50
+ document.addEventListener('keydown', onKeydown)
51
+ return () => document.removeEventListener('keydown', onKeydown)
52
+ }, [isOpen, currentIndex, imageCount, onClose, onNavigate])
53
+
54
+ // Scroll lock while the lightbox is open (mirrors Vue's body overflow watch).
55
+ useEffect(() => {
56
+ if (!isOpen) return
57
+ document.body.style.overflow = 'hidden'
58
+ return () => {
59
+ document.body.style.overflow = ''
60
+ }
61
+ }, [isOpen])
62
+
63
+ // Touch swipe.
64
+ const touchStartX = useRef(0)
65
+ const touchStartY = useRef(0)
66
+
67
+ const onTouchStart = useCallback((e: ReactTouchEvent) => {
68
+ touchStartX.current = e.touches[0].clientX
69
+ touchStartY.current = e.touches[0].clientY
70
+ }, [])
71
+
72
+ const onTouchEnd = useCallback(
73
+ (e: ReactTouchEvent) => {
74
+ const dx = e.changedTouches[0].clientX - touchStartX.current
75
+ const dy = e.changedTouches[0].clientY - touchStartY.current
76
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
77
+ if (dx < 0 && hasNext) {
78
+ onNavigate(currentIndex + 1)
79
+ } else if (dx > 0 && hasPrev) {
80
+ onNavigate(currentIndex - 1)
81
+ }
82
+ }
83
+ },
84
+ [currentIndex, hasNext, hasPrev, onNavigate],
85
+ )
86
+
87
+ const onBackdropClick = useCallback(
88
+ (e: ReactMouseEvent) => {
89
+ if (e.target === e.currentTarget) {
90
+ onClose()
91
+ }
92
+ },
93
+ [onClose],
94
+ )
95
+
96
+ if (!isOpen || typeof document === 'undefined') return null
97
+
98
+ const description = getImageDescription(currentIndex)
99
+
100
+ return createPortal(
101
+ <div
102
+ className="lightbox-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/90"
103
+ role="dialog"
104
+ aria-modal="true"
105
+ onClick={onBackdropClick}
106
+ onTouchStart={onTouchStart}
107
+ onTouchEnd={onTouchEnd}
108
+ >
109
+ {/* Close button */}
110
+ <button
111
+ type="button"
112
+ className="lightbox-close absolute top-4 right-4 z-10 text-white/80 hover:text-white p-2"
113
+ aria-label="Close lightbox"
114
+ onClick={onClose}
115
+ >
116
+ <svg
117
+ xmlns="http://www.w3.org/2000/svg"
118
+ width="28"
119
+ height="28"
120
+ viewBox="0 0 24 24"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ strokeWidth={2}
124
+ strokeLinecap="round"
125
+ strokeLinejoin="round"
126
+ >
127
+ <line x1="18" y1="6" x2="6" y2="18" />
128
+ <line x1="6" y1="6" x2="18" y2="18" />
129
+ </svg>
130
+ </button>
131
+
132
+ {/* Image counter */}
133
+ {showNav ? (
134
+ <div className="lightbox-counter absolute top-4 left-4 text-white/80 text-sm select-none">
135
+ {currentIndex + 1} / {imageCount}
136
+ </div>
137
+ ) : null}
138
+
139
+ {/* Previous button */}
140
+ {showNav && hasPrev ? (
141
+ <button
142
+ type="button"
143
+ className="lightbox-prev absolute left-4 top-1/2 -translate-y-1/2 z-10 text-white/80 hover:text-white p-2"
144
+ aria-label="Previous image"
145
+ onClick={() => onNavigate(currentIndex - 1)}
146
+ >
147
+ <svg
148
+ xmlns="http://www.w3.org/2000/svg"
149
+ width="32"
150
+ height="32"
151
+ viewBox="0 0 24 24"
152
+ fill="none"
153
+ stroke="currentColor"
154
+ strokeWidth={2}
155
+ strokeLinecap="round"
156
+ strokeLinejoin="round"
157
+ >
158
+ <polyline points="15 18 9 12 15 6" />
159
+ </svg>
160
+ </button>
161
+ ) : null}
162
+
163
+ {/* Next button */}
164
+ {showNav && hasNext ? (
165
+ <button
166
+ type="button"
167
+ className="lightbox-next absolute right-4 top-1/2 -translate-y-1/2 z-10 text-white/80 hover:text-white p-2"
168
+ aria-label="Next image"
169
+ onClick={() => onNavigate(currentIndex + 1)}
170
+ >
171
+ <svg
172
+ xmlns="http://www.w3.org/2000/svg"
173
+ width="32"
174
+ height="32"
175
+ viewBox="0 0 24 24"
176
+ fill="none"
177
+ stroke="currentColor"
178
+ strokeWidth={2}
179
+ strokeLinecap="round"
180
+ strokeLinejoin="round"
181
+ >
182
+ <polyline points="9 18 15 12 9 6" />
183
+ </svg>
184
+ </button>
185
+ ) : null}
186
+
187
+ {/* Image + caption */}
188
+ <div className="flex flex-col items-center max-h-[95vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
189
+ <img
190
+ src={getImageSrc(currentIndex)}
191
+ alt={getImageAlt(currentIndex)}
192
+ className="lightbox-image max-h-[85vh] max-w-[90vw] object-contain"
193
+ />
194
+ {description ? (
195
+ <p className="lightbox-caption text-white/80 text-sm mt-3 text-center max-w-[80vw]">{description}</p>
196
+ ) : null}
197
+ </div>
198
+ </div>,
199
+ document.body,
200
+ )
201
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Gallery helpers — mirrored from
3
+ * `@blogmcp/mdx-render-vue/src/composables/useGallery.ts`.
4
+ */
5
+ import type { CSSProperties } from 'react'
6
+ import { galleryTileLayout as sharedGalleryTileLayout } from '@blogmcp/mdx-schema/styles'
7
+ import type { GalleryImage, GalleryProps, MediaObject } from '@blogmcp/mdx-schema'
8
+
9
+ const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|avi|mkv|m4v)(\?.*)?$/i
10
+ const KB_MEDIA_FILE_RE = /\/api\/media\/file\/([^/?#]+)/
11
+
12
+ export const gridColsClasses: Record<number, string> = {
13
+ 1: 'grid-cols-1',
14
+ 2: 'grid-cols-2',
15
+ 3: 'grid-cols-3',
16
+ 4: 'grid-cols-4',
17
+ }
18
+
19
+ export function isMediaObject(value: unknown): value is MediaObject {
20
+ return (
21
+ typeof value === 'object' &&
22
+ value !== null &&
23
+ 'src' in value &&
24
+ typeof (value as MediaObject).src === 'string'
25
+ )
26
+ }
27
+
28
+ export function isVideoUrl(src: string): boolean {
29
+ return VIDEO_EXTENSIONS.test(src)
30
+ }
31
+
32
+ export function gridColsClass(columns?: number): string {
33
+ return gridColsClasses[columns || 2] || 'grid-cols-2'
34
+ }
35
+
36
+ export function gapClass(gap?: number): string {
37
+ return `gap-${gap || 4}`
38
+ }
39
+
40
+ /**
41
+ * React-typed adapter over the shared, framework-agnostic `galleryTileLayout`
42
+ * in `@blogmcp/mdx-schema/styles` (the single source of truth for tile
43
+ * geometry — keeps the Vue + React flavors byte-identical). The shared helper
44
+ * returns `Record<string,string>`; the values are valid CSS, so casting to
45
+ * React's `CSSProperties` for the `style` prop is safe.
46
+ */
47
+ export interface GalleryTileLayout {
48
+ tileStyle: CSSProperties
49
+ mediaStyle: CSSProperties
50
+ mediaSizeClass: string
51
+ }
52
+
53
+ export function galleryTileLayout(
54
+ props: Pick<GalleryProps, 'aspectRatio' | 'fit' | 'position'>,
55
+ ): GalleryTileLayout {
56
+ const layout = sharedGalleryTileLayout(props)
57
+ return {
58
+ tileStyle: layout.tileStyle as CSSProperties,
59
+ mediaStyle: layout.mediaStyle as CSSProperties,
60
+ mediaSizeClass: layout.mediaSizeClass,
61
+ }
62
+ }
63
+
64
+ export function getImageSrc(image: GalleryImage): string {
65
+ return isMediaObject(image) ? image.src : image
66
+ }
67
+
68
+ export function getImageAlt(image: GalleryImage, index: number): string {
69
+ if (isMediaObject(image)) return image.alt || `Gallery image ${index + 1}`
70
+ return `Gallery image ${index + 1}`
71
+ }
72
+
73
+ export function getImageDescription(image: GalleryImage): string | undefined {
74
+ return isMediaObject(image) ? image.description : undefined
75
+ }
76
+
77
+ export function isVideo(image: GalleryImage): boolean {
78
+ if (isMediaObject(image)) {
79
+ if (image.type === 'video') return true
80
+ if (image.type === 'image') return false
81
+ return isVideoUrl(image.src)
82
+ }
83
+ return isVideoUrl(image)
84
+ }
85
+
86
+ export function getVideoPoster(image: GalleryImage): string | undefined {
87
+ const src = getImageSrc(image)
88
+ const match = src.match(KB_MEDIA_FILE_RE)
89
+ return match ? `/api/media/poster/${match[1]}` : undefined
90
+ }
@@ -0,0 +1,25 @@
1
+ import { Icon as IconifyIcon } from '@iconify/react'
2
+
3
+ export interface IconProps {
4
+ name: string
5
+ size?: number
6
+ color?: string
7
+ }
8
+
9
+ /**
10
+ * Inline Iconify icon (any set). Mirrors Vue Icon.vue — the
11
+ * data-mdx-component attribute lands on the rendered <svg> via Iconify's
12
+ * attribute pass-through.
13
+ */
14
+ export default function Icon({ name, size = 20, color }: IconProps) {
15
+ return (
16
+ <IconifyIcon
17
+ icon={name}
18
+ width={size}
19
+ height={size}
20
+ style={color ? { color } : undefined}
21
+ className="inline-block align-[-0.125em]"
22
+ data-mdx-component="Icon"
23
+ />
24
+ )
25
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * React MDX render components.
3
+ *
4
+ * Named exports for every render component. The two client islands (Tooltip,
5
+ * AudioPlayerDefault) carry their own `'use client'` banner.
6
+ */
7
+ export { default as CalloutDefault } from './callout/CalloutDefault'
8
+ export { default as CalloutMinimal } from './callout/CalloutMinimal'
9
+ export { default as YouTubeDefault } from './youtube/YouTubeDefault'
10
+ export { default as GalleryGrid } from './gallery/GalleryGrid'
11
+ export { default as CodeBlockDefault } from './codeblock/CodeBlockDefault'
12
+ export { default as AudioPlayerDefault } from './audioplayer/AudioPlayerDefault'
13
+ export { default as Button } from './button/Button'
14
+ export { default as Card } from './card/Card'
15
+ export { default as Alert } from './alert/Alert'
16
+ export { default as Badge } from './badge/Badge'
17
+ export { default as Icon } from './icon/Icon'
18
+ export { default as Tooltip } from './tooltip/Tooltip'
19
+
20
+ export type { ButtonProps } from './button/Button'
21
+ export type { BadgeProps } from './badge/Badge'
22
+ export type { AlertProps } from './alert/Alert'
23
+ export type { CardProps } from './card/Card'
24
+ export type { IconProps } from './icon/Icon'
25
+ export type { TooltipProps } from './tooltip/Tooltip'
26
+ export type { CalloutProps } from './callout/CalloutDefault'
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import { Tooltip as ArkTooltip, Portal } from '@ark-ui/react'
5
+
6
+ export interface TooltipProps {
7
+ label: string
8
+ placement?: 'top' | 'bottom' | 'left' | 'right'
9
+ children?: ReactNode
10
+ }
11
+
12
+ /**
13
+ * Hover-reveal tooltip backed by Ark UI (React). Client island — mirrors Vue
14
+ * Tooltip.vue: same Ark namespaced primitive, same delays/positioning, same
15
+ * content classes, portalled content. The trigger wraps an inline span that
16
+ * carries `data-mdx-component="Tooltip"`.
17
+ */
18
+ export default function Tooltip({ label, placement = 'top', children }: TooltipProps) {
19
+ return (
20
+ <ArkTooltip.Root positioning={{ placement }} openDelay={200} closeDelay={100}>
21
+ <ArkTooltip.Trigger asChild>
22
+ <span className="inline-flex" data-mdx-component="Tooltip">
23
+ {children}
24
+ </span>
25
+ </ArkTooltip.Trigger>
26
+ <Portal>
27
+ <ArkTooltip.Positioner>
28
+ <ArkTooltip.Content className="z-50 rounded-md bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out">
29
+ <ArkTooltip.Arrow>
30
+ <ArkTooltip.ArrowTip className="border-gray-900" />
31
+ </ArkTooltip.Arrow>
32
+ {label}
33
+ </ArkTooltip.Content>
34
+ </ArkTooltip.Positioner>
35
+ </Portal>
36
+ </ArkTooltip.Root>
37
+ )
38
+ }
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { YouTubeProps } from '@blogmcp/mdx-schema'
5
+ import { embedUrl, thumbnailUrl, YOUTUBE_IFRAME_ALLOW } from './youtube'
6
+
7
+ /**
8
+ * YouTube facade. Client island — mirrors Vue YouTubeDefault.vue + useYouTube.ts.
9
+ *
10
+ * Facade pattern: renders a thumbnail + play-button overlay initially (the
11
+ * server/initial DOM), and swaps to the real autoplay iframe on click — saving
12
+ * ~500KB per embed until the user actually plays. The `youtube-embed` wrapper,
13
+ * `data-mdx-component="YouTube"`, and the 56.25% aspect-ratio box are identical
14
+ * to the static/Vue versions; the interactive bit lives inside the box.
15
+ */
16
+ export default function YouTubeDefault({ id, title }: YouTubeProps) {
17
+ const iframeTitle = title || 'YouTube video'
18
+ const thumb = thumbnailUrl(id)
19
+ const [activated, setActivated] = useState(false)
20
+
21
+ return (
22
+ <div className="youtube-embed my-6" data-mdx-component="YouTube" data-allow={YOUTUBE_IFRAME_ALLOW}>
23
+ <div className="relative w-full rounded-lg overflow-hidden shadow-md" style={{ paddingBottom: '56.25%' }}>
24
+ {!activated ? (
25
+ <button
26
+ type="button"
27
+ className="absolute inset-0 w-full h-full cursor-pointer border-0 p-0 bg-black group"
28
+ aria-label={`Play ${iframeTitle}`}
29
+ onClick={() => setActivated(true)}
30
+ >
31
+ <img src={thumb} alt={iframeTitle} className="absolute inset-0 w-full h-full object-cover" />
32
+ <svg
33
+ className="absolute inset-0 m-auto w-16 h-16 text-white drop-shadow-lg opacity-90 group-hover:opacity-100 transition-opacity"
34
+ viewBox="0 0 68 48"
35
+ aria-hidden="true"
36
+ >
37
+ <path
38
+ d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"
39
+ fill="red"
40
+ />
41
+ <path d="M45 24L27 14v20" fill="white" />
42
+ </svg>
43
+ </button>
44
+ ) : (
45
+ <iframe
46
+ src={`${embedUrl(id)}?autoplay=1`}
47
+ title={iframeTitle}
48
+ className="absolute inset-0 w-full h-full"
49
+ frameBorder="0"
50
+ allow={YOUTUBE_IFRAME_ALLOW}
51
+ allowFullScreen
52
+ />
53
+ )}
54
+ </div>
55
+ {title ? <p className="text-center text-sm text-gray-600 mt-3">{title}</p> : null}
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * YouTube URL helpers — mirrored from
3
+ * `@blogmcp/mdx-render-vue/src/composables/useYouTube.ts`.
4
+ */
5
+ export const YOUTUBE_EMBED_BASE = 'https://www.youtube.com/embed/'
6
+
7
+ export const YOUTUBE_IFRAME_ALLOW =
8
+ 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
9
+
10
+ export function embedUrl(id: string): string {
11
+ return `${YOUTUBE_EMBED_BASE}${id}`
12
+ }
13
+
14
+ export function thumbnailUrl(id: string): string {
15
+ if (!id) return ''
16
+ return `https://img.youtube.com/vi/${id}/maxresdefault.jpg`
17
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Static render map used by MdxContent.
3
+ *
4
+ * Direct (non-lazy) imports of every render component, keyed by the schema's
5
+ * PascalCase component name. The two client islands (Tooltip, AudioPlayer)
6
+ * are referenced here too — a Next.js consumer ships them as islands because
7
+ * each carries its own `'use client'` banner.
8
+ *
9
+ * `default` variants only; the registry (registry.ts) carries the full
10
+ * variant map (e.g. Callout `minimal`) for future tooling.
11
+ *
12
+ * Two maps are exported:
13
+ *
14
+ * - `serverRenderMap` — raw components. Kept as an escape hatch for
15
+ * tooling that needs the bare component reference (snapshot fixtures,
16
+ * manual instantiation in tests). PRODUCTION CODE SHOULD NOT USE IT.
17
+ *
18
+ * - `wrappedServerRenderMap` — `prepareProps`-aware components. Each
19
+ * entry is the raw component wrapped in a tiny HOC that runs every
20
+ * incoming prop through `prepareProps(props, metadata)` before
21
+ * forwarding to the real component. This makes the schema's render-
22
+ * time shim impossible to forget at call sites: any consumer doing
23
+ * `<Comp {...item.props}>` gets normalized props automatically,
24
+ * including for recursively-rendered nested MDX children.
25
+ */
26
+ import { createElement, type ComponentType, type ReactNode } from 'react'
27
+ import {
28
+ componentMetadata,
29
+ prepareProps,
30
+ type ComponentMetadata,
31
+ } from '@blogmcp/mdx-schema'
32
+
33
+ import CalloutDefault from './render/callout/CalloutDefault'
34
+ import YouTubeDefault from './render/youtube/YouTubeDefault'
35
+ import GalleryGrid from './render/gallery/GalleryGrid'
36
+ import CodeBlockDefault from './render/codeblock/CodeBlockDefault'
37
+ import AudioPlayerDefault from './render/audioplayer/AudioPlayerDefault'
38
+ import Button from './render/button/Button'
39
+ import Card from './render/card/Card'
40
+ import Alert from './render/alert/Alert'
41
+ import Badge from './render/badge/Badge'
42
+ import Icon from './render/icon/Icon'
43
+ import Tooltip from './render/tooltip/Tooltip'
44
+
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ export const serverRenderMap: Record<string, ComponentType<any>> = {
47
+ Callout: CalloutDefault,
48
+ YouTube: YouTubeDefault,
49
+ Gallery: GalleryGrid,
50
+ CodeBlock: CodeBlockDefault,
51
+ AudioPlayer: AudioPlayerDefault,
52
+ Button,
53
+ Card,
54
+ Alert,
55
+ Badge,
56
+ Icon,
57
+ Tooltip,
58
+ }
59
+
60
+ /**
61
+ * Sentinel property name read by the per-renderer "prepareProps is
62
+ * wired" integration test. Wrapped components carry this with the
63
+ * component's schema name so the test can confirm wrapping end-to-end
64
+ * (vs the historical regex-grep on registry source, which was easily
65
+ * defeated by commented-out code and unused imports).
66
+ */
67
+ export const MDX_PREPARED_SENTINEL = '__mdxPreparePropsAppliedFor' as const
68
+
69
+ /**
70
+ * HOC that wraps a render component so every invocation runs incoming
71
+ * props through `prepareProps(props, metadata)` before forwarding to
72
+ * the inner component. Re-rendered components from nested MDX go
73
+ * through the same wrapped reference, so recursion does not bypass
74
+ * the shim.
75
+ *
76
+ * Children are forwarded untouched — `prepareProps` operates on the
77
+ * named props only; the `children` slot is a structural concern.
78
+ *
79
+ * The wrapper sets a `displayName` for React DevTools and stamps the
80
+ * `MDX_PREPARED_SENTINEL` property so the CI integration test can
81
+ * confirm the wrapper was applied.
82
+ */
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ export function wrapReactComponentForMdx<P extends Record<string, any>>(
85
+ Inner: ComponentType<P>,
86
+ metadata: ComponentMetadata,
87
+ ): ComponentType<P & { children?: ReactNode }> {
88
+ const Wrapped = function MdxPrepared(props: P & { children?: ReactNode }) {
89
+ const { children, ...rest } = props as P & { children?: ReactNode }
90
+ const normalized = prepareProps(rest as Record<string, unknown>, metadata) as P
91
+ return createElement(Inner, normalized, children)
92
+ }
93
+ ;(Wrapped as unknown as Record<string, unknown>)[MDX_PREPARED_SENTINEL] = metadata.name
94
+ Wrapped.displayName = `MdxPrepared(${metadata.name})`
95
+ return Wrapped as ComponentType<P & { children?: ReactNode }>
96
+ }
97
+
98
+ const metadataByName: Map<string, ComponentMetadata> = new Map(
99
+ componentMetadata.map(m => [m.name, m]),
100
+ )
101
+
102
+ function getMetadataOrThrow(name: string): ComponentMetadata {
103
+ const m = metadataByName.get(name)
104
+ if (!m) {
105
+ throw new Error(
106
+ `[mdx-render-react] No componentMetadata for "${name}" — the React render map ` +
107
+ `has a component the schema does not know about. Add an entry in ` +
108
+ `packages/mdx-schema/src/metadata/index.ts or remove the entry from serverRenderMap.`,
109
+ )
110
+ }
111
+ return m
112
+ }
113
+
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ export const wrappedServerRenderMap: Record<string, ComponentType<any>> = Object.fromEntries(
116
+ Object.entries(serverRenderMap).map(([name, Inner]) => [
117
+ name,
118
+ wrapReactComponentForMdx(Inner, getMetadataOrThrow(name)),
119
+ ]),
120
+ )