1. The Problem
Build a text analyser that counts words, characters, sentences, and paragraphs,
and reports the top 5 most-used content words (excluding stop words like "the", "a", "and").
Accept input from stdin so it can be piped: cat essay.txt | node wordcount.mjs.
Why this project? String manipulation, regex splitting, Map/object for frequency counting, sorting and slicing — plus stdin streaming, a common Node.js pattern.
2. How to Think About This
Split → count → rank. Split on whitespace for words, split on sentence-ending punctuation for sentences, split on blank lines for paragraphs. For frequency, iterate every word, clean it (lowercase, strip punctuation), skip stop words, increment a counter in an object. Sort the entries by count descending, take the top 5.
3. The Build
// ── Analysis engine (pure — no I/O) ─────────────────────────────────────────
const STOP_WORDS = new Set([
"the","a","an","and","or","but","in","on","at","to","for","of","with",
"is","are","was","were","be","been","have","has","had","do","does","did",
"it","its","this","that","i","you","we","he","she","they","my","your",
]);
export function analyse(text) {
if (!text?.trim()) return null;
const words = text.trim().split(/\s+/).filter(Boolean);
const sentences = text.split(/[.!?]+/).filter(s => s.trim());
const paragraphs = text.split(/
\s*
/).filter(p => p.trim());
// Frequency map
const freq = {};
for (const raw of words) {
const word = raw.toLowerCase().replace(/[^a-z'-]/g, "");
if (word.length > 2 && !STOP_WORDS.has(word)) {
freq[word] = (freq[word] ?? 0) + 1;
}
}
const topWords = Object.entries(freq)
.sort(([,a],[,b]) => b - a)
.slice(0, 10)
.map(([word, count]) => ({ word, count }));
return {
words: words.length,
uniqueWords: new Set(words.map(w => w.toLowerCase())).size,
chars: text.length,
charsNoSpaces: text.replace(/\s/g, "").length,
sentences: Math.max(sentences.length, 1),
paragraphs: Math.max(paragraphs.length, 1),
avgWordsPerSentence: +(words.length / Math.max(sentences.length, 1)).toFixed(1),
topWords,
};
}
// ── CLI / stdin ───────────────────────────────────────────────────────────────
// Read all stdin then analyse
process.stdin.setEncoding("utf-8");
let input = "";
process.stdin.on("data", chunk => { input += chunk; });
process.stdin.on("end", () => {
if (!input.trim()) {
// No piped input — show usage
console.log("Usage: echo 'Your text here' | node wordcount.mjs");
console.log(" or: cat myfile.txt | node wordcount.mjs");
process.exit(0);
}
const r = analyse(input);
if (!r) { console.error("Empty input."); process.exit(1); }
console.log("=== Word Count Analysis ===
");
console.log(`Words: ${r.words} (${r.uniqueWords} unique)`);
console.log(`Characters: ${r.chars} (${r.charsNoSpaces} without spaces)`);
console.log(`Sentences: ${r.sentences}`);
console.log(`Paragraphs: ${r.paragraphs}`);
console.log(`Avg words/sentence: ${r.avgWordsPerSentence}`);
console.log("
Top words:");
const maxCount = r.topWords[0]?.count ?? 1;
for (const { word, count } of r.topWords) {
const bar = "█".repeat(Math.round(count / maxCount * 20));
console.log(` ${word.padEnd(20)} ${bar} ${count}`);
}
});
4. Test & Prove
import { analyse } from "./wordcount.mjs";
let pass=0,fail=0;
function test(desc,fn){try{fn();console.log(" ✓",desc);pass++;}catch(e){console.log(" ✗",desc,"—",e.message);fail++;}}
const TEXT = "The quick brown fox. The fox jumps over the lazy dog. A quick dog!";
test("word count", () => { const r=analyse(TEXT); if(r.words!==13) throw new Error(r.words); });
test("sentence count", () => { const r=analyse(TEXT); if(r.sentences!==3) throw new Error(r.sentences); });
test("top word is fox", () => { const r=analyse(TEXT); if(r.topWords[0].word!=="fox") throw new Error(r.topWords[0].word); });
test("stop words excluded", () => { const r=analyse(TEXT); if(r.topWords.find(w=>w.word==="the")) throw new Error("'the' should be excluded"); });
test("null on empty string", () => { if(analyse("")!==null) throw new Error("expected null"); });
test("null on whitespace", () => { if(analyse(" ")!==null) throw new Error("expected null"); });
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
Input: text via stdin (pipe from echo, cat, or any program).
Output: a summary table with counts and an ASCII bar chart of top words.
$ echo "JavaScript is great. JavaScript runs everywhere." | node wordcount.mjs === Word Count Analysis === Words: 8 (7 unique) Characters: 49 (42 without spaces) Sentences: 2 Paragraphs: 1 Avg words/sentence: 4.0 Top words: javascript ████████████████████ 2 great ██████████ 1 runs ██████████ 1 everywhere ██████████ 1
6. Run It
echo "Your text here" | node wordcount.mjs
cat README.md | node wordcount.mjs
node wordcount.test.mjs🎯 Try this next
- Add a readability score (Flesch-Kincaid: based on syllables and sentence length)
- Accept a filename as argument:
node wordcount.mjs myfile.txt - Output JSON with
--jsonflag for piping into other tools - Compare two files side by side — which has more complex vocabulary?
What you practised
Regex splitting (/\s+/, /[.!?]+/, /
\s*
/),
frequency counting with an object, Set for unique values, stdin streaming with event listeners,
padEnd for aligned output, and ASCII bar charts from a ratio.
Reference: Strings · Regular Expressions · Set & Map