{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "link-preview",
  "type": "registry:block",
  "title": "Link Preview",
  "description": "Rich link previews with OG data.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "files": [
    {
      "path": "components/tool-ui/link-preview/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/link-preview/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n */\n\"use client\";\n\nexport { cn } from \"@/lib/utils\";\n"
    },
    {
      "path": "components/tool-ui/link-preview/index.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/link-preview/index.ts",
      "content": "export { LinkPreview } from \"./link-preview\";\nexport type { LinkPreviewProps } from \"./link-preview\";\nexport type { SerializableLinkPreview } from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/link-preview/link-preview.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/link-preview/link-preview.tsx",
      "content": "\"use client\";\n\nimport { Globe } from \"lucide-react\";\nimport { cn } from \"./_adapter\";\n\nimport {\n  RATIO_CLASS_MAP,\n  getFitClass,\n  openSafeNavigationHref,\n  sanitizeHref,\n} from \"../shared/media\";\nimport type { SerializableLinkPreview } from \"./schema\";\n\nconst FALLBACK_LOCALE = \"en-US\";\nconst CONTENT_SPACING = \"px-5 py-4 gap-2\";\n\nexport interface LinkPreviewProps extends SerializableLinkPreview {\n  className?: string;\n  onNavigate?: (href: string, preview: SerializableLinkPreview) => void;\n}\n\nexport function LinkPreview(props: LinkPreviewProps) {\n  const { className, onNavigate, ...serializable } = props;\n\n  const {\n    id,\n    href: rawHref,\n    title,\n    description,\n    image,\n    domain,\n    favicon,\n    ratio = \"16:9\",\n    fit = \"cover\",\n    locale: providedLocale,\n  } = serializable;\n\n  const locale = providedLocale ?? FALLBACK_LOCALE;\n  const sanitizedHref = sanitizeHref(rawHref);\n\n  const previewData: SerializableLinkPreview = {\n    ...serializable,\n    href: sanitizedHref ?? rawHref,\n    locale,\n  };\n\n  const handleClick = () => {\n    if (!sanitizedHref) return;\n    if (onNavigate) {\n      onNavigate(sanitizedHref, previewData);\n    } else {\n      openSafeNavigationHref(sanitizedHref);\n    }\n  };\n\n  return (\n    <article\n      className={cn(\"relative w-full max-w-md min-w-80\", className)}\n      lang={locale}\n      data-tool-ui-id={id}\n      data-slot=\"link-preview\"\n    >\n      <div\n        className={cn(\n          \"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl\",\n          \"border-border bg-card border text-sm shadow-xs\",\n          sanitizedHref && \"cursor-pointer\",\n        )}\n        onClick={sanitizedHref ? handleClick : undefined}\n        role={sanitizedHref ? \"link\" : undefined}\n        tabIndex={sanitizedHref ? 0 : undefined}\n        onKeyDown={\n          sanitizedHref\n            ? (e) => {\n                if (e.key === \"Enter\" || e.key === \" \") {\n                  e.preventDefault();\n                  handleClick();\n                }\n              }\n            : undefined\n        }\n      >\n        <div className=\"flex flex-col\">\n          {image && (\n            <div\n              className={cn(\n                \"bg-muted relative w-full overflow-hidden\",\n                ratio !== \"auto\" ? RATIO_CLASS_MAP[ratio] : \"aspect-[5/3]\",\n              )}\n            >\n              <img\n                src={image}\n                alt=\"\"\n                loading=\"lazy\"\n                decoding=\"async\"\n                className={cn(\n                  \"absolute inset-0 h-full w-full\",\n                  getFitClass(fit),\n                  \"object-center transition-transform duration-200 group-hover:scale-[1.01]\",\n                )}\n              />\n            </div>\n          )}\n          <div className={cn(\"flex flex-col\", CONTENT_SPACING)}>\n            {domain && (\n              <div className=\"text-muted-foreground flex items-center gap-2 text-xs\">\n                {favicon ? (\n                  <img\n                    src={favicon}\n                    alt=\"\"\n                    aria-hidden=\"true\"\n                    width={16}\n                    height={16}\n                    className=\"size-4 rounded-full object-cover\"\n                    loading=\"lazy\"\n                    decoding=\"async\"\n                  />\n                ) : (\n                  <div className=\"border-border/60 bg-muted flex size-4 shrink-0 items-center justify-center rounded-full border\">\n                    <Globe className=\"h-2.5 w-2.5\" aria-hidden=\"true\" />\n                  </div>\n                )}\n                <span>{domain}</span>\n              </div>\n            )}\n            {title && (\n              <h3 className=\"text-foreground text-base font-medium text-pretty\">\n                <span className=\"line-clamp-2\">{title}</span>\n              </h3>\n            )}\n            {description && (\n              <p className=\"text-muted-foreground leading-snug text-pretty\">\n                <span className=\"line-clamp-2\">{description}</span>\n              </p>\n            )}\n          </div>\n        </div>\n      </div>\n    </article>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/link-preview/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/link-preview/README.md",
      "content": "# Link Preview\n\nImplementation for the \"link-preview\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/link-preview/index.ts\n- serializable schema + parse helpers: components/tool-ui/link-preview/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/link-preview/content.mdx\n- Preset payload: lib/presets/link-preview.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/link-preview/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/link-preview/schema.ts",
      "content": "import { z } from \"zod\";\nimport { defineToolUiContract } from \"../shared/contract\";\nimport {\n  ToolUIIdSchema,\n  ToolUIReceiptSchema,\n  ToolUIRoleSchema,\n} from \"../shared/schema\";\n\nimport { AspectRatioSchema, MediaFitSchema } from \"../shared/media\";\n\nexport const SerializableLinkPreviewSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n  href: z.url(),\n  title: z.string().optional(),\n  description: z.string().optional(),\n  image: z.url().optional(),\n  domain: z.string().optional(),\n  favicon: z.url().optional(),\n  ratio: AspectRatioSchema.optional(),\n  fit: MediaFitSchema.optional(),\n  createdAt: z.string().datetime().optional(),\n  locale: z.string().optional(),\n});\n\nexport type SerializableLinkPreview = z.infer<\n  typeof SerializableLinkPreviewSchema\n>;\n\nconst SerializableLinkPreviewSchemaContract = defineToolUiContract(\n  \"LinkPreview\",\n  SerializableLinkPreviewSchema,\n);\n\nexport const parseSerializableLinkPreview: (\n  input: unknown,\n) => SerializableLinkPreview = SerializableLinkPreviewSchemaContract.parse;\n\nexport const safeParseSerializableLinkPreview: (\n  input: unknown,\n) => SerializableLinkPreview | null =\n  SerializableLinkPreviewSchemaContract.safeParse;\n"
    },
    {
      "path": "components/tool-ui/shared/contract.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/contract.ts",
      "content": "import { z } from \"zod\";\nimport { parseWithSchema, safeParseWithSchema } from \"./parse\";\n\nexport interface ToolUiContract<T> {\n  schema: z.ZodType<T>;\n  parse: (input: unknown) => T;\n  safeParse: (input: unknown) => T | null;\n}\n\nexport function defineToolUiContract<T>(\n  componentName: string,\n  schema: z.ZodType<T>,\n): ToolUiContract<T> {\n  return {\n    schema,\n    parse: (input: unknown) => parseWithSchema(schema, input, componentName),\n    safeParse: (input: unknown) => safeParseWithSchema(schema, input),\n  };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/aspect-ratio.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/aspect-ratio.ts",
      "content": "import { z } from \"zod\";\n\nexport const AspectRatioSchema = z\n  .enum([\"auto\", \"1:1\", \"4:3\", \"16:9\", \"9:16\"])\n  .default(\"auto\");\n\nexport type AspectRatio = z.infer<typeof AspectRatioSchema>;\n\nexport const MediaFitSchema = z.enum([\"cover\", \"contain\"]).default(\"cover\");\n\nexport type MediaFit = z.infer<typeof MediaFitSchema>;\n\nexport const RATIO_CLASS_MAP: Record<AspectRatio, string> = {\n  auto: \"\",\n  \"1:1\": \"aspect-square\",\n  \"4:3\": \"aspect-[4/3]\",\n  \"16:9\": \"aspect-video\",\n  \"9:16\": \"aspect-[9/16]\",\n};\n\nexport function getRatioClass(ratio: AspectRatio): string {\n  return RATIO_CLASS_MAP[ratio];\n}\n\nexport function getFitClass(fit: MediaFit): string {\n  return fit === \"cover\" ? \"object-cover\" : \"object-contain\";\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/format-utils.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/format-utils.ts",
      "content": "/**\n * Format duration in milliseconds to human-readable string.\n * @example formatDuration(128000) => \"2:08\"\n * @example formatDuration(3661000) => \"1:01:01\"\n */\nexport function formatDuration(durationMs: number): string {\n  const totalSeconds = Math.round(durationMs / 1000);\n  const hours = Math.floor(totalSeconds / 3600);\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  const seconds = totalSeconds % 60;\n\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, \"0\")}:${seconds\n      .toString()\n      .padStart(2, \"0\")}`;\n  }\n  return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n\n/**\n * Format file size in bytes to human-readable string.\n * @example formatFileSize(1024) => \"1 KB\"\n * @example formatFileSize(1536000) => \"1.5 MB\"\n */\nexport function formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\"];\n  let size = bytes / 1024;\n  let unit = 0;\n  while (size >= 1024 && unit < units.length - 1) {\n    size /= 1024;\n    unit += 1;\n  }\n  return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/index.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/index.ts",
      "content": "export {\n  AspectRatioSchema,\n  MediaFitSchema,\n  RATIO_CLASS_MAP,\n  getRatioClass,\n  getFitClass,\n  type AspectRatio,\n  type MediaFit,\n} from \"./aspect-ratio\";\n\nexport { OVERLAY_GRADIENT } from \"./overlay-gradient\";\n\nexport { formatDuration, formatFileSize } from \"./format-utils\";\n\nexport { sanitizeHref } from \"./sanitize-href\";\nexport {\n  resolveSafeNavigationHref,\n  openSafeNavigationHref,\n} from \"./safe-navigation\";\n"
    },
    {
      "path": "components/tool-ui/shared/media/overlay-gradient.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/overlay-gradient.ts",
      "content": "/**\n * Eased gradient for hover overlays on media elements.\n * Creates a smooth fade from opaque black at top to transparent.\n *\n * @see https://larsenwork.com/easing-gradients/\n */\nexport const OVERLAY_GRADIENT = `linear-gradient(\n  to bottom,\n  hsl(0, 0%, 0%) 0%,\n  hsla(0, 0%, 0%, 0.987) 8.3%,\n  hsla(0, 0%, 0%, 0.951) 16.6%,\n  hsla(0, 0%, 0%, 0.896) 24.6%,\n  hsla(0, 0%, 0%, 0.825) 32.5%,\n  hsla(0, 0%, 0%, 0.741) 40.1%,\n  hsla(0, 0%, 0%, 0.648) 47.6%,\n  hsla(0, 0%, 0%, 0.55) 54.8%,\n  hsla(0, 0%, 0%, 0.45) 61.7%,\n  hsla(0, 0%, 0%, 0.352) 68.3%,\n  hsla(0, 0%, 0%, 0.259) 74.5%,\n  hsla(0, 0%, 0%, 0.175) 80.4%,\n  hsla(0, 0%, 0%, 0.104) 86%,\n  hsla(0, 0%, 0%, 0.049) 91.1%,\n  hsla(0, 0%, 0%, 0.013) 95.8%,\n  hsla(0, 0%, 0%, 0) 100%\n)` as const;\n"
    },
    {
      "path": "components/tool-ui/shared/media/safe-navigation.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/safe-navigation.ts",
      "content": "import { sanitizeHref } from \"./sanitize-href\";\n\nexport function resolveSafeNavigationHref(\n  ...candidates: Array<string | null | undefined>\n): string | undefined {\n  for (const candidate of candidates) {\n    const safeHref = sanitizeHref(candidate ?? undefined);\n    if (safeHref) {\n      return safeHref;\n    }\n  }\n\n  return undefined;\n}\n\nexport function openSafeNavigationHref(href: string | undefined): boolean {\n  if (!href || typeof window === \"undefined\") {\n    return false;\n  }\n\n  window.open(href, \"_blank\", \"noopener,noreferrer\");\n  return true;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/sanitize-href.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/sanitize-href.ts",
      "content": "/**\n * Sanitize a URL to ensure it's safe for use in href attributes.\n * Allows:\n * - Absolute http(s) URLs\n * - Relative URLs (/path, ./path, ../path, ?query, #hash)\n *\n * @returns The sanitized URL string, or undefined if invalid/unsafe\n */\nexport function sanitizeHref(href?: string): string | undefined {\n  if (!href) return undefined;\n  const candidate = href.trim();\n  if (!candidate) return undefined;\n\n  if (\n    candidate.startsWith(\"/\") ||\n    candidate.startsWith(\"./\") ||\n    candidate.startsWith(\"../\") ||\n    candidate.startsWith(\"?\") ||\n    candidate.startsWith(\"#\")\n  ) {\n    if (candidate.startsWith(\"//\")) return undefined;\n    // eslint-disable-next-line no-control-regex -- intentionally matching control characters\n    if (/[\\u0000-\\u001F\\u007F]/.test(candidate)) return undefined;\n    return candidate;\n  }\n\n  try {\n    const url = new URL(candidate);\n    if (url.protocol === \"http:\" || url.protocol === \"https:\") {\n      return url.toString();\n    }\n  } catch {\n    return undefined;\n  }\n  return undefined;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/parse.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/parse.ts",
      "content": "import { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n/**\n * Parse unknown input, returning `null` instead of throwing on failure.\n *\n * Use this in assistant-ui `render` functions where `args` stream in\n * incrementally and may be incomplete until the tool call finishes.\n */\nexport function safeParseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n): T | null {\n  const res = schema.safeParse(input);\n  return res.success ? res.data : null;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/schema.ts",
      "content": "import { z } from \"zod\";\nimport type { ReactNode } from \"react\";\n\n/**\n * Tool UI conventions:\n * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).\n * - Schema: `SerializableXSchema`\n * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)\n * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)\n * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions\n * - Root attrs: `data-tool-ui-id` + `data-slot`\n */\n\n/**\n * Schema for tool UI identity.\n *\n * Every tool UI should have a unique identifier that:\n * - Is stable across re-renders\n * - Is meaningful (not auto-generated)\n * - Is unique within the conversation\n *\n * Format recommendation: `{component-type}-{semantic-identifier}`\n * Examples: \"data-table-expenses-q3\", \"option-list-deploy-target\"\n */\nexport const ToolUIIdSchema = z.string().min(1);\n\nexport type ToolUIId = z.infer<typeof ToolUIIdSchema>;\n\n/**\n * Primary role of a Tool UI surface in a chat context.\n */\nexport const ToolUIRoleSchema = z.enum([\n  \"information\",\n  \"decision\",\n  \"control\",\n  \"state\",\n  \"composite\",\n]);\n\nexport type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;\n\nexport const ToolUIReceiptOutcomeSchema = z.enum([\n  \"success\",\n  \"partial\",\n  \"failed\",\n  \"cancelled\",\n]);\n\nexport type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;\n\n/**\n * Optional receipt metadata: a durable summary of an outcome.\n */\nexport const ToolUIReceiptSchema = z.object({\n  outcome: ToolUIReceiptOutcomeSchema,\n  summary: z.string().min(1),\n  identifiers: z.record(z.string(), z.string()).optional(),\n  at: z.string().datetime(),\n});\n\nexport type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;\n\n/**\n * Base schema for Tool UI payloads (id + optional role/receipt).\n */\nexport const ToolUISurfaceSchema = z.object({\n  id: ToolUIIdSchema,\n  role: ToolUIRoleSchema.optional(),\n  receipt: ToolUIReceiptSchema.optional(),\n});\n\nexport type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;\n\nexport const ActionSchema = z.object({\n  id: z.string().min(1),\n  label: z.string().min(1),\n  /**\n   * Canonical narration the assistant can use after this action is taken.\n   *\n   * Example: \"I exported the table as CSV.\" / \"I opened the link in a new tab.\"\n   */\n  sentence: z.string().optional(),\n  confirmLabel: z.string().optional(),\n  variant: z\n    .enum([\"default\", \"destructive\", \"secondary\", \"ghost\", \"outline\"])\n    .optional(),\n  icon: z.custom<ReactNode>().optional(),\n  loading: z.boolean().optional(),\n  disabled: z.boolean().optional(),\n  shortcut: z.string().optional(),\n});\n\nexport type Action = z.infer<typeof ActionSchema>;\nexport type LocalAction = Action;\nexport type DecisionAction = Action;\n\nexport const DecisionResultSchema = z.object({\n  kind: z.literal(\"decision\"),\n  version: z.literal(1),\n  decisionId: z.string().min(1),\n  actionId: z.string().min(1),\n  actionLabel: z.string().min(1),\n  at: z.string().datetime(),\n  payload: z.record(z.string(), z.unknown()).optional(),\n});\n\nexport type DecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n> = Omit<z.infer<typeof DecisionResultSchema>, \"payload\"> & {\n  payload?: TPayload;\n};\n\nexport function createDecisionResult<\n  TPayload extends Record<string, unknown> = Record<string, unknown>,\n>(args: {\n  decisionId: string;\n  action: { id: string; label: string };\n  payload?: TPayload;\n}): DecisionResult<TPayload> {\n  return {\n    kind: \"decision\",\n    version: 1,\n    decisionId: args.decisionId,\n    actionId: args.action.id,\n    actionLabel: args.action.label,\n    at: new Date().toISOString(),\n    payload: args.payload,\n  };\n}\n\nexport const ActionButtonsPropsSchema = z.object({\n  actions: z.array(ActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n  className: z.string().optional(),\n});\n\nexport const SerializableActionSchema = ActionSchema.omit({ icon: true });\nexport const SerializableActionsSchema = ActionButtonsPropsSchema.extend({\n  actions: z.array(SerializableActionSchema),\n}).omit({ className: true });\n\nexport interface ActionsConfig {\n  items: Action[];\n  align?: \"left\" | \"center\" | \"right\";\n  confirmTimeout?: number;\n}\n\nexport const SerializableActionsConfigSchema = z.object({\n  items: z.array(SerializableActionSchema).min(1),\n  align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n  confirmTimeout: z.number().positive().optional(),\n});\n\nexport type SerializableActionsConfig = z.infer<\n  typeof SerializableActionsConfigSchema\n>;\n\nexport type SerializableAction = z.infer<typeof SerializableActionSchema>;\n"
    }
  ]
}
