Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/_shared/utils/applyApTitleCase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,42 @@ describe('applyApTitleCase', () => {
expect(applyApTitleCase(swc.result)).toEqual(swc.expected);
});
});

it('should handle new AP style stop words correctly', () => {
const testCases = [
{
result: 'The Dog Jumped Up And Over The Fence',
expected: 'The Dog Jumped Up and Over the Fence',
},
{
result: 'Work As A Team',
expected: 'Work as a Team',
},
{
result: 'If You Can Dream It',
expected: 'If You Can Dream It',
},
{
result: 'Turn Off The Lights',
expected: 'Turn off the Lights',
},
{
result: 'Going Out Tonight',
expected: 'Going out Tonight',
},
{
result: 'So What Do You Think',
expected: 'So What Do You Think',
},
{
result: 'Come If You Can So We Can Talk',
expected: 'Come if You Can so We Can Talk',
},
];
testCases.forEach(({ result, expected }) => {
expect(applyApTitleCase(result)).toEqual(expected);
});
});
it('should correctly format titles with curly apostrophes', () => {
const testCases = [
{
Expand All @@ -148,6 +184,42 @@ describe('applyApTitleCase', () => {
expect(applyApTitleCase(result)).toEqual(expected);
});
});

it('should keep iPhone and similar Apple products with lowercase i', () => {
const testCases = [
{
result: 'The New IPhone Is Here',
expected: 'The New iPhone Is Here',
},
{
result: 'IPad Pro Vs IPad Air',
expected: 'iPad Pro vs iPad Air',
},
{
result: 'Using ICloud With Your IPod',
expected: 'Using iCloud With Your iPod',
},
{
result: 'IMac and MacBook Pro Comparison',
expected: 'iMac and MacBook Pro Comparison',
},
{
result: 'ITunes Is Now Apple Music',
expected: 'iTunes Is Now Apple Music',
},
{
result: 'Send IMessage From Your IPhone',
expected: 'Send iMessage From Your iPhone',
},
{
result: 'IBooks: The Complete Guide',
expected: 'iBooks: The Complete Guide',
},
];
testCases.forEach(({ result, expected }) => {
expect(applyApTitleCase(result)).toEqual(expected);
});
});
});

describe('lowercaseAfterApostrophe', () => {
Expand Down
23 changes: 18 additions & 5 deletions src/_shared/utils/applyApTitleCase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const STOP_WORDS =
'a an and at but by for in nor of on or the to up yet';
'a an and as at but by for if in nor of off on or out so the to vs yet';

// Matches a colon (:) and 0+ white spaces following after
// Matches 1+ white spaces
Expand All @@ -11,12 +11,14 @@ export const stop = STOP_WORDS.split(' ');
/**
* Format a string: Match the letter after an apostrophe & capture the apostrophe and matched char.
* Lowercase the captured letter & return the formatted string.
* Exception: O' prefix (like O'Hearn) should have the letter after apostrophe capitalized.
* @param input
* @returns {string}
*/
export const lowercaseAfterApostrophe = (input: string): string => {
// Match either an ASCII or curly apostrophe followed by a letter, after a word character.
const regex = /(?<=\w)(['\u2018\u2019])(\w)/g;
// Negative lookbehind to exclude O' prefix
const regex = /(?<!^O)(?<![\s]O)(?<=\w)(['\u2018\u2019])(\w)/g;
return input.replace(
regex,
(_, apostrophe, letter) => `${apostrophe}${letter.toLowerCase()}`,
Expand Down Expand Up @@ -63,8 +65,8 @@ export const applyApTitleCase = (value: string): string => {
index > 0 &&
(allWords[index - 1] === "'" ||
allWords[index - 1] === '"' ||
allWords[index - 1] === '\u2018' || // Opening single quote
allWords[index - 1] === '\u201C'); // Opening double quote
allWords[index - 1] === '\u2018' || // Opening single quote '
allWords[index - 1] === '\u201C'); // Opening double quote "

if (
index === 0 || // first word
Expand All @@ -79,5 +81,16 @@ export const applyApTitleCase = (value: string): string => {
return word.toLowerCase();
})
.join(''); // join without additional spaces
return lowercaseAfterApostrophe(result);

// Apply special formatting rules
let formattedResult = lowercaseAfterApostrophe(result);

// Handle special cases like iPhone, iPad, iPod, etc.
// This regex looks for word boundaries followed by capital I and then Phone/Pad/Pod/etc.
formattedResult = formattedResult.replace(
/\bI(Phone|Pad|Pod|Mac|Cloud|Tunes|Books|Message)/g,
'i$1',
);

return formattedResult;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { render, screen } from '@testing-library/react';
import { SnackbarProvider } from 'notistack';
import { RemoveSectionItemAction } from './RemoveSectionItemAction';
import { getTestApprovedItem } from '../../../helpers/approvedItem';
import {
successMock,
} from '../../../integration-test-mocks/removeSectionItem';
import { successMock } from '../../../integration-test-mocks/removeSectionItem';
import userEvent from '@testing-library/user-event';
import { apolloCache } from '../../../../api/client';
import {
Expand Down
79 changes: 79 additions & 0 deletions temp/applyApTitleCase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.applyApTitleCase = exports.lowercaseAfterApostrophe = exports.stop = exports.SEPARATORS = exports.STOP_WORDS = void 0;
exports.STOP_WORDS = 'a an and as at but by for if in nor of off on or out so the to yet';
// Matches a colon (:) and 0+ white spaces following after
// Matches 1+ white spaces
// Matches special chars (i.e. hyphens, quotes, etc)
exports.SEPARATORS = /(:\s*|\s+|[-‑–—,:;!?()“”'‘"])/; // Include curly quotes as separators
exports.stop = exports.STOP_WORDS.split(' ');
/**
* Format a string: Match the letter after an apostrophe & capture the apostrophe and matched char.
* Lowercase the captured letter & return the formatted string.
* Exception: O' prefix (like O'Hearn) should have the letter after apostrophe capitalized.
* @param input
* @returns {string}
*/
const lowercaseAfterApostrophe = (input) => {
// Match either an ASCII or curly apostrophe followed by a letter, after a word character.
// Negative lookbehind to exclude O' prefix
const regex = /(?<!^O)(?<![\s]O)(?<=\w)(['\u2018\u2019])(\w)/g;
return input.replace(regex, (_, apostrophe, letter) => `${apostrophe}${letter.toLowerCase()}`);
};
exports.lowercaseAfterApostrophe = lowercaseAfterApostrophe;
/**
* Capitalize first character for string
*
* @param {string} value
* @returns {string}
*/
const capitalize = (value) => {
if (!value) {
return '';
}
return value.charAt(0).toUpperCase() + value.slice(1);
};
/**
* Helper to convert text to AP title case
* adapted from https://github.com/words/ap-style-title-case
* text should match https://headlinecapitalization.com/
*
* @param {string} [value]
* @returns {string}
*/
const applyApTitleCase = (value) => {
if (!value) {
return '';
}
// Split and filter empty strings
// Boolean here acts as a callback, evaluates each word:
// If it's a non-empty string, keep the word in the array;
// If it's an empty string (or falsy), remove from array.
const allWords = value.split(exports.SEPARATORS).filter(Boolean); // Split and filter empty strings
const result = allWords
.map((word, index, all) => {
const isAfterColon = index > 0 && all[index - 1].trim() === ':';
const isAfterQuote = index > 0 &&
(allWords[index - 1] === "'" ||
allWords[index - 1] === '"' ||
allWords[index - 1] === '\u2018' || // Opening single quote '
allWords[index - 1] === '\u201C'); // Opening double quote "
if (index === 0 || // first word
index === all.length - 1 || // last word
isAfterColon || // capitalize the first word after a colon
isAfterQuote || // capitalize the first word after a quote
!exports.stop.includes(word.toLowerCase()) // not a stop word
) {
return capitalize(word);
}
return word.toLowerCase();
})
.join(''); // join without additional spaces
// Apply special formatting rules
let formattedResult = (0, exports.lowercaseAfterApostrophe)(result);
// Handle special cases like iPhone, iPad, iPod, etc.
// This regex looks for word boundaries followed by capital I and then Phone/Pad/Pod/etc.
formattedResult = formattedResult.replace(/\bI(Phone|Pad|Pod|Mac|Cloud|Tunes|Books|Message)/g, 'i$1');
return formattedResult;
};
exports.applyApTitleCase = applyApTitleCase;