├── .editorconfig
├── .eslintrc.cjs
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .prettierrc
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE.md
├── README.md
├── index.d.ts
├── index.js
├── package-lock.json
├── package.json
└── test.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = tab
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['eslint:recommended', 'prettier', 'plugin:unicorn/recommended'],
4 | plugins: ['unicorn'],
5 | parserOptions: {
6 | sourceType: 'module',
7 | ecmaVersion: 2020
8 | },
9 | env: {
10 | browser: true,
11 | es2017: true,
12 | node: true
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 | branches: [master]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | node-version: [14.x, 16.x, 18.x]
16 | steps:
17 | - uses: actions/checkout@v3
18 | - name: Use Node ${{ matrix.node-version }}
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 | cache: 'npm'
23 | - run: npm ci
24 | - run: npm run build --if-present
25 | - run: npm run lint
26 | - run: npm test
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | coverage/
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "editorconfig.editorconfig",
4 | "dbaeumer.vscode-eslint",
5 | "esbenp.prettier-vscode"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "explorer.fileNesting.enabled": true,
3 | "explorer.fileNesting.patterns": {
4 | "package.json": "package-lock.json"
5 | },
6 | "[javascript]": {
7 | "editor.defaultFormatter": "esbenp.prettier-vscode",
8 | "editor.formatOnSave": true
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) [Brad Dougherty](https://brad.is)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # better-title-case
2 |
3 | > Convert a string to title case based on the [Daring Fireball](https://daringfireball.net/2008/05/title_case) rules.
4 |
5 | ## Rules
6 |
7 | - If the string is all-caps, it will be corrected
8 | - The following words are not capitalized by default: a, an, and, at, but, by, for, in, nor, of, on, or, so, the, to, up, yet, v, v., vs, and vs.
9 | - Words with capital letters other than the first are assumed to be capitalized properly and are skipped
10 | - It also skips any word that looks like a file path, file name, or URL
11 | - The first and last word are always capitalized
12 | - Sub-strings (those that are within quotes or parens/braces) are capitalized according to the same rules
13 |
14 | ## Installation
15 |
16 | ```
17 | $ npm install --save better-title-case
18 | ```
19 |
20 | ## Usage
21 |
22 | ```js
23 | import titleCase from 'better-title-case';
24 | console.log(titleCase('Nothing to Be Afraid of?'));
25 | // Nothing to Be Afraid Of?
26 | ```
27 |
28 | ## Advanced
29 |
30 | You can configure `better-title-case` to add your own excluded words to the default list, or to prevent the use of the default list by passing a `config` object as the second parameter.
31 |
32 | ### excludedWords
33 |
34 | Type: `[string]`
35 | Default: `[]`
36 |
37 | Additional words to exclude from capitalization.
38 |
39 | ```js
40 | titleCase('Nothing to be afraid of?', {
41 | excludedWords: ['be']
42 | });
43 | // 'Nothing to be Afraid Of?'
44 | ```
45 |
46 | ### useDefaultExcludedWords
47 |
48 | Type: `boolean`
49 | Default: `true`
50 |
51 | Disable the usage of the default list of excluded words.
52 |
53 | ```js
54 | titleCase('Nothing to be afraid of?', {
55 | useDefaultExcludedWords: false
56 | });
57 | // 'Nothing To Be Afraid Of?'
58 | ```
59 |
60 | ### preserveWhitespace
61 |
62 | Type: `boolean`
63 | Default: `false`
64 |
65 | Maintain extra whitespace between words. By default, all whitespace between words is collapsed to a single space.
66 |
67 | ```js
68 | titleCase('Nothing to be afraid of?', {
69 | preserveWhitespace: true
70 | });
71 | // 'Nothing To Be Afraid Of?'
72 | ```
73 |
74 | ## License
75 |
76 | MIT © [Brad Dougherty](https://brad.is)
77 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'better-title-case' {
2 | interface TitleCaseOptions {
3 | /**
4 | * Additional words to exclude from capitalization.
5 | * @default []
6 | */
7 | excludedWords?: string[];
8 |
9 | /**
10 | * Disable the usage of the default list of excluded words.
11 | * @default true
12 | */
13 | useDefaultExcludedWords?: boolean;
14 |
15 | /**
16 | * Maintain extra whitespace between words. By default, all whitespace between words is collapsed to a single space.
17 | * @default false
18 | */
19 | preserveWhitespace?: boolean;
20 | }
21 |
22 | /**
23 | * Convert a string to title case based on the Daring Fireball rules.
24 | */
25 | export default function titleCase(string?: string, options?: TitleCaseOptions): string;
26 | }
27 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const alwaysLowercase = [
2 | 'a',
3 | 'an',
4 | 'and',
5 | 'at',
6 | 'but',
7 | 'by',
8 | 'for',
9 | 'in',
10 | 'nor',
11 | 'of',
12 | 'on',
13 | 'or',
14 | 'so',
15 | 'the',
16 | 'to',
17 | 'up',
18 | 'yet',
19 | 'v',
20 | 'v.',
21 | 'vs',
22 | 'vs.'
23 | ];
24 |
25 | const containers = new Set(['(', '[', '{', '"', `'`, '_']);
26 |
27 | const isEmail = /.+@.+\..+/;
28 | const isFilePath = /^(\/[\w.]+)+/;
29 | const isFileName = /^\w+\.\w{1,3}$/;
30 | const hasInternalCapital = /(?![‑–—-])[a-z]+[A-Z].*/;
31 | const hasHyphen = /[‑–—-]/g;
32 |
33 | function isUrl(url) {
34 | try {
35 | const parsed = new URL(url);
36 | return Boolean(parsed.hostname);
37 | } catch {
38 | return false;
39 | }
40 | }
41 |
42 | function capitalize(string) {
43 | if (string.length === 0) {
44 | return string;
45 | }
46 |
47 | const letters = [...string];
48 | const firstLetter = letters.shift();
49 |
50 | if (containers.has(firstLetter)) {
51 | return `${firstLetter}${capitalize(letters)}`;
52 | }
53 |
54 | return `${firstLetter.toUpperCase()}${letters.join('')}`;
55 | }
56 |
57 | export default function titleCase(
58 | string = '',
59 | { excludedWords = [], useDefaultExcludedWords = true, preserveWhitespace = false } = {}
60 | ) {
61 | if (string.toUpperCase() === string) {
62 | string = string.toLowerCase();
63 | }
64 |
65 | if (useDefaultExcludedWords) {
66 | excludedWords.push(...alwaysLowercase);
67 | }
68 |
69 | const words = string.trim().split(/(\s+)/);
70 |
71 | const capitalizedWords = words.map((word, index, array) => {
72 | if (/\s+/.test(word)) {
73 | return preserveWhitespace ? word : ' ';
74 | }
75 |
76 | if (
77 | isEmail.test(word) ||
78 | isUrl(word) ||
79 | isFilePath.test(word) ||
80 | isFileName.test(word) ||
81 | hasInternalCapital.test(word)
82 | ) {
83 | return word;
84 | }
85 |
86 | const hyphenMatch = word.match(hasHyphen);
87 |
88 | if (hyphenMatch) {
89 | const isMultiPart = hyphenMatch.length > 1;
90 | const [hyphenCharacter] = hyphenMatch;
91 |
92 | return word
93 | .split(hyphenCharacter)
94 | .map((subWord) => {
95 | if (isMultiPart && excludedWords.includes(subWord.toLowerCase())) {
96 | return subWord;
97 | }
98 |
99 | return capitalize(subWord);
100 | })
101 | .join(hyphenCharacter);
102 | }
103 |
104 | if (word.includes('/')) {
105 | return word
106 | .split('/')
107 | .map((part) => capitalize(part))
108 | .join('/');
109 | }
110 |
111 | const isFirstWord = index === 0;
112 | const isLastWord = index === words.length - 1;
113 | const previousWord = index > 1 ? array[index - 2] : '';
114 | const startOfSubPhrase = index > 1 && previousWord.endsWith(':');
115 |
116 | if (
117 | !isFirstWord &&
118 | !isLastWord &&
119 | !startOfSubPhrase &&
120 | excludedWords.includes(word.toLowerCase())
121 | ) {
122 | return word.toLowerCase();
123 | }
124 |
125 | return capitalize(word);
126 | });
127 |
128 | return capitalizedWords.join('');
129 | }
130 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "better-title-case",
3 | "version": "2.1.0",
4 | "description": "Convert a string to title case based on the Daring Fireball rules.",
5 | "type": "module",
6 | "main": "index.js",
7 | "files": [
8 | "index.d.ts"
9 | ],
10 | "author": "Brad Dougherty ",
11 | "license": "MIT",
12 | "repository": "github:bdougherty/better-title-case",
13 | "homepage": "https://github.com/bdougherty/better-title-case#readme",
14 | "bugs": "https://github.com/bdougherty/better-title-case/issues",
15 | "engines": {
16 | "node": ">=14.0.0"
17 | },
18 | "scripts": {
19 | "test": "c8 ava",
20 | "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
21 | "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
22 | },
23 | "devDependencies": {
24 | "ava": "4.3.3",
25 | "c8": "7.12.0",
26 | "eslint": "8.24.0",
27 | "eslint-config-prettier": "8.5.0",
28 | "eslint-plugin-unicorn": "44.0.0",
29 | "prettier": "2.7.1"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/test.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import titleCase from './index.js';
3 |
4 | const convert = test.macro({
5 | exec(t, input, expected) {
6 | t.is(titleCase(input), expected);
7 | },
8 | title(_, input) {
9 | return `Properly capitalizes “${input}”`;
10 | }
11 | });
12 |
13 | test(convert, undefined, '');
14 | test(convert, '', '');
15 | test(
16 | convert,
17 | `For step-by-step directions email someone@gmail.com`,
18 | `For Step-by-Step Directions Email someone@gmail.com`
19 | );
20 | test(
21 | convert,
22 | `2lmc Spool: 'Gruber on OmniFocus and Vapo(u)rware'`,
23 | `2lmc Spool: 'Gruber on OmniFocus and Vapo(u)rware'`
24 | );
25 | test(convert, `Have you read “The Lottery”?`, `Have You Read “The Lottery”?`);
26 | test(convert, `your hair[cut] looks (nice)`, `Your Hair[cut] Looks (Nice)`);
27 | test(
28 | convert,
29 | `People probably won't put http://foo.com/bar/ in titles`,
30 | `People Probably Won't Put http://foo.com/bar/ in Titles`
31 | );
32 | test(
33 | convert,
34 | `Scott Moritz and TheStreet.com’s million iPhone la‑la land`,
35 | `Scott Moritz and TheStreet.com’s Million iPhone La‑La Land`
36 | );
37 | test(convert, `BlackBerry vs. iPhone`, `BlackBerry vs. iPhone`);
38 | test(
39 | convert,
40 | `Notes and observations regarding Apple’s announcements from ‘The Beat Goes On’ special event`,
41 | `Notes and Observations Regarding Apple’s Announcements From ‘The Beat Goes On’ Special Event`
42 | );
43 | test(
44 | convert,
45 | `Read markdown_rules.txt to find out how _underscores around words_ will be interpretted`,
46 | `Read markdown_rules.txt to Find Out How _Underscores Around Words_ Will Be Interpretted`
47 | );
48 | test(
49 | convert,
50 | `Q&A with Steve Jobs: 'That's what happens in technology'`,
51 | `Q&A With Steve Jobs: 'That's What Happens in Technology'`
52 | );
53 | test(convert, `What is AT&T's problem?`, `What Is AT&T's Problem?`);
54 | test(convert, `Apple deal with AT&T falls through`, `Apple Deal With AT&T Falls Through`);
55 | test(convert, `this v that`, `This v That`);
56 | test(convert, `this vs that`, `This vs That`);
57 | test(convert, `this v. that`, `This v. That`);
58 | test(convert, `this vs. that`, `This vs. That`);
59 | test(
60 | convert,
61 | `The SEC's Apple probe: what you need to know`,
62 | `The SEC's Apple Probe: What You Need to Know`
63 | );
64 | test(
65 | convert,
66 | `'by the way, small word at the start but within quotes.'`,
67 | `'By the Way, Small Word at the Start but Within Quotes.'`
68 | );
69 | test(
70 | convert,
71 | `Small word at end is nothing to be afraid of`,
72 | `Small Word at End Is Nothing to Be Afraid Of`
73 | );
74 | test(
75 | convert,
76 | `Starting sub-phrase with a small word: a trick, perhaps?`,
77 | `Starting Sub-Phrase With a Small Word: A Trick, Perhaps?`
78 | );
79 | test(
80 | convert,
81 | `Sub-phrase with a small word in quotes: 'a trick, perhaps?'`,
82 | `Sub-Phrase With a Small Word in Quotes: 'A Trick, Perhaps?'`
83 | );
84 | test(
85 | convert,
86 | `Sub-phrase with a small word in quotes: "a trick, perhaps?"`,
87 | `Sub-Phrase With a Small Word in Quotes: "A Trick, Perhaps?"`
88 | );
89 | test(convert, `"Nothing to Be Afraid of?"`, `"Nothing to Be Afraid Of?"`);
90 | test(convert, `a thing`, `A Thing`);
91 | test(
92 | convert,
93 | `Dr. Strangelove (or: how I Learned to Stop Worrying and Love the Bomb)`,
94 | `Dr. Strangelove (Or: How I Learned to Stop Worrying and Love the Bomb)`
95 | );
96 | test(convert, ` this is trimming`, `This Is Trimming`);
97 | test(convert, `this is trimming `, `This Is Trimming`);
98 | test(convert, ` this is trimming `, `This Is Trimming`);
99 | test(convert, `this is removing extra space`, `This Is Removing Extra Space`);
100 | test(convert, `IF IT’S ALL CAPS, FIX IT`, `If It’s All Caps, Fix It`);
101 | test(convert, `___if emphasized, keep that way___`, `___If Emphasized, Keep That Way___`);
102 | test(
103 | convert,
104 | `What could/should be done about slashes?`,
105 | `What Could/Should Be Done About Slashes?`
106 | );
107 | test(
108 | convert,
109 | `Never touch paths like /var/run before/after /boot`,
110 | `Never Touch Paths Like /var/run Before/After /boot`
111 | );
112 |
113 | test('exclusions', (t) => {
114 | t.is(
115 | titleCase('Nothing to Be Afraid of?', {
116 | excludedWords: ['be', 'of']
117 | }),
118 | 'Nothing to be Afraid Of?'
119 | );
120 |
121 | t.is(
122 | titleCase('Nothing to Be Afraid of?', {
123 | excludedWords: ['be', 'of'],
124 | useDefaultExcludedWords: false
125 | }),
126 | 'Nothing To be Afraid Of?'
127 | );
128 | });
129 |
130 | test('preserve whitespace', (t) => {
131 | t.is(
132 | titleCase('this is preserving extra space', {
133 | preserveWhitespace: true
134 | }),
135 | 'This Is Preserving Extra Space'
136 | );
137 | });
138 |
--------------------------------------------------------------------------------