diff --git a/.vscode/settings.json b/.vscode/settings.json index deb0e8c..f2bd070 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,5 @@ "**/.yarn": true, "**/dist/": true, "**/.git/": true - }, + } } diff --git a/.yarnrc.yml b/.yarnrc.yml index 44adcc0..769a2be 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -9,6 +9,6 @@ nodeLinker: node-modules plugins: - checksum: f8ce3a541a57481f6213647cfe7ac8555d87cb1fc23a73a102e64e12be07f603826d822f4d0285333400f0f15baed92510c33dcce75dde2953b2811e36efa012 path: .yarn/plugins/@yarnpkg/plugin-git-hooks.cjs - spec: "https://raw.githubusercontent.com/trufflehq/yarn-plugin-git-hooks/main/bundles/%40yarnpkg/plugin-git-hooks.js" + spec: 'https://raw.githubusercontent.com/trufflehq/yarn-plugin-git-hooks/main/bundles/%40yarnpkg/plugin-git-hooks.js' yarnPath: .yarn/releases/yarn-4.3.1.cjs diff --git a/README.md b/README.md index 742b127..3781f5a 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ ## Table of Contents -- [Table of Contents](#table-of-contents) -- [Description](#description) -- [Features](#features) -- [Usage](#usage) - - [Global Options](#global-options) - - [Commands](#commands) -- [Conversion Exemples](#conversion-exemples) - - [Input](#input) - - [Output](#output) -- [Contributors](#contributors) +- [Table of Contents](#table-of-contents) +- [Description](#description) +- [Features](#features) +- [Usage](#usage) + - [Global Options](#global-options) + - [Commands](#commands) +- [Conversion Exemples](#conversion-exemples) + - [Input](#input) + - [Output](#output) +- [Contributors](#contributors) ## Description @@ -34,10 +34,10 @@ This CLI tool is designed to convert Sapphire Framework command files from JavaS [Back to top][toc] -- Convert a single command file -- Convert all command files within a directory -- Replace original JS command file(s) with converted TypeScript files -- Overwrite existing TS file +- Convert a single command file +- Convert all command files within a directory +- Replace original JS command file(s) with converted TypeScript files +- Overwrite existing TS file ## Usage @@ -63,13 +63,13 @@ npx saph-convert cdir [ouptutDirectory] [options] ### Global Options -- `-o, --overwrite`: Overwrite existing TypeScript file(s) if they exist. Default: Enable. -- `-r, --replace`: Replace original JavaScript file(s) with converted TypeScript file(s). Default: Enable. +- `-o, --overwrite`: Overwrite existing TypeScript file(s) if they exist. Default: Enable. +- `-r, --replace`: Replace original JavaScript file(s) with converted TypeScript file(s). Default: Enable. ### Commands -- `cf [ouptutDirectory] [options]`: Convert a single file. -- `cdir [ouptutDirectory] [options]`: Convert all files in a directory. +- `cf [ouptutDirectory] [options]`: Convert a single file. +- `cdir [ouptutDirectory] [options]`: Convert all files in a directory. ## Conversion Exemples @@ -89,31 +89,29 @@ npx saph-convert cdir ./commands const { ApplicationCommandRegistry, Command } = require('@sapphire/framework'); module.exports = class UserCommand extends Command { - /* - * @param {Command.LoaderContext} registry - */ - constructor(context) { - super(context, { - name: 'ping', - description: 'Ping the bot to check if it is alive.', - }); - } - - /** - * @param {ApplicationCommandRegistry} registry - */ - registerApplicationCommands(registry) { - registry.registerChatInputCommand((builder) => - builder.setName(this.name).setDescription(this.description) - ); - } - - /** - * @param {Command.ChatInputCommandInteraction} interaction - */ - async chatInputRun(interaction) { - return interaction.reply('Pong!'); - } + /* + * @param {Command.LoaderContext} registry + */ + constructor(context) { + super(context, { + name: 'ping', + description: 'Ping the bot to check if it is alive.' + }); + } + + /** + * @param {ApplicationCommandRegistry} registry + */ + registerApplicationCommands(registry) { + registry.registerChatInputCommand((builder) => builder.setName(this.name).setDescription(this.description)); + } + + /** + * @param {Command.ChatInputCommandInteraction} interaction + */ + async chatInputRun(interaction) { + return interaction.reply('Pong!'); + } }; ``` @@ -126,16 +124,13 @@ import { ApplyOptions } from '@sapphire/decorators'; @ApplyOptions({ description: 'Ping the bot to check if it is alive.' }) export class UserCommand extends Command { - - public override registerApplicationCommands(registry: ApplicationCommandRegistry) { - registry.registerChatInputCommand((builder) => - builder.setName(this.name).setDescription(this.description) - ); - } - - public async chatInputRun(interaction: Command.ChatInputCommandInteraction) { - return interaction.reply('Pong!'); - } + public override registerApplicationCommands(registry: ApplicationCommandRegistry) { + registry.registerChatInputCommand((builder) => builder.setName(this.name).setDescription(this.description)); + } + + public async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + return interaction.reply('Pong!'); + } } ``` diff --git a/.cliff.toml b/cliff.toml similarity index 100% rename from .cliff.toml rename to cliff.toml diff --git a/package.json b/package.json index 13fabac..cc858fe 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,9 @@ "publishConfig": { "access": "public" }, + "eslintConfig": { + "extends": "@sapphire" + }, "prettier": "@sapphire/prettier-config", "packageManager": "yarn@4.3.1" } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f34d1ea --- /dev/null +++ b/src/index.ts @@ -0,0 +1,345 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { ClassDeclaration, ConstructorDeclaration, MethodDeclaration, Project, Scope, SourceFile } from 'ts-morph'; +import Logger from './utils/Logger.js'; + +const program = new Command(); + +const packageFile = new URL('../package.json', import.meta.url); +const packageJson = JSON.parse(await fs.readFile(packageFile, 'utf-8')); + +program // + .name('saph-convert') + .description('CLI tool to convert Sapphire.js command files from JS to TS') + .version(packageJson.version); + +program + .option('-r, --replace', 'Replace original JS command files with converted TypeScript files. Default: Disabled') + .option('-o, --overwrite', 'Overwrite existing TypeScript files. Default: Enabled'); + +program + .command('cf') + .description('Convert a specific JS command file to TS') + .argument('', 'Path to the JS command file to convert') + .argument('[outputPath]', 'Output path for the TS file. Defaults to same directory as input file.') + .addHelpText('afterAll', `\nExample:\n $ saph-convert cf src/commands/myCommand.js [dist/commands/myCommand]\n`) + .action(async (inputFile: string, outputPath?: string) => { + const options = program.opts(); + await convertFile(inputFile, outputPath, options.overwrite, options.replace); + }); + +program + .command('cdir') + .description('Recursively convert all JS command files in a directory to TS') + .argument( + '', + 'Directory containing Sapphire.js JS command files to convert to TS. ❗ Be cautious: this will blindly convert by the `.js` extension in the directory' + ) + .argument('[outputDirectory]', 'Output directory for the TS files. Defaults to same directory as input.') + .addHelpText('afterAll', `\nExample:\n $ saph-convert cdir src/commands [dist/commands]\n`) + .action(async (inputDirectory: string, outputDirectory?: string) => { + const options = program.opts(); + await convertDirectory(inputDirectory, outputDirectory, options.overwrite, options.replace); + }); + +if (!process.argv.slice(2).length) { + program.outputHelp(); +} + +program.parse(process.argv); + +/** + * Reads a JavaScript file from the given path. + * + * @param {string} inputFile - The path to the input JavaScript file. + * @returns {Promise} The content of the JavaScript file. + */ +async function readJavaScriptFile(inputFile: string): Promise { + if (!inputFile.endsWith('.js')) { + inputFile += '.js'; + } + return fs.readFile(inputFile, 'utf-8'); +} + +/** + * Converts JavaScript code to TypeScript code using several transformation methods + * + * @param {string} jsCode - The JavaScript code to convert. + * @returns {string} The converted TypeScript code. + */ +function convertToTypeScript(jsCode: string): string { + const project = new Project(); + const sourceFile = project.createSourceFile('temp.ts', jsCode); + + transformClasses(sourceFile); + transformFunctions(sourceFile); + transformMethods(sourceFile); + + addApplyOptionsImport(sourceFile); + + return sourceFile.getText(); +} + +/** + * Transforms the classes in the source file. + * + * @param {SourceFile} sourceFile - The source file containing the classes to transform. + */ +function transformClasses(sourceFile: SourceFile) { + sourceFile.getClasses().forEach((cls) => { + cls.rename('UserCommand'); + + const constructor = cls.getConstructors()[0]; + if (constructor) { + const description = getDescriptionFromConstructor(constructor); + if (description) { + addApplyOptionsDecorator(cls, description); + } + constructor.remove(); + } + }); +} + +/** + * Extracts the description from the constructor parameters using regex. + * Used for {@link addApplyOptionsDecorator ApplyOptions decorator}. + * @param {any} constructor - The constructor to extract the description from. + * @returns {string | undefined} The description if found, otherwise undefined. + */ +function getDescriptionFromConstructor(constructor: ConstructorDeclaration): string | undefined { + const constructorText = constructor.getText().replace(/\t/g, ''); + const descriptionMatch = constructorText.match(/description:\s*['"](.+?)['"]/); + + if (!descriptionMatch) { + return undefined; + } + + const descriptionValue = descriptionMatch[1]; + + return descriptionValue; +} + +/** + * Adds the `@ApplyOptions` decorator to the class with the existing description from the command constructor. + * + * @param {any} cls - The class to add the decorator to. + * @param {string} description - The description to use in the decorator. + */ +function addApplyOptionsDecorator(cls: ClassDeclaration, description: string) { + cls.addDecorator({ + name: 'ApplyOptions', + arguments: [`{ description: "${description}" }`], + typeArguments: ['Command.Options'] + }); +} + +/** + * Transforms the functions in the source file by setting their return type to Promise. + * + * @param {SourceFile} sourceFile - The source file containing the functions to transform. + */ +function transformFunctions(sourceFile: SourceFile) { + sourceFile.getFunctions().forEach((func) => { + if (func.getName() === 'registerApplicationCommands' || func.getName() === 'chatInputRun') { + func.setReturnType('Promise'); + } + }); +} + +/** + * Utility function to set scope and override keyword on methods. + * + * Intended to be anonymously used but it's currently intended for `registerApplicationCommands` and `chatInputRun`. + * + * @param {MethodDeclaration} method - The method to transform. + * @param {{prefix?: true}} args - Arguments to specify transformation options. + * @returns {MethodDeclaration} The transformed method. + */ +function methodTransUtil(method: MethodDeclaration, args: { prefix?: true }): MethodDeclaration { + if (args.prefix) { + method.setScope(Scope.Public); + method.setHasOverrideKeyword(true); + } + return method; +} + +/** + * Utility function to set parameter types on methods based on a predefined mapping. + * + * @param {MethodDeclaration} method - The method to transform. + * @param {{prefix?: true}} args - Arguments to specify transformation options. + * @returns {MethodDeclaration} The transformed method. + */ +function paramTypeUtils(method: MethodDeclaration, args: { prefix?: true }): MethodDeclaration { + const paramTypes: { [key: string]: string } = { + registry: 'Command.Registry', + interaction: 'Command.ChatInputCommandInteraction' + }; + if (args.prefix) { + // Ensure we target `registry` param + const parameter = method.getParameters(); + parameter.forEach((param) => { + const paramName = param.getName(); + if (paramTypes[paramName]) { + param.setType(paramTypes[paramName]); + } + }); + } + return method; +} + +/** + * Transforms the methods in the source file by setting their parameter types and adding the scope and override keyword. + * + * @param {SourceFile} sourceFile - The source file containing the methods to transform. + */ +function transformMethods(sourceFile: SourceFile) { + const methodsToTransform = ['registerApplicationCommands', 'chatInputRun']; + sourceFile.getClasses().forEach((cls) => { + cls.getMethods().forEach((method) => { + if (methodsToTransform.includes(method.getName())) { + methodTransUtil(method, { prefix: true }); + paramTypeUtils(method, { prefix: true }); + } + }); + }); +} + +/** + * + * @param {SourceFile} sourceFile - The source file to check and modify. + */ +function addApplyOptionsImport(sourceFile: SourceFile) { + const applyOptionsUsed = sourceFile.getClasses().some((cls) => cls.getDecorators().some((dec) => dec.getName() === 'ApplyOptions')); + + if (applyOptionsUsed) { + sourceFile.addImportDeclaration({ + moduleSpecifier: '@sapphire/decorators', + namedImports: ['ApplyOptions'] + }); + } +} + +/** + * Saves the TypeScript code to the specified output path. + * + * @param {string} tsCode - The TypeScript code to save. + * @param {string} outputPath - The path to save the TypeScript file, including the directory and file name without extension. + * @param {boolean} overwrite - Whether to overwrite existing files. + * @param {boolean} replace - Whether to delete the original JavaScript file after conversion. + */ +async function saveTypeScriptFile(tsCode: string, outputPath: string, overwrite: boolean, replace: boolean) { + const outputDir = path.dirname(outputPath); + const outputFileName = `${path.basename(outputPath, path.extname(outputPath))}.ts`; + + await fs.mkdir(outputDir, { recursive: true }); + const outputFilePath = path.join(outputDir, outputFileName); + + if (!overwrite) { + try { + await fs.access(outputFilePath); + Logger.warn(`File ${outputFilePath} already exists. Skipping.`); + return; + } catch { + // File does not exist, proceed with writing + } + } + + await fs.writeFile(outputFilePath, tsCode); + if (replace) { + try { + await fs.unlink(outputFilePath.replace(/\.ts$/, '.js')); + } catch (error: unknown) { + if (error instanceof Error) { + Logger.error(`Error deleting original JavaScript file: ${error.message}`); + } else { + Logger.error(`Unexpected error occurred while deleting original JavaScript file`); + } + } + } +} + +/** + * Finds all JavaScript files in a directory recursively. + * + * @param {string} directory - The directory to search for JavaScript files. + * @returns {Promise} A list of JavaScript file paths. + */ +async function findJavaScriptFiles(directory: string): Promise { + let jsFiles: string[] = []; + const items = await fs.readdir(directory, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(directory, item.name); + if (item.isDirectory()) { + jsFiles = jsFiles.concat(await findJavaScriptFiles(fullPath)); + } else if (item.isFile() && fullPath.endsWith('.js')) { + jsFiles.push(fullPath); + } + } + + return jsFiles; +} + +/** + * Converts a specific JavaScript file to TypeScript. + * + * @param {string} inputFile - The JavaScript file to convert. + * @param {string} outputPath - The output path for the TypeScript file. + * @param {boolean} overwrite - Whether to overwrite existing files. + * @param {boolean} replace - Whether to delete the original JavaScript file after conversion. + */ + +async function convertFile(inputFile: string, outputPath?: string, overwrite: boolean = true, replace: boolean = false) { + try { + const jsCode = await readJavaScriptFile(inputFile); + const tsCode = convertToTypeScript(jsCode); + if (!outputPath) { + outputPath = path.join(path.dirname(inputFile), path.basename(inputFile, '.js')); + } + await saveTypeScriptFile(tsCode, outputPath, overwrite, replace); + Logger.info(`Cmd converted & saved to ${outputPath}`); + } catch (error: unknown) { + if (error instanceof Error) { + Logger.error(`Error: ${error.message}`); + } else { + Logger.error(`Unexpected error occurred`); + } + } +} +/** + * Recursively converts all JavaScript files in a directory to TypeScript. + * + * @param {string} inputDirectory - The directory containing JavaScript files to convert. + * @param outputDirectory - The output directory for the TypeScript files. + * @param {boolean} overwrite - Whether to overwrite existing files. + * @param {boolean} replace - Whether to delete the original JavaScript file after conversion. + */ +async function convertDirectory(inputDirectory: string, outputDirectory?: string, overwrite: boolean = true, replace: boolean = false) { + try { + const jsFiles = await findJavaScriptFiles(inputDirectory); + const totalFiles = jsFiles.length; + if (totalFiles === 0) { + Logger.error(`No JavaScript files found in directory ${inputDirectory}.`); + return; + } + Logger.info(`Converting ${totalFiles} JavaScript files to TypeScript...`); + + for (const jsFile of jsFiles) { + const relativePath = path.relative(inputDirectory, jsFile); + const outputPath = outputDirectory ? path.join(outputDirectory, relativePath.replace(/\.js$/, '.ts')) : jsFile.replace(/\.js$/, '.ts'); + const jsCode = await readJavaScriptFile(jsFile); + const tsCode = convertToTypeScript(jsCode); + await saveTypeScriptFile(tsCode, outputPath, overwrite, replace); + } + Logger.info(`Completed TS conversion!`); + } catch (error: unknown) { + if (error instanceof Error) { + Logger.error(`Error: ${error.message}`); + } else { + Logger.error(`Unexpected error occurred`); + } + } +} diff --git a/src/lib/Logger.ts b/src/lib/Logger.ts index 57254a3..0d172fe 100644 --- a/src/lib/Logger.ts +++ b/src/lib/Logger.ts @@ -1,28 +1,28 @@ -import { cyan, magenta, red, yellow } from 'ansis' +import { cyan, magenta, red, yellow } from 'ansis'; -type LogLevel = 'info' | 'error' | 'warn' | 'debug' -type Loggable = string | number | boolean | object +type LogLevel = 'info' | 'error' | 'warn' | 'debug'; +type Loggable = string | number | boolean | object; interface LevelColors { - [key: string]: string + [key: string]: string; } class InternalLogger { - private levels: LevelColors - private cliToolName: string + private levels: LevelColors; + private cliToolName: string; /** * Creates an instance of InternalLogger. * @param {string} cliToolName - The name of the CLI tool. */ - constructor(cliToolName: string) { - this.cliToolName = cliToolName + public constructor(cliToolName: string) { + this.cliToolName = cliToolName; this.levels = { info: cyan(this.cliToolName), debug: magenta(this.cliToolName), error: red(this.cliToolName), - warn: yellow(this.cliToolName), - } + warn: yellow(this.cliToolName) + }; } /** @@ -30,7 +30,7 @@ class InternalLogger { * @param {...Loggable[]} messages - The messages to log. */ public info(...messages: T[]): void { - this.log('info', messages) + this.log('info', messages); } /** @@ -38,7 +38,7 @@ class InternalLogger { * @param {...Loggable[]} messages - The messages to log. */ public error(...messages: T[]): void { - this.log('error', messages) + this.log('error', messages); } /** @@ -46,7 +46,7 @@ class InternalLogger { * @param {...Loggable[]} messages - The messages to log. */ public warn(...messages: T[]): void { - this.log('warn', messages) + this.log('warn', messages); } /** @@ -54,7 +54,7 @@ class InternalLogger { * @param {...Loggable[]} messages - The messages to log. */ public debug(...messages: T[]): void { - this.log('debug', messages) + this.log('debug', messages); } /** @@ -63,14 +63,14 @@ class InternalLogger { * @param {Loggable[]} messages - The messages to log. */ private log(level: LogLevel, messages: T[]): void { - const lvlPrefix = this.levels[level] + const lvlPrefix = this.levels[level]; if (Array.isArray(messages) && messages.length > 1) { - console.log(`${lvlPrefix}:`) + console.log(`${lvlPrefix}:`); messages.forEach((message) => { - console.log(this.formatMessage(message)) - }) + console.log(this.formatMessage(message)); + }); } else { - console.log(`${lvlPrefix}: ${this.formatMessage(messages[0])}`) + console.log(`${lvlPrefix}: ${this.formatMessage(messages[0])}`); } } @@ -81,11 +81,11 @@ class InternalLogger { */ private formatMessage(message: T): string { if (typeof message === 'object' && message !== null) { - return JSON.stringify(message, null, 2) + return JSON.stringify(message, null, 2); } - return String(message) + return String(message); } } -const Logger = new InternalLogger('saph-convert') -export default Logger +const Logger = new InternalLogger('saph-convert'); +export default Logger;