diff --git a/README.md b/README.md
index cb1a5cfc..385c3a04 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,7 @@ const CustomComponent = () => {
- [x] List (ordered, unordered)
- [x] Horizontal Rule
- [x] Table
+- [x] React Components (via `useMarkdownWithComponents`)
- [ ] HTML
Ref: [CommonMark](https://commonmark.org/help/)
@@ -119,6 +120,77 @@ Ref: [CommonMark](https://commonmark.org/help/)
> HTML will be treated as plain text. Please refer [issue#290](https://github.com/gmsgowtham/react-native-marked/issues/290) for a potential solution
## Advanced
+
+### Embedding React Components in Markdown
+
+You can embed React components directly in your markdown using JSX-style syntax. This is useful for adding interactive elements like buttons, custom info boxes, or any other React component.
+
+```tsx
+import React, { Fragment } from "react";
+import { Pressable, ScrollView, Text, View } from "react-native";
+import {
+ ReactComponentRegistryProvider,
+ useMarkdownWithComponents,
+ type ReactComponentRegistry,
+} from "react-native-marked";
+
+// Define your components
+const components: ReactComponentRegistry = {
+ Button: ({ props }) => (
+ console.log("Pressed!")}>
+ {String(props.label ?? "Click me")}
+
+ ),
+ InfoBox: ({ props, children }) => (
+
+ {props.title && {String(props.title)}}
+ {children}
+
+ ),
+};
+
+const markdown = `
+# Hello World
+
+Click the button below:
+
+
+
+
+This is an info box with **markdown** content.
+
+`;
+
+function MarkdownContent() {
+ const elements = useMarkdownWithComponents(markdown);
+ return (
+
+ {elements.map((element, index) => (
+ {element}
+ ))}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
+```
+
+#### Component Syntax
+
+- **Self-closing:** ``
+- **With children:** `content`
+- **Props:** Supports string (`"value"`), number (`{42}`), and boolean (`{true}`) props
+
+#### Component Registry
+
+Components must be registered via `ReactComponentRegistryProvider`. Unregistered components are automatically removed from the output.
+
### Using custom components
> Custom components can be used to override elements, i.e. Code Highlighting, Fast Image integration
diff --git a/examples/react-native-marked-sample/App.tsx b/examples/react-native-marked-sample/App.tsx
index 2863bbba..bff57230 100644
--- a/examples/react-native-marked-sample/App.tsx
+++ b/examples/react-native-marked-sample/App.tsx
@@ -1,20 +1,24 @@
-import React, { type ReactNode } from "react";
+import React, { Fragment, type ReactNode } from "react";
import {
SafeAreaView,
+ ScrollView,
StatusBar,
StyleSheet,
Text,
type TextStyle,
useColorScheme,
+ View,
} from "react-native";
-import Markdown, {
+import {
MarkedHooks,
MarkedTokenizer,
Renderer,
type RendererInterface,
type Tokens,
+ useMarkdown,
} from "react-native-marked";
import { MD_STRING } from "./const";
+import ReactComponentsExample from "./ReactComponentsExample";
class CustomTokenizer extends MarkedTokenizer {
codespan(this: MarkedTokenizer, src: string): Tokens.Codespan | undefined {
@@ -26,7 +30,6 @@ class CustomTokenizer extends MarkedTokenizer {
text: match[1].trim(),
};
}
-
return super.codespan(src);
}
}
@@ -47,39 +50,60 @@ const renderer = new CustomRenderer();
class CustomHooks extends MarkedHooks {
emStrongMask(src: string): string {
- // mask part of the content that should not be interpreted as Markdown em/strong delimiters.
return src;
}
}
const hooks = new CustomHooks();
+function StandardMarkdownSection() {
+ const elements = useMarkdown(MD_STRING, {
+ renderer,
+ tokenizer,
+ hooks,
+ });
+
+ return (
+ <>
+ {elements.map((element, index) => (
+ {element as ReactNode}
+ ))}
+ >
+ );
+}
+
export default function App() {
const theme = useColorScheme();
const isLightTheme = theme === "light";
+
return (
<>
-
-
+
+
+
+
+
+
>
);
}
const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ },
container: {
paddingHorizontal: 16,
+ paddingBottom: 32,
+ },
+ divider: {
+ borderTopColor: "#ccc",
+ borderTopWidth: 1,
+ marginVertical: 24,
},
});
diff --git a/examples/react-native-marked-sample/ReactComponentsExample.tsx b/examples/react-native-marked-sample/ReactComponentsExample.tsx
new file mode 100644
index 00000000..67f63aba
--- /dev/null
+++ b/examples/react-native-marked-sample/ReactComponentsExample.tsx
@@ -0,0 +1,90 @@
+import React, { Fragment, type ReactNode } from "react";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import {
+ type ReactComponentRegistry,
+ ReactComponentRegistryProvider,
+ useMarkdownWithComponents,
+} from "react-native-marked";
+import { MARKDOWN_WITH_COMPONENTS } from "./const";
+
+const components: ReactComponentRegistry = {
+ Button: ({ props }) => (
+ console.log(`Button pressed: ${props.label}`)}
+ >
+ {String(props.label ?? "Click me")}
+
+ ),
+ InfoBox: ({ props, children }) => (
+
+ {props.title && (
+ {String(props.title)}
+ )}
+ {children as ReactNode}
+
+ ),
+ Highlight: ({ children }) => (
+
+ {children as ReactNode}
+
+ ),
+};
+
+function MarkdownContent() {
+ const elements = useMarkdownWithComponents(MARKDOWN_WITH_COMPONENTS);
+
+ return (
+ <>
+ {elements.map((element, index) => (
+ {element as ReactNode}
+ ))}
+ >
+ );
+}
+
+export default function ReactComponentsExample() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ backgroundColor: "#007AFF",
+ borderRadius: 8,
+ marginVertical: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ buttonText: {
+ color: "#FFFFFF",
+ fontSize: 16,
+ fontWeight: "600",
+ textAlign: "center",
+ },
+ infoBox: {
+ backgroundColor: "#E3F2FD",
+ borderRadius: 8,
+ marginVertical: 8,
+ padding: 16,
+ },
+ infoTitle: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 4,
+ },
+ infoContent: {
+ fontSize: 14,
+ },
+ highlight: {
+ backgroundColor: "#FFF3E0",
+ borderLeftColor: "#FF9800",
+ borderLeftWidth: 4,
+ marginVertical: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ },
+});
diff --git a/examples/react-native-marked-sample/const.ts b/examples/react-native-marked-sample/const.ts
index 02abc305..1658555e 100644
--- a/examples/react-native-marked-sample/const.ts
+++ b/examples/react-native-marked-sample/const.ts
@@ -138,4 +138,36 @@ With a reference later in the document defining the URL location:
$ latex code $\n\n\` other code\`
`;
-export { MD_STRING };
+const MARKDOWN_WITH_COMPONENTS = `
+# Markdown with React Components
+
+This example shows how to embed **React components** inside markdown.
+
+
+
+You can create info boxes with custom content:
+
+
+Components can contain text and will be rendered inline with the rest of the content.
+
+
+## More Examples
+
+Here's a highlight box:
+
+
+This is highlighted content that stands out from the rest.
+
+
+And another button:
+
+
+
+Regular markdown continues after components.
+
+- List item 1
+- List item 2
+- List item 3
+`;
+
+export { MD_STRING, MARKDOWN_WITH_COMPONENTS };
diff --git a/src/hooks/__tests__/useMarkdownWithComponents.spec.tsx b/src/hooks/__tests__/useMarkdownWithComponents.spec.tsx
new file mode 100644
index 00000000..633d3ad2
--- /dev/null
+++ b/src/hooks/__tests__/useMarkdownWithComponents.spec.tsx
@@ -0,0 +1,159 @@
+import { render, screen } from "@testing-library/react-native";
+import React from "react";
+import { Text, View } from "react-native";
+import {
+ type ReactComponentRegistry,
+ ReactComponentRegistryProvider,
+} from "../../lib/ReactComponentRegistry";
+import useMarkdownWithComponents from "../useMarkdownWithComponents";
+
+const TestRenderer = ({
+ value,
+ components,
+}: {
+ value: string;
+ components?: ReactComponentRegistry;
+}) => {
+ const content = components ? (
+
+
+
+ ) : (
+
+ );
+
+ return content;
+};
+
+const InnerRenderer = ({ value }: { value: string }) => {
+ const elements = useMarkdownWithComponents(value);
+ return <>{elements}>;
+};
+
+describe("useMarkdownWithComponents", () => {
+ describe("without registry", () => {
+ it("renders regular markdown without components", () => {
+ render();
+ expect(screen.queryByText("Hello World")).toBeTruthy();
+ });
+
+ it("removes unregistered components from output", () => {
+ render();
+ expect(screen.queryByText("Before")).toBeTruthy();
+ expect(screen.queryByText("After")).toBeTruthy();
+ });
+ });
+
+ describe("with registry", () => {
+ const components: ReactComponentRegistry = {
+ Button: ({ props }) => (
+
+ {String(props.label ?? "Default")}
+
+ ),
+ InfoBox: ({ props, children }) => (
+
+ {props.title && (
+ {String(props.title)}
+ )}
+ {children}
+
+ ),
+ };
+
+ it("renders a self-closing component", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByTestId("custom-button")).toBeTruthy();
+ expect(screen.queryByText("Click me")).toBeTruthy();
+ });
+
+ it("renders a component with children", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByTestId("info-box")).toBeTruthy();
+ expect(screen.queryByText("Note")).toBeTruthy();
+ expect(screen.queryByText("Important info")).toBeTruthy();
+ });
+
+ it("renders components mixed with markdown", () => {
+ const markdown = `# Title
+
+Some text before.
+
+
+
+Some text after.`;
+
+ render();
+
+ expect(screen.queryByText("Title")).toBeTruthy();
+ expect(screen.queryByText("Some text before.")).toBeTruthy();
+ expect(screen.queryByTestId("custom-button")).toBeTruthy();
+ expect(screen.queryByText("Action")).toBeTruthy();
+ expect(screen.queryByText("Some text after.")).toBeTruthy();
+ });
+
+ it("removes components not in registry", () => {
+ const markdown = `
+
+
+
+Hi`;
+
+ render();
+ expect(screen.queryByTestId("custom-button")).toBeTruthy();
+ expect(screen.queryByTestId("info-box")).toBeTruthy();
+ });
+
+ it("passes props correctly to components", () => {
+ const propsComponents: ReactComponentRegistry = {
+ TestProps: ({ props }) => (
+
+ {String(props.str)}
+ {String(props.num)}
+ {String(props.bool)}
+
+ ),
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("hello")).toBeTruthy();
+ expect(screen.queryByText("42")).toBeTruthy();
+ expect(screen.queryByText("true")).toBeTruthy();
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles empty markdown", () => {
+ const components: ReactComponentRegistry = {};
+ render();
+ });
+
+ it("handles markdown with only components", () => {
+ const components: ReactComponentRegistry = {
+ A: () => Component A,
+ B: () => Component B,
+ };
+
+ render();
+
+ expect(screen.queryByText("Component A")).toBeTruthy();
+ expect(screen.queryByText("Component B")).toBeTruthy();
+ });
+ });
+});
diff --git a/src/hooks/useMarkdownWithComponents.tsx b/src/hooks/useMarkdownWithComponents.tsx
new file mode 100644
index 00000000..ca724859
--- /dev/null
+++ b/src/hooks/useMarkdownWithComponents.tsx
@@ -0,0 +1,89 @@
+import type { ReactNode } from "react";
+import React, { useMemo } from "react";
+import { useReactComponentRegistry } from "../lib/ReactComponentRegistry";
+import type { ReactComponentToken } from "../lib/ReactComponentTokenizer";
+import {
+ isReactComponentToken,
+ ReactComponentTokenizer,
+} from "../lib/ReactComponentTokenizer";
+import Renderer from "../lib/Renderer";
+import type { RendererInterface } from "../lib/types";
+import type { useMarkdownHookOptions } from "./useMarkdown";
+import useMarkdown from "./useMarkdown";
+
+export interface useMarkdownWithComponentsOptions
+ extends Omit {}
+
+interface ComponentData {
+ token: ReactComponentToken;
+ id: string;
+}
+
+export function useMarkdownWithComponents(
+ value: string,
+ options?: useMarkdownWithComponentsOptions,
+): ReactNode[] {
+ const registry = useReactComponentRegistry();
+
+ const { tokenizer, componentMap } = useMemo(() => {
+ const map = new Map();
+
+ const customTokenizer = new ReactComponentTokenizer();
+ const originalHtml = customTokenizer.html.bind(customTokenizer);
+
+ let componentCounter = 0;
+
+ customTokenizer.html = (src: string) => {
+ const token = originalHtml(src);
+ if (token && isReactComponentToken(token)) {
+ const id = `${token.componentName}-${componentCounter++}`;
+ map.set(token.raw, { token, id });
+ }
+ return token;
+ };
+
+ return { tokenizer: customTokenizer, componentMap: map };
+ }, []);
+
+ const baseRenderer = useMemo(
+ () => options?.renderer ?? new Renderer(),
+ [options?.renderer],
+ );
+
+ const renderer = useMemo(() => {
+ const wrappedRenderer = Object.create(baseRenderer) as typeof baseRenderer;
+
+ wrappedRenderer.html = (text: string | ReactNode[], styles): ReactNode => {
+ if (typeof text === "string") {
+ const data = componentMap.get(text);
+ if (data) {
+ if (registry?.hasComponent(data.token.componentName)) {
+ const Component = registry.getComponent(data.token.componentName);
+ if (Component) {
+ return (
+
+ {data.token.componentChildren || undefined}
+
+ );
+ }
+ }
+ return null;
+ }
+ }
+
+ return baseRenderer.html(text, styles);
+ };
+
+ return wrappedRenderer;
+ }, [baseRenderer, componentMap, registry]);
+
+ const elements = useMarkdown(value, {
+ ...options,
+ tokenizer,
+ renderer,
+ });
+
+ return elements;
+}
+
+export default useMarkdownWithComponents;
diff --git a/src/index.ts b/src/index.ts
index e998775e..b75b4f4e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,7 +5,10 @@ import {
marked,
} from "marked";
import useMarkdown, { type useMarkdownHookOptions } from "./hooks/useMarkdown";
+import useMarkdownWithComponents from "./hooks/useMarkdownWithComponents";
import Markdown from "./lib/Markdown";
+import type { ReactComponentRegistry } from "./lib/ReactComponentRegistry";
+import { ReactComponentRegistryProvider } from "./lib/ReactComponentRegistry";
import Renderer from "./lib/Renderer";
import type {
MarkdownProps,
@@ -24,8 +27,17 @@ export type {
useMarkdownHookOptions,
Token,
Tokens,
+ ReactComponentRegistry,
};
-export { useMarkdown, MarkedLexer, Renderer, MarkedTokenizer, MarkedHooks };
+export {
+ useMarkdown,
+ useMarkdownWithComponents,
+ MarkedLexer,
+ Renderer,
+ MarkedTokenizer,
+ MarkedHooks,
+ ReactComponentRegistryProvider,
+};
export default Markdown;
diff --git a/src/lib/ReactComponentRegistry.tsx b/src/lib/ReactComponentRegistry.tsx
new file mode 100644
index 00000000..3a6d9c80
--- /dev/null
+++ b/src/lib/ReactComponentRegistry.tsx
@@ -0,0 +1,52 @@
+import type { ReactNode } from "react";
+import React, { createContext, useContext, useMemo } from "react";
+import type { ReactComponentProps } from "./ReactComponentTokenizer";
+
+export interface ReactComponentRendererProps {
+ props: ReactComponentProps;
+ children?: ReactNode;
+}
+
+export type ReactComponentRenderer = (
+ props: ReactComponentRendererProps,
+) => ReactNode;
+
+export type ReactComponentRegistry = Record;
+
+interface ReactComponentRegistryContextValue {
+ components: ReactComponentRegistry;
+ getComponent: (name: string) => ReactComponentRenderer | undefined;
+ hasComponent: (name: string) => boolean;
+}
+
+const ReactComponentRegistryContext =
+ createContext(null);
+
+interface ReactComponentRegistryProviderProps {
+ components: ReactComponentRegistry;
+ children: ReactNode;
+}
+
+export function ReactComponentRegistryProvider({
+ components,
+ children,
+}: ReactComponentRegistryProviderProps) {
+ const contextValue = useMemo(
+ () => ({
+ components,
+ getComponent: (name: string) => components[name],
+ hasComponent: (name: string) => name in components,
+ }),
+ [components],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useReactComponentRegistry(): ReactComponentRegistryContextValue | null {
+ return useContext(ReactComponentRegistryContext);
+}
diff --git a/src/lib/ReactComponentTokenizer.ts b/src/lib/ReactComponentTokenizer.ts
new file mode 100644
index 00000000..c585c31d
--- /dev/null
+++ b/src/lib/ReactComponentTokenizer.ts
@@ -0,0 +1,149 @@
+import type { Tokens } from "marked";
+import { Tokenizer } from "marked";
+
+const SELF_CLOSING_REGEX = /^<([A-Z][a-zA-Z0-9]*)([^>]*?)\s*\/>/;
+const OPENING_TAG_REGEX = /^<([A-Z][a-zA-Z0-9]*)([^>]*)>/;
+const PROP_REGEX = /(\w+)(?:=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/g;
+
+export type ReactComponentProps = Record;
+
+export interface ReactComponentToken extends Tokens.HTML {
+ componentName: string;
+ componentProps: ReactComponentProps;
+ componentChildren: string;
+}
+
+function parseProps(propsString: string): ReactComponentProps {
+ const props: ReactComponentProps = {};
+ if (!propsString) return props;
+
+ let match: RegExpExecArray | null;
+
+ match = PROP_REGEX.exec(propsString);
+ while (match !== null) {
+ const [, propName, doubleQuoted, singleQuoted, braced] = match;
+ if (propName) {
+ if (doubleQuoted !== undefined) {
+ props[propName] = doubleQuoted;
+ } else if (singleQuoted !== undefined) {
+ props[propName] = singleQuoted;
+ } else if (braced !== undefined) {
+ const trimmed = braced.trim();
+ if (trimmed === "true") {
+ props[propName] = true;
+ } else if (trimmed === "false") {
+ props[propName] = false;
+ } else if (!Number.isNaN(Number(trimmed))) {
+ props[propName] = Number(trimmed);
+ } else {
+ props[propName] = trimmed;
+ }
+ } else {
+ props[propName] = true;
+ }
+ }
+ match = PROP_REGEX.exec(propsString);
+ }
+
+ PROP_REGEX.lastIndex = 0;
+ return props;
+}
+
+function findClosingTag(
+ src: string,
+ componentName: string,
+ startIndex: number,
+): { endIndex: number; children: string } | null {
+ const openingTag = new RegExp(`<${componentName}(?:\\s[^>]*)?>`, "g");
+ const closingTag = `${componentName}>`;
+
+ let depth = 1;
+ let currentIndex = startIndex;
+
+ while (depth > 0 && currentIndex < src.length) {
+ const closingIndex = src.indexOf(closingTag, currentIndex);
+ if (closingIndex === -1) return null;
+
+ openingTag.lastIndex = currentIndex;
+ let nestedMatch: RegExpExecArray | null;
+ nestedMatch = openingTag.exec(src);
+ while (nestedMatch !== null && nestedMatch.index < closingIndex) {
+ depth++;
+ nestedMatch = openingTag.exec(src);
+ }
+
+ depth--;
+ if (depth === 0) {
+ return {
+ endIndex: closingIndex + closingTag.length,
+ children: src.slice(startIndex, closingIndex),
+ };
+ }
+ currentIndex = closingIndex + closingTag.length;
+ }
+
+ return null;
+}
+
+export class ReactComponentTokenizer extends Tokenizer {
+ override html(src: string): Tokens.HTML | undefined {
+ const selfClosingMatch = SELF_CLOSING_REGEX.exec(src);
+ if (selfClosingMatch) {
+ const [raw, componentName, propsString] = selfClosingMatch;
+ return this.createReactComponentToken(
+ raw,
+ componentName ?? "",
+ propsString ?? "",
+ "",
+ );
+ }
+
+ const openingMatch = OPENING_TAG_REGEX.exec(src);
+ if (openingMatch) {
+ const [openingTag, componentName, propsString] = openingMatch;
+ const result = findClosingTag(
+ src,
+ componentName ?? "",
+ openingTag?.length ?? 0,
+ );
+
+ if (result) {
+ const raw = src.slice(0, result.endIndex);
+ return this.createReactComponentToken(
+ raw,
+ componentName ?? "",
+ propsString ?? "",
+ result.children,
+ );
+ }
+ }
+
+ return super.html(src);
+ }
+
+ private createReactComponentToken(
+ raw: string,
+ componentName: string,
+ propsString: string,
+ children: string,
+ ): ReactComponentToken {
+ return {
+ type: "html",
+ raw,
+ text: raw,
+ block: true,
+ pre: false,
+ componentName,
+ componentProps: parseProps(propsString),
+ componentChildren: children.trim(),
+ };
+ }
+}
+
+export function isReactComponentToken(
+ token: Tokens.HTML,
+): token is ReactComponentToken {
+ return "componentName" in token && typeof token.componentName === "string";
+}
+
+export default ReactComponentTokenizer;
diff --git a/src/lib/__tests__/ReactComponentTokenizer.spec.ts b/src/lib/__tests__/ReactComponentTokenizer.spec.ts
new file mode 100644
index 00000000..6a6afe7f
--- /dev/null
+++ b/src/lib/__tests__/ReactComponentTokenizer.spec.ts
@@ -0,0 +1,165 @@
+import type { ReactComponentToken } from "../ReactComponentTokenizer";
+import {
+ isReactComponentToken,
+ ReactComponentTokenizer,
+} from "../ReactComponentTokenizer";
+
+describe("ReactComponentTokenizer", () => {
+ let tokenizer: ReactComponentTokenizer;
+
+ beforeEach(() => {
+ tokenizer = new ReactComponentTokenizer();
+ });
+
+ describe("self-closing components", () => {
+ it("parses a simple self-closing component", () => {
+ const result = tokenizer.html("");
+
+ expect(result).toBeDefined();
+ if (!result) throw new Error("Expected result");
+ expect(isReactComponentToken(result)).toBe(true);
+
+ const token = result as ReactComponentToken;
+ expect(token.componentName).toBe("Button");
+ expect(token.componentProps).toEqual({});
+ expect(token.componentChildren).toBe("");
+ });
+
+ it("parses a self-closing component with string props", () => {
+ const result = tokenizer.html('');
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentName).toBe("Button");
+ expect(token.componentProps).toEqual({ label: "Click me" });
+ });
+
+ it("parses props with single quotes", () => {
+ const result = tokenizer.html("");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentProps).toEqual({ label: "Click me" });
+ });
+
+ it("parses boolean props in braces", () => {
+ const result = tokenizer.html("");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentProps).toEqual({ disabled: true });
+ });
+
+ it("parses numeric props in braces", () => {
+ const result = tokenizer.html("");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentProps).toEqual({ count: 42 });
+ });
+
+ it("parses shorthand boolean props", () => {
+ const result = tokenizer.html("");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentProps).toEqual({ disabled: true });
+ });
+
+ it("parses multiple props", () => {
+ const result = tokenizer.html(
+ '',
+ );
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentProps).toEqual({
+ label: "Submit",
+ disabled: true,
+ size: 2,
+ });
+ });
+ });
+
+ describe("components with children", () => {
+ it("parses a component with text children", () => {
+ const result = tokenizer.html("Hello World");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentName).toBe("InfoBox");
+ expect(token.componentChildren).toBe("Hello World");
+ });
+
+ it("parses a component with props and children", () => {
+ const result = tokenizer.html(
+ 'This is important',
+ );
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentName).toBe("InfoBox");
+ expect(token.componentProps).toEqual({ title: "Note" });
+ expect(token.componentChildren).toBe("This is important");
+ });
+
+ it("preserves markdown in children", () => {
+ const result = tokenizer.html("**Bold** and *italic*");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentChildren).toBe("**Bold** and *italic*");
+ });
+
+ it("handles multiline children", () => {
+ const result = tokenizer.html("\nLine 1\nLine 2\n");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentChildren).toBe("Line 1\nLine 2");
+ });
+ });
+
+ describe("nested components", () => {
+ it("handles nested components of same type", () => {
+ const result = tokenizer.html("Inner");
+
+ expect(result).toBeDefined();
+ const token = result as ReactComponentToken;
+ expect(token.componentName).toBe("Box");
+ expect(token.componentChildren).toBe("Inner");
+ });
+ });
+
+ describe("component name validation", () => {
+ it("matches PascalCase component names", () => {
+ const button = tokenizer.html("");
+ const myComponent = tokenizer.html("");
+ const a = tokenizer.html("");
+ const component123 = tokenizer.html("");
+
+ expect(button && isReactComponentToken(button)).toBe(true);
+ expect(myComponent && isReactComponentToken(myComponent)).toBe(true);
+ expect(a && isReactComponentToken(a)).toBe(true);
+ expect(component123 && isReactComponentToken(component123)).toBe(true);
+ });
+ });
+
+ describe("isReactComponentToken", () => {
+ it("returns true for ReactComponentToken", () => {
+ const result = tokenizer.html("");
+ expect(result && isReactComponentToken(result)).toBe(true);
+ });
+
+ it("returns false for regular HTML token", () => {
+ const regularToken = {
+ type: "html" as const,
+ raw: "",
+ text: "",
+ block: true,
+ pre: false,
+ };
+ expect(isReactComponentToken(regularToken)).toBe(false);
+ });
+ });
+});