├── .gitignore ├── test ├── setup.js ├── utils.test.js ├── cli.test.js └── index.test.js ├── vitest.config.js ├── .husky └── pre-push ├── src └── icons │ ├── MdiHome.svelte │ └── FluentSearch12Regular.svelte ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── release.yml │ └── ci.yml ├── CHANGELOG.md ├── package.json ├── README.md ├── cli.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules/ -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | // Test setup file 2 | import { vi } from 'vitest'; 3 | 4 | // Global test setup - minimal mocking here 5 | // Individual test files should handle their own specific mocks 6 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | globals: true, 7 | setupFiles: ['./test/setup.js'] 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | echo "🧪 Running tests before push..." 5 | npm run test:run 6 | 7 | if [ $? -ne 0 ]; then 8 | echo "❌ Tests failed! Push aborted." 9 | exit 1 10 | fi 11 | 12 | echo "✅ All tests passed! Proceeding with push..." 13 | -------------------------------------------------------------------------------- /src/icons/MdiHome.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /src/icons/FluentSearch12Regular.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run command: `npx svelicon ...` 16 | 2. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots/Output** 22 | If applicable, add screenshots or command output to help explain your problem. 23 | 24 | **Environment (please complete the following information):** 25 | - OS: [e.g. macOS, Windows, Linux] 26 | - Node.js version: [e.g. 18.x, 20.x] 27 | - Svelicon version: [e.g. 2.0.0-beta.1] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | - Automated CI/CD pipeline with NPM publishing 7 | - Path traversal security protection 8 | - Pre-push git hooks for testing 9 | - Version management scripts 10 | 11 | ### Security 12 | - Fixed path traversal vulnerability 13 | - Added security audit in CI pipeline 14 | 15 | ## [2.0.0-beta.1] - 2024-11-14 16 | 17 | ### Added 18 | - Colon-to-slash icon format conversion (`fluent:home-24-filled` → `fluent/home-24-filled`) 19 | - Batch icon downloads with comma-separated lists 20 | - TypeScript support with `--withts` flag 21 | - Comprehensive test suite with 39+ tests 22 | - CLI commands: `download` and `search` 23 | - Interactive icon selection 24 | - Progress tracking for batch operations 25 | - Concurrency control for API requests 26 | - TSConfig validation and path mapping suggestions 27 | 28 | ### Changed 29 | - Improved error handling and user feedback 30 | - Better component name generation 31 | - Enhanced CLI interface with Commander.js 32 | 33 | ### Fixed 34 | - Icon name parsing edge cases 35 | - Component generation for complex icon names 36 | - File path handling improvements 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelicon", 3 | "version": "2.0.2", 4 | "description": "✨ Svelte Icon Library - Search, discover and download Iconify icons as Svelte components with TypeScript support", 5 | "keywords": [ 6 | "svelte", 7 | "svelte5", 8 | "svelte icon", 9 | "icons", 10 | "svg", 11 | "iconify", 12 | "search", 13 | "batch download", 14 | "typescript", 15 | "cli", 16 | "parallel", 17 | "font awesome", 18 | "fluent", 19 | "lucide", 20 | "material design" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/friendofsvelte/svelicon.git" 25 | }, 26 | "main": "index.js", 27 | "type": "module", 28 | "bin": { 29 | "svelicon": "cli.js" 30 | }, 31 | "dependencies": { 32 | "@iconify/utils": "^2.1.5", 33 | "axios": "^1.6.2", 34 | "commander": "^11.1.0", 35 | "mkdirp": "^3.0.1", 36 | "p-limit": "^6.2.0" 37 | }, 38 | "devDependencies": { 39 | "vitest": "^2.1.0", 40 | "jsdom": "^25.0.0", 41 | "@vitest/ui": "^2.1.0", 42 | "husky": "^8.0.3" 43 | }, 44 | "scripts": { 45 | "test": "vitest", 46 | "test:ui": "vitest --ui", 47 | "test:run": "vitest run", 48 | "prepare": "husky install", 49 | "pre-push": "npm run test:run", 50 | "version:patch": "npm version patch", 51 | "version:minor": "npm version minor", 52 | "version:major": "npm version major", 53 | "version:beta": "npm version prerelease --preid=beta", 54 | "release": "npm run test:run && npm run version:patch", 55 | "release:minor": "npm run test:run && npm run version:minor", 56 | "release:major": "npm run test:run && npm run version:major", 57 | "release:beta": "npm run test:run && npm run version:beta" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20.x' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Run tests 26 | run: npm run test:run 27 | 28 | - name: Run security audit 29 | run: | 30 | echo "🔍 Running security audit for release..." 31 | # Only fail on high/critical vulnerabilities in runtime dependencies 32 | npm audit --audit-level=high || { 33 | echo "⚠️ High/Critical vulnerabilities found. Checking if they affect runtime..." 34 | if npm audit --json | jq -r '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical") | .value.via[] | select(type == "object") | .dependency' | grep -v -E '^(vitest|@vitest|vite|esbuild)'; then 35 | echo "❌ Runtime dependencies have high/critical vulnerabilities! Cannot release." 36 | exit 1 37 | else 38 | echo "✅ Only development dependencies affected, safe to release" 39 | fi 40 | } 41 | 42 | publish: 43 | needs: test 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - name: Checkout code 48 | uses: actions/checkout@v4 49 | 50 | - name: Setup Node.js 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: '20.x' 54 | cache: 'npm' 55 | registry-url: 'https://registry.npmjs.org' 56 | 57 | - name: Install dependencies 58 | run: npm ci 59 | 60 | - name: Publish to NPM 61 | run: npm publish 62 | env: 63 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 64 | 65 | - name: Create GitHub Release 66 | uses: actions/create-release@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | tag_name: ${{ github.ref }} 71 | release_name: Release ${{ github.ref }} 72 | body: | 73 | ## Changes 74 | 75 | See [CHANGELOG.md](CHANGELOG.md) for details. 76 | 77 | ## Installation 78 | 79 | ```bash 80 | npm install -g svelicon@${{ github.ref_name }} 81 | ``` 82 | draft: false 83 | prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }} 84 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | permissions: 10 | contents: write # Allow creating tags and releases 11 | packages: write # Allow publishing packages 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Run tests 35 | run: npm run test:run 36 | 37 | - name: Test CLI functionality 38 | run: | 39 | echo "Testing CLI basic functionality..." 40 | node cli.js --help 41 | 42 | security: 43 | runs-on: ubuntu-latest 44 | needs: test 45 | 46 | steps: 47 | - name: Checkout code 48 | uses: actions/checkout@v4 49 | 50 | - name: Setup Node.js 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: '20.x' 54 | cache: 'npm' 55 | 56 | - name: Install dependencies 57 | run: npm ci 58 | 59 | - name: Run security audit 60 | run: | 61 | echo "🔍 Running security audit..." 62 | # Check for high and critical vulnerabilities only 63 | # Moderate vulnerabilities in dev dependencies (esbuild/vite) are acceptable 64 | # as they only affect development server, not runtime CLI functionality 65 | npm audit --audit-level=high || { 66 | echo "⚠️ High/Critical vulnerabilities found. Checking if they affect runtime..." 67 | 68 | # Check if vulnerabilities are only in devDependencies 69 | if npm audit --json | jq -r '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical") | .value.via[] | select(type == "object") | .dependency' | grep -v -E '^(vitest|@vitest|vite|esbuild)'; then 70 | echo "❌ Runtime dependencies have high/critical vulnerabilities!" 71 | exit 1 72 | else 73 | echo "✅ Only development dependencies affected, runtime is secure" 74 | fi 75 | } 76 | 77 | 78 | publish: 79 | runs-on: ubuntu-latest 80 | needs: [test, security] 81 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 82 | 83 | steps: 84 | - name: Checkout code 85 | uses: actions/checkout@v4 86 | with: 87 | fetch-depth: 0 # Fetch full history for version comparison 88 | 89 | - name: Setup Node.js 90 | uses: actions/setup-node@v4 91 | with: 92 | node-version: '20.x' 93 | cache: 'npm' 94 | registry-url: 'https://registry.npmjs.org' 95 | 96 | - name: Install dependencies 97 | run: npm ci 98 | 99 | - name: Check if version changed 100 | id: version-check 101 | run: | 102 | # Get current version from package.json 103 | CURRENT_VERSION=$(node -p "require('./package.json').version") 104 | echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 105 | 106 | # Check if this version exists on NPM 107 | if npm view svelicon@$CURRENT_VERSION version 2>/dev/null; then 108 | echo "Version $CURRENT_VERSION already published" 109 | echo "should-publish=false" >> $GITHUB_OUTPUT 110 | else 111 | echo "Version $CURRENT_VERSION is new" 112 | echo "should-publish=true" >> $GITHUB_OUTPUT 113 | fi 114 | 115 | - name: Build package 116 | if: steps.version-check.outputs.should-publish == 'true' 117 | run: | 118 | echo "📦 Building package for version ${{ steps.version-check.outputs.current-version }}" 119 | # Add any build steps here if needed 120 | 121 | - name: Publish to NPM 122 | if: steps.version-check.outputs.should-publish == 'true' 123 | run: | 124 | echo "🚀 Publishing svelicon@${{ steps.version-check.outputs.current-version }} to NPM..." 125 | npm publish 126 | env: 127 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 128 | 129 | - name: Create Git Tag 130 | if: steps.version-check.outputs.should-publish == 'true' 131 | run: | 132 | git config --local user.email "action@github.com" 133 | git config --local user.name "GitHub Action" 134 | git tag "v${{ steps.version-check.outputs.current-version }}" 135 | git push origin "v${{ steps.version-check.outputs.current-version }}" 136 | env: 137 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 138 | 139 | - name: Create GitHub Release 140 | if: steps.version-check.outputs.should-publish == 'true' 141 | uses: actions/create-release@v1 142 | env: 143 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 144 | with: 145 | tag_name: "v${{ steps.version-check.outputs.current-version }}" 146 | release_name: "Release v${{ steps.version-check.outputs.current-version }}" 147 | body: | 148 | ## 🎉 New Release: v${{ steps.version-check.outputs.current-version }} 149 | 150 | ### Installation 151 | ```bash 152 | npm install -g svelicon@${{ steps.version-check.outputs.current-version }} 153 | ``` 154 | 155 | ### Changes 156 | See [CHANGELOG.md](https://github.com/friendofsvelte/svelicon/blob/main/CHANGELOG.md) for detailed changes. 157 | 158 | --- 159 | *This release was automatically created by GitHub Actions* 160 | draft: false 161 | prerelease: ${{ contains(steps.version-check.outputs.current-version, 'beta') || contains(steps.version-check.outputs.current-version, 'alpha') }} 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Icon Library - Svelicon 🎨 2 | 3 | Create Svelte components from Iconify SVG icons with type-safe support. A simple CLI tool for generating Svelte icons. 4 | 5 | ## Features ✨ 6 | 7 | - 🎯 **Iconify Integration**: Access and download icons from the Iconify collection. 8 | - 🔍 **Smart Search**: Search through thousands of icons with interactive selection. 9 | - 🚀 **Batch Downloads**: Download multiple icons at lightning speed with parallel processing. 10 | - ⚡ **Fast Conversion**: Quickly convert SVG icons to Svelte components. 11 | - 📦 **TypeScript Support**: Generate fully typed components with interfaces for Svelte TypeScript projects. 12 | - ⚙️ **Auto-Config Validation**: Automatically checks and validates tsconfig.json path mappings. 13 | - 🎨 **Customizable Icons**: Control icon size, display behavior, and spacing. 14 | - 🛠️ **Advanced CLI**: Powerful command-line interface with progress tracking and error handling. 15 | - 🔄 **Flexible Output**: Generate JavaScript or TypeScript Svelte components. 16 | 17 | > Svelicon streamlines the process of using Iconify icons in your Svelte projects, offering TypeScript support and flexible customization. 18 | 19 | ## Requirements 🗒️ 20 | 21 | - Svelte 5 22 | - Awesomeness 23 | 24 | ## Quick Start 🚀 25 | 26 | ### 1. Search & Discover 27 | ```bash 28 | npx svelicon search "home" --collection mdi 29 | ``` 30 | 31 | ### 2. Interactive Selection 32 | Choose from the search results using numbers, ranges, or "all" 33 | 34 | ### 3. Automatic Download 35 | Icons are downloaded with tsconfig validation and progress tracking 36 | 37 | ### 4. Use in Your Project 38 | ```svelte 39 | 42 | 43 | 44 | ``` 45 | 46 | ## Usage 🚀 47 | 48 | ### 🔍 Search Icons 49 | 50 | Search through thousands of icons interactively: 51 | 52 | ```bash 53 | # Search for icons 54 | npx svelicon search "arrow" 55 | 56 | # Search within a specific collection 57 | npx svelicon search "home" --collection mdi 58 | 59 | # Search and browse without downloading 60 | npx svelicon search "user" --no-download 61 | 62 | # Advanced search with filters 63 | npx svelicon search "database" --collection lucide --limit 30 64 | ``` 65 | 66 | ### 📦 Download Icons 67 | 68 | #### Single Icon Download 69 | ```bash 70 | npx svelicon download "mdi:home" 71 | ``` 72 | 73 | #### Batch Download (Super Fast!) 74 | ```bash 75 | # Download multiple icons at once 76 | npx svelicon download "mdi:home,lucide:star,heroicons:user" 77 | 78 | # Batch download with custom concurrency 79 | npx svelicon download "mdi:home,mdi:user,lucide:star" --concurrent 20 80 | ``` 81 | 82 | ### Legacy Format (Still Supported) 83 | ```bash 84 | npx svelicon fluent/person-passkey-28-filled 85 | ``` 86 | 87 | ### 🛠️ CLI Commands & Options 88 | 89 | #### Search Command 90 | ```bash 91 | npx svelicon search [options] 92 | 93 | Options: 94 | -c, --collection Filter by icon collection (e.g., mdi, lucide) 95 | --category Filter by category 96 | -l, --limit Number of results to show (default: 20) 97 | -o, --output Output directory (default: "src/icons") 98 | --withts Generate TypeScript version (default: true) 99 | --withjs Generate JavaScript version 100 | --concurrent Concurrent downloads (default: 10) 101 | --skip-tsconfig Skip tsconfig.json validation 102 | --no-download Only search, don't download 103 | ``` 104 | 105 | #### Download Command 106 | ```bash 107 | npx svelicon download [options] 108 | 109 | Arguments: 110 | Icon name or comma-separated list 111 | 112 | Options: 113 | -o, --output Output directory (default: "src/icons") 114 | --withts Generate TypeScript version (default: true) 115 | --withjs Generate JavaScript version 116 | -c, --concurrent Concurrent downloads for batch (default: 10) 117 | --skip-tsconfig Skip tsconfig.json validation 118 | ``` 119 | 120 | ### ⚙️ TypeScript Configuration 121 | 122 | Svelicon automatically validates your `tsconfig.json` and suggests the optimal configuration: 123 | 124 | ```json 125 | { 126 | "compilerOptions": { 127 | "paths": { 128 | "$icons": ["src/icons"], 129 | "$icons/*": ["src/icons/*"] 130 | } 131 | } 132 | } 133 | ``` 134 | 135 | This enables clean imports: 136 | ```typescript 137 | import HomeIcon from '$icons/MdiHome.svelte'; 138 | ``` 139 | 140 | ## Component Props 🎛️ 141 | 142 | All generated components accept these props: 143 | 144 | ```typescript 145 | interface IconProps { 146 | size?: number; // Icon size in em units 147 | class?: string; // Add custom CSS classes to the SVG element 148 | } 149 | ``` 150 | 151 | ## Examples 📝 152 | 153 | ### 🎯 With Path Mapping (Recommended) 154 | 155 | ```svelte 156 | 160 | 161 | 162 | 163 | ``` 164 | 165 | ### TypeScript Usage 166 | 167 | ```svelte 168 | 176 | 177 | 178 | ``` 179 | 180 | ### Without Path Mapping 181 | 182 | ```svelte 183 | 186 | 187 | 188 | ``` 189 | 190 | ## Component Output Structure 191 | 192 | Generated components include: 193 | 194 | ```svelte 195 | 201 | 202 | 205 | 206 | 212 | 213 | 214 | ``` 215 | 216 | ## Benefits 🌟 217 | 218 | - **🔍 Smart Discovery**: Search through 200,000+ icons with intelligent filtering 219 | - **⚡ Lightning Fast**: Parallel batch downloads with configurable concurrency 220 | - **🎯 Zero Runtime Dependencies**: Svelte icon components are standalone 221 | - **🌲 Tree-Shakeable**: Only import the Svelte icons you use 222 | - **🔧 Auto-Configuration**: Intelligent tsconfig.json validation and suggestions 223 | - **📦 Type-Safe**: Full TypeScript support with generated interfaces 224 | - **📏 Small Bundle Size**: Minimal impact on your Svelte app's size 225 | - **🎨 Flexible**: Use any Iconify icon in your Svelte project 226 | - **📊 Progress Tracking**: Real-time feedback during batch operations 227 | - **🛡️ Error Resilient**: Comprehensive error handling and retry logic 228 | 229 | https://youtu.be/6cpXq1MHg-A 230 | 231 | ## Contributing 🤝 232 | 233 | Contributions are welcome! Please read our Contributing Guide for details. 234 | 235 | ## License 📄 236 | 237 | MIT © [Friend of Svelte](https://github.com/friendofsvelte) 238 | 239 | ## Support 💖 240 | 241 | If you find this Svelte icon library helpful, please consider: 242 | 243 | - ⭐ Starring the GitHub repo 244 | - 🐛 Creating issues for bugs and feature requests 245 | - 🔀 Contributing to the code base 246 | 247 | ## Related Projects 🔗 248 | 249 | - [Iconify](https://iconify.design/) 250 | - [SvelteKit](https://kit.svelte.dev/) 251 | - [Friend of Svelte](https://github.com/friendofsvelte) 252 | 253 | --- 254 | 255 | Made with ❤️ by [Friend of Svelte](https://github.com/friendofsvelte) 256 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { promises as fs } from 'fs'; 3 | 4 | // Mock the fs module 5 | vi.mock('fs'); 6 | const mockFs = vi.mocked(fs); 7 | 8 | describe('Utility Functions', () => { 9 | beforeEach(() => { 10 | vi.clearAllMocks(); 11 | }); 12 | 13 | describe('TSConfig Validation', () => { 14 | it('should detect valid $icons path mapping', async () => { 15 | const validTsConfig = { 16 | compilerOptions: { 17 | paths: { 18 | '$icons': ['src/icons'], 19 | '$icons/*': ['src/icons/*'] 20 | } 21 | } 22 | }; 23 | 24 | mockFs.readFile.mockResolvedValue(JSON.stringify(validTsConfig)); 25 | 26 | // Simulate the validateTsConfig function logic 27 | const configContent = await fs.readFile('tsconfig.json', 'utf8'); 28 | const config = JSON.parse(configContent); 29 | const paths = config.compilerOptions?.paths; 30 | const hasIconsMapping = paths['$icons'] || paths['$icons/*']; 31 | 32 | expect(hasIconsMapping).toBeTruthy(); 33 | }); 34 | 35 | it('should handle missing tsconfig.json', async () => { 36 | mockFs.readFile.mockRejectedValue(new Error('File not found')); 37 | 38 | try { 39 | await fs.readFile('tsconfig.json', 'utf8'); 40 | } catch (error) { 41 | expect(error.message).toBe('File not found'); 42 | } 43 | }); 44 | 45 | it('should handle invalid JSON in tsconfig', async () => { 46 | mockFs.readFile.mockResolvedValue('invalid json content'); 47 | 48 | try { 49 | const configContent = await fs.readFile('tsconfig.json', 'utf8'); 50 | JSON.parse(configContent); 51 | } catch (error) { 52 | expect(error).toBeInstanceOf(SyntaxError); 53 | } 54 | }); 55 | 56 | it('should check multiple tsconfig locations', async () => { 57 | const possiblePaths = [ 58 | 'tsconfig.json', 59 | '.svelte-kit/tsconfig.json', 60 | 'tsconfig.app.json' 61 | ]; 62 | 63 | // Mock first two to fail, third to succeed 64 | mockFs.readFile 65 | .mockRejectedValueOnce(new Error('Not found')) 66 | .mockRejectedValueOnce(new Error('Not found')) 67 | .mockResolvedValueOnce(JSON.stringify({ 68 | compilerOptions: { 69 | paths: { 70 | '$icons/*': ['src/icons/*'] 71 | } 72 | } 73 | })); 74 | 75 | let foundConfig = false; 76 | for (const configPath of possiblePaths) { 77 | try { 78 | const content = await fs.readFile(configPath, 'utf8'); 79 | const config = JSON.parse(content); 80 | if (config.compilerOptions?.paths?.['$icons/*']) { 81 | foundConfig = true; 82 | break; 83 | } 84 | } catch (error) { 85 | continue; 86 | } 87 | } 88 | 89 | expect(foundConfig).toBe(true); 90 | }); 91 | }); 92 | 93 | describe('Progress Tracking', () => { 94 | it('should track progress correctly', () => { 95 | // Simulate the createProgressTracker function 96 | const createProgressTracker = (total, label = 'Processing') => { 97 | let completed = 0; 98 | return { 99 | update: () => { 100 | completed++; 101 | const percentage = Math.round((completed / total) * 100); 102 | return { completed, total, percentage }; 103 | }, 104 | reset: () => { completed = 0; }, 105 | getCompleted: () => completed 106 | }; 107 | }; 108 | 109 | const tracker = createProgressTracker(5, 'Testing'); 110 | 111 | expect(tracker.getCompleted()).toBe(0); 112 | 113 | const result1 = tracker.update(); 114 | expect(result1).toEqual({ completed: 1, total: 5, percentage: 20 }); 115 | 116 | const result2 = tracker.update(); 117 | expect(result2).toEqual({ completed: 2, total: 5, percentage: 40 }); 118 | 119 | tracker.reset(); 120 | expect(tracker.getCompleted()).toBe(0); 121 | }); 122 | }); 123 | 124 | describe('String Capitalization', () => { 125 | it('should capitalize icon names correctly', () => { 126 | const capitalizeFirstLetter = (string) => { 127 | return string 128 | .split(/[:,-]/) 129 | .map(word => word.charAt(0).toUpperCase() + word.slice(1)) 130 | .join(''); 131 | }; 132 | 133 | const testCases = [ 134 | { input: 'fluent:home-24-filled', expected: 'FluentHome24Filled' }, 135 | { input: 'material-design-icons', expected: 'MaterialDesignIcons' }, 136 | { input: 'simple', expected: 'Simple' }, 137 | { input: 'multi:part-name', expected: 'MultiPartName' }, 138 | { input: 'with,comma-separated', expected: 'WithCommaSeparated' } 139 | ]; 140 | 141 | testCases.forEach(({ input, expected }) => { 142 | expect(capitalizeFirstLetter(input)).toBe(expected); 143 | }); 144 | }); 145 | 146 | it('should handle edge cases in capitalization', () => { 147 | const capitalizeFirstLetter = (string) => { 148 | return string 149 | .split(/[:,-]/) 150 | .map(word => word.charAt(0).toUpperCase() + word.slice(1)) 151 | .join(''); 152 | }; 153 | 154 | const edgeCases = [ 155 | { input: '', expected: '' }, 156 | { input: 'a', expected: 'A' }, 157 | { input: 'a:b', expected: 'AB' }, 158 | { input: '123-test', expected: '123Test' }, 159 | { input: 'UPPERCASE', expected: 'UPPERCASE' } 160 | ]; 161 | 162 | edgeCases.forEach(({ input, expected }) => { 163 | expect(capitalizeFirstLetter(input)).toBe(expected); 164 | }); 165 | }); 166 | }); 167 | 168 | describe('SVG Template Generation', () => { 169 | it('should generate correct SVG template', () => { 170 | const generateSvgTemplate = (pathData, width, height, size) => ` 176 | ${pathData} 177 | `; 178 | 179 | const result = generateSvgTemplate('', 24, 24, 'size'); 180 | 181 | expect(result).toContain('width="{size}em"'); 182 | expect(result).toContain('height="{size}em"'); 183 | expect(result).toContain('viewBox="0 0 24 24"'); 184 | expect(result).toContain(''); 185 | expect(result).toContain('class="{className}"'); 186 | }); 187 | 188 | it('should generate TypeScript component template', () => { 189 | const generateComponentTemplate = ({ scriptContent, size = 'size', pathData, width, height }) => { 190 | const svgTemplate = ` 196 | ${pathData} 197 | `; 198 | return `${scriptContent}\n\n${svgTemplate}`; 199 | }; 200 | 201 | const scriptContent = ` 207 | 208 | `; 211 | 212 | const result = generateComponentTemplate({ 213 | scriptContent, 214 | pathData: '', 215 | width: 24, 216 | height: 24 217 | }); 218 | 219 | expect(result).toContain(' 100 | 101 | ` : ``; 106 | 107 | const svgTemplate = ` 113 | ${pathData} 114 | `; 115 | 116 | return `${scriptContent}\n\n${svgTemplate}`; 117 | }; 118 | 119 | const tsResult = generateComponent('', 24, 24, 'TestIcon', true); 120 | expect(tsResult).toContain(' 110 | 111 | ` : ``; 116 | 117 | return generateComponentTemplate({ 118 | scriptContent, pathData, width, height 119 | }); 120 | } 121 | 122 | async function processIconData(svgContent) { 123 | const parsedSVG = parseSVGContent(svgContent); 124 | if (!parsedSVG) { 125 | throw new Error('Could not parse SVG content'); 126 | } 127 | 128 | const iconData = convertParsedSVG(parsedSVG); 129 | if (!iconData) { 130 | throw new Error('Could not convert SVG to icon data'); 131 | } 132 | 133 | return iconData; 134 | } 135 | 136 | // Pattern matching for collection downloads 137 | function matchesPattern(iconName, pattern) { 138 | // Convert wildcard pattern to regex 139 | if (pattern === '*') { 140 | return true; // Match all 141 | } 142 | 143 | if (pattern.startsWith('*') && pattern.endsWith('*')) { 144 | // *text* - contains 145 | const text = pattern.slice(1, -1); 146 | return iconName.includes(text); 147 | } 148 | 149 | if (pattern.startsWith('*')) { 150 | // *suffix - ends with 151 | const suffix = pattern.slice(1); 152 | return iconName.endsWith(suffix); 153 | } 154 | 155 | if (pattern.endsWith('*')) { 156 | // prefix* - starts with 157 | const prefix = pattern.slice(0, -1); 158 | return iconName.startsWith(prefix); 159 | } 160 | 161 | // Try as regex pattern 162 | try { 163 | const regex = new RegExp(pattern, 'i'); 164 | return regex.test(iconName); 165 | } catch (error) { 166 | // If regex fails, do exact match 167 | return iconName === pattern; 168 | } 169 | } 170 | 171 | // Download icons matching a pattern from a collection 172 | export async function downloadCollection(pattern, options = {}) { 173 | const { 174 | collection = '', 175 | outputDir = 'src/icons', 176 | withTs = false, 177 | withJs = true, 178 | concurrency = 10, 179 | limit = 100, 180 | skipTsConfigCheck = false 181 | } = options; 182 | 183 | if (!collection) { 184 | throw new Error('Collection name is required for pattern downloads'); 185 | } 186 | 187 | console.log(`🔍 Searching for icons matching "${pattern}" in collection "${collection}"...`); 188 | 189 | try { 190 | // Get all icons from the collection using the collection API 191 | let searchResults; 192 | try { 193 | // Try to get collection info first 194 | const collectionResponse = await axios.get(`https://api.iconify.design/collection?prefix=${collection}`); 195 | if (collectionResponse.data && collectionResponse.data.uncategorized) { 196 | // Get icons from collection info 197 | const icons = collectionResponse.data.uncategorized.map(icon => `${collection}:${icon}`); 198 | searchResults = { 199 | icons: icons.slice(0, Math.max(limit, 500)), 200 | total: icons.length 201 | }; 202 | } else { 203 | throw new Error('Collection not found'); 204 | } 205 | } catch (collectionError) { 206 | // Fallback to search with a generic query 207 | searchResults = await searchIcons('icon', { 208 | collection, 209 | limit: Math.max(limit, 500) 210 | }); 211 | } 212 | 213 | if (searchResults.icons.length === 0) { 214 | console.log(`❌ No icons found in collection "${collection}"`); 215 | return []; 216 | } 217 | 218 | // Filter icons by pattern 219 | const matchingIcons = searchResults.icons.filter(iconFullName => { 220 | // Extract just the icon name (after collection prefix) 221 | const iconName = iconFullName.includes(':') 222 | ? iconFullName.split(':')[1] 223 | : iconFullName.split('/')[1]; 224 | 225 | return iconName && matchesPattern(iconName, pattern); 226 | }); 227 | 228 | if (matchingIcons.length === 0) { 229 | console.log(`❌ No icons matching pattern "${pattern}" found in collection "${collection}"`); 230 | console.log(`💡 Available icons: ${searchResults.icons.slice(0, 5).join(', ')}${searchResults.icons.length > 5 ? '...' : ''}`); 231 | return []; 232 | } 233 | 234 | console.log(`✅ Found ${matchingIcons.length} icons matching pattern "${pattern}"`); 235 | console.log(`📦 Icons to download: ${matchingIcons.slice(0, 5).join(', ')}${matchingIcons.length > 5 ? ` and ${matchingIcons.length - 5} more...` : ''}`); 236 | 237 | // Convert to proper format and download 238 | const iconsToDownload = matchingIcons.map(icon => { 239 | // Convert colon format to slash format if needed 240 | return icon.includes(':') && !icon.includes('/') 241 | ? icon.replace(':', '/') 242 | : icon; 243 | }); 244 | 245 | return await downloadIcons(iconsToDownload, { 246 | outputDir, 247 | withTs, 248 | withJs, 249 | concurrency, 250 | skipTsConfigCheck 251 | }); 252 | 253 | } catch (error) { 254 | console.error(`Failed to download collection: ${error.message}`); 255 | return []; 256 | } 257 | } 258 | 259 | // Search icons using Iconify API 260 | export async function searchIcons(query, options = {}) { 261 | const { 262 | collection = '', 263 | category = '', 264 | limit = 50, 265 | start = 0 266 | } = options; 267 | 268 | try { 269 | const params = new URLSearchParams({ 270 | query, 271 | limit: limit.toString(), 272 | start: start.toString() 273 | }); 274 | 275 | if (collection) params.append('prefix', collection); 276 | if (category) params.append('category', category); 277 | 278 | const response = await axios.get(`https://api.iconify.design/search?${params}`); 279 | 280 | if (response.status !== 200) { 281 | throw new Error(`Search failed with status ${response.status}`); 282 | } 283 | 284 | return { 285 | icons: response.data.icons || [], 286 | total: response.data.total || 0, 287 | start: response.data.start || 0, 288 | limit: response.data.limit || limit 289 | }; 290 | } catch (error) { 291 | console.error(`Search failed: ${error.message}`); 292 | return { icons: [], total: 0, start: 0, limit }; 293 | } 294 | } 295 | 296 | // Progress callback for batch operations 297 | const createProgressTracker = (total, label = 'Processing') => { 298 | let completed = 0; 299 | return { 300 | update: () => { 301 | completed++; 302 | const percentage = Math.round((completed / total) * 100); 303 | process.stdout.write(`\r${label}: ${completed}/${total} (${percentage}%)`); 304 | if (completed === total) { 305 | console.log('\n'); 306 | } 307 | }, 308 | reset: () => { completed = 0; }, 309 | getCompleted: () => completed 310 | }; 311 | }; 312 | 313 | // Batch download with concurrency control and progress tracking 314 | export async function downloadIcons(icons, options = {}) { 315 | const { 316 | outputDir = 'src/icons', 317 | withTs = false, 318 | withJs = true, 319 | concurrency = 10, 320 | delayMs = 100, 321 | skipTsConfigCheck = false 322 | } = options; 323 | 324 | if (!Array.isArray(icons) || icons.length === 0) { 325 | console.log('No icons to download'); 326 | return []; 327 | } 328 | 329 | // Validate tsconfig.json before starting download 330 | if (!skipTsConfigCheck) { 331 | await validateTsConfig(outputDir); 332 | } 333 | 334 | console.log(`Starting batch download of ${icons.length} icons with ${concurrency} concurrent workers...`); 335 | 336 | const limit = pLimit(concurrency); 337 | const progress = createProgressTracker(icons.length, 'Downloading'); 338 | const results = []; 339 | const errors = []; 340 | 341 | // Add delay between requests to be respectful to the API 342 | const downloadWithDelay = async (icon, index) => { 343 | if (index > 0 && delayMs > 0) { 344 | await new Promise(resolve => setTimeout(resolve, delayMs)); 345 | } 346 | 347 | try { 348 | const result = await downloadIcon(icon, { outputDir, withTs, withJs, _batchMode: true }); 349 | progress.update(); 350 | return { icon, success: true, files: result }; 351 | } catch (error) { 352 | progress.update(); 353 | errors.push({ icon, error: error.message }); 354 | return { icon, success: false, error: error.message }; 355 | } 356 | }; 357 | 358 | // Process all icons with concurrency limit 359 | const promises = icons.map((icon, index) => 360 | limit(() => downloadWithDelay(icon, index)) 361 | ); 362 | 363 | try { 364 | const downloadResults = await Promise.allSettled(promises); 365 | 366 | downloadResults.forEach((result, index) => { 367 | if (result.status === 'fulfilled') { 368 | results.push(result.value); 369 | } else { 370 | errors.push({ icon: icons[index], error: result.reason?.message || 'Unknown error' }); 371 | } 372 | }); 373 | 374 | // Summary 375 | const successful = results.filter(r => r.success).length; 376 | const failed = errors.length; 377 | 378 | console.log(`\n📊 Batch download completed:`); 379 | console.log(` ✅ Successful: ${successful}`); 380 | if (failed > 0) { 381 | console.log(` ❌ Failed: ${failed}`); 382 | console.log(`\nFailed downloads:`); 383 | errors.forEach(({ icon, error }) => { 384 | console.log(` - ${icon}: ${error}`); 385 | }); 386 | } 387 | 388 | return results.filter(r => r.success).flatMap(r => r.files); 389 | } catch (error) { 390 | console.error(`Batch download failed: ${error.message}`); 391 | return []; 392 | } 393 | } 394 | 395 | export async function downloadIcon(icon, options = {}) { 396 | const { 397 | outputDir = 'src/icons', withTs = false, withJs = true, skipTsConfigCheck = false 398 | } = options; 399 | 400 | try { 401 | // Validate tsconfig.json before downloading (only for single downloads) 402 | if (!skipTsConfigCheck && !options._batchMode) { 403 | await validateTsConfig(outputDir); 404 | } 405 | 406 | // Replace spaces with slashes in the name 407 | // Fetch and validate icon 408 | const response = await axios.get(`https://api.iconify.design/${icon}.svg`); 409 | if (response.status !== 200) { 410 | console.log(`Failed to download icon ${icon}`); 411 | return []; 412 | } 413 | 414 | // Process icon data 415 | const iconData = await processIconData(response.data); 416 | const renderData = iconToSVG(iconData, { 417 | height: 'auto', width: 'auto' 418 | }); 419 | 420 | // Prepare output with security validation 421 | const safeOutputDir = sanitizeOutputDir(outputDir); 422 | await mkdirp(safeOutputDir); 423 | const names = icon.split('/'); 424 | const collectionName = names[0]; 425 | const iconName = names[1]; 426 | if (!iconName) { 427 | throw new Error('Invalid icon name'); 428 | } 429 | const componentName = `${capitalizeFirstLetter(collectionName)}${capitalizeFirstLetter(iconName.replace(/ /g, '-'))}`; 430 | 431 | // Generate component content based on type 432 | const content = generateComponent(renderData.body, iconData.height, iconData.width, componentName, withTs); 433 | 434 | // Write file to sanitized path 435 | const outputPath = path.join(safeOutputDir, `${componentName}.svelte`); 436 | await fs.writeFile(outputPath, content, 'utf8'); 437 | 438 | return [outputPath]; 439 | } catch (error) { 440 | console.error(`Failed to download icon ${icon}:\n ${error.message}`); 441 | return []; 442 | } 443 | } --------------------------------------------------------------------------------