generator-bitloops
Version:
Next.js with TypeScript, Tailwind, Storybook and Cypress generator by Bitloops
382 lines (340 loc) • 12.2 kB
JavaScript
import fs from 'fs';
import { spawnSync } from 'child_process';
import { exec, execSync } from 'child_process';
import Generator from 'yeoman-generator';
import path from 'path';
import { fileURLToPath } from 'url';
// Convert `import.meta.url` to a path
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DOT = '.';
function isKebabCase(str) {
// Check if the string is empty
if (!str || str.trim().length === 0) {
return false;
}
// Regular expression to check if a string is kebab-case,
// ensuring it starts with a lowercase letter or digit, allowing for lowercase letters and digits in the middle or end,
// and ensuring each new word starts with a lowercase letter or digit
const kebabCaseRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
return kebabCaseRegex.test(str);
}
function toKebabCase(str) {
if (isKebabCase(str)) {
return str;
}
const words = str
.trim()
// Split by non-alphanumeric characters and the transition from lowercase to uppercase
.split(/(?=[A-Z])|[^a-zA-Z0-9]+/)
.filter((word) => word.length > 0);
return words
.map((word) => word.toLowerCase())
.filter((word) => word.length > 0) // Remove empty words
.join('-');
}
function deleteFileIfExists(filePath) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
export default class extends Generator {
constructor(args, opts) {
super(args, opts);
this.sourceRoot(path.join(__dirname, 'templates'));
// Define options
this.option('project', {
type: String,
description: 'Project name (used to create the project folder)',
required: true,
});
this.option('nextjs', {
type: Boolean,
description: 'Install Next.js',
required: true, // Make Next.js mandatory
default: false,
});
this.option('typescript', {
type: Boolean,
description: 'Add TypeScript support',
default: false,
});
this.option('tailwind', {
type: Boolean,
description: 'Add Tailwind CSS',
default: false,
});
this.option('storybook', {
type: Boolean,
description: 'Add Storybook',
default: false,
});
this.option('cypress', {
type: Boolean,
description: 'Add Cypress for testing',
default: false,
});
this.option('bitloops', {
type: Boolean,
description: 'Add Bitloops specific boilerplate files',
default: false,
});
this.option('git', {
type: Boolean,
description: 'Commit changes to git',
default: false,
});
this.installNextJS = async function () {
// Clone Next.js template with Tailwind if specified, using the project name
const createNextAppCommand = ['-y', 'create-next-app@15.3.3'];
createNextAppCommand.push(toKebabCase(this.options.project)); // Use the project name for the directory
createNextAppCommand.push('--app');
createNextAppCommand.push('--empty');
createNextAppCommand.push('--src-dir');
createNextAppCommand.push('--turbopack'); // when we go to Next.js 15
createNextAppCommand.push('--import-alias');
createNextAppCommand.push('@/*');
createNextAppCommand.push('--use-npm');
createNextAppCommand.push('--eslint');
if (this.options.typescript) {
createNextAppCommand.push('--typescript'); // This will avoid the TypeScript prompt
} else {
createNextAppCommand.push('--js');
}
if (this.options.tailwind) {
createNextAppCommand.push('--tailwind');
}
this.log('Installing Next.js...');
const patchPackages = ''; //'next@14 react@18 react-dom@18';
const additionalPackages = `react-tooltip ${patchPackages} class-variance-authority tailwind-merge`;
await new Promise((resolve, error) => {
exec(
`npx ${createNextAppCommand.join(' ')} && cd ${toKebabCase(
this.options.project
)} && npm install ${additionalPackages}`
).on('exit', (code) => {
this.destinationRoot(
this.destinationPath(toKebabCase(this.options.project))
);
resolve();
});
});
};
this.installStorybook = function () {
// Conditionally initialize Storybook
if (this.options.storybook) {
this.log('Installing Storybook...');
const versionsRaw = execSync('npm view storybook versions --json', {
encoding: 'utf-8',
});
const versions = JSON.parse(versionsRaw);
// Filter for stable 9.0.x versions (exclude alpha/beta)
const stableVersions = versions
.filter(version => version.startsWith('9.0.'))
.filter(version => !version.includes('-')); // Exclude pre-releases like -alpha or -beta
// Sort descending and get the latest
const latest90 = stableVersions
.sort((a, b) => {
// Split version strings like '9.0.9' into [9, 0, 9]
const aParts = a.split(DOT).map(Number);
const bParts = b.split(DOT).map(Number);
// Compare major, then minor, then patch
if (aParts[0] !== bParts[0]) return bParts[0] - aParts[0];
if (aParts[1] !== bParts[1]) return bParts[1] - aParts[1];
return bParts[2] - aParts[2];
})[0];
if (!latest90) {
throw new Error('No stable 9.0.x versions found.');
}
// Log the chosen version (optional)
this.log(`Latest stable 9.0 version: ${latest90}`);
spawnSync('npx', [
'-y',
`storybook@${latest90}`,
'init',
'--no-dev',
'--yes', // Skip all prompts
'--type', 'nextjs', // Specify Next.js as the framework
], { stdio: 'inherit', cwd: this.destinationRoot() });
this.log('Storybook installed!');
this.log('Installing @storybook/react-vite for Vite builder support...');
spawnSync('npm', [
'install',
'--save-dev',
'@storybook/react-vite'
], { stdio: 'inherit', cwd: this.destinationRoot() });
this.log('@storybook/react-vite installed!');
// if (this.options.tailwind && this.options.storybook) {
// Tailwind CSS specific setup for older versions of Storybook
// this.spawnCommandSync('npx', ['storybook@latest', 'add', '@storybook/addon-styling-webpack']);
// }
}
};
this.installCypress = function () {
// Conditionally add Cypress
if (this.options.cypress) {
this.log('Installing Cypress...');
spawnSync('npm', ['install', '--save-dev', 'cypress'], { stdio: 'inherit', cwd: this.destinationRoot() });
this.log('Cypress installed!');
if (this.options.bitloops) {
spawnSync('npm', [
'install',
'--save-dev',
'mochawesome',
'mochawesome-merge',
'mochawesome-report-generator',
], { stdio: 'inherit', cwd: this.destinationRoot() });
}
}
};
this.patchFiles = async function () {
// Conditionally initialize Storybook
if (this.options.storybook) {
this.log('Making Storybook changes...');
if (this.options.tailwind) {
deleteFileIfExists(this.destinationPath('.storybook/preview.ts'));
this.log('Setting up Tailwind CSS with Storybook...');
this.fs.copyTpl(
this.templatePath('storybook.preview.ts'),
this.destinationPath('.storybook/preview.ts')
);
}
this.log('Removing default Storybook stories...');
try {
fs.rmSync(this.destinationPath('src/stories'), {
recursive: true,
force: true,
});
console.log('Sample stories directory deleted successfully!');
} catch (err) {
console.error('Error deleting sample stories directory:', err);
}
}
if (this.options.cypress) {
this.log('Adding Cypress config...');
this.fs.copyTpl(
this.templatePath('cypress.config.ts'),
this.destinationPath('cypress.config.ts')
);
}
deleteFileIfExists(this.destinationPath('src/app/page.tsx'));
this.fs.copyTpl(
this.templatePath('next.app.page.tsx'),
this.destinationPath('src/app/page.tsx')
);
deleteFileIfExists(this.destinationPath('src/app/layout.tsx'));
this.fs.copyTpl(
this.templatePath('next.app.layout.tsx'),
this.destinationPath('src/app/layout.tsx'),
{ projectName: this.options.project }
);
this.log('Adding Meyer reset in global.css...');
deleteFileIfExists(this.destinationPath('src/app/globals.css'));
this.fs.copyTpl(
this.templatePath('globals.css'),
this.destinationPath('src/app/globals.css')
);
if (this.options.bitloops) {
this.log('Adding Bitloops support components...');
const unsupportedPath =
'src/components/bitloops/unsupported/Unsupported.tsx';
this.fs.copyTpl(
this.templatePath(unsupportedPath),
this.destinationPath(unsupportedPath)
);
const buttonPath = 'src/components/bitloops/button/Button.tsx';
this.fs.copyTpl(
this.templatePath(buttonPath),
this.destinationPath(buttonPath)
);
if (this.options.storybook) {
const unsupportedPath =
'src/components/bitloops/unsupported/Unsupported.stories.tsx';
this.fs.copyTpl(
this.templatePath(unsupportedPath),
this.destinationPath(unsupportedPath)
);
const buttonPath =
'src/components/bitloops/button/Button.stories.tsx';
this.fs.copyTpl(
this.templatePath(buttonPath),
this.destinationPath(buttonPath)
);
}
if (this.options.cypress) {
const path = 'cypress/helpers/index.ts';
this.fs.copyTpl(this.templatePath(path), this.destinationPath(path));
}
spawnSync('npm', [
'install',
'--save-dev',
'react-aria-components',
], { stdio: 'inherit', cwd: this.destinationRoot() });
}
};
this.commitChanges = async function () {
this.log('Committing changes to git...');
await new Promise((resolve) => {
exec(
`cd ${toKebabCase(
this.options.project
)} && git add . && git commit -m "Initial setup"`
).on('exit', (code) => {
if (code !== 0) {
this.log('Error committing changes to git! ', code);
resolve();
}
this.log('Git changes committed!');
resolve();
});
});
};
}
initializing() {
// Check if the project name and --nextjs flag are provided
if (!this.options.project) {
this.log(
'Error: --project option is required to specify the project name.'
);
process.exit(1);
}
if (!this.options.nextjs) {
this.log(
'Error: --nextjs option is currently required to scaffold a project.'
);
process.exit(1);
}
this.log(
`Initializing project ${toKebabCase(
this.options.project
)} with the selected options...`
);
}
async main() {
await this.installNextJS();
this.installStorybook();
this.installCypress();
await this.patchFiles();
if (this.options.git) {
await this.commitChanges();
}
}
end() {
this.log(
`Your Bitloops project '${toKebabCase(
this.options.project
)}' setup is complete! 🎉🎉🎉`
);
this.log('');
this.log('Use the following commands to start:');
this.log('- `npm run dev` to start the Next.js app.');
if (this.options.storybook)
this.log('- `npm run storybook` to start Storybook.');
if (this.options.cypress)
this.log('- `npx cypress open --e2e --browser chrome` to open Cypress.');
if (this.options.cypress)
this.log(
'- `npx cypress run --e2e --browser chrome` to run Cypress on the terminal.'
);
}
}