diff --git a/cloud/app/components/blocks/copy-markdown-button.tsx b/cloud/app/components/blocks/copy-markdown-button.tsx new file mode 100644 index 000000000..e9bf4a501 --- /dev/null +++ b/cloud/app/components/blocks/copy-markdown-button.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { Button } from "@/app/components/ui/button"; +import { Clipboard, Check } from "lucide-react"; +// import analyticsManager from "@/app/lib/services/analytics"; + +interface CopyMarkdownButtonProps { + content: string; + itemId: string; + contentType: "blog_markdown" | "document_markdown"; + className?: string; +} + +export function CopyMarkdownButton({ + content, + className = "", +}: CopyMarkdownButtonProps) { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = () => { + if (!content) return; + + navigator.clipboard + .writeText(content) + .then(() => { + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 2000); + + // analyticsManager.trackCopyEvent({ + // contentType, + // itemId, + // }); + }) + .catch((err) => { + console.error("Failed to copy content: ", err); + }); + }; + + return ( + + ); +} diff --git a/cloud/app/components/blocks/docs/api-signature.tsx b/cloud/app/components/blocks/docs/api-signature.tsx new file mode 100644 index 000000000..79d79aac9 --- /dev/null +++ b/cloud/app/components/blocks/docs/api-signature.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +/** + * Component to display an API function/method signature + */ +export default function ApiSignature({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/cloud/app/components/blocks/docs/api-type.tsx b/cloud/app/components/blocks/docs/api-type.tsx new file mode 100644 index 000000000..8852b95b2 --- /dev/null +++ b/cloud/app/components/blocks/docs/api-type.tsx @@ -0,0 +1,33 @@ +/** + * Component to display the API object type within headings + * + * Renders the type as a colored label and stores metadata for documentation linking. + */ +export interface ApiTypeProps { + /** The type of API object (Module, Class, Function, Alias, Attribute) */ + type: string; + /** The path to the document (e.g., "core/anthropic/call_params") */ + path: string; + /** The name of the symbol (e.g., "AnthropicCallParams") */ + symbolName: string; + /** The slug for this API object (used for heading IDs) */ + slug: string; +} + +export default function ApiType({ + type, + path, + symbolName, + slug, +}: ApiTypeProps) { + return ( + + {type} + + ); +} diff --git a/cloud/app/components/blocks/docs/attributes-table.tsx b/cloud/app/components/blocks/docs/attributes-table.tsx new file mode 100644 index 000000000..2eb56e3dc --- /dev/null +++ b/cloud/app/components/blocks/docs/attributes-table.tsx @@ -0,0 +1,53 @@ +import { type TypeInfo, TypeLink } from "./type-link"; + +export type Attribute = { + name: string; + type_info: TypeInfo; + description?: string; +}; + +interface AttributesTableProps { + attributes: Attribute[]; +} + +/** + * Component to display a table of class attributes + */ +export default function AttributesTable({ attributes }: AttributesTableProps) { + if (!attributes || attributes.length === 0) { + return null; + } + + return ( +
+

Attributes

+
+ + + + + + + + + + {attributes.map((attr, index) => ( + + + + + + ))} + +
NameTypeDescription
{attr.name} + + + {attr.description || "-"} +
+
+
+ ); +} diff --git a/cloud/app/components/blocks/docs/parameters-table.tsx b/cloud/app/components/blocks/docs/parameters-table.tsx new file mode 100644 index 000000000..1bce1ab3f --- /dev/null +++ b/cloud/app/components/blocks/docs/parameters-table.tsx @@ -0,0 +1,61 @@ +import { type TypeInfo, TypeLink } from "./type-link"; + +export type Parameter = { + name: string; + type_info: TypeInfo; + description?: string; + default?: string; +}; + +interface ParametersTableProps { + parameters: Parameter[]; +} + +/** + * Component to display a table of parameters with their types + */ +export default function ParametersTable({ parameters }: ParametersTableProps) { + if (!parameters || parameters.length === 0) { + return null; + } + + return ( +
+

Parameters

+
+ + + + + + + + + + {parameters.map((param, index) => ( + + + + + + ))} + +
NameTypeDescription
+ {param.name} + {param.default && ( + + = {param.default} + + )} + + + + {param.description || "-"} +
+
+
+ ); +} diff --git a/cloud/app/components/blocks/docs/return-table.tsx b/cloud/app/components/blocks/docs/return-table.tsx new file mode 100644 index 000000000..bc62829f9 --- /dev/null +++ b/cloud/app/components/blocks/docs/return-table.tsx @@ -0,0 +1,54 @@ +import { type TypeInfo, TypeLink } from "./type-link"; + +export type ReturnTypeInfo = { + name?: string; + type_info: TypeInfo; + description?: string; +}; + +interface ReturnTableProps { + returnType: ReturnTypeInfo; +} + +/** + * Component to display a function's return type in a table format consistent with other tables + */ +export default function ReturnTable({ returnType }: ReturnTableProps) { + if (!returnType || !returnType.type_info) { + return null; + } + + return ( +
+

Returns

+
+ + + + {returnType.name && ( + + )} + + + + + + + {returnType.name && ( + + )} + + + + +
NameTypeDescription
+ {returnType.name} + + + + {returnType.description || "-"} +
+
+
+ ); +} diff --git a/cloud/app/components/blocks/docs/type-link.tsx b/cloud/app/components/blocks/docs/type-link.tsx new file mode 100644 index 000000000..af837a603 --- /dev/null +++ b/cloud/app/components/blocks/docs/type-link.tsx @@ -0,0 +1,136 @@ +export interface TypeInfo { + kind: "simple" | "generic" | "union" | "optional" | "callable" | "tuple"; + type_str: string; + description?: string; + // Documentation URL for the type (primarily for simple types) + doc_url?: string; + // For generic types + base_type?: TypeInfo; + parameters?: TypeInfo[]; +} + +export interface TypeLinkProps { + type: TypeInfo; +} + +/** + * TypeLink component that displays type information from a structured TypeInfo object. + * Renders types recursively, with links to documentation where available. + */ +export function TypeLink({ type }: TypeLinkProps) { + if (!type) { + return -; + } + + // For simple types, render with a link if available + if (type.kind === "simple") { + const content = {type.type_str}; + + // If we have a documentation URL, make the type clickable + if (type.doc_url) { + // Only use rel="noopener noreferrer" for external links + const isExternal = type.doc_url.startsWith("http"); + return ( + + {type.type_str} + + ); + } + + return content; + } + + // For generic types, render recursively + if (type.kind === "generic" && type.base_type && type.parameters) { + return ( + + + {"["} + {type.parameters.map((param, index) => ( + + {index > 0 && ", "} + + + ))} + {"]"} + + ); + } + + // For union types (A | B | C) + if (type.kind === "union" && type.parameters) { + return ( + + {type.parameters.map((param, index) => ( + + {index > 0 && " | "} + + + ))} + + ); + } + + // For optional types (T | None or T?) + if ( + type.kind === "optional" && + type.parameters && + type.parameters.length > 0 + ) { + return ( + + + {" | None"} + + ); + } + + // For callable types (Callable[[args], return_type]) + if ( + type.kind === "callable" && + type.parameters && + type.parameters.length === 2 + ) { + const argsType = type.parameters[0]; // This is usually a tuple + const returnType = type.parameters[1]; + + return ( + + ( + {argsType.parameters + ? argsType.parameters.map((param, index) => ( + + {index > 0 && ", "} + + + )) + : ""} + ) {"=> "} + + + ); + } + + // For tuple types ([A, B, C]) + if (type.kind === "tuple" && type.parameters) { + return ( + + {"["} + {type.parameters.map((param, index) => ( + + {index > 0 && ", "} + + + ))} + {"]"} + + ); + } + + // For other types or fallback, just render the type string + return {type.type_str}; +} diff --git a/cloud/app/components/blocks/model-provider-provider.tsx b/cloud/app/components/blocks/model-provider-provider.tsx new file mode 100644 index 000000000..213bc967b --- /dev/null +++ b/cloud/app/components/blocks/model-provider-provider.tsx @@ -0,0 +1,228 @@ +import { createContext, useContext, useState } from "react"; +import type { ReactNode } from "react"; +// Import from shared config file + +/** + * Provider configuration and defaults + * + * This file defines the available providers, their defaults, and related configuration. + * It serves as the single source of truth for provider information used throughout the application. + */ + +// Define available providers using their package names +export type Provider = + | "openai" + | "anthropic" + | "mistral" + | "google" + | "groq" + | "xai" + | "cohere" + | "litellm" + | "azure" + | "bedrock"; + +// All available providers +export const providers: Provider[] = [ + "openai", + "anthropic", + "google", + "groq", + "xai", + "mistral", + "cohere", + "litellm", + "azure", + "bedrock", +]; + +// Provider information including display name and default model +export const providerDefaults: Record< + Provider, + { + displayName: string; + defaultModel: string; + } +> = { + openai: { + displayName: "OpenAI", + defaultModel: "gpt-4o-mini", + }, + anthropic: { + displayName: "Anthropic", + defaultModel: "claude-3-5-sonnet-latest", + }, + mistral: { + displayName: "Mistral", + defaultModel: "mistral-large-latest", + }, + xai: { + displayName: "xAI", + defaultModel: "grok-3", + }, + google: { + displayName: "Google", + defaultModel: "gemini-2.0-flash", + }, + groq: { + displayName: "Groq", + defaultModel: "llama-3.3-70b-versatile", + }, + cohere: { + displayName: "Cohere", + defaultModel: "command-r-plus", + }, + litellm: { + displayName: "LiteLLM", + defaultModel: "gpt-4o-mini", + }, + azure: { + displayName: "Azure AI", + defaultModel: "gpt-4o-mini", + }, + bedrock: { + displayName: "Bedrock", + defaultModel: "amazon.nova-lite-v1:0", + }, +}; + +/** + * Get the default alternative provider and model + * Uses Anthropic as the alternative unless the primary provider is Anthropic, + * in which case it uses OpenAI + */ +export function getAlternativeProvider(primaryProvider: Provider): { + provider: Provider; + model: string; +} { + const alternativeProvider = + primaryProvider === "anthropic" ? "openai" : "anthropic"; + return { + provider: alternativeProvider, + model: providerDefaults[alternativeProvider].defaultModel, + }; +} + +/** + * Replace provider variables in content + * Replaces $PROVIDER, $MODEL, $OTHER_PROVIDER, $OTHER_MODEL, and $PROVIDER:MODEL + */ +export function replaceProviderVariables( + content: string, + primaryProvider: Provider, +): string { + const primaryInfo = providerDefaults[primaryProvider]; + const { provider: secondaryProvider, model: secondaryModel } = + getAlternativeProvider(primaryProvider); + + const after = content + .replace( + /\$PROVIDER:MODEL/g, + `${primaryProvider}:${primaryInfo.defaultModel}`, + ) + .replace(/\$PROVIDER/g, primaryProvider) + .replace(/\$MODEL/g, primaryInfo.defaultModel) + .replace(/\$OTHER_PROVIDER/g, secondaryProvider) + .replace(/\$OTHER_MODEL/g, secondaryModel); + return after; +} + +import { temporarilyEnableSyncHighlighting } from "@/app/lib/code-highlight"; + +// Create a context to share the selected provider +interface ProviderContextType { + provider: Provider; + setProvider: (provider: Provider) => void; + providerInfo: { + displayName: string; + defaultModel: string; + }; +} + +const ProviderContext = createContext( + undefined, +); + +// Helper function to validate a provider string +const validateProvider = ( + provider: string | null, + defaultFallback: Provider, +): Provider => { + if (!provider || !providers.includes(provider as Provider)) { + return defaultFallback; // Default fallback if invalid + } + return provider as Provider; +}; + +// Provider component that wraps the content and provides the state +export function ProviderContextProvider({ + children, + defaultProvider = "openai", + onProviderChange, +}: { + children: ReactNode; + defaultProvider?: Provider; + onProviderChange?: (provider: Provider) => void; +}) { + // Initialize Provider from localStorage if available + const [provider, setProvider] = useState(() => { + if (typeof window !== "undefined") { + const savedProvider = localStorage.getItem("selectedProvider"); + return validateProvider(savedProvider, defaultProvider); + } + return defaultProvider; + }); + + // Create a wrapper for setProvider that calls the callback and updates localStorage + const handleProviderChange = (newProvider: Provider) => { + temporarilyEnableSyncHighlighting(); + setProvider(newProvider); + + // Save to localStorage + if (typeof window !== "undefined") { + localStorage.setItem("selectedProvider", newProvider); + } + + // Call external callback if provided + if (onProviderChange) { + onProviderChange(newProvider); + } + }; + + // Get the provider info + const providerInfo = providerDefaults[provider]; + + return ( + + {children} + + ); +} + +// Default provider to use when outside of ProviderContextProvider +const defaultProvider: Provider = "openai"; + +// Hook to access the provider context +export function useProvider() { + const context = useContext(ProviderContext); + if (context === undefined) { + // Return a default context when no provider is available + // This happens in blog posts or other areas without the provider dropdown + return { + provider: defaultProvider, + setProvider: () => { + console.warn( + "Attempted to set provider outside of ProviderContextProvider", + ); + }, + providerInfo: providerDefaults[defaultProvider], + }; + } + return context; +} diff --git a/cloud/app/components/blocks/tabbed-section-provider.tsx b/cloud/app/components/blocks/tabbed-section-provider.tsx new file mode 100644 index 000000000..7c271996e --- /dev/null +++ b/cloud/app/components/blocks/tabbed-section-provider.tsx @@ -0,0 +1,80 @@ +import React, { createContext, useContext, useState } from "react"; + +type TabMemoryContextType = { + getTabValue: (options: string[]) => string | undefined; + setTabValue: (options: string[], value: string) => void; +}; + +const TabMemoryContext = createContext( + undefined, +); + +const STORAGE_KEY = "mirascope-tabbed-section-memory"; + +// Helper to load from localStorage safely +function loadFromStorage(): Record { + if (typeof window === "undefined") return {}; + + try { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? (JSON.parse(saved) as Record) : {}; + } catch (e) { + console.error("Failed to load tabbed section memory from localStorage", e); + return {}; + } +} + +export function TabbedSectionMemoryProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [tabMemory, setTabMemory] = + useState>(loadFromStorage); + + const getTabValue = (options: string[]): string | undefined => { + const key = createTabKey(options); + return tabMemory[key]; + }; + + const setTabValue = (options: string[], value: string) => { + const key = createTabKey(options); + const newMemory = { ...tabMemory, [key]: value }; + + // Save to localStorage + if (typeof window !== "undefined") { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newMemory)); + } catch (e) { + console.error( + "Failed to save tabbed section memory to localStorage", + e, + ); + } + } + + setTabMemory(newMemory); + }; + + return ( + + {children} + + ); +} + +// Helper to create a consistent key from tab options +function createTabKey(options: string[]): string { + return options.sort().join("__"); +} + +// Custom hook to use the tab memory +export function useTabMemory() { + const context = useContext(TabMemoryContext); + if (context === undefined) { + throw new Error( + "useTabMemory must be used within a TabbedSectionMemoryProvider", + ); + } + return context; +} diff --git a/cloud/app/components/blog-post-page.tsx b/cloud/app/components/blog-post-page.tsx index 43a1025e9..2da55bade 100644 --- a/cloud/app/components/blog-post-page.tsx +++ b/cloud/app/components/blog-post-page.tsx @@ -1,13 +1,13 @@ -import { ButtonLink } from "@/app/components/ui/button-link"; import { ChevronLeft } from "lucide-react"; -// import { MDXRenderer } from "@/app/components/mdx/renderer"; -// import { CopyMarkdownButton } from "@/app/components/blocks/copy-markdown-button"; +import { ButtonLink } from "@/app/components/ui/button-link"; +import { MDXRenderer } from "@/app/components/mdx/renderer"; +import { CopyMarkdownButton } from "@/app/components/blocks/copy-markdown-button"; import LoadingContent from "@/app/components/blocks/loading-content"; -// import { TableOfContents } from "@/app/components/table-of-contents"; +import { PageTOC } from "@/app/components/page-toc"; // import { PagefindMeta } from "@/app/components/pagefind-meta"; import type { BlogContent } from "@/app/lib/content/types"; import PageLayout from "@/app/components/page-layout"; -import { MDXRenderer } from "./mdx/renderer"; +import { useEffect, useState } from "react"; // Reusable component for "Back to Blog" button function BackToBlogLink() { @@ -29,40 +29,38 @@ type BlogPostPageProps = { export function BlogPostPage({ post, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - slug: _slug, // Temporarily unused - will be used for PageMeta/CopyMarkdownButton + slug, isLoading = false, }: BlogPostPageProps) { - // TODO: Re-enable when PageMeta is implemented - // const [ogImage, setOgImage] = useState(undefined); + // todo(sebastian): disabled - did this work before? + const [, setOgImage] = useState(undefined); // Find the first available image in the blog post directory - // TODO: Re-enable when PageMeta is implemented - // useEffect(() => { - // if (isLoading) return; + useEffect(() => { + if (isLoading) return; - // const findOgImage = async () => { - // try { - // const response = await fetch(`/assets/blog/${slug}/`); - // if (response.ok) { - // const text = await response.text(); - // const parser = new DOMParser(); - // const doc = parser.parseFromString(text, "text/html"); - // const links = Array.from(doc.querySelectorAll("a")) - // .map((a) => a.getAttribute("href")) - // .filter((href) => href && /\.(png|jpg|jpeg|gif)$/i.test(href)); + const findOgImage = async () => { + try { + const response = await fetch(`/assets/blog/${slug}/`); + if (response.ok) { + const text = await response.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "text/html"); + const links = Array.from(doc.querySelectorAll("a")) + .map((a) => a.getAttribute("href")) + .filter((href) => href && /\.(png|jpg|jpeg|gif)$/i.test(href)); - // if (links.length > 0) { - // setOgImage(`/assets/blog/${slug}/${links[0]}`); - // } - // } - // } catch (err) { - // console.error("Error finding OG image:", err); - // } - // }; + if (links.length > 0) { + setOgImage(`/assets/blog/${slug}/${links[0]}`); + } + } + } catch (err) { + console.error("Error finding OG image:", err); + } + }; - // findOgImage(); - // }, [slug, isLoading]); + void findOgImage(); + }, [slug, isLoading]); // Extract metadata for easier access const { title, date, readTime, author, lastUpdated } = post.meta; @@ -88,10 +86,14 @@ export function BlogPostPage({
{post.mdx ? ( - + ) : ( )} + {/* todo(sebastian): re-enable when PagefindMeta is implemented */} {/* {post.mdx ? (
- {/* */} + />

On this page @@ -128,10 +130,11 @@ export function BlogPostPage({

- {/* */} + />
); diff --git a/cloud/app/components/mdx/component-registry.tsx b/cloud/app/components/mdx/component-registry.tsx new file mode 100644 index 000000000..7e2e91506 --- /dev/null +++ b/cloud/app/components/mdx/component-registry.tsx @@ -0,0 +1,443 @@ +import React from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { Link as LinkIcon } from "lucide-react"; +// MDX components +import { InstallSnippet } from "@/app/components/mdx/elements/install-snippet"; +import ProviderCodeBlock from "@/app/components/mdx/elements/code-block-provider"; +import { + Callout, + Note, + Warning, + Info, + Success, +} from "@/app/components/mdx/elements/callout"; +import { + TabbedSection, + Tab, +} from "@/app/components/mdx/elements/tabbed-section"; +import MermaidDiagram from "@/app/components/mdx/elements/mermaid-diagram"; +import Icon from "@/app/components/mdx/elements/icon-wrapper"; +// API documentation components +import ApiType from "@/app/components/blocks/docs/api-type"; +import ApiSignature from "@/app/components/blocks/docs/api-signature"; +import ParametersTable from "@/app/components/blocks/docs/parameters-table"; +import ReturnTable from "@/app/components/blocks/docs/return-table"; +import AttributesTable from "@/app/components/blocks/docs/attributes-table"; +import { TypeLink } from "@/app/components/blocks/docs/type-link"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@/app/components/ui/tabs"; +import { Button } from "@/app/components/ui/button"; +import { ButtonLink } from "@/app/components/ui/button-link"; +import MirascopeLogo from "@/app/components/blocks/branding/mirascope-logo"; +import ProductLogo from "@/app/components/blocks/branding/mirascope-logo"; +// import { ProviderCodeWrapper } from "./ProviderCodeWrapper"; +// import { ResponsiveImage } from "@/src/components/mdx/providers/ResponsiveImage"; +// import { devComponents } from "@/app/components/mdx/elements/DevComponents"; +import { idSlugFromChildren } from "@/app/lib/mdx/heading-utils"; + +// ----------------------------------------------------------------------------- +// Helper Components +// ----------------------------------------------------------------------------- + +// MDX-specific ButtonLink wrapper that bypasses type checking at the MDX boundary +// and handles nested paragraph tags that MDX generates +const MDXButtonLink = (props: React.ComponentProps) => { + // Extract children from paragraph tags that MDX might add + return ( + + {React.Children.map(props.children, (child) => { + // If it's a paragraph element, extract its children + if ( + React.isValidElement(child) && + (child.type === "p" || + (typeof child.type === "function" && child.type.name === "p")) + ) { + // Type assertion to access props safely + const elementProps = ( + child as React.ReactElement<{ children?: React.ReactNode }> + ).props; + return elementProps.children; + } + return child; + })} + + ); +}; + +// Heading anchor link component +const HeadingAnchor = ({ id }: { id?: string }) => { + const navigate = useNavigate(); + + if (!id) return null; + + const handleClick = (e: React.MouseEvent) => { + // Prevent default browser behavior + e.preventDefault(); + + // Use TanStack Router's navigate to update the hash + // This leverages the router's built-in scroll restoration + void navigate({ + hash: id, + // Use replace to avoid adding to history stack + replace: true, + }); + }; + + return ( + + + + ); +}; + +// ----------------------------------------------------------------------------- +// Custom Components & UI Elements +// ----------------------------------------------------------------------------- + +const customComponents = { + // Custom components for docs + InstallSnippet, + ProviderCodeBlock, + TabbedSection, + Tab, + Callout, + Note, + Warning, + Info, + Success, + MermaidDiagram, + Icon, + + // API documentation components + ApiType, + ApiSignature, + AttributesTable, + ParametersTable, + ReturnTable, + TypeLink, + + // UI Components + Button, + ButtonLink: MDXButtonLink, // Use the MDX-specific wrapper for ButtonLink + Tabs, + TabsList, + TabsTrigger, + TabsContent, + ProductLogo, + MirascopeLogo, + + // todo(sebastian): consolidate with /docs/v1/placeholder for central styling + // Dev components + // ...devComponents, +}; + +// ----------------------------------------------------------------------------- +// HTML Elements +// ----------------------------------------------------------------------------- + +const headingElements = { + h1: ({ children, ...props }: React.ComponentPropsWithoutRef<"h1">) => { + // Generate an ID from the text content if not provided + const id = props.id || idSlugFromChildren(children); + return ( +

+ {children} + +

+ ); + }, + h2: ({ children, ...props }: React.ComponentPropsWithoutRef<"h2">) => { + const id = props.id || idSlugFromChildren(children); + return ( +

+ {children} + +

+ ); + }, + h3: ({ children, ...props }: React.ComponentPropsWithoutRef<"h3">) => { + const id = props.id || idSlugFromChildren(children); + return ( +

+ {children} + +

+ ); + }, + h4: ({ children, ...props }: React.ComponentPropsWithoutRef<"h4">) => { + const id = props.id || idSlugFromChildren(children); + return ( +

+ {children} + +

+ ); + }, + h5: ({ children, ...props }: React.ComponentPropsWithoutRef<"h5">) => { + const id = props.id || idSlugFromChildren(children); + return ( +
+ {children} + +
+ ); + }, +}; + +const textElements = { + p: (props: React.ComponentPropsWithoutRef<"p">) => ( +

+ ), + strong: (props: React.ComponentPropsWithoutRef<"strong">) => ( + + ), + em: (props: React.ComponentPropsWithoutRef<"em">) => , + blockquote: (props: React.ComponentPropsWithoutRef<"blockquote">) => ( +

+ ), + hr: (props: React.ComponentPropsWithoutRef<"hr">) => ( +
+ ), +}; + +const listElements = { + ul: (props: React.ComponentPropsWithoutRef<"ul">) => ( +