@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 +63 -0
- package/bin/cli.js +76 -0
- package/package.json +31 -0
- package/src/Icon.jsx +77 -0
- package/src/core.js +88 -0
- package/src/index.js +2 -0
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