UNPKG

@jspsych/new-plugin

Version:

CLI tool to create new jsPsych plugins

430 lines (379 loc) 14.7 kB
#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { input, select } from "@inquirer/prompts"; import { Command } from "commander"; import { deleteSync } from "del"; import { dest, series, src } from "gulp"; import rename from "gulp-rename"; import replace from "gulp-replace"; import { simpleGit } from "simple-git"; const git = simpleGit(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Import version from package.json const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')); const { version } = packageJson; async function getRepoRoot() { try { const rootDir = await git.revparse(["--show-toplevel"]); return rootDir; } catch (error) { return ""; } } async function getRemoteGitRootUrl() { if (await git.checkIsRepo()) { try { const remotes = await git.getRemotes(true); const originRemote = remotes.find((remote) => remote.name === "origin"); if (originRemote) { let remoteGitRootUrl = originRemote.refs.fetch; if (remoteGitRootUrl.startsWith("git@github.com:")) { remoteGitRootUrl = remoteGitRootUrl.replace("git@github.com:", "git+https://github.com/"); } return remoteGitRootUrl; } console.warn("No remote named 'origin' found."); return ""; } catch (error) { console.error("Error getting remote root Git URL:", error); return ""; } } return ""; } async function getRemoteGitUrl() { let remoteGitUrl; const remoteGitRootUrl = await getRemoteGitRootUrl(); const repoRoot = await getRepoRoot(); if (repoRoot) { const currentDir = process.cwd(); const relativePath = path.relative(repoRoot, currentDir); if (relativePath) { remoteGitUrl = `${remoteGitRootUrl}/tree/main/${relativePath}`; } return remoteGitUrl; } return ""; } function getGitHttpsUrl(gitUrl) { if (!gitUrl || typeof gitUrl !== 'string') { return ''; } let httpsUrl = gitUrl.trim(); // Handle git+https:// format httpsUrl = httpsUrl.replace(/^git\+https:\/\//, 'https://'); // Handle git:// format httpsUrl = httpsUrl.replace(/^git:\/\/(.+)$/, 'https://$1'); // Handle ssh://git@host.com/user/repo format httpsUrl = httpsUrl.replace(/^ssh:\/\/git@([^\/]+)\/(.+)$/, 'https://$1/$2'); // Handle git@github.com:user/repo format (standard SSH URL format) httpsUrl = httpsUrl.replace(/^git@([^:]+):(.+)$/, 'https://$1/$2'); // Remove trailing .git httpsUrl = httpsUrl.replace(/\.git$/, ''); return httpsUrl; } function getHyphenateName(input) { return input .trim() .replace(/[\s_]+/g, "-") // Replace all spaces and underscores with hyphens .replace(/([a-z])([A-Z])/g, "$1-$2") // Replace camelCase with hyphens .replace(/[^a-zA-Z0-9-]/g, "") // Remove all non-alphanumeric characters .toLowerCase(); } function getCamelCaseName(input) { input = input.charAt(0).toUpperCase() + input.slice(1).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); input = input.replace(/[^a-zA-Z0-9]/g, "") // Remove all non-alphanumeric characters return input; } async function detectContribRepo() { const repoRoot = await getRepoRoot(); if (repoRoot) { const packageJsonPath = path.join(repoRoot, "package.json"); // If package.json doesn't exist at the root, it cannot be the contrib repo. if (!fs.existsSync(packageJsonPath)) { return false; } // If package.json exists, try to read and parse it. try { const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); const packageJson = JSON.parse(packageJsonContent); return packageJson.name === "@jspsych/jspsych-contrib"; } catch (error) { // Log an error if reading or parsing an existing package.json fails. console.error("Error reading or parsing package.json:", error); return false; } } // Return false if not in a git repo or repoRoot couldn't be determined. return false; } async function getCwdInfo() { const isRepo = await git.checkIsRepo(); let isContribRepo; // Check if current directory is the jspsych-contrib repository if (isRepo) { isContribRepo = await detectContribRepo(); if (isContribRepo) { return { isRepo: isRepo, isContribRepo: isContribRepo, destDir: path.join(await getRepoRoot(), "packages"), }; } } // If current directory is not the jspsych-contrib repository return { isRepo: isRepo, isContribRepo: false, destDir: process.cwd(), }; } async function runPrompts(cwdInfo) { const name = await input({ message: "Enter the name you would like this plugin package to be called:", required: true, validate: (input) => { const packagePath = `${cwdInfo.destDir}/plugin-${getHyphenateName(input)}`; if (fs.existsSync(packagePath)) { return "A plugin package with this name already exists in this directory. Please choose a different name."; } else { return true; } }, }); const description = await input({ message: "Enter a brief description of this plugin package:", required: true, }); const author = await input({ message: "Enter the name of the author of this plugin package:", required: true, }); const authorUrl = await input({ message: "Enter a profile URL for the author, e.g. a link to their GitHub profile [Optional]:", }); const language = await select({ message: "Choose a language to use for this plugin package:", choices: [ { name: "TypeScript", value: "ts" }, { name: "JavaScript", value: "js" }, ], loop: false, }); // If not in the jspsych-contrib repository, ask for the path to the README.md file let readmePath; if (!cwdInfo.isContribRepo) { const remoteGitUrl = await getRemoteGitUrl(); readmePath = await input({ message: "Enter the path to the README.md file for this plugin package [Optional]:", default: `${getGitHttpsUrl(remoteGitUrl)}/plugin-${getHyphenateName(name)}/README.md`, // '/plugin-${name}/README.md' if not a Git repository }); } else { readmePath = `https://github.com/jspsych/jspsych-contrib/packages/plugin-${getHyphenateName(name)}/README.md`; } return { name: name, description: description, author: author, authorUrl: authorUrl, language: language, readmePath: readmePath, destDir: cwdInfo.destDir, isContribRepo: cwdInfo.isContribRepo, }; } async function processAnswers(answers) { Object.keys(answers).forEach((key) => { if (typeof answers[key] === "string" && key != "language") { answers[key] = JSON.stringify(answers[key]); // Properly escape for JSON answers[key] = answers[key].slice(1, -1); // Remove outer quotes } }) answers.name = getHyphenateName(answers.name); const camelCaseName = getCamelCaseName(answers.name); const globalName = "jsPsych" + camelCaseName; const packageName = `plugin-${answers.name}`; const destPath = path.join(answers.destDir, packageName); const npmPackageName = (() => { if (answers.isContribRepo) { return `@jspsych-contrib/${packageName}`; } else { return packageName; } })(); const templatesDir = path.resolve(__dirname, "../templates"); let repoRoot = await getRepoRoot(); let packageDir = answers.isContribRepo ? "packages" : repoRoot ? path.relative(repoRoot, process.cwd()) : "./"; const gitRootUrl = await getRemoteGitRootUrl(); const gitRootHttpsUrl = getGitHttpsUrl(gitRootUrl); function processTemplate() { return src(`${templatesDir}/plugin-template-${answers.language}/**/*`) .pipe(replace("{npmPackageName}", npmPackageName)) .pipe(replace("{author}", answers.author)) .pipe(replace("{authorUrl}", answers.authorUrl)) .pipe(replace("{description}", answers.description)) .pipe(replace("_globalName_", globalName)) .pipe(replace("{globalName}", globalName)) .pipe(replace("{camelCaseName}", camelCaseName)) .pipe(replace("PluginNamePlugin", `${camelCaseName}Plugin`)) .pipe(replace("{packageName}", packageName)) .pipe(replace("{gitRootUrl}", gitRootUrl)) .pipe(replace("{gitRootHttpsUrl}", gitRootHttpsUrl)) .pipe(replace("{documentationUrl}", answers.readmePath)) .pipe(replace("{packageDir}", packageDir)) .pipe(dest(destPath)); } function renameExampleTemplate() { return src(`${destPath}/examples/index.html`) .pipe(replace("{globalName}", globalName)) .pipe( replace( "{publishingComment}\n", answers.isContribRepo ? // prettier-ignore `<!-- Once this plugin package is published, it can be loaded via\n<script src="https://unpkg.com/@jspsych-contrib/${packageName}"></script>\n<script src="../dist/index.browser.js"></script> -->\n` : `<!-- Load the published plugin package here, e.g.\n<script src="https://unpkg.com/${packageName}"></script>\n<script src="../dist/index.browser.js"></script> -->\n` ) ) .pipe(dest(`${destPath}/examples`)); } function renameDocsTemplate() { return src(`${destPath}/docs/docs-template.md`) .pipe(rename(`${packageName}.md`)) .pipe( replace( "## Install", answers.isContribRepo ? // prettier-ignore `## Install\n\nUsing the CDN-hosted JavaScript file:\n\n\`\`\`js\n<script src="https://unpkg.com/@jspsych-contrib/${packageName}"></script>\n\`\`\`\n\nUsing the JavaScript file downloaded from a GitHub release dist archive:\n\n\`\`\`js\n<script src="jspsych/${packageName}.js"></script>\n\`\`\`\n\nUsing NPM:\n\n\`\`\`\nnpm install ${npmPackageName}\n\`\`\`\n\n\`\`\`js\nimport ${camelCaseName} from "${npmPackageName}";\n\`\`\`\n` : "## Install\n\n*Enter instructions for installing the plugin package here.*" ) ) .pipe(dest(`${destPath}/docs`)) .on("end", function () { deleteSync(`${destPath}/docs/docs-template.md`, { force: true }); }); } function renameReadmeTemplate() { return src(`${destPath}/README.md`) .pipe(replace(`{npmPackageName}`, npmPackageName)) .pipe( replace( `{authorInfo}`, answers.authorUrl ? `[${answers.author}](${answers.authorUrl})` : `${answers.author}` ) ) .pipe( replace( `## Loading`, answers.isContribRepo ? // prettier-ignore answers.language == "ts" ? // prettier-ignore `## Loading\n\n### In browser\n\n\`\`\`html\n<script src="https://unpkg.com/@jspsych-contrib/${packageName}">\n\`\`\`\n\n### Via NPM\n\n\`\`\`\nnpm install ${npmPackageName}\n\`\`\`\n\n\`\`\`js\nimport ${globalName} from "${packageName}";\n\`\`\`` : `## Loading\n\n### In browser\n\n\`\`\`html\n<script src="https://unpkg.com/@jspsych-contrib/${packageName}">\n\`\`\`\n\n### Via NPM\n\n\`\`\`\nnpm install ${npmPackageName}\n\`\`\`` : `## Loading\n\n*Enter instructions for loading the plugin package here.*` ) ) .pipe(dest(destPath)); } series(processTemplate, renameExampleTemplate, renameDocsTemplate, renameReadmeTemplate)(); } async function runWithArgs(cwdInfo, options) { // Check if package already exists const packagePath = `${cwdInfo.destDir}/plugin-${getHyphenateName(options.name)}`; if (fs.existsSync(packagePath)) { console.error(`Error: A plugin package with this name already exists in this directory: ${packagePath}`); process.exit(1); } // Set defaults and calculate derived values const name = options.name; const language = options.language || 'ts'; let readmePath = options.readmePath; if (!readmePath) { if (!cwdInfo.isContribRepo) { const remoteGitUrl = await getRemoteGitUrl(); readmePath = `${getGitHttpsUrl(remoteGitUrl)}/plugin-${getHyphenateName(name)}/README.md`; } else { readmePath = `https://github.com/jspsych/jspsych-contrib/packages/plugin-${getHyphenateName(name)}/README.md`; } } return { name: name, description: options.description, author: options.author, authorUrl: options.authorUrl || '', language: language, readmePath: readmePath, destDir: cwdInfo.destDir, isContribRepo: cwdInfo.isContribRepo, }; } // Set up Commander.js const program = new Command(); program .name('new-plugin') .description('Creates a new jsPsych plugin package') .version(version) .option('--name <name>', 'Name of the plugin package (required)') .option('--description <description>', 'Brief description of the plugin package (required)') .option('--author <author>', 'Name of the author (required)') .option('--author-url <url>', 'Profile URL for the author (optional)') .option('--language <lang>', 'Language to use: ts or js (default: ts)', 'ts') .option('--readme-path <path>', 'Path to README.md file (optional)') .addHelpText('after', ` Examples: $ new-plugin --name "my-plugin" --description "My awesome plugin" --author "John Doe" $ new-plugin --name "my-plugin" --description "My plugin" --author "John Doe" --language js`); async function main(options, isNonInteractive = false) { const cwdInfo = await getCwdInfo(); let answers; if (isNonInteractive) { // Validate required options if (!options.name) { console.error('Error: --name is required'); process.exit(1); } if (!options.description) { console.error('Error: --description is required'); process.exit(1); } if (!options.author) { console.error('Error: --author is required'); process.exit(1); } // Validate language if (options.language && !['ts', 'js'].includes(options.language)) { console.error('Error: --language must be either "ts" or "js"'); process.exit(1); } // Non-interactive mode answers = await runWithArgs(cwdInfo, options); } else { // Interactive mode (existing behavior) answers = await runPrompts(cwdInfo); } await processAnswers(answers); } program.parse(); const options = program.opts(); // Check if we have command line arguments (any option except defaults) const hasArgs = Object.keys(options).some(key => (key !== 'language' || options[key] !== 'ts') && key !== 'version' && options[key] !== undefined ); if (!hasArgs) { // No arguments provided, run in interactive mode await main({}, false); } else { // Arguments provided, run in non-interactive mode await main(options, true); }