@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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nx g @ng-native/nx:tailwind mobile --library ui`: Tailwind in an Angular Native app, and the
|
|
3
|
+
* Tailwind-styled workspace libraries it uses.
|
|
4
|
+
*
|
|
5
|
+
* Without it an app that uses a library's classes renders them unstyled, and nothing fails: the
|
|
6
|
+
* classes are only missing from the sheet. An app needs the entry stylesheet, `withTailwind` around
|
|
7
|
+
* its Metro config, the generated sheet passed to `mount` as `globalStyles`, and the packages. Each
|
|
8
|
+
* library needs its classes scanned, which Tailwind does not do outside the app's directory, and its
|
|
9
|
+
* theme shared:
|
|
10
|
+
*
|
|
11
|
+
* - Tailwind 4: the library's `theme.css` names its own sources with `@source`, relative to itself,
|
|
12
|
+
* and holds its `@theme`. The app's stylesheet imports it.
|
|
13
|
+
* - Tailwind 3: the library's `tailwind.preset.cjs` lists its sources in `content`, which Tailwind 3
|
|
14
|
+
* ignores in a preset, so the app's config spreads them into its own. The app requires the preset
|
|
15
|
+
* by a relative path, which `@nx/enforce-module-boundaries` reports unless the rule allows it, as
|
|
16
|
+
* it allows the ESLint configs Nx requires the same way.
|
|
17
|
+
*
|
|
18
|
+
* A project with a Vite config that runs `ngNativeWeb()` also builds for a browser, beside the
|
|
19
|
+
* device build or on its own, and the browser takes the web preset in place of the native one:
|
|
20
|
+
*
|
|
21
|
+
* - Tailwind 4: a stylesheet importing `@ng-native/tailwind/web.css` (`src/styles.web.css` beside a
|
|
22
|
+
* device build, whose `src/styles.css` imports `native.css`), and `@tailwindcss/vite`'s plugin
|
|
23
|
+
* after `ngNativeWeb()`.
|
|
24
|
+
* - Tailwind 3: a config with `web-preset.cjs` (`tailwind.web.config.js` beside a device build,
|
|
25
|
+
* taking the rest from its `tailwind.config.js`), and the PostCSS config Vite runs it from.
|
|
26
|
+
*
|
|
27
|
+
* `index.html` links the stylesheet.
|
|
28
|
+
*
|
|
29
|
+
* It only adds, so running it again changes nothing, and running it with another library adds that
|
|
30
|
+
* one. A file it cannot follow is left alone, with a warning saying what to add by hand.
|
|
31
|
+
*/
|
|
32
|
+
const path = require('node:path').posix;
|
|
33
|
+
const {
|
|
34
|
+
addDependenciesToPackageJson,
|
|
35
|
+
formatFiles,
|
|
36
|
+
getProjects,
|
|
37
|
+
installPackagesTask,
|
|
38
|
+
joinPathFragments,
|
|
39
|
+
logger,
|
|
40
|
+
readJson,
|
|
41
|
+
updateProjectConfiguration,
|
|
42
|
+
} = require('@nx/devkit');
|
|
43
|
+
const semver = require('semver');
|
|
44
|
+
const native = require('../native-app.cjs');
|
|
45
|
+
const { usesWorkspaces } = require('../application/workspaces.cjs');
|
|
46
|
+
const { asSaved } = require('../save-exact.cjs');
|
|
47
|
+
|
|
48
|
+
const SHEET = '../.angular-native/app.tailwind.js';
|
|
49
|
+
const V4_BASE = ["@import 'tailwindcss/theme.css';", "@import 'tailwindcss/utilities.css';"];
|
|
50
|
+
const V4_IMPORTS = [...V4_BASE, "@import '@ng-native/tailwind/native.css';"];
|
|
51
|
+
const V4_WEB_IMPORTS = [...V4_BASE, "@import '@ng-native/tailwind/web.css';"];
|
|
52
|
+
const VITE_CONFIGS = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'].map((ext) => `vite.config.${ext}`);
|
|
53
|
+
const POSTCSS_CONFIGS = [
|
|
54
|
+
...['js', 'cjs', 'mjs', 'ts', 'cts', 'mts'].map((ext) => `postcss.config.${ext}`),
|
|
55
|
+
...['', '.json', '.yaml', '.yml', '.js', '.cjs', '.mjs'].map((ext) => `.postcssrc${ext}`),
|
|
56
|
+
];
|
|
57
|
+
const V3_DIRECTIVES = ['@tailwind base;', '@tailwind components;', '@tailwind utilities;'];
|
|
58
|
+
const PRESET_ALLOWED = String.raw`'^.*/tailwind\\.preset\\.cjs$'`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The packages each version needs, for a device build, a web build or both. Metro runs Tailwind
|
|
62
|
+
* 4's CLI and Vite its plugin. Tailwind 3 ships its CLI inside `tailwindcss`, and Vite runs it
|
|
63
|
+
* through PostCSS.
|
|
64
|
+
*/
|
|
65
|
+
function packagesFor(version, { device, web }) {
|
|
66
|
+
const own = { '@ng-native/tailwind': native.dependencies['@ng-native/components'] };
|
|
67
|
+
if (version === 3) return { ...own, tailwindcss: '^3.4.1' };
|
|
68
|
+
return {
|
|
69
|
+
...own,
|
|
70
|
+
tailwindcss: '^4.3.3',
|
|
71
|
+
...(device ? { '@tailwindcss/cli': '^4.3.3' } : {}),
|
|
72
|
+
...(web ? { '@tailwindcss/vite': '^4.3.3' } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The project's Vite config, when it builds for a browser with `ngNativeWeb()`. */
|
|
77
|
+
function viteConfig(tree, root) {
|
|
78
|
+
return VITE_CONFIGS.map((name) => joinPathFragments(root, name)).find((file) =>
|
|
79
|
+
tree.read(file, 'utf-8')?.includes('ngNativeWeb'),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function projectNamed(tree, name) {
|
|
84
|
+
const project = getProjects(tree).get(name);
|
|
85
|
+
if (!project) throw new Error(`The workspace has no project called "${name}".`);
|
|
86
|
+
return project;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The app's package.json when it is a workspace package, which its dependencies go in. */
|
|
90
|
+
function manifestFor(tree, app) {
|
|
91
|
+
const own = joinPathFragments(app.root, 'package.json');
|
|
92
|
+
return usesWorkspaces(tree) && tree.exists(own) ? own : 'package.json';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The Tailwind major to set up: the one asked for, or the one already installed, or 4. Asking for
|
|
97
|
+
* the other one than installed is an upgrade of every project using it, not a generator's call.
|
|
98
|
+
*/
|
|
99
|
+
function versionFor(tree, manifest, asked) {
|
|
100
|
+
const listed = (file) => {
|
|
101
|
+
if (!tree.exists(file)) return undefined;
|
|
102
|
+
const { dependencies, devDependencies } = readJson(tree, file);
|
|
103
|
+
return { ...devDependencies, ...dependencies }.tailwindcss;
|
|
104
|
+
};
|
|
105
|
+
const range = listed(manifest) ?? listed('package.json');
|
|
106
|
+
const installed = range && semver.validRange(range) ? semver.minVersion(range)?.major : undefined;
|
|
107
|
+
if (asked && installed && Number(asked) !== installed) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`The workspace has Tailwind ${installed} (${range}). Upgrade it before setting up Tailwind ` +
|
|
110
|
+
`${asked}, or leave out --tailwindVersion.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return Number(asked ?? installed ?? 4);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A warning that says what to add by hand, for a file this cannot follow. */
|
|
117
|
+
function byHand(file, what) {
|
|
118
|
+
logger.warn(`Could not update ${file}: ${what}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function wrapMetroConfig(tree, root) {
|
|
122
|
+
const file = joinPathFragments(root, 'metro.config.js');
|
|
123
|
+
const config = tree.read(file, 'utf-8');
|
|
124
|
+
if (config === null) return byHand(file, 'it does not exist.');
|
|
125
|
+
if (config.includes('withTailwind')) return;
|
|
126
|
+
const exported = /^module\.exports = (.+);$/m.exec(config);
|
|
127
|
+
if (!exported) {
|
|
128
|
+
return byHand(
|
|
129
|
+
file,
|
|
130
|
+
"wrap its export in withTailwind(config, { input: './src/styles.css' }), from " +
|
|
131
|
+
"'@ng-native/tailwind/config.cjs'.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const requires = [...config.matchAll(/^const .+ = require\(.+\);$/gm)];
|
|
135
|
+
const last = requires.at(-1);
|
|
136
|
+
const at = last ? last.index + last[0].length : 0;
|
|
137
|
+
const required = "\nconst { withTailwind } = require('@ng-native/tailwind/config.cjs');";
|
|
138
|
+
const withRequire = config.slice(0, at) + required + config.slice(at);
|
|
139
|
+
tree.write(
|
|
140
|
+
file,
|
|
141
|
+
withRequire.replace(
|
|
142
|
+
exported[0],
|
|
143
|
+
`module.exports = withTailwind(${exported[1]}, {\n input: './src/styles.css',\n});`,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function passGlobalStyles(tree, root) {
|
|
149
|
+
const file = joinPathFragments(root, 'src/main.ts');
|
|
150
|
+
const main = tree.read(file, 'utf-8');
|
|
151
|
+
if (main === null) return byHand(file, 'it does not exist.');
|
|
152
|
+
if (main.includes(SHEET)) return;
|
|
153
|
+
const options = /(\bmount\([^\n]*\{\n)([ \t]*)/.exec(main);
|
|
154
|
+
const imports = [...main.matchAll(/^import .+;$/gm)];
|
|
155
|
+
if (!options || !imports.length || /\bglobalStyles\b/.test(main)) {
|
|
156
|
+
return byHand(
|
|
157
|
+
file,
|
|
158
|
+
`import tailwind from '${SHEET}' and pass it to mount as { globalStyles: tailwind }.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const last = imports.at(-1);
|
|
162
|
+
const at = last.index + last[0].length;
|
|
163
|
+
const imported = `${main.slice(0, at)}\nimport tailwind from '${SHEET}';${main.slice(at)}`;
|
|
164
|
+
tree.write(
|
|
165
|
+
file,
|
|
166
|
+
imported.replace(
|
|
167
|
+
options[0],
|
|
168
|
+
`${options[1]}${options[2]}globalStyles: tailwind,\n${options[2]}`,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `#160` additions an app generated before them lacks: build the sheet first, and ignore it. */
|
|
174
|
+
function prepareForTheSheet(tree, name, app) {
|
|
175
|
+
const typecheck = app.targets?.typecheck;
|
|
176
|
+
const command = typecheck?.options?.command;
|
|
177
|
+
if (command === 'ngc -p tsconfig.json --noEmit') {
|
|
178
|
+
typecheck.options.command = native.TYPECHECK;
|
|
179
|
+
updateProjectConfiguration(tree, name, app);
|
|
180
|
+
} else if (typeof command === 'string' && !command.includes('metro.config.js')) {
|
|
181
|
+
// One of the app's own: a fresh checkout has no sheet for main.ts to import until Metro's
|
|
182
|
+
// config has been loaded once.
|
|
183
|
+
byHand(
|
|
184
|
+
`${name}'s typecheck target`,
|
|
185
|
+
`run "node metro.config.js && " before "${command}", which builds the sheet src/main.ts imports.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const ignore = joinPathFragments(app.root, '.gitignore');
|
|
189
|
+
const text = tree.read(ignore, 'utf-8') ?? '';
|
|
190
|
+
if (!/^\.angular-native\/?$/m.test(text)) {
|
|
191
|
+
const lines = "# Generated by @ng-native/tailwind's withTailwind, rebuilt on every Metro start";
|
|
192
|
+
tree.write(ignore, `${text.replace(/\n*$/, text ? '\n\n' : '')}${lines}\n.angular-native/\n`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The library's `theme.css` for Tailwind 4, written when it has none, and its path. */
|
|
197
|
+
function libraryTheme(tree, library) {
|
|
198
|
+
const file = joinPathFragments(library.root, 'theme.css');
|
|
199
|
+
if (!tree.exists(file)) {
|
|
200
|
+
tree.write(
|
|
201
|
+
file,
|
|
202
|
+
[
|
|
203
|
+
"/* This library's Tailwind theme, which each app using it imports. @source is relative to",
|
|
204
|
+
' this file, so the app scans the classes the library uses. */',
|
|
205
|
+
"@source './src';",
|
|
206
|
+
'',
|
|
207
|
+
'@theme {',
|
|
208
|
+
'}',
|
|
209
|
+
'',
|
|
210
|
+
].join('\n'),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return file;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The library's `tailwind.preset.cjs` for Tailwind 3, written when it has none, and its path. */
|
|
217
|
+
function libraryPreset(tree, library) {
|
|
218
|
+
const file = joinPathFragments(library.root, 'tailwind.preset.cjs');
|
|
219
|
+
if (!tree.exists(file)) {
|
|
220
|
+
tree.write(
|
|
221
|
+
file,
|
|
222
|
+
[
|
|
223
|
+
"// This library's Tailwind theme, and where its classes are. Tailwind 3 ignores `content` in a",
|
|
224
|
+
"// preset, so each app's config spreads it into its own. The path starts from __dirname because",
|
|
225
|
+
'// Tailwind resolves a relative one against the directory it runs in, which is the app.',
|
|
226
|
+
"const { join } = require('path');",
|
|
227
|
+
'',
|
|
228
|
+
'module.exports = {',
|
|
229
|
+
" content: [join(__dirname, 'src/**/*.{ts,html}')],",
|
|
230
|
+
' theme: { extend: {} },',
|
|
231
|
+
'};',
|
|
232
|
+
'',
|
|
233
|
+
].join('\n'),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return file;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A Tailwind 4 entry stylesheet with `base` (the native or the web preset's imports) when it is
|
|
241
|
+
* new, and an import of each library's theme it lacks, after the imports it has.
|
|
242
|
+
*/
|
|
243
|
+
function writeV4Stylesheet(tree, file, base, themes) {
|
|
244
|
+
const existing = tree.read(file, 'utf-8');
|
|
245
|
+
const lines =
|
|
246
|
+
existing === null ? ["/* The app's Tailwind entry, without preflight. */", ...base] : [];
|
|
247
|
+
const text = existing ?? '';
|
|
248
|
+
const imports = themes
|
|
249
|
+
.map((theme) => `@import '${path.relative(path.dirname(file), theme)}';`)
|
|
250
|
+
.filter((line) => !text.includes(line));
|
|
251
|
+
if (existing !== null && imports.length === 0) return;
|
|
252
|
+
const last = [...text.matchAll(/^@import .+;$/gm)].at(-1);
|
|
253
|
+
const at = last ? last.index + last[0].length + 1 : text.length;
|
|
254
|
+
const before = text.slice(0, at).replace(/\n*$/, at ? '\n' : '');
|
|
255
|
+
tree.write(file, `${before}${[...lines, ...imports].join('\n')}\n${text.slice(at)}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The web build's Tailwind 4 stylesheet. One the app has already, without Tailwind, gets the web
|
|
260
|
+
* preset's imports ahead of its rules, where CSS requires an `@import`.
|
|
261
|
+
*/
|
|
262
|
+
function writeWebV4Stylesheet(tree, file, themes) {
|
|
263
|
+
const existing = tree.read(file, 'utf-8');
|
|
264
|
+
if (existing !== null && !existing.includes('@ng-native/tailwind/web.css')) {
|
|
265
|
+
if (existing.includes('tailwindcss')) {
|
|
266
|
+
return byHand(
|
|
267
|
+
file,
|
|
268
|
+
`import '@ng-native/tailwind/web.css' after Tailwind's theme and utilities.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
tree.write(file, `${V4_WEB_IMPORTS.join('\n')}\n\n${existing}`);
|
|
272
|
+
}
|
|
273
|
+
writeV4Stylesheet(tree, file, V4_WEB_IMPORTS, themes);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Adds `tailwindcss()` from `@tailwindcss/vite` after `ngNativeWeb()` in the Vite config. */
|
|
277
|
+
function addVitePlugin(tree, file) {
|
|
278
|
+
const config = tree.read(file, 'utf-8') ?? '';
|
|
279
|
+
if (config.includes('@tailwindcss/vite')) return;
|
|
280
|
+
const call = /\bngNativeWeb\(/.exec(config);
|
|
281
|
+
const end = call ? closingBracket(config, call.index + call[0].length, '(', ')') : -1;
|
|
282
|
+
const last = [...config.matchAll(/^import .+;$/gm)].at(-1);
|
|
283
|
+
if (end < 0 || !last) {
|
|
284
|
+
return byHand(
|
|
285
|
+
file,
|
|
286
|
+
"import tailwindcss from '@tailwindcss/vite' and add tailwindcss() after ngNativeWeb() in its plugins.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const at = last.index + last[0].length;
|
|
290
|
+
tree.write(
|
|
291
|
+
file,
|
|
292
|
+
`${config.slice(0, at)}\nimport tailwindcss from '@tailwindcss/vite';` +
|
|
293
|
+
`${config.slice(at, end + 1)}, tailwindcss()${config.slice(end + 1)}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Whether `code` names `file` in a string, in either quote. */
|
|
298
|
+
function mentions(code, file) {
|
|
299
|
+
return code.includes(`'${file}'`) || code.includes(`"${file}"`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Links `stylesheet` from the `index.html` Vite serves, which builds it with the page. A link
|
|
304
|
+
* rather than an import from the entry, which TypeScript 6 refuses in an app whose types do not
|
|
305
|
+
* declare `.css` modules, as a native app's do not. One the entry imports already is left as is.
|
|
306
|
+
*/
|
|
307
|
+
function linkStylesheet(tree, root, stylesheet) {
|
|
308
|
+
const file = joinPathFragments(root, 'index.html');
|
|
309
|
+
const html = tree.read(file, 'utf-8') ?? '';
|
|
310
|
+
const href = `/${path.relative(root, stylesheet)}`;
|
|
311
|
+
const script = /<script\b[^>]*\btype=["']module["'][^>]*>/.exec(html);
|
|
312
|
+
const src = script && /\bsrc=["']([^"']+)["']/.exec(script[0]);
|
|
313
|
+
const entry = src && joinPathFragments(root, src[1].replace(/^\.?\//, ''));
|
|
314
|
+
const relative = entry && path.relative(path.dirname(entry), stylesheet);
|
|
315
|
+
const imported = relative && (relative.startsWith('.') ? relative : `./${relative}`);
|
|
316
|
+
if (mentions(html, href) || (imported && mentions(tree.read(entry, 'utf-8') ?? '', imported))) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const head = /^([ \t]*)<\/head>/m.exec(html);
|
|
320
|
+
const link = `<link rel="stylesheet" href="${href}" />`;
|
|
321
|
+
if (!head) return byHand(file, `add ${link} to its head.`);
|
|
322
|
+
const indent = `${head[1]} `;
|
|
323
|
+
tree.write(file, `${html.slice(0, head.index)}${indent}${link}\n${html.slice(head.index)}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function writeV3Stylesheet(tree, root) {
|
|
327
|
+
const file = joinPathFragments(root, 'src/styles.css');
|
|
328
|
+
if (!tree.exists(file)) tree.write(file, `${V3_DIRECTIVES.join('\n')}\n`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A Tailwind 3 config: the app's `tailwind.config.js`, or the web build's. One this generator
|
|
333
|
+
* wrote is written again with every library it had and the new ones; any other is left alone,
|
|
334
|
+
* with a warning.
|
|
335
|
+
*/
|
|
336
|
+
function writeV3Config(tree, file, presets, options) {
|
|
337
|
+
const existing = tree.read(file, 'utf-8');
|
|
338
|
+
const relative = (preset) => path.relative(path.dirname(file), preset);
|
|
339
|
+
const wanted = presets.map(relative);
|
|
340
|
+
if (existing !== null) {
|
|
341
|
+
if (!existing.includes(GENERATED)) {
|
|
342
|
+
const missing = wanted.filter((preset) => !existing.includes(preset));
|
|
343
|
+
if (missing.length) {
|
|
344
|
+
byHand(file, `list ${missing.join(', ')} in presets, and spread each one's content in.`);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const had = [...existing.matchAll(/require\('([^']+\/tailwind\.preset\.cjs)'\)/g)].map(
|
|
349
|
+
(match) => match[1],
|
|
350
|
+
);
|
|
351
|
+
wanted.unshift(...had.filter((preset) => !wanted.includes(preset)));
|
|
352
|
+
}
|
|
353
|
+
tree.write(file, v3Config(wanted, options));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const GENERATED = '// Written by nx g @ng-native/nx:tailwind.';
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* A config listing `presets` ahead of Angular Native's `preset`. With `extend`, it is the config
|
|
360
|
+
* that file holds with the presets swapped, and takes its `content` from it.
|
|
361
|
+
*/
|
|
362
|
+
function v3Config(presets, { preset = 'preset.cjs', extend } = {}) {
|
|
363
|
+
const names = [];
|
|
364
|
+
for (const preset of presets) {
|
|
365
|
+
const base = identifier(path.basename(path.dirname(preset)));
|
|
366
|
+
let name = base;
|
|
367
|
+
for (let n = 2; names.includes(name); n++) name = `${base}${n}`;
|
|
368
|
+
names.push(name);
|
|
369
|
+
}
|
|
370
|
+
const requires = presets.map((preset, i) => `const ${names[i]} = require('${preset}');`);
|
|
371
|
+
const own = `require('@ng-native/tailwind/${preset}')`;
|
|
372
|
+
const native = presets.length ? `{ ...${own}, presets: [] }` : own;
|
|
373
|
+
const content = ["'./src/**/*.{ts,html}'", ...names.map((name) => `...${name}.content`)];
|
|
374
|
+
// Only the first preset brings Tailwind's defaults: a later one's would override its theme.
|
|
375
|
+
const listed = names.map((name, i) => (i === 0 ? name : `{ ...${name}, presets: [] }`));
|
|
376
|
+
return [
|
|
377
|
+
GENERATED,
|
|
378
|
+
...(extend
|
|
379
|
+
? [
|
|
380
|
+
"// The web build's config: the app's own, with the web preset in place of the native one.",
|
|
381
|
+
]
|
|
382
|
+
: []),
|
|
383
|
+
...(presets.length
|
|
384
|
+
? [
|
|
385
|
+
'// The first preset brings Tailwind defaults; each after it takes presets: [] so it does not',
|
|
386
|
+
'// bring them again over the themes before it.',
|
|
387
|
+
]
|
|
388
|
+
: []),
|
|
389
|
+
...requires,
|
|
390
|
+
...(requires.length ? [''] : []),
|
|
391
|
+
'module.exports = {',
|
|
392
|
+
...(extend ? [` ...require('${extend}'),`] : []),
|
|
393
|
+
` presets: [${[...listed, native].join(', ')}],`,
|
|
394
|
+
...(extend ? [] : [` content: [${content.join(', ')}],`]),
|
|
395
|
+
'};',
|
|
396
|
+
'',
|
|
397
|
+
].join('\n');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The PostCSS config Vite runs Tailwind 3 from, pointed at `config`. One the app has already is
|
|
402
|
+
* left alone, with a warning when it does not run Tailwind.
|
|
403
|
+
*/
|
|
404
|
+
function writePostcssConfig(tree, root, config, ext) {
|
|
405
|
+
const existing = POSTCSS_CONFIGS.map((name) => joinPathFragments(root, name)).find((file) =>
|
|
406
|
+
tree.exists(file),
|
|
407
|
+
);
|
|
408
|
+
if (existing) {
|
|
409
|
+
if (!tree.read(existing, 'utf-8')?.includes('tailwindcss')) {
|
|
410
|
+
byHand(existing, `add tailwindcss: { config: './${config}' } to its plugins.`);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
tree.write(
|
|
415
|
+
joinPathFragments(root, `postcss.config.${ext}`),
|
|
416
|
+
[
|
|
417
|
+
'// Vite runs Tailwind 3 as a PostCSS plugin.',
|
|
418
|
+
'module.exports = {',
|
|
419
|
+
` plugins: { tailwindcss: { config: './${config}' } },`,
|
|
420
|
+
'};',
|
|
421
|
+
'',
|
|
422
|
+
].join('\n'),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* `cjs` where a `.js` file would be an ES module, which a `module.exports` config cannot be: in a
|
|
428
|
+
* package whose nearest `package.json` says `"type": "module"`.
|
|
429
|
+
*/
|
|
430
|
+
function commonJsExtension(tree, root) {
|
|
431
|
+
for (let dir = root; ; dir = path.dirname(dir)) {
|
|
432
|
+
const manifest = joinPathFragments(dir, 'package.json');
|
|
433
|
+
if (tree.exists(manifest)) return readJson(tree, manifest).type === 'module' ? 'cjs' : 'js';
|
|
434
|
+
if (dir === '.' || dir === '' || dir === '/') return 'js';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The web build, from the Vite config that runs `ngNativeWeb()`. Beside a device build, Tailwind
|
|
440
|
+
* 4 takes a stylesheet of its own, since the device's imports `native.css`, and Tailwind 3 a
|
|
441
|
+
* config of its own that swaps the preset in the device's.
|
|
442
|
+
*/
|
|
443
|
+
function setUpWeb(tree, root, vite, version, libraries, besideDevice) {
|
|
444
|
+
if (version === 3) {
|
|
445
|
+
const ext = commonJsExtension(tree, root);
|
|
446
|
+
const config = besideDevice ? `tailwind.web.config.${ext}` : `tailwind.config.${ext}`;
|
|
447
|
+
writeV3Config(
|
|
448
|
+
tree,
|
|
449
|
+
joinPathFragments(root, config),
|
|
450
|
+
libraries.map((library) => libraryPreset(tree, library)),
|
|
451
|
+
{ preset: 'web-preset.cjs', extend: besideDevice ? `./tailwind.config.${ext}` : undefined },
|
|
452
|
+
);
|
|
453
|
+
writePostcssConfig(tree, root, config, ext);
|
|
454
|
+
writeV3Stylesheet(tree, root);
|
|
455
|
+
return linkStylesheet(tree, root, joinPathFragments(root, 'src/styles.css'));
|
|
456
|
+
}
|
|
457
|
+
const file = joinPathFragments(root, besideDevice ? 'src/styles.web.css' : 'src/styles.css');
|
|
458
|
+
writeWebV4Stylesheet(
|
|
459
|
+
tree,
|
|
460
|
+
file,
|
|
461
|
+
libraries.map((library) => libraryTheme(tree, library)),
|
|
462
|
+
);
|
|
463
|
+
addVitePlugin(tree, vite);
|
|
464
|
+
linkStylesheet(tree, root, file);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** A JavaScript name for a library's preset: `ui`, `sharedUi`. */
|
|
468
|
+
function identifier(name) {
|
|
469
|
+
const camel = name.replace(/[^a-zA-Z0-9]+(.)/g, (_, next) => next.toUpperCase());
|
|
470
|
+
return /^[a-zA-Z_$]/.test(camel) ? camel : `_${camel}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Adds the preset's file name to `@nx/enforce-module-boundaries`' `allow` list, where there is one. */
|
|
474
|
+
function allowPresets(tree) {
|
|
475
|
+
const file = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs'].find((name) =>
|
|
476
|
+
tree.exists(name),
|
|
477
|
+
);
|
|
478
|
+
if (!file) return;
|
|
479
|
+
const config = tree.read(file, 'utf-8') ?? '';
|
|
480
|
+
if (!config.includes('enforce-module-boundaries') || config.includes('tailwind\\\\.preset'))
|
|
481
|
+
return;
|
|
482
|
+
const start = /allow:\s*\[/.exec(config);
|
|
483
|
+
const end = start ? closingBracket(config, start.index + start[0].length, '[', ']') : -1;
|
|
484
|
+
if (!start || end < 0) {
|
|
485
|
+
return byHand(file, `add ${PRESET_ALLOWED} to @nx/enforce-module-boundaries' allow list.`);
|
|
486
|
+
}
|
|
487
|
+
const open = start.index + start[0].length;
|
|
488
|
+
const entries = config.slice(open, end).trimEnd();
|
|
489
|
+
const list = entries.trim() ? `${entries.replace(/,$/, '')}, ${PRESET_ALLOWED}` : PRESET_ALLOWED;
|
|
490
|
+
tree.write(file, config.slice(0, open) + list + config.slice(end));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The index of the `close` ending the bracket that starts at `from`, skipping strings. */
|
|
494
|
+
function closingBracket(text, from, open, close) {
|
|
495
|
+
let depth = 0;
|
|
496
|
+
let quote = '';
|
|
497
|
+
for (let i = from; i < text.length; i++) {
|
|
498
|
+
const char = text[i];
|
|
499
|
+
if (quote) {
|
|
500
|
+
if (char === '\\') i++;
|
|
501
|
+
else if (char === quote) quote = '';
|
|
502
|
+
} else if (char === "'" || char === '"' || char === '`') quote = char;
|
|
503
|
+
else if (char === open) depth++;
|
|
504
|
+
else if (char === close && depth-- === 0) return i;
|
|
505
|
+
}
|
|
506
|
+
return -1;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function addPackages(tree, manifest, version, builds) {
|
|
510
|
+
const wanted = packagesFor(version, builds);
|
|
511
|
+
const own = manifest === 'package.json' ? readJson(tree, manifest) : readJson(tree, manifest);
|
|
512
|
+
const installed = Object.keys({ ...own.dependencies, ...own.devDependencies });
|
|
513
|
+
const ranges =
|
|
514
|
+
manifest === 'package.json'
|
|
515
|
+
? wanted
|
|
516
|
+
: native.reuseRootRanges(wanted, readJson(tree, 'package.json'));
|
|
517
|
+
addDependenciesToPackageJson(tree, {}, await asSaved(tree, ranges, installed), manifest, true);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {import('@nx/devkit').Tree} tree
|
|
522
|
+
* @param {{ project: string, tailwindVersion?: 3 | 4, library?: string, skipInstall?: boolean, skipFormat?: boolean }} options
|
|
523
|
+
*/
|
|
524
|
+
async function tailwind(tree, options) {
|
|
525
|
+
const app = projectNamed(tree, options.project);
|
|
526
|
+
const device =
|
|
527
|
+
tree.exists(joinPathFragments(app.root, 'app.json')) &&
|
|
528
|
+
tree.exists(joinPathFragments(app.root, 'metro.config.js'));
|
|
529
|
+
const vite = viteConfig(tree, app.root);
|
|
530
|
+
if (!device && !vite) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`${options.project} is not an Angular Native app: it has no app.json and metro.config.js, ` +
|
|
533
|
+
'and no Vite config with ngNativeWeb(). Generate one with nx g @ng-native/nx:app.',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
const libraries = (options.library ?? '')
|
|
537
|
+
.split(',')
|
|
538
|
+
.map((name) => name.trim())
|
|
539
|
+
.filter(Boolean)
|
|
540
|
+
.map((name) => projectNamed(tree, name));
|
|
541
|
+
const manifest = manifestFor(tree, app);
|
|
542
|
+
const version = versionFor(tree, manifest, options.tailwindVersion);
|
|
543
|
+
|
|
544
|
+
if (device) {
|
|
545
|
+
if (version === 3) {
|
|
546
|
+
writeV3Stylesheet(tree, app.root);
|
|
547
|
+
writeV3Config(
|
|
548
|
+
tree,
|
|
549
|
+
joinPathFragments(app.root, `tailwind.config.${commonJsExtension(tree, app.root)}`),
|
|
550
|
+
libraries.map((library) => libraryPreset(tree, library)),
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
writeV4Stylesheet(
|
|
554
|
+
tree,
|
|
555
|
+
joinPathFragments(app.root, 'src/styles.css'),
|
|
556
|
+
V4_IMPORTS,
|
|
557
|
+
libraries.map((library) => libraryTheme(tree, library)),
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
wrapMetroConfig(tree, app.root);
|
|
561
|
+
passGlobalStyles(tree, app.root);
|
|
562
|
+
prepareForTheSheet(tree, options.project, app);
|
|
563
|
+
}
|
|
564
|
+
if (vite) setUpWeb(tree, app.root, vite, version, libraries, device);
|
|
565
|
+
if (version === 3 && libraries.length) allowPresets(tree);
|
|
566
|
+
await addPackages(tree, manifest, version, { device, web: Boolean(vite) });
|
|
567
|
+
|
|
568
|
+
if (!options.skipFormat) await formatFiles(tree);
|
|
569
|
+
if (options.skipInstall) return () => {};
|
|
570
|
+
return () => installPackagesTask(tree, true);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = { tailwind };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/schema",
|
|
3
|
+
"$id": "NgNativeNxTailwind",
|
|
4
|
+
"title": "Tailwind in an Angular Native app",
|
|
5
|
+
"description": "Sets up Tailwind in an Angular Native app, and the Tailwind-styled workspace libraries it uses.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"project": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "The Angular Native app, or a browser app built with Vite and ngNativeWeb(). An app with both builds gets both set up.",
|
|
11
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
12
|
+
"x-prompt": "Which app should Tailwind be set up in?",
|
|
13
|
+
"x-dropdown": "projects"
|
|
14
|
+
},
|
|
15
|
+
"tailwindVersion": {
|
|
16
|
+
"type": "number",
|
|
17
|
+
"description": "The Tailwind major: 4, or 3. Defaults to the one the workspace has, or 4.",
|
|
18
|
+
"enum": [3, 4]
|
|
19
|
+
},
|
|
20
|
+
"library": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Comma-separated workspace libraries whose classes the app uses: each gets a Tailwind theme of its own that the app loads."
|
|
23
|
+
},
|
|
24
|
+
"skipInstall": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "Write the files without installing.",
|
|
27
|
+
"default": false
|
|
28
|
+
},
|
|
29
|
+
"skipFormat": {
|
|
30
|
+
"type": "boolean",
|
|
31
|
+
"description": "Leave the generated files unformatted.",
|
|
32
|
+
"default": false,
|
|
33
|
+
"x-priority": "internal"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"required": ["project"]
|
|
37
|
+
}
|