@mrmeg/expo-ui 0.28.0 → 0.29.1

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.
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * expo-ui-icons — compile an app's icon list into glyph data for
4
+ * `@mrmeg/expo-ui`'s `Icon`.
5
+ *
6
+ * `Icon` draws plain glyph data with renderers the package owns. The package
7
+ * registers the names its own components use; an app lists every other Lucide
8
+ * name it draws in `client/icons.json` and this CLI writes one module with
9
+ * those glyphs (`appIcons`) and the `IconRegistry` augmentation that types
10
+ * them. The root layout calls `registerIcons(appIcons)` once at module scope.
11
+ * `tabs` rasterizes the `tabIcons` subset to template PNGs for a native tab
12
+ * bar.
13
+ *
14
+ * The glyphs come from the Lucide snapshot the package ships
15
+ * (`dist/icons/lucide.json`, ISC, see `dist/icons/LICENSE.lucide`), so adding
16
+ * a name needs no package release. Node >= 20, no dependencies.
17
+ *
18
+ * Usage: expo-ui-icons [generate|add|init|tabs] [options]; see --help.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { dirname, join, relative, resolve, sep } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const USAGE = `
28
+ Usage:
29
+ expo-ui-icons [generate] [--check] Write the glyph module from the manifest
30
+ expo-ui-icons add <name...> Add Lucide names to the manifest and regenerate
31
+ expo-ui-icons init [--force] Seed the manifest from names used in app/, client/, shared/
32
+ expo-ui-icons tabs [--check] Rasterize tabIcons to template PNGs (needs rsvg-convert)
33
+
34
+ Options:
35
+ --manifest <path> Icon list (default client/icons.json): { "icons": [...], "tabIcons": [...] }
36
+ --out <path> Generated glyph module (default client/icons.generated.ts)
37
+ --module <spec> Module to import types from and augment (default @mrmeg/expo-ui/components/Icon)
38
+ --assets <dir> Tab icon PNGs and manifest.json (default assets/icons/tabs)
39
+ --tabs-out <path> Generated tab icon module (default client/tabIcons.generated.ts)
40
+ --check Exit 1 if the output is missing or stale; write nothing
41
+ --force Let init overwrite an existing manifest
42
+ -h, --help Usage info
43
+
44
+ Environment:
45
+ RSVG_CONVERT Path to rsvg-convert; when set, the only rasterizer tried
46
+ `;
47
+
48
+ const COMMANDS = new Set(["generate", "add", "init", "tabs"]);
49
+ const VALUE_OPTIONS = new Set(["--manifest", "--out", "--module", "--assets", "--tabs-out"]);
50
+ const FLAG_OPTIONS = new Set(["--check", "--force", "-h", "--help"]);
51
+
52
+ class CliError extends Error {}
53
+
54
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
55
+
56
+ function fail(message) {
57
+ throw new CliError(message);
58
+ }
59
+
60
+ /** Paths in messages and headers: relative to the working directory, `/`-separated. */
61
+ function label(path) {
62
+ return relative(process.cwd(), path).split(sep).join("/") || ".";
63
+ }
64
+
65
+ function parseArgs(argv) {
66
+ const options = {
67
+ manifest: "client/icons.json",
68
+ out: "client/icons.generated.ts",
69
+ module: "@mrmeg/expo-ui/components/Icon",
70
+ assets: "assets/icons/tabs",
71
+ "tabs-out": "client/tabIcons.generated.ts",
72
+ };
73
+ const flags = new Set();
74
+ const positional = [];
75
+ for (let index = 0; index < argv.length; index += 1) {
76
+ const arg = argv[index];
77
+ const [key, inlineValue] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
78
+ if (VALUE_OPTIONS.has(key)) {
79
+ const value = inlineValue ?? argv[(index += 1)];
80
+ if (!value || value.startsWith("--")) fail(`${key} needs a value`);
81
+ options[key.slice(2)] = value;
82
+ } else if (FLAG_OPTIONS.has(key) && inlineValue === undefined) {
83
+ flags.add(key);
84
+ } else if (arg.startsWith("-")) {
85
+ fail(`unknown option ${arg}`);
86
+ } else {
87
+ positional.push(arg);
88
+ }
89
+ }
90
+ const command = COMMANDS.has(positional[0]) ? positional.shift() : "generate";
91
+ return { command, names: positional, options, flags };
92
+ }
93
+
94
+ // --- Snapshot ------------------------------------------------------------------
95
+
96
+ const binDir = dirname(fileURLToPath(import.meta.url));
97
+
98
+ /**
99
+ * The published package ships `dist/icons/lucide.json`. In the package's own
100
+ * repository `src/icons/lucide.json` is the source of truth (a `dist` there may
101
+ * predate a snapshot bump), so it wins when present.
102
+ */
103
+ function loadSnapshot() {
104
+ const candidates = [join(binDir, "..", "src", "icons", "lucide.json"), join(binDir, "..", "dist", "icons", "lucide.json")];
105
+ const path = candidates.find((candidate) => existsSync(candidate));
106
+ if (!path) fail(`no Lucide snapshot next to this CLI (looked for ${candidates.join(", ")}); reinstall @mrmeg/expo-ui`);
107
+ const snapshot = JSON.parse(readFileSync(path, "utf8"));
108
+ if (typeof snapshot.lucide !== "string" || !snapshot.icons || !snapshot.aliases) fail(`${path} is not a Lucide snapshot`);
109
+ return snapshot;
110
+ }
111
+
112
+ // --- Manifest ------------------------------------------------------------------
113
+
114
+ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
115
+
116
+ function readManifest(path, { allowMissing = false } = {}) {
117
+ if (!existsSync(path)) {
118
+ if (allowMissing) return { icons: [], tabIcons: [] };
119
+ fail(`${label(path)} not found. Run \`expo-ui-icons init\` to seed it, or \`expo-ui-icons add <name>\`.`);
120
+ }
121
+ let manifest;
122
+ try {
123
+ manifest = JSON.parse(readFileSync(path, "utf8"));
124
+ } catch (error) {
125
+ fail(`${label(path)} is not valid JSON: ${error.message}`);
126
+ }
127
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) fail(`${label(path)} must be an object: { "icons": [...], "tabIcons": [...] }`);
128
+ const extra = Object.keys(manifest).filter((key) => key !== "icons" && key !== "tabIcons");
129
+ if (extra.length > 0) fail(`${label(path)} has unknown key(s) ${extra.join(", ")}; expected "icons" and "tabIcons"`);
130
+ const icons = manifest.icons ?? [];
131
+ const tabIcons = manifest.tabIcons ?? [];
132
+ for (const [key, list] of [["icons", icons], ["tabIcons", tabIcons]]) {
133
+ if (!Array.isArray(list) || list.some((name) => typeof name !== "string")) fail(`${label(path)}: "${key}" must be an array of strings`);
134
+ }
135
+ return { icons, tabIcons };
136
+ }
137
+
138
+ /** Every problem with one name list: shape, order, duplicates, and names outside the snapshot. */
139
+ function listProblems(list, key, snapshot) {
140
+ const problems = [];
141
+ const duplicates = [...new Set(list.filter((name, index) => list.indexOf(name) !== index))];
142
+ if (duplicates.length > 0) problems.push(`"${key}" lists the same name twice: ${duplicates.join(", ")}`);
143
+ const sorted = [...list].sort();
144
+ const firstOutOfOrder = list.find((name, index) => name !== sorted[index]);
145
+ if (firstOutOfOrder !== undefined) problems.push(`"${key}" must be sorted (first out-of-order entry: "${firstOutOfOrder}")`);
146
+ for (const name of list) {
147
+ if (name in snapshot.icons) continue;
148
+ if (snapshot.aliases[name]) {
149
+ problems.push(`"${name}" is an alias; use the canonical name: ${name} → ${snapshot.aliases[name]}`);
150
+ } else if (!KEBAB.test(name)) {
151
+ problems.push(`"${name}" is not a kebab-case icon name`);
152
+ } else {
153
+ problems.push(`"${name}" is not a Lucide ${snapshot.lucide} icon (search https://lucide.dev/icons)`);
154
+ }
155
+ }
156
+ return problems;
157
+ }
158
+
159
+ function validateManifest(manifest, path, snapshot) {
160
+ const problems = [...listProblems(manifest.icons, "icons", snapshot), ...listProblems(manifest.tabIcons, "tabIcons", snapshot)];
161
+ if (problems.length > 0) fail(`${label(path)}:\n ${problems.join("\n ")}`);
162
+ }
163
+
164
+ function writeManifest(path, manifest) {
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ writeFileSync(path, `${JSON.stringify({ icons: manifest.icons, tabIcons: manifest.tabIcons }, null, 2)}\n`);
167
+ }
168
+
169
+ // --- Glyph module ----------------------------------------------------------------
170
+
171
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
172
+ const propertyKey = (key) => (IDENTIFIER.test(key) ? key : JSON.stringify(key));
173
+
174
+ /** One glyph as a TypeScript literal: `[["path", { d: "…" }], …]`. */
175
+ function glyphLiteral(glyph) {
176
+ const nodes = glyph.map(([tag, attrs]) => {
177
+ const fields = Object.entries(attrs).map(([key, value]) => `${propertyKey(key)}: ${JSON.stringify(value)}`);
178
+ return `[${JSON.stringify(tag)}, { ${fields.join(", ")} }]`;
179
+ });
180
+ return `[${nodes.join(", ")}]`;
181
+ }
182
+
183
+ function renderGlyphModule(manifest, manifestPath, moduleName, snapshot) {
184
+ return [
185
+ "/* eslint-disable */",
186
+ `// GENERATED by \`bun run icons\` from ${label(manifestPath)} (lucide ${snapshot.lucide}). Do not edit.`,
187
+ `import type { IconGlyph } from ${JSON.stringify(moduleName)};`,
188
+ "",
189
+ "export const appIcons = {",
190
+ ...manifest.icons.map((name) => ` ${JSON.stringify(name)}: ${glyphLiteral(snapshot.icons[name])},`),
191
+ "} as const satisfies Record<string, IconGlyph>;",
192
+ "",
193
+ `declare module ${JSON.stringify(moduleName)} {`,
194
+ " interface IconRegistry {",
195
+ ...manifest.icons.map((name) => ` ${JSON.stringify(name)}: true;`),
196
+ " }",
197
+ "}",
198
+ "",
199
+ ].join("\n");
200
+ }
201
+
202
+ function generate({ options, flags }, snapshot) {
203
+ const manifestPath = resolve(options.manifest);
204
+ const outPath = resolve(options.out);
205
+ const manifest = readManifest(manifestPath);
206
+ validateManifest(manifest, manifestPath, snapshot);
207
+ const content = renderGlyphModule(manifest, manifestPath, options.module, snapshot);
208
+ const current = existsSync(outPath) ? readFileSync(outPath, "utf8") : null;
209
+ const summary = `${count(manifest.icons.length, "icon")}, lucide ${snapshot.lucide}`;
210
+
211
+ if (flags.has("--check")) {
212
+ if (current === content) {
213
+ console.log(`${label(outPath)} is up to date (${summary}).`);
214
+ return 0;
215
+ }
216
+ console.error(
217
+ `expo-ui-icons: ${label(outPath)} is ${current === null ? "missing" : "stale"}. Run \`bun run icons\` and commit the result.`
218
+ );
219
+ return 1;
220
+ }
221
+ if (current === content) {
222
+ console.log(`${label(outPath)} already up to date (${summary}).`);
223
+ } else {
224
+ mkdirSync(dirname(outPath), { recursive: true });
225
+ writeFileSync(outPath, content);
226
+ console.log(`Wrote ${label(outPath)} (${summary}).`);
227
+ }
228
+ return 0;
229
+ }
230
+
231
+ // --- add -------------------------------------------------------------------------
232
+
233
+ function add(args, snapshot) {
234
+ if (args.names.length === 0) fail("add needs at least one icon name: expo-ui-icons add archive bell");
235
+ const manifestPath = resolve(args.options.manifest);
236
+ const manifest = readManifest(manifestPath, { allowMissing: true });
237
+ validateManifest(manifest, manifestPath, snapshot);
238
+
239
+ const problems = [];
240
+ const added = [];
241
+ for (const requested of args.names) {
242
+ let name = requested;
243
+ if (!(name in snapshot.icons)) {
244
+ if (snapshot.aliases[name]) {
245
+ name = snapshot.aliases[name];
246
+ console.log(`"${requested}" is an alias: adding the canonical name (${requested} → ${name}).`);
247
+ } else {
248
+ problems.push(`"${requested}" is not a Lucide ${snapshot.lucide} icon (search https://lucide.dev/icons)`);
249
+ continue;
250
+ }
251
+ }
252
+ if (!manifest.icons.includes(name) && !added.includes(name)) added.push(name);
253
+ }
254
+ if (problems.length > 0) fail(problems.join("\n "));
255
+
256
+ manifest.icons = [...manifest.icons, ...added].sort();
257
+ writeManifest(manifestPath, manifest);
258
+ console.log(added.length > 0 ? `Added ${added.join(", ")} to ${label(manifestPath)}.` : `${label(manifestPath)} already lists them.`);
259
+ return generate({ ...args, flags: new Set() }, snapshot);
260
+ }
261
+
262
+ // --- init ------------------------------------------------------------------------
263
+
264
+ const SCAN_DIRS = ["app", "client", "shared"];
265
+ const SCAN_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
266
+ const SKIP_FILES = /(\.test\.|\.spec\.|\.generated\.ts$)/;
267
+ const SKIP_DIRS = new Set(["node_modules", "__tests__", "__mocks__"]);
268
+ const ICON_LITERAL = /\b(name|icon)\s*[=:]\s*\{?\s*"([a-z0-9-]+)"/g;
269
+
270
+ function sourceFiles(directory) {
271
+ if (!existsSync(directory)) return [];
272
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
273
+ const path = join(directory, entry.name);
274
+ if (entry.isDirectory()) return SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") ? [] : sourceFiles(path);
275
+ return SCAN_EXTENSIONS.test(entry.name) && !SKIP_FILES.test(entry.name) ? [path] : [];
276
+ });
277
+ }
278
+
279
+ function init(args, snapshot) {
280
+ const manifestPath = resolve(args.options.manifest);
281
+ let tabIcons = [];
282
+ if (existsSync(manifestPath)) {
283
+ if (!args.flags.has("--force")) fail(`${label(manifestPath)} already exists; pass --force to overwrite it.`);
284
+ try {
285
+ tabIcons = readManifest(manifestPath).tabIcons;
286
+ } catch {
287
+ tabIcons = [];
288
+ }
289
+ }
290
+
291
+ const files = SCAN_DIRS.flatMap((dir) => sourceFiles(resolve(dir)));
292
+ const found = new Set();
293
+ const aliasHits = new Map();
294
+ for (const file of files) {
295
+ for (const match of readFileSync(file, "utf8").matchAll(ICON_LITERAL)) {
296
+ const name = match[2];
297
+ if (name in snapshot.icons) found.add(name);
298
+ else if (snapshot.aliases[name]) aliasHits.set(name, snapshot.aliases[name]);
299
+ }
300
+ }
301
+
302
+ // An overwritten manifest keeps its tab icons, which must also be in "icons".
303
+ const manifest = { icons: [...new Set([...found, ...tabIcons])].sort(), tabIcons };
304
+ writeManifest(manifestPath, manifest);
305
+ console.log(`Seeded ${label(manifestPath)} with ${count(manifest.icons.length, "name")} found in ${count(files.length, "file")} under ${SCAN_DIRS.join("/, ")}/.`);
306
+ if (aliasHits.size > 0) {
307
+ console.log("Skipped alias names (if they are icons, use the canonical name and `expo-ui-icons add` it):");
308
+ for (const [alias, canonical] of aliasHits) console.log(` ${alias} → ${canonical}`);
309
+ }
310
+ const status = generate({ ...args, flags: new Set() }, snapshot);
311
+ console.log(
312
+ "Next: typecheck and `expo-ui-icons add` any name it reports (names built at runtime are not found by the scan), " +
313
+ "and call `registerIcons(appIcons)` at module scope in the root layout."
314
+ );
315
+ return status;
316
+ }
317
+
318
+ // --- tabs ------------------------------------------------------------------------
319
+
320
+ const TAB_SCALES = [
321
+ ["", 24],
322
+ ["@2x", 48],
323
+ ["@3x", 72],
324
+ ];
325
+
326
+ const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
327
+
328
+ /** A glyph as a standalone SVG document, black on transparent (tab bars tint it). */
329
+ function glyphSvg(glyph) {
330
+ const children = glyph
331
+ .map(([tag, attrs]) => `<${tag}${Object.entries(attrs).map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join("")}/>`)
332
+ .join("");
333
+ return (
334
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" ' +
335
+ `stroke-linecap="round" stroke-linejoin="round">${children}</svg>`
336
+ );
337
+ }
338
+
339
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
340
+
341
+ function renderTabModule(names, tabsOutPath, assetsDir, manifestPath, snapshot) {
342
+ let assetsSpecifier = relative(dirname(tabsOutPath), assetsDir).split(sep).join("/");
343
+ if (!assetsSpecifier.startsWith(".")) assetsSpecifier = `./${assetsSpecifier}`;
344
+ return [
345
+ "/* eslint-disable */",
346
+ `// GENERATED by \`bun run icons:tabs\` from ${label(manifestPath)} (lucide ${snapshot.lucide}). Do not edit.`,
347
+ "// Template PNGs of the tabIcons glyphs for a native tab bar, which tints them.",
348
+ "",
349
+ "export const TAB_ICONS = {",
350
+ ...names.map((name) => ` ${propertyKey(name)}: require(${JSON.stringify(`${assetsSpecifier}/${name}.png`)}),`),
351
+ "} as const;",
352
+ "",
353
+ "export type TabIconName = keyof typeof TAB_ICONS;",
354
+ "",
355
+ ].join("\n");
356
+ }
357
+
358
+ /**
359
+ * Explicit `RSVG_CONVERT` is authoritative; otherwise PATH and the Homebrew
360
+ * prefixes, like the template's brand-asset script.
361
+ */
362
+ function findRasterizer() {
363
+ const candidates = process.env.RSVG_CONVERT
364
+ ? [process.env.RSVG_CONVERT]
365
+ : ["rsvg-convert", "/opt/homebrew/bin/rsvg-convert", "/usr/local/bin/rsvg-convert"];
366
+ for (const candidate of candidates) {
367
+ try {
368
+ execFileSync(candidate, ["--version"], { stdio: "ignore" });
369
+ return candidate;
370
+ } catch {
371
+ // try the next one
372
+ }
373
+ }
374
+ return null;
375
+ }
376
+
377
+ function tabs({ options, flags }, snapshot) {
378
+ const manifestPath = resolve(options.manifest);
379
+ const assetsDir = resolve(options.assets);
380
+ const tabsOutPath = resolve(options["tabs-out"]);
381
+ const assetManifestPath = join(assetsDir, "manifest.json");
382
+ const manifest = readManifest(manifestPath);
383
+ validateManifest(manifest, manifestPath, snapshot);
384
+
385
+ const notListed = manifest.tabIcons.filter((name) => !manifest.icons.includes(name));
386
+ if (notListed.length > 0) {
387
+ fail(`${label(manifestPath)}: every "tabIcons" name must also be in "icons" (missing: ${notListed.join(", ")})`);
388
+ }
389
+
390
+ const svgs = Object.fromEntries(manifest.tabIcons.map((name) => [name, glyphSvg(snapshot.icons[name])]));
391
+ const expectedAssetManifest = `${JSON.stringify(
392
+ { lucide: snapshot.lucide, icons: Object.fromEntries(manifest.tabIcons.map((name) => [name, sha256(svgs[name])])) },
393
+ null,
394
+ 2
395
+ )}\n`;
396
+ const expectedModule = renderTabModule(manifest.tabIcons, tabsOutPath, assetsDir, manifestPath, snapshot);
397
+
398
+ // Freshness from hashes of the SVG source, never PNG bytes: rasterizers
399
+ // differ by version and platform, and CI has none.
400
+ const pngProblems = [];
401
+ const currentAssetManifest = existsSync(assetManifestPath) ? readFileSync(assetManifestPath, "utf8") : null;
402
+ let recorded = null;
403
+ try {
404
+ recorded = currentAssetManifest === null ? null : JSON.parse(currentAssetManifest);
405
+ } catch {
406
+ pngProblems.push(`${label(assetManifestPath)} is not valid JSON`);
407
+ }
408
+ if (currentAssetManifest === null) {
409
+ pngProblems.push(`${label(assetManifestPath)} is missing`);
410
+ } else if (recorded) {
411
+ if (recorded.lucide !== snapshot.lucide) {
412
+ pngProblems.push(`${label(assetManifestPath)} was rendered from lucide ${recorded.lucide}, the snapshot is ${snapshot.lucide}`);
413
+ }
414
+ const recordedIcons = recorded.icons ?? {};
415
+ for (const name of manifest.tabIcons) {
416
+ if (recordedIcons[name] !== sha256(svgs[name])) pngProblems.push(`${name}: hash mismatch (glyph changed or manifest edited)`);
417
+ }
418
+ for (const name of Object.keys(recordedIcons)) {
419
+ if (!manifest.tabIcons.includes(name)) pngProblems.push(`${name}: rendered but no longer in "tabIcons"`);
420
+ }
421
+ if (pngProblems.length === 0 && currentAssetManifest !== expectedAssetManifest) {
422
+ pngProblems.push(`${label(assetManifestPath)} is not in its generated form`);
423
+ }
424
+ }
425
+ for (const name of manifest.tabIcons) {
426
+ for (const [suffix] of TAB_SCALES) {
427
+ const png = join(assetsDir, `${name}${suffix}.png`);
428
+ if (!existsSync(png)) pngProblems.push(`${label(png)} is missing`);
429
+ }
430
+ }
431
+ const currentModule = existsSync(tabsOutPath) ? readFileSync(tabsOutPath, "utf8") : null;
432
+ const moduleStale = currentModule !== expectedModule;
433
+ const summary = `${count(manifest.tabIcons.length, "tab icon")}, lucide ${snapshot.lucide}`;
434
+
435
+ if (flags.has("--check")) {
436
+ if (pngProblems.length === 0 && !moduleStale) {
437
+ console.log(`Tab icons are up to date (${summary}).`);
438
+ return 0;
439
+ }
440
+ const problems = [...pngProblems];
441
+ if (moduleStale) problems.push(`${label(tabsOutPath)} is ${currentModule === null ? "missing" : "stale"}`);
442
+ console.error(`expo-ui-icons: tab icons are stale:\n ${problems.join("\n ")}\nRun \`bun run icons:tabs\` and commit the result.`);
443
+ return 1;
444
+ }
445
+
446
+ if (pngProblems.length === 0 && !moduleStale) {
447
+ console.log(`Tab icons are up to date (${summary}); nothing to rasterize.`);
448
+ return 0;
449
+ }
450
+
451
+ if (pngProblems.length > 0) {
452
+ const rasterizer = findRasterizer();
453
+ if (!rasterizer) {
454
+ fail(
455
+ `tab icons need rasterizing (${pngProblems[0]}${pngProblems.length > 1 ? `, and ${pngProblems.length - 1} more` : ""}), ` +
456
+ "but rsvg-convert was not found. Install librsvg (brew install librsvg) or set RSVG_CONVERT."
457
+ );
458
+ }
459
+ mkdirSync(assetsDir, { recursive: true });
460
+ const scratch = mkdtempSync(join(tmpdir(), "expo-ui-icons-"));
461
+ try {
462
+ for (const name of manifest.tabIcons) {
463
+ const svgPath = join(scratch, `${name}.svg`);
464
+ writeFileSync(svgPath, svgs[name]);
465
+ for (const [suffix, px] of TAB_SCALES) {
466
+ execFileSync(rasterizer, ["-w", String(px), "-h", String(px), "-o", join(assetsDir, `${name}${suffix}.png`), svgPath], {
467
+ stdio: "inherit",
468
+ });
469
+ }
470
+ console.log(`Rendered ${name} at ${TAB_SCALES.map(([, px]) => `${px}px`).join(", ")}`);
471
+ }
472
+ } finally {
473
+ rmSync(scratch, { recursive: true, force: true });
474
+ }
475
+ for (const name of Object.keys(recorded?.icons ?? {})) {
476
+ if (manifest.tabIcons.includes(name)) continue;
477
+ for (const [suffix] of TAB_SCALES) rmSync(join(assetsDir, `${name}${suffix}.png`), { force: true });
478
+ console.log(`Removed ${name} PNGs (no longer in "tabIcons")`);
479
+ }
480
+ writeFileSync(assetManifestPath, expectedAssetManifest);
481
+ console.log(`Wrote ${label(assetManifestPath)}`);
482
+ }
483
+ if (moduleStale) {
484
+ mkdirSync(dirname(tabsOutPath), { recursive: true });
485
+ writeFileSync(tabsOutPath, expectedModule);
486
+ console.log(`Wrote ${label(tabsOutPath)} (${summary}).`);
487
+ }
488
+ return 0;
489
+ }
490
+
491
+ // --- main ------------------------------------------------------------------------
492
+
493
+ try {
494
+ const args = parseArgs(process.argv.slice(2));
495
+ if (args.flags.has("-h") || args.flags.has("--help")) {
496
+ console.log(USAGE);
497
+ process.exit(0);
498
+ }
499
+ if (args.command !== "add" && args.names.length > 0) fail(`unexpected argument(s) ${args.names.join(" ")}`);
500
+ if (args.flags.has("--check") && (args.command === "add" || args.command === "init")) fail(`--check does not apply to ${args.command}`);
501
+ const snapshot = loadSnapshot();
502
+ const handlers = { generate, add, init, tabs };
503
+ process.exit(handlers[args.command](args, snapshot));
504
+ } catch (error) {
505
+ if (!(error instanceof CliError)) throw error;
506
+ console.error(`expo-ui-icons: ${error.message}`);
507
+ console.error("Run expo-ui-icons --help for usage.");
508
+ process.exit(1);
509
+ }
@@ -1,14 +1,40 @@
1
1
  import * as React from "react";
2
- import { type StyleProp, type TextStyle, type ViewProps, type ViewStyle } from "react-native";
2
+ import { type StyleProp, type TextStyle } from "react-native";
3
3
  import type { ThemeColors } from "../constants/colors.js";
4
- import { type IconName } from "./iconRegistry.generated.js";
4
+ import { type BuiltinIconName } from "./iconBuiltins.generated.js";
5
+ import type { IconComponent, IconGlyph } from "./iconTypes.js";
6
+ export type { IconAccessibilityProps, IconComponent, IconGlyph, IconTag } from "./iconTypes.js";
5
7
  /**
6
- * Kebab-case Lucide icon names the package bundles, served from
7
- * `icon-names.json` through the generated registry. Add a name there and run
8
- * `bun run ui:icons` to extend the union; pass any other Lucide component
9
- * through `component` without touching the registry.
8
+ * Every name `Icon` accepts, as keys. The package registers the names its own
9
+ * components draw (`icon-names.json`); an app adds the rest by listing them in
10
+ * `client/icons.json` and running `expo-ui-icons`, whose generated module
11
+ * augments this interface and exports the glyphs for `registerIcons`:
12
+ *
13
+ * ```ts
14
+ * declare module "@mrmeg/expo-ui/components/Icon" {
15
+ * interface IconRegistry { "archive": true }
16
+ * }
17
+ * ```
18
+ */
19
+ export interface IconRegistry extends Record<BuiltinIconName, true> {
20
+ }
21
+ /**
22
+ * Kebab-case Lucide names `Icon` can draw: the package builtins plus every
23
+ * name the app registered. An unregistered literal is a type error.
24
+ */
25
+ export type IconName = Extract<keyof IconRegistry, string>;
26
+ /**
27
+ * Make glyphs drawable by name. Call it once at module scope in the root
28
+ * layout with the module `expo-ui-icons` generates
29
+ * (`registerIcons(appIcons)`), so it has run before any route renders, on the
30
+ * server too. Idempotent; a later call wins for a name it repeats.
31
+ */
32
+ export declare function registerIcons(icons: Readonly<Record<string, IconGlyph>>): void;
33
+ /**
34
+ * Whether a runtime value (a database field, a server payload) names a
35
+ * registered icon. Narrows it to `IconName` for `<Icon name>`.
10
36
  */
11
- export type { IconName };
37
+ export declare function isIconName(value: unknown): value is IconName;
12
38
  /**
13
39
  * Theme color names that can be used as shortcuts.
14
40
  *
@@ -37,30 +63,35 @@ type IconBaseProps = {
37
63
  * which would otherwise announce an unnamed image.
38
64
  */
39
65
  accessibilityLabel?: string;
66
+ /** Test id for a named icon's SVG root. Defaults to `icon-<name>` in development. */
67
+ testID?: string;
40
68
  };
41
- type IconAccessibilityProps = Pick<ViewProps, "accessible" | "importantForAccessibility" | "accessibilityElementsHidden" | "accessibilityLabel" | "aria-hidden" | "aria-label" | "role">;
42
- /**
43
- * Props handed to a `component`. Wide enough that any `lucide-react-native`
44
- * component type-checks as-is (`LucideProps` extends `SvgProps`, whose `style`
45
- * is a view style), and any component that accepts `size` and `color` works.
46
- */
47
- type CustomIconComponentProps = {
48
- size: number;
49
- color: string;
50
- style?: StyleProp<ViewStyle>;
51
- } & Partial<IconAccessibilityProps>;
52
- type RegistryIconProps = IconBaseProps & {
53
- /** The icon name to render (a Lucide name from the package registry) */
69
+ export type NamedIconProps = IconBaseProps & {
70
+ /** A registered icon name: a package builtin or one listed in the app's `client/icons.json`. */
54
71
  name: IconName;
55
72
  component?: never;
56
73
  };
57
- type CustomIconProps = IconBaseProps & {
74
+ export type IconProps = NamedIconProps | (IconBaseProps & {
58
75
  name?: never;
59
76
  /**
60
- * Custom component to render instead of a registry icon. Receives size and
61
- * color as props — any `lucide-react-native` icon import works here.
77
+ * A component to render instead of a registered glyph, for art that is
78
+ * not a Lucide icon. Receives the resolved size, color, style, and
79
+ * accessibility props, exactly like a named icon.
62
80
  */
63
- component: React.ComponentType<CustomIconComponentProps>;
64
- };
65
- export type IconProps = RegistryIconProps | CustomIconProps;
81
+ component: IconComponent;
82
+ });
83
+ /**
84
+ * Universal Icon Component
85
+ * Draws a registered glyph as SVG with theme integration and style support:
86
+ * an inline `<svg>` on web and the server, `react-native-svg` on iOS and
87
+ * Android. No font to load, and server markup carries the glyph.
88
+ *
89
+ * Usage:
90
+ * ```tsx
91
+ * <Icon name="check" color="primary" size={16} />
92
+ * <Icon name="calendar" color="#FF0000" size={24} />
93
+ * <Icon name="terminal" style={{ marginRight: 8 }} />
94
+ * <Icon component={BrandMark} color="accent" /> // non-Lucide art
95
+ * ```
96
+ */
66
97
  export declare function Icon(props: IconProps): React.JSX.Element | null;