Warm-up · Activity 1 of 7
// B2.5 · ~38 min · Beginner
Build: a utility package with its own tests
After this build you can turn functions into a tested ES module package and use it in another project, from a folder or a tarball.
Lesson 5 of 5 in B2 npm and packages
You will be able to
- Set up package.json with name, version, "type", "exports" and a "test" script, and one entry file with named exports
- Write pure utility functions and test the package through its own name with npm test
- Install the package into a second project from a folder or an npm pack tarball, and predict what each gives you
Predict · Activity 2 of 7
Predict before you read on. textkit is installed in the app, and its package.json has "exports": "./index.js". The file src/slug.js is there, in node_modules/textkit. What does node main.js do?
// node_modules/textkit/package.json // {"name": "textkit", "type": "module", "exports": "./index.js", ...} // main.js import {slugify} from "textkit/src/slug.js"; console.log(slugify("Tea & Cake!"));Practice · Activity 3 of 7
Fill in the flag so that npm test runs the test files of textkit with the built-in test runner.
"scripts": { "test": "node ____" }"test": "node "Practice · Activity 4 of 7
Match each command or field to what it gives you.
Practice · Activity 5 of 7
textkit holds index.js, package.json, src/slug.js, src/truncate.js and test/textkit.test.js. Its package.json has "files": ["index.js", "src"]. Which files does npm pack put into the tarball?
"files": ["index.js", "src"]Brain teaser · Activity 6 of 7
Brain teaser. test/textkit.test.js is inside the textkit folder and imports {slugify, truncate} from "textkit". textkit is not installed anywhere and there is no node_modules folder. You run node --test in textkit. What happens?
// textkit/test/textkit.test.js import {slugify, truncate} from "textkit";Apply · Activity 7 of 7
Mini-task, offline. Build textkit: package.json as in the lesson, index.js that re-exports slugify and truncate, your steps 1 and 2 in src, and test/textkit.test.js importing from "textkit". Run npm test until it passes, then npm pack. Next to it, make app with "type": "module", install ../textkit/textkit-1.0.0.tgz and print slugify("Tea & Cake!") and truncate("A long title", 7). Delete node_modules, run npm ci, and run it again.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
From a folder of functions to an installed package
This program plays the part of your terminal and needs no network. It copies the package files next to it into a textkit folder (index.js to the top, slug.js and truncate.js into src, the test into test), runs npm test and npm pack there, installs the tarball into a second project, app, and runs app. You see each command and its result.
main.js
// Plays the part of your terminal: builds the textkit package, tests and packs
// it, then installs the tarball into a second project and runs that. Offline.
import {execSync} from "node:child_process";
import {existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {dirname, join} from "node:path";
const root = mkdtempSync(join(tmpdir(), "build-"));
const put = (file, text) => {
mkdirSync(dirname(join(root, file)), {recursive: true});
writeFileSync(join(root, file), text);
};
const here = (name) => readFileSync(join(import.meta.dirname, name), "utf8");
const sh = (command, dir) => execSync(command, {cwd: join(root, dir), encoding: "utf8"});
// The package: package.json, one entry file, the code in src, the tests in test.
const pkg = {
name: "textkit",
version: "1.0.0",
private: true,
type: "module",
exports: "./index.js",
files: ["index.js", "src"],
scripts: {test: "node --test"}
};
put("textkit/package.json", JSON.stringify(pkg, null, 2));
put("textkit/index.js", here("index.js"));
put("textkit/src/slug.js", here("slug.js"));
put("textkit/src/truncate.js", here("truncate.js"));
put("textkit/test/textkit.test.js", here("textkit.test.js"));
console.log("$ npm test");
const report = sh("npm test --silent", "textkit");
console.log(report.split("\n").filter((line) => /^ℹ (tests|pass|fail) /.test(line)).join("\n"));
console.log("$ npm pack");
console.log(sh("npm pack --silent", "textkit").trim());
// The second project installs the tarball and imports the package by name.
put("app/package.json", JSON.stringify({name: "app", version: "1.0.0", private: true, type: "module"}, null, 2));
put("app/main.js", 'import {slugify, truncate} from "textkit";\nconsole.log(slugify("Crème Brûlée & Tea!"));\nconsole.log(truncate("A long title for a short line", 13));\n');
console.log("$ npm install ../textkit/textkit-1.0.0.tgz");
sh("npm install --silent --no-audit --no-fund ../textkit/textkit-1.0.0.tgz", "app");
console.log("dependencies:", JSON.parse(readFileSync(join(root, "app/package.json"), "utf8")).dependencies);
console.log("test/ installed:", existsSync(join(root, "app/node_modules/textkit/test")));
console.log("$ node main.js");
console.log(sh("node main.js", "app").trim());
index.js
export {slugify} from "./src/slug.js";
export {truncate} from "./src/truncate.js";
slug.js
// Turns a title into a URL slug: "Tea & Cake!" -> "tea-cake".
export function slugify(text) {
if (typeof text !== "string") throw new TypeError("slugify expects a string, got " + typeof text);
return text
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
truncate.js
// Shortens text to at most max characters, ending with an ellipsis: "A long…".
export const ELLIPSIS = "…";
export function truncate(text, max) {
if (typeof text !== "string") throw new TypeError("truncate expects a string, got " + typeof text);
if (!Number.isInteger(max) || max < 1) throw new RangeError("max must be a whole number from 1, got " + max);
if (text.length <= max) return text;
return text.slice(0, max - 1).trimEnd() + ELLIPSIS;
}
textkit.test.js
import {test} from "node:test";
import assert from "node:assert/strict";
import {slugify, truncate} from "textkit";
test("slugify makes a URL slug", () => {
assert.equal(slugify("Tea & Cake!"), "tea-cake", "slugify('Tea & Cake!') should be tea-cake");
});
test("truncate keeps short text and shortens long text", () => {
assert.equal(truncate("Tea", 5), "Tea", "short text should stay as it is");
assert.equal(truncate("A long title", 7), "A long…", "long text should end with …");
});
test("slugify refuses what is not a string", () => {
assert.throws(() => slugify(42), TypeError, "slugify(42) should throw a TypeError");
});
Run it with
node main.jsOutput
$ npm test
ℹ tests 3
ℹ pass 3
ℹ fail 0
$ npm pack
textkit-1.0.0.tgz
$ npm install ../textkit/textkit-1.0.0.tgz
dependencies: { textkit: 'file:../textkit/textkit-1.0.0.tgz' }
test/ installed: false
$ node main.js
creme-brulee-tea
A long title…- The tests import from "textkit" and pass without any install: the package imports itself by name, which "exports" allows.
- npm pack printed only the file name; it packed index.js, package.json and src, as "files" says.
- test/ is not in the app: the tarball holds only what "files" lists, plus package.json.
- The app imports the bare name "textkit", and "exports" sends it to index.js.
Exercises
Exercise 1 of 3
Step 1: slugify
Write slugify(text), which turns a title into a URL slug: lower case, every run of characters other than a–z and 0–9 replaced by one hyphen, no hyphen at the start or end. Accents go: "Crème Brûlée" becomes "creme-brulee". If text is not a string, throw a TypeError. The function uses no Node.js API, so it runs in the browser too, and it becomes src/slug.js of textkit.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
Check the type first: if (typeof text !== "string") throw new TypeError("slugify expects a string, got " + typeof text);
Hint 2
.replace(/[^a-z0-9]+/g, "-") turns each run of other characters into one hyphen; .replace(/^-+|-+$/g, "") removes hyphens at the ends.
Hint 3
text.normalize("NFD") splits é into e and an accent mark; .replace(/[\u0300-\u036f]/g, "") then removes the marks. Do both before the other replacements.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Turns a title into a URL slug: "Tea & Cake!" -> "tea-cake".
export function slugify(text) {
if (typeof text !== "string") throw new TypeError("slugify expects a string, got " + typeof text);
return text
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
console.log(slugify("Tea & Cake!"));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// Turns a title into a URL slug: "Tea & Cake!" -> "tea-cake".
export function slugify(text) {
return text.toLowerCase();
}
console.log(slugify("Tea & Cake!"));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {slugify} from './main.js';
test('Tea & Cake! becomes tea-cake', () => {
assert.equal(slugify('Tea & Cake!'), 'tea-cake', `slugify('Tea & Cake!') returned ${JSON.stringify(slugify('Tea & Cake!'))}`);
});
test('runs of spaces and signs become one hyphen, none at the ends', () => {
const got = slugify(' Hello, World ');
assert.equal(got, 'hello-world', `slugify(' Hello, World ') returned ${JSON.stringify(got)}`);
});
test('digits stay', () => {
assert.equal(slugify('Top 10 Tips'), 'top-10-tips', `slugify('Top 10 Tips') returned ${JSON.stringify(slugify('Top 10 Tips'))}`);
});
test('accents are removed: Crème Brûlée becomes creme-brulee', () => {
const got = slugify('Crème Brûlée');
assert.equal(got, 'creme-brulee', `slugify('Crème Brûlée') returned ${JSON.stringify(got)}`);
});
test('a value that is not a string throws a TypeError', () => {
assert.throws(() => slugify(42), TypeError, 'slugify(42) should throw a TypeError');
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 3
Step 2: truncate
Write truncate(text, max). Text of at most max characters comes back unchanged. Longer text is cut to max - 1 characters, trailing spaces removed, and ELLIPSIS (…) added, so the result is never longer than max: truncate("A long title", 7) is "A long…". Throw a TypeError when text is not a string, and a RangeError when max is not a whole number from 1. It becomes src/truncate.js of textkit and runs in the browser too.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
Check the arguments first: typeof text !== "string" means TypeError; !Number.isInteger(max) || max < 1 means RangeError.
Hint 2
If text.length <= max, return text as it is.
Hint 3
Otherwise text.slice(0, max - 1) leaves room for the ellipsis; .trimEnd() removes a space before it.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Shortens text to at most max characters, ending with an ellipsis: "A long…".
export const ELLIPSIS = "…";
export function truncate(text, max) {
if (typeof text !== "string") throw new TypeError("truncate expects a string, got " + typeof text);
if (!Number.isInteger(max) || max < 1) throw new RangeError("max must be a whole number from 1, got " + max);
if (text.length <= max) return text;
return text.slice(0, max - 1).trimEnd() + ELLIPSIS;
}
console.log(truncate("A long title", 7));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// Shortens text to at most max characters, ending with an ellipsis: "A long…".
export const ELLIPSIS = "…";
export function truncate(text, max) {
return text;
}
console.log(truncate("A long title", 7));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {truncate} from './main.js';
test('text that fits comes back unchanged', () => {
assert.equal(truncate('Tea', 5), 'Tea', `truncate('Tea', 5) returned ${JSON.stringify(truncate('Tea', 5))}`);
assert.equal(truncate('Hello', 5), 'Hello', `truncate('Hello', 5) returned ${JSON.stringify(truncate('Hello', 5))}`);
});
test('long text ends with …: truncate("A long title", 7) is "A long…"', () => {
const got = truncate('A long title', 7);
assert.equal(got, 'A long…', `truncate('A long title', 7) returned ${JSON.stringify(got)}`);
});
test('no space before the …: truncate("Hello world", 7) is "Hello…"', () => {
const got = truncate('Hello world', 7);
assert.equal(got, 'Hello…', `truncate('Hello world', 7) returned ${JSON.stringify(got)}`);
});
test('the result is never longer than max', () => {
for (let max = 1; max <= 12; max++) {
const got = truncate('A long title here', max);
assert.ok(got.length <= max, `truncate('A long title here', ${max}) returned ${JSON.stringify(got)}, ${got.length} characters`);
}
});
test('a max that is not a whole number from 1 throws a RangeError', () => {
assert.throws(() => truncate('Tea', 0), RangeError, 'truncate("Tea", 0) should throw a RangeError');
assert.throws(() => truncate('Tea', 2.5), RangeError, 'truncate("Tea", 2.5) should throw a RangeError');
});
test('text that is not a string throws a TypeError', () => {
assert.throws(() => truncate(null, 5), TypeError, 'truncate(null, 5) should throw a TypeError');
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 3 of 3
Step 3: the public face
main.js plays index.js, the entry file of textkit; slug.js and truncate.js sit next to it here (in the package, they are in src). The starter re-exports everything with export *, so ELLIPSIS, a detail of truncate.js, leaks out as part of the package. Re-export exactly slugify and truncate by name, and nothing else: no ELLIPSIS, no default export. What the entry exports is what every app can rely on.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
export * passes on every named export of a module, ELLIPSIS included.
Hint 2
Name what you pass on: export {slugify} from "./slug.js";
Hint 3
One line per module: export {truncate} from "./truncate.js"; and no export default.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// The entry file of textkit: what it exports is the package.
export {slugify} from "./slug.js";
export {truncate} from "./truncate.js";
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// The entry file of textkit: what it exports is the package.
export * from "./slug.js";
export * from "./truncate.js";
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import * as textkit from './main.js';
test('exports slugify and truncate, and nothing else', () => {
const names = Object.keys(textkit).sort();
assert.deepEqual(names, ['slugify', 'truncate'], `the entry exports ${JSON.stringify(names)}`);
});
test('the functions work through the entry file', () => {
assert.equal(textkit.slugify('Tea & Cake!'), 'tea-cake', 'slugify through the entry should give tea-cake');
assert.equal(textkit.truncate('A long title', 7), 'A long…', 'truncate through the entry should give A long…');
});
test('has no default export', () => {
assert.equal('default' in textkit, false, 'the entry should have named exports only');
});
slug.js
// Turns a title into a URL slug: "Tea & Cake!" -> "tea-cake".
export function slugify(text) {
if (typeof text !== "string") throw new TypeError("slugify expects a string, got " + typeof text);
return text
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
truncate.js
// Shortens text to at most max characters, ending with an ellipsis: "A long…".
export const ELLIPSIS = "…";
export function truncate(text, max) {
if (typeof text !== "string") throw new TypeError("truncate expects a string, got " + typeof text);
if (!Number.isInteger(max) || max < 1) throw new RangeError("max must be a whole number from 1, got " + max);
if (text.length <= max) return text;
return text.slice(0, max - 1).trimEnd() + ELLIPSIS;
}
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsCommon mistakes
Importing a default the package does not have
// index.js stands in for the entry file of textkit: named exports only.
import slugify from "./index.js";
console.log(slugify("Tea & Cake!"));
What Node.js prints
SyntaxError: The requested module './index.js' does not provide an export named 'default'Why, and the fix
textkit has named exports only, so an import without braces asks for a default export that does not exist; from the installed package the message names 'textkit'. Use braces with the exact name: import {slugify} from "textkit". Named exports also keep every app using the same name, which makes searching and renaming easy.
Re-exporting a name the module does not export
// The entry file passes on a helper that slug.js keeps to itself.
export {slugify, clean} from "./slug.js";
What Node.js prints
SyntaxError: The requested module './slug.js' does not provide an export named 'clean'Why, and the fix
An entry file can pass on only what a module exports, and clean is a private function of slug.js. Node.js checks every import and re-export before any line runs, so the whole package fails to load. Either leave clean private and drop it from the entry, or export it from slug.js first, but only if apps should really depend on it.
A utility that trusts its input
// An early slugify without a type check. A page number arrives as a number.
function slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}
console.log(slugify(42));
What Node.js prints
TypeError: text.toLowerCase is not a functionWhy, and the fix
A number has no toLowerCase, so the error comes from deep inside the package and names a variable the caller never saw. Check the type at the start of every exported function and throw a TypeError that says what was expected: "slugify expects a string, got number". A test with assert.throws keeps that promise.
JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.