Warm-up · Activity 1 of 7
// B3.5 · ~45 min · Beginner
Build: a notes CLI that stores JSON on disk
You build a notes command that keeps each note as its own JSON file: saved safely, listed in order, shown, deleted and cleared, with clear exit codes.
Lesson 5 of 5 in B3 Files and paths
You will be able to
- Store one JSON file per note: slug names, a folder made by mkdir, and temp-file-plus-rename writes
- List, show and delete notes with readdir, stat, readFile and rm, skipping entries that are not notes
- Wire it into a command with util.parseArgs, messages on stderr and exit codes 0, 1 and 2
Predict · Activity 2 of 7
Predict before you read on. The finished tool has no notes folder yet. You run the command below. Which file is in notes afterwards?
node main.js save "Buy Milk!" --body "two litres"Practice · Activity 3 of 7
Fill in the replacement so that every run of characters other than a-z and 0-9 becomes one -, and " Buy Milk! " becomes buy-milk.
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "____").replace(/^-+|-+$/g, "");const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "").replace(/^-+|-+$/g, "");Practice · Activity 4 of 7
The finished tool has no notes yet. Match each command line to what it does.
Practice · Activity 5 of 7
What does the last command print?
node main.js save "Buy milk" --body "two litres" node main.js save "buy MILK" --body "one litre" node main.js listBrain teaser · Activity 6 of 7
Brain teaser. A run crashed before its rename and left a half-written temp file. notes now holds buy-milk.json, .call-ada.json.4012.tmp and an empty folder archive. What does node main.js list print?
Apply · Activity 7 of 7
Mini-task. Add a command archive <slug> to run(): it moves notes/<slug>.json into the folder notes/archive, which it creates when needed, and prints archived and the slug. list must not show archived notes; check that it does not, and say why. A missing note prints no note: and the name, with exit code 1. Reuse isSlug, so archive ../main is refused.
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
The same shape, a different tool: bookmarks
Before you build the notes CLI, here is the same storage idea on a smaller scale. Every bookmark is its own file, bookmarks/<name>.json. save makes the folder if needed and replaces the file through a temp file and rename; list keeps only .json files, sorted, with their size from stat. The demo starts from an empty folder, adds a subfolder that list must skip, saves a name twice, and tries to remove a file that does not exist.
main.js
import {mkdir, readdir, rename, rm, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
const dir = join(import.meta.dirname, 'bookmarks');
// One file per bookmark, replaced in one step.
async function save(name, url) {
await mkdir(dir, {recursive: true});
const temp = join(dir, `.${name}.json.${process.pid}.tmp`);
await writeFile(temp, JSON.stringify({url}, null, 2) + '\n');
await rename(temp, join(dir, `${name}.json`));
}
// Only .json files count, sorted, each with its size.
async function list() {
const lines = [];
for (const entry of await readdir(dir, {withFileTypes: true})) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const {size} = await stat(join(dir, entry.name));
lines.push(`${entry.name} ${size} bytes`);
}
return lines.sort().join(' | ');
}
await rm(dir, {recursive: true, force: true}); // a clean start for the demo
await save('nodejs', 'https://nodejs.org');
await save('mdn', 'https://developer.mozilla.org');
await mkdir(join(dir, 'old'), {recursive: true}); // a folder, not a bookmark
console.log(await list());
await save('nodejs', 'https://nodejs.org/en/learn'); // same name: replaced
console.log(await list());
try {
await rm(join(dir, 'python.json'));
} catch (error) {
console.log('rm python.json:', error.code);
}
console.log('in bookmarks:', (await readdir(dir)).sort().join(', '));
Run it with
node main.jsOutput
mdn.json 45 bytes | nodejs.json 34 bytes
mdn.json 45 bytes | nodejs.json 43 bytes
rm python.json: ENOENT
in bookmarks: mdn.json, nodejs.json, old- The folder old exists, but list skipped it, because it is not a file ending in .json.
- Saving nodejs again replaced the file: still two bookmarks, and nodejs.json grew from 34 to 43 bytes.
- rm on a missing file rejects with ENOENT; the notes CLI turns that into no note: and exit code 1.
- No temp file is left in the folder: each one was renamed to its real name.
Exercises
Exercise 1 of 4
Step 1: names from titles
Write slugify(title): lower-case the title, replace every run of characters other than a-z and 0-9 with one -, and remove any - at both ends. " Buy Milk! " gives buy-milk. If nothing is left, throw an Error that names the title. Also write isSlug(text), true only for such names: buy-milk and 2026, but not ../main, Buy-Milk or a--b. Both are pure, so they run 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
title.toLowerCase().replace(/[^a-z0-9]+/g, '-') turns every run of other characters into one -.
Hint 2
A second replace(/^-+|-+$/g, '') removes - at the start and at the end.
Hint 3
For isSlug, test the whole text with /^[a-z0-9]+(-[a-z0-9]+)*$/: groups of letters and digits, one - between them.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (slug === '') throw new Error('the title "' + title + '" has no letters or digits');
return slug;
}
// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}
console.log(slugify(' Buy Milk! '), isSlug('../main'));
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
// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
return title.toLowerCase();
}
// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
return text !== '';
}
console.log(slugify(' Buy Milk! '), isSlug('../main'));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {isSlug, slugify} from './main.js';
test('" Buy Milk! " becomes buy-milk', () => {
assert.equal(slugify(' Buy Milk! '), 'buy-milk', `slugify returned ${JSON.stringify(slugify(' Buy Milk! '))}`);
});
test('runs of other characters become one -', () => {
assert.equal(slugify('Ideas: 2026 -- draft'), 'ideas-2026-draft', `slugify returned ${JSON.stringify(slugify('Ideas: 2026 -- draft'))}`);
});
test('a title without letters or digits throws an Error naming it', () => {
assert.throws(() => slugify('!!!'), {message: /!!!/}, 'slugify("!!!") should throw an Error whose message contains !!!');
});
test('isSlug accepts slugs only', () => {
for (const text of ['buy-milk', '2026', 'a1-b2']) assert.equal(isSlug(text), true, `isSlug(${JSON.stringify(text)}) should be true`);
for (const text of ['../main', 'Buy-Milk', 'a--b', '-a', 'a-', '', 'a b']) assert.equal(isSlug(text), false, `isSlug(${JSON.stringify(text)}) should be false`);
});
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 4
Step 2: check a note file
A note file can be edited by hand, so loadNote checks what JSON.parse gives back before it trusts it. Write validateNote(value): when value is a plain object, not null and not an array, with a title that is a non-empty string and a body that is a string, return a new object with just title and body. Otherwise throw an Error that says what is wrong. It is pure, so it 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
typeof null is 'object', and so is typeof [], so test value === null and Array.isArray(value) as well.
Hint 2
value.title.trim() === '' catches a title that is only spaces; check typeof value.title === 'string' first.
Hint 3
Build the result yourself, {title: value.title, body: value.body}, so other properties are dropped.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('a note must be an object');
if (typeof value.title !== 'string' || value.title.trim() === '') throw new Error('a note needs a title');
if (typeof value.body !== 'string') throw new Error('the body of a note must be text');
return {title: value.title, body: value.body};
}
console.log(validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'}));
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
// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
return value;
}
console.log(validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'}));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {validateNote} from './main.js';
test('a good note comes back with title and body only', () => {
const got = validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'});
assert.deepEqual(got, {title: 'Buy milk', body: 'two litres'}, `validateNote returned ${JSON.stringify(got)}`);
});
test('an empty body is fine', () => {
const got = validateNote({title: 'Call Ada', body: ''});
assert.deepEqual(got, {title: 'Call Ada', body: ''}, `validateNote returned ${JSON.stringify(got)}`);
});
test('null, an array and a string are not notes', () => {
for (const value of [null, [], 'Buy milk']) assert.throws(() => validateNote(value), Error, `validateNote(${JSON.stringify(value)}) should throw`);
});
test('a missing or empty title throws', () => {
for (const value of [{body: 'x'}, {title: '', body: 'x'}, {title: ' ', body: 'x'}, {title: 42, body: 'x'}]) {
assert.throws(() => validateNote(value), Error, `validateNote(${JSON.stringify(value)}) should throw`);
}
});
test('a body that is not a string throws', () => {
assert.throws(() => validateNote({title: 'Buy milk'}), Error, 'validateNote without a body should throw');
assert.throws(() => validateNote({title: 'Buy milk', body: 2}), Error, 'validateNote with body 2 should throw');
});
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 4
Step 3: the listing
Write formatListing(records, by = "name"). Each record is {slug, title, size, mtimeMs}. Return one line per note, slug, two spaces, title, two spaces, and the size as (50 bytes), each line ending with a newline. Sort by slug as sort() sorts strings, or newest first when by is "newest". No records give "no notes yet" and a newline. Do not change the array you were given. It is pure, so it 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
Sort a copy, [...records].sort(order), because sort() changes the array it is called on.
Hint 2
For newest first: (a, b) => b.mtimeMs - a.mtimeMs. For slugs: (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0).
Hint 3
Build each line with r.slug + ' ' + r.title + ' (' + r.size + ' bytes)', join them with a newline, and add one at the end.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
if (records.length === 0) return 'no notes yet\n';
const order = by === 'newest' ? (a, b) => b.mtimeMs - a.mtimeMs : (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
return [...records].sort(order).map((r) => r.slug + ' ' + r.title + ' (' + r.size + ' bytes)').join('\n') + '\n';
}
const records = [
{slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
{slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
console.log(formatListing(records));
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 output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
return records.map((r) => r.slug).join('\n');
}
const records = [
{slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
{slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
console.log(formatListing(records));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {formatListing} from './main.js';
const records = [
{slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
{slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
test('sorted by slug, one line per note', () => {
const got = formatListing(records);
assert.equal(got, 'buy-milk Buy milk (50 bytes)\ncall-ada Call Ada (49 bytes)\n', `formatListing returned ${JSON.stringify(got)}`);
});
test('newest first with "newest"', () => {
const got = formatListing(records, 'newest');
assert.equal(got, 'call-ada Call Ada (49 bytes)\nbuy-milk Buy milk (50 bytes)\n', `formatListing returned ${JSON.stringify(got)}`);
});
test('no records give no notes yet', () => {
assert.equal(formatListing([]), 'no notes yet\n', `formatListing([]) returned ${JSON.stringify(formatListing([]))}`);
});
test('the array passed in keeps its order', () => {
const copy = [...records];
formatListing(copy);
assert.deepEqual(copy, records, 'formatListing changed the order of the array it was given');
});
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 4 of 4
Step 4: the store on disk
notes.js holds steps 1 to 3, and main.js has run() and its wiring. Write the file-system part. saveNote creates the folder with mkdir and {recursive: true}, writes indented JSON plus a newline to a temp file in the same folder, and renames it over <slug>.json. listNotes returns a record for every .json file, and an empty list for a missing folder. clearNotes deletes every note, then the folder with rmdir if it is empty. Check with node --test.
This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
Build the temp path next to the note: join(dir, '.' + slug + '.json.' + process.pid + '.tmp'), then rename(temp, join(dir, slug + '.json')).
Hint 2
In listNotes, catch ENOENT from readdir and return []; skip entries with !entry.isFile() || !entry.name.endsWith(".json").
Hint 3
In clearNotes, call deleteNote for each record of listNotes, then rmdir(dir), ignoring ENOENT and ENOTEMPTY.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {parseArgs} from 'node:util';
import {formatListing, isSlug, slugify, validateNote} from './notes.js';
const USAGE = 'Usage: node main.js <save|list|show|delete|clear> [title or slug] [--body <text>] [--newest] [--dir <folder>]';
async function loadNote(dir, slug) {
return validateNote(JSON.parse(await readFile(join(dir, slug + '.json'), 'utf8')));
}
async function deleteNote(dir, slug) {
await rm(join(dir, slug + '.json'));
}
// One file per note: <dir>/<slug>.json, replaced in one step.
async function saveNote(dir, note) {
const slug = slugify(note.title);
await mkdir(dir, {recursive: true});
const temp = join(dir, '.' + slug + '.json.' + process.pid + '.tmp');
await writeFile(temp, JSON.stringify(note, null, 2) + '\n');
await rename(temp, join(dir, slug + '.json'));
return slug;
}
// A record for every *.json file in dir; a missing dir means no notes yet.
async function listNotes(dir) {
let entries;
try {
entries = await readdir(dir, {withFileTypes: true});
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
const records = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const slug = entry.name.slice(0, -'.json'.length);
const info = await stat(join(dir, entry.name));
records.push({slug, title: (await loadNote(dir, slug)).title, size: info.size, mtimeMs: info.mtimeMs});
}
return records;
}
// Deletes every note, then the folder itself if nothing else is left in it.
async function clearNotes(dir) {
for (const {slug} of await listNotes(dir)) await deleteNote(dir, slug);
try {
await rmdir(dir);
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY') throw error;
}
}
// What to print, and the exit code: 0 = done, 1 = a note problem, 2 = wrong usage.
async function run(args) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {
body: {type: 'string', default: ''},
dir: {type: 'string', default: join(import.meta.dirname, 'notes')},
newest: {type: 'boolean'}
},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
const [command, name, ...rest] = positionals;
const named = name !== undefined && rest.length === 0;
const {dir} = values;
try {
if (command === 'save' && named) return {out: 'saved ' + (await saveNote(dir, {title: name, body: values.body})) + '\n', err: '', code: 0};
if (command === 'list' && name === undefined) return {out: formatListing(await listNotes(dir), values.newest ? 'newest' : 'name'), err: '', code: 0};
if (command === 'clear' && name === undefined) {
await clearNotes(dir);
return {out: 'cleared\n', err: '', code: 0};
}
if ((command === 'show' || command === 'delete') && named) {
if (!isSlug(name)) return {out: '', err: 'not a note name: ' + name + '\n', code: 2};
if (command === 'delete') {
await deleteNote(dir, name);
return {out: 'deleted ' + name + '\n', err: '', code: 0};
}
const note = await loadNote(dir, name);
return {out: note.title + '\n\n' + note.body + '\n', err: '', code: 0};
}
} catch (error) {
if (error.code === 'ENOENT') return {out: '', err: 'no note: ' + name + '\n', code: 1};
return {out: '', err: 'error: ' + error.message + '\n', code: 1};
}
return {out: '', err: USAGE + '\n', code: 2};
}
// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
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
import {mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {parseArgs} from 'node:util';
import {formatListing, isSlug, slugify, validateNote} from './notes.js';
const USAGE = 'Usage: node main.js <save|list|show|delete|clear> [title or slug] [--body <text>] [--newest] [--dir <folder>]';
async function loadNote(dir, slug) {
return validateNote(JSON.parse(await readFile(join(dir, slug + '.json'), 'utf8')));
}
async function deleteNote(dir, slug) {
await rm(join(dir, slug + '.json'));
}
// One file per note: <dir>/<slug>.json, replaced in one step.
async function saveNote(dir, note) {
const slug = slugify(note.title);
await writeFile(join(dir, slug + '.json'), JSON.stringify(note));
return slug;
}
// A record for every *.json file in dir; a missing dir means no notes yet.
async function listNotes(dir) {
return [];
}
// Deletes every note, then the folder itself if nothing else is left in it.
async function clearNotes(dir) {}
// What to print, and the exit code: 0 = done, 1 = a note problem, 2 = wrong usage.
async function run(args) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {
body: {type: 'string', default: ''},
dir: {type: 'string', default: join(import.meta.dirname, 'notes')},
newest: {type: 'boolean'}
},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
const [command, name, ...rest] = positionals;
const named = name !== undefined && rest.length === 0;
const {dir} = values;
try {
if (command === 'save' && named) return {out: 'saved ' + (await saveNote(dir, {title: name, body: values.body})) + '\n', err: '', code: 0};
if (command === 'list' && name === undefined) return {out: formatListing(await listNotes(dir), values.newest ? 'newest' : 'name'), err: '', code: 0};
if (command === 'clear' && name === undefined) {
await clearNotes(dir);
return {out: 'cleared\n', err: '', code: 0};
}
if ((command === 'show' || command === 'delete') && named) {
if (!isSlug(name)) return {out: '', err: 'not a note name: ' + name + '\n', code: 2};
if (command === 'delete') {
await deleteNote(dir, name);
return {out: 'deleted ' + name + '\n', err: '', code: 0};
}
const note = await loadNote(dir, name);
return {out: note.title + '\n\n' + note.body + '\n', err: '', code: 0};
}
} catch (error) {
if (error.code === 'ENOENT') return {out: '', err: 'no note: ' + name + '\n', code: 1};
return {out: '', err: 'error: ' + error.message + '\n', code: 1};
}
return {out: '', err: USAGE + '\n', code: 2};
}
// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
// A folder of its own for this test run, inside the exercise folder.
const dir = 'test-notes-' + Date.now() + '-' + Math.floor(Math.random() * 1e6);
const notes = async (...args) => runMain({args: [...args, '--dir', dir]});
test('list before any save prints no notes yet', async () => {
const got = await notes('list');
assert.equal(got, 'no notes yet\n', `list printed ${JSON.stringify(got)}`);
});
test('save creates the folder and prints saved buy-milk', async () => {
const got = await notes('save', 'Buy milk', '--body', 'two litres');
assert.equal(got, 'saved buy-milk\n', `save printed ${JSON.stringify(got)}`);
});
test('list shows the note with the size of its indented JSON', async () => {
const got = await notes('list');
assert.equal(got, 'buy-milk Buy milk (50 bytes)\n', `list printed ${JSON.stringify(got)}`);
});
test('a second note: list is sorted by slug', async () => {
await notes('save', 'Call Ada', '--body', 'ring back');
const got = await notes('list');
assert.equal(got, 'buy-milk Buy milk (50 bytes)\ncall-ada Call Ada (49 bytes)\n', `list printed ${JSON.stringify(got)}`);
});
test('saving the same slug again replaces the note', async () => {
await notes('save', 'buy MILK', '--body', 'one litre');
const got = (await notes('list')).split('\n')[0];
assert.equal(got, 'buy-milk buy MILK (50 bytes)', `the first line of list was ${JSON.stringify(got)}`);
const shown = await notes('show', 'buy-milk');
assert.equal(shown, 'buy MILK\n\none litre\n', `show printed ${JSON.stringify(shown)}`);
});
test('clear removes every note', async () => {
const cleared = await notes('clear');
assert.equal(cleared, 'cleared\n', `clear printed ${JSON.stringify(cleared)}`);
const got = await notes('list');
assert.equal(got, 'no notes yet\n', `after clear, list printed ${JSON.stringify(got)}`);
});
notes.js
// Pure functions: no fs, no process, so they run anywhere.
// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (slug === '') throw new Error('the title "' + title + '" has no letters or digits');
return slug;
}
// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}
// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('a note must be an object');
if (typeof value.title !== 'string' || value.title.trim() === '') throw new Error('a note needs a title');
if (typeof value.body !== 'string') throw new Error('the body of a note must be text');
return {title: value.title, body: value.body};
}
// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
if (records.length === 0) return 'no notes yet\n';
const order = by === 'newest' ? (a, b) => b.mtimeMs - a.mtimeMs : (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
return [...records].sort(order).map((r) => r.slug + ' ' + r.title + ' (' + r.size + ' bytes)').join('\n') + '\n';
}
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
Calling stat with the bare name
import {mkdir, readdir, stat, writeFile} from 'node:fs/promises';
await mkdir('notes', {recursive: true});
await writeFile('notes/buy-milk.json', '{}\n');
for (const name of await readdir('notes')) {
console.log(name, (await stat(name)).size);
}
What Node.js prints
Error: ENOENT: no such file or directory, stat 'buy-milk.json'Why, and the fix
readdir gives names relative to the folder it read, and stat resolves a bare name against the current folder, where buy-milk.json does not exist. Join the folder back on: stat(join("notes", name)). The same holds for readFile and rm.
Listing a folder that is not there yet
import {readdir} from 'node:fs/promises';
const names = await readdir('notes');
console.log(names.length, 'notes');
What Node.js prints
Error: ENOENT: no such file or directory, scandir 'notes'Why, and the fix
Before the first save there is no notes folder, and readdir rejects with ENOENT. For a list, that simply means no notes: wrap readdir in try/catch, return [] when error.code is ENOENT, and throw any other error again.
Forgetting allowPositionals
import {parseArgs} from 'node:util';
const {values, positionals} = parseArgs({args: ['save', 'Buy milk', '--body', 'two litres'], options: {body: {type: 'string'}}});
console.log(values, positionals);
What Node.js prints
TypeError [ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL]: Unexpected argument 'save'. This command does not take positional argumentsWhy, and the fix
By default, parseArgs accepts options only, and the command save and the title are positionals. Pass allowPositionals: true; then positionals is ["save", "Buy milk"]. In run(), the error would be caught and reported as wrong usage with exit code 2.
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.