{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"citation","title":"Citation","description":"Inline source references with hover preview and multi-source carousel","dependencies":["radix-ui","@hugeicons/react","@hugeicons/core-free-icons","tldts"],"registryDependencies":["carousel","hover-card"],"files":[{"path":"components/nexus-ui/citation.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowLeft01Icon, ArrowRight01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { parse as parseDomain } from \"tldts\";\n\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  type CarouselApi,\n} from \"@/components/ui/carousel\";\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CitationSourceInput = {\n  url: string;\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n};\n\nexport type ResolvedCitation = {\n  url: string;\n  title: React.ReactNode | null;\n  description: React.ReactNode | null;\n  siteName: string;\n  faviconSrc: string;\n};\n\nconst HTTP_PROTOCOL_RE = /^https?:$/i;\nconst tryParseUrl = (value: string): URL | null => {\n  try {\n    return new URL(value);\n  } catch {\n    return null;\n  }\n};\nconst parseCitationUrlOrNull = (urlStr: string): URL | null => {\n  const trimmed = urlStr.trim();\n  if (!trimmed) return null;\n  try {\n    return parseCitationUrl(trimmed);\n  } catch {\n    return null;\n  }\n};\n\nexport function parseCitationUrl(urlStr: string): URL {\n  const trimmed = urlStr.trim();\n  const parsed = tryParseUrl(trimmed) ?? tryParseUrl(`https://${trimmed}`);\n  if (!parsed || !HTTP_PROTOCOL_RE.test(parsed.protocol)) {\n    throw new Error(\"Citation URL must use http or https\");\n  }\n  return parsed;\n}\n\nfunction formatSiteToken(token: string): string {\n  const lower = token.toLowerCase();\n  const isAlpha = /^[a-z]+$/i.test(lower);\n  if (!isAlpha) return token;\n  if (lower.length <= 3) {\n    return lower.toUpperCase();\n  }\n  return lower.slice(0, 1).toUpperCase() + lower.slice(1);\n}\n\nfunction titleCaseLabel(label: string): string {\n  if (!label) return label;\n  return label\n    .split(\"-\")\n    .filter(Boolean)\n    .map(formatSiteToken)\n    .join(\" \");\n}\n\nexport function rootDomainSiteName(url: URL): string {\n  const host = url.hostname.replace(/^www\\./i, \"\").toLowerCase();\n  const { domainWithoutSuffix, isIp } = parseDomain(host, {\n    allowPrivateDomains: true,\n  });\n  const label = isIp ? host : (domainWithoutSuffix ?? host.split(\".\")[0] ?? \"\");\n\n  return titleCaseLabel(label);\n}\n\nfunction hasCitationField(v: unknown): boolean {\n  return (\n    v !== undefined && v !== null && !(typeof v === \"string\" && v.trim() === \"\")\n  );\n}\n\nexport function resolveCitationSource(\n  input: CitationSourceInput,\n): ResolvedCitation {\n  const parsed = parseCitationUrlOrNull(input.url);\n  const siteName = parsed ? rootDomainSiteName(parsed) : \"Source\";\n  const faviconSrc = parsed\n    ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(parsed.hostname)}&sz=64`\n    : \"\";\n\n  return {\n    url: parsed?.href ?? \"\",\n    title: hasCitationField(input.title) ? input.title! : null,\n    description: hasCitationField(input.description)\n      ? input.description!\n      : null,\n    siteName,\n    faviconSrc,\n  };\n}\n\nexport function resolveCitationSources(\n  inputs: CitationSourceInput[],\n): ResolvedCitation[] {\n  return inputs.map(resolveCitationSource);\n}\n\ntype CitationRootContextValue = {\n  citations: ResolvedCitation[];\n  activeIndex: number;\n  setActiveIndex: React.Dispatch<React.SetStateAction<number>>;\n  carouselApi: CarouselApi | null;\n  setCarouselApi: (api: CarouselApi | undefined) => void;\n  scrollPrev: () => void;\n  scrollNext: () => void;\n  canScrollPrev: boolean;\n  canScrollNext: boolean;\n  carouselCurrent: number;\n  carouselCount: number;\n};\n\nconst CitationRootContext =\n  React.createContext<CitationRootContextValue | null>(null);\n\nconst CitationItemContext = React.createContext<ResolvedCitation | null>(null);\n\nfunction useCitationRoot(component: string): CitationRootContextValue {\n  const ctx = React.useContext(CitationRootContext);\n  if (!ctx) {\n    throw new Error(`${component} must be used within Citation`);\n  }\n  return ctx;\n}\n\nfunction useResolvedCitation(component: string): ResolvedCitation {\n  const item = React.useContext(CitationItemContext);\n  const root = useCitationRoot(component);\n  const idx = Math.min(\n    Math.max(0, root.activeIndex),\n    Math.max(0, root.citations.length - 1),\n  );\n  const fromRoot = root.citations[idx];\n  const resolved = item ?? fromRoot;\n  if (!resolved) {\n    throw new Error(`${component}: no citation for this scope`);\n  }\n  return resolved;\n}\n\nexport type CitationProps = Omit<\n  React.ComponentProps<typeof HoverCard>,\n  \"children\"\n> & {\n  citations: CitationSourceInput[];\n  children?: React.ReactNode;\n};\n\nfunction Citation({\n  citations: citationInputs,\n  children,\n  ...hoverCardProps\n}: CitationProps) {\n  const resolved = React.useMemo(\n    () => resolveCitationSources(citationInputs),\n    [citationInputs],\n  );\n\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [carouselApi, setCarouselApi] = React.useState<CarouselApi | null>(\n    null,\n  );\n  const [, setCarouselVersion] = React.useState(0);\n\n  const len = resolved.length;\n  const clampedActiveIndex =\n    len === 0 ? 0 : Math.min(Math.max(0, activeIndex), len - 1);\n  const contextActiveIndex = carouselApi\n    ? carouselApi.selectedScrollSnap()\n    : clampedActiveIndex;\n  const canScrollPrev = carouselApi?.canScrollPrev() ?? false;\n  const canScrollNext = carouselApi?.canScrollNext() ?? false;\n  const carouselCount = carouselApi?.scrollSnapList().length ?? 0;\n  const carouselCurrent = carouselApi ? carouselApi.selectedScrollSnap() + 1 : 1;\n\n  const onCarouselChange = React.useCallback(() => {\n    if (!carouselApi) return;\n    setActiveIndex(carouselApi.selectedScrollSnap());\n    setCarouselVersion((v) => v + 1);\n  }, [carouselApi]);\n\n  React.useEffect(() => {\n    if (!carouselApi) return;\n    carouselApi.on(\"select\", onCarouselChange);\n    carouselApi.on(\"reInit\", onCarouselChange);\n    return () => {\n      carouselApi.off(\"select\", onCarouselChange);\n      carouselApi.off(\"reInit\", onCarouselChange);\n    };\n  }, [carouselApi, onCarouselChange]);\n\n  const setCarouselApiCb = React.useCallback((api: CarouselApi | undefined) => {\n    setCarouselApi(api ?? null);\n  }, []);\n\n  const scrollPrev = React.useCallback(() => {\n    carouselApi?.scrollPrev();\n  }, [carouselApi]);\n\n  const scrollNext = React.useCallback(() => {\n    carouselApi?.scrollNext();\n  }, [carouselApi]);\n\n  const value = React.useMemo<CitationRootContextValue>(\n    () => ({\n      citations: resolved,\n      activeIndex: contextActiveIndex,\n      setActiveIndex,\n      carouselApi,\n      setCarouselApi: setCarouselApiCb,\n      scrollPrev,\n      scrollNext,\n      canScrollPrev,\n      canScrollNext,\n      carouselCurrent,\n      carouselCount,\n    }),\n    [\n      resolved,\n      contextActiveIndex,\n      carouselApi,\n      setCarouselApiCb,\n      scrollPrev,\n      scrollNext,\n      canScrollPrev,\n      canScrollNext,\n      carouselCurrent,\n      carouselCount,\n    ],\n  );\n\n  if (len === 0) {\n    return null;\n  }\n\n  return (\n    <CitationRootContext.Provider value={value}>\n      <HoverCard\n        data-slot=\"citation\"\n        {...hoverCardProps}\n        openDelay={50}\n        closeDelay={50}\n      >\n        {children}\n      </HoverCard>\n    </CitationRootContext.Provider>\n  );\n}\n\nexport type CitationTriggerProps = Omit<\n  React.ComponentProps<typeof HoverCardTrigger>,\n  \"children\"\n> & {\n  label?: React.ReactNode;\n  showFavicon?: boolean;\n  showSiteName?: boolean;\n};\n\nfunction CitationTrigger({\n  className,\n  label,\n  showFavicon = true,\n  showSiteName = true,\n  ...props\n}: CitationTriggerProps) {\n  const root = useCitationRoot(\"CitationTrigger\");\n  const c = root.citations[0]!;\n  const hasValidUrl = !!c.url;\n\n  let text: React.ReactNode =\n    label !== undefined && label !== null\n      ? label\n      : showSiteName\n        ? c.siteName\n        : null;\n  let hasText =\n    text !== null &&\n    text !== undefined &&\n    !(typeof text === \"string\" && text.trim() === \"\");\n\n  if (!showFavicon && !hasText) {\n    text = \"Source\";\n    hasText = true;\n  }\n\n  const baseClassName = cn(\n    \"inline-flex h-5.5 max-w-full cursor-default items-center rounded-full bg-secondary opacity-100 transition-colors hover:bg-border data-[state=open]:opacity-100 align-middle\",\n    hasText && showFavicon && \"gap-1 py-1 pr-1.5 pl-1\",\n    hasText && !showFavicon && \"px-2 py-1\",\n    !hasText && showFavicon && \"p-1\",\n  );\n  const multipleSources = root.citations.length > 1;\n\n  const chipBody = (\n    <>\n      {showFavicon ? <CitationFavicon src={c.faviconSrc} /> : null}\n      {hasText ? <CitationSiteName>{text}</CitationSiteName> : null}\n      {multipleSources && (\n        <span\n          data-slot=\"citation-extra-count\"\n          className=\"text-xs leading-4.5 font-[350] text-muted-foreground tabular-nums\"\n        >\n          +{root.citations.length - 1}\n        </span>\n      )}\n    </>\n  );\n\n  return (\n    <HoverCardTrigger data-slot=\"citation-trigger\" asChild {...props}>\n      {multipleSources || !hasValidUrl ? (\n        <span className={cn(baseClassName, className)}>{chipBody}</span>\n      ) : (\n        <a\n          href={c.url}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          className={cn(baseClassName, className, \"not-prose\")}\n        >\n          {chipBody}\n        </a>\n      )}\n    </HoverCardTrigger>\n  );\n}\n\nexport type CitationContentProps = React.ComponentProps<\n  typeof HoverCardContent\n>;\n\nfunction CitationContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: CitationContentProps) {\n  return (\n    <HoverCardContent\n      data-slot=\"citation-content\"\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"flex w-66 flex-col overflow-hidden rounded-lg border-border p-0 shadow-modal dark:border-accent\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\ntype CitationCarouselProps = React.ComponentProps<typeof Carousel>;\n\nfunction CitationCarousel({\n  setApi: setApiProp,\n  ...props\n}: CitationCarouselProps) {\n  const { setCarouselApi } = useCitationRoot(\"CitationCarousel\");\n  return (\n    <Carousel\n      data-slot=\"citation-carousel\"\n      setApi={(api) => {\n        setCarouselApi(api);\n        setApiProp?.(api);\n      }}\n      {...props}\n    />\n  );\n}\n\ntype CitationCarouselHeaderProps = React.ComponentProps<\"div\">;\n\nfunction CitationCarouselHeader({\n  className,\n  ...props\n}: CitationCarouselHeaderProps) {\n  return (\n    <div\n      data-slot=\"citation-carousel-header\"\n      className={cn(\n        \"flex h-auto w-full items-center justify-between px-3 pt-3\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction useCarouselViewportHeight(\n  wrapRef: React.RefObject<HTMLDivElement | null>,\n  carouselApi: CarouselApi | null,\n  activeIndex: number,\n) {\n  React.useLayoutEffect(() => {\n    const vp = wrapRef.current?.querySelector<HTMLElement>(\n      \"[data-slot=carousel-content]\",\n    );\n    if (!vp) return;\n    const clear = () => {\n      vp.style.height = \"\";\n      vp.style.transition = \"\";\n    };\n    if (!carouselApi) return clear();\n    vp.style.transition = \"height 500ms ease-out\";\n    const sync = () => {\n      const h = carouselApi.slideNodes()[activeIndex]?.offsetHeight ?? 0;\n      vp.style.height = h > 0 ? `${h}px` : \"\";\n    };\n    sync();\n    const slide = carouselApi.slideNodes()[activeIndex];\n    if (!slide) return clear;\n    const ro = new ResizeObserver(sync);\n    ro.observe(slide);\n    return () => (ro.disconnect(), clear());\n  }, [wrapRef, carouselApi, activeIndex]);\n}\n\ntype CitationCarouselContentProps = React.ComponentProps<\n  typeof CarouselContent\n>;\n\nfunction CitationCarouselContent({\n  className,\n  ...props\n}: CitationCarouselContentProps) {\n  const { carouselApi, activeIndex } = useCitationRoot(\n    \"CitationCarouselContent\",\n  );\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  useCarouselViewportHeight(wrapRef, carouselApi, activeIndex);\n\n  return (\n    <div ref={wrapRef} className=\"contents\">\n      <CarouselContent\n        data-slot=\"citation-carousel-content\"\n        className={className}\n        {...props}\n      />\n    </div>\n  );\n}\n\ntype CitationCarouselItemProps = React.ComponentProps<typeof CarouselItem> & {\n  index: number;\n};\n\nfunction CitationCarouselItem({\n  index,\n  className,\n  children,\n  ...props\n}: CitationCarouselItemProps) {\n  const { citations } = useCitationRoot(\"CitationCarouselItem\");\n  const item = citations[index];\n  if (!item) return null;\n\n  return (\n    <CarouselItem\n      data-slot=\"citation-carousel-item\"\n      className={cn(\"self-start\", className)}\n      {...props}\n    >\n      <CitationItemContext.Provider value={item}>\n        {children}\n      </CitationItemContext.Provider>\n    </CarouselItem>\n  );\n}\n\ntype CitationCarouselPaginationProps = React.ComponentProps<\"div\">;\n\nfunction CitationCarouselPagination({\n  className,\n  ...props\n}: CitationCarouselPaginationProps) {\n  return (\n    <div\n      data-slot=\"citation-carousel-pagination\"\n      className={cn(\"flex items-center gap-1\", className)}\n      {...props}\n    />\n  );\n}\n\ntype CitationCarouselNavButtonProps =\n  React.ButtonHTMLAttributes<HTMLButtonElement>;\n\nfunction CitationCarouselPrev({\n  className,\n  children,\n  ...props\n}: CitationCarouselNavButtonProps) {\n  const { scrollPrev, canScrollPrev } = useCitationRoot(\"CitationCarouselPrev\");\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"citation-carousel-prev\"\n      disabled={!canScrollPrev}\n      className={cn(\n        \"flex size-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-0 transition-all hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-accent/50 active:scale-97\",\n        className,\n      )}\n      onClick={scrollPrev}\n      {...props}\n    >\n      {children ?? (\n        <HugeiconsIcon\n          icon={ArrowLeft01Icon}\n          strokeWidth={2}\n          className=\"size-4\"\n        />\n      )}\n    </button>\n  );\n}\n\nfunction CitationCarouselNext({\n  className,\n  children,\n  ...props\n}: CitationCarouselNavButtonProps) {\n  const { scrollNext, canScrollNext } = useCitationRoot(\"CitationCarouselNext\");\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"citation-carousel-next\"\n      disabled={!canScrollNext}\n      className={cn(\n        \"flex size-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-0 transition-all hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-accent/50 active:scale-97\",\n        className,\n      )}\n      onClick={scrollNext}\n      {...props}\n    >\n      {children ?? (\n        <HugeiconsIcon\n          icon={ArrowRight01Icon}\n          strokeWidth={2}\n          className=\"size-4\"\n        />\n      )}\n    </button>\n  );\n}\n\ntype CitationCarouselIndexProps = React.HTMLAttributes<HTMLSpanElement>;\n\nfunction CitationCarouselIndex({\n  className,\n  ...props\n}: CitationCarouselIndexProps) {\n  const { carouselCurrent, carouselCount } = useCitationRoot(\n    \"CitationCarouselIndex\",\n  );\n\n  return (\n    <span\n      data-slot=\"citation-carousel-index\"\n      className={cn(\n        \"text-xs leading-4.5 font-[350] text-muted-foreground tabular-nums\",\n        className,\n      )}\n      {...props}\n    >\n      {carouselCurrent}/{carouselCount}\n    </span>\n  );\n}\n\nexport type CitationSourcesBadgeProps = Omit<\n  React.ComponentPropsWithoutRef<\"div\">,\n  \"children\"\n> & {\n  showFavicons?: boolean;\n  label?: React.ReactNode;\n};\n\nfunction CitationSourcesBadge({\n  className,\n  showFavicons = true,\n  label,\n  ...props\n}: CitationSourcesBadgeProps) {\n  const { citations } = useCitationRoot(\"CitationSourcesBadge\");\n  const count = citations.length;\n  const text = label ?? `${count} ${count === 1 ? \"source\" : \"sources\"}`;\n\n  return (\n    <div\n      data-slot=\"citation-sources-badge\"\n      className={cn(\n        \"mt-0 flex h-6 items-center gap-1.5 rounded-full bg-secondary pr-2 w-fit\",\n        showFavicons ? \"pl-1\" : \"pl-2\",\n        className,\n      )}\n      {...props}\n    >\n      {showFavicons ? (\n        <div\n          data-slot=\"citation-favicon-group\"\n          className=\"flex -space-x-2 *:data-[slot=citation-favicon]:ring-2 *:data-[slot=citation-favicon]:ring-secondary\"\n        >\n          {citations.slice(0, 3).map((citation, i) => (\n            <CitationFavicon key={citation.url + i} src={citation.faviconSrc} />\n          ))}\n        </div>\n      ) : null}\n      <span className=\"text-xs leading-4.5 font-normal text-primary\">\n        {text}\n      </span>\n    </div>\n  );\n}\n\nexport type CitationItemProps = React.ComponentPropsWithoutRef<\"a\"> & {\n  showTitle?: boolean;\n  showDescription?: boolean;\n  showSource?: boolean;\n};\n\nfunction CitationItem({\n  className,\n  children,\n  href,\n  showTitle = true,\n  showDescription = true,\n  showSource = true,\n  ...props\n}: CitationItemProps) {\n  const c = useResolvedCitation(\"CitationItem\");\n  const resolvedHref = href ?? c.url;\n  const safeHref = resolvedHref ? parseCitationUrlOrNull(resolvedHref)?.href : \"\";\n  const hasValidUrl = !!safeHref;\n  const defaultContent = (\n    <>\n      {showTitle && c.title != null ? (\n        <h4\n          data-slot=\"citation-title\"\n          className=\"line-clamp-2 text-xs leading-4 font-[450] text-primary\"\n        >\n          {c.title}\n        </h4>\n      ) : null}\n      {showDescription && c.description != null ? (\n        <p\n          data-slot=\"citation-description\"\n          className=\"line-clamp-3 text-xs leading-4.5 font-[350] text-muted-foreground\"\n        >\n          {c.description}\n        </p>\n      ) : null}\n      {showSource ? <CitationSource /> : null}\n    </>\n  );\n\n  return (\n    <a\n      data-slot=\"citation-item\"\n      className={cn(\n        \"flex w-full flex-col gap-1 p-3 text-start no-underline outline-none\",\n        className,\n      )}\n      href={hasValidUrl ? safeHref : undefined}\n      target={hasValidUrl ? \"_blank\" : undefined}\n      rel={hasValidUrl ? \"noreferrer\" : undefined}\n      {...props}\n    >\n      {children ?? defaultContent}\n    </a>\n  );\n}\n\ntype CitationSourceProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction CitationSource({\n  className,\n  ...props\n}: CitationSourceProps) {\n  return (\n    <div\n      data-slot=\"citation-source\"\n      className={cn(\"mt-2 flex items-center gap-1.5\", className)}\n      {...props}\n    >\n      <CitationFavicon />\n      <CitationSiteName />\n    </div>\n  );\n}\n\ntype CitationFaviconProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  src?: string;\n};\n\nfunction CitationFavicon({ className, src, ...props }: CitationFaviconProps) {\n  const c = useResolvedCitation(\"CitationFavicon\");\n  const resolvedSrc = src ?? c.faviconSrc;\n  if (resolvedSrc === \"\") return null;\n\n  return (\n    <img\n      src={resolvedSrc}\n      alt=\"\"\n      data-slot=\"citation-favicon\"\n      className={cn(\"size-4 shrink-0 rounded-full bg-background\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CitationSiteName({\n  className,\n  children,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) {\n  const c = useResolvedCitation(\"CitationSiteName\");\n  const content = children ?? c.siteName;\n  if (content == null) return null;\n\n  return (\n    <span\n      data-slot=\"citation-site-name\"\n      className={cn(\"text-xs leading-4.5 font-normal text-primary\", className)}\n      {...props}\n    >\n      {content}\n    </span>\n  );\n}\n\nexport {\n  Citation,\n  CitationCarousel,\n  CitationCarouselContent,\n  CitationCarouselHeader,\n  CitationCarouselIndex,\n  CitationCarouselItem,\n  CitationCarouselNext,\n  CitationCarouselPagination,\n  CitationCarouselPrev,\n  CitationContent,\n  CitationFavicon,\n  CitationItem,\n  CitationSourcesBadge,\n  CitationSiteName,\n  CitationSource,\n  CitationTrigger,\n};\n","type":"registry:file","target":"~/components/nexus-ui/citation.tsx"}],"type":"registry:ui"}