{
  "name": "rich-text",
  "title": "RichText",
  "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.",
  "type": "component",
  "registryDependencies": [
    "cn"
  ],
  "files": [
    {
      "path": "rich-text.tsx",
      "content": "\"use client\";\n\nimport React, { useMemo } from \"react\";\nimport { FilterXSS, safeAttrValue as baseSafeAttrValue } from \"xss\";\nimport { cn } from \"@cimplify/sdk/react\";\n\n// Hosts allowed for <iframe> embeds. Everything else has its src blanked.\nconst ALLOWED_EMBED_HOSTS = new Set<string>([\n  \"www.youtube.com\",\n  \"youtube.com\",\n  \"www.youtube-nocookie.com\",\n  \"youtube-nocookie.com\",\n  \"player.vimeo.com\",\n]);\n\n// Fixed permission policy for embeds. Merchant-supplied `allow` is discarded\n// and replaced with this — a description must never request camera/mic/etc.\nconst IFRAME_ALLOW =\n  \"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen\";\n\n// `class` is an unrestricted CSS channel in a Tailwind storefront — a merchant\n// class like `fixed inset-0 z-50` re-enables exactly what the inline-CSS filter\n// below forbids. So `class` values are filtered to this namespace: only the\n// SDK's own `cimplify-*` layout primitives survive; everything else is dropped.\nconst SAFE_CLASS_PATTERN = /^cimplify-[a-z0-9-]+$/i;\n\n// Inline CSS properties we permit (via the xss css filter). Anything else —\n// position, z-index, behaviours, url() exfil vectors — is dropped.\nconst ALLOWED_CSS_PROPERTIES: Record<string, boolean> = {\n  \"text-align\": true,\n  color: true,\n  \"background-color\": true,\n  \"font-weight\": true,\n  \"font-style\": true,\n  \"text-decoration\": true,\n  \"vertical-align\": true,\n  width: true,\n  height: true,\n};\n\nconst STYLEABLE = [\"style\", \"class\"];\n\n/**\n * Allowlist covering rich product copy: rich text, spec and\n * comparison tables, figures, definition lists, collapsible sections, and\n * host-locked video embeds. Tags outside this set are dropped; `javascript:`\n * URLs, event handlers, and unlisted CSS properties are stripped.\n */\nconst RICH_TEXT_WHITELIST: Record<string, string[]> = {\n  p: STYLEABLE,\n  br: [],\n  div: STYLEABLE,\n  section: [\"class\"],\n  span: STYLEABLE,\n  strong: [],\n  b: [],\n  em: [],\n  i: [],\n  u: [],\n  s: [],\n  strike: [],\n  del: [],\n  ins: [],\n  mark: [],\n  small: [],\n  sub: [],\n  sup: [],\n  h1: STYLEABLE,\n  h2: STYLEABLE,\n  h3: STYLEABLE,\n  h4: STYLEABLE,\n  h5: STYLEABLE,\n  h6: STYLEABLE,\n  ul: [\"class\"],\n  ol: [\"start\", \"type\", \"class\"],\n  li: [\"class\"],\n  dl: [\"class\"],\n  dt: [\"class\"],\n  dd: [\"class\"],\n  blockquote: [\"class\"],\n  code: [],\n  pre: [\"class\"],\n  hr: [],\n  a: [\"href\", \"title\", \"target\", \"rel\"],\n  img: [\"src\", \"alt\", \"title\", \"width\", \"height\", \"loading\", \"class\", \"style\"],\n  figure: [\"class\"],\n  figcaption: [\"class\"],\n  details: [],\n  summary: [],\n  table: [\"class\", \"style\"],\n  thead: [],\n  tbody: [],\n  tfoot: [],\n  caption: [],\n  colgroup: [],\n  col: [\"span\", \"style\"],\n  tr: [\"class\"],\n  th: [\"colspan\", \"rowspan\", \"scope\", \"align\", \"class\", \"style\"],\n  td: [\"colspan\", \"rowspan\", \"align\", \"class\", \"style\"],\n  iframe: [\"src\", \"width\", \"height\", \"allow\", \"allowfullscreen\", \"frameborder\", \"title\", \"loading\"],\n};\n\nfunction safeAttrValue(\n  tag: string,\n  name: string,\n  value: string,\n  cssFilter: Parameters<typeof baseSafeAttrValue>[3],\n): string {\n  if (tag === \"iframe\" && name === \"src\") {\n    try {\n      const url = new URL(value);\n      if (url.protocol === \"https:\" && ALLOWED_EMBED_HOSTS.has(url.hostname)) {\n        return value;\n      }\n    } catch {\n      // malformed URL — fall through to blank\n    }\n    return \"\";\n  }\n  if (tag === \"iframe\" && name === \"allow\") {\n    return IFRAME_ALLOW;\n  }\n  if (name === \"class\") {\n    return value\n      .split(/\\s+/)\n      .filter((token) => SAFE_CLASS_PATTERN.test(token))\n      .join(\" \");\n  }\n  return baseSafeAttrValue(tag, name, value, cssFilter);\n}\n\n// Reverse-tabnabbing guard: force `rel=\"noopener noreferrer\"` on any\n// `target=\"_blank\"` anchor. Runs after FILTER.process, whose output has already\n// escaped attribute values — so no raw `>` can appear inside an attribute and a\n// tag-level regex is safe. Deterministic (identical SSR + client).\nfunction withLinkSafety(html: string): string {\n  return html.replace(/<a\\b[^>]*>/gi, (tag) => {\n    if (!/\\btarget\\s*=\\s*[\"']_blank[\"']/i.test(tag)) return tag;\n    return /\\brel\\s*=\\s*[\"'][^\"']*[\"']/i.test(tag)\n      ? tag.replace(/\\brel\\s*=\\s*[\"'][^\"']*[\"']/i, 'rel=\"noopener noreferrer\"')\n      : tag.replace(/>$/, ' rel=\"noopener noreferrer\">');\n  });\n}\n\n// A single configured filter instance, reused across every call.\nconst FILTER = new FilterXSS({\n  whiteList: RICH_TEXT_WHITELIST,\n  css: { whiteList: ALLOWED_CSS_PROPERTIES },\n  safeAttrValue,\n  stripIgnoreTag: true,\n  stripIgnoreTagBody: [\"script\", \"style\"],\n  allowCommentTag: false,\n});\n\n/**\n * Sanitize merchant-authored HTML for safe rendering. Runs identically on the\n * Cloudflare Workers edge (SSR) and in the browser — no DOM dependency.\n */\nexport function sanitizeRichTextHtml(html: string): string {\n  return withLinkSafety(FILTER.process(html));\n}\n\n// Theme-aware prose styling. Wired to the consumer's own design tokens\n// (`foreground`, `muted-foreground`, `primary`, `border`, `muted`) so rich\n// text inherits each storefront's type scale and colours.\nconst RICH_TEXT_PROSE = cn(\n  \"text-muted-foreground leading-relaxed\",\n  \"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n  \"[&_p]:my-3\",\n  \"[&_h1]:mt-6 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground\",\n  \"[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground\",\n  \"[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-foreground\",\n  \"[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-foreground\",\n  \"[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:font-semibold [&_h5]:text-foreground\",\n  \"[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:font-semibold [&_h6]:text-foreground\",\n  \"[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground\",\n  \"[&_em]:italic [&_i]:italic\",\n  \"[&_u]:underline [&_ins]:underline [&_s]:line-through [&_del]:line-through\",\n  \"[&_mark]:rounded [&_mark]:px-0.5 [&_sub]:align-sub [&_sub]:text-[0.75em] [&_sup]:align-super [&_sup]:text-[0.75em]\",\n  \"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2\",\n  \"[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1\",\n  \"[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1\",\n  \"[&_li]:pl-1 [&_li>p]:my-0 [&_ul_ul]:my-1 [&_ol_ol]:my-1 [&_ul_ul]:list-[circle]\",\n  \"[&_dl]:my-3 [&_dt]:font-semibold [&_dt]:text-foreground [&_dd]:mb-2 [&_dd]:pl-4\",\n  \"[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic\",\n  \"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]\",\n  \"[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[0.85em]\",\n  \"[&_pre>code]:bg-transparent [&_pre>code]:p-0\",\n  \"[&_hr]:my-4 [&_hr]:border-border\",\n  \"[&_figure]:my-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-xs [&_figcaption]:text-muted-foreground\",\n  \"[&_img]:my-3 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md\",\n  \"[&_details]:my-3 [&_details]:rounded-md [&_details]:border [&_details]:border-border [&_details]:p-3\",\n  \"[&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground\",\n  \"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm\",\n  \"[&_caption]:caption-bottom [&_caption]:mt-2 [&_caption]:text-xs [&_caption]:text-muted-foreground\",\n  \"[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground\",\n  \"[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top\",\n  \"[&_iframe]:my-4 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-md\",\n);\n\nexport interface RichTextProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"dangerouslySetInnerHTML\" | \"children\"> {\n  /** Merchant-authored HTML (e.g. a product description). Sanitized before render. */\n  html?: string | null;\n}\n\n/**\n * RichText — renders sanitized merchant-authored HTML with theme-aware prose\n * styling. Headings, lists, tables, figures, quotes, code, collapsible\n * sections and host-locked embeds all display correctly; scripts, event\n * handlers and unlisted CSS are stripped.\n *\n * Returns `null` when there's no renderable content.\n */\nexport function RichText({ html, className, ...rest }: RichTextProps): React.ReactElement | null {\n  const clean = useMemo(() => (html ? sanitizeRichTextHtml(html) : \"\"), [html]);\n\n  if (!clean) {\n    return null;\n  }\n\n  return (\n    <div\n      data-cimplify-rich-text\n      className={cn(RICH_TEXT_PROSE, className)}\n      dangerouslySetInnerHTML={{ __html: clean }}\n      {...rest}\n    />\n  );\n}\n"
    }
  ]
}
