@tamagui/cli 3.0.0-beta.1479.1 → 3.0.0-beta.1540.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 +55 -0
- package/dist/icons.cjs +263 -0
- package/dist/preview-theme.cjs +70 -0
- package/package.json +9 -9
- package/src/cli.ts +59 -2
- package/src/icons.ts +308 -0
- package/src/preview-theme.ts +54 -0
- package/types/icons.d.ts +14 -0
- package/types/icons.d.ts.map +1 -0
- package/types/preview-theme.d.ts +8 -0
- package/types/preview-theme.d.ts.map +1 -0
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.
|
|
3
|
+
"version": "3.0.0-beta.1540.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.
|
|
56
|
-
"@tamagui/metro-plugin": "3.0.0-beta.
|
|
57
|
-
"@tamagui/static": "3.0.0-beta.
|
|
58
|
-
"@tamagui/to-tailwind": "3.0.0-beta.
|
|
59
|
-
"@tamagui/types": "3.0.0-beta.
|
|
60
|
-
"@tamagui/vite-plugin": "3.0.0-beta.
|
|
55
|
+
"@tamagui/generate-themes": "3.0.0-beta.1540.1",
|
|
56
|
+
"@tamagui/metro-plugin": "3.0.0-beta.1540.1",
|
|
57
|
+
"@tamagui/static": "3.0.0-beta.1540.1",
|
|
58
|
+
"@tamagui/to-tailwind": "3.0.0-beta.1540.1",
|
|
59
|
+
"@tamagui/types": "3.0.0-beta.1540.1",
|
|
60
|
+
"@tamagui/vite-plugin": "3.0.0-beta.1540.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.
|
|
79
|
+
"@tamagui/language-service": "3.0.0-beta.1540.1"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@tamagui/build": "3.0.0-beta.
|
|
82
|
+
"@tamagui/build": "3.0.0-beta.1540.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
|
|
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
|
+
}
|
package/types/icons.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|