@ng-native/nx 0.2.0 → 0.4.0

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/native-app.cjs CHANGED
@@ -2,9 +2,9 @@
2
2
  * What an Angular Native app is made of, in an Nx workspace.
3
3
  *
4
4
  * The source of truth is `template/`, the starter `create-expo-app` uses and the setup this project
5
- * verifies before every release. `files/` holds verbatim copies of the template's source files and
6
- * the versions below are the template's own, and `native-app.test.ts` fails the moment either
7
- * drifts from it.
5
+ * verifies before every release. `files/` holds verbatim copies of the template's source files,
6
+ * the versions are the template's own, and `native-app.test.ts` fails the moment either drifts
7
+ * from it.
8
8
  *
9
9
  * Three files differ from the template's, each because of something Nx does:
10
10
  *
@@ -16,59 +16,35 @@
16
16
  * - `vitest.config.mts` adds `nxViteTsPaths` when the workspace has `@nx/vite`, for the same
17
17
  * aliases, which Vitest does not read from tsconfig either.
18
18
  *
19
- * The `@ng-native/*` packages are pinned to this package's own version, because every one of them
20
- * is released in lockstep with it.
19
+ * What does not depend on Nx, the dependencies, `app.json` and bundle identifier among them, is
20
+ * `@ng-native/migrate/native-app.cjs`, which `@ng-native/schematics` builds its apps from too.
21
21
  */
22
22
  const { readFileSync } = require('node:fs');
23
23
  const path = require('node:path');
24
24
  const semver = require('semver');
25
+ const shared = require('@ng-native/migrate/native-app.cjs');
25
26
 
26
- const { version } = require('./package.json');
27
-
28
- const FRAMEWORK = [
29
- '@ng-native/components',
30
- '@ng-native/device',
31
- '@ng-native/fabric',
32
- '@ng-native/metro',
33
- '@ng-native/platform',
34
- ];
35
-
36
- /** @type {Record<string, string>} */
37
- const dependencies = {
38
- ...Object.fromEntries(FRAMEWORK.map((name) => [name, version])),
39
- '@angular/common': '^22.0.0',
40
- '@angular/core': '^22.0.0',
41
- expo: '~57.0.26',
42
- 'expo-status-bar': '~57.0.1',
43
- 'expo-system-ui': '~57.0.4',
44
- react: '19.2.3',
45
- 'react-native': '0.86.3',
46
- 'react-native-safe-area-context': '~5.7.0',
47
- };
27
+ const { dependencies, devDependencies, accepted } = shared;
48
28
 
49
29
  /**
50
30
  * What `nx add` puts beside `@nx/expo`, in place of the `@nx/expo:init` it does not run: see
51
- * `init/index.cjs`. `@expo/cli` and `@babel/runtime` are the ranges the template's `expo` depends
52
- * on.
31
+ * `init/index.cjs`. `@expo/cli`, `@babel/runtime` and `@expo/metro` are the ranges the template's
32
+ * `expo` depends on, and `@babel/core` is the app's own, which every plugin in Expo's Babel preset peers on.
53
33
  */
54
34
  const expoCompanions = {
55
35
  'react-dom': dependencies.react,
56
36
  '@expo/cli': '^57.0.27',
57
37
  '@babel/runtime': '^7.20.0',
38
+ '@babel/core': devDependencies['@babel/core'],
39
+ '@expo/metro': '~56.0.2',
58
40
  };
59
41
 
60
- /** @type {Record<string, string>} */
61
- const devDependencies = {
62
- // An Angular workspace already has Babel 8 through @angular/compiler-cli, and React Native's
63
- // Babel peer would otherwise take the newest copy there is.
64
- // The typecheck script compiles the templates too, which only the Angular compiler can.
65
- '@angular/compiler-cli': '^22.0.0',
66
- '@babel/core': '^7.29.7',
67
- '@ng-native/testing': version,
68
- '@types/react': '~19.2.2',
69
- typescript: '~6.0.3',
70
- vitest: '^5.0.0',
71
- };
42
+ /**
43
+ * The template's `typecheck` script. It loads the Metro config first, which builds the sheet
44
+ * `@ng-native/tailwind` generates into `.angular-native/` once and exits: `src/main.ts` imports it
45
+ * once Tailwind is added, and a fresh checkout has none until Metro has run.
46
+ */
47
+ const TYPECHECK = 'node metro.config.js && ngc -p tsconfig.json --noEmit';
72
48
 
73
49
  /** The template's files, copied verbatim into the new app. */
74
50
  const SOURCE_FILES = ['src/app/app.ts', 'src/main.ts', 'src/app/app.test.ts'];
@@ -91,74 +67,6 @@ function agentsFile(commands) {
91
67
  );
92
68
  }
93
69
 
94
- /** Java's reserved words, which Android refuses as a segment of a package name. */
95
- const JAVA_KEYWORDS = new Set(
96
- (
97
- 'abstract assert boolean break byte case catch char class const continue default do double ' +
98
- 'else enum extends false final finally float for goto if implements import instanceof int ' +
99
- 'interface long native new null package private protected public return short static ' +
100
- 'strictfp super switch synchronized this throw throws transient true try void volatile while'
101
- ).split(' '),
102
- );
103
-
104
- /**
105
- * Whether iOS takes `id` as a bundle identifier and Android as a package name: two or more
106
- * segments, each a letter and then letters or digits (iOS refuses `_`, Android `-`), none of them
107
- * a Java keyword.
108
- *
109
- * @param {string} id
110
- */
111
- function isBundleIdentifier(id) {
112
- const segments = id.split('.');
113
- return (
114
- segments.length > 1 &&
115
- segments.every((s) => /^[a-z][a-z0-9]*$/i.test(s) && !JAVA_KEYWORDS.has(s))
116
- );
117
- }
118
-
119
- /**
120
- * `com.<scope>.<name>`, for an app given no `--bundleIdentifier`, where prebuild would otherwise
121
- * make it `com.anonymous.<name>`. The scope is left out when it cannot be made a segment.
122
- *
123
- * @param {string} name the app's name, without a scope
124
- * @param {string | undefined} scope the workspace's npm scope, `@acme`
125
- */
126
- function bundleIdentifier(name, scope) {
127
- const clean = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
128
- const valid = (segment) => /^[a-z]/.test(segment) && !JAVA_KEYWORDS.has(segment);
129
- const org = clean(scope ?? '');
130
- const app = clean(name);
131
- return ['com', ...(valid(org) ? [org] : []), valid(app) ? app : `app${app}`].join('.');
132
- }
133
-
134
- /**
135
- * The template's `app.json`, named for this app, without the template's icons: they are the
136
- * template's branding, and Expo draws its own default until `icon` is set.
137
- *
138
- * @param {string} name the project name, which is also the Expo slug
139
- * @param {string} [id] the iOS bundle identifier and Android package, by default one from `name`
140
- */
141
- function appJson(name, id) {
142
- const slug = name.replace(/^@[^/]+\//, '');
143
- const bundle = id ?? bundleIdentifier(slug, name.match(/^@[^/]+/)?.[0]);
144
- const expo = {
145
- name: slug,
146
- slug,
147
- version: '1.0.0',
148
- // Not left to Expo, which adds `web` whenever `react-dom` resolves, and @nx/react puts one there.
149
- platforms: ['ios', 'android'],
150
- orientation: 'portrait',
151
- userInterfaceStyle: 'dark',
152
- ios: { supportsTablet: true, bundleIdentifier: bundle },
153
- android: { package: bundle, predictiveBackGestureEnabled: false },
154
- scheme: slug.replace(/[^a-z0-9]/gi, '').toLowerCase(),
155
- // The template's: Expo otherwise guesses a router root from src/app and says so on every start.
156
- extra: { router: { root: 'src/app' } },
157
- plugins: ['@ng-native/metro'],
158
- };
159
- return JSON.stringify({ expo }, null, 2) + '\n';
160
- }
161
-
162
70
  const METRO_CONFIG = `const { withNxMetro } = require('@nx/expo');
163
71
  const { getDefaultConfig } = require('expo/metro-config');
164
72
  const { withAngularNative } = require('@ng-native/metro/config.cjs');
@@ -213,6 +121,31 @@ function tsconfig(workspaceBase, conditions = []) {
213
121
  };
214
122
  }
215
123
 
124
+ /** The `@ng-native` packages with entry points beside their main one, each published in `dist`. */
125
+ const WITH_ENTRY_POINTS = ['components', 'expo', 'platform', 'web'];
126
+
127
+ /** `nx typecheck` in a workspace whose libraries are the package manager's links. */
128
+ const TYPECHECK_ONE_COPY = TYPECHECK.replace('tsconfig.json', 'tsconfig.typecheck.json');
129
+
130
+ /**
131
+ * The tsconfig `nx typecheck` reads where libraries are the package manager's links: the app's,
132
+ * with every `@ng-native` package resolved to the app's own copy.
133
+ *
134
+ * pnpm gives a library a copy of its own of a package whose peers it does not share with the app,
135
+ * the gesture and animation packages an app adds among them. `ngc` then finds a component a
136
+ * library imports in one copy and the app's import of it in the other, and stops at `NG3004`
137
+ * before it checks a template. The Metro preset and the Vitest plugin make the same choice where
138
+ * they resolve. It is a file of its own because Metro reads the `paths` of `tsconfig.json`, and
139
+ * these skip the `exports` of each package, its `browser` condition included.
140
+ */
141
+ function typecheckTsconfig() {
142
+ const paths = { '@ng-native/*': ['./node_modules/@ng-native/*'] };
143
+ for (const name of WITH_ENTRY_POINTS) {
144
+ paths[`@ng-native/${name}/*`] = [`./node_modules/@ng-native/${name}/dist/*`];
145
+ }
146
+ return { extends: './tsconfig.json', compilerOptions: { paths } };
147
+ }
148
+
216
149
  /** @param {boolean} withPaths whether to resolve the workspace's tsconfig path aliases */
217
150
  function vitestConfig(withPaths) {
218
151
  const template = readFileSync(path.join(__dirname, 'files', 'vitest.config.mts'), 'utf8');
@@ -226,32 +159,6 @@ function vitestConfig(withPaths) {
226
159
  .replace('plugins: [ngNative()],', 'plugins: [nxViteTsPaths(), ngNative()],');
227
160
  }
228
161
 
229
- /**
230
- * The dependencies an integrated workspace's app lists in its own `package.json`, at the ranges
231
- * the root installs, though nothing installs from it.
232
- *
233
- * All of them, because Expo's autolinking links the native modules the app's `package.json` names
234
- * and no others: without `react-native-safe-area-context` here, Android crashed on RNCSafeAreaView.
235
- * And `expo prebuild`, which `expo run:ios` starts with, adds `expo`, `react` or `react-native` if
236
- * it does not find them and then offers to install them into the app's directory, which would put a
237
- * second copy of React Native beside the root's.
238
- *
239
- * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} root
240
- */
241
- function prebuildPins(root) {
242
- const installed = { ...root.devDependencies, ...root.dependencies };
243
- return Object.fromEntries(
244
- Object.keys(dependencies).map((pkg) => [pkg, installed[pkg] ?? dependencies[pkg]]),
245
- );
246
- }
247
-
248
- /**
249
- * Where what a workspace already has can be older than what a new app is given: `@ng-native/testing`
250
- * installs Vitest 5 but its plugin runs on 4, which `ng new` 22.1 and the Nx preset still start on.
251
- * The range here is that package's `vitest` peer, and a test holds the two together.
252
- */
253
- const accepted = { vitest: '^4.0.8 || ^5.0.0' };
254
-
255
162
  /**
256
163
  * The ranges a workspace package's `package.json` lists: the root's own where everything it can
257
164
  * resolve to is a version Angular Native accepts, so that pnpm does not install a second Vitest
@@ -276,49 +183,18 @@ function reuseRootRanges(wanted, root) {
276
183
  );
277
184
  }
278
185
 
279
- /**
280
- * Why an existing dependency would stop this app installing or running, as a sentence each.
281
- *
282
- * Only ranges already in the manifest are judged, and only a range that cannot overlap the one
283
- * wanted is a conflict: `~6.0.2` and `~6.0.3` resolve to the same TypeScript. A missing package is
284
- * added at the version above. Nothing is rewritten, because moving a workspace's Angular or Vitest
285
- * is an upgrade of every project in it, and that belongs to `nx migrate`, not to a generator.
286
- *
287
- * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} manifest
288
- * @returns {string[]}
289
- */
290
- function conflicts(manifest) {
291
- const existing = { ...manifest.dependencies, ...manifest.devDependencies };
292
- const problems = [];
293
- for (const [name, installed] of Object.entries({ ...dependencies, ...devDependencies })) {
294
- const range = existing[name];
295
- const wanted = accepted[name] ?? installed;
296
- if (!range || name.startsWith('@ng-native/')) continue;
297
- if (semver.validRange(range) && !semver.intersects(range, wanted)) {
298
- problems.push(
299
- `${name} is ${range} here, and Angular Native needs ${wanted}. ` +
300
- `npm will refuse the install until it is moved.`,
301
- );
302
- }
303
- }
304
- return problems;
305
- }
306
-
307
186
  module.exports = {
308
- dependencies,
309
- devDependencies,
187
+ ...shared,
310
188
  SOURCE_FILES,
311
189
  sourceFile,
312
190
  agentsFile,
313
- appJson,
314
- bundleIdentifier,
315
- isBundleIdentifier,
316
191
  METRO_CONFIG,
192
+ TYPECHECK,
193
+ TYPECHECK_ONE_COPY,
194
+ WITH_ENTRY_POINTS,
317
195
  tsconfig,
196
+ typecheckTsconfig,
318
197
  vitestConfig,
319
- prebuildPins,
320
198
  reuseRootRanges,
321
199
  expoCompanions,
322
- conflicts,
323
- accepted,
324
200
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ng-native/nx",
3
- "version": "0.2.0",
4
- "description": "Nx generators for Angular Native: nx add, and an Expo app project whose targets run it.",
3
+ "version": "0.4.0",
4
+ "description": "Nx generators for Angular Native: nx add, an Expo app project whose targets run it, and native libraries and components.",
5
5
  "keywords": [
6
6
  "angular",
7
7
  "react-native",
@@ -17,7 +17,27 @@
17
17
  ],
18
18
  "type": "module",
19
19
  "generators": "./generators.json",
20
+ "nx-migrations": {
21
+ "migrations": "./migrations.json",
22
+ "packageGroup": [
23
+ "@ng-native/components",
24
+ "@ng-native/device",
25
+ "@ng-native/expo",
26
+ "@ng-native/fabric",
27
+ "@ng-native/icons",
28
+ "@ng-native/metro",
29
+ "@ng-native/migrate",
30
+ "@ng-native/nx",
31
+ "@ng-native/platform",
32
+ "@ng-native/router",
33
+ "@ng-native/schematics",
34
+ "@ng-native/tailwind",
35
+ "@ng-native/testing",
36
+ "@ng-native/web"
37
+ ]
38
+ },
20
39
  "dependencies": {
40
+ "@ng-native/migrate": "0.4.0",
21
41
  "@nx/devkit": "^23.0.0",
22
42
  "minimatch": "^10.0.0",
23
43
  "semver": "^7.7.0"
@@ -26,12 +46,17 @@
26
46
  "nx": "^23.0.0"
27
47
  },
28
48
  "devDependencies": {
29
- "@types/semver": "^7.7.0"
49
+ "@types/semver": "^7.7.0",
50
+ "typescript": "~6.0.3"
30
51
  },
31
52
  "nx": {
53
+ "projectType": "library",
32
54
  "tags": [
33
55
  "scope:nx",
34
56
  "layer:build"
57
+ ],
58
+ "implicitDependencies": [
59
+ "@ng-native/template"
35
60
  ]
36
61
  },
37
62
  "license": "MIT",
@@ -47,6 +72,7 @@
47
72
  "files": [
48
73
  "*.cjs",
49
74
  "generators.json",
75
+ "migrations.json",
50
76
  "*/*.cjs",
51
77
  "*/*.json",
52
78
  "files"
@@ -56,6 +82,6 @@
56
82
  },
57
83
  "scripts": {
58
84
  "typecheck": "tsc -p tsconfig.json --noEmit",
59
- "test": "node --test \"*.test.ts\" \"{init,application}/*.test.ts\""
85
+ "test": "node --test \"*.test.ts\" \"{init,application,sync,library,component,migrations,tailwind}/*.test.ts\""
60
86
  }
61
87
  }
package/sync/index.cjs ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `@ng-native/nx:sync-native-modules`: lists in each Angular Native app's `package.json` the native
3
+ * modules the workspace libraries it uses import, and adds their config plugins to its `app.json`.
4
+ *
5
+ * Expo links only the native modules an app's own `package.json` names. Expo Go contains every
6
+ * module in the SDK, so an app whose library imports one the app does not list works there and
7
+ * fails in a development or release build. A library cannot declare what it needs of an app, but
8
+ * Nx's project graph records what each one imports, so this reads it from there.
9
+ *
10
+ * It is a sync generator: the app generator registers it on the app's `start`, `export` and
11
+ * `prebuild` targets, so Nx runs it before them, and `nx sync:check` fails a CI run it would
12
+ * change. It only adds. A module the app lists stays listed when no library imports it any more,
13
+ * since the app may use it itself.
14
+ *
15
+ * A native module is a package with an Expo module config or a native project of its own, as
16
+ * Expo's autolinking finds them. It counts when a library imports it, and when a package a
17
+ * library imports peers on it without marking the peer optional, as `@ng-native/icons` does on
18
+ * `react-native-svg`.
19
+ */
20
+ const { existsSync, readdirSync, readFileSync } = require('node:fs');
21
+ const nodePath = require('node:path');
22
+ const {
23
+ createProjectGraphAsync,
24
+ formatFiles,
25
+ getProjects,
26
+ joinPathFragments,
27
+ readJson,
28
+ readProjectConfiguration,
29
+ updateJson,
30
+ updateProjectConfiguration,
31
+ } = require('@nx/devkit');
32
+
33
+ const SYNC_GENERATOR = '@ng-native/nx:sync-native-modules';
34
+
35
+ /** Whether the package in `dir` has native code Expo's autolinking links. */
36
+ function isNativeModule(dir) {
37
+ if (existsSync(nodePath.join(dir, 'expo-module.config.json'))) return true;
38
+ if (readdirSync(dir).some((file) => file.endsWith('.podspec'))) return true;
39
+ return ['build.gradle', 'build.gradle.kts'].some((file) =>
40
+ existsSync(nodePath.join(dir, 'android', file)),
41
+ );
42
+ }
43
+
44
+ /** The directory `pkg` is installed in, as Node resolves it from `from`. */
45
+ function packageDir(pkg, from) {
46
+ try {
47
+ return nodePath.dirname(require.resolve(`${pkg}/package.json`, { paths: [from] }));
48
+ } catch {
49
+ // A package whose exports leave out its package.json: up from its entry point.
50
+ }
51
+ let dir;
52
+ try {
53
+ dir = nodePath.dirname(require.resolve(pkg, { paths: [from] }));
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ for (; dir !== nodePath.dirname(dir); dir = nodePath.dirname(dir)) {
58
+ const manifest = nodePath.join(dir, 'package.json');
59
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === pkg) return dir;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /** The workspace beyond the tree: its project graph and its installed packages. Replaced in tests. */
65
+ const workspace = {
66
+ graph: () => createProjectGraphAsync(),
67
+
68
+ /**
69
+ * What `pkg`, resolved from the directory `from`, is to an app, or undefined when it is not
70
+ * installed.
71
+ *
72
+ * @param {string} pkg
73
+ * @param {string} from
74
+ * @returns {{ version: string, native: boolean, plugin: boolean, peers: string[], dir: string } | undefined}
75
+ */
76
+ installed(pkg, from) {
77
+ const dir = packageDir(pkg, from);
78
+ if (!dir) return undefined;
79
+ const manifest = JSON.parse(readFileSync(nodePath.join(dir, 'package.json'), 'utf8'));
80
+ const meta = manifest.peerDependenciesMeta ?? {};
81
+ return {
82
+ version: manifest.version,
83
+ native: isNativeModule(dir),
84
+ plugin: existsSync(nodePath.join(dir, 'app.plugin.js')),
85
+ peers: Object.keys(manifest.peerDependencies ?? {}).filter((peer) => !meta[peer]?.optional),
86
+ dir,
87
+ };
88
+ },
89
+
90
+ /** The config plugins Expo applies whether an app lists them or not, as `expo install` skips. */
91
+ autoPlugins(from) {
92
+ try {
93
+ const expo = packageDir('expo', from);
94
+ const config = require(require.resolve('@expo/prebuild-config', { paths: [expo ?? from] }));
95
+ return config.getAutoPlugins();
96
+ } catch {
97
+ return [];
98
+ }
99
+ },
100
+ };
101
+
102
+ /** The projects the app `name` depends on, directly or through another, in the graph. */
103
+ function librariesOf(graph, name) {
104
+ const found = new Set();
105
+ const visit = (project) => {
106
+ for (const { target } of graph.dependencies[project] ?? []) {
107
+ if (!graph.nodes[target] || found.has(target) || target === name) continue;
108
+ found.add(target);
109
+ visit(target);
110
+ }
111
+ };
112
+ visit(name);
113
+ return [...found];
114
+ }
115
+
116
+ /** The packages a project imports, by name. */
117
+ function importsOf(graph, project) {
118
+ return (graph.dependencies[project] ?? [])
119
+ .map(({ target }) => graph.externalNodes?.[target]?.data?.packageName)
120
+ .filter(Boolean);
121
+ }
122
+
123
+ /** Whether the project is an Angular Native app: an Expo app that runs on `@ng-native/platform`. */
124
+ function isNativeApp(tree, root) {
125
+ const manifest = joinPathFragments(root, 'package.json');
126
+ if (!tree.exists(joinPathFragments(root, 'app.json')) || !tree.exists(manifest)) return false;
127
+ const { dependencies, devDependencies } = readJson(tree, manifest);
128
+ return Boolean({ ...dependencies, ...devDependencies }['@ng-native/platform']);
129
+ }
130
+
131
+ function listed(manifest) {
132
+ return { ...manifest.devDependencies, ...manifest.dependencies };
133
+ }
134
+
135
+ /**
136
+ * The native modules the app `name` needs for its libraries, each with the library that brings it
137
+ * in, the range to list and whether it has a config plugin.
138
+ */
139
+ function needed(tree, graph, name) {
140
+ const root = listed(readJson(tree, 'package.json'));
141
+ const modules = new Map();
142
+ for (const library of librariesOf(graph, name)) {
143
+ const libraryRoot = graph.nodes[library].data.root;
144
+ const from = nodePath.join(tree.root, libraryRoot);
145
+ const manifest = joinPathFragments(libraryRoot, 'package.json');
146
+ const own = tree.exists(manifest) ? listed(readJson(tree, manifest)) : {};
147
+ const add = (pkg, info, via) => {
148
+ if (!info?.native || modules.has(pkg)) return;
149
+ const range = root[pkg] ?? own[pkg] ?? info.version;
150
+ modules.set(pkg, { library: libraryRoot, via, range, plugin: info.plugin });
151
+ };
152
+ for (const pkg of importsOf(graph, library)) {
153
+ const info = workspace.installed(pkg, from);
154
+ add(pkg, info);
155
+ for (const peer of info?.peers ?? []) add(peer, workspace.installed(peer, info.dir), pkg);
156
+ }
157
+ }
158
+ return modules;
159
+ }
160
+
161
+ /** The names in an `app.json`'s `plugins`, each a name or a `[name, options]` pair. */
162
+ function pluginNames(plugins) {
163
+ return (plugins ?? []).map((plugin) => (Array.isArray(plugin) ? plugin[0] : plugin));
164
+ }
165
+
166
+ /**
167
+ * @param {import('@nx/devkit').Tree} tree
168
+ * @returns {Promise<{ outOfSyncMessage?: string, outOfSyncDetails?: string[] }>}
169
+ */
170
+ async function syncNativeModules(tree) {
171
+ const graph = await workspace.graph();
172
+ const details = [];
173
+ for (const [name, project] of getProjects(tree)) {
174
+ if (!isNativeApp(tree, project.root) || !graph.nodes[name]) continue;
175
+ const manifestPath = joinPathFragments(project.root, 'package.json');
176
+ const has = listed(readJson(tree, manifestPath));
177
+ const all = [...needed(tree, graph, name)];
178
+ const modules = all.filter(([pkg]) => !(pkg in has));
179
+ if (modules.length > 0) {
180
+ updateJson(tree, manifestPath, (manifest) => {
181
+ manifest.dependencies ??= {};
182
+ for (const [pkg, { range }] of modules) manifest.dependencies[pkg] = range;
183
+ return manifest;
184
+ });
185
+ }
186
+ for (const [pkg, { library, via }] of modules) {
187
+ const reason = via ? `${library} imports ${via}, which needs it` : `${library} imports it`;
188
+ details.push(`${project.root}/package.json needs ${pkg}: ${reason}.`);
189
+ }
190
+
191
+ const appJson = joinPathFragments(project.root, 'app.json');
192
+ const auto = new Set(workspace.autoPlugins(nodePath.join(tree.root, project.root)));
193
+ const plugins = all.filter(([pkg, { plugin }]) => plugin && !auto.has(pkg)).map(([pkg]) => pkg);
194
+ const present = pluginNames(readJson(tree, appJson).expo?.plugins);
195
+ const missing = plugins.filter((pkg) => !present.includes(pkg));
196
+ if (missing.length > 0) {
197
+ updateJson(tree, appJson, (config) => {
198
+ config.expo ??= {};
199
+ config.expo.plugins = [...(config.expo.plugins ?? []), ...missing];
200
+ return config;
201
+ });
202
+ for (const pkg of missing) details.push(`${appJson} needs the config plugin of ${pkg}.`);
203
+ }
204
+ }
205
+ if (details.length === 0) return {};
206
+ // As the app generator wrote them, rather than as JSON.stringify does.
207
+ await formatFiles(tree);
208
+ return {
209
+ outOfSyncMessage:
210
+ 'An app does not list a native module its libraries import, so a development build would not link it.',
211
+ outOfSyncDetails: details,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Registers the sync generator on the app's `start`, `export` and `prebuild` targets. `export` and
217
+ * `prebuild` are `@nx/expo`'s, inferred, and take the option from `project.json` beside the rest.
218
+ *
219
+ * @param {import('@nx/devkit').Tree} tree
220
+ * @param {string} projectName
221
+ */
222
+ function registerSyncGenerator(tree, projectName) {
223
+ const project = readProjectConfiguration(tree, projectName);
224
+ const targets = { ...project.targets };
225
+ for (const target of ['start', 'export', 'prebuild']) {
226
+ const syncGenerators = targets[target]?.syncGenerators ?? [];
227
+ if (syncGenerators.includes(SYNC_GENERATOR)) continue;
228
+ targets[target] = { ...targets[target], syncGenerators: [...syncGenerators, SYNC_GENERATOR] };
229
+ }
230
+ updateProjectConfiguration(tree, projectName, { ...project, targets });
231
+ }
232
+
233
+ module.exports = { syncNativeModules, registerSyncGenerator, workspace, SYNC_GENERATOR };
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json-schema.org/schema",
3
+ "$id": "NgNativeNxSyncNativeModules",
4
+ "title": "Sync an app's native modules",
5
+ "description": "Lists the native modules an app's libraries import in the app's package.json, with their config plugins in app.json. A sync generator.",
6
+ "type": "object",
7
+ "properties": {}
8
+ }