pob 40.3.3 → 40.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.
Files changed (28) hide show
  1. package/lib/generators/app/PobAppGenerator.js +24 -4
  2. package/lib/generators/common/format-lint/CommonLintGenerator.js +14 -2
  3. package/lib/generators/common/husky/CommonHuskyGenerator.js +3 -1
  4. package/lib/generators/common/testing/CommonTestingGenerator.js +10 -2
  5. package/lib/generators/common/transpiler/CommonTranspilerGenerator.js +11 -9
  6. package/lib/generators/common/typescript/CommonTypescriptGenerator.js +110 -38
  7. package/lib/generators/core/bun/CoreBunGenerator.js +1 -1
  8. package/lib/generators/core/ci/CoreCIGenerator.js +4 -1
  9. package/lib/generators/core/git/CoreGitGenerator.js +2 -0
  10. package/lib/generators/core/git/generators/github/CoreGitGithubGenerator.js +1 -0
  11. package/lib/generators/core/package/CorePackageGenerator.js +12 -3
  12. package/lib/generators/core/pnpm/CorePnpmGenerator.js +8 -2
  13. package/lib/generators/core/vscode/CoreVSCodeGenerator.js +9 -0
  14. package/lib/generators/core/vscode/templates/extensions.json.ejs +2 -1
  15. package/lib/generators/core/vscode/templates/settings.json.ejs +5 -0
  16. package/lib/generators/core/yarn/CoreYarnGenerator.js +6 -2
  17. package/lib/generators/lib/PobLibGenerator.js +15 -13
  18. package/lib/generators/monorepo/PobMonorepoGenerator.js +18 -0
  19. package/lib/generators/monorepo/typescript/MonorepoTypescriptGenerator.js +13 -1
  20. package/lib/generators/monorepo/typescript/templates/tsconfig.tools.json.ejs +7 -0
  21. package/lib/generators/monorepo/workspaces/MonorepoWorkspacesGenerator.js +7 -2
  22. package/lib/generators/pob/PobBaseGenerator.js +4 -2
  23. package/lib/pob.js +75 -5
  24. package/lib/utils/alouette.js +18 -0
  25. package/lib/utils/appTsconfigTarget.js +60 -0
  26. package/lib/utils/appTsconfigTarget.test.js +169 -0
  27. package/lib/utils/nodeApps.js +8 -0
  28. package/package.json +5 -5
@@ -1,9 +1,12 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { platform } from "node:process";
3
3
  import Generator from "yeoman-generator";
4
+ import { detectAlouette } from "../../utils/alouette.js";
5
+ import { appsWithBrowser } from "../../utils/appTsconfigTarget.js";
4
6
  import { removeFromGitIndex } from "../../utils/git.js";
5
7
  import inMonorepo from "../../utils/inMonorepo.js";
6
8
  import removeLegacyGeneratedDocs from "../../utils/legacyGeneratedDocs.js";
9
+ import { nodeAppTypes } from "../../utils/nodeApps.js";
7
10
  import * as packageUtils from "../../utils/package.js";
8
11
  import { detectStorybookReactNative } from "../../utils/storybook.js";
9
12
  import { appIgnorePaths } from "./ignorePaths.js";
@@ -16,7 +19,6 @@ const appsWithTypescript = [
16
19
  "expo",
17
20
  "yarn-plugin",
18
21
  ];
19
- const appsWithBrowser = ["alp", "next.js", "vite", "vite-with-server"];
20
22
  const shouldEnableHashSlash = (appType) =>
21
23
  appType === "alp" ||
22
24
  appType === "vite" ||
@@ -96,12 +98,15 @@ export default class PobAppGenerator extends Generator {
96
98
  delete config.ci;
97
99
  }
98
100
 
99
- const storybookDefault = detectStorybookReactNative([
100
- this.fs.readJSON(this.destinationPath("package.json"), {}),
101
- ]);
101
+ const pkg = this.fs.readJSON(this.destinationPath("package.json"), {});
102
+ const storybookDefault = detectStorybookReactNative([pkg]);
102
103
  if (config?.type === "expo" && config.storybook === undefined) {
103
104
  config.storybook = storybookDefault;
104
105
  }
106
+ const alouetteDefault = detectAlouette(pkg);
107
+ if (config?.type === "expo" && config.alouette === undefined) {
108
+ config.alouette = alouetteDefault;
109
+ }
105
110
 
106
111
  if (config && this.options.updateOnly) {
107
112
  this.config.set("app", this.appConfig);
@@ -162,6 +167,13 @@ export default class PobAppGenerator extends Generator {
162
167
  default: config?.storybook ?? storybookDefault,
163
168
  when: (values) => values.type === "expo",
164
169
  },
170
+ {
171
+ type: "confirm",
172
+ name: "alouette",
173
+ message: "Do you use alouette ?",
174
+ default: config?.alouette ?? alouetteDefault,
175
+ when: (values) => values.type === "expo",
176
+ },
165
177
  ]);
166
178
 
167
179
  this.config.set("app", this.appConfig);
@@ -262,10 +274,12 @@ export default class PobAppGenerator extends Generator {
262
274
  inMonorepo.pobMonorepoConfig?.typescript === "check-only"),
263
275
  isApp: true,
264
276
  isAppLibrary,
277
+ appType: this.appConfig.type,
265
278
  nextConfig: this.appConfig.type === "next.js",
266
279
  // nextjs now supports src
267
280
  rootDir: this.appConfig.type === "expo" ? "." : srcDirectory,
268
281
  srcDirectory,
282
+ includeScripts: nodeAppTypes.includes(this.appConfig.type),
269
283
  builddefs: false,
270
284
  dom: browser,
271
285
  jsx,
@@ -277,6 +291,8 @@ export default class PobAppGenerator extends Generator {
277
291
  onlyLatestLTS: true,
278
292
  packageManager: this.options.packageManager,
279
293
  enableHashSlash,
294
+ // only asked for expo apps: the on-device react-native storybook
295
+ storybook: this.appConfig.storybook,
280
296
  plugins: (() => {
281
297
  if (this.appConfig.type === "next.js") {
282
298
  return "next";
@@ -363,6 +379,8 @@ export default class PobAppGenerator extends Generator {
363
379
  typescript,
364
380
  testing: this.appConfig.testing,
365
381
  testRunner: this.appConfig.testRunner,
382
+ // only asked for expo apps
383
+ tailwind: this.appConfig.alouette,
366
384
  });
367
385
 
368
386
  // only for gitignore
@@ -412,6 +430,8 @@ export default class PobAppGenerator extends Generator {
412
430
  this.fs.delete(this.destinationPath("next-env.d.ts"));
413
431
  }
414
432
 
433
+ if (this.options.check) return;
434
+
415
435
  if (platform !== "win32") {
416
436
  execSync(
417
437
  `rm -Rf ${["lib-*", "coverage", "generated-docs", "dist"].filter(Boolean).join(" ")}`,
@@ -281,6 +281,10 @@ export default class CommonFormatLintGenerator extends Generator {
281
281
  const rootIgnorePatterns = new Set(
282
282
  this.options.rootIgnorePaths.split("\n").filter(Boolean),
283
283
  );
284
+ if (this.options.storybookReactNative) {
285
+ // regenerated by metro on each start (sb-rn-get-stories)
286
+ rootIgnorePatterns.add("**/storybook.requires.ts");
287
+ }
284
288
  const ignorePatterns = new Set(
285
289
  this.options.ignorePaths.split("\n").filter(Boolean),
286
290
  );
@@ -508,7 +512,8 @@ export default class CommonFormatLintGenerator extends Generator {
508
512
  // tsconfig.json; point typed lint at that project. The monorepo root
509
513
  // owns the shared tsconfig.tools.json (its **/scripts/** glob covers
510
514
  // package scripts too), and single repos own their own; in-monorepo
511
- // packages inherit the root config.
515
+ // packages inherit the root config. Node apps type-check them with
516
+ // their tsconfig.json instead, but still need the vitest resolution.
512
517
  if ((isMonorepoRoot || !inMonorepo) && useTypescript) {
513
518
  flatCascade.push("...pobConfig.configs.toolsProject");
514
519
  }
@@ -575,11 +580,12 @@ export default class CommonFormatLintGenerator extends Generator {
575
580
  }
576
581
 
577
582
  if (this.options.storybook) {
578
- ignorePatterns.add("**/.storybook/");
579
583
  ignorePatterns.add("**/storybook-static/");
580
584
  }
581
585
  if (this.options.storybookReactNative) {
582
586
  ignorePatterns.add("**/.rnstorybook/");
587
+ // generated in .storybook/ by sb-rn-get-stories
588
+ ignorePatterns.add("**/storybook.requires.ts");
583
589
  }
584
590
 
585
591
  if (hasDesignSync(this)) {
@@ -753,6 +759,12 @@ export default class CommonFormatLintGenerator extends Generator {
753
759
  */
754
760
  async end() {
755
761
  if (!this.runOxlintOnlyCodemods) return;
762
+ if (this.options.check) {
763
+ console.log(
764
+ "@pob/eslint-config 68: eslint-disable comments of rules checked by oxlint would be rewritten",
765
+ );
766
+ return;
767
+ }
756
768
  const cwd = this.destinationPath();
757
769
  const files = execFileSync(
758
770
  "git",
@@ -16,7 +16,9 @@ export default class CommonHuskyGenerator extends Generator {
16
16
  }
17
17
 
18
18
  writing() {
19
- rmSync("git-hooks", { recursive: true, force: true });
19
+ if (!this.options.check) {
20
+ rmSync("git-hooks", { recursive: true, force: true });
21
+ }
20
22
 
21
23
  const gitHookDestination = this.destinationPath(".git/hooks/pre-commit");
22
24
  let isSymlink;
@@ -523,8 +523,16 @@ export default class CommonTestingGenerator extends Generator {
523
523
  }
524
524
  }
525
525
 
526
- // legacy jest babel config
527
- this.fs.delete("babel.config.cjs");
526
+ // legacy jest babel config, only when generated by pob:
527
+ // apps (expo, react-native) can have their own, read by metro
528
+ const babelConfigPath = this.destinationPath("babel.config.cjs");
529
+ if (
530
+ this.fs
531
+ .read(babelConfigPath, { defaults: "" })
532
+ .includes("pob-babel/preset")
533
+ ) {
534
+ this.fs.delete(babelConfigPath);
535
+ }
528
536
 
529
537
  return writeAndFormatJson(
530
538
  this.fs,
@@ -127,15 +127,17 @@ export default class CommonTranspilerGenerator extends Generator {
127
127
  this.entries = pkg.pob.entries;
128
128
  this.babelEnvs = pkg.pob.babelEnvs || [];
129
129
 
130
- if (
131
- this.babelEnvs.length > 0 ||
132
- pkg.pob.typescript === true ||
133
- pkg.pob.bundler
134
- ) {
135
- fs.mkdirSync(this.destinationPath("src"), { recursive: true });
136
- } else {
137
- // recursive does not throw if directory already exists
138
- fs.mkdirSync(this.destinationPath("lib"), { recursive: true });
130
+ if (!this.options.check) {
131
+ if (
132
+ this.babelEnvs.length > 0 ||
133
+ pkg.pob.typescript === true ||
134
+ pkg.pob.bundler
135
+ ) {
136
+ fs.mkdirSync(this.destinationPath("src"), { recursive: true });
137
+ } else {
138
+ // recursive does not throw if directory already exists
139
+ fs.mkdirSync(this.destinationPath("lib"), { recursive: true });
140
+ }
139
141
  }
140
142
  }
141
143
 
@@ -1,5 +1,9 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import Generator from "yeoman-generator";
3
+ import {
4
+ getAppTsconfigTarget,
5
+ getConsumerAppsTsconfigTarget,
6
+ } from "../../../utils/appTsconfigTarget.js";
3
7
  import inMonorepo from "../../../utils/inMonorepo.js";
4
8
  import { latestLTS, maintenanceLTS } from "../../../utils/nodeVersions.js";
5
9
  import * as packageUtils from "../../../utils/package.js";
@@ -27,6 +31,12 @@ export default class CommonTypescriptGenerator extends Generator {
27
31
  default: false,
28
32
  });
29
33
 
34
+ this.option("appType", {
35
+ type: String,
36
+ default: "",
37
+ description: "app type, when isApp",
38
+ });
39
+
30
40
  this.option("rootDir", {
31
41
  type: String,
32
42
  default: "src",
@@ -39,6 +49,13 @@ export default class CommonTypescriptGenerator extends Generator {
39
49
  description: "customize srcDirectory, if different than rootDir",
40
50
  });
41
51
 
52
+ this.option("includeScripts", {
53
+ type: Boolean,
54
+ default: false,
55
+ description:
56
+ "type-check scripts and root config files with tsconfig.json instead of tsconfig.tools.json (node apps)",
57
+ });
58
+
42
59
  this.option("jsx", {
43
60
  type: Boolean,
44
61
  default: true,
@@ -128,6 +145,13 @@ export default class CommonTypescriptGenerator extends Generator {
128
145
  default: false,
129
146
  description: "enable #/ in paths",
130
147
  });
148
+
149
+ this.option("storybook", {
150
+ type: Boolean,
151
+ required: false,
152
+ default: false,
153
+ description: "Type-check the storybook config in .storybook",
154
+ });
131
155
  }
132
156
 
133
157
  default() {
@@ -154,7 +178,22 @@ export default class CommonTypescriptGenerator extends Generator {
154
178
  const withNode = envs.some((env) => env.target === "node");
155
179
  const withBun = this.options.packageManager === "bun";
156
180
 
181
+ // untranspiled libraries are never built on their own: they are compiled
182
+ // by the bundler of the apps importing them
183
+ const consumerAppsTarget =
184
+ this.options.appType === "untranspiled-library" &&
185
+ inMonorepo &&
186
+ !inMonorepo.root
187
+ ? await getConsumerAppsTsconfigTarget({
188
+ rootPath: inMonorepo.rootPath,
189
+ packageName: pkg.name,
190
+ withBun,
191
+ })
192
+ : undefined;
193
+
157
194
  const presets = (() => {
195
+ if (consumerAppsTarget) return [consumerAppsTarget];
196
+
158
197
  const babelEnvs =
159
198
  pkg.pob?.babelEnvs ||
160
199
  (pkg.pob?.bundler === "rollup-babel" && pkg.pob.envs) ||
@@ -198,20 +237,12 @@ export default class CommonTypescriptGenerator extends Generator {
198
237
  return ["@pob/root/tsconfigs/targets/rollup-es2015.json"];
199
238
  }
200
239
 
201
- if (this.options.isApp && pkg.dependencies?.expo) {
202
- return ["@pob/root/tsconfigs/targets/expo.json"];
203
- }
204
-
205
- if (this.options.dom) {
206
- if (this.options.isApp && pkg.devDependencies?.vite) {
207
- return ["@pob/root/tsconfigs/targets/vite.json"];
208
- }
209
- return ["@pob/root/tsconfigs/targets/webpack.json"];
210
- }
211
- if (withBun) {
212
- return ["@pob/root/tsconfigs/targets/bun.json"];
213
- }
214
- return [];
240
+ const target = getAppTsconfigTarget({
241
+ appType: this.options.isApp ? this.options.appType : undefined,
242
+ dom: this.options.dom,
243
+ withBun,
244
+ });
245
+ return target ? [target] : [];
215
246
  })();
216
247
 
217
248
  packageUtils.removeDevDependencies(pkg, ["flow-bin"]);
@@ -237,12 +268,18 @@ export default class CommonTypescriptGenerator extends Generator {
237
268
  const tsconfigBuildPath = this.destinationPath("tsconfig.build.json");
238
269
  const tsconfigToolsPath = this.destinationPath("tsconfig.tools.json");
239
270
 
271
+ // the check-js template only covers the src directory
272
+ const includeScripts =
273
+ this.options.includeScripts && !this.options.onlyCheck;
274
+
240
275
  if (this.options.enable) {
241
276
  const { jsx, dom } = this.options;
242
277
  let monorepoComposite;
243
278
  let monorepoPackageReferences;
244
279
  // let monorepoPackageBuildReferences;
245
280
  let monorepoPackageSrcPaths;
281
+ // another workspace package depends on this one, so its tsconfig references it
282
+ let isMonorepoDependency = false;
246
283
 
247
284
  if (inMonorepo && !inMonorepo.root) {
248
285
  const yoConfig = inMonorepo.rootYoConfig;
@@ -253,24 +290,42 @@ export default class CommonTypescriptGenerator extends Generator {
253
290
  yoConfig.pob.monorepo.typescript;
254
291
 
255
292
  if (monorepoComposite) {
293
+ const dependsOn = (dependentPkg, packageName) =>
294
+ Boolean(
295
+ dependentPkg.dependencies?.[packageName] ||
296
+ dependentPkg.devDependencies?.[packageName] ||
297
+ dependentPkg.peerDependencies?.[packageName],
298
+ );
299
+ const getPackageLocation = (packageName) =>
300
+ `../../${
301
+ packageName[0] === "@"
302
+ ? // oxlint-disable-next-line unicorn/no-nested-ternary
303
+ yoConfig.pob.project.type === "app"
304
+ ? `packages/${packageName.slice(packageName.indexOf("/") + 1)}`
305
+ : packageName
306
+ : `packages/${packageName}`
307
+ }`;
308
+
309
+ isMonorepoDependency = yoConfig.pob.monorepo.packageNames.some(
310
+ (packageName) => {
311
+ if (packageName === pkg.name) return false;
312
+ const dependentPkgPath = `${getPackageLocation(packageName)}/package.json`;
313
+ return (
314
+ existsSync(dependentPkgPath) &&
315
+ dependsOn(
316
+ JSON.parse(readFileSync(dependentPkgPath, "utf8")),
317
+ pkg.name,
318
+ )
319
+ );
320
+ },
321
+ );
322
+
256
323
  const packageLocations = new Map(
257
324
  yoConfig.pob.monorepo.packageNames
258
- .filter(
259
- (packageName) =>
260
- (pkg.dependencies && pkg.dependencies[packageName]) ||
261
- (pkg.devDependencies && pkg.devDependencies[packageName]) ||
262
- (pkg.peerDependencies && pkg.peerDependencies[packageName]),
263
- )
325
+ .filter((packageName) => dependsOn(pkg, packageName))
264
326
  .map((packageName) => [
265
327
  packageName,
266
- `../../${
267
- packageName[0] === "@"
268
- ? // oxlint-disable-next-line unicorn/no-nested-ternary
269
- yoConfig.pob.project.type === "app"
270
- ? `packages/${packageName.slice(packageName.indexOf("/") + 1)}`
271
- : packageName
272
- : `packages/${packageName}`
273
- }`,
328
+ getPackageLocation(packageName),
274
329
  ]),
275
330
  );
276
331
 
@@ -304,6 +359,14 @@ export default class CommonTypescriptGenerator extends Generator {
304
359
  }
305
360
  }
306
361
 
362
+ // composite is meant for packages built and exported before the ones
363
+ // depending on them: libraries, and apps imported by other packages.
364
+ // Other apps only enable incremental.
365
+ const isReferenceable =
366
+ !this.options.isApp ||
367
+ this.options.isAppLibrary ||
368
+ isMonorepoDependency;
369
+
307
370
  if (this.fs.exists(tsconfigEslintPath)) {
308
371
  this.fs.delete(tsconfigEslintPath);
309
372
  }
@@ -328,29 +391,37 @@ export default class CommonTypescriptGenerator extends Generator {
328
391
  pkg.pob?.rollup === false ||
329
392
  pkg.pob?.bundler === false ||
330
393
  pkg.pob?.bundler === "tsc",
331
- cacheEnabled: !this.options.isApp || this.options.isAppLibrary,
394
+ cacheEnabled: isReferenceable,
332
395
  monorepoPackageSrcPaths,
333
396
  monorepoPackageReferences,
334
- rootDir: this.options.rootDir,
397
+ // "." also includes root *.ts files, like vitest.config.ts
398
+ rootDir: includeScripts ? "." : this.options.rootDir,
335
399
  srcDirectory: this.options.srcDirectory || this.options.rootDir,
336
400
  enableHashSlash: this.options.enableHashSlash,
337
401
  jsx,
338
402
  jsxPreserve: this.options.jsxPreserve,
339
403
  nextConfig: this.options.nextConfig,
340
- // for apps, only enable incremental. Composite is meant for libraries to build and export before the ones depending on them.
341
404
  composite:
342
405
  monorepoComposite &&
343
406
  monorepoComposite !== "check-only" &&
344
- (!this.options.isApp || this.options.isAppLibrary),
407
+ isReferenceable,
345
408
  incremental: monorepoComposite,
346
409
  dom,
347
410
  resolveJsonModule: this.options.resolveJsonModule,
348
411
  forceExcludeNodeModules: this.options.forceExcludeNodeModules,
349
412
  forceAllowJs: this.options.forceAllowJs,
350
413
  plugins: this.options.plugins.split(",").filter(Boolean),
351
- additionalIncludes: this.options.additionalIncludes
352
- .split(",")
353
- .filter(Boolean),
414
+ additionalIncludes: [
415
+ ...this.options.additionalIncludes.split(",").filter(Boolean),
416
+ ...(includeScripts
417
+ ? ["scripts/**/*.ts", "scripts/**/*.mts", "scripts/**/*.cts"]
418
+ : []),
419
+ // the app only imports the generated storybook.requires.ts, which
420
+ // loads ./preview with a require() that typescript does not follow
421
+ ...(this.options.storybook
422
+ ? [".storybook/*.ts", ".storybook/*.tsx"]
423
+ : []),
424
+ ],
354
425
  node: withNode,
355
426
  presets,
356
427
  },
@@ -381,7 +452,8 @@ export default class CommonTypescriptGenerator extends Generator {
381
452
  // dedicated noEmit project so it is type-checked without emitting
382
453
  // definitions nor violating rootDir. Monorepos handle this at the root
383
454
  // via the shared tsconfig.tools.json, so only single repos get it here.
384
- if (inMonorepo) {
455
+ // Node apps include them in tsconfig.json instead.
456
+ if (inMonorepo || includeScripts) {
385
457
  this.fs.delete(tsconfigToolsPath);
386
458
  } else {
387
459
  await copyAndFormatTpl(
@@ -405,7 +477,7 @@ export default class CommonTypescriptGenerator extends Generator {
405
477
  // tsrs and tsc do not share their .tsbuildinfo: switching between them
406
478
  // triggers a full rebuild
407
479
  const tscCommand = (compiler) =>
408
- inMonorepo || !this.options.enable
480
+ inMonorepo || includeScripts || !this.options.enable
409
481
  ? compiler
410
482
  : `${compiler} && ${compiler} -p tsconfig.tools.json`;
411
483
  packageUtils.addOrRemoveScripts(pkg, this.options.enable, {
@@ -41,7 +41,7 @@ export default class CoreBunGenerator extends Generator {
41
41
  }
42
42
 
43
43
  end() {
44
- if (this.options.enable) {
44
+ if (this.options.enable && !this.options.check) {
45
45
  this.spawnSync("bun", ["install", "--save-text-lockfile"], {});
46
46
 
47
47
  const pkg = this.fs.readJSON(this.destinationPath("package.json"));
@@ -107,7 +107,10 @@ export default class CoreCIGenerator extends Generator {
107
107
  }
108
108
 
109
109
  async default() {
110
- if (fs.existsSync(this.destinationPath(".circleci"))) {
110
+ if (
111
+ !this.options.check &&
112
+ fs.existsSync(this.destinationPath(".circleci"))
113
+ ) {
111
114
  fs.rmdirSync(this.destinationPath(".circleci"), { recursive: true });
112
115
  }
113
116
 
@@ -167,6 +167,8 @@ export default class CoreGitGenerator extends Generator {
167
167
 
168
168
  this.fs.writeJSON(this.destinationPath("package.json"), pkg);
169
169
 
170
+ if (this.options.check) return;
171
+
170
172
  const cwd = this.destinationPath();
171
173
 
172
174
  const isGitRepository =
@@ -264,6 +264,7 @@ export default class CoreGitGithubGenerator extends Generator {
264
264
  }
265
265
 
266
266
  async end() {
267
+ if (this.options.check) return;
267
268
  const pkg = this.fs.readJSON(this.destinationPath("package.json"), {});
268
269
  const owner = this.options.gitHostAccount;
269
270
  const repo = this.options.repoName || getRepoName(pkg.name);
@@ -205,15 +205,24 @@ export default class CorePackageGenerator extends Generator {
205
205
  if (pkg.private) delete pkg.bugs;
206
206
  else if (rootMonorepoPkg.bugs) pkg.bugs = rootMonorepoPkg.bugs;
207
207
 
208
- if (this.fs.exists(this.destinationPath("yarn.lock"))) {
208
+ if (
209
+ !this.options.check &&
210
+ this.fs.exists(this.destinationPath("yarn.lock"))
211
+ ) {
209
212
  fs.unlinkSync(this.destinationPath("yarn.lock"));
210
213
  }
211
- if (this.fs.exists(this.destinationPath("pnpm-lock.yaml"))) {
214
+ if (
215
+ !this.options.check &&
216
+ this.fs.exists(this.destinationPath("pnpm-lock.yaml"))
217
+ ) {
212
218
  fs.unlinkSync(this.destinationPath("pnpm-lock.yaml"));
213
219
  }
214
220
  }
215
221
 
216
- if (this.fs.exists(this.destinationPath("yarn-error.log"))) {
222
+ if (
223
+ !this.options.check &&
224
+ this.fs.exists(this.destinationPath("yarn-error.log"))
225
+ ) {
217
226
  fs.unlinkSync(this.destinationPath("yarn-error.log"));
218
227
  }
219
228
 
@@ -131,7 +131,13 @@ export default class CorePnpmGenerator extends Generator {
131
131
  }
132
132
  config.savePrefix = this.options.type === "app" ? "" : "^";
133
133
  config.minimumReleaseAge = 1440 * 2.5; // 2.5 days in minutes
134
- config.minimumReleaseAgeExclude = minimumReleaseAgeExcludePackages;
134
+ // keep the entries added by renovate for security updates, or manually
135
+ config.minimumReleaseAgeExclude = [
136
+ ...new Set([
137
+ ...minimumReleaseAgeExcludePackages,
138
+ ...(config.minimumReleaseAgeExclude ?? []),
139
+ ]),
140
+ ];
135
141
  config.dedupePeerDependents = true;
136
142
  config.nodeLinker = "hoisted";
137
143
 
@@ -156,7 +162,7 @@ export default class CorePnpmGenerator extends Generator {
156
162
  }
157
163
 
158
164
  end() {
159
- if (this.options.enable) {
165
+ if (this.options.enable && !this.options.check) {
160
166
  if (this.pendingAllowBuilds.length > 0) {
161
167
  console.warn(
162
168
  `pnpm-workspace.yaml: set allowBuilds to true or false for ${this.pendingAllowBuilds.join(", ")}`,
@@ -55,6 +55,13 @@ export default class CoreVSCodeGenerator extends Generator {
55
55
  description: "Typescript enabled",
56
56
  });
57
57
 
58
+ this.option("tailwind", {
59
+ type: Boolean,
60
+ required: false,
61
+ default: false,
62
+ description: "Tailwind enabled, with tailwind-variants (alouette)",
63
+ });
64
+
58
65
  this.option("packageNames", {
59
66
  type: String,
60
67
  required: false,
@@ -95,6 +102,7 @@ export default class CoreVSCodeGenerator extends Generator {
95
102
  yarn: this.options.packageManager === "yarn",
96
103
  pnp: this.options.yarnNodeLinker === "pnp",
97
104
  typescript: this.options.typescript,
105
+ tailwind: this.options.tailwind,
98
106
  },
99
107
  );
100
108
  await copyAndFormatTpl(
@@ -111,6 +119,7 @@ export default class CoreVSCodeGenerator extends Generator {
111
119
  tsrs: withTsrs,
112
120
  testing: this.options.testing,
113
121
  testRunner: this.options.testRunner,
122
+ tailwind: this.options.tailwind,
114
123
  module: pkg.type === "module",
115
124
  },
116
125
  );
@@ -11,7 +11,8 @@
11
11
  "dbaeumer.vscode-eslint",
12
12
  "EditorConfig.EditorConfig",
13
13
  "esbenp.prettier-vscode"<% if (typescript) { -%>,
14
- "TypeScriptTeam.native-preview"<% } %>
14
+ "TypeScriptTeam.native-preview"<% } %><% if (tailwind) { -%>,
15
+ "bradlc.vscode-tailwindcss"<% } %>
15
16
  ],
16
17
  // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
17
18
  "unwantedRecommendations": []
@@ -63,4 +63,9 @@
63
63
  // format config
64
64
  "eslint.format.enable": false,
65
65
  "editor.defaultFormatter": "oxc.oxc-vscode",
66
+ <% if (tailwind) { -%>
67
+
68
+ // tailwind config: class names in tailwind-variants tv() calls
69
+ "tailwindCSS.classFunctions": ["tv"],
70
+ <% } -%>
66
71
  }
@@ -46,7 +46,7 @@ export default class CoreYarnGenerator extends Generator {
46
46
  async initializing() {
47
47
  if (this.options.enable) {
48
48
  // dont use this.fs here, as it will cache the result
49
- if (!fs.existsSync(".yarnrc.yml")) {
49
+ if (!this.options.check && !fs.existsSync(".yarnrc.yml")) {
50
50
  // yarn 2 not yet installed
51
51
  // https://yarnpkg.com/getting-started/install
52
52
  this.spawnSync("yarn", ["set", "version", "stable"]);
@@ -103,6 +103,10 @@ export default class CoreYarnGenerator extends Generator {
103
103
  // this.spawnSync("yarn", ["plugin", "import", nameOrUrl]);
104
104
  // };
105
105
  const removePlugin = (name) => {
106
+ if (this.options.check) {
107
+ console.log(`yarn plugin ${name} would be removed`);
108
+ return;
109
+ }
106
110
  this.spawnSync("yarn", ["plugin", "remove", name]);
107
111
  };
108
112
 
@@ -224,7 +228,7 @@ export default class CoreYarnGenerator extends Generator {
224
228
 
225
229
  end() {
226
230
  this.fs.delete(this.destinationPath(".yarn/build-state.yml"));
227
- if (this.options.enable) {
231
+ if (this.options.enable && !this.options.check) {
228
232
  if (this.options.yarnNodeLinker === "pnp") {
229
233
  this.spawnSync("yarn", ["dlx", "@yarnpkg/sdks", "vscode"]);
230
234
  } else {
@@ -476,19 +476,21 @@ export default class PobLibGenerator extends Generator {
476
476
  }
477
477
 
478
478
  this.fs.writeJSON(this.destinationPath("package.json"), pkg);
479
- [
480
- "lib-node14",
481
- "lib-node16",
482
- "coverage",
483
- this.pobjson.documentation && "generated-docs",
484
- !(withBabel || withTypescript) && "dist",
485
- ]
486
- .filter(Boolean)
487
- .forEach((path) => {
488
- rmSync(path, { recursive: true, force: true });
489
- });
490
-
491
- removeLegacyGeneratedDocs((path) => this.destinationPath(path));
479
+ if (!this.options.check) {
480
+ [
481
+ "lib-node14",
482
+ "lib-node16",
483
+ "coverage",
484
+ this.pobjson.documentation && "generated-docs",
485
+ !(withBabel || withTypescript) && "dist",
486
+ ]
487
+ .filter(Boolean)
488
+ .forEach((path) => {
489
+ rmSync(path, { recursive: true, force: true });
490
+ });
491
+
492
+ removeLegacyGeneratedDocs((path) => this.destinationPath(path));
493
+ }
492
494
 
493
495
  const { pobjson } = this;
494
496
 
@@ -2,7 +2,9 @@ import { execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import { platform } from "node:process";
4
4
  import Generator from "yeoman-generator";
5
+ import { isAlouetteApp } from "../../utils/alouette.js";
5
6
  import removeLegacyGeneratedDocs from "../../utils/legacyGeneratedDocs.js";
7
+ import { isNodeApp } from "../../utils/nodeApps.js";
6
8
  import * as packageUtils from "../../utils/package.js";
7
9
  import { packageManagerRun } from "../../utils/packageManagerUtils.js";
8
10
  import {
@@ -389,6 +391,9 @@ export default class PobMonorepoGenerator extends Generator {
389
391
  typescript: this.pobMonorepoConfig.typescript,
390
392
  testing: this.pobMonorepoConfig.testing,
391
393
  testRunner: this.pobMonorepoConfig.testRunner,
394
+ tailwind: this.packageConfigs.some((config, index) =>
395
+ isAlouetteApp(config, this.packages[index]),
396
+ ),
392
397
  packageNames: JSON.stringify(packageNames),
393
398
  packageLocations: JSON.stringify(this.packageLocations),
394
399
  });
@@ -428,6 +433,17 @@ export default class PobMonorepoGenerator extends Generator {
428
433
  isAppProject: this.options.isAppProject,
429
434
  packageNames: JSON.stringify(packageNames),
430
435
  packagePaths: JSON.stringify(packagePaths),
436
+ // mirrors the includeScripts option given by the app generator
437
+ packagePathsWithScriptsIncluded: JSON.stringify(
438
+ this.packageLocations.filter(
439
+ (location, index) =>
440
+ this.pobMonorepoConfig.typescript !== "check-only" &&
441
+ this.packages[index].pob?.typescript !== "check-only" &&
442
+ isNodeApp(this.packageConfigs[index]) &&
443
+ packagePaths.includes(location) &&
444
+ fs.existsSync(`${location}/scripts`),
445
+ ),
446
+ ),
431
447
  testRunner: this.pobMonorepoConfig.testRunner,
432
448
  onlyLatestLTS: this.options.onlyLatestLTS,
433
449
  packageManager: this.options.packageManager,
@@ -435,6 +451,8 @@ export default class PobMonorepoGenerator extends Generator {
435
451
 
436
452
  this.fs.writeJSON(this.destinationPath("package.json"), pkg);
437
453
 
454
+ if (this.options.check) return;
455
+
438
456
  if (platform !== "win32") {
439
457
  execSync(
440
458
  `rm -Rf ${["lib-*", "coverage", "generated-docs"].filter(Boolean).join(" ")}`,
@@ -57,6 +57,14 @@ export default class MonorepoTypescriptGenerator extends Generator {
57
57
  required: true,
58
58
  });
59
59
 
60
+ this.option("packagePathsWithScriptsIncluded", {
61
+ type: String,
62
+ required: false,
63
+ default: "[]",
64
+ description:
65
+ "packages whose tsconfig.json includes their scripts (node apps)",
66
+ });
67
+
60
68
  this.option("testRunner", {
61
69
  type: String,
62
70
  required: false,
@@ -85,7 +93,8 @@ export default class MonorepoTypescriptGenerator extends Generator {
85
93
  // emitting tsconfig, so it needs a dedicated noEmit project for type-aware
86
94
  // linting and tsc -b: root config files (e.g. vitest.config.ts), a root
87
95
  // scripts/ dir, and each package's scripts/ dir (matched via the workspace
88
- // globs so all packages are covered)
96
+ // globs so all packages are covered, except node apps which include them in
97
+ // their own tsconfig.json)
89
98
  #hasTools() {
90
99
  return (
91
100
  hasRootConfigFiles(this.destinationPath()) ||
@@ -199,6 +208,9 @@ export default class MonorepoTypescriptGenerator extends Generator {
199
208
  {
200
209
  nodeVersion: latestLTS,
201
210
  workspaceGlobs,
211
+ packagePathsWithScriptsIncluded: JSON.parse(
212
+ this.options.packagePathsWithScriptsIncluded,
213
+ ),
202
214
  },
203
215
  );
204
216
  } else {
@@ -21,6 +21,13 @@
21
21
  "<%= workspaceGlob %>/e2e/**/*.cts"<%= index === workspaceGlobs.length - 1 ? '' : ',' %>
22
22
  <% }) -%>
23
23
  ],
24
+ <% if (packagePathsWithScriptsIncluded.length > 0) { -%>
25
+ "exclude": [
26
+ <% packagePathsWithScriptsIncluded.forEach((packagePath, index) => { -%>
27
+ "<%= packagePath %>/scripts/**"<%= index === packagePathsWithScriptsIncluded.length - 1 ? '' : ',' %>
28
+ <% }) -%>
29
+ ],
30
+ <% } -%>
24
31
  "compilerOptions": {
25
32
  "noEmit": true,
26
33
  "tsBuildInfoFile": "node_modules/.cache/tsc/tsbuildinfo-tools",
@@ -199,21 +199,26 @@ export default class MonorepoWorkspacesGenerator extends Generator {
199
199
  return;
200
200
  }
201
201
  console.log(`=> update ${packagePath}`);
202
- spawnSync(
202
+ const { status } = spawnSync(
203
203
  process.argv[0],
204
204
  [
205
205
  process.argv[1],
206
206
  "update",
207
207
  "from-pob",
208
- this.options.force ? "--force" : undefined,
208
+ this.options.check && "--check",
209
+ !this.options.check && this.options.force && "--force",
209
210
  ].filter(Boolean),
210
211
  {
211
212
  cwd: packagePath,
212
213
  stdio: "inherit",
213
214
  },
214
215
  );
216
+ // with --check, a package exits with 1 when a file would change
217
+ if (this.options.check && status !== 0) process.exitCode = 1;
215
218
  });
216
219
 
220
+ if (this.options.check) return;
221
+
217
222
  switch (this.options.packageManager) {
218
223
  case "npm":
219
224
  this.spawnSync("npm", ["install"]);
@@ -45,7 +45,9 @@ export default class PobBaseGenerator extends Generator {
45
45
 
46
46
  async initializing() {
47
47
  // format package.json to ensure diff is correct
48
- await ensureJsonFileFormatted(this.destinationPath("package.json"));
48
+ if (!this.options.check) {
49
+ await ensureJsonFileFormatted(this.destinationPath("package.json"));
50
+ }
49
51
 
50
52
  if (this.options.monorepo) {
51
53
  this.isMonorepo = true;
@@ -296,7 +298,7 @@ export default class PobBaseGenerator extends Generator {
296
298
  }
297
299
 
298
300
  install() {
299
- if (this.options.fromPob) return;
301
+ if (this.options.fromPob || this.options.check) return;
300
302
 
301
303
  switch (this.#getPackageManager()) {
302
304
  case "npm":
package/lib/pob.js CHANGED
@@ -51,12 +51,42 @@ import { getNewPackagePath } from "./utils/workspaceUtils.js";
51
51
 
52
52
  const printUsage = () => {
53
53
  console.error("Usage: pob [monorepo] [lib|app|init]");
54
- console.error(" pob [monorepo] update [--force]");
54
+ console.error(" pob [monorepo] update [--force|--check]");
55
55
  console.error(
56
56
  " pob add <packageName> [--app <appType> [--testing] [--e2e]]",
57
57
  );
58
58
  };
59
59
 
60
+ const printHelp = () => {
61
+ console.log(`Usage: pob [monorepo] [lib|app|init]
62
+ pob [monorepo] update [from-pob] [--force|--check]
63
+ pob add <packageName> [--app <appType> [--testing] [--e2e]]
64
+ pob migrate-to-monorepo
65
+
66
+ Commands:
67
+ lib Generate a library
68
+ app Generate an app
69
+ init Initialize pob config, prompting for project type
70
+ update Regenerate files from the existing .yo-rc.json, without prompts
71
+ update from-pob Same as update, without running install or build
72
+ add <packageName> Add a new package to the monorepo workspaces
73
+ migrate-to-monorepo Move the current project into packages/<name> and create a monorepo root
74
+
75
+ monorepo Prefix to force monorepo mode (auto-detected from workspaces or lerna.json)
76
+
77
+ Options:
78
+ --force Overwrite every file without asking
79
+ --check With update (implied when no command is given): print
80
+ the diffs without writing anything or running install,
81
+ exit with code 1 when a file would change
82
+ --app <appType> With add: generate an app package without prompts
83
+ (alp, vite, vite-with-server, next.js, node, node-library,
84
+ untranspiled-library, alp-node, expo, other)
85
+ --testing With add --app: enable testing
86
+ --e2e With add --app: enable e2e testing
87
+ -h, --help Show this help`);
88
+ };
89
+
60
90
  const readJson = (filepath) => {
61
91
  try {
62
92
  return JSON.parse(readFileSync(filepath, "utf8"));
@@ -69,14 +99,39 @@ const readJson = (filepath) => {
69
99
  // console.log(pkg.version);
70
100
  // };
71
101
 
72
- const argv = minimist(process.argv.slice(2));
102
+ const argv = minimist(process.argv.slice(2), { alias: { h: "help" } });
103
+
104
+ if (argv.help || argv._[0] === "help") {
105
+ printHelp();
106
+ process.exit(0);
107
+ }
73
108
 
74
109
  if (argv.version) {
75
110
  // printVersion();
76
111
  process.exit(0);
77
112
  }
78
113
 
79
- const env = yeoman.createEnv();
114
+ const check = !!argv.check;
115
+
116
+ // dryRun: the conflicter prints the diffs and skips every write.
117
+ // sharedOptions.check: generators skip their side effects outside mem-fs.
118
+ const env = yeoman.createEnv(
119
+ check
120
+ ? { dryRun: true, skipInstall: true, sharedOptions: { check: true } }
121
+ : {},
122
+ );
123
+
124
+ // the conflicter clears its file statuses after each file: collect them as logged
125
+ const changedFiles = [];
126
+ if (check) {
127
+ for (const status of ["create", "conflict"]) {
128
+ const log = env.adapter.log[status];
129
+ env.adapter.log[status] = (filePath, ...args) => {
130
+ changedFiles.push(filePath);
131
+ return log.call(env.adapter.log, filePath, ...args);
132
+ };
133
+ }
134
+ }
80
135
 
81
136
  env.on("error", (err) => {
82
137
  console.error(err.stack || err.message || err);
@@ -266,7 +321,13 @@ env.registerStub(
266
321
  const projectPkg = readJson(path.resolve("./package.json"));
267
322
  const monorepoArg = argv._[0] === "lerna" || argv._[0] === "monorepo";
268
323
  let monorepo = monorepoArg || !!(projectPkg && projectPkg.workspaces);
269
- const action = monorepoArg ? argv._[1] : argv._[0];
324
+ const action = (monorepoArg ? argv._[1] : argv._[0]) ?? (check && "update");
325
+
326
+ if (check && action !== "update") {
327
+ console.error("--check can only be used with update");
328
+ printUsage();
329
+ process.exit(1);
330
+ }
270
331
 
271
332
  if (action === "add") {
272
333
  if (!projectPkg.workspaces) {
@@ -417,7 +478,8 @@ const options = {
417
478
  updateOnly,
418
479
  monorepo,
419
480
  fromPob,
420
- force: argv.force,
481
+ // force bypasses the conflicter diff
482
+ force: check ? false : argv.force,
421
483
  };
422
484
 
423
485
  try {
@@ -429,6 +491,14 @@ try {
429
491
  }
430
492
  }
431
493
 
494
+ if (check) {
495
+ if (changedFiles.length > 0) {
496
+ console.error(`pob --check: ${changedFiles.length} file(s) would change:`);
497
+ for (const file of changedFiles) console.error(` ${file}`);
498
+ process.exitCode = 1;
499
+ }
500
+ }
501
+
432
502
  // generator.on('error', (err) => {
433
503
  // console.error(err.stack || err.message || err);
434
504
  // process.exit(1);
@@ -0,0 +1,18 @@
1
+ // alouette styles components with tailwind (nativewind) and tailwind-variants
2
+
3
+ /** @param {Record<string, any>} pkg */
4
+ export const detectAlouette = (pkg) =>
5
+ [pkg.dependencies, pkg.devDependencies].some(
6
+ (dependencies) => dependencies?.alouette !== undefined,
7
+ );
8
+
9
+ /**
10
+ * Fallback on the dependencies for an app config missing the option.
11
+ *
12
+ * @param {Record<string, any> | undefined} pobConfig
13
+ * @param {Record<string, any>} pkg
14
+ */
15
+ export const isAlouetteApp = (pobConfig, pkg) =>
16
+ pobConfig?.project?.type === "app" &&
17
+ pobConfig.app?.type === "expo" &&
18
+ (pobConfig.app.alouette ?? detectAlouette(pkg));
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildDependentsMaps, discoverWorkspaces } from "./workspaceUtils.js";
4
+
5
+ export const appsWithBrowser = ["alp", "next.js", "vite", "vite-with-server"];
6
+
7
+ const expoTarget = "@pob/root/tsconfigs/targets/expo.json";
8
+
9
+ // based on the app type from the pob config rather than on the package.json
10
+ // dependencies, which can change without pob being run again
11
+ export const getAppTsconfigTarget = ({ appType, dom, withBun }) => {
12
+ if (appType === "expo") return expoTarget;
13
+ if (dom) {
14
+ return appType === "vite" || appType === "vite-with-server"
15
+ ? "@pob/root/tsconfigs/targets/vite.json"
16
+ : "@pob/root/tsconfigs/targets/webpack.json";
17
+ }
18
+ if (withBun) return "@pob/root/tsconfigs/targets/bun.json";
19
+ return undefined;
20
+ };
21
+
22
+ const readPobConfig = (location) => {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(path.join(location, ".yo-rc.json"))).pob;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ };
29
+
30
+ /**
31
+ * Target of the apps directly depending on the package, for packages compiled
32
+ * by the bundler of these apps (untranspiled libraries). Expo wins as the
33
+ * widest target, otherwise the apps must agree on a single target.
34
+ */
35
+ export const getConsumerAppsTsconfigTarget = async ({
36
+ rootPath,
37
+ packageName,
38
+ withBun,
39
+ }) => {
40
+ const workspaces = await discoverWorkspaces(rootPath);
41
+ const workspace = workspaces.find((w) => w.name === packageName);
42
+ if (!workspace) return undefined;
43
+
44
+ const dependents = buildDependentsMaps(workspaces).get(workspace) || [];
45
+ const targets = new Set();
46
+ for (const [dependent] of dependents) {
47
+ const pobConfig = readPobConfig(path.join(rootPath, dependent.location));
48
+ if (pobConfig?.project?.type !== "app") continue;
49
+ const appType = pobConfig.app?.type;
50
+ const target = getAppTsconfigTarget({
51
+ appType,
52
+ dom: appsWithBrowser.includes(appType),
53
+ withBun,
54
+ });
55
+ if (target) targets.add(target);
56
+ }
57
+
58
+ if (targets.has(expoTarget)) return expoTarget;
59
+ return targets.size === 1 ? [...targets][0] : undefined;
60
+ };
@@ -0,0 +1,169 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ getAppTsconfigTarget,
7
+ getConsumerAppsTsconfigTarget,
8
+ } from "./appTsconfigTarget.js";
9
+
10
+ const targets = {
11
+ expo: "@pob/root/tsconfigs/targets/expo.json",
12
+ vite: "@pob/root/tsconfigs/targets/vite.json",
13
+ webpack: "@pob/root/tsconfigs/targets/webpack.json",
14
+ bun: "@pob/root/tsconfigs/targets/bun.json",
15
+ };
16
+
17
+ describe("getAppTsconfigTarget", () => {
18
+ it("returns expo for expo apps", () => {
19
+ expect(getAppTsconfigTarget({ appType: "expo", dom: false })).toBe(
20
+ targets.expo,
21
+ );
22
+ });
23
+
24
+ it("returns vite for vite apps", () => {
25
+ expect(getAppTsconfigTarget({ appType: "vite", dom: true })).toBe(
26
+ targets.vite,
27
+ );
28
+ expect(
29
+ getAppTsconfigTarget({ appType: "vite-with-server", dom: true }),
30
+ ).toBe(targets.vite);
31
+ });
32
+
33
+ it("returns webpack for other browser apps and dom libraries", () => {
34
+ expect(getAppTsconfigTarget({ appType: "alp", dom: true })).toBe(
35
+ targets.webpack,
36
+ );
37
+ expect(getAppTsconfigTarget({ appType: undefined, dom: true })).toBe(
38
+ targets.webpack,
39
+ );
40
+ });
41
+
42
+ it("returns bun without dom with bun", () => {
43
+ expect(
44
+ getAppTsconfigTarget({ appType: "node", dom: false, withBun: true }),
45
+ ).toBe(targets.bun);
46
+ });
47
+
48
+ it("returns undefined for node apps", () => {
49
+ expect(getAppTsconfigTarget({ appType: "node", dom: false })).toBe(
50
+ undefined,
51
+ );
52
+ });
53
+ });
54
+
55
+ async function writeWorkspace(rootPath, location, pkg, pobConfig) {
56
+ const dir = path.join(rootPath, location);
57
+ await fs.mkdir(dir, { recursive: true });
58
+ await fs.writeFile(path.join(dir, "package.json"), JSON.stringify(pkg));
59
+ if (pobConfig) {
60
+ await fs.writeFile(
61
+ path.join(dir, ".yo-rc.json"),
62
+ JSON.stringify({ pob: pobConfig }),
63
+ );
64
+ }
65
+ }
66
+
67
+ const app = (type) => ({ project: { type: "app" }, app: { type } });
68
+
69
+ /**
70
+ * Monorepo with an untranspiled library "@ex/lib" and the given consumers,
71
+ * each one an [appType, pobConfig] pair depending on the library.
72
+ */
73
+ async function prepareMonorepo(consumers) {
74
+ const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), "pob-"));
75
+ await writeWorkspace(rootPath, ".", {
76
+ name: "root",
77
+ workspaces: ["packages/*"],
78
+ });
79
+ await writeWorkspace(
80
+ rootPath,
81
+ "packages/lib",
82
+ { name: "@ex/lib" },
83
+ app("untranspiled-library"),
84
+ );
85
+ for (const [name, pobConfig] of consumers) {
86
+ await writeWorkspace(
87
+ rootPath,
88
+ `packages/${name}`,
89
+ { name: `@ex/${name}`, dependencies: { "@ex/lib": "workspace:*" } },
90
+ pobConfig,
91
+ );
92
+ }
93
+ return rootPath;
94
+ }
95
+
96
+ const getLibTarget = async (consumers, { withBun = false } = {}) =>
97
+ getConsumerAppsTsconfigTarget({
98
+ rootPath: await prepareMonorepo(consumers),
99
+ packageName: "@ex/lib",
100
+ withBun,
101
+ });
102
+
103
+ describe("getConsumerAppsTsconfigTarget", () => {
104
+ it("uses the target of a consumer expo app", async () => {
105
+ expect(await getLibTarget([["mobile", app("expo")]])).toBe(targets.expo);
106
+ });
107
+
108
+ it("prefers expo when consumer apps disagree", async () => {
109
+ expect(
110
+ await getLibTarget([
111
+ ["mobile", app("expo")],
112
+ ["web", app("vite")],
113
+ ["api", app("node")],
114
+ ]),
115
+ ).toBe(targets.expo);
116
+ });
117
+
118
+ it("uses the target shared by all consumer apps", async () => {
119
+ expect(
120
+ await getLibTarget([
121
+ ["web", app("vite")],
122
+ ["api", app("node")],
123
+ ]),
124
+ ).toBe(targets.vite);
125
+ });
126
+
127
+ it("returns undefined when consumer apps disagree without expo", async () => {
128
+ expect(
129
+ await getLibTarget([
130
+ ["web", app("vite")],
131
+ ["legacy", app("alp")],
132
+ ]),
133
+ ).toBe(undefined);
134
+ });
135
+
136
+ it("returns undefined without consumer apps with a target", async () => {
137
+ expect(await getLibTarget([])).toBe(undefined);
138
+ expect(await getLibTarget([["api", app("node")]])).toBe(undefined);
139
+ expect(
140
+ await getLibTarget([["other-lib", { project: { type: "lib" } }]]),
141
+ ).toBe(undefined);
142
+ expect(await getLibTarget([["no-config", undefined]])).toBe(undefined);
143
+ });
144
+
145
+ it("uses bun for node consumer apps with bun", async () => {
146
+ expect(await getLibTarget([["api", app("node")]], { withBun: true })).toBe(
147
+ targets.bun,
148
+ );
149
+ });
150
+
151
+ it("ignores apps depending on the library through another library", async () => {
152
+ const rootPath = await prepareMonorepo([
153
+ ["other-lib", app("untranspiled-library")],
154
+ ]);
155
+ await writeWorkspace(
156
+ rootPath,
157
+ "packages/mobile",
158
+ { name: "@ex/mobile", dependencies: { "@ex/other-lib": "workspace:*" } },
159
+ app("expo"),
160
+ );
161
+ expect(
162
+ await getConsumerAppsTsconfigTarget({
163
+ rootPath,
164
+ packageName: "@ex/lib",
165
+ withBun: false,
166
+ }),
167
+ ).toBe(undefined);
168
+ });
169
+ });
@@ -0,0 +1,8 @@
1
+ // node backend apps: their scripts run in the same environment as their
2
+ // sources, so the package tsconfig.json type-checks them too instead of a
3
+ // separate tsconfig.tools.json
4
+ export const nodeAppTypes = ["node", "node-library", "alp-node"];
5
+
6
+ export const isNodeApp = (pobConfig) =>
7
+ pobConfig?.project?.type === "app" &&
8
+ nodeAppTypes.includes(pobConfig.app?.type);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pob",
3
- "version": "40.3.3",
3
+ "version": "40.4.0",
4
4
  "description": "Pile of bones, library generator with git/babel/typescript/typedoc/readme/vitest",
5
5
  "keywords": [
6
6
  "skeleton"
@@ -32,8 +32,8 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@pob/codemods": "0.18.2",
35
- "@pob/eslint-config": "68.0.0",
36
- "@pob/eslint-config-typescript-react": "68.0.0",
35
+ "@pob/eslint-config": "68.1.0",
36
+ "@pob/eslint-config-typescript-react": "68.1.0",
37
37
  "@pob/sort-object": "11.2.0",
38
38
  "@pob/sort-pkg": "13.2.0",
39
39
  "@types/inquirer": "9.0.10",
@@ -51,7 +51,7 @@
51
51
  "minimist": "1.2.8",
52
52
  "oxfmt": "0.72.0",
53
53
  "parse-author": "2.0.0",
54
- "pob-dependencies": "28.2.0",
54
+ "pob-dependencies": "28.3.0",
55
55
  "semver": "7.8.5",
56
56
  "typescript": "npm:@typescript/typescript6@6.0.2",
57
57
  "validate-npm-package-name": "^8.0.0",
@@ -59,7 +59,7 @@
59
59
  "yeoman-generator": "10.3.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@pob/root": "28.2.2",
62
+ "@pob/root": "28.3.0",
63
63
  "@types/node": "24.19.1",
64
64
  "@typescript/native": "npm:typescript@7.0.2",
65
65
  "@yeoman/types": "1.13.1",