Skip to content

Text Statistics & Readability — JavaScript source

Count words, sentences, paragraphs, characters, lines, and reading time, plus Flesch Reading Ease and Flesch-Kincaid grade-level readability scores.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// text-stats - JavaScript port
// Language: JavaScript (ES2020+, runs unchanged in Node and modern browsers)
// CosmoDev polyglot showcase. Ported from src/lib/textStats.ts.
// Display source - part of CosmoDev's polyglot tool pages.
//
// Pure text-statistics & readability logic: counts characters, words, sentences,
// paragraphs, lines and syllables, then derives reading/speaking time and the
// Flesch readability scores. Deterministic; never throws.

// NOTE on character counting: this file mirrors src/lib/textStats.ts exactly,
// including JavaScript's UTF-16 code-unit semantics for `string.length`. For
// text outside the Basic Multilingual Plane (e.g. emoji) the count therefore
// differs from a code-point count in the Go/Rust/Python/PHP ports. Real-world
// prose input is unaffected.

/**
 * @typedef {Object} TextStats
 * @property {number} characters          total UTF-16 code units
 * @property {number} charactersNoSpaces  code units excluding all whitespace
 * @property {number} words               tokens matched by the word regex
 * @property {number} sentences           terminal-punctuation boundaries
 * @property {number} paragraphs          blocks separated by blank lines
 * @property {number} lines               newline-separated rows
 * @property {number} syllables           estimated via vowel-group heuristic
 * @property {number} readingTimeMs       words / 200 wpm
 * @property {number} speakingTimeMs      words / 130 wpm
 * @property {?number} fleschReadingEase  null when no words/sentences
 * @property {?number} fleschKincaidGrade null when no words/sentences
 * @property {?string} readabilityLabel   null when no words/sentences
 */

/**
 * Count syllables in a single word via a vowel-group heuristic.
 *
 * True syllabification needs a dictionary; this heuristic is cheap and accurate
 * enough to feed the Flesch formulas. It works on the ASCII letters only.
 */
function countSyllables(word) {
  // Normalise to lowercase ASCII letters, discarding digits, apostrophes and
  // hyphens so "don't" / "well-being" are scored on their letter cores.
  const w = word.toLowerCase().replace(/[^a-z]/g, '');
  if (!w) return 0;
  if (w.length <= 3) return 1;

  // Drop a silent trailing 'e', 'es' or 'ed'. The consonant class excludes
  // 'l' so that "...le" endings (apple, table) keep their final syllable.
  let s = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
  // A leading 'y' is a consonant ("yellow"), not a vowel - strip before grouping.
  s = s.replace(/^y/, '');

  // Each maximal run of vowels counts as one syllable nucleus.
  const groups = s.match(/[aeiouy]{1,}/g);
  const count = groups ? groups.length : 1;
  return Math.max(1, count);
}

/**
 * Map a Flesch reading-ease score onto a qualitative label.
 * Buckets follow the original Flesch interpretation bands.
 */
function labelForFlesch(f) {
  if (f >= 80) return 'Very Easy';
  if (f >= 70) return 'Easy';
  if (f >= 60) return 'Standard';
  if (f >= 50) return 'Fairly Hard';
  if (f >= 30) return 'Hard';
  return 'Very Hard';
}

/**
 * Analyse a string and return its statistics.
 *
 * Accepts null/undefined (treated as the empty string) and never throws.
 * The reading/speaking times use the conventional 200 wpm / 130 wpm rates.
 *
 * @param {string} [input]
 * @returns {TextStats}
 */
function analyzeText(input) {
  const text = input ?? '';
  const characters = text.length;
  const charactersNoSpaces = text.replace(/\s/g, '').length;

  // Words are runs of ASCII letters/digits plus apostrophes (both ' and the
  // typographic ') and hyphens, so contractions and hyphenated terms stay whole.
  const wordList = text.match(/[A-Za-z0-9''-]+/g) ?? [];
  const words = wordList.length;

  // A sentence ends at terminal punctuation followed by whitespace or EOF.
  // With no words there is nothing to terminate; otherwise clamp to >= 1 so a
  // word block without any closing punctuation still counts as one sentence.
  const sentences =
    words === 0 ? 0 : Math.max(1, (text.match(/[.!?]+(?:\s|$)/g) ?? []).length);

  // Paragraphs are separated by two or more line breaks. Whitespace-only input
  // has zero paragraphs; otherwise trim each block and drop empties.
  const paragraphs =
    text.trim().length === 0
      ? 0
      : text
          .split(/\n{2,}/)
          .map((p) => p.trim())
          .filter(Boolean).length;

  const lines = text === '' ? 0 : text.split('\n').length;

  const syllables = wordList.reduce((sum, w) => sum + countSyllables(w), 0);

  const readingTimeMs = Math.round((words / 200) * 60000);
  const speakingTimeMs = Math.round((words / 130) * 60000);

  // Readability needs at least one word and one sentence to be defined.
  let fleschReadingEase = null;
  let fleschKincaidGrade = null;
  let readabilityLabel = null;
  if (words > 0 && sentences > 0) {
    const wordsPerSentence = words / sentences;
    const syllablesPerWord = syllables / words;
    fleschReadingEase =
      Math.round((206.835 - 1.015 * wordsPerSentence - 84.6 * syllablesPerWord) * 10) / 10;
    fleschKincaidGrade =
      Math.round((0.39 * wordsPerSentence + 11.8 * syllablesPerWord - 15.59) * 10) / 10;
    readabilityLabel = labelForFlesch(fleschReadingEase);
  }

  return {
    characters,
    charactersNoSpaces,
    words,
    sentences,
    paragraphs,
    lines,
    syllables,
    readingTimeMs,
    speakingTimeMs,
    fleschReadingEase,
    fleschKincaidGrade,
    readabilityLabel,
  };
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →