@iconsroom/react 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/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @iconsroom/react
2
+
3
+ 276,000+ open source icons for React — two ways to use them, both free.
4
+
5
+ ## 1. Runtime component (all 276K, nothing bundled)
6
+
7
+ ```bash
8
+ npm install @iconsroom/react react
9
+ ```
10
+
11
+ ```jsx
12
+ import { Icon } from '@iconsroom/react';
13
+
14
+ <Icon pack="heroicons" name="academic-cap" size={24} color="#0f172a" />
15
+ ```
16
+
17
+ The icon's SVG is fetched from the IconsRoom CDN on first use and cached in
18
+ memory (deduped across every instance). Recoloring is knockout-safe — white
19
+ detail is preserved, multicolor icons are left alone. SSR-safe; reserves its
20
+ box before load so nothing shifts.
21
+
22
+ Props: `pack`, `name`, `size` (default 24), `color` (optional hex),
23
+ `title` (accessible label), plus any `<span>` props.
24
+
25
+ ## 2. Vendor icons as local components (zero runtime dependency)
26
+
27
+ Prefer to own the code and ship nothing extra? Generate a standalone component
28
+ per icon — tree-shakeable, offline forever, no dependency on this package:
29
+
30
+ ```bash
31
+ npx @iconsroom/react add heroicons/academic-cap
32
+ npx @iconsroom/react add tabler/home tabler/settings --ts --out src/icons
33
+ ```
34
+
35
+ ```jsx
36
+ import { AcademicCapIcon } from './src/icons/AcademicCapIcon';
37
+
38
+ <AcademicCapIcon size={20} className="text-slate-700" />
39
+ ```
40
+
41
+ `--ts` emits typed `.tsx`; `--out` sets the directory (default `src/icons`).
42
+
43
+ ## Which one?
44
+
45
+ | | Runtime `<Icon>` | Vendored (CLI) |
46
+ |---|---|---|
47
+ | Bundle size | tiny (one component) | only what you add |
48
+ | Works offline | needs the CDN | yes |
49
+ | Access all 276K | instantly | one command each |
50
+ | Own the code | no | yes |
51
+
52
+ ## Licensing
53
+
54
+ Every icon is open source; most packs are MIT / Apache 2.0 / CC BY, a few are
55
+ CC BY-NC (non-commercial). Check the pack on [iconsroom.com](https://iconsroom.com).
56
+
57
+ ## Development
58
+
59
+ ```bash
60
+ npm test # framework-free core + codegen (10 checks)
61
+ ```
62
+
63
+ MIT © IconsRoom
package/bin/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // @iconsroom/react CLI — vendor icons as local React components.
3
+ //
4
+ // npx @iconsroom/react add heroicons/academic-cap
5
+ // npx @iconsroom/react add tabler/home tabler/settings --ts --out src/icons
6
+ //
7
+ // Writes a standalone component per icon (no runtime dependency). You own the
8
+ // code; it's tree-shakeable and offline forever.
9
+ import { mkdirSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { iconUrl, toComponentSource, pascalCase } from '../src/core.js';
12
+
13
+ function parseArgs(argv) {
14
+ const args = { icons: [], ts: false, out: 'src/icons' };
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const a = argv[i];
17
+ if (a === '--ts' || a === '--typescript') args.ts = true;
18
+ else if (a === '--out') args.out = argv[++i];
19
+ else if (a === 'add') continue;
20
+ else args.icons.push(a);
21
+ }
22
+ return args;
23
+ }
24
+
25
+ async function fetchSvg(pack, name) {
26
+ const res = await fetch(iconUrl(pack, name));
27
+ if (!res.ok) throw new Error(`not found (HTTP ${res.status})`);
28
+ const svg = await res.text();
29
+ if (!svg.includes('<svg')) throw new Error('did not return SVG');
30
+ return svg;
31
+ }
32
+
33
+ async function main() {
34
+ const [, , ...argv] = process.argv;
35
+ if (argv[0] !== 'add' || argv.length < 2) {
36
+ console.log(`@iconsroom/react — vendor open source icons as local components
37
+
38
+ Usage:
39
+ npx @iconsroom/react add <pack>/<name> [more…] [--ts] [--out DIR]
40
+
41
+ Examples:
42
+ npx @iconsroom/react add heroicons/academic-cap
43
+ npx @iconsroom/react add tabler/home tabler/settings --ts --out src/icons
44
+
45
+ Find icons at https://iconsroom.com`);
46
+ process.exit(argv.length ? 1 : 0);
47
+ }
48
+
49
+ const { icons, ts, out } = parseArgs(argv);
50
+ mkdirSync(out, { recursive: true });
51
+
52
+ const ext = ts ? 'tsx' : 'jsx';
53
+ let ok = 0;
54
+ for (const spec of icons) {
55
+ const [pack, name] = spec.split('/');
56
+ if (!pack || !name) {
57
+ console.error(`✗ ${spec} — use pack/name (e.g. tabler/home)`);
58
+ continue;
59
+ }
60
+ try {
61
+ // eslint-disable-next-line no-await-in-loop
62
+ const svg = await fetchSvg(pack, name);
63
+ const source = toComponentSource(svg, name, { typescript: ts });
64
+ const file = join(out, `${pascalCase(name)}Icon.${ext}`);
65
+ writeFileSync(file, source);
66
+ console.log(`✓ ${spec} → ${file}`);
67
+ ok++;
68
+ } catch (err) {
69
+ console.error(`✗ ${spec} — ${err.message}`);
70
+ }
71
+ }
72
+ console.log(`\n${ok}/${icons.length} icon${icons.length === 1 ? '' : 's'} added to ${out}/`);
73
+ process.exit(ok === icons.length ? 0 : 1);
74
+ }
75
+
76
+ main();
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@iconsroom/react",
3
+ "version": "0.1.0",
4
+ "description": "276,000+ open source icons for React — a runtime <Icon> component, plus a CLI to vendor any icon as a local component.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./core": "./src/core.js"
11
+ },
12
+ "bin": {
13
+ "iconsroom-react": "bin/cli.js"
14
+ },
15
+ "files": ["src/", "bin/", "README.md"],
16
+ "sideEffects": false,
17
+ "keywords": ["react", "icons", "svg", "iconsroom", "heroicons", "tabler", "lucide", "open-source"],
18
+ "author": "IconsRoom (https://iconsroom.com)",
19
+ "license": "MIT",
20
+ "homepage": "https://iconsroom.com",
21
+ "repository": { "type": "git", "url": "https://github.com/iconsroom/react" },
22
+ "peerDependencies": {
23
+ "react": ">=17"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "react": { "optional": true }
27
+ },
28
+ "scripts": {
29
+ "test": "node test.mjs"
30
+ }
31
+ }
package/src/Icon.jsx ADDED
@@ -0,0 +1,77 @@
1
+ // @iconsroom/react — runtime <Icon> component.
2
+ // Fetches an icon's SVG from the IconsRoom CDN on first use, caches it in
3
+ // memory (deduped across all instances), optionally recolors it, and renders
4
+ // it inline. Works for all 276,000+ icons without bundling any of them.
5
+ //
6
+ // import { Icon } from '@iconsroom/react';
7
+ // <Icon pack="heroicons" name="academic-cap" size={24} color="#0f172a" />
8
+ import * as React from 'react';
9
+ import { iconUrl, colorizeSvg, normalizeSvg } from './core.js';
10
+
11
+ const cache = new Map(); // "pack/name" -> Promise<string svg> | string svg
12
+
13
+ function loadSvg(pack, name) {
14
+ const key = `${pack}/${name}`;
15
+ if (cache.has(key)) return cache.get(key);
16
+ const promise = fetch(iconUrl(pack, name))
17
+ .then((res) => {
18
+ if (!res.ok) throw new Error(`Icon ${key} not found`);
19
+ return res.text();
20
+ })
21
+ .then((svg) => {
22
+ const normalized = normalizeSvg(svg);
23
+ cache.set(key, normalized); // replace promise with resolved value
24
+ return normalized;
25
+ })
26
+ .catch((err) => {
27
+ cache.delete(key);
28
+ throw err;
29
+ });
30
+ cache.set(key, promise);
31
+ return promise;
32
+ }
33
+
34
+ export function Icon({ pack, name, size = 24, color, title, className, style, ...rest }) {
35
+ const key = `${pack}/${name}`;
36
+ const cached = cache.get(key);
37
+ const initial = typeof cached === 'string' ? cached : '';
38
+ const [svg, setSvg] = React.useState(initial);
39
+
40
+ React.useEffect(() => {
41
+ let active = true;
42
+ const result = loadSvg(pack, name);
43
+ if (typeof result === 'string') {
44
+ setSvg(result);
45
+ return undefined;
46
+ }
47
+ result.then((markup) => active && setSvg(markup)).catch(() => active && setSvg(''));
48
+ return () => {
49
+ active = false;
50
+ };
51
+ }, [pack, name]);
52
+
53
+ const markup = svg && color ? colorizeSvg(svg, color) : svg;
54
+
55
+ // Reserve the box before load so layout doesn't shift.
56
+ const boxStyle = {
57
+ display: 'inline-flex',
58
+ width: size,
59
+ height: size,
60
+ color,
61
+ ...style,
62
+ };
63
+
64
+ return (
65
+ <span
66
+ className={className}
67
+ style={boxStyle}
68
+ role="img"
69
+ aria-label={title || `${name} icon`}
70
+ aria-hidden={title ? undefined : 'true'}
71
+ {...rest}
72
+ dangerouslySetInnerHTML={markup ? { __html: markup } : undefined}
73
+ />
74
+ );
75
+ }
76
+
77
+ export default Icon;
package/src/core.js ADDED
@@ -0,0 +1,88 @@
1
+ // @iconsroom/react — framework-free core (no React import here, so it's
2
+ // unit-testable in plain Node). Shared by the runtime <Icon> component and
3
+ // the vendoring CLI.
4
+
5
+ export const CDN = 'https://files.svgcdn.io';
6
+
7
+ export function iconUrl(pack, name) {
8
+ return `${CDN}/${pack}/${name}.svg`;
9
+ }
10
+
11
+ // Knockout-safe recolor (same rules as the rest of IconsRoom): white paint is
12
+ // protected detail; multicolor icons are left untouched.
13
+ const isKnockout = (v) => /^(#fff(?:fff)?|white|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))$/i.test((v || '').trim());
14
+
15
+ export function colorizeSvg(svg, color) {
16
+ if (!svg || !color) return svg;
17
+
18
+ const fills = new Set();
19
+ const strokes = new Set();
20
+ let m;
21
+ const attrRe = /\s(fill|stroke)="([^"]+)"/g;
22
+ while ((m = attrRe.exec(svg))) {
23
+ const [, kind, value] = m;
24
+ if (value === 'none' || value === 'currentColor' || value.startsWith('url') || isKnockout(value)) continue;
25
+ (kind === 'fill' ? fills : strokes).add(value.trim());
26
+ }
27
+ const hasDuotone = /opacity="\.?0?\.2"/.test(svg);
28
+ if ((fills.size > 1 || strokes.size > 1) && !hasDuotone) return svg;
29
+
30
+ let out = svg.replace(/fill="currentColor"/g, `fill="${color}"`);
31
+ out = out.replace(/stroke="([^"]*)"/g, (mm, v) => (v === 'none' || isKnockout(v) ? mm : `stroke="${color}"`));
32
+ for (const tag of ['path', 'circle', 'rect', 'polygon', 'polyline', 'ellipse', 'line', 'g']) {
33
+ const re = new RegExp(`<${tag}([^>]*)fill="([^"]*)"`, 'g');
34
+ out = out.replace(re, (mm, a, v) => (v !== 'none' && !isKnockout(v) ? `<${tag}${a}fill="${color}"` : mm));
35
+ }
36
+ if (fills.size === 0 && strokes.size === 0) {
37
+ out = out.replace(/<svg([^>]*)>/, (mm, a) => (a.includes('fill=') ? mm : `<svg${a} fill="${color}">`));
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // Strip fixed width/height and ensure a viewBox so the SVG scales to its box.
43
+ export function normalizeSvg(svg) {
44
+ return svg.replace(/<svg([^>]*)>/, (match, attrs) => {
45
+ let a = attrs;
46
+ if (!/viewBox=/i.test(a)) {
47
+ const w = (a.match(/\swidth="([0-9.]+)(?:px)?"/i) || [])[1];
48
+ const h = (a.match(/\sheight="([0-9.]+)(?:px)?"/i) || [])[1];
49
+ if (w && h) a += ` viewBox="0 0 ${w} ${h}"`;
50
+ }
51
+ a = a.replace(/\swidth="[^"]*"/i, '').replace(/\sheight="[^"]*"/i, '');
52
+ return `<svg${a}>`;
53
+ });
54
+ }
55
+
56
+ export function pascalCase(name) {
57
+ return name
58
+ .split(/[-_/]/)
59
+ .filter(Boolean)
60
+ .map((w) => w[0].toUpperCase() + w.slice(1))
61
+ .join('');
62
+ }
63
+
64
+ // Generate a standalone React component source string from raw SVG markup.
65
+ // Used by the CLI to vendor icons (no runtime dependency in the user's app).
66
+ export function toComponentSource(svg, name, { typescript = false } = {}) {
67
+ const componentName = `${pascalCase(name)}Icon`;
68
+ const inner = normalizeSvg(svg)
69
+ .replace(/<svg([^>]*)>/, '<svg$1 width={size} height={size} {...props}>')
70
+ .replace(/\sclass="/g, ' className="')
71
+ .replace(
72
+ /\s(stroke-width|stroke-linecap|stroke-linejoin|fill-rule|clip-rule|clip-path|stroke-miterlimit|stroke-dasharray|stroke-dashoffset|fill-opacity|stroke-opacity)=/g,
73
+ (mm, attr) => ` ${attr.replace(/-([a-z])/g, (x, c) => c.toUpperCase())}=`
74
+ );
75
+
76
+ if (typescript) {
77
+ return (
78
+ `import * as React from 'react';\n\n` +
79
+ `export function ${componentName}(\n` +
80
+ ` { size = 24, ...props }: React.SVGProps<SVGSVGElement> & { size?: number | string }\n` +
81
+ `) {\n return (\n ${inner}\n );\n}\n`
82
+ );
83
+ }
84
+ return (
85
+ `export function ${componentName}({ size = 24, ...props }) {\n` +
86
+ ` return (\n ${inner}\n );\n}\n`
87
+ );
88
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Icon, default } from './Icon.jsx';
2
+ export { iconUrl, colorizeSvg } from './core.js';