@tamagui/cli 3.0.0-beta.1479.1 → 3.0.0-beta.1537.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.
package/dist/cli.cjs CHANGED
@@ -169,6 +169,37 @@ const COMMAND_MAP = {
169
169
  await installGeneratedPackage(type, path);
170
170
  }
171
171
  },
172
+ icons: {
173
+ shorthands: [],
174
+ description: `Generate themed SVG icon components from Lucide, Heroicons, Phosphor, or a folder of svgs.`,
175
+ usage: `$ tamagui icons add Search ChevronDown
176
+ $ tamagui icons add Copy --from phosphor --weight bold
177
+ $ tamagui icons add Clipboard --from heroicons --variant solid
178
+ $ tamagui icons add Logo --from ./assets/icons --out src/icons
179
+ $ tamagui icons add Search=magnifying-glass --from phosphor`,
180
+ flags: {
181
+ "--help": Boolean,
182
+ "--from": String,
183
+ "--weight": String,
184
+ "--variant": String,
185
+ "--out": String
186
+ },
187
+ async run() {
188
+ const { _, ...flags2 } = (0, import_arg.default)(this.flags);
189
+ const [_cmd, subcommand, ...names] = _;
190
+ if (subcommand !== "add") {
191
+ throw new Error("usage: tamagui icons add <Name...> [--from] [--out]");
192
+ }
193
+ const { addIcons } = require("./icons.cjs");
194
+ await addIcons({
195
+ names,
196
+ from: flags2["--from"],
197
+ weight: flags2["--weight"],
198
+ variant: flags2["--variant"],
199
+ out: flags2["--out"]
200
+ });
201
+ }
202
+ },
172
203
  build: {
173
204
  shorthands: ["b"],
174
205
  description: `Use to pre-build a Tamagui component directory. Use -- to run a command after optimization, then auto-restore files.`,
@@ -321,6 +352,30 @@ $ tamagui migrate --from v1`,
321
352
  output: flags2["--output"]
322
353
  });
323
354
  }
355
+ },
356
+ "preview-theme": {
357
+ shorthands: ["preview"],
358
+ description: `Preview a theme configuration in the browser on tamagui.dev/theme`,
359
+ usage: `$ tamagui preview-theme ./theme.json
360
+ $ tamagui preview-theme --theme '{"palettes": ...}'
361
+ $ tamagui preview-theme ./theme.json --print`,
362
+ flags: {
363
+ "--help": Boolean,
364
+ "--theme": String,
365
+ "--url": String,
366
+ "--print": Boolean
367
+ },
368
+ async run() {
369
+ const { _, ...flags2 } = (0, import_arg.default)(this.flags);
370
+ const [_cmd, themeArg] = _;
371
+ const { previewTheme } = require("./preview-theme.cjs");
372
+ await previewTheme({
373
+ theme: flags2["--theme"],
374
+ themeArg,
375
+ url: flags2["--url"],
376
+ print: flags2["--print"]
377
+ });
378
+ }
324
379
  }
325
380
  };
326
381
  const commandEntries = Object.keys(COMMAND_MAP).flatMap((command2) => {
package/dist/icons.cjs ADDED
@@ -0,0 +1,263 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all) __defProp(target, name, {
9
+ get: all[name],
10
+ enumerable: true
11
+ });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
16
+ get: () => from[key],
17
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
18
+ });
19
+ }
20
+ return to;
21
+ };
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
23
+ value: mod,
24
+ enumerable: true
25
+ }) : target, mod));
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var icons_exports = {};
28
+ __export(icons_exports, {
29
+ HEROICONS_VERSION: () => HEROICONS_VERSION,
30
+ LUCIDE_VERSION: () => LUCIDE_VERSION,
31
+ PHOSPHOR_VERSION: () => PHOSPHOR_VERSION,
32
+ addIcons: () => addIcons,
33
+ svgToComponent: () => svgToComponent
34
+ });
35
+ module.exports = __toCommonJS(icons_exports);
36
+ var import_node_fs = require("node:fs");
37
+ var import_promises = require("node:fs/promises");
38
+ var import_node_path = __toESM(require("node:path"));
39
+ var import_chalk = __toESM(require("chalk"));
40
+ const LUCIDE_VERSION = "1.49.0";
41
+ const HEROICONS_VERSION = "2.2.0";
42
+ const PHOSPHOR_VERSION = "2.1.1";
43
+ const libraries = [
44
+ "lucide",
45
+ "heroicons",
46
+ "phosphor"
47
+ ];
48
+ const phosphorWeights = [
49
+ "regular",
50
+ "bold",
51
+ "duotone",
52
+ "fill",
53
+ "light",
54
+ "thin"
55
+ ];
56
+ const heroiconsDirs = {
57
+ outline: "24/outline",
58
+ solid: "24/solid",
59
+ mini: "20/solid",
60
+ micro: "16/solid"
61
+ };
62
+ async function addIcons(options) {
63
+ const cwd = options.cwd ?? process.cwd();
64
+ const from = options.from ?? "lucide";
65
+ const outDir = import_node_path.default.resolve(cwd, options.out ?? "components/icons");
66
+ const localDir = libraries.includes(from) ? null : import_node_path.default.resolve(cwd, from);
67
+ if (localDir && !((0, import_node_fs.existsSync)(localDir) && (0, import_node_fs.statSync)(localDir).isDirectory())) {
68
+ throw new Error(`--from must be ${libraries.join(", ")}, or a directory of svg files (got "${from}")`);
69
+ }
70
+ if (!options.names.length) {
71
+ throw new Error("give the icons to add, e.g. `tamagui icons add Search ChevronDown`");
72
+ }
73
+ if (options.weight && from !== "phosphor") {
74
+ throw new Error("--weight applies to phosphor only");
75
+ }
76
+ if (options.weight && !phosphorWeights.includes(options.weight)) {
77
+ throw new Error(`--weight is one of ${phosphorWeights.join(", ")}`);
78
+ }
79
+ if (options.variant && from !== "heroicons") {
80
+ throw new Error("--variant applies to heroicons only");
81
+ }
82
+ if (options.variant && !heroiconsDirs[options.variant]) {
83
+ throw new Error(`--variant is one of ${Object.keys(heroiconsDirs).join(", ")}`);
84
+ }
85
+ const icons = [...new Set(options.names)].map((arg) => {
86
+ const [name, source = name] = arg.split("=");
87
+ const isKebab = (x) => /^[a-z0-9-]+$/.test(x);
88
+ const isPascal = (x) => /^[A-Z][A-Za-z0-9]*$/.test(x);
89
+ if (!(isKebab(name) || isPascal(name)) || !(isKebab(source) || isPascal(source)) || source !== name && !isPascal(name)) {
90
+ throw new Error(`icon names are PascalCase, kebab-case, or Name=source-name, got "${arg}"`);
91
+ }
92
+ return {
93
+ componentName: isKebab(name) ? kebabToPascal(name) : name,
94
+ file: isKebab(source) ? source : pascalToKebab(stripIconSuffix(source))
95
+ };
96
+ });
97
+ const loaded = await Promise.all(icons.map(async (icon) => {
98
+ if (localDir) {
99
+ const file = import_node_path.default.join(localDir, `${icon.file}.svg`);
100
+ return {
101
+ ...icon,
102
+ svg: (0, import_node_fs.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf-8") : null
103
+ };
104
+ }
105
+ const url = sourceUrl(from, icon.file, options);
106
+ const response = await fetch(url);
107
+ if (response.status === 404) return {
108
+ ...icon,
109
+ svg: null
110
+ };
111
+ if (!response.ok) {
112
+ throw new Error(`fetching ${url} failed (${response.status})`);
113
+ }
114
+ return {
115
+ ...icon,
116
+ svg: await response.text()
117
+ };
118
+ }));
119
+ const unknown = loaded.filter((icon) => icon.svg === null);
120
+ if (unknown.length) {
121
+ throw new Error(`unknown ${from} icon${unknown.length === 1 ? "" : "s"}: ${unknown.map((icon) => `${icon.componentName} (${icon.file}.svg)`).join(", ")}`);
122
+ }
123
+ await (0, import_promises.mkdir)(outDir, { recursive: true });
124
+ for (const icon of loaded) {
125
+ const file = import_node_path.default.join(outDir, `${icon.componentName}.tsx`);
126
+ await (0, import_promises.writeFile)(file, svgToComponent(icon.componentName, icon.svg));
127
+ console.info(`${import_chalk.default.green("wrote")} ${import_node_path.default.relative(cwd, file)}`);
128
+ }
129
+ const components = (await (0, import_promises.readdir)(outDir)).filter((file) => file.endsWith(".tsx")).map((file) => file.slice(0, -".tsx".length)).sort();
130
+ await (0, import_promises.writeFile)(import_node_path.default.join(outDir, "index.ts"), components.map((name) => `export { ${name} } from './${name}'`).join("\n") + "\n");
131
+ console.info(`${import_chalk.default.green("wrote")} ${import_node_path.default.relative(cwd, import_node_path.default.join(outDir, "index.ts"))} (${components.length} icons)`);
132
+ }
133
+ function sourceUrl(library, file, options) {
134
+ switch (library) {
135
+ case "lucide": return `https://unpkg.com/lucide-static@${LUCIDE_VERSION}/icons/${file}.svg`;
136
+ case "heroicons": return `https://unpkg.com/heroicons@${HEROICONS_VERSION}/${heroiconsDirs[options.variant ?? "outline"]}/${file}.svg`;
137
+ case "phosphor": {
138
+ const weight = options.weight ?? "regular";
139
+ const name = weight === "regular" ? file : `${file}-${weight}`;
140
+ return `https://unpkg.com/@phosphor-icons/core@${PHOSPHOR_VERSION}/assets/${weight}/${name}.svg`;
141
+ }
142
+ }
143
+ }
144
+ function pascalToKebab(name) {
145
+ return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Za-z])(\d)/g, "$1-$2").toLowerCase();
146
+ }
147
+ function kebabToPascal(name) {
148
+ return name.split("-").map((part) => part ? part[0].toUpperCase() + part.slice(1) : part).join("");
149
+ }
150
+ function stripIconSuffix(name) {
151
+ return name.endsWith("Icon") && name.length > 4 ? name.slice(0, -4) : name;
152
+ }
153
+ function parseSvg(svg) {
154
+ const root = {
155
+ tag: "",
156
+ attrs: [],
157
+ children: []
158
+ };
159
+ const stack = [root];
160
+ const tagPattern = /<(\/?)([A-Za-z][A-Za-z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
161
+ for (const [, closing, tag, attrText, selfClose] of svg.replace(/<!--[\s\S]*?-->/g, "").matchAll(tagPattern)) {
162
+ if (closing) {
163
+ if (stack.pop()?.tag !== tag) throw new Error(`mismatched </${tag}> in svg`);
164
+ continue;
165
+ }
166
+ const node = {
167
+ tag,
168
+ attrs: [],
169
+ children: []
170
+ };
171
+ for (const [, name, double, single] of attrText.matchAll(/([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
172
+ node.attrs.push([name, double ?? single]);
173
+ }
174
+ stack[stack.length - 1].children.push(node);
175
+ if (!selfClose) stack.push(node);
176
+ }
177
+ const svgNode = root.children.find((node) => node.tag === "svg");
178
+ if (stack.length !== 1 || !svgNode) throw new Error("svg source has no complete <svg> root");
179
+ return svgNode;
180
+ }
181
+ const elements = {
182
+ path: "Path",
183
+ circle: "Circle",
184
+ ellipse: "Ellipse",
185
+ line: "Line",
186
+ polyline: "Polyline",
187
+ polygon: "Polygon",
188
+ rect: "Rect",
189
+ g: "G",
190
+ defs: "Defs",
191
+ clipPath: "ClipPath",
192
+ linearGradient: "LinearGradient",
193
+ radialGradient: "RadialGradient",
194
+ stop: "Stop",
195
+ use: "Use"
196
+ };
197
+ const restrictedNames = [
198
+ "Infinity",
199
+ "NaN",
200
+ "undefined"
201
+ ];
202
+ function svgToComponent(name, svg) {
203
+ const root = parseSvg(svg);
204
+ const used = /* @__PURE__ */ new Set();
205
+ const ref = (element) => element === name ? `_${element}` : element;
206
+ const jsxAttr = (attrName, value) => {
207
+ if (attrName === "style") {
208
+ throw new Error(`${name}: style attributes have no react-native-svg equivalent`);
209
+ }
210
+ const prop = attrName === "xlink:href" ? "href" : attrName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
211
+ return value === "currentColor" ? `${prop}={color}` : `${prop}="${value}"`;
212
+ };
213
+ const keepAttr = ([attrName]) => !(attrName === "xmlns" || attrName.startsWith("xmlns:") || attrName === "class" || attrName === "version" || attrName.startsWith("data-") || attrName.startsWith("aria-"));
214
+ const rootAttrs = root.attrs.filter(keepAttr);
215
+ const viewBox = rootAttrs.find(([attrName]) => attrName === "viewBox")?.[1];
216
+ if (!viewBox) throw new Error(`${name}: svg source has no viewBox`);
217
+ const rootStroke = rootAttrs.some(([attrName, value]) => attrName === "stroke" && value === "currentColor");
218
+ const strokeWidth = rootAttrs.find(([attrName]) => attrName === "stroke-width")?.[1];
219
+ const emit = (node, indent) => {
220
+ const element = elements[node.tag];
221
+ if (!element) throw new Error(`${name}: unsupported svg element <${node.tag}>`);
222
+ used.add(element);
223
+ const attrs = node.attrs.filter(keepAttr).map(([n, v]) => jsxAttr(n, v));
224
+ if (rootStroke && !node.attrs.some(([n]) => n === "stroke") && node.tag !== "g") {
225
+ attrs.push("stroke={color}");
226
+ }
227
+ const open = `${indent}<${ref(element)}${attrs.map((attr) => ` ${attr}`).join("")}`;
228
+ if (!node.children.length) return `${open} />`;
229
+ return `${open}>
230
+ ${node.children.map((child) => emit(child, `${indent} `)).join("\n")}
231
+ ${indent}</${ref(element)}>`;
232
+ };
233
+ const children = root.children.map((child) => emit(child, " "));
234
+ if (!children.length) throw new Error(`${name}: svg source draws nothing`);
235
+ const svgAttrs = [
236
+ "width={size}",
237
+ "height={size}",
238
+ ...rootAttrs.filter(([attrName]) => attrName !== "width" && attrName !== "height").map(([attrName, value]) => jsxAttr(attrName, value)),
239
+ "{...otherProps}"
240
+ ];
241
+ const imports = [...used].sort().map((element) => element === name ? `${element} as _${element}` : element);
242
+ const themedOptions = strokeWidth && strokeWidth !== "2" ? `, { defaultStrokeWidth: ${strokeWidth} }` : "";
243
+ return `${restrictedNames.includes(name) ? "/* eslint-disable no-shadow-restricted-names */\n" : ""}import { memo, type JSX } from 'react'
244
+ import { Svg, ${imports.join(", ")}, type SvgProps } from 'react-native-svg'
245
+ import { themed, type IconProps } from '@tamagui/helpers-icon'
246
+
247
+ export const ${name}: (props: IconProps) => JSX.Element = themed(
248
+ memo(function ${name}(props: IconProps) {
249
+ const { color = 'black', size = 24, ...otherProps } = props as SvgProps & {
250
+ color?: string
251
+ size?: number
252
+ }
253
+ return (
254
+ <Svg
255
+ ${svgAttrs.map((attr) => ` ${attr}`).join("\n")}
256
+ >
257
+ ${children.join("\n")}
258
+ </Svg>
259
+ )
260
+ })${themedOptions}
261
+ )
262
+ `;
263
+ }
@@ -0,0 +1,70 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all) __defProp(target, name, {
9
+ get: all[name],
10
+ enumerable: true
11
+ });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
16
+ get: () => from[key],
17
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
18
+ });
19
+ }
20
+ return to;
21
+ };
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
23
+ value: mod,
24
+ enumerable: true
25
+ }) : target, mod));
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var preview_theme_exports = {};
28
+ __export(preview_theme_exports, { previewTheme: () => previewTheme });
29
+ module.exports = __toCommonJS(preview_theme_exports);
30
+ var import_node_fs = __toESM(require("node:fs"));
31
+ var import_node_path = __toESM(require("node:path"));
32
+ var import_chalk = __toESM(require("chalk"));
33
+ var import_opener = __toESM(require("opener"));
34
+ async function previewTheme(options) {
35
+ const themeInput = (options.theme || options.themeArg || "").trim();
36
+ let themeData = null;
37
+ if (themeInput) {
38
+ if (themeInput.startsWith("{") || themeInput.startsWith("[")) {
39
+ try {
40
+ themeData = JSON.parse(themeInput);
41
+ } catch (err) {
42
+ throw new Error(`Invalid JSON provided for --theme: ${err.message}`);
43
+ }
44
+ } else {
45
+ const filePath = import_node_path.default.resolve(process.cwd(), themeInput);
46
+ if (!import_node_fs.default.existsSync(filePath)) {
47
+ throw new Error(`Theme file not found at: ${filePath}`);
48
+ }
49
+ try {
50
+ const fileContent = import_node_fs.default.readFileSync(filePath, "utf-8");
51
+ themeData = JSON.parse(fileContent);
52
+ } catch (err) {
53
+ throw new Error(`Failed to read theme file ${filePath}: ${err.message}`);
54
+ }
55
+ }
56
+ }
57
+ const baseUrl = options.url || "https://tamagui.dev/theme";
58
+ let targetUrl = baseUrl;
59
+ if (themeData) {
60
+ const jsonStr = JSON.stringify(themeData);
61
+ const base64 = Buffer.from(jsonStr).toString("base64");
62
+ targetUrl = `${baseUrl}#theme=${encodeURIComponent(base64)}`;
63
+ }
64
+ if (options.print) {
65
+ console.info(targetUrl);
66
+ return;
67
+ }
68
+ console.info(import_chalk.default.green(`Opening theme preview:`), targetUrl);
69
+ (0, import_opener.default)(targetUrl);
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamagui/cli",
3
- "version": "3.0.0-beta.1479.1",
3
+ "version": "3.0.0-beta.1537.1",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "tama": "dist/index.cjs",
@@ -52,12 +52,12 @@
52
52
  "test:web": "bun run test"
53
53
  },
54
54
  "dependencies": {
55
- "@tamagui/generate-themes": "3.0.0-beta.1479.1",
56
- "@tamagui/metro-plugin": "3.0.0-beta.1479.1",
57
- "@tamagui/static": "3.0.0-beta.1479.1",
58
- "@tamagui/to-tailwind": "3.0.0-beta.1479.1",
59
- "@tamagui/types": "3.0.0-beta.1479.1",
60
- "@tamagui/vite-plugin": "3.0.0-beta.1479.1",
55
+ "@tamagui/generate-themes": "3.0.0-beta.1537.1",
56
+ "@tamagui/metro-plugin": "3.0.0-beta.1537.1",
57
+ "@tamagui/static": "3.0.0-beta.1537.1",
58
+ "@tamagui/to-tailwind": "3.0.0-beta.1537.1",
59
+ "@tamagui/types": "3.0.0-beta.1537.1",
60
+ "@tamagui/vite-plugin": "3.0.0-beta.1537.1",
61
61
  "arg": "^5.0.2",
62
62
  "chalk": "^4.1.2",
63
63
  "change-case": "^4.1.2",
@@ -76,10 +76,10 @@
76
76
  "ts-morph": "^28.0.0",
77
77
  "typescript": "7.0.2",
78
78
  "url": "^0.11.0",
79
- "@tamagui/language-service": "3.0.0-beta.1479.1"
79
+ "@tamagui/language-service": "3.0.0-beta.1537.1"
80
80
  },
81
81
  "devDependencies": {
82
- "@tamagui/build": "3.0.0-beta.1479.1",
82
+ "@tamagui/build": "3.0.0-beta.1537.1",
83
83
  "@types/chokidar": "^2.1.3",
84
84
  "@types/marked": "^5.0.0",
85
85
  "vitest": "4.1.0"
package/src/cli.ts CHANGED
@@ -43,7 +43,7 @@ const COMMAND_MAP = {
43
43
  }
44
44
  }
45
45
  const { checkStyleFiles, formatCheckResults, MissingConfigArtifactError } =
46
- require('@tamagui/language-service/check') as typeof import('@tamagui/language-service/check')
46
+ require('@tamagui/language-service/check') as any
47
47
  try {
48
48
  const result = checkStyleFiles({
49
49
  root: options.paths.root,
@@ -52,7 +52,7 @@ const COMMAND_MAP = {
52
52
  })
53
53
  console.info(formatCheckResults(result))
54
54
  if (result.diagnosticCount > 0) process.exitCode = 1
55
- } catch (error) {
55
+ } catch (error: any) {
56
56
  if (error instanceof MissingConfigArtifactError && !flags['--strict']) {
57
57
  console.warn(chalk.yellow(`skipping flat value check: ${error.message}`))
58
58
  return
@@ -167,6 +167,38 @@ const COMMAND_MAP = {
167
167
  },
168
168
  },
169
169
 
170
+ icons: {
171
+ shorthands: [],
172
+ description: `Generate themed SVG icon components from Lucide, Heroicons, Phosphor, or a folder of svgs.`,
173
+ usage: `$ tamagui icons add Search ChevronDown
174
+ $ tamagui icons add Copy --from phosphor --weight bold
175
+ $ tamagui icons add Clipboard --from heroicons --variant solid
176
+ $ tamagui icons add Logo --from ./assets/icons --out src/icons
177
+ $ tamagui icons add Search=magnifying-glass --from phosphor`,
178
+ flags: {
179
+ '--help': Boolean,
180
+ '--from': String,
181
+ '--weight': String,
182
+ '--variant': String,
183
+ '--out': String,
184
+ },
185
+ async run() {
186
+ const { _, ...flags } = arg(this.flags)
187
+ const [_cmd, subcommand, ...names] = _
188
+ if (subcommand !== 'add') {
189
+ throw new Error('usage: tamagui icons add <Name...> [--from] [--out]')
190
+ }
191
+ const { addIcons } = require('./icons')
192
+ await addIcons({
193
+ names,
194
+ from: flags['--from'],
195
+ weight: flags['--weight'],
196
+ variant: flags['--variant'],
197
+ out: flags['--out'],
198
+ })
199
+ },
200
+ },
201
+
170
202
  build: {
171
203
  shorthands: ['b'],
172
204
  description: `Use to pre-build a Tamagui component directory. Use -- to run a command after optimization, then auto-restore files.`,
@@ -352,6 +384,31 @@ $ tamagui migrate --from v1`,
352
384
  })
353
385
  },
354
386
  },
387
+
388
+ 'preview-theme': {
389
+ shorthands: ['preview'],
390
+ description: `Preview a theme configuration in the browser on tamagui.dev/theme`,
391
+ usage: `$ tamagui preview-theme ./theme.json
392
+ $ tamagui preview-theme --theme '{"palettes": ...}'
393
+ $ tamagui preview-theme ./theme.json --print`,
394
+ flags: {
395
+ '--help': Boolean,
396
+ '--theme': String,
397
+ '--url': String,
398
+ '--print': Boolean,
399
+ },
400
+ async run() {
401
+ const { _, ...flags } = arg(this.flags)
402
+ const [_cmd, themeArg] = _
403
+ const { previewTheme } = require('./preview-theme')
404
+ await previewTheme({
405
+ theme: flags['--theme'],
406
+ themeArg,
407
+ url: flags['--url'],
408
+ print: flags['--print'],
409
+ })
410
+ },
411
+ },
355
412
  }
356
413
 
357
414
  type CommandDefinitions = typeof COMMAND_MAP
package/src/icons.ts ADDED
@@ -0,0 +1,308 @@
1
+ // `tamagui icons add`: writes one themed react-native-svg component per icon,
2
+ // plus an index of every component in the output dir. sources are pinned
3
+ // upstream releases fetched from unpkg, or a local dir of svg files.
4
+
5
+ import { existsSync, statSync } from 'node:fs'
6
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
7
+ import path from 'node:path'
8
+
9
+ import chalk from 'chalk'
10
+
11
+ // pinned so a cli release only changes output when these move
12
+ export const LUCIDE_VERSION = '1.49.0'
13
+ export const HEROICONS_VERSION = '2.2.0'
14
+ export const PHOSPHOR_VERSION = '2.1.1'
15
+
16
+ const libraries = ['lucide', 'heroicons', 'phosphor'] as const
17
+ type Library = (typeof libraries)[number]
18
+
19
+ const phosphorWeights = ['regular', 'bold', 'duotone', 'fill', 'light', 'thin']
20
+ // heroicons ships outline only at 24px; solid at 24px, mini at 20px, micro at 16px
21
+ const heroiconsDirs: Record<string, string> = {
22
+ outline: '24/outline',
23
+ solid: '24/solid',
24
+ mini: '20/solid',
25
+ micro: '16/solid',
26
+ }
27
+
28
+ export type AddIconsOptions = {
29
+ names: string[]
30
+ // a library name, or a dir holding <kebab-name>.svg files
31
+ from?: string
32
+ weight?: string
33
+ variant?: string
34
+ out?: string
35
+ cwd?: string
36
+ }
37
+
38
+ export async function addIcons(options: AddIconsOptions) {
39
+ const cwd = options.cwd ?? process.cwd()
40
+ const from = options.from ?? 'lucide'
41
+ const outDir = path.resolve(cwd, options.out ?? 'components/icons')
42
+ const localDir = libraries.includes(from as Library) ? null : path.resolve(cwd, from)
43
+
44
+ if (localDir && !(existsSync(localDir) && statSync(localDir).isDirectory())) {
45
+ throw new Error(
46
+ `--from must be ${libraries.join(', ')}, or a directory of svg files (got "${from}")`
47
+ )
48
+ }
49
+ if (!options.names.length) {
50
+ throw new Error('give the icons to add, e.g. `tamagui icons add Search ChevronDown`')
51
+ }
52
+ if (options.weight && from !== 'phosphor') {
53
+ throw new Error('--weight applies to phosphor only')
54
+ }
55
+ if (options.weight && !phosphorWeights.includes(options.weight)) {
56
+ throw new Error(`--weight is one of ${phosphorWeights.join(', ')}`)
57
+ }
58
+ if (options.variant && from !== 'heroicons') {
59
+ throw new Error('--variant applies to heroicons only')
60
+ }
61
+ if (options.variant && !heroiconsDirs[options.variant]) {
62
+ throw new Error(`--variant is one of ${Object.keys(heroiconsDirs).join(', ')}`)
63
+ }
64
+
65
+ // `Search=magnifying-glass` keeps the component name while drawing another
66
+ // source icon, so a set can switch libraries without touching its imports
67
+ const icons = [...new Set(options.names)].map((arg) => {
68
+ const [name, source = name] = arg.split('=')
69
+ const isKebab = (x: string) => /^[a-z0-9-]+$/.test(x)
70
+ const isPascal = (x: string) => /^[A-Z][A-Za-z0-9]*$/.test(x)
71
+ if (
72
+ !(isKebab(name) || isPascal(name)) ||
73
+ !(isKebab(source) || isPascal(source)) ||
74
+ (source !== name && !isPascal(name))
75
+ ) {
76
+ throw new Error(
77
+ `icon names are PascalCase, kebab-case, or Name=source-name, got "${arg}"`
78
+ )
79
+ }
80
+ return {
81
+ componentName: isKebab(name) ? kebabToPascal(name) : name,
82
+ file: isKebab(source) ? source : pascalToKebab(stripIconSuffix(source)),
83
+ }
84
+ })
85
+
86
+ const loaded = await Promise.all(
87
+ icons.map(async (icon) => {
88
+ if (localDir) {
89
+ const file = path.join(localDir, `${icon.file}.svg`)
90
+ return { ...icon, svg: existsSync(file) ? await readFile(file, 'utf-8') : null }
91
+ }
92
+ const url = sourceUrl(from as Library, icon.file, options)
93
+ const response = await fetch(url)
94
+ if (response.status === 404) return { ...icon, svg: null }
95
+ if (!response.ok) {
96
+ throw new Error(`fetching ${url} failed (${response.status})`)
97
+ }
98
+ return { ...icon, svg: await response.text() }
99
+ })
100
+ )
101
+
102
+ const unknown = loaded.filter((icon) => icon.svg === null)
103
+ if (unknown.length) {
104
+ throw new Error(
105
+ `unknown ${from} icon${unknown.length === 1 ? '' : 's'}: ${unknown
106
+ .map((icon) => `${icon.componentName} (${icon.file}.svg)`)
107
+ .join(', ')}`
108
+ )
109
+ }
110
+
111
+ await mkdir(outDir, { recursive: true })
112
+ for (const icon of loaded) {
113
+ const file = path.join(outDir, `${icon.componentName}.tsx`)
114
+ await writeFile(file, svgToComponent(icon.componentName, icon.svg!))
115
+ console.info(`${chalk.green('wrote')} ${path.relative(cwd, file)}`)
116
+ }
117
+
118
+ // the index lists every component in the dir, so repeated adds accumulate
119
+ const components = (await readdir(outDir))
120
+ .filter((file) => file.endsWith('.tsx'))
121
+ .map((file) => file.slice(0, -'.tsx'.length))
122
+ .sort()
123
+ await writeFile(
124
+ path.join(outDir, 'index.ts'),
125
+ components.map((name) => `export { ${name} } from './${name}'`).join('\n') + '\n'
126
+ )
127
+ console.info(
128
+ `${chalk.green('wrote')} ${path.relative(cwd, path.join(outDir, 'index.ts'))} (${components.length} icons)`
129
+ )
130
+ }
131
+
132
+ function sourceUrl(library: Library, file: string, options: AddIconsOptions) {
133
+ switch (library) {
134
+ case 'lucide':
135
+ return `https://unpkg.com/lucide-static@${LUCIDE_VERSION}/icons/${file}.svg`
136
+ case 'heroicons':
137
+ return `https://unpkg.com/heroicons@${HEROICONS_VERSION}/${heroiconsDirs[options.variant ?? 'outline']}/${file}.svg`
138
+ case 'phosphor': {
139
+ const weight = options.weight ?? 'regular'
140
+ const name = weight === 'regular' ? file : `${file}-${weight}`
141
+ return `https://unpkg.com/@phosphor-icons/core@${PHOSPHOR_VERSION}/assets/${weight}/${name}.svg`
142
+ }
143
+ }
144
+ }
145
+
146
+ function pascalToKebab(name: string) {
147
+ return name
148
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
149
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
150
+ .replace(/([A-Za-z])(\d)/g, '$1-$2')
151
+ .toLowerCase()
152
+ }
153
+
154
+ function kebabToPascal(name: string) {
155
+ return name
156
+ .split('-')
157
+ .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
158
+ .join('')
159
+ }
160
+
161
+ // one trailing Icon is dropped for the lookup only: HeartIcon finds heart
162
+ function stripIconSuffix(name: string) {
163
+ return name.endsWith('Icon') && name.length > 4 ? name.slice(0, -4) : name
164
+ }
165
+
166
+ type SvgNode = {
167
+ tag: string
168
+ attrs: [name: string, value: string][]
169
+ children: SvgNode[]
170
+ }
171
+
172
+ // upstream icon files are machine-written and shallow; this handles nesting so
173
+ // a grouped source never drops shapes
174
+ function parseSvg(svg: string): SvgNode {
175
+ const root: SvgNode = { tag: '', attrs: [], children: [] }
176
+ const stack = [root]
177
+ const tagPattern = /<(\/?)([A-Za-z][A-Za-z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g
178
+ for (const [, closing, tag, attrText, selfClose] of svg
179
+ .replace(/<!--[\s\S]*?-->/g, '')
180
+ .matchAll(tagPattern)) {
181
+ if (closing) {
182
+ if (stack.pop()?.tag !== tag) throw new Error(`mismatched </${tag}> in svg`)
183
+ continue
184
+ }
185
+ const node: SvgNode = { tag, attrs: [], children: [] }
186
+ for (const [, name, double, single] of attrText.matchAll(
187
+ /([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g
188
+ )) {
189
+ node.attrs.push([name, double ?? single])
190
+ }
191
+ stack[stack.length - 1].children.push(node)
192
+ if (!selfClose) stack.push(node)
193
+ }
194
+ const svgNode = root.children.find((node) => node.tag === 'svg')
195
+ if (stack.length !== 1 || !svgNode)
196
+ throw new Error('svg source has no complete <svg> root')
197
+ return svgNode
198
+ }
199
+
200
+ const elements: Record<string, string> = {
201
+ path: 'Path',
202
+ circle: 'Circle',
203
+ ellipse: 'Ellipse',
204
+ line: 'Line',
205
+ polyline: 'Polyline',
206
+ polygon: 'Polygon',
207
+ rect: 'Rect',
208
+ g: 'G',
209
+ defs: 'Defs',
210
+ clipPath: 'ClipPath',
211
+ linearGradient: 'LinearGradient',
212
+ radialGradient: 'RadialGradient',
213
+ stop: 'Stop',
214
+ use: 'Use',
215
+ }
216
+
217
+ // globals a component name would shadow; oxlint/eslint flag these
218
+ const restrictedNames = ['Infinity', 'NaN', 'undefined']
219
+
220
+ export function svgToComponent(name: string, svg: string) {
221
+ const root = parseSvg(svg)
222
+ const used = new Set<string>()
223
+ const ref = (element: string) => (element === name ? `_${element}` : element)
224
+
225
+ const jsxAttr = (attrName: string, value: string) => {
226
+ if (attrName === 'style') {
227
+ throw new Error(`${name}: style attributes have no react-native-svg equivalent`)
228
+ }
229
+ const prop =
230
+ attrName === 'xlink:href'
231
+ ? 'href'
232
+ : attrName.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
233
+ return value === 'currentColor' ? `${prop}={color}` : `${prop}="${value}"`
234
+ }
235
+ const keepAttr = ([attrName]: [string, string]) =>
236
+ !(
237
+ attrName === 'xmlns' ||
238
+ attrName.startsWith('xmlns:') ||
239
+ attrName === 'class' ||
240
+ attrName === 'version' ||
241
+ attrName.startsWith('data-') ||
242
+ attrName.startsWith('aria-')
243
+ )
244
+
245
+ const rootAttrs = root.attrs.filter(keepAttr)
246
+ const viewBox = rootAttrs.find(([attrName]) => attrName === 'viewBox')?.[1]
247
+ if (!viewBox) throw new Error(`${name}: svg source has no viewBox`)
248
+ // native draws children with their own stroke, so a currentColor stroke on
249
+ // the root is repeated on each child that has none
250
+ const rootStroke = rootAttrs.some(
251
+ ([attrName, value]) => attrName === 'stroke' && value === 'currentColor'
252
+ )
253
+ const strokeWidth = rootAttrs.find(([attrName]) => attrName === 'stroke-width')?.[1]
254
+
255
+ const emit = (node: SvgNode, indent: string): string => {
256
+ const element = elements[node.tag]
257
+ if (!element) throw new Error(`${name}: unsupported svg element <${node.tag}>`)
258
+ used.add(element)
259
+ const attrs = node.attrs.filter(keepAttr).map(([n, v]) => jsxAttr(n, v))
260
+ if (rootStroke && !node.attrs.some(([n]) => n === 'stroke') && node.tag !== 'g') {
261
+ attrs.push('stroke={color}')
262
+ }
263
+ const open = `${indent}<${ref(element)}${attrs.map((attr) => ` ${attr}`).join('')}`
264
+ if (!node.children.length) return `${open} />`
265
+ return `${open}>\n${node.children.map((child) => emit(child, `${indent} `)).join('\n')}\n${indent}</${ref(element)}>`
266
+ }
267
+
268
+ const children = root.children.map((child) => emit(child, ' '))
269
+ if (!children.length) throw new Error(`${name}: svg source draws nothing`)
270
+
271
+ const svgAttrs = [
272
+ 'width={size}',
273
+ 'height={size}',
274
+ ...rootAttrs
275
+ .filter(([attrName]) => attrName !== 'width' && attrName !== 'height')
276
+ .map(([attrName, value]) => jsxAttr(attrName, value)),
277
+ '{...otherProps}',
278
+ ]
279
+ const imports = [...used]
280
+ .sort()
281
+ .map((element) => (element === name ? `${element} as _${element}` : element))
282
+ const themedOptions =
283
+ strokeWidth && strokeWidth !== '2' ? `, { defaultStrokeWidth: ${strokeWidth} }` : ''
284
+
285
+ // the explicit component type lets isolatedDeclarations builds emit types.
286
+ // themed resolves theme and token values before the inner component runs, so
287
+ // it reads plain svg values rather than the app's token-typed IconProps
288
+ return `${restrictedNames.includes(name) ? '/* eslint-disable no-shadow-restricted-names */\n' : ''}import { memo, type JSX } from 'react'
289
+ import { Svg, ${imports.join(', ')}, type SvgProps } from 'react-native-svg'
290
+ import { themed, type IconProps } from '@tamagui/helpers-icon'
291
+
292
+ export const ${name}: (props: IconProps) => JSX.Element = themed(
293
+ memo(function ${name}(props: IconProps) {
294
+ const { color = 'black', size = 24, ...otherProps } = props as SvgProps & {
295
+ color?: string
296
+ size?: number
297
+ }
298
+ return (
299
+ <Svg
300
+ ${svgAttrs.map((attr) => ` ${attr}`).join('\n')}
301
+ >
302
+ ${children.join('\n')}
303
+ </Svg>
304
+ )
305
+ })${themedOptions}
306
+ )
307
+ `
308
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import chalk from 'chalk'
4
+ import open from 'opener'
5
+
6
+ export interface PreviewThemeOptions {
7
+ theme?: string
8
+ themeArg?: string
9
+ url?: string
10
+ print?: boolean
11
+ }
12
+
13
+ export async function previewTheme(options: PreviewThemeOptions) {
14
+ const themeInput = (options.theme || options.themeArg || '').trim()
15
+ let themeData: any = null
16
+
17
+ if (themeInput) {
18
+ if (themeInput.startsWith('{') || themeInput.startsWith('[')) {
19
+ try {
20
+ themeData = JSON.parse(themeInput)
21
+ } catch (err: any) {
22
+ throw new Error(`Invalid JSON provided for --theme: ${err.message}`)
23
+ }
24
+ } else {
25
+ const filePath = path.resolve(process.cwd(), themeInput)
26
+ if (!fs.existsSync(filePath)) {
27
+ throw new Error(`Theme file not found at: ${filePath}`)
28
+ }
29
+ try {
30
+ const fileContent = fs.readFileSync(filePath, 'utf-8')
31
+ themeData = JSON.parse(fileContent)
32
+ } catch (err: any) {
33
+ throw new Error(`Failed to read theme file ${filePath}: ${err.message}`)
34
+ }
35
+ }
36
+ }
37
+
38
+ const baseUrl = options.url || 'https://tamagui.dev/theme'
39
+ let targetUrl = baseUrl
40
+
41
+ if (themeData) {
42
+ const jsonStr = JSON.stringify(themeData)
43
+ const base64 = Buffer.from(jsonStr).toString('base64')
44
+ targetUrl = `${baseUrl}#theme=${encodeURIComponent(base64)}`
45
+ }
46
+
47
+ if (options.print) {
48
+ console.info(targetUrl)
49
+ return
50
+ }
51
+
52
+ console.info(chalk.green(`Opening theme preview:`), targetUrl)
53
+ open(targetUrl)
54
+ }
@@ -0,0 +1,14 @@
1
+ export declare const LUCIDE_VERSION = "1.49.0";
2
+ export declare const HEROICONS_VERSION = "2.2.0";
3
+ export declare const PHOSPHOR_VERSION = "2.1.1";
4
+ export type AddIconsOptions = {
5
+ names: string[];
6
+ from?: string;
7
+ weight?: string;
8
+ variant?: string;
9
+ out?: string;
10
+ cwd?: string;
11
+ };
12
+ export declare function addIcons(options: AddIconsOptions): Promise<void>;
13
+ export declare function svgToComponent(name: string, svg: string): string;
14
+ //# sourceMappingURL=icons.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icons.d.ts","sourceRoot":"","sources":["../src/icons.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,cAAc,WAAW,CAAA;AACtC,eAAO,MAAM,iBAAiB,UAAU,CAAA;AACxC,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAcvC,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,EAAE,CAAA;IAEf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,wBAAsB,QAAQ,CAAC,OAAO,EAAE,eAAe,iBA4FtD;AA0FD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,UAwFvD"}
@@ -0,0 +1,8 @@
1
+ export interface PreviewThemeOptions {
2
+ theme?: string;
3
+ themeArg?: string;
4
+ url?: string;
5
+ print?: boolean;
6
+ }
7
+ export declare function previewTheme(options: PreviewThemeOptions): Promise<void>;
8
+ //# sourceMappingURL=preview-theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview-theme.d.ts","sourceRoot":"","sources":["../src/preview-theme.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,iBAyC9D"}