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

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,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - **`Icon` keeps the stroke width of the SVG.** It no longer passes `strokeWidth`, so an icon loaded by name is drawn with the width it has when imported directly (`logout` at 1.5, not 2). `defaultStrokeWidth` now only applies to an SVG that has no `stroke-width`. If you relied on `Icon` forcing the width, pass `strokeWidth` to it.
6
+ - **The full package name** is in the output: the banner of the generated files and the start of every message say `@mrzr/react-svg-icons`, and the docs write `npx @mrzr/react-svg-icons`. A bare `npx react-svg-icons` would run a different package of that name from npm. Files with the old banner are still recognised as generated.
7
+ - The README and the wiki say what the tool does, and no longer mention Figma or other frameworks.
8
+ - A test no longer depends on whether `tsc` prints colours, which failed `npm publish` in a terminal that forces them.
9
+
10
+ ## 0.1.0
11
+
12
+ First release.
13
+
14
+ - Turns a folder of SVGs 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.
15
+ - 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.
16
+ - 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.
17
+ - Formats the output with the project's own prettier and config (`format: prettier`, or `false` for raw text).
18
+ - `react-svg-icons --check` reports `missing`, `changed` and `leftover` files and exits with 1; line endings are ignored.
19
+ - Never wipes the output folder: only a generated file that a run no longer produces is deleted, with the folder it leaves empty.
20
+ - Folders with no SVG are ignored, and an SVG that would overwrite a generated file (`icon`, `registry`, `index`) is an error.
21
+ - `generate`, `write` and `compare` for use from a script.
22
+ - 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,66 @@
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
+ Turn a folder of SVG files into typed React components (TSX). Every icon gets props for its size and colours, so you can restyle it without editing the SVG.
4
+
5
+ ```bash
6
+ npm install --save-dev @mrzr/react-svg-icons
7
+ npx @mrzr/react-svg-icons
8
+ ```
9
+
10
+ ---
11
+
12
+ ## Quick start
13
+
14
+ Put your SVG files in a folder (subfolders are variants) and add `react-svg-icons.config.yaml` next to your `package.json`:
15
+
16
+ <!-- verbatim: examples/basic/react-svg-icons.config.yaml -->
17
+ ```yaml
18
+ input: icons
19
+ outDir: generated
20
+ ```
21
+
22
+ ```bash
23
+ npx @mrzr/react-svg-icons # writes the components into generated/
24
+ npx @mrzr/react-svg-icons --check # writes nothing; exit code 1 when they are out of date
25
+ ```
26
+
27
+ Use an icon by name, loaded on demand, or import it directly:
28
+
29
+ ```tsx
30
+ import { Icon } from "./generated";
31
+ import { Logout } from "./generated/bold";
32
+
33
+ <Icon name="bold.logout" size={20} />
34
+ <Logout size={20} color="crimson" />
35
+ ```
36
+
37
+ The config can also be `.yml`, `.json` or an rc file (`.react-svg-iconsrc`).
38
+
39
+ ---
40
+
41
+ ## What you get
42
+
43
+ - **A typed component per SVG**, optimised with SVGO, with a forwarded `ref`
44
+ - **Props to customise it**: `size`, `color`, `stroke`, `fill`, `strokeWidth`, `opacity`, `title`, and `colors`, `opacities`, `strokeWidths` lists that set the colours of the drawing one by one
45
+ - **A typed `name`** for `Icon`: `name="bold.logut"` is a type error, and each icon loads as its own chunk
46
+ - **Output formatted by your own prettier**, so it matches what your formatting would leave
47
+ - **`--check`** for CI, and cleanup of generated files whose SVG you removed. A file the tool did not write is never touched
48
+
49
+ ---
50
+
51
+ ## Documentation
52
+
53
+ Everything is in the **[Wiki](https://github.com/mohammadreza-zr/react-svg-icons/wiki)**:
54
+
55
+ - **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)
56
+ - **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)
57
+ - **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)
58
+ - **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)
59
+
60
+ ## Requirements
61
+
62
+ Node.js 20 or newer. The generated code needs React (tested with 19) and, in TypeScript, `"moduleResolution": "bundler"`, because its imports have no file extension.
63
+
64
+ ## License
65
+
66
+ 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_PATTERN } 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(`@mrzr/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) });
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(`@mrzr/react-svg-icons: input folder not found: ${inputDir}`);
76
+ if (outDir === inputDir || inputDir.startsWith(outDir + path.sep)) {
77
+ throw new Error(`@mrzr/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(`@mrzr/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) && MARKER_PATTERN.test(readFileSync(full, "utf8").slice(0, 200)))
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 = `@mrzr/react-svg-icons — SVG icons to typed React components
7
+
8
+ Usage:
9
+ npx @mrzr/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("@mrzr/react-svg-icons: --config needs a file");
34
+ args.config = value;
35
+ }
36
+ else
37
+ throw new Error(`@mrzr/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(`@mrzr/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(`@mrzr/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: `[ic, 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(`@mrzr/react-svg-icons: unknown option "${key}" (options: ${OPTIONS.join(", ")})`);
12
+ }
13
+ }
14
+ if (!config.input)
15
+ throw new Error("@mrzr/react-svg-icons: `input` is required");
16
+ if (!config.outDir)
17
+ throw new Error("@mrzr/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(`@mrzr/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("@mrzr/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('@mrzr/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(`@mrzr/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(`@mrzr/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(`@mrzr/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(`@mrzr/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(`@mrzr/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("@mrzr/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(`@mrzr/react-svg-icons: the generated ${file.path} does not parse (${error.message})`);
39
+ }
40
+ }
41
+ return out;
42
+ }
@@ -0,0 +1,4 @@
1
+ export declare const MARKER = "auto-generated by @mrzr/react-svg-icons";
2
+ /** Also the banner of 0.1.0, which did not carry the scope. */
3
+ export declare const MARKER_PATTERN: RegExp;
4
+ export declare const HEADER = "/* eslint-disable */\n/* This file is auto-generated by @mrzr/react-svg-icons. Do not edit manually. */\n\n";
package/dist/header.js ADDED
@@ -0,0 +1,4 @@
1
+ export const MARKER = "auto-generated by @mrzr/react-svg-icons";
2
+ /** Also the banner of 0.1.0, which did not carry the scope. */
3
+ export const MARKER_PATTERN = /auto-generated by (@mrzr\/)?react-svg-icons/;
4
+ 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): string;
@@ -0,0 +1,119 @@
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(`@mrzr/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) {
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
+ };
56
+
57
+ type RegistryLoader = (typeof iconRegistry)[IconName];
58
+ type ResolvedIconComponent = Awaited<ReturnType<RegistryLoader>>;
59
+ type LazyIconComponent = React.LazyExoticComponent<ResolvedIconComponent>;
60
+
61
+ // CRITICAL: React.lazy() must NEVER be called inside render — a fresh lazy
62
+ // wrapper type on every render makes React unmount the icon, re-suspend and
63
+ // remount it on every single parent render. Create one wrapper per icon
64
+ // name and reuse it for the lifetime of the app.
65
+ const lazyComponents = new Map<IconName, LazyIconComponent>();
66
+
67
+ const getLazyIcon = (name: IconName): LazyIconComponent | null => {
68
+ const cached = lazyComponents.get(name);
69
+ if (cached) return cached;
70
+
71
+ const loader = iconRegistry[name];
72
+ if (!loader) return null;
73
+
74
+ const created = React.lazy(() =>
75
+ loader().then((Component) => ({ default: Component })),
76
+ ) as LazyIconComponent;
77
+ lazyComponents.set(name, created);
78
+ return created;
79
+ };
80
+
81
+ const IconBase = React.forwardRef<SVGSVGElement, IconProps>(
82
+ ({ name, ...props }, ref) => {
83
+ const LazyIcon = getLazyIcon(name);
84
+
85
+ if (!LazyIcon) {
86
+ if (process.env.NODE_ENV !== "production") {
87
+ console.warn(\`[Icon] Unknown icon name: "\${name}".\`);
88
+ }
89
+ return null;
90
+ }
91
+
92
+ const mergedProps = { ...DEFAULT_ICON_PROPS, ...props };
93
+ const size = mergedProps.size ?? ${defaultSize};
94
+
95
+ return (
96
+ <React.Suspense
97
+ fallback={
98
+ <svg
99
+ width={size}
100
+ height={size}
101
+ viewBox="0 0 24 24"
102
+ className={mergedProps.className}
103
+ aria-hidden="true"
104
+ />
105
+ }
106
+ >
107
+ <LazyIcon ref={ref} {...mergedProps} />
108
+ </React.Suspense>
109
+ );
110
+ }
111
+ );
112
+
113
+ IconBase.displayName = "Icon";
114
+
115
+ // Only export the lazy component here
116
+ export const Icon = IconBase;
117
+ export type { IconName };
118
+ `;
119
+ }
package/package.json CHANGED
@@ -1,6 +1,77 @@
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.1",
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
+ "icons",
7
+ "svg",
8
+ "react",
9
+ "svgo",
10
+ "codegen",
11
+ "typescript"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/mohammadreza-zr/react-svg-icons.git"
17
+ },
18
+ "homepage": "https://github.com/mohammadreza-zr/react-svg-icons#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/mohammadreza-zr/react-svg-icons/issues"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "react-svg-icons": "dist/cli.js"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p .",
46
+ "typecheck": "tsc -p . --noEmit",
47
+ "test": "npm run build && node scripts/test.mjs",
48
+ "docs:sync": "bash scripts/sync-wiki.sh",
49
+ "prepublishOnly": "npm test"
50
+ },
51
+ "dependencies": {
52
+ "svgo": "^4.0.2",
53
+ "svgson": "^5.3.1",
54
+ "yaml": "^2.9.0"
55
+ },
56
+ "peerDependencies": {
57
+ "prettier": ">=3"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "prettier": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "^26.6.5",
69
+ "@types/react": "^19.2.14",
70
+ "@types/react-dom": "^19.2.3",
71
+ "esbuild": "^0.28.2",
72
+ "prettier": "3.9.10",
73
+ "react": "^19.2.5",
74
+ "react-dom": "^19.2.5",
75
+ "typescript": "~6.0.2"
76
+ }
77
+ }