@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.
- package/README.md +32 -0
- package/dist/MdxContent.d.ts +29 -0
- package/dist/MdxContent.js +51 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +43 -0
- package/dist/marked.d.ts +34 -0
- package/dist/marked.js +15 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.js +88 -0
- package/dist/render/alert/Alert.d.ts +13 -0
- package/dist/render/alert/Alert.js +24 -0
- package/dist/render/audioplayer/AudioPlayerDefault.d.ts +6 -0
- package/dist/render/audioplayer/AudioPlayerDefault.js +75 -0
- package/dist/render/badge/Badge.d.ts +11 -0
- package/dist/render/badge/Badge.js +12 -0
- package/dist/render/button/Button.d.ts +14 -0
- package/dist/render/button/Button.js +20 -0
- package/dist/render/callout/CalloutDefault.d.ts +11 -0
- package/dist/render/callout/CalloutDefault.js +19 -0
- package/dist/render/callout/CalloutMinimal.d.ts +10 -0
- package/dist/render/callout/CalloutMinimal.js +16 -0
- package/dist/render/card/Card.d.ts +9 -0
- package/dist/render/card/Card.js +17 -0
- package/dist/render/codeblock/CodeBlockDefault.d.ts +14 -0
- package/dist/render/codeblock/CodeBlockDefault.js +56 -0
- package/dist/render/codeblock/language-labels.d.ts +8 -0
- package/dist/render/codeblock/language-labels.js +42 -0
- package/dist/render/gallery/GalleryGrid.d.ts +11 -0
- package/dist/render/gallery/GalleryGrid.js +106 -0
- package/dist/render/gallery/GalleryLightbox.d.ts +18 -0
- package/dist/render/gallery/GalleryLightbox.js +154 -0
- package/dist/render/gallery/gallery.d.ts +29 -0
- package/dist/render/gallery/gallery.js +56 -0
- package/dist/render/icon/Icon.d.ts +11 -0
- package/dist/render/icon/Icon.js +18 -0
- package/dist/render/index.d.ts +25 -0
- package/dist/render/index.js +26 -0
- package/dist/render/tooltip/Tooltip.d.ts +13 -0
- package/dist/render/tooltip/Tooltip.js +15 -0
- package/dist/render/youtube/YouTubeDefault.d.ts +11 -0
- package/dist/render/youtube/YouTubeDefault.js +53 -0
- package/dist/render/youtube/youtube.d.ts +8 -0
- package/dist/render/youtube/youtube.js +13 -0
- package/dist/server-render-map.d.ts +54 -0
- package/dist/server-render-map.js +56 -0
- package/package.json +77 -0
- package/src/MdxContent.tsx +105 -0
- package/src/index.ts +37 -0
- package/src/marked.ts +49 -0
- package/src/registry.ts +131 -0
- package/src/render/alert/Alert.tsx +32 -0
- package/src/render/audioplayer/AudioPlayerDefault.tsx +106 -0
- package/src/render/badge/Badge.tsx +24 -0
- package/src/render/button/Button.tsx +49 -0
- package/src/render/callout/CalloutDefault.tsx +34 -0
- package/src/render/callout/CalloutMinimal.tsx +25 -0
- package/src/render/card/Card.tsx +21 -0
- package/src/render/codeblock/CodeBlockDefault.tsx +82 -0
- package/src/render/codeblock/language-labels.ts +46 -0
- package/src/render/gallery/GalleryGrid.tsx +143 -0
- package/src/render/gallery/GalleryLightbox.tsx +201 -0
- package/src/render/gallery/gallery.ts +90 -0
- package/src/render/icon/Icon.tsx +25 -0
- package/src/render/index.ts +26 -0
- package/src/render/tooltip/Tooltip.tsx +38 -0
- package/src/render/youtube/YouTubeDefault.tsx +58 -0
- package/src/render/youtube/youtube.ts +17 -0
- 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
|
+
)
|