@enigmax/icons 0.1.1 → 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/README.md CHANGED
@@ -3,9 +3,6 @@
3
3
  Icons for React. 391 icons in 36 categories, each in its own module, so a
4
4
  bundle carries only the ones it renders.
5
5
 
6
- Bold duotone is the weight that ships today. The package is built to hold more
7
- than one, so nothing in its name, its exports or its API is tied to that weight.
8
-
9
6
  ```tsx
10
7
  import { IconMapPin } from "@enigmax/icons";
11
8
 
@@ -13,10 +10,9 @@ import { IconMapPin } from "@enigmax/icons";
13
10
  ```
14
11
 
15
12
  The icons are filled, not stroked: colour comes from the surrounding text
16
- colour (`currentColor`), and the lighter half of each glyph is the same colour
17
- at 50% opacity. There is no line to widen, so `stroke` is accepted and ignored
18
- rather than forwarded into the SVG attribute of that name, which takes a colour
19
- 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.
20
16
 
21
17
  ## Only what you use
22
18
 
@@ -36,13 +32,44 @@ import * as icons from "@enigmax/icons/categories";
36
32
  Categories come from the set's own taxonomy: `arrows`, `devices`, `map`,
37
33
  `security`, `ui`, `weather`, and thirty more.
38
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
+
39
66
  ## Conventions
40
67
 
41
68
  An external link is marked with the **diagonal arrow pointing up and to the
42
69
  right** (`IconExternalLink`), not the box-with-an-arrow-leaving-it glyph: the
43
70
  latter reads as clutter at small sizes in a filled set.
44
71
 
45
- Brand marks (`IconBrandGithub`, `IconBrandDiscord`, ) are re-exported from
72
+ Brand marks (`IconBrandGithub`, `IconBrandDiscord`, ...) are re-exported from
46
73
  `@tabler/icons-react`, an optional peer dependency. This set has no brand
47
74
  marks, and drawing a company's logo by hand is worse than shipping the real
48
75
  one. Install `@tabler/icons-react` only if you import a brand mark.
@@ -50,12 +77,12 @@ one. Install `@tabler/icons-react` only if you import a brand mark.
50
77
  ## Regenerating
51
78
 
52
79
  ```sh
53
- node generate.mjs --set <path/to/set.json>
80
+ node generate.mjs --set <path/to/set.json> [--weight <name>]
54
81
  ```
55
82
 
56
83
  Edit `icon-map.json` to add, remove or re-point an icon, then regenerate. A
57
- name that is not in the set fails the run rather than rendering an empty square
58
- 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.
59
86
 
60
87
  ## Licence
61
88
 
package/generate.mjs CHANGED
@@ -8,7 +8,17 @@
8
8
  // fetched at install time - the bodies are written here as plain strings.
9
9
  //
10
10
  // Usage:
11
- // node generate.mjs --set <path/to/icons.json> [--out src]
11
+ // node generate.mjs --set <path/to/icons.json> [--weight <name>] [--out src]
12
+ //
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.
18
+ //
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>/`.
12
22
  //
13
23
  // The set is a build-time input, not a dependency of the published package: it
14
24
  // is read here and the ~400 bodies the map names are copied out. The full set
@@ -25,10 +35,9 @@ const arg = (flag, fallback) => {
25
35
  return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
26
36
  };
27
37
 
28
- const OUT = resolve(HERE, arg("--out", "src"));
29
38
  const SET = arg("--set", null);
30
39
  if (!SET || !existsSync(SET)) {
31
- console.error("usage: node generate.mjs --set <path/to/icons.json> [--out src]");
40
+ console.error("usage: node generate.mjs --set <path/to/icons.json> [--weight <name>] [--out src]");
32
41
  process.exit(1);
33
42
  }
34
43
 
@@ -48,7 +57,7 @@ export type IconProps = {
48
57
  * Accepted and ignored. These icons are filled, not stroked, so there is no
49
58
  * line to widen - and forwarding a number into the SVG \`stroke\` attribute,
50
59
  * which takes a COLOUR, paints the icon with an invalid value until it
51
- * 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
52
61
  * without touching the call sites that pass it.
53
62
  */
54
63
  stroke?: number | string;
@@ -65,8 +74,8 @@ export type Icon = ForwardRefExoticComponent<IconProps & RefAttributes<SVGSVGEle
65
74
  *
66
75
  * The body is a constant written by the generator, never anything a caller
67
76
  * supplies, which is what makes dangerouslySetInnerHTML the right tool here
68
- * rather than a hole. Both paths of a duotone glyph carry fill="currentColor",
69
- * 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.
70
79
  */
71
80
  export function icon(name: string, body: string): Icon {
72
81
  const Component = forwardRef<SVGSVGElement, IconProps>(
@@ -90,34 +99,70 @@ export function icon(name: string, body: string): Icon {
90
99
  }
91
100
  `;
92
101
 
93
- const { icons, brands } = JSON.parse(readFileSync(MAP, "utf8"));
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
+ }
112
+
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
+
94
129
  const drawings = JSON.parse(readFileSync(SET, "utf8")).icons;
95
130
 
96
- // A name that is not in the set does not throw at runtime - it renders an empty
97
- // square forever - so it has to fail here instead.
98
- const missing = Object.entries(icons).filter(([, spec]) => !drawings[spec.glyph]);
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)]);
99
140
  if (missing.length > 0) {
100
141
  console.error("Names not present in the icon set:");
101
- for (const [name, spec] of missing.slice(0, 10)) console.error(` ${name} -> ${spec.glyph}`);
142
+ for (const [name, spec] of missing.slice(0, 10)) console.error(` ${name} -> ${glyphFor(spec)}`);
102
143
  process.exit(1);
103
144
  }
104
145
 
105
- rmSync(OUT, { recursive: true, force: true });
146
+ rmSync(resolve(OUT, "icons"), { recursive: true, force: true });
147
+ rmSync(resolve(OUT, "categories"), { recursive: true, force: true });
106
148
  mkdirSync(resolve(OUT, "icons"), { recursive: true });
107
149
  mkdirSync(resolve(OUT, "categories"), { recursive: true });
108
150
 
109
- 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");
110
154
 
111
155
  const names = Object.keys(icons).sort();
112
156
  const byCategory = new Map();
113
157
  let bytes = 0;
114
158
 
115
159
  for (const name of names) {
116
- const { glyph, category } = icons[name];
160
+ const { category } = icons[name];
161
+ const glyph = glyphFor(icons[name]);
117
162
  const body = drawings[glyph].body;
118
163
  bytes += body.length;
119
164
  const file = `// ${name} - ${glyph}. GENERATED, do not hand-edit.
120
- import { icon } from "../icon";
165
+ import { icon } from "${FACTORY_IMPORT}";
121
166
 
122
167
  export const ${name} = icon(${JSON.stringify(name)}, ${JSON.stringify(body)});
123
168
  `;
@@ -135,26 +180,28 @@ for (const category of categories) {
135
180
  }
136
181
 
137
182
  const index = [
138
- "// GENERATED, do not hand-edit. Run: node generate.mjs --set <set.json>",
183
+ `// GENERATED, do not hand-edit. Run: node generate.mjs --set <set.json> --weight ${WEIGHT}`,
139
184
  "//",
140
185
  "// Import from the root for a single icon (the bundler keeps only that module),",
141
186
  "// or from a category when you want the group:",
142
187
  "// import { IconMapPin } from \"@<scope>/icons\";",
143
188
  "// import { IconMapPin } from \"@<scope>/icons/categories/map\";",
144
189
  "",
145
- "export type { Icon, IconProps } from \"./icon\";",
146
- "",
147
190
  ];
191
+ if (isDefault) index.push("export type { Icon, IconProps } from \"./icon\";", "");
148
192
  for (const name of names) index.push(`export { ${name} } from "./icons/${name}";`);
149
- index.push("", "// Brand marks keep their real logo: this set has none, and drawing one by hand");
150
- index.push("// is worse than shipping the real mark.", "export {");
151
- // No comma after the last entry: a trailing one in a named export list is a style
152
- // violation here, and the generator is the only thing that writes this file.
153
- const sortedBrands = [...brands].sort();
154
- for (const [position, brand] of sortedBrands.entries()) {
155
- 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\";");
156
203
  }
157
- index.push("} from \"@tabler/icons-react\";", "");
204
+ index.push("");
158
205
  writeFileSync(resolve(OUT, "index.ts"), index.join("\n"), "utf8");
159
206
 
160
207
  // A namespace export needs an identifier, so the category's display name becomes
@@ -166,6 +213,7 @@ for (const category of categories) {
166
213
  }
167
214
  writeFileSync(resolve(OUT, "categories", "index.ts"), `${catIndex.join("\n")}\n`, "utf8");
168
215
 
216
+ const brandNote = isDefault ? `, ${brands.length} brand re-exports` : "";
169
217
  process.stdout.write(
170
- `${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`,
171
219
  );
package/icon-map.json CHANGED
@@ -1,4 +1,8 @@
1
1
  {
2
+ "weights": {
3
+ "default": "bold-duotone",
4
+ "available": ["bold-duotone"]
5
+ },
2
6
  "icons": {
3
7
  "IconAbc": {
4
8
  "glyph": "text-bold-duotone",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/icons",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Icon set for React, one module per icon so a bundle carries only what it renders.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,6 +21,22 @@
21
21
  "./categories/*": {
22
22
  "types": "./dist/categories/*.d.ts",
23
23
  "import": "./dist/categories/*.js"
24
+ },
25
+ "./bold-duotone": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ },
29
+ "./bold-duotone/icons/*": {
30
+ "types": "./dist/icons/*.d.ts",
31
+ "import": "./dist/icons/*.js"
32
+ },
33
+ "./bold-duotone/categories": {
34
+ "types": "./dist/categories/index.d.ts",
35
+ "import": "./dist/categories/index.js"
36
+ },
37
+ "./bold-duotone/categories/*": {
38
+ "types": "./dist/categories/*.d.ts",
39
+ "import": "./dist/categories/*.js"
24
40
  }
25
41
  },
26
42
  "files": [