@ng-native/nx 0.2.0 → 0.4.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/README.md +8 -3
- package/application/index.cjs +64 -14
- package/component/index.cjs +66 -0
- package/component/schema.json +38 -0
- package/component/sources.cjs +47 -0
- package/files/AGENTS.md +11 -3
- package/files/src/app/app.ts +22 -4
- package/files/src/main.ts +4 -0
- package/generators.json +22 -0
- package/init/index.cjs +91 -1
- package/library/index.cjs +336 -0
- package/library/schema.json +36 -0
- package/migrations/run.cjs +5 -0
- package/migrations.json +24 -0
- package/native-app.cjs +47 -171
- package/package.json +30 -4
- package/sync/index.cjs +233 -0
- package/sync/schema.json +8 -0
- package/tailwind/index.cjs +573 -0
- package/tailwind/schema.json +37 -0
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @ng-native/nx
|
|
2
2
|
|
|
3
|
-
Nx generators for Angular Native: `nx add @ng-native/nx`,
|
|
4
|
-
|
|
3
|
+
Nx generators for Angular Native: `nx add @ng-native/nx`, an app generator that adds an Expo app
|
|
4
|
+
project to the workspace, able to import its Angular libraries, and library and component
|
|
5
|
+
generators whose tests render on the fake Fabric.
|
|
5
6
|
|
|
6
7
|
Alpha: APIs may change before 1.0.
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ Alpha: APIs may change before 1.0.
|
|
|
10
11
|
```sh
|
|
11
12
|
nx add @ng-native/nx
|
|
12
13
|
nx g @ng-native/nx:app apps/mobile
|
|
14
|
+
nx g @ng-native/nx:library packages/ui
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
`nx add` adds `@nx/expo` at the workspace's Nx version and registers its plugin, which infers the
|
|
@@ -30,7 +32,10 @@ In a workspace whose root package is scoped, the project is `@org/mobile`.
|
|
|
30
32
|
|
|
31
33
|
## What's in the package
|
|
32
34
|
|
|
33
|
-
- `generators.json` - `init`, which `nx add` runs,
|
|
35
|
+
- `generators.json` - `init`, which `nx add` runs, `application` (alias `app`), `library` (alias
|
|
36
|
+
`lib`), `component` (alias `c`), `tailwind`, which sets up Tailwind in an app and the libraries
|
|
37
|
+
it uses, and `sync-native-modules`, the sync generator an app runs before `start`, `export` and
|
|
38
|
+
`prebuild`.
|
|
34
39
|
- `files/` - the template's source files, which a test keeps identical to `template/`.
|
|
35
40
|
|
|
36
41
|
## Docs
|
package/application/index.cjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const path = require('node:path').posix;
|
|
19
19
|
const {
|
|
20
20
|
formatFiles,
|
|
21
|
+
getProjects,
|
|
21
22
|
joinPathFragments,
|
|
22
23
|
logger,
|
|
23
24
|
offsetFromRoot,
|
|
@@ -31,6 +32,7 @@ const native = require('../native-app.cjs');
|
|
|
31
32
|
const { init, workspaceNxVersion } = require('../init/index.cjs');
|
|
32
33
|
const { usesWorkspaces, includeInWorkspaces } = require('./workspaces.cjs');
|
|
33
34
|
const { asSaved } = require('../save-exact.cjs');
|
|
35
|
+
const { registerSyncGenerator } = require('../sync/index.cjs');
|
|
34
36
|
|
|
35
37
|
function names(tree, options) {
|
|
36
38
|
const directory = path.normalize(options.directory).replace(/\/$/, '');
|
|
@@ -57,6 +59,15 @@ function hasPathAliases(tree, workspaces) {
|
|
|
57
59
|
* The compiler options `file` ends up with, those it inherits through `extends` included, as
|
|
58
60
|
* TypeScript merges them: each option from the last config that sets it.
|
|
59
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* Whether the app reaches its libraries through the package manager's links alone, where a
|
|
64
|
+
* library can have a copy of its own of a package the app has too. With path aliases the
|
|
65
|
+
* workspace has one `node_modules`, and a `paths` list of the app's would replace the aliases.
|
|
66
|
+
*/
|
|
67
|
+
function linksLibraries(tree, workspaces) {
|
|
68
|
+
return workspaces && !hasPathAliases(tree, workspaces);
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
function baseCompilerOptions(tree, file = 'tsconfig.base.json', ancestors = []) {
|
|
61
72
|
if (ancestors.includes(file) || !tree.exists(file)) return {};
|
|
62
73
|
const config = readJson(tree, file);
|
|
@@ -85,17 +96,56 @@ function extendedFile(tree, directory, parent) {
|
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
/** Expo's own, which `expo start` listens on unless given `--port`. */
|
|
100
|
+
const DEFAULT_PORT = 8081;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The lowest Metro port no other Expo app in the workspace listens on, so that
|
|
104
|
+
* `nx run-many -t start` can run them together: two on Expo's default raced for it, and the second
|
|
105
|
+
* died with `EADDRINUSE`. Each `expo start` a target runs, as `command` or in `commands`, takes the
|
|
106
|
+
* port its `--port` names, or Expo's default without one. An app with an `app.json` and no such
|
|
107
|
+
* command has the default too: its `start` is inferred.
|
|
108
|
+
*/
|
|
109
|
+
function metroPort(tree) {
|
|
110
|
+
const taken = new Set();
|
|
111
|
+
for (const [, project] of getProjects(tree)) {
|
|
112
|
+
const starts = Object.values(project.targets ?? {})
|
|
113
|
+
.flatMap(({ options }) => [options?.command, ...(options?.commands ?? [])])
|
|
114
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.command))
|
|
115
|
+
.filter((command) => typeof command === 'string' && /\bexpo start\b/.test(command));
|
|
116
|
+
for (const command of starts) {
|
|
117
|
+
taken.add(Number(command.match(/--port[= ](\d+)/)?.[1] ?? DEFAULT_PORT));
|
|
118
|
+
}
|
|
119
|
+
if (!starts.length && tree.exists(joinPathFragments(project.root, 'app.json'))) {
|
|
120
|
+
taken.add(DEFAULT_PORT);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let port = DEFAULT_PORT;
|
|
124
|
+
while (taken.has(port)) port++;
|
|
125
|
+
return port;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function targets(directory, port, oneCopy) {
|
|
89
129
|
const run = (command) => ({ executor: 'nx:run-commands', options: { cwd: directory, command } });
|
|
130
|
+
const own = port === DEFAULT_PORT ? '' : ` --port ${port}`;
|
|
131
|
+
// `expo run:ios` and `expo run:android` build the port into the app, so a later app's has to be
|
|
132
|
+
// its own too. The first app's are @nx/expo's inferred ones, on Expo's default.
|
|
133
|
+
const runs = own && {
|
|
134
|
+
'run-ios': { ...run(`expo run:ios${own}`), continuous: true },
|
|
135
|
+
'run-android': { ...run(`expo run:android${own}`), continuous: true },
|
|
136
|
+
};
|
|
90
137
|
return {
|
|
91
138
|
typecheck: {
|
|
92
|
-
...run(
|
|
139
|
+
...run(oneCopy ? native.TYPECHECK_ONE_COPY : native.TYPECHECK),
|
|
93
140
|
cache: true,
|
|
94
141
|
inputs: ['default', '^production'],
|
|
142
|
+
// The Tailwind sheet the command builds, which a cache hit restores rather than rebuilds.
|
|
143
|
+
outputs: ['{projectRoot}/.angular-native'],
|
|
95
144
|
},
|
|
96
145
|
test: { ...run('vitest run'), cache: true, inputs: ['default', '^production'] },
|
|
97
|
-
start: { ...run(
|
|
98
|
-
serve: { ...run(
|
|
146
|
+
start: { ...run(`expo start${own}`), continuous: true },
|
|
147
|
+
serve: { ...run(`expo start${own}`), continuous: true },
|
|
148
|
+
...runs,
|
|
99
149
|
};
|
|
100
150
|
}
|
|
101
151
|
|
|
@@ -143,13 +193,15 @@ async function writeFiles(tree, { directory, projectName, workspaces, bundleIden
|
|
|
143
193
|
file('AGENTS.md', native.agentsFile(commands(projectName)));
|
|
144
194
|
file('CLAUDE.md', '@AGENTS.md\n');
|
|
145
195
|
file('metro.config.js', native.METRO_CONFIG);
|
|
146
|
-
|
|
147
|
-
file('.gitignore', '# generated native folders\n/ios\n/android\n');
|
|
196
|
+
file('.gitignore', native.GITIGNORE);
|
|
148
197
|
|
|
149
198
|
const base = hasPathAliases(tree, workspaces);
|
|
150
199
|
const workspaceBase = base ? `${offsetFromRoot(directory)}tsconfig.base.json` : undefined;
|
|
151
200
|
const conditions = baseCompilerOptions(tree).customConditions ?? [];
|
|
152
201
|
file('tsconfig.json', JSON.stringify(native.tsconfig(workspaceBase, conditions), null, 2) + '\n');
|
|
202
|
+
if (linksLibraries(tree, workspaces)) {
|
|
203
|
+
file('tsconfig.typecheck.json', JSON.stringify(native.typecheckTsconfig(), null, 2) + '\n');
|
|
204
|
+
}
|
|
153
205
|
file('vitest.config.mts', native.vitestConfig(Boolean(base)));
|
|
154
206
|
|
|
155
207
|
const manifest = { name: projectName, version: '0.0.1', private: true, main: 'src/main.ts' };
|
|
@@ -181,16 +233,13 @@ function ignoreExpo(tree) {
|
|
|
181
233
|
async function application(tree, options) {
|
|
182
234
|
const resolved = names(tree, options);
|
|
183
235
|
const { directory, projectName, workspaces } = resolved;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
`${resolved.bundleIdentifier} is not a bundle identifier both iOS and Android accept: ` +
|
|
187
|
-
'two or more dot-separated segments, each a letter and then letters or digits, none a Java keyword.',
|
|
188
|
-
);
|
|
189
|
-
}
|
|
236
|
+
const problem = native.bundleIdentifierProblem(resolved.bundleIdentifier);
|
|
237
|
+
if (problem) throw new Error(problem);
|
|
190
238
|
if (tree.exists(joinPathFragments(directory, 'package.json'))) {
|
|
191
239
|
throw new Error(`${directory} already has a package.json. Choose another directory.`);
|
|
192
240
|
}
|
|
193
241
|
const initTask = await init(tree, { skipInstall: true, skipFormat: true });
|
|
242
|
+
const port = metroPort(tree);
|
|
194
243
|
|
|
195
244
|
if (workspaces) {
|
|
196
245
|
includeInWorkspaces(tree, directory);
|
|
@@ -221,10 +270,11 @@ async function application(tree, options) {
|
|
|
221
270
|
.split(',')
|
|
222
271
|
.map((tag) => tag.trim())
|
|
223
272
|
.filter(Boolean),
|
|
224
|
-
targets: targets(directory),
|
|
273
|
+
targets: targets(directory, port, linksLibraries(tree, workspaces)),
|
|
225
274
|
});
|
|
226
275
|
|
|
227
276
|
ignoreExpo(tree);
|
|
277
|
+
registerSyncGenerator(tree, projectName);
|
|
228
278
|
|
|
229
279
|
if (!options.skipFormat) await formatFiles(tree);
|
|
230
280
|
logger.info(
|
|
@@ -235,4 +285,4 @@ async function application(tree, options) {
|
|
|
235
285
|
return runTasksInSerial(initTask, () => installPackagesTask(tree, true));
|
|
236
286
|
}
|
|
237
287
|
|
|
238
|
-
module.exports = { application };
|
|
288
|
+
module.exports = { application, hasPathAliases };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nx g @ng-native/nx:component profile-card --project ui`: a component for an Angular Native app
|
|
3
|
+
* or library.
|
|
4
|
+
*
|
|
5
|
+
* `@nx/angular:component` writes a `<p>` and a `TestBed` spec, neither of which works on a device
|
|
6
|
+
* or on the fake Fabric. This writes what `@ng-native/schematics`' component schematic writes for
|
|
7
|
+
* the Angular CLI: a component built from `<view>` and `<text>`, with an inline template, and a
|
|
8
|
+
* `.test.ts` beside it that renders it. It goes under `src/lib` in a library and `src/app` in an
|
|
9
|
+
* app, in a folder of its own unless `--flat`.
|
|
10
|
+
*/
|
|
11
|
+
const path = require('node:path').posix;
|
|
12
|
+
const { formatFiles, getProjects, joinPathFragments, names } = require('@nx/devkit');
|
|
13
|
+
const { componentSource, testSource } = require('./sources.cjs');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the project is an app: by its `projectType`, or, where it has none, as `@nx/js:library`
|
|
17
|
+
* leaves a package.json-only project in the TypeScript preset, by the `app.json` an Expo app has.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
20
|
+
* @param {import('@nx/devkit').ProjectConfiguration} project
|
|
21
|
+
*/
|
|
22
|
+
function isApp(tree, project) {
|
|
23
|
+
if (project.projectType) return project.projectType === 'application';
|
|
24
|
+
return tree.exists(joinPathFragments(project.root, 'app.json'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
29
|
+
* @param {{ name: string, project: string, flat?: boolean, skipTests?: boolean, skipFormat?: boolean }} options
|
|
30
|
+
*/
|
|
31
|
+
async function component(tree, options) {
|
|
32
|
+
const project = getProjects(tree).get(options.project);
|
|
33
|
+
if (!project) throw new Error(`The workspace has no project called "${options.project}".`);
|
|
34
|
+
const file = names(path.basename(options.name)).fileName;
|
|
35
|
+
const base = joinPathFragments(
|
|
36
|
+
project.root,
|
|
37
|
+
'src',
|
|
38
|
+
isApp(tree, project) ? 'app' : 'lib',
|
|
39
|
+
path.dirname(options.name),
|
|
40
|
+
);
|
|
41
|
+
const folder = options.flat ? base : joinPathFragments(base, file);
|
|
42
|
+
writeComponent(tree, folder, file, project.prefix, options.skipTests);
|
|
43
|
+
if (!options.skipFormat) await formatFiles(tree);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The component `file` in `folder`, and its test unless `skipTests`. The library generator writes
|
|
48
|
+
* its first component with this too.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
51
|
+
* @param {string} folder
|
|
52
|
+
* @param {string} file the file name, kebab-case, without `.ts`
|
|
53
|
+
* @param {string | undefined} prefix the project's selector prefix
|
|
54
|
+
* @param {boolean} [skipTests]
|
|
55
|
+
*/
|
|
56
|
+
function writeComponent(tree, folder, file, prefix, skipTests = false) {
|
|
57
|
+
const className = names(file).className;
|
|
58
|
+
const selector = prefix ? `${prefix}-${file}` : file;
|
|
59
|
+
const label = `${file} works`;
|
|
60
|
+
tree.write(joinPathFragments(folder, `${file}.ts`), componentSource(className, selector, label));
|
|
61
|
+
if (!skipTests) {
|
|
62
|
+
tree.write(joinPathFragments(folder, `${file}.test.ts`), testSource(className, file, label));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { component, writeComponent };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/schema",
|
|
3
|
+
"$id": "NgNativeNxComponent",
|
|
4
|
+
"title": "Angular Native component",
|
|
5
|
+
"description": "A component for an Angular Native app or library, with native elements and a test that runs in Node.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "The component's name, optionally with a path: settings/profile-card.",
|
|
11
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
12
|
+
"x-prompt": "What should the component be called?"
|
|
13
|
+
},
|
|
14
|
+
"project": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "The app or library it belongs to.",
|
|
17
|
+
"$default": { "$source": "projectName" },
|
|
18
|
+
"x-dropdown": "projects"
|
|
19
|
+
},
|
|
20
|
+
"flat": {
|
|
21
|
+
"type": "boolean",
|
|
22
|
+
"description": "Create the files in the directory itself rather than in a folder of their own.",
|
|
23
|
+
"default": false
|
|
24
|
+
},
|
|
25
|
+
"skipTests": {
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"description": "Leave out the .test.ts file.",
|
|
28
|
+
"default": false
|
|
29
|
+
},
|
|
30
|
+
"skipFormat": {
|
|
31
|
+
"type": "boolean",
|
|
32
|
+
"description": "Leave the generated files unformatted.",
|
|
33
|
+
"default": false,
|
|
34
|
+
"x-priority": "internal"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"required": ["name", "project"]
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files a generated component is made of: a component built from `<view>` and `<text>`, and a
|
|
3
|
+
* test that renders it on the fake Fabric. `@ng-native/schematics` and `@ng-native/nx` each ship
|
|
4
|
+
* this file, and a test in `@ng-native/nx` keeps the two identical.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} className
|
|
9
|
+
* @param {string} selector
|
|
10
|
+
* @param {string} label the text it shows, which its test finds
|
|
11
|
+
*/
|
|
12
|
+
function componentSource(className, selector, label) {
|
|
13
|
+
return `import { Component } from '@angular/core';
|
|
14
|
+
import { Text, View } from '@ng-native/components';
|
|
15
|
+
|
|
16
|
+
@Component({
|
|
17
|
+
selector: '${selector}',
|
|
18
|
+
imports: [Text, View],
|
|
19
|
+
template: \`
|
|
20
|
+
<view>
|
|
21
|
+
<text>${label}</text>
|
|
22
|
+
</view>
|
|
23
|
+
\`,
|
|
24
|
+
})
|
|
25
|
+
export class ${className} {}
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} className
|
|
31
|
+
* @param {string} file the component's file name, without `.ts`
|
|
32
|
+
* @param {string} label
|
|
33
|
+
*/
|
|
34
|
+
function testSource(className, file, label) {
|
|
35
|
+
return `import { render, screen } from '@ng-native/testing';
|
|
36
|
+
import { expect, test } from 'vitest';
|
|
37
|
+
import { ${className} } from './${file}.ts';
|
|
38
|
+
|
|
39
|
+
test('renders', async () => {
|
|
40
|
+
await render(${className});
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText('${label}')).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { componentSource, testSource };
|
package/files/AGENTS.md
CHANGED
|
@@ -31,9 +31,17 @@ change; both are fast.
|
|
|
31
31
|
imported from `@ng-native/components` into the component's `imports`, like any Angular
|
|
32
32
|
component: `imports: [View, Text]`. `<View>` compiles to an empty template, and an element used
|
|
33
33
|
without its import renders as a plain view.
|
|
34
|
-
- **There is no DOM.** No `document`, no `window`, no `<
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
- **There is no DOM.** No `document`, no `window`, no `<button>`, `<a>`, `<input>` or `<img>`, no
|
|
35
|
+
`@angular/platform-browser`, and no `@angular/animations`. Use the native elements above. HTML's
|
|
36
|
+
text elements (`p`, `span`, `h1`, `strong`) draw as text and its layout elements (`div`,
|
|
37
|
+
`section`, `ul`) as plain views, with none of a browser's default styles.
|
|
38
|
+
- **A component's host is a flex item.** `<app-case-list />` commits as a view of its own, with no
|
|
39
|
+
`flex`, so a `flex: 1` scroll view inside it gets a height of zero and shows nothing. A component
|
|
40
|
+
that fills the space it is given sets `host: { style: 'flex: 1' }` (or `:host { flex: 1; }`).
|
|
41
|
+
- **Text is drawn in a text element.** `<text>` is the one with inputs and outputs (`numberOfLines`,
|
|
42
|
+
`(press)`). `p`, `span` and the other HTML text elements are text while they hold only text and
|
|
43
|
+
other text elements; one that holds a view is a view. Text written straight into a view is given
|
|
44
|
+
a paragraph of its own, one per run, and never flows inline with the elements beside it.
|
|
37
45
|
- **Events are native:** `(press)` on `<pressable>`, not `(click)`; `[(value)]` or `(changeText)` on
|
|
38
46
|
`<text-input>`; `(scroll)`, `(layout)`. A pressable text is `<text pressable (press)="...">`.
|
|
39
47
|
- **Signals, zoneless, AOT.** State is signals and `computed()`; there is no zone.js, so nothing
|
package/files/src/app/app.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Component, signal } from '@angular/core';
|
|
1
|
+
import { Component, inject, signal } from '@angular/core';
|
|
2
2
|
import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-native/components';
|
|
3
|
+
import { StatusBar } from '@ng-native/device';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Element names are lowercase, and that is load-bearing rather than style: an uppercase name is
|
|
@@ -28,7 +29,7 @@ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-nativ
|
|
|
28
29
|
}
|
|
29
30
|
.screen {
|
|
30
31
|
flex: 1;
|
|
31
|
-
background-color: #
|
|
32
|
+
background-color: #f5f5f7;
|
|
32
33
|
}
|
|
33
34
|
.body {
|
|
34
35
|
flex: 1;
|
|
@@ -37,12 +38,12 @@ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-nativ
|
|
|
37
38
|
padding: 24px;
|
|
38
39
|
}
|
|
39
40
|
.title {
|
|
40
|
-
color: #
|
|
41
|
+
color: #101014;
|
|
41
42
|
font-size: 28px;
|
|
42
43
|
font-weight: 700;
|
|
43
44
|
}
|
|
44
45
|
.hint {
|
|
45
|
-
color: #
|
|
46
|
+
color: #5f5f6b;
|
|
46
47
|
font-size: 15px;
|
|
47
48
|
}
|
|
48
49
|
.button {
|
|
@@ -57,8 +58,25 @@ import { Pressable, SafeAreaProvider, SafeAreaView, Text, View } from '@ng-nativ
|
|
|
57
58
|
font-size: 16px;
|
|
58
59
|
font-weight: 600;
|
|
59
60
|
}
|
|
61
|
+
@media (prefers-color-scheme: dark) {
|
|
62
|
+
.screen {
|
|
63
|
+
background-color: #101014;
|
|
64
|
+
}
|
|
65
|
+
.title {
|
|
66
|
+
color: #ffffff;
|
|
67
|
+
}
|
|
68
|
+
.hint {
|
|
69
|
+
color: #8b8b96;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
60
72
|
`,
|
|
61
73
|
})
|
|
62
74
|
export class App {
|
|
63
75
|
protected readonly count = signal(0);
|
|
76
|
+
|
|
77
|
+
constructor() {
|
|
78
|
+
// Dark status bar icons in light mode and light ones in dark mode. Unclaimed, Android keeps
|
|
79
|
+
// light icons whatever the scheme.
|
|
80
|
+
inject(StatusBar).set({ style: 'auto' });
|
|
81
|
+
}
|
|
64
82
|
}
|
package/files/src/main.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Expo's runtime: its fetch, whose response streams a body, and URL, TextDecoderStream and
|
|
2
|
+
// structuredClone. Metro runs it before this file only when something imports it, and nothing
|
|
3
|
+
// else does in a release build, which would then get React Native's fetch, with no body.
|
|
4
|
+
import 'expo';
|
|
1
5
|
import { AppRegistry, Image, Platform, processColor } from 'react-native';
|
|
2
6
|
import { mount } from '@ng-native/platform';
|
|
3
7
|
import { currentConditions, deviceTokens, watchConditions } from '@ng-native/device';
|
package/generators.json
CHANGED
|
@@ -13,6 +13,28 @@
|
|
|
13
13
|
"factory": "./application/index.cjs#application",
|
|
14
14
|
"schema": "./application/schema.json",
|
|
15
15
|
"description": "An Angular Native app: an Expo project with start, run-ios, run-android, export, test and typecheck targets."
|
|
16
|
+
},
|
|
17
|
+
"sync-native-modules": {
|
|
18
|
+
"factory": "./sync/index.cjs#syncNativeModules",
|
|
19
|
+
"schema": "./sync/schema.json",
|
|
20
|
+
"description": "Lists the native modules an app's libraries import in the app's package.json, with their config plugins in app.json. A sync generator."
|
|
21
|
+
},
|
|
22
|
+
"library": {
|
|
23
|
+
"aliases": ["lib"],
|
|
24
|
+
"factory": "./library/index.cjs#library",
|
|
25
|
+
"schema": "./library/schema.json",
|
|
26
|
+
"description": "A library of native components, whose tests render on the fake Fabric as an app's do."
|
|
27
|
+
},
|
|
28
|
+
"tailwind": {
|
|
29
|
+
"factory": "./tailwind/index.cjs#tailwind",
|
|
30
|
+
"schema": "./tailwind/schema.json",
|
|
31
|
+
"description": "Sets up Tailwind in an Angular Native app, and the Tailwind-styled workspace libraries it uses."
|
|
32
|
+
},
|
|
33
|
+
"component": {
|
|
34
|
+
"aliases": ["c"],
|
|
35
|
+
"factory": "./component/index.cjs#component",
|
|
36
|
+
"schema": "./component/schema.json",
|
|
37
|
+
"description": "A component for an Angular Native app or library, with native elements and a test that runs in Node."
|
|
16
38
|
}
|
|
17
39
|
}
|
|
18
40
|
}
|
package/init/index.cjs
CHANGED
|
@@ -17,11 +17,18 @@
|
|
|
17
17
|
*
|
|
18
18
|
* It also adds Babel 7's `@babel/runtime`, the one Expo's Babel preset writes imports of. In an
|
|
19
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.
|
|
20
|
+
* `regenerator`, and every `nx start` warned that Metro fell back to file-based resolution. And
|
|
21
|
+
* Babel 7's `@babel/core`, which every plugin in that preset peers on, and which the same hoisted
|
|
22
|
+
* Babel 8 otherwise answers.
|
|
23
|
+
*
|
|
24
|
+
* And `@expo/metro`, which `withNxMetro` in the app's `metro.config.js` loads from the app's
|
|
25
|
+
* directory up. It is a dependency of `expo`, which pnpm keeps out of the root's `node_modules`, and
|
|
26
|
+
* loading the config threw "Unable to load Metro config" without it.
|
|
21
27
|
*/
|
|
22
28
|
const {
|
|
23
29
|
addDependenciesToPackageJson,
|
|
24
30
|
formatFiles,
|
|
31
|
+
logger,
|
|
25
32
|
readJson,
|
|
26
33
|
readNxJson,
|
|
27
34
|
updateNxJson,
|
|
@@ -88,12 +95,95 @@ function rootDependencies(tree) {
|
|
|
88
95
|
return { ...companions, expo, react, 'react-native': reactNative };
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The dependencies of `@nx/expo` with an install script, through `@nx/jest`. Both ship prebuilt
|
|
100
|
+
* binaries for every platform, and the script only builds from source when one is missing, so
|
|
101
|
+
* they are declined rather than allowed. pnpm 11 refuses to install until each one is decided,
|
|
102
|
+
* with `ERR_PNPM_IGNORED_BUILDS`.
|
|
103
|
+
*/
|
|
104
|
+
const DECLINED_BUILDS = ['@parcel/watcher', 'unrs-resolver'];
|
|
105
|
+
|
|
106
|
+
const PNPM = 'pnpm-workspace.yaml';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `allowBuilds`' entries with `name` declined, unless it is already `true` or `false`.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} entries
|
|
112
|
+
* @param {string} name
|
|
113
|
+
*/
|
|
114
|
+
function decline(entries, name) {
|
|
115
|
+
const key = name.startsWith('@') ? `'${name}'` : name;
|
|
116
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
117
|
+
const line = new RegExp(`^([ \\t]+)['"]?${escaped}['"]?:[ \\t]*(.*)$`, 'm');
|
|
118
|
+
const found = line.exec(entries);
|
|
119
|
+
if (!found) return `${entries && entries.replace(/\n?$/, '\n')} ${key}: false\n`;
|
|
120
|
+
if (/^(true|false)\b/.test(found[2])) return entries;
|
|
121
|
+
return entries.replace(line, `$1${key}: false`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `allowBuilds` as a block mapping, the form pnpm writes: the key alone on its line, with a comment
|
|
126
|
+
* after it at most, then its entries, indented, with blank and comment lines among them. The
|
|
127
|
+
* entries end at their last indented line, before any blank lines that lead to the next key. Null
|
|
128
|
+
* when there is no such block.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} yaml
|
|
131
|
+
* @returns {{ head: string, entries: string, start: number, end: number } | null}
|
|
132
|
+
*/
|
|
133
|
+
function allowBuildsBlock(yaml) {
|
|
134
|
+
const key = /^allowBuilds:[ \t]*(?:#.*)?(?:\n|$)/m.exec(yaml);
|
|
135
|
+
if (!key) return null;
|
|
136
|
+
const head = key[0].endsWith('\n') ? key[0] : `${key[0]}\n`;
|
|
137
|
+
const from = key.index + key[0].length;
|
|
138
|
+
let end = from;
|
|
139
|
+
for (let at = from; at < yaml.length;) {
|
|
140
|
+
const next = yaml.indexOf('\n', at);
|
|
141
|
+
const lineEnd = next === -1 ? yaml.length : next + 1;
|
|
142
|
+
const line = yaml.slice(at, lineEnd);
|
|
143
|
+
if (/^[ \t]+\S/.test(line)) end = lineEnd;
|
|
144
|
+
else if (!/^[ \t]*\n?$/.test(line)) break;
|
|
145
|
+
at = lineEnd;
|
|
146
|
+
}
|
|
147
|
+
return { head, entries: yaml.slice(from, end), start: key.index, end };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decides the builds above in `pnpm-workspace.yaml`'s `allowBuilds`, which pnpm 10.26 and later
|
|
152
|
+
* read. A decision the workspace already made stays, and the placeholder pnpm writes after refusing
|
|
153
|
+
* an install is settled. A workspace without pnpm is left alone.
|
|
154
|
+
*
|
|
155
|
+
* Edited as text, so the file keeps its comments and layout. An `allowBuilds` in any form other than
|
|
156
|
+
* a block mapping, such as a flow mapping, is left as it is with a warning: a second key beside it
|
|
157
|
+
* would make the file invalid YAML, which pnpm refuses to read.
|
|
158
|
+
*/
|
|
159
|
+
function declineBuilds(tree) {
|
|
160
|
+
if (!tree.exists(PNPM) && !tree.exists('pnpm-lock.yaml')) return;
|
|
161
|
+
const yaml = tree.read(PNPM, 'utf-8') ?? '';
|
|
162
|
+
const block = allowBuildsBlock(yaml);
|
|
163
|
+
if (!block && /^allowBuilds:/m.test(yaml)) {
|
|
164
|
+
logger.warn(
|
|
165
|
+
`${PNPM} sets allowBuilds in a form this cannot add to. Add ` +
|
|
166
|
+
`${DECLINED_BUILDS.map((name) => `${name}: false`).join(' and ')} to it, ` +
|
|
167
|
+
'or pnpm 11 stops the install until they are decided.',
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const entries = DECLINED_BUILDS.reduce(decline, block?.entries ?? '');
|
|
172
|
+
const allowBuilds = `${block?.head ?? 'allowBuilds:\n'}${entries}`;
|
|
173
|
+
if (block) {
|
|
174
|
+
tree.write(PNPM, yaml.slice(0, block.start) + allowBuilds + yaml.slice(block.end));
|
|
175
|
+
} else {
|
|
176
|
+
tree.write(PNPM, yaml ? `${yaml.replace(/\n*$/, '\n')}\n${allowBuilds}` : allowBuilds);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
91
180
|
/**
|
|
92
181
|
* @param {import('@nx/devkit').Tree} tree
|
|
93
182
|
* @param {{ skipInstall?: boolean, skipFormat?: boolean }} options
|
|
94
183
|
*/
|
|
95
184
|
async function init(tree, options = {}) {
|
|
96
185
|
registerExpoPlugin(tree);
|
|
186
|
+
declineBuilds(tree);
|
|
97
187
|
const { dependencies, devDependencies } = readJson(tree, 'package.json');
|
|
98
188
|
const existing = Object.keys({ ...dependencies, ...devDependencies });
|
|
99
189
|
const companions = await asSaved(tree, rootDependencies(tree), existing);
|