diff --git a/.changeset/tough-tigers-notice.md b/.changeset/tough-tigers-notice.md new file mode 100644 index 0000000..6f3ac4e --- /dev/null +++ b/.changeset/tough-tigers-notice.md @@ -0,0 +1,5 @@ +--- +"@rnm/tscx": minor +--- + +feat: init diff --git a/packages/tscx/README.md b/packages/tscx/README.md index 6f6d045..40befd3 100644 --- a/packages/tscx/README.md +++ b/packages/tscx/README.md @@ -1,3 +1,50 @@ -# tscx(WIP) +# TSCX + +[![](https://img.shields.io/npm/l/@rnm/tscx.svg)](https://github.com/rnmjs/tscx/blob/main/LICENSE) +[![](https://img.shields.io/npm/v/@rnm/tscx.svg)](https://www.npmjs.com/package/@rnm/tscx) +[![](https://img.shields.io/npm/dm/@rnm/tscx.svg)](https://www.npmjs.com/package/@rnm/tscx) +[![](https://img.shields.io/librariesio/release/npm/@rnm/tscx)](https://www.npmjs.com/package/@rnm/tscx) +[![](https://packagephobia.com/badge?p=@rnm/tscx)](https://packagephobia.com/result?p=@rnm/tscx) A tsc wrapper with many convenient features. + +## Highlight + +- Same usages as `tsc` with few additional options. +- Remove output folder before before every compilation. +- Copy non-ts files to output folder after every compilation. +- Execute js file after compilation success. +- Respect `tsconfig.json`. +- ESM. + +## Install + +```sh +npm install typescript @nrm/tscx +``` + +## Usages + +```sh +# Equivalent to `npx tsc` +$ npx tscx + +# Equivalent to `npx tsc --project tsconfig.build.json --watch` +$ npx tscx --project tsconfig.build.json --watch + +# Remove output folder before before compilation and then compile ts code. +$ npx tscx --remove + +# Compile ts code and then copy non-ts files to output folder after compilation. +$ npx tscx --copyfiles + +# Compile ts code in watch mode and execute bootstrap.js after every compilation success. +$ npx tscx --project tsconfig.build.json --watch --exec ./bootstrap.js + +# Remove => Compile => Copy => Execute => Edit files to repeat it +$ npx tscx --project tsconfig.build.json --watch --remove --copyfiles --exec ./bootstrap.js +``` + +License + +MIT diff --git a/packages/tscx/package.json b/packages/tscx/package.json index cde42c8..a8ae39a 100644 --- a/packages/tscx/package.json +++ b/packages/tscx/package.json @@ -20,6 +20,9 @@ "license": "MIT", "author": "hellozmj@qq.com", "type": "module", + "scripts": { + "build": "tsc -p tsconfig.build.json" + }, "dependencies": { "chokidar": "3.6.0", "commander": "12.0.0" diff --git a/packages/tscx/src/action.ts b/packages/tscx/src/action.ts new file mode 100644 index 0000000..9a59cd6 --- /dev/null +++ b/packages/tscx/src/action.ts @@ -0,0 +1,67 @@ +import path from "node:path"; +import process from "node:process"; +import chokidar, { type FSWatcher } from "chokidar"; +import { Compiler, type CompilerOptions } from "./compiler.js"; + +interface TscxOptions extends CompilerOptions { + watch: boolean; +} + +export class Action { + private readonly compiler; + private watcher?: FSWatcher; + constructor(private readonly options: TscxOptions) { + this.compiler = new Compiler(options); + } + + private setupWatcher() { + const include = this.compiler.getInclude() ?? []; + const watchFiles = + include.length <= 0 + ? [process.cwd()] + : include + .map((i) => path.resolve(process.cwd(), i)) + .concat(path.resolve(process.cwd(), this.options.project)); + + this.watcher = chokidar.watch(watchFiles, { + ignored: ["**/node_modules/**", "**/.git/**", this.compiler.getOutDir()], + ignoreInitial: true, + }); + this.watcher + .on("add", (filepath) => this.cb(filepath)) + .on("unlink", (filepath) => this.cb(filepath)) + .on("change", (filepath) => this.cb(filepath)) + .on("ready", () => this.cb()); + } + + private cb(filepath?: string) { + console.log("Recompile for the file updated", filepath); + if ( + !filepath || + path.resolve(process.cwd(), filepath) !== + path.resolve(process.cwd(), this.options.project) + ) { + return this.compiler.exec(); + } + + this.compiler.refreshTsConfig(); + this.watcher + ?.close() + .then(() => { + this.setupWatcher(); + }) + .catch((e) => { + console.error("Close watcher fail!", e); + process.exit(1); + }); + } + + start() { + if (!this.options.watch) { + this.compiler.exec(); + return; + } + + this.setupWatcher(); + } +} diff --git a/packages/tscx/src/bin/tscx.ts b/packages/tscx/src/bin/tscx.ts new file mode 100644 index 0000000..b150711 --- /dev/null +++ b/packages/tscx/src/bin/tscx.ts @@ -0,0 +1,44 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Command } from "commander"; +import { Action } from "../action.js"; + +const version: string = JSON.parse( + await fs.readFile( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "package.json", + ), + "utf8", + ), +).version; + +new Command() + .name("tscx") + .version(version) + .description("The TypeScript Compiler. Run `tsc` under the hood.") + .option( + "-p, --project ", + "Compile the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.", + "tsconfig.json", + ) + .option("-w, --watch", "Watch input files.") + .option( + "-r, --remove", + "Remove output folder before before every compilation.", + ) + .option( + "-c, --copyfiles", + "Copy non-ts files to output folder after every compilation.", + ) + .option( + "-e, --exec ", + "Execute the specified js file after compilation success", + ) + .action((options) => { + new Action(options).start(); + }) + .parse(); diff --git a/packages/tscx/src/cmd/copyfiles.mts b/packages/tscx/src/cmd/copyfiles.mts new file mode 100644 index 0000000..b2c1ba6 --- /dev/null +++ b/packages/tscx/src/cmd/copyfiles.mts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +/** + * Copy non-ts/non-js files to outDir + * @param rootDir absolute path + * @param outDir absolute path + */ +async function copyfiles(rootDir: string, outDir: string) { + rootDir = path.resolve(rootDir); + outDir = path.resolve(outDir); + async function walkDir(dir: string, cb: (filepath: string) => Promise) { + await Promise.all( + (await fs.readdir(dir)) + .map((filepath) => path.resolve(dir, filepath)) + .map(async (filepath) => { + if ((await fs.stat(filepath)).isDirectory()) { + if ( + filepath !== outDir && + !filepath.endsWith(`${path.sep}node_modules`) + ) { + await walkDir(filepath, cb); + } + } else { + if (!/\.(js|cjs|mjs|jsx|ts|cts|mts|tsx)$/.test(filepath)) { + await cb(filepath); + } + } + }), + ); + } + await walkDir(rootDir, async (filepath) => { + const dest = filepath.replace(rootDir, outDir); + console.log("Copy", filepath, "=>", dest); + await fs.copyFile(filepath, dest); + }); +} + +const rootDir = process.argv[2]; +const outDir = process.argv[3]; +if (!rootDir || !outDir) { + throw new Error("`rootDir` and `outDir` are required"); +} + +await copyfiles(rootDir, outDir); diff --git a/packages/tscx/src/cmd/index.ts b/packages/tscx/src/cmd/index.ts new file mode 100644 index 0000000..18c895b --- /dev/null +++ b/packages/tscx/src/cmd/index.ts @@ -0,0 +1,37 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REMOVE_PATH = path.resolve(__dirname, "remove.mjs"); +const COPYFILES_PATH = path.resolve(__dirname, "copyfiles.mjs"); +const TSC_PATH = path.resolve( + process.cwd(), + "node_modules", + "typescript", + "bin", + "tsc", +); + +export function remove(filepath: string) { + console.log("Remove", filepath); + return spawn("node", [REMOVE_PATH, filepath], { stdio: "inherit" }); +} + +export function tsc(options: { project: string }) { + console.log("Tsc", options); + return spawn("node", [TSC_PATH, "--project", options.project], { + stdio: "inherit", + }); +} + +export function copyfiles(rootDir: string, outDir: string) { + console.log("Copyfiles", rootDir, "=>", outDir); + return spawn("node", [COPYFILES_PATH, rootDir, outDir], { stdio: "inherit" }); +} + +export function exec(filepath: string) { + console.log("Execute", filepath); + return spawn("node", [filepath], { stdio: "inherit" }); +} diff --git a/packages/tscx/src/cmd/remove.mts b/packages/tscx/src/cmd/remove.mts new file mode 100644 index 0000000..3f3f4e1 --- /dev/null +++ b/packages/tscx/src/cmd/remove.mts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import process from "node:process"; + +/** + * @param filepath absolute filepath + */ +async function remove(filepath: string) { + await new Promise((resolve, reject) => { + fs.stat(filepath, (err) => { + if (err) { + return err.code === "ENOENT" ? resolve() : reject(err); // do nothing if file not found + } + fs.rm(filepath, { recursive: true }, (e) => (e ? reject(e) : resolve())); + }); + }); + console.log(`Removed ${filepath}`); +} + +const filepath = process.argv[2]; +if (!filepath) { + throw new Error("File path is required"); +} + +await remove(filepath); diff --git a/packages/tscx/src/compiler.ts b/packages/tscx/src/compiler.ts new file mode 100644 index 0000000..7b6f0ad --- /dev/null +++ b/packages/tscx/src/compiler.ts @@ -0,0 +1,169 @@ +// eslint-disable-next-line n/no-sync +import { type ChildProcess, execSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import type ts from "typescript"; +import { copyfiles, exec, remove, tsc } from "./cmd/index.js"; + +export interface CompilerOptions { + project: string; + remove: boolean; + copyfiles: boolean; + exec?: string; +} + +export interface TsConfig { + compilerOptions?: ts.CompilerOptions; + include?: string[]; + exclude?: string[]; + files?: string[]; +} + +export class Compiler { + private id = ""; + private currentSubprocess?: ChildProcess; + private tsconfig: TsConfig; + + constructor(private readonly options: CompilerOptions) { + // setup options + this.options.project = path.resolve(process.cwd(), this.options.project); + if (this.options.exec) { + this.options.exec = path.resolve(process.cwd(), this.options.exec); + } + // setup tsconfig + this.tsconfig = this.getTsConfig(); + } + + exec() { + const id = Date.now() + "_" + Math.random().toString(36).slice(2); + this.id = id; + + if (!this.currentSubprocess) { + this.execTasks(id); + return; + } + if (typeof this.currentSubprocess.exitCode === "number") { + this.execTasks(id); + return; + } + if (!this.currentSubprocess.killed) { + this.currentSubprocess.kill(); + } + this.currentSubprocess.on("close", () => { + this.execTasks(id); + }); + } + + private execTasks(id: string) { + const outDir = this.getOutDir(); + const rootDir = this.getRootDir(); + + const removeTask = () => remove(outDir); + const tscTask = () => tsc({ project: this.options.project }); + const copyfilesTask = () => copyfiles(rootDir, outDir); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const execTask = () => exec(this.options.exec!); + + const tasks = [ + ...(this.options.remove ? [removeTask] : []), + tscTask, + ...(this.options.copyfiles ? [copyfilesTask] : []), + ...(this.options.exec ? [execTask] : []), + ]; + + const execNextTask = (index = 0) => { + const currentTask = tasks[index]; + if (!currentTask || this.id !== id) { + return; + } + this.currentSubprocess = currentTask(); + this.currentSubprocess.on("close", (code, signal) => { + if (code || signal) { + return; + } + execNextTask(index + 1); + }); + }; + execNextTask(); + } + + refreshTsConfig() { + this.tsconfig = this.getTsConfig(); + } + + private getTsConfig(): TsConfig { + const tscPath = path.resolve( + process.cwd(), + "node_modules", + "typescript", + "bin", + "tsc", + ); + const cmd = `node ${tscPath} --showConfig --project ${this.options.project}`; + // eslint-disable-next-line n/no-sync + return JSON.parse(execSync(cmd).toString("utf8")); + } + + getInclude() { + return this.tsconfig.include; + } + + getOutDir() { + const outDir = this.tsconfig.compilerOptions?.outDir; + if (!outDir) { + throw new Error(`"outDir" is not found`); + } + const absoluteOutDir = path.resolve(process.cwd(), outDir); + if (process.cwd().startsWith(absoluteOutDir)) { + throw new Error( + '"outDir" in tsconfig.json should not be current or parent directory', + ); + } + return absoluteOutDir; + } + + private getRootDir() { + const rootDir = this.tsconfig.compilerOptions?.rootDir; + if (rootDir) { + return path.resolve(process.cwd(), rootDir); + } else { + return path.resolve( + process.cwd(), + this.getRootDirByFiles(this.tsconfig.files ?? []), + ); + } + } + + /** + * Get the longest common dir. https://www.typescriptlang.org/tsconfig#rootDir + * @param files file paths like ['./src/index.ts', './index.ts'] + * @returns absolute path + */ + private getRootDirByFiles(files: string[]) { + if (files.length === 0) { + throw new Error( + "Cannot get the longest common dir when the arguments is empty", + ); + } + + const folder = files + .map((file) => file.split(path.sep).slice(0, -1)) + .reduce((prev, item) => { + if (prev.length === 0) { + return item; + } + const result: string[] = []; + for (let i = 0; i < prev.length && i < item.length; i += 1) { + const sub = prev[i]; + if (sub && sub === item[i]) { + result[i] = sub; + } else { + break; + } + } + return result; + }, []); + + return path.join(...folder); + } +} diff --git a/packages/tscx/src/index.ts b/packages/tscx/src/index.ts deleted file mode 100644 index e69de29..0000000