a.k.a what npm create vite@latest
does
Table of Contents
- What we want
- Before you continue
- The easiest way (template repository)
- The npm create way
- A simple scaffolding package
- A crossroad
- One package, many files
- One package, many repos
- Conclusion
- Resources
What we want
We want custom scaffolding to reduce the time needed to start a new project.
Just think about it: how often do you take the same three, four, or five steps to start a project?
Let's use Vue as an example:
- install Vue
- select maybe the extra packages (Vitest, Vue Router, Pinia)
- add Bootstrap (or Tailwind)
- add Sass
- add Vuex (if you don't want Pinia)
- add the simple state management (if you don't want Vuex)
- remove unwanted components
- add some basic components (i.e. HomePage for Vue Router)
- setup Vue Router
- and so on...
Instead of repeating all these steps every time, we want a way to have all of them already done on any new project!
Even more: we want the ability to keep our scaffolding updated in time.
How can we do this?
Before you continue
This tutorial is not a step-by-step guide on how to achieve the desired result. Is more of a: we can do things this way, it's possible and not so hard.
The code is publicly available on GitHub and also are the npm packages. Even more: try the packages before you dive into the tutorial.
See if this way of doing things is what you are looking for and only in this case keep reading the tutorial.
Just remember this is not a tutorial on how to do everything. I firmly believe that in some cases is better to learn things "the hard way" rather than have everything handed to you.
Try to make things work, struggle with Node errors, bash your head against the monitor, and close the day with at least a 1% of new things learned.
Try the package
Run this command to install a new VueJS customized template for your project:
npm create @ricciodev/templar@latest
The easiest way (template repository)
One way to achieve the desired result is to create a GitHub repository and mark that repository as a template repository
.
We can do this from the tab Settings, right under the repository name.
Now we can create a new repository on GitHub from this template repository. This means the new repository will have all the files included in the template, right from the start.
This is the easiest way to have a basic scaffolding ready to use.
The pros are: easy to do, and easy to maintain/update.
The cons are: if we need more than a couple of different scaffoldings, it becomes a bit hard to manage.
Of course, we can push things further!
NOTE
We will come back to this method later.
The npm create
way
aka what Vite does
We can create a npm package with the sole purpose of generating a project already structured with all the files and dependencies we need.
Vite do this. Vue does this. Astro does this, and so on... the point being: that we already used this approach.
Now is the time to make our own package!
Before we start, we'll need just a bit of context.
The npm create command
The npm create <package-spec>
command is only an alias for npm init <package-spec>
.
All the information we need is available in the npm documentation
To sum it up we quote the first paragraph of the npm init
documentation page:
npm init <initializer>
can be used to set up a new or existing npm package.
initializer
in this case is an npm package namedcreate-<initializer>
, which will be installed bynpm-exec
, and then have its main bin executed -- presumably creating or updatingpackage.json
and running any other initialization-related operations.
This means our package name will be prefixed with create-
.
I.E. create-scaffolding-vue
, create-custom-react
or to make everything even more organized we could publish our package under a scope to avoid naming conflicts (@myorganization/create-react
.)
Package requirements
We need to define some options in the package.json
file.
main / exports
First, we set the entry point using the main
key. The value will be the path to our main file (usually index.js
.)
Later in this tutorial, we will drop main
and use the exports
field.
bin
We also want to define the bin
key pointing to our executable file. For our package, we could use something like this:
{
"bin": {
"create-mypackage-name": "./index.js"
}
}
A really basic package
To start we need to initialize a project using the familiar npm init
command.
This will create the basic scaffold for our package and we'll be able to set the requirements we saw in the previous step.
NOTE
If we want to create a scoped package now is the time to make it known to npm by adding our organization name before the package name.
I.E.
@myorg/create-mypackage-name
instead ofcreate-mypackage-name
NOTE
When creating a scoped package NPM will default the package to private visibility. To publish the package as public we need to usenpm publish --access public
.
More on this topic can be found in the notes at the end of this article.
Once the init procedure is done, let's add a new file called index.js
(or main.js
, or whatever your default naming convention is).
Then we meet the previous requirement: to define the main
and the bin
keys in package.json
.
Both keys need to be defined at the root level of your package.json
.
Let's point main
to the file we just created:
{
"main": "./index.js"
}
Then we need to define the bin
property pointing to the main executable file of our package.
The files defined here will be installed by npm into the PATH and be available to the package and the user.
The bin
key is just a map of the command name to the local file name.
As per the official documentation, if we have only a single executable, and its name should be the name of the package, we could just use a string as the value for bin
:
{
"bin": "./index.js"
}
If we want to be more verbose or we think we'll need to add more executables in the future, we can provide an object instead of a string, with a key named like our package (without the @org prefix).
{
"bin": {
"create-mypackage-name": "./index.js"
}
}
Both these examples are equivalent in our context.
A simple console.log
In our index.js
add the following code:
#!/usr/bin/env node
console.log("Hello World!");
NOTE
Please make sure that the file referenced inbin
starts with#!/usr/bin/env node
, otherwise the scripts won't be executed by node!
Make the package available globally on your local machine
We don't need to publish our package to test it.
Executing the npm link
command inside the root of our project will make our local package available globally in our system.
Next, we need to create a new directory in another path (i.e. C:\test
) and execute the command npm link create-mypackage-name
or npm link @myorg/create-mypackage-name
if you created a scoped package.
Now our package will be available in the current directory. Yay!
To be sure everything works as expected we need to type the following command in the terminal (from our test folder): npx create-mypackage-name
.
NOTE
npx
is not a typo, it's the command to run arbitrary commands from local or remote packages.
NPM Docs - npx
The name of the command is the same we defined inside the bin
property in package.json
.
If we can see the console.log in the terminal, everything is working properly!
NOTE
Now could be a great time to save our code in a repository on GitHub and publish our package.
This way we can try to install it using thenpm create @myorg/mypackage-name@latest
command and see if everything is ok.
If we see the console.log message after running our command, we are on the right track!When creating a scoped package NPM will default the package visibility to private. To publish the package as public we need to use
npm publish --access public
.
More on this topic can be found in the following links.In this tutorial we won't cover how to publish a package, but the following links will surely help you:
NPM Docs - Creating and publishing unscoped public packages
NPM Docs - Creating and publishing scoped public packages
freeCodeCamp - How to Create and Publish an NPM Package – a Step-by-Step Guide
How to manage a more complex scaffolding
Before we dive into the scaffolding package itself, it is important to understand how to edit the package.json
to better organize our code and files.
Imagine we want to create a utility function in a file called toUppercase.js
inside the utils
folder.
Scaffolding:
index.js
package.json
utils
toUppercase.js
We want to use the toUppercase.js
module inside index.js
. To do so we need to import the file at the start of index.js
but this will not be sufficient and will throw a ERR_MODULE_NOT_FOUND error.
If you published your package or you made it available globally on your local machine, go ahead and try.
To avoid this error we must update our package.json
to export all the files we need and change the import inside index.js
to be consistent with our package name.
Inside package.json
let's add the exports
property:
{
"exports": {
".": "./index.js",
"./utils/*.js": "./utils/*.js"
},
}
The official documentation states that:
The "exports" provides a modern alternative to "main" allowing multiple entry points to be defined, conditional entry resolution support between environments, and preventing any other entry points besides those defined in "exports".
This means we won't need the main
field anymore.
In the exports
field we defined:
- the default entry point
index.js
- all other entry points inside the
utils
folder
In index.js
we update our import by self-referencing our package using its name.
From:
import toUppercase from './utils/toUppercase.js';
To:
import toUppercase from '@myorg/mypackage-name/utils/toUppercase.js';
This is not a necessary step and only works if we define the exports
inside package.json
.
Since we defined the property in exports
with the .js
extension, we must remember to explicitly state the extension when importing our modules.
This will throw an error:
import toUppercase from '@myorg/mypackage-name/utils/toUppercase'; // <-- NO EXTENSION
This will work:
import toUppercase from '@myorg/mypackage-name/utils/toUppercase.js'; // <-- WITH EXTENSION
NOTE
If we already published our package we need to update its patch value and publish it again.
npm version patch
will update the version (i.e. from 0.0.1 to 0.0.2).
npm publish
will publish the updated version of the package, so that we can install it usingnpm create @myorg/mypackage-name@latest
ornpm create @myorg/mypackage-name@0.0.2
We can still test the package locally using the npm link
command as before.
A simple scaffolding package
Now is the time to create our custom scaffolding package to bootstrap our projects!
We need to take a couple of minutes to think about how we want your projects to be developed:
- Which framework do we want to use?
- I.E. React? Vue? Astro?
- What are the packages we use most often?
- I.E. For Vue I find myself always installing Bootstrap, Sass, Vue Router, and Make JS Component
- What are some common components or plugins that we always include in our project?
- I.E. Header, Footer, Navigation, a simple state manager, some views...
Create a new project
When we have a clear idea of what we need the following step is to create this project step-by-step, as we did until today.
We create everything we need and nothing more. We do not want to add specific details to this project.
A crossroad
At this moment we are at a crossroads and in front of us have two ways to accomplish the same thing.
One will allow us to have everything in a single package, easy to maintain but it'll need a bit more tweaks to really shine (we won't cover these details right now).
It needs a bit of knowledge of NodeJS and JS in order to be developed and maintained. Other than that it's still a nice way to pack everything up.
The second way leverage NodeJS methods for executing commands, but requires us to create as many repositories as we need scaffolding variations.
On the other hand, this makes updating the scaffolding really easy, and doesn't need to make changes and update the main package in order to have those updates available for us and our users.
We need to execute one command using NodeJS execSync
but the whole process is faster and the starter kits are easy to maintain.
Let's explore how every single method works.
NOTE
I will not, at this moment, focus on the meaning of single lines of code.
I will probably make a new tutorial dedicated to some of the functions used in the following parts.
One package, many files
First way, ready to go!
Pros:
- single package to maintain
- with a bit of NodeJS and NPM knowledge it could really shine
- everything is in a single place
- Vite also does things this way
Cons:
- we need to copy/paste the files from a skeleton project
- if we don't want to copy/paste we need to better understand how git, NodeJS, and NPM work
- for every change in our scaffolding we need to publish a new version of the package
Update the scaffolding of the npm package
Back to our package!
We want to create a template
directory, and if we want to be future proof we should also make a specific framework/library/tech directory.
In this example, we will create a vue
directory.
This is the current scaffolding of our npm package:
index.js
package.json
utils
toUppercase.js
template
vue
Inside the vue
folder we will copy everything from the project we created in the previous step.
NOTE
Of course we will not copy thenode_modules
folder and neither thepackage-lock.json
file.
A basic skeleton of a fresh new project will be enough.
How to interact with the user and copy the scaffolding files
NOTE
As mentioned before this part will be more of a copy/paste for now. If you are interested I will make a future article focusing on some of the methods and packages I used.
We need to ask our users about the project our package will create.
To accomplish this task we need the inquirer package (the following code is referencing the linked version, but feel free to use the new version of inquirer and edit the code example).
After installing inquirer we need to update our main entry point, index.js
:
#!/usr/bin/env node
import toUppercase from '@myorg/mypackage-name/utils/toUppercase.js';
import inquirer from 'inquirer';
(async () => inquirer
.prompt([
{
type: "input",
name: "projectName",
message: "Enter your project name",
default: "obi-wan-kenobi"
}
])
.then((answers) => {
console.log(toUppercase(answers.projectName));
})
.catch((error) => {
if (error.isTtyError) {
// Prompt couldn't be rendered in the current environment
console.error("Cannot render the prompt...");
} else {
console.error(error.message);
}
}))();
Running our project (locally or after publishing a new version and installing it again) will give us a nice prompt inside our terminal, asking for the name of our shiny new project.
Instead of printing the project name all in uppercase inside the console, we will copy the files and folders from template/vue
inside the current project directory.
We must create a new file inside the utils
folder: createProject.js
.
Inside this file we will add two functions:
- a
init
function that will start the whole process - a
copyTemplateFilesAndFolders
function that will recursively copy all the files and folders from the template directory
import fs from 'fs/promises';
import { fileURLToPath } from "node:url";
import path from "node:path";
import chalk from 'chalk';
export const copyTemplateFilesAndFolders = async (source, destination, projectName) => {
const filesAndFolders = await fs.readdir(source);
for (const entry of filesAndFolders) {
const currentSource = path.join(source, entry);
const currentDestination = path.join(destination, entry);
const stat = await fs.lstat(currentSource);
if (stat.isDirectory()) {
await fs.mkdir(currentDestination);
await copyTemplateFilesAndFolders(currentSource, currentDestination);
} else {
// If the file is package.json we replace the default name with the one provided by the user
if (/package.json/.test(currentSource)) {
const currentPackageJson = await fs.readFile(currentSource, 'utf8');
const newFileContent = currentPackageJson.replace(/custom-scaffolding/g, projectName);
await fs.writeFile(currentDestination, newFileContent, 'utf8');
} else {
await fs.copyFile(currentSource, currentDestination);
}
}
}
};
export const init = async (projectName) => {
const destination = path.join(process.cwd(), projectName);
const source = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../template/vue"
);
try {
console.log('📑 Copying files...');
await fs.mkdir(destination);
await copyTemplateFilesAndFolders(source, destination, projectName);
console.log('📑 Files copied...');
console.log(chalk.green(`\ncd ${projectName}\nnpm install\nnpm run dev`));
} catch (error) {
console.log(error);
}
};
To make the message more pretty in the terminal we could install chalk.
This is not a mandatory step but a nice one. Of course, if we don't want to use this library it's sufficient to remove all references to it from the previous code.
Just a bit of context before we continue.
The previous code has two functions. The init
function receives the project name and with the help of the path
module we create a path (of course) pointing to a folder named like the project name we provided, concatenated to our current working folder (this comes from process.cwd()
).
The join
methods will use the correct path separator based on the OS the package is installed on.
const destination = path.join(process.cwd(), projectName);
With the help of the fs
module NodeJS can use the method mkdir
to create a new folder:
await fs.mkdir(destination);
We need to resolve the path to our scaffolding files and folders. To do this we will use the resolve
method of the path
module:
const source = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../template/vue"
);
I leave to the official documentation and to your curiosity the explanation for this bit ;)
The copyTemplateFilesAndFolders
will be called with three arguments: source
(where the template is), destination
(the folder just created and named after what we typed in the prompt), projectName
(the name of our project).
copyTemplateFilesAndFolders
will then copy every single folder and file from the source
to the destination
. For each folder it will recursively call itself to copy every file from it.
When the function finds the package.json
file it will overwrite the default project name with the one we provided.
NOTE
We could also parse the file content as JSON and overwrite the name property using direct access.
We will then re-encode the object as JSON string and write it back to the file.
Now it's a good time to test again our package and see if everything works correctly.
If the test with the local package is showing you index.js
instead of executing it, just unlink and re-link the project.
With this step, we are done! Now we can create a new project with everything we want, using a single npm command.
One package, many repos
This is the last method to tackle this problem.
Pros:
- it's easier to add a new scaffolding and to keep things up to date
- only one single package to update and publish
- starter kit repos are easy to maintain
Cons:
- you don't really need a npm package to install a git repo, so it's a bit of an extra step
A new old repo
First things first!
Remember the template repository we saw at the start of this tutorial? We are going back to it and will sprinkle a bit of NodeJS and NPM magic dust over it.
How to interact with the user and install the starter kit
As we did for the other version, we need to use inquirer to ask our users (or our future selves) the name of the project and what kit we want to use.
Since we want to be able to define more than one starter kit and we want things to be tidy and clean, we should create a new file inside the utils
folder called allowedPackages.js
.
This package will define the scope (our GitHub username -- my-github-username
), the starter kit (an array of strings), and a method that will create an array of allowed packages (this will come in handy later for security purposes).
Here's the content of the file:
export const scope = 'my-github-username';
export const starterKits = [
'vue'
];
export function allowedPackages() {
return starterKits.map(kit => `${scope}/${kit}-starter-kit`);
}
We need to update index.js
to import this file and to ask our users which kit they want to install.
Here's the updated index.js
:
#!/usr/bin/env node
import { init } from '@myorg/mypackage-name/utils/createProject.js';
import { allowedPackages, starterKits, scope } from '@myorg/mypackage-name/utils/allowedPackages.js';
import inquirer from 'inquirer';
(() => inquirer
.prompt([
{
type: "input",
name: "projectName",
message: "Enter your project name",
default: "obi-wan-kenobi"
},
{
type: "list",
name: "prefix",
message: "Select a starter kit",
choices: [...starterKits]
}
])
.then((answers) => {
const requestedPackage = `${scope}/${answers.prefix}-starter-kit`;
if (!allowedPackages().includes(requestedPackage)) {
throw new Error("Invalid package");
}
init(answers.projectName, requestedPackage);
})
.catch((error) => {
if (error.isTtyError) {
// Prompt couldn't be rendered in the current environment
console.error("Cannot render the prompt...");
} else {
console.error(error.message);
}
}))();
We added a new option to our users: what starter kit they want.
Note that we check if the selected package is valid. If not it means the user tried to send some arbitrary value and the script will throw an error and stop the execution.
Of course, the createProject.js
module changed as well:
import fs from 'fs/promises';
import path from "node:path";
import chalk from 'chalk';
import { execSync } from 'node:child_process';
const updatePackageJson = async (projectPath, requestedPackage, projectName) => {
console.log(chalk.gray('Updating package.json...'));
const packagePath = path.join(projectPath, 'package.json');
const stat = await fs.lstat(packagePath);
if (stat.isFile()) {
const currentPackageJson = await fs.readFile(packagePath, 'utf8');
const newFileContent = currentPackageJson.replace(requestedPackage, projectName);
await fs.writeFile(packagePath, newFileContent, 'utf8');
}
}
const removeGitFolder = async (projectPath) => {
console.log(chalk.gray('Removing .git folder...'));
const gitFolderPath = path.join(projectPath, '.git');
const stat = await fs.lstat(gitFolderPath);
if (stat.isDirectory()) {
await fs.rm(gitFolderPath, {
recursive: true
});
}
}
export const init = async (projectName, requestedPackage) => {
try {
const currentDir = process.cwd();
const destination = path.join(currentDir, projectName);
const fullURL = `https://github.com/${requestedPackage}.git`
console.log(chalk.gray('📑 Copying files...'));
await fs.mkdir(destination);
execSync(`git clone --depth 1 ${fullURL} .`, { stdio: "inherit", cwd: destination });
console.log(chalk.gray('📑 Files copied...'));
await removeGitFolder(destination);
await updatePackageJson(destination, requestedPackage, projectName);
console.log(chalk.green(`\ncd ${projectName}\nnpm install\nnpm run dev`));
} catch (error) {
console.log(chalk.red(error));
}
};
On this one, we have simplified a couple of things.
The copyTemplateFilesAndFolders
doesn't exist anymore. This time we need two functions to update the default package.json
we are getting from the starter kit repo and a function to remove the .git
folder from the cloned repo.
The init
function will use execSync
to execute the git clone command for our starter kit repo:
git clone --depth 1 ${fullURL} .
This will clone the repo in the project folder defined by the cwd
property in the second parameter of the execSync
command:
{ stdio: "inherit", cwd: destination }
NOTE
Theexec
andexecSync
methods of NodeJS use a shell to execute our command. Since we are also adding some user-provided data we need to be careful not to allow arbitrary commands to be executed (i.e.rm -rf
). This is the reason for our previous validation insideindex.js
.If the user sends an invalid selection (i.e. not a valid starter kit, some arbitrary commands, etc...), our code will not allow that string to be passed to
execSync
and be executed. We will stop everything and be safe.
As before we update the package.json
file so the name of the project will be the same one the user provided.
Conclusion
I decided to make this tutorial for a reason: to show how we can make things following the example set by others (Vite in this specific case) and how we can add some of our needs on top of that.
This tutorial doesn't want to be a comprehensive explanation of how to create this kind of package. As I said before is more like a finger pointing in a direction. If you want, follow that direction, make your own mistakes along the way, and learn.
At the end of that path, you will find knowledge and maybe you'll have your own personal scaffolder!
If this article was helpful or want to start a conversation, feel free to reach out in the comments or on LinkedIn
I'll be happy to receive any feedback or ideas for future articles and tutorials.
Top comments (0)