wmt-polyicon 0.1.5
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 +65 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/polyicon.js +4 -0
- package/package.json +39 -0
- package/src/cli/commands/generate.js +27 -0
- package/src/cli/commands/init.js +22 -0
- package/src/cli/index.js +59 -0
- package/src/config/defaults.js +14 -0
- package/src/config/load.js +15 -0
- package/src/constants.js +21 -0
- package/src/generator/index.js +116 -0
- package/src/generator/parse_svg.js +57 -0
- package/src/generator/run_svgtofont.js +22 -0
- package/src/generator/stroke_to_fill.js +257 -0
- package/src/generator/write_css.js +34 -0
- package/src/generator/write_fonts.js +25 -0
- package/src/generator/write_html.js +302 -0
- package/src/generator/write_polyicon_config.js +28 -0
- package/src/generator/write_types.js +28 -0
- package/src/index.js +3 -0
- package/src/utils/fs.js +21 -0
- package/src/utils/paths.js +11 -0
- package/src/utils/strings.js +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.5] — 2026-04-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Fixed an issue in `stroke-linecap="round"` conversion where inward SVG arcs caused generated icons (like `close.svg`) to render redundantly or blank instead of matching their outline.
|
|
9
|
+
|
|
10
|
+
## [0.1.4] — 2026-04-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Stroke-to-fill conversion (`stroke_to_fill.js`) — stroked SVG paths (e.g. line icons from Figma/Heroicons) are now automatically converted to filled outlines for font generation, with support for round, square, and butt linecaps
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Unified shape extraction regex to correctly match both self-closing and paired SVG element tags in a single pass — the previous `||` alternation silently dropped closing tags from paired elements
|
|
17
|
+
|
|
18
|
+
## [0.1.3] — 2026-04-02
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Blank icon generation for SVGs using non-path elements (`<circle>`, `<ellipse>`, `<rect>`, `<polygon>`, `<polyline>`, `<line>`) — previously only `<path>` elements were extracted, silently dropping all other shapes
|
|
22
|
+
- White background rect stripping no longer removes closing tags from non-white rects
|
|
23
|
+
|
|
24
|
+
## [0.1.2] — 2026-03-18
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- `svgtofont` v6 is ESM-only — switched from `require()` to dynamic `import()` in `run_svgtofont.js` to resolve "svgtofont is not a function" error
|
|
28
|
+
|
|
29
|
+
## [0.1.1] — 2026-03-18
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- `.gitignore` — excludes `node_modules/` and tmp build dirs
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- `svgtofont` upgraded `4.x` → `6.5.1`
|
|
36
|
+
- `fast-xml-parser` upgraded `4.x` → `5.5.6` (fixes CVE-2026-26278)
|
|
37
|
+
- `CHANGELOG.md` included in published npm package via `files` field
|
|
38
|
+
|
|
39
|
+
## [0.1.0] — 2026-03-18
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- Interactive HTML preview (`polyicon-preview.html`) generated at the root of the output folder alongside fonts and CSS
|
|
43
|
+
- Live search filter by icon name
|
|
44
|
+
- Click any icon card to copy its class name to clipboard
|
|
45
|
+
- Displays unicode code point for each icon
|
|
46
|
+
- Programmatic API — `const { buildIcons } = require('polyicon')`
|
|
47
|
+
- `"main"` field in `package.json` pointing to `src/index.js`
|
|
48
|
+
- Success log printed after `polyicon generate` completes
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- `write_polyicon_config.js`: replaced `charCodeAt(0)` with `codePointAt(0)` for correct unicode handling above U+FFFF
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- `svgtofont` upgraded `4.x` → `6.5.1` — drops `del`/`node-gyp` chain, eliminating all deprecated transitive dependencies (`rimraf@3`, `glob@7`, `tar@6`, `npmlog`, `gauge`, `are-we-there-yet`, `inflight`)
|
|
55
|
+
- `fast-xml-parser` upgraded `4.x` → `5.5.6` — fixes high-severity CVE-2026-26278 (numeric entity expansion bypass)
|
|
56
|
+
- `npm audit`: 0 vulnerabilities, 0 deprecation warnings
|
|
57
|
+
|
|
58
|
+
## [0.0.1] — initial release
|
|
59
|
+
|
|
60
|
+
- SVG → icon font pipeline (`eot`, `svg`, `ttf`, `woff`, `woff2`)
|
|
61
|
+
- Generated CSS with configurable font URL prefix (`importFontsPath`)
|
|
62
|
+
- JS/TS types file (`IconTypes`)
|
|
63
|
+
- Fontello-compatible `config.json` output
|
|
64
|
+
- `polyicon init` and `polyicon generate` CLI commands
|
|
65
|
+
- `.polyiconrc` config file support
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WebMob Technologies Pvt. Ltd.
|
|
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
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Polyicon
|
|
2
|
+
|
|
3
|
+
SVG to icon font generator with HTML preview — Fontello-style output for React and web projects.
|
|
4
|
+
|
|
5
|
+
Converts a folder of SVGs into `woff2`, `woff`, `ttf`, `eot`, `svg` font files plus a CSS file, a TypeScript/JS types file, and an interactive HTML preview page.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# project dev dependency
|
|
11
|
+
npm i -D polyicon
|
|
12
|
+
|
|
13
|
+
# or global
|
|
14
|
+
npm i -g polyicon
|
|
15
|
+
|
|
16
|
+
# or one-off
|
|
17
|
+
npx polyicon init
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
polyicon init # creates .polyiconrc with defaults
|
|
24
|
+
polyicon generate # builds fonts, CSS, types, and preview
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Place your SVGs in the folder specified by `svg` in `.polyiconrc`, then run `generate`. Output folders are created automatically.
|
|
28
|
+
|
|
29
|
+
## Config (`.polyiconrc`)
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"svg": "./src/assets/svg",
|
|
34
|
+
"outputFonts": "./src/assets/fontello/font",
|
|
35
|
+
"outputStyles": "./src/assets/fontello/css/polyicon.css",
|
|
36
|
+
"outputTypes": "./src/types/IconTypes.js",
|
|
37
|
+
"polyiconConfig": "./src/assets/fontello/config.json",
|
|
38
|
+
"importFontsPath": "../font/",
|
|
39
|
+
"formats": ["eot", "svg", "ttf", "woff", "woff2"]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Field | Required | Description |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `svg` | ✓ | Folder containing your `.svg` files |
|
|
46
|
+
| `outputFonts` | ✓ | Where font files are written |
|
|
47
|
+
| `outputStyles` | | CSS file output path |
|
|
48
|
+
| `outputTypes` | ✓ | JS or TS types file output path |
|
|
49
|
+
| `polyiconConfig` | | Fontello-compatible `config.json` output path |
|
|
50
|
+
| `importFontsPath` | | Font URL prefix in the generated CSS (e.g. `../font/`) |
|
|
51
|
+
| `formats` | | Font formats to generate (default: all five) |
|
|
52
|
+
|
|
53
|
+
SVG filenames become CSS class names. Spaces and special characters are auto-sanitised to hyphens. Use clean, single-colour SVGs — icon fonts are monochrome.
|
|
54
|
+
|
|
55
|
+
## Output
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
src/assets/fontello/
|
|
59
|
+
polyicon-preview.html ← interactive icon browser
|
|
60
|
+
css/
|
|
61
|
+
polyicon.css
|
|
62
|
+
font/
|
|
63
|
+
polyicon.eot
|
|
64
|
+
polyicon.svg
|
|
65
|
+
polyicon.ttf
|
|
66
|
+
polyicon.woff
|
|
67
|
+
polyicon.woff2
|
|
68
|
+
config.json
|
|
69
|
+
src/types/
|
|
70
|
+
IconTypes.js
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Open `polyicon-preview.html` in a browser to browse all generated icons, search by name, and click any card to copy its class name to the clipboard.
|
|
74
|
+
|
|
75
|
+
## Usage in React
|
|
76
|
+
|
|
77
|
+
Import the generated CSS once (e.g. in your root component or entry file):
|
|
78
|
+
|
|
79
|
+
```jsx
|
|
80
|
+
import "./assets/fontello/css/polyicon.css";
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Use icons by class name:
|
|
84
|
+
|
|
85
|
+
```jsx
|
|
86
|
+
<i className="polyicon polyicon-home" />
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or with the generated types for autocomplete:
|
|
90
|
+
|
|
91
|
+
```jsx
|
|
92
|
+
import IconTypes from "./types/IconTypes";
|
|
93
|
+
|
|
94
|
+
<i className={`polyicon polyicon-${IconTypes.HOME}`} />
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Programmatic API
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const { buildIcons } = require("polyicon");
|
|
101
|
+
|
|
102
|
+
await buildIcons({
|
|
103
|
+
svg: "./src/assets/svg",
|
|
104
|
+
outputFonts: "./dist/fonts",
|
|
105
|
+
outputStyles: "./dist/polyicon.css",
|
|
106
|
+
outputTypes: "./src/types/IconTypes.js",
|
|
107
|
+
importFontsPath: "./fonts/",
|
|
108
|
+
formats: ["woff2", "woff", "ttf"]
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## CLI
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
polyicon init Create .polyiconrc
|
|
116
|
+
polyicon generate Generate fonts, CSS, types, and preview
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
-c, --config Path to config (default: .polyiconrc)
|
|
120
|
+
-f, --force Overwrite config on init
|
|
121
|
+
```
|
package/bin/polyicon.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wmt-polyicon",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "SVG to icon font generator with HTML preview — Fontello-style output for React and web projects.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"polyicon",
|
|
10
|
+
"icon",
|
|
11
|
+
"icons",
|
|
12
|
+
"svg",
|
|
13
|
+
"font",
|
|
14
|
+
"iconfont",
|
|
15
|
+
"fontello",
|
|
16
|
+
"react",
|
|
17
|
+
"cli",
|
|
18
|
+
"woff",
|
|
19
|
+
"woff2",
|
|
20
|
+
"ttf"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"polyicon": "bin/polyicon.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"generate": "node bin/polyicon.js generate"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"fast-xml-parser": "^5.5.6",
|
|
36
|
+
"fs-extra": "^11.1.1",
|
|
37
|
+
"svgtofont": "^6.5.1"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const { loadConfig } = require("../../config/load");
|
|
3
|
+
const { buildIcons } = require("../../generator");
|
|
4
|
+
const { resolveFromCwd } = require("../../utils/paths");
|
|
5
|
+
|
|
6
|
+
async function generateCommand({ configPath }) {
|
|
7
|
+
const absConfig = resolveFromCwd(configPath);
|
|
8
|
+
if (!fs.existsSync(absConfig)) {
|
|
9
|
+
throw new Error(`Missing config: ${configPath}. Run \`polyicon init\`.`);
|
|
10
|
+
}
|
|
11
|
+
const conf = loadConfig(absConfig);
|
|
12
|
+
if (!conf.svg) {
|
|
13
|
+
throw new Error("Missing `svg` path in config. Set it to your SVG folder.");
|
|
14
|
+
}
|
|
15
|
+
const absSvg = resolveFromCwd(conf.svg);
|
|
16
|
+
if (!fs.existsSync(absSvg)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`SVG path not found: ${conf.svg}. Update \`svg\` in ${configPath}.`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
await buildIcons(conf);
|
|
22
|
+
console.log(`Done. Generated ${conf.outputFonts} and preview HTML.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
generateCommand
|
|
27
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { DEFAULT_CONFIG } = require("../../config/defaults");
|
|
4
|
+
const { resolveFromCwd } = require("../../utils/paths");
|
|
5
|
+
const { ensureDir } = require("../../utils/fs");
|
|
6
|
+
|
|
7
|
+
async function initCommand({ configPath, force }) {
|
|
8
|
+
const targetPath = resolveFromCwd(configPath);
|
|
9
|
+
if (fs.existsSync(targetPath) && !force) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`Config already exists at ${configPath}. Use --force to overwrite.`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
await ensureDir(path.dirname(targetPath));
|
|
16
|
+
fs.writeFileSync(targetPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
17
|
+
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
initCommand
|
|
22
|
+
};
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { initCommand } = require("./commands/init");
|
|
2
|
+
const { generateCommand } = require("./commands/generate");
|
|
3
|
+
|
|
4
|
+
function getFlagValue(args, longName, shortName) {
|
|
5
|
+
const longIndex = args.indexOf(longName);
|
|
6
|
+
if (longIndex !== -1 && args[longIndex + 1]) return args[longIndex + 1];
|
|
7
|
+
const shortIndex = shortName ? args.indexOf(shortName) : -1;
|
|
8
|
+
if (shortIndex !== -1 && args[shortIndex + 1]) return args[shortIndex + 1];
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasFlag(args, flag) {
|
|
13
|
+
return args.includes(flag);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printHelp() {
|
|
17
|
+
console.log("polyicon <command>");
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log("Commands:");
|
|
20
|
+
console.log(" init Create .polyiconrc");
|
|
21
|
+
console.log(" generate Generate fonts and types");
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log("Options:");
|
|
24
|
+
console.log(" -c, --config Path to config (default: ./.polyiconrc)");
|
|
25
|
+
console.log(" -f, --force Overwrite config on init");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function run(argv) {
|
|
29
|
+
const args = argv.slice(2);
|
|
30
|
+
const command = args[0];
|
|
31
|
+
const configPath = getFlagValue(args, "--config", "-c") || ".polyiconrc";
|
|
32
|
+
const force = hasFlag(args, "--force") || hasFlag(args, "-f");
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (command === "init") {
|
|
36
|
+
await initCommand({ configPath, force });
|
|
37
|
+
console.log(`Created ${configPath}`);
|
|
38
|
+
console.log(
|
|
39
|
+
"Place SVGs in ./src/assets/svg and run `polyicon generate`."
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (command === "generate") {
|
|
45
|
+
await generateCommand({ configPath });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
printHelp();
|
|
50
|
+
process.exit(command ? 1 : 0);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(err && err.message ? err.message : err);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
run
|
|
59
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const DEFAULT_CONFIG = {
|
|
2
|
+
svg: "./src/assets/svg",
|
|
3
|
+
outputFonts: "./src/assets/fontello/font",
|
|
4
|
+
importFontsPath: "../font/",
|
|
5
|
+
outputTypes: "./src/types/IconTypes.js",
|
|
6
|
+
outputStyles: "./src/assets/fontello/css/polyicon.css",
|
|
7
|
+
formats: ["eot", "svg", "ttf", "woff", "woff2"],
|
|
8
|
+
polyiconConfig: "./src/assets/fontello/config.json",
|
|
9
|
+
classPrefix: "icon"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
DEFAULT_CONFIG
|
|
14
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
|
|
3
|
+
function loadConfig(filePath) {
|
|
4
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(raw);
|
|
7
|
+
} catch (err) {
|
|
8
|
+
const message = err && err.message ? err.message : "Invalid JSON";
|
|
9
|
+
throw new Error(`Invalid JSON in ${filePath}: ${message}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
loadConfig
|
|
15
|
+
};
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const WEBSITE = {
|
|
2
|
+
title: "polyicon",
|
|
3
|
+
version: 1,
|
|
4
|
+
logo: "logo.png",
|
|
5
|
+
meta: {
|
|
6
|
+
description: "Converts SVG icons to font files.",
|
|
7
|
+
keywords: "polyicon,TTF,EOT,WOFF,WOFF2,SVG"
|
|
8
|
+
},
|
|
9
|
+
links: []
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const FONT_NAME = "polyicon";
|
|
13
|
+
const CLASS_PREFIX = "icon";
|
|
14
|
+
const TMP_DIR = ".polyicon_tmp";
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
WEBSITE,
|
|
18
|
+
FONT_NAME,
|
|
19
|
+
CLASS_PREFIX,
|
|
20
|
+
TMP_DIR
|
|
21
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const fse = require("fs-extra");
|
|
3
|
+
const { TMP_DIR, CLASS_PREFIX } = require("../constants");
|
|
4
|
+
const { resolveFromCwd } = require("../utils/paths");
|
|
5
|
+
const { runSvgToFont } = require("./run_svgtofont");
|
|
6
|
+
const { parseGlyphs } = require("./parse_svg");
|
|
7
|
+
const { writeTypes } = require("./write_types");
|
|
8
|
+
const { writeCss } = require("./write_css");
|
|
9
|
+
const { writeFonts } = require("./write_fonts");
|
|
10
|
+
const { writeHtml } = require("./write_html");
|
|
11
|
+
const { writePolyiconConfig } = require("./write_polyicon_config");
|
|
12
|
+
const { convertStrokesToFills } = require("./stroke_to_fill");
|
|
13
|
+
|
|
14
|
+
async function buildIcons(conf) {
|
|
15
|
+
const svgPath = resolveFromCwd(conf.svg);
|
|
16
|
+
const outputFontsPath = resolveFromCwd(conf.outputFonts);
|
|
17
|
+
const outputTypesPath = resolveFromCwd(conf.outputTypes);
|
|
18
|
+
const outputStylesPath = conf.outputStyles
|
|
19
|
+
? resolveFromCwd(conf.outputStyles)
|
|
20
|
+
: null;
|
|
21
|
+
const polyiconConfigPath = conf.polyiconConfig
|
|
22
|
+
? resolveFromCwd(conf.polyiconConfig)
|
|
23
|
+
: null;
|
|
24
|
+
const importFontsPath = conf.importFontsPath || "";
|
|
25
|
+
const formats = conf.formats || ["eot", "svg", "ttf", "woff", "woff2"];
|
|
26
|
+
const classPrefix = conf.classPrefix || CLASS_PREFIX;
|
|
27
|
+
const tmpPath = path.resolve(process.cwd(), TMP_DIR);
|
|
28
|
+
const tmpSvgPath = path.resolve(process.cwd(), `${TMP_DIR}_svgs`);
|
|
29
|
+
|
|
30
|
+
await fse.ensureDir(tmpSvgPath);
|
|
31
|
+
const svgFiles = (await fse.readdir(svgPath)).filter((file) =>
|
|
32
|
+
file.toLowerCase().endsWith(".svg")
|
|
33
|
+
);
|
|
34
|
+
if (!svgFiles.length) {
|
|
35
|
+
throw new Error(`No SVG files found in ${svgPath}.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
const sanitizeName = (name) => {
|
|
40
|
+
let safe = name
|
|
41
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
42
|
+
.replace(/-+/g, "-")
|
|
43
|
+
.replace(/^-+|-+$/g, "");
|
|
44
|
+
if (!safe) safe = "icon";
|
|
45
|
+
if (/^\d/.test(safe)) safe = `icon-${safe}`;
|
|
46
|
+
return safe;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (const file of svgFiles) {
|
|
50
|
+
const inputPath = path.resolve(svgPath, file);
|
|
51
|
+
const baseName = path.basename(file, ".svg");
|
|
52
|
+
const safeBase = sanitizeName(baseName);
|
|
53
|
+
if (seen.has(safeBase)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Duplicate icon name after sanitizing: "${baseName}" conflicts with "${seen.get(
|
|
56
|
+
safeBase
|
|
57
|
+
)}". Rename one of the SVG files.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
seen.set(safeBase, baseName);
|
|
61
|
+
const outputPath = path.resolve(tmpSvgPath, `${safeBase}.svg`);
|
|
62
|
+
const raw = await fse.readFile(inputPath, "utf8");
|
|
63
|
+
const cleaned = raw
|
|
64
|
+
.replace(/<defs[\s\S]*?<\/defs>/gi, "")
|
|
65
|
+
.replace(/\sclip-path="[^"]*"/gi, "")
|
|
66
|
+
.replace(/\sclip-path='[^']*'/gi, "")
|
|
67
|
+
.replace(/\sfilter="[^"]*"/gi, "")
|
|
68
|
+
.replace(/\sfilter='[^']*'/gi, "")
|
|
69
|
+
.replace(/\smask="[^"]*"/gi, "")
|
|
70
|
+
.replace(/\smask='[^']*'/gi, "")
|
|
71
|
+
// Remove common white background rects (and their closing tags)
|
|
72
|
+
// that create square glyphs.
|
|
73
|
+
.replace(
|
|
74
|
+
/<rect\b[^>]*fill=["'](?:#fff|#ffffff|white)["'][^>]*(?:\/>|>(?:\s*<\/rect>)?)/gi,
|
|
75
|
+
""
|
|
76
|
+
);
|
|
77
|
+
// Convert stroke-only paths to filled outlines — font renderers
|
|
78
|
+
// only understand fills, so stroked paths produce blank glyphs.
|
|
79
|
+
const converted = convertStrokesToFills(cleaned);
|
|
80
|
+
// Extract all SVG drawing elements — not just <path>.
|
|
81
|
+
// Covers circle, ellipse, rect, polygon, polyline, and line so icons
|
|
82
|
+
// built from basic shapes are no longer silently dropped.
|
|
83
|
+
const shapeTags = converted.match(
|
|
84
|
+
/<(path|circle|ellipse|rect|polygon|polyline|line)\b[^>]*>[\s\S]*?<\/\1>|<(?:path|circle|ellipse|rect|polygon|polyline|line)\b[^>]*\/>/gi
|
|
85
|
+
);
|
|
86
|
+
let outputSvg = converted;
|
|
87
|
+
if (shapeTags && shapeTags.length) {
|
|
88
|
+
const viewBoxMatch = converted.match(/viewBox="([^"]+)"/i);
|
|
89
|
+
const viewBoxAttr = viewBoxMatch ? ` viewBox="${viewBoxMatch[1]}"` : "";
|
|
90
|
+
outputSvg = `<svg xmlns="http://www.w3.org/2000/svg"${viewBoxAttr}>${shapeTags.join(
|
|
91
|
+
""
|
|
92
|
+
)}</svg>`;
|
|
93
|
+
}
|
|
94
|
+
await fse.writeFile(outputPath, outputSvg, "utf8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await runSvgToFont({ svgPath: tmpSvgPath, tmpPath, classPrefix });
|
|
98
|
+
const glyphs = await parseGlyphs(tmpPath);
|
|
99
|
+
|
|
100
|
+
await writeTypes({ outputTypesPath, glyphs });
|
|
101
|
+
await writePolyiconConfig({ outputPath: polyiconConfigPath, glyphs, classPrefix });
|
|
102
|
+
await writeCss({
|
|
103
|
+
tmpPath,
|
|
104
|
+
outputStylesPath,
|
|
105
|
+
importFontsPath
|
|
106
|
+
});
|
|
107
|
+
await writeFonts({ tmpPath, outputFontsPath, formats });
|
|
108
|
+
await writeHtml({ tmpPath, outputFontsPath, glyphs, classPrefix });
|
|
109
|
+
|
|
110
|
+
await fse.remove(tmpPath);
|
|
111
|
+
await fse.remove(tmpSvgPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
buildIcons
|
|
116
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const fse = require("fs-extra");
|
|
2
|
+
const { XMLParser } = require("fast-xml-parser");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { FONT_NAME } = require("../constants");
|
|
5
|
+
|
|
6
|
+
async function parseGlyphs(tmpPath) {
|
|
7
|
+
const svgPath = path.resolve(tmpPath, `${FONT_NAME}.svg`);
|
|
8
|
+
const svg = await fse.readFile(svgPath, "utf8");
|
|
9
|
+
const parser = new XMLParser({
|
|
10
|
+
ignoreAttributes: false,
|
|
11
|
+
attributeNamePrefix: "",
|
|
12
|
+
processEntities: true,
|
|
13
|
+
htmlEntities: true
|
|
14
|
+
});
|
|
15
|
+
const json = parser.parse(svg);
|
|
16
|
+
const defs = json && json.svg && json.svg.defs ? json.svg.defs : null;
|
|
17
|
+
let font = defs && defs.font ? defs.font : null;
|
|
18
|
+
if (Array.isArray(font)) font = font[0];
|
|
19
|
+
let glyphs = font && font.glyph ? font.glyph : null;
|
|
20
|
+
if (!glyphs) {
|
|
21
|
+
throw new Error(`No glyphs found in ${svgPath}.`);
|
|
22
|
+
}
|
|
23
|
+
if (!(glyphs instanceof Array)) glyphs = [glyphs];
|
|
24
|
+
|
|
25
|
+
const normalizeUnicode = (value) => {
|
|
26
|
+
if (typeof value !== "string") return value;
|
|
27
|
+
if (value.startsWith("&#x") && value.endsWith(";")) {
|
|
28
|
+
const code = parseInt(value.slice(3, -1), 16);
|
|
29
|
+
if (!Number.isNaN(code)) return String.fromCharCode(code);
|
|
30
|
+
}
|
|
31
|
+
if (value.startsWith("&#") && value.endsWith(";")) {
|
|
32
|
+
const code = parseInt(value.slice(2, -1), 10);
|
|
33
|
+
if (!Number.isNaN(code)) return String.fromCharCode(code);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
glyphs = glyphs.map((g) => {
|
|
39
|
+
if (!g) return g;
|
|
40
|
+
const unicode = normalizeUnicode(g.unicode);
|
|
41
|
+
return { ...g, unicode };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const invalid = glyphs.filter(
|
|
45
|
+
(g) => !g || !g["glyph-name"] || !g.unicode
|
|
46
|
+
);
|
|
47
|
+
if (invalid.length) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Invalid glyphs in ${svgPath}. Expected glyph-name and unicode attributes.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return glyphs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
parseGlyphs
|
|
57
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { WEBSITE, FONT_NAME } = require("../constants");
|
|
2
|
+
|
|
3
|
+
async function runSvgToFont({ svgPath, tmpPath, classPrefix }) {
|
|
4
|
+
const mod = await import("svgtofont");
|
|
5
|
+
const svgtofont = mod.default;
|
|
6
|
+
await svgtofont({
|
|
7
|
+
src: svgPath,
|
|
8
|
+
dist: tmpPath,
|
|
9
|
+
fontName: FONT_NAME,
|
|
10
|
+
css: true,
|
|
11
|
+
classNamePrefix: classPrefix,
|
|
12
|
+
website: WEBSITE,
|
|
13
|
+
svgicons2svgfont: {
|
|
14
|
+
fontHeight: 1000,
|
|
15
|
+
normalize: true
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
runSvgToFont
|
|
22
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert stroked SVG elements to filled outlines.
|
|
3
|
+
*
|
|
4
|
+
* Font generators (svgtofont / svg2ttf) only render filled shapes.
|
|
5
|
+
* Stroke-only paths (e.g. line icons from Figma / Heroicons) produce
|
|
6
|
+
* zero-area glyphs → blank icons. This module converts the most
|
|
7
|
+
* common stroke patterns into equivalent filled outlines that font
|
|
8
|
+
* renderers can handle.
|
|
9
|
+
*
|
|
10
|
+
* Supported:
|
|
11
|
+
* - <path> with M/L/H/V commands (straight line segments)
|
|
12
|
+
* - <line> elements
|
|
13
|
+
* - Stroke linecaps: round, butt, square
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse an SVG path `d` attribute into an array of
|
|
18
|
+
* { cmd, args[] } objects. Only absolute commands are
|
|
19
|
+
* handled here (the icons we target are already absolute).
|
|
20
|
+
*/
|
|
21
|
+
function parsePathData(d) {
|
|
22
|
+
const commands = [];
|
|
23
|
+
const re = /([MLHVZmlhvz])\s*([^MLHVZmlhvz]*)/gi;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = re.exec(d)) !== null) {
|
|
26
|
+
const cmd = m[1];
|
|
27
|
+
const nums = m[2]
|
|
28
|
+
.trim()
|
|
29
|
+
.split(/[\s,]+/)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map(Number);
|
|
32
|
+
commands.push({ cmd, args: nums });
|
|
33
|
+
}
|
|
34
|
+
return commands;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Given a line segment (x1,y1)→(x2,y2), stroke-width `w` and
|
|
39
|
+
* linecap style, return an SVG path `d` string that represents
|
|
40
|
+
* the filled outline of that stroke.
|
|
41
|
+
*/
|
|
42
|
+
function lineToFilledPath(x1, y1, x2, y2, w, linecap) {
|
|
43
|
+
const hw = w / 2;
|
|
44
|
+
const dx = x2 - x1;
|
|
45
|
+
const dy = y2 - y1;
|
|
46
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
47
|
+
if (len === 0) {
|
|
48
|
+
// Degenerate: a dot → circle
|
|
49
|
+
return `M${x1 - hw} ${y1}A${hw} ${hw} 0 1 0 ${x1 + hw} ${y1}A${hw} ${hw} 0 1 0 ${x1 - hw} ${y1}Z`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Perpendicular offsets
|
|
53
|
+
const px = (-dy / len) * hw;
|
|
54
|
+
const py = (dx / len) * hw;
|
|
55
|
+
|
|
56
|
+
// Four corners of the stroke rectangle
|
|
57
|
+
const p1 = `${x1 + px} ${y1 + py}`;
|
|
58
|
+
const p2 = `${x2 + px} ${y2 + py}`;
|
|
59
|
+
const p3 = `${x2 - px} ${y2 - py}`;
|
|
60
|
+
const p4 = `${x1 - px} ${y1 - py}`;
|
|
61
|
+
|
|
62
|
+
if (linecap === "round") {
|
|
63
|
+
// Rectangle + semicircular arcs at each end
|
|
64
|
+
return `M${p1}L${p2}A${hw} ${hw} 0 0 0 ${p3}L${p4}A${hw} ${hw} 0 0 0 ${p1}Z`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (linecap === "square") {
|
|
68
|
+
// Extend rectangle by hw along the line direction at each end
|
|
69
|
+
const ex = (dx / len) * hw;
|
|
70
|
+
const ey = (dy / len) * hw;
|
|
71
|
+
const s1 = `${x1 - ex + px} ${y1 - ey + py}`;
|
|
72
|
+
const s2 = `${x2 + ex + px} ${y2 + ey + py}`;
|
|
73
|
+
const s3 = `${x2 + ex - px} ${y2 + ey - py}`;
|
|
74
|
+
const s4 = `${x1 - ex - px} ${y1 - ey - py}`;
|
|
75
|
+
return `M${s1}L${s2}L${s3}L${s4}Z`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// butt (default)
|
|
79
|
+
return `M${p1}L${p2}L${p3}L${p4}Z`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect whether a <path> is stroke-only (has stroke, no meaningful
|
|
84
|
+
* fill), extract the relevant attributes, and return filled
|
|
85
|
+
* replacement markup. Returns `null` when conversion is not needed
|
|
86
|
+
* or not supported.
|
|
87
|
+
*/
|
|
88
|
+
function convertPathElement(tag) {
|
|
89
|
+
const attr = (name) => {
|
|
90
|
+
const re = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i");
|
|
91
|
+
const m = tag.match(re);
|
|
92
|
+
return m ? m[1] : null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const stroke = attr("stroke");
|
|
96
|
+
if (!stroke || stroke === "none") return null; // nothing to convert
|
|
97
|
+
|
|
98
|
+
const fill = attr("fill");
|
|
99
|
+
// If the element already has a visible fill, leave it alone
|
|
100
|
+
if (fill && fill !== "none") return null;
|
|
101
|
+
|
|
102
|
+
const d = attr("d");
|
|
103
|
+
if (!d) return null;
|
|
104
|
+
|
|
105
|
+
const strokeWidth = parseFloat(attr("stroke-width") || "1");
|
|
106
|
+
const linecap = attr("stroke-linecap") || "butt";
|
|
107
|
+
|
|
108
|
+
const commands = parsePathData(d);
|
|
109
|
+
|
|
110
|
+
// Collect line segments from consecutive M/L/H/V commands
|
|
111
|
+
const segments = [];
|
|
112
|
+
let cx = 0,
|
|
113
|
+
cy = 0,
|
|
114
|
+
startX = 0,
|
|
115
|
+
startY = 0;
|
|
116
|
+
let supported = true;
|
|
117
|
+
|
|
118
|
+
for (const { cmd, args } of commands) {
|
|
119
|
+
switch (cmd) {
|
|
120
|
+
case "M":
|
|
121
|
+
cx = args[0];
|
|
122
|
+
cy = args[1];
|
|
123
|
+
startX = cx;
|
|
124
|
+
startY = cy;
|
|
125
|
+
// M can carry implicit L args after the first pair
|
|
126
|
+
for (let i = 2; i + 1 < args.length; i += 2) {
|
|
127
|
+
segments.push({ x1: cx, y1: cy, x2: args[i], y2: args[i + 1] });
|
|
128
|
+
cx = args[i];
|
|
129
|
+
cy = args[i + 1];
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
case "m":
|
|
133
|
+
cx += args[0];
|
|
134
|
+
cy += args[1];
|
|
135
|
+
startX = cx;
|
|
136
|
+
startY = cy;
|
|
137
|
+
for (let i = 2; i + 1 < args.length; i += 2) {
|
|
138
|
+
const nx = cx + args[i],
|
|
139
|
+
ny = cy + args[i + 1];
|
|
140
|
+
segments.push({ x1: cx, y1: cy, x2: nx, y2: ny });
|
|
141
|
+
cx = nx;
|
|
142
|
+
cy = ny;
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
case "L":
|
|
146
|
+
for (let i = 0; i + 1 < args.length; i += 2) {
|
|
147
|
+
segments.push({ x1: cx, y1: cy, x2: args[i], y2: args[i + 1] });
|
|
148
|
+
cx = args[i];
|
|
149
|
+
cy = args[i + 1];
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case "l":
|
|
153
|
+
for (let i = 0; i + 1 < args.length; i += 2) {
|
|
154
|
+
const nx = cx + args[i],
|
|
155
|
+
ny = cy + args[i + 1];
|
|
156
|
+
segments.push({ x1: cx, y1: cy, x2: nx, y2: ny });
|
|
157
|
+
cx = nx;
|
|
158
|
+
cy = ny;
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
case "H":
|
|
162
|
+
segments.push({ x1: cx, y1: cy, x2: args[0], y2: cy });
|
|
163
|
+
cx = args[0];
|
|
164
|
+
break;
|
|
165
|
+
case "h":
|
|
166
|
+
segments.push({ x1: cx, y1: cy, x2: cx + args[0], y2: cy });
|
|
167
|
+
cx += args[0];
|
|
168
|
+
break;
|
|
169
|
+
case "V":
|
|
170
|
+
segments.push({ x1: cx, y1: cy, x2: cx, y2: args[0] });
|
|
171
|
+
cy = args[0];
|
|
172
|
+
break;
|
|
173
|
+
case "v":
|
|
174
|
+
segments.push({ x1: cx, y1: cy, x2: cx, y2: cy + args[0] });
|
|
175
|
+
cy += args[0];
|
|
176
|
+
break;
|
|
177
|
+
case "Z":
|
|
178
|
+
case "z":
|
|
179
|
+
if (cx !== startX || cy !== startY) {
|
|
180
|
+
segments.push({ x1: cx, y1: cy, x2: startX, y2: startY });
|
|
181
|
+
}
|
|
182
|
+
cx = startX;
|
|
183
|
+
cy = startY;
|
|
184
|
+
break;
|
|
185
|
+
default:
|
|
186
|
+
// Curves, arcs, etc. — bail out and leave the element untouched
|
|
187
|
+
supported = false;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
if (!supported) break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!supported || segments.length === 0) return null;
|
|
194
|
+
|
|
195
|
+
// Build filled outline for each segment
|
|
196
|
+
const color = stroke === "currentColor" ? "black" : stroke;
|
|
197
|
+
const paths = segments.map((s) => {
|
|
198
|
+
const outline = lineToFilledPath(
|
|
199
|
+
s.x1,
|
|
200
|
+
s.y1,
|
|
201
|
+
s.x2,
|
|
202
|
+
s.y2,
|
|
203
|
+
strokeWidth,
|
|
204
|
+
linecap
|
|
205
|
+
);
|
|
206
|
+
return `<path d="${outline}" fill="${color}"/>`;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return paths.join("");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Convert a <line> element to a filled path.
|
|
214
|
+
*/
|
|
215
|
+
function convertLineElement(tag) {
|
|
216
|
+
const attr = (name) => {
|
|
217
|
+
const re = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i");
|
|
218
|
+
const m = tag.match(re);
|
|
219
|
+
return m ? m[1] : null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const stroke = attr("stroke");
|
|
223
|
+
if (!stroke || stroke === "none") return null;
|
|
224
|
+
|
|
225
|
+
const x1 = parseFloat(attr("x1") || "0");
|
|
226
|
+
const y1 = parseFloat(attr("y1") || "0");
|
|
227
|
+
const x2 = parseFloat(attr("x2") || "0");
|
|
228
|
+
const y2 = parseFloat(attr("y2") || "0");
|
|
229
|
+
const strokeWidth = parseFloat(attr("stroke-width") || "1");
|
|
230
|
+
const linecap = attr("stroke-linecap") || "butt";
|
|
231
|
+
|
|
232
|
+
const color = stroke === "currentColor" ? "black" : stroke;
|
|
233
|
+
const outline = lineToFilledPath(x1, y1, x2, y2, strokeWidth, linecap);
|
|
234
|
+
return `<path d="${outline}" fill="${color}"/>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Main entry point — transform an SVG string, converting stroked
|
|
239
|
+
* elements to filled outlines where possible.
|
|
240
|
+
*/
|
|
241
|
+
function convertStrokesToFills(svgString) {
|
|
242
|
+
// Handle <path> elements (self-closing)
|
|
243
|
+
let result = svgString.replace(
|
|
244
|
+
/<path\b[^>]*\/?>/gi,
|
|
245
|
+
(match) => convertPathElement(match) || match
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Handle <line> elements (self-closing)
|
|
249
|
+
result = result.replace(
|
|
250
|
+
/<line\b[^>]*\/?>/gi,
|
|
251
|
+
(match) => convertLineElement(match) || match
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { convertStrokesToFills };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const fse = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { ensureDir } = require("../utils/fs");
|
|
4
|
+
const { FONT_NAME } = require("../constants");
|
|
5
|
+
|
|
6
|
+
async function writeCss({ tmpPath, outputStylesPath, importFontsPath }) {
|
|
7
|
+
if (!outputStylesPath) return;
|
|
8
|
+
|
|
9
|
+
await ensureDir(path.dirname(outputStylesPath));
|
|
10
|
+
|
|
11
|
+
let css = await fse.readFile(
|
|
12
|
+
path.resolve(tmpPath, `${FONT_NAME}.css`),
|
|
13
|
+
"utf8"
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
while (css.indexOf(`url('${FONT_NAME}`) > -1) {
|
|
17
|
+
css = css.replace(
|
|
18
|
+
`url('${FONT_NAME}`,
|
|
19
|
+
`url('${importFontsPath}${FONT_NAME}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
while (css.indexOf(`url("${FONT_NAME}`) > -1) {
|
|
23
|
+
css = css.replace(
|
|
24
|
+
`url("${FONT_NAME}`,
|
|
25
|
+
`url("${importFontsPath}${FONT_NAME}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await fse.writeFile(outputStylesPath, css, "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
writeCss
|
|
34
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const fse = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { ensureDir } = require("../utils/fs");
|
|
4
|
+
const { FONT_NAME } = require("../constants");
|
|
5
|
+
|
|
6
|
+
async function writeFonts({ tmpPath, outputFontsPath, formats }) {
|
|
7
|
+
await ensureDir(outputFontsPath);
|
|
8
|
+
|
|
9
|
+
const copyIf = async (ext) => {
|
|
10
|
+
await fse.copy(
|
|
11
|
+
path.resolve(tmpPath, `${FONT_NAME}.${ext}`),
|
|
12
|
+
path.resolve(outputFontsPath, `${FONT_NAME}.${ext}`)
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (formats.includes("svg")) await copyIf("svg");
|
|
17
|
+
if (formats.includes("ttf")) await copyIf("ttf");
|
|
18
|
+
if (formats.includes("woff")) await copyIf("woff");
|
|
19
|
+
if (formats.includes("woff2")) await copyIf("woff2");
|
|
20
|
+
if (formats.includes("eot")) await copyIf("eot");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
writeFonts
|
|
25
|
+
};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
const fse = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { ensureDir } = require("../utils/fs");
|
|
4
|
+
const { FONT_NAME } = require("../constants");
|
|
5
|
+
|
|
6
|
+
async function writeHtml({ tmpPath, outputFontsPath, glyphs, classPrefix }) {
|
|
7
|
+
const outputDir = path.dirname(outputFontsPath);
|
|
8
|
+
const fontsSubDir = path.basename(outputFontsPath);
|
|
9
|
+
|
|
10
|
+
let css = await fse.readFile(
|
|
11
|
+
path.resolve(tmpPath, `${FONT_NAME}.css`),
|
|
12
|
+
"utf8"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
css = css.replace(
|
|
16
|
+
/url\((['"]?)([^'")\s]+\.(eot|woff2?|ttf|svg)[^'")\s]*)\1\)/gi,
|
|
17
|
+
(_, quote, href) => {
|
|
18
|
+
const q = quote || "'";
|
|
19
|
+
const file = href.split("?")[0];
|
|
20
|
+
const query = href.includes("?") ? "?" + href.split("?")[1] : "";
|
|
21
|
+
return `url(${q}${fontsSubDir}/${path.basename(file)}${query}${q})`;
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const items = glyphs
|
|
26
|
+
.map((g) => {
|
|
27
|
+
const name = g["glyph-name"];
|
|
28
|
+
const className = `${classPrefix}-${name}`;
|
|
29
|
+
const code = g.unicode.codePointAt(0).toString(16).padStart(4, "0");
|
|
30
|
+
return ` <div class="icon-card" data-class="${classPrefix} ${className}" data-name="${className}">
|
|
31
|
+
<div class="icon-preview">
|
|
32
|
+
<i class="${classPrefix} ${className}"></i>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="icon-info">
|
|
35
|
+
<span class="icon-name" title="${className}">${className}</span>
|
|
36
|
+
<span class="icon-code">U+${code.toUpperCase()}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<button class="copy-btn" title="Copy class name">
|
|
39
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
40
|
+
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
41
|
+
</svg>
|
|
42
|
+
</button>
|
|
43
|
+
<div class="copied-badge">Copied!</div>
|
|
44
|
+
</div>`;
|
|
45
|
+
})
|
|
46
|
+
.join("\n");
|
|
47
|
+
|
|
48
|
+
const html = `<!DOCTYPE html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="UTF-8" />
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
53
|
+
<title>${FONT_NAME} — Icon Preview</title>
|
|
54
|
+
<style>
|
|
55
|
+
${css}
|
|
56
|
+
|
|
57
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
58
|
+
|
|
59
|
+
body {
|
|
60
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
61
|
+
background: #f0f2f5;
|
|
62
|
+
color: #1a1a1a;
|
|
63
|
+
min-height: 100vh;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ── Header ── */
|
|
67
|
+
.header {
|
|
68
|
+
background: #fff;
|
|
69
|
+
border-bottom: 1px solid #e5e7eb;
|
|
70
|
+
padding: 28px 40px;
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: space-between;
|
|
74
|
+
gap: 16px;
|
|
75
|
+
flex-wrap: wrap;
|
|
76
|
+
}
|
|
77
|
+
.header-left h1 {
|
|
78
|
+
font-size: 20px;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
color: #111;
|
|
81
|
+
letter-spacing: -0.3px;
|
|
82
|
+
}
|
|
83
|
+
.header-left p {
|
|
84
|
+
font-size: 13px;
|
|
85
|
+
color: #6b7280;
|
|
86
|
+
margin-top: 2px;
|
|
87
|
+
}
|
|
88
|
+
.search-wrap {
|
|
89
|
+
position: relative;
|
|
90
|
+
flex: 0 0 260px;
|
|
91
|
+
}
|
|
92
|
+
.search-wrap svg {
|
|
93
|
+
position: absolute;
|
|
94
|
+
left: 10px;
|
|
95
|
+
top: 50%;
|
|
96
|
+
transform: translateY(-50%);
|
|
97
|
+
color: #9ca3af;
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
}
|
|
100
|
+
#search {
|
|
101
|
+
width: 100%;
|
|
102
|
+
padding: 8px 12px 8px 34px;
|
|
103
|
+
border: 1px solid #d1d5db;
|
|
104
|
+
border-radius: 8px;
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
outline: none;
|
|
107
|
+
background: #f9fafb;
|
|
108
|
+
transition: border-color 0.15s, background 0.15s;
|
|
109
|
+
}
|
|
110
|
+
#search:focus { border-color: #6366f1; background: #fff; }
|
|
111
|
+
|
|
112
|
+
/* ── Grid ── */
|
|
113
|
+
.grid-wrap { padding: 32px 40px; }
|
|
114
|
+
.grid {
|
|
115
|
+
display: grid;
|
|
116
|
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
117
|
+
gap: 12px;
|
|
118
|
+
}
|
|
119
|
+
.empty-msg {
|
|
120
|
+
display: none;
|
|
121
|
+
grid-column: 1 / -1;
|
|
122
|
+
text-align: center;
|
|
123
|
+
padding: 60px 0;
|
|
124
|
+
color: #9ca3af;
|
|
125
|
+
font-size: 14px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ── Icon card ── */
|
|
129
|
+
.icon-card {
|
|
130
|
+
position: relative;
|
|
131
|
+
background: #fff;
|
|
132
|
+
border: 1px solid #e5e7eb;
|
|
133
|
+
border-radius: 10px;
|
|
134
|
+
padding: 20px 12px 14px;
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 10px;
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
|
141
|
+
overflow: hidden;
|
|
142
|
+
}
|
|
143
|
+
.icon-card:hover {
|
|
144
|
+
border-color: #6366f1;
|
|
145
|
+
box-shadow: 0 4px 16px rgba(99,102,241,0.12);
|
|
146
|
+
transform: translateY(-1px);
|
|
147
|
+
}
|
|
148
|
+
.icon-preview {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
justify-content: center;
|
|
152
|
+
width: 48px;
|
|
153
|
+
height: 48px;
|
|
154
|
+
}
|
|
155
|
+
.icon-preview i { font-size: 30px; color: #374151; transition: color 0.15s; }
|
|
156
|
+
.icon-card:hover .icon-preview i { color: #6366f1; }
|
|
157
|
+
.icon-info {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 3px;
|
|
162
|
+
width: 100%;
|
|
163
|
+
}
|
|
164
|
+
.icon-name {
|
|
165
|
+
font-size: 11px;
|
|
166
|
+
color: #374151;
|
|
167
|
+
font-weight: 500;
|
|
168
|
+
text-align: center;
|
|
169
|
+
word-break: break-all;
|
|
170
|
+
line-height: 1.4;
|
|
171
|
+
max-width: 100%;
|
|
172
|
+
}
|
|
173
|
+
.icon-code {
|
|
174
|
+
font-size: 10px;
|
|
175
|
+
color: #9ca3af;
|
|
176
|
+
font-family: "SF Mono", "Fira Code", monospace;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── Copy button ── */
|
|
180
|
+
.copy-btn {
|
|
181
|
+
position: absolute;
|
|
182
|
+
top: 7px;
|
|
183
|
+
right: 7px;
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: center;
|
|
187
|
+
width: 24px;
|
|
188
|
+
height: 24px;
|
|
189
|
+
background: transparent;
|
|
190
|
+
border: none;
|
|
191
|
+
border-radius: 5px;
|
|
192
|
+
color: #9ca3af;
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
opacity: 0;
|
|
195
|
+
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
|
196
|
+
}
|
|
197
|
+
.icon-card:hover .copy-btn { opacity: 1; }
|
|
198
|
+
.copy-btn:hover { background: #f3f4f6; color: #6366f1; }
|
|
199
|
+
|
|
200
|
+
/* ── Copied badge ── */
|
|
201
|
+
.copied-badge {
|
|
202
|
+
position: absolute;
|
|
203
|
+
inset: 0;
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
justify-content: center;
|
|
207
|
+
background: rgba(99,102,241,0.92);
|
|
208
|
+
color: #fff;
|
|
209
|
+
font-size: 12px;
|
|
210
|
+
font-weight: 600;
|
|
211
|
+
border-radius: 10px;
|
|
212
|
+
opacity: 0;
|
|
213
|
+
pointer-events: none;
|
|
214
|
+
transition: opacity 0.15s;
|
|
215
|
+
}
|
|
216
|
+
.icon-card.copied .copied-badge { opacity: 1; }
|
|
217
|
+
|
|
218
|
+
/* ── Footer ── */
|
|
219
|
+
.footer {
|
|
220
|
+
text-align: center;
|
|
221
|
+
padding: 24px;
|
|
222
|
+
color: #9ca3af;
|
|
223
|
+
font-size: 11px;
|
|
224
|
+
border-top: 1px solid #e5e7eb;
|
|
225
|
+
background: #fff;
|
|
226
|
+
}
|
|
227
|
+
</style>
|
|
228
|
+
</head>
|
|
229
|
+
<body>
|
|
230
|
+
|
|
231
|
+
<header class="header">
|
|
232
|
+
<div class="header-left">
|
|
233
|
+
<h1>${FONT_NAME}</h1>
|
|
234
|
+
<p id="count">${glyphs.length} icon${glyphs.length !== 1 ? "s" : ""}</p>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="search-wrap">
|
|
237
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
238
|
+
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
239
|
+
</svg>
|
|
240
|
+
<input id="search" type="text" placeholder="Search icons…" autocomplete="off" />
|
|
241
|
+
</div>
|
|
242
|
+
</header>
|
|
243
|
+
|
|
244
|
+
<main class="grid-wrap">
|
|
245
|
+
<div class="grid" id="grid">
|
|
246
|
+
${items}
|
|
247
|
+
<div class="empty-msg" id="empty">No icons match your search.</div>
|
|
248
|
+
</div>
|
|
249
|
+
</main>
|
|
250
|
+
|
|
251
|
+
<footer class="footer">Generated by polyicon · ${glyphs.length} icons total</footer>
|
|
252
|
+
|
|
253
|
+
<script>
|
|
254
|
+
const cards = Array.from(document.querySelectorAll('.icon-card'));
|
|
255
|
+
const search = document.getElementById('search');
|
|
256
|
+
const empty = document.getElementById('empty');
|
|
257
|
+
const count = document.getElementById('count');
|
|
258
|
+
const total = ${glyphs.length};
|
|
259
|
+
|
|
260
|
+
// Copy on card click or copy-btn click
|
|
261
|
+
function copyClass(card, e) {
|
|
262
|
+
e.stopPropagation();
|
|
263
|
+
const cls = card.dataset.name;
|
|
264
|
+
navigator.clipboard.writeText(cls).then(() => {
|
|
265
|
+
card.classList.add('copied');
|
|
266
|
+
setTimeout(() => card.classList.remove('copied'), 1200);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
cards.forEach(card => {
|
|
271
|
+
card.addEventListener('click', e => copyClass(card, e));
|
|
272
|
+
card.querySelector('.copy-btn').addEventListener('click', e => copyClass(card, e));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Search filter
|
|
276
|
+
search.addEventListener('input', () => {
|
|
277
|
+
const q = search.value.trim().toLowerCase();
|
|
278
|
+
let visible = 0;
|
|
279
|
+
cards.forEach(card => {
|
|
280
|
+
const match = !q || card.dataset.name.toLowerCase().includes(q);
|
|
281
|
+
card.style.display = match ? '' : 'none';
|
|
282
|
+
if (match) visible++;
|
|
283
|
+
});
|
|
284
|
+
empty.style.display = visible === 0 ? 'block' : 'none';
|
|
285
|
+
count.textContent = q
|
|
286
|
+
? visible + ' of ' + total + ' icon' + (total !== 1 ? 's' : '')
|
|
287
|
+
: total + ' icon' + (total !== 1 ? 's' : '');
|
|
288
|
+
});
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
</body>
|
|
292
|
+
</html>`;
|
|
293
|
+
|
|
294
|
+
await ensureDir(outputDir);
|
|
295
|
+
await fse.writeFile(
|
|
296
|
+
path.resolve(outputDir, `${FONT_NAME}-preview.html`),
|
|
297
|
+
html,
|
|
298
|
+
"utf8"
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { writeHtml };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { ensureFile, writeJson } = require("../utils/fs");
|
|
2
|
+
const { FONT_NAME } = require("../constants");
|
|
3
|
+
|
|
4
|
+
async function writePolyiconConfig({ outputPath, glyphs, classPrefix }) {
|
|
5
|
+
if (!outputPath) return;
|
|
6
|
+
|
|
7
|
+
const config = {
|
|
8
|
+
name: FONT_NAME,
|
|
9
|
+
css_prefix_text: `${classPrefix}-`,
|
|
10
|
+
css_use_suffix: false,
|
|
11
|
+
hinting: true,
|
|
12
|
+
units_per_em: 1000,
|
|
13
|
+
ascent: 850,
|
|
14
|
+
glyphs: glyphs.map((glyph) => {
|
|
15
|
+
return {
|
|
16
|
+
css: glyph["glyph-name"],
|
|
17
|
+
code: glyph.unicode.codePointAt(0)
|
|
18
|
+
};
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
await ensureFile(outputPath);
|
|
23
|
+
await writeJson(outputPath, config);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
writePolyiconConfig
|
|
28
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const fse = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { toConstName } = require("../utils/strings");
|
|
4
|
+
const { ensureFile } = require("../utils/fs");
|
|
5
|
+
|
|
6
|
+
async function writeTypes({ outputTypesPath, glyphs }) {
|
|
7
|
+
const isTypescript = outputTypesPath.endsWith(".ts");
|
|
8
|
+
let types = "";
|
|
9
|
+
|
|
10
|
+
glyphs.forEach((glyph, index) => {
|
|
11
|
+
const line = isTypescript
|
|
12
|
+
? `\t${toConstName(glyph["glyph-name"])} = '${glyph["glyph-name"]}',`
|
|
13
|
+
: `\t${toConstName(glyph["glyph-name"])}: '${glyph["glyph-name"]}',`;
|
|
14
|
+
types += line;
|
|
15
|
+
if (index < glyphs.length - 1) types += "\n";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const content = isTypescript
|
|
19
|
+
? `enum IconTypes { ${types} } export default IconTypes; `
|
|
20
|
+
: `const IconTypes = { ${types} }; export default IconTypes; `;
|
|
21
|
+
|
|
22
|
+
await ensureFile(outputTypesPath);
|
|
23
|
+
await fse.writeFile(outputTypesPath, content, "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
writeTypes
|
|
28
|
+
};
|
package/src/index.js
ADDED
package/src/utils/fs.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const fse = require("fs-extra");
|
|
2
|
+
|
|
3
|
+
async function ensureDir(dirPath) {
|
|
4
|
+
if (!dirPath || dirPath === ".") return;
|
|
5
|
+
await fse.ensureDir(dirPath);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function ensureFile(filePath) {
|
|
9
|
+
await fse.ensureFile(filePath);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function writeJson(filePath, data) {
|
|
13
|
+
await fse.ensureFile(filePath);
|
|
14
|
+
await fse.writeJSON(filePath, data);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
ensureDir,
|
|
19
|
+
ensureFile,
|
|
20
|
+
writeJson
|
|
21
|
+
};
|