All files / text / unstable_to_title_case.ts

100.00% Branches 32/32
100.00% Functions 4/4
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
x18
 
 
x18
x18
x18
x18
x18
 
x18
x94
x53
x53
x53
x53
x53
x94
x41
x41
x94
 
x18
x18
 
x2
x2
x2
x2
 
x18
x18
x17
x16
 
x1
x1
 
x1
x8
x13
x13
x4
x1
x18
 
x2
x2
x2














































































































// Copyright 2018-2026 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";
}