Functional Weave
Code in TypeScript

form.character-count@1.0.1

impl/typescript.ts

2,482 bytes · the TypeScript implementation · view raw

import type { CharacterCount, CountUnit } from "./form_character_count_types.ts";

/**
 * The Unicode White_Space property, spelled out so all three languages agree
 * on what separates words (JavaScript's \s, Python's str.isspace and Rust's
 * char::is_whitespace each draw the line slightly differently).
 */
function isWhitespace(cp: number): boolean {
  return (
    (cp >= 0x09 && cp <= 0x0d) ||
    cp === 0x20 ||
    cp === 0x85 ||
    cp === 0xa0 ||
    cp === 0x1680 ||
    (cp >= 0x2000 && cp <= 0x200a) ||
    cp === 0x2028 ||
    cp === 0x2029 ||
    cp === 0x202f ||
    cp === 0x205f ||
    cp === 0x3000
  );
}

function countWords(text: string): number {
  let words = 0;
  let inWord = false;
  for (const ch of text) {
    if (isWhitespace(ch.codePointAt(0) as number)) {
      inWord = false;
    } else if (!inWord) {
      inWord = true;
      words++;
    }
  }
  return words;
}

/**
 * GOV.UK's character count: how many characters (code points) or words have
 * been typed against a limit, and the sentence to show under the text box.
 */
export function characterCount(text: string, limit: number, unit: CountUnit, threshold: number | null): CharacterCount {
  if (typeof text !== "string") throw new TypeError("text must be a string");
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError(`limit must be a whole number of at least 1, received ${limit}`);
  if (unit !== "characters" && unit !== "words") throw new RangeError(`unit must be characters or words, received ${unit}`);
  if (threshold !== null && (!Number.isInteger(threshold) || threshold < 0 || threshold > 100)) {
    throw new RangeError(`threshold must be a whole number from 0 to 100, received ${threshold}`);
  }
  // Code points, not UTF-16 units: an emoji is one character, as in Python and Rust.
  const count = unit === "words" ? countWords(text) : Array.from(text).length;
  const remaining = limit - count;
  const over = remaining < 0;
  // count >= limit * threshold / 100, kept in whole numbers so 50% of 3 is 1.5, not 1.
  const visible = over || threshold === null || count * 100 >= limit * threshold;
  const one = unit === "words" ? "word" : "character";
  const noun = (n: number) => (n === 1 ? one : unit);
  const message = over ? `You have ${-remaining} ${noun(-remaining)} too many` : `You have ${remaining} ${noun(remaining)} remaining`;
  return { count, remaining, over, visible, message, description: `You can enter up to ${limit} ${noun(limit)}` };
}