@ng-native/nx 0.1.3 → 0.3.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 +52 -14
- package/component/index.cjs +66 -0
- package/component/schema.json +38 -0
- package/component/sources.cjs +47 -0
- package/files/AGENTS.md +3 -0
- 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 +239 -0
- package/library/schema.json +36 -0
- package/migrations/run.cjs +5 -0
- package/migrations.json +24 -0
- package/native-app.cjs +19 -171
- package/package.json +27 -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(/\/$/, '');
|
|
@@ -85,17 +87,56 @@ function extendedFile(tree, directory, parent) {
|
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
/** Expo's own, which `expo start` listens on unless given `--port`. */
|
|
91
|
+
const DEFAULT_PORT = 8081;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The lowest Metro port no other Expo app in the workspace listens on, so that
|
|
95
|
+
* `nx run-many -t start` can run them together: two on Expo's default raced for it, and the second
|
|
96
|
+
* died with `EADDRINUSE`. Each `expo start` a target runs, as `command` or in `commands`, takes the
|
|
97
|
+
* port its `--port` names, or Expo's default without one. An app with an `app.json` and no such
|
|
98
|
+
* command has the default too: its `start` is inferred.
|
|
99
|
+
*/
|
|
100
|
+
function metroPort(tree) {
|
|
101
|
+
const taken = new Set();
|
|
102
|
+
for (const [, project] of getProjects(tree)) {
|
|
103
|
+
const starts = Object.values(project.targets ?? {})
|
|
104
|
+
.flatMap(({ options }) => [options?.command, ...(options?.commands ?? [])])
|
|
105
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.command))
|
|
106
|
+
.filter((command) => typeof command === 'string' && /\bexpo start\b/.test(command));
|
|
107
|
+
for (const command of starts) {
|
|
108
|
+
taken.add(Number(command.match(/--port[= ](\d+)/)?.[1] ?? DEFAULT_PORT));
|
|
109
|
+
}
|
|
110
|
+
if (!starts.length && tree.exists(joinPathFragments(project.root, 'app.json'))) {
|
|
111
|
+
taken.add(DEFAULT_PORT);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
let port = DEFAULT_PORT;
|
|
115
|
+
while (taken.has(port)) port++;
|
|
116
|
+
return port;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function targets(directory, port) {
|
|
89
120
|
const run = (command) => ({ executor: 'nx:run-commands', options: { cwd: directory, command } });
|
|
121
|
+
const own = port === DEFAULT_PORT ? '' : ` --port ${port}`;
|
|
122
|
+
// `expo run:ios` and `expo run:android` build the port into the app, so a later app's has to be
|
|
123
|
+
// its own too. The first app's are @nx/expo's inferred ones, on Expo's default.
|
|
124
|
+
const runs = own && {
|
|
125
|
+
'run-ios': { ...run(`expo run:ios${own}`), continuous: true },
|
|
126
|
+
'run-android': { ...run(`expo run:android${own}`), continuous: true },
|
|
127
|
+
};
|
|
90
128
|
return {
|
|
91
129
|
typecheck: {
|
|
92
|
-
...run(
|
|
130
|
+
...run(native.TYPECHECK),
|
|
93
131
|
cache: true,
|
|
94
132
|
inputs: ['default', '^production'],
|
|
133
|
+
// The Tailwind sheet the command builds, which a cache hit restores rather than rebuilds.
|
|
134
|
+
outputs: ['{projectRoot}/.angular-native'],
|
|
95
135
|
},
|
|
96
136
|
test: { ...run('vitest run'), cache: true, inputs: ['default', '^production'] },
|
|
97
|
-
start: { ...run(
|
|
98
|
-
serve: { ...run(
|
|
137
|
+
start: { ...run(`expo start${own}`), continuous: true },
|
|
138
|
+
serve: { ...run(`expo start${own}`), continuous: true },
|
|
139
|
+
...runs,
|
|
99
140
|
};
|
|
100
141
|
}
|
|
101
142
|
|
|
@@ -143,8 +184,7 @@ async function writeFiles(tree, { directory, projectName, workspaces, bundleIden
|
|
|
143
184
|
file('AGENTS.md', native.agentsFile(commands(projectName)));
|
|
144
185
|
file('CLAUDE.md', '@AGENTS.md\n');
|
|
145
186
|
file('metro.config.js', native.METRO_CONFIG);
|
|
146
|
-
|
|
147
|
-
file('.gitignore', '# generated native folders\n/ios\n/android\n');
|
|
187
|
+
file('.gitignore', native.GITIGNORE);
|
|
148
188
|
|
|
149
189
|
const base = hasPathAliases(tree, workspaces);
|
|
150
190
|
const workspaceBase = base ? `${offsetFromRoot(directory)}tsconfig.base.json` : undefined;
|
|
@@ -181,16 +221,13 @@ function ignoreExpo(tree) {
|
|
|
181
221
|
async function application(tree, options) {
|
|
182
222
|
const resolved = names(tree, options);
|
|
183
223
|
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
|
-
}
|
|
224
|
+
const problem = native.bundleIdentifierProblem(resolved.bundleIdentifier);
|
|
225
|
+
if (problem) throw new Error(problem);
|
|
190
226
|
if (tree.exists(joinPathFragments(directory, 'package.json'))) {
|
|
191
227
|
throw new Error(`${directory} already has a package.json. Choose another directory.`);
|
|
192
228
|
}
|
|
193
229
|
const initTask = await init(tree, { skipInstall: true, skipFormat: true });
|
|
230
|
+
const port = metroPort(tree);
|
|
194
231
|
|
|
195
232
|
if (workspaces) {
|
|
196
233
|
includeInWorkspaces(tree, directory);
|
|
@@ -221,10 +258,11 @@ async function application(tree, options) {
|
|
|
221
258
|
.split(',')
|
|
222
259
|
.map((tag) => tag.trim())
|
|
223
260
|
.filter(Boolean),
|
|
224
|
-
targets: targets(directory),
|
|
261
|
+
targets: targets(directory, port),
|
|
225
262
|
});
|
|
226
263
|
|
|
227
264
|
ignoreExpo(tree);
|
|
265
|
+
registerSyncGenerator(tree, projectName);
|
|
228
266
|
|
|
229
267
|
if (!options.skipFormat) await formatFiles(tree);
|
|
230
268
|
logger.info(
|
|
@@ -235,4 +273,4 @@ async function application(tree, options) {
|
|
|
235
273
|
return runTasksInSerial(initTask, () => installPackagesTask(tree, true));
|
|
236
274
|
}
|
|
237
275
|
|
|
238
|
-
module.exports = { application };
|
|
276
|
+
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
|
@@ -33,6 +33,9 @@ change; both are fast.
|
|
|
33
33
|
without its import renders as a plain view.
|
|
34
34
|
- **There is no DOM.** No `document`, no `window`, no `<div>`, `<span>`, `<button>` or `<input>`,
|
|
35
35
|
no `@angular/platform-browser`, and no `@angular/animations`. Use the native elements above.
|
|
36
|
+
- **A component's host is a flex item.** `<app-case-list />` commits as a view of its own, with no
|
|
37
|
+
`flex`, so a `flex: 1` scroll view inside it gets a height of zero and shows nothing. A component
|
|
38
|
+
that fills the space it is given sets `host: { style: 'flex: 1' }` (or `:host { flex: 1; }`).
|
|
36
39
|
- **Text only renders inside `<text>`.** `<view>Hello</view>` compiles and shows nothing.
|
|
37
40
|
- **Events are native:** `(press)` on `<pressable>`, not `(click)`; `[(value)]` or `(changeText)` on
|
|
38
41
|
`<text-input>`; `(scroll)`, `(layout)`. A pressable text is `<text pressable (press)="...">`.
|
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);
|