miaoda-expo-devkit 0.1.1-beta.9 → 0.1.1-beta.91

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 (55) hide show
  1. package/README.md +427 -8
  2. package/biome-config.json +37 -1
  3. package/dist/babel/plugin-jsx-source.d.ts +9 -2
  4. package/dist/babel/plugin-jsx-source.js +21 -0
  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 +24 -6
  9. package/dist/cli/lint.js +17304 -11
  10. package/dist/metro.d.mts +316 -62
  11. package/dist/metro.d.ts +316 -62
  12. package/dist/metro.js +428 -12
  13. package/dist/metro.mjs +415 -12
  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-gifted-charts-missing-linear-gradient.js +101 -0
  17. package/dist/rules/no-invalid-tabs-screen.js +193 -0
  18. package/dist/rules/no-missing-css-import.js +13 -11
  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-function-style.js +78 -0
  22. package/dist/rules/no-pressable-without-on-press.js +77 -0
  23. package/dist/rules/no-splash-screen-missing-image.js +140 -0
  24. package/dist/rules/no-undeclared-expo-plugin.js +34 -12
  25. package/dist/rules/no-unregistered-dynamic-tab-route.js +191 -0
  26. package/dist/rules/no-unused-expo-plugin.js +211 -0
  27. package/dist/stubs/entry-inject.js +2 -0
  28. package/dist/stubs/expo-blur-stub.js +29 -0
  29. package/dist/stubs/expo-calendar-stub.js +316 -0
  30. package/dist/stubs/expo-camera-record-stub.js +143 -0
  31. package/dist/stubs/expo-contacts-stub.js +310 -0
  32. package/dist/stubs/expo-file-system-next-stub.js +204 -0
  33. package/dist/stubs/expo-file-system-stub.js +215 -0
  34. package/dist/stubs/expo-haptics-stub.js +85 -0
  35. package/dist/stubs/expo-image-picker-stub.js +264 -0
  36. package/dist/stubs/expo-linear-gradient-stub.js +28 -0
  37. package/dist/stubs/expo-media-library-stub.js +142 -0
  38. package/dist/stubs/expo-notifications-stub.js +178 -0
  39. package/dist/stubs/i18n/en.js +195 -0
  40. package/dist/stubs/i18n/index.js +66 -0
  41. package/dist/stubs/i18n/zh.js +198 -0
  42. package/dist/stubs/lgui-control.js +68 -5
  43. package/dist/stubs/navigation-guard-spy.js +86 -0
  44. package/dist/stubs/no-op-logbox.js +5 -2
  45. package/dist/stubs/screenshot-control.js +3729 -0
  46. package/dist/stubs/sentry-feedback-stub.js +60 -0
  47. package/dist/stubs/sentry-react-native-stub.js +1 -1
  48. package/dist/stubs/sentry-replay-canvas-stub.js +35 -0
  49. package/dist/stubs/sentry-replay-stub.js +41 -0
  50. package/dist/stubs/web-stub-dialog.js +169 -0
  51. package/dist/utils/navigation-guard-detector.js +57 -0
  52. package/oxlint-config.json +40 -1
  53. package/package.json +86 -30
  54. package/pnpm-config.json +2 -2
  55. package/tsconfig-base.json +7 -0
package/README.md CHANGED
@@ -7,6 +7,20 @@ 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 不受影响
23
+ - **devkit-lint** — 集成 Oxlint(18 条自定义规则)、Biome、TypeScript 类型检查、以及 `app.json` 字段合法性校验,一条命令完成项目全量静态检查
10
24
 
11
25
  ## 安装
12
26
 
@@ -32,16 +46,16 @@ pnpm install
32
46
  ```js
33
47
  // metro.config.js
34
48
  const { getDefaultConfig } = require('expo/metro-config');
35
- const { withDevStubs, withEntryInjection } = require('miaoda-expo-devkit/metro');
49
+ const { withDevkit } = require('miaoda-expo-devkit/metro');
36
50
 
37
- const config = getDefaultConfig(__dirname);
38
- module.exports = withEntryInjection(withDevStubs(config));
51
+ module.exports = withDevkit(getDefaultConfig(__dirname));
39
52
  ```
40
53
 
41
- 支持与其他 Metro wrapper 链式组合:
54
+ `withDevkit` 已内置所有 wrapper(含 expo-notifications、expo-media-library、expo-calendar stub),无需手动叠加。也可单独使用各 wrapper:
42
55
 
43
56
  ```js
44
- module.exports = withNativeWind(withEntryInjection(withDevStubs(config)), { input: './global.css' });
57
+ const { withDevStubs, withEntryInjection, withExpoMediaLibraryStub, withExpoCalendarStub } = require('miaoda-expo-devkit/metro');
58
+ module.exports = withExpoCalendarStub(withExpoMediaLibraryStub(withEntryInjection(withDevStubs(config))));
45
59
  ```
46
60
 
47
61
  ### Sentry 初始化
@@ -220,18 +234,88 @@ expect(onError).toHaveBeenCalledWith(
220
234
  | 变量 | 默认值 | 说明 |
221
235
  |---|---|---|
222
236
  | `SENTRY_OVERRIDE_DSN` | `https://stubPublicKey@o0.ingest.sentry.io/0` | 覆盖 Sentry DSN,可指向本地 relay 等自定义端点 |
237
+ | `METRO_CACHE_DIR` | `projectRoot/.metro-cache` | Metro transform 缓存目录绝对路径,优先级最高,适合容器/CI 挂载外部持久目录 |
238
+ | `METRO_TRANSFORM_LOG` | _(未设置时不记录)_ | 构建日志输出文件的绝对路径(JSONL 格式),仅开发模式(`__DEV__`)下生效 |
223
239
 
224
240
  ## 工作原理
225
241
 
226
242
  ```
227
243
  metro.config.js
228
- └─ withEntryInjection(withDevStubs(config))
244
+ └─ withDevkit(config)
245
+ │
246
+ ├─ withTransformLogger → unstable_perfLoggerFactory + metro-core Logger
247
+ │ ├─ CACHE_CONFIG 条目(启动时由 withPersistentCache 写入,含 cache_root / store_class / source)
248
+ │ ├─ BUNDLING_REQUEST 条目(每次 bundle 请求写一条)
249
+ │ │ ├─ duration_ms、status、initial_build、graph_node_count
250
+ │ │ └─ transform_miss_count(cache miss 文件数;0 = 完全命中)
251
+ │ └─ TRANSFORM_FILE 条目(每个 cache miss 文件写一条,命中则不写)
252
+ │ └─ file、duration_ms
253
+ │ (需设置 METRO_TRANSFORM_LOG 且 __DEV__ 才生效)
254
+ │
255
+ ├─ withPersistentCache → config.cacheStores
256
+ │ ├─ 缓存路径优先级:METRO_CACHE_DIR > options.cacheDir > projectRoot/.metro-cache
257
+ │ └─ 保留 @expo/metro-config FileStore 子类行为(NativeWind skipCache 标志)
258
+ │
259
+ ├─ withWorkspaceNodeModules → watchFolders + resolver.nodeModulesPaths
260
+ │ ├─ 向上查找祖先目录的 node_modules,加入 watchFolders
261
+ │ └─ 若 .pnpm 是指向外部路径的 symlink,也将外部真实路径加入 watchFolders
262
+ │
263
+ ├─ withWasmSupport → resolver.assetExts
264
+ │ └─ 将 .wasm 加入 assetExts,修复 expo-sqlite web worker 打包
265
+ │
266
+ ├─ withCssInterop → 为 expo-image / expo-camera 等注入 NativeWind cssInterop
267
+ │
268
+ ├─ withEsbuildMinify → transformer.minifierPath(仅生产构建)
269
+ │ └─ 切换为 metro-minify-esbuild,清空 terser 专属 minifierConfig
270
+ │
271
+ ├─ withLucideResolver → resolver.resolveRequest
272
+ │ └─ lucide-react-native/dist/** 子路径 → 绝对文件路径(绕过 exports 检查)
273
+ │ └─ 自动检测 .mjs(>= 1.9)vs .js(1.8.x)扩展名
229
274
  │
230
275
  ├─ withDevStubs → resolver.resolveRequest
231
276
  │ ├─ @sentry/react-native → dist/stubs/sentry-react-native-stub.js (全平台)
232
277
  │ └─ @expo/log-box → dist/stubs/no-op-logbox.js (仅 web)
233
278
  │
234
- └─ withEntryInjection → resolver.resolveRequest
279
+ ├─ withExpoNotificationsStub → resolver.resolveRequest
280
+ │ └─ expo-notifications → dist/stubs/expo-notifications-stub.js (仅 Android)
281
+ │ ├─ Expo Go:no-op + 调试 Alert(含参数校验)
282
+ │ └─ Dev Build:透传真实 expo-notifications
283
+ │
284
+ ├─ withExpoMediaLibraryStub → resolver.resolveRequest
285
+ │ └─ expo-media-library → dist/stubs/expo-media-library-stub.js (全平台)
286
+ │ ├─ Expo Go / Web:no-op + Alert 提示(不崩溃)
287
+ │ └─ Dev Build(原生):透传真实 expo-media-library
288
+ │
289
+ ├─ withExpoCalendarStub → resolver.resolveRequest
290
+ │ └─ expo-calendar → dist/stubs/expo-calendar-stub.js (全平台)
291
+ │ ├─ Expo Go / Web:no-op + Alert 提示 + 参数校验(不崩溃)
292
+ │ └─ Dev Build(原生):透传真实 expo-calendar
293
+ │
294
+ ├─ withExpoFileSystemStub → resolver.resolveRequest
295
+ │ ├─ expo-file-system/legacy → dist/stubs/expo-file-system-stub.js (全平台)
296
+ │ └─ expo-file-system → dist/stubs/expo-file-system-next-stub.js
297
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
298
+ │ └─ Expo Go / Dev Build(原生):透传真实模块
299
+ │
300
+ ├─ withExpoHapticsStub → resolver.resolveRequest
301
+ │ └─ expo-haptics → dist/stubs/expo-haptics-stub.js (仅 web)
302
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
303
+ │ └─ native:透传真实 expo-haptics
304
+ │
305
+ ├─ withExpoContactsStub → resolver.resolveRequest
306
+ │ └─ expo-contacts → dist/stubs/expo-contacts-stub.js (仅 web)
307
+ │ ├─ Web / Expo Go:no-op + Dialog 提示(不崩溃)
308
+ │ └─ Dev Build(原生):透传真实 expo-contacts
309
+ │
310
+ ├─ withExpoImagePickerStub → resolver.resolveRequest
311
+ │ └─ expo-image-picker → dist/stubs/expo-image-picker-stub.js (仅 web)
312
+ │ ├─ 桌面 Web:launchCameraAsync → getUserMedia + #__devkit_camera_overlay__ 弹窗
313
+ │ │ ├─ 点"拍照":canvas.toDataURL → ImagePickerResult { canceled:false, assets }
314
+ │ │ └─ 点"取消":返回 { canceled:true, assets:null }
315
+ │ ├─ 移动端浏览器:透传 expo 原实现(capture 属性正常工作)
316
+ │ └─ getUserMedia 不可用:降级透传 expo 原实现(不崩溃)
317
+ │
318
+ └─ withEntryInjection → resolver.resolveRequest(仅 __DEV__)
235
319
  └─ expo-router/entry-classic → dist/stubs/expo-router-entry-stub.js
236
320
  ├─ require('./entry-inject') ← 注入脚本(bundle 首部执行)
237
321
  │ ├─ globalThis.__DEVKIT_INJECTED__ = true
@@ -262,10 +346,90 @@ sentry-react-native-stub.js
262
346
  | 子路径 | 文件 | 内容 |
263
347
  |---|---|---|
264
348
  | `.` | `dist/index.js` | `SentryCapture`、`MetroSymbolicator`、全部类型 |
265
- | `./metro` | `dist/metro.js` | `withDevStubs`、`withEntryInjection` |
349
+ | `./metro` | `dist/metro.js` | `withDevkit`、`withDevStubs`、`withEntryInjection`、`withPersistentCache`、`withTransformLogger`、`withExpoNotificationsStub`、`withExpoMediaLibraryStub`、`withExpoCalendarStub` 等全部 Metro wrapper |
266
350
  | `./babel-plugin-jsx-source` | `dist/babel/plugin-jsx-source.js` | Babel 插件:为 JSX 注入 source 信息 |
351
+ | `./babel-preset` | `dist/babel/preset.js` | Babel Preset:集成 jsx-source 和 lucide 插件 |
267
352
  | `./sentry-react-native-stub` | `dist/stubs/sentry-react-native-stub.js` | `@sentry/react-native` 模块替换 stub |
268
353
  | `./no-op-logbox` | `dist/stubs/no-op-logbox.js` | LogBox no-op stub |
354
+ | `./expo-notifications-stub` | `dist/stubs/expo-notifications-stub.js` | `expo-notifications` Expo Go Android stub |
355
+ | `./expo-media-library-stub` | `dist/stubs/expo-media-library-stub.js` | `expo-media-library` Expo Go / Web stub |
356
+ | `./expo-calendar-stub` | `dist/stubs/expo-calendar-stub.js` | `expo-calendar` Expo Go / Web stub |
357
+ | `./expo-file-system-stub` | `dist/stubs/expo-file-system-stub.js` | `expo-file-system/legacy` Web stub |
358
+ | `./expo-file-system-next-stub` | `dist/stubs/expo-file-system-next-stub.js` | `expo-file-system` 新版 API Web stub |
359
+ | `./expo-haptics-stub` | `dist/stubs/expo-haptics-stub.js` | `expo-haptics` Web stub |
360
+ | `./expo-contacts-stub` | `dist/stubs/expo-contacts-stub.js` | `expo-contacts` Web / Expo Go stub |
361
+ | `./expo-image-picker-stub` | `dist/stubs/expo-image-picker-stub.js` | `expo-image-picker` 桌面 Web stub |
362
+
363
+ ---
364
+
365
+ ## devkit-lint
366
+
367
+ `devkit-lint` 是内置的静态检查命令,一次执行涵盖四个阶段:
368
+
369
+ ```
370
+ oxlint → biome → tsc --noEmit → app.json schema 校验
371
+ ```
372
+
373
+ 所有阶段都会跑完再退出,最终汇总哪个阶段失败。
374
+
375
+ ### 使用
376
+
377
+ ```json
378
+ // package.json
379
+ {
380
+ "scripts": {
381
+ "lint": "devkit-lint"
382
+ }
383
+ }
384
+ ```
385
+
386
+ ```sh
387
+ bun run lint
388
+ ```
389
+
390
+ ### app.json 字段校验
391
+
392
+ `devkit-lint` 在最后一步用 [`@expo/schemer`](https://github.com/expo/expo/tree/main/packages/@expo/schemer) 对 `app.json` 做 JSON Schema 校验,拦截字段值错误(枚举值不合法、类型错误等)。
393
+
394
+ 典型场景:`"orientation": "all"` 不是合法的 Expo 枚举值(合法值为 `"default" | "portrait" | "landscape"`),这类错误原本只会在 Gradle/AAPT 阶段(构建约 1 分钟后)以晦涩的资源链接报错暴露,现在在 lint 阶段秒级报出:
395
+
396
+ ```
397
+ [app.json schema errors]
398
+ /path/to/project/app.json: field 'orientation' — must be equal to one of the allowed values (got: "all")
399
+
400
+ ────────────────────────────────────────────────────────────
401
+ RESULT: FAILED — Found errors in: app.json
402
+ ```
403
+
404
+ #### Schema 来源与存储
405
+
406
+ Expo 随每个 SDK 版本在其文档站发布版本化 JSON Schema(位于 `docs/public/static/schemas/v{VERSION}/app-config-schema.json`)。devkit 将对应版本的 schema 文件复制到 `src/schemas/app-config-schema-v55.json`,随 CLI bundle(`dist/cli/lint.js`)一起内联打包。
407
+
408
+ 这意味着:
409
+
410
+ - **完全离线**:不在运行时请求 `exp.host` 或任何外部服务,在无网络的 CI 容器中也能正常工作
411
+ - **版本固定**:schema 与 Expo SDK 55 对应,校验结果与 Expo 官方行为一致
412
+ - **零运行时依赖**:`@expo/schemer`(及其依赖 `ajv`)在构建时内联进 `dist/cli/lint.js`,消费方无需安装额外依赖
413
+
414
+ #### 升级 Expo SDK 时的 schema 更新
415
+
416
+ 当项目升级 Expo SDK 版本时,需同步更新 schema 文件:
417
+
418
+ ```sh
419
+ # 从 Expo 源码仓库复制新版 schema(以升级到 v56 为例)
420
+ cp /path/to/expo/docs/public/static/schemas/v56.0.0/app-config-schema.json \
421
+ packages/devkit/src/schemas/app-config-schema-v56.json
422
+
423
+ # 删除旧版 schema 文件
424
+ rm packages/devkit/src/schemas/app-config-schema-v55.json
425
+ ```
426
+
427
+ 并相应修改 `src/cli/lint.ts` 中的 import 路径:
428
+
429
+ ```ts
430
+ // 改为
431
+ import rawSchema from '../schemas/app-config-schema-v56.json';
432
+ ```
269
433
 
270
434
  ---
271
435
 
@@ -388,6 +552,183 @@ LogBox no-op stub,用于 web 平台禁用 Expo 全屏错误遮罩。
388
552
 
389
553
  ---
390
554
 
555
+ ### expo-notifications-stub.js
556
+
557
+ `expo-notifications` 模块替换 stub,由 `withExpoNotificationsStub()` 在 Metro 层注入(**仅 Android 平台**)。
558
+
559
+ **背景:** Expo SDK 53 起,`expo-notifications` 的 Android native module 已从 Expo Go 中移除,直接 import 会在 Expo Go 启动时崩溃。
560
+
561
+ **运行时行为:**
562
+ - **Expo Go(Android)**:提供 no-op 实现,`requestPermissionsAsync`、`setNotificationChannelAsync`、`scheduleNotificationAsync` 等核心 API 调用时弹出带参数校验的调试 Alert
563
+ - **Development Build(Android)**:透传真实 `expo-notifications`,功能完全正常
564
+ - **iOS(任意)**:不经过此 stub,直接使用真实 `expo-notifications`
565
+
566
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Notification Stub 验证」页面,逐按钮触发并对照期望结果。
567
+
568
+ ---
569
+
570
+ ### expo-media-library-stub.js
571
+
572
+ `expo-media-library` 模块替换 stub,由 `withExpoMediaLibraryStub()` 在 Metro 层注入(**全平台**)。
573
+
574
+ **背景:** `expo-media-library` 依赖原生相册 API,在 Expo Go 和 Web 环境中不可用,调用 `saveToLibraryAsync` 等 API 会直接崩溃。
575
+
576
+ **运行时行为:**
577
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert 提示(不崩溃):
578
+ - `usePermissions()` — 返回 `{ status: 'undetermined', granted: false }`,`requestPermission()` 弹 Alert
579
+ - `requestPermissionsAsync()` / `getPermissionsAsync()` — 前者弹 Alert,后者静默返回 denied
580
+ - `saveToLibraryAsync(uri)` — 弹 Alert 显示操作和 URI(超 60 字符自动截断)
581
+ - `createAssetAsync(uri)` — 弹 Alert 并返回合法的伪资产对象
582
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
583
+ - **Development Build(原生)**:透传真实 `expo-media-library`,功能完全正常
584
+
585
+ **Alert 消息格式:**
586
+ ```
587
+ 保存到相册
588
+ Expo Go 扫码预览不支持访问手机相册
589
+ 操作: 图片: file:///tmp/test.jpg
590
+ 发布为正式 App 后可正常使用
591
+ ```
592
+
593
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Media Library Stub 验证」页面,逐按钮触发并对照期望结果。
594
+
595
+ ---
596
+
597
+ ### expo-calendar-stub.js
598
+
599
+ `expo-calendar` 模块替换 stub,由 `withExpoCalendarStub()` 在 Metro 层注入(**全平台**)。
600
+
601
+ **背景:** `expo-calendar` 依赖原生系统日历 API,在 Expo Go 和 Web 环境中不可用,调用 `getEventsAsync` 等 API 会直接崩溃。
602
+
603
+ **运行时行为:**
604
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert / Dialog 提示(不崩溃):
605
+ - `useCalendarPermissions()` / `useRemindersPermissions()` — 初始返回 `undetermined`,`requestPermission()` 弹 Alert
606
+ - `requestCalendarPermissionsAsync()` / `requestRemindersPermissionsAsync()` — 弹 Alert 提示,返回 denied
607
+ - `getCalendarPermissionsAsync()` / `getRemindersPermissionsAsync()` — 静默返回 denied
608
+ - `getCalendarsAsync(entityType?)` — 弹 Alert 显示查询类型,返回空数组
609
+ - `createCalendarAsync(details)` — 弹 Alert 含名称和颜色,校验 `details.title` 非空
610
+ - `updateCalendarAsync(id, details)` / `deleteCalendarAsync(id)` — 弹 Alert 含日历 ID,校验 ID 格式
611
+ - `getEventsAsync(calendarIds, startDate, endDate)` — 弹 Alert 含时间范围,校验 calendarIds 非空及日期合法性,返回空数组
612
+ - `createEventAsync(calendarId, eventData)` — 弹 Alert 含标题和时间,Android 下校验 startDate/endDate
613
+ - `updateEventAsync(id, details)` / `deleteEventAsync(id)` — 弹 Alert 含事件 ID,校验 ID 格式
614
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
615
+ - **Development Build(原生)**:透传真实 `expo-calendar`,功能完全正常
616
+
617
+ **枚举常量**(stub & Dev Build 均可用):
618
+ `EntityTypes`、`Frequency`、`Availability`、`CalendarType`、`EventStatus`、`SourceType`、
619
+ `AttendeeRole`、`AttendeeStatus`、`AttendeeType`、`AlarmMethod`、`EventAccessLevel`、
620
+ `CalendarAccessLevel`、`ReminderStatus`、`DayOfTheWeek`、`MonthOfTheYear`
621
+
622
+ **Alert 消息格式(合规示例):**
623
+ ```
624
+ 创建日历事件
625
+ 秒哒扫码预览不支持访问手机日历
626
+
627
+ 日历 ID: cal1
628
+ 标题: 团队会议
629
+ 开始: 2025/1/1 10:00:00
630
+ 结束: 2025/1/1 11:00:00
631
+
632
+ ✅ 参数合规
633
+ 发布为正式 App 后可正常使用
634
+ ```
635
+
636
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Calendar Stub 验证」页面,逐按钮触发并对照期望结果。
637
+
638
+ ---
639
+
640
+ ### expo-file-system-stub.js / expo-file-system-next-stub.js
641
+
642
+ `expo-file-system` 模块替换 stub,由 `withExpoFileSystemStub()` 在 Metro 层注入(**全平台**)。
643
+
644
+ **背景:** `expo-file-system` 的文件 API 在 Web 端不可用:legacy API 底层方法缺失会抛 `UnavailabilityError`;新版 API(`File` / `Directory`)基类缺少 `validatePath()`,`new File(...)` 会抛 `TypeError: this.validatePath is not a function`。
645
+
646
+ **拦截路径:**
647
+ - `expo-file-system/legacy` → `expo-file-system-stub.js`(legacy API)
648
+ - `expo-file-system` → `expo-file-system-next-stub.js`(新版 File/Directory/Paths API)
649
+
650
+ **运行时行为:**
651
+ - **Web**:提供 no-op 实现,核心 API 调用时弹出 Dialog 提示(不崩溃)
652
+ - **Expo Go / Development Build(原生)**:透传真实 `expo-file-system`,功能完全正常
653
+
654
+ ---
655
+
656
+ ### expo-haptics-stub.js
657
+
658
+ `expo-haptics` 模块替换 stub,由 `withExpoHapticsStub()` 在 Metro 层注入(**仅 web 平台**)。
659
+
660
+ **背景:** Web 没有振动/触觉硬件 API,`expo-haptics` 在 web 会运行时崩溃。
661
+
662
+ **运行时行为:**
663
+ - **Web**:提供 no-op 实现,`impactAsync`、`notificationAsync`、`selectionAsync` 调用时弹出 Dialog 提示参数信息(不崩溃);枚举常量(`ImpactFeedbackStyle`、`NotificationFeedbackType` 等)正常可用
664
+ - **Expo Go / Development Build(native)**:透传真实 `expo-haptics`,功能完全正常
665
+
666
+ ---
667
+
668
+ ### expo-contacts-stub.js
669
+
670
+ `expo-contacts` 模块替换 stub,由 `withExpoContactsStub()` 在 Metro 层注入(**仅 web 平台**)。
671
+
672
+ **背景:** `expo-contacts` 依赖 native module,在 Web 和 Expo Go 中不可用。
673
+
674
+ **运行时行为:**
675
+ - **Web / Expo Go**:提供 no-op 实现,联系人 API 调用时弹出 Dialog 提示(不崩溃)
676
+ - **Development Build(原生)**:透传真实 `expo-contacts`,功能完全正常
677
+
678
+ ---
679
+
680
+ ### expo-image-picker-stub.js
681
+
682
+ `expo-image-picker` 模块替换 stub,由 `withExpoImagePickerStub()` 在 Metro 层注入(**仅 web 平台**)。
683
+
684
+ **背景:** `expo-image-picker` 的 `launchCameraAsync` 在 web 上底层使用 `<input type="file" capture="environment">`。桌面浏览器(Chrome、Firefox、Safari)**有意忽略 `capture` 属性**,直接弹出文件选择框而不是摄像头。这是浏览器厂商的硬限制,无法通过任何 HTML 属性或 meta 标签绕过。
685
+
686
+ **运行时行为:**
687
+ - **桌面 Web(非 mobile UA)**:`launchCameraAsync` 调用 `navigator.mediaDevices.getUserMedia` 打开摄像头预览弹窗(`#__devkit_camera_overlay__`),用户点击"📷 拍照"后截取一帧并以 `data:image/jpeg` 格式返回,点击"取消"返回 `{ canceled: true }`
688
+ - **移动端浏览器(Android Chrome / iOS Safari)**:透传 expo 原实现,`capture` 属性在移动端正常调起系统相机
689
+ - **`getUserMedia` 不可用**(无摄像头或浏览器限制):静默降级,透传 expo 原实现(不崩溃)
690
+ - **Native(iOS / Android)**:Metro resolver 不拦截,直接使用原生 `expo-image-picker`,功能完全不变
691
+
692
+ **弹窗 DOM 结构(供 Playwright 定位):**
693
+ ```html
694
+ <div id="__devkit_camera_overlay__"> <!-- 全屏遮罩 -->
695
+ <div> <!-- 面板 -->
696
+ <p>拍照</p>
697
+ <video autoplay muted playsinline> <!-- 摄像头预览 -->
698
+ <div>
699
+ <button>📷 拍照</button>
700
+ <button>取消</button>
701
+ </div>
702
+ <p><!-- 错误信息(getUserMedia 失败时显示) --></p>
703
+ </div>
704
+ </div>
705
+ ```
706
+
707
+ **返回值格式(与 expo 原实现兼容):**
708
+ ```ts
709
+ // 拍照成功
710
+ {
711
+ canceled: false,
712
+ assets: [{
713
+ uri: 'data:image/jpeg;base64,...',
714
+ width: 640,
715
+ height: 480,
716
+ type: 'image',
717
+ fileName: 'photo_1234567890.jpg',
718
+ mimeType: 'image/jpeg',
719
+ base64: null,
720
+ exif: null,
721
+ }]
722
+ }
723
+
724
+ // 取消
725
+ { canceled: true, assets: null }
726
+ ```
727
+
728
+ **E2E 测试:** `devkit-e2e/tests/e2e/image-picker-stub.spec.ts`,使用 `addInitScript` mock `getUserMedia`(注入 canvas stream 替代真实摄像头),覆盖弹窗出现/消失、拍照返回结果、取消、降级等场景。
729
+
730
+ ---
731
+
391
732
  ## Babel 插件:jsx-source
392
733
 
393
734
  `babel-plugin-jsx-source` 为 JSX 元素注入 source 属性(文件路径、行列号),用于开发调试。通过 `dataSet` 对象注入,这是 React Web 可识别的数据通道。
@@ -434,3 +775,81 @@ module.exports = {
434
775
  |---|---|---|---|
435
776
  | `rootDir` | `string` | - | 项目根目录,用于计算相对路径。不提供则使用绝对路径 |
436
777
  | `excludePaths` | `string[]` | `[]` | 跳过注入的路径模式列表(相对于 rootDir 的路径片段) |
778
+
779
+ ---
780
+
781
+ ## Babel 插件:lucide-react-native
782
+
783
+ Metro 没有 tree-shaking,直接写 `import { Star } from "lucide-react-native"` 会把整个图标库(约 1500 个图标)打进 bundle。`babel-plugin-lucide-react-native` 在编译阶段将具名导入改写为直接按文件导入,彻底规避这个问题。
784
+
785
+ **转换效果:**
786
+
787
+ ```ts
788
+ // 转换前
789
+ import { Star, BookOpen } from "lucide-react-native";
790
+
791
+ // 转换后(CJS,默认)
792
+ import _Star from "lucide-react-native/dist/cjs/icons/star";
793
+ import _BookOpen from "lucide-react-native/dist/cjs/icons/book-open";
794
+ ```
795
+
796
+ ### 配置
797
+
798
+ 推荐通过内置 Preset 一次性启用所有 Babel 插件:
799
+
800
+ ```js
801
+ // babel.config.js
802
+ module.exports = {
803
+ presets: [['miaoda-expo-devkit/babel-preset', { excludePaths: ['src/components/ui'] }]],
804
+ };
805
+ ```
806
+
807
+ 也可以单独使用:
808
+
809
+ ```js
810
+ module.exports = {
811
+ plugins: [['miaoda-expo-devkit/babel-plugin-lucide-react-native']],
812
+ };
813
+ ```
814
+
815
+ ### 选项
816
+
817
+ | 选项 | 类型 | 默认值 | 说明 |
818
+ |---|---|---|---|
819
+ | `useES` | `boolean` | `false` | 使用 ESM 格式(`dist/esm/icons/`)而非默认的 CJS |
820
+
821
+ ### 支持的写法
822
+
823
+ 插件可识别所有常见的图标引用写法:
824
+
825
+ ```ts
826
+ import { BookOpen, Star, Crown } from "lucide-react-native";
827
+
828
+ // JSX 直接使用
829
+ <BookOpen size={24} />
830
+
831
+ // 赋值给变量
832
+ const icon = BookOpen;
833
+
834
+ // 对象 value
835
+ const ITEMS = [{ icon: BookOpen }, { icon: Star }];
836
+
837
+ // 计算属性 key
838
+ const map = { [BookOpen]: 'read' };
839
+
840
+ // 数组、三元、函数参数
841
+ const list = [BookOpen, Star];
842
+ const active = flag ? BookOpen : Star;
843
+ renderIcon(Crown);
844
+
845
+ // 导入别名
846
+ import { BookOpen as ReadIcon } from "lucide-react-native";
847
+ <ReadIcon />
848
+
849
+ // re-export
850
+ export { Star, BookOpen as ReadIcon } from "lucide-react-native";
851
+ ```
852
+
853
+ ### 版本兼容性
854
+
855
+ 插件在运行时探测 `lucide-react-native` 的实际目录结构,自动适配新旧版本的路径差异(`>= 1.9` 新增 `icons/` 子目录;旧版 `1.8.x` 图标直接位于 `dist/cjs/`),lucide 升级时无需修改配置。
package/biome-config.json CHANGED
@@ -1,14 +1,50 @@
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": {
5
19
  "recommended": false,
6
20
  "correctness": {
7
21
  "noUndeclaredDependencies": "error"
22
+ },
23
+ "style": {
24
+ "noRestrictedImports": {
25
+ "level": "error",
26
+ "options": {
27
+ "paths": {
28
+ "expo-barcode-scanner": "Deprecated in SDK 51+. Use expo-camera with barcode scanning instead."
29
+ }
30
+ }
31
+ }
8
32
  }
9
33
  }
10
34
  },
11
35
  "formatter": {
12
36
  "enabled": false
13
- }
37
+ },
38
+ "overrides": [
39
+ {
40
+ "includes": ["tailwind.config.js"],
41
+ "linter": {
42
+ "rules": {
43
+ "style": {
44
+ "noCommonJs": "off"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ]
14
50
  }
@@ -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
@@ -37,12 +37,19 @@ interface JsxSourcePluginOptions {
37
37
  rootDir?: string;
38
38
  /** 需要跳过注入的路径模式列表(相对于 rootDir 的路径片段) */
39
39
  excludePaths?: string[];
40
+ /** 需要跳过注入的包名列表,从这些包导入的组件不会被注入 */
41
+ excludePackages?: string[];
42
+ }
43
+ interface PluginState extends PluginPass {
44
+ opts: JsxSourcePluginOptions;
45
+ /** 当前文件中来自被排除包的本地标识符集合,Program 阶段初始化 */
46
+ excludedComponents: Set<string>;
40
47
  }
41
48
  /**
42
49
  * Babel 插件:为 JSX 元素注入 dataSet
43
50
  */
44
51
  declare function babelPluginJsxSource({ types: t, }: {
45
52
  types: typeof types;
46
- }): PluginObj;
53
+ }): PluginObj<PluginState>;
47
54
 
48
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";
@@ -106,12 +107,32 @@ function babelPluginJsxSource({
106
107
  return {
107
108
  name: "babel-plugin-jsx-source",
108
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
+ },
109
127
  JSXOpeningElement(path, state) {
110
128
  const { opts, filename } = state;
111
129
  if (!filename) return;
112
130
  if (filename.includes("node_modules")) return;
113
131
  if (opts.excludePaths?.some((p) => filename.includes(p))) return;
114
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;
115
136
  const loc = path.node.loc;
116
137
  if (!loc) return;
117
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 };