diff --git a/apps/web/app/components/Layout.tsx b/apps/web/app/components/Layout.tsx
index 4d990fa..6b7af7b 100644
--- a/apps/web/app/components/Layout.tsx
+++ b/apps/web/app/components/Layout.tsx
@@ -11,6 +11,7 @@ import {
User,
Settings,
LogOut,
+ Boxes,
} from "lucide-react";
import { ThemeToggle } from "./ThemeToggle";
import { useNavTransition } from "./NavProgress";
@@ -102,6 +103,10 @@ export function Header() {
{z.name}
))}
+
+
+ CNPM
+
API
@@ -317,6 +322,12 @@ function MobileNavTrigger({ visibleZones = [] }: { visibleZones?: any[] }) {
>
API
+
+ CNPM
+
该版本没有依赖信息
;
+ }
+
+ const groups = GROUPS.filter((group) => {
+ const deps = versionData[group.key];
+ return deps && Object.keys(deps).length > 0;
+ });
+
+ if (groups.length === 0) {
+ return 该版本没有任何依赖
;
+ }
+
+ return (
+
+ {groups.map((group) => {
+ const deps = versionData[group.key]!;
+ const entries = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b));
+ return (
+
+
+ {group.label}
+ {entries.length}
+
+
+
+
+ 名称
+ 版本范围
+
+
+
+ {entries.map(([pkg, spec]) => (
+
+
+
+ {pkg}
+
+
+ {spec}
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/DownloadCard.tsx b/apps/web/app/components/cnpm/DownloadCard.tsx
new file mode 100644
index 0000000..86f8b1d
--- /dev/null
+++ b/apps/web/app/components/cnpm/DownloadCard.tsx
@@ -0,0 +1,132 @@
+import { getDownloads, useRegistryQuery } from "~/lib/registry/client";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "~/components/ui/chart";
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
+import { Skeleton } from "~/components/ui/skeleton";
+import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import { Button } from "~/components/ui/button";
+
+const chartConfig = {
+ downloads: {
+ label: "下载量",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig;
+
+export function DownloadCard({
+ pkgName,
+ range = 7,
+}: {
+ pkgName: string;
+ range?: number;
+}) {
+ const { data, error, loading, retry } = useRegistryQuery(
+ () => getDownloads(pkgName, range),
+ [pkgName, range],
+ );
+
+ if (loading) {
+ return (
+
+
+ 下载量
+
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ 下载量
+
+
+
+
+ 下载数据加载失败
+
+
+
+
+
+ );
+ }
+
+ const points = data?.downloads || [];
+ const total = points.reduce((sum, point) => sum + point.downloads, 0);
+
+ if (points.length === 0) {
+ return (
+
+
+ 下载量
+
+
+
+ 暂无数据
+ 该包暂无下载数据
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ 近 {range} 天下载
+
+ {total.toLocaleString("en-US")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ value.slice(5)}
+ />
+ } />
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/FilesView.tsx b/apps/web/app/components/cnpm/FilesView.tsx
new file mode 100644
index 0000000..723c912
--- /dev/null
+++ b/apps/web/app/components/cnpm/FilesView.tsx
@@ -0,0 +1,284 @@
+import { useCallback, useState } from "react";
+import { useSearchParams } from "react-router";
+import { ChevronDown, ChevronRight, File as FileIcon, Folder } from "lucide-react";
+import { getDir, getFileContent, useRegistryQuery } from "~/lib/registry/client";
+import type { RegistryFile } from "~/lib/registry/types";
+import { cn } from "~/lib/utils";
+import { Skeleton } from "~/components/ui/skeleton";
+import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty";
+import { Alert, AlertDescription } from "~/components/ui/alert";
+import { Button } from "~/components/ui/button";
+import hljs from "highlight.js/lib/common";
+
+export function FilesView({ pkgName, spec }: { pkgName: string; spec: string }) {
+ const [params, setParams] = useSearchParams();
+ const selectedPath = params.get("path") || "";
+ const [dirChildren, setDirChildren] = useState>({});
+ const [expanded, setExpanded] = useState>({});
+ const [dirLoading, setDirLoading] = useState>({});
+ const [dirError, setDirError] = useState(null);
+
+ const {
+ data: root,
+ error: rootError,
+ loading: rootLoading,
+ retry,
+ } = useRegistryQuery(() => getDir(pkgName, spec, ""), [pkgName, spec]);
+
+ const loadDir = useCallback(
+ async (path: string) => {
+ if (dirChildren[path] || dirLoading[path]) return;
+ setDirLoading((prev) => ({ ...prev, [path]: true }));
+ setDirError(null);
+ try {
+ const res = await getDir(pkgName, spec, path);
+ setDirChildren((prev) => ({ ...prev, [path]: res.files || [] }));
+ } catch (err) {
+ setDirChildren((prev) => {
+ const next = { ...prev };
+ delete next[path];
+ return next;
+ });
+ setExpanded((prev) => ({ ...prev, [path]: false }));
+ setDirError(err instanceof Error ? err.message : "目录加载失败");
+ } finally {
+ setDirLoading((prev) => ({ ...prev, [path]: false }));
+ }
+ },
+ [pkgName, spec, dirChildren, dirLoading],
+ );
+
+ const toggleDir = useCallback(
+ (path: string) => {
+ setExpanded((prev) => {
+ const next = !prev[path];
+ if (next) void loadDir(path);
+ return { ...prev, [path]: next };
+ });
+ },
+ [loadDir],
+ );
+
+ const selectFile = useCallback(
+ (path: string) => {
+ const next = new URLSearchParams(params);
+ if (path) next.set("path", path);
+ else next.delete("path");
+ setParams(next, { replace: true });
+ },
+ [params, setParams],
+ );
+
+ if (rootLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (rootError) {
+ return (
+
+
+ 产物预览失败
+
+
+
+ );
+ }
+
+ if (!root?.files || root.files.length === 0) {
+ return (
+
+ 没有文件
+ 该版本没有可浏览的文件
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {selectedPath ? (
+
+ ) : (
+
+ )}
+
+ {dirError && (
+
+ 目录加载失败:{dirError},请重新展开重试
+
+ )}
+
+ );
+}
+
+function FileNode({
+ entries,
+ depth,
+ selectedPath,
+ onSelect,
+ onToggleDir,
+ dirChildren,
+ expanded,
+ dirLoading,
+}: {
+ entries: RegistryFile[];
+ depth: number;
+ selectedPath: string;
+ onSelect: (path: string) => void;
+ onToggleDir: (path: string) => void;
+ dirChildren: Record;
+ expanded: Record;
+ dirLoading: Record;
+}) {
+ const sorted = [...entries].sort((a, b) => {
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
+ return a.path.localeCompare(b.path);
+ });
+
+ return (
+
+ {sorted.map((entry) => {
+ if (entry.type === "directory") {
+ const isExpanded = !!expanded[entry.path];
+ const children = dirChildren[entry.path] ?? entry.files ?? [];
+ const isLoading = !!dirLoading[entry.path];
+ return (
+ -
+
+ {isExpanded && children.length > 0 && (
+
+ )}
+
+ );
+ }
+ const isSelected = entry.path === selectedPath;
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
+
+function FileViewer({ pkgName, spec, path }: { pkgName: string; spec: string; path: string }) {
+ const { data: content, error, loading, retry } = useRegistryQuery(
+ () => getFileContent(pkgName, spec, path),
+ [pkgName, spec, path],
+ );
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
+ 文件加载失败
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+const HIGHLIGHT_MAX_LENGTH = 256 * 1024;
+
+function highlighted(code: string) {
+ if (code.length > HIGHLIGHT_MAX_LENGTH) return escapeHtml(code);
+ try {
+ return hljs.highlightAuto(code).value;
+ } catch {
+ return escapeHtml(code);
+ }
+}
+
+function escapeHtml(value: string) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+function basename(path: string) {
+ const normalized = path.replace(/\/+$/, "");
+ const index = normalized.lastIndexOf("/");
+ return index >= 0 ? normalized.slice(index + 1) : normalized;
+}
diff --git a/apps/web/app/components/cnpm/MaintainersCard.tsx b/apps/web/app/components/cnpm/MaintainersCard.tsx
new file mode 100644
index 0000000..cdc561f
--- /dev/null
+++ b/apps/web/app/components/cnpm/MaintainersCard.tsx
@@ -0,0 +1,46 @@
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
+import type { RegistryManifest } from "~/lib/registry/types";
+import { gravatarHash, gravatarUrl, initials } from "~/lib/registry/gravatar";
+
+type Maintainer = NonNullable[number];
+
+export function MaintainersCard({ maintainers }: { maintainers: Maintainer[] }) {
+ return (
+
+
+ 维护者
+
+
+ {maintainers.map((maintainer) => (
+
+ ))}
+
+
+ );
+}
+
+function MaintainerRow({ maintainer }: { maintainer: Maintainer }) {
+ const hash = gravatarHash(maintainer.email);
+
+ return (
+
+
+
+ {initials(maintainer.name)}
+
+
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/NpmSearchForm.tsx b/apps/web/app/components/cnpm/NpmSearchForm.tsx
new file mode 100644
index 0000000..ddd2f34
--- /dev/null
+++ b/apps/web/app/components/cnpm/NpmSearchForm.tsx
@@ -0,0 +1,77 @@
+import { useNavigate } from "react-router";
+import { useEffect, useRef, useState } from "react";
+import { Search as SearchIcon } from "lucide-react";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from "~/components/ui/input-group";
+import { cn } from "~/lib/utils";
+
+export function NpmSearchForm({
+ initialValue = "",
+ autoFocus = false,
+ size = "default",
+ className,
+}: {
+ initialValue?: string;
+ autoFocus?: boolean;
+ size?: "default" | "lg";
+ className?: string;
+}) {
+ const navigate = useNavigate();
+ const [value, setValue] = useState(initialValue);
+ const inputRef = useRef(null);
+ const large = size === "lg";
+
+ useEffect(() => {
+ setValue(initialValue);
+ }, [initialValue]);
+
+ useEffect(() => {
+ if (autoFocus) inputRef.current?.focus();
+ }, [autoFocus]);
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/PkgHeader.tsx b/apps/web/app/components/cnpm/PkgHeader.tsx
new file mode 100644
index 0000000..329e6fa
--- /dev/null
+++ b/apps/web/app/components/cnpm/PkgHeader.tsx
@@ -0,0 +1,127 @@
+import { useState } from "react";
+import { Check, Copy, ExternalLink, GitFork, Globe, Package as PackageIcon } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { sortVersions, getVersionTags, repoUrl, safeExternalUrl, type PkgTab } from "~/lib/registry/parse";
+import type { RegistryManifest } from "~/lib/registry/types";
+import { PkgTabs } from "./PkgTabs";
+
+export function PkgHeader({
+ manifest,
+ version,
+ active,
+ onVersionChange,
+}: {
+ manifest: RegistryManifest;
+ version: string;
+ active: PkgTab;
+ onVersionChange: (version: string) => void;
+}) {
+ const versions = sortVersions(manifest.versions);
+ const tags = getVersionTags(manifest);
+ const [copied, setCopied] = useState(false);
+ const installCommand = `npm install ${manifest.name}@${version}`;
+ const repo = repoUrl(manifest.repository);
+
+ const handleVersionChange = (value: string | null) => {
+ if (value) onVersionChange(value);
+ };
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(installCommand);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1600);
+ } catch {
+ // clipboard unavailable
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {manifest.name}
+ {version}
+
+ {tags[version]?.length ? (
+
+ {tags[version].join(", ")}
+
+ ) : null}
+
+ {manifest.description && (
+
{manifest.description}
+ )}
+
+ {manifest.license && (
+
{manifest.license}
+ )}
+ {repo && (
+
+ 源码
+
+ )}
+ {safeExternalUrl(manifest.homepage) && (
+
+ 主页
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/PkgSidebar.tsx b/apps/web/app/components/cnpm/PkgSidebar.tsx
new file mode 100644
index 0000000..a29e5fc
--- /dev/null
+++ b/apps/web/app/components/cnpm/PkgSidebar.tsx
@@ -0,0 +1,56 @@
+import { Download, ExternalLink, GitFork, Globe, Package as PackageIcon } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
+import { DownloadCard } from "./DownloadCard";
+import { MaintainersCard } from "./MaintainersCard";
+import { formatBytes, repoUrl, safeExternalUrl } from "~/lib/registry/parse";
+import type { RegistryManifest } from "~/lib/registry/types";
+
+export function PkgSidebar({ manifest, version }: { manifest: RegistryManifest; version: string }) {
+ const repo = repoUrl(manifest.repository);
+ const dist = manifest.versions?.[version]?.dist;
+ const links: Array<{ label: string; href?: string; icon: React.ReactNode } | null> = [
+ { label: "仓库", href: repo, icon: },
+ { label: "主页", href: safeExternalUrl(manifest.homepage), icon: },
+ { label: "npmjs.com", href: `https://www.npmjs.com/package/${manifest.name}`, icon: },
+ { label: "unpkg", href: `https://unpkg.com/${manifest.name}@${version}`, icon: },
+ dist?.tarball
+ ? {
+ label: `tarball${dist.size !== undefined ? ` · ${formatBytes(dist.size)}` : ""}`,
+ href: safeExternalUrl(dist.tarball),
+ icon: ,
+ }
+ : null,
+ ];
+ const visibleLinks = links.filter(
+ (link): link is { label: string; href: string; icon: React.ReactNode } => Boolean(link && link.href),
+ );
+
+ return (
+
+
+ {manifest.maintainers && manifest.maintainers.length > 0 && (
+
+ )}
+
+
+ 资源信息
+
+
+ {visibleLinks.map((link) => (
+
+ {link.icon}
+ {link.label}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/PkgTabs.tsx b/apps/web/app/components/cnpm/PkgTabs.tsx
new file mode 100644
index 0000000..714e500
--- /dev/null
+++ b/apps/web/app/components/cnpm/PkgTabs.tsx
@@ -0,0 +1,48 @@
+import { useNavigate } from "react-router";
+import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import type { PkgTab } from "~/lib/registry/parse";
+
+const TABS: Array<{ key: PkgTab; label: string }> = [
+ { key: "home", label: "首页" },
+ { key: "versions", label: "版本" },
+ { key: "files", label: "文件" },
+ { key: "deps", label: "依赖" },
+ { key: "trends", label: "趋势" },
+];
+
+export function PkgTabs({
+ name,
+ active,
+ version,
+}: {
+ name: string;
+ active: PkgTab;
+ version?: string;
+}) {
+ const navigate = useNavigate();
+ const versionQuery = version ? `?version=${encodeURIComponent(version)}` : "";
+
+ const handleChange = (value: string | number) => {
+ const tab = String(value) as PkgTab;
+ void navigate(
+ tab === "home"
+ ? `/cnpm/pkg/${name}${versionQuery}`
+ : `/cnpm/pkg/${name}/${tab}${versionQuery}`,
+ );
+ };
+
+ return (
+
+
+ {TABS.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/RecentVisited.tsx b/apps/web/app/components/cnpm/RecentVisited.tsx
new file mode 100644
index 0000000..7d2dceb
--- /dev/null
+++ b/apps/web/app/components/cnpm/RecentVisited.tsx
@@ -0,0 +1,38 @@
+import { Link } from "react-router";
+import { X } from "lucide-react";
+import { useRecentVisited } from "~/lib/registry/use-recent";
+import { Button } from "~/components/ui/button";
+
+export function RecentVisited() {
+ const { recent, removeRecent } = useRecentVisited();
+ if (recent.length === 0) return null;
+
+ return (
+
+ 最近访问
+ {recent.map((name) => (
+
+
+ {name}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/RegistryGuide.tsx b/apps/web/app/components/cnpm/RegistryGuide.tsx
new file mode 100644
index 0000000..cc53f5b
--- /dev/null
+++ b/apps/web/app/components/cnpm/RegistryGuide.tsx
@@ -0,0 +1,90 @@
+import { useState } from "react";
+import { Check, Copy, ExternalLink } from "lucide-react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
+import { Button } from "~/components/ui/button";
+
+const STEPS = [
+ {
+ title: "临时使用",
+ description: "单次安装时指定镜像源,不影响全局配置",
+ command: "npm install react --registry=https://registry.npmmirror.com",
+ },
+ {
+ title: "全局配置",
+ description: "将默认 registry 指向 npmmirror,之后安装自动走镜像",
+ command: "npm config set registry https://registry.npmmirror.com",
+ },
+ {
+ title: "验证配置",
+ description: "确认当前 registry 是否为镜像地址",
+ command: "npm config get registry",
+ },
+];
+
+const REGISTRY_URL = "https://registry.npmmirror.com";
+
+function CopyCommand({ command }: { command: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(command);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1600);
+ } catch {
+ // clipboard unavailable
+ }
+ };
+
+ return (
+
+
+ {command}
+
+
+
+ );
+}
+
+export function RegistryGuide() {
+ return (
+
+
+ 使用 npmmirror 镜像
+
+ 通过国内镜像源加速 npm 安装,官方镜像地址:
+
+ {REGISTRY_URL}
+
+
+
+
+
+ {STEPS.map((step, index) => (
+
+
+ {index + 1}.
+
{step.title}
+
+
{step.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/RegistryStats.tsx b/apps/web/app/components/cnpm/RegistryStats.tsx
new file mode 100644
index 0000000..fd2a752
--- /dev/null
+++ b/apps/web/app/components/cnpm/RegistryStats.tsx
@@ -0,0 +1,43 @@
+import { getRegistryStats, useRegistryQuery } from "~/lib/registry/client";
+import { formatCompactNumber } from "~/lib/registry/parse";
+import { Skeleton } from "~/components/ui/skeleton";
+
+export function RegistryStats() {
+ const { data, loading } = useRegistryQuery(getRegistryStats, []);
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!data) {
+ return null;
+ }
+ if (typeof data.doc_count !== "number") {
+ return null;
+ }
+
+ const items = [
+ { label: "包数量", value: formatCompactNumber(data.doc_count) },
+ { label: "本周下载", value: formatCompactNumber(data.download?.thisweek) },
+ { label: "今日下载", value: formatCompactNumber(data.download?.today) },
+ ];
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.value}
+
+ {item.label}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/components/cnpm/VersionTable.tsx b/apps/web/app/components/cnpm/VersionTable.tsx
new file mode 100644
index 0000000..4884602
--- /dev/null
+++ b/apps/web/app/components/cnpm/VersionTable.tsx
@@ -0,0 +1,73 @@
+import { Link } from "react-router";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "~/components/ui/table";
+import { Badge } from "~/components/ui/badge";
+import { formatBytes, getVersionTags, sortVersions } from "~/lib/registry/parse";
+import type { RegistryManifest } from "~/lib/registry/types";
+
+const dateFormatter = new Intl.DateTimeFormat("en-CA", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+});
+
+function formatDate(value: number | string | undefined) {
+ if (value === undefined || value === null || value === "") return "-";
+ const numeric = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
+ const date = new Date(numeric);
+ if (Number.isNaN(date.getTime())) return "-";
+ return dateFormatter.format(date);
+}
+
+export function VersionTable({ manifest, version }: { manifest: RegistryManifest; version: string }) {
+ const versions = sortVersions(manifest.versions);
+ const tags = getVersionTags(manifest);
+
+ return (
+
+
+
+ 版本
+ 标签
+ 发布时间
+ 大小
+
+
+
+ {versions.map((item) => (
+
+
+
+ {item.version}
+
+
+
+
+ {tags[item.version]?.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ {formatDate(item.publish_time ?? item._cnpmcore_publish_time)}
+
+
+ {formatBytes(item.dist?.size ?? item.dist?.unpackedSize)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/app/components/ui/chart.tsx b/apps/web/app/components/ui/chart.tsx
new file mode 100644
index 0000000..0c8f983
--- /dev/null
+++ b/apps/web/app/components/ui/chart.tsx
@@ -0,0 +1,371 @@
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+import type { TooltipValueType } from "recharts"
+
+import { cn } from "~/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+const INITIAL_DIMENSION = { width: 320, height: 200 } as const
+type TooltipNameType = number | string
+
+export type ChartConfig = Record<
+ string,
+ {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+>
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ initialDimension = INITIAL_DIMENSION,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+ initialDimension?: {
+ width: number
+ height: number
+ }
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${(id ?? uniqueId).replace(/[^a-zA-Z0-9-]/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme ?? config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+