Skip to content

Commit 0f6e762

Browse files
committed
Add sentenceCase option
1 parent 3d4faa6 commit 0f6e762

File tree

3 files changed

+50
-20
lines changed

3 files changed

+50
-20
lines changed

packages/title-case/README.md

+9
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ titleCase("string"); //=> "String"
1717
titleCase("follow step-by-step instructions"); //=> "Follow Step-by-Step Instructions"
1818
```
1919

20+
### Options
21+
22+
- `locale?: string | string[]`
23+
- `sentenceCase?: boolean` Only capitalize the first word of each sentence (default: `false`)
24+
- `sentenceTerminators?: Set<string>` Set of characters to consider a new sentence under sentence case behavior (e.g. `.`, default: `SENTENCE_TERMINATORS`)
25+
- `smallWords?: Set<string>` Set of words to keep lower-case when `sentenceCase === false` (default: `SMALL_WORDS`)
26+
- `titleTerminators?: Set<string>` Set of characters to consider a new sentence under title case behavior (e.g. `:`, default: `TITLE_TERMINATORS`).
27+
- `wordSeparators?: Set<string>` Set of characters to consider a new word for capitalization, such as hyphenation (default: `WORD_SEPARATORS`).
28+
2029
## TypeScript and ESM
2130

2231
This package is a [pure ESM package](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) and ships with TypeScript definitions. It cannot be `require`'d or used with CommonJS module resolution in TypeScript.

packages/title-case/src/index.spec.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { describe, it, expect } from "vitest";
22
import { inspect } from "util";
3-
import { titleCase } from "./index.js";
3+
import { titleCase, Options } from "./index.js";
44

55
/**
66
* Based on https://github.com/gouch/to-title-case/blob/master/test/tests.json.
77
*/
8-
const TEST_CASES: [string, string][] = [
8+
const TEST_CASES: [string, string, Options?][] = [
99
["", ""],
1010
["2019", "2019"],
1111
["test", "Test"],
@@ -71,18 +71,30 @@ const TEST_CASES: [string, string][] = [
7171
['"a quote." a test.', '"A Quote." A Test.'],
7272
['"The U.N." a quote.', '"The U.N." A Quote.'],
7373
['"The U.N.". a quote.', '"The U.N.". A Quote.'],
74+
['"The U.N.". a quote.', '"The U.N.". A quote.', { sentenceCase: true }],
7475
['"go without"', '"Go Without"'],
7576
["the iPhone: a quote", "The iPhone: A Quote"],
77+
["the iPhone: a quote", "The iPhone: a quote", { sentenceCase: true }],
7678
["the U.N. and me", "The U.N. and Me"],
79+
["the U.N. and me", "The U.N. and me", { sentenceCase: true }],
80+
["the U.N. and me", "The U.N. And Me", { smallWords: new Set() }],
7781
["start-and-end", "Start-and-End"],
7882
["go-to-iPhone", "Go-to-iPhone"],
7983
["Keep #tag", "Keep #tag"],
84+
['"Hello world", says John.', '"Hello World", Says John.'],
85+
[
86+
'"Hello world", says John.',
87+
'"Hello world", says John.',
88+
{ sentenceCase: true },
89+
],
8090
];
8191

8292
describe("swap case", () => {
83-
for (const [input, result] of TEST_CASES) {
84-
it(`${inspect(input)} -> ${inspect(result)}`, () => {
85-
expect(titleCase(input)).toEqual(result);
93+
for (const [input, result, options] of TEST_CASES) {
94+
it(`${inspect(input)} (${
95+
options ? JSON.stringify(options) : "null"
96+
}) -> ${inspect(result)}`, () => {
97+
expect(titleCase(input, options)).toEqual(result);
8698
});
8799
}
88100
});

packages/title-case/src/index.ts

+24-15
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ const IS_ACRONYM = /(?:\p{Lu}\.){2,}$/u;
66

77
export const WORD_SEPARATORS = new Set(["—", "–", "-", "―", "/"]);
88

9-
export const SENTENCE_TERMINATORS = new Set([
10-
".",
11-
"!",
12-
"?",
9+
export const SENTENCE_TERMINATORS = new Set([".", "!", "?"]);
10+
11+
export const TITLE_TERMINATORS = new Set([
12+
...SENTENCE_TERMINATORS,
1313
":",
1414
'"',
1515
"'",
@@ -56,32 +56,36 @@ export const SMALL_WORDS = new Set([
5656
]);
5757

5858
export interface Options {
59-
smallWords?: Set<string>;
59+
locale?: string | string[];
60+
sentenceCase?: boolean;
6061
sentenceTerminators?: Set<string>;
62+
smallWords?: Set<string>;
63+
titleTerminators?: Set<string>;
6164
wordSeparators?: Set<string>;
62-
locale?: string | string[];
6365
}
6466

6567
export function titleCase(
6668
input: string,
6769
options: Options | string[] | string = {},
6870
) {
69-
let result = "";
70-
let m: RegExpExecArray | null;
71-
let isNewSentence = true;
72-
7371
const {
74-
smallWords = SMALL_WORDS,
72+
locale = undefined,
73+
sentenceCase = false,
7574
sentenceTerminators = SENTENCE_TERMINATORS,
75+
titleTerminators = TITLE_TERMINATORS,
76+
smallWords = SMALL_WORDS,
7677
wordSeparators = WORD_SEPARATORS,
77-
locale,
7878
} = typeof options === "string" || Array.isArray(options)
7979
? { locale: options }
8080
: options;
8181

82+
const terminators = sentenceCase ? sentenceTerminators : titleTerminators;
83+
let result = "";
84+
let isNewSentence = true;
85+
8286
// tslint:disable-next-line
83-
while ((m = TOKENS.exec(input)) !== null) {
84-
const { 1: token, 2: whiteSpace, index } = m;
87+
for (const m of input.matchAll(TOKENS)) {
88+
const { 1: token, 2: whiteSpace, index = 0 } = m;
8589

8690
if (whiteSpace) {
8791
result += whiteSpace;
@@ -108,6 +112,11 @@ export function titleCase(
108112
if (isNewSentence) {
109113
isNewSentence = false;
110114
} else {
115+
// Skip capitalizing all words if sentence case is enabled.
116+
if (sentenceCase) {
117+
continue;
118+
}
119+
111120
// Ignore small words except at beginning or end,
112121
// or previous token is a new sentence.
113122
if (
@@ -138,7 +147,7 @@ export function titleCase(
138147
}
139148

140149
const lastChar = token.charAt(token.length - 1);
141-
isNewSentence = sentenceTerminators.has(lastChar);
150+
isNewSentence = terminators.has(lastChar);
142151
}
143152

144153
return result;

0 commit comments

Comments
 (0)