""" npm Registry Search for Design Systems. This module provides npm registry search capabilities to find design system packages when they're not in our built-in registry. """ import asyncio import aiohttp from dataclasses import dataclass from typing import List, Optional, Dict, Any import re NPM_REGISTRY_URL = "https://registry.npmjs.org" NPM_SEARCH_URL = "https://registry.npmjs.org/-/v1/search" @dataclass class NpmPackageInfo: """Information about an npm package.""" name: str version: str description: str keywords: List[str] homepage: Optional[str] repository: Optional[str] npm_url: str downloads_weekly: int = 0 is_design_system: bool = False confidence_score: float = 0.0 def to_dict(self) -> Dict[str, Any]: return { "name": self.name, "version": self.version, "description": self.description, "keywords": self.keywords, "homepage": self.homepage, "repository": self.repository, "npm_url": self.npm_url, "downloads_weekly": self.downloads_weekly, "is_design_system": self.is_design_system, "confidence_score": self.confidence_score, } # Keywords that indicate a design system package DESIGN_SYSTEM_KEYWORDS = { # High confidence "design-system": 3.0, "design-tokens": 3.0, "ui-kit": 2.5, "component-library": 2.5, "design tokens": 3.0, # Medium confidence "ui-components": 2.0, "react-components": 1.5, "vue-components": 1.5, "css-framework": 2.0, "theme": 1.5, "theming": 1.5, "tokens": 1.5, "styles": 1.0, "components": 1.0, # Low confidence (common in design systems) "ui": 0.5, "react": 0.3, "vue": 0.3, "css": 0.3, "scss": 0.3, "tailwind": 1.5, "material": 1.0, "bootstrap": 1.0, } def calculate_design_system_score(package_info: Dict[str, Any]) -> float: """ Calculate a confidence score that a package is a design system. Returns a score from 0.0 to 1.0. """ score = 0.0 # Check keywords keywords = package_info.get("keywords", []) or [] for keyword in keywords: keyword_lower = keyword.lower() for ds_keyword, weight in DESIGN_SYSTEM_KEYWORDS.items(): if ds_keyword in keyword_lower: score += weight # Check description description = (package_info.get("description", "") or "").lower() if "design system" in description: score += 3.0 if "design tokens" in description: score += 2.5 if "component library" in description: score += 2.0 if "ui components" in description: score += 1.5 if "ui kit" in description: score += 2.0 if any(word in description for word in ["react", "vue", "angular", "svelte"]): score += 0.5 if "css" in description: score += 0.3 # Check name patterns name = package_info.get("name", "").lower() if any(term in name for term in ["design", "theme", "ui", "components", "tokens"]): score += 1.0 if name.startswith("@"): # Scoped packages often are design systems score += 0.5 # Normalize to 0-1 range normalized_score = min(1.0, score / 10.0) return normalized_score async def search_npm( query: str, limit: int = 10, design_systems_only: bool = True ) -> List[NpmPackageInfo]: """ Search npm registry for packages matching the query. Args: query: Search query limit: Maximum number of results design_systems_only: If True, filter to likely design system packages Returns: List of NpmPackageInfo objects """ params = { "text": query, "size": limit * 3 if design_systems_only else limit, # Get more to filter } results = [] try: async with aiohttp.ClientSession() as session: async with session.get(NPM_SEARCH_URL, params=params) as response: if response.status != 200: return [] data = await response.json() for obj in data.get("objects", []): package = obj.get("package", {}) # Calculate design system score ds_score = calculate_design_system_score(package) is_design_system = ds_score >= 0.3 if design_systems_only and not is_design_system: continue # Extract repository URL repo = package.get("links", {}).get("repository") if not repo: repo_info = package.get("repository") if isinstance(repo_info, dict): repo = repo_info.get("url", "") elif isinstance(repo_info, str): repo = repo_info info = NpmPackageInfo( name=package.get("name", ""), version=package.get("version", ""), description=package.get("description", ""), keywords=package.get("keywords", []) or [], homepage=package.get("links", {}).get("homepage"), repository=repo, npm_url=f"https://www.npmjs.com/package/{package.get('name', '')}", downloads_weekly=obj.get("downloads", {}).get("weekly", 0) if obj.get("downloads") else 0, is_design_system=is_design_system, confidence_score=ds_score, ) results.append(info) if len(results) >= limit: break except Exception as e: print(f"npm search error: {e}") return [] # Sort by confidence score results.sort(key=lambda x: x.confidence_score, reverse=True) return results async def get_package_info(package_name: str) -> Optional[NpmPackageInfo]: """ Get detailed information about a specific npm package. """ url = f"{NPM_REGISTRY_URL}/{package_name}" try: async with aiohttp.ClientSession() as session: async with session.get(url) as response: if response.status != 200: return None data = await response.json() # Get latest version info latest_version = data.get("dist-tags", {}).get("latest", "") version_info = data.get("versions", {}).get(latest_version, {}) # Extract repository URL repo = data.get("repository") repo_url = None if isinstance(repo, dict): repo_url = repo.get("url", "") elif isinstance(repo, str): repo_url = repo # Clean up repo URL if repo_url: repo_url = re.sub(r'^git\+', '', repo_url) repo_url = re.sub(r'\.git$', '', repo_url) repo_url = repo_url.replace('git://', 'https://') repo_url = repo_url.replace('ssh://git@', 'https://') ds_score = calculate_design_system_score({ "name": data.get("name", ""), "description": data.get("description", ""), "keywords": data.get("keywords", []), }) return NpmPackageInfo( name=data.get("name", ""), version=latest_version, description=data.get("description", ""), keywords=data.get("keywords", []) or [], homepage=data.get("homepage"), repository=repo_url, npm_url=f"https://www.npmjs.com/package/{package_name}", is_design_system=ds_score >= 0.3, confidence_score=ds_score, ) except Exception as e: print(f"npm package info error: {e}") return None async def verify_package_exists(package_name: str) -> bool: """Check if an npm package exists.""" url = f"{NPM_REGISTRY_URL}/{package_name}" try: async with aiohttp.ClientSession() as session: async with session.head(url) as response: return response.status == 200 except Exception: return False async def get_package_files(package_name: str, version: str = "latest") -> List[str]: """ Get list of files in an npm package (for finding token files). Uses unpkg.com to browse package contents. """ url = f"https://unpkg.com/{package_name}@{version}/?meta" try: async with aiohttp.ClientSession() as session: async with session.get(url) as response: if response.status != 200: return [] data = await response.json() def extract_files(node: Dict, prefix: str = "") -> List[str]: files = [] if node.get("type") == "file": files.append(f"{prefix}/{node.get('path', '')}") elif node.get("type") == "directory": for child in node.get("files", []): files.extend(extract_files(child, prefix)) return files return extract_files(data) except Exception as e: print(f"Error getting package files: {e}") return [] def find_token_files(file_list: List[str]) -> Dict[str, List[str]]: """ Identify potential design token files from a list of package files. """ token_files = { "json_tokens": [], "css_variables": [], "scss_variables": [], "js_tokens": [], "style_dictionary": [], } for file_path in file_list: lower_path = file_path.lower() # JSON tokens if lower_path.endswith(".json"): if any(term in lower_path for term in ["token", "theme", "color", "spacing", "typography"]): token_files["json_tokens"].append(file_path) # CSS variables elif lower_path.endswith(".css"): if any(term in lower_path for term in ["variables", "tokens", "theme", "custom-properties"]): token_files["css_variables"].append(file_path) # SCSS variables elif lower_path.endswith((".scss", ".sass")): if any(term in lower_path for term in ["variables", "tokens", "_variables", "_tokens"]): token_files["scss_variables"].append(file_path) # JS/TS tokens elif lower_path.endswith((".js", ".ts", ".mjs")): if any(term in lower_path for term in ["theme", "tokens", "colors", "spacing"]): token_files["js_tokens"].append(file_path) # Style Dictionary elif "style-dictionary" in lower_path or "tokens.json" in lower_path: token_files["style_dictionary"].append(file_path) return token_files # Synchronous wrapper for use in non-async contexts def search_npm_sync(query: str, limit: int = 10, design_systems_only: bool = True) -> List[NpmPackageInfo]: """Synchronous wrapper for search_npm.""" return asyncio.run(search_npm(query, limit, design_systems_only)) def get_package_info_sync(package_name: str) -> Optional[NpmPackageInfo]: """Synchronous wrapper for get_package_info.""" return asyncio.run(get_package_info(package_name))