@mrzr/react-svg-icons 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - Turns a folder of SVGs (for example exported from Figma) into React components: SVGO-optimised, typed, with `size`, `color`, `stroke`, `fill`, `strokeWidth`, `opacity`, `title`, a forwarded `ref`, and `colors`, `opacities` and `strokeWidths` lists with one entry per colour of the drawing.
8
+ - Writes a typed registry (`variant.name`, one lazy import per icon) and a lazy `Icon` component. A nested folder is part of the name, and two icons with one name stop the run.
9
+ - The config is data, never a script: YAML (`react-svg-icons.config.yaml`, `.yml`), JSON (`react-svg-icons.config.json`) or an rc file (`.react-svg-iconsrc` and its `.yaml`, `.yml` and `.json` variants). Options: `input`, `outDir`, `removeWords`, `defaultSize`, `defaultStrokeWidth`, `format`. An unknown option is an error.
10
+ - Formats the output with the project's own prettier and config (`format: prettier`, or `false` for raw text).
11
+ - `react-svg-icons --check` reports `missing`, `changed` and `leftover` files and exits with 1; line endings are ignored.
12
+ - Never wipes the output folder: only a generated file that a run no longer produces is deleted, with the folder it leaves empty.
13
+ - Folders with no SVG are ignored, and an SVG that would overwrite a generated file (`icon`, `registry`, `index`) is an error.
14
+ - `generate`, `write` and `compare` for use from a script.
15
+ - Requires Node.js 20 or newer.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mrzr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @mrzr/react-svg-icons
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@mrzr/react-svg-icons)](https://www.npmjs.com/package/@mrzr/react-svg-icons)
4
+ [![types](https://img.shields.io/npm/types/@mrzr/react-svg-icons)](https://www.npmjs.com/package/@mrzr/react-svg-icons)
5
+ [![license](https://img.shields.io/npm/l/@mrzr/react-svg-icons)](LICENSE)
6
+
7
+ Turn the SVG icons you export from Figma into typed React components. Each icon takes `size`, `color` and a prop per colour in the drawing, and a lazy `Icon` loads any of them by name.
8
+
9
+ **[Wiki](https://github.com/mohammadreza-zr/react-svg-icons/wiki)** · [Quick start](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Quick-Start) · [Components](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Components) · [Configuration](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Configuration) · [Changelog](https://github.com/mohammadreza-zr/react-svg-icons/blob/main/CHANGELOG.md)
10
+
11
+ ```bash
12
+ npm install --save-dev @mrzr/react-svg-icons
13
+ npx @mrzr/react-svg-icons
14
+ ```
15
+
16
+ ---
17
+
18
+ ## Is this for you?
19
+
20
+ **Use it if**
21
+
22
+ - Your icons are SVG files exported from Figma, grouped in folders (`bold/`, `linear/`, …)
23
+ - You use React, and want an icon to be a component that takes `size` and `color`
24
+ - You want two-tone icons whose colours you can set one by one
25
+ - You want CI to fail when someone adds an SVG but forgets to regenerate
26
+
27
+ **Use something else if not.** It writes React components only: no Vue, Svelte or sprite sheet. For a single icon set from npm, use that set's own package.
28
+
29
+ ---
30
+
31
+ ## Quick start
32
+
33
+ Put the exported SVGs in a folder (subfolders are variants) and add `react-svg-icons.config.yaml` next to your `package.json`:
34
+
35
+ <!-- verbatim: examples/basic/react-svg-icons.config.yaml -->
36
+ ```yaml
37
+ input: icons
38
+ outDir: generated
39
+
40
+ # Words dropped from every icon name.
41
+ removeWords: [vuesax, icon]
42
+ ```
43
+
44
+ ```bash
45
+ npx @mrzr/react-svg-icons # writes the components into generated/
46
+ npx @mrzr/react-svg-icons --check # writes nothing; exit code 1 when they are out of date
47
+ ```
48
+
49
+ ```tsx
50
+ import { Icon } from "./generated";
51
+ import { Logout } from "./generated/bold";
52
+
53
+ <Icon name="bold.logout" size={20} /> {/* loaded on demand, the name is typed */}
54
+ <Logout size={20} color="crimson" /> {/* imported directly */}
55
+ ```
56
+
57
+ The config can also be `.yml`, `.json` or an rc file (`.react-svg-iconsrc`). It is always data, never a script.
58
+
59
+ ---
60
+
61
+ ## What it does
62
+
63
+ - **One component per SVG**, optimised with SVGO, with `size`, `color`, `stroke`, `fill`, `strokeWidth`, `opacity`, a `title` for accessibility, and a forwarded `ref`
64
+ - **A prop per colour**: `colors={["red", "blue"]}` sets the first and the second colour of the drawing, and `opacities` and `strokeWidths` do the same
65
+ - **A lazy `Icon`** and a typed registry: `name="bold.logout"`, one chunk per icon
66
+ - **Prettier-formatted output** with your project's prettier and its config, so `--check` passes after your own formatting
67
+ - **`--check`** and cleanup: a generated file for an SVG you removed is deleted, and nothing else is ever deleted
68
+
69
+ ## What it does not do
70
+
71
+ No Vue, Svelte or sprite output. Gradients, masks and ids are kept as they are, so two icons that use the same id can clash on one page. The `style` attribute of an SVG is dropped, and `stroke-width` becomes one prop for the whole icon. The [Home](https://github.com/mohammadreza-zr/react-svg-icons/wiki) page lists every limit.
72
+
73
+ ---
74
+
75
+ ## Documentation
76
+
77
+ Everything is in the **[Wiki](https://github.com/mohammadreza-zr/react-svg-icons/wiki)**:
78
+
79
+ - **Start:** [Installation](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Installation) · [Quick start](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Quick-Start)
80
+ - **Guides:** [Input format](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Input-Format) · [Naming](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Naming) · [Components](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Components) · [The Icon component](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Icon-Component)
81
+ - **Reference:** [Configuration](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Configuration) · [CLI](https://github.com/mohammadreza-zr/react-svg-icons/wiki/CLI) · [API](https://github.com/mohammadreza-zr/react-svg-icons/wiki/API-Reference)
82
+ - **Help:** [Troubleshooting](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Troubleshooting) · [FAQ](https://github.com/mohammadreza-zr/react-svg-icons/wiki/FAQ) · [Contributing](https://github.com/mohammadreza-zr/react-svg-icons/wiki/Contributing)
83
+
84
+ ## Requirements
85
+
86
+ Node.js 20 or newer. The generated code needs React (tested with 19) and a bundler-style module resolution (`"moduleResolution": "bundler"` in TypeScript), because its imports have no file extension.
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,17 @@
1
+ import { type ReactSvgIconsConfig } from "./config.js";
2
+ export interface OutputFile {
3
+ path: string;
4
+ content: string;
5
+ }
6
+ export interface Output {
7
+ outDir: string;
8
+ files: OutputFile[];
9
+ }
10
+ export interface Difference {
11
+ path: string;
12
+ reason: "missing" | "changed" | "leftover";
13
+ }
14
+ export declare function generate(input: ReactSvgIconsConfig, cwd?: string): Promise<Output>;
15
+ export declare function compare(output: Output): Difference[];
16
+ /** Writes the files, and removes only generated files that are no longer produced. */
17
+ export declare function write(output: Output): void;
package/dist/build.js ADDED
@@ -0,0 +1,137 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { formatFiles } from "./format.js";
4
+ import { generateComponentCode } from "./component.js";
5
+ import { resolveConfig } from "./config.js";
6
+ import { HEADER, MARKER } from "./header.js";
7
+ import { cleanIconName, toCamelCase, toKebabCase } from "./names.js";
8
+ import { iconComponentCode, registryCode } from "./registry.js";
9
+ function buildTree(dir, basePath = []) {
10
+ const node = { name: path.basename(dir), path: basePath, files: [], children: [] };
11
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
12
+ for (const entry of entries) {
13
+ const fullPath = path.join(dir, entry.name);
14
+ if (entry.isDirectory())
15
+ node.children.push(buildTree(fullPath, [...basePath, entry.name]));
16
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".svg"))
17
+ node.files.push({ name: entry.name, fullPath });
18
+ }
19
+ return node;
20
+ }
21
+ const hasIcons = (node) => node.files.length > 0 || node.children.some(hasIcons);
22
+ function walkTree(node, isRoot, outDir, config, out) {
23
+ const currentDir = isRoot ? outDir : path.join(outDir, ...node.path);
24
+ const generated = [];
25
+ const used = new Set();
26
+ const allFiles = [];
27
+ for (const file of node.files) {
28
+ let componentName = cleanIconName(file.name, node.name, config.removeWords);
29
+ const wanted = componentName;
30
+ let counter = 2;
31
+ while (used.has(componentName))
32
+ componentName = `${wanted}${counter++}`;
33
+ used.add(componentName);
34
+ const fileName = `${toKebabCase(componentName)}.tsx`;
35
+ const stem = fileName.replace(".tsx", "");
36
+ if (stem === "index" || (isRoot && (stem === "icon" || stem === "registry"))) {
37
+ throw new Error(`react-svg-icons: ${file.fullPath} would be written to ${fileName}, a name the generated ${stem} file already uses; rename the SVG`);
38
+ }
39
+ const code = generateComponentCode(readFileSync(file.fullPath, "utf8"), componentName, file.fullPath, config);
40
+ out.push({ path: path.join(currentDir, fileName), content: code });
41
+ generated.push({ componentName, fileName });
42
+ allFiles.push({ componentName, fileName, folderName: node.name, path: node.path });
43
+ }
44
+ const children = [];
45
+ for (const child of node.children.filter(hasIcons)) {
46
+ const result = walkTree(child, false, outDir, config, out);
47
+ children.push(result);
48
+ allFiles.push(...result.allFiles);
49
+ }
50
+ let index = HEADER;
51
+ if (isRoot) {
52
+ // No root-level namespace export on purpose: a static `export * from` of a
53
+ // variant would pull every icon of it into one chunk and defeat the lazy
54
+ // imports of the registry.
55
+ out.push({ path: path.join(currentDir, "registry.ts"), content: registryCode(allFiles) });
56
+ out.push({ path: path.join(currentDir, "icon.tsx"), content: iconComponentCode(config.defaultSize, config.defaultStrokeWidth) });
57
+ index += `export * from "./registry";\nexport * from "./icon";\n`;
58
+ }
59
+ else {
60
+ for (const file of generated) {
61
+ const base = file.fileName.replace(".tsx", "");
62
+ index += `export { ${file.componentName} } from "./${base}";\nexport type { ${file.componentName}Props } from "./${base}";\n`;
63
+ }
64
+ for (const child of children)
65
+ index += `export * as ${child.camelName} from "./${child.name}";\n`;
66
+ }
67
+ out.push({ path: path.join(currentDir, "index.ts"), content: index });
68
+ return { name: node.name, camelName: toCamelCase(node.name), allFiles };
69
+ }
70
+ export async function generate(input, cwd = process.cwd()) {
71
+ const config = resolveConfig(input);
72
+ const inputDir = path.resolve(cwd, config.input);
73
+ const outDir = path.resolve(cwd, config.outDir);
74
+ if (!existsSync(inputDir))
75
+ throw new Error(`react-svg-icons: input folder not found: ${inputDir}`);
76
+ if (outDir === inputDir || inputDir.startsWith(outDir + path.sep)) {
77
+ throw new Error(`react-svg-icons: outDir (${outDir}) must not hold the input folder (${inputDir})`);
78
+ }
79
+ const tree = buildTree(inputDir);
80
+ const files = [];
81
+ walkTree(tree, true, outDir, config, files);
82
+ const iconCount = files.filter((f) => f.path.endsWith(".tsx") && !f.path.endsWith(`${path.sep}icon.tsx`)).length;
83
+ if (iconCount === 0)
84
+ throw new Error(`react-svg-icons: no .svg files in ${inputDir}`);
85
+ return { outDir, files: await formatFiles(files, cwd, config.format) };
86
+ }
87
+ const lf = (text) => text.replace(/\r\n/g, "\n");
88
+ function existingGenerated(dir) {
89
+ if (!existsSync(dir))
90
+ return [];
91
+ const found = [];
92
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
93
+ const full = path.join(dir, entry.name);
94
+ if (entry.isDirectory())
95
+ found.push(...existingGenerated(full));
96
+ else if (/\.tsx?$/.test(entry.name) && readFileSync(full, "utf8").slice(0, 200).includes(MARKER))
97
+ found.push(full);
98
+ }
99
+ return found;
100
+ }
101
+ export function compare(output) {
102
+ const wanted = new Set(output.files.map((f) => f.path));
103
+ const differences = [];
104
+ for (const file of output.files) {
105
+ if (!existsSync(file.path))
106
+ differences.push({ path: file.path, reason: "missing" });
107
+ else if (lf(readFileSync(file.path, "utf8")) !== lf(file.content))
108
+ differences.push({ path: file.path, reason: "changed" });
109
+ }
110
+ for (const file of existingGenerated(output.outDir)) {
111
+ if (!wanted.has(file))
112
+ differences.push({ path: file, reason: "leftover" });
113
+ }
114
+ return differences;
115
+ }
116
+ function removeEmptyDirs(dir, keep) {
117
+ if (!existsSync(dir))
118
+ return;
119
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
120
+ if (entry.isDirectory())
121
+ removeEmptyDirs(path.join(dir, entry.name), keep);
122
+ }
123
+ if (dir !== keep && readdirSync(dir).length === 0)
124
+ rmdirSync(dir);
125
+ }
126
+ /** Writes the files, and removes only generated files that are no longer produced. */
127
+ export function write(output) {
128
+ for (const file of output.files) {
129
+ mkdirSync(path.dirname(file.path), { recursive: true });
130
+ writeFileSync(file.path, file.content);
131
+ }
132
+ for (const difference of compare(output)) {
133
+ if (difference.reason === "leftover")
134
+ unlinkSync(difference.path);
135
+ }
136
+ removeEmptyDirs(output.outDir, output.outDir);
137
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { compare, generate, write } from "./build.js";
5
+ import { findConfig, loadConfig } from "./config.js";
6
+ const HELP = `react-svg-icons — SVG icons to typed React components
7
+
8
+ Usage:
9
+ react-svg-icons [build] [--config <file>] [--check]
10
+
11
+ Options:
12
+ --config <file> Config file (default: react-svg-icons.config.yaml|yml|json or .react-svg-iconsrc in the cwd)
13
+ --check Write nothing; exit 1 when the output on disk is out of date
14
+ -v, --version Print the version
15
+ -h, --help Show this help
16
+ `;
17
+ function parse(argv) {
18
+ const args = { check: false, help: false, version: false };
19
+ const rest = [...argv];
20
+ if (rest[0] === "build")
21
+ rest.shift();
22
+ for (let i = 0; i < rest.length; i++) {
23
+ const arg = rest[i];
24
+ if (arg === "--check")
25
+ args.check = true;
26
+ else if (arg === "-h" || arg === "--help")
27
+ args.help = true;
28
+ else if (arg === "-v" || arg === "--version")
29
+ args.version = true;
30
+ else if (arg === "--config") {
31
+ const value = rest[++i];
32
+ if (!value)
33
+ throw new Error("react-svg-icons: --config needs a file");
34
+ args.config = value;
35
+ }
36
+ else
37
+ throw new Error(`react-svg-icons: unknown argument "${arg}" (see --help)`);
38
+ }
39
+ return args;
40
+ }
41
+ async function main(argv) {
42
+ const args = parse(argv);
43
+ if (args.help) {
44
+ process.stdout.write(HELP);
45
+ return 0;
46
+ }
47
+ if (args.version) {
48
+ const pkg = new URL("../package.json", import.meta.url);
49
+ console.log(JSON.parse(readFileSync(pkg, "utf8")).version);
50
+ return 0;
51
+ }
52
+ const cwd = process.cwd();
53
+ const file = findConfig(cwd, args.config);
54
+ const config = await loadConfig(file);
55
+ const output = await generate(config, path.dirname(file));
56
+ if (args.check) {
57
+ const differences = compare(output);
58
+ if (differences.length === 0) {
59
+ console.log(`✔ icons are up to date (${output.files.length} files)`);
60
+ return 0;
61
+ }
62
+ for (const d of differences)
63
+ console.error(`✖ ${d.reason}: ${path.relative(cwd, d.path)}`);
64
+ console.error("Run `npx @mrzr/react-svg-icons` and commit the result.");
65
+ return 1;
66
+ }
67
+ write(output);
68
+ console.log(`✔ ${output.files.length} files in ${path.relative(cwd, output.outDir) || "."}`);
69
+ return 0;
70
+ }
71
+ main(process.argv.slice(2)).then((code) => {
72
+ process.exitCode = code;
73
+ }, (error) => {
74
+ console.error(error instanceof Error ? error.message : error);
75
+ process.exitCode = 1;
76
+ });
@@ -0,0 +1,22 @@
1
+ import type { INode } from "svgson";
2
+ export interface ColorInfo {
3
+ key: string;
4
+ normalizedColor: string;
5
+ value: string;
6
+ count: number;
7
+ firstOrder: number;
8
+ isTranslucent: boolean;
9
+ }
10
+ export declare function normalizeColor(value: unknown): string;
11
+ export declare function isColorLike(value: unknown): boolean;
12
+ export declare function isTranslucentColorValue(value: unknown): boolean;
13
+ export declare function collectColorInfo(svgNode: INode): ColorInfo[];
14
+ export interface IconContext {
15
+ colorSlots: string[];
16
+ getColorIndex(originalColor: unknown, isTranslucent: boolean): number;
17
+ }
18
+ /**
19
+ * Slot 0 is the most used opaque colour, then the other opaque ones, then the
20
+ * translucent ones; equal counts keep the order they first appear in.
21
+ */
22
+ export declare function createIconContext(colorInfos: ColorInfo[]): IconContext;
package/dist/color.js ADDED
@@ -0,0 +1,132 @@
1
+ export function normalizeColor(value) {
2
+ return String(value).trim().toLowerCase().replace(/\s+/g, " ");
3
+ }
4
+ export function isColorLike(value) {
5
+ if (value == null)
6
+ return false;
7
+ const v = normalizeColor(value);
8
+ if (!v)
9
+ return false;
10
+ if (["none", "transparent", "currentcolor", "inherit"].includes(v))
11
+ return false;
12
+ if (v.startsWith("url("))
13
+ return false;
14
+ return true;
15
+ }
16
+ export function isTranslucentColorValue(value) {
17
+ const v = normalizeColor(value);
18
+ if (!v)
19
+ return false;
20
+ if (v.includes("rgba")) {
21
+ const match = v.match(/rgba\(([^)]+)\)/);
22
+ if (match) {
23
+ const parts = match[1].split(",").map((p) => p.trim());
24
+ if (parts.length === 4) {
25
+ const a = Number.parseFloat(parts[3]);
26
+ if (Number.isFinite(a) && a < 1)
27
+ return true;
28
+ }
29
+ }
30
+ }
31
+ const slashAlpha = v.match(/\/\s*([0-9]*\.?[0-9]+)\s*\)/);
32
+ if (slashAlpha) {
33
+ const a = Number.parseFloat(slashAlpha[1]);
34
+ if (Number.isFinite(a) && a < 1)
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ export function collectColorInfo(svgNode) {
40
+ const infos = new Map();
41
+ let firstOrder = 0;
42
+ function register(value, opacityHint) {
43
+ if (value == null)
44
+ return;
45
+ const normalized = normalizeColor(value);
46
+ if (normalized === "currentcolor" || !isColorLike(value))
47
+ return;
48
+ const isTranslucent = opacityHint < 1 || isTranslucentColorValue(value);
49
+ const key = `${normalized}__${isTranslucent ? "alpha" : "solid"}`;
50
+ if (!infos.has(key)) {
51
+ infos.set(key, {
52
+ key,
53
+ normalizedColor: normalized,
54
+ value: String(value),
55
+ count: 0,
56
+ firstOrder: firstOrder++,
57
+ isTranslucent,
58
+ });
59
+ }
60
+ infos.get(key).count += 1;
61
+ }
62
+ function visit(node, inheritedOpacity) {
63
+ if (node.type !== "element")
64
+ return;
65
+ const attrs = node.attributes || {};
66
+ let currentOpacity = inheritedOpacity;
67
+ if (attrs.opacity != null) {
68
+ const p = Number.parseFloat(attrs.opacity);
69
+ if (Number.isFinite(p))
70
+ currentOpacity *= p;
71
+ }
72
+ const fo = attrs["fill-opacity"] ?? attrs.fillOpacity;
73
+ const so = attrs["stroke-opacity"] ?? attrs.strokeOpacity;
74
+ if (attrs.fill != null) {
75
+ let fo2 = currentOpacity;
76
+ if (fo != null) {
77
+ const p = Number.parseFloat(fo);
78
+ if (Number.isFinite(p))
79
+ fo2 *= p;
80
+ }
81
+ register(attrs.fill, fo2);
82
+ }
83
+ if (attrs.stroke != null) {
84
+ let so2 = currentOpacity;
85
+ if (so != null) {
86
+ const p = Number.parseFloat(so);
87
+ if (Number.isFinite(p))
88
+ so2 *= p;
89
+ }
90
+ register(attrs.stroke, so2);
91
+ }
92
+ for (const child of node.children || [])
93
+ visit(child, currentOpacity);
94
+ }
95
+ visit(svgNode, 1);
96
+ return [...infos.values()];
97
+ }
98
+ /**
99
+ * Slot 0 is the most used opaque colour, then the other opaque ones, then the
100
+ * translucent ones; equal counts keep the order they first appear in.
101
+ */
102
+ export function createIconContext(colorInfos) {
103
+ const sorted = [...colorInfos].sort((a, b) => {
104
+ if (a.isTranslucent !== b.isTranslucent)
105
+ return a.isTranslucent ? 1 : -1;
106
+ if (b.count !== a.count)
107
+ return b.count - a.count;
108
+ return a.firstOrder - b.firstOrder;
109
+ });
110
+ const colorIndex = new Map();
111
+ const colorSlots = [];
112
+ sorted.forEach((info, index) => {
113
+ colorIndex.set(info.key, index);
114
+ colorSlots.push(info.value);
115
+ });
116
+ return {
117
+ colorSlots,
118
+ getColorIndex(originalColor, isTranslucent) {
119
+ const normalized = normalizeColor(originalColor);
120
+ const key = `${normalized}__${isTranslucent ? "alpha" : "solid"}`;
121
+ if (colorIndex.has(key))
122
+ return colorIndex.get(key);
123
+ const other = `${normalized}__${isTranslucent ? "solid" : "alpha"}`;
124
+ if (colorIndex.has(other))
125
+ return colorIndex.get(other);
126
+ const index = colorSlots.length;
127
+ colorIndex.set(key, index);
128
+ colorSlots.push(String(originalColor));
129
+ return index;
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,5 @@
1
+ export interface ComponentOptions {
2
+ defaultSize: number;
3
+ defaultStrokeWidth: number;
4
+ }
5
+ export declare function generateComponentCode(rawSvg: string, componentName: string, file: string, options: ComponentOptions): string;
@@ -0,0 +1,162 @@
1
+ import { optimize } from "svgo";
2
+ import * as svgson from "svgson";
3
+ import { collectColorInfo, createIconContext, isTranslucentColorValue, normalizeColor } from "./color.js";
4
+ import { HEADER } from "./header.js";
5
+ import { attributeToJsx, escapeJsString, nodeToJsx, transformNode } from "./jsx.js";
6
+ const parseSync = svgson.parseSync ??
7
+ svgson.default.parseSync;
8
+ function optimizeSvg(svg) {
9
+ return optimize(svg, {
10
+ multipass: true,
11
+ plugins: [
12
+ {
13
+ name: "preset-default",
14
+ params: { overrides: { cleanupIds: false } },
15
+ },
16
+ ],
17
+ }).data;
18
+ }
19
+ function walk(node, fn) {
20
+ if (!node)
21
+ return;
22
+ fn(node);
23
+ for (const child of node.children || [])
24
+ walk(child, fn);
25
+ }
26
+ function mostCommon(values) {
27
+ if (!values.length)
28
+ return undefined;
29
+ const counts = new Map();
30
+ let best = values[0];
31
+ for (const value of values) {
32
+ counts.set(value, (counts.get(value) || 0) + 1);
33
+ if (counts.get(value) > (counts.get(best) || 0))
34
+ best = value;
35
+ }
36
+ return best;
37
+ }
38
+ export function generateComponentCode(rawSvg, componentName, file, options) {
39
+ let ast;
40
+ try {
41
+ ast = parseSync(optimizeSvg(rawSvg));
42
+ }
43
+ catch (error) {
44
+ throw new Error(`react-svg-icons: ${file} is not a valid SVG (${error.message})`);
45
+ }
46
+ const svgNode = (ast.name === "svg" ? ast : (ast.children || []).find((n) => n.name === "svg"));
47
+ if (!svgNode)
48
+ throw new Error(`react-svg-icons: no <svg> root in ${file}`);
49
+ svgNode.attributes = svgNode.attributes || {};
50
+ const fallbackWidth = Number.parseFloat(svgNode.attributes.width || "24");
51
+ const fallbackHeight = Number.parseFloat(svgNode.attributes.height || "24");
52
+ const viewBox = svgNode.attributes.viewBox ||
53
+ svgNode.attributes.viewbox ||
54
+ `0 0 ${Number.isFinite(fallbackWidth) ? fallbackWidth : 24} ${Number.isFinite(fallbackHeight) ? fallbackHeight : 24}`;
55
+ const strokeWidthValues = [];
56
+ walk(svgNode, (node) => {
57
+ if (node.type !== "element")
58
+ return;
59
+ const v = node.attributes?.["stroke-width"] ?? node.attributes?.strokeWidth;
60
+ if (v != null)
61
+ strokeWidthValues.push(String(v));
62
+ });
63
+ const commonStrokeWidth = mostCommon(strokeWidthValues);
64
+ const parsedStrokeWidth = commonStrokeWidth == null ? options.defaultStrokeWidth : Number.parseFloat(commonStrokeWidth);
65
+ const strokeWidthJsDefault = Number.isFinite(parsedStrokeWidth)
66
+ ? parsedStrokeWidth
67
+ : escapeJsString(commonStrokeWidth);
68
+ const ctx = createIconContext(collectColorInfo(svgNode));
69
+ const rootFill = svgNode.attributes.fill;
70
+ const rootStroke = svgNode.attributes.stroke;
71
+ let rootFillAttribute;
72
+ if (rootFill == null || normalizeColor(rootFill) === "none" || normalizeColor(rootFill) === "transparent") {
73
+ rootFillAttribute = `fill="none"`;
74
+ }
75
+ else {
76
+ const index = ctx.getColorIndex(rootFill, isTranslucentColorValue(rootFill));
77
+ rootFillAttribute = `fill={colors?.[${index}] ?? fill ?? color ?? "currentColor"}`;
78
+ }
79
+ let rootStrokeAttribute = "";
80
+ if (rootStroke != null && normalizeColor(rootStroke) !== "none") {
81
+ const index = ctx.getColorIndex(rootStroke, isTranslucentColorValue(rootStroke));
82
+ rootStrokeAttribute = `stroke={colors?.[${index}] ?? stroke ?? color ?? "currentColor"}`;
83
+ }
84
+ for (const name of [
85
+ "width", "height", "viewBox", "viewbox", "fill", "stroke",
86
+ "stroke-width", "strokeWidth", "xmlns", "xmlns:xlink", "version", "style",
87
+ ]) {
88
+ delete svgNode.attributes[name];
89
+ }
90
+ transformNode(svgNode, ctx);
91
+ const rootExtraAttrs = Object.entries(svgNode.attributes || {})
92
+ .map(([name, value]) => attributeToJsx(name, value))
93
+ .filter(Boolean)
94
+ .join(" ");
95
+ const childrenJsx = (svgNode.children || []).map((child) => nodeToJsx(child, 3)).join("");
96
+ const colorComment = ctx.colorSlots.length
97
+ ? `/**\n * Original color slots:\n${ctx.colorSlots.map((slot, i) => ` * - colors[${i}] = ${slot}`).join("\n")}\n */\n`
98
+ : "";
99
+ const rootPropLines = [
100
+ "ref={ref}",
101
+ "width={size}",
102
+ "height={size}",
103
+ `viewBox={${escapeJsString(viewBox)}}`,
104
+ rootFillAttribute,
105
+ rootStrokeAttribute,
106
+ rootExtraAttrs,
107
+ `role={title ? "img" : undefined}`,
108
+ `aria-hidden={title ? undefined : true}`,
109
+ "{...props}",
110
+ ]
111
+ .filter(Boolean)
112
+ .map((line) => ` ${line}`)
113
+ .join("\n");
114
+ return `${HEADER}${colorComment}import * as React from "react";
115
+
116
+ export type ${componentName}Props = Omit<
117
+ React.SVGProps<SVGSVGElement>,
118
+ "color" | "stroke" | "fill" | "strokeWidth" | "opacity"
119
+ > & {
120
+ size?: number | string;
121
+ color?: string;
122
+ stroke?: string;
123
+ fill?: string;
124
+ strokeWidth?: number | string;
125
+ opacity?: number | string;
126
+ colors?: (string | undefined)[];
127
+ opacities?: (number | string | undefined)[];
128
+ strokeWidths?: (number | string | undefined)[];
129
+ title?: string;
130
+ };
131
+
132
+ export const ${componentName} = React.forwardRef<
133
+ SVGSVGElement,
134
+ ${componentName}Props
135
+ >(
136
+ function ${componentName}(
137
+ {
138
+ size = ${options.defaultSize},
139
+ color = "currentColor",
140
+ stroke,
141
+ fill,
142
+ strokeWidth = ${strokeWidthJsDefault},
143
+ opacity,
144
+ colors,
145
+ opacities,
146
+ strokeWidths,
147
+ title,
148
+ ...props
149
+ },
150
+ ref
151
+ ) {
152
+ return (
153
+ <svg
154
+ ${rootPropLines}
155
+ >
156
+ {title ? <title>{title}</title> : null}
157
+ ${childrenJsx} </svg>
158
+ );
159
+ }
160
+ );
161
+ `;
162
+ }
@@ -0,0 +1,26 @@
1
+ export interface ReactSvgIconsConfig {
2
+ /** Folder with the exported `*.svg` files. Subfolders become variants. */
3
+ input: string;
4
+ /** Folder the components are written to. */
5
+ outDir: string;
6
+ /** Words dropped from every icon name, in any case: `[vuesax, icon]`. */
7
+ removeWords?: string[];
8
+ /** The `size` an icon has when none is given. Default 24. */
9
+ defaultSize?: number;
10
+ /** The `strokeWidth` of an icon whose SVG has none. Default 2. */
11
+ defaultStrokeWidth?: number;
12
+ /** `"prettier"` formats with the project's prettier and its config; `false` writes raw. Default `"prettier"`. */
13
+ format?: "prettier" | false;
14
+ }
15
+ export interface ResolvedConfig {
16
+ input: string;
17
+ outDir: string;
18
+ removeWords: string[];
19
+ defaultSize: number;
20
+ defaultStrokeWidth: number;
21
+ format: "prettier" | false;
22
+ }
23
+ export declare function defineConfig(config: ReactSvgIconsConfig): ReactSvgIconsConfig;
24
+ export declare function resolveConfig(config: ReactSvgIconsConfig): ResolvedConfig;
25
+ export declare function findConfig(cwd: string, explicit?: string): string;
26
+ export declare function loadConfig(file: string): Promise<ReactSvgIconsConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ const OPTIONS = ["input", "outDir", "removeWords", "defaultSize", "defaultStrokeWidth", "format"];
5
+ export function defineConfig(config) {
6
+ return config;
7
+ }
8
+ export function resolveConfig(config) {
9
+ for (const key of Object.keys(config)) {
10
+ if (!OPTIONS.includes(key)) {
11
+ throw new Error(`react-svg-icons: unknown option "${key}" (options: ${OPTIONS.join(", ")})`);
12
+ }
13
+ }
14
+ if (!config.input)
15
+ throw new Error("react-svg-icons: `input` is required");
16
+ if (!config.outDir)
17
+ throw new Error("react-svg-icons: `outDir` is required");
18
+ const number = (name, value, fallback) => {
19
+ if (value === undefined)
20
+ return fallback;
21
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
22
+ throw new Error(`react-svg-icons: \`${name}\` must be a positive number`);
23
+ }
24
+ return value;
25
+ };
26
+ if (config.removeWords !== undefined && !Array.isArray(config.removeWords)) {
27
+ throw new Error("react-svg-icons: `removeWords` must be a list of words");
28
+ }
29
+ if (config.format !== undefined && config.format !== "prettier" && config.format !== false) {
30
+ throw new Error('react-svg-icons: `format` must be "prettier" or false');
31
+ }
32
+ return {
33
+ input: config.input,
34
+ outDir: config.outDir,
35
+ removeWords: (config.removeWords ?? []).map((w) => String(w).toLowerCase()),
36
+ defaultSize: number("defaultSize", config.defaultSize, 24),
37
+ defaultStrokeWidth: number("defaultStrokeWidth", config.defaultStrokeWidth, 2),
38
+ format: config.format ?? "prettier",
39
+ };
40
+ }
41
+ const CONFIG_NAMES = [
42
+ "react-svg-icons.config.yaml",
43
+ "react-svg-icons.config.yml",
44
+ "react-svg-icons.config.json",
45
+ ".react-svg-iconsrc",
46
+ ".react-svg-iconsrc.yaml",
47
+ ".react-svg-iconsrc.yml",
48
+ ".react-svg-iconsrc.json",
49
+ ];
50
+ const SCRIPT = /\.(c|m)?(j|t)s$/;
51
+ export function findConfig(cwd, explicit) {
52
+ if (explicit) {
53
+ const file = path.resolve(cwd, explicit);
54
+ if (!existsSync(file))
55
+ throw new Error(`react-svg-icons: no config at ${file}`);
56
+ return file;
57
+ }
58
+ for (const name of CONFIG_NAMES) {
59
+ const file = path.resolve(cwd, name);
60
+ if (existsSync(file))
61
+ return file;
62
+ }
63
+ throw new Error(`react-svg-icons: no config found; create ${CONFIG_NAMES[0]} (see the README)`);
64
+ }
65
+ export async function loadConfig(file) {
66
+ if (SCRIPT.test(file)) {
67
+ throw new Error(`react-svg-icons: ${file} is a script; the config must be YAML or JSON`);
68
+ }
69
+ const text = readFileSync(file, "utf8").replace(/^/, "");
70
+ const isJson = file.endsWith(".json");
71
+ let parsed;
72
+ try {
73
+ parsed = isJson ? JSON.parse(text) : parseYaml(text);
74
+ }
75
+ catch (error) {
76
+ throw new Error(`react-svg-icons: ${file} is not valid ${isJson ? "JSON" : "YAML"} (${error.message})`);
77
+ }
78
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
79
+ throw new Error(`react-svg-icons: ${file} must hold a mapping of options`);
80
+ }
81
+ return parsed;
82
+ }
@@ -0,0 +1,7 @@
1
+ export declare function formatFiles(files: {
2
+ path: string;
3
+ content: string;
4
+ }[], cwd: string, format: "prettier" | false): Promise<{
5
+ path: string;
6
+ content: string;
7
+ }[]>;
package/dist/format.js ADDED
@@ -0,0 +1,42 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ /** The project's own prettier first, then the one installed next to this package. */
5
+ async function loadPrettier(cwd) {
6
+ try {
7
+ const found = createRequire(path.join(cwd, "noop.js")).resolve("prettier");
8
+ return (await import(pathToFileURL(found).href)).default;
9
+ }
10
+ catch {
11
+ try {
12
+ return (await import("prettier")).default;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ }
19
+ export async function formatFiles(files, cwd, format) {
20
+ if (format === false)
21
+ return files;
22
+ const prettier = await loadPrettier(cwd);
23
+ if (!prettier) {
24
+ console.warn("react-svg-icons: prettier is not installed, so the output is not formatted. " +
25
+ "Install it in the project, or set `format: false` to silence this.");
26
+ return files;
27
+ }
28
+ const config = (await prettier.resolveConfig(path.join(cwd, "package.json"))) ?? {};
29
+ const out = [];
30
+ for (const file of files) {
31
+ try {
32
+ out.push({
33
+ path: file.path,
34
+ content: await prettier.format(file.content, { ...config, parser: "typescript", filepath: file.path }),
35
+ });
36
+ }
37
+ catch (error) {
38
+ throw new Error(`react-svg-icons: the generated ${file.path} does not parse (${error.message})`);
39
+ }
40
+ }
41
+ return out;
42
+ }
@@ -0,0 +1,2 @@
1
+ export declare const MARKER = "auto-generated by react-svg-icons";
2
+ export declare const HEADER = "/* eslint-disable */\n/* This file is auto-generated by react-svg-icons. Do not edit manually. */\n\n";
package/dist/header.js ADDED
@@ -0,0 +1,2 @@
1
+ export const MARKER = "auto-generated by react-svg-icons";
2
+ export const HEADER = `/* eslint-disable */\n/* This file is ${MARKER}. Do not edit manually. */\n\n`;
@@ -0,0 +1,2 @@
1
+ export { compare, generate, write, type Difference, type Output, type OutputFile } from "./build.js";
2
+ export { defineConfig, findConfig, loadConfig, resolveConfig, type ReactSvgIconsConfig, type ResolvedConfig, } from "./config.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { compare, generate, write } from "./build.js";
2
+ export { defineConfig, findConfig, loadConfig, resolveConfig, } from "./config.js";
package/dist/jsx.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { INode } from "svgson";
2
+ import { type IconContext } from "./color.js";
3
+ export interface IconNode extends INode {
4
+ mappedFill?: number;
5
+ mappedStroke?: number;
6
+ nativeOpacity?: number;
7
+ nativeFillOpacity?: number;
8
+ nativeStrokeOpacity?: number;
9
+ }
10
+ export declare const escapeJsString: (value: unknown) => string;
11
+ export declare function attributeToJsx(name: string, value: unknown): string;
12
+ export declare function transformNode(node: IconNode, ctx: IconContext, inheritedOpacity?: number): void;
13
+ export declare function nodeToJsx(node: IconNode, indent?: number): string;
package/dist/jsx.js ADDED
@@ -0,0 +1,132 @@
1
+ import { isColorLike, isTranslucentColorValue } from "./color.js";
2
+ const IGNORE_ATTRS = new Set(["xmlns", "xmlns:xlink", "version", "style"]);
3
+ const ATTR_ALIAS = {
4
+ class: "className",
5
+ "xlink:href": "href",
6
+ "xml:space": "xmlSpace",
7
+ };
8
+ export const escapeJsString = (value) => JSON.stringify(String(value));
9
+ function camelize(name) {
10
+ return name.replace(/[-:](\w)/g, (_, c) => c.toUpperCase());
11
+ }
12
+ function toJsxAttributeName(name) {
13
+ if (name.startsWith("data-") || name.startsWith("aria-"))
14
+ return name;
15
+ if (ATTR_ALIAS[name])
16
+ return ATTR_ALIAS[name];
17
+ return camelize(name);
18
+ }
19
+ export function attributeToJsx(name, value) {
20
+ if (IGNORE_ATTRS.has(name))
21
+ return "";
22
+ const key = toJsxAttributeName(name);
23
+ if (value === true)
24
+ return `${key}=""`;
25
+ if (value == null || value === false)
26
+ return "";
27
+ return `${key}={${escapeJsString(value)}}`;
28
+ }
29
+ export function transformNode(node, ctx, inheritedOpacity = 1) {
30
+ if (node.type !== "element")
31
+ return;
32
+ const attrs = node.attributes || {};
33
+ let currentOpacity = inheritedOpacity;
34
+ if (attrs.opacity != null) {
35
+ const parsed = Number.parseFloat(attrs.opacity);
36
+ if (Number.isFinite(parsed)) {
37
+ node.nativeOpacity = parsed;
38
+ currentOpacity *= parsed;
39
+ delete attrs.opacity;
40
+ }
41
+ }
42
+ if (node.name !== "svg") {
43
+ const fillOpacityAttr = attrs["fill-opacity"] ?? attrs.fillOpacity;
44
+ const strokeOpacityAttr = attrs["stroke-opacity"] ?? attrs.strokeOpacity;
45
+ if (attrs.fill != null && isColorLike(attrs.fill)) {
46
+ let fillOpacity = currentOpacity;
47
+ let nativeFillOpacity = 1;
48
+ if (fillOpacityAttr != null) {
49
+ const p = Number.parseFloat(fillOpacityAttr);
50
+ if (Number.isFinite(p)) {
51
+ fillOpacity *= p;
52
+ nativeFillOpacity = p;
53
+ }
54
+ }
55
+ const isTranslucent = fillOpacity < 1 || isTranslucentColorValue(attrs.fill);
56
+ node.mappedFill = ctx.getColorIndex(attrs.fill, isTranslucent);
57
+ node.nativeFillOpacity = nativeFillOpacity < 1 ? nativeFillOpacity : undefined;
58
+ delete attrs.fill;
59
+ delete attrs["fill-opacity"];
60
+ delete attrs.fillOpacity;
61
+ }
62
+ if (attrs.stroke != null && isColorLike(attrs.stroke)) {
63
+ let strokeOpacity = currentOpacity;
64
+ let nativeStrokeOpacity = 1;
65
+ if (strokeOpacityAttr != null) {
66
+ const p = Number.parseFloat(strokeOpacityAttr);
67
+ if (Number.isFinite(p)) {
68
+ strokeOpacity *= p;
69
+ nativeStrokeOpacity = p;
70
+ }
71
+ }
72
+ const isTranslucent = strokeOpacity < 1 || isTranslucentColorValue(attrs.stroke);
73
+ node.mappedStroke = ctx.getColorIndex(attrs.stroke, isTranslucent);
74
+ node.nativeStrokeOpacity = nativeStrokeOpacity < 1 ? nativeStrokeOpacity : undefined;
75
+ delete attrs.stroke;
76
+ delete attrs["stroke-opacity"];
77
+ delete attrs.strokeOpacity;
78
+ }
79
+ delete attrs["stroke-width"];
80
+ delete attrs.strokeWidth;
81
+ }
82
+ for (const child of node.children || [])
83
+ transformNode(child, ctx, currentOpacity);
84
+ }
85
+ export function nodeToJsx(node, indent = 3) {
86
+ const pad = " ".repeat(indent);
87
+ if (node.type === "text") {
88
+ const text = String(node.value || "").trim();
89
+ if (!text)
90
+ return "";
91
+ return `${pad}{${escapeJsString(text)}}\n`;
92
+ }
93
+ if (node.type !== "element")
94
+ return "";
95
+ const attrParts = Object.entries(node.attributes || {})
96
+ .map(([name, value]) => attributeToJsx(name, value))
97
+ .filter(Boolean);
98
+ const styleProps = [];
99
+ const hasMappedFill = node.mappedFill !== undefined;
100
+ const hasMappedStroke = node.mappedStroke !== undefined;
101
+ const mappedIndex = hasMappedStroke ? node.mappedStroke : hasMappedFill ? node.mappedFill : undefined;
102
+ if (hasMappedFill) {
103
+ styleProps.push(`fill: colors?.[${node.mappedFill}] ?? fill ?? color ?? "currentColor"`);
104
+ }
105
+ if (hasMappedStroke) {
106
+ styleProps.push(`stroke: colors?.[${node.mappedStroke}] ?? stroke ?? color ?? "currentColor"`);
107
+ }
108
+ if (mappedIndex !== undefined) {
109
+ let baseOpacity = node.nativeOpacity ?? 1;
110
+ if (hasMappedFill && node.nativeFillOpacity !== undefined)
111
+ baseOpacity *= node.nativeFillOpacity;
112
+ else if (hasMappedStroke && node.nativeStrokeOpacity !== undefined)
113
+ baseOpacity *= node.nativeStrokeOpacity;
114
+ if (baseOpacity !== 1) {
115
+ styleProps.push(`opacity: opacities?.[${mappedIndex}] ?? opacity ?? ${baseOpacity}`);
116
+ }
117
+ else {
118
+ styleProps.push(`opacity: opacities?.[${mappedIndex}] ?? opacity`);
119
+ }
120
+ attrParts.push(`strokeWidth={strokeWidths?.[${mappedIndex}] ?? strokeWidth}`);
121
+ }
122
+ else if (node.nativeOpacity !== undefined) {
123
+ styleProps.push(`opacity: opacity ?? ${node.nativeOpacity}`);
124
+ }
125
+ if (styleProps.length > 0)
126
+ attrParts.push(`style={{ ${styleProps.join(", ")} }}`);
127
+ const openingAttrs = attrParts.length ? ` ${attrParts.join(" ")}` : "";
128
+ const children = (node.children || []).map((child) => nodeToJsx(child, indent + 1)).join("");
129
+ if (!children.trim())
130
+ return `${pad}<${node.name}${openingAttrs} />\n`;
131
+ return `${pad}<${node.name}${openingAttrs}>\n${children}${pad}</${node.name}>\n`;
132
+ }
@@ -0,0 +1,4 @@
1
+ export declare function splitWords(value: string): string[];
2
+ export declare function cleanIconName(fileName: string, folderName: string, removeWords: readonly string[]): string;
3
+ export declare function toKebabCase(str: string): string;
4
+ export declare function toCamelCase(str: string): string;
package/dist/names.js ADDED
@@ -0,0 +1,52 @@
1
+ export function splitWords(value) {
2
+ const normalized = String(value)
3
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
4
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
5
+ .replace(/(\d+)/g, " $1 ")
6
+ .replace(/[^a-zA-Z0-9]+/g, " ")
7
+ .trim();
8
+ if (!normalized)
9
+ return [];
10
+ return normalized.split(/\s+/).filter(Boolean);
11
+ }
12
+ function capitalizeWord(word) {
13
+ if (/^\d+$/.test(word))
14
+ return word;
15
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
16
+ }
17
+ export function cleanIconName(fileName, folderName, removeWords) {
18
+ let words = splitWords(fileName.replace(/\.svg$/i, ""));
19
+ if (words[0]?.toLowerCase() === "icon") {
20
+ words.shift();
21
+ while (words.length && /^\d+$/.test(words[0]))
22
+ words.shift();
23
+ }
24
+ words = words.filter((w) => !removeWords.includes(w.toLowerCase()));
25
+ const folderWords = new Set(splitWords(folderName).map((w) => w.toLowerCase()));
26
+ words = words.filter((w) => !folderWords.has(w.toLowerCase()));
27
+ if (!words.length)
28
+ return "Icon";
29
+ let pascal = words.map(capitalizeWord).join("");
30
+ if (/^[0-9]/.test(pascal))
31
+ pascal = `Icon${pascal}`;
32
+ pascal = pascal.replace(/[^a-zA-Z0-9]/g, "");
33
+ return pascal || "Icon";
34
+ }
35
+ export function toKebabCase(str) {
36
+ return String(str)
37
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
38
+ .replace(/[\s_]+/g, "-")
39
+ .replace(/[^a-zA-Z0-9-]/g, "")
40
+ .toLowerCase();
41
+ }
42
+ export function toCamelCase(str) {
43
+ const pascal = String(str)
44
+ .replace(/[-_]+/g, " ")
45
+ .replace(/[^\w\s]/g, "")
46
+ .replace(/\s+(.)(\w*)/g, (_, $1, $2) => $1.toUpperCase() + $2.toLowerCase())
47
+ .replace(/^./, (s) => s.toUpperCase());
48
+ let camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
49
+ if (/^[0-9]/.test(camel) || !camel)
50
+ camel = `folder${camel || "Unknown"}`;
51
+ return camel;
52
+ }
@@ -0,0 +1,10 @@
1
+ export interface IconFile {
2
+ componentName: string;
3
+ fileName: string;
4
+ folderName: string;
5
+ path: string[];
6
+ }
7
+ /** `bold.logout`, `a.b.x` for a nested folder; an icon at the top takes the input folder's name. */
8
+ export declare function registryKey(file: IconFile): string;
9
+ export declare function registryCode(files: IconFile[]): string;
10
+ export declare function iconComponentCode(defaultSize: number, defaultStrokeWidth: number): string;
@@ -0,0 +1,120 @@
1
+ import { HEADER } from "./header.js";
2
+ /** `bold.logout`, `a.b.x` for a nested folder; an icon at the top takes the input folder's name. */
3
+ export function registryKey(file) {
4
+ const base = file.fileName.replace(".tsx", "");
5
+ return `${file.path.length ? file.path.join(".") : file.folderName}.${base}`;
6
+ }
7
+ export function registryCode(files) {
8
+ const seen = new Map();
9
+ const entries = files.map((file) => {
10
+ const base = file.fileName.replace(".tsx", "");
11
+ const importPath = "./" + [...file.path, base].join("/");
12
+ const key = registryKey(file);
13
+ if (seen.has(key)) {
14
+ throw new Error(`react-svg-icons: two icons have the name "${key}": ${seen.get(key)} and ${importPath}`);
15
+ }
16
+ seen.set(key, importPath);
17
+ return ` "${key}": () => import("${importPath}").then((m) => m.${file.componentName}),`;
18
+ });
19
+ return `${HEADER}
20
+
21
+ export const iconRegistry = {
22
+ ${entries.join("\n")}
23
+ } as const;
24
+
25
+ export type IconName = keyof typeof iconRegistry;
26
+ `;
27
+ }
28
+ export function iconComponentCode(defaultSize, defaultStrokeWidth) {
29
+ return `${HEADER}
30
+ "use client";
31
+
32
+ import * as React from "react";
33
+ import { iconRegistry, type IconName } from "./registry";
34
+
35
+ export type IconProps = Omit<
36
+ React.SVGProps<SVGSVGElement>,
37
+ "color" | "stroke" | "fill" | "strokeWidth" | "opacity"
38
+ > & {
39
+ name: IconName;
40
+ size?: number | string;
41
+ color?: string;
42
+ stroke?: string;
43
+ fill?: string;
44
+ strokeWidth?: number | string;
45
+ opacity?: number | string;
46
+ colors?: (string | undefined)[];
47
+ opacities?: (number | string | undefined)[];
48
+ strokeWidths?: (number | string | undefined)[];
49
+ title?: string;
50
+ };
51
+
52
+ const DEFAULT_ICON_PROPS = {
53
+ size: ${defaultSize},
54
+ color: "currentColor",
55
+ strokeWidth: ${defaultStrokeWidth},
56
+ };
57
+
58
+ type RegistryLoader = (typeof iconRegistry)[IconName];
59
+ type ResolvedIconComponent = Awaited<ReturnType<RegistryLoader>>;
60
+ type LazyIconComponent = React.LazyExoticComponent<ResolvedIconComponent>;
61
+
62
+ // CRITICAL: React.lazy() must NEVER be called inside render — a fresh lazy
63
+ // wrapper type on every render makes React unmount the icon, re-suspend and
64
+ // remount it on every single parent render. Create one wrapper per icon
65
+ // name and reuse it for the lifetime of the app.
66
+ const lazyComponents = new Map<IconName, LazyIconComponent>();
67
+
68
+ const getLazyIcon = (name: IconName): LazyIconComponent | null => {
69
+ const cached = lazyComponents.get(name);
70
+ if (cached) return cached;
71
+
72
+ const loader = iconRegistry[name];
73
+ if (!loader) return null;
74
+
75
+ const created = React.lazy(() =>
76
+ loader().then((Component) => ({ default: Component })),
77
+ ) as LazyIconComponent;
78
+ lazyComponents.set(name, created);
79
+ return created;
80
+ };
81
+
82
+ const IconBase = React.forwardRef<SVGSVGElement, IconProps>(
83
+ ({ name, ...props }, ref) => {
84
+ const LazyIcon = getLazyIcon(name);
85
+
86
+ if (!LazyIcon) {
87
+ if (process.env.NODE_ENV !== "production") {
88
+ console.warn(\`[Icon] Unknown icon name: "\${name}".\`);
89
+ }
90
+ return null;
91
+ }
92
+
93
+ const mergedProps = { ...DEFAULT_ICON_PROPS, ...props };
94
+ const size = mergedProps.size ?? ${defaultSize};
95
+
96
+ return (
97
+ <React.Suspense
98
+ fallback={
99
+ <svg
100
+ width={size}
101
+ height={size}
102
+ viewBox="0 0 24 24"
103
+ className={mergedProps.className}
104
+ aria-hidden="true"
105
+ />
106
+ }
107
+ >
108
+ <LazyIcon ref={ref} {...mergedProps} />
109
+ </React.Suspense>
110
+ );
111
+ }
112
+ );
113
+
114
+ IconBase.displayName = "Icon";
115
+
116
+ // Only export the lazy component here
117
+ export const Icon = IconBase;
118
+ export type { IconName };
119
+ `;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@mrzr/react-svg-icons",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Turn a folder of SVG icons into typed React components, with a prop per colour and a lazy-loaded Icon.",
5
+ "keywords": [
6
+ "figma",
7
+ "icons",
8
+ "svg",
9
+ "react",
10
+ "svgo",
11
+ "codegen",
12
+ "typescript"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/mohammadreza-zr/react-svg-icons.git"
18
+ },
19
+ "homepage": "https://github.com/mohammadreza-zr/react-svg-icons#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/mohammadreza-zr/react-svg-icons/issues"
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "react-svg-icons": "dist/cli.js"
26
+ },
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "CHANGELOG.md",
40
+ "LICENSE"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p .",
47
+ "typecheck": "tsc -p . --noEmit",
48
+ "test": "npm run build && node scripts/test.mjs",
49
+ "docs:sync": "bash scripts/sync-wiki.sh",
50
+ "prepublishOnly": "npm test"
51
+ },
52
+ "dependencies": {
53
+ "svgo": "^4.0.2",
54
+ "svgson": "^5.3.1",
55
+ "yaml": "^2.9.0"
56
+ },
57
+ "peerDependencies": {
58
+ "prettier": ">=3"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "prettier": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "^26.6.5",
70
+ "@types/react": "^19.2.14",
71
+ "@types/react-dom": "^19.2.3",
72
+ "esbuild": "^0.28.2",
73
+ "prettier": "3.9.10",
74
+ "react": "^19.2.5",
75
+ "react-dom": "^19.2.5",
76
+ "typescript": "~6.0.2"
77
+ }
78
+ }