delacour 0.5.0-alpha.20261021114607 → 0.5.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 (2) hide show
  1. package/dist/index.js +871 -521
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4068,6 +4068,70 @@ ${r ? styleText("cyan", S_BAR_END) : ""}
4068
4068
  }
4069
4069
  }).prompt();
4070
4070
  //#endregion
4071
+ //#region src/registry/test-runner.ts
4072
+ /**
4073
+ * The runner a project's copied tests are written for.
4074
+ *
4075
+ * The library's suites are Bun's, and the registry build holds every shipped one
4076
+ * to the API Bun, Jest and Vitest share — `describe` / `test` / `it` / `expect`
4077
+ * and the four hooks, a `node:*` read beside itself through
4078
+ * `import.meta.dirname`, and nothing else. So a suite moves between runners by
4079
+ * two substitutions and no parsing: the module the API is imported from, and,
4080
+ * for Jest, which cannot evaluate `import.meta` in CommonJS, `__dirname`.
4081
+ */
4082
+ const TEST_RUNNERS = [
4083
+ "bun",
4084
+ "jest",
4085
+ "vitest"
4086
+ ];
4087
+ /** Where each runner's shared API is imported from. */
4088
+ const RUNNER_MODULE = {
4089
+ bun: "bun:test",
4090
+ jest: "@jest/globals",
4091
+ vitest: "vitest"
4092
+ };
4093
+ /**
4094
+ * What a project needs to typecheck and run its copied tests. `node:*` imports
4095
+ * need Node's types everywhere but Bun, whose own types carry them.
4096
+ */
4097
+ const RUNNER_PACKAGES = {
4098
+ bun: ["@types/bun"],
4099
+ jest: ["@jest/globals", "@types/node"],
4100
+ vitest: ["@types/node", "vitest"]
4101
+ };
4102
+ const BUN_TEST_IMPORT = /(from\s*)(["'])bun:test\2/g;
4103
+ /** A copied test's text as `runner` runs it. */
4104
+ function adaptTest(content, runner) {
4105
+ if (runner === "bun") return content;
4106
+ const imported = content.replace(BUN_TEST_IMPORT, (_match, from, quote) => {
4107
+ return `${from}${quote}${RUNNER_MODULE[runner]}${quote}`;
4108
+ });
4109
+ return runner === "jest" ? imported.replaceAll("import.meta.dirname", "__dirname") : imported;
4110
+ }
4111
+ function testRunnerPackages(runner) {
4112
+ return [...RUNNER_PACKAGES[runner]];
4113
+ }
4114
+ /**
4115
+ * The runner a `package.json` tests with, or `null` when it names none.
4116
+ *
4117
+ * The `test` script is read first: it is the runner the project actually
4118
+ * invokes, where a dependency may be a leftover. `jest-expo` is Expo's own
4119
+ * preset, so it counts as Jest.
4120
+ */
4121
+ function detectTestRunner(manifest) {
4122
+ const script = manifest.scripts?.test ?? "";
4123
+ if (/\bvitest\b/.test(script)) return "vitest";
4124
+ if (/\bjest\b/.test(script)) return "jest";
4125
+ if (/\bbun\s+test\b/.test(script)) return "bun";
4126
+ const packages = {
4127
+ ...manifest.dependencies,
4128
+ ...manifest.devDependencies
4129
+ };
4130
+ if ("vitest" in packages) return "vitest";
4131
+ if ("jest" in packages || "jest-expo" in packages || manifest.jest !== void 0) return "jest";
4132
+ return null;
4133
+ }
4134
+ //#endregion
4071
4135
  //#region ../../node_modules/zod/v4/core/util.js
4072
4136
  function getEnumValues(entries) {
4073
4137
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
@@ -10016,6 +10080,12 @@ const registrySchema = object$1({
10016
10080
  const packageSchema = object$1({
10017
10081
  /** The package's npm name, and therefore the app's import prefix. */
10018
10082
  name: string().min(1) });
10083
+ /**
10084
+ * How copied tests are written. Without a `runner`, `add --tests` reads the one
10085
+ * the project's `package.json` tests with, and falls back to Bun's, which the
10086
+ * library's suites are written for.
10087
+ */
10088
+ const testsSchema = object$1({ runner: _enum(TEST_RUNNERS).optional() });
10019
10089
  const configSchema = object$1({
10020
10090
  $schema: string().optional(),
10021
10091
  framework: _enum(["expo", "react-native"]).default("expo"),
@@ -10027,7 +10097,8 @@ const configSchema = object$1({
10027
10097
  aliases: aliasesSchema.default({}),
10028
10098
  /** Absent when the components live in the app itself. */
10029
10099
  package: packageSchema.optional(),
10030
- app: appSchema.prefault({})
10100
+ app: appSchema.prefault({}),
10101
+ tests: testsSchema.optional()
10031
10102
  });
10032
10103
  const CONFIG_FILENAME = "native-components.json";
10033
10104
  const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/delacournz/delacour-ui/main/registry/config.schema.json";
@@ -10069,14 +10140,35 @@ async function readConfig(configPath) {
10069
10140
  const issues = result.error.issues.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`);
10070
10141
  throw new Error(`${configPath} is not a valid config:\n${issues.join("\n")}`);
10071
10142
  }
10072
- return resolveConfig(result.data, dirname(configPath), configPath);
10143
+ const root = dirname(configPath);
10144
+ const detected = result.data.tests?.runner ? null : await readTestRunner([root, absolute(root, result.data.app.root)]);
10145
+ return resolveConfig(result.data, root, configPath, detected);
10146
+ }
10147
+ /**
10148
+ * The runner the first of `directories` to name one tests with. The config's
10149
+ * own `package.json` comes first — in a monorepo that is the shared package the
10150
+ * tests land in — then the app's.
10151
+ */
10152
+ async function readTestRunner(directories) {
10153
+ for (const directory of new Set(directories)) {
10154
+ let manifest;
10155
+ try {
10156
+ manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf-8"));
10157
+ } catch {
10158
+ continue;
10159
+ }
10160
+ const runner = detectTestRunner(manifest);
10161
+ if (runner) return runner;
10162
+ }
10163
+ return null;
10073
10164
  }
10074
- function resolveConfig(config, root, configPath) {
10165
+ function resolveConfig(config, root, configPath, detectedRunner = null) {
10075
10166
  const appRoot = absolute(root, config.app.root);
10076
10167
  return {
10077
10168
  ...config,
10078
10169
  root,
10079
10170
  configPath,
10171
+ testRunner: config.tests?.runner ?? detectedRunner ?? "bun",
10080
10172
  directories: {
10081
10173
  ui: absolute(root, config.paths.ui),
10082
10174
  lib: absolute(root, config.paths.lib),
@@ -10405,10 +10497,10 @@ const LOCKFILES = [
10405
10497
  async function detectProject(cwd) {
10406
10498
  const packageJsonPath = findUp(cwd, "package.json");
10407
10499
  const packageRoot = packageJsonPath ? dirname(packageJsonPath) : null;
10408
- const packageJson = packageJsonPath ? await readJson$1(packageJsonPath) : null;
10500
+ const packageJson = packageJsonPath ? await readJson$2(packageJsonPath) : null;
10409
10501
  const workspaceRoot = await findWorkspaceRoot(packageRoot ?? cwd);
10410
10502
  const appRoot = await findAppRoot(cwd, workspaceRoot);
10411
- const appPackage = appRoot ? await readJson$1(join(appRoot, "package.json")) : null;
10503
+ const appPackage = appRoot ? await readJson$2(join(appRoot, "package.json")) : null;
10412
10504
  const tsconfigPath = findUp(cwd, "tsconfig.json");
10413
10505
  const tsconfig = tsconfigPath ? await readJsonc(tsconfigPath) : null;
10414
10506
  return {
@@ -10444,7 +10536,7 @@ async function findWorkspaceRoot(from) {
10444
10536
  let directory = resolve(from);
10445
10537
  while (true) {
10446
10538
  if (existsSync(join(directory, "pnpm-workspace.yaml"))) return directory;
10447
- if ((await readJson$1(join(directory, "package.json")))?.workspaces) return directory;
10539
+ if ((await readJson$2(join(directory, "package.json")))?.workspaces) return directory;
10448
10540
  const parent = dirname(directory);
10449
10541
  if (parent === directory) return null;
10450
10542
  directory = parent;
@@ -10460,7 +10552,7 @@ async function findWorkspaceRoot(from) {
10460
10552
  async function findAppRoot(cwd, workspaceRoot) {
10461
10553
  let directory = resolve(cwd);
10462
10554
  while (true) {
10463
- const packageJson = await readJson$1(join(directory, "package.json"));
10555
+ const packageJson = await readJson$2(join(directory, "package.json"));
10464
10556
  if (packageJson && versionOf(packageJson, "expo")) return directory;
10465
10557
  const parent = dirname(directory);
10466
10558
  if (parent === directory || workspaceRoot && directory === workspaceRoot) break;
@@ -10479,7 +10571,7 @@ async function findExpoPackageIn(directory) {
10479
10571
  }
10480
10572
  for (const entry of entries.sort()) {
10481
10573
  const candidate = join(directory, entry);
10482
- const packageJson = await readJson$1(join(candidate, "package.json"));
10574
+ const packageJson = await readJson$2(join(candidate, "package.json"));
10483
10575
  if (packageJson && versionOf(packageJson, "expo")) return candidate;
10484
10576
  }
10485
10577
  return null;
@@ -10503,7 +10595,7 @@ function findUp(from, filename) {
10503
10595
  directory = parent;
10504
10596
  }
10505
10597
  }
10506
- async function readJson$1(path) {
10598
+ async function readJson$2(path) {
10507
10599
  try {
10508
10600
  return JSON.parse(await readFile(path, "utf-8"));
10509
10601
  } catch {
@@ -10863,7 +10955,7 @@ const exec = x;
10863
10955
  function channelFor(version) {
10864
10956
  return /-alpha\b/.test(version) ? "alpha" : "latest";
10865
10957
  }
10866
- const CLI_CHANNEL = channelFor("0.5.0-alpha.20261021114607");
10958
+ const CLI_CHANNEL = channelFor("0.5.0");
10867
10959
  //#endregion
10868
10960
  //#region src/project/package-manager.ts
10869
10961
  const ADD = {
@@ -10971,9 +11063,9 @@ function installCommands(request) {
10971
11063
  function planDependencies(request, packageJson) {
10972
11064
  const groups = installCommands({
10973
11065
  packageManager: request.packageManager,
10974
- expoDependencies: missingPackages(packageJson, request.expoDependencies),
10975
- dependencies: missingPackages(packageJson, request.dependencies),
10976
- devDependencies: missingPackages(packageJson, request.devDependencies),
11066
+ expoDependencies: missingPackages$1(packageJson, request.expoDependencies),
11067
+ dependencies: missingPackages$1(packageJson, request.dependencies),
11068
+ devDependencies: missingPackages$1(packageJson, request.devDependencies),
10977
11069
  channel: request.channel
10978
11070
  });
10979
11071
  const wanted = unique([
@@ -10998,7 +11090,7 @@ function unique(names) {
10998
11090
  return [...new Set(names)].sort();
10999
11091
  }
11000
11092
  /** The packages in `wanted` that the project does not already have. */
11001
- function missingPackages(packageJson, wanted) {
11093
+ function missingPackages$1(packageJson, wanted) {
11002
11094
  const installed = /* @__PURE__ */ new Set([...Object.keys(packageJson?.dependencies ?? {}), ...Object.keys(packageJson?.devDependencies ?? {})]);
11003
11095
  return wanted.filter((name) => !installed.has(name));
11004
11096
  }
@@ -11117,7 +11209,7 @@ function sharedTsconfig() {
11117
11209
  async function syncSharedPackage(config, peers) {
11118
11210
  if (!config.package) return null;
11119
11211
  const path = join(config.root, "package.json");
11120
- const existing = await readJson(path);
11212
+ const existing = await readJson$1(path);
11121
11213
  const merged = mergePackageJson(existing, {
11122
11214
  name: config.package.name,
11123
11215
  exports: buildExportsMap(config.paths, await collectWritten(config)),
@@ -11144,7 +11236,7 @@ async function linkPackageToApp(config) {
11144
11236
  if (!config.package) return false;
11145
11237
  if (config.app.resolved.root === config.root) return false;
11146
11238
  const path = join(config.app.resolved.root, "package.json");
11147
- const app = await readJson(path);
11239
+ const app = await readJson$1(path);
11148
11240
  if (!app) return false;
11149
11241
  const name = config.package.name;
11150
11242
  if (app.dependencies?.[name] || app.peerDependencies?.[name]) return false;
@@ -11170,7 +11262,7 @@ async function listFiles(root, recursive) {
11170
11262
  withFileTypes: true
11171
11263
  })).filter((entry) => entry.isFile()).map((entry) => toPosix$6(relative(root, join(entry.parentPath, entry.name)))).sort();
11172
11264
  }
11173
- async function readJson(path) {
11265
+ async function readJson$1(path) {
11174
11266
  try {
11175
11267
  return JSON.parse(await readFile(path, "utf-8"));
11176
11268
  } catch {
@@ -11184,6 +11276,212 @@ function toPosix$6(path) {
11184
11276
  return sep === "/" ? path : path.split(sep).join("/");
11185
11277
  }
11186
11278
  //#endregion
11279
+ //#region src/registry/classify.ts
11280
+ /**
11281
+ * Maps a path inside `packages/react-native-ui/src` onto the registry item it
11282
+ * belongs to and where it lands in a consumer's project.
11283
+ *
11284
+ * The rules here are `react-native-ui`'s own conventions, restated — one component
11285
+ * per folder with an `index.ts` entry point, flat files under `lib/`, `hooks/`
11286
+ * and `icons/`. `packages/react-native-ui/scripts/gen-exports.ts` reads the same
11287
+ * shape to build the package's `exports` map, so a component folder that is
11288
+ * malformed fails there first.
11289
+ *
11290
+ * Pure and filesystem-free, so the whole mapping is reachable from `bun test`.
11291
+ */
11292
+ const REGISTRY_TYPES = [
11293
+ "registry:ui",
11294
+ "registry:lib",
11295
+ "registry:hook",
11296
+ "registry:style"
11297
+ ];
11298
+ /** A test, or a file that exists only to serve tests — `*.test-helpers.ts`, `*.fixtures.ts`. */
11299
+ function isTest(path) {
11300
+ return /\.(test|test-helpers|fixtures)\.tsx?$/.test(path);
11301
+ }
11302
+ //#endregion
11303
+ //#region src/project/test-setup.ts
11304
+ /** Expo's base config, which SDK 58's `exports` map hides from Vite's tsconfig loader. */
11305
+ const EXPO_TSCONFIG = "expo/tsconfig.base";
11306
+ function checkTestRunner(setup) {
11307
+ const name = "Test runner";
11308
+ if (setup.copiedTests === 0) return {
11309
+ name,
11310
+ status: "skip",
11311
+ detail: "no copied tests"
11312
+ };
11313
+ const problems = [
11314
+ ...missingPackages(setup),
11315
+ ...mismatchedScript(setup),
11316
+ ...runnerProblems(setup)
11317
+ ];
11318
+ const count = `${setup.copiedTests} copied test${setup.copiedTests === 1 ? "" : "s"}`;
11319
+ if (problems.length === 0) return {
11320
+ name,
11321
+ status: "pass",
11322
+ detail: `${count} set up for ${setup.runner}`
11323
+ };
11324
+ return {
11325
+ name,
11326
+ status: "warn",
11327
+ detail: `${count} for ${setup.runner}, but ${problems.map((problem) => problem.detail).join("; ")}`,
11328
+ fix: problems.map((problem) => problem.fix).join(" ")
11329
+ };
11330
+ }
11331
+ function missingPackages(setup) {
11332
+ const missing = [...setup.runner === "jest" ? ["jest"] : [], ...testRunnerPackages(setup.runner)].filter((name) => !(name in setup.dependencies));
11333
+ if (missing.length === 0) return [];
11334
+ return [{
11335
+ detail: `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not installed`,
11336
+ fix: `Install ${missing.map((name) => `\`${name}\``).join(", ")} as dev dependencies.`
11337
+ }];
11338
+ }
11339
+ function mismatchedScript(setup) {
11340
+ if (setup.scripted === null || setup.scripted === setup.runner) return [];
11341
+ return [{
11342
+ detail: `the project runs ${setup.scripted}`,
11343
+ fix: `Set \`tests.runner\` to "${setup.scripted}" in native-components.json and run \`update --tests\` to rewrite them, or point the \`test\` script at ${setup.runner}.`
11344
+ }];
11345
+ }
11346
+ function runnerProblems(setup) {
11347
+ switch (setup.runner) {
11348
+ case "bun": return [];
11349
+ case "jest": return jestProblems(setup);
11350
+ case "vitest": return vitestProblems(setup);
11351
+ }
11352
+ }
11353
+ /**
11354
+ * Bare Jest compiles neither TypeScript nor React Native, and `jest-expo` is
11355
+ * the preset that does both — and resolves the `tsconfig` aliases the copies
11356
+ * import through. Any other preset has to map them itself.
11357
+ */
11358
+ function jestProblems(setup) {
11359
+ if (setup.jestConfig === null) return [{
11360
+ detail: "there is no Jest config",
11361
+ fix: "Add `\"jest\": { \"preset\": \"jest-expo\" }` to package.json, beside `jest-expo` installed at the app's SDK."
11362
+ }];
11363
+ if (setup.jestConfig.includes("jest-expo")) return [];
11364
+ const unmapped = setup.aliasPrefixes.filter((prefix) => !(setup.jestConfig?.includes("moduleNameMapper") && setup.jestConfig.includes(trimSlash(prefix))));
11365
+ if (unmapped.length === 0) return [];
11366
+ return [{
11367
+ detail: `Jest does not resolve ${unmapped.join(", ")}`,
11368
+ fix: `Use the \`jest-expo\` preset, or map ${unmapped.map((prefix) => `\`${prefix}\``).join(", ")} in \`moduleNameMapper\`.`
11369
+ }];
11370
+ }
11371
+ /**
11372
+ * Vitest reads no `tsconfig` paths, so an alias needs its own entry or the
11373
+ * `vite-tsconfig-paths` plugin. And Vite's own tsconfig loader cannot follow
11374
+ * `extends: "expo/tsconfig.base"` on SDK 58, which fails every suite before one
11375
+ * runs; `esbuild.tsconfigRaw` stops it reading `tsconfig.json` at all.
11376
+ */
11377
+ function vitestProblems(setup) {
11378
+ const config = setup.vitestConfig ?? "";
11379
+ const problems = [];
11380
+ const unmapped = config.includes("vite-tsconfig-paths") || config.includes("tsconfigPaths") ? [] : setup.aliasPrefixes.filter((prefix) => !quotes(config, trimSlash(prefix)));
11381
+ if (unmapped.length > 0) problems.push({
11382
+ detail: `Vitest does not resolve ${unmapped.join(", ")}`,
11383
+ fix: `Map ${unmapped.map((prefix) => `\`${prefix}\``).join(", ")} in \`resolve.alias\` in vitest.config.ts.`
11384
+ });
11385
+ if (setup.tsconfigExtends === EXPO_TSCONFIG && !config.includes("tsconfigRaw")) problems.push({
11386
+ detail: `Vite cannot follow tsconfig.json's extends of ${EXPO_TSCONFIG}`,
11387
+ fix: "Set `esbuild: { tsconfigRaw: \"{}\" }` in vitest.config.ts."
11388
+ });
11389
+ return problems;
11390
+ }
11391
+ function trimSlash(prefix) {
11392
+ return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
11393
+ }
11394
+ /** Whether `text` holds `name`, or `name/`, as a whole quoted string. */
11395
+ function quotes(text, name) {
11396
+ return [
11397
+ "\"",
11398
+ "'",
11399
+ "`"
11400
+ ].some((quote) => text.includes(`${quote}${name}${quote}`) || text.includes(`${quote}${name}/`));
11401
+ }
11402
+ const JEST_CONFIG_FILES = [
11403
+ "jest.config.js",
11404
+ "jest.config.ts",
11405
+ "jest.config.cjs",
11406
+ "jest.config.mjs",
11407
+ "jest.config.json"
11408
+ ];
11409
+ const VITEST_CONFIG_FILES = [
11410
+ "vitest.config.ts",
11411
+ "vitest.config.mts",
11412
+ "vitest.config.js",
11413
+ "vitest.config.mjs",
11414
+ "vitest.config.cjs",
11415
+ "vite.config.ts",
11416
+ "vite.config.mts",
11417
+ "vite.config.js",
11418
+ "vite.config.mjs"
11419
+ ];
11420
+ /** Reads what `checkTestRunner` and `checkJestTransform` need, from the config's package and then the app. */
11421
+ async function readTestSetup(config, project) {
11422
+ const roots = [.../* @__PURE__ */ new Set([config.root, config.app.resolved.root])];
11423
+ const manifests = await Promise.all(roots.map((root) => readJson(join(root, "package.json"))));
11424
+ const dependencies = Object.assign({}, project.packageJson?.dependencies, project.packageJson?.devDependencies, ...manifests.flatMap((manifest) => [manifest?.dependencies, manifest?.devDependencies]));
11425
+ return {
11426
+ runner: config.testRunner,
11427
+ scripted: manifests.map((manifest) => manifest ? detectTestRunner(manifest) : null).find(Boolean) ?? null,
11428
+ copiedTests: await countCopiedTests(config.root),
11429
+ dependencies,
11430
+ jestConfig: await readJestConfig(roots, manifests),
11431
+ vitestConfig: await readFirst(roots, VITEST_CONFIG_FILES),
11432
+ tsconfigExtends: await readTsconfigExtends(roots),
11433
+ aliasPrefixes: aliasPrefixes(config)
11434
+ };
11435
+ }
11436
+ function aliasPrefixes(config) {
11437
+ const prefixes = Object.values(config.aliases).flatMap((alias) => alias?.match(/^[^/]+\//)?.[0] ?? []);
11438
+ return [...new Set(prefixes)];
11439
+ }
11440
+ async function countCopiedTests(root) {
11441
+ try {
11442
+ const lock = await readLock(root);
11443
+ return Object.values(lock.items).flatMap((item) => Object.keys(item.files)).filter(isTest).length;
11444
+ } catch {
11445
+ return 0;
11446
+ }
11447
+ }
11448
+ async function readJestConfig(roots, manifests) {
11449
+ for (const [index, root] of roots.entries()) {
11450
+ const file = await readFirst([root], JEST_CONFIG_FILES);
11451
+ if (file !== null) return file;
11452
+ const inline = manifests[index]?.jest;
11453
+ if (inline !== void 0) return JSON.stringify(inline);
11454
+ }
11455
+ return null;
11456
+ }
11457
+ async function readFirst(roots, names) {
11458
+ for (const root of roots) for (const name of names) {
11459
+ const path = join(root, name);
11460
+ if (existsSync(path)) return readFile(path, "utf-8");
11461
+ }
11462
+ return null;
11463
+ }
11464
+ async function readTsconfigExtends(roots) {
11465
+ for (const root of roots) {
11466
+ const text = await readFile(join(root, "tsconfig.json"), "utf-8").catch(() => null);
11467
+ if (text === null) continue;
11468
+ try {
11469
+ const { extends: base } = parseJsonc(text);
11470
+ return typeof base === "string" ? base : null;
11471
+ } catch {
11472
+ return null;
11473
+ }
11474
+ }
11475
+ return null;
11476
+ }
11477
+ async function readJson(path) {
11478
+ try {
11479
+ return JSON.parse(await readFile(path, "utf-8"));
11480
+ } catch {
11481
+ return null;
11482
+ }
11483
+ }
11484
+ //#endregion
11187
11485
  //#region src/registry/transform.ts
11188
11486
  /**
11189
11487
  * Anchored on the surrounding quote or backtick.
@@ -11238,13 +11536,17 @@ function destination$1(config, namespace, target) {
11238
11536
  displayPath: toPosix$4(relative(config.root, path))
11239
11537
  };
11240
11538
  }
11241
- /** A fetched file's text as this project would hold it at `path`: placeholders resolved to its aliases. */
11539
+ /**
11540
+ * A fetched file's text as this project would hold it at `path`: placeholders
11541
+ * resolved to its aliases, and a test written for the runner it tests with.
11542
+ */
11242
11543
  function transformFile(content, path, config) {
11243
- return transformContent(content, {
11544
+ const transformed = transformContent(content, {
11244
11545
  fileDirectory: dirname(path),
11245
11546
  directories: config.directories,
11246
11547
  aliases: config.aliases
11247
11548
  });
11549
+ return isTest(path) ? adaptTest(transformed, config.testRunner) : transformed;
11248
11550
  }
11249
11551
  async function writeFiles(files) {
11250
11552
  for (const file of files) {
@@ -11296,30 +11598,6 @@ function escapePattern(value) {
11296
11598
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11297
11599
  }
11298
11600
  //#endregion
11299
- //#region src/registry/classify.ts
11300
- /**
11301
- * Maps a path inside `packages/react-native-ui/src` onto the registry item it
11302
- * belongs to and where it lands in a consumer's project.
11303
- *
11304
- * The rules here are `react-native-ui`'s own conventions, restated — one component
11305
- * per folder with an `index.ts` entry point, flat files under `lib/`, `hooks/`
11306
- * and `icons/`. `packages/react-native-ui/scripts/gen-exports.ts` reads the same
11307
- * shape to build the package's `exports` map, so a component folder that is
11308
- * malformed fails there first.
11309
- *
11310
- * Pure and filesystem-free, so the whole mapping is reachable from `bun test`.
11311
- */
11312
- const REGISTRY_TYPES = [
11313
- "registry:ui",
11314
- "registry:lib",
11315
- "registry:hook",
11316
- "registry:style"
11317
- ];
11318
- /** A test, or a file that exists only to serve tests — `*.test-helpers.ts`, `*.fixtures.ts`. */
11319
- function isTest(path) {
11320
- return /\.(test|test-helpers|fixtures)\.tsx?$/.test(path);
11321
- }
11322
- //#endregion
11323
11601
  //#region src/registry/schema.ts
11324
11602
  /**
11325
11603
  * The shape of the JSON the builder emits and the `add` command consumes.
@@ -11446,13 +11724,14 @@ const registryIndexSchema = object$1({
11446
11724
  * then treats a test like any other file, which is the point: a copied test is
11447
11725
  * the consumer's from then on, recorded and updated exactly as the source is.
11448
11726
  */
11449
- function withTests(item) {
11727
+ function withTests(item, runner = "bun") {
11450
11728
  const tests = "tests" in item ? item.tests : [];
11729
+ const testDependencies = item.testDependencies.flatMap((name) => name === "@types/bun" ? testRunnerPackages(runner) : [name]);
11451
11730
  return {
11452
11731
  ...item,
11453
11732
  ..."files" in item && "tests" in item ? { files: [...item.files, ...tests] } : {},
11454
11733
  registryDependencies: union(item.registryDependencies, item.testRegistryDependencies),
11455
- devDependencies: union(item.devDependencies, item.testDependencies)
11734
+ devDependencies: union(item.devDependencies, testDependencies)
11456
11735
  };
11457
11736
  }
11458
11737
  function union(a, b) {
@@ -11564,13 +11843,13 @@ const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/delacournz/delac
11564
11843
  * Replaced at build time with the git tag being published (see `tsdown.config.ts`).
11565
11844
  * `main` in a working tree, where there is no tag yet.
11566
11845
  */
11567
- const DEFAULT_REGISTRY_REF = "abcaf1b8a49718368bc5726b1801ec58e75a2477";
11846
+ const DEFAULT_REGISTRY_REF = "713984b74d21a6bb6b0e1f719a947cce7a2d847e";
11568
11847
  const RAW_GITHUB = "https://raw.githubusercontent.com";
11569
11848
  const GITHUB_SHORTHAND = /^github:([^/#]+)\/([^/#]+)(?:#(.+))?$/;
11570
11849
  /** `https://raw.githubusercontent.com/owner/repo` and nothing more. */
11571
11850
  const BARE_RAW_REPO = new RegExp(`^${RAW_GITHUB}/([^/]+)/([^/]+)$`);
11572
11851
  function resolveRegistrySource(options) {
11573
- const ref = options.ref || "abcaf1b8a49718368bc5726b1801ec58e75a2477";
11852
+ const ref = options.ref || "713984b74d21a6bb6b0e1f719a947cce7a2d847e";
11574
11853
  const url = options.url?.trim();
11575
11854
  if (!url) return remote(`${DEFAULT_REGISTRY_URL}/${ref}`);
11576
11855
  const shorthand = GITHUB_SHORTHAND.exec(url);
@@ -11984,477 +12263,90 @@ const style = {
11984
12263
  path: (value) => import_picocolors.default.dim(value)
11985
12264
  };
11986
12265
  //#endregion
11987
- //#region src/project/css.ts
12266
+ //#region ../design-system/src/styles.ts
11988
12267
  /**
11989
- * Wires the copied styles into the app's Tailwind entry.
11990
- *
11991
- * Two things have to happen and only one of them is obvious. The `@import`
11992
- * pulls in the tokens and the theme. The `@source` lines are what make the
11993
- * classes inside the copied components survive a production build: Tailwind
11994
- * scans source text, and a class it never saw is a class it never compiled.
12268
+ * The three control heights, the type paired with each, and the icon scale.
11995
12269
  *
11996
- * The paths are computed from where the files actually landed, which resolves
11997
- * the symlink trap on its own. In a monorepo the components live in a package
11998
- * that Bun links into `node_modules`, and Tailwind's scanner does not follow
11999
- * symlinks — a `@source` pointing at the linked copy silently contributes
12000
- * nothing, and the components render unstyled in release and fine in dev. The
12001
- * comment in `apps/playground/src/styles/global.css` is this bug, already paid
12002
- * for once.
12270
+ * A style names one density and everything steps from it, rather than listing
12271
+ * two dozen numbers per style and inviting them to drift out of proportion.
12272
+ * `heights` are the button and input scales, which the library keeps separate
12273
+ * but which no style has yet had a reason to disagree on.
12003
12274
  */
12004
- const START = "/* delacour:start — managed by `delacour init`, edit outside this block */";
12005
- const END = "/* delacour:end */";
12275
+ function geometry({ radius, heights, text, icons, navbar, gutter, buttonRadius }) {
12276
+ return {
12277
+ radius,
12278
+ "spacing-button-sm": heights[0],
12279
+ "spacing-button-md": heights[1],
12280
+ "spacing-button-lg": heights[2],
12281
+ "text-button-sm": text[0],
12282
+ "text-button-md": text[1],
12283
+ "text-button-lg": text[2],
12284
+ "radius-button-sm": Math.min(buttonRadius[0], heights[0] / 2),
12285
+ "radius-button-md": Math.min(buttonRadius[1], heights[1] / 2),
12286
+ "radius-button-lg": Math.min(buttonRadius[2], heights[2] / 2),
12287
+ "spacing-input-sm": heights[0],
12288
+ "spacing-input-md": heights[1],
12289
+ "spacing-input-lg": heights[2],
12290
+ "text-input-sm": text[0],
12291
+ "text-input-md": text[1],
12292
+ "text-input-lg": text[2],
12293
+ "spacing-icon-xs": icons[0],
12294
+ "spacing-icon-sm": icons[1],
12295
+ "spacing-icon-md": icons[2],
12296
+ "spacing-icon-lg": icons[3],
12297
+ "spacing-icon-xl": icons[4],
12298
+ "spacing-icon-2xl": icons[5],
12299
+ "spacing-navbar-row": navbar,
12300
+ "spacing-screen-gutter": gutter,
12301
+ "spacing-chart-sm": chartHeight(LIBRARY_CHART[0], heights[1]),
12302
+ "spacing-chart-md": chartHeight(LIBRARY_CHART[1], heights[1]),
12303
+ "spacing-chart-lg": chartHeight(LIBRARY_CHART[2], heights[1]),
12304
+ "spacing-fab-sm": LIBRARY_FAB[0],
12305
+ "spacing-fab-md": LIBRARY_FAB[1],
12306
+ "spacing-fab-lg": LIBRARY_FAB[2]
12307
+ };
12308
+ }
12309
+ const LIBRARY_HEIGHTS = [
12310
+ 36,
12311
+ 44,
12312
+ 52
12313
+ ];
12314
+ const LIBRARY_TEXT = [
12315
+ 14,
12316
+ 16,
12317
+ 18
12318
+ ];
12319
+ const LIBRARY_ICONS = [
12320
+ 14,
12321
+ 16,
12322
+ 18,
12323
+ 20,
12324
+ 24,
12325
+ 32
12326
+ ];
12327
+ const CAPSULE = [
12328
+ 18,
12329
+ 22,
12330
+ 26
12331
+ ];
12332
+ const LIBRARY_CHART = [
12333
+ 160,
12334
+ 220,
12335
+ 280
12336
+ ];
12006
12337
  /**
12007
- * The managed block: one import for the theme, one source glob per directory
12008
- * holding copied code.
12338
+ * A floating action button's footprint, the same under every style. It floats
12339
+ * over content rather than sitting in a row of controls, so a denser style's
12340
+ * shorter buttons are no reason to shrink the one target above the list — and
12341
+ * 44 is the floor a touch target cannot go under.
12009
12342
  */
12010
- function buildStylesBlock(context) {
12011
- const from = dirname(context.cssPath);
12012
- const lines = [
12013
- START,
12014
- `@import "${toSpecifier$1(from, `${context.directories.styles}/index.css`)}";`,
12015
- ""
12016
- ];
12017
- const scanned = new Set(NAMESPACES.filter((namespace) => namespace !== "styles").map((namespace) => context.directories[namespace]));
12018
- for (const directory of [...scanned].sort()) {
12019
- if ([...scanned].some((other) => other !== directory && isInside(directory, other))) continue;
12020
- lines.push(`@source "${toSpecifier$1(from, directory)}";`);
12021
- }
12022
- lines.push(END);
12023
- return lines.join("\n");
12024
- }
12343
+ const LIBRARY_FAB = [
12344
+ 44,
12345
+ 56,
12346
+ 64
12347
+ ];
12025
12348
  /**
12026
- * Writes the block into an existing entry, or creates one.
12027
- *
12028
- * Bounded by markers so a second `init` replaces the block rather than
12029
- * appending a second copy, and so everything a developer adds around it is left
12030
- * alone.
12031
- */
12032
- function patchGlobalCss(existing, block) {
12033
- if (existing === null) return {
12034
- content: `${DEFAULT_ENTRY}\n${block}\n`,
12035
- changed: true
12036
- };
12037
- const start = existing.indexOf(START);
12038
- const end = existing.indexOf(END);
12039
- if (start !== -1 && end > start) {
12040
- const content = `${existing.slice(0, start)}${block}${existing.slice(end + 18)}`;
12041
- return {
12042
- content,
12043
- changed: content !== existing
12044
- };
12045
- }
12046
- return {
12047
- content: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${block}\n`,
12048
- changed: true
12049
- };
12050
- }
12051
- const DEFAULT_ENTRY = `@import "tailwindcss";
12052
- @import "uniwind";
12053
-
12054
- @source "../**/*.{ts,tsx}";
12055
- `;
12056
- /**
12057
- * The directories a Tailwind entry actually scans.
12058
- *
12059
- * Read back rather than assumed, because this is the check that matters: a
12060
- * `@source` that does not cover where the components landed produces a build
12061
- * with no error and no styles. Only directory sources are returned — a glob
12062
- * like `../**\/*.{ts,tsx}` is reduced to the directory it starts from, which is
12063
- * what decides coverage.
12064
- */
12065
- function parseSources(css, cssPath) {
12066
- const from = dirname(cssPath);
12067
- const sources = [];
12068
- for (const match of css.matchAll(/@source\s+(?:not\s+)?["']([^"']+)["']/g)) {
12069
- const value = match[1];
12070
- if (value.startsWith("inline(")) continue;
12071
- const wildcard = value.search(/[*?[]/);
12072
- const directory = wildcard === -1 ? value : value.slice(0, wildcard);
12073
- sources.push(resolve(from, directory));
12074
- }
12075
- return sources;
12076
- }
12077
- /** Whether any source directory contains `directory`. */
12078
- function isCovered(directory, sources) {
12079
- return sources.some((source) => {
12080
- const suffix = relative(source, directory);
12081
- return suffix === "" || !suffix.startsWith("..");
12082
- });
12083
- }
12084
- /** A path Tailwind can resolve from the entry file, always POSIX and always relative. */
12085
- function toSpecifier$1(from, target) {
12086
- const path = toPosix$3(relative(from, target));
12087
- return path.startsWith(".") ? path : `./${path}`;
12088
- }
12089
- function isInside(directory, parent) {
12090
- const suffix = relative(parent, directory);
12091
- return suffix !== "" && !suffix.startsWith("..");
12092
- }
12093
- function toPosix$3(path) {
12094
- return sep === "/" ? path : path.split(sep).join("/");
12095
- }
12096
- //#endregion
12097
- //#region src/project/css-entry.ts
12098
- /**
12099
- * Finds the Tailwind entry an app already has.
12100
- *
12101
- * Creating a second one is the failure this exists to prevent. Metro compiles
12102
- * the file its `cssEntryFile` names and no other; the app imports whichever
12103
- * file its root layout imports. Write a new entry beside an existing one and
12104
- * those become two different files — Tailwind compiles the one with the
12105
- * `@source` globs, the app loads the one without them, and every component
12106
- * renders unstyled with nothing logged.
12107
- *
12108
- * The Metro config wins when it names one, because that is what actually
12109
- * compiles — see `readUniwindPaths`. This is the fallback for a project that
12110
- * has an entry but has not wired Metro to it yet, which is every app that
12111
- * installed Uniwind and stopped there.
12112
- *
12113
- * Shallowest first: an app that ran an older version of this CLI has both
12114
- * `src/global.css` and `src/styles/global.css`, and the one it imports is the
12115
- * one it had first.
12116
- */
12117
- const CANDIDATES$1 = [
12118
- "global.css",
12119
- "src/global.css",
12120
- "app/global.css",
12121
- "src/app/global.css",
12122
- "styles/global.css",
12123
- "src/styles/global.css",
12124
- "src/styles/index.css"
12125
- ];
12126
- /** `@import "tailwindcss"`, however it is quoted and whatever follows it. */
12127
- const TAILWIND_IMPORT = /@import\s+["']tailwindcss["']/;
12128
- function findTailwindEntry(appRoot) {
12129
- for (const candidate of CANDIDATES$1) {
12130
- const path = join(appRoot, candidate);
12131
- if (!existsSync(path)) continue;
12132
- try {
12133
- if (TAILWIND_IMPORT.test(readFileSync(path, "utf-8"))) return path;
12134
- } catch {}
12135
- }
12136
- return null;
12137
- }
12138
- //#endregion
12139
- //#region src/project/metro.ts
12140
- /**
12141
- * Wraps the app's Metro config with Uniwind's.
12142
- *
12143
- * `withUniwindConfig` has to stay the outermost wrapper — it installs the
12144
- * transformer that compiles `className` into styles, and a wrapper applied
12145
- * after it can replace the `transformer` key and leave every component
12146
- * unstyled with nothing logged. So the patch rewrites the export itself rather
12147
- * than inserting a line somewhere in the middle.
12148
- *
12149
- * When the export is not in a shape this can safely rewrite, it says so and
12150
- * prints what to write. Guessing at an unfamiliar config file earns nothing —
12151
- * the failure it causes is a Metro error at bundle time in someone else's app.
12152
- */
12153
- /** Modules that must resolve to exactly one copy across a workspace. */
12154
- const PINNED_MODULES = [
12155
- "@shopify/react-native-skia",
12156
- "react",
12157
- "react-native",
12158
- "react-native-gesture-handler",
12159
- "react-native-keyboard-controller",
12160
- "react-native-reanimated",
12161
- "react-native-safe-area-context",
12162
- "react-native-svg",
12163
- "react-native-teleport",
12164
- "react-native-worklets"
12165
- ];
12166
- const REQUIRE = "const { withUniwindConfig } = require(\"uniwind/metro\");";
12167
- const IMPORT = "import { withUniwindConfig } from \"uniwind/metro\";";
12168
- /** `module.exports = config;` or `export default config;`, with nothing after it. */
12169
- const COMMONJS_EXPORT = /module\.exports\s*=\s*([\s\S]+?);?\s*$/;
12170
- const ESM_EXPORT = /export default\s+([\s\S]+?);?\s*$/;
12171
- const CSS_ENTRY = /cssEntryFile\s*:\s*["'`]([^"'`]+)["'`]/;
12172
- const DTS_FILE = /dtsFile\s*:\s*["'`]([^"'`]+)["'`]/;
12173
- /**
12174
- * Reads the paths a wrapped Metro config already names.
12175
- *
12176
- * A project that arrives with Uniwind set up — Expo's `with-router-uniwind`
12177
- * example is the common one — has chosen its own CSS entry, and Metro compiles
12178
- * **that file and no other**. Writing the `@source` block into the path this
12179
- * CLI would have picked leaves Tailwind scanning a file with no globs in it, so
12180
- * every class the components use is dropped and every one of them renders
12181
- * unstyled. Nothing errors; the app just looks wrong.
12182
- *
12183
- * So the wired config wins. Returns nothing when the config is not wired, in
12184
- * which case `init` is about to wrap it with paths of its own.
12185
- */
12186
- function readUniwindPaths(existing) {
12187
- if (!existing?.includes("withUniwindConfig")) return {};
12188
- return {
12189
- css: CSS_ENTRY.exec(existing)?.[1],
12190
- types: DTS_FILE.exec(existing)?.[1]
12191
- };
12192
- }
12193
- function patchMetroConfig(existing, options) {
12194
- const wrapper = optionsLiteral(options);
12195
- const monorepo = options.workspaceRoot && options.workspaceRoot !== options.metroConfigPath ? monorepoResolverBlock(dirname(options.metroConfigPath), options.workspaceRoot) : "";
12196
- if (existing === null) return {
12197
- status: "created",
12198
- content: template(wrapper, monorepo)
12199
- };
12200
- if (existing.includes("withUniwindConfig")) return { status: "already-wired" };
12201
- const isEsm = /^\s*(import|export default)/m.test(existing);
12202
- const match = (isEsm ? ESM_EXPORT : COMMONJS_EXPORT).exec(existing);
12203
- if (!match?.[1]) return {
12204
- status: "manual",
12205
- snippet: manualSnippet(wrapper, isEsm)
12206
- };
12207
- const keyword = isEsm ? "export default" : "module.exports =";
12208
- const head = existing.slice(0, match.index);
12209
- const wrapped = `${keyword} withUniwindConfig(${match[1].trim()}, ${wrapper});\n`;
12210
- const body = monorepo ? `${head}${monorepo}\n` : head;
12211
- return {
12212
- status: "patched",
12213
- content: `${isEsm ? IMPORT : REQUIRE}\n${body}${wrapped}`
12214
- };
12215
- }
12216
- function optionsLiteral(options) {
12217
- const from = dirname(options.metroConfigPath);
12218
- return `{\n\tcssEntryFile: "${toSpecifier(from, options.cssPath)}",\n\tdtsFile: "${toSpecifier(from, options.typesPath)}",\n}`;
12219
- }
12220
- /**
12221
- * The resolver settings an app needs to reach a package outside its own tree.
12222
- *
12223
- * Metro resolves by walking `node_modules` from the app downwards, so without
12224
- * `watchFolders` and `nodeModulesPaths` it simply cannot see a sibling package —
12225
- * the import fails at bundle time with a missing module, pointing at the app
12226
- * rather than at the workspace.
12227
- *
12228
- * `extraNodeModules` is the other half, and it is about runtime rather than
12229
- * resolution: a package manager can materialise a second copy of a native module
12230
- * under the app, and two registrations of one native module break. This is the
12231
- * block `apps/playground/metro.config.js` in this repository carries for exactly
12232
- * this reason.
12233
- */
12234
- function monorepoResolverBlock(appRoot, workspaceRoot) {
12235
- const pins = PINNED_MODULES.map((name) => `\t${JSON.stringify(name)}: path.resolve(workspaceRoot, "node_modules/${name}"),`).join("\n");
12236
- return `const path = require("node:path");
12237
-
12238
- const workspaceRoot = path.resolve(__dirname, "${toSpecifier(appRoot, workspaceRoot)}");
12239
-
12240
- // The components live in a package outside this app, so Metro has to watch and
12241
- // resolve beyond the app's own directory.
12242
- config.watchFolders = [workspaceRoot];
12243
- config.resolver.nodeModulesPaths = [
12244
- \tpath.resolve(__dirname, "node_modules"),
12245
- \tpath.resolve(workspaceRoot, "node_modules"),
12246
- ];
12247
- config.resolver.disableHierarchicalLookup = true;
12248
-
12249
- // One copy of each native module for the whole bundle: two registrations of a
12250
- // native module break at runtime.
12251
- config.resolver.extraNodeModules = {
12252
- ${pins}
12253
- };
12254
- `;
12255
- }
12256
- function template(wrapper, monorepo) {
12257
- return `const { getDefaultConfig } = require("expo/metro-config");
12258
- ${REQUIRE}
12259
-
12260
- const config = getDefaultConfig(__dirname);
12261
- ${monorepo ? `\n${monorepo}` : ""}
12262
- // withUniwindConfig must stay the outermost wrapper.
12263
- module.exports = withUniwindConfig(config, ${wrapper});
12264
- `;
12265
- }
12266
- function manualSnippet(wrapper, isEsm) {
12267
- return `${isEsm ? IMPORT : REQUIRE}\n\n// Keep this the outermost wrapper.\n${isEsm ? "export default" : "module.exports ="} withUniwindConfig(config, ${wrapper});`;
12268
- }
12269
- function toSpecifier(from, target) {
12270
- const path = toPosix$2(relative(from, target));
12271
- return path.startsWith(".") ? path : `./${path}`;
12272
- }
12273
- function toPosix$2(path) {
12274
- return sep === "/" ? path : path.split(sep).join("/");
12275
- }
12276
- //#endregion
12277
- //#region src/project/root-layout.ts
12278
- /**
12279
- * Router layouts first: an app holding both is an Expo Router app whose
12280
- * `App.tsx` is left over, and the layout is the file that actually renders.
12281
- */
12282
- const CANDIDATES = [
12283
- {
12284
- path: "src/app/_layout.tsx",
12285
- router: true
12286
- },
12287
- {
12288
- path: "app/_layout.tsx",
12289
- router: true
12290
- },
12291
- {
12292
- path: "src/app/_layout.jsx",
12293
- router: true
12294
- },
12295
- {
12296
- path: "app/_layout.jsx",
12297
- router: true
12298
- },
12299
- {
12300
- path: "App.tsx",
12301
- router: false
12302
- },
12303
- {
12304
- path: "src/App.tsx",
12305
- router: false
12306
- },
12307
- {
12308
- path: "App.jsx",
12309
- router: false
12310
- },
12311
- {
12312
- path: "index.tsx",
12313
- router: false
12314
- }
12315
- ];
12316
- function findRootLayout(appRoot) {
12317
- return CANDIDATES.find((candidate) => existsSync(join(appRoot, candidate.path))) ?? null;
12318
- }
12319
- /**
12320
- * The root layout, whole, ready to paste.
12321
- *
12322
- * The CSS import is the first line because that is the rule it exists to
12323
- * teach — anywhere else and every component renders unstyled with nothing
12324
- * logged.
12325
- *
12326
- * `<Slot />` is Expo Router's minimal root. A layout already rendering a
12327
- * `<Stack>` keeps it; the provider goes around whatever is there, which is
12328
- * what the line printed beside this says.
12329
- */
12330
- function renderRootLayout(layout, specifiers) {
12331
- const { css, provider } = specifiers;
12332
- if (!layout.router) return [
12333
- `import "${css}";`,
12334
- `import { DelacourProvider } from "${provider}";`,
12335
- "",
12336
- "export default function App() {",
12337
- " return (",
12338
- " <DelacourProvider>",
12339
- " <YourApp />",
12340
- " </DelacourProvider>",
12341
- " );",
12342
- "}"
12343
- ].join("\n");
12344
- return [
12345
- `import "${css}";`,
12346
- `import { DelacourProvider } from "${provider}";`,
12347
- "import { Slot } from \"expo-router\";",
12348
- "",
12349
- "export default function RootLayout() {",
12350
- " return (",
12351
- " <DelacourProvider>",
12352
- " <Slot />",
12353
- " </DelacourProvider>",
12354
- " );",
12355
- "}"
12356
- ].join("\n");
12357
- }
12358
- //#endregion
12359
- //#region src/project/uniwind-env.ts
12360
- /**
12361
- * The one line that makes `className` a real prop in TypeScript.
12362
- *
12363
- * Uniwind augments React Native's types through a global `/// <reference>`, and
12364
- * a declaration file is only loaded if it is part of the program — either
12365
- * matched by `tsconfig`'s `include` or reachable by import. This file is neither
12366
- * importable nor, in a shared package, inside the app's `include`, which is why
12367
- * the app needs a copy of its own.
12368
- *
12369
- * Kept here rather than inlined so `init` and `doctor` agree on the contents.
12370
- */
12371
- const UNIWIND_ENV_FILENAME = "uniwind-env.d.ts";
12372
- const UNIWIND_ENV_REFERENCE = "/// <reference types=\"uniwind/types\" />";
12373
- //#endregion
12374
- //#region ../design-system/src/styles.ts
12375
- /**
12376
- * The three control heights, the type paired with each, and the icon scale.
12377
- *
12378
- * A style names one density and everything steps from it, rather than listing
12379
- * two dozen numbers per style and inviting them to drift out of proportion.
12380
- * `heights` are the button and input scales, which the library keeps separate
12381
- * but which no style has yet had a reason to disagree on.
12382
- */
12383
- function geometry({ radius, heights, text, icons, navbar, gutter, buttonRadius }) {
12384
- return {
12385
- radius,
12386
- "spacing-button-sm": heights[0],
12387
- "spacing-button-md": heights[1],
12388
- "spacing-button-lg": heights[2],
12389
- "text-button-sm": text[0],
12390
- "text-button-md": text[1],
12391
- "text-button-lg": text[2],
12392
- "radius-button-sm": Math.min(buttonRadius[0], heights[0] / 2),
12393
- "radius-button-md": Math.min(buttonRadius[1], heights[1] / 2),
12394
- "radius-button-lg": Math.min(buttonRadius[2], heights[2] / 2),
12395
- "spacing-input-sm": heights[0],
12396
- "spacing-input-md": heights[1],
12397
- "spacing-input-lg": heights[2],
12398
- "text-input-sm": text[0],
12399
- "text-input-md": text[1],
12400
- "text-input-lg": text[2],
12401
- "spacing-icon-xs": icons[0],
12402
- "spacing-icon-sm": icons[1],
12403
- "spacing-icon-md": icons[2],
12404
- "spacing-icon-lg": icons[3],
12405
- "spacing-icon-xl": icons[4],
12406
- "spacing-icon-2xl": icons[5],
12407
- "spacing-navbar-row": navbar,
12408
- "spacing-screen-gutter": gutter,
12409
- "spacing-chart-sm": chartHeight(LIBRARY_CHART[0], heights[1]),
12410
- "spacing-chart-md": chartHeight(LIBRARY_CHART[1], heights[1]),
12411
- "spacing-chart-lg": chartHeight(LIBRARY_CHART[2], heights[1]),
12412
- "spacing-fab-sm": LIBRARY_FAB[0],
12413
- "spacing-fab-md": LIBRARY_FAB[1],
12414
- "spacing-fab-lg": LIBRARY_FAB[2]
12415
- };
12416
- }
12417
- const LIBRARY_HEIGHTS = [
12418
- 36,
12419
- 44,
12420
- 52
12421
- ];
12422
- const LIBRARY_TEXT = [
12423
- 14,
12424
- 16,
12425
- 18
12426
- ];
12427
- const LIBRARY_ICONS = [
12428
- 14,
12429
- 16,
12430
- 18,
12431
- 20,
12432
- 24,
12433
- 32
12434
- ];
12435
- const CAPSULE = [
12436
- 18,
12437
- 22,
12438
- 26
12439
- ];
12440
- const LIBRARY_CHART = [
12441
- 160,
12442
- 220,
12443
- 280
12444
- ];
12445
- /**
12446
- * A floating action button's footprint, the same under every style. It floats
12447
- * over content rather than sitting in a row of controls, so a denser style's
12448
- * shorter buttons are no reason to shrink the one target above the list — and
12449
- * 44 is the floor a touch target cannot go under.
12450
- */
12451
- const LIBRARY_FAB = [
12452
- 44,
12453
- 56,
12454
- 64
12455
- ];
12456
- /**
12457
- * A chart's height, scaled to a style's control density.
12349
+ * A chart's height, scaled to a style's control density.
12458
12350
  *
12459
12351
  * A plot is a region rather than a control, so none of the axes a style
12460
12352
  * actually sets — a corner, an icon, a button's height — describes it directly.
@@ -13205,6 +13097,213 @@ function escapeRegExp(value) {
13205
13097
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13206
13098
  }
13207
13099
  //#endregion
13100
+ //#region src/project/css.ts
13101
+ /**
13102
+ * Wires the copied styles into the app's Tailwind entry.
13103
+ *
13104
+ * Two things have to happen and only one of them is obvious. The `@import`
13105
+ * pulls in the tokens and the theme. The `@source` lines are what make the
13106
+ * classes inside the copied components survive a production build: Tailwind
13107
+ * scans source text, and a class it never saw is a class it never compiled.
13108
+ *
13109
+ * The paths are computed from where the files actually landed, which resolves
13110
+ * the symlink trap on its own. In a monorepo the components live in a package
13111
+ * that Bun links into `node_modules`, and Tailwind's scanner does not follow
13112
+ * symlinks — a `@source` pointing at the linked copy silently contributes
13113
+ * nothing, and the components render unstyled in release and fine in dev. The
13114
+ * comment in `apps/playground/src/styles/global.css` is this bug, already paid
13115
+ * for once.
13116
+ */
13117
+ const START = "/* delacour:start — managed by `delacour init`, edit outside this block */";
13118
+ const END = "/* delacour:end */";
13119
+ /**
13120
+ * The managed block: one import for the theme, one source glob per directory
13121
+ * holding copied code.
13122
+ */
13123
+ function buildStylesBlock(context) {
13124
+ const from = dirname(context.cssPath);
13125
+ const lines = [
13126
+ START,
13127
+ `@import "${toSpecifier$1(from, `${context.directories.styles}/index.css`)}";`,
13128
+ ""
13129
+ ];
13130
+ const scanned = new Set(NAMESPACES.filter((namespace) => namespace !== "styles").map((namespace) => context.directories[namespace]));
13131
+ for (const directory of [...scanned].sort()) {
13132
+ if ([...scanned].some((other) => other !== directory && isInside(directory, other))) continue;
13133
+ lines.push(`@source "${toSpecifier$1(from, directory)}";`);
13134
+ }
13135
+ lines.push(END);
13136
+ return lines.join("\n");
13137
+ }
13138
+ /**
13139
+ * Writes the block into an existing entry, or creates one.
13140
+ *
13141
+ * Bounded by markers so a second `init` replaces the block rather than
13142
+ * appending a second copy, and so everything a developer adds around it is left
13143
+ * alone.
13144
+ */
13145
+ function patchGlobalCss(existing, block) {
13146
+ if (existing === null) return {
13147
+ content: `${DEFAULT_ENTRY}\n${block}\n`,
13148
+ changed: true
13149
+ };
13150
+ const start = existing.indexOf(START);
13151
+ const end = existing.indexOf(END);
13152
+ if (start !== -1 && end > start) {
13153
+ const content = `${existing.slice(0, start)}${block}${existing.slice(end + 18)}`;
13154
+ return {
13155
+ content,
13156
+ changed: content !== existing
13157
+ };
13158
+ }
13159
+ return {
13160
+ content: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${block}\n`,
13161
+ changed: true
13162
+ };
13163
+ }
13164
+ const DEFAULT_ENTRY = `@import "tailwindcss";
13165
+ @import "uniwind";
13166
+
13167
+ @source "../**/*.{ts,tsx}";
13168
+ `;
13169
+ /**
13170
+ * The directories a Tailwind entry actually scans.
13171
+ *
13172
+ * Read back rather than assumed, because this is the check that matters: a
13173
+ * `@source` that does not cover where the components landed produces a build
13174
+ * with no error and no styles. Only directory sources are returned — a glob
13175
+ * like `../**\/*.{ts,tsx}` is reduced to the directory it starts from, which is
13176
+ * what decides coverage.
13177
+ */
13178
+ function parseSources(css, cssPath) {
13179
+ const from = dirname(cssPath);
13180
+ const sources = [];
13181
+ for (const match of css.matchAll(/@source\s+(?:not\s+)?["']([^"']+)["']/g)) {
13182
+ const value = match[1];
13183
+ if (value.startsWith("inline(")) continue;
13184
+ const wildcard = value.search(/[*?[]/);
13185
+ const directory = wildcard === -1 ? value : value.slice(0, wildcard);
13186
+ sources.push(resolve(from, directory));
13187
+ }
13188
+ return sources;
13189
+ }
13190
+ /** Whether any source directory contains `directory`. */
13191
+ function isCovered(directory, sources) {
13192
+ return sources.some((source) => {
13193
+ const suffix = relative(source, directory);
13194
+ return suffix === "" || !suffix.startsWith("..");
13195
+ });
13196
+ }
13197
+ /** A path Tailwind can resolve from the entry file, always POSIX and always relative. */
13198
+ function toSpecifier$1(from, target) {
13199
+ const path = toPosix$3(relative(from, target));
13200
+ return path.startsWith(".") ? path : `./${path}`;
13201
+ }
13202
+ function isInside(directory, parent) {
13203
+ const suffix = relative(parent, directory);
13204
+ return suffix !== "" && !suffix.startsWith("..");
13205
+ }
13206
+ function toPosix$3(path) {
13207
+ return sep === "/" ? path : path.split(sep).join("/");
13208
+ }
13209
+ //#endregion
13210
+ //#region src/project/root-layout.ts
13211
+ /**
13212
+ * Router layouts first: an app holding both is an Expo Router app whose
13213
+ * `App.tsx` is left over, and the layout is the file that actually renders.
13214
+ */
13215
+ const CANDIDATES$1 = [
13216
+ {
13217
+ path: "src/app/_layout.tsx",
13218
+ router: true
13219
+ },
13220
+ {
13221
+ path: "app/_layout.tsx",
13222
+ router: true
13223
+ },
13224
+ {
13225
+ path: "src/app/_layout.jsx",
13226
+ router: true
13227
+ },
13228
+ {
13229
+ path: "app/_layout.jsx",
13230
+ router: true
13231
+ },
13232
+ {
13233
+ path: "App.tsx",
13234
+ router: false
13235
+ },
13236
+ {
13237
+ path: "src/App.tsx",
13238
+ router: false
13239
+ },
13240
+ {
13241
+ path: "App.jsx",
13242
+ router: false
13243
+ },
13244
+ {
13245
+ path: "index.tsx",
13246
+ router: false
13247
+ }
13248
+ ];
13249
+ function findRootLayout(appRoot) {
13250
+ return CANDIDATES$1.find((candidate) => existsSync(join(appRoot, candidate.path))) ?? null;
13251
+ }
13252
+ /**
13253
+ * The root layout, whole, ready to paste.
13254
+ *
13255
+ * The CSS import is the first line because that is the rule it exists to
13256
+ * teach — anywhere else and every component renders unstyled with nothing
13257
+ * logged.
13258
+ *
13259
+ * `<Slot />` is Expo Router's minimal root. A layout already rendering a
13260
+ * `<Stack>` keeps it; the provider goes around whatever is there, which is
13261
+ * what the line printed beside this says.
13262
+ */
13263
+ function renderRootLayout(layout, specifiers) {
13264
+ const { css, provider } = specifiers;
13265
+ if (!layout.router) return [
13266
+ `import "${css}";`,
13267
+ `import { DelacourProvider } from "${provider}";`,
13268
+ "",
13269
+ "export default function App() {",
13270
+ " return (",
13271
+ " <DelacourProvider>",
13272
+ " <YourApp />",
13273
+ " </DelacourProvider>",
13274
+ " );",
13275
+ "}"
13276
+ ].join("\n");
13277
+ return [
13278
+ `import "${css}";`,
13279
+ `import { DelacourProvider } from "${provider}";`,
13280
+ "import { Slot } from \"expo-router\";",
13281
+ "",
13282
+ "export default function RootLayout() {",
13283
+ " return (",
13284
+ " <DelacourProvider>",
13285
+ " <Slot />",
13286
+ " </DelacourProvider>",
13287
+ " );",
13288
+ "}"
13289
+ ].join("\n");
13290
+ }
13291
+ //#endregion
13292
+ //#region src/project/uniwind-env.ts
13293
+ /**
13294
+ * The one line that makes `className` a real prop in TypeScript.
13295
+ *
13296
+ * Uniwind augments React Native's types through a global `/// <reference>`, and
13297
+ * a declaration file is only loaded if it is part of the program — either
13298
+ * matched by `tsconfig`'s `include` or reachable by import. This file is neither
13299
+ * importable nor, in a shared package, inside the app's `include`, which is why
13300
+ * the app needs a copy of its own.
13301
+ *
13302
+ * Kept here rather than inlined so `init` and `doctor` agree on the contents.
13303
+ */
13304
+ const UNIWIND_ENV_FILENAME = "uniwind-env.d.ts";
13305
+ const UNIWIND_ENV_REFERENCE = "/// <reference types=\"uniwind/types\" />";
13306
+ //#endregion
13208
13307
  //#region src/commands/doctor.ts
13209
13308
  async function doctor(options) {
13210
13309
  const output = createOutput(options);
@@ -13233,10 +13332,65 @@ async function runChecks(options) {
13233
13332
  await checkCssEntryImported(config),
13234
13333
  await checkGestureHandlerRoot(config),
13235
13334
  await checkDuplicateNativeModules(project),
13335
+ ...testChecks(await readTestSetup(config, project)),
13236
13336
  checkLock(existsSync(lockPath(config.root)), await hasEntries(config.directories.ui))
13237
13337
  ];
13238
13338
  }
13239
13339
  /**
13340
+ * What `transformIgnorePatterns` has to let through for each engine. Both
13341
+ * publish TypeScript source; the charts engine also imports d3, whose packages
13342
+ * ship ES modules only.
13343
+ */
13344
+ const ENGINE_TRANSFORMS = {
13345
+ "@delacour/react-native-bottom-sheet": ["@delacour/.*"],
13346
+ "@delacour/react-native-charts": [
13347
+ "@delacour/.*",
13348
+ "d3-.*",
13349
+ "internmap"
13350
+ ]
13351
+ };
13352
+ /** Whether copied tests can run: the runner's setup, then what Jest has to transform. */
13353
+ function testChecks(setup) {
13354
+ return [checkTestRunner(setup), checkJestTransform(setup)];
13355
+ }
13356
+ /**
13357
+ * A Jest project that imports an engine has to transform it.
13358
+ *
13359
+ * Jest skips `node_modules` by default and `jest-expo`'s list does not name
13360
+ * these, so every suite reaching `chart` or `bottom-sheet` dies with "Must use
13361
+ * import to load ES Module" and a path inside `node_modules`. Nothing in the app
13362
+ * is wrong, so it is a warning: only the tests are broken. The config is read as
13363
+ * text, so a pattern built in code may be missed — the fix names exactly what
13364
+ * the pattern has to contain.
13365
+ */
13366
+ function checkJestTransform(setup) {
13367
+ const name = "Jest transforms";
13368
+ if (setup.runner !== "jest") return {
13369
+ name,
13370
+ status: "pass",
13371
+ detail: `tests run on ${setup.runner}, not Jest`
13372
+ };
13373
+ const engines = Object.keys(ENGINE_TRANSFORMS).filter((engine) => engine in setup.dependencies);
13374
+ if (engines.length === 0) return {
13375
+ name,
13376
+ status: "pass",
13377
+ detail: "no engine package to transform"
13378
+ };
13379
+ const patterns = setup.jestConfig?.includes("transformIgnorePatterns") ? setup.jestConfig : "";
13380
+ const missing = [...new Set(engines.flatMap((engine) => ENGINE_TRANSFORMS[engine] ?? []))].filter((pattern) => !patterns.includes(pattern.replace(".*", "")));
13381
+ if (missing.length === 0) return {
13382
+ name,
13383
+ status: "pass",
13384
+ detail: `${engines.join(", ")} transformed`
13385
+ };
13386
+ return {
13387
+ name,
13388
+ status: "warn",
13389
+ detail: `${engines.join(", ")} ship${engines.length === 1 ? "s" : ""} untranspiled, and transformIgnorePatterns does not let ${missing.join(", ")} through`,
13390
+ fix: `Add ${missing.map((pattern) => `\`${pattern}\``).join(", ")} to the packages transformIgnorePatterns lets through, beside jest-expo's own.`
13391
+ };
13392
+ }
13393
+ /**
13240
13394
  * Components copied in before the lock existed have nothing to merge from.
13241
13395
  *
13242
13396
  * A warning, never a failure: the components work exactly as they did. What is
@@ -13696,10 +13850,10 @@ function cssImportSpecifier(config) {
13696
13850
  }
13697
13851
  /** A relative path as an import specifier: `../styles/global.css`, `./global.css`. */
13698
13852
  function relativeSpecifier(from, target) {
13699
- const path = toPosix$1(relative(from, target));
13853
+ const path = toPosix$2(relative(from, target));
13700
13854
  return path.startsWith(".") ? path : `./${path}`;
13701
13855
  }
13702
- function toPosix$1(path) {
13856
+ function toPosix$2(path) {
13703
13857
  return sep === "/" ? path : path.split(sep).join("/");
13704
13858
  }
13705
13859
  /**
@@ -13865,6 +14019,186 @@ async function read$1(path) {
13865
14019
  }
13866
14020
  }
13867
14021
  //#endregion
14022
+ //#region src/project/css-entry.ts
14023
+ /**
14024
+ * Finds the Tailwind entry an app already has.
14025
+ *
14026
+ * Creating a second one is the failure this exists to prevent. Metro compiles
14027
+ * the file its `cssEntryFile` names and no other; the app imports whichever
14028
+ * file its root layout imports. Write a new entry beside an existing one and
14029
+ * those become two different files — Tailwind compiles the one with the
14030
+ * `@source` globs, the app loads the one without them, and every component
14031
+ * renders unstyled with nothing logged.
14032
+ *
14033
+ * The Metro config wins when it names one, because that is what actually
14034
+ * compiles — see `readUniwindPaths`. This is the fallback for a project that
14035
+ * has an entry but has not wired Metro to it yet, which is every app that
14036
+ * installed Uniwind and stopped there.
14037
+ *
14038
+ * Shallowest first: an app that ran an older version of this CLI has both
14039
+ * `src/global.css` and `src/styles/global.css`, and the one it imports is the
14040
+ * one it had first.
14041
+ */
14042
+ const CANDIDATES = [
14043
+ "global.css",
14044
+ "src/global.css",
14045
+ "app/global.css",
14046
+ "src/app/global.css",
14047
+ "styles/global.css",
14048
+ "src/styles/global.css",
14049
+ "src/styles/index.css"
14050
+ ];
14051
+ /** `@import "tailwindcss"`, however it is quoted and whatever follows it. */
14052
+ const TAILWIND_IMPORT = /@import\s+["']tailwindcss["']/;
14053
+ function findTailwindEntry(appRoot) {
14054
+ for (const candidate of CANDIDATES) {
14055
+ const path = join(appRoot, candidate);
14056
+ if (!existsSync(path)) continue;
14057
+ try {
14058
+ if (TAILWIND_IMPORT.test(readFileSync(path, "utf-8"))) return path;
14059
+ } catch {}
14060
+ }
14061
+ return null;
14062
+ }
14063
+ //#endregion
14064
+ //#region src/project/metro.ts
14065
+ /**
14066
+ * Wraps the app's Metro config with Uniwind's.
14067
+ *
14068
+ * `withUniwindConfig` has to stay the outermost wrapper — it installs the
14069
+ * transformer that compiles `className` into styles, and a wrapper applied
14070
+ * after it can replace the `transformer` key and leave every component
14071
+ * unstyled with nothing logged. So the patch rewrites the export itself rather
14072
+ * than inserting a line somewhere in the middle.
14073
+ *
14074
+ * When the export is not in a shape this can safely rewrite, it says so and
14075
+ * prints what to write. Guessing at an unfamiliar config file earns nothing —
14076
+ * the failure it causes is a Metro error at bundle time in someone else's app.
14077
+ */
14078
+ /** Modules that must resolve to exactly one copy across a workspace. */
14079
+ const PINNED_MODULES = [
14080
+ "@shopify/react-native-skia",
14081
+ "react",
14082
+ "react-native",
14083
+ "react-native-gesture-handler",
14084
+ "react-native-keyboard-controller",
14085
+ "react-native-reanimated",
14086
+ "react-native-safe-area-context",
14087
+ "react-native-svg",
14088
+ "react-native-teleport",
14089
+ "react-native-worklets"
14090
+ ];
14091
+ const REQUIRE = "const { withUniwindConfig } = require(\"uniwind/metro\");";
14092
+ const IMPORT = "import { withUniwindConfig } from \"uniwind/metro\";";
14093
+ /** `module.exports = config;` or `export default config;`, with nothing after it. */
14094
+ const COMMONJS_EXPORT = /module\.exports\s*=\s*([\s\S]+?);?\s*$/;
14095
+ const ESM_EXPORT = /export default\s+([\s\S]+?);?\s*$/;
14096
+ const CSS_ENTRY = /cssEntryFile\s*:\s*["'`]([^"'`]+)["'`]/;
14097
+ const DTS_FILE = /dtsFile\s*:\s*["'`]([^"'`]+)["'`]/;
14098
+ /**
14099
+ * Reads the paths a wrapped Metro config already names.
14100
+ *
14101
+ * A project that arrives with Uniwind set up — Expo's `with-router-uniwind`
14102
+ * example is the common one — has chosen its own CSS entry, and Metro compiles
14103
+ * **that file and no other**. Writing the `@source` block into the path this
14104
+ * CLI would have picked leaves Tailwind scanning a file with no globs in it, so
14105
+ * every class the components use is dropped and every one of them renders
14106
+ * unstyled. Nothing errors; the app just looks wrong.
14107
+ *
14108
+ * So the wired config wins. Returns nothing when the config is not wired, in
14109
+ * which case `init` is about to wrap it with paths of its own.
14110
+ */
14111
+ function readUniwindPaths(existing) {
14112
+ if (!existing?.includes("withUniwindConfig")) return {};
14113
+ return {
14114
+ css: CSS_ENTRY.exec(existing)?.[1],
14115
+ types: DTS_FILE.exec(existing)?.[1]
14116
+ };
14117
+ }
14118
+ function patchMetroConfig(existing, options) {
14119
+ const wrapper = optionsLiteral(options);
14120
+ const monorepo = options.workspaceRoot && options.workspaceRoot !== options.metroConfigPath ? monorepoResolverBlock(dirname(options.metroConfigPath), options.workspaceRoot) : "";
14121
+ if (existing === null) return {
14122
+ status: "created",
14123
+ content: template(wrapper, monorepo)
14124
+ };
14125
+ if (existing.includes("withUniwindConfig")) return { status: "already-wired" };
14126
+ const isEsm = /^\s*(import|export default)/m.test(existing);
14127
+ const match = (isEsm ? ESM_EXPORT : COMMONJS_EXPORT).exec(existing);
14128
+ if (!match?.[1]) return {
14129
+ status: "manual",
14130
+ snippet: manualSnippet(wrapper, isEsm)
14131
+ };
14132
+ const keyword = isEsm ? "export default" : "module.exports =";
14133
+ const head = existing.slice(0, match.index);
14134
+ const wrapped = `${keyword} withUniwindConfig(${match[1].trim()}, ${wrapper});\n`;
14135
+ const body = monorepo ? `${head}${monorepo}\n` : head;
14136
+ return {
14137
+ status: "patched",
14138
+ content: `${isEsm ? IMPORT : REQUIRE}\n${body}${wrapped}`
14139
+ };
14140
+ }
14141
+ function optionsLiteral(options) {
14142
+ const from = dirname(options.metroConfigPath);
14143
+ return `{\n\tcssEntryFile: "${toSpecifier(from, options.cssPath)}",\n\tdtsFile: "${toSpecifier(from, options.typesPath)}",\n}`;
14144
+ }
14145
+ /**
14146
+ * The resolver settings an app needs to reach a package outside its own tree.
14147
+ *
14148
+ * Metro resolves by walking `node_modules` from the app downwards, so without
14149
+ * `watchFolders` and `nodeModulesPaths` it simply cannot see a sibling package —
14150
+ * the import fails at bundle time with a missing module, pointing at the app
14151
+ * rather than at the workspace.
14152
+ *
14153
+ * `extraNodeModules` is the other half, and it is about runtime rather than
14154
+ * resolution: a package manager can materialise a second copy of a native module
14155
+ * under the app, and two registrations of one native module break. This is the
14156
+ * block `apps/playground/metro.config.js` in this repository carries for exactly
14157
+ * this reason.
14158
+ */
14159
+ function monorepoResolverBlock(appRoot, workspaceRoot) {
14160
+ const pins = PINNED_MODULES.map((name) => `\t${JSON.stringify(name)}: path.resolve(workspaceRoot, "node_modules/${name}"),`).join("\n");
14161
+ return `const path = require("node:path");
14162
+
14163
+ const workspaceRoot = path.resolve(__dirname, "${toSpecifier(appRoot, workspaceRoot)}");
14164
+
14165
+ // The components live in a package outside this app, so Metro has to watch and
14166
+ // resolve beyond the app's own directory.
14167
+ config.watchFolders = [workspaceRoot];
14168
+ config.resolver.nodeModulesPaths = [
14169
+ \tpath.resolve(__dirname, "node_modules"),
14170
+ \tpath.resolve(workspaceRoot, "node_modules"),
14171
+ ];
14172
+ config.resolver.disableHierarchicalLookup = true;
14173
+
14174
+ // One copy of each native module for the whole bundle: two registrations of a
14175
+ // native module break at runtime.
14176
+ config.resolver.extraNodeModules = {
14177
+ ${pins}
14178
+ };
14179
+ `;
14180
+ }
14181
+ function template(wrapper, monorepo) {
14182
+ return `const { getDefaultConfig } = require("expo/metro-config");
14183
+ ${REQUIRE}
14184
+
14185
+ const config = getDefaultConfig(__dirname);
14186
+ ${monorepo ? `\n${monorepo}` : ""}
14187
+ // withUniwindConfig must stay the outermost wrapper.
14188
+ module.exports = withUniwindConfig(config, ${wrapper});
14189
+ `;
14190
+ }
14191
+ function manualSnippet(wrapper, isEsm) {
14192
+ return `${isEsm ? IMPORT : REQUIRE}\n\n// Keep this the outermost wrapper.\n${isEsm ? "export default" : "module.exports ="} withUniwindConfig(config, ${wrapper});`;
14193
+ }
14194
+ function toSpecifier(from, target) {
14195
+ const path = toPosix$1(relative(from, target));
14196
+ return path.startsWith(".") ? path : `./${path}`;
14197
+ }
14198
+ function toPosix$1(path) {
14199
+ return sep === "/" ? path : path.split(sep).join("/");
14200
+ }
14201
+ //#endregion
13868
14202
  //#region src/commands/init.ts
13869
14203
  async function init(components, options) {
13870
14204
  const output = createOutput(options);
@@ -14213,7 +14547,7 @@ async function add(names, options) {
14213
14547
  output.warn("Nothing to add. Name a component, or pass --all.");
14214
14548
  return null;
14215
14549
  }
14216
- const scope = (item) => options.tests ? withTests(item) : item;
14550
+ const scope = (item) => options.tests ? withTests(item, config.testRunner) : item;
14217
14551
  const byName = new Map(index.items.map((item) => [item.name, item]));
14218
14552
  const order = resolveItemGraph(requested, (name) => {
14219
14553
  const entry = byName.get(name);
@@ -14227,16 +14561,31 @@ async function add(names, options) {
14227
14561
  const toWrite = await resolveConflicts(planned, options, output);
14228
14562
  if (toWrite === null) return null;
14229
14563
  await writeFiles(toWrite);
14230
- await recordLock(config, planned, toWrite, options.ref || config.registry.ref || "abcaf1b8a49718368bc5726b1801ec58e75a2477");
14564
+ await recordLock(config, planned, toWrite, options.ref || config.registry.ref || "713984b74d21a6bb6b0e1f719a947cce7a2d847e");
14231
14565
  report$1(toWrite, planned, requested, output);
14232
14566
  const settled = await settleDependencies(items, config, project, options, output);
14567
+ const testSetup = options.tests ? await checkTestSetup(config, project, output) : [];
14233
14568
  return {
14234
14569
  items: items.map((item) => item.name),
14235
14570
  written: toWrite.length,
14236
- ...settled
14571
+ ...settled,
14572
+ testSetup
14237
14573
  };
14238
14574
  }
14239
14575
  /**
14576
+ * Says what the runner still needs before the copied tests will run.
14577
+ *
14578
+ * After the install, so a package it just added is not reported missing. The
14579
+ * CLI writes no runner config — that file is the project's — so a suite that
14580
+ * cannot load is said here, where it was copied, rather than found at the first
14581
+ * test run with an error naming neither the cause nor the fix.
14582
+ */
14583
+ async function checkTestSetup(config, project, output) {
14584
+ const checks = testChecks(await readTestSetup(config, project));
14585
+ for (const check of checks.filter((entry) => entry.status === "warn" || entry.status === "fail")) output.warn([`${check.name}: ${check.detail}`, ...check.fix ? [` ${check.fix}`] : []].join("\n"));
14586
+ return checks;
14587
+ }
14588
+ /**
14240
14589
  * Everything that follows a write: the shared package's manifest, the report of
14241
14590
  * what the components need from npm, and the install if it is wanted.
14242
14591
  *
@@ -14389,7 +14738,7 @@ function printSkippedInstall(plan, appPath, output) {
14389
14738
  * true.
14390
14739
  */
14391
14740
  function warnAboutNativeRebuild(items, project, output) {
14392
- const added = missingPackages(project.packageJson, collect(items, "expoDependencies"));
14741
+ const added = missingPackages$1(project.packageJson, collect(items, "expoDependencies"));
14393
14742
  if (added.length === 0) return;
14394
14743
  output.warn([
14395
14744
  `Added native modules: ${added.join(", ")}`,
@@ -15260,7 +15609,7 @@ async function planUpdate(input) {
15260
15609
  const byName = new Map(index.items.map((item) => [item.name, item]));
15261
15610
  const candidates = input.names.length > 0 ? [...input.names] : [.../* @__PURE__ */ new Set([...Object.keys(lock.items), ...await installedItems(index.items, config)])];
15262
15611
  const dropped = candidates.filter((name) => !byName.has(name) && lock.items[name] !== void 0);
15263
- const scope = (item) => input.tests || hasTests(lock, item.name) ? withTests(item) : item;
15612
+ const scope = (item) => input.tests || hasTests(lock, item.name) ? withTests(item, config.testRunner) : item;
15264
15613
  const order = resolveItemGraph(candidates.filter((name) => !dropped.includes(name)), (name) => {
15265
15614
  const entry = byName.get(name);
15266
15615
  return entry && scope(entry);
@@ -15518,7 +15867,7 @@ async function promptUntracked(files) {
15518
15867
  async function readPlan(names, options, output, clients) {
15519
15868
  const config = await loadConfig(options.cwd);
15520
15869
  const lock = await readLock(config.root);
15521
- const ref = options.ref || config.registry.ref || "abcaf1b8a49718368bc5726b1801ec58e75a2477";
15870
+ const ref = options.ref || config.registry.ref || "713984b74d21a6bb6b0e1f719a947cce7a2d847e";
15522
15871
  const url = options.registry ?? config.registry.url;
15523
15872
  const open = (at) => createRegistryClient({
15524
15873
  cwd: options.cwd,
@@ -31669,6 +32018,7 @@ function describeAdd(result) {
31669
32018
  } else if (result.installed) lines.push(`Installed: ${dependencies.missing.join(", ")}.`);
31670
32019
  else lines.push("Not installed. These have to be run from the app before it will build:", ...dependencies.groups.map((group) => ` ${commandLine(group)}`), "Re-run with install: true to have them run for you.");
31671
32020
  if (dependencies.groups.some((group) => group.label === "expo install")) lines.push("A native module changed — rebuild the dev client; a JS reload will not pick it up.");
32021
+ for (const check of result.testSetup.filter((entry) => entry.status === "warn" || entry.status === "fail")) lines.push(`The copied tests will not run yet — ${check.name}: ${check.detail}.${check.fix ? ` ${check.fix}` : ""}`);
31672
32022
  return lines;
31673
32023
  }
31674
32024
  //#endregion
@@ -32784,7 +33134,7 @@ function report(result, output) {
32784
33134
  if (result.derived.length > 0) output.info(`${result.derived.length} filled in, derived from tokens the theme does define: ${result.derived.map((name) => name.slice(2)).join(", ")}.`);
32785
33135
  for (const warning of result.warnings) output.warn(warning);
32786
33136
  }
32787
- const program = new Command().name("delacour").description("Add React Native components to your Expo app. You own the code.").version("0.5.0-alpha.20261021114607");
33137
+ const program = new Command().name("delacour").description("Add React Native components to your Expo app. You own the code.").version("0.5.0");
32788
33138
  /** Shared by every command that reads the registry. */
32789
33139
  function withRegistryOptions(command) {
32790
33140
  return command.addOption(new Option("--registry <url>", "registry to read from: a URL, github:owner/repo, or a local path")).addOption(new Option("--ref <ref>", "git ref to read the registry at").default(DEFAULT_REGISTRY_REF)).addOption(new Option("--offline", "use only what is already cached")).addOption(new Option("-c, --cwd <path>", "directory to work in").default(process.cwd()));