@ng-native/schematics 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 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/schematics
2
2
 
3
- A placeholder: this version has no code. See https://ng-native.com.
3
+ `ng add` and `ng generate` for Angular Native: an Expo app added to an Angular CLI workspace,
4
+ beside its web app, with builders that let `ng serve`, `ng build` and `ng test` run it.
5
+
6
+ Alpha: APIs may change before 1.0.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ ng add @ng-native/schematics
12
+ ```
13
+
14
+ That writes the template's app to `projects/native`, registers it in `angular.json`, and installs
15
+ its dependencies into the workspace's root `package.json`. For an app on its own, without a
16
+ workspace, use `npx create-expo-app@latest my-app --template @ng-native/template` instead.
17
+
18
+ ## Example
19
+
20
+ ```sh
21
+ ng serve native # expo start: Metro, with Expo Go or a simulator
22
+ ng build native # expo export, into dist/native
23
+ ng test native # vitest run, on the fake Fabric
24
+ ng run native:run-ios # expo run:ios
25
+
26
+ cd projects/native && ng generate component profile-card # <view>, <text>, and a test
27
+ ```
28
+
29
+ ## What's in the package
30
+
31
+ - `collection.json` - the `ng-add`, `application` and `component` schematics.
32
+ - `builders.json` - `@ng-native/schematics:expo`, which runs an Expo CLI command in the project, and
33
+ `@ng-native/schematics:vitest`, which runs its tests.
34
+ - `files/` - the template's source files, which a test keeps identical to `template/`.
35
+
36
+ ## Docs
37
+
38
+ - [Angular CLI](https://ng-native.com/packages/schematics)
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
+ * `ng generate @ng-native/schematics:application`: an Angular Native app inside an Angular CLI
3
+ * workspace.
4
+ *
5
+ * The app is an Expo project in its own directory, beside whatever web app the workspace already
6
+ * has, and Metro builds it rather than `@angular/build`. What makes it a project of this workspace
7
+ * is its entry in `angular.json`: the builders there run Expo, so `ng serve native` starts Metro,
8
+ * `ng build native` exports a bundle and `ng test native` runs its tests in Node.
9
+ *
10
+ * Its dependencies go in the root `package.json`, because an Angular CLI workspace has one. The
11
+ * project's own `package.json` is for Expo, which reads `main` from it to find the entry file.
12
+ */
13
+ const path = require('node:path').posix;
14
+ const { SchematicsException } = require('@angular-devkit/schematics');
15
+ const { NodePackageInstallTask } = require('@angular-devkit/schematics/tasks');
16
+ const native = require('../native-app.cjs');
17
+
18
+ function readJson(tree, file) {
19
+ return JSON.parse(tree.readText(file));
20
+ }
21
+
22
+ function writeJson(tree, file, value) {
23
+ tree.overwrite(file, JSON.stringify(value, null, 2) + '\n');
24
+ }
25
+
26
+ /**
27
+ * The project's targets. Each is an Expo command run in the project's directory, which is where
28
+ * Expo reads `app.json` and `metro.config.js` from.
29
+ */
30
+ function targets(root, name) {
31
+ const expo = (command, args) => ({
32
+ builder: '@ng-native/schematics:expo',
33
+ options: args ? { command, args } : { command },
34
+ });
35
+ // Beside the workspace's other build output, rather than in a `dist/` inside the project.
36
+ const outputDir = path.relative(root, path.join('dist', name));
37
+ return {
38
+ serve: expo('start'),
39
+ build: expo('export', ['--output-dir', outputDir]),
40
+ 'run-ios': expo('run:ios'),
41
+ 'run-android': expo('run:android'),
42
+ test: { builder: '@ng-native/schematics:vitest', options: {} },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Angular's own schematics, which write a spec this project cannot run: each assumes Vitest's
48
+ * globals and most assume `TestBed` on a DOM, and a test here renders onto the fake Fabric.
49
+ */
50
+ const WEB_SPECS = [
51
+ 'class',
52
+ 'component',
53
+ 'directive',
54
+ 'guard',
55
+ 'interceptor',
56
+ 'pipe',
57
+ 'resolver',
58
+ 'service',
59
+ ];
60
+
61
+ function project(root, name, prefix) {
62
+ return {
63
+ projectType: 'application',
64
+ root,
65
+ sourceRoot: root,
66
+ prefix,
67
+ // `ng generate component` run inside this project makes a native component, with `<view>` and
68
+ // a test that runs on the fake Fabric. Anything this collection does not have, a service or a
69
+ // pipe, falls through to Angular's own. Only inside it: `--project` from the workspace root
70
+ // does not switch collections, which is the Angular CLI's rule rather than this project's.
71
+ cli: { schematicCollections: ['@ng-native/schematics', '@schematics/angular'] },
72
+ schematics: Object.fromEntries(
73
+ WEB_SPECS.map((schematic) => [`@schematics/angular:${schematic}`, { skipTests: true }]),
74
+ ),
75
+ architect: targets(root, name),
76
+ };
77
+ }
78
+
79
+ /** The Commands section of the app's `AGENTS.md`, as this workspace runs them. */
80
+ function commands(name) {
81
+ const rows = [
82
+ [`ng serve ${name}`, 'Metro; press i or a for a simulator, or scan the QR code with Expo Go'],
83
+ [`ng run ${name}:run-ios`, 'the iOS simulator'],
84
+ [`ng run ${name}:run-android`, 'the Android emulator'],
85
+ [`ng test ${name}`, 'Vitest, in Node against a fake native layer: no simulator needed'],
86
+ [`ng build ${name}`, 'an exported bundle'],
87
+ ];
88
+ const width = Math.max(...rows.map(([command]) => command.length)) + 2;
89
+ return [
90
+ '```sh',
91
+ ...rows.map(([command, what]) => `${command.padEnd(width)}# ${what}`),
92
+ '```',
93
+ '',
94
+ '`src/main.ts` mounts the root component, `src/app/app.ts`. ' +
95
+ `Run \`ng test ${name}\` after a change; it is fast.`,
96
+ ].join('\n');
97
+ }
98
+
99
+ function writeFiles(tree, root, name) {
100
+ for (const file of native.SOURCE_FILES) {
101
+ tree.create(path.join(root, file), native.sourceFile(file));
102
+ }
103
+ tree.create(path.join(root, 'app.json'), native.appJson(name));
104
+ tree.create(path.join(root, 'AGENTS.md'), native.agentsFile(commands(name)));
105
+ tree.create(path.join(root, 'CLAUDE.md'), '@AGENTS.md\n');
106
+ const manifest = native.projectManifest(name, readJson(tree, 'package.json'));
107
+ tree.create(path.join(root, 'package.json'), JSON.stringify(manifest, null, 2) + '\n');
108
+ }
109
+
110
+ /** Adds what is missing and leaves every version the workspace already chose alone. */
111
+ function addDependencies(tree, context) {
112
+ const manifest = readJson(tree, 'package.json');
113
+ for (const problem of native.conflicts(manifest)) context.logger.warn(problem);
114
+ for (const field of ['dependencies', 'devDependencies']) {
115
+ const existing = { ...manifest.dependencies, ...manifest.devDependencies };
116
+ const added = Object.entries(native[field]).filter(([name]) => !(name in existing));
117
+ manifest[field] = Object.fromEntries(
118
+ [...Object.entries(manifest[field] ?? {}), ...added].sort(([a], [b]) => (a < b ? -1 : 1)),
119
+ );
120
+ }
121
+ writeJson(tree, 'package.json', manifest);
122
+ }
123
+
124
+ /** Expo's cache directory, once, wherever the project is. */
125
+ function ignoreExpo(tree) {
126
+ if (!tree.exists('.gitignore')) return;
127
+ const ignore = tree.readText('.gitignore');
128
+ if (/^\.expo\/?$/m.test(ignore)) return;
129
+ tree.overwrite('.gitignore', ignore.replace(/\n*$/, '\n\n# Expo\n.expo/\n'));
130
+ }
131
+
132
+ /**
133
+ * @param {{ name: string, directory?: string, prefix?: string, skipInstall?: boolean }} options
134
+ */
135
+ function application(options) {
136
+ return (tree, context) => {
137
+ if (!tree.exists('angular.json')) {
138
+ throw new SchematicsException(
139
+ 'No angular.json here. This adds an app to an Angular CLI workspace; for an app on its ' +
140
+ 'own, run: npx create-expo-app@latest my-app --template @ng-native/template',
141
+ );
142
+ }
143
+ const workspace = readJson(tree, 'angular.json');
144
+ const { name } = options;
145
+ if (workspace.projects?.[name]) {
146
+ throw new SchematicsException(`angular.json already has a project called "${name}".`);
147
+ }
148
+ const root = options.directory ?? path.join(workspace.newProjectRoot ?? 'projects', name);
149
+
150
+ addDependencies(tree, context);
151
+ writeFiles(tree, root, name);
152
+ workspace.projects = { ...workspace.projects, [name]: project(root, name, options.prefix) };
153
+ writeJson(tree, 'angular.json', workspace);
154
+ ignoreExpo(tree);
155
+
156
+ if (!options.skipInstall) context.addTask(new NodePackageInstallTask());
157
+ context.logger.info(
158
+ `Created ${root}. Run it with: ng serve ${name} (Metro), ng run ${name}:run-ios, ` +
159
+ `ng test ${name}, ng build ${name}.`,
160
+ );
161
+ };
162
+ }
163
+
164
+ module.exports = { application, targets };
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema",
3
+ "$id": "NgNativeApplication",
4
+ "title": "Angular Native application",
5
+ "type": "object",
6
+ "properties": {
7
+ "name": {
8
+ "type": "string",
9
+ "description": "The project name, in angular.json and as the Expo slug.",
10
+ "pattern": "^[a-z][a-z0-9-]*$",
11
+ "$default": { "$source": "argv", "index": 0 },
12
+ "x-prompt": "What should the native app be called?"
13
+ },
14
+ "directory": {
15
+ "type": "string",
16
+ "description": "Where the project goes. Defaults to newProjectRoot/name, usually projects/name."
17
+ },
18
+ "prefix": {
19
+ "type": "string",
20
+ "description": "The selector prefix for components generated in this project.",
21
+ "default": "app",
22
+ "pattern": "^[a-z][a-z0-9-]*$"
23
+ },
24
+ "skipInstall": {
25
+ "type": "boolean",
26
+ "description": "Write the files and package.json without installing.",
27
+ "default": false
28
+ }
29
+ },
30
+ "required": ["name"]
31
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `@ng-native/schematics:expo`: an Expo CLI command, run in the project.
3
+ *
4
+ * `ng serve native` is `expo start`, `ng build native` is `expo export` and
5
+ * `ng run native:run-ios` is `expo run:ios`. Nothing is translated: Metro builds an Angular Native
6
+ * app, Expo drives Metro, and this is how `ng` reaches it.
7
+ */
8
+ const { createBuilder } = require('@angular-devkit/architect');
9
+ const { spawnBin } = require('./spawn-bin.cjs');
10
+
11
+ /**
12
+ * The Expo CLI's arguments for a target's options.
13
+ *
14
+ * @param {{ command: string, args?: string[], platform?: string, port?: number, clear?: boolean }} options
15
+ */
16
+ function expoArgs(options) {
17
+ const args = [options.command, ...(options.args ?? [])];
18
+ if (options.platform) args.push('--platform', options.platform);
19
+ if (options.port) args.push('--port', String(options.port));
20
+ if (options.clear) args.push('--clear');
21
+ return args;
22
+ }
23
+
24
+ module.exports = createBuilder((options, context) => spawnBin('expo', expoArgs(options), context));
25
+ module.exports.expoArgs = expoArgs;
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema",
3
+ "$id": "NgNativeExpoBuilder",
4
+ "title": "Expo command",
5
+ "type": "object",
6
+ "properties": {
7
+ "command": {
8
+ "type": "string",
9
+ "description": "The Expo CLI command: start, export, run:ios, run:android, prebuild."
10
+ },
11
+ "args": {
12
+ "type": "array",
13
+ "items": { "type": "string" },
14
+ "description": "Arguments passed to the command as they are.",
15
+ "default": []
16
+ },
17
+ "platform": {
18
+ "type": "string",
19
+ "enum": ["ios", "android", "web", "all"],
20
+ "description": "Passed as --platform, for export."
21
+ },
22
+ "port": {
23
+ "type": "number",
24
+ "description": "Passed as --port: the port Metro listens on."
25
+ },
26
+ "clear": {
27
+ "type": "boolean",
28
+ "description": "Passed as --clear: start Metro with an empty transform cache."
29
+ }
30
+ },
31
+ "required": ["command"]
32
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Runs a package's own CLI in an Angular Native project's directory, for the builders.
3
+ *
4
+ * The CLI is found from the project, not from this package, so it is the copy the app installed:
5
+ * `expo` from wherever the workspace hoisted it. It is found the way Node looks a package up, one
6
+ * `node_modules` at a time upwards, and not through `require.resolve`: a package's `exports` map
7
+ * can hide its bin file, as Vitest's does, or its `package.json`.
8
+ */
9
+ const { spawn } = require('node:child_process');
10
+ const { existsSync, readFileSync } = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ /** @param {string} name @param {string} from */
14
+ function findManifest(name, from) {
15
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
16
+ const manifest = path.join(dir, 'node_modules', name, 'package.json');
17
+ if (existsSync(manifest)) return manifest;
18
+ if (dir === path.dirname(dir))
19
+ throw new Error(`${name} is not installed, looking from ${from}.`);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * @param {string} name the package, `expo` or `vitest`
25
+ * @param {string} from the directory to resolve it from
26
+ */
27
+ function resolveBin(name, from) {
28
+ const manifestPath = findManifest(name, from);
29
+ const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8'));
30
+ const file = typeof bin === 'string' ? bin : bin[name];
31
+ return path.join(path.dirname(manifestPath), file);
32
+ }
33
+
34
+ /**
35
+ * Where the target's project lives, as an absolute path.
36
+ *
37
+ * @param {import('@angular-devkit/architect').BuilderContext} context
38
+ */
39
+ async function projectRoot(context) {
40
+ const project = context.target?.project;
41
+ if (!project) throw new Error('This builder runs as a project target, not on its own.');
42
+ const metadata = await context.getProjectMetadata(project);
43
+ return path.join(context.workspaceRoot, String(metadata['root'] ?? ''));
44
+ }
45
+
46
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
47
+
48
+ /**
49
+ * Spawns the CLI with the terminal attached, so Metro's QR code and keyboard shortcuts work as
50
+ * they do under `npx expo start`, and resolves when it exits.
51
+ *
52
+ * @param {string} name
53
+ * @param {string[]} args
54
+ * @param {import('@angular-devkit/architect').BuilderContext} context
55
+ * @returns {Promise<{ success: boolean, error?: string }>}
56
+ */
57
+ async function spawnBin(name, args, context) {
58
+ const cwd = await projectRoot(context);
59
+ const bin = resolveBin(name, cwd);
60
+ context.logger.info(
61
+ `${name} ${args.join(' ')} (in ${path.relative(context.workspaceRoot, cwd)})`,
62
+ );
63
+ return new Promise((resolve) => {
64
+ const child = spawn(process.execPath, [bin, ...args], { cwd, stdio: 'inherit' });
65
+ // Ctrl-C reaches the whole process group, but a supervisor, CI or an IDE's task runner signals
66
+ // `ng` alone, and Metro would outlive it on its port. So a stop is passed on.
67
+ const stop = (signal) => child.kill(signal);
68
+ for (const signal of SIGNALS) process.on(signal, stop);
69
+ const settle = (result) => {
70
+ for (const signal of SIGNALS) process.off(signal, stop);
71
+ resolve(result);
72
+ };
73
+ child.on('error', (error) => settle({ success: false, error: error.message }));
74
+ child.on('exit', (code, signal) =>
75
+ settle(
76
+ code === 0
77
+ ? { success: true }
78
+ : { success: false, error: `${name} exited with ${signal ?? `code ${code}`}` },
79
+ ),
80
+ );
81
+ });
82
+ }
83
+
84
+ module.exports = { resolveBin, projectRoot, spawnBin };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@ng-native/schematics:vitest`: the project's tests, once.
3
+ *
4
+ * Not `@angular/build:unit-test`, which compiles the tests with the workspace's web build and runs
5
+ * them against a DOM. An Angular Native test renders onto the fake Fabric from
6
+ * `@ng-native/testing`, compiled by the same transform Metro uses, and that is set up in the
7
+ * project's own `vitest.config.mts`.
8
+ */
9
+ const { createBuilder } = require('@angular-devkit/architect');
10
+ const { spawnBin } = require('./spawn-bin.cjs');
11
+
12
+ /** @param {{ watch?: boolean, filter?: string }} options */
13
+ function vitestArgs(options) {
14
+ const args = [options.watch ? 'watch' : 'run'];
15
+ if (options.filter) args.push(options.filter);
16
+ return args;
17
+ }
18
+
19
+ module.exports = createBuilder((options, context) =>
20
+ spawnBin('vitest', vitestArgs(options), context),
21
+ );
22
+ module.exports.vitestArgs = vitestArgs;
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema",
3
+ "$id": "NgNativeVitestBuilder",
4
+ "title": "Vitest",
5
+ "type": "object",
6
+ "properties": {
7
+ "watch": {
8
+ "type": "boolean",
9
+ "description": "Keep running and re-run on change.",
10
+ "default": false
11
+ },
12
+ "filter": {
13
+ "type": "string",
14
+ "description": "Only test files whose path contains this."
15
+ }
16
+ }
17
+ }
package/builders.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "../../node_modules/@angular-devkit/architect/src/builders-schema.json",
3
+ "builders": {
4
+ "expo": {
5
+ "implementation": "./builders/expo.cjs",
6
+ "schema": "./builders/expo.json",
7
+ "description": "Runs an Expo CLI command in the project: start, export, run:ios, run:android."
8
+ },
9
+ "vitest": {
10
+ "implementation": "./builders/vitest.cjs",
11
+ "schema": "./builders/vitest.json",
12
+ "description": "Runs the project's tests once with Vitest, through @ng-native/testing."
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "../../node_modules/@angular-devkit/schematics/collection-schema.json",
3
+ "schematics": {
4
+ "ng-add": {
5
+ "description": "Adds an Angular Native app to this workspace, with its dependencies installed.",
6
+ "factory": "./ng-add/index.cjs#ngAdd",
7
+ "schema": "./ng-add/schema.json"
8
+ },
9
+ "application": {
10
+ "aliases": ["app"],
11
+ "description": "An Angular Native app: an Expo project in the workspace, registered in angular.json.",
12
+ "factory": "./application/index.cjs#application",
13
+ "schema": "./application/schema.json"
14
+ },
15
+ "component": {
16
+ "aliases": ["c"],
17
+ "description": "A component for an Angular Native app, with native elements and a test that runs in Node.",
18
+ "factory": "./component/index.cjs#component",
19
+ "schema": "./component/schema.json"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `ng generate component` in an Angular Native project.
3
+ *
4
+ * Angular's own component schematic runs here too, and what it writes does not work on a device:
5
+ * a `<p>` is not a native element, and its spec needs `TestBed` on a DOM that does not exist. This
6
+ * writes the native equivalent, a component built from `<view>` and `<text>` with a test that
7
+ * renders it on the fake Fabric, following the template's conventions: kebab-case files, no
8
+ * `Component` suffix, an inline template, and a `.test.ts` beside it.
9
+ */
10
+ const path = require('node:path').posix;
11
+ const { SchematicsException } = require('@angular-devkit/schematics');
12
+ const { strings } = require('@angular-devkit/core');
13
+
14
+ function componentSource(className, selector, label) {
15
+ return `import { Component } from '@angular/core';
16
+ import { Text, View } from '@ng-native/components';
17
+
18
+ @Component({
19
+ selector: '${selector}',
20
+ imports: [Text, View],
21
+ template: \`
22
+ <view>
23
+ <text>${label}</text>
24
+ </view>
25
+ \`,
26
+ })
27
+ export class ${className} {}
28
+ `;
29
+ }
30
+
31
+ function testSource(className, file, label) {
32
+ return `import { render, screen } from '@ng-native/testing';
33
+ import { expect, test } from 'vitest';
34
+ import { ${className} } from './${file}.ts';
35
+
36
+ test('renders', async () => {
37
+ await render(${className});
38
+
39
+ expect(screen.getByText('${label}')).toBeTruthy();
40
+ });
41
+ `;
42
+ }
43
+
44
+ /**
45
+ * The directory the component goes in: the one `ng generate` was run from if that is inside the
46
+ * project, the project's root otherwise.
47
+ */
48
+ function directoryFor(workspace, options) {
49
+ const project = workspace.projects?.[options.project];
50
+ if (!project) {
51
+ throw new SchematicsException(`angular.json has no project called "${options.project}".`);
52
+ }
53
+ const root = project.sourceRoot ?? project.root;
54
+ const cwd = path.normalize(options.path ?? '');
55
+ const inside = cwd === root || cwd.startsWith(root + '/');
56
+ return { directory: inside ? cwd : root, prefix: project.prefix };
57
+ }
58
+
59
+ /**
60
+ * @param {{ name: string, project: string, path?: string, flat?: boolean, skipTests?: boolean }} options
61
+ */
62
+ function component(options) {
63
+ return (tree) => {
64
+ const workspace = JSON.parse(tree.readText('angular.json'));
65
+ const { directory, prefix } = directoryFor(workspace, options);
66
+ const file = strings.dasherize(path.basename(options.name));
67
+ const folder = path.join(directory, path.dirname(options.name), options.flat ? '' : file);
68
+ const className = strings.classify(file);
69
+ const selector = prefix ? `${prefix}-${file}` : file;
70
+ const label = `${file} works`;
71
+
72
+ tree.create(path.join(folder, `${file}.ts`), componentSource(className, selector, label));
73
+ if (!options.skipTests) {
74
+ tree.create(path.join(folder, `${file}.test.ts`), testSource(className, file, label));
75
+ }
76
+ };
77
+ }
78
+
79
+ module.exports = { component };
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema",
3
+ "$id": "NgNativeComponent",
4
+ "title": "Angular Native component",
5
+ "type": "object",
6
+ "properties": {
7
+ "name": {
8
+ "type": "string",
9
+ "description": "The component's name, optionally with a path: settings/profile-card.",
10
+ "$default": { "$source": "argv", "index": 0 },
11
+ "x-prompt": "What should the component be called?"
12
+ },
13
+ "project": {
14
+ "type": "string",
15
+ "description": "The Angular Native project it belongs to.",
16
+ "$default": { "$source": "projectName" }
17
+ },
18
+ "path": {
19
+ "type": "string",
20
+ "format": "path",
21
+ "description": "The directory to create it in, relative to the workspace root.",
22
+ "$default": { "$source": "workingDirectory" },
23
+ "visible": false
24
+ },
25
+ "flat": {
26
+ "type": "boolean",
27
+ "description": "Create the files in the directory itself rather than in a folder of their own.",
28
+ "default": false
29
+ },
30
+ "skipTests": {
31
+ "type": "boolean",
32
+ "description": "Leave out the .test.ts file.",
33
+ "default": false
34
+ }
35
+ },
36
+ "required": ["name", "project"]
37
+ }
@@ -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,7 @@
1
+ const { getDefaultConfig } = require('expo/metro-config');
2
+ const { withAngularNative } = require('@ng-native/metro/config.cjs');
3
+
4
+ // Registers the transformer that compiles Angular ahead of time, compiles each component's CSS
5
+ // into the sheet the engine reads, and adds the polyfills Angular needs before `@angular/core`
6
+ // is first evaluated. No options: the framework packages are ordinary dependencies here.
7
+ module.exports = withAngularNative(getDefaultConfig(__dirname));
@@ -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,12 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "allowImportingTsExtensions": true
6
+ },
7
+ "angularCompilerOptions": {
8
+ "strictTemplates": true,
9
+ "typeCheckHostBindings": false,
10
+ "strictDomEventTypes": false
11
+ }
12
+ }
@@ -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/native-app.cjs ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * What an Angular Native app is made of, whichever workspace it is generated into.
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. A generated app and a templated app are the same app.
8
+ *
9
+ * The `@ng-native/*` packages are pinned to this package's own version, because every one of them
10
+ * is released in lockstep with it.
11
+ */
12
+ const { readFileSync } = require('node:fs');
13
+ const path = require('node:path');
14
+ const semver = require('semver');
15
+
16
+ const { version } = require('./package.json');
17
+
18
+ const FRAMEWORK = [
19
+ '@ng-native/components',
20
+ '@ng-native/device',
21
+ '@ng-native/fabric',
22
+ '@ng-native/metro',
23
+ '@ng-native/platform',
24
+ ];
25
+
26
+ /** @type {Record<string, string>} */
27
+ const dependencies = {
28
+ ...Object.fromEntries(FRAMEWORK.map((name) => [name, version])),
29
+ '@angular/common': '^22.0.0',
30
+ '@angular/core': '^22.0.0',
31
+ expo: '~57.0.20',
32
+ 'expo-status-bar': '~57.0.1',
33
+ react: '19.2.3',
34
+ 'react-native': '0.86.3',
35
+ 'react-native-safe-area-context': '~5.7.0',
36
+ };
37
+
38
+ /** @type {Record<string, string>} */
39
+ const devDependencies = {
40
+ // An Angular workspace already has Babel 8 through @angular/compiler-cli, and React Native's
41
+ // Babel peer would otherwise take the newest copy there is.
42
+ // The typecheck script compiles the templates too, which only the Angular compiler can.
43
+ '@angular/compiler-cli': '^22.0.0',
44
+ '@babel/core': '^7.29.7',
45
+ '@ng-native/testing': version,
46
+ '@types/react': '~19.2.2',
47
+ typescript: '~6.0.3',
48
+ vitest: '^5.0.0',
49
+ };
50
+
51
+ /**
52
+ * The template's files, copied verbatim into the new app. Its `metro.config.js` and `tsconfig.json`
53
+ * need nothing changed here: the dependencies are hoisted to the workspace root, where Node's and
54
+ * Metro's resolution both find them by walking up.
55
+ */
56
+ const SOURCE_FILES = [
57
+ 'src/app/app.ts',
58
+ 'src/main.ts',
59
+ 'src/app/app.test.ts',
60
+ 'vitest.config.mts',
61
+ 'metro.config.js',
62
+ 'tsconfig.json',
63
+ ];
64
+
65
+ /** @param {string} name */
66
+ function sourceFile(name) {
67
+ return readFileSync(path.join(__dirname, 'files', name), 'utf8');
68
+ }
69
+
70
+ /**
71
+ * The template's `AGENTS.md`, with its Commands section replaced by this workspace's own. The rest
72
+ * describes the framework, which is the same wherever the app lives.
73
+ *
74
+ * @param {string} commands the markdown that goes under the Commands heading
75
+ */
76
+ function agentsFile(commands) {
77
+ return sourceFile('AGENTS.md').replace(
78
+ /## Commands\n[\s\S]*?(?=\n## )/,
79
+ `## Commands\n\n${commands}\n`,
80
+ );
81
+ }
82
+
83
+ /**
84
+ * The template's `app.json`, named for this app.
85
+ *
86
+ * Without the template's icons: they are the template's branding, not this app's, and Expo draws
87
+ * its own default until `icon` is set.
88
+ *
89
+ * @param {string} name the project name, which is also the Expo slug
90
+ */
91
+ function appJson(name) {
92
+ const expo = {
93
+ name,
94
+ slug: name,
95
+ version: '1.0.0',
96
+ orientation: 'portrait',
97
+ userInterfaceStyle: 'dark',
98
+ ios: { supportsTablet: true },
99
+ android: { predictiveBackGestureEnabled: false },
100
+ scheme: name.replace(/[^a-z0-9]/gi, '').toLowerCase(),
101
+ };
102
+ return JSON.stringify({ expo }, null, 2) + '\n';
103
+ }
104
+
105
+ /**
106
+ * The project's own `package.json`, which only Expo reads: `main` is how it finds the entry file.
107
+ *
108
+ * It lists every dependency the app has too, at the ranges the workspace root installs, though
109
+ * nothing installs from it. Expo's autolinking links the native modules named here and no others,
110
+ * so one left out, such as `react-native-safe-area-context`, is missing from the native build. And
111
+ * `expo prebuild`, which `expo run:ios` starts with, adds `expo`, `react` or `react-native` if it
112
+ * does not find them and then offers to install them into the project's directory, which would put
113
+ * a second copy of React Native beside the root's.
114
+ *
115
+ * @param {string} name
116
+ * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} root
117
+ * the workspace's root `package.json`, once this app's dependencies are in it
118
+ */
119
+ function projectManifest(name, root) {
120
+ const installed = { ...root.devDependencies, ...root.dependencies };
121
+ const pinned = Object.keys(dependencies).map((pkg) => [pkg, installed[pkg] ?? dependencies[pkg]]);
122
+ return { name, private: true, main: 'src/main.ts', dependencies: Object.fromEntries(pinned) };
123
+ }
124
+
125
+ /**
126
+ * Where what a workspace already has can be older than what a new app is given: `@ng-native/testing`
127
+ * installs Vitest 5 but its plugin runs on 4, which `ng new` 22.1 and the Nx preset still start on.
128
+ * The range here is that package's `vitest` peer, and a test holds the two together.
129
+ */
130
+ const accepted = { vitest: '^4.0.8 || ^5.0.0' };
131
+
132
+ /**
133
+ * Why an existing dependency would stop this app installing or running, as a sentence each.
134
+ *
135
+ * Only ranges already in the manifest are judged, and only a range that cannot overlap the one
136
+ * wanted is a conflict: `~6.0.2` and `~6.0.3` resolve to the same TypeScript. A missing package is
137
+ * added at the version above. Nothing is rewritten, because moving a workspace's Angular or Vitest is an upgrade of every
138
+ * project in it, and that belongs to `ng update` or `nx migrate`, not to a generator.
139
+ *
140
+ * @param {{ dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} manifest
141
+ * @returns {string[]}
142
+ */
143
+ function conflicts(manifest) {
144
+ const existing = { ...manifest.dependencies, ...manifest.devDependencies };
145
+ const problems = [];
146
+ for (const [name, installed] of Object.entries({ ...dependencies, ...devDependencies })) {
147
+ const range = existing[name];
148
+ const wanted = accepted[name] ?? installed;
149
+ if (!range || name.startsWith('@ng-native/')) continue;
150
+ if (semver.validRange(range) && !semver.intersects(range, wanted)) {
151
+ problems.push(
152
+ `${name} is ${range} here, and Angular Native needs ${wanted}. ` +
153
+ `npm will refuse the install until it is moved.`,
154
+ );
155
+ }
156
+ }
157
+ return problems;
158
+ }
159
+
160
+ module.exports = {
161
+ dependencies,
162
+ devDependencies,
163
+ SOURCE_FILES,
164
+ sourceFile,
165
+ agentsFile,
166
+ appJson,
167
+ projectManifest,
168
+ conflicts,
169
+ accepted,
170
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `ng add @ng-native/schematics`: the application schematic, with the name defaulted.
3
+ *
4
+ * `ng add` has already installed this package by the time this runs. What it adds is a second
5
+ * project, the native app, and leaves the workspace's existing projects as they were: an Angular
6
+ * CLI workspace cannot become a native app, because `@angular/build` has no native target, so the
7
+ * native app lives beside the web one and Metro builds it.
8
+ */
9
+ const { application } = require('../application/index.cjs');
10
+
11
+ /** @param {{ name?: string, directory?: string, skipInstall?: boolean }} options */
12
+ function ngAdd(options) {
13
+ return application({ ...options, name: options.name ?? 'native', prefix: 'app' });
14
+ }
15
+
16
+ module.exports = { ngAdd };
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema",
3
+ "$id": "NgNativeNgAdd",
4
+ "title": "Add Angular Native",
5
+ "type": "object",
6
+ "properties": {
7
+ "name": {
8
+ "type": "string",
9
+ "description": "The native app's project name, in angular.json and as the Expo slug.",
10
+ "pattern": "^[a-z][a-z0-9-]*$",
11
+ "default": "native"
12
+ },
13
+ "directory": {
14
+ "type": "string",
15
+ "description": "Where the project goes. Defaults to newProjectRoot/name, usually projects/native."
16
+ },
17
+ "skipInstall": {
18
+ "type": "boolean",
19
+ "description": "Write the files and package.json without installing.",
20
+ "default": false
21
+ }
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,12 +1,65 @@
1
1
  {
2
2
  "name": "@ng-native/schematics",
3
- "version": "0.0.1",
4
- "description": "Reserved for ng-native. The first release follows.",
3
+ "version": "0.1.0",
4
+ "description": "ng add and ng generate for Angular Native: an Expo app in an Angular CLI workspace, with builders that run it.",
5
+ "keywords": [
6
+ "angular",
7
+ "react-native",
8
+ "angular-native",
9
+ "ios",
10
+ "android",
11
+ "fabric",
12
+ "native",
13
+ "schematics",
14
+ "angular-cli",
15
+ "ng-add",
16
+ "expo"
17
+ ],
18
+ "type": "module",
19
+ "schematics": "./collection.json",
20
+ "builders": "./builders.json",
21
+ "ng-add": {
22
+ "save": "devDependencies"
23
+ },
24
+ "dependencies": {
25
+ "@angular-devkit/architect": ">=0.2200.0 <0.2300.0",
26
+ "@angular-devkit/core": "^22.0.0",
27
+ "@angular-devkit/schematics": "^22.0.0",
28
+ "semver": "^7.7.0"
29
+ },
30
+ "devDependencies": {
31
+ "@schematics/angular": "22.1.9",
32
+ "@types/semver": "^7.7.0"
33
+ },
34
+ "nx": {
35
+ "tags": [
36
+ "scope:schematics",
37
+ "layer:build"
38
+ ]
39
+ },
5
40
  "license": "MIT",
41
+ "homepage": "https://ng-native.com/packages/schematics",
42
+ "bugs": {
43
+ "url": "https://github.com/ng-native/ng-native/issues"
44
+ },
6
45
  "repository": {
7
46
  "type": "git",
8
47
  "url": "git+https://github.com/ng-native/ng-native.git",
9
48
  "directory": "packages/schematics"
10
49
  },
11
- "homepage": "https://ng-native.com/packages/schematics"
50
+ "files": [
51
+ "*.cjs",
52
+ "collection.json",
53
+ "builders.json",
54
+ "*/*.cjs",
55
+ "*/*.json",
56
+ "files"
57
+ ],
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "scripts": {
62
+ "typecheck": "tsc -p tsconfig.json --noEmit",
63
+ "test": "node --test \"*.test.ts\" \"{application,component,builders}/*.test.ts\""
64
+ }
12
65
  }