/** * Helper functions for PyPI registry * Package name normalization, HTML generation, etc. */ import type { IPypiFile, IPypiPackageMetadata } from './interfaces.pypi.js'; /** * Normalize package name according to PEP 503 * Lowercase and replace runs of [._-] with a single dash * @param name - Package name * @returns Normalized name */ export function normalizePypiPackageName(name: string): string { return name .toLowerCase() .replace(/[-_.]+/g, '-'); } /** * Escape HTML special characters to prevent XSS * @param str - String to escape * @returns Escaped string */ export function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Generate PEP 503 compliant HTML for root index (all packages) * @param packages - List of package names * @returns HTML string */ export function generateSimpleRootHtml(packages: string[]): string { const links = packages .map(pkg => { const normalized = normalizePypiPackageName(pkg); return ` ${escapeHtml(pkg)}`; }) .join('\n'); return ` Simple Index

Simple Index

${links} `; } /** * Generate PEP 503 compliant HTML for package index (file list) * @param packageName - Package name (normalized) * @param files - List of files * @param baseUrl - Base URL for downloads * @returns HTML string */ export function generateSimplePackageHtml( packageName: string, files: IPypiFile[], baseUrl: string ): string { const links = files .map(file => { // Build URL let url = file.url; if (!url.startsWith('http://') && !url.startsWith('https://')) { // Relative URL - make it absolute url = `${baseUrl}/packages/${packageName}/${file.filename}`; } // Add hash fragment const hashName = Object.keys(file.hashes)[0]; const hashValue = file.hashes[hashName]; const fragment = hashName && hashValue ? `#${hashName}=${hashValue}` : ''; // Build data attributes const dataAttrs: string[] = []; if (file['requires-python']) { const escaped = escapeHtml(file['requires-python']); dataAttrs.push(`data-requires-python="${escaped}"`); } if (file['gpg-sig'] !== undefined) { dataAttrs.push(`data-gpg-sig="${file['gpg-sig'] ? 'true' : 'false'}"`); } if (file.yanked) { const reason = typeof file.yanked === 'string' ? file.yanked : ''; if (reason) { dataAttrs.push(`data-yanked="${escapeHtml(reason)}"`); } else { dataAttrs.push(`data-yanked=""`); } } const dataAttrStr = dataAttrs.length > 0 ? ' ' + dataAttrs.join(' ') : ''; return ` ${escapeHtml(file.filename)}`; }) .join('\n'); return ` Links for ${escapeHtml(packageName)}

Links for ${escapeHtml(packageName)}

${links} `; } /** * Parse filename to extract package info * Supports wheel and sdist formats * @param filename - Package filename * @returns Parsed info or null */ export function parsePackageFilename(filename: string): { name: string; version: string; filetype: 'bdist_wheel' | 'sdist'; pythonVersion?: string; } | null { // Wheel format: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl const wheelMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+?)(?:-(\d+))?-([^-]+)-([^-]+)-([^-]+)\.whl$/); if (wheelMatch) { return { name: wheelMatch[1], version: wheelMatch[2], filetype: 'bdist_wheel', pythonVersion: wheelMatch[4], }; } // Sdist tar.gz format: {name}-{version}.tar.gz const sdistTarMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+)\.tar\.gz$/); if (sdistTarMatch) { return { name: sdistTarMatch[1], version: sdistTarMatch[2], filetype: 'sdist', pythonVersion: 'source', }; } // Sdist zip format: {name}-{version}.zip const sdistZipMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+)\.zip$/); if (sdistZipMatch) { return { name: sdistZipMatch[1], version: sdistZipMatch[2], filetype: 'sdist', pythonVersion: 'source', }; } return null; } /** * Calculate hash digest for a buffer * @param data - Data to hash * @param algorithm - Hash algorithm (sha256, md5, blake2b) * @returns Hex-encoded hash */ export async function calculateHash(data: Buffer, algorithm: 'sha256' | 'md5' | 'blake2b'): Promise { const crypto = await import('crypto'); let hash: any; if (algorithm === 'blake2b') { // Node.js uses 'blake2b512' for blake2b hash = crypto.createHash('blake2b512'); } else { hash = crypto.createHash(algorithm); } hash.update(data); return hash.digest('hex'); } /** * Validate package name * Must contain only ASCII letters, numbers, ., -, and _ * @param name - Package name * @returns true if valid */ export function isValidPackageName(name: string): boolean { return /^[a-zA-Z0-9._-]+$/.test(name); } /** * Validate version string (basic check) * @param version - Version string * @returns true if valid */ export function isValidVersion(version: string): boolean { // Basic check - allows numbers, letters, dots, hyphens, underscores // More strict validation would follow PEP 440 return /^[a-zA-Z0-9._-]+$/.test(version); } /** * Extract metadata from package metadata * Filters and normalizes metadata fields * @param metadata - Raw metadata object * @returns Filtered metadata */ export function extractCoreMetadata(metadata: Record): Record { const coreFields = [ 'metadata-version', 'name', 'version', 'platform', 'supported-platform', 'summary', 'description', 'description-content-type', 'keywords', 'home-page', 'download-url', 'author', 'author-email', 'maintainer', 'maintainer-email', 'license', 'classifier', 'requires-python', 'requires-dist', 'requires-external', 'provides-dist', 'project-url', 'provides-extra', ]; const result: Record = {}; for (const [key, value] of Object.entries(metadata)) { const normalizedKey = key.toLowerCase().replace(/_/g, '-'); if (coreFields.includes(normalizedKey)) { result[normalizedKey] = value; } } return result; } /** * Generate JSON API response for package list (PEP 691) * @param packages - List of package names * @returns JSON object */ export function generateJsonRootResponse(packages: string[]): any { return { meta: { 'api-version': '1.0', }, projects: packages.map(name => ({ name })), }; } /** * Generate JSON API response for package files (PEP 691) * @param packageName - Package name (normalized) * @param files - List of files * @returns JSON object */ export function generateJsonPackageResponse(packageName: string, files: IPypiFile[]): any { return { meta: { 'api-version': '1.0', }, name: packageName, files: files.map(file => ({ filename: file.filename, url: file.url, hashes: file.hashes, 'requires-python': file['requires-python'], 'dist-info-metadata': file['dist-info-metadata'], 'gpg-sig': file['gpg-sig'], yanked: file.yanked, size: file.size, 'upload-time': file['upload-time'], })), }; }