diff --git a/packages/title-case/README.md b/packages/title-case/README.md index 4fc71161..e8e9a6c5 100644 --- a/packages/title-case/README.md +++ b/packages/title-case/README.md @@ -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` Set of characters to consider a new sentence under sentence case behavior (e.g. `.`, default: `SENTENCE_TERMINATORS`) +- `smallWords?: Set` Set of words to keep lower-case when `sentenceCase === false` (default: `SMALL_WORDS`) +- `titleTerminators?: Set` Set of characters to consider a new sentence under title case behavior (e.g. `:`, default: `TITLE_TERMINATORS`). +- `wordSeparators?: Set` 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. diff --git a/packages/title-case/src/index.spec.ts b/packages/title-case/src/index.spec.ts index 366a5aee..463231a0 100644 --- a/packages/title-case/src/index.spec.ts +++ b/packages/title-case/src/index.spec.ts @@ -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"], @@ -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); }); } }); diff --git a/packages/title-case/src/index.ts b/packages/title-case/src/index.ts index c8ec4ca8..69431999 100644 --- a/packages/title-case/src/index.ts +++ b/packages/title-case/src/index.ts @@ -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, ":", '"', "'", @@ -56,32 +56,36 @@ export const SMALL_WORDS = new Set([ ]); export interface Options { - smallWords?: Set; + locale?: string | string[]; + sentenceCase?: boolean; sentenceTerminators?: Set; + smallWords?: Set; + titleTerminators?: Set; wordSeparators?: Set; - 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; @@ -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 ( @@ -138,7 +147,7 @@ export function titleCase( } const lastChar = token.charAt(token.length - 1); - isNewSentence = sentenceTerminators.has(lastChar); + isNewSentence = terminators.has(lastChar); } return result;