{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"content-renderer","type":"registry:ui","title":"ContentRenderer","description":"ContentRenderer from react-fancy","package":"react-fancy","dependencies":["marked"],"registryDependencies":["editor"],"files":[{"path":"components\/fancy\/content-renderer\/ContentRenderer.tsx","content":"import { useMemo } from \"react\";\r\nimport { marked } from \"marked\";\r\nimport { cn } from \"..\/..\/utils\/cn\";\r\nimport { sanitizeHtml } from \"..\/..\/utils\/sanitize\";\r\nimport { proseClasses, detectFormat } from \"..\/Editor\/editor.utils\";\r\nimport { mergeExtensions } from \".\/extensions\";\r\nimport { RenderedContent } from \".\/RenderedContent\";\r\nimport type { ContentRendererProps } from \".\/ContentRenderer.types\";\r\n\r\nexport function ContentRenderer({\r\n  value,\r\n  format = \"auto\",\r\n  lineSpacing = 1.6,\r\n  className,\r\n  extensions: instanceExtensions,\r\n  unsafe = false,\r\n}: ContentRendererProps) {\r\n  const extensions = useMemo(\r\n    () => mergeExtensions(instanceExtensions),\r\n    [instanceExtensions],\r\n  );\r\n\r\n  const html = useMemo(() => {\r\n    \/\/ Guard nullish input: marked.parse throws \"input parameter is undefined\r\n    \/\/ or null\", which (inside this useMemo during render) would crash the whole\r\n    \/\/ React tree. Treat missing content as empty.\r\n    const safe = value ?? \"\";\r\n    const resolvedFormat = format === \"auto\" ? detectFormat(safe) : format;\r\n    const raw = resolvedFormat === \"markdown\"\r\n      ? (marked.parse(safe, { async: false }) as string)\r\n      : safe;\r\n    return unsafe ? raw : sanitizeHtml(raw);\r\n  }, [value, format, unsafe]);\r\n\r\n  const hasExtensions = extensions.length > 0;\r\n\r\n  return (\r\n    <div\r\n      data-react-fancy-content-renderer=\"\"\r\n      style={{ lineHeight: lineSpacing }}\r\n      className={cn(\"text-sm\", proseClasses, className)}\r\n    >\r\n      {hasExtensions ? (\r\n        <RenderedContent html={html} extensions={extensions} unsafe={unsafe} \/>\r\n      ) : (\r\n        <div dangerouslySetInnerHTML={{ __html: html }} \/>\r\n      )}\r\n    <\/div>\r\n  );\r\n}\r\n\r\nContentRenderer.displayName = \"ContentRenderer\";\r\n","type":"registry:ui","target":"components\/fancy\/content-renderer\/ContentRenderer.tsx"},{"path":"components\/fancy\/content-renderer\/ContentRenderer.types.ts","content":"import type { RenderExtension } from \".\/extensions\";\r\n\r\nexport interface ContentRendererProps {\r\n  value: string;\r\n  format?: \"html\" | \"markdown\" | \"auto\";\r\n  lineSpacing?: number;\r\n  className?: string;\r\n  \/** Per-instance render extensions. Merged with any globally-registered extensions. *\/\r\n  extensions?: RenderExtension[];\r\n  \/**\r\n   * Skip HTML sanitization. By default, rendered output is sanitized to remove\r\n   * `<script>`, `<iframe>`, event handlers, and `javascript:` URIs. Pass\r\n   * `unsafe` only when the input is fully trusted (e.g. server-rendered\r\n   * markdown from your own CMS).\r\n   * @default false\r\n   *\/\r\n  unsafe?: boolean;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/content-renderer\/ContentRenderer.types.ts"},{"path":"components\/fancy\/content-renderer\/RenderedContent.tsx","content":"import { useMemo } from \"react\";\r\nimport { sanitizeHtml } from \"..\/..\/utils\/sanitize\";\r\nimport type { RenderExtension } from \".\/extensions\";\r\nimport { parseSegments, mergeExtensions } from \".\/extensions\";\r\n\r\ninterface RenderedContentProps {\r\n  html: string;\r\n  extensions?: RenderExtension[];\r\n  \/**\r\n   * If true, html is rendered as-is without per-segment sanitization. Caller\r\n   * (ContentRenderer) is expected to have already sanitized \u2014 or explicitly\r\n   * opted out \u2014 at the top level.\r\n   *\/\r\n  unsafe?: boolean;\r\n}\r\n\r\n\/**\r\n * Internal component that renders an HTML string with extension support.\r\n * Plain HTML segments use dangerouslySetInnerHTML; custom-tag segments\r\n * are rendered via their registered React component.\r\n *\/\r\nexport function RenderedContent({\r\n  html,\r\n  extensions: instanceExtensions,\r\n  unsafe = false,\r\n}: RenderedContentProps) {\r\n  const extensions = useMemo(\r\n    () => mergeExtensions(instanceExtensions),\r\n    [instanceExtensions],\r\n  );\r\n\r\n  const segments = useMemo(\r\n    () => parseSegments(html, extensions),\r\n    [html, extensions],\r\n  );\r\n\r\n  const renderHtml = (content: string): string =>\r\n    unsafe ? content : sanitizeHtml(content);\r\n\r\n  \/\/ Fast path: no extensions matched \u2014 single dangerouslySetInnerHTML\r\n  if (segments.length === 1 && segments[0].type === \"html\") {\r\n    return <div dangerouslySetInnerHTML={{ __html: renderHtml(segments[0].content) }} \/>;\r\n  }\r\n\r\n  \/\/ No content at all\r\n  if (segments.length === 0) {\r\n    return null;\r\n  }\r\n\r\n  return (\r\n    <>\r\n      {segments.map((segment, i) => {\r\n        if (segment.type === \"html\") {\r\n          return segment.content ? (\r\n            <div key={i} dangerouslySetInnerHTML={{ __html: renderHtml(segment.content) }} \/>\r\n          ) : null;\r\n        }\r\n\r\n        const ext = extensions.find(\r\n          (e) => e.tag.toLowerCase() === segment.tag,\r\n        );\r\n        if (!ext) return null;\r\n\r\n        const Component = ext.component;\r\n        const isBlock = ext.block !== false;\r\n        const Wrapper = isBlock ? \"div\" : \"span\";\r\n\r\n        return (\r\n          <Wrapper key={i} data-render-extension={segment.tag}>\r\n            <Component content={segment.content} attributes={segment.attributes} \/>\r\n          <\/Wrapper>\r\n        );\r\n      })}\r\n    <\/>\r\n  );\r\n}\r\n\r\nRenderedContent.displayName = \"RenderedContent\";\r\n","type":"registry:ui","target":"components\/fancy\/content-renderer\/RenderedContent.tsx"},{"path":"components\/fancy\/content-renderer\/extensions.ts","content":"import type { ComponentType } from \"react\";\r\n\r\n\/** Props passed to every render-extension component. *\/\r\nexport interface RenderExtensionProps {\r\n  \/** The inner content of the custom tag (raw string). *\/\r\n  content: string;\r\n  \/** Parsed HTML attributes from the opening tag. *\/\r\n  attributes: Record<string, string>;\r\n}\r\n\r\n\/** Registers a custom tag with a React component for rendering. *\/\r\nexport interface RenderExtension {\r\n  \/** Tag name to match, e.g. \"questions\", \"thinking\". Case-insensitive. *\/\r\n  tag: string;\r\n  \/** React component that renders the matched tag. *\/\r\n  component: ComponentType<RenderExtensionProps>;\r\n  \/**\r\n   * Whether this is a block-level element.\r\n   * Block extensions are wrapped in a `<div>`, inline in a `<span>`.\r\n   * @default true\r\n   *\/\r\n  block?: boolean;\r\n}\r\n\r\n\/** A parsed segment \u2014 either raw HTML or a matched extension tag. *\/\r\nexport type ContentSegment =\r\n  | { type: \"html\"; content: string }\r\n  | { type: \"extension\"; tag: string; content: string; attributes: Record<string, string> };\r\n\r\n\/\/ ---------------------------------------------------------------------------\r\n\/\/ Global extension registry\r\n\/\/ ---------------------------------------------------------------------------\r\n\r\nconst globalRegistry: RenderExtension[] = [];\r\n\r\n\/** Register a single render extension globally (available to all ContentRenderer and Editor instances). *\/\r\nexport function registerExtension(extension: RenderExtension): void {\r\n  const idx = globalRegistry.findIndex(\r\n    (e) => e.tag.toLowerCase() === extension.tag.toLowerCase(),\r\n  );\r\n  if (idx >= 0) {\r\n    globalRegistry[idx] = extension;\r\n  } else {\r\n    globalRegistry.push(extension);\r\n  }\r\n}\r\n\r\n\/** Register multiple render extensions globally. *\/\r\nexport function registerExtensions(extensions: RenderExtension[]): void {\r\n  for (const ext of extensions) {\r\n    registerExtension(ext);\r\n  }\r\n}\r\n\r\n\/** Get a snapshot of the current global registry. *\/\r\nexport function getGlobalExtensions(): RenderExtension[] {\r\n  return [...globalRegistry];\r\n}\r\n\r\n\/\/ ---------------------------------------------------------------------------\r\n\/\/ Parsing\r\n\/\/ ---------------------------------------------------------------------------\r\n\r\n\/** Parse attributes from an opening tag string like `tag foo=\"bar\" baz=\"qux\"`. *\/\r\nfunction parseAttributes(attrString: string): Record<string, string> {\r\n  const attrs: Record<string, string> = {};\r\n  const re = \/(\\w[\\w-]*)(?:\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|(\\S+)))?\/g;\r\n  let m: RegExpExecArray | null;\r\n  while ((m = re.exec(attrString)) !== null) {\r\n    attrs[m[1]] = m[2] ?? m[3] ?? m[4] ?? \"\";\r\n  }\r\n  return attrs;\r\n}\r\n\r\n\/**\r\n * Split an HTML string into segments of plain HTML and matched extension tags.\r\n *\r\n * Handles:\r\n * - `<tag>content<\/tag>`\r\n * - `<tag attr=\"val\">content<\/tag>`\r\n * - Nested different-tags inside the content (preserved as raw HTML)\r\n * - Self-nesting of the same tag (greedy \u2014 matches outermost)\r\n *\/\r\nexport function parseSegments(\r\n  html: string,\r\n  extensions: RenderExtension[],\r\n): ContentSegment[] {\r\n  if (extensions.length === 0) {\r\n    return html ? [{ type: \"html\", content: html }] : [];\r\n  }\r\n\r\n  const tagNames = extensions.map((e) => e.tag).join(\"|\");\r\n  \/\/ Match opening tag with optional attributes, then content, then closing tag.\r\n  \/\/ Uses a non-greedy match for content \u2014 if same-tag nesting is needed the\r\n  \/\/ consumer should use distinct tag names.\r\n  const pattern = new RegExp(\r\n    `<(${tagNames})((?:\\\\s+[^>]*)?)>([\\\\s\\\\S]*?)<\\\\\/\\\\1>`,\r\n    \"gi\",\r\n  );\r\n\r\n  const segments: ContentSegment[] = [];\r\n  let lastIndex = 0;\r\n  let match: RegExpExecArray | null;\r\n\r\n  while ((match = pattern.exec(html)) !== null) {\r\n    \/\/ Push any HTML before this match\r\n    if (match.index > lastIndex) {\r\n      segments.push({ type: \"html\", content: html.slice(lastIndex, match.index) });\r\n    }\r\n\r\n    const tag = match[1].toLowerCase();\r\n    const rawAttrs = match[2] ? match[2].trim() : \"\";\r\n    const innerContent = match[3];\r\n\r\n    segments.push({\r\n      type: \"extension\",\r\n      tag,\r\n      content: innerContent,\r\n      attributes: rawAttrs ? parseAttributes(rawAttrs) : {},\r\n    });\r\n\r\n    lastIndex = match.index + match[0].length;\r\n  }\r\n\r\n  \/\/ Push any trailing HTML\r\n  if (lastIndex < html.length) {\r\n    segments.push({ type: \"html\", content: html.slice(lastIndex) });\r\n  }\r\n\r\n  return segments;\r\n}\r\n\r\n\/**\r\n * Merge per-instance extensions with the global registry.\r\n * Instance extensions override globals with the same tag name.\r\n *\/\r\nexport function mergeExtensions(\r\n  instanceExtensions?: RenderExtension[],\r\n): RenderExtension[] {\r\n  const globals = getGlobalExtensions();\r\n  if (!instanceExtensions || instanceExtensions.length === 0) return globals;\r\n  if (globals.length === 0) return instanceExtensions;\r\n\r\n  const merged = [...globals];\r\n  for (const ext of instanceExtensions) {\r\n    const idx = merged.findIndex(\r\n      (e) => e.tag.toLowerCase() === ext.tag.toLowerCase(),\r\n    );\r\n    if (idx >= 0) {\r\n      merged[idx] = ext;\r\n    } else {\r\n      merged.push(ext);\r\n    }\r\n  }\r\n  return merged;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/content-renderer\/extensions.ts"},{"path":"components\/fancy\/content-renderer\/index.ts","content":"export { ContentRenderer } from \".\/ContentRenderer\";\r\nexport type { ContentRendererProps } from \".\/ContentRenderer.types\";\r\nexport { registerExtension, registerExtensions } from \".\/extensions\";\r\nexport type {\r\n  RenderExtension,\r\n  RenderExtensionProps,\r\n  ContentSegment,\r\n} from \".\/extensions\";\r\n","type":"registry:ui","target":"components\/fancy\/content-renderer\/index.ts"}]}