Functional Weave
Code in TypeScript

text.truncate@1.0.0

impl/typescript.ts

2,517 bytes · the TypeScript implementation · view raw

/**
 * The Unicode White_Space property, spelled out so all three languages agree:
 * 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
  );
}

/** Punctuation that reads badly directly before an ellipsis. */
function isTrailingPunctuation(ch: string): boolean {
  return ch === "," || ch === ";" || ch === ":" || ch === "-" || ch === ".";
}

function codePoint(ch: string): number {
  return ch.codePointAt(0) as number;
}

/**
 * Shorten `value` to at most `maxLength` code points, ellipsis included,
 * cutting at a word boundary where there is one.
 */
export function truncate(value: string, maxLength: number, ellipsis: string): string {
  if (typeof value !== "string") {
    throw new TypeError(`truncate needs a string, received ${value}`);
  }
  if (!Number.isInteger(maxLength)) {
    throw new TypeError(`maxLength must be a whole number, received ${maxLength}`);
  }
  if (maxLength < 0) {
    throw new RangeError(`maxLength must be 0 or greater, received ${maxLength}`);
  }
  if (typeof ellipsis !== "string") {
    throw new TypeError(`ellipsis must be a string, received ${ellipsis}`);
  }

  // Code points, not UTF-16 units: an emoji is one character here, as it is
  // in Python and Rust.
  const chars = Array.from(value);
  const mark = Array.from(ellipsis);
  if (mark.length > maxLength) {
    throw new RangeError(`maxLength must be at least the length of the ellipsis, received ${maxLength}`);
  }
  if (chars.length <= maxLength) return value;

  const budget = maxLength - mark.length;
  let end = budget;
  // A cut just before whitespace is already a clean boundary.
  if (!isWhitespace(codePoint(chars[budget]))) {
    let lastSpace = -1;
    for (let i = budget - 1; i >= 0; i--) {
      if (isWhitespace(codePoint(chars[i]))) {
        lastSpace = i;
        break;
      }
    }
    // No boundary to move back to: one long word is cut hard rather than
    // reduced to a bare ellipsis.
    if (lastSpace > 0) end = lastSpace;
  }

  while (end > 0 && (isWhitespace(codePoint(chars[end - 1])) || isTrailingPunctuation(chars[end - 1]))) {
    end--;
  }
  return chars.slice(0, end).join("") + ellipsis;
}