mini-icon 1.0.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 +91 -0
- package/bin/mini-icon.js +5 -0
- package/package.json +34 -0
- package/src/build.js +186 -0
- package/src/cli.js +55 -0
- package/src/index.js +3 -0
- package/src/load-config.js +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# mini-icon
|
|
2
|
+
|
|
3
|
+
Build SVG icons into CSS mask classes, an SVG sprite, and TypeScript union types.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install mini-icon
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI
|
|
12
|
+
|
|
13
|
+
Add to `package.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"scripts": {
|
|
18
|
+
"icons": "mini-icon"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run icons
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Config
|
|
30
|
+
|
|
31
|
+
Create `mini-icon.config.js` in your project root:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
export default {
|
|
35
|
+
iconsDir: "_icons",
|
|
36
|
+
spriteOut: "public/icons.svg",
|
|
37
|
+
cssOut: "src/icons.css",
|
|
38
|
+
typesOut: "src/types/icon.ts",
|
|
39
|
+
htmlFile: "index.html",
|
|
40
|
+
injectSpriteLink: true,
|
|
41
|
+
spriteHref: "/icons.svg",
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
JSON config (`mini-icon.config.json`) is also supported.
|
|
46
|
+
|
|
47
|
+
## CLI flags
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
mini-icon --icons-dir assets/icons --css-out src/icons.css
|
|
51
|
+
mini-icon --cwd ./apps/web
|
|
52
|
+
mini-icon --inject-html --html-file index.html
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Programmatic API
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { buildIcons } from "mini-icon";
|
|
59
|
+
|
|
60
|
+
const result = buildIcons({
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
iconsDir: "_icons",
|
|
63
|
+
spriteOut: "public/icons.svg",
|
|
64
|
+
cssOut: "src/icons.css",
|
|
65
|
+
typesOut: "src/types/icon.ts",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
console.log(result.names);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage in HTML
|
|
72
|
+
|
|
73
|
+
Put SVG source files in `_icons/` (or your configured folder), then:
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<link rel="preload" href="/icons.svg" as="image" type="image/svg+xml" />
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```css
|
|
80
|
+
@import "./icons.css";
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```html
|
|
84
|
+
<i class="icon search-lg"></i>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Color follows `currentColor`.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
package/bin/mini-icon.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mini-icon",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Build SVG icons into CSS mask classes, SVG sprite, and TypeScript types",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"svg",
|
|
10
|
+
"icons",
|
|
11
|
+
"sprite",
|
|
12
|
+
"css",
|
|
13
|
+
"mask",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"mini-icon": "./bin/mini-icon.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "./src/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"src",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node test/run.mjs"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
iconsDir: "_icons",
|
|
6
|
+
spriteOut: "public/icons.svg",
|
|
7
|
+
cssOut: "src/icons.css",
|
|
8
|
+
typesOut: "src/types/icon.ts",
|
|
9
|
+
htmlFile: null,
|
|
10
|
+
injectSpriteLink: false,
|
|
11
|
+
spriteHref: "/icons.svg",
|
|
12
|
+
spriteMarker: "<!-- icon-sprite -->",
|
|
13
|
+
cssBanner: "/* Auto-generated — do not edit. Run: mini-icon */",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function extractSvgParts(content) {
|
|
17
|
+
const svgOpenMatch = content.match(/<svg([^>]*)>/i);
|
|
18
|
+
const svgAttrs = svgOpenMatch?.[1] ?? "";
|
|
19
|
+
const viewBoxMatch = svgAttrs.match(/viewBox="([^"]+)"/i);
|
|
20
|
+
const viewBox = viewBoxMatch?.[1] ?? "0 0 24 24";
|
|
21
|
+
|
|
22
|
+
let inner = content.replace(/<svg[^>]*>/i, "").replace(/<\/svg>\s*$/i, "").trim();
|
|
23
|
+
|
|
24
|
+
const presentationAttrs = ["fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "fill-rule"];
|
|
25
|
+
const inherited = presentationAttrs.flatMap((attr) => {
|
|
26
|
+
const match = svgAttrs.match(new RegExp(`${attr}="([^"]*)"`, "i"));
|
|
27
|
+
return match ? [`${attr}="${match[1]}"`] : [];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (inherited.length > 0 && !/<(?:g|path|circle|rect|line|polyline|polygon)[^>]*\bstroke=/.test(inner)) {
|
|
31
|
+
inner = `<g ${inherited.join(" ")}>${inner}</g>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { viewBox, inner };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toMaskSvg(content) {
|
|
38
|
+
return content
|
|
39
|
+
.replace(/currentColor/g, "#000")
|
|
40
|
+
.replace(/fill="(?!none|currentColor)[^"]*"/gi, 'fill="#000"')
|
|
41
|
+
.replace(/stroke="(?!none|currentColor)[^"]*"/gi, 'stroke="#000"');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toDataUri(content) {
|
|
45
|
+
const encoded = encodeURIComponent(toMaskSvg(content)).replace(/'/g, "%27").replace(/"/g, "%22");
|
|
46
|
+
return `url("data:image/svg+xml,${encoded}")`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function injectSpriteLink(htmlFile, spriteHref, spriteMarker) {
|
|
50
|
+
if (!htmlFile || !fs.existsSync(htmlFile)) return;
|
|
51
|
+
|
|
52
|
+
const linkTag = `<link rel="preload" href="${spriteHref}" as="image" type="image/svg+xml" />`;
|
|
53
|
+
let html = fs.readFileSync(htmlFile, "utf8");
|
|
54
|
+
|
|
55
|
+
if (html.includes(spriteMarker)) {
|
|
56
|
+
html = html.replace(
|
|
57
|
+
new RegExp(`${escapeRegExp(spriteMarker)}[\\s\\S]*?${escapeRegExp(spriteMarker)}`),
|
|
58
|
+
`${spriteMarker}\n ${linkTag}\n ${spriteMarker}`,
|
|
59
|
+
);
|
|
60
|
+
} else if (!html.includes(spriteHref)) {
|
|
61
|
+
html = html.replace("</head>", ` ${spriteMarker}\n ${linkTag}\n ${spriteMarker}\n </head>`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(htmlFile, html);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function escapeRegExp(value) {
|
|
68
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveFromCwd(cwd, target) {
|
|
72
|
+
return path.isAbsolute(target) ? target : path.join(cwd, target);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Partial<typeof DEFAULTS> & { cwd?: string }} userOptions
|
|
77
|
+
*/
|
|
78
|
+
export function buildIcons(userOptions = {}) {
|
|
79
|
+
const cwd = userOptions.cwd ? path.resolve(userOptions.cwd) : process.cwd();
|
|
80
|
+
const options = { ...DEFAULTS, ...userOptions };
|
|
81
|
+
|
|
82
|
+
const iconsDir = resolveFromCwd(cwd, options.iconsDir);
|
|
83
|
+
const spriteOut = resolveFromCwd(cwd, options.spriteOut);
|
|
84
|
+
const cssOut = resolveFromCwd(cwd, options.cssOut);
|
|
85
|
+
const typesOut = resolveFromCwd(cwd, options.typesOut);
|
|
86
|
+
const htmlFile = options.htmlFile ? resolveFromCwd(cwd, options.htmlFile) : null;
|
|
87
|
+
|
|
88
|
+
if (!fs.existsSync(iconsDir)) {
|
|
89
|
+
fs.mkdirSync(iconsDir, { recursive: true });
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
createdIconsDir: true,
|
|
93
|
+
count: 0,
|
|
94
|
+
names: [],
|
|
95
|
+
outputs: {},
|
|
96
|
+
message: `Created ${options.iconsDir}/ — add .svg files and run mini-icon again.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const files = fs.readdirSync(iconsDir).filter((file) => file.endsWith(".svg"));
|
|
101
|
+
if (files.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
count: 0,
|
|
105
|
+
names: [],
|
|
106
|
+
outputs: {},
|
|
107
|
+
message: `No SVG files found in ${options.iconsDir}/`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fs.mkdirSync(path.dirname(spriteOut), { recursive: true });
|
|
112
|
+
fs.mkdirSync(path.dirname(cssOut), { recursive: true });
|
|
113
|
+
fs.mkdirSync(path.dirname(typesOut), { recursive: true });
|
|
114
|
+
|
|
115
|
+
const names = [];
|
|
116
|
+
const rules = [];
|
|
117
|
+
const symbols = [];
|
|
118
|
+
|
|
119
|
+
for (const file of files.sort()) {
|
|
120
|
+
const name = path.basename(file, ".svg");
|
|
121
|
+
const content = fs.readFileSync(path.join(iconsDir, file), "utf8");
|
|
122
|
+
const { viewBox, inner } = extractSvgParts(content);
|
|
123
|
+
|
|
124
|
+
names.push(name);
|
|
125
|
+
symbols.push(` <symbol id="${name}" viewBox="${viewBox}">${inner}</symbol>`);
|
|
126
|
+
rules.push(`.icon.${name}::before {
|
|
127
|
+
-webkit-mask-image: ${toDataUri(content)};
|
|
128
|
+
mask-image: ${toDataUri(content)};
|
|
129
|
+
}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:none">\n${symbols.join("\n")}\n</svg>\n`;
|
|
133
|
+
fs.writeFileSync(spriteOut, sprite);
|
|
134
|
+
|
|
135
|
+
const css = `${options.cssBanner}
|
|
136
|
+
.icon {
|
|
137
|
+
display: inline-block;
|
|
138
|
+
width: 1em;
|
|
139
|
+
height: 1em;
|
|
140
|
+
min-width: 1em;
|
|
141
|
+
min-height: 1em;
|
|
142
|
+
line-height: 1;
|
|
143
|
+
flex-shrink: 0;
|
|
144
|
+
vertical-align: middle;
|
|
145
|
+
font-style: normal;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.icon::before {
|
|
149
|
+
content: "";
|
|
150
|
+
display: block;
|
|
151
|
+
width: 100%;
|
|
152
|
+
height: 100%;
|
|
153
|
+
background-color: currentColor;
|
|
154
|
+
mask-repeat: no-repeat;
|
|
155
|
+
mask-position: center;
|
|
156
|
+
mask-size: contain;
|
|
157
|
+
-webkit-mask-repeat: no-repeat;
|
|
158
|
+
-webkit-mask-position: center;
|
|
159
|
+
-webkit-mask-size: contain;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
${rules.join("\n\n")}
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
fs.writeFileSync(cssOut, css);
|
|
166
|
+
fs.writeFileSync(typesOut, `export type Icon =\n${names.map((name) => ` | '${name}'`).join("\n")};\n`);
|
|
167
|
+
|
|
168
|
+
if (options.injectSpriteLink && htmlFile) {
|
|
169
|
+
injectSpriteLink(htmlFile, options.spriteHref, options.spriteMarker);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
count: names.length,
|
|
175
|
+
names,
|
|
176
|
+
outputs: {
|
|
177
|
+
spriteOut,
|
|
178
|
+
cssOut,
|
|
179
|
+
typesOut,
|
|
180
|
+
htmlFile: options.injectSpriteLink ? htmlFile : null,
|
|
181
|
+
},
|
|
182
|
+
message: `Built ${names.length} icon(s): ${names.join(", ")}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { DEFAULTS };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { buildIcons } from "./build.js";
|
|
3
|
+
import { loadConfig, parseCliArgs } from "./load-config.js";
|
|
4
|
+
|
|
5
|
+
function printHelp() {
|
|
6
|
+
console.log(`mini-icon — build SVG icons into CSS masks, sprite, and TypeScript types
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
mini-icon [options]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--cwd <dir> Working directory (default: process.cwd())
|
|
13
|
+
--icons-dir <path> Source SVG folder (default: _icons)
|
|
14
|
+
--sprite-out <path> Output sprite path (default: public/icons.svg)
|
|
15
|
+
--css-out <path> Output CSS path (default: src/icons.css)
|
|
16
|
+
--types-out <path> Output TypeScript path (default: src/types/icon.ts)
|
|
17
|
+
--html-file <path> HTML file for optional sprite preload injection
|
|
18
|
+
--sprite-href <url> Sprite href used in HTML preload (default: /icons.svg)
|
|
19
|
+
--inject-html Inject sprite preload link into HTML
|
|
20
|
+
--no-inject-html Disable HTML injection
|
|
21
|
+
-h, --help Show help
|
|
22
|
+
|
|
23
|
+
Config:
|
|
24
|
+
mini-icon.config.js | .mjs | .json in project root
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runCli(argv = process.argv) {
|
|
29
|
+
const { options: cliOptions, showHelp } = parseCliArgs(argv);
|
|
30
|
+
if (showHelp) {
|
|
31
|
+
printHelp();
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cwd = cliOptions.cwd ?? process.cwd();
|
|
36
|
+
const fileConfig = await loadConfig(cwd);
|
|
37
|
+
const options = {
|
|
38
|
+
...fileConfig,
|
|
39
|
+
...cliOptions,
|
|
40
|
+
cwd,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const result = buildIcons(options);
|
|
44
|
+
|
|
45
|
+
if (result.message) {
|
|
46
|
+
if (result.ok) console.log(result.message);
|
|
47
|
+
else console.warn(result.message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (result.outputs?.spriteOut) {
|
|
51
|
+
console.log(`Sprite: ${path.relative(cwd, result.outputs.spriteOut)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result.ok || result.createdIconsDir ? 0 : 1;
|
|
55
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { DEFAULTS } from "./build.js";
|
|
5
|
+
|
|
6
|
+
const CONFIG_NAMES = ["mini-icon.config.js", "mini-icon.config.mjs", "mini-icon.config.json"];
|
|
7
|
+
|
|
8
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
9
|
+
for (const name of CONFIG_NAMES) {
|
|
10
|
+
const filePath = path.join(cwd, name);
|
|
11
|
+
if (!fs.existsSync(filePath)) continue;
|
|
12
|
+
|
|
13
|
+
if (name.endsWith(".json")) {
|
|
14
|
+
return {
|
|
15
|
+
...DEFAULTS,
|
|
16
|
+
...JSON.parse(fs.readFileSync(filePath, "utf8")),
|
|
17
|
+
configFile: filePath,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const module = await import(`${pathToFileURL(filePath).href}?t=${Date.now()}`);
|
|
22
|
+
const config = module.default ?? module.config ?? module;
|
|
23
|
+
return {
|
|
24
|
+
...DEFAULTS,
|
|
25
|
+
...config,
|
|
26
|
+
configFile: filePath,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { ...DEFAULTS, configFile: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseCliArgs(argv) {
|
|
34
|
+
const args = argv.slice(2);
|
|
35
|
+
const options = { cwd: process.cwd() };
|
|
36
|
+
let showHelp = false;
|
|
37
|
+
|
|
38
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
39
|
+
const arg = args[index];
|
|
40
|
+
|
|
41
|
+
if (arg === "--help" || arg === "-h") {
|
|
42
|
+
showHelp = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (arg === "--cwd") {
|
|
47
|
+
options.cwd = path.resolve(args[index + 1] ?? process.cwd());
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const map = {
|
|
53
|
+
"--icons-dir": "iconsDir",
|
|
54
|
+
"--sprite-out": "spriteOut",
|
|
55
|
+
"--css-out": "cssOut",
|
|
56
|
+
"--types-out": "typesOut",
|
|
57
|
+
"--html-file": "htmlFile",
|
|
58
|
+
"--sprite-href": "spriteHref",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (arg in map) {
|
|
62
|
+
options[map[arg]] = args[index + 1];
|
|
63
|
+
index += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (arg === "--inject-html") {
|
|
68
|
+
options.injectSpriteLink = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (arg === "--no-inject-html") {
|
|
73
|
+
options.injectSpriteLink = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { options, showHelp };
|
|
78
|
+
}
|