Skip to content

Commit

Permalink
Add sentenceCase option
Browse files Browse the repository at this point in the history
  • Loading branch information
blakeembrey committed Dec 15, 2023
1 parent 3d4faa6 commit 0f6e762
Show file tree
Hide file tree
Showing 3 changed files with 50 additions and 20 deletions.
9 changes: 9 additions & 0 deletions packages/title-case/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ titleCase("string"); //=> "String"
titleCase("follow step-by-step instructions"); //=> "Follow Step-by-Step Instructions"
```

### Options

- `locale?: string | string[]`
- `sentenceCase?: boolean` Only capitalize the first word of each sentence (default: `false`)
- `sentenceTerminators?: Set<string>` Set of characters to consider a new sentence under sentence case behavior (e.g. `.`, default: `SENTENCE_TERMINATORS`)
- `smallWords?: Set<string>` Set of words to keep lower-case when `sentenceCase === false` (default: `SMALL_WORDS`)
- `titleTerminators?: Set<string>` Set of characters to consider a new sentence under title case behavior (e.g. `:`, default: `TITLE_TERMINATORS`).
- `wordSeparators?: Set<string>` Set of characters to consider a new word for capitalization, such as hyphenation (default: `WORD_SEPARATORS`).

## TypeScript and ESM

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.
Expand Down
22 changes: 17 additions & 5 deletions packages/title-case/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest";
import { inspect } from "util";
import { titleCase } from "./index.js";
import { titleCase, Options } from "./index.js";

/**
* Based on https://github.com/gouch/to-title-case/blob/master/test/tests.json.
*/
const TEST_CASES: [string, string][] = [
const TEST_CASES: [string, string, Options?][] = [
["", ""],
["2019", "2019"],
["test", "Test"],
Expand Down Expand Up @@ -71,18 +71,30 @@ const TEST_CASES: [string, string][] = [
['"a quote." a test.', '"A Quote." A Test.'],
['"The U.N." a quote.', '"The U.N." A Quote.'],
['"The U.N.". a quote.', '"The U.N.". A Quote.'],
['"The U.N.". a quote.', '"The U.N.". A quote.', { sentenceCase: true }],
['"go without"', '"Go Without"'],
["the iPhone: a quote", "The iPhone: A Quote"],
["the iPhone: a quote", "The iPhone: a quote", { sentenceCase: true }],
["the U.N. and me", "The U.N. and Me"],
["the U.N. and me", "The U.N. and me", { sentenceCase: true }],
["the U.N. and me", "The U.N. And Me", { smallWords: new Set() }],
["start-and-end", "Start-and-End"],
["go-to-iPhone", "Go-to-iPhone"],
["Keep #tag", "Keep #tag"],
['"Hello world", says John.', '"Hello World", Says John.'],
[
'"Hello world", says John.',
'"Hello world", says John.',
{ sentenceCase: true },
],
];

describe("swap case", () => {
for (const [input, result] of TEST_CASES) {
it(`${inspect(input)} -> ${inspect(result)}`, () => {
expect(titleCase(input)).toEqual(result);
for (const [input, result, options] of TEST_CASES) {
it(`${inspect(input)} (${
options ? JSON.stringify(options) : "null"
}) -> ${inspect(result)}`, () => {
expect(titleCase(input, options)).toEqual(result);
});
}
});
39 changes: 24 additions & 15 deletions packages/title-case/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ const IS_ACRONYM = /(?:\p{Lu}\.){2,}$/u;

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

export const SENTENCE_TERMINATORS = new Set([
".",
"!",
"?",
export const SENTENCE_TERMINATORS = new Set([".", "!", "?"]);

export const TITLE_TERMINATORS = new Set([
...SENTENCE_TERMINATORS,
":",
'"',
"'",
Expand Down Expand Up @@ -56,32 +56,36 @@ export const SMALL_WORDS = new Set([
]);

export interface Options {
smallWords?: Set<string>;
locale?: string | string[];
sentenceCase?: boolean;
sentenceTerminators?: Set<string>;
smallWords?: Set<string>;
titleTerminators?: Set<string>;
wordSeparators?: Set<string>;
locale?: string | string[];
}

export function titleCase(
input: string,
options: Options | string[] | string = {},
) {
let result = "";
let m: RegExpExecArray | null;
let isNewSentence = true;

const {
smallWords = SMALL_WORDS,
locale = undefined,
sentenceCase = false,
sentenceTerminators = SENTENCE_TERMINATORS,
titleTerminators = TITLE_TERMINATORS,
smallWords = SMALL_WORDS,
wordSeparators = WORD_SEPARATORS,
locale,
} = typeof options === "string" || Array.isArray(options)
? { locale: options }
: options;

const terminators = sentenceCase ? sentenceTerminators : titleTerminators;
let result = "";
let isNewSentence = true;

// tslint:disable-next-line
while ((m = TOKENS.exec(input)) !== null) {
const { 1: token, 2: whiteSpace, index } = m;
for (const m of input.matchAll(TOKENS)) {
const { 1: token, 2: whiteSpace, index = 0 } = m;

if (whiteSpace) {
result += whiteSpace;
Expand All @@ -108,6 +112,11 @@ export function titleCase(
if (isNewSentence) {
isNewSentence = false;
} else {
// Skip capitalizing all words if sentence case is enabled.
if (sentenceCase) {
continue;
}

// Ignore small words except at beginning or end,
// or previous token is a new sentence.
if (
Expand Down Expand Up @@ -138,7 +147,7 @@ export function titleCase(
}

const lastChar = token.charAt(token.length - 1);
isNewSentence = sentenceTerminators.has(lastChar);
isNewSentence = terminators.has(lastChar);
}

return result;
Expand Down

0 comments on commit 0f6e762

Please sign in to comment.