{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "docx-viewer",
  "title": "DOCX Viewer",
  "description": "Renders a Word document as elements built through a tag and attribute allowlist.",
  "dependencies": [
    "lucide-react@^1.31.0",
    "mammoth@^1.12.1"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/docx-viewer/docx-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { TriangleAlert } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type DocxResult = {\n  html: string\n  messages?: string[]\n}\n\nexport type DocxConverter = (source: ArrayBuffer) => Promise<DocxResult>\n\nexport type DocxViewerProps = Omit<\n  React.HTMLAttributes<HTMLElement>,\n  \"children\"\n> & {\n  source?: ArrayBuffer | Blob\n  result?: DocxResult\n  converter?: DocxConverter\n  allowedTags?: readonly string[]\n  label?: string\n  loadingLabel?: React.ReactNode\n  showWarnings?: boolean\n}\n\n/** Hold the loading state back so a fast result never flashes it. */\nconst LOADING_DELAY_MS = 120\n\nconst MISSING_MAMMOTH =\n  'DocxViewer needs the \"mammoth\" package, or a `converter` prop that returns { html }.'\n\n/**\n * Converted documents come from files this app did not write, so the HTML is\n * rebuilt as React elements through this allowlist rather than injected. Any\n * tag, attribute, or URL scheme not named here is dropped.\n */\nconst ALLOWED_TAGS = [\n  \"p\",\n  \"br\",\n  \"strong\",\n  \"b\",\n  \"em\",\n  \"i\",\n  \"u\",\n  \"s\",\n  \"sub\",\n  \"sup\",\n  \"h1\",\n  \"h2\",\n  \"h3\",\n  \"h4\",\n  \"h5\",\n  \"h6\",\n  \"ul\",\n  \"ol\",\n  \"li\",\n  \"blockquote\",\n  \"pre\",\n  \"code\",\n  \"table\",\n  \"thead\",\n  \"tbody\",\n  \"tr\",\n  \"th\",\n  \"td\",\n  \"a\",\n  \"img\",\n] as const\n\nconst ALLOWED_ATTRIBUTES: Record<string, readonly string[]> = {\n  a: [\"href\", \"title\"],\n  img: [\"src\", \"alt\", \"width\", \"height\"],\n  th: [\"colspan\", \"rowspan\"],\n  td: [\"colspan\", \"rowspan\"],\n}\n\n/** Tags whose contents are discarded rather than unwrapped into text. */\nconst DROPPED_SUBTREES = new Set([\n  \"script\",\n  \"style\",\n  \"iframe\",\n  \"object\",\n  \"embed\",\n])\n\n/** Tags where a stray whitespace text node is invalid HTML. */\nconst STRUCTURAL = new Set([\n  \"table\",\n  \"thead\",\n  \"tbody\",\n  \"tfoot\",\n  \"tr\",\n  \"ul\",\n  \"ol\",\n])\n\nconst REACT_ATTRIBUTE: Record<string, string> = {\n  colspan: \"colSpan\",\n  rowspan: \"rowSpan\",\n}\n\n/** DOMParser is browser-only, so the body is built after hydration. */\nconst subscribeToNothing = () => () => {}\n\nfunction isSafeUrl(value: string) {\n  const trimmed = value.trim().toLowerCase()\n\n  if (trimmed.startsWith(\"#\") || trimmed.startsWith(\"/\")) return true\n  if (trimmed.startsWith(\"data:image/\")) return true\n\n  return /^https?:/.test(trimmed) || /^mailto:/.test(trimmed)\n}\n\nfunction toElements(\n  nodes: readonly ChildNode[],\n  allowed: ReadonlySet<string>,\n  keyPrefix = \"n\",\n  parentTag?: string\n): React.ReactNode[] {\n  return nodes.flatMap((node, index) => {\n    const key = `${keyPrefix}-${index}`\n\n    if (node.nodeType === 3) {\n      const text = node.textContent ?? \"\"\n      // A table or list may not hold loose text, and the source markup is\n      // usually indented, so whitespace between rows would break hydration.\n      if (parentTag && STRUCTURAL.has(parentTag) && !text.trim()) return []\n      return [text]\n    }\n\n    if (node.nodeType !== 1) return []\n\n    const element = node as Element\n    const tag = element.tagName.toLowerCase()\n\n    if (DROPPED_SUBTREES.has(tag)) return []\n\n    const children = toElements([...element.childNodes], allowed, key, tag)\n\n    // An unknown tag loses its wrapper but keeps whatever text it held.\n    if (!allowed.has(tag)) return children\n\n    const props: Record<string, unknown> = { key }\n\n    for (const name of ALLOWED_ATTRIBUTES[tag] ?? []) {\n      const value = element.getAttribute(name)\n      if (value == null) continue\n      if ((name === \"href\" || name === \"src\") && !isSafeUrl(value)) continue\n\n      props[REACT_ATTRIBUTE[name] ?? name] = value\n    }\n\n    if (tag === \"a\") {\n      props.rel = \"noreferrer noopener\"\n      props.target = \"_blank\"\n    }\n\n    if (tag === \"br\" || tag === \"img\") return [React.createElement(tag, props)]\n\n    return [React.createElement(tag, props, ...children)]\n  })\n}\n\nasync function convertWithMammoth(source: ArrayBuffer): Promise<DocxResult> {\n  let mammoth: typeof import(\"mammoth\")\n\n  try {\n    mammoth = await import(\"mammoth\")\n  } catch {\n    throw new Error(MISSING_MAMMOTH)\n  }\n\n  const convert = (\n    \"default\" in mammoth ? mammoth.default : mammoth\n  ) as typeof mammoth\n  const result = await convert.convertToHtml({ arrayBuffer: source })\n\n  return {\n    html: result.value,\n    messages: result.messages.map(\n      (message: { message: string }) => message.message\n    ),\n  }\n}\n\nexport function DocxViewer({\n  source,\n  result,\n  converter = convertWithMammoth,\n  allowedTags = ALLOWED_TAGS,\n  label = \"Document\",\n  loadingLabel = \"Reading the document…\",\n  showWarnings = false,\n  className,\n  ...rootProps\n}: DocxViewerProps) {\n  const [converted, setConverted] = React.useState<DocxResult | null>(null)\n  const [error, setError] = React.useState<string | null>(null)\n  const [loading, setLoading] = React.useState(false)\n\n  const output = result ?? converted\n\n  React.useEffect(() => {\n    if (result || source == null) return\n\n    const controller = new AbortController()\n    const spinner = window.setTimeout(() => setLoading(true), LOADING_DELAY_MS)\n\n    void (async () => {\n      try {\n        const buffer =\n          source instanceof Blob ? await source.arrayBuffer() : source\n        const next = await converter(buffer)\n        if (controller.signal.aborted) return\n        setConverted(next)\n        setError(null)\n      } catch (cause) {\n        if (controller.signal.aborted) return\n        setError(cause instanceof Error ? cause.message : String(cause))\n      } finally {\n        window.clearTimeout(spinner)\n        if (!controller.signal.aborted) setLoading(false)\n      }\n    })()\n\n    return () => {\n      controller.abort()\n      window.clearTimeout(spinner)\n    }\n  }, [source, result, converter])\n\n  const isClient = React.useSyncExternalStore(\n    subscribeToNothing,\n    () => true,\n    () => false\n  )\n\n  const body = React.useMemo(() => {\n    if (!isClient || !output) return null\n\n    const parsed = new DOMParser().parseFromString(output.html, \"text/html\")\n\n    return toElements([...parsed.body.childNodes], new Set(allowedTags))\n  }, [isClient, output, allowedTags])\n\n  if (error) {\n    return (\n      <section\n        data-slot=\"docx-viewer\"\n        data-state=\"error\"\n        className={cn(\n          \"border-border bg-card text-destructive flex items-center gap-2 rounded-[var(--radius)] border px-4 py-3 text-sm\",\n          className\n        )}\n        {...rootProps}\n      >\n        <TriangleAlert aria-hidden=\"true\" size={15} className=\"shrink-0\" />\n        <p role=\"alert\">{error}</p>\n      </section>\n    )\n  }\n\n  return (\n    <section\n      data-slot=\"docx-viewer\"\n      data-state={loading ? \"loading\" : \"ready\"}\n      aria-label={label}\n      aria-busy={loading || undefined}\n      className={cn(\n        \"border-border bg-card text-card-foreground rounded-[var(--radius)] border\",\n        className\n      )}\n      {...rootProps}\n    >\n      {loading && !output ? (\n        <p className=\"text-muted-foreground px-5 py-6 text-sm\">\n          {loadingLabel}\n        </p>\n      ) : null}\n\n      {body ? (\n        <div\n          data-slot=\"docx-viewer-body\"\n          className=\"max-h-[30rem] overflow-auto px-5 py-5 text-sm leading-relaxed [&_a]:underline [&_a]:underline-offset-4 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1.5 [&_h3]:text-lg [&_h3]:font-semibold [&_img]:max-w-full [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5\"\n        >\n          {body}\n        </div>\n      ) : null}\n\n      {showWarnings && output?.messages?.length ? (\n        <ul\n          data-slot=\"docx-viewer-warnings\"\n          className=\"text-muted-foreground border-border border-t px-5 py-3 text-xs\"\n        >\n          {output.messages.map((message) => (\n            <li key={message}>{message}</li>\n          ))}\n        </ul>\n      ) : null}\n    </section>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "document",
    "docx",
    "word",
    "viewer"
  ],
  "type": "registry:ui"
}