@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,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as a, jsx as e } from "react/jsx-runtime";
|
|
3
|
+
import { useState as c } from "react";
|
|
4
|
+
import { YOUTUBE_IFRAME_ALLOW as r, thumbnailUrl as d, embedUrl as n } from "./youtube.js";
|
|
5
|
+
function b({ id: o, title: t }) {
|
|
6
|
+
const l = t || "YouTube video", s = d(o), [i, u] = c(!1);
|
|
7
|
+
return /* @__PURE__ */ a("div", { className: "youtube-embed my-6", "data-mdx-component": "YouTube", "data-allow": r, children: [
|
|
8
|
+
/* @__PURE__ */ e("div", { className: "relative w-full rounded-lg overflow-hidden shadow-md", style: { paddingBottom: "56.25%" }, children: i ? /* @__PURE__ */ e(
|
|
9
|
+
"iframe",
|
|
10
|
+
{
|
|
11
|
+
src: `${n(o)}?autoplay=1`,
|
|
12
|
+
title: l,
|
|
13
|
+
className: "absolute inset-0 w-full h-full",
|
|
14
|
+
frameBorder: "0",
|
|
15
|
+
allow: r,
|
|
16
|
+
allowFullScreen: !0
|
|
17
|
+
}
|
|
18
|
+
) : /* @__PURE__ */ a(
|
|
19
|
+
"button",
|
|
20
|
+
{
|
|
21
|
+
type: "button",
|
|
22
|
+
className: "absolute inset-0 w-full h-full cursor-pointer border-0 p-0 bg-black group",
|
|
23
|
+
"aria-label": `Play ${l}`,
|
|
24
|
+
onClick: () => u(!0),
|
|
25
|
+
children: [
|
|
26
|
+
/* @__PURE__ */ e("img", { src: s, alt: l, className: "absolute inset-0 w-full h-full object-cover" }),
|
|
27
|
+
/* @__PURE__ */ a(
|
|
28
|
+
"svg",
|
|
29
|
+
{
|
|
30
|
+
className: "absolute inset-0 m-auto w-16 h-16 text-white drop-shadow-lg opacity-90 group-hover:opacity-100 transition-opacity",
|
|
31
|
+
viewBox: "0 0 68 48",
|
|
32
|
+
"aria-hidden": "true",
|
|
33
|
+
children: [
|
|
34
|
+
/* @__PURE__ */ e(
|
|
35
|
+
"path",
|
|
36
|
+
{
|
|
37
|
+
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",
|
|
38
|
+
fill: "red"
|
|
39
|
+
}
|
|
40
|
+
),
|
|
41
|
+
/* @__PURE__ */ e("path", { d: "M45 24L27 14v20", fill: "white" })
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
) }),
|
|
48
|
+
t ? /* @__PURE__ */ e("p", { className: "text-center text-sm text-gray-600 mt-3", children: t }) : null
|
|
49
|
+
] });
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
b as default
|
|
53
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube URL helpers — mirrored from
|
|
3
|
+
* `@blogmcp/mdx-render-vue/src/composables/useYouTube.ts`.
|
|
4
|
+
*/
|
|
5
|
+
export declare const YOUTUBE_EMBED_BASE = "https://www.youtube.com/embed/";
|
|
6
|
+
export declare const YOUTUBE_IFRAME_ALLOW = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
|
|
7
|
+
export declare function embedUrl(id: string): string;
|
|
8
|
+
export declare function thumbnailUrl(id: string): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const t = "https://www.youtube.com/embed/", r = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
|
|
2
|
+
function o(e) {
|
|
3
|
+
return `${t}${e}`;
|
|
4
|
+
}
|
|
5
|
+
function u(e) {
|
|
6
|
+
return e ? `https://img.youtube.com/vi/${e}/maxresdefault.jpg` : "";
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
t as YOUTUBE_EMBED_BASE,
|
|
10
|
+
r as YOUTUBE_IFRAME_ALLOW,
|
|
11
|
+
o as embedUrl,
|
|
12
|
+
u as thumbnailUrl
|
|
13
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { type ComponentType, type ReactNode } from 'react';
|
|
27
|
+
import { type ComponentMetadata } from '@blogmcp/mdx-schema';
|
|
28
|
+
export declare const serverRenderMap: Record<string, ComponentType<any>>;
|
|
29
|
+
/**
|
|
30
|
+
* Sentinel property name read by the per-renderer "prepareProps is
|
|
31
|
+
* wired" integration test. Wrapped components carry this with the
|
|
32
|
+
* component's schema name so the test can confirm wrapping end-to-end
|
|
33
|
+
* (vs the historical regex-grep on registry source, which was easily
|
|
34
|
+
* defeated by commented-out code and unused imports).
|
|
35
|
+
*/
|
|
36
|
+
export declare const MDX_PREPARED_SENTINEL: "__mdxPreparePropsAppliedFor";
|
|
37
|
+
/**
|
|
38
|
+
* HOC that wraps a render component so every invocation runs incoming
|
|
39
|
+
* props through `prepareProps(props, metadata)` before forwarding to
|
|
40
|
+
* the inner component. Re-rendered components from nested MDX go
|
|
41
|
+
* through the same wrapped reference, so recursion does not bypass
|
|
42
|
+
* the shim.
|
|
43
|
+
*
|
|
44
|
+
* Children are forwarded untouched — `prepareProps` operates on the
|
|
45
|
+
* named props only; the `children` slot is a structural concern.
|
|
46
|
+
*
|
|
47
|
+
* The wrapper sets a `displayName` for React DevTools and stamps the
|
|
48
|
+
* `MDX_PREPARED_SENTINEL` property so the CI integration test can
|
|
49
|
+
* confirm the wrapper was applied.
|
|
50
|
+
*/
|
|
51
|
+
export declare function wrapReactComponentForMdx<P extends Record<string, any>>(Inner: ComponentType<P>, metadata: ComponentMetadata): ComponentType<P & {
|
|
52
|
+
children?: ReactNode;
|
|
53
|
+
}>;
|
|
54
|
+
export declare const wrappedServerRenderMap: Record<string, ComponentType<any>>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createElement as p } from "react";
|
|
2
|
+
import { componentMetadata as d, prepareProps as i } from "@blogmcp/mdx-schema";
|
|
3
|
+
import c from "./render/callout/CalloutDefault.js";
|
|
4
|
+
import f from "./render/youtube/YouTubeDefault.js";
|
|
5
|
+
import l from "./render/gallery/GalleryGrid.js";
|
|
6
|
+
import s from "./render/codeblock/CodeBlockDefault.js";
|
|
7
|
+
import u from "./render/audioplayer/AudioPlayerDefault.js";
|
|
8
|
+
import M from "./render/button/Button.js";
|
|
9
|
+
import h from "./render/card/Card.js";
|
|
10
|
+
import P from "./render/alert/Alert.js";
|
|
11
|
+
import x from "./render/badge/Badge.js";
|
|
12
|
+
import y from "./render/icon/Icon.js";
|
|
13
|
+
import w from "./render/tooltip/Tooltip.js";
|
|
14
|
+
const E = {
|
|
15
|
+
Callout: c,
|
|
16
|
+
YouTube: f,
|
|
17
|
+
Gallery: l,
|
|
18
|
+
CodeBlock: s,
|
|
19
|
+
AudioPlayer: u,
|
|
20
|
+
Button: M,
|
|
21
|
+
Card: h,
|
|
22
|
+
Alert: P,
|
|
23
|
+
Badge: x,
|
|
24
|
+
Icon: y,
|
|
25
|
+
Tooltip: w
|
|
26
|
+
}, R = "__mdxPreparePropsAppliedFor";
|
|
27
|
+
function A(r, e) {
|
|
28
|
+
const o = function(t) {
|
|
29
|
+
const { children: a, ...n } = t, m = i(n, e);
|
|
30
|
+
return p(r, m, a);
|
|
31
|
+
};
|
|
32
|
+
return o[R] = e.name, o.displayName = `MdxPrepared(${e.name})`, o;
|
|
33
|
+
}
|
|
34
|
+
const C = new Map(
|
|
35
|
+
d.map((r) => [r.name, r])
|
|
36
|
+
);
|
|
37
|
+
function D(r) {
|
|
38
|
+
const e = C.get(r);
|
|
39
|
+
if (!e)
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[mdx-render-react] No componentMetadata for "${r}" — the React render map has a component the schema does not know about. Add an entry in packages/mdx-schema/src/metadata/index.ts or remove the entry from serverRenderMap.`
|
|
42
|
+
);
|
|
43
|
+
return e;
|
|
44
|
+
}
|
|
45
|
+
const Y = Object.fromEntries(
|
|
46
|
+
Object.entries(E).map(([r, e]) => [
|
|
47
|
+
r,
|
|
48
|
+
A(e, D(r))
|
|
49
|
+
])
|
|
50
|
+
);
|
|
51
|
+
export {
|
|
52
|
+
R as MDX_PREPARED_SENTINEL,
|
|
53
|
+
E as serverRenderMap,
|
|
54
|
+
A as wrapReactComponentForMdx,
|
|
55
|
+
Y as wrappedServerRenderMap
|
|
56
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blogmcp/mdx-render-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React renderer for MDX components — implements the @blogmcp/mdx-schema contract alongside @blogmcp/mdx-render-vue. Server-Component-first with two client islands (Tooltip, AudioPlayer).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./render": {
|
|
18
|
+
"types": "./dist/render/index.d.ts",
|
|
19
|
+
"import": "./dist/render/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./registry": {
|
|
22
|
+
"types": "./dist/registry.d.ts",
|
|
23
|
+
"import": "./dist/registry.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "vite build && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
32
|
+
"dev": "vite build --watch",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@ark-ui/react": "^5.0.0",
|
|
39
|
+
"@blogmcp/mdx-schema": "^0.1.0",
|
|
40
|
+
"@blogmcp/sanitize": "^0.1.0",
|
|
41
|
+
"@blogmcp/sdk": "^0.1.0",
|
|
42
|
+
"@iconify/react": "^6.0.0",
|
|
43
|
+
"marked": "^15.0.7"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": ">=18",
|
|
47
|
+
"react-dom": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"react": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"react-dom": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@testing-library/react": "^16.1.0",
|
|
59
|
+
"@types/node": "^20.10.0",
|
|
60
|
+
"@types/react": "^18.3.0",
|
|
61
|
+
"@types/react-dom": "^18.3.0",
|
|
62
|
+
"happy-dom": "^17.4.0",
|
|
63
|
+
"react": "^18.3.0",
|
|
64
|
+
"react-dom": "^18.3.0",
|
|
65
|
+
"rollup-plugin-preserve-directives": "^0.4.0",
|
|
66
|
+
"typescript": "^5.3.3",
|
|
67
|
+
"vite": "^7.3.0",
|
|
68
|
+
"vitest": "^3.0.0"
|
|
69
|
+
},
|
|
70
|
+
"keywords": [
|
|
71
|
+
"mdx",
|
|
72
|
+
"react",
|
|
73
|
+
"components",
|
|
74
|
+
"markdown"
|
|
75
|
+
],
|
|
76
|
+
"license": "MIT"
|
|
77
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from 'react'
|
|
2
|
+
import { stripFrontmatter, extractMdxComponents, HAS_MDX_RE } from '@blogmcp/mdx-schema/composables/parsing'
|
|
3
|
+
import type { ImageHtmlRenderer, SanitizeFn } from '@blogmcp/sdk'
|
|
4
|
+
import { createMarked } from './marked'
|
|
5
|
+
// `wrappedServerRenderMap` runs every component's incoming props through
|
|
6
|
+
// `prepareProps(props, metadata)` before forwarding to the inner component.
|
|
7
|
+
// Recursive `MdxContent` invocations below see the same wrapped map, so
|
|
8
|
+
// nested-MDX children also get normalized props — no per-call-site wiring
|
|
9
|
+
// to forget. See server-render-map.ts for the HOC.
|
|
10
|
+
import { wrappedServerRenderMap } from './server-render-map'
|
|
11
|
+
|
|
12
|
+
export interface MdxContentProps {
|
|
13
|
+
/** Raw MDX/markdown source (frontmatter allowed; it is stripped). */
|
|
14
|
+
content: string
|
|
15
|
+
/** Optional custom image HTML renderer (IPX, Cloudinary, …). */
|
|
16
|
+
imageRenderer?: ImageHtmlRenderer
|
|
17
|
+
/**
|
|
18
|
+
* Optional HTML sanitizer override. Defaults to `sanitizeHtml` from
|
|
19
|
+
* `@blogmcp/sanitize` (DOMPurify with our strict config). Pass `null`
|
|
20
|
+
* to opt out for trusted-self surfaces.
|
|
21
|
+
*/
|
|
22
|
+
sanitize?: SanitizeFn | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders MDX content as a tree of React Server Components.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors the Vue blog renderer (KbMdxContent.vue):
|
|
29
|
+
* - strips frontmatter
|
|
30
|
+
* - fast-paths pure markdown via `marked` → dangerouslySetInnerHTML
|
|
31
|
+
* - otherwise splits via `extractMdxComponents`, rendering html chunks via
|
|
32
|
+
* `marked` and components via `serverRenderMap[name]`
|
|
33
|
+
* - recurses into component children by rendering a nested <MdxContent>
|
|
34
|
+
* (React equivalent of Vue's raw-string default slot)
|
|
35
|
+
* - unknown component → the same yellow fallback box the Vue renderer shows
|
|
36
|
+
*
|
|
37
|
+
* Async Server Component: safe to `await` in RSC; works synchronously in tests.
|
|
38
|
+
*/
|
|
39
|
+
export default async function MdxContent({ content, imageRenderer, sanitize }: MdxContentProps): Promise<ReactNode> {
|
|
40
|
+
const marked = createMarked({ imageRenderer, sanitize })
|
|
41
|
+
|
|
42
|
+
if (!content) {
|
|
43
|
+
return <div className="mdx-content" />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const source = stripFrontmatter(content)
|
|
47
|
+
|
|
48
|
+
// Fast path: pure markdown (no MDX components)
|
|
49
|
+
if (!HAS_MDX_RE.test(source)) {
|
|
50
|
+
const html = (await marked.parse(source)) as string
|
|
51
|
+
return <div className="mdx-content" dangerouslySetInnerHTML={{ __html: html }} />
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const items = extractMdxComponents(source)
|
|
55
|
+
|
|
56
|
+
// Fallback: parser found no components despite the HAS_MDX_RE match.
|
|
57
|
+
if (items.length === 1 && items[0].type === 'html') {
|
|
58
|
+
const html = (await marked.parse(items[0].content)) as string
|
|
59
|
+
return <div className="mdx-content" dangerouslySetInnerHTML={{ __html: html }} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nodes: ReactNode[] = []
|
|
63
|
+
for (let i = 0; i < items.length; i++) {
|
|
64
|
+
const item = items[i]
|
|
65
|
+
if (item.type === 'html') {
|
|
66
|
+
const html = (await marked.parse(item.content)) as string
|
|
67
|
+
nodes.push(<div key={i} dangerouslySetInnerHTML={{ __html: html }} />)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const Comp = wrappedServerRenderMap[item.name]
|
|
72
|
+
if (!Comp) {
|
|
73
|
+
nodes.push(
|
|
74
|
+
<div
|
|
75
|
+
key={i}
|
|
76
|
+
className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 my-4 text-yellow-800"
|
|
77
|
+
>
|
|
78
|
+
{`Unknown component: ${item.name}`}
|
|
79
|
+
</div>,
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Recurse into children: render nested MDX (markdown + components) exactly
|
|
85
|
+
// like the Vue renderer passes the raw children string back through itself.
|
|
86
|
+
// Sanitize policy propagates so nested chunks inherit the same trust contract.
|
|
87
|
+
const childNode = item.children
|
|
88
|
+
? await MdxContent({ content: item.children, imageRenderer, sanitize })
|
|
89
|
+
: undefined
|
|
90
|
+
|
|
91
|
+
nodes.push(
|
|
92
|
+
<Comp key={i} {...item.props}>
|
|
93
|
+
{childNode}
|
|
94
|
+
</Comp>,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="mdx-content">
|
|
100
|
+
{nodes.map((node, i) => (
|
|
101
|
+
<Fragment key={i}>{node}</Fragment>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blogmcp/mdx-render-react
|
|
3
|
+
*
|
|
4
|
+
* React renderer for MDX components. Implements the same contract as
|
|
5
|
+
* @blogmcp/mdx-render-vue against the shared @blogmcp/mdx-schema. Authoring
|
|
6
|
+
* (MDX syntax, metadata, MCP tool output) is identical across frameworks —
|
|
7
|
+
* only the renderer swaps.
|
|
8
|
+
*
|
|
9
|
+
* The blog/RSC path uses `MdxContent` (async Server Component) + the static
|
|
10
|
+
* `serverRenderMap`. `reactRegistry` mirrors the Vue registry for future
|
|
11
|
+
* tooling (lazy variant resolution, an eventual React md-editor).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Primary render entry point
|
|
15
|
+
export { default as MdxContent } from './MdxContent'
|
|
16
|
+
export type { MdxContentProps } from './MdxContent'
|
|
17
|
+
|
|
18
|
+
// Static render map (used internally by MdxContent; exported for consumers)
|
|
19
|
+
export { serverRenderMap } from './server-render-map'
|
|
20
|
+
|
|
21
|
+
// Registry (schema metadata + React lazy loaders)
|
|
22
|
+
export {
|
|
23
|
+
reactRegistry,
|
|
24
|
+
componentMetadata,
|
|
25
|
+
getComponentMetadata,
|
|
26
|
+
getComponentNames,
|
|
27
|
+
getComponentVariants,
|
|
28
|
+
getRenderComponentAsync,
|
|
29
|
+
type ReactComponentRegistration,
|
|
30
|
+
} from './registry'
|
|
31
|
+
|
|
32
|
+
// Marked instance + helpers
|
|
33
|
+
export { createMarked, marked, defaultImageHtml } from './marked'
|
|
34
|
+
export type { ImageHtmlRenderer } from './marked'
|
|
35
|
+
|
|
36
|
+
// Render components (named) + their prop types
|
|
37
|
+
export * from './render/index'
|
package/src/marked.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configured `marked` instance for the React renderer.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the SDK's `createMarkedInstance` so the image/link renderers, table
|
|
5
|
+
* wrap, and `th scope` post-processing match the Vue blog (KbMdxContent.vue)
|
|
6
|
+
* exactly. Consumers may pass a custom image renderer (IPX, Cloudinary, etc.)
|
|
7
|
+
* and an optional HTML sanitizer.
|
|
8
|
+
*
|
|
9
|
+
* Sanitization is wired by default via `@blogmcp/sanitize` — the reference
|
|
10
|
+
* renderer ships safe-by-default. Callers can opt out by passing `null`
|
|
11
|
+
* (intentional, grep-able) for trusted-self surfaces or when a downstream
|
|
12
|
+
* renderer applies its own sanitization.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
createMarkedInstance,
|
|
16
|
+
defaultImageHtml,
|
|
17
|
+
type ImageHtmlRenderer,
|
|
18
|
+
type SanitizeFn,
|
|
19
|
+
} from '@blogmcp/sdk'
|
|
20
|
+
import { sanitizeHtml } from '@blogmcp/sanitize'
|
|
21
|
+
import type { Marked } from 'marked'
|
|
22
|
+
|
|
23
|
+
export { defaultImageHtml, sanitizeHtml }
|
|
24
|
+
export type { ImageHtmlRenderer, SanitizeFn }
|
|
25
|
+
|
|
26
|
+
export interface CreateMarkedOptions {
|
|
27
|
+
/** Custom image renderer. Defaults to plain <img> with lazy loading. */
|
|
28
|
+
imageRenderer?: ImageHtmlRenderer
|
|
29
|
+
/**
|
|
30
|
+
* HTML sanitizer applied as the last step of `marked.parse()`. Defaults
|
|
31
|
+
* to `sanitizeHtml` from `@blogmcp/sanitize` (DOMPurify with our strict
|
|
32
|
+
* config). Pass `null` to opt out — only do this for trusted-self surfaces
|
|
33
|
+
* or when a downstream renderer applies its own sanitization. The opt-out
|
|
34
|
+
* is explicit and grep-able.
|
|
35
|
+
*/
|
|
36
|
+
sanitize?: SanitizeFn | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Create a configured Marked instance (sanitize-on by default). */
|
|
40
|
+
export function createMarked(opts: CreateMarkedOptions = {}): Marked {
|
|
41
|
+
const imageRenderer = opts.imageRenderer ?? defaultImageHtml
|
|
42
|
+
// `null` is the explicit opt-out; `undefined` falls back to the default.
|
|
43
|
+
const sanitize =
|
|
44
|
+
opts.sanitize === null ? undefined : (opts.sanitize ?? sanitizeHtml)
|
|
45
|
+
return createMarkedInstance({ imageRenderer, sanitize })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Shared default instance for the common case (no custom options). */
|
|
49
|
+
export const marked: Marked = createMarked()
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React MDX Component Registry.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `@blogmcp/mdx-render-vue/src/registry.ts` against the shared schema.
|
|
5
|
+
* Attaches React-specific lazy render loaders to the framework-agnostic
|
|
6
|
+
* metadata published by `@blogmcp/mdx-schema`. Schema is the single source of
|
|
7
|
+
* truth for component descriptions, prop shapes, and MCP-facing examples.
|
|
8
|
+
*
|
|
9
|
+
* This registry is for future tooling (dynamic resolution, editor previews).
|
|
10
|
+
* The blog/RSC render path uses the static `serverRenderMap` instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
componentMetadata,
|
|
15
|
+
getComponentMetadata,
|
|
16
|
+
getComponentNames,
|
|
17
|
+
type ComponentMetadata,
|
|
18
|
+
} from '@blogmcp/mdx-schema/metadata'
|
|
19
|
+
|
|
20
|
+
// Re-export schema helpers so callers that already have the registry can reach
|
|
21
|
+
// metadata without a second import.
|
|
22
|
+
export { componentMetadata, getComponentMetadata, getComponentNames }
|
|
23
|
+
|
|
24
|
+
export interface ReactComponentRegistration {
|
|
25
|
+
metadata: ComponentMetadata
|
|
26
|
+
/** Render variants: variant name → async loader. Must include 'default'. */
|
|
27
|
+
render: Record<string, () => Promise<{ default: unknown }>>
|
|
28
|
+
/**
|
|
29
|
+
* Optional edit component loader. The blog only needs `render`; an editor
|
|
30
|
+
* (React md-editor port) would populate this later.
|
|
31
|
+
*/
|
|
32
|
+
edit?: () => Promise<{ default: unknown }>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-component React wiring (lazy render loaders), keyed by component name. */
|
|
36
|
+
interface ReactWiring {
|
|
37
|
+
render: Record<string, () => Promise<{ default: unknown }>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const reactWiring: Record<string, ReactWiring> = {
|
|
41
|
+
Callout: {
|
|
42
|
+
render: {
|
|
43
|
+
default: () => import('./render/callout/CalloutDefault'),
|
|
44
|
+
minimal: () => import('./render/callout/CalloutMinimal'),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
YouTube: {
|
|
48
|
+
render: {
|
|
49
|
+
default: () => import('./render/youtube/YouTubeDefault'),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
Gallery: {
|
|
53
|
+
render: {
|
|
54
|
+
default: () => import('./render/gallery/GalleryGrid'),
|
|
55
|
+
grid: () => import('./render/gallery/GalleryGrid'),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
CodeBlock: {
|
|
59
|
+
render: {
|
|
60
|
+
default: () => import('./render/codeblock/CodeBlockDefault'),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
AudioPlayer: {
|
|
64
|
+
render: {
|
|
65
|
+
default: () => import('./render/audioplayer/AudioPlayerDefault'),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
Button: {
|
|
69
|
+
render: {
|
|
70
|
+
default: () => import('./render/button/Button'),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
Card: {
|
|
74
|
+
render: {
|
|
75
|
+
default: () => import('./render/card/Card'),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
Alert: {
|
|
79
|
+
render: {
|
|
80
|
+
default: () => import('./render/alert/Alert'),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
Badge: {
|
|
84
|
+
render: {
|
|
85
|
+
default: () => import('./render/badge/Badge'),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
Icon: {
|
|
89
|
+
render: {
|
|
90
|
+
default: () => import('./render/icon/Icon'),
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
Tooltip: {
|
|
94
|
+
render: {
|
|
95
|
+
default: () => import('./render/tooltip/Tooltip'),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Merged registry: schema metadata + React wiring, keyed by component name. */
|
|
101
|
+
export const reactRegistry: Record<string, ReactComponentRegistration> = Object.fromEntries(
|
|
102
|
+
componentMetadata.map((metadata) => {
|
|
103
|
+
const wiring = reactWiring[metadata.name]
|
|
104
|
+
if (!wiring) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`[mdx-render-react] No React wiring registered for component "${metadata.name}". ` +
|
|
107
|
+
`Add an entry to reactWiring in registry.ts or remove the schema entry.`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return [metadata.name, { metadata, ...wiring }] as const
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
/** Get available render variant names for a component. */
|
|
115
|
+
export function getComponentVariants(name: string): string[] {
|
|
116
|
+
const reg = reactRegistry[name]
|
|
117
|
+
return reg ? Object.keys(reg.render) : []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Async resolver — loads only the requested render component. */
|
|
121
|
+
export async function getRenderComponentAsync(
|
|
122
|
+
name: string,
|
|
123
|
+
variant = 'default',
|
|
124
|
+
): Promise<unknown | undefined> {
|
|
125
|
+
const reg = reactRegistry[name]
|
|
126
|
+
if (!reg) return undefined
|
|
127
|
+
const loader = reg.render[variant] || reg.render.default
|
|
128
|
+
if (!loader) return undefined
|
|
129
|
+
const mod = await loader()
|
|
130
|
+
return mod.default
|
|
131
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Icon } from '@iconify/react'
|
|
3
|
+
import { alertStyles, type AlertSeverity } from '@blogmcp/mdx-schema/styles'
|
|
4
|
+
|
|
5
|
+
export interface AlertProps {
|
|
6
|
+
severity?: AlertSeverity
|
|
7
|
+
title?: string
|
|
8
|
+
children?: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Inline notice — flatter/smaller than Callout. Mirrors Vue Alert.vue:
|
|
13
|
+
* severity → wrap classes + iconify icon + icon color (shared table),
|
|
14
|
+
* optional bold title, children inside a `prose-sm` body.
|
|
15
|
+
*/
|
|
16
|
+
export default function Alert({ severity = 'info', title, children }: AlertProps) {
|
|
17
|
+
const cfg = alertStyles.severity[severity]
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={[alertStyles.base, cfg.wrap].join(' ')}
|
|
22
|
+
role="alert"
|
|
23
|
+
data-mdx-component="Alert"
|
|
24
|
+
>
|
|
25
|
+
<Icon icon={cfg.icon} className={['mt-0.5 shrink-0', cfg.iconClass].join(' ')} width={18} height={18} />
|
|
26
|
+
<div className="min-w-0 flex-1">
|
|
27
|
+
{title ? <div className="mb-0.5 font-semibold">{title}</div> : null}
|
|
28
|
+
<div className="prose-sm">{children}</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|