miaoda-expo-devkit 0.1.1-beta.11 → 0.1.1-beta.110

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 (68) hide show
  1. package/README.md +492 -8
  2. package/biome-config.json +39 -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 +17498 -15
  10. package/dist/metro.d.mts +323 -62
  11. package/dist/metro.d.ts +323 -62
  12. package/dist/metro.js +430 -13
  13. package/dist/metro.mjs +417 -13
  14. package/dist/rules/no-duplicate-expo-router-url.js +17 -15
  15. package/dist/rules/no-expo-audio-compat.js +2041 -0
  16. package/dist/rules/no-expo-video-compat.js +2041 -0
  17. package/dist/rules/no-gifted-charts-missing-linear-gradient.js +101 -0
  18. package/dist/rules/no-invalid-expo-config-value.js +141 -0
  19. package/dist/rules/no-invalid-notification-config.js +228 -0
  20. package/dist/rules/no-invalid-tabs-screen.js +193 -0
  21. package/dist/rules/no-missing-css-import.js +13 -11
  22. package/dist/rules/no-missing-image-import.js +105 -0
  23. package/dist/rules/no-missing-notification-asset.js +138 -0
  24. package/dist/rules/no-pressable-function-style.js +78 -0
  25. package/dist/rules/no-pressable-without-on-press.js +77 -0
  26. package/dist/rules/no-splash-screen-missing-image.js +140 -0
  27. package/dist/rules/no-undeclared-expo-plugin.js +34 -12
  28. package/dist/rules/no-unregistered-dynamic-tab-route.js +191 -0
  29. package/dist/rules/no-unused-expo-plugin.js +211 -0
  30. package/dist/stubs/entry-inject.js +4 -0
  31. package/dist/stubs/expo-blur-stub.js +29 -0
  32. package/dist/stubs/expo-calendar-stub.js +316 -0
  33. package/dist/stubs/expo-camera-record-stub.js +143 -0
  34. package/dist/stubs/expo-contacts-stub.js +310 -0
  35. package/dist/stubs/expo-file-system-next-stub.js +204 -0
  36. package/dist/stubs/expo-file-system-stub.js +215 -0
  37. package/dist/stubs/expo-haptics-stub.js +85 -0
  38. package/dist/stubs/expo-image-picker-stub.js +264 -0
  39. package/dist/stubs/expo-linear-gradient-stub.js +28 -0
  40. package/dist/stubs/expo-media-library-stub.js +142 -0
  41. package/dist/stubs/expo-native-wechat-stub.js +116 -0
  42. package/dist/stubs/expo-notifications-stub.js +178 -0
  43. package/dist/stubs/i18n/en.js +210 -0
  44. package/dist/stubs/i18n/index.js +66 -0
  45. package/dist/stubs/i18n/zh.js +213 -0
  46. package/dist/stubs/lgui-control.js +67 -5
  47. package/dist/stubs/navigation-guard-spy.js +86 -0
  48. package/dist/stubs/no-op-logbox.js +5 -2
  49. package/dist/stubs/preview-control.js +60 -0
  50. package/dist/stubs/screenshot-control.js +3729 -0
  51. package/dist/stubs/sentry-feedback-stub.js +60 -0
  52. package/dist/stubs/sentry-react-native-stub.js +1 -1
  53. package/dist/stubs/sentry-replay-canvas-stub.js +35 -0
  54. package/dist/stubs/sentry-replay-stub.js +41 -0
  55. package/dist/stubs/web-stub-dialog.js +169 -0
  56. package/dist/utils/navigation-guard-detector.js +57 -0
  57. package/oxlint-config.json +46 -1
  58. package/package.json +88 -31
  59. package/pnpm-config.json +10 -2
  60. package/pnpm-patches/@expo__cli@55.0.16-55.0.22.patch +218 -0
  61. package/pnpm-patches/@shopify__react-native-skia@2.4.18.patch +14 -0
  62. package/pnpm-patches/expo-modules-core@55.0.15.patch +21 -0
  63. package/pnpm-patches/expo@55.0.6.patch +22 -0
  64. package/pnpm-patches/react-native-css-interop@0.2.6.patch +12 -0
  65. package/pnpm-patches/react-native-reanimated@4.2.1.patch +53 -0
  66. package/pnpm-patches/react-native-worklets@0.7.2.patch +74 -0
  67. package/pnpmfile.cjs +147 -0
  68. 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,114 @@ 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__`)下生效 |
239
+ | `EXPO_AI_ERROR_COLLECTOR` | _(未设置时关闭)_ | 设为 `1` 后由 DevKit 管理的 Expo CLI patch 采集当前 Metro 编译状态 |
240
+ | `EXPO_AI_ERROR_FILE` | `<projectRoot>/node_modules/.miaoda-expo/ai-error.json` | AI 当前错误状态文件;支持绝对路径或相对项目根目录的路径 |
241
+
242
+ ### Metro 编译错误采集
243
+
244
+ DevKit 会通过 pnpm patch 接入 Expo CLI 自己的 Metro reporter wrapper,不修改
245
+ LogBox 或 Metro 核心。启用后,首次 bundle 失败也能在前端启动前写入状态文件:
246
+
247
+ ```sh
248
+ EXPO_AI_ERROR_COLLECTOR=1 npx expo start
249
+ ```
250
+
251
+ 默认状态文件位于 `node_modules/.miaoda-expo/ai-error.json`,目录会自动创建。依赖重装
252
+ 可能清理这个目录;需要持久化时请设置 `EXPO_AI_ERROR_FILE` 指向项目外部路径。
253
+
254
+ 状态文件只表示“当前编译”:`bundle_build_started` 会立即清除上一次错误,
255
+ `bundling_error` 写入结构化错误,下一次编译成功或失败都会替换旧状态。AI
256
+ 进程直接读取这个文件即可,不需要额外的 HTTP 服务或 WebSocket 客户端。
257
+
258
+ 返回对象包含 `status`(`compiling`、`failed`、`succeeded`)和 `error`。失败时
259
+ `error` 至少包含 `type`、`message`、`filename`、`lineNumber`、`column`、
260
+ `errors` 和 `stack`(存在时)。
261
+
262
+ 当前模板为 Expo CLI `55.0.16` 至 `55.0.22` 提供一个范围补丁(`>=55.0.16 <55.0.23`)。
263
+ 该范围以外的 CLI 版本不会静默套用补丁;升级时需要先检查发布产物,确认兼容后再调整
264
+ `pnpmfile.cjs` 中的范围和对应测试。
223
265
 
224
266
  ## 工作原理
225
267
 
226
268
  ```
227
269
  metro.config.js
228
- └─ withEntryInjection(withDevStubs(config))
270
+ └─ withDevkit(config)
271
+ │
272
+ ├─ withTransformLogger → unstable_perfLoggerFactory + metro-core Logger
273
+ │ ├─ CACHE_CONFIG 条目(启动时由 withPersistentCache 写入,含 cache_root / store_class / source)
274
+ │ ├─ BUNDLING_REQUEST 条目(每次 bundle 请求写一条)
275
+ │ │ ├─ duration_ms、status、initial_build、graph_node_count
276
+ │ │ └─ transform_miss_count(cache miss 文件数;0 = 完全命中)
277
+ │ └─ TRANSFORM_FILE 条目(每个 cache miss 文件写一条,命中则不写)
278
+ │ └─ file、duration_ms
279
+ │ (需设置 METRO_TRANSFORM_LOG 且 __DEV__ 才生效)
280
+ │
281
+ ├─ withPersistentCache → config.cacheStores
282
+ │ ├─ 缓存路径优先级:METRO_CACHE_DIR > options.cacheDir > projectRoot/.metro-cache
283
+ │ └─ 保留 @expo/metro-config FileStore 子类行为(NativeWind skipCache 标志)
284
+ │
285
+ ├─ withWorkspaceNodeModules → watchFolders + resolver.nodeModulesPaths
286
+ │ ├─ 向上查找祖先目录的 node_modules,加入 watchFolders
287
+ │ └─ 若 .pnpm 是指向外部路径的 symlink,也将外部真实路径加入 watchFolders
288
+ │
289
+ ├─ withWasmSupport → resolver.assetExts
290
+ │ └─ 将 .wasm 加入 assetExts,修复 expo-sqlite web worker 打包
291
+ │
292
+ ├─ withCssInterop → 为 expo-image / expo-camera 等注入 NativeWind cssInterop
293
+ │
294
+ ├─ withEsbuildMinify → transformer.minifierPath(仅生产构建)
295
+ │ └─ 切换为 metro-minify-esbuild,清空 terser 专属 minifierConfig
296
+ │
297
+ ├─ withLucideResolver → resolver.resolveRequest
298
+ │ └─ lucide-react-native/dist/** 子路径 → 绝对文件路径(绕过 exports 检查)
299
+ │ └─ 自动检测 .mjs(>= 1.9)vs .js(1.8.x)扩展名
229
300
  │
230
301
  ├─ withDevStubs → resolver.resolveRequest
231
302
  │ ├─ @sentry/react-native → dist/stubs/sentry-react-native-stub.js (全平台)
232
303
  │ └─ @expo/log-box → dist/stubs/no-op-logbox.js (仅 web)
233
304
  │
234
- └─ withEntryInjection → resolver.resolveRequest
305
+ ├─ withExpoNotificationsStub → resolver.resolveRequest
306
+ │ └─ expo-notifications → dist/stubs/expo-notifications-stub.js (仅 Android)
307
+ │ ├─ Expo Go:no-op + 调试 Alert(含参数校验)
308
+ │ └─ Dev Build:透传真实 expo-notifications
309
+ │
310
+ ├─ withExpoMediaLibraryStub → resolver.resolveRequest
311
+ │ └─ expo-media-library → dist/stubs/expo-media-library-stub.js (全平台)
312
+ │ ├─ Expo Go / Web:no-op + Alert 提示(不崩溃)
313
+ │ └─ Dev Build(原生):透传真实 expo-media-library
314
+ │
315
+ ├─ withExpoCalendarStub → resolver.resolveRequest
316
+ │ └─ expo-calendar → dist/stubs/expo-calendar-stub.js (全平台)
317
+ │ ├─ Expo Go / Web:no-op + Alert 提示 + 参数校验(不崩溃)
318
+ │ └─ Dev Build(原生):透传真实 expo-calendar
319
+ │
320
+ ├─ withExpoFileSystemStub → resolver.resolveRequest
321
+ │ ├─ expo-file-system/legacy → dist/stubs/expo-file-system-stub.js (全平台)
322
+ │ └─ expo-file-system → dist/stubs/expo-file-system-next-stub.js
323
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
324
+ │ └─ Expo Go / Dev Build(原生):透传真实模块
325
+ │
326
+ ├─ withExpoHapticsStub → resolver.resolveRequest
327
+ │ └─ expo-haptics → dist/stubs/expo-haptics-stub.js (仅 web)
328
+ │ ├─ Web:no-op + Dialog 提示(不崩溃)
329
+ │ └─ native:透传真实 expo-haptics
330
+ │
331
+ ├─ withExpoContactsStub → resolver.resolveRequest
332
+ │ └─ expo-contacts → dist/stubs/expo-contacts-stub.js (仅 web)
333
+ │ ├─ Web / Expo Go:no-op + Dialog 提示(不崩溃)
334
+ │ └─ Dev Build(原生):透传真实 expo-contacts
335
+ │
336
+ ├─ withExpoImagePickerStub → resolver.resolveRequest
337
+ │ └─ expo-image-picker → dist/stubs/expo-image-picker-stub.js (仅 web)
338
+ │ ├─ 桌面 Web:launchCameraAsync → getUserMedia + #__devkit_camera_overlay__ 弹窗
339
+ │ │ ├─ 点"拍照":canvas.toDataURL → ImagePickerResult { canceled:false, assets }
340
+ │ │ └─ 点"取消":返回 { canceled:true, assets:null }
341
+ │ ├─ 移动端浏览器:透传 expo 原实现(capture 属性正常工作)
342
+ │ └─ getUserMedia 不可用:降级透传 expo 原实现(不崩溃)
343
+ │
344
+ └─ withEntryInjection → resolver.resolveRequest(仅 __DEV__)
235
345
  └─ expo-router/entry-classic → dist/stubs/expo-router-entry-stub.js
236
346
  ├─ require('./entry-inject') ← 注入脚本(bundle 首部执行)
237
347
  │ ├─ globalThis.__DEVKIT_INJECTED__ = true
@@ -262,10 +372,90 @@ sentry-react-native-stub.js
262
372
  | 子路径 | 文件 | 内容 |
263
373
  |---|---|---|
264
374
  | `.` | `dist/index.js` | `SentryCapture`、`MetroSymbolicator`、全部类型 |
265
- | `./metro` | `dist/metro.js` | `withDevStubs`、`withEntryInjection` |
375
+ | `./metro` | `dist/metro.js` | `withDevkit`、`withDevStubs`、`withEntryInjection`、`withPersistentCache`、`withTransformLogger`、`withExpoNotificationsStub`、`withExpoMediaLibraryStub`、`withExpoCalendarStub` 等全部 Metro wrapper |
266
376
  | `./babel-plugin-jsx-source` | `dist/babel/plugin-jsx-source.js` | Babel 插件:为 JSX 注入 source 信息 |
377
+ | `./babel-preset` | `dist/babel/preset.js` | Babel Preset:集成 jsx-source 和 lucide 插件 |
267
378
  | `./sentry-react-native-stub` | `dist/stubs/sentry-react-native-stub.js` | `@sentry/react-native` 模块替换 stub |
268
379
  | `./no-op-logbox` | `dist/stubs/no-op-logbox.js` | LogBox no-op stub |
380
+ | `./expo-notifications-stub` | `dist/stubs/expo-notifications-stub.js` | `expo-notifications` Expo Go Android stub |
381
+ | `./expo-media-library-stub` | `dist/stubs/expo-media-library-stub.js` | `expo-media-library` Expo Go / Web stub |
382
+ | `./expo-calendar-stub` | `dist/stubs/expo-calendar-stub.js` | `expo-calendar` Expo Go / Web stub |
383
+ | `./expo-file-system-stub` | `dist/stubs/expo-file-system-stub.js` | `expo-file-system/legacy` Web stub |
384
+ | `./expo-file-system-next-stub` | `dist/stubs/expo-file-system-next-stub.js` | `expo-file-system` 新版 API Web stub |
385
+ | `./expo-haptics-stub` | `dist/stubs/expo-haptics-stub.js` | `expo-haptics` Web stub |
386
+ | `./expo-contacts-stub` | `dist/stubs/expo-contacts-stub.js` | `expo-contacts` Web / Expo Go stub |
387
+ | `./expo-image-picker-stub` | `dist/stubs/expo-image-picker-stub.js` | `expo-image-picker` 桌面 Web stub |
388
+
389
+ ---
390
+
391
+ ## devkit-lint
392
+
393
+ `devkit-lint` 是内置的静态检查命令,一次执行涵盖四个阶段:
394
+
395
+ ```
396
+ oxlint → biome → tsc --noEmit → app.json schema 校验
397
+ ```
398
+
399
+ 所有阶段都会跑完再退出,最终汇总哪个阶段失败。
400
+
401
+ ### 使用
402
+
403
+ ```json
404
+ // package.json
405
+ {
406
+ "scripts": {
407
+ "lint": "devkit-lint"
408
+ }
409
+ }
410
+ ```
411
+
412
+ ```sh
413
+ bun run lint
414
+ ```
415
+
416
+ ### app.json 字段校验
417
+
418
+ `devkit-lint` 在最后一步用 [`@expo/schemer`](https://github.com/expo/expo/tree/main/packages/@expo/schemer) 对 `app.json` 做 JSON Schema 校验,拦截字段值错误(枚举值不合法、类型错误等)。
419
+
420
+ 典型场景:`"orientation": "all"` 不是合法的 Expo 枚举值(合法值为 `"default" | "portrait" | "landscape"`),这类错误原本只会在 Gradle/AAPT 阶段(构建约 1 分钟后)以晦涩的资源链接报错暴露,现在在 lint 阶段秒级报出:
421
+
422
+ ```
423
+ [app.json schema errors]
424
+ /path/to/project/app.json: field 'orientation' — must be equal to one of the allowed values (got: "all")
425
+
426
+ ────────────────────────────────────────────────────────────
427
+ RESULT: FAILED — Found errors in: app.json
428
+ ```
429
+
430
+ #### Schema 来源与存储
431
+
432
+ 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`)一起内联打包。
433
+
434
+ 这意味着:
435
+
436
+ - **完全离线**:不在运行时请求 `exp.host` 或任何外部服务,在无网络的 CI 容器中也能正常工作
437
+ - **版本固定**:schema 与 Expo SDK 55 对应,校验结果与 Expo 官方行为一致
438
+ - **零运行时依赖**:`@expo/schemer`(及其依赖 `ajv`)在构建时内联进 `dist/cli/lint.js`,消费方无需安装额外依赖
439
+
440
+ #### 升级 Expo SDK 时的 schema 更新
441
+
442
+ 当项目升级 Expo SDK 版本时,需同步更新 schema 文件:
443
+
444
+ ```sh
445
+ # 从 Expo 源码仓库复制新版 schema(以升级到 v56 为例)
446
+ cp /path/to/expo/docs/public/static/schemas/v56.0.0/app-config-schema.json \
447
+ packages/devkit/src/schemas/app-config-schema-v56.json
448
+
449
+ # 删除旧版 schema 文件
450
+ rm packages/devkit/src/schemas/app-config-schema-v55.json
451
+ ```
452
+
453
+ 并相应修改 `src/cli/lint.ts` 中的 import 路径:
454
+
455
+ ```ts
456
+ // 改为
457
+ import rawSchema from '../schemas/app-config-schema-v56.json';
458
+ ```
269
459
 
270
460
  ---
271
461
 
@@ -388,6 +578,183 @@ LogBox no-op stub,用于 web 平台禁用 Expo 全屏错误遮罩。
388
578
 
389
579
  ---
390
580
 
581
+ ### expo-notifications-stub.js
582
+
583
+ `expo-notifications` 模块替换 stub,由 `withExpoNotificationsStub()` 在 Metro 层注入(**仅 Android 平台**)。
584
+
585
+ **背景:** Expo SDK 53 起,`expo-notifications` 的 Android native module 已从 Expo Go 中移除,直接 import 会在 Expo Go 启动时崩溃。
586
+
587
+ **运行时行为:**
588
+ - **Expo Go(Android)**:提供 no-op 实现,`requestPermissionsAsync`、`setNotificationChannelAsync`、`scheduleNotificationAsync` 等核心 API 调用时弹出带参数校验的调试 Alert
589
+ - **Development Build(Android)**:透传真实 `expo-notifications`,功能完全正常
590
+ - **iOS(任意)**:不经过此 stub,直接使用真实 `expo-notifications`
591
+
592
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Notification Stub 验证」页面,逐按钮触发并对照期望结果。
593
+
594
+ ---
595
+
596
+ ### expo-media-library-stub.js
597
+
598
+ `expo-media-library` 模块替换 stub,由 `withExpoMediaLibraryStub()` 在 Metro 层注入(**全平台**)。
599
+
600
+ **背景:** `expo-media-library` 依赖原生相册 API,在 Expo Go 和 Web 环境中不可用,调用 `saveToLibraryAsync` 等 API 会直接崩溃。
601
+
602
+ **运行时行为:**
603
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert 提示(不崩溃):
604
+ - `usePermissions()` — 返回 `{ status: 'undetermined', granted: false }`,`requestPermission()` 弹 Alert
605
+ - `requestPermissionsAsync()` / `getPermissionsAsync()` — 前者弹 Alert,后者静默返回 denied
606
+ - `saveToLibraryAsync(uri)` — 弹 Alert 显示操作和 URI(超 60 字符自动截断)
607
+ - `createAssetAsync(uri)` — 弹 Alert 并返回合法的伪资产对象
608
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
609
+ - **Development Build(原生)**:透传真实 `expo-media-library`,功能完全正常
610
+
611
+ **Alert 消息格式:**
612
+ ```
613
+ 保存到相册
614
+ Expo Go 扫码预览不支持访问手机相册
615
+ 操作: 图片: file:///tmp/test.jpg
616
+ 发布为正式 App 后可正常使用
617
+ ```
618
+
619
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Media Library Stub 验证」页面,逐按钮触发并对照期望结果。
620
+
621
+ ---
622
+
623
+ ### expo-calendar-stub.js
624
+
625
+ `expo-calendar` 模块替换 stub,由 `withExpoCalendarStub()` 在 Metro 层注入(**全平台**)。
626
+
627
+ **背景:** `expo-calendar` 依赖原生系统日历 API,在 Expo Go 和 Web 环境中不可用,调用 `getEventsAsync` 等 API 会直接崩溃。
628
+
629
+ **运行时行为:**
630
+ - **Expo Go / Web**:提供 no-op 实现,以下 API 调用时弹出 Alert / Dialog 提示(不崩溃):
631
+ - `useCalendarPermissions()` / `useRemindersPermissions()` — 初始返回 `undetermined`,`requestPermission()` 弹 Alert
632
+ - `requestCalendarPermissionsAsync()` / `requestRemindersPermissionsAsync()` — 弹 Alert 提示,返回 denied
633
+ - `getCalendarPermissionsAsync()` / `getRemindersPermissionsAsync()` — 静默返回 denied
634
+ - `getCalendarsAsync(entityType?)` — 弹 Alert 显示查询类型,返回空数组
635
+ - `createCalendarAsync(details)` — 弹 Alert 含名称和颜色,校验 `details.title` 非空
636
+ - `updateCalendarAsync(id, details)` / `deleteCalendarAsync(id)` — 弹 Alert 含日历 ID,校验 ID 格式
637
+ - `getEventsAsync(calendarIds, startDate, endDate)` — 弹 Alert 含时间范围,校验 calendarIds 非空及日期合法性,返回空数组
638
+ - `createEventAsync(calendarId, eventData)` — 弹 Alert 含标题和时间,Android 下校验 startDate/endDate
639
+ - `updateEventAsync(id, details)` / `deleteEventAsync(id)` — 弹 Alert 含事件 ID,校验 ID 格式
640
+ - 其他未知 API — Proxy 兜底,静默返回 `undefined`;以 `PermissionsAsync` 结尾的 API 返回 denied 结构
641
+ - **Development Build(原生)**:透传真实 `expo-calendar`,功能完全正常
642
+
643
+ **枚举常量**(stub & Dev Build 均可用):
644
+ `EntityTypes`、`Frequency`、`Availability`、`CalendarType`、`EventStatus`、`SourceType`、
645
+ `AttendeeRole`、`AttendeeStatus`、`AttendeeType`、`AlarmMethod`、`EventAccessLevel`、
646
+ `CalendarAccessLevel`、`ReminderStatus`、`DayOfTheWeek`、`MonthOfTheYear`
647
+
648
+ **Alert 消息格式(合规示例):**
649
+ ```
650
+ 创建日历事件
651
+ 秒哒扫码预览不支持访问手机日历
652
+
653
+ 日历 ID: cal1
654
+ 标题: 团队会议
655
+ 开始: 2025/1/1 10:00:00
656
+ 结束: 2025/1/1 11:00:00
657
+
658
+ ✅ 参数合规
659
+ 发布为正式 App 后可正常使用
660
+ ```
661
+
662
+ **手动验证:** 在 `devkit-e2e` App 中扫码进入「Calendar Stub 验证」页面,逐按钮触发并对照期望结果。
663
+
664
+ ---
665
+
666
+ ### expo-file-system-stub.js / expo-file-system-next-stub.js
667
+
668
+ `expo-file-system` 模块替换 stub,由 `withExpoFileSystemStub()` 在 Metro 层注入(**全平台**)。
669
+
670
+ **背景:** `expo-file-system` 的文件 API 在 Web 端不可用:legacy API 底层方法缺失会抛 `UnavailabilityError`;新版 API(`File` / `Directory`)基类缺少 `validatePath()`,`new File(...)` 会抛 `TypeError: this.validatePath is not a function`。
671
+
672
+ **拦截路径:**
673
+ - `expo-file-system/legacy` → `expo-file-system-stub.js`(legacy API)
674
+ - `expo-file-system` → `expo-file-system-next-stub.js`(新版 File/Directory/Paths API)
675
+
676
+ **运行时行为:**
677
+ - **Web**:提供 no-op 实现,核心 API 调用时弹出 Dialog 提示(不崩溃)
678
+ - **Expo Go / Development Build(原生)**:透传真实 `expo-file-system`,功能完全正常
679
+
680
+ ---
681
+
682
+ ### expo-haptics-stub.js
683
+
684
+ `expo-haptics` 模块替换 stub,由 `withExpoHapticsStub()` 在 Metro 层注入(**仅 web 平台**)。
685
+
686
+ **背景:** Web 没有振动/触觉硬件 API,`expo-haptics` 在 web 会运行时崩溃。
687
+
688
+ **运行时行为:**
689
+ - **Web**:提供 no-op 实现,`impactAsync`、`notificationAsync`、`selectionAsync` 调用时弹出 Dialog 提示参数信息(不崩溃);枚举常量(`ImpactFeedbackStyle`、`NotificationFeedbackType` 等)正常可用
690
+ - **Expo Go / Development Build(native)**:透传真实 `expo-haptics`,功能完全正常
691
+
692
+ ---
693
+
694
+ ### expo-contacts-stub.js
695
+
696
+ `expo-contacts` 模块替换 stub,由 `withExpoContactsStub()` 在 Metro 层注入(**仅 web 平台**)。
697
+
698
+ **背景:** `expo-contacts` 依赖 native module,在 Web 和 Expo Go 中不可用。
699
+
700
+ **运行时行为:**
701
+ - **Web / Expo Go**:提供 no-op 实现,联系人 API 调用时弹出 Dialog 提示(不崩溃)
702
+ - **Development Build(原生)**:透传真实 `expo-contacts`,功能完全正常
703
+
704
+ ---
705
+
706
+ ### expo-image-picker-stub.js
707
+
708
+ `expo-image-picker` 模块替换 stub,由 `withExpoImagePickerStub()` 在 Metro 层注入(**仅 web 平台**)。
709
+
710
+ **背景:** `expo-image-picker` 的 `launchCameraAsync` 在 web 上底层使用 `<input type="file" capture="environment">`。桌面浏览器(Chrome、Firefox、Safari)**有意忽略 `capture` 属性**,直接弹出文件选择框而不是摄像头。这是浏览器厂商的硬限制,无法通过任何 HTML 属性或 meta 标签绕过。
711
+
712
+ **运行时行为:**
713
+ - **桌面 Web(非 mobile UA)**:`launchCameraAsync` 调用 `navigator.mediaDevices.getUserMedia` 打开摄像头预览弹窗(`#__devkit_camera_overlay__`),用户点击"📷 拍照"后截取一帧并以 `data:image/jpeg` 格式返回,点击"取消"返回 `{ canceled: true }`
714
+ - **移动端浏览器(Android Chrome / iOS Safari)**:透传 expo 原实现,`capture` 属性在移动端正常调起系统相机
715
+ - **`getUserMedia` 不可用**(无摄像头或浏览器限制):静默降级,透传 expo 原实现(不崩溃)
716
+ - **Native(iOS / Android)**:Metro resolver 不拦截,直接使用原生 `expo-image-picker`,功能完全不变
717
+
718
+ **弹窗 DOM 结构(供 Playwright 定位):**
719
+ ```html
720
+ <div id="__devkit_camera_overlay__"> <!-- 全屏遮罩 -->
721
+ <div> <!-- 面板 -->
722
+ <p>拍照</p>
723
+ <video autoplay muted playsinline> <!-- 摄像头预览 -->
724
+ <div>
725
+ <button>📷 拍照</button>
726
+ <button>取消</button>
727
+ </div>
728
+ <p><!-- 错误信息(getUserMedia 失败时显示) --></p>
729
+ </div>
730
+ </div>
731
+ ```
732
+
733
+ **返回值格式(与 expo 原实现兼容):**
734
+ ```ts
735
+ // 拍照成功
736
+ {
737
+ canceled: false,
738
+ assets: [{
739
+ uri: 'data:image/jpeg;base64,...',
740
+ width: 640,
741
+ height: 480,
742
+ type: 'image',
743
+ fileName: 'photo_1234567890.jpg',
744
+ mimeType: 'image/jpeg',
745
+ base64: null,
746
+ exif: null,
747
+ }]
748
+ }
749
+
750
+ // 取消
751
+ { canceled: true, assets: null }
752
+ ```
753
+
754
+ **E2E 测试:** `devkit-e2e/tests/e2e/image-picker-stub.spec.ts`,使用 `addInitScript` mock `getUserMedia`(注入 canvas stream 替代真实摄像头),覆盖弹窗出现/消失、拍照返回结果、取消、降级等场景。
755
+
756
+ ---
757
+
391
758
  ## Babel 插件:jsx-source
392
759
 
393
760
  `babel-plugin-jsx-source` 为 JSX 元素注入 source 属性(文件路径、行列号),用于开发调试。通过 `dataSet` 对象注入,这是 React Web 可识别的数据通道。
@@ -434,3 +801,120 @@ module.exports = {
434
801
  |---|---|---|---|
435
802
  | `rootDir` | `string` | - | 项目根目录,用于计算相对路径。不提供则使用绝对路径 |
436
803
  | `excludePaths` | `string[]` | `[]` | 跳过注入的路径模式列表(相对于 rootDir 的路径片段) |
804
+
805
+ ---
806
+
807
+ ## Babel 插件:lucide-react-native
808
+
809
+ Metro 没有 tree-shaking,直接写 `import { Star } from "lucide-react-native"` 会把整个图标库(约 1500 个图标)打进 bundle。`babel-plugin-lucide-react-native` 在编译阶段将具名导入改写为直接按文件导入,彻底规避这个问题。
810
+
811
+ **转换效果:**
812
+
813
+ ```ts
814
+ // 转换前
815
+ import { Star, BookOpen } from "lucide-react-native";
816
+
817
+ // 转换后(CJS,默认)
818
+ import _Star from "lucide-react-native/dist/cjs/icons/star";
819
+ import _BookOpen from "lucide-react-native/dist/cjs/icons/book-open";
820
+ ```
821
+
822
+ ### 配置
823
+
824
+ 推荐通过内置 Preset 一次性启用所有 Babel 插件:
825
+
826
+ ```js
827
+ // babel.config.js
828
+ module.exports = {
829
+ presets: [['miaoda-expo-devkit/babel-preset', { excludePaths: ['src/components/ui'] }]],
830
+ };
831
+ ```
832
+
833
+ 也可以单独使用:
834
+
835
+ ```js
836
+ module.exports = {
837
+ plugins: [['miaoda-expo-devkit/babel-plugin-lucide-react-native']],
838
+ };
839
+ ```
840
+
841
+ ### 选项
842
+
843
+ | 选项 | 类型 | 默认值 | 说明 |
844
+ |---|---|---|---|
845
+ | `useES` | `boolean` | `false` | 使用 ESM 格式(`dist/esm/icons/`)而非默认的 CJS |
846
+
847
+ ### 支持的写法
848
+
849
+ 插件可识别所有常见的图标引用写法:
850
+
851
+ ```ts
852
+ import { BookOpen, Star, Crown } from "lucide-react-native";
853
+
854
+ // JSX 直接使用
855
+ <BookOpen size={24} />
856
+
857
+ // 赋值给变量
858
+ const icon = BookOpen;
859
+
860
+ // 对象 value
861
+ const ITEMS = [{ icon: BookOpen }, { icon: Star }];
862
+
863
+ // 计算属性 key
864
+ const map = { [BookOpen]: 'read' };
865
+
866
+ // 数组、三元、函数参数
867
+ const list = [BookOpen, Star];
868
+ const active = flag ? BookOpen : Star;
869
+ renderIcon(Crown);
870
+
871
+ // 导入别名
872
+ import { BookOpen as ReadIcon } from "lucide-react-native";
873
+ <ReadIcon />
874
+
875
+ // re-export
876
+ export { Star, BookOpen as ReadIcon } from "lucide-react-native";
877
+ ```
878
+
879
+ ### 版本兼容性
880
+
881
+ 插件在运行时探测 `lucide-react-native` 的实际目录结构,自动适配新旧版本的路径差异(`>= 1.9` 新增 `icons/` 子目录;旧版 `1.8.x` 图标直接位于 `dist/cjs/`),lucide 升级时无需修改配置。
882
+
883
+ ---
884
+
885
+ ## pnpm 配置依赖与兼容补丁
886
+
887
+ DevKit 可以作为 pnpm `configDependency`,在普通依赖解析前集中注入经过验证的 Expo/React Native 兼容补丁。要求 pnpm `>=10.32.0`。
888
+
889
+ 发布新版 DevKit 后,在应用工作区根目录执行:
890
+
891
+ ```bash
892
+ pnpm add --config --registry=https://registry.npmjs.org/ miaoda-expo-devkit@<version>
893
+ ```
894
+
895
+ 由于现有包名不属于 pnpm 的自动插件命名空间,还需要在 `pnpm-workspace.yaml` 中指定 hook:
896
+
897
+ ```yaml
898
+ pnpmfile:
899
+ - node_modules/.pnpm-config/miaoda-expo-devkit/pnpmfile.cjs
900
+ ```
901
+
902
+ 本仓库的 `scripts/build.sh` 会自动把生成 Template 的 `pnpmfile` 改为上述 config dependency 路径,并写入精确版本和完整性校验;无需在生成产物中维护额外的 `.pnpmfile.cjs`。
903
+
904
+ DevKit 当前管理以下补丁:
905
+
906
+ - `@shopify/react-native-skia@2.4.18`
907
+ - `expo@55.0.6`(固定 `expo-audio@55.0.6` 和 `expo-video@55.0.5` 的 Expo 兼容版本表)
908
+ - `expo-modules-core@55.0.15`
909
+ - `react-native-css-interop@0.2.6`
910
+ - `react-native-reanimated@4.2.1`
911
+
912
+ 迁移历史项目时,hook 会计算同名本地 patch 的 SHA-256:已知模板 patch 会自动切换到 DevKit 内置文件;内容不同的项目定制 patch 会继续优先,并输出警告。项目中其他 `patchedDependencies` 不受影响。迁移后必须重新生成并提交 `pnpm-lock.yaml`。
913
+
914
+ 发布前可用一条命令运行轻量集成测试:
915
+
916
+ ```bash
917
+ pnpm --dir packages/devkit test:patches
918
+ ```
919
+
920
+ 该命令会自动构建并打包 DevKit,在临时项目中模拟 pnpm 安装 Config Dependency 后的目录布局,安装五个精确版本的目标依赖,检查补丁后的文件及 lockfile,然后执行 frozen-lockfile 复装并清理临时目录。它不会运行 Expo Prebuild、Gradle 或 Android 编译。
package/biome-config.json CHANGED
@@ -1,14 +1,52 @@
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
+ "expo-background-fetch": "Deprecated. Use expo-background-task instead.",
30
+ "expo-av": "Removed in SDK 55. Use expo-video for video and expo-audio for audio instead."
31
+ }
32
+ }
33
+ }
8
34
  }
9
35
  }
10
36
  },
11
37
  "formatter": {
12
38
  "enabled": false
13
- }
39
+ },
40
+ "overrides": [
41
+ {
42
+ "includes": ["tailwind.config.js"],
43
+ "linter": {
44
+ "rules": {
45
+ "style": {
46
+ "noCommonJs": "off"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ]
14
52
  }
@@ -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;