All files / text / unstable_to_title_case.ts

100.00% Branches 22/22
100.00% Lines 41/41
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
 
 
x3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x3
x21
 
 
x63
x21
x21
x21
x21
 
x21
x115
x168
x168
x168
x168
x168
x115
x156
x156
x115
 
x21
x21
 
x5
x5
x5
x5
 
x21
x21
x38
x54
 
x55
x55
 
x55
x63
x76
x76
x67
x55
x21
 
x5
x5
x5














































































































// Copyright 2018-2025 the Deno authors. MIT license.
// This module is browser compatible.
import { resolveOptions, titleCaseSegment } from "./_title_case_util.ts";
import type { BaseTitleCaseOptions } from "./_title_case_util.ts";
export type { BaseTitleCaseOptions };
export type { TrailingCase } from "./_title_case_util.ts";

/**
 * A function that filters words in the string. If a word returns `true` from this function, it will not be title-cased.
 * @param value The word to be filtered.
 * @param index The index of the word in the array.
 * @param array The array of words.
 * @returns `true` if the word should be excluded from title casing, `false` otherwise.
 */
export type ExcludeWordFilter = (
  value: Intl.SegmentData,
  index: number,
  array: Intl.SegmentData[],
) => boolean;

/**
 * A filter function or array of stop words to exclude them from title casing,
 * or multiple filter functions and stop word arrays to combine for filtering.
 */
export type ExcludeWordConfig =
  | ExcludeWordFilter
  | readonly string[]
  | (ExcludeWordFilter | readonly string[])[];

/** Options for {@linkcode toTitleCase} */
export interface TitleCaseOptions extends BaseTitleCaseOptions {
  /**
   * A filter function or array of stop words to exclude them from title casing,
   * or multiple filter functions and stop word arrays to combine for filtering.
   *
   * @default {() => false} (no filtering)
   */
  exclude?: ExcludeWordConfig;
}

/**
 * Converts a string into Title Case.
 *
 * > [!NOTE]
 * > This function preserves punctuation and does not insert spaces or other
 * > characters where none exist in the input (e.g. it doesn't split up
 * > `camelCase` words). This is in contrast to some other `to{X}Case`
 * > functions, such as `toSnakeCase`.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @param input The string that is going to be converted into Title Case
 * @param options Optional settings to customize the conversion
 * @returns The string as Title Case
 *
 * @example Usage
 * ```ts
 * import { toTitleCase } from "@std/text/unstable-to-title-case";
 * import { assertEquals } from "@std/assert/equals";
 *
 * assertEquals(toTitleCase("deno is awesome"), "Deno Is Awesome");
 * ```
 */
export function toTitleCase(input: string, options?: TitleCaseOptions): string {
  const opts = resolveOptions(options);
  // [3.13.2 Default Case Conversion `toTitlecase`](https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G34078)
  // toTitlecase(X): Find the word boundaries in X according to Unicode Standard Annex #29, “Unicode Text Segmentation.”
  const segments = [...opts.words.segment(input)];
  const words = segments.filter((x) => x.isWordLike);
  const exclude = toExcludeFilter(options?.exclude);
  let out = "";
  let i = 0;

  for (const s of segments) {
    if (s.isWordLike) {
      out += !exclude(s, i++, words)
        ? titleCaseSegment(s.segment, opts)
        : opts.trailingCase === "lower"
        ? s.segment.toLocaleLowerCase(opts.locale)
        : s.segment;
    } else {
      out += s.segment;
    }
  }

  return out;
}

function excludeStopWords(stopWords: readonly string[]): ExcludeWordFilter {
  const words = new Set(stopWords);
  return (s, i, w) => i !== 0 && i !== w.length - 1 && words.has(s.segment);
}

function toExcludeFilter(exclude?: ExcludeWordConfig): ExcludeWordFilter {
  if (exclude == null) return () => false;
  if (typeof exclude === "function") return exclude;
  if (isStrings(exclude)) return excludeStopWords(exclude);

  const filters = exclude
    .map((x) => typeof x === "function" ? x : excludeStopWords(x));

  return (s, i, w) => {
    for (const filter of filters) {
      if (filter(s, i, w)) return true;
    }
    return false;
  };
}

function isStrings(x: readonly unknown[]): x is readonly string[] {
  return typeof x[0] === "string";
}