build-ts-package.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. #!/usr/bin/env node
  2. import glob from "fast-glob";
  3. import fs from "fs-extra";
  4. import path from "node:path";
  5. import createCLI from "yargs";
  6. import { hideBin } from "yargs/helpers";
  7. import { findTsconfigFile } from "./find-tsconfig-file.js";
  8. import { execAsync } from "./exec-async.js";
  9. import { generateTsconfigs } from "./generate-tsconfigs.js";
  10. import { Logger } from "./logger.js";
  11. import chalk from "chalk";
  12. /**
  13. * @typedef {'cjs' | 'esm'} ModuleType
  14. */
  15. /**
  16. * builds a typescript package, using tsdown and its Node-friendly API
  17. * @returns {Promise<void>}
  18. */
  19. export async function buildTsPackage(argv = process.argv) {
  20. const yargs = createCLI(hideBin(argv));
  21. const {
  22. clean,
  23. cwd,
  24. generateTsconfig,
  25. noCjs,
  26. noDts,
  27. noEsm,
  28. outDir,
  29. tsconfig: tsconfigOverride,
  30. watch,
  31. } = await yargs
  32. .scriptName("build-ts-package")
  33. .option("clean", {
  34. default: false,
  35. description:
  36. "if set, will clean out the build dirs before compiling anything",
  37. type: "boolean",
  38. })
  39. .option("cwd", {
  40. default: process.cwd(),
  41. description: "the CWD to use when building",
  42. type: "string",
  43. })
  44. .option("generateTsconfig", {
  45. default: false,
  46. description:
  47. "if set, will NOT build, but instead, will generate reasonable default TSConfig files that will work with dual publishing, and in most other use cases, as well",
  48. type: "boolean",
  49. })
  50. .option("noCjs", {
  51. default: false,
  52. description:
  53. "if true, will not build the CommonJS variant of this package",
  54. type: "boolean",
  55. })
  56. .option("noDts", {
  57. default: false,
  58. description: "if set, will not write typescript typings",
  59. type: "boolean",
  60. })
  61. .option("noEsm", {
  62. default: false,
  63. description: "if true, will not build the ESM variant of this package",
  64. type: "boolean",
  65. })
  66. .option("outDir", {
  67. default: "dist",
  68. description: "the folder where the built files will be written",
  69. type: "string",
  70. })
  71. .option("tsconfig", {
  72. description:
  73. "if provided, will explicitly use this tsconfig.json location instead of searching for a tsconfig.build.json or a plain tsconfig.json",
  74. type: "string",
  75. })
  76. .option("watch", {
  77. default: false,
  78. description:
  79. "if set, will automatically watch for any changes to this library and rebuild, making it easier for you to consume changes in the monorepo while doing local development",
  80. type: "boolean",
  81. })
  82. .help().argv;
  83. if (generateTsconfig) {
  84. return generateTsconfigs(cwd);
  85. }
  86. const outDirPath = path.isAbsolute(outDir) ? outDir : path.join(cwd, outDir);
  87. if (clean) await fs.remove(outDirPath);
  88. // ESM Must come before CJS, as those typings and such take precedence
  89. // when dual publishing.
  90. const formats = /** @type {ModuleType[]} */ (
  91. [noEsm ? undefined : "esm", noCjs ? undefined : "cjs"].filter(Boolean)
  92. );
  93. const tsconfig = await findTsconfigFile(cwd, tsconfigOverride);
  94. if (!tsconfig) {
  95. throw new Error(`unable to build ${cwd} because no tsconfig was found`);
  96. }
  97. const pjsonPath = path.join(path.dirname(tsconfig), "package.json");
  98. const numFormats = formats.length;
  99. const pjson = JSON.parse(await fs.readFile(pjsonPath, "utf8"));
  100. // always freshly reset the exports and let the tool take over
  101. pjson.exports = {};
  102. Logger.info("building package", chalk.magenta(pjson.name));
  103. for (const format of formats) {
  104. try {
  105. Logger.info("building", chalk.magenta(format), "variant in", cwd);
  106. Logger.info(" tsconfig", chalk.magenta(tsconfig));
  107. const outDir =
  108. numFormats <= 1 ? outDirPath : path.join(outDirPath, format);
  109. let cmd =
  110. `pnpm tsc --project ${tsconfig} --outDir ${outDir} --declaration ${!noDts} --module ${format === "cjs" ? "nodenext" : "esnext"} --target esnext --resolveJsonModule false ${format === "cjs" ? "--moduleResolution nodenext" : ""}`.trim();
  111. if (watch) cmd += ` --watch`;
  112. await execAsync(cmd, { cwd, stdio: "inherit", verbose: true });
  113. const builtFiles = (
  114. await glob(
  115. [
  116. path.join(outDir, "**", "*.d.ts"),
  117. path.join(outDir, "**", "*.js"),
  118. path.join(outDir, "**", "*.cjs"),
  119. path.join(outDir, "**", "*.mjs"),
  120. ],
  121. { absolute: true, onlyFiles: true },
  122. )
  123. )
  124. .map((fp) => {
  125. const relPath = path.relative(outDir, fp);
  126. if (numFormats <= 1) return `.${path.sep}${relPath}`;
  127. return `.${path.sep}${path.join(format, relPath)}`;
  128. })
  129. .sort();
  130. const indexFile = builtFiles.find((fp) => {
  131. const r = /^\.(\/|\\)((cjs|esm)(\/|\\))?index\.(c|m)?js$/;
  132. return r.test(fp);
  133. });
  134. if (indexFile) {
  135. Logger.info("index file detected");
  136. if (format === "cjs" || numFormats <= 1) {
  137. // we use the legacy type of typing exports for the top-level
  138. // typings
  139. pjson.types = indexFile.replace(path.extname(indexFile), ".d.ts");
  140. }
  141. if (format === "esm") {
  142. pjson.module = indexFile;
  143. } else {
  144. pjson.main = indexFile;
  145. }
  146. }
  147. const exports =
  148. Array.isArray(pjson.exports) || typeof pjson.exports === "string"
  149. ? {}
  150. : (pjson.exports ?? {});
  151. const outDirBasename = path.basename(outDirPath);
  152. for (const fp of builtFiles) {
  153. const fpWithNoExt = fp
  154. .replace(/(\.d)?\.(c|m)?(js|ts)$/, "")
  155. .replaceAll(/\\/g, "/");
  156. const key = fpWithNoExt
  157. .replace(/(\/|\\)?index$/, "")
  158. .replace(/^\.(\/|\\)(cjs|esm)/, ".")
  159. .replaceAll(/\\/g, "/");
  160. const fpWithBasename = `./${path
  161. .join(outDirBasename, fp)
  162. .replaceAll(/\\/g, "/")}`;
  163. // Ensure key object exists
  164. const tempExports = exports[key] ?? {};
  165. // Add require/import entry without nuking the other
  166. if (format === "cjs") {
  167. tempExports.require = fpWithBasename;
  168. } else {
  169. tempExports.import = fpWithBasename;
  170. }
  171. // Also handle types if present
  172. if (
  173. (format === "esm" || numFormats <= 1) &&
  174. !noDts &&
  175. fp.endsWith(".d.ts")
  176. ) {
  177. tempExports.types = fpWithBasename;
  178. }
  179. exports[key] = tempExports;
  180. }
  181. pjson.exports = exports;
  182. Logger.info(chalk.green(`${pjson.name} - ${format} has been built!`));
  183. } catch (error) {
  184. Logger.error(
  185. "**building",
  186. pjson.name,
  187. chalk.underline(format),
  188. "variant has failed**",
  189. );
  190. throw error;
  191. }
  192. }
  193. pjson.exports["./package.json"] = "./package.json";
  194. await fs.writeFile(pjsonPath, JSON.stringify(pjson, null, 2), "utf8");
  195. }
  196. await buildTsPackage();