@ng-native/nx 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +41 -1
- package/application/index.cjs +160 -0
- package/application/schema.json +35 -0
- package/application/workspaces.cjs +68 -0
- package/files/AGENTS.md +75 -0
- package/files/src/app/app.test.ts +11 -0
- package/files/src/app/app.ts +62 -0
- package/files/src/main.ts +27 -0
- package/files/vitest.config.mts +8 -0
- package/generators.json +18 -0
- package/init/index.cjs +79 -0
- package/init/schema.json +30 -0
- package/native-app.cjs +246 -0
- package/package.json +52 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ashley Hunter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
1
|
# @ng-native/nx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Nx generators for Angular Native: `nx add @ng-native/nx`, and an app generator that adds an Expo
|
|
4
|
+
app project to the workspace, able to import its Angular libraries.
|
|
5
|
+
|
|
6
|
+
Alpha: APIs may change before 1.0.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
nx add @ng-native/nx
|
|
12
|
+
nx g @ng-native/nx:app apps/mobile
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`nx add` adds `@nx/expo` at the workspace's Nx version and registers its plugin, which infers the
|
|
16
|
+
app's Expo targets. The generator writes the template's app, with a `metro.config.js` wrapped in
|
|
17
|
+
`withNxMetro` so workspace libraries resolve, and adds `typecheck` and `test` targets.
|
|
18
|
+
|
|
19
|
+
## Example
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
nx start mobile # expo start
|
|
23
|
+
nx run mobile:run-ios # expo run:ios
|
|
24
|
+
nx export mobile --platform ios
|
|
25
|
+
nx test mobile # vitest run, on the fake Fabric
|
|
26
|
+
nx typecheck mobile
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
In a workspace whose root package is scoped, the project is `@org/mobile`.
|
|
30
|
+
|
|
31
|
+
## What's in the package
|
|
32
|
+
|
|
33
|
+
- `generators.json` - `init`, which `nx add` runs, and `application` (alias `app`).
|
|
34
|
+
- `files/` - the template's source files, which a test keeps identical to `template/`.
|
|
35
|
+
|
|
36
|
+
## Docs
|
|
37
|
+
|
|
38
|
+
- [Nx](https://ng-native.com/packages/nx)
|
|
39
|
+
- [Root README](https://github.com/ng-native/ng-native/blob/main/README.md)
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nx g @ng-native/nx:app apps/mobile`: an Angular Native app as a project of this workspace.
|
|
3
|
+
*
|
|
4
|
+
* The app is an Expo project, built by Metro. `@nx/expo`'s plugin, which `init` registers, infers
|
|
5
|
+
* its Expo targets from the files written here; `project.json` adds the two it cannot infer:
|
|
6
|
+
* `typecheck`, since Expo's `tsconfig` sets `noEmit` and `@nx/js` disables its own for that, and
|
|
7
|
+
* `test`, which runs Vitest once rather than watching.
|
|
8
|
+
*
|
|
9
|
+
* Where the dependencies go depends on the workspace. With package-manager workspaces, the Nx
|
|
10
|
+
* default since 20, the app is a workspace package and lists them itself, which pnpm needs in
|
|
11
|
+
* order to link them. In an integrated workspace, with one root `package.json`, they go there.
|
|
12
|
+
*/
|
|
13
|
+
const path = require('node:path').posix;
|
|
14
|
+
const {
|
|
15
|
+
formatFiles,
|
|
16
|
+
joinPathFragments,
|
|
17
|
+
logger,
|
|
18
|
+
offsetFromRoot,
|
|
19
|
+
readJson,
|
|
20
|
+
runTasksInSerial,
|
|
21
|
+
addProjectConfiguration,
|
|
22
|
+
installPackagesTask,
|
|
23
|
+
addDependenciesToPackageJson,
|
|
24
|
+
} = require('@nx/devkit');
|
|
25
|
+
const native = require('../native-app.cjs');
|
|
26
|
+
const { init, workspaceNxVersion } = require('../init/index.cjs');
|
|
27
|
+
const { usesWorkspaces, includeInWorkspaces } = require('./workspaces.cjs');
|
|
28
|
+
|
|
29
|
+
function names(tree, options) {
|
|
30
|
+
const directory = path.normalize(options.directory).replace(/\/$/, '');
|
|
31
|
+
const name = options.name ?? path.basename(directory);
|
|
32
|
+
const scope = readJson(tree, 'package.json').name?.match(/^@[^/]+/)?.[0];
|
|
33
|
+
const workspaces = usesWorkspaces(tree);
|
|
34
|
+
// A workspace package is named for the workspace's scope, as Nx names its own.
|
|
35
|
+
const projectName = workspaces && scope ? `${scope}/${name}` : name;
|
|
36
|
+
return { directory, name, projectName, workspaces };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function targets(directory) {
|
|
40
|
+
const run = (command) => ({ executor: 'nx:run-commands', options: { cwd: directory, command } });
|
|
41
|
+
return {
|
|
42
|
+
typecheck: {
|
|
43
|
+
...run('ngc -p tsconfig.json --noEmit'),
|
|
44
|
+
cache: true,
|
|
45
|
+
inputs: ['default', '^production'],
|
|
46
|
+
},
|
|
47
|
+
test: { ...run('vitest run'), cache: true, inputs: ['default', '^production'] },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The Commands section of the app's `AGENTS.md`, as this workspace runs them. */
|
|
52
|
+
function commands(name) {
|
|
53
|
+
const rows = [
|
|
54
|
+
[`nx start ${name}`, 'Metro; press i or a for a simulator, or scan the QR code with Expo Go'],
|
|
55
|
+
[`nx run ${name}:run-ios`, 'the iOS simulator'],
|
|
56
|
+
[`nx run ${name}:run-android`, 'the Android emulator'],
|
|
57
|
+
[`nx test ${name}`, 'Vitest, in Node against a fake native layer: no simulator needed'],
|
|
58
|
+
[`nx typecheck ${name}`, 'TypeScript, without emitting'],
|
|
59
|
+
];
|
|
60
|
+
const width = Math.max(...rows.map(([command]) => command.length)) + 2;
|
|
61
|
+
return [
|
|
62
|
+
'```sh',
|
|
63
|
+
...rows.map(([command, what]) => `${command.padEnd(width)}# ${what}`),
|
|
64
|
+
'```',
|
|
65
|
+
'',
|
|
66
|
+
'`src/main.ts` mounts the root component, `src/app/app.ts`. ' +
|
|
67
|
+
`Run \`nx test ${name}\` and \`nx typecheck ${name}\` after a change; both are fast.`,
|
|
68
|
+
].join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeFiles(tree, { directory, projectName, workspaces }) {
|
|
72
|
+
const file = (name, content) => tree.write(joinPathFragments(directory, name), content);
|
|
73
|
+
for (const name of native.SOURCE_FILES) file(name, native.sourceFile(name));
|
|
74
|
+
file('app.json', native.appJson(projectName));
|
|
75
|
+
file('AGENTS.md', native.agentsFile(commands(projectName)));
|
|
76
|
+
file('CLAUDE.md', '@AGENTS.md\n');
|
|
77
|
+
file('metro.config.js', native.METRO_CONFIG);
|
|
78
|
+
|
|
79
|
+
const base = !workspaces && tree.exists('tsconfig.base.json');
|
|
80
|
+
const workspaceBase = base ? `${offsetFromRoot(directory)}tsconfig.base.json` : undefined;
|
|
81
|
+
const conditions = tree.exists('tsconfig.base.json')
|
|
82
|
+
? (readJson(tree, 'tsconfig.base.json').compilerOptions?.customConditions ?? [])
|
|
83
|
+
: [];
|
|
84
|
+
file('tsconfig.json', JSON.stringify(native.tsconfig(workspaceBase, conditions), null, 2) + '\n');
|
|
85
|
+
file('vitest.config.mts', native.vitestConfig(Boolean(base)));
|
|
86
|
+
|
|
87
|
+
const manifest = { name: projectName, version: '0.0.1', private: true, main: 'src/main.ts' };
|
|
88
|
+
if (workspaces) {
|
|
89
|
+
Object.assign(manifest, {
|
|
90
|
+
dependencies: native.dependencies,
|
|
91
|
+
devDependencies: native.devDependencies,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
manifest.dependencies = native.prebuildPins(readJson(tree, 'package.json'));
|
|
95
|
+
}
|
|
96
|
+
file('package.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Expo's cache directory, once. */
|
|
100
|
+
function ignoreExpo(tree) {
|
|
101
|
+
if (!tree.exists('.gitignore')) return;
|
|
102
|
+
const ignore = tree.read('.gitignore', 'utf-8') ?? '';
|
|
103
|
+
if (/^\.expo\/?$/m.test(ignore)) return;
|
|
104
|
+
tree.write('.gitignore', ignore.replace(/\n*$/, '\n\n# Expo\n.expo/\n'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
109
|
+
* @param {{ directory: string, name?: string, tags?: string, skipInstall?: boolean, skipFormat?: boolean }} options
|
|
110
|
+
*/
|
|
111
|
+
async function application(tree, options) {
|
|
112
|
+
const resolved = names(tree, options);
|
|
113
|
+
const { directory, projectName, workspaces } = resolved;
|
|
114
|
+
if (tree.exists(joinPathFragments(directory, 'package.json'))) {
|
|
115
|
+
throw new Error(`${directory} already has a package.json. Choose another directory.`);
|
|
116
|
+
}
|
|
117
|
+
const initTask = await init(tree, { skipInstall: true, skipFormat: true });
|
|
118
|
+
|
|
119
|
+
if (workspaces) {
|
|
120
|
+
includeInWorkspaces(tree, directory);
|
|
121
|
+
} else {
|
|
122
|
+
for (const problem of native.conflicts(readJson(tree, 'package.json'))) logger.warn(problem);
|
|
123
|
+
// `nxViteTsPaths()`, which the app's Vitest config uses to reach the workspace's libraries.
|
|
124
|
+
// Added now rather than only when present, because a library generated later is the ordinary
|
|
125
|
+
// case and nothing would come back to add it then.
|
|
126
|
+
const aliases = tree.exists('tsconfig.base.json')
|
|
127
|
+
? { '@nx/vite': workspaceNxVersion(tree) }
|
|
128
|
+
: {};
|
|
129
|
+
addDependenciesToPackageJson(
|
|
130
|
+
tree,
|
|
131
|
+
native.dependencies,
|
|
132
|
+
{ ...native.devDependencies, ...aliases },
|
|
133
|
+
'package.json',
|
|
134
|
+
true,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
writeFiles(tree, resolved);
|
|
138
|
+
addProjectConfiguration(tree, projectName, {
|
|
139
|
+
root: directory,
|
|
140
|
+
sourceRoot: directory,
|
|
141
|
+
projectType: 'application',
|
|
142
|
+
tags: (options.tags ?? '')
|
|
143
|
+
.split(',')
|
|
144
|
+
.map((tag) => tag.trim())
|
|
145
|
+
.filter(Boolean),
|
|
146
|
+
targets: targets(directory),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ignoreExpo(tree);
|
|
150
|
+
|
|
151
|
+
if (!options.skipFormat) await formatFiles(tree);
|
|
152
|
+
logger.info(
|
|
153
|
+
`Created ${directory}. nx start ${projectName} runs Metro; nx run ${projectName}:run-ios, ` +
|
|
154
|
+
`nx test ${projectName}, nx typecheck ${projectName}, nx export ${projectName}.`,
|
|
155
|
+
);
|
|
156
|
+
if (options.skipInstall) return initTask;
|
|
157
|
+
return runTasksInSerial(initTask, () => installPackagesTask(tree, true));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { application };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/schema",
|
|
3
|
+
"$id": "NgNativeNxApplication",
|
|
4
|
+
"title": "Angular Native application",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"directory": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "Where the app goes, relative to the workspace root: apps/mobile.",
|
|
10
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
11
|
+
"x-prompt": "Which directory should the app be created in?"
|
|
12
|
+
},
|
|
13
|
+
"name": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "The project name. Defaults to the directory's last segment.",
|
|
16
|
+
"pattern": "^[a-z][a-z0-9-]*$"
|
|
17
|
+
},
|
|
18
|
+
"tags": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Comma-separated tags for the project."
|
|
21
|
+
},
|
|
22
|
+
"skipInstall": {
|
|
23
|
+
"type": "boolean",
|
|
24
|
+
"description": "Write the files without installing.",
|
|
25
|
+
"default": false
|
|
26
|
+
},
|
|
27
|
+
"skipFormat": {
|
|
28
|
+
"type": "boolean",
|
|
29
|
+
"description": "Leave the generated files unformatted.",
|
|
30
|
+
"default": false,
|
|
31
|
+
"x-priority": "internal"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["directory"]
|
|
35
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package manager's workspaces, which decide where an app's dependencies go.
|
|
3
|
+
*
|
|
4
|
+
* An app outside every workspace glob is not a workspace package: pnpm installs nothing for it,
|
|
5
|
+
* and its `metro.config.js` cannot find Expo. Nx's own TypeScript preset starts with `packages/*`
|
|
6
|
+
* only, so an app in `apps/` is outside it until the glob is added.
|
|
7
|
+
*/
|
|
8
|
+
const { minimatch } = require('minimatch');
|
|
9
|
+
const { readJson, updateJson } = require('@nx/devkit');
|
|
10
|
+
|
|
11
|
+
const PNPM = 'pnpm-workspace.yaml';
|
|
12
|
+
|
|
13
|
+
/** The `packages:` list of a `pnpm-workspace.yaml`, as written: one quoted or bare glob a line. */
|
|
14
|
+
function pnpmGlobs(yaml) {
|
|
15
|
+
const block = /^packages:[ \t]*\n((?:[ \t]+-.*\n?)*)/m.exec(yaml)?.[1] ?? '';
|
|
16
|
+
return [...block.matchAll(/^[ \t]+-\s*['"]?([^'"\n#]+?)['"]?\s*(?:#.*)?$/gm)].map(
|
|
17
|
+
(match) => match[1],
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function manifestGlobs(tree) {
|
|
22
|
+
const { workspaces } = readJson(tree, 'package.json');
|
|
23
|
+
return Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? []);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function globs(tree) {
|
|
27
|
+
if (tree.exists(PNPM)) return pnpmGlobs(tree.read(PNPM, 'utf-8') ?? '');
|
|
28
|
+
return manifestGlobs(tree);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the workspace has package-manager workspaces at all. A `pnpm-workspace.yaml` with no
|
|
33
|
+
* `packages:` list does not: pnpm 11 writes one to hold settings alone, in an integrated
|
|
34
|
+
* workspace like the `angular-monorepo` preset's.
|
|
35
|
+
*/
|
|
36
|
+
function usesWorkspaces(tree) {
|
|
37
|
+
return globs(tree).length > 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Adds `<parent>/*` for the app's directory when no glob already covers it.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
44
|
+
* @param {string} directory
|
|
45
|
+
*/
|
|
46
|
+
function includeInWorkspaces(tree, directory) {
|
|
47
|
+
// A negated glob excludes; it never makes a directory a workspace package.
|
|
48
|
+
const including = globs(tree).filter((glob) => !glob.startsWith('!'));
|
|
49
|
+
if (including.some((glob) => minimatch(directory, glob))) return;
|
|
50
|
+
const parent = directory.includes('/') ? directory.slice(0, directory.lastIndexOf('/')) : '';
|
|
51
|
+
const glob = parent ? `${parent}/*` : directory;
|
|
52
|
+
|
|
53
|
+
if (tree.exists(PNPM)) {
|
|
54
|
+
const yaml = tree.read(PNPM, 'utf-8') ?? '';
|
|
55
|
+
const next = /^packages:[ \t]*$/m.test(yaml)
|
|
56
|
+
? yaml.replace(/^packages:[ \t]*$/m, `packages:\n - '${glob}'`)
|
|
57
|
+
: `packages:\n - '${glob}'\n${yaml}`;
|
|
58
|
+
tree.write(PNPM, next);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
updateJson(tree, 'package.json', (manifest) => {
|
|
62
|
+
if (Array.isArray(manifest.workspaces)) manifest.workspaces.push(glob);
|
|
63
|
+
else manifest.workspaces.packages = [...(manifest.workspaces.packages ?? []), glob];
|
|
64
|
+
return manifest;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { usesWorkspaces, includeInWorkspaces, pnpmGlobs };
|
package/files/AGENTS.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This is an Angular app rendering real native iOS and Android views with
|
|
4
|
+
[Angular Native](https://ng-native.com): Angular components on React Native's Fabric renderer,
|
|
5
|
+
inside an Expo app. It is not a web app and not React: there is no DOM and no JSX.
|
|
6
|
+
|
|
7
|
+
The whole documentation, for reading before a change: https://ng-native.com/llms-full.txt. An
|
|
8
|
+
outline with a line per page: https://ng-native.com/llms.txt.
|
|
9
|
+
|
|
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
|
|
12
|
+
(accessibility is the props below). Where the two disagree, this file wins.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm start # Metro; press i or a for a simulator, or scan the QR code with Expo Go
|
|
18
|
+
npm run ios # the iOS simulator
|
|
19
|
+
npm run android # the Android emulator
|
|
20
|
+
npm test # Vitest, in Node against a fake native layer: no simulator needed
|
|
21
|
+
npm run typecheck
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`src/main.ts` mounts the root component, `src/app/app.ts`. Run `npm test` and `npm run typecheck` after a
|
|
25
|
+
change; both are fast.
|
|
26
|
+
|
|
27
|
+
## Rules that are easy to get wrong
|
|
28
|
+
|
|
29
|
+
- **Element names are lowercase:** `<view>`, `<text>`, `<pressable>`, `<scroll-view>`,
|
|
30
|
+
`<text-input>`, `<image>`, `<switch>`, `<safe-area-view>`, `<virtual-list>`, `<modal>`. Each is
|
|
31
|
+
imported from `@ng-native/components` into the component's `imports`, like any Angular
|
|
32
|
+
component: `imports: [View, Text]`. `<View>` compiles to an empty template, and an element used
|
|
33
|
+
without its import renders as a plain view.
|
|
34
|
+
- **There is no DOM.** No `document`, no `window`, no `<div>`, `<span>`, `<button>` or `<input>`,
|
|
35
|
+
no `@angular/platform-browser`, and no `@angular/animations`. Use the native elements above.
|
|
36
|
+
- **Text only renders inside `<text>`.** `<view>Hello</view>` compiles and shows nothing.
|
|
37
|
+
- **Events are native:** `(press)` on `<pressable>`, not `(click)`; `[(value)]` or `(changeText)` on
|
|
38
|
+
`<text-input>`; `(scroll)`, `(layout)`. A pressable text is `<text pressable (press)="...">`.
|
|
39
|
+
- **Signals, zoneless, AOT.** State is signals and `computed()`; there is no zone.js, so nothing
|
|
40
|
+
updates from a plain field changing outside a signal or an event.
|
|
41
|
+
- **No backticks inside an inline template**, even in an HTML comment: they end the template string
|
|
42
|
+
and the build fails with a misleading parse error.
|
|
43
|
+
- **Accessibility is props:** `accessibilityRole="button"`, `accessibilityLabel`,
|
|
44
|
+
`[accessibilityState]`. Screen readers and the testing library's `getByRole` both read them.
|
|
45
|
+
|
|
46
|
+
## Styling
|
|
47
|
+
|
|
48
|
+
- `[style]` takes a React Native style object: camelCase keys, numbers in points
|
|
49
|
+
(`{ padding: 16, backgroundColor: '#fff' }`).
|
|
50
|
+
- A component's `styles` is real CSS, compiled at build time: type, class, id and attribute
|
|
51
|
+
selectors, combinators, `:host`, `@media`, custom properties, transitions and `@keyframes`.
|
|
52
|
+
Grid, float, `::before`/`::after`, `:hover` and `:focus-visible` are dropped with a build
|
|
53
|
+
warning naming the file, line and reason - lay out with flexbox, and drive a hover or focus look
|
|
54
|
+
from a bound attribute. Everything is `display: flex`
|
|
55
|
+
with `flex-direction: column` by default, as in React Native.
|
|
56
|
+
- Tailwind v4 works through `@ng-native/tailwind`, with `ios:`, `android:` and `dark:` variants.
|
|
57
|
+
|
|
58
|
+
## Lists, navigation, forms
|
|
59
|
+
|
|
60
|
+
- A long list is `<virtual-list>`: `@for (row of list.window(); track row.slot)` recycles rows as
|
|
61
|
+
they scroll. `<scroll-view>` renders everything, so keep it for short content.
|
|
62
|
+
- Navigation is `@angular/router` on native stacks and tabs: `provideNativeRouter(routes)` from
|
|
63
|
+
`@ng-native/router` in `mount`'s `providers`, and `<native-stack-outlet />` in a template. Add
|
|
64
|
+
`withComponentInputBinding()` from `@angular/router` for route params to arrive as inputs, in
|
|
65
|
+
tests too.
|
|
66
|
+
- Forms are Signal Forms (`@angular/forms/signals`), with `[formField]` on `<text-input>` and
|
|
67
|
+
`<switch>`.
|
|
68
|
+
- HTTP needs `provideNativeHttpClient()` from `@ng-native/platform/http`, not `provideHttpClient()`.
|
|
69
|
+
|
|
70
|
+
## Tests
|
|
71
|
+
|
|
72
|
+
`app.test.ts` shows the shape: `render(Component)`, then query with `screen.getByRole`,
|
|
73
|
+
`getByText` or `getByTestId` (which matches `nativeID` and `testID`), and act with
|
|
74
|
+
`userEvent.setup().press(...)` or `.type(...)`, all from `@ng-native/testing`. Tests run in Node
|
|
75
|
+
with no simulator, so write one alongside a change.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { render, screen, userEvent } from '@ng-native/testing';
|
|
2
|
+
import { expect, test } from 'vitest';
|
|
3
|
+
import { App } from './app.ts';
|
|
4
|
+
|
|
5
|
+
test('counts taps', async () => {
|
|
6
|
+
await render(App);
|
|
7
|
+
|
|
8
|
+
await userEvent.setup().press(screen.getByRole('button', { name: 'Tapped 0 times' }));
|
|
9
|
+
|
|
10
|
+
expect(screen.getByText('Tapped 1 times')).toBeTruthy();
|
|
11
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { Pressable, SafeAreaView, Text, View } from '@ng-native/components';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Element names are lowercase, and that is load-bearing rather than style: an uppercase name is
|
|
6
|
+
* read as an unknown Angular component and commits as a plain view with nothing in it.
|
|
7
|
+
*/
|
|
8
|
+
@Component({
|
|
9
|
+
imports: [Pressable, SafeAreaView, Text, View],
|
|
10
|
+
selector: 'app-root',
|
|
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>
|
|
16
|
+
|
|
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>
|
|
22
|
+
`,
|
|
23
|
+
styles: `
|
|
24
|
+
:host {
|
|
25
|
+
flex: 1;
|
|
26
|
+
}
|
|
27
|
+
.screen {
|
|
28
|
+
flex: 1;
|
|
29
|
+
background-color: #101014;
|
|
30
|
+
}
|
|
31
|
+
.body {
|
|
32
|
+
flex: 1;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
gap: 12px;
|
|
35
|
+
padding: 24px;
|
|
36
|
+
}
|
|
37
|
+
.title {
|
|
38
|
+
color: #ffffff;
|
|
39
|
+
font-size: 28px;
|
|
40
|
+
font-weight: 700;
|
|
41
|
+
}
|
|
42
|
+
.hint {
|
|
43
|
+
color: #8b8b96;
|
|
44
|
+
font-size: 15px;
|
|
45
|
+
}
|
|
46
|
+
.button {
|
|
47
|
+
align-items: center;
|
|
48
|
+
margin-top: 8px;
|
|
49
|
+
padding: 14px;
|
|
50
|
+
border-radius: 10px;
|
|
51
|
+
background-color: #3b6ef5;
|
|
52
|
+
}
|
|
53
|
+
.label {
|
|
54
|
+
color: #ffffff;
|
|
55
|
+
font-size: 16px;
|
|
56
|
+
font-weight: 600;
|
|
57
|
+
}
|
|
58
|
+
`,
|
|
59
|
+
})
|
|
60
|
+
export class App {
|
|
61
|
+
protected readonly count = signal(0);
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AppRegistry, Image, Platform, processColor } from 'react-native';
|
|
2
|
+
import { mount } from '@ng-native/platform';
|
|
3
|
+
import { currentConditions, deviceTokens, watchConditions } from '@ng-native/device';
|
|
4
|
+
import { getFabricUIManager, registerPlatformComponents } from '@ng-native/fabric';
|
|
5
|
+
import { App } from './app/app.ts';
|
|
6
|
+
|
|
7
|
+
registerPlatformComponents(Platform.OS);
|
|
8
|
+
|
|
9
|
+
AppRegistry.registerRunnable('main', ({ rootTag }: { rootTag: number | string }) => {
|
|
10
|
+
const app = mount(Number(rootTag), App, getFabricUIManager(), {
|
|
11
|
+
// Colours, as the integers the platform wants.
|
|
12
|
+
processColor,
|
|
13
|
+
// What `@media` resolves against. Without it every media query is false and a responsive
|
|
14
|
+
// layout renders as its smallest case.
|
|
15
|
+
conditions: currentConditions(),
|
|
16
|
+
// Values only the device knows - the hairline width, which is a third of a point on a 3x
|
|
17
|
+
// screen. Without it `1px` is what you get, and that is a visibly fat divider.
|
|
18
|
+
tokens: deviceTokens(),
|
|
19
|
+
// Turns a `require('./x.png')` into something native can load. Without it images are blank.
|
|
20
|
+
resolveAssetSource: (value) => Image.resolveAssetSource(value as never),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Re-resolves the conditions when the device rotates or the theme changes. A rotation dirties
|
|
24
|
+
// no component and no binding, so without this nothing re-renders and `dark:` stops following
|
|
25
|
+
// the system switch.
|
|
26
|
+
watchConditions(app.engine);
|
|
27
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ngNative } from '@ng-native/testing/vitest';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
// Compiles Angular for the tests the way Metro compiles it for the app. Tests run in Node against
|
|
5
|
+
// a fake of the native side: no simulator, no device.
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [ngNative()],
|
|
8
|
+
});
|
package/generators.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../node_modules/nx/schemas/generators-schema.json",
|
|
3
|
+
"name": "@ng-native/nx",
|
|
4
|
+
"generators": {
|
|
5
|
+
"init": {
|
|
6
|
+
"factory": "./init/index.cjs#init",
|
|
7
|
+
"schema": "./init/schema.json",
|
|
8
|
+
"description": "Registers @nx/expo's plugin, which infers the Expo targets. nx add runs this.",
|
|
9
|
+
"hidden": true
|
|
10
|
+
},
|
|
11
|
+
"application": {
|
|
12
|
+
"aliases": ["app"],
|
|
13
|
+
"factory": "./application/index.cjs#application",
|
|
14
|
+
"schema": "./application/schema.json",
|
|
15
|
+
"description": "An Angular Native app: an Expo project with start, run-ios, run-android, export, test and typecheck targets."
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/init/index.cjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nx add @ng-native/nx`, and the first step of every app generated here.
|
|
3
|
+
*
|
|
4
|
+
* An Angular Native app is an Expo app, so its targets are Expo's, and `@nx/expo`'s plugin already
|
|
5
|
+
* infers them from any project with an `app.json`, a `metro.config.js` and a `package.json`:
|
|
6
|
+
* `start`, `run-ios`, `run-android`, `export`, `prebuild`, and EAS `build` and `submit`. This adds
|
|
7
|
+
* `@nx/expo` at the workspace's own Nx version and registers that plugin.
|
|
8
|
+
*
|
|
9
|
+
* It does not run `@nx/expo:init`, which installs Expo itself at the SDK `@nx/expo` was released
|
|
10
|
+
* against. On Nx 23.2 that is SDK 56, with `@expo/cli` 56 and a `react-dom` that asks for React
|
|
11
|
+
* 19.3, and an Angular Native app needs SDK 57 and React 19.2.3: npm refuses the install.
|
|
12
|
+
*
|
|
13
|
+
* Two things `init` would have added, it adds itself at the versions the app needs. `react-dom`,
|
|
14
|
+
* because `@nx/expo` depends on `@nx/react`, whose `react-dom` peer npm otherwise takes at the
|
|
15
|
+
* newest React and then refuses every later install beside React 19.2.3. And `@expo/cli`, which
|
|
16
|
+
* `@nx/expo`'s prebuild executor loads from the root, where npm does not put it on its own.
|
|
17
|
+
*/
|
|
18
|
+
const {
|
|
19
|
+
addDependenciesToPackageJson,
|
|
20
|
+
formatFiles,
|
|
21
|
+
readJson,
|
|
22
|
+
readNxJson,
|
|
23
|
+
updateNxJson,
|
|
24
|
+
NX_VERSION,
|
|
25
|
+
} = require('@nx/devkit');
|
|
26
|
+
const native = require('../native-app.cjs');
|
|
27
|
+
|
|
28
|
+
/** The plugin entry `@nx/expo:init` itself writes, so the target names are the ones Nx documents. */
|
|
29
|
+
const EXPO_PLUGIN = {
|
|
30
|
+
plugin: '@nx/expo/plugin',
|
|
31
|
+
options: {
|
|
32
|
+
startTargetName: 'start',
|
|
33
|
+
buildTargetName: 'build',
|
|
34
|
+
prebuildTargetName: 'prebuild',
|
|
35
|
+
serveTargetName: 'serve',
|
|
36
|
+
installTargetName: 'install',
|
|
37
|
+
exportTargetName: 'export',
|
|
38
|
+
submitTargetName: 'submit',
|
|
39
|
+
runIosTargetName: 'run-ios',
|
|
40
|
+
runAndroidTargetName: 'run-android',
|
|
41
|
+
buildDepsTargetName: 'build-deps',
|
|
42
|
+
watchDepsTargetName: 'watch-deps',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The Nx this workspace installed, which every `@nx/*` package in it has to match. */
|
|
47
|
+
function workspaceNxVersion(tree) {
|
|
48
|
+
const manifest = readJson(tree, 'package.json');
|
|
49
|
+
return manifest.devDependencies?.nx ?? manifest.dependencies?.nx ?? NX_VERSION;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function registerExpoPlugin(tree) {
|
|
53
|
+
const nxJson = readNxJson(tree) ?? {};
|
|
54
|
+
const plugins = nxJson.plugins ?? [];
|
|
55
|
+
const registered = plugins.some(
|
|
56
|
+
(entry) => (typeof entry === 'string' ? entry : entry.plugin) === EXPO_PLUGIN.plugin,
|
|
57
|
+
);
|
|
58
|
+
if (registered) return;
|
|
59
|
+
updateNxJson(tree, { ...nxJson, plugins: [...plugins, EXPO_PLUGIN] });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
64
|
+
* @param {{ skipInstall?: boolean, skipFormat?: boolean }} options
|
|
65
|
+
*/
|
|
66
|
+
async function init(tree, options = {}) {
|
|
67
|
+
registerExpoPlugin(tree);
|
|
68
|
+
const install = addDependenciesToPackageJson(
|
|
69
|
+
tree,
|
|
70
|
+
{},
|
|
71
|
+
{ '@nx/expo': workspaceNxVersion(tree), ...native.expoCompanions },
|
|
72
|
+
'package.json',
|
|
73
|
+
true,
|
|
74
|
+
);
|
|
75
|
+
if (!options.skipFormat) await formatFiles(tree);
|
|
76
|
+
return options.skipInstall ? () => {} : install;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { init, workspaceNxVersion, EXPO_PLUGIN };
|
package/init/schema.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/schema",
|
|
3
|
+
"$id": "NgNativeNxInit",
|
|
4
|
+
"title": "Set up Angular Native",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"skipInstall": {
|
|
8
|
+
"type": "boolean",
|
|
9
|
+
"description": "Update package.json without installing.",
|
|
10
|
+
"default": false
|
|
11
|
+
},
|
|
12
|
+
"skipFormat": {
|
|
13
|
+
"type": "boolean",
|
|
14
|
+
"description": "Leave the changed files unformatted.",
|
|
15
|
+
"default": false,
|
|
16
|
+
"x-priority": "internal"
|
|
17
|
+
},
|
|
18
|
+
"keepExistingVersions": {
|
|
19
|
+
"type": "boolean",
|
|
20
|
+
"description": "Passed by nx add. Existing versions are always kept.",
|
|
21
|
+
"default": true,
|
|
22
|
+
"x-priority": "internal"
|
|
23
|
+
},
|
|
24
|
+
"updatePackageScripts": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "Passed by nx add. There are no package scripts to update.",
|
|
27
|
+
"x-priority": "internal"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/native-app.cjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an Angular Native app is made of, in an Nx workspace.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Three files differ from the template's, each because of something Nx does:
|
|
10
|
+
*
|
|
11
|
+
* - `metro.config.js` wraps the preset in `withNxMetro`, which resolves the workspace's tsconfig
|
|
12
|
+
* path aliases and watches its libraries. Without it an app in an integrated workspace cannot
|
|
13
|
+
* import a library at all: Metro reports `Cannot resolve @org/ui`.
|
|
14
|
+
* - `tsconfig.json` also extends the workspace's `tsconfig.base.json` when there is one, which is
|
|
15
|
+
* where those aliases live, and puts back the Expo settings the workspace's base overrides.
|
|
16
|
+
* - `vitest.config.mts` adds `nxViteTsPaths` when the workspace has `@nx/vite`, for the same
|
|
17
|
+
* aliases, which Vitest does not read from tsconfig either.
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
const { readFileSync } = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const semver = require('semver');
|
|
25
|
+
|
|
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.20',
|
|
42
|
+
'expo-status-bar': '~57.0.1',
|
|
43
|
+
react: '19.2.3',
|
|
44
|
+
'react-native': '0.86.3',
|
|
45
|
+
'react-native-safe-area-context': '~5.7.0',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What `nx add` puts beside `@nx/expo`, in place of the `@nx/expo:init` it does not run: see
|
|
50
|
+
* `init/index.cjs`. `@expo/cli` is the range the template's `expo` depends on.
|
|
51
|
+
*/
|
|
52
|
+
const expoCompanions = { 'react-dom': dependencies.react, '@expo/cli': '^57.0.22' };
|
|
53
|
+
|
|
54
|
+
/** @type {Record<string, string>} */
|
|
55
|
+
const devDependencies = {
|
|
56
|
+
// An Angular workspace already has Babel 8 through @angular/compiler-cli, and React Native's
|
|
57
|
+
// Babel peer would otherwise take the newest copy there is.
|
|
58
|
+
// The typecheck script compiles the templates too, which only the Angular compiler can.
|
|
59
|
+
'@angular/compiler-cli': '^22.0.0',
|
|
60
|
+
'@babel/core': '^7.29.7',
|
|
61
|
+
'@ng-native/testing': version,
|
|
62
|
+
'@types/react': '~19.2.2',
|
|
63
|
+
typescript: '~6.0.3',
|
|
64
|
+
vitest: '^5.0.0',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The template's files, copied verbatim into the new app. */
|
|
68
|
+
const SOURCE_FILES = ['src/app/app.ts', 'src/main.ts', 'src/app/app.test.ts'];
|
|
69
|
+
|
|
70
|
+
/** @param {string} name */
|
|
71
|
+
function sourceFile(name) {
|
|
72
|
+
return readFileSync(path.join(__dirname, 'files', name), 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The template's `AGENTS.md`, with its Commands section replaced by this workspace's own. The rest
|
|
77
|
+
* describes the framework, which is the same wherever the app lives.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} commands the markdown that goes under the Commands heading
|
|
80
|
+
*/
|
|
81
|
+
function agentsFile(commands) {
|
|
82
|
+
return sourceFile('AGENTS.md').replace(
|
|
83
|
+
/## Commands\n[\s\S]*?(?=\n## )/,
|
|
84
|
+
`## Commands\n\n${commands}\n`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The template's `app.json`, named for this app, without the template's icons: they are the
|
|
90
|
+
* template's branding, and Expo draws its own default until `icon` is set.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} name the project name, which is also the Expo slug
|
|
93
|
+
*/
|
|
94
|
+
function appJson(name) {
|
|
95
|
+
const slug = name.replace(/^@[^/]+\//, '');
|
|
96
|
+
const expo = {
|
|
97
|
+
name: slug,
|
|
98
|
+
slug,
|
|
99
|
+
version: '1.0.0',
|
|
100
|
+
// Not left to Expo, which adds `web` whenever `react-dom` resolves, and @nx/react puts one there.
|
|
101
|
+
platforms: ['ios', 'android'],
|
|
102
|
+
orientation: 'portrait',
|
|
103
|
+
userInterfaceStyle: 'dark',
|
|
104
|
+
ios: { supportsTablet: true },
|
|
105
|
+
android: { predictiveBackGestureEnabled: false },
|
|
106
|
+
scheme: slug.replace(/[^a-z0-9]/gi, '').toLowerCase(),
|
|
107
|
+
};
|
|
108
|
+
return JSON.stringify({ expo }, null, 2) + '\n';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const METRO_CONFIG = `const { withNxMetro } = require('@nx/expo');
|
|
112
|
+
const { getDefaultConfig } = require('expo/metro-config');
|
|
113
|
+
const { withAngularNative } = require('@ng-native/metro/config.cjs');
|
|
114
|
+
|
|
115
|
+
// withNxMetro resolves the workspace's libraries, through its tsconfig path aliases or its package
|
|
116
|
+
// manager's links, and watches them. withAngularNative registers the transformer that compiles
|
|
117
|
+
// Angular ahead of time, compiles each component's CSS into the sheet the engine reads, and adds
|
|
118
|
+
// the polyfills Angular needs before \`@angular/core\` is first evaluated. It goes on the outside,
|
|
119
|
+
// so its resolver can wrap Nx's: a library's \`./lib/ui.js\` import has to find \`ui.ts\`.
|
|
120
|
+
module.exports = withAngularNative(withNxMetro(getDefaultConfig(__dirname)));
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string | undefined} workspaceBase the workspace's `tsconfig.base.json`, relative to the
|
|
125
|
+
* app, when there is one to take path aliases from
|
|
126
|
+
*/
|
|
127
|
+
function tsconfig(workspaceBase, conditions = []) {
|
|
128
|
+
const compilerOptions = { strict: true, allowImportingTsExtensions: true };
|
|
129
|
+
// The template's own: templates checked strictly, less the two checks that read a native host
|
|
130
|
+
// element's props and events as a DOM element's.
|
|
131
|
+
const angularCompilerOptions = {
|
|
132
|
+
strictTemplates: true,
|
|
133
|
+
typeCheckHostBindings: false,
|
|
134
|
+
strictDomEventTypes: false,
|
|
135
|
+
};
|
|
136
|
+
if (!workspaceBase) {
|
|
137
|
+
// The workspace's custom conditions, which a library in Nx's TypeScript preset exports its
|
|
138
|
+
// source under; the Metro preset and the Vitest plugin read them from here too. Beside
|
|
139
|
+
// Expo's own `react-native`, which setting the option would otherwise replace.
|
|
140
|
+
const extra = conditions.filter((c) => c !== 'react-native');
|
|
141
|
+
if (extra.length) compilerOptions.customConditions = ['react-native', ...extra];
|
|
142
|
+
return { extends: 'expo/tsconfig.base', compilerOptions, angularCompilerOptions };
|
|
143
|
+
}
|
|
144
|
+
// The workspace's base is written for a web build or for emitting declarations, and wins over
|
|
145
|
+
// Expo's where they overlap. These are Expo's, restored: without `DOM` and `ESNext` the framework
|
|
146
|
+
// packages' own source, which the app compiles, fails to typecheck.
|
|
147
|
+
return {
|
|
148
|
+
extends: ['expo/tsconfig.base', workspaceBase],
|
|
149
|
+
compilerOptions: {
|
|
150
|
+
...compilerOptions,
|
|
151
|
+
noEmit: true,
|
|
152
|
+
lib: ['DOM', 'ESNext'],
|
|
153
|
+
target: 'ESNext',
|
|
154
|
+
module: 'preserve',
|
|
155
|
+
moduleResolution: 'bundler',
|
|
156
|
+
customConditions: ['react-native'],
|
|
157
|
+
composite: false,
|
|
158
|
+
declaration: false,
|
|
159
|
+
emitDeclarationOnly: false,
|
|
160
|
+
},
|
|
161
|
+
angularCompilerOptions,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @param {boolean} withPaths whether to resolve the workspace's tsconfig path aliases */
|
|
166
|
+
function vitestConfig(withPaths) {
|
|
167
|
+
const template = readFileSync(path.join(__dirname, 'files', 'vitest.config.mts'), 'utf8');
|
|
168
|
+
if (!withPaths) return template;
|
|
169
|
+
return template
|
|
170
|
+
.replace(
|
|
171
|
+
"import { defineConfig } from 'vitest/config';",
|
|
172
|
+
"import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';\n" +
|
|
173
|
+
"import { defineConfig } from 'vitest/config';",
|
|
174
|
+
)
|
|
175
|
+
.replace('plugins: [ngNative()],', 'plugins: [nxViteTsPaths(), ngNative()],');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The dependencies an integrated workspace's app lists in its own `package.json`, at the ranges
|
|
180
|
+
* the root installs, though nothing installs from it.
|
|
181
|
+
*
|
|
182
|
+
* All of them, because Expo's autolinking links the native modules the app's `package.json` names
|
|
183
|
+
* and no others: without `react-native-safe-area-context` here, Android crashed on RNCSafeAreaView.
|
|
184
|
+
* And `expo prebuild`, which `expo run:ios` starts with, adds `expo`, `react` or `react-native` if
|
|
185
|
+
* it does not find them and then offers to install them into the app's directory, which would put a
|
|
186
|
+
* second copy of React Native beside the root's.
|
|
187
|
+
*
|
|
188
|
+
* @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} root
|
|
189
|
+
*/
|
|
190
|
+
function prebuildPins(root) {
|
|
191
|
+
const installed = { ...root.devDependencies, ...root.dependencies };
|
|
192
|
+
return Object.fromEntries(
|
|
193
|
+
Object.keys(dependencies).map((pkg) => [pkg, installed[pkg] ?? dependencies[pkg]]),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Where what a workspace already has can be older than what a new app is given: `@ng-native/testing`
|
|
199
|
+
* installs Vitest 5 but its plugin runs on 4, which `ng new` 22.1 and the Nx preset still start on.
|
|
200
|
+
* The range here is that package's `vitest` peer, and a test holds the two together.
|
|
201
|
+
*/
|
|
202
|
+
const accepted = { vitest: '^4.0.8 || ^5.0.0' };
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Why an existing dependency would stop this app installing or running, as a sentence each.
|
|
206
|
+
*
|
|
207
|
+
* Only ranges already in the manifest are judged, and only a range that cannot overlap the one
|
|
208
|
+
* wanted is a conflict: `~6.0.2` and `~6.0.3` resolve to the same TypeScript. A missing package is
|
|
209
|
+
* added at the version above. Nothing is rewritten, because moving a workspace's Angular or Vitest
|
|
210
|
+
* is an upgrade of every project in it, and that belongs to `nx migrate`, not to a generator.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} manifest
|
|
213
|
+
* @returns {string[]}
|
|
214
|
+
*/
|
|
215
|
+
function conflicts(manifest) {
|
|
216
|
+
const existing = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
217
|
+
const problems = [];
|
|
218
|
+
for (const [name, installed] of Object.entries({ ...dependencies, ...devDependencies })) {
|
|
219
|
+
const range = existing[name];
|
|
220
|
+
const wanted = accepted[name] ?? installed;
|
|
221
|
+
if (!range || name.startsWith('@ng-native/')) continue;
|
|
222
|
+
if (semver.validRange(range) && !semver.intersects(range, wanted)) {
|
|
223
|
+
problems.push(
|
|
224
|
+
`${name} is ${range} here, and Angular Native needs ${wanted}. ` +
|
|
225
|
+
`npm will refuse the install until it is moved.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return problems;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
dependencies,
|
|
234
|
+
devDependencies,
|
|
235
|
+
SOURCE_FILES,
|
|
236
|
+
sourceFile,
|
|
237
|
+
agentsFile,
|
|
238
|
+
appJson,
|
|
239
|
+
METRO_CONFIG,
|
|
240
|
+
tsconfig,
|
|
241
|
+
vitestConfig,
|
|
242
|
+
prebuildPins,
|
|
243
|
+
expoCompanions,
|
|
244
|
+
conflicts,
|
|
245
|
+
accepted,
|
|
246
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ng-native/nx",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nx generators for Angular Native: nx add, and an Expo app project whose targets run it.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"angular",
|
|
7
|
+
"react-native",
|
|
8
|
+
"angular-native",
|
|
9
|
+
"ios",
|
|
10
|
+
"android",
|
|
11
|
+
"fabric",
|
|
12
|
+
"native",
|
|
13
|
+
"nx",
|
|
14
|
+
"nx-plugin",
|
|
15
|
+
"generators",
|
|
16
|
+
"expo"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"generators": "./generators.json",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@nx/devkit": "^23.0.0",
|
|
22
|
+
"minimatch": "^10.0.0",
|
|
23
|
+
"semver": "^7.7.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"nx": "^23.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/semver": "^7.7.0"
|
|
30
|
+
},
|
|
31
|
+
"nx": {
|
|
32
|
+
"tags": [
|
|
33
|
+
"scope:nx",
|
|
34
|
+
"layer:build"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
5
37
|
"license": "MIT",
|
|
38
|
+
"homepage": "https://ng-native.com/packages/nx",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/ng-native/ng-native/issues"
|
|
41
|
+
},
|
|
6
42
|
"repository": {
|
|
7
43
|
"type": "git",
|
|
8
44
|
"url": "git+https://github.com/ng-native/ng-native.git",
|
|
9
45
|
"directory": "packages/nx"
|
|
10
46
|
},
|
|
11
|
-
"
|
|
47
|
+
"files": [
|
|
48
|
+
"*.cjs",
|
|
49
|
+
"generators.json",
|
|
50
|
+
"*/*.cjs",
|
|
51
|
+
"*/*.json",
|
|
52
|
+
"files"
|
|
53
|
+
],
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
59
|
+
"test": "node --test \"*.test.ts\" \"{init,application}/*.test.ts\""
|
|
60
|
+
}
|
|
12
61
|
}
|