Tier 1 — Beginner

Word Counter

Analyse any text — word count, frequency, top words, and stdin streaming.

🐍 Python 🟨 JavaScript

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

wordcount.mjs
// ── 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

wordcount.test.mjs
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

  1. Add a readability score (Flesch-Kincaid: based on syllables and sentence length)
  2. Accept a filename as argument: node wordcount.mjs myfile.txt
  3. Output JSON with --json flag for piping into other tools
  4. 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

Try It Yourself