@enigmax/icons 0.1.0 → 0.2.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/LICENSE CHANGED
@@ -19,12 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
23
- ---
24
-
25
- The icon artwork is the Solar Icon Set by 480 Design, licensed under
26
- Creative Commons Attribution 4.0 International (CC BY 4.0):
27
- https://creativecommons.org/licenses/by/4.0/
28
-
29
- Attribution is required by that licence. Keep this notice, and surface the
30
- credit somewhere in any application that ships these icons.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @enigmax/icons
2
2
 
3
- Filled duotone icons for React. 391 icons in 36 categories, each in its own
4
- module, so a bundle carries only the ones it renders.
3
+ Icons for React. 391 icons in 36 categories, each in its own module, so a
4
+ bundle carries only the ones it renders.
5
5
 
6
6
  ```tsx
7
7
  import { IconMapPin } from "@enigmax/icons";
@@ -10,10 +10,9 @@ import { IconMapPin } from "@enigmax/icons";
10
10
  ```
11
11
 
12
12
  The icons are filled, not stroked: colour comes from the surrounding text
13
- colour (`currentColor`), and the lighter half of each glyph is the same colour
14
- at 50% opacity. There is no line to widen, so `stroke` is accepted and ignored
15
- rather than forwarded into the SVG attribute of that name, which takes a colour
16
- and would paint the icon with an invalid value until it disappeared.
13
+ colour (`currentColor`). There is no line to widen, so `stroke` is accepted and
14
+ ignored rather than forwarded into the SVG attribute of that name, which takes
15
+ a colour and would paint the icon with an invalid value until it disappeared.
17
16
 
18
17
  ## Only what you use
19
18
 
@@ -33,13 +32,44 @@ import * as icons from "@enigmax/icons/categories";
33
32
  Categories come from the set's own taxonomy: `arrows`, `devices`, `map`,
34
33
  `security`, `ui`, `weather`, and thirty more.
35
34
 
35
+ ## Weights
36
+
37
+ Bold duotone is the weight that ships today, and it is addressable by name:
38
+
39
+ ```tsx
40
+ import { IconMapPin } from "@enigmax/icons"; // the default weight
41
+ import { IconMapPin } from "@enigmax/icons/bold-duotone"; // the same icon, named
42
+ ```
43
+
44
+ **A weight is a build dimension, not a prop.** A `weight` prop would have to
45
+ reach every body at runtime, which puts all of them in the bundle for any icon
46
+ the app imports and destroys the one thing this package is for. So each weight
47
+ is generated into its own directory with its own subpath, and an app pays only
48
+ for the weights it actually imports. Two weights of the same icon are two
49
+ imports, and a project that uses one never downloads the other.
50
+
51
+ Adding one is three steps and no breaking change:
52
+
53
+ 1. Declare it in `icon-map.json` under `weights.available`.
54
+ 2. Give every icon its name in that weight under `glyphs`, e.g.
55
+ `"IconSun": { "glyph": "sun-bold-duotone", "glyphs": { "outline": "sun-linear" }, "category": "Weather" }`.
56
+ Names are written out per weight rather than derived by swapping a suffix: a
57
+ set is free to name a weight's glyph anything, and guessing it produces an
58
+ icon that silently renders nothing.
59
+ 3. `node generate.mjs --set <set.json> --weight outline`.
60
+
61
+ The default weight keeps the root paths, so nothing that already imports from
62
+ this package changes when a weight is added. A weight that is declared but
63
+ incomplete fails the test suite rather than shipping: half a weight renders
64
+ some icons and vanishes the rest, which reads as a broken page.
65
+
36
66
  ## Conventions
37
67
 
38
68
  An external link is marked with the **diagonal arrow pointing up and to the
39
69
  right** (`IconExternalLink`), not the box-with-an-arrow-leaving-it glyph: the
40
70
  latter reads as clutter at small sizes in a filled set.
41
71
 
42
- Brand marks (`IconBrandGithub`, `IconBrandDiscord`, ) are re-exported from
72
+ Brand marks (`IconBrandGithub`, `IconBrandDiscord`, ...) are re-exported from
43
73
  `@tabler/icons-react`, an optional peer dependency. This set has no brand
44
74
  marks, and drawing a company's logo by hand is worse than shipping the real
45
75
  one. Install `@tabler/icons-react` only if you import a brand mark.
@@ -47,18 +77,13 @@ one. Install `@tabler/icons-react` only if you import a brand mark.
47
77
  ## Regenerating
48
78
 
49
79
  ```sh
50
- node generate.mjs --solar <path/to/set.json>
80
+ node generate.mjs --set <path/to/set.json> [--weight <name>]
51
81
  ```
52
82
 
53
83
  Edit `icon-map.json` to add, remove or re-point an icon, then regenerate. A
54
- name that is not in the set fails the run rather than rendering an empty square
55
- forever.
84
+ name that is not in the set, or an icon with no name for the weight being
85
+ generated, fails the run rather than rendering an empty square forever.
56
86
 
57
87
  ## Licence
58
88
 
59
- The icon artwork is the Solar Icon Set by 480 Design, used under
60
- [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). That licence
61
- requires attribution: keep this notice, and surface it somewhere in any app
62
- that ships these icons (an About or Credits screen is the usual place).
63
-
64
- The package code is MIT.
89
+ MIT.
package/generate.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Generate the icon package's source from icon-map.json and the Solar set.
2
+ // Generate the icon package's source from icon-map.json and a set of drawings.
3
3
  //
4
4
  // One module per icon, one barrel per category, and a root index that re-exports
5
5
  // everything. That shape is what lets a bundler drop what an app never renders:
@@ -8,15 +8,21 @@
8
8
  // fetched at install time - the bodies are written here as plain strings.
9
9
  //
10
10
  // Usage:
11
- // node generate.mjs --solar <path/to/solar-icons.json> [--out src]
11
+ // node generate.mjs --set <path/to/icons.json> [--weight <name>] [--out src]
12
12
  //
13
- // The Solar set is a build-time input, not a dependency of the published
14
- // package: it is read here and the ~400 bodies the map names are copied out.
15
- // The full set is 7,759 icons, of which this keeps about five percent.
13
+ // WEIGHT IS A BUILD DIMENSION, NOT A PROP. A `weight` prop would have to reach
14
+ // every body at runtime, which puts all of them in the bundle for any icon the
15
+ // app imports and destroys the one thing this package is for. So each weight is
16
+ // generated into its own directory and addressed by its own subpath, and an app
17
+ // pays only for the weights it actually imports.
16
18
  //
17
- // Solar Icon Set by 480 Design, CC BY 4.0
18
- // (https://creativecommons.org/licenses/by/4.0/). The attribution the licence
19
- // requires ships in the package README and must not be stripped.
19
+ // The DEFAULT weight (declared in icon-map.json) is written to the root of
20
+ // `--out` so the package's main entry keeps working unchanged; every other
21
+ // weight is written to `<out>/<weight>/`.
22
+ //
23
+ // The set is a build-time input, not a dependency of the published package: it
24
+ // is read here and the ~400 bodies the map names are copied out. The full set
25
+ // is several thousand icons, of which this keeps about five percent.
20
26
  import { fileURLToPath } from "node:url";
21
27
  import { resolve, dirname } from "node:path";
22
28
  import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
@@ -29,10 +35,9 @@ const arg = (flag, fallback) => {
29
35
  return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
30
36
  };
31
37
 
32
- const OUT = resolve(HERE, arg("--out", "src"));
33
- const SOLAR = arg("--solar", null);
34
- if (!SOLAR || !existsSync(SOLAR)) {
35
- console.error("usage: node generate.mjs --solar <path/to/solar-icons.json> [--out src]");
38
+ const SET = arg("--set", null);
39
+ if (!SET || !existsSync(SET)) {
40
+ console.error("usage: node generate.mjs --set <path/to/icons.json> [--weight <name>] [--out src]");
36
41
  process.exit(1);
37
42
  }
38
43
 
@@ -52,7 +57,7 @@ export type IconProps = {
52
57
  * Accepted and ignored. These icons are filled, not stroked, so there is no
53
58
  * line to widen - and forwarding a number into the SVG \`stroke\` attribute,
54
59
  * which takes a COLOUR, paints the icon with an invalid value until it
55
- * disappears. Kept in the signature so a stroked set can be swapped out
60
+ * disappears. Kept in the signature so a stroked weight can be swapped in
56
61
  * without touching the call sites that pass it.
57
62
  */
58
63
  stroke?: number | string;
@@ -69,8 +74,8 @@ export type Icon = ForwardRefExoticComponent<IconProps & RefAttributes<SVGSVGEle
69
74
  *
70
75
  * The body is a constant written by the generator, never anything a caller
71
76
  * supplies, which is what makes dangerouslySetInnerHTML the right tool here
72
- * rather than a hole. Both paths of a duotone glyph carry fill="currentColor",
73
- * so colour comes from the surrounding text colour and nothing else.
77
+ * rather than a hole. Every path carries fill="currentColor", so colour comes
78
+ * from the surrounding text colour and nothing else.
74
79
  */
75
80
  export function icon(name: string, body: string): Icon {
76
81
  const Component = forwardRef<SVGSVGElement, IconProps>(
@@ -94,34 +99,70 @@ export function icon(name: string, body: string): Icon {
94
99
  }
95
100
  `;
96
101
 
97
- const { icons, brands } = JSON.parse(readFileSync(MAP, "utf8"));
98
- const solar = JSON.parse(readFileSync(SOLAR, "utf8")).icons;
102
+ const { icons, brands, weights } = JSON.parse(readFileSync(MAP, "utf8"));
103
+ const defaultWeight = weights?.default ?? "bold-duotone";
104
+ const available = weights?.available ?? [defaultWeight];
105
+
106
+ const WEIGHT = arg("--weight", defaultWeight);
107
+ if (!available.includes(WEIGHT)) {
108
+ console.error(`Unknown weight '${WEIGHT}'. icon-map.json declares: ${available.join(", ")}.`);
109
+ console.error("Add it to `weights.available` and give each icon its name under `glyphs` first.");
110
+ process.exit(1);
111
+ }
99
112
 
100
- // A name that is not in the set does not throw at runtime - it renders an empty
101
- // square forever - so it has to fail here instead.
102
- const missing = Object.entries(icons).filter(([, spec]) => !solar[spec.solar]);
113
+ const isDefault = WEIGHT === defaultWeight;
114
+ // The default weight owns the root of the output so the package's main entry is
115
+ // unchanged by this ever growing; another weight gets a directory of its own.
116
+ const OUT = resolve(HERE, arg("--out", "src"), isDefault ? "" : WEIGHT);
117
+ const FACTORY_IMPORT = isDefault ? "../icon" : "../../icon";
118
+
119
+ /**
120
+ * What this icon is called in the requested weight.
121
+ *
122
+ * `glyph` is the default weight's name and every icon has one; another weight
123
+ * reads `glyphs[weight]`, which is written out per icon rather than derived by
124
+ * swapping a suffix - a set is free to name a weight's glyph anything at all,
125
+ * and guessing it produces an icon that silently renders nothing.
126
+ */
127
+ const glyphFor = (spec) => (isDefault ? spec.glyph : spec.glyphs?.[WEIGHT]);
128
+
129
+ const drawings = JSON.parse(readFileSync(SET, "utf8")).icons;
130
+
131
+ // A name that is missing, or not in the set, does not throw at runtime - it
132
+ // renders an empty square forever - so it has to fail here instead.
133
+ const unnamed = Object.entries(icons).filter(([, spec]) => !glyphFor(spec));
134
+ if (unnamed.length > 0) {
135
+ console.error(`${unnamed.length} icon(s) have no name for weight '${WEIGHT}':`);
136
+ for (const [name] of unnamed.slice(0, 10)) console.error(` ${name}`);
137
+ process.exit(1);
138
+ }
139
+ const missing = Object.entries(icons).filter(([, spec]) => !drawings[glyphFor(spec)]);
103
140
  if (missing.length > 0) {
104
141
  console.error("Names not present in the icon set:");
105
- for (const [name, spec] of missing.slice(0, 10)) console.error(` ${name} -> ${spec.solar}`);
142
+ for (const [name, spec] of missing.slice(0, 10)) console.error(` ${name} -> ${glyphFor(spec)}`);
106
143
  process.exit(1);
107
144
  }
108
145
 
109
- rmSync(OUT, { recursive: true, force: true });
146
+ rmSync(resolve(OUT, "icons"), { recursive: true, force: true });
147
+ rmSync(resolve(OUT, "categories"), { recursive: true, force: true });
110
148
  mkdirSync(resolve(OUT, "icons"), { recursive: true });
111
149
  mkdirSync(resolve(OUT, "categories"), { recursive: true });
112
150
 
113
- writeFileSync(resolve(OUT, "icon.tsx"), FACTORY, "utf8");
151
+ // One factory for every weight: it is the same component either way, so a second
152
+ // weight must not ship a second copy of it.
153
+ if (isDefault) writeFileSync(resolve(OUT, "icon.tsx"), FACTORY, "utf8");
114
154
 
115
155
  const names = Object.keys(icons).sort();
116
156
  const byCategory = new Map();
117
157
  let bytes = 0;
118
158
 
119
159
  for (const name of names) {
120
- const { solar: solarName, category } = icons[name];
121
- const body = solar[solarName].body;
160
+ const { category } = icons[name];
161
+ const glyph = glyphFor(icons[name]);
162
+ const body = drawings[glyph].body;
122
163
  bytes += body.length;
123
- const file = `// ${name} - ${solarName}. GENERATED, do not hand-edit.
124
- import { icon } from "../icon";
164
+ const file = `// ${name} - ${glyph}. GENERATED, do not hand-edit.
165
+ import { icon } from "${FACTORY_IMPORT}";
125
166
 
126
167
  export const ${name} = icon(${JSON.stringify(name)}, ${JSON.stringify(body)});
127
168
  `;
@@ -139,26 +180,28 @@ for (const category of categories) {
139
180
  }
140
181
 
141
182
  const index = [
142
- "// GENERATED, do not hand-edit. Run: node generate.mjs --solar <set.json>",
183
+ `// GENERATED, do not hand-edit. Run: node generate.mjs --set <set.json> --weight ${WEIGHT}`,
143
184
  "//",
144
185
  "// Import from the root for a single icon (the bundler keeps only that module),",
145
186
  "// or from a category when you want the group:",
146
187
  "// import { IconMapPin } from \"@<scope>/icons\";",
147
188
  "// import { IconMapPin } from \"@<scope>/icons/categories/map\";",
148
189
  "",
149
- "export type { Icon, IconProps } from \"./icon\";",
150
- "",
151
190
  ];
191
+ if (isDefault) index.push("export type { Icon, IconProps } from \"./icon\";", "");
152
192
  for (const name of names) index.push(`export { ${name} } from "./icons/${name}";`);
153
- index.push("", "// Brand marks keep their real logo: this set has none, and drawing one by hand");
154
- index.push("// is worse than shipping the real mark.", "export {");
155
- // No comma after the last entry: a trailing one in a named export list is a style
156
- // violation here, and the generator is the only thing that writes this file.
157
- const sortedBrands = [...brands].sort();
158
- for (const [position, brand] of sortedBrands.entries()) {
159
- index.push(` ${brand}${position === sortedBrands.length - 1 ? "" : ","}`);
193
+ if (isDefault) {
194
+ index.push("", "// Brand marks keep their real logo: this set has none, and drawing one by hand");
195
+ index.push("// is worse than shipping the real mark.", "export {");
196
+ // No comma after the last entry: a trailing one in a named export list is a style
197
+ // violation here, and the generator is the only thing that writes this file.
198
+ const sortedBrands = [...brands].sort();
199
+ for (const [position, brand] of sortedBrands.entries()) {
200
+ index.push(` ${brand}${position === sortedBrands.length - 1 ? "" : ","}`);
201
+ }
202
+ index.push("} from \"@tabler/icons-react\";");
160
203
  }
161
- index.push("} from \"@tabler/icons-react\";", "");
204
+ index.push("");
162
205
  writeFileSync(resolve(OUT, "index.ts"), index.join("\n"), "utf8");
163
206
 
164
207
  // A namespace export needs an identifier, so the category's display name becomes
@@ -170,6 +213,7 @@ for (const category of categories) {
170
213
  }
171
214
  writeFileSync(resolve(OUT, "categories", "index.ts"), `${catIndex.join("\n")}\n`, "utf8");
172
215
 
216
+ const brandNote = isDefault ? `, ${brands.length} brand re-exports` : "";
173
217
  process.stdout.write(
174
- `${names.length} icons in ${categories.length} categories, ${brands.length} brand re-exports, ${(bytes / 1024).toFixed(0)} KB of bodies\n`,
218
+ `${WEIGHT}: ${names.length} icons in ${categories.length} categories${brandNote}, ${(bytes / 1024).toFixed(0)} KB of bodies\n`,
175
219
  );