@ng-native/nx 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,9 @@
7
7
  * `test`, which runs Vitest once rather than watching. It also replaces the inferred `start`,
8
8
  * which is `@nx/expo:start`, an executor Nx deprecates and warns about on every run, with the
9
9
  * `expo start` that executor runs, as the plugin already infers `run-ios` and `export`. Once the
10
- * plugin infers `start` as a command too, this one can go.
10
+ * plugin infers `start` as a command too, this one can go. And it replaces the inferred `serve`,
11
+ * `expo start --web`, which the plugin adds to every Expo app, with `expo start` too: the app has
12
+ * no web platform.
11
13
  *
12
14
  * Where the dependencies go depends on the workspace. With package-manager workspaces, the Nx
13
15
  * default since 20, the app is a workspace package and lists them itself, which pnpm needs in
@@ -28,6 +30,7 @@ const {
28
30
  const native = require('../native-app.cjs');
29
31
  const { init, workspaceNxVersion } = require('../init/index.cjs');
30
32
  const { usesWorkspaces, includeInWorkspaces } = require('./workspaces.cjs');
33
+ const { asSaved } = require('../save-exact.cjs');
31
34
 
32
35
  function names(tree, options) {
33
36
  const directory = path.normalize(options.directory).replace(/\/$/, '');
@@ -36,7 +39,50 @@ function names(tree, options) {
36
39
  const workspaces = usesWorkspaces(tree);
37
40
  // A workspace package is named for the workspace's scope, as Nx names its own.
38
41
  const projectName = workspaces && scope ? `${scope}/${name}` : name;
39
- return { directory, name, projectName, workspaces };
42
+ const bundleIdentifier = options.bundleIdentifier ?? native.bundleIdentifier(name, scope);
43
+ return { directory, name, projectName, workspaces, bundleIdentifier };
44
+ }
45
+
46
+ /**
47
+ * Whether the workspace's `tsconfig.base.json` is where its libraries' path aliases live, or will.
48
+ * With package-manager workspaces, only when it has a `paths` list, as the `angular-monorepo`
49
+ * preset's does: the TypeScript preset's has none, and its libraries are packages linked instead.
50
+ */
51
+ function hasPathAliases(tree, workspaces) {
52
+ if (!tree.exists('tsconfig.base.json')) return false;
53
+ return !workspaces || 'paths' in baseCompilerOptions(tree);
54
+ }
55
+
56
+ /**
57
+ * The compiler options `file` ends up with, those it inherits through `extends` included, as
58
+ * TypeScript merges them: each option from the last config that sets it.
59
+ */
60
+ function baseCompilerOptions(tree, file = 'tsconfig.base.json', ancestors = []) {
61
+ if (ancestors.includes(file) || !tree.exists(file)) return {};
62
+ const config = readJson(tree, file);
63
+ const inherited = [config.extends ?? []]
64
+ .flat()
65
+ .map((parent) => extendedFile(tree, path.dirname(file), parent))
66
+ .filter(Boolean)
67
+ .map((parent) => baseCompilerOptions(tree, parent, [...ancestors, file]));
68
+ return Object.assign({}, ...inherited, config.compilerOptions);
69
+ }
70
+
71
+ /**
72
+ * The file an `extends` entry in `directory` names: a path, or a package in a `node_modules` from
73
+ * `directory` up, where a workspace's own shared-config package is linked. A bare package name
74
+ * means its `tsconfig.json`.
75
+ */
76
+ function extendedFile(tree, directory, parent) {
77
+ const withJson = (file) => (tree.exists(file) || file.endsWith('.json') ? file : `${file}.json`);
78
+ if (/^\.\.?\//.test(parent)) return withJson(path.join(directory, parent));
79
+ const bare = /^(@[^/]+\/)?[^/]+$/.test(parent);
80
+ for (let dir = directory; ; dir = path.dirname(dir)) {
81
+ const file = path.join(dir, 'node_modules', parent);
82
+ const found = bare ? path.join(file, 'tsconfig.json') : withJson(file);
83
+ if (tree.exists(found)) return found;
84
+ if (dir === '.' || dir === '/') return undefined;
85
+ }
40
86
  }
41
87
 
42
88
  function targets(directory) {
@@ -49,6 +95,7 @@ function targets(directory) {
49
95
  },
50
96
  test: { ...run('vitest run'), cache: true, inputs: ['default', '^production'] },
51
97
  start: { ...run('expo start'), continuous: true },
98
+ serve: { ...run('expo start'), continuous: true },
52
99
  };
53
100
  }
54
101
 
@@ -72,30 +119,49 @@ function commands(name) {
72
119
  ].join('\n');
73
120
  }
74
121
 
75
- function writeFiles(tree, { directory, projectName, workspaces }) {
122
+ /**
123
+ * What a workspace package's app lists: the root's version of a package the root already has,
124
+ * where the app can use it, and its own otherwise, exact if the workspace saves exact versions.
125
+ */
126
+ async function appDependencies(tree, root, wanted) {
127
+ const pinned = native.reuseRootRanges(wanted, root);
128
+ // By what the root lists, not by what changed: the root's range can be the app's own text.
129
+ const installed = { ...root.devDependencies, ...root.dependencies };
130
+ const settled = Object.keys(wanted).filter((pkg) => pinned[pkg] === installed[pkg]);
131
+ return asSaved(tree, pinned, settled);
132
+ }
133
+
134
+ /** The packages a manifest lists, which adding a dependency leaves at the version they have. */
135
+ function listed(manifest) {
136
+ return Object.keys({ ...manifest.dependencies, ...manifest.devDependencies });
137
+ }
138
+
139
+ async function writeFiles(tree, { directory, projectName, workspaces, bundleIdentifier }) {
76
140
  const file = (name, content) => tree.write(joinPathFragments(directory, name), content);
77
141
  for (const name of native.SOURCE_FILES) file(name, native.sourceFile(name));
78
- file('app.json', native.appJson(projectName));
142
+ file('app.json', native.appJson(projectName, bundleIdentifier));
79
143
  file('AGENTS.md', native.agentsFile(commands(projectName)));
80
144
  file('CLAUDE.md', '@AGENTS.md\n');
81
145
  file('metro.config.js', native.METRO_CONFIG);
146
+ // The template's own, for the native projects `expo prebuild` writes beside app.json.
147
+ file('.gitignore', '# generated native folders\n/ios\n/android\n');
82
148
 
83
- const base = !workspaces && tree.exists('tsconfig.base.json');
149
+ const base = hasPathAliases(tree, workspaces);
84
150
  const workspaceBase = base ? `${offsetFromRoot(directory)}tsconfig.base.json` : undefined;
85
- const conditions = tree.exists('tsconfig.base.json')
86
- ? (readJson(tree, 'tsconfig.base.json').compilerOptions?.customConditions ?? [])
87
- : [];
151
+ const conditions = baseCompilerOptions(tree).customConditions ?? [];
88
152
  file('tsconfig.json', JSON.stringify(native.tsconfig(workspaceBase, conditions), null, 2) + '\n');
89
153
  file('vitest.config.mts', native.vitestConfig(Boolean(base)));
90
154
 
91
155
  const manifest = { name: projectName, version: '0.0.1', private: true, main: 'src/main.ts' };
156
+ const root = readJson(tree, 'package.json');
92
157
  if (workspaces) {
158
+ const root = readJson(tree, 'package.json');
93
159
  Object.assign(manifest, {
94
- dependencies: native.dependencies,
95
- devDependencies: native.devDependencies,
160
+ dependencies: await appDependencies(tree, root, native.dependencies),
161
+ devDependencies: await appDependencies(tree, root, native.devDependencies),
96
162
  });
97
163
  } else {
98
- manifest.dependencies = native.prebuildPins(readJson(tree, 'package.json'));
164
+ manifest.dependencies = native.prebuildPins(root);
99
165
  }
100
166
  file('package.json', JSON.stringify(manifest, null, 2) + '\n');
101
167
  }
@@ -110,11 +176,17 @@ function ignoreExpo(tree) {
110
176
 
111
177
  /**
112
178
  * @param {import('@nx/devkit').Tree} tree
113
- * @param {{ directory: string, name?: string, tags?: string, skipInstall?: boolean, skipFormat?: boolean }} options
179
+ * @param {{ directory: string, name?: string, bundleIdentifier?: string, tags?: string, skipInstall?: boolean, skipFormat?: boolean }} options
114
180
  */
115
181
  async function application(tree, options) {
116
182
  const resolved = names(tree, options);
117
183
  const { directory, projectName, workspaces } = resolved;
184
+ if (!native.isBundleIdentifier(resolved.bundleIdentifier)) {
185
+ throw new Error(
186
+ `${resolved.bundleIdentifier} is not a bundle identifier both iOS and Android accept: ` +
187
+ 'two or more dot-separated segments, each a letter and then letters or digits, none a Java keyword.',
188
+ );
189
+ }
118
190
  if (tree.exists(joinPathFragments(directory, 'package.json'))) {
119
191
  throw new Error(`${directory} already has a package.json. Choose another directory.`);
120
192
  }
@@ -124,21 +196,23 @@ async function application(tree, options) {
124
196
  includeInWorkspaces(tree, directory);
125
197
  } else {
126
198
  for (const problem of native.conflicts(readJson(tree, 'package.json'))) logger.warn(problem);
127
- // `nxViteTsPaths()`, which the app's Vitest config uses to reach the workspace's libraries.
128
- // Added now rather than only when present, because a library generated later is the ordinary
129
- // case and nothing would come back to add it then.
130
- const aliases = tree.exists('tsconfig.base.json')
131
- ? { '@nx/vite': workspaceNxVersion(tree) }
132
- : {};
199
+ const existing = listed(readJson(tree, 'package.json'));
133
200
  addDependenciesToPackageJson(
134
201
  tree,
135
- native.dependencies,
136
- { ...native.devDependencies, ...aliases },
202
+ await asSaved(tree, native.dependencies, existing),
203
+ await asSaved(tree, native.devDependencies, existing),
137
204
  'package.json',
138
205
  true,
139
206
  );
140
207
  }
141
- writeFiles(tree, resolved);
208
+ // `nxViteTsPaths()`, which the app's Vitest config uses to reach the workspace's libraries.
209
+ // Added now rather than only when present, because a library generated later is the ordinary
210
+ // case and nothing would come back to add it then.
211
+ if (hasPathAliases(tree, workspaces)) {
212
+ const vite = { '@nx/vite': workspaceNxVersion(tree) };
213
+ addDependenciesToPackageJson(tree, {}, vite, 'package.json', true);
214
+ }
215
+ await writeFiles(tree, resolved);
142
216
  addProjectConfiguration(tree, projectName, {
143
217
  root: directory,
144
218
  sourceRoot: directory,
@@ -2,6 +2,7 @@
2
2
  "$schema": "https://json-schema.org/schema",
3
3
  "$id": "NgNativeNxApplication",
4
4
  "title": "Angular Native application",
5
+ "description": "An Angular Native app: an Expo project with start, run-ios, run-android, export, test and typecheck targets.",
5
6
  "type": "object",
6
7
  "properties": {
7
8
  "directory": {
@@ -15,6 +16,10 @@
15
16
  "description": "The project name. Defaults to the directory's last segment.",
16
17
  "pattern": "^[a-z][a-z0-9-]*$"
17
18
  },
19
+ "bundleIdentifier": {
20
+ "type": "string",
21
+ "description": "The iOS bundle identifier and Android package: com.acme.mobile. Defaults to com.<workspace scope>.<name>."
22
+ },
18
23
  "tags": {
19
24
  "type": "string",
20
25
  "description": "Comma-separated tags for the project."
@@ -38,7 +38,9 @@ function usesWorkspaces(tree) {
38
38
  }
39
39
 
40
40
  /**
41
- * Adds `<parent>/*` for the app's directory when no glob already covers it.
41
+ * Adds the app's directory when no glob already covers it: as `<parent>/*` when that takes in
42
+ * nothing else, as Nx adds its own projects, and as the directory itself when a sibling already
43
+ * has a `package.json`, which the workspace either lists one by one or has left out.
42
44
  *
43
45
  * @param {import('@nx/devkit').Tree} tree
44
46
  * @param {string} directory
@@ -48,7 +50,9 @@ function includeInWorkspaces(tree, directory) {
48
50
  const including = globs(tree).filter((glob) => !glob.startsWith('!'));
49
51
  if (including.some((glob) => minimatch(directory, glob))) return;
50
52
  const parent = directory.includes('/') ? directory.slice(0, directory.lastIndexOf('/')) : '';
51
- const glob = parent ? `${parent}/*` : directory;
53
+ const siblings = parent ? tree.children(parent).map((child) => `${parent}/${child}`) : [];
54
+ const alone = !siblings.some((dir) => dir !== directory && tree.exists(`${dir}/package.json`));
55
+ const glob = parent && alone ? `${parent}/*` : directory;
52
56
 
53
57
  if (tree.exists(PNPM)) {
54
58
  const yaml = tree.read(PNPM, 'utf-8') ?? '';
package/files/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ The whole documentation, for reading before a change: https://ng-native.com/llms
8
8
  outline with a line per page: https://ng-native.com/llms.txt.
9
9
 
10
10
  Angular's own rules apply as well: https://angular.dev/assets/context/best-practices.md. Its
11
- browser-only parts do not: there is no `NgOptimizedImage` (use `<image>`), and no AXE or ARIA
11
+ browser-only parts do not: there is no `NgOptimizedImage` (use `<image>`), and no AXE
12
12
  (accessibility is the props below). Where the two disagree, this file wins.
13
13
 
14
14
  ## Commands
@@ -41,7 +41,9 @@ change; both are fast.
41
41
  - **No backticks inside an inline template**, even in an HTML comment: they end the template string
42
42
  and the build fails with a misleading parse error.
43
43
  - **Accessibility is props:** `accessibilityRole="button"`, `accessibilityLabel`,
44
- `[accessibilityState]`. Screen readers and the testing library's `getByRole` both read them.
44
+ `[accessibilityState]`. The web spellings work too, as aliases: `role`, and the `aria-*` names
45
+ React Native maps (`aria-label`, `aria-checked`, `aria-hidden` and so on). Screen readers and
46
+ the testing library's `getByRole` both read them.
45
47
 
46
48
  ## Styling
47
49
 
@@ -1,24 +1,26 @@
1
1
  import { Component, signal } from '@angular/core';
2
- import { Pressable, SafeAreaView, Text, View } from '@ng-native/components';
2
+ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-native/components';
3
3
 
4
4
  /**
5
5
  * Element names are lowercase, and that is load-bearing rather than style: an uppercase name is
6
6
  * read as an unknown Angular component and commits as a plain view with nothing in it.
7
7
  */
8
8
  @Component({
9
- imports: [Pressable, SafeAreaView, Text, View],
9
+ imports: [Pressable, SafeAreaProvider, SafeAreaView, Text, View],
10
10
  selector: 'app-root',
11
11
  template: `
12
- <safe-area-view class="screen">
13
- <view class="body">
14
- <text class="title">Angular, natively</text>
15
- <text class="hint">Real native views. React is never in the render path.</text>
12
+ <safe-area-provider>
13
+ <safe-area-view class="screen">
14
+ <view class="body">
15
+ <text class="title">Angular, natively</text>
16
+ <text class="hint">Real native views. React is never in the render path.</text>
16
17
 
17
- <pressable accessibilityRole="button" class="button" (press)="count.set(count() + 1)">
18
- <text class="label">Tapped {{ count() }} times</text>
19
- </pressable>
20
- </view>
21
- </safe-area-view>
18
+ <pressable accessibilityRole="button" class="button" (press)="count.set(count() + 1)">
19
+ <text class="label">Tapped {{ count() }} times</text>
20
+ </pressable>
21
+ </view>
22
+ </safe-area-view>
23
+ </safe-area-provider>
22
24
  `,
23
25
  styles: `
24
26
  :host {
package/init/index.cjs CHANGED
@@ -27,7 +27,10 @@ const {
27
27
  updateNxJson,
28
28
  NX_VERSION,
29
29
  } = require('@nx/devkit');
30
+ const semver = require('semver');
30
31
  const native = require('../native-app.cjs');
32
+ const { usesWorkspaces } = require('../application/workspaces.cjs');
33
+ const { asSaved, savesExact } = require('../save-exact.cjs');
31
34
 
32
35
  /** The plugin entry `@nx/expo:init` itself writes, so the target names are the ones Nx documents. */
33
36
  const EXPO_PLUGIN = {
@@ -47,10 +50,16 @@ const EXPO_PLUGIN = {
47
50
  },
48
51
  };
49
52
 
50
- /** The Nx this workspace installed, which every `@nx/*` package in it has to match. */
53
+ /**
54
+ * The Nx this workspace installed, which every `@nx/*` package in it has to match: the version
55
+ * running now when the workspace saves exact versions and lists Nx at a range, rather than the
56
+ * newest the range allows, which may not be the one installed.
57
+ */
51
58
  function workspaceNxVersion(tree) {
52
59
  const manifest = readJson(tree, 'package.json');
53
- return manifest.devDependencies?.nx ?? manifest.dependencies?.nx ?? NX_VERSION;
60
+ const nx = manifest.devDependencies?.nx ?? manifest.dependencies?.nx ?? NX_VERSION;
61
+ const running = !semver.valid(nx) && savesExact(tree) && semver.satisfies(NX_VERSION, nx);
62
+ return running ? NX_VERSION : nx;
54
63
  }
55
64
 
56
65
  function registerExpoPlugin(tree) {
@@ -63,16 +72,35 @@ function registerExpoPlugin(tree) {
63
72
  updateNxJson(tree, { ...nxJson, plugins: [...plugins, EXPO_PLUGIN] });
64
73
  }
65
74
 
75
+ /**
76
+ * What goes beside `@nx/expo` at the root. With package-manager workspaces the app's own
77
+ * `package.json` lists Expo, React and React Native, and the root lists none of them, so pnpm
78
+ * resolves `@nx/expo`'s `expo` peer, and that Expo's own peers, in the root's context at the
79
+ * newest versions there are: a second React Native and React in the lockfile. Pinning them here
80
+ * at the app's versions makes the root's copies the app's. `react-dom` stays for `@nx/react`'s
81
+ * peer, which needs a React beside it. `@expo/cli` is left out: `@nx/expo` runs the CLI through
82
+ * `expo/bin/cli`, and `@expo/cli` peers on Expo and React Native too.
83
+ */
84
+ function rootDependencies(tree) {
85
+ if (!usesWorkspaces(tree)) return native.expoCompanions;
86
+ const { '@expo/cli': _cli, ...companions } = native.expoCompanions;
87
+ const { expo, react, 'react-native': reactNative } = native.dependencies;
88
+ return { ...companions, expo, react, 'react-native': reactNative };
89
+ }
90
+
66
91
  /**
67
92
  * @param {import('@nx/devkit').Tree} tree
68
93
  * @param {{ skipInstall?: boolean, skipFormat?: boolean }} options
69
94
  */
70
95
  async function init(tree, options = {}) {
71
96
  registerExpoPlugin(tree);
97
+ const { dependencies, devDependencies } = readJson(tree, 'package.json');
98
+ const existing = Object.keys({ ...dependencies, ...devDependencies });
99
+ const companions = await asSaved(tree, rootDependencies(tree), existing);
72
100
  const install = addDependenciesToPackageJson(
73
101
  tree,
74
102
  {},
75
- { '@nx/expo': workspaceNxVersion(tree), ...native.expoCompanions },
103
+ { '@nx/expo': workspaceNxVersion(tree), ...companions },
76
104
  'package.json',
77
105
  true,
78
106
  );
package/init/schema.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "$schema": "https://json-schema.org/schema",
3
3
  "$id": "NgNativeNxInit",
4
4
  "title": "Set up Angular Native",
5
+ "description": "Registers @nx/expo's plugin, which infers the Expo targets. nx add runs this.",
5
6
  "type": "object",
6
7
  "properties": {
7
8
  "skipInstall": {
package/native-app.cjs CHANGED
@@ -38,8 +38,9 @@ const dependencies = {
38
38
  ...Object.fromEntries(FRAMEWORK.map((name) => [name, version])),
39
39
  '@angular/common': '^22.0.0',
40
40
  '@angular/core': '^22.0.0',
41
- expo: '~57.0.20',
41
+ expo: '~57.0.26',
42
42
  'expo-status-bar': '~57.0.1',
43
+ 'expo-system-ui': '~57.0.4',
43
44
  react: '19.2.3',
44
45
  'react-native': '0.86.3',
45
46
  'react-native-safe-area-context': '~5.7.0',
@@ -52,7 +53,7 @@ const dependencies = {
52
53
  */
53
54
  const expoCompanions = {
54
55
  'react-dom': dependencies.react,
55
- '@expo/cli': '^57.0.22',
56
+ '@expo/cli': '^57.0.27',
56
57
  '@babel/runtime': '^7.20.0',
57
58
  };
58
59
 
@@ -90,14 +91,56 @@ function agentsFile(commands) {
90
91
  );
91
92
  }
92
93
 
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
+
93
134
  /**
94
135
  * The template's `app.json`, named for this app, without the template's icons: they are the
95
136
  * template's branding, and Expo draws its own default until `icon` is set.
96
137
  *
97
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`
98
140
  */
99
- function appJson(name) {
141
+ function appJson(name, id) {
100
142
  const slug = name.replace(/^@[^/]+\//, '');
143
+ const bundle = id ?? bundleIdentifier(slug, name.match(/^@[^/]+/)?.[0]);
101
144
  const expo = {
102
145
  name: slug,
103
146
  slug,
@@ -106,11 +149,12 @@ function appJson(name) {
106
149
  platforms: ['ios', 'android'],
107
150
  orientation: 'portrait',
108
151
  userInterfaceStyle: 'dark',
109
- ios: { supportsTablet: true },
110
- android: { predictiveBackGestureEnabled: false },
152
+ ios: { supportsTablet: true, bundleIdentifier: bundle },
153
+ android: { package: bundle, predictiveBackGestureEnabled: false },
111
154
  scheme: slug.replace(/[^a-z0-9]/gi, '').toLowerCase(),
112
155
  // The template's: Expo otherwise guesses a router root from src/app and says so on every start.
113
156
  extra: { router: { root: 'src/app' } },
157
+ plugins: ['@ng-native/metro'],
114
158
  };
115
159
  return JSON.stringify({ expo }, null, 2) + '\n';
116
160
  }
@@ -140,12 +184,12 @@ function tsconfig(workspaceBase, conditions = []) {
140
184
  typeCheckHostBindings: false,
141
185
  strictDomEventTypes: false,
142
186
  };
187
+ // The workspace's custom conditions, which a library in Nx's TypeScript preset exports its
188
+ // source under; the Metro preset and the Vitest plugin read them from here too. Beside
189
+ // Expo's own `react-native`, which setting the option would otherwise replace.
190
+ const customConditions = ['react-native', ...conditions.filter((c) => c !== 'react-native')];
143
191
  if (!workspaceBase) {
144
- // The workspace's custom conditions, which a library in Nx's TypeScript preset exports its
145
- // source under; the Metro preset and the Vitest plugin read them from here too. Beside
146
- // Expo's own `react-native`, which setting the option would otherwise replace.
147
- const extra = conditions.filter((c) => c !== 'react-native');
148
- if (extra.length) compilerOptions.customConditions = ['react-native', ...extra];
192
+ if (customConditions.length > 1) compilerOptions.customConditions = customConditions;
149
193
  return { extends: 'expo/tsconfig.base', compilerOptions, angularCompilerOptions };
150
194
  }
151
195
  // The workspace's base is written for a web build or for emitting declarations, and wins over
@@ -160,7 +204,7 @@ function tsconfig(workspaceBase, conditions = []) {
160
204
  target: 'ESNext',
161
205
  module: 'preserve',
162
206
  moduleResolution: 'bundler',
163
- customConditions: ['react-native'],
207
+ customConditions,
164
208
  composite: false,
165
209
  declaration: false,
166
210
  emitDeclarationOnly: false,
@@ -208,6 +252,30 @@ function prebuildPins(root) {
208
252
  */
209
253
  const accepted = { vitest: '^4.0.8 || ^5.0.0' };
210
254
 
255
+ /**
256
+ * The ranges a workspace package's `package.json` lists: the root's own where everything it can
257
+ * resolve to is a version Angular Native accepts, so that pnpm does not install a second Vitest
258
+ * beside the root's. A wider root range is not reused: React Native needs its exact React, not
259
+ * whatever `^19.0.0` resolves to.
260
+ *
261
+ * @param {Record<string, string>} wanted
262
+ * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} root
263
+ */
264
+ function reuseRootRanges(wanted, root) {
265
+ const installed = { ...root.devDependencies, ...root.dependencies };
266
+ return Object.fromEntries(
267
+ Object.entries(wanted).map(([name, range]) => {
268
+ const existing = installed[name];
269
+ const reuse =
270
+ existing &&
271
+ !name.startsWith('@ng-native/') &&
272
+ semver.validRange(existing) &&
273
+ semver.subset(existing, accepted[name] ?? range);
274
+ return [name, reuse ? existing : range];
275
+ }),
276
+ );
277
+ }
278
+
211
279
  /**
212
280
  * Why an existing dependency would stop this app installing or running, as a sentence each.
213
281
  *
@@ -243,10 +311,13 @@ module.exports = {
243
311
  sourceFile,
244
312
  agentsFile,
245
313
  appJson,
314
+ bundleIdentifier,
315
+ isBundleIdentifier,
246
316
  METRO_CONFIG,
247
317
  tsconfig,
248
318
  vitestConfig,
249
319
  prebuildPins,
320
+ reuseRootRanges,
250
321
  expoCompanions,
251
322
  conflicts,
252
323
  accepted,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ng-native/nx",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Nx generators for Angular Native: nx add, and an Expo app project whose targets run it.",
5
5
  "keywords": [
6
6
  "angular",
package/save-exact.cjs ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Whether the workspace saves exact versions, and the dependencies written that way when it does.
3
+ *
4
+ * Each package manager has its own setting for it, and a generator that writes ranges into such a
5
+ * workspace lets the app's Angular and TypeScript drift from the versions the rest of it pins.
6
+ */
7
+ const { exec } = require('node:child_process');
8
+ const { promisify } = require('node:util');
9
+ const semver = require('semver');
10
+
11
+ /** The rest of a line, which in YAML and TOML may end in a comment. */
12
+ const END = String.raw`[ \t]*(#.*)?$`;
13
+
14
+ const SETTINGS = {
15
+ 'pnpm-workspace.yaml': [
16
+ new RegExp(`^saveExact:[ \\t]*true${END}`, 'm'),
17
+ new RegExp(`^savePrefix:[ \\t]*(''|"")${END}`, 'm'),
18
+ ],
19
+ '.npmrc': [
20
+ /^[ \t]*save-exact[ \t]*=[ \t]*true[ \t]*$/m,
21
+ /^[ \t]*save-prefix[ \t]*=[ \t]*(''|"")?[ \t]*$/m,
22
+ ],
23
+ '.yarnrc': [new RegExp(`^save-prefix[ \\t]+(''|"")${END}`, 'm')],
24
+ '.yarnrc.yml': [new RegExp(`^defaultSemverRangePrefix:[ \\t]*(''|"")${END}`, 'm')],
25
+ 'bunfig.toml': [new RegExp(`^[ \\t]*exact[ \\t]*=[ \\t]*true${END}`, 'm')],
26
+ };
27
+
28
+ /** @param {import('@nx/devkit').Tree} tree */
29
+ function savesExact(tree) {
30
+ return Object.entries(SETTINGS).some(
31
+ ([file, patterns]) =>
32
+ tree.exists(file) && patterns.some((pattern) => pattern.test(tree.read(file, 'utf-8') ?? '')),
33
+ );
34
+ }
35
+
36
+ /**
37
+ * The registry, as npm sees it from the workspace's root, so a registry set in its `.npmrc` is the
38
+ * one asked. Replaced in tests, which never reach the network.
39
+ */
40
+ const registry = {
41
+ /**
42
+ * @param {string} name
43
+ * @param {string} cwd
44
+ * @returns {Promise<string[]>}
45
+ */
46
+ async versions(name, cwd) {
47
+ // A command line rather than arguments, which Windows needs to run `npm.cmd`. The name is one
48
+ // of this package's own dependencies, and the range, whose `^` cmd.exe would eat, stays out.
49
+ const { stdout } = await promisify(exec)(`npm view ${name} versions --json`, {
50
+ cwd,
51
+ timeout: 10_000,
52
+ windowsHide: true,
53
+ });
54
+ return [JSON.parse(stdout)].flat();
55
+ },
56
+ };
57
+
58
+ /**
59
+ * The version `pnpm add` saves for a range with exact versions on: the newest the registry has
60
+ * that the range allows, or the lowest it allows when the registry cannot be reached.
61
+ *
62
+ * @param {import('@nx/devkit').Tree} tree
63
+ * @param {string} name
64
+ * @param {string} range
65
+ */
66
+ async function newest(tree, name, range) {
67
+ if (semver.valid(range) || !semver.validRange(range)) return range;
68
+ const floor = semver.minVersion(range)?.version ?? range;
69
+ try {
70
+ return semver.maxSatisfying(await registry.versions(name, tree.root), range) ?? floor;
71
+ } catch {
72
+ return floor;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The range an Angular package resolves within: also the root's `@angular/core`, where the two
78
+ * overlap, since every Angular package peers on the others at exactly its own version.
79
+ *
80
+ * @param {import('@nx/devkit').Tree} tree
81
+ * @param {string} name
82
+ * @param {string} range
83
+ */
84
+ function withinAngular(tree, name, range) {
85
+ if (!name.startsWith('@angular/') || !tree.exists('package.json')) return range;
86
+ const { dependencies, devDependencies } = JSON.parse(tree.read('package.json', 'utf-8') ?? '{}');
87
+ const core = { ...devDependencies, ...dependencies }['@angular/core'];
88
+ if (!core || !semver.validRange(core) || !semver.validRange(range)) return range;
89
+ // Alternative by alternative, as joining two ranges with `||` in them would let either side's
90
+ // other alternatives through on their own.
91
+ const alternatives = (one) => semver.validRange(one).split('||');
92
+ const both = alternatives(range).flatMap((ours) =>
93
+ alternatives(core)
94
+ .filter((theirs) => semver.intersects(ours, theirs))
95
+ .map((theirs) => `${ours} ${theirs}`),
96
+ );
97
+ return both.length ? both.join(' || ') : range;
98
+ }
99
+
100
+ /**
101
+ * Each range as the exact version it resolves to when the workspace saves exact versions, and as
102
+ * it is otherwise. The packages in `settled`, which take a version the workspace already has, are
103
+ * left as they are.
104
+ *
105
+ * @param {import('@nx/devkit').Tree} tree
106
+ * @param {Record<string, string>} dependencies
107
+ * @param {Iterable<string>} [settled]
108
+ * @returns {Promise<Record<string, string>>}
109
+ */
110
+ async function asSaved(tree, dependencies, settled = []) {
111
+ if (!savesExact(tree)) return dependencies;
112
+ const skip = new Set(settled);
113
+ return Object.fromEntries(
114
+ await Promise.all(
115
+ Object.entries(dependencies).map(async ([name, range]) => [
116
+ name,
117
+ skip.has(name) ? range : await newest(tree, name, withinAngular(tree, name, range)),
118
+ ]),
119
+ ),
120
+ );
121
+ }
122
+
123
+ module.exports = { savesExact, asSaved, registry };