├── .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 |
--------------------------------------------------------------------------------
/src/icons/FluentSearch12Regular.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/.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 |
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) => ``;
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 = ``;
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 = ``;
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 | }
--------------------------------------------------------------------------------