@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,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()
@@ -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
+ }