@ng-native/nx 0.1.2 → 0.2.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/application/index.cjs +95 -21
- package/application/schema.json +5 -0
- package/application/workspaces.cjs +6 -2
- package/files/src/app/app.ts +13 -11
- package/init/index.cjs +31 -3
- package/init/schema.json +1 -0
- package/native-app.cjs +78 -9
- package/package.json +1 -1
- package/save-exact.cjs +123 -0
package/application/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
149
|
+
const base = hasPathAliases(tree, workspaces);
|
|
84
150
|
const workspaceBase = base ? `${offsetFromRoot(directory)}tsconfig.base.json` : undefined;
|
|
85
|
-
const conditions = tree.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/application/schema.json
CHANGED
|
@@ -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
|
|
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
|
|
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/src/app/app.ts
CHANGED
|
@@ -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-
|
|
13
|
-
<view class="
|
|
14
|
-
<
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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), ...
|
|
103
|
+
{ '@nx/expo': workspaceNxVersion(tree), ...companions },
|
|
76
104
|
'package.json',
|
|
77
105
|
true,
|
|
78
106
|
);
|
package/init/schema.json
CHANGED
package/native-app.cjs
CHANGED
|
@@ -91,14 +91,56 @@ function agentsFile(commands) {
|
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
134
|
/**
|
|
95
135
|
* The template's `app.json`, named for this app, without the template's icons: they are the
|
|
96
136
|
* template's branding, and Expo draws its own default until `icon` is set.
|
|
97
137
|
*
|
|
98
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`
|
|
99
140
|
*/
|
|
100
|
-
function appJson(name) {
|
|
141
|
+
function appJson(name, id) {
|
|
101
142
|
const slug = name.replace(/^@[^/]+\//, '');
|
|
143
|
+
const bundle = id ?? bundleIdentifier(slug, name.match(/^@[^/]+/)?.[0]);
|
|
102
144
|
const expo = {
|
|
103
145
|
name: slug,
|
|
104
146
|
slug,
|
|
@@ -107,8 +149,8 @@ function appJson(name) {
|
|
|
107
149
|
platforms: ['ios', 'android'],
|
|
108
150
|
orientation: 'portrait',
|
|
109
151
|
userInterfaceStyle: 'dark',
|
|
110
|
-
ios: { supportsTablet: true },
|
|
111
|
-
android: { predictiveBackGestureEnabled: false },
|
|
152
|
+
ios: { supportsTablet: true, bundleIdentifier: bundle },
|
|
153
|
+
android: { package: bundle, predictiveBackGestureEnabled: false },
|
|
112
154
|
scheme: slug.replace(/[^a-z0-9]/gi, '').toLowerCase(),
|
|
113
155
|
// The template's: Expo otherwise guesses a router root from src/app and says so on every start.
|
|
114
156
|
extra: { router: { root: 'src/app' } },
|
|
@@ -142,12 +184,12 @@ function tsconfig(workspaceBase, conditions = []) {
|
|
|
142
184
|
typeCheckHostBindings: false,
|
|
143
185
|
strictDomEventTypes: false,
|
|
144
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')];
|
|
145
191
|
if (!workspaceBase) {
|
|
146
|
-
|
|
147
|
-
// source under; the Metro preset and the Vitest plugin read them from here too. Beside
|
|
148
|
-
// Expo's own `react-native`, which setting the option would otherwise replace.
|
|
149
|
-
const extra = conditions.filter((c) => c !== 'react-native');
|
|
150
|
-
if (extra.length) compilerOptions.customConditions = ['react-native', ...extra];
|
|
192
|
+
if (customConditions.length > 1) compilerOptions.customConditions = customConditions;
|
|
151
193
|
return { extends: 'expo/tsconfig.base', compilerOptions, angularCompilerOptions };
|
|
152
194
|
}
|
|
153
195
|
// The workspace's base is written for a web build or for emitting declarations, and wins over
|
|
@@ -162,7 +204,7 @@ function tsconfig(workspaceBase, conditions = []) {
|
|
|
162
204
|
target: 'ESNext',
|
|
163
205
|
module: 'preserve',
|
|
164
206
|
moduleResolution: 'bundler',
|
|
165
|
-
customConditions
|
|
207
|
+
customConditions,
|
|
166
208
|
composite: false,
|
|
167
209
|
declaration: false,
|
|
168
210
|
emitDeclarationOnly: false,
|
|
@@ -210,6 +252,30 @@ function prebuildPins(root) {
|
|
|
210
252
|
*/
|
|
211
253
|
const accepted = { vitest: '^4.0.8 || ^5.0.0' };
|
|
212
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
|
+
|
|
213
279
|
/**
|
|
214
280
|
* Why an existing dependency would stop this app installing or running, as a sentence each.
|
|
215
281
|
*
|
|
@@ -245,10 +311,13 @@ module.exports = {
|
|
|
245
311
|
sourceFile,
|
|
246
312
|
agentsFile,
|
|
247
313
|
appJson,
|
|
314
|
+
bundleIdentifier,
|
|
315
|
+
isBundleIdentifier,
|
|
248
316
|
METRO_CONFIG,
|
|
249
317
|
tsconfig,
|
|
250
318
|
vitestConfig,
|
|
251
319
|
prebuildPins,
|
|
320
|
+
reuseRootRanges,
|
|
252
321
|
expoCompanions,
|
|
253
322
|
conflicts,
|
|
254
323
|
accepted,
|
package/package.json
CHANGED
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 };
|