miaoda-expo-devkit 0.1.1-beta.8 → 0.1.1-beta.80

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.
Files changed (48) hide show
  1. package/README.md +355 -8
  2. package/biome-config.json +27 -1
  3. package/dist/babel/plugin-jsx-source.d.ts +11 -2
  4. package/dist/babel/plugin-jsx-source.js +33 -3
  5. package/dist/babel/plugin-lucide-react-native.d.ts +31 -0
  6. package/dist/babel/plugin-lucide-react-native.js +14790 -0
  7. package/dist/babel/preset.d.ts +8 -0
  8. package/dist/babel/preset.js +18 -6
  9. package/dist/cli/lint.js +51 -11
  10. package/dist/metro.d.mts +311 -62
  11. package/dist/metro.d.ts +311 -62
  12. package/dist/metro.js +424 -11
  13. package/dist/metro.mjs +411 -11
  14. package/dist/rules/no-duplicate-expo-router-url.js +17 -15
  15. package/dist/rules/no-expo-video-compat.js +2041 -0
  16. package/dist/rules/no-inline-box-shadow-string.js +56 -0
  17. package/dist/rules/no-invalid-tabs-screen.js +193 -0
  18. package/dist/rules/no-missing-css-import.js +100 -0
  19. package/dist/rules/no-missing-image-import.js +105 -0
  20. package/dist/rules/no-missing-notification-asset.js +127 -0
  21. package/dist/rules/no-pressable-without-on-press.js +77 -0
  22. package/dist/rules/no-splash-screen-missing-image.js +126 -0
  23. package/dist/rules/no-undeclared-expo-plugin.js +34 -12
  24. package/dist/rules/no-unregistered-dynamic-tab-route.js +183 -0
  25. package/dist/rules/no-unused-expo-plugin.js +210 -0
  26. package/dist/stubs/expo-blur-stub.js +29 -0
  27. package/dist/stubs/expo-calendar-stub.js +314 -0
  28. package/dist/stubs/expo-camera-record-stub.js +143 -0
  29. package/dist/stubs/expo-contacts-stub.js +309 -0
  30. package/dist/stubs/expo-file-system-next-stub.js +203 -0
  31. package/dist/stubs/expo-file-system-stub.js +214 -0
  32. package/dist/stubs/expo-haptics-stub.js +84 -0
  33. package/dist/stubs/expo-image-picker-stub.js +263 -0
  34. package/dist/stubs/expo-linear-gradient-stub.js +28 -0
  35. package/dist/stubs/expo-media-library-stub.js +141 -0
  36. package/dist/stubs/expo-notifications-stub.js +177 -0
  37. package/dist/stubs/lgui-control.js +68 -5
  38. package/dist/stubs/navigation-guard-spy.js +86 -0
  39. package/dist/stubs/no-op-logbox.js +5 -2
  40. package/dist/stubs/sentry-feedback-stub.js +60 -0
  41. package/dist/stubs/sentry-replay-canvas-stub.js +35 -0
  42. package/dist/stubs/sentry-replay-stub.js +41 -0
  43. package/dist/stubs/web-stub-dialog.js +168 -0
  44. package/dist/utils/navigation-guard-detector.js +57 -0
  45. package/oxlint-config.json +39 -1
  46. package/package.json +85 -30
  47. package/pnpm-config.json +1 -6
  48. package/tsconfig-base.json +7 -0
package/README.md CHANGED
@@ -7,6 +7,19 @@ Expo / React Native 开发环境工具集,通过 Metro 构建层注入以下
7
7
  - **Bundle 首部注入** — 在 expo-router 初始化之前执行自定义脚本
8
8
  - **HMR postMessage 控制** — 通过 `window.postMessage` 在运行时启动或停止 Fast Refresh
9
9
  - **LogBox 屏蔽** — web 平台禁用 Expo 全屏错误遮罩
10
+ - **Metro transform 缓存持久化** — 将缓存目录固定到项目根目录(可通过 `METRO_CACHE_DIR` 指定),容器/CI 重启后不丢失
11
+ - **构建耗时日志** — 将 bundle 总耗时和每个 cache miss 文件的 transform 耗时写入 JSONL 文件(通过 `METRO_TRANSFORM_LOG` 启用)
12
+ - **esbuild minifier** — 生产构建时将 Metro minifier 切换为 esbuild(比 terser 快数十倍)
13
+ - **WASM 支持** — 将 `.wasm` 加入 assetExts,修复 expo-sqlite web worker 打包失败
14
+ - **lucide-react-native 路径解析** — 配合 babel 插件,消除图标子路径 exports 未声明的 warning
15
+ - **workspace node_modules 修复** — 修复沙箱环境中 node_modules 位于祖先目录时 Metro 模块解析和 bundle 请求失败的问题
16
+ - **expo-notifications stub** — Expo Go(Android)中提供 no-op 实现,核心 API 调用时弹出带参数校验的调试 Alert,Dev Build 透传真实模块
17
+ - **expo-media-library stub** — Expo Go / Web 中提供 no-op 实现,`saveToLibraryAsync`、`createAssetAsync`、权限请求等 API 调用时弹出 Alert 提示,Dev Build 原生环境透传真实模块
18
+ - **expo-calendar stub** — Expo Go / Web 中提供 no-op 实现,`getEventsAsync`、`createEventAsync`、`getCalendarsAsync`、权限请求等核心 API 调用时弹出 Alert 提示并校验参数,Dev Build 原生环境透传真实模块
19
+ - **expo-file-system stub** — Web 中将 `expo-file-system` 和 `expo-file-system/legacy` 替换为 no-op stub,核心 API 弹 Dialog 提示,Expo Go / Dev Build 透传真实模块
20
+ - **expo-haptics stub** — Web 中提供 no-op 实现,触觉 API 调用时弹 Dialog 提示参数信息,native 不受影响
21
+ - **expo-contacts stub** — Web / Expo Go 中提供 no-op 实现,联系人 API 调用时弹 Dialog 提示,Dev Build 透传真实模块
22
+ - **expo-image-picker stub** — 桌面 Web 中 `launchCameraAsync` 通过 `getUserMedia` 打开摄像头预览弹窗(浏览器原生 `capture` 属性在 PC 端被忽略),移动端浏览器透传 expo 原实现,Native 不受影响
10
23
 
11
24
  ## 安装
12
25
 
@@ -32,16 +45,16 @@ pnpm install
32
45
  ```js
33
46
  // metro.config.js
34
47
  const { getDefaultConfig } = require('expo/metro-config');
35
- const { withDevStubs, withEntryInjection } = require('miaoda-expo-devkit/metro');
48
+ const { withDevkit } = require('miaoda-expo-devkit/metro');
36
49
 
37
- const config = getDefaultConfig(__dirname);
38
- module.exports = withEntryInjection(withDevStubs(config));
50
+ module.exports = withDevkit(getDefaultConfig(__dirname));
39
51
  ```
40
52
 
41
- 支持与其他 Metro wrapper 链式组合:
53
+ `withDevkit` 已内置所有 wrapper(含 expo-notifications、expo-media-library、expo-calendar stub),无需手动叠加。也可单独使用各 wrapper:
42
54
 
43
55
  ```js
44
- module.exports = withNativeWind(withEntryInjection(withDevStubs(config)), { input: './global.css' });
56
+ const { withDevStubs, withEntryInjection, withExpoMediaLibraryStub, withExpoCalendarStub } = require('miaoda-expo-devkit/metro');
57
+ module.exports = withExpoCalendarStub(withExpoMediaLibraryStub(withEntryInjection(withDevStubs(config))));
45
58
  ```
46
59
 
47
60
  ### Sentry 初始化
@@ -220,18 +233,88 @@ expect(onError).toHaveBeenCalledWith(
220
233
  | 变量 | 默认值 | 说明 |
221
234
  |---|---|---|
222
235
  | `SENTRY_OVERRIDE_DSN` | `https://stubPublicKey@o0.ingest.sentry.io/0` | 覆盖 Sentry DSN,可指向本地 relay 等自定义端点 |
236
+ | `METRO_CACHE_DIR` | `projectRoot/.metro-cache` | Metro transform 缓存目录绝对路径,优先级最高,适合容器/CI 挂载外部持久目录 |
237
+ | `METRO_TRANSFORM_LOG` | _(未设置时不记录)_ | 构建日志输出文件的绝对路径(JSONL 格式),仅开发模式(`__DEV__`)下生效 |
223
238
 
224
239
  ## 工作原理
225
240
 
226
241
  ```
227
242
  metro.config.js
228
- └─ withEntryInjection(withDevStubs(config))
243
+ └─ withDevkit(config)
244
+
245
+ ├─ withTransformLogger → unstable_perfLoggerFactory + metro-core Logger
246
+ │ ├─ CACHE_CONFIG 条目(启动时由 withPersistentCache 写入,含 cache_root / store_class / source)
247
+ │ ├─ BUNDLING_REQUEST 条目(每次 bundle 请求写一条)
248
+ │ │ ├─ duration_ms、status、initial_build、graph_node_count
249
+ │ │ └─ transform_miss_count(cache miss 文件数;0 = 完全命中)
250
+ │ └─ TRANSFORM_FILE 条目(每个 cache miss 文件写一条,命中则不写)
251
+ │ └─ file、duration_ms
252
+ │ (需设置 METRO_TRANSFORM_LOG 且 __DEV__ 才生效)
253
+
254
+ ├─ withPersistentCache → config.cacheStores
255
+ │ ├─ 缓存路径优先级:METRO_CACHE_DIR > options.cacheDir > projectRoot/.metro-cache
256
+ │ └─ 保留 @expo/metro-config FileStore 子类行为(NativeWind skipCache 标志)
257
+
258
+ ├─ withWorkspaceNodeModules → watchFolders + resolver.nodeModulesPaths
259
+ │ ├─ 向上查找祖先目录的 node_modules,加入 watchFolders
260
+ │ └─ 若 .pnpm 是指向外部路径的 symlink,也将外部真实路径加入 watchFolders
261
+
262
+ ├─ withWasmSupport → resolver.assetExts
263
+ │ └─ 将 .wasm 加入 assetExts,修复 expo-sqlite web worker 打包
264
+
265
+ ├─ withCssInterop → 为 expo-image / expo-camera 等注入 NativeWind cssInterop
266
+
267
+ ├─ withEsbuildMinify → transformer.minifierPath(仅生产构建)
268
+ │ └─ 切换为 metro-minify-esbuild,清空 terser 专属 minifierConfig
269
+
270
+ ├─ withLucideResolver → resolver.resolveRequest
271
+ │ └─ lucide-react-native/dist/** 子路径 → 绝对文件路径(绕过 exports 检查)
272
+ │ └─ 自动检测 .mjs(>= 1.9)vs .js(1.8.x)扩展名
229
273
 
230
274
  ├─ withDevStubs → resolver.resolveRequest
231
275
  │ ├─ @sentry/react-native → dist/stubs/sentry-react-native-stub.js (全平台)
232
276
  │ └─ @expo/log-box → dist/stubs/no-op-logbox.js (仅 web)
233
277
 
234
- └─ withEntryInjection → resolver.resolveRequest
278
+ ├─ withExpoNotificationsStub → resolver.resolveRequest
279
+ │ └─ expo-notifications → dist/stubs/expo-notifications-stub.js (仅 Android)
280
+ │ ├─ Expo Go:no-op + 调试 Alert(含参数校验)
281
+ │ └─ Dev Build:透传真实 expo-notifications
282
+
283
+ ├─ withExpoMediaLibraryStub → resolver.resolveRequest
284
+ │ └─ expo-media-library → dist/stubs/expo-media-library-stub.js (全平台)
285
+ │ ├─ Expo Go / Web:no-op + Alert 提示(不崩溃)
286
+ │ └─ Dev Build(原生):透传真实 expo-media-library
287
+
288
+ ├─ withExpoCalendarStub → resolver.resolveRequest
289
+ │ └─ expo-calendar → dist/stubs/expo-calendar-stub.js (全平台)
290
+ │ ├─ Expo Go / Web:no-op + Alert 提示 + 参数校验(不崩溃)
291
+ │ └─ Dev Build(原生):透传真实 expo-calendar
292
+
293
+ ├─ withExpoFileSystemStub → resolver.resolveRequest
294
+ │ ├─ expo-file-system/legacy → dist/stubs/expo-file-system-stub.js (全平台)
295
+ │ └─ expo-file-system → dist/stubs/expo-file-system-next-stub.js
296
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
297
+ │ └─ Expo Go / Dev Build(原生):透传真实模块
298
+
299
+ ├─ withExpoHapticsStub → resolver.resolveRequest
300
+ │ └─ expo-haptics → dist/stubs/expo-haptics-stub.js (仅 web)
301
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
302
+ │ └─ native:透传真实 expo-haptics
303
+
304
+ ├─ withExpoContactsStub → resolver.resolveRequest
305
+ │ └─ expo-contacts → dist/stubs/expo-contacts-stub.js (仅 web)
306
+ │ ├─ Web / Expo Go:no-op + Dialog 提示(不崩溃)
307
+ │ └─ Dev Build(原生):透传真实 expo-contacts
308
+
309
+ ├─ withExpoImagePickerStub → resolver.resolveRequest
310
+ │ └─ expo-image-picker → dist/stubs/expo-image-picker-stub.js (仅 web)
311
+ │ ├─ 桌面 Web:launchCameraAsync → getUserMedia + #__devkit_camera_overlay__ 弹窗
312
+ │ │ ├─ 点"拍照":canvas.toDataURL → ImagePickerResult { canceled:false, assets }
313
+ │ │ └─ 点"取消":返回 { canceled:true, assets:null }
314
+ │ ├─ 移动端浏览器:透传 expo 原实现(capture 属性正常工作)
315
+ │ └─ getUserMedia 不可用:降级透传 expo 原实现(不崩溃)
316
+
317
+ └─ withEntryInjection → resolver.resolveRequest(仅 __DEV__)
235
318
  └─ expo-router/entry-classic → dist/stubs/expo-router-entry-stub.js
236
319
  ├─ require('./entry-inject') ← 注入脚本(bundle 首部执行)
237
320
  │ ├─ globalThis.__DEVKIT_INJECTED__ = true
@@ -262,10 +345,19 @@ sentry-react-native-stub.js
262
345
  | 子路径 | 文件 | 内容 |
263
346
  |---|---|---|
264
347
  | `.` | `dist/index.js` | `SentryCapture`、`MetroSymbolicator`、全部类型 |
265
- | `./metro` | `dist/metro.js` | `withDevStubs`、`withEntryInjection` |
348
+ | `./metro` | `dist/metro.js` | `withDevkit`、`withDevStubs`、`withEntryInjection`、`withPersistentCache`、`withTransformLogger`、`withExpoNotificationsStub`、`withExpoMediaLibraryStub`、`withExpoCalendarStub` 等全部 Metro wrapper |
266
349
  | `./babel-plugin-jsx-source` | `dist/babel/plugin-jsx-source.js` | Babel 插件:为 JSX 注入 source 信息 |
350
+ | `./babel-preset` | `dist/babel/preset.js` | Babel Preset:集成 jsx-source 和 lucide 插件 |
267
351
  | `./sentry-react-native-stub` | `dist/stubs/sentry-react-native-stub.js` | `@sentry/react-native` 模块替换 stub |
268
352
  | `./no-op-logbox` | `dist/stubs/no-op-logbox.js` | LogBox no-op stub |
353
+ | `./expo-notifications-stub` | `dist/stubs/expo-notifications-stub.js` | `expo-notifications` Expo Go Android stub |
354
+ | `./expo-media-library-stub` | `dist/stubs/expo-media-library-stub.js` | `expo-media-library` Expo Go / Web stub |
355
+ | `./expo-calendar-stub` | `dist/stubs/expo-calendar-stub.js` | `expo-calendar` Expo Go / Web stub |
356
+ | `./expo-file-system-stub` | `dist/stubs/expo-file-system-stub.js` | `expo-file-system/legacy` Web stub |
357
+ | `./expo-file-system-next-stub` | `dist/stubs/expo-file-system-next-stub.js` | `expo-file-system` 新版 API Web stub |
358
+ | `./expo-haptics-stub` | `dist/stubs/expo-haptics-stub.js` | `expo-haptics` Web stub |
359
+ | `./expo-contacts-stub` | `dist/stubs/expo-contacts-stub.js` | `expo-contacts` Web / Expo Go stub |
360
+ | `./expo-image-picker-stub` | `dist/stubs/expo-image-picker-stub.js` | `expo-image-picker` 桌面 Web stub |
269
361
 
270
362
  ---
271
363
 
@@ -388,6 +480,183 @@ LogBox no-op stub,用于 web 平台禁用 Expo 全屏错误遮罩。
388
480
 
389
481
  ---
390
482
 
483
+ ### expo-notifications-stub.js
484
+
485
+ `expo-notifications` 模块替换 stub,由 `withExpoNotificationsStub()` 在 Metro 层注入(**仅 Android 平台**)。
486
+
487
+ **背景:** Expo SDK 53 起,`expo-notifications` 的 Android native module 已从 Expo Go 中移除,直接 import 会在 Expo Go 启动时崩溃。
488
+
489
+ **运行时行为:**
490
+ - **Expo Go(Android)**:提供 no-op 实现,`requestPermissionsAsync`、`setNotificationChannelAsync`、`scheduleNotificationAsync` 等核心 API 调用时弹出带参数校验的调试 Alert
491
+ - **Development Build(Android)**:透传真实 `expo-notifications`,功能完全正常
492
+ - **iOS(任意)**:不经过此 stub,直接使用真实 `expo-notifications`
493
+
494
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Notification Stub 验证」页面,逐按钮触发并对照期望结果。
495
+
496
+ ---
497
+
498
+ ### expo-media-library-stub.js
499
+
500
+ `expo-media-library` 模块替换 stub,由 `withExpoMediaLibraryStub()` 在 Metro 层注入(**全平台**)。
501
+
502
+ **背景:** `expo-media-library` 依赖原生相册 API,在 Expo Go 和 Web 环境中不可用,调用 `saveToLibraryAsync` 等 API 会直接崩溃。
503
+
504
+ **运行时行为:**
505
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert 提示(不崩溃):
506
+ - `usePermissions()` — 返回 `{ status: 'undetermined', granted: false }`,`requestPermission()` 弹 Alert
507
+ - `requestPermissionsAsync()` / `getPermissionsAsync()` — 前者弹 Alert,后者静默返回 denied
508
+ - `saveToLibraryAsync(uri)` — 弹 Alert 显示操作和 URI(超 60 字符自动截断)
509
+ - `createAssetAsync(uri)` — 弹 Alert 并返回合法的伪资产对象
510
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
511
+ - **Development Build(原生)**:透传真实 `expo-media-library`,功能完全正常
512
+
513
+ **Alert 消息格式:**
514
+ ```
515
+ 保存到相册
516
+ Expo Go 扫码预览不支持访问手机相册
517
+ 操作: 图片: file:///tmp/test.jpg
518
+ 发布为正式 App 后可正常使用
519
+ ```
520
+
521
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Media Library Stub 验证」页面,逐按钮触发并对照期望结果。
522
+
523
+ ---
524
+
525
+ ### expo-calendar-stub.js
526
+
527
+ `expo-calendar` 模块替换 stub,由 `withExpoCalendarStub()` 在 Metro 层注入(**全平台**)。
528
+
529
+ **背景:** `expo-calendar` 依赖原生系统日历 API,在 Expo Go 和 Web 环境中不可用,调用 `getEventsAsync` 等 API 会直接崩溃。
530
+
531
+ **运行时行为:**
532
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert / Dialog 提示(不崩溃):
533
+ - `useCalendarPermissions()` / `useRemindersPermissions()` — 初始返回 `undetermined`,`requestPermission()` 弹 Alert
534
+ - `requestCalendarPermissionsAsync()` / `requestRemindersPermissionsAsync()` — 弹 Alert 提示,返回 denied
535
+ - `getCalendarPermissionsAsync()` / `getRemindersPermissionsAsync()` — 静默返回 denied
536
+ - `getCalendarsAsync(entityType?)` — 弹 Alert 显示查询类型,返回空数组
537
+ - `createCalendarAsync(details)` — 弹 Alert 含名称和颜色,校验 `details.title` 非空
538
+ - `updateCalendarAsync(id, details)` / `deleteCalendarAsync(id)` — 弹 Alert 含日历 ID,校验 ID 格式
539
+ - `getEventsAsync(calendarIds, startDate, endDate)` — 弹 Alert 含时间范围,校验 calendarIds 非空及日期合法性,返回空数组
540
+ - `createEventAsync(calendarId, eventData)` — 弹 Alert 含标题和时间,Android 下校验 startDate/endDate
541
+ - `updateEventAsync(id, details)` / `deleteEventAsync(id)` — 弹 Alert 含事件 ID,校验 ID 格式
542
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
543
+ - **Development Build(原生)**:透传真实 `expo-calendar`,功能完全正常
544
+
545
+ **枚举常量**(stub & Dev Build 均可用):
546
+ `EntityTypes`、`Frequency`、`Availability`、`CalendarType`、`EventStatus`、`SourceType`、
547
+ `AttendeeRole`、`AttendeeStatus`、`AttendeeType`、`AlarmMethod`、`EventAccessLevel`、
548
+ `CalendarAccessLevel`、`ReminderStatus`、`DayOfTheWeek`、`MonthOfTheYear`
549
+
550
+ **Alert 消息格式(合规示例):**
551
+ ```
552
+ 创建日历事件
553
+ 秒哒扫码预览不支持访问手机日历
554
+
555
+ 日历 ID: cal1
556
+ 标题: 团队会议
557
+ 开始: 2025/1/1 10:00:00
558
+ 结束: 2025/1/1 11:00:00
559
+
560
+ ✅ 参数合规
561
+ 发布为正式 App 后可正常使用
562
+ ```
563
+
564
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Calendar Stub 验证」页面,逐按钮触发并对照期望结果。
565
+
566
+ ---
567
+
568
+ ### expo-file-system-stub.js / expo-file-system-next-stub.js
569
+
570
+ `expo-file-system` 模块替换 stub,由 `withExpoFileSystemStub()` 在 Metro 层注入(**全平台**)。
571
+
572
+ **背景:** `expo-file-system` 的文件 API 在 Web 端不可用:legacy API 底层方法缺失会抛 `UnavailabilityError`;新版 API(`File` / `Directory`)基类缺少 `validatePath()`,`new File(...)` 会抛 `TypeError: this.validatePath is not a function`。
573
+
574
+ **拦截路径:**
575
+ - `expo-file-system/legacy` → `expo-file-system-stub.js`(legacy API)
576
+ - `expo-file-system` → `expo-file-system-next-stub.js`(新版 File/Directory/Paths API)
577
+
578
+ **运行时行为:**
579
+ - **Web**:提供 no-op 实现,核心 API 调用时弹出 Dialog 提示(不崩溃)
580
+ - **Expo Go / Development Build(原生)**:透传真实 `expo-file-system`,功能完全正常
581
+
582
+ ---
583
+
584
+ ### expo-haptics-stub.js
585
+
586
+ `expo-haptics` 模块替换 stub,由 `withExpoHapticsStub()` 在 Metro 层注入(**仅 web 平台**)。
587
+
588
+ **背景:** Web 没有振动/触觉硬件 API,`expo-haptics` 在 web 会运行时崩溃。
589
+
590
+ **运行时行为:**
591
+ - **Web**:提供 no-op 实现,`impactAsync`、`notificationAsync`、`selectionAsync` 调用时弹出 Dialog 提示参数信息(不崩溃);枚举常量(`ImpactFeedbackStyle`、`NotificationFeedbackType` 等)正常可用
592
+ - **Expo Go / Development Build(native)**:透传真实 `expo-haptics`,功能完全正常
593
+
594
+ ---
595
+
596
+ ### expo-contacts-stub.js
597
+
598
+ `expo-contacts` 模块替换 stub,由 `withExpoContactsStub()` 在 Metro 层注入(**仅 web 平台**)。
599
+
600
+ **背景:** `expo-contacts` 依赖 native module,在 Web 和 Expo Go 中不可用。
601
+
602
+ **运行时行为:**
603
+ - **Web / Expo Go**:提供 no-op 实现,联系人 API 调用时弹出 Dialog 提示(不崩溃)
604
+ - **Development Build(原生)**:透传真实 `expo-contacts`,功能完全正常
605
+
606
+ ---
607
+
608
+ ### expo-image-picker-stub.js
609
+
610
+ `expo-image-picker` 模块替换 stub,由 `withExpoImagePickerStub()` 在 Metro 层注入(**仅 web 平台**)。
611
+
612
+ **背景:** `expo-image-picker` 的 `launchCameraAsync` 在 web 上底层使用 `<input type="file" capture="environment">`。桌面浏览器(Chrome、Firefox、Safari)**有意忽略 `capture` 属性**,直接弹出文件选择框而不是摄像头。这是浏览器厂商的硬限制,无法通过任何 HTML 属性或 meta 标签绕过。
613
+
614
+ **运行时行为:**
615
+ - **桌面 Web(非 mobile UA)**:`launchCameraAsync` 调用 `navigator.mediaDevices.getUserMedia` 打开摄像头预览弹窗(`#__devkit_camera_overlay__`),用户点击"📷 拍照"后截取一帧并以 `data:image/jpeg` 格式返回,点击"取消"返回 `{ canceled: true }`
616
+ - **移动端浏览器(Android Chrome / iOS Safari)**:透传 expo 原实现,`capture` 属性在移动端正常调起系统相机
617
+ - **`getUserMedia` 不可用**(无摄像头或浏览器限制):静默降级,透传 expo 原实现(不崩溃)
618
+ - **Native(iOS / Android)**:Metro resolver 不拦截,直接使用原生 `expo-image-picker`,功能完全不变
619
+
620
+ **弹窗 DOM 结构(供 Playwright 定位):**
621
+ ```html
622
+ <div id="__devkit_camera_overlay__"> <!-- 全屏遮罩 -->
623
+ <div> <!-- 面板 -->
624
+ <p>拍照</p>
625
+ <video autoplay muted playsinline> <!-- 摄像头预览 -->
626
+ <div>
627
+ <button>📷 拍照</button>
628
+ <button>取消</button>
629
+ </div>
630
+ <p><!-- 错误信息(getUserMedia 失败时显示) --></p>
631
+ </div>
632
+ </div>
633
+ ```
634
+
635
+ **返回值格式(与 expo 原实现兼容):**
636
+ ```ts
637
+ // 拍照成功
638
+ {
639
+ canceled: false,
640
+ assets: [{
641
+ uri: 'data:image/jpeg;base64,...',
642
+ width: 640,
643
+ height: 480,
644
+ type: 'image',
645
+ fileName: 'photo_1234567890.jpg',
646
+ mimeType: 'image/jpeg',
647
+ base64: null,
648
+ exif: null,
649
+ }]
650
+ }
651
+
652
+ // 取消
653
+ { canceled: true, assets: null }
654
+ ```
655
+
656
+ **E2E 测试:** `devkit-e2e/tests/e2e/image-picker-stub.spec.ts`,使用 `addInitScript` mock `getUserMedia`(注入 canvas stream 替代真实摄像头),覆盖弹窗出现/消失、拍照返回结果、取消、降级等场景。
657
+
658
+ ---
659
+
391
660
  ## Babel 插件:jsx-source
392
661
 
393
662
  `babel-plugin-jsx-source` 为 JSX 元素注入 source 属性(文件路径、行列号),用于开发调试。通过 `dataSet` 对象注入,这是 React Web 可识别的数据通道。
@@ -434,3 +703,81 @@ module.exports = {
434
703
  |---|---|---|---|
435
704
  | `rootDir` | `string` | - | 项目根目录,用于计算相对路径。不提供则使用绝对路径 |
436
705
  | `excludePaths` | `string[]` | `[]` | 跳过注入的路径模式列表(相对于 rootDir 的路径片段) |
706
+
707
+ ---
708
+
709
+ ## Babel 插件:lucide-react-native
710
+
711
+ Metro 没有 tree-shaking,直接写 `import { Star } from "lucide-react-native"` 会把整个图标库(约 1500 个图标)打进 bundle。`babel-plugin-lucide-react-native` 在编译阶段将具名导入改写为直接按文件导入,彻底规避这个问题。
712
+
713
+ **转换效果:**
714
+
715
+ ```ts
716
+ // 转换前
717
+ import { Star, BookOpen } from "lucide-react-native";
718
+
719
+ // 转换后(CJS,默认)
720
+ import _Star from "lucide-react-native/dist/cjs/icons/star";
721
+ import _BookOpen from "lucide-react-native/dist/cjs/icons/book-open";
722
+ ```
723
+
724
+ ### 配置
725
+
726
+ 推荐通过内置 Preset 一次性启用所有 Babel 插件:
727
+
728
+ ```js
729
+ // babel.config.js
730
+ module.exports = {
731
+ presets: [['miaoda-expo-devkit/babel-preset', { excludePaths: ['src/components/ui'] }]],
732
+ };
733
+ ```
734
+
735
+ 也可以单独使用:
736
+
737
+ ```js
738
+ module.exports = {
739
+ plugins: [['miaoda-expo-devkit/babel-plugin-lucide-react-native']],
740
+ };
741
+ ```
742
+
743
+ ### 选项
744
+
745
+ | 选项 | 类型 | 默认值 | 说明 |
746
+ |---|---|---|---|
747
+ | `useES` | `boolean` | `false` | 使用 ESM 格式(`dist/esm/icons/`)而非默认的 CJS |
748
+
749
+ ### 支持的写法
750
+
751
+ 插件可识别所有常见的图标引用写法:
752
+
753
+ ```ts
754
+ import { BookOpen, Star, Crown } from "lucide-react-native";
755
+
756
+ // JSX 直接使用
757
+ <BookOpen size={24} />
758
+
759
+ // 赋值给变量
760
+ const icon = BookOpen;
761
+
762
+ // 对象 value
763
+ const ITEMS = [{ icon: BookOpen }, { icon: Star }];
764
+
765
+ // 计算属性 key
766
+ const map = { [BookOpen]: 'read' };
767
+
768
+ // 数组、三元、函数参数
769
+ const list = [BookOpen, Star];
770
+ const active = flag ? BookOpen : Star;
771
+ renderIcon(Crown);
772
+
773
+ // 导入别名
774
+ import { BookOpen as ReadIcon } from "lucide-react-native";
775
+ <ReadIcon />
776
+
777
+ // re-export
778
+ export { Star, BookOpen as ReadIcon } from "lucide-react-native";
779
+ ```
780
+
781
+ ### 版本兼容性
782
+
783
+ 插件在运行时探测 `lucide-react-native` 的实际目录结构,自动适配新旧版本的路径差异(`>= 1.9` 新增 `icons/` 子目录;旧版 `1.8.x` 图标直接位于 `dist/cjs/`),lucide 升级时无需修改配置。
package/biome-config.json CHANGED
@@ -1,4 +1,18 @@
1
1
  {
2
+ "vcs": {
3
+ "enabled": true,
4
+ "clientKind": "git",
5
+ "useIgnoreFile": true
6
+ },
7
+ "files": {
8
+ "includes": [
9
+ "src/**/*.{js,jsx,ts,tsx,css,scss}",
10
+ "tailwind.config.js"
11
+ ],
12
+ "experimentalScannerIgnores": [
13
+ "src/components/ui/**"
14
+ ]
15
+ },
2
16
  "linter": {
3
17
  "enabled": true,
4
18
  "rules": {
@@ -10,5 +24,17 @@
10
24
  },
11
25
  "formatter": {
12
26
  "enabled": false
13
- }
27
+ },
28
+ "overrides": [
29
+ {
30
+ "includes": ["tailwind.config.js"],
31
+ "linter": {
32
+ "rules": {
33
+ "style": {
34
+ "noCommonJs": "off"
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ]
14
40
  }
@@ -1,4 +1,4 @@
1
- import { types, PluginObj } from '@babel/core';
1
+ import { types, PluginObj, PluginPass } from '@babel/core';
2
2
 
3
3
  /**
4
4
  * babel-plugin-jsx-source
@@ -14,8 +14,10 @@ import { types, PluginObj } from '@babel/core';
14
14
  *
15
15
  * 对于纯文本节点:
16
16
  * <div>hello world</div>
17
+ * <div>{"hello world"}</div>
17
18
  * 转换后:
18
19
  * <div dataSet={{"mdId": "path/to/file.tsx:10:4", "componentContent": "{\"text\":\"hello world\"}"}}>hello world</div>
20
+ * <div dataSet={{"mdId": "path/to/file.tsx:11:4", "componentContent": "{\"text\":\"hello world\"}"}}>{"hello world"}</div>
19
21
  *
20
22
  * 用法(babel.config.js):
21
23
  *
@@ -35,12 +37,19 @@ interface JsxSourcePluginOptions {
35
37
  rootDir?: string;
36
38
  /** 需要跳过注入的路径模式列表(相对于 rootDir 的路径片段) */
37
39
  excludePaths?: string[];
40
+ /** 需要跳过注入的包名列表,从这些包导入的组件不会被注入 */
41
+ excludePackages?: string[];
42
+ }
43
+ interface PluginState extends PluginPass {
44
+ opts: JsxSourcePluginOptions;
45
+ /** 当前文件中来自被排除包的本地标识符集合,Program 阶段初始化 */
46
+ excludedComponents: Set<string>;
38
47
  }
39
48
  /**
40
49
  * Babel 插件:为 JSX 元素注入 dataSet
41
50
  */
42
51
  declare function babelPluginJsxSource({ types: t, }: {
43
52
  types: typeof types;
44
- }): PluginObj;
53
+ }): PluginObj<PluginState>;
45
54
 
46
55
  export { type JsxSourcePluginOptions, babelPluginJsxSource as default };
@@ -23,6 +23,7 @@ __export(plugin_jsx_source_exports, {
23
23
  default: () => babelPluginJsxSource
24
24
  });
25
25
  module.exports = __toCommonJS(plugin_jsx_source_exports);
26
+ var DEFAULT_EXCLUDE_PACKAGES = ["react-native-gifted-charts"];
26
27
  var DATASET_PROP_NAME = "dataSet";
27
28
  var MD_ID_KEY = "mdId";
28
29
  var MD_CONTENT_KEY = "componentContent";
@@ -69,9 +70,18 @@ function getTextNodeContent(t, path) {
69
70
  const children = parent.children;
70
71
  if (children.length !== 1) return null;
71
72
  const child = children[0];
72
- if (!t.isJSXText(child)) return null;
73
- const text = child.value.trim();
74
- return text || null;
73
+ if (t.isJSXText(child)) {
74
+ const text = child.value.trim();
75
+ return text || null;
76
+ }
77
+ if (t.isJSXExpressionContainer(child) && t.isStringLiteral(child.expression)) {
78
+ const text = child.expression.value.trim();
79
+ return text || null;
80
+ }
81
+ if (t.isJSXExpressionContainer(child) && t.isNumericLiteral(child.expression)) {
82
+ return String(child.expression.value);
83
+ }
84
+ return null;
75
85
  }
76
86
  function shouldSkipElement(t, name) {
77
87
  if (t.isJSXIdentifier(name)) {
@@ -97,12 +107,32 @@ function babelPluginJsxSource({
97
107
  return {
98
108
  name: "babel-plugin-jsx-source",
99
109
  visitor: {
110
+ Program(_path, state) {
111
+ const excludePackages = [
112
+ ...DEFAULT_EXCLUDE_PACKAGES,
113
+ ...state.opts.excludePackages ?? []
114
+ ];
115
+ const excluded = /* @__PURE__ */ new Set();
116
+ for (const node of _path.node.body) {
117
+ if (t.isImportDeclaration(node) && excludePackages.some(
118
+ (pkg) => node.source.value === pkg || node.source.value.startsWith(pkg + "/")
119
+ )) {
120
+ for (const specifier of node.specifiers) {
121
+ excluded.add(specifier.local.name);
122
+ }
123
+ }
124
+ }
125
+ state.excludedComponents = excluded;
126
+ },
100
127
  JSXOpeningElement(path, state) {
101
128
  const { opts, filename } = state;
102
129
  if (!filename) return;
103
130
  if (filename.includes("node_modules")) return;
104
131
  if (opts.excludePaths?.some((p) => filename.includes(p))) return;
105
132
  if (shouldSkipElement(t, path.node.name)) return;
133
+ const elementName = path.node.name;
134
+ if (t.isJSXIdentifier(elementName) && state.excludedComponents.has(elementName.name)) return;
135
+ if (t.isJSXMemberExpression(elementName) && t.isJSXIdentifier(elementName.object) && state.excludedComponents.has(elementName.object.name)) return;
106
136
  const loc = path.node.loc;
107
137
  if (!loc) return;
108
138
  let filePath = filename;
@@ -0,0 +1,31 @@
1
+ import babelCore from '@babel/core';
2
+
3
+ /**
4
+ * babel-plugin-lucide-react-native
5
+ *
6
+ * 将 lucide-react-native 的具名导入转换为按图标文件直接导入,绕过 Metro
7
+ * 缺乏 tree-shaking 的问题,避免将整个图标库打进 bundle。
8
+ *
9
+ * 转换示意:
10
+ * import { Star, BookHeart } from "lucide-react-native"
11
+ * →
12
+ * import _Star from "lucide-react-native/dist/cjs/icons/star"
13
+ * import _BookHeart from "lucide-react-native/dist/cjs/icons/book-heart"
14
+ */
15
+
16
+ type Core = typeof babelCore;
17
+ interface Config extends babelCore.PluginPass {
18
+ opts: {
19
+ /** 使用 ESM 格式(dist/esm)。默认 false,使用 CJS(dist/cjs)。 */
20
+ useES?: boolean;
21
+ /**
22
+ * lucide-react-native 包根目录的绝对路径(含 package.json 的那一层)。
23
+ * 不传时通过 require.resolve 自动定位,适用于生产场景。
24
+ * 测试时可传入特定版本的路径,验证不同版本的兼容性。
25
+ */
26
+ lucidePkgRoot?: string;
27
+ };
28
+ }
29
+ declare function lucideReactNativePlugin({ types: t }: Core): babelCore.PluginObj<Config>;
30
+
31
+ export { lucideReactNativePlugin as default };