@expokit/cli 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,23 @@
9
9
  The CLI for **[ExpoKit](https://expokit.in)** — animated UI components for Expo and React Native. It copies
10
10
  the component source into your app (like shadcn/ui), so you own the code.
11
11
 
12
+ <p align="center">
13
+ <a href="https://expokit.in/showcase"><img src="https://expokit.in/readme/charts.gif" alt="ExpoKit charts on iPhone: scrubbing an area chart and a bar chart, dragging a gauge, tapping through a donut" width="720"></a>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://expokit.in/docs/components"><img src="https://expokit.in/readme/screens.jpg" alt="ExpoKit on iPhone in dark and light mode: payment card, flip card, dashboard block and checkout block" width="720"></a>
18
+ <br>
19
+ <sub>Payment card and flip card are free; the dashboard and checkout screens are <a href="https://expokit.in/pricing">Pro</a> blocks.</sub>
20
+ </p>
21
+
22
+ - **Buttons, inputs, cards, sheets, tabs, charts and full screens**, all animated, with light and dark mode
23
+ - **Built for touch**: springs, drag gestures and haptics, on iOS, Android, web and in Expo Go
24
+ - **You own the code**: plain typed files in your app, with no runtime library to lock you in
25
+
26
+ **[Browse every component with live previews →](https://expokit.in/docs/components)** ·
27
+ **[Try them on your phone in Expo Go →](https://expokit.in/docs/run-on-your-phone)**
28
+
12
29
  ## Usage
13
30
 
14
31
  In your Expo app's folder:
@@ -33,12 +50,16 @@ Everything else — live previews, props, installation, theming, Pro, AI / MCP s
33
50
 
34
51
  ## Security
35
52
 
36
- - **No dependencies, no install scripts, no telemetry.**
53
+ - **No dependencies, no install scripts.**
54
+ - **Anonymous install counts only** — after a successful `add`, the CLI sends the item's name (`{"items":["button"]}`)
55
+ to expokit.in so the site can show how often each component is installed. No token, files, paths or machine details;
56
+ repeat adds count once a day. Turn it off with `DO_NOT_TRACK=1` or `EXPO_KIT_TELEMETRY=0`; it never runs in CI,
57
+ `--dry-run` or `update`.
37
58
  - **Writes only inside your app** — `components.json`, the `@/*` alias in `tsconfig.json` and the component files.
38
59
  `--dry-run` shows every change first; `add` never overwrites a file you changed unless you pass `--force`.
39
60
  `paths` in `components.json` that point outside the app are refused. Symlinks inside the app are followed, not
40
61
  checked, so a cloned repo that symlinks a folder elsewhere can still send writes there.
41
- - **Free components need no network** — they ship inside this package. Pro components are downloaded over HTTPS from
62
+ - **Free components need no network to install** — they ship inside this package. Pro components are downloaded over HTTPS from
42
63
  expokit.in; your token is stored readable only by you, sent only to that site and never through a redirect.
43
64
  In scripts, pipe it to `login` or set `EXPO_KIT_TOKEN` (`--token` leaves it in your shell history).
44
65
  - **Runs one command:** `npx expo install` for the packages a component needs — without `EXPO_KIT_TOKEN` in its
package/bin/expo-kit.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  retryDelayMs,
35
35
  retryHint,
36
36
  tabComponentName,
37
+ telemetryEnabled,
37
38
  needsDevBuild,
38
39
  pickMainExport,
39
40
  } from './lib.mjs';
@@ -942,12 +943,33 @@ function cmdInit(cwd, flags, dir) {
942
943
  log(`Done. Add items with: ${RUN} add <name>`);
943
944
  }
944
945
 
945
- async function cmdAdd(args, cwd) {
946
+ /**
947
+ * After a successful add: tells the site which item was installed — just its name, no token or project
948
+ * data (README "Privacy"). Off with DO_NOT_TRACK=1 / EXPO_KIT_TELEMETRY=0 and in CI. Never fails the add.
949
+ */
950
+ async function reportInstalled(name) {
951
+ if (!telemetryEnabled(process.env)) return;
952
+ try {
953
+ parseSiteUrl(SITE_URL);
954
+ const res = await fetch(`${SITE_URL}/api/cli/installed`, {
955
+ method: 'POST',
956
+ headers: { 'content-type': 'application/json' },
957
+ body: JSON.stringify({ items: [name] }),
958
+ signal: AbortSignal.timeout(1500),
959
+ });
960
+ await res.arrayBuffer();
961
+ } catch {
962
+ // offline, slow or blocked: the count just misses this one
963
+ }
964
+ }
965
+
966
+ /** `report: false` for re-adds that aren't new installs (`update --force`). */
967
+ async function cmdAdd(args, cwd, { report = true } = {}) {
946
968
  const { flags, positional } = parseFlags(args);
947
969
  // `add a b c` → add each in turn with the same flags.
948
970
  if (positional.length > 1 && !positional.includes('--list')) {
949
971
  const flagArgs = args.filter((a) => a.startsWith('--'));
950
- for (const item of positional) await cmdAdd([item, ...flagArgs], cwd);
972
+ for (const item of positional) await cmdAdd([item, ...flagArgs], cwd, { report });
951
973
  return;
952
974
  }
953
975
  const name = positional[0];
@@ -1049,6 +1071,7 @@ async function cmdAdd(args, cwd) {
1049
1071
  const depsOk = runExpoInstall(projectRoot, meta.dependencies ?? [], false);
1050
1072
 
1051
1073
  if (depsOk) markTemplateInstalled(projectRoot, config, resolved);
1074
+ if (report) await reportInstalled(resolved);
1052
1075
 
1053
1076
  console.log('');
1054
1077
  if (resolved === 'liquidglass-tab') {
@@ -1092,7 +1115,7 @@ async function cmdUpdate(args, cwd) {
1092
1115
  if (flags.force) {
1093
1116
  for (const resolved of targets) {
1094
1117
  log(`Full overwrite ${resolved} (--force${flags.dryRun ? ', dry run' : ''})…\n`);
1095
- await cmdAdd([resolved, '--force', ...(flags.dryRun ? ['--dry-run'] : [])], cwd);
1118
+ await cmdAdd([resolved, '--force', ...(flags.dryRun ? ['--dry-run'] : [])], cwd, { report: false });
1096
1119
  }
1097
1120
  return;
1098
1121
  }
package/bin/lib.mjs CHANGED
@@ -318,6 +318,7 @@ export function kitVersion(text) {
318
318
  */
319
319
  export const EXPO_GO_PACKAGES = new Set([
320
320
  '@expo/vector-icons',
321
+ '@react-native-async-storage/async-storage',
321
322
  '@shopify/react-native-skia',
322
323
  'expo-blur',
323
324
  'expo-charts',
@@ -325,6 +326,7 @@ export const EXPO_GO_PACKAGES = new Set([
325
326
  'expo-haptics',
326
327
  'expo-router',
327
328
  'expo-splash-screen',
329
+ 'expo-status-bar',
328
330
  'react-native-gesture-handler',
329
331
  'react-native-reanimated',
330
332
  'react-native-safe-area-context',
@@ -366,6 +368,18 @@ export function mergeDependencies(...lists) {
366
368
 
367
369
  export const DEFAULT_SITE_URL = 'https://expokit.in';
368
370
 
371
+ const OFF = /^(0|false|off|no)$/i;
372
+ const set = (v) => v !== undefined && v !== '' && !OFF.test(v);
373
+
374
+ /**
375
+ * Anonymous install counts (README "Privacy"): on unless DO_NOT_TRACK is set, EXPO_KIT_TELEMETRY is
376
+ * 0/false/off/no, or it's a CI run (builds re-adding items aren't new installs).
377
+ */
378
+ export function telemetryEnabled(env) {
379
+ if (set(env.DO_NOT_TRACK) || set(env.CI)) return false;
380
+ return !(env.EXPO_KIT_TELEMETRY !== undefined && OFF.test(env.EXPO_KIT_TELEMETRY));
381
+ }
382
+
369
383
  export function isLoopbackHost(hostname) {
370
384
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
371
385
  }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Pure helpers for the liquidglass-tab template (no I/O): which Expo Router API shape to write for
3
+ * the installed SDK, starter-app detection, and tabs.ts generation for an existing app.
4
+ */
5
+
6
+ import { CliError } from './lib.mjs';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Expo SDK → API shape
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Newest SDK this CLI knows the API of; also assumed when neither expo nor expo-router is installed. */
13
+ export const LATEST_KNOWN_SDK = 58;
14
+ export const MIN_SDK = 54;
15
+
16
+ /** "~6.0.24", "^54.0.0", "6.0.24", "54" → [6, 0, 24]; "latest", "*", "file:…" → null. */
17
+ export function parseVersion(spec) {
18
+ const m = /^(?:[\^~]|>=?|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(spec ?? '').trim());
19
+ return m ? [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)] : null;
20
+ }
21
+
22
+ /** expo-router majors before it switched to SDK numbering (55.x = SDK 55). */
23
+ const ROUTER_TO_SDK = { 6: 54, 5: 53, 4: 52, 3: 50, 2: 49 };
24
+
25
+ /**
26
+ * What to write for an installed expo / expo-router (versions or package.json ranges):
27
+ * SDK 54 NativeTabs + standalone Icon/Label/Badge/VectorIcon; Tabs from 'expo-router'
28
+ * SDK 55 NativeTabs.Trigger.*; Tabs from 'expo-router'
29
+ * SDK 56–57 NativeTabs.Trigger.*; Tabs from 'expo-router/js-tabs'; themes from 'expo-router'
30
+ * SDK 58+ as 56–57, NativeTabs from 'expo-router/native-tabs'
31
+ */
32
+ export function routerTarget({ router, expo } = {}) {
33
+ const r = parseVersion(router);
34
+ const e = parseVersion(expo);
35
+ let sdk = null;
36
+ let source = null;
37
+ if (r) {
38
+ sdk = r[0] >= 50 ? r[0] : (ROUTER_TO_SDK[r[0]] ?? (r[0] < 2 ? 48 : null));
39
+ source = `expo-router ${r.join('.')}`;
40
+ }
41
+ if (sdk === null && e) {
42
+ sdk = e[0];
43
+ source = `expo ${e.join('.')}`;
44
+ }
45
+ const assumed = sdk === null;
46
+ if (assumed) sdk = LATEST_KNOWN_SDK;
47
+ if (sdk < MIN_SDK) return { sdk, source, unsupported: true };
48
+ const legacyNav = sdk <= 55;
49
+ return {
50
+ sdk,
51
+ source: source ?? `assumed SDK ${sdk}`,
52
+ assumed,
53
+ icons: sdk === 54 ? 'standalone' : 'trigger',
54
+ nativeTabs: sdk >= 58 ? 'expo-router/native-tabs' : 'expo-router/unstable-native-tabs',
55
+ tabs: legacyNav ? 'expo-router' : 'expo-router/js-tabs',
56
+ tabBarProps: legacyNav ? '@react-navigation/bottom-tabs' : 'expo-router/js-tabs',
57
+ themes: legacyNav ? '@react-navigation/native' : 'expo-router',
58
+ /** Imported directly on SDK 54–55 (expo-router depends on them; strict installers need them listed). */
59
+ deps: legacyNav ? ['@react-navigation/bottom-tabs', '@react-navigation/native'] : [],
60
+ };
61
+ }
62
+
63
+ function replaceOnce(text, re, replacement, what) {
64
+ if (!re.test(text)) throw new CliError(`liquidglass-tab template changed shape (${what}) — update the CLI: npx @expokit/cli@latest`);
65
+ return text.replace(re, replacement);
66
+ }
67
+
68
+ /** Registry `src/`-relative paths in comments ("src/app/(tabs)", "src/lib/tabs.ts") → the project's folders. */
69
+ export function localizePaths(text, paths = {}) {
70
+ const map = { app: paths.app, components: paths.components, lib: paths.lib };
71
+ return text.replace(/\bsrc\/(app|components|lib)\//g, (all, folder) => (map[folder] ? `${map[folder].replace(/\/+$/, '')}/` : all));
72
+ }
73
+
74
+ /** A template file rewritten for `target` (from routerTarget). Files without SDK-specific imports come back unchanged. */
75
+ export function adaptLiquidGlassFile(rel, text, target) {
76
+ if (rel === 'components/LiquidGlassTabs.tsx') {
77
+ const standalone = target.icons === 'standalone';
78
+ const header =
79
+ `// Expo SDK ${target.sdk}${standalone ? ': standalone Icon / Label / Badge / VectorIcon (NativeTabs.Trigger.* is SDK 55+)' : ''}.\n` +
80
+ `// After upgrading Expo, run \`npx @expokit/cli@latest update\` to get the matching imports.\n` +
81
+ `import { Tabs } from '${target.tabs}';\n` +
82
+ (standalone
83
+ ? `import { Badge, Icon, Label, NativeTabs, VectorIcon } from '${target.nativeTabs}';\n`
84
+ : `import { NativeTabs } from '${target.nativeTabs}';\n`);
85
+ let out = replaceOnce(
86
+ text,
87
+ /\/\/ Written for your Expo SDK[^\n]*\n\/\/[^\n]*\nimport \{ Tabs \} from 'expo-router\/js-tabs';\nimport \{ NativeTabs \} from 'expo-router\/unstable-native-tabs';\n/,
88
+ header,
89
+ 'LiquidGlassTabs imports'
90
+ );
91
+ if (standalone) out = out.replace(/\bNativeTabs\.Trigger\.(Icon|Label|Badge|VectorIcon)\b/g, '$1');
92
+ return out;
93
+ }
94
+ if (rel === 'components/GlassTabBar.tsx') {
95
+ return replaceOnce(
96
+ text,
97
+ /\/\/ '@react-navigation\/bottom-tabs' on SDK[^\n]*\nimport type \{ BottomTabBarProps \} from 'expo-router\/js-tabs';\n/,
98
+ `import type { BottomTabBarProps } from '${target.tabBarProps}';\n`,
99
+ 'GlassTabBar imports'
100
+ );
101
+ }
102
+ if (rel === 'app/_layout.tsx') {
103
+ const imports =
104
+ target.themes === 'expo-router'
105
+ ? "// SDK 56+: navigation themes come from expo-router itself. Using @react-navigation/native's\n" +
106
+ '// ThemeProvider here does nothing (expo-router ships its own copy) and causes a white flash.\n' +
107
+ "import { DarkTheme, DefaultTheme, Slot, ThemeProvider } from 'expo-router';\n"
108
+ : "import { Slot } from 'expo-router';\n" +
109
+ `// SDK ${target.sdk}: navigation themes come from React Navigation (expo-router re-exports them from SDK 56).\n` +
110
+ "import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';\n";
111
+ return replaceOnce(
112
+ text,
113
+ /\/\/ Navigation themes:[^\n]*\n\/\/[^\n]*\nimport \{ DarkTheme, DefaultTheme, Slot, ThemeProvider \} from 'expo-router';\n/,
114
+ imports,
115
+ 'root layout imports'
116
+ );
117
+ }
118
+ return text;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Existing apps: routes → tabs.ts
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const ROUTE_EXT = /\.(tsx|ts|jsx|js)$/;
126
+ const PLATFORM_EXT = /\.(ios|android|web|native)$/;
127
+
128
+ /**
129
+ * Tab names of a navigator from the entries of its folder (`{ name, dir }`): route files minus
130
+ * platform suffixes, and sub-folders. Skips layouts, `+not-found`-style special files and `_`/`.` names.
131
+ */
132
+ export function routeNamesFromEntries(entries) {
133
+ const names = [];
134
+ for (const { name, dir } of entries) {
135
+ if (name.startsWith('.') || name.startsWith('_') || name.startsWith('+')) continue;
136
+ let route;
137
+ if (dir) route = name;
138
+ else {
139
+ if (!ROUTE_EXT.test(name) || name.endsWith('.d.ts')) continue;
140
+ route = name.replace(ROUTE_EXT, '').replace(PLATFORM_EXT, '');
141
+ }
142
+ if (!names.includes(route)) names.push(route);
143
+ }
144
+ return names;
145
+ }
146
+
147
+ /** "index" → "Home", "my-cards" → "My cards", "[id]" → "Id". */
148
+ export function tabTitle(name) {
149
+ if (name === 'index') return 'Home';
150
+ const words = String(name).replace(/[[\]().]/g, ' ').split(/[\s_-]+/).filter(Boolean);
151
+ if (!words.length) return String(name);
152
+ const text = words.join(' ').toLowerCase();
153
+ return text.charAt(0).toUpperCase() + text.slice(1);
154
+ }
155
+
156
+ /**
157
+ * Tab order and titles from an existing layout: `<Tabs.Screen name="x" options={{ title: 'X' }}>` or
158
+ * `<NativeTabs.Trigger name="x"><…Label>X</…Label>`. Best effort — unknown shapes give [].
159
+ */
160
+ export function readTabsFromLayout(text) {
161
+ const src = String(text ?? '');
162
+ const re = /<(Tabs\.Screen|NativeTabs\.Trigger)\b[^>]*?\bname=["'{]+([^"'}]+)["'}]+/g;
163
+ const hits = [...src.matchAll(re)];
164
+ return hits.map((m, i) => {
165
+ const end = i + 1 < hits.length ? hits[i + 1].index : src.length;
166
+ const block = src.slice(m.index, end);
167
+ const title =
168
+ /\btitle:\s*["']([^"']+)["']/.exec(block)?.[1] ??
169
+ /<(?:NativeTabs\.Trigger\.)?Label\b[^>]*>\s*([^<{]+?)\s*<\//.exec(block)?.[1] ??
170
+ null;
171
+ return { name: m[2], title };
172
+ });
173
+ }
174
+
175
+ /** [match words, SF Symbol default/selected, Ionicons default/selected] — a sensible icon per common tab name. */
176
+ const ICON_GUESSES = [
177
+ [['index', 'home', 'feed'], 'house', 'house.fill', 'home-outline', 'home'],
178
+ [['explore', 'discover', 'browse'], 'safari', 'safari.fill', 'compass-outline', 'compass'],
179
+ [['search', 'find'], 'magnifyingglass', 'magnifyingglass', 'search-outline', 'search'],
180
+ [['profile', 'account', 'me', 'you', 'user'], 'person.crop.circle', 'person.crop.circle.fill', 'person-circle-outline', 'person-circle'],
181
+ [['settings', 'preferences'], 'gearshape', 'gearshape.fill', 'settings-outline', 'settings'],
182
+ [['alerts', 'notifications', 'activity'], 'bell', 'bell.fill', 'notifications-outline', 'notifications'],
183
+ [['messages', 'chat', 'chats', 'inbox'], 'bubble.left.and.bubble.right', 'bubble.left.and.bubble.right.fill', 'chatbubbles-outline', 'chatbubbles'],
184
+ [['cart', 'bag', 'shop', 'store', 'orders'], 'bag', 'bag.fill', 'bag-outline', 'bag'],
185
+ [['favorites', 'favourites', 'likes', 'saved'], 'heart', 'heart.fill', 'heart-outline', 'heart'],
186
+ [['cards', 'wallet', 'pay', 'payments'], 'creditcard', 'creditcard.fill', 'card-outline', 'card'],
187
+ [['calendar', 'schedule', 'events'], 'calendar', 'calendar', 'calendar-outline', 'calendar'],
188
+ [['library', 'books'], 'books.vertical', 'books.vertical.fill', 'library-outline', 'library'],
189
+ ];
190
+
191
+ export function guessTabIcon(name) {
192
+ const key = String(name).toLowerCase().replace(/[^a-z]/g, '');
193
+ const hit = ICON_GUESSES.find(([words]) => words.includes(key)) ?? ICON_GUESSES.find(([words]) => words.some((w) => key.includes(w)));
194
+ const [, sf, sfSelected, ion, ionSelected] = hit ?? [null, 'circle', 'circle.fill', 'ellipse-outline', 'ellipse'];
195
+ return { sf, sfSelected, ion, ionSelected };
196
+ }
197
+
198
+ const q = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
199
+
200
+ /** One TABS entry (same layout as the template's, so parseTabNamesFromConfig reads it back). */
201
+ export function tabEntrySource({ name, title }) {
202
+ const icon = guessTabIcon(name);
203
+ const sf = icon.sf === icon.sfSelected ? q(icon.sf) : `{ default: ${q(icon.sf)}, selected: ${q(icon.sfSelected)} }`;
204
+ const lines = [
205
+ ' {',
206
+ ` name: ${q(name)},`,
207
+ ` title: ${q(title ?? tabTitle(name))},`,
208
+ ...(/^\[.*\]$/.test(name) ? [' hidden: true,'] : []),
209
+ ` sf: ${sf},`,
210
+ ` icon: { set: 'Ionicons', name: ${q(icon.ion)}, selected: ${q(icon.ionSelected)} },`,
211
+ ' },',
212
+ ];
213
+ return lines.join('\n');
214
+ }
215
+
216
+ /**
217
+ * Tabs for an existing app: its route names, ordered and titled like its current layout when that
218
+ * layout names them, `index` first otherwise.
219
+ */
220
+ export function tabsFromRoutes(routes, layoutTabs = []) {
221
+ const known = layoutTabs.filter((t) => routes.includes(t.name));
222
+ const rest = routes.filter((r) => !known.some((t) => t.name === r)).sort((a, b) => (a === 'index' ? -1 : b === 'index' ? 1 : a.localeCompare(b)));
223
+ return [...known.map((t) => ({ name: t.name, title: t.title ?? tabTitle(t.name) })), ...rest.map((name) => ({ name, title: tabTitle(name) }))];
224
+ }
225
+
226
+ /** The template's tabs.ts with its TABS list replaced by `tabs`. */
227
+ export function replaceTabsList(tabsTs, tabs) {
228
+ const body = tabs.map(tabEntrySource).join('\n');
229
+ return replaceOnce(tabsTs, /export const TABS: TabItem\[\] = \[[\s\S]*?\n\];/, `export const TABS: TabItem[] = [\n${body}\n];`, 'tabs.ts TABS');
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // create-expo-app starters (safe to replace: nothing of the user's in them yet)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /** SDK 54 starter app/(tabs)/_layout.tsx: JS Tabs with HapticTab + IconSymbol. */
237
+ export function isStarterTabsLayout(text) {
238
+ const s = String(text ?? '');
239
+ return /from ['"]@\/components\/haptic-?tab['"]/i.test(s) && /IconSymbol/.test(s) && !/Redirect|useAuth|useSession/.test(s);
240
+ }
241
+
242
+ /** SDK 55+ starter components/app-tabs(.web).tsx: the starter's two tabs (index, explore). */
243
+ export function isStarterAppTabs(text) {
244
+ const s = String(text ?? '');
245
+ if (/Redirect|useAuth|useSession/.test(s)) return false;
246
+ const native = /tabIcons\/home\.png/.test(s) && /tabIcons\/explore\.png/.test(s);
247
+ const web = /from ['"]expo-router\/ui['"]/.test(s) && /TabList/.test(s) && /href=["']\/explore["']/.test(s);
248
+ return native || web;
249
+ }
250
+
251
+ /** create-expo-app root layouts (SDK 54: Stack anchored on (tabs) + modal; SDK 55+: AnimatedSplashOverlay + AppTabs). */
252
+ export function isStarterRootLayout(text) {
253
+ const s = String(text ?? '');
254
+ const sdk54 = /anchor:\s*['"]\(tabs\)['"]/.test(s) && /name=["']modal["']/.test(s);
255
+ const sdk55 = /AnimatedSplashOverlay/.test(s) && /AppTabs/.test(s);
256
+ const providers = [...s.matchAll(/<(\w+Provider)\b/g)].map((m) => m[1]).filter((p) => p !== 'ThemeProvider');
257
+ return (sdk54 || sdk55) && !providers.length && !/Redirect/.test(s);
258
+ }
259
+
260
+ /** Files the template ships that a later `update` may refresh: anything that names its building blocks. */
261
+ export function isLiquidGlassSource(text) {
262
+ return /\b(USE_NATIVE_TABS|LiquidGlassTabs|GlassTabBar|TabScreenFromConfig|tabBarCollapse|asFamily|TAB_BAR)\b/.test(String(text ?? ''));
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // package.json diff (what `npx expo install` changed)
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /** `{ added: [[name, version]], changed: [[name, from, to]] }` between two package.json objects. */
270
+ export function diffDependencies(before = {}, after = {}) {
271
+ const all = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
272
+ const a = all(before);
273
+ const b = all(after);
274
+ const added = [];
275
+ const changed = [];
276
+ for (const [name, version] of Object.entries(b)) {
277
+ if (!(name in a)) added.push([name, version]);
278
+ else if (a[name] !== version) changed.push([name, a[name], version]);
279
+ }
280
+ return { added, changed };
281
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@expokit/cli",
3
- "version": "0.4.1",
4
- "description": "shadcn-style CLI for Expo — add native UI blocks and components to your app",
3
+ "version": "0.4.3",
4
+ "mcpName": "io.github.Anshkaran7/expokit",
5
+ "description": "Animated UI components for Expo and React Native — a shadcn-style CLI that copies buttons, inputs, cards, charts and full screens into your app",
5
6
  "license": "MIT",
6
7
  "type": "module",
7
8
  "bin": {
@@ -17,9 +18,22 @@
17
18
  "keywords": [
18
19
  "expo",
19
20
  "react-native",
20
- "cli",
21
+ "react-native-ui",
22
+ "ui-components",
23
+ "components",
24
+ "component-library",
25
+ "ui-kit",
21
26
  "ui",
27
+ "design-system",
28
+ "animation",
29
+ "animated",
30
+ "haptics",
31
+ "dark-mode",
32
+ "ios",
33
+ "android",
22
34
  "shadcn",
35
+ "shadcn-ui",
36
+ "cli",
23
37
  "liquid-glass",
24
38
  "native-tabs",
25
39
  "expo-router"
@@ -11,6 +11,7 @@
11
11
  "src/app/(tabs)/profile.tsx",
12
12
  "src/app/_layout.tsx",
13
13
  "src/components/GlassTabBar.tsx",
14
+ "src/components/LiquidGlassTabs.tsx",
14
15
  "src/components/TabScreen.tsx",
15
16
  "src/lib/tab-bar.ts",
16
17
  "src/lib/tab-icons.ts",
@@ -1,97 +1,6 @@
1
- import React from 'react';
2
- import { DynamicColorIOS, Platform } from 'react-native';
3
- import { Tabs } from 'expo-router/js-tabs';
4
- // SDK 54–57 path. On SDK 58+ change this to 'expo-router/native-tabs'.
5
- import { NativeTabs } from 'expo-router/unstable-native-tabs';
6
-
7
- import { GlassTabBar } from '@/components/GlassTabBar';
8
- import { USE_NATIVE_TABS } from '@/lib/tab-bar';
9
- import {
10
- asFamily,
11
- resolveIconLibrary,
12
- resolveIconNames,
13
- TAB_BAR,
14
- TABS,
15
- type TabItem,
16
- } from '@/lib/tabs';
17
-
18
- type IconProps = React.ComponentProps<typeof NativeTabs.Trigger.Icon>;
19
-
20
- function dynamicColor(pair: { light: string; dark: string }) {
21
- return Platform.OS === 'ios' ? DynamicColorIOS(pair) : pair.light;
22
- }
23
-
24
- /** Vector-font icon rasterised for the native bar (Android, or iOS when no SF Symbol is set). */
25
- function vectorSrc(tab: TabItem) {
26
- const library = resolveIconLibrary(tab);
27
- const { name, selected } = resolveIconNames(tab);
28
- if (!library || !name) return undefined;
29
-
30
- const family = asFamily(library);
31
- const icon = <NativeTabs.Trigger.VectorIcon family={family} name={name} />;
32
- if (!selected) return icon;
33
- return { default: icon, selected: <NativeTabs.Trigger.VectorIcon family={family} name={selected} /> };
34
- }
35
-
36
- /**
37
- * Returns the Icon element itself (not a wrapper component): NativeTabs reads its
38
- * children by type, so <NativeTabs.Trigger.Icon> must be a direct child of the Trigger.
39
- */
40
- function nativeIcon(tab: TabItem, tint: IconProps['selectedColor']) {
41
- const src = tab.src ?? vectorSrc(tab);
42
- if (!tab.sf && !src) return null;
43
- // sf wins on iOS, src is used on Android.
44
- const props = { sf: tab.sf, src, selectedColor: tint } as IconProps;
45
- return <NativeTabs.Trigger.Icon {...props} />;
46
- }
47
-
48
- function NativeTabLayout() {
49
- const tint = dynamicColor(TAB_BAR.colors.tint);
50
- const inactive = dynamicColor(TAB_BAR.colors.inactive);
51
- const { fontSize, fontWeight, selectedFontWeight } = TAB_BAR.labelStyle;
52
-
53
- return (
54
- <NativeTabs
55
- tintColor={tint}
56
- iconColor={inactive}
57
- labelVisibilityMode={TAB_BAR.labelVisibilityMode}
58
- minimizeBehavior={TAB_BAR.minimizeBehavior}
59
- labelStyle={{ color: inactive, fontSize, fontWeight }}
60
- {...(TAB_BAR.backgroundColor != null ? { backgroundColor: TAB_BAR.backgroundColor } : null)}
61
- >
62
- {TABS.map((tab) => (
63
- <NativeTabs.Trigger key={tab.name} name={tab.name} hidden={tab.hidden}>
64
- {nativeIcon(tab, tint)}
65
- <NativeTabs.Trigger.Label
66
- hidden={tab.hiddenLabel}
67
- selectedStyle={{ color: tint, fontSize, fontWeight: selectedFontWeight }}
68
- >
69
- {tab.title}
70
- </NativeTabs.Trigger.Label>
71
- {tab.badge ? <NativeTabs.Trigger.Badge>{tab.badge}</NativeTabs.Trigger.Badge> : null}
72
- </NativeTabs.Trigger>
73
- ))}
74
- </NativeTabs>
75
- );
76
- }
77
-
78
- function GlassTabLayout() {
79
- return (
80
- <Tabs
81
- tabBar={(props) => <GlassTabBar {...props} />}
82
- screenOptions={{ headerShown: false, animation: 'fade' }}
83
- >
84
- {TABS.map((tab) => (
85
- <Tabs.Screen
86
- key={tab.name}
87
- name={tab.name}
88
- options={{ title: tab.title, href: tab.hidden ? null : undefined }}
89
- />
90
- ))}
91
- </Tabs>
92
- );
93
- }
1
+ import { LiquidGlassTabs } from '@/components/LiquidGlassTabs';
94
2
 
3
+ // Tabs, icons and colours live in lib/tabs.ts. Auth redirects etc. go here, before the return.
95
4
  export default function TabLayout() {
96
- return USE_NATIVE_TABS ? <NativeTabLayout /> : <GlassTabLayout />;
5
+ return <LiquidGlassTabs />;
97
6
  }
@@ -1,5 +1,6 @@
1
1
  import { TabScreenFromConfig } from '@/components/TabScreen';
2
2
 
3
3
  export default function AlertsScreen() {
4
- return <TabScreenFromConfig tabName="alerts" />;
4
+ // The button is pinned with useTabBarInset(), so it clears the floating bar on every OS.
5
+ return <TabScreenFromConfig tabName="alerts" action="Mark all as read" />;
5
6
  }
@@ -3,8 +3,8 @@ import React, { useEffect } from 'react';
3
3
  import { StyleSheet, useColorScheme } from 'react-native';
4
4
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
5
5
  import { SafeAreaProvider } from 'react-native-safe-area-context';
6
- // SDK 56+: navigation themes come from expo-router itself. Using @react-navigation/native's
7
- // ThemeProvider here does nothing (expo-router ships its own copy) and causes a white flash.
6
+ // Navigation themes: from 'expo-router' on SDK 56+ (it ships its own copy, so the
7
+ // @react-navigation/native ThemeProvider would do nothing there), '@react-navigation/native' on SDK 54–55.
8
8
  import { DarkTheme, DefaultTheme, Slot, ThemeProvider } from 'expo-router';
9
9
  import * as SplashScreen from 'expo-splash-screen';
10
10
 
@@ -14,6 +14,7 @@ import {
14
14
  } from 'react-native';
15
15
  import { BlurView } from 'expo-blur';
16
16
  import * as Haptics from 'expo-haptics';
17
+ // '@react-navigation/bottom-tabs' on SDK 54–55 (the CLI writes the right one).
17
18
  import type { BottomTabBarProps } from 'expo-router/js-tabs';
18
19
 
19
20
  import { GLASS_BAR_HEIGHT, setTabBarCollapsed, tabBarCollapse } from '@/lib/tab-bar';
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import { DynamicColorIOS, Platform } from 'react-native';
3
+ // Written for your Expo SDK by `npx @expokit/cli add liquidglass-tab` (SDK 54–55: Tabs from
4
+ // 'expo-router'; SDK 58+: NativeTabs from 'expo-router/native-tabs'). `update` keeps it in sync.
5
+ import { Tabs } from 'expo-router/js-tabs';
6
+ import { NativeTabs } from 'expo-router/unstable-native-tabs';
7
+
8
+ import { GlassTabBar } from '@/components/GlassTabBar';
9
+ import { USE_NATIVE_TABS } from '@/lib/tab-bar';
10
+ import {
11
+ asFamily,
12
+ resolveIconLibrary,
13
+ resolveIconNames,
14
+ TAB_BAR,
15
+ TABS,
16
+ type TabItem,
17
+ } from '@/lib/tabs';
18
+
19
+ type IconProps = React.ComponentProps<typeof NativeTabs.Trigger.Icon>;
20
+
21
+ function dynamicColor(pair: { light: string; dark: string }) {
22
+ return Platform.OS === 'ios' ? DynamicColorIOS(pair) : pair.light;
23
+ }
24
+
25
+ /** Vector-font icon rasterised for the native bar (Android, or iOS when no SF Symbol is set). */
26
+ function vectorSrc(tab: TabItem) {
27
+ const library = resolveIconLibrary(tab);
28
+ const { name, selected } = resolveIconNames(tab);
29
+ if (!library || !name) return undefined;
30
+
31
+ const family = asFamily(library);
32
+ const icon = <NativeTabs.Trigger.VectorIcon family={family} name={name} />;
33
+ if (!selected) return icon;
34
+ return { default: icon, selected: <NativeTabs.Trigger.VectorIcon family={family} name={selected} /> };
35
+ }
36
+
37
+ /**
38
+ * Returns the Icon element itself (not a wrapper component): NativeTabs reads its
39
+ * children by type, so the Icon must be a direct child of the Trigger.
40
+ */
41
+ function nativeIcon(tab: TabItem, tint: IconProps['selectedColor']) {
42
+ // SF Symbol on iOS when set; otherwise the image / vector icon (always on Android).
43
+ if (Platform.OS === 'ios' && tab.sf) {
44
+ return <NativeTabs.Trigger.Icon {...({ sf: tab.sf, selectedColor: tint } as IconProps)} />;
45
+ }
46
+ const src = tab.src ?? vectorSrc(tab);
47
+ if (!src) return null;
48
+ return <NativeTabs.Trigger.Icon {...({ src, selectedColor: tint } as IconProps)} />;
49
+ }
50
+
51
+ function NativeTabLayout() {
52
+ const tint = dynamicColor(TAB_BAR.colors.tint);
53
+ const inactive = dynamicColor(TAB_BAR.colors.inactive);
54
+ const { fontSize, fontWeight, selectedFontWeight } = TAB_BAR.labelStyle;
55
+
56
+ return (
57
+ <NativeTabs
58
+ tintColor={tint}
59
+ iconColor={inactive}
60
+ labelVisibilityMode={TAB_BAR.labelVisibilityMode}
61
+ minimizeBehavior={TAB_BAR.minimizeBehavior}
62
+ labelStyle={{ color: inactive, fontSize, fontWeight }}
63
+ {...(TAB_BAR.backgroundColor != null ? { backgroundColor: TAB_BAR.backgroundColor } : null)}
64
+ >
65
+ {TABS.map((tab) => (
66
+ <NativeTabs.Trigger key={tab.name} name={tab.name} hidden={tab.hidden}>
67
+ {nativeIcon(tab, tint)}
68
+ <NativeTabs.Trigger.Label
69
+ hidden={tab.hiddenLabel}
70
+ selectedStyle={{ color: tint, fontSize, fontWeight: selectedFontWeight }}
71
+ >
72
+ {tab.title}
73
+ </NativeTabs.Trigger.Label>
74
+ {tab.badge ? <NativeTabs.Trigger.Badge>{tab.badge}</NativeTabs.Trigger.Badge> : null}
75
+ </NativeTabs.Trigger>
76
+ ))}
77
+ </NativeTabs>
78
+ );
79
+ }
80
+
81
+ function GlassTabLayout() {
82
+ return (
83
+ <Tabs
84
+ tabBar={(props) => <GlassTabBar {...props} />}
85
+ screenOptions={{ headerShown: false, animation: 'fade' }}
86
+ >
87
+ {TABS.map((tab) => (
88
+ <Tabs.Screen
89
+ key={tab.name}
90
+ name={tab.name}
91
+ options={{ title: tab.title, href: tab.hidden ? null : undefined }}
92
+ />
93
+ ))}
94
+ </Tabs>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The whole tab bar, driven by lib/tabs.ts: system NativeTabs with real Liquid Glass on
100
+ * iOS 26+, the floating glass fallback everywhere else. Render it from the layout that owns
101
+ * your tab routes — app/(tabs)/_layout.tsx — after any redirects:
102
+ *
103
+ * export default function TabLayout() {
104
+ * if (!signedIn) return <Redirect href="/sign-in" />;
105
+ * return <LiquidGlassTabs />;
106
+ * }
107
+ */
108
+ export function LiquidGlassTabs() {
109
+ return USE_NATIVE_TABS ? <NativeTabLayout /> : <GlassTabLayout />;
110
+ }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import {
3
+ Pressable,
3
4
  ScrollView,
4
5
  StyleSheet,
5
6
  Text,
@@ -15,13 +16,17 @@ interface TabScreenProps {
15
16
  title: string;
16
17
  description: string;
17
18
  accent: string;
19
+ /** Label of a button pinned to the bottom of the screen, above the tab bar. */
20
+ action?: string;
18
21
  }
19
22
 
20
- export function TabScreen({ title, description, accent }: TabScreenProps) {
23
+ export function TabScreen({ title, description, accent, action }: TabScreenProps) {
21
24
  const isDark = useColorScheme() === 'dark';
22
25
  const insets = useSafeAreaInsets();
23
26
  const onScroll = useTabBarScroll();
24
- const bottomInset = useTabBarInset();
27
+ const bottomInset = useTabBarInset(24);
28
+ // Bottom-anchored UI sits on top of the bar's footprint, not on the screen edge.
29
+ const actionBottom = useTabBarInset(12);
25
30
  const bg = isDark ? '#0f0f1a' : '#f2f2f7';
26
31
  const text = isDark ? '#fff' : '#1c1c1e';
27
32
  const sub = isDark ? '#9898a8' : '#6c6c70';
@@ -32,11 +37,11 @@ export function TabScreen({ title, description, accent }: TabScreenProps) {
32
37
  style={{ flex: 1, backgroundColor: bg }}
33
38
  contentContainerStyle={[
34
39
  styles.scroll,
35
- { paddingTop: USE_NATIVE_TABS ? 12 : insets.top + 12, paddingBottom: bottomInset },
40
+ { paddingTop: insets.top + 12, paddingBottom: bottomInset },
36
41
  ]}
37
42
  showsVerticalScrollIndicator={false}
38
- // Native tabs inset content for the system bars; the glass fallback pads it manually.
39
- contentInsetAdjustmentBehavior={USE_NATIVE_TABS ? 'automatic' : 'never'}
43
+ // Pad by hand on every bar: useTabBarInset() already counts the floating bar and home indicator.
44
+ contentInsetAdjustmentBehavior="never"
40
45
  scrollEventThrottle={16}
41
46
  onScroll={onScroll}
42
47
  >
@@ -64,12 +69,21 @@ export function TabScreen({ title, description, accent }: TabScreenProps) {
64
69
  </Text>
65
70
  </View>
66
71
  ))}
72
+ {action ? <View style={{ height: 64 }} /> : null}
67
73
  </ScrollView>
74
+ {action ? (
75
+ <Pressable
76
+ accessibilityRole="button"
77
+ style={({ pressed }) => [styles.action, { bottom: actionBottom, backgroundColor: accent, opacity: pressed ? 0.85 : 1 }]}
78
+ >
79
+ <Text style={styles.actionText}>{action}</Text>
80
+ </Pressable>
81
+ ) : null}
68
82
  </View>
69
83
  );
70
84
  }
71
85
 
72
- export function TabScreenFromConfig({ tabName }: { tabName: string }) {
86
+ export function TabScreenFromConfig({ tabName, action }: { tabName: string; action?: string }) {
73
87
  const tab = getTab(tabName);
74
88
  if (!tab) {
75
89
  return null;
@@ -83,6 +97,7 @@ export function TabScreenFromConfig({ tabName }: { tabName: string }) {
83
97
  'Scroll to test native tab bar minimize behavior on iOS 26+.'
84
98
  }
85
99
  accent={tab.accent ?? '#6366f1'}
100
+ action={action}
86
101
  />
87
102
  );
88
103
  }
@@ -119,4 +134,14 @@ const styles = StyleSheet.create({
119
134
  },
120
135
  cardTitle: { fontSize: 17, fontWeight: '400' },
121
136
  cardSub: { fontSize: 14, marginTop: 4, lineHeight: 20 },
137
+ action: {
138
+ position: 'absolute',
139
+ left: 20,
140
+ right: 20,
141
+ height: 50,
142
+ borderRadius: 25,
143
+ alignItems: 'center',
144
+ justifyContent: 'center',
145
+ },
146
+ actionText: { color: '#fff', fontSize: 16, fontWeight: '500' },
122
147
  });
@@ -53,9 +53,17 @@ export function useTabBarScroll() {
53
53
  }, []);
54
54
  }
55
55
 
56
- /** Bottom padding a scrolling screen needs so its last row clears the floating bar. */
57
- export function useTabBarInset() {
56
+ /**
57
+ * How far the tab bar covers the bottom of a screen, plus `gap`. Both bars float over the
58
+ * content — iOS 26's Liquid Glass bar and the glass fallback — so use it for anything that
59
+ * reaches the bottom edge: a ScrollView's paddingBottom (with
60
+ * contentInsetAdjustmentBehavior="never"), a bottom-anchored button's `bottom`, or the
61
+ * paddingBottom of a full-screen layout. Android's system bar (TAB_BAR.legacy = 'native')
62
+ * sits below the content, so there it is just `gap`.
63
+ */
64
+ export function useTabBarInset(gap = 8) {
58
65
  const insets = useSafeAreaInsets();
59
- if (USE_NATIVE_TABS) return 24;
60
- return GLASS_BAR_HEIGHT + Math.max(insets.bottom, 12) + 24;
66
+ if (USE_NATIVE_TABS && Platform.OS === 'android') return gap;
67
+ // The system bar has no height API; it sits in the same spot as the fallback pill.
68
+ return GLASS_BAR_HEIGHT + Math.max(insets.bottom, 12) + gap;
61
69
  }
@@ -19,7 +19,8 @@ import type { ColorValue, ImageSourcePropType } from 'react-native';
19
19
  * Preset sets (no import needed): Ionicons, Feather, FontAwesome, …
20
20
  * Custom font: iconFont() / icoMoon()
21
21
  * Custom module: library + icon on TabItem
22
- * PNG: src on TabItem
22
+ * PNG: src on TabItem — SVG kits (Hugeicons, Lucide, Tabler) → PNGs with
23
+ * npx @expokit/cli@latest icons --from hugeicons --names Home01Icon --out assets/tab-icons
23
24
  */
24
25
 
25
26
  export type IconSetName =
@@ -85,8 +86,10 @@ export function asFamily(mod: unknown): VectorIconFamily {
85
86
  }
86
87
 
87
88
  throw new Error(
88
- '[expo-kit] This icon package has no getImageSource (SVG kits like Hugeicons, Tabler, Lucide).\n' +
89
- 'Use icon.set preset, src PNG, iconFont(), or icoMoon().'
89
+ '[expo-kit] This icon package has no getImageSource — SVG kits (Hugeicons, Lucide, Tabler) can not be drawn by the native tab bar.\n' +
90
+ 'Render them to PNGs and use `src` on the tab:\n' +
91
+ ' npx @expokit/cli@latest icons --from hugeicons --names Home01Icon,Search01Icon --out assets/tab-icons\n' +
92
+ 'Or use an icon.set preset, iconFont() or icoMoon().'
90
93
  );
91
94
  }
92
95
 
@@ -14,12 +14,14 @@ export type { IconSetName, TabIconPreset } from '@/lib/tab-icons';
14
14
 
15
15
  /**
16
16
  * Edit this file only — tabs, labels, icons, colors, and screen copy.
17
- * Each tab needs a route file: src/app/(tabs)/{name}.tsx (use TabScreenFromConfig).
17
+ * Each tab needs a route file: src/app/(tabs)/{name}.tsx — `npx @expokit/cli@latest update`
18
+ * creates a missing one.
18
19
  *
19
20
  * Icons (pick one per tab):
20
21
  * icon: { set: 'Ionicons', name: 'home-outline', selected: 'home' }
21
22
  * library + iconName (any package with getImageSource)
22
- * src: require('../assets/icon.png')
23
+ * src: require('../assets/icon.png') — or { default, selected }. SVG kits (Hugeicons, Lucide,
24
+ * Tabler): npx @expokit/cli@latest icons --from hugeicons --names Home01Icon --out assets/tab-icons
23
25
  */
24
26
 
25
27
  export interface TabItem {