@ng-native/nx 0.0.1 → 0.1.1

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 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
- A placeholder: this version has no code. See https://ng-native.com.
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,164 @@
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. It also replaces the inferred `start`,
8
+ * which is `@nx/expo:start`, an executor Nx deprecates and warns about on every run, with the
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.
11
+ *
12
+ * Where the dependencies go depends on the workspace. With package-manager workspaces, the Nx
13
+ * default since 20, the app is a workspace package and lists them itself, which pnpm needs in
14
+ * order to link them. In an integrated workspace, with one root `package.json`, they go there.
15
+ */
16
+ const path = require('node:path').posix;
17
+ const {
18
+ formatFiles,
19
+ joinPathFragments,
20
+ logger,
21
+ offsetFromRoot,
22
+ readJson,
23
+ runTasksInSerial,
24
+ addProjectConfiguration,
25
+ installPackagesTask,
26
+ addDependenciesToPackageJson,
27
+ } = require('@nx/devkit');
28
+ const native = require('../native-app.cjs');
29
+ const { init, workspaceNxVersion } = require('../init/index.cjs');
30
+ const { usesWorkspaces, includeInWorkspaces } = require('./workspaces.cjs');
31
+
32
+ function names(tree, options) {
33
+ const directory = path.normalize(options.directory).replace(/\/$/, '');
34
+ const name = options.name ?? path.basename(directory);
35
+ const scope = readJson(tree, 'package.json').name?.match(/^@[^/]+/)?.[0];
36
+ const workspaces = usesWorkspaces(tree);
37
+ // A workspace package is named for the workspace's scope, as Nx names its own.
38
+ const projectName = workspaces && scope ? `${scope}/${name}` : name;
39
+ return { directory, name, projectName, workspaces };
40
+ }
41
+
42
+ function targets(directory) {
43
+ const run = (command) => ({ executor: 'nx:run-commands', options: { cwd: directory, command } });
44
+ return {
45
+ typecheck: {
46
+ ...run('ngc -p tsconfig.json --noEmit'),
47
+ cache: true,
48
+ inputs: ['default', '^production'],
49
+ },
50
+ test: { ...run('vitest run'), cache: true, inputs: ['default', '^production'] },
51
+ start: { ...run('expo start'), continuous: true },
52
+ };
53
+ }
54
+
55
+ /** The Commands section of the app's `AGENTS.md`, as this workspace runs them. */
56
+ function commands(name) {
57
+ const rows = [
58
+ [`nx start ${name}`, 'Metro; press i or a for a simulator, or scan the QR code with Expo Go'],
59
+ [`nx run ${name}:run-ios`, 'the iOS simulator'],
60
+ [`nx run ${name}:run-android`, 'the Android emulator'],
61
+ [`nx test ${name}`, 'Vitest, in Node against a fake native layer: no simulator needed'],
62
+ [`nx typecheck ${name}`, 'TypeScript, without emitting'],
63
+ ];
64
+ const width = Math.max(...rows.map(([command]) => command.length)) + 2;
65
+ return [
66
+ '```sh',
67
+ ...rows.map(([command, what]) => `${command.padEnd(width)}# ${what}`),
68
+ '```',
69
+ '',
70
+ '`src/main.ts` mounts the root component, `src/app/app.ts`. ' +
71
+ `Run \`nx test ${name}\` and \`nx typecheck ${name}\` after a change; both are fast.`,
72
+ ].join('\n');
73
+ }
74
+
75
+ function writeFiles(tree, { directory, projectName, workspaces }) {
76
+ const file = (name, content) => tree.write(joinPathFragments(directory, name), content);
77
+ for (const name of native.SOURCE_FILES) file(name, native.sourceFile(name));
78
+ file('app.json', native.appJson(projectName));
79
+ file('AGENTS.md', native.agentsFile(commands(projectName)));
80
+ file('CLAUDE.md', '@AGENTS.md\n');
81
+ file('metro.config.js', native.METRO_CONFIG);
82
+
83
+ const base = !workspaces && tree.exists('tsconfig.base.json');
84
+ 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
+ : [];
88
+ file('tsconfig.json', JSON.stringify(native.tsconfig(workspaceBase, conditions), null, 2) + '\n');
89
+ file('vitest.config.mts', native.vitestConfig(Boolean(base)));
90
+
91
+ const manifest = { name: projectName, version: '0.0.1', private: true, main: 'src/main.ts' };
92
+ if (workspaces) {
93
+ Object.assign(manifest, {
94
+ dependencies: native.dependencies,
95
+ devDependencies: native.devDependencies,
96
+ });
97
+ } else {
98
+ manifest.dependencies = native.prebuildPins(readJson(tree, 'package.json'));
99
+ }
100
+ file('package.json', JSON.stringify(manifest, null, 2) + '\n');
101
+ }
102
+
103
+ /** Expo's cache directory, once. */
104
+ function ignoreExpo(tree) {
105
+ if (!tree.exists('.gitignore')) return;
106
+ const ignore = tree.read('.gitignore', 'utf-8') ?? '';
107
+ if (/^\.expo\/?$/m.test(ignore)) return;
108
+ tree.write('.gitignore', ignore.replace(/\n*$/, '\n\n# Expo\n.expo/\n'));
109
+ }
110
+
111
+ /**
112
+ * @param {import('@nx/devkit').Tree} tree
113
+ * @param {{ directory: string, name?: string, tags?: string, skipInstall?: boolean, skipFormat?: boolean }} options
114
+ */
115
+ async function application(tree, options) {
116
+ const resolved = names(tree, options);
117
+ const { directory, projectName, workspaces } = resolved;
118
+ if (tree.exists(joinPathFragments(directory, 'package.json'))) {
119
+ throw new Error(`${directory} already has a package.json. Choose another directory.`);
120
+ }
121
+ const initTask = await init(tree, { skipInstall: true, skipFormat: true });
122
+
123
+ if (workspaces) {
124
+ includeInWorkspaces(tree, directory);
125
+ } else {
126
+ 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
+ : {};
133
+ addDependenciesToPackageJson(
134
+ tree,
135
+ native.dependencies,
136
+ { ...native.devDependencies, ...aliases },
137
+ 'package.json',
138
+ true,
139
+ );
140
+ }
141
+ writeFiles(tree, resolved);
142
+ addProjectConfiguration(tree, projectName, {
143
+ root: directory,
144
+ sourceRoot: directory,
145
+ projectType: 'application',
146
+ tags: (options.tags ?? '')
147
+ .split(',')
148
+ .map((tag) => tag.trim())
149
+ .filter(Boolean),
150
+ targets: targets(directory),
151
+ });
152
+
153
+ ignoreExpo(tree);
154
+
155
+ if (!options.skipFormat) await formatFiles(tree);
156
+ logger.info(
157
+ `Created ${directory}. nx start ${projectName} runs Metro; nx run ${projectName}:run-ios, ` +
158
+ `nx test ${projectName}, nx typecheck ${projectName}, nx export ${projectName}.`,
159
+ );
160
+ if (options.skipInstall) return initTask;
161
+ return runTasksInSerial(initTask, () => installPackagesTask(tree, true));
162
+ }
163
+
164
+ 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 };
@@ -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
+ });
@@ -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,83 @@
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
+ * It also adds Babel 7's `@babel/runtime`, the one Expo's Babel preset writes imports of. In an
19
+ * Angular workspace `@angular-devkit/build-angular` hoists Babel 8's to the root, which has no
20
+ * `regenerator`, and every `nx start` warned that Metro fell back to file-based resolution.
21
+ */
22
+ const {
23
+ addDependenciesToPackageJson,
24
+ formatFiles,
25
+ readJson,
26
+ readNxJson,
27
+ updateNxJson,
28
+ NX_VERSION,
29
+ } = require('@nx/devkit');
30
+ const native = require('../native-app.cjs');
31
+
32
+ /** The plugin entry `@nx/expo:init` itself writes, so the target names are the ones Nx documents. */
33
+ const EXPO_PLUGIN = {
34
+ plugin: '@nx/expo/plugin',
35
+ options: {
36
+ startTargetName: 'start',
37
+ buildTargetName: 'build',
38
+ prebuildTargetName: 'prebuild',
39
+ serveTargetName: 'serve',
40
+ installTargetName: 'install',
41
+ exportTargetName: 'export',
42
+ submitTargetName: 'submit',
43
+ runIosTargetName: 'run-ios',
44
+ runAndroidTargetName: 'run-android',
45
+ buildDepsTargetName: 'build-deps',
46
+ watchDepsTargetName: 'watch-deps',
47
+ },
48
+ };
49
+
50
+ /** The Nx this workspace installed, which every `@nx/*` package in it has to match. */
51
+ function workspaceNxVersion(tree) {
52
+ const manifest = readJson(tree, 'package.json');
53
+ return manifest.devDependencies?.nx ?? manifest.dependencies?.nx ?? NX_VERSION;
54
+ }
55
+
56
+ function registerExpoPlugin(tree) {
57
+ const nxJson = readNxJson(tree) ?? {};
58
+ const plugins = nxJson.plugins ?? [];
59
+ const registered = plugins.some(
60
+ (entry) => (typeof entry === 'string' ? entry : entry.plugin) === EXPO_PLUGIN.plugin,
61
+ );
62
+ if (registered) return;
63
+ updateNxJson(tree, { ...nxJson, plugins: [...plugins, EXPO_PLUGIN] });
64
+ }
65
+
66
+ /**
67
+ * @param {import('@nx/devkit').Tree} tree
68
+ * @param {{ skipInstall?: boolean, skipFormat?: boolean }} options
69
+ */
70
+ async function init(tree, options = {}) {
71
+ registerExpoPlugin(tree);
72
+ const install = addDependenciesToPackageJson(
73
+ tree,
74
+ {},
75
+ { '@nx/expo': workspaceNxVersion(tree), ...native.expoCompanions },
76
+ 'package.json',
77
+ true,
78
+ );
79
+ if (!options.skipFormat) await formatFiles(tree);
80
+ return options.skipInstall ? () => {} : install;
81
+ }
82
+
83
+ module.exports = { init, workspaceNxVersion, EXPO_PLUGIN };
@@ -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,253 @@
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` and `@babel/runtime` are the ranges the template's `expo` depends
51
+ * on.
52
+ */
53
+ const expoCompanions = {
54
+ 'react-dom': dependencies.react,
55
+ '@expo/cli': '^57.0.22',
56
+ '@babel/runtime': '^7.20.0',
57
+ };
58
+
59
+ /** @type {Record<string, string>} */
60
+ const devDependencies = {
61
+ // An Angular workspace already has Babel 8 through @angular/compiler-cli, and React Native's
62
+ // Babel peer would otherwise take the newest copy there is.
63
+ // The typecheck script compiles the templates too, which only the Angular compiler can.
64
+ '@angular/compiler-cli': '^22.0.0',
65
+ '@babel/core': '^7.29.7',
66
+ '@ng-native/testing': version,
67
+ '@types/react': '~19.2.2',
68
+ typescript: '~6.0.3',
69
+ vitest: '^5.0.0',
70
+ };
71
+
72
+ /** The template's files, copied verbatim into the new app. */
73
+ const SOURCE_FILES = ['src/app/app.ts', 'src/main.ts', 'src/app/app.test.ts'];
74
+
75
+ /** @param {string} name */
76
+ function sourceFile(name) {
77
+ return readFileSync(path.join(__dirname, 'files', name), 'utf8');
78
+ }
79
+
80
+ /**
81
+ * The template's `AGENTS.md`, with its Commands section replaced by this workspace's own. The rest
82
+ * describes the framework, which is the same wherever the app lives.
83
+ *
84
+ * @param {string} commands the markdown that goes under the Commands heading
85
+ */
86
+ function agentsFile(commands) {
87
+ return sourceFile('AGENTS.md').replace(
88
+ /## Commands\n[\s\S]*?(?=\n## )/,
89
+ `## Commands\n\n${commands}\n`,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * The template's `app.json`, named for this app, without the template's icons: they are the
95
+ * template's branding, and Expo draws its own default until `icon` is set.
96
+ *
97
+ * @param {string} name the project name, which is also the Expo slug
98
+ */
99
+ function appJson(name) {
100
+ const slug = name.replace(/^@[^/]+\//, '');
101
+ const expo = {
102
+ name: slug,
103
+ slug,
104
+ version: '1.0.0',
105
+ // Not left to Expo, which adds `web` whenever `react-dom` resolves, and @nx/react puts one there.
106
+ platforms: ['ios', 'android'],
107
+ orientation: 'portrait',
108
+ userInterfaceStyle: 'dark',
109
+ ios: { supportsTablet: true },
110
+ android: { predictiveBackGestureEnabled: false },
111
+ scheme: slug.replace(/[^a-z0-9]/gi, '').toLowerCase(),
112
+ // The template's: Expo otherwise guesses a router root from src/app and says so on every start.
113
+ extra: { router: { root: 'src/app' } },
114
+ };
115
+ return JSON.stringify({ expo }, null, 2) + '\n';
116
+ }
117
+
118
+ const METRO_CONFIG = `const { withNxMetro } = require('@nx/expo');
119
+ const { getDefaultConfig } = require('expo/metro-config');
120
+ const { withAngularNative } = require('@ng-native/metro/config.cjs');
121
+
122
+ // withNxMetro resolves the workspace's libraries, through its tsconfig path aliases or its package
123
+ // manager's links, and watches them. withAngularNative registers the transformer that compiles
124
+ // Angular ahead of time, compiles each component's CSS into the sheet the engine reads, and adds
125
+ // the polyfills Angular needs before \`@angular/core\` is first evaluated. It goes on the outside,
126
+ // so its resolver can wrap Nx's: a library's \`./lib/ui.js\` import has to find \`ui.ts\`.
127
+ module.exports = withAngularNative(withNxMetro(getDefaultConfig(__dirname)));
128
+ `;
129
+
130
+ /**
131
+ * @param {string | undefined} workspaceBase the workspace's `tsconfig.base.json`, relative to the
132
+ * app, when there is one to take path aliases from
133
+ */
134
+ function tsconfig(workspaceBase, conditions = []) {
135
+ const compilerOptions = { strict: true, allowImportingTsExtensions: true };
136
+ // The template's own: templates checked strictly, less the two checks that read a native host
137
+ // element's props and events as a DOM element's.
138
+ const angularCompilerOptions = {
139
+ strictTemplates: true,
140
+ typeCheckHostBindings: false,
141
+ strictDomEventTypes: false,
142
+ };
143
+ 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];
149
+ return { extends: 'expo/tsconfig.base', compilerOptions, angularCompilerOptions };
150
+ }
151
+ // The workspace's base is written for a web build or for emitting declarations, and wins over
152
+ // Expo's where they overlap. These are Expo's, restored: without `DOM` and `ESNext` the framework
153
+ // packages' own source, which the app compiles, fails to typecheck.
154
+ return {
155
+ extends: ['expo/tsconfig.base', workspaceBase],
156
+ compilerOptions: {
157
+ ...compilerOptions,
158
+ noEmit: true,
159
+ lib: ['DOM', 'ESNext'],
160
+ target: 'ESNext',
161
+ module: 'preserve',
162
+ moduleResolution: 'bundler',
163
+ customConditions: ['react-native'],
164
+ composite: false,
165
+ declaration: false,
166
+ emitDeclarationOnly: false,
167
+ },
168
+ angularCompilerOptions,
169
+ };
170
+ }
171
+
172
+ /** @param {boolean} withPaths whether to resolve the workspace's tsconfig path aliases */
173
+ function vitestConfig(withPaths) {
174
+ const template = readFileSync(path.join(__dirname, 'files', 'vitest.config.mts'), 'utf8');
175
+ if (!withPaths) return template;
176
+ return template
177
+ .replace(
178
+ "import { defineConfig } from 'vitest/config';",
179
+ "import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';\n" +
180
+ "import { defineConfig } from 'vitest/config';",
181
+ )
182
+ .replace('plugins: [ngNative()],', 'plugins: [nxViteTsPaths(), ngNative()],');
183
+ }
184
+
185
+ /**
186
+ * The dependencies an integrated workspace's app lists in its own `package.json`, at the ranges
187
+ * the root installs, though nothing installs from it.
188
+ *
189
+ * All of them, because Expo's autolinking links the native modules the app's `package.json` names
190
+ * and no others: without `react-native-safe-area-context` here, Android crashed on RNCSafeAreaView.
191
+ * And `expo prebuild`, which `expo run:ios` starts with, adds `expo`, `react` or `react-native` if
192
+ * it does not find them and then offers to install them into the app's directory, which would put a
193
+ * second copy of React Native beside the root's.
194
+ *
195
+ * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} root
196
+ */
197
+ function prebuildPins(root) {
198
+ const installed = { ...root.devDependencies, ...root.dependencies };
199
+ return Object.fromEntries(
200
+ Object.keys(dependencies).map((pkg) => [pkg, installed[pkg] ?? dependencies[pkg]]),
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Where what a workspace already has can be older than what a new app is given: `@ng-native/testing`
206
+ * installs Vitest 5 but its plugin runs on 4, which `ng new` 22.1 and the Nx preset still start on.
207
+ * The range here is that package's `vitest` peer, and a test holds the two together.
208
+ */
209
+ const accepted = { vitest: '^4.0.8 || ^5.0.0' };
210
+
211
+ /**
212
+ * Why an existing dependency would stop this app installing or running, as a sentence each.
213
+ *
214
+ * Only ranges already in the manifest are judged, and only a range that cannot overlap the one
215
+ * wanted is a conflict: `~6.0.2` and `~6.0.3` resolve to the same TypeScript. A missing package is
216
+ * added at the version above. Nothing is rewritten, because moving a workspace's Angular or Vitest
217
+ * is an upgrade of every project in it, and that belongs to `nx migrate`, not to a generator.
218
+ *
219
+ * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} manifest
220
+ * @returns {string[]}
221
+ */
222
+ function conflicts(manifest) {
223
+ const existing = { ...manifest.dependencies, ...manifest.devDependencies };
224
+ const problems = [];
225
+ for (const [name, installed] of Object.entries({ ...dependencies, ...devDependencies })) {
226
+ const range = existing[name];
227
+ const wanted = accepted[name] ?? installed;
228
+ if (!range || name.startsWith('@ng-native/')) continue;
229
+ if (semver.validRange(range) && !semver.intersects(range, wanted)) {
230
+ problems.push(
231
+ `${name} is ${range} here, and Angular Native needs ${wanted}. ` +
232
+ `npm will refuse the install until it is moved.`,
233
+ );
234
+ }
235
+ }
236
+ return problems;
237
+ }
238
+
239
+ module.exports = {
240
+ dependencies,
241
+ devDependencies,
242
+ SOURCE_FILES,
243
+ sourceFile,
244
+ agentsFile,
245
+ appJson,
246
+ METRO_CONFIG,
247
+ tsconfig,
248
+ vitestConfig,
249
+ prebuildPins,
250
+ expoCompanions,
251
+ conflicts,
252
+ accepted,
253
+ };
package/package.json CHANGED
@@ -1,12 +1,61 @@
1
1
  {
2
2
  "name": "@ng-native/nx",
3
- "version": "0.0.1",
4
- "description": "Reserved for ng-native. The first release follows.",
3
+ "version": "0.1.1",
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
- "homepage": "https://ng-native.com/packages/nx"
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
  }