diff --git a/icon-sprite/README.md b/icon-sprite/README.md
index 332573b..940c21d 100644
--- a/icon-sprite/README.md
+++ b/icon-sprite/README.md
@@ -1,19 +1,15 @@
- # @react-zero-ui/icon-sprite
+# @react-zero-ui/icon-sprite
- [](https://github.com/react-zero-ui/icon-sprite/blob/main/LICENSE) [](https://www.npmjs.com/package/@react-zero-ui/icon-sprite)
+[](https://github.com/react-zero-ui/icon-sprite/blob/main/LICENSE) [](https://www.npmjs.com/package/@react-zero-ui/icon-sprite)
-
-
-> 
-> **Generates one SVG sprite containing only the icons you used** - Lucide + custom SVGs.
+>  > **Generates one SVG sprite containing only the icons you used** - Lucide + custom SVGs.
> DX with real ` ` in dev β‘οΈ zero-runtime ` ` in prod.
> Part of the [React Zero-UI](https://github.com/react-zero-ui) ecosystem.
-
---
@@ -34,26 +30,26 @@
Only icons actually used in your app are included.
## π Custom Icon Support
+
Drop SVGs into **`/public/zero-ui-icons/`**, then use ` ` with the filename (no `.svg`).
->
->```txt
->π/public
+> 
+>
+> ```txt
+> π/public
> βββπ/zero-ui-icons/
> βββdog.svg
> ```
+>
> ```tsx
->import { CustomIcon } from "@react-zero-ui/icon-sprite";
->//βThe name MUST match the name of the file name (no .svg extension).
->
->```
-
+> import { CustomIcon } from '@react-zero-ui/icon-sprite';
+> //βThe name MUST match the name of the file name (no .svg extension).
+> ;
+> ```
->
+> 
> In dev you may see a brief FOUC using custom icons; this is removed in production.
-
-
---
## π¦ Installation
@@ -65,15 +61,18 @@ npm install @react-zero-ui/icon-sprite
---
## β Build Command
+
> 
> Run this before your app build so the sprite exists.
->```bash
->npx zero-icons
->```
+>
+> ```bash
+> npx zero-icons
+> ```
This command builds the icons sprite for production.
Or add this to your `package.json` scripts:
+
```json
{
"scripts": {
@@ -82,14 +81,14 @@ Or add this to your `package.json` scripts:
}
}
```
+
That's it!
---
## π¨ Usage
-> 
-> **Pass `size`, or both `width` and `height`, to ensure identical dev/prod rendering.**
+>  > **Pass `size`, or both `width` and `height`, to ensure identical dev/prod rendering.**
> Dev defaults (Lucide 24Γ24) differ from sprite viewBoxes in production. Missing these props will **very likely** change the visual size in prod.
### For Lucide Icons:
@@ -104,14 +103,13 @@ import { ArrowRight, Mail } from "@react-zero-ui/icon-sprite";
### Custom Icons:
Drop SVGs into **`/public/zero-ui-icons/`**, then use ` ` with the filename (no `.svg`).
+
```tsx
-import { CustomIcon } from "@react-zero-ui/icon-sprite";
+import { CustomIcon } from '@react-zero-ui/icon-sprite';
//βThe name MUST match the name of the file name (without .svg).
-
+ ;
```
-
-
---
## π§ͺ How It Works (Under the Hood)
@@ -121,10 +119,10 @@ import { CustomIcon } from "@react-zero-ui/icon-sprite";
In dev, each icon wrapper looks like this:
```tsx
-import { ArrowRight as DevIcon } from "lucide-react";
+import { ArrowRight as DevIcon } from 'lucide-react';
-export const ArrowRight = (props) =>
- process.env.NODE_ENV === "development" ? (
+export const ArrowRight = props =>
+ process.env.NODE_ENV === 'development' ? (
) : (
@@ -135,10 +133,10 @@ export const ArrowRight = (props) =>
This ensures:
-* Dev uses Lucide's real React components (`lucide-react`)
-* Full props support (e.g. `strokeWidth`, `className`)
-* No caching issues from SVG sprites
-* No FOUC (Flash of Unstyled Content)
+- Dev uses Lucide's real React components (`lucide-react`)
+- Full props support (e.g. `strokeWidth`, `className`)
+- No caching issues from SVG sprites
+- No FOUC (Flash of Unstyled Content)
### βοΈ Production Mode: Minimal Runtime, Maximum Speed
@@ -150,6 +148,41 @@ At build time:
---
+## βοΈ Configuration
+
+You can customize the scanner behavior by creating a `zero-ui.config.js` file in your project root:
+
+```js
+// zero-ui.config.js
+export default {
+ // Package name to scan for (default: "@react-zero-ui/icon-sprite")
+ IMPORT_NAME: '@react-zero-ui/icon-sprite',
+
+ // Path where the sprite will be served (default: "/icons.svg")
+ SPRITE_PATH: '/icons.svg',
+
+ // Directory to scan for icon usage (default: "src")
+ ROOT_DIR: 'src',
+
+ // Directory containing custom SVG files (default: "zero-ui-icons")
+ CUSTOM_SVG_DIR: 'zero-ui-icons',
+
+ // Output directory for the sprite (default: "public")
+ OUTPUT_DIR: 'public',
+
+ // Icon names to ignore during scanning (default: ["CustomIcon"])
+ IGNORE_ICONS: ['CustomIcon'],
+
+ // Directories to exclude from scanning (default: ["node_modules", ".git", "dist", "build", ".next", "out"])
+ EXCLUDE_DIRS: ['node_modules', '.git', 'dist', 'build', '.next', 'out'],
+};
+```
+
+> 
+> The scanner now defaults to scanning only the `src` directory and automatically excludes `node_modules` and other common build directories. This prevents build failures from dependencies with unsupported syntax (e.g., TypeScript decorators).
+
+---
+
## β‘οΈ Tooling
To generate everything:
@@ -160,22 +193,19 @@ npx zero-icons
This runs the full pipeline:
-| Script | Purpose |
-| --- | --- |
-| `scan-icons.js` | Parse your codebase for used icons (`Icon` usage or named imports) |
-| `used-icons.js` | Collects a list of unique icon names |
+| Script | Purpose |
+| ----------------- | ------------------------------------------------------------------------------------------------------------ |
+| `scan-icons.js` | Parse your codebase for used icons (`Icon` usage or named imports) |
+| `used-icons.js` | Collects a list of unique icon names |
| `build-sprite.js` | Uses [`svgstore`](https://github.com/DIYgod/svgstore) to generate `icons.svg` from used Lucide + custom SVGs |
-
----
+---
## β¨ Why This Beats Icon Libraries Everywhere
-* **DX-first in dev**: No flicker. No sprite caching. Live updates.
-* **Zero-runtime in production**: Sprites are native, fast, lightweight & highly Cached.
-* **Only ships the icons you actually use** - smallest possible sprite.
-* **Custom icon support**: Drop SVGs into `/public/zero-ui-icons/` and use ` `
-
+- **DX-first in dev**: No flicker. No sprite caching. Live updates.
+- **Zero-runtime in production**: Sprites are native, fast, lightweight & highly Cached.
+- **Only ships the icons you actually use** - smallest possible sprite.
+- **Custom icon support**: Drop SVGs into `/public/zero-ui-icons/` and use ` `
Made with β€οΈ for the React community by [@austin1serb](https://github.com/austin1serb)
-
diff --git a/icon-sprite/package.json b/icon-sprite/package.json
index 9ab81d3..f78faf1 100644
--- a/icon-sprite/package.json
+++ b/icon-sprite/package.json
@@ -37,7 +37,7 @@
],
"scripts": {
"build": "rm -rf dist && node scripts/gen-wrappers.js && tsc && node scripts/gen-dist.js",
- "test": "node tests/test-mapping.test.js",
+ "test": "node tests/run-all-tests.js",
"prepare": "npm run build && npm run test",
"type-check": "tsc --noEmit | tee type-errors.log"
},
diff --git a/icon-sprite/scripts/scan-icons.js b/icon-sprite/scripts/scan-icons.js
index 1d1c731..21ac96c 100644
--- a/icon-sprite/scripts/scan-icons.js
+++ b/icon-sprite/scripts/scan-icons.js
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
import * as babel from "@babel/core";
import traverseImport from "@babel/traverse";
import * as t from "@babel/types";
-import { IMPORT_NAME, ROOT_DIR, IGNORE_ICONS } from "../dist/config.js";
+import { IMPORT_NAME, ROOT_DIR, IGNORE_ICONS, EXCLUDE_DIRS } from "../dist/config.js";
// ESM __dirname shim
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -30,6 +30,10 @@ function collect(dir) {
for (const file of fs.readdirSync(dir)) {
const full = path.join(dir, file);
if (fs.statSync(full).isDirectory()) {
+ // Skip excluded directories
+ if (EXCLUDE_DIRS.includes(file)) {
+ continue;
+ }
collect(full);
continue;
}
diff --git a/icon-sprite/src/config.ts b/icon-sprite/src/config.ts
index fe2d7c5..7bc38ca 100644
--- a/icon-sprite/src/config.ts
+++ b/icon-sprite/src/config.ts
@@ -1,42 +1,37 @@
// src/config.ts
-// import fs from "fs";
-// import path from "path";
-// import { pathToFileURL } from "url";
+import fs from "fs";
+import path from "path";
+import { pathToFileURL } from "url";
-// const DEFAULT_CONFIG = {
-// IMPORT_NAME: "@react-zero-ui/icon-sprite",
-// SPRITE_PATH: "/icons.svg",
-// ROOT_DIR: "",
-// CUSTOM_SVG_DIR: "zero-ui-icons",
-// OUTPUT_DIR: "public",
-// IGNORE_ICONS: ["CustomIcon"],
-// };
+const DEFAULT_CONFIG = {
+ IMPORT_NAME: "@react-zero-ui/icon-sprite",
+ SPRITE_PATH: "/icons.svg",
+ ROOT_DIR: "src",
+ CUSTOM_SVG_DIR: "zero-ui-icons",
+ OUTPUT_DIR: "public",
+ IGNORE_ICONS: ["CustomIcon"],
+ EXCLUDE_DIRS: ["node_modules", ".git", "dist", "build", ".next", "out"],
+};
-// let userConfig = {};
-// const configFile = path.resolve(process.cwd(), "zero-ui.config.js");
+let userConfig = {};
+const configFile = path.resolve(process.cwd(), "zero-ui.config.js");
-// if (fs.existsSync(configFile)) {
-// try {
-// const mod = await import(pathToFileURL(configFile).href);
-// userConfig = mod.default ?? mod;
-// } catch (e) {
-// // @ts-expect-error
-// console.warn("β οΈ Failed to load zero-ui.config.js:", e.message);
-// }
-// }
+if (fs.existsSync(configFile)) {
+ try {
+ const mod = await import(pathToFileURL(configFile).href);
+ userConfig = mod.default ?? mod;
+ } catch (e) {
+ // @ts-expect-error
+ console.warn("β οΈ Failed to load zero-ui.config.js:", e.message);
+ }
+}
-// const merged = { ...DEFAULT_CONFIG, ...userConfig };
+const merged = { ...DEFAULT_CONFIG, ...userConfig };
-// export const IMPORT_NAME = merged.IMPORT_NAME;
-// export const SPRITE_PATH = merged.SPRITE_PATH;
-// export const ROOT_DIR = merged.ROOT_DIR;
-// export const CUSTOM_SVG_DIR = merged.CUSTOM_SVG_DIR;
-// export const OUTPUT_DIR = merged.OUTPUT_DIR;
-// export const IGNORE_ICONS = merged.IGNORE_ICONS;
-
-export const IMPORT_NAME = "@react-zero-ui/icon-sprite";
-export const SPRITE_PATH = "/icons.svg";
-export const ROOT_DIR = "";
-export const CUSTOM_SVG_DIR = "zero-ui-icons";
-export const OUTPUT_DIR = "public";
-export const IGNORE_ICONS = ["CustomIcon"];
+export const IMPORT_NAME = merged.IMPORT_NAME;
+export const SPRITE_PATH = merged.SPRITE_PATH;
+export const ROOT_DIR = merged.ROOT_DIR;
+export const CUSTOM_SVG_DIR = merged.CUSTOM_SVG_DIR;
+export const OUTPUT_DIR = merged.OUTPUT_DIR;
+export const IGNORE_ICONS = merged.IGNORE_ICONS;
+export const EXCLUDE_DIRS = merged.EXCLUDE_DIRS;
diff --git a/icon-sprite/tests/run-all-tests.js b/icon-sprite/tests/run-all-tests.js
new file mode 100644
index 0000000..67aacb6
--- /dev/null
+++ b/icon-sprite/tests/run-all-tests.js
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+import { execSync } from "child_process";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// Test suite configuration
+const testSuites = [
+ {
+ name: "Icon Mapping Tests",
+ file: "test-mapping.test.js",
+ description: "Verify all React icon components map to lucide-static SVGs",
+ },
+ {
+ name: "Config Loading Tests",
+ file: "test-config.test.js",
+ description: "Verify config loading and merging with defaults",
+ },
+ {
+ name: "Scanner Exclusion Tests",
+ file: "test-scanner-exclusion.test.js",
+ description: "Verify directory exclusion and ROOT_DIR functionality",
+ },
+];
+
+// Color codes for terminal output
+const colors = {
+ reset: "\x1b[0m",
+ bright: "\x1b[1m",
+ green: "\x1b[32m",
+ red: "\x1b[31m",
+ yellow: "\x1b[33m",
+ blue: "\x1b[34m",
+ cyan: "\x1b[36m",
+};
+
+function printHeader() {
+ console.log("\n" + "=".repeat(60));
+ console.log(colors.bright + colors.cyan + "π§ͺ @react-zero-ui/icon-sprite Test Suite" + colors.reset);
+ console.log("=".repeat(60) + "\n");
+}
+
+function printTestSuiteHeader(suite, index, total) {
+ console.log(colors.bright + `\n[${ index + 1}/${total}] ${suite.name}` + colors.reset);
+ console.log(colors.blue + `π ${suite.description}` + colors.reset);
+ console.log("-".repeat(60));
+}
+
+function runTest(testFile) {
+ const testPath = path.join(__dirname, testFile);
+ try {
+ const output = execSync(`node "${testPath}"`, {
+ cwd: __dirname,
+ encoding: "utf8",
+ stdio: "pipe",
+ });
+ return { success: true, output };
+ } catch (err) {
+ return { success: false, output: err.stdout || err.message };
+ }
+}
+
+async function main() {
+ printHeader();
+
+ const results = [];
+ let totalPassed = 0;
+ let totalFailed = 0;
+
+ // Run each test suite
+ for (let i = 0; i < testSuites.length; i++) {
+ const suite = testSuites[i];
+ printTestSuiteHeader(suite, i, testSuites.length);
+
+ const result = runTest(suite.file);
+ results.push({ suite, result });
+
+ if (result.success) {
+ console.log(result.output);
+ console.log(colors.green + `β
${suite.name} PASSED` + colors.reset);
+ totalPassed++;
+ } else {
+ console.log(result.output);
+ console.error(colors.red + `β ${suite.name} FAILED` + colors.reset);
+ totalFailed++;
+ }
+ }
+
+ // Print summary
+ console.log("\n" + "=".repeat(60));
+ console.log(colors.bright + "π Test Summary" + colors.reset);
+ console.log("=".repeat(60));
+
+ for (const { suite, result } of results) {
+ const status = result.success
+ ? colors.green + "β
PASS" + colors.reset
+ : colors.red + "β FAIL" + colors.reset;
+ console.log(`${status} - ${suite.name}`);
+ }
+
+ console.log("-".repeat(60));
+ console.log(
+ `Total: ${totalPassed + totalFailed} | ` +
+ colors.green + `Passed: ${totalPassed}` + colors.reset + " | " +
+ colors.red + `Failed: ${totalFailed}` + colors.reset
+ );
+ console.log("=".repeat(60));
+
+ if (totalFailed === 0) {
+ console.log(colors.green + colors.bright + "\nπ All tests passed!" + colors.reset);
+ console.log(colors.green + "β¨ Your changes are ready to ship!\n" + colors.reset);
+ process.exit(0);
+ } else {
+ console.error(colors.red + colors.bright + "\nβ Some tests failed!" + colors.reset);
+ console.error(colors.yellow + "Please fix the failing tests before committing.\n" + colors.reset);
+ process.exit(1);
+ }
+}
+
+main().catch((err) => {
+ console.error(colors.red + "β Test runner error:" + colors.reset, err);
+ process.exit(1);
+});
diff --git a/icon-sprite/tests/test-config.test.js b/icon-sprite/tests/test-config.test.js
new file mode 100644
index 0000000..f0b4b8d
--- /dev/null
+++ b/icon-sprite/tests/test-config.test.js
@@ -0,0 +1,280 @@
+#!/usr/bin/env node
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import { execSync } from "child_process";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const TEST_PROJECT_ROOT = path.resolve(__dirname, "../test-project");
+const CONFIG_FILE = path.join(TEST_PROJECT_ROOT, "zero-ui.config.js");
+const PACKAGE_JSON = path.join(TEST_PROJECT_ROOT, "package.json");
+
+// Cleanup and setup test project directory
+function setupTestProject() {
+ if (fs.existsSync(TEST_PROJECT_ROOT)) {
+ fs.rmSync(TEST_PROJECT_ROOT, { recursive: true, force: true });
+ }
+ fs.mkdirSync(TEST_PROJECT_ROOT, { recursive: true });
+
+ // Create package.json to make it a valid project root
+ fs.writeFileSync(
+ PACKAGE_JSON,
+ JSON.stringify({ name: "test-project", version: "1.0.0" }, null, 2),
+ "utf8"
+ );
+}
+
+function cleanup() {
+ if (fs.existsSync(TEST_PROJECT_ROOT)) {
+ fs.rmSync(TEST_PROJECT_ROOT, { recursive: true, force: true });
+ }
+}
+
+// Helper to run a test script in a child process
+function runConfigTest() {
+ const testScript = `
+import { IMPORT_NAME, ROOT_DIR, SPRITE_PATH, CUSTOM_SVG_DIR, OUTPUT_DIR, IGNORE_ICONS, EXCLUDE_DIRS } from "../dist/config.js";
+console.log(JSON.stringify({
+ IMPORT_NAME,
+ ROOT_DIR,
+ SPRITE_PATH,
+ CUSTOM_SVG_DIR,
+ OUTPUT_DIR,
+ IGNORE_ICONS,
+ EXCLUDE_DIRS
+}));
+`;
+
+ const testScriptPath = path.join(TEST_PROJECT_ROOT, "test-runner.js");
+ fs.writeFileSync(testScriptPath, testScript, "utf8");
+
+ const result = execSync(`node "${testScriptPath}"`, {
+ cwd: TEST_PROJECT_ROOT,
+ encoding: "utf8",
+ });
+
+ return JSON.parse(result.trim());
+}
+
+// Test 1: Default config values (no config file)
+async function testDefaultConfig() {
+ console.log("\nπ§ͺ Test 1: Default config values (no config file)");
+ setupTestProject();
+
+ try {
+ const config = runConfigTest();
+
+ const assertions = [
+ { name: "IMPORT_NAME", expected: "@react-zero-ui/icon-sprite", actual: config.IMPORT_NAME },
+ { name: "ROOT_DIR", expected: "src", actual: config.ROOT_DIR },
+ { name: "SPRITE_PATH", expected: "/icons.svg", actual: config.SPRITE_PATH },
+ { name: "CUSTOM_SVG_DIR", expected: "zero-ui-icons", actual: config.CUSTOM_SVG_DIR },
+ { name: "OUTPUT_DIR", expected: "public", actual: config.OUTPUT_DIR },
+ { name: "IGNORE_ICONS", expected: JSON.stringify(["CustomIcon"]), actual: JSON.stringify(config.IGNORE_ICONS) },
+ { name: "EXCLUDE_DIRS", expected: JSON.stringify(["node_modules", ".git", "dist", "build", ".next", "out"]), actual: JSON.stringify(config.EXCLUDE_DIRS) },
+ ];
+
+ let passed = true;
+ for (const assertion of assertions) {
+ if (assertion.expected === assertion.actual) {
+ console.log(` β
${assertion.name}: ${assertion.actual}`);
+ } else {
+ console.error(` β ${assertion.name}: expected ${assertion.expected}, got ${assertion.actual}`);
+ passed = false;
+ }
+ }
+
+ return passed;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ } finally {
+ cleanup();
+ }
+}
+
+// Test 2: Custom config loading
+async function testCustomConfig() {
+ console.log("\nπ§ͺ Test 2: Custom config loading from zero-ui.config.js");
+ setupTestProject();
+
+ try {
+ // Create a custom config file
+ const customConfig = `export default {
+ IMPORT_NAME: "@my-custom/icons",
+ ROOT_DIR: "app",
+ SPRITE_PATH: "/custom-icons.svg",
+ CUSTOM_SVG_DIR: "my-icons",
+ OUTPUT_DIR: "static",
+ IGNORE_ICONS: ["MyCustomIcon", "AnotherIcon"],
+ EXCLUDE_DIRS: ["node_modules", "vendor", "tmp"],
+};`;
+ fs.writeFileSync(CONFIG_FILE, customConfig, "utf8");
+
+ const config = runConfigTest();
+
+ const assertions = [
+ { name: "IMPORT_NAME", expected: "@my-custom/icons", actual: config.IMPORT_NAME },
+ { name: "ROOT_DIR", expected: "app", actual: config.ROOT_DIR },
+ { name: "SPRITE_PATH", expected: "/custom-icons.svg", actual: config.SPRITE_PATH },
+ { name: "CUSTOM_SVG_DIR", expected: "my-icons", actual: config.CUSTOM_SVG_DIR },
+ { name: "OUTPUT_DIR", expected: "static", actual: config.OUTPUT_DIR },
+ { name: "IGNORE_ICONS", expected: JSON.stringify(["MyCustomIcon", "AnotherIcon"]), actual: JSON.stringify(config.IGNORE_ICONS) },
+ { name: "EXCLUDE_DIRS", expected: JSON.stringify(["node_modules", "vendor", "tmp"]), actual: JSON.stringify(config.EXCLUDE_DIRS) },
+ ];
+
+ let passed = true;
+ for (const assertion of assertions) {
+ if (assertion.expected === assertion.actual) {
+ console.log(` β
${assertion.name}: ${assertion.actual}`);
+ } else {
+ console.error(` β ${assertion.name}: expected ${assertion.expected}, got ${assertion.actual}`);
+ passed = false;
+ }
+ }
+
+ return passed;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ } finally {
+ cleanup();
+ }
+}
+
+// Test 3: Partial config override (merging with defaults)
+async function testPartialConfig() {
+ console.log("\nπ§ͺ Test 3: Partial config override (merging with defaults)");
+ setupTestProject();
+
+ try {
+ // Create a partial config file (only override ROOT_DIR and EXCLUDE_DIRS)
+ const partialConfig = `export default {
+ ROOT_DIR: "lib",
+ EXCLUDE_DIRS: ["node_modules", "coverage"],
+};`;
+ fs.writeFileSync(CONFIG_FILE, partialConfig, "utf8");
+
+ const config = runConfigTest();
+
+ const assertions = [
+ { name: "IMPORT_NAME (default)", expected: "@react-zero-ui/icon-sprite", actual: config.IMPORT_NAME },
+ { name: "ROOT_DIR (override)", expected: "lib", actual: config.ROOT_DIR },
+ { name: "SPRITE_PATH (default)", expected: "/icons.svg", actual: config.SPRITE_PATH },
+ { name: "EXCLUDE_DIRS (override)", expected: JSON.stringify(["node_modules", "coverage"]), actual: JSON.stringify(config.EXCLUDE_DIRS) },
+ ];
+
+ let passed = true;
+ for (const assertion of assertions) {
+ if (assertion.expected === assertion.actual) {
+ console.log(` β
${assertion.name}: ${assertion.actual}`);
+ } else {
+ console.error(` β ${assertion.name}: expected ${assertion.expected}, got ${assertion.actual}`);
+ passed = false;
+ }
+ }
+
+ return passed;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ } finally {
+ cleanup();
+ }
+}
+
+// Test 4: Config file with syntax error (should warn and use defaults)
+async function testInvalidConfig() {
+ console.log("\nπ§ͺ Test 4: Invalid config file (should warn and use defaults)");
+ setupTestProject();
+
+ try {
+ // Create an invalid config file
+ const invalidConfig = `export default {
+ IMPORT_NAME: "unclosed string
+};`;
+ fs.writeFileSync(CONFIG_FILE, invalidConfig, "utf8");
+
+ const testScript = `
+import { ROOT_DIR, EXCLUDE_DIRS } from "../dist/config.js";
+console.log(JSON.stringify({ ROOT_DIR, EXCLUDE_DIRS }));
+`;
+
+ const testScriptPath = path.join(TEST_PROJECT_ROOT, "test-runner.js");
+ fs.writeFileSync(testScriptPath, testScript, "utf8");
+
+ // This should warn but still work with defaults
+ const result = execSync(`node "${testScriptPath}"`, {
+ cwd: TEST_PROJECT_ROOT,
+ encoding: "utf8",
+ stdio: ["pipe", "pipe", "pipe"]
+ });
+
+ const config = JSON.parse(result.trim());
+
+ // Should use defaults when config fails to load
+ const assertions = [
+ { name: "ROOT_DIR (fallback to default)", expected: "src", actual: config.ROOT_DIR },
+ { name: "EXCLUDE_DIRS (fallback to default)", expected: JSON.stringify(["node_modules", ".git", "dist", "build", ".next", "out"]), actual: JSON.stringify(config.EXCLUDE_DIRS) },
+ ];
+
+ let passed = true;
+ for (const assertion of assertions) {
+ if (assertion.expected === assertion.actual) {
+ console.log(` β
${assertion.name}`);
+ } else {
+ console.error(` β ${assertion.name}: expected ${assertion.expected}, got ${assertion.actual}`);
+ passed = false;
+ }
+ }
+
+ console.log(" βΉοΈ Warning should have been displayed to stderr");
+ return passed;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ } finally {
+ cleanup();
+ }
+}
+
+// Run all tests
+async function runTests() {
+ console.log("π Running config tests...\n");
+
+ const results = [];
+
+ results.push({ name: "Default config", passed: await testDefaultConfig() });
+ results.push({ name: "Custom config", passed: await testCustomConfig() });
+ results.push({ name: "Partial config", passed: await testPartialConfig() });
+ results.push({ name: "Invalid config", passed: await testInvalidConfig() });
+
+ console.log("\n" + "=".repeat(50));
+ console.log("π Test Results:");
+ console.log("=".repeat(50));
+
+ let allPassed = true;
+ for (const result of results) {
+ const status = result.passed ? "β
PASS" : "β FAIL";
+ console.log(`${status} - ${result.name}`);
+ if (!result.passed) allPassed = false;
+ }
+
+ console.log("=".repeat(50));
+
+ if (allPassed) {
+ console.log("\nβ
All config tests passed!");
+ process.exit(0);
+ } else {
+ console.error("\nβ Some config tests failed!");
+ process.exit(1);
+ }
+}
+
+runTests().catch((err) => {
+ console.error("β Test runner error:", err);
+ cleanup();
+ process.exit(1);
+});
diff --git a/icon-sprite/tests/test-scanner-exclusion.test.js b/icon-sprite/tests/test-scanner-exclusion.test.js
new file mode 100644
index 0000000..cf83428
--- /dev/null
+++ b/icon-sprite/tests/test-scanner-exclusion.test.js
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// Test 1: Verify EXCLUDE_DIRS is exported from config
+async function testExcludeDirsExport() {
+ console.log("\nπ§ͺ Test 1: EXCLUDE_DIRS is exported from config");
+
+ try {
+ const { EXCLUDE_DIRS } = await import("../dist/config.js");
+
+ if (!Array.isArray(EXCLUDE_DIRS)) {
+ console.error(" β EXCLUDE_DIRS is not an array");
+ return false;
+ }
+
+ if (!EXCLUDE_DIRS.includes("node_modules")) {
+ console.error(" β EXCLUDE_DIRS doesn't include 'node_modules'");
+ return false;
+ }
+
+ console.log(` β
EXCLUDE_DIRS exported: ${JSON.stringify(EXCLUDE_DIRS)}`);
+ console.log(` β
Contains 'node_modules': ${EXCLUDE_DIRS.includes("node_modules")}`);
+ console.log(` β
Contains '.git': ${EXCLUDE_DIRS.includes(".git")}`);
+ console.log(` β
Contains 'dist': ${EXCLUDE_DIRS.includes("dist")}`);
+
+ return true;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ }
+}
+
+// Test 2: Verify scan-icons.js imports EXCLUDE_DIRS
+async function testScannerImportsExcludeDirs() {
+ console.log("\nπ§ͺ Test 2: scan-icons.js imports EXCLUDE_DIRS");
+
+ try {
+ const scannerPath = path.resolve(__dirname, "../scripts/scan-icons.js");
+ const scannerContent = fs.readFileSync(scannerPath, "utf8");
+
+ // Check if EXCLUDE_DIRS is imported
+ if (!scannerContent.includes("EXCLUDE_DIRS")) {
+ console.error(" β scan-icons.js doesn't import EXCLUDE_DIRS");
+ return false;
+ }
+
+ // Check if the exclusion logic exists
+ if (!scannerContent.includes("EXCLUDE_DIRS.includes")) {
+ console.error(" β scan-icons.js doesn't use EXCLUDE_DIRS.includes()");
+ return false;
+ }
+
+ console.log(" β
scan-icons.js imports EXCLUDE_DIRS");
+ console.log(" β
scan-icons.js uses EXCLUDE_DIRS for exclusion logic");
+
+ return true;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ }
+}
+
+// Test 3: Verify default ROOT_DIR is "src"
+async function testDefaultRootDir() {
+ console.log("\nπ§ͺ Test 3: Default ROOT_DIR is 'src' (not empty string)");
+
+ try {
+ const { ROOT_DIR } = await import("../dist/config.js");
+
+ if (ROOT_DIR !== "src") {
+ console.error(` β ROOT_DIR is "${ROOT_DIR}", expected "src"`);
+ return false;
+ }
+
+ console.log(` β
ROOT_DIR: "${ROOT_DIR}"`);
+ console.log(" β
This prevents scanning from project root by default");
+
+ return true;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ }
+}
+
+// Test 4: Verify config loading logic is uncommented
+async function testConfigLoadingUncommented() {
+ console.log("\nπ§ͺ Test 4: Config loading logic is uncommented");
+
+ try {
+ const configPath = path.resolve(__dirname, "../src/config.ts");
+ const configContent = fs.readFileSync(configPath, "utf8");
+
+ // Check that imports are not commented
+ if (configContent.includes("// import fs from") || configContent.includes("// import path from")) {
+ console.error(" β Config imports are still commented");
+ return false;
+ }
+
+ // Check that DEFAULT_CONFIG is not commented
+ if (configContent.includes("// const DEFAULT_CONFIG")) {
+ console.error(" β DEFAULT_CONFIG is still commented");
+ return false;
+ }
+
+ // Check that config file loading is not commented
+ if (configContent.includes("// if (fs.existsSync(configFile))")) {
+ console.error(" β Config file loading is still commented");
+ return false;
+ }
+
+ console.log(" β
Config imports are uncommented");
+ console.log(" β
Config loading logic is active");
+ console.log(" β
zero-ui.config.js can be used");
+
+ return true;
+ } catch (err) {
+ console.error(` β Test failed: ${err.message}`);
+ return false;
+ }
+}
+
+// Run all tests
+async function runTests() {
+ console.log("π Running scanner exclusion tests...\n");
+
+ const results = [];
+
+ results.push({ name: "EXCLUDE_DIRS export", passed: await testExcludeDirsExport() });
+ results.push({ name: "Scanner imports EXCLUDE_DIRS", passed: await testScannerImportsExcludeDirs() });
+ results.push({ name: "Default ROOT_DIR", passed: await testDefaultRootDir() });
+ results.push({ name: "Config loading uncommented", passed: await testConfigLoadingUncommented() });
+
+ console.log("\n" + "=".repeat(50));
+ console.log("π Test Results:");
+ console.log("=".repeat(50));
+
+ let allPassed = true;
+ for (const result of results) {
+ const status = result.passed ? "β
PASS" : "β FAIL";
+ console.log(`${status} - ${result.name}`);
+ if (!result.passed) allPassed = false;
+ }
+
+ console.log("=".repeat(50));
+
+ if (allPassed) {
+ console.log("\nβ
All scanner exclusion tests passed!");
+ console.log("\nβΉοΈ These tests verify that:");
+ console.log(" 1. EXCLUDE_DIRS is properly exported from config");
+ console.log(" 2. The scanner uses EXCLUDE_DIRS to skip directories");
+ console.log(" 3. ROOT_DIR defaults to 'src' to avoid scanning node_modules");
+ console.log(" 4. Config loading from zero-ui.config.js is enabled");
+ process.exit(0);
+ } else {
+ console.error("\nβ Some scanner exclusion tests failed!");
+ process.exit(1);
+ }
+}
+
+runTests().catch((err) => {
+ console.error("β Test runner error:", err);
+ process.exit(1);
+});