Modules, packages, and the toolchain · Lesson 65 of 77 · concept
Project layout and scripts
About 45 minutes.
By the end of this lesson you can
- Name what belongs in the common top-level folders of a JavaScript project.
- Say which files at the root are configuration and which are generated.
- Read a scripts block and say how to run a project's tests, build and dev server.
- Orient yourself in an unfamiliar repository in a fixed, repeatable order.
Read, not run. The sandbox on this site has no modules, the DOM, the network and the filesystem, so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.
Most projects look like each other
Explanation
There is no standard enforced by anything, and yet open twenty JavaScript projects and you will see the same shape nineteen times. Knowing the shape is the difference between opening an unfamiliar repository and knowing where to look, and opening it and closing it again.
The root holds the project's identity and configuration: package.json, the lockfile, a README, a .gitignore, and a small pile of tool configuration files. Your code lives in src. Tests live in tests, or beside the file they test. Anything a build produces goes in dist or build. Anything served as-is - images, fonts, an icon - goes in public or static.
The one folder that is not like the others is node_modules. It is not yours, it was not written by anyone on the project, and it is reproducible from two small files. Every other folder in the list is authored; that one is downloaded.
The most useful question: did a person write this?
A mental model
Split everything you see into authored and generated, and half the confusion goes away. Authored files are the ones a person edited on purpose: src, tests, the README, package.json, the config files. Generated files are produced by a command: node_modules, dist, coverage reports, build caches.
Two rules follow, and they are worth having as reflexes. Never edit a generated file - your change will be overwritten by the next build, and it will not exist for anybody else. Never commit a generated folder - it is large, machine-specific and reproducible, and .gitignore exists to keep it out.
The lockfile is the one that looks generated and is committed anyway, which is why it confuses people. It IS generated - by npm, not by you, and you never hand-edit it - but it is committed because it is the record of exactly which versions were installed, which is the whole point of the previous lesson.
The scripts block is the project's front door
Why it matters
The scripts object in package.json is a named list of the commands this project supports. npm run build runs whatever build is defined as; npm run dev starts the development server; npm test runs the tests. Two names are special enough to have their own commands: npm test and npm start work without the word run.
This is more valuable than it first looks, for a reason that has nothing to do with saving keystrokes. A script is documentation that cannot go stale, because it is also the thing people actually run. If the test command changes, it changes here, and everybody picks it up on their next pull. A README that says "run mocha --recursive" is a claim about the past; "npm test" is a claim the project keeps true.
There is a second reason, and it is the one that ends "works on my machine" arguments. npm run puts the project's own node_modules/.bin at the front of the PATH, so a script that says vitest run uses the version this project installed and pinned - not whatever version happens to be installed globally on your laptop. The script and the lockfile together are what make one command mean the same thing on two machines.
Check there: npm run executes an arbitrary command from a package's scripts object, lists the available scripts when called with no arguments, and adds node_modules/.bin to the PATH so locally installed binaries can be used without a path prefix.
How to open a repository you have never seen
Explanation
Do it in the same order every time and it takes five minutes rather than an afternoon. First, package.json - and inside it, scripts before anything else. That tells you how to run, test and build the thing, which is most of what you need to be useful. Then the dependency lists, which tell you what kind of project this is: a framework in there tells you more than the folder names will.
Second, the README. Not for the API details, which are usually stale, but for the project's purpose and any setup step the scripts cannot capture, like a required environment variable.
Third, the entry point. main or exports in package.json names it, or the dev script does; open that file and read its imports. The first fifteen lines of an entry file are a map of the whole system, because everything the program uses has to get in through there.
Fourth, and only now, the folders. By this point you are not browsing at random - you are looking for something specific, which is the difference between exploring and being lost. And if the project has tests, read one: a test is a worked example of the code being used correctly, written by someone who had to make it actually run.
What trips people up
A common mistake
Running a tool globally instead of through a script. npx some-tool or a globally installed binary can be a different version from the one the project pinned, and the failure it produces will not reproduce for anybody else. If a project has a script for it, use the script.
Editing files in dist or build. They are output. The next build overwrites them, and the source they came from is still wrong. Find the file in src that produced it.
Treating the absence of a folder as a mistake. Not every project has src, not every project has tests, and plenty of good ones colocate tests beside the code they cover. The shape is a convention with useful variations, not a rule anybody has to follow.
Assuming a config file at the root is boilerplate to ignore. Those small files are where a project's real behaviour is decided - which files the type checker sees, what the linter forbids, where the build output goes. When something behaves in a way the code does not explain, the explanation is usually in one of them.
Recap
Recap
A typical project keeps configuration and identity at the root, source in src, tests in tests or beside the code, build output in dist, static assets in public, and dependencies in the one folder nobody authored. Sort everything into authored and generated: never edit or commit the generated, with the lockfile as the deliberate exception. The scripts block is the project's front door and its most reliable documentation, and it runs the project's own pinned tools rather than whatever is on your machine. To orient yourself: scripts, then README, then the entry file, then the folders - in that order.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
A typical project, annotated
Not a template to copy - a map to recognise. Read it once now and the next unfamiliar repository will look familiar, because the same eight or nine names keep appearing.
1kettle-timer/2 package.json <- identity, scripts, dependencies3 package-lock.json <- exact versions installed (generated, committed)4 README.md <- what this is and how to start5 .gitignore <- what git must never track6 vite.config.js <- build configuration7 src/ <- your code8 index.js <- entry point named by "main"9 format.js10 tests/ <- your tests11 format.test.js12 public/ <- files served as-is13 icon.svg14 dist/ <- build output (generated, ignored)15 node_modules/ <- installed packages (generated, ignored)- line 3
Generated by npm but committed on purpose - the exception to the rule below it, and the subject of the previous lesson.
- line 5
The file that keeps the two generated folders at the bottom out of the repository. If node_modules ever shows up in a diff, look here first.
- line 7
Where you spend your time. Everything above this line describes the project; everything from here down is the project.
- line 12
Served exactly as it is, with no processing. This is the difference between public and src that catches people out.
- line 14
This folder and the one below it can be deleted at any time and rebuilt by a command. Nothing in either is authored, and neither is committed.
This one does not run hereThis is a listing of files and folders, not a program. The sandbox has no file system, so there is nothing here to execute - it is here to be recognised on sight.
Reading the scripts block
A scripts object is a plain object, so you can read it the way any tool does. The loop prints what you would type on the left and what actually runs on the right - which is the exact translation you perform in your head when you open a new project.
1const scripts = {2 dev: "next dev",3 build: "next build",4 test: "vitest run",5 lint: "eslint .",6};7 8const names = Object.keys(scripts);9 10console.log("This project answers to", names.length, "commands.");11 12for (const name of names) {13 console.log("npm run " + name + " -> " + scripts[name]);14}- line 1
Exactly the object that sits under "scripts" in package.json, here as a JavaScript literal so it can run.
- line 8
The names on the left are the project's vocabulary; you type these. The values are the real commands, and you rarely need to type those at all.
- line 13
npm run test also works, and npm test is the shorter form for that one and for start. Every other name needs the word run.
What it prints
This project answers to 4 commands.npm run dev -> next devnpm run build -> next buildnpm run test -> vitest runnpm run lint -> eslint .
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
You have cloned an unfamiliar project and need to run its tests. Where do you look first?
A small scripts object is questioned three times. The third asks for a script this project does not define.
const scripts = { build: "vite build", test: "vitest run" };console.log(Object.keys(scripts).length);console.log(scripts.test);console.log(scripts.deploy);
Which of these should be committed to the repository?
No exercise in this lesson
Reading a project's layout needs a file system, and the sandbox has none: no folders to list, no package.json to open. The runnable example treats a scripts block as what it is, an ordinary object, and the checks test this lesson's real skill - authored versus generated, and where to look first.
Worth remembering
- In what order do you open an unfamiliar repository?
package.json scripts, then the README, then the entry file's imports, then the folders. Read a test too: it is a worked example that must run.
- Which generated file is committed anyway, and why?
The lockfile. It is the record of exactly which versions were installed, which is what makes node_modules reproducible on another machine.
- Why does a script saying "vitest run" use the right version?
npm run puts the project's node_modules/.bin first on the PATH, so scripts use the versions this project installed, not global ones.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: npm run runs an arbitrary command from the package's scripts object, and adds node_modules/.bin to the PATH so a script uses the project's own installed binaries.
Check there: npm test runs the predefined command in the test property of the scripts object, without needing the word run.
Check there: The scripts field holds commands that are run at various points in the package's lifecycle.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown