color-name-list 13.0.0 → 13.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build-and-release.yml +3 -3
- package/.github/workflows/build.yml +1 -1
- package/.husky/pre-commit +1 -1
- package/CONTRIBUTING.md +1 -1
- package/package.json +2 -4
- package/scripts/sortSrc.js +1 -1
- package/src/colornames.csv +1 -1
- package/tests/duplicate-allowlist.json +2 -1
- package/tests/duplicates.test.js +128 -16
- package/tests/title-case.test.js +320 -0
package/.husky/pre-commit
CHANGED
package/CONTRIBUTING.md
CHANGED
|
@@ -27,7 +27,7 @@ interactions with the project.
|
|
|
27
27
|
- No protected brand-names (`Facebook Blue`, `Coca Cola Red`)
|
|
28
28
|
- No enumerations (`Grey 1`, `Grey 2`, `Grey 3`, `Grey 4`)
|
|
29
29
|
- British English spelling (ex. `Grey` not `Gray`), unless its something U.S. typical.
|
|
30
|
-
- Capitalize colors: `Kind of Orange`
|
|
30
|
+
- Capitalize colors: `Kind of Orange` following [APA style](https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case)
|
|
31
31
|
- Prefer common names, especially when adding colors of flora and fauna
|
|
32
32
|
(plants and animals ;) ) ex. `Venus Slipper Orchid` instead of `Paphiopedilum`.
|
|
33
33
|
- Avoid ethnic & racial assumptions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "color-name-list",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.1.0",
|
|
4
4
|
"description": "long list of color names",
|
|
5
5
|
"main": "dist/colornames.json",
|
|
6
6
|
"browser": "dist/colornames.umd.js",
|
|
@@ -24,10 +24,8 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"commit": "git-cz",
|
|
26
26
|
"pull-colors": "curl -L 'https://docs.google.com/spreadsheets/d/e/2PACX-1vQube6Y0wHyEtJnjg0eU3N7VseoxVnD4L9uDqvWZdl_tzzrHDVN10IPP7cdFipX8j70atNMLfPCB0Q6/pub?gid=40578722&single=true&output=csv' -o src/colornames.csv",
|
|
27
|
-
"test": "
|
|
27
|
+
"test": "vitest run",
|
|
28
28
|
"test:watch": "vitest",
|
|
29
|
-
"test:precommit": "npm run build:test",
|
|
30
|
-
"build:test": "node scripts/build.js --testonly",
|
|
31
29
|
"build": "node scripts/build.js && npm run prettier",
|
|
32
30
|
"prettier": "prettier --write ./dist/*",
|
|
33
31
|
"lint": "npm run lint:scripts && npm run lint:markdown",
|
package/scripts/sortSrc.js
CHANGED
|
@@ -45,7 +45,7 @@ const readAndSortCSV = () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
// Combine header & sorted lines (no blank line). Ensure exactly one final newline.
|
|
48
|
-
const sortedData = [header, ...sortedColorLines].join('\n')
|
|
48
|
+
const sortedData = [header, ...sortedColorLines].join('\n');
|
|
49
49
|
|
|
50
50
|
// Write back
|
|
51
51
|
fs.writeFileSync(csvPath, sortedData, 'utf8');
|
package/src/colornames.csv
CHANGED
|
@@ -30117,4 +30117,4 @@ Zumthor,#cdd5d5,
|
|
|
30117
30117
|
Zunda Green,#6bc026,x
|
|
30118
30118
|
Zuni,#008996,
|
|
30119
30119
|
Zürich Blue,#248bcc,
|
|
30120
|
-
Zürich White,#e6e1d9,
|
|
30120
|
+
Zürich White,#e6e1d9,
|
package/tests/duplicates.test.js
CHANGED
|
@@ -11,6 +11,88 @@ describe('Duplicate-like color names', () => {
|
|
|
11
11
|
csvTestData.load();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
// Shared stopwords for normalization across tests
|
|
15
|
+
const STOPWORDS = [
|
|
16
|
+
'of', 'the', 'and', 'a', 'an',
|
|
17
|
+
'in', 'on', 'at', 'to', 'for',
|
|
18
|
+
'by', 'with', 'from', 'as', 'is',
|
|
19
|
+
'it', 'this', 'that', 'these', 'those',
|
|
20
|
+
'be', 'are', 'was', 'were', 'or',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Helper: normalize a phrase similar to scripts/lib normalization but keeping token boundaries
|
|
24
|
+
const normalize = (s) => String(s)
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.normalize('NFD')
|
|
27
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
28
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
29
|
+
.trim();
|
|
30
|
+
|
|
31
|
+
const tokenize = (name) => {
|
|
32
|
+
const base = normalize(name);
|
|
33
|
+
const tokens = base.match(/[a-z0-9]+/g) || [];
|
|
34
|
+
const stopSet = new Set(STOPWORDS.map((w) => normalize(w)));
|
|
35
|
+
return tokens.filter((t) => t && !stopSet.has(t));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Detect two-word names that are exact reversals of each other (after stopword filtering)
|
|
39
|
+
function findTwoWordReversedPairs(items) {
|
|
40
|
+
const groups = new Map(); // key: sorted pair "a|b" -> list of { name, lineNumber, order: "a b" }
|
|
41
|
+
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (!item || typeof item.name !== 'string') continue;
|
|
44
|
+
const tokens = tokenize(item.name);
|
|
45
|
+
if (tokens.length !== 2) continue; // only 2-token (after stopword removal)
|
|
46
|
+
const [a, b] = tokens;
|
|
47
|
+
if (!a || !b) continue;
|
|
48
|
+
if (a === b) continue; // "blue blue" reversed is the same – ignore
|
|
49
|
+
|
|
50
|
+
const key = [a, b].sort().join('|');
|
|
51
|
+
const order = `${a} ${b}`;
|
|
52
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
53
|
+
groups.get(key).push({ name: item.name, lineNumber: item.lineNumber, order });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const conflicts = [];
|
|
57
|
+
for (const [key, entries] of groups.entries()) {
|
|
58
|
+
// We have a potential conflict if we see both orders "a b" and "b a"
|
|
59
|
+
const uniqOrders = [...new Set(entries.map((e) => e.order))];
|
|
60
|
+
if (uniqOrders.length < 2) continue;
|
|
61
|
+
|
|
62
|
+
const [t1, t2] = key.split('|');
|
|
63
|
+
const forward = `${t1} ${t2}`;
|
|
64
|
+
const backward = `${t2} ${t1}`;
|
|
65
|
+
|
|
66
|
+
const hasForward = uniqOrders.includes(forward);
|
|
67
|
+
const hasBackward = uniqOrders.includes(backward);
|
|
68
|
+
|
|
69
|
+
if (hasForward && hasBackward) {
|
|
70
|
+
// Keep entries unique by name@line and sorted by line number for stable output
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const unique = entries
|
|
73
|
+
.filter((e) => {
|
|
74
|
+
const k = `${e.name}@${e.lineNumber}`;
|
|
75
|
+
if (seen.has(k)) return false;
|
|
76
|
+
seen.add(k);
|
|
77
|
+
return true;
|
|
78
|
+
})
|
|
79
|
+
.sort((a, b) => a.lineNumber - b.lineNumber);
|
|
80
|
+
|
|
81
|
+
// Respect allowlist: if either direction string is allowlisted, skip
|
|
82
|
+
const allowSet = new Set(
|
|
83
|
+
(Array.isArray(allowlist) ? allowlist : [])
|
|
84
|
+
.filter((v) => typeof v === 'string' && v.trim().length)
|
|
85
|
+
.map((v) => normalize(v))
|
|
86
|
+
);
|
|
87
|
+
if (allowSet.has(forward) || allowSet.has(backward)) continue;
|
|
88
|
+
|
|
89
|
+
conflicts.push({ key, tokens: [t1, t2], entries: unique });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return conflicts;
|
|
94
|
+
}
|
|
95
|
+
|
|
14
96
|
it('should not contain the same name twice', () => {
|
|
15
97
|
expect(csvTestData.lineCount).toBeGreaterThan(1);
|
|
16
98
|
|
|
@@ -45,13 +127,7 @@ describe('Duplicate-like color names', () => {
|
|
|
45
127
|
foldPlurals: true,
|
|
46
128
|
pluralAllowlist,
|
|
47
129
|
foldStopwords: true,
|
|
48
|
-
stopwords:
|
|
49
|
-
'of', 'the', 'and', 'a', 'an',
|
|
50
|
-
'in', 'on', 'at', 'to', 'for',
|
|
51
|
-
'by', 'with', 'from', 'as', 'is',
|
|
52
|
-
'it', 'this', 'that', 'these', 'those',
|
|
53
|
-
'be', 'are', 'was', 'were', 'or'
|
|
54
|
-
],
|
|
130
|
+
stopwords: STOPWORDS,
|
|
55
131
|
});
|
|
56
132
|
|
|
57
133
|
if (conflicts.length) {
|
|
@@ -146,20 +222,56 @@ describe('Duplicate-like color names', () => {
|
|
|
146
222
|
{ name: 'Heart Gold' },
|
|
147
223
|
{ name: 'Heart of Gold' },
|
|
148
224
|
];
|
|
149
|
-
const stopwords = [
|
|
150
|
-
'of', 'the', 'and', 'a', 'an',
|
|
151
|
-
'in', 'on', 'at', 'to', 'for',
|
|
152
|
-
'by', 'with', 'from', 'as', 'is',
|
|
153
|
-
'it', 'this', 'that', 'these', 'those',
|
|
154
|
-
'be', 'are', 'was', 'were', 'or',
|
|
155
|
-
];
|
|
156
|
-
|
|
157
225
|
const conflicts = findNearDuplicateNameConflicts(items, {
|
|
158
226
|
foldStopwords: true,
|
|
159
|
-
stopwords,
|
|
227
|
+
stopwords: STOPWORDS,
|
|
160
228
|
});
|
|
161
229
|
|
|
162
230
|
expect(conflicts.length).toBe(1);
|
|
163
231
|
expect(conflicts[0].entries.length).toBe(2);
|
|
164
232
|
});
|
|
233
|
+
|
|
234
|
+
it.skip('should not contain two-word names that are exact reversals of each other', () => {
|
|
235
|
+
expect(csvTestData.lineCount).toBeGreaterThan(1);
|
|
236
|
+
|
|
237
|
+
const conflicts = findTwoWordReversedPairs(csvTestData.items);
|
|
238
|
+
|
|
239
|
+
if (conflicts.length) {
|
|
240
|
+
// Build helpful details with line numbers
|
|
241
|
+
const details = [];
|
|
242
|
+
const offenderNames = new Set();
|
|
243
|
+
|
|
244
|
+
conflicts
|
|
245
|
+
.sort((a, b) => a.tokens.join(' ').localeCompare(b.tokens.join(' ')))
|
|
246
|
+
.forEach(({ tokens, entries }) => {
|
|
247
|
+
const [a, b] = tokens;
|
|
248
|
+
details.push(` • ${a} / ${b}:`);
|
|
249
|
+
entries.forEach((e) => {
|
|
250
|
+
offenderNames.add(e.name);
|
|
251
|
+
details.push(` - line ${e.lineNumber}: "${e.name}"`);
|
|
252
|
+
});
|
|
253
|
+
details.push('');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
throw new Error(
|
|
257
|
+
buildFailureMessage({
|
|
258
|
+
title: 'Found {n} word-order reversed {items}:',
|
|
259
|
+
offenders: [...offenderNames],
|
|
260
|
+
offenderLabel: 'name',
|
|
261
|
+
details: [
|
|
262
|
+
...details,
|
|
263
|
+
'Names that only differ by word order (e.g., "Beach Sand" vs "Sand Beach") should be unified.',
|
|
264
|
+
'Please keep a single preferred order and remove the other.',
|
|
265
|
+
],
|
|
266
|
+
tips: [
|
|
267
|
+
'Edit src/colornames.csv and keep only one order for each two-word pair.',
|
|
268
|
+
'After changes, run: npm run sort-colors',
|
|
269
|
+
],
|
|
270
|
+
count: conflicts.length,
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
expect(conflicts.length).toBe(0);
|
|
276
|
+
});
|
|
165
277
|
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { csvTestData } from './csv-test-data.js';
|
|
3
|
+
import { buildFailureMessage } from './_utils/report.js';
|
|
4
|
+
|
|
5
|
+
describe('APA Title Case Validation', () => {
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
// Load CSV data once for all tests
|
|
8
|
+
csvTestData.load();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Determine if a word should be capitalized according to APA title case rules
|
|
13
|
+
* @param {string} word - The word to check
|
|
14
|
+
* @param {boolean} isFirstWordOrAfterPunctuation - True if this is the first word or after punctuation
|
|
15
|
+
* @param {boolean} isAfterPunctuation - True if this word comes after punctuation
|
|
16
|
+
* @param {boolean} isLastWord - True if this is the last word in the title
|
|
17
|
+
* @returns {boolean} - True if the word should be capitalized
|
|
18
|
+
*/
|
|
19
|
+
function shouldBeCapitalized(word, isFirstWordOrAfterPunctuation, isAfterPunctuation, isLastWord = false) {
|
|
20
|
+
// Always capitalize first words, words after punctuation, and last words
|
|
21
|
+
if (isFirstWordOrAfterPunctuation || isAfterPunctuation || isLastWord) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Capitalize words of 4 or more letters
|
|
26
|
+
if (word.length >= 4) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Short words (3 letters or fewer) that should be lowercase (unless first, after punctuation, or last)
|
|
31
|
+
const minorWords = new Set([
|
|
32
|
+
// Short conjunctions
|
|
33
|
+
'and', 'as', 'but', 'for', 'if', 'nor', 'or', 'so', 'yet',
|
|
34
|
+
// Articles
|
|
35
|
+
'a', 'an', 'the',
|
|
36
|
+
// Short prepositions
|
|
37
|
+
'at', 'by', 'in', 'of', 'off', 'on', 'per', 'to', 'up', 'via',
|
|
38
|
+
// for short prepositions
|
|
39
|
+
'de', 'la', 'le', 'les', 'un', 'une', 'du', 'des', 'et', 'ou', 'à', 'au', 'aux'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
return !minorWords.has(word.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert a color name to proper APA title case
|
|
47
|
+
* @param {string} name - The color name to convert
|
|
48
|
+
* @returns {string} - The properly formatted title case name
|
|
49
|
+
*/
|
|
50
|
+
function toTitleCase(name) {
|
|
51
|
+
// Special cases that should maintain their exact capitalization
|
|
52
|
+
const specialCases = new Map([
|
|
53
|
+
// Roman numerals (case-insensitive key, exact case value)
|
|
54
|
+
['ii', 'II'],
|
|
55
|
+
['iii', 'III'],
|
|
56
|
+
['iv', 'IV'],
|
|
57
|
+
['vi', 'VI'],
|
|
58
|
+
['vii', 'VII'],
|
|
59
|
+
['viii', 'VIII'],
|
|
60
|
+
['ix', 'IX'],
|
|
61
|
+
['xi', 'XI'],
|
|
62
|
+
['xii', 'XII'],
|
|
63
|
+
|
|
64
|
+
// Common abbreviations that should stay uppercase
|
|
65
|
+
['ny', 'NY'],
|
|
66
|
+
['nyc', 'NYC'],
|
|
67
|
+
['usa', 'USA'],
|
|
68
|
+
['uk', 'UK'],
|
|
69
|
+
['us', 'US'],
|
|
70
|
+
['bbc', 'BBC'],
|
|
71
|
+
['cnn', 'CNN'],
|
|
72
|
+
['fbi', 'FBI'],
|
|
73
|
+
['cia', 'CIA'],
|
|
74
|
+
['nasa', 'NASA'],
|
|
75
|
+
['nato', 'NATO'],
|
|
76
|
+
['nypd', 'NYPD'],
|
|
77
|
+
['usmc', 'USMC'],
|
|
78
|
+
['usc', 'USC'],
|
|
79
|
+
['ucla', 'UCLA'],
|
|
80
|
+
['ksu', 'KSU'],
|
|
81
|
+
['ku', 'KU'],
|
|
82
|
+
['ou', 'OU'],
|
|
83
|
+
['msu', 'MSU'],
|
|
84
|
+
['ua', 'UA'],
|
|
85
|
+
|
|
86
|
+
// Technical terms
|
|
87
|
+
['cg', 'CG'],
|
|
88
|
+
['cga', 'CGA'],
|
|
89
|
+
['ega', 'EGA'],
|
|
90
|
+
['led', 'LED'],
|
|
91
|
+
['lcd', 'LCD'],
|
|
92
|
+
['crt', 'CRT'],
|
|
93
|
+
['vic', 'VIC'],
|
|
94
|
+
['mvs', 'MVS'],
|
|
95
|
+
['lua', 'LUA'],
|
|
96
|
+
['php', 'PHP'],
|
|
97
|
+
['sql', 'SQL'],
|
|
98
|
+
['ufo', 'UFO'],
|
|
99
|
+
['uv', 'UV'],
|
|
100
|
+
['pcb', 'PCB'],
|
|
101
|
+
['ntsc', 'NTSC'],
|
|
102
|
+
['nes', 'NES'],
|
|
103
|
+
['gmb', 'GMB'],
|
|
104
|
+
['ocd', 'OCD'],
|
|
105
|
+
['bbq', 'BBQ'],
|
|
106
|
+
['ok', 'OK'],
|
|
107
|
+
['ff', 'FF'],
|
|
108
|
+
['po', 'PO'],
|
|
109
|
+
|
|
110
|
+
// Chemical formulas (maintain exact case)
|
|
111
|
+
['co₂', 'CO₂'],
|
|
112
|
+
['h₂o', 'H₂O'],
|
|
113
|
+
['mos₂', 'MoS₂'],
|
|
114
|
+
|
|
115
|
+
// Proper nouns with specific capitalization
|
|
116
|
+
['mckenzie', 'McKenzie'],
|
|
117
|
+
['mcnuke', 'McNuke'],
|
|
118
|
+
['mcquarrie', 'McQuarrie'],
|
|
119
|
+
["o'brien", "O'Brien"],
|
|
120
|
+
["o'neal", "O'Neal"],
|
|
121
|
+
["lechuck's", "LeChuck's"],
|
|
122
|
+
['davanzo', 'DaVanzo'],
|
|
123
|
+
['bioshock', 'BioShock'],
|
|
124
|
+
['microprose', 'MicroProse'],
|
|
125
|
+
['dodgeroll', 'DodgeRoll'],
|
|
126
|
+
['aurometalsaurus', 'AuroMetalSaurus'],
|
|
127
|
+
['yinmn', 'YInMn'],
|
|
128
|
+
['redяum', 'RedЯum'],
|
|
129
|
+
['omgreen', 'OMGreen'],
|
|
130
|
+
|
|
131
|
+
// foreign words and acronyms
|
|
132
|
+
['Vers De Terre', 'Vers de Terre'],
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// Time pattern regex for expressions like 3AM, 12PM, etc.
|
|
136
|
+
const timePattern = /^\d{1,2}[AP]M$/i;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if "LA" in this context likely refers to Los Angeles
|
|
140
|
+
* @param {string} fullName - The complete color name
|
|
141
|
+
* @param {number} wordIndex - Index of the current word
|
|
142
|
+
* @param {string[]} words - Array of all words
|
|
143
|
+
* @returns {boolean} - True if "LA" likely refers to Los Angeles
|
|
144
|
+
*/
|
|
145
|
+
function isLosAngelesContext(fullName, wordIndex, words) {
|
|
146
|
+
const lowerName = fullName.toLowerCase();
|
|
147
|
+
|
|
148
|
+
// Patterns that suggest Los Angeles context
|
|
149
|
+
const losAngelesIndicators = [
|
|
150
|
+
'vibes', 'style', 'sunset', 'beach', 'hollywood', 'california', 'west coast',
|
|
151
|
+
'city', 'metro', 'downtown', 'freeway', 'boulevard'
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
// If the name contains any LA indicators, treat "la" as Los Angeles
|
|
155
|
+
if (losAngelesIndicators.some(indicator => lowerName.includes(indicator))) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// If "La" is followed by a clearly non-French word, it might be Los Angeles
|
|
160
|
+
const nextWord = words[wordIndex + 2]; // +2 to skip whitespace
|
|
161
|
+
if (nextWord) {
|
|
162
|
+
const nonFrenchPatterns = ['vibes', 'style', 'sunset', 'beach'];
|
|
163
|
+
if (nonFrenchPatterns.some(pattern => nextWord.toLowerCase().includes(pattern))) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Split on spaces and handle punctuation
|
|
172
|
+
const words = name.split(/(\s+)/);
|
|
173
|
+
let result = '';
|
|
174
|
+
let isFirstWord = true;
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < words.length; i++) {
|
|
177
|
+
const segment = words[i];
|
|
178
|
+
|
|
179
|
+
// Skip whitespace segments
|
|
180
|
+
if (/^\s+$/.test(segment)) {
|
|
181
|
+
result += segment;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check if previous segment ended with punctuation that requires capitalization
|
|
186
|
+
const isAfterPunctuation = i > 0 && /[:—–-]$/.test(words[i - 2]);
|
|
187
|
+
|
|
188
|
+
// Check for alphanumeric patterns that should stay uppercase (like model numbers)
|
|
189
|
+
// Exclude ordinal numbers (1st, 2nd, 3rd, 18th, etc.)
|
|
190
|
+
const isAlphanumericPattern = /^[0-9]+[A-Z]+$/i.test(segment) &&
|
|
191
|
+
!/^\d+(st|nd|rd|th)$/i.test(segment);
|
|
192
|
+
|
|
193
|
+
// Handle hyphenated words - both parts should follow title case rules
|
|
194
|
+
if (segment.includes('-')) {
|
|
195
|
+
const parts = segment.split('-');
|
|
196
|
+
const capitalizedParts = parts.map((part, partIndex) => {
|
|
197
|
+
if (!part) return part; // Handle cases like "--"
|
|
198
|
+
|
|
199
|
+
// Check if this is a special case
|
|
200
|
+
const lowerPart = part.toLowerCase();
|
|
201
|
+
if (specialCases.has(lowerPart)) {
|
|
202
|
+
return specialCases.get(lowerPart);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check for time patterns
|
|
206
|
+
if (timePattern.test(part)) {
|
|
207
|
+
return part.toUpperCase();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check for alphanumeric patterns (exclude ordinal numbers)
|
|
211
|
+
if (/^[0-9]+[A-Z]+$/i.test(part) && !/^\d+(st|nd|rd|th)$/i.test(part)) {
|
|
212
|
+
return part.toUpperCase();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check for Los Angeles context in hyphenated words
|
|
216
|
+
if (part.toLowerCase() === 'la' && isLosAngelesContext(name, i, words)) {
|
|
217
|
+
return 'LA';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check if this is the last part of the last word in the title
|
|
221
|
+
const isLastPart = partIndex === parts.length - 1;
|
|
222
|
+
const isLastWordInTitle = i === words.length - 1 || (i === words.length - 2 && /^\s+$/.test(words[words.length - 1]));
|
|
223
|
+
const isLastWord = isLastPart && isLastWordInTitle;
|
|
224
|
+
|
|
225
|
+
const shouldCap = shouldBeCapitalized(part, isFirstWord || partIndex > 0, isAfterPunctuation, isLastWord);
|
|
226
|
+
return shouldCap ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() : part.toLowerCase();
|
|
227
|
+
});
|
|
228
|
+
result += capitalizedParts.join('-');
|
|
229
|
+
} else {
|
|
230
|
+
// Check if this is a special case
|
|
231
|
+
const lowerSegment = segment.toLowerCase();
|
|
232
|
+
if (specialCases.has(lowerSegment)) {
|
|
233
|
+
result += specialCases.get(lowerSegment);
|
|
234
|
+
} else if (timePattern.test(segment)) {
|
|
235
|
+
// Handle time expressions like 3AM, 12PM
|
|
236
|
+
result += segment.toUpperCase();
|
|
237
|
+
} else if (isAlphanumericPattern) {
|
|
238
|
+
// Handle alphanumeric patterns like "400XT"
|
|
239
|
+
result += segment.toUpperCase();
|
|
240
|
+
} else if (lowerSegment === 'la' && isLosAngelesContext(name, i, words)) {
|
|
241
|
+
// Handle Los Angeles context - force uppercase
|
|
242
|
+
result += 'LA';
|
|
243
|
+
} else {
|
|
244
|
+
// Regular word - check if this is the last non-whitespace word
|
|
245
|
+
const isLastWordInTitle = i === words.length - 1 || (i === words.length - 2 && /^\s+$/.test(words[words.length - 1]));
|
|
246
|
+
const shouldCap = shouldBeCapitalized(segment, isFirstWord, isAfterPunctuation, isLastWordInTitle);
|
|
247
|
+
result += shouldCap ? segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase() : segment.toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
isFirstWord = false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Check if a color name follows APA title case rules
|
|
259
|
+
* @param {string} name - The color name to validate
|
|
260
|
+
* @returns {boolean} - True if the name follows proper title case
|
|
261
|
+
*/
|
|
262
|
+
function isValidTitleCase(name) {
|
|
263
|
+
const expectedTitleCase = toTitleCase(name);
|
|
264
|
+
return name === expectedTitleCase;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
it.skip('should follow APA title case capitalization rules', () => {
|
|
268
|
+
const invalidNames = [];
|
|
269
|
+
|
|
270
|
+
csvTestData.data.values['name'].forEach((name, index) => {
|
|
271
|
+
if (!isValidTitleCase(name)) {
|
|
272
|
+
const entry = csvTestData.data.entries[index];
|
|
273
|
+
const expectedTitleCase = toTitleCase(name);
|
|
274
|
+
invalidNames.push({
|
|
275
|
+
name,
|
|
276
|
+
expected: expectedTitleCase,
|
|
277
|
+
hex: entry.hex,
|
|
278
|
+
lineNumber: index + 2, // +2 for header and 0-based index
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (invalidNames.length) {
|
|
284
|
+
const details = invalidNames.map(
|
|
285
|
+
({ name, expected, hex, lineNumber }) =>
|
|
286
|
+
` * line ${lineNumber}: "${name}" -> should be "${expected}" (${hex})`
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
throw new Error(
|
|
290
|
+
buildFailureMessage({
|
|
291
|
+
title: 'Found {n} color {items} not following APA title case:',
|
|
292
|
+
offenders: invalidNames.map((i) => `"${i.name}"`),
|
|
293
|
+
offenderLabel: 'name',
|
|
294
|
+
details: [
|
|
295
|
+
...details,
|
|
296
|
+
'',
|
|
297
|
+
'Color names should follow APA Style title case capitalization rules:',
|
|
298
|
+
'• Capitalize the first word',
|
|
299
|
+
'• Capitalize major words (nouns, verbs, adjectives, adverbs, pronouns)',
|
|
300
|
+
'• Capitalize words of 4+ letters',
|
|
301
|
+
'• Capitalize both parts of hyphenated words',
|
|
302
|
+
'• Lowercase minor words (3 letters or fewer): articles (a, an, the), short conjunctions (and, as, but, for, if, nor, or, so, yet), and short prepositions (at, by, in, of, off, on, per, to, up, via)',
|
|
303
|
+
'• Always capitalize words after colons, em dashes, or end punctuation',
|
|
304
|
+
'',
|
|
305
|
+
'Reference: https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case',
|
|
306
|
+
],
|
|
307
|
+
tips: [
|
|
308
|
+
'Edit src/colornames.csv and update the capitalization',
|
|
309
|
+
'Examples: "Red and Blue" → "Red and Blue" (correct)',
|
|
310
|
+
'Examples: "A Shade Of Green" → "A Shade of Green" (of should be lowercase)',
|
|
311
|
+
'Examples: "Self-Report Blue" → "Self-Report Blue" (both parts capitalized)',
|
|
312
|
+
'After changes, run: npm run sort-colors',
|
|
313
|
+
],
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
expect(invalidNames.length).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
});
|