color-name-list 13.0.0 → 13.2.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.
Files changed (47) hide show
  1. package/.github/workflows/build-and-release.yml +3 -3
  2. package/.github/workflows/build.yml +1 -1
  3. package/.husky/pre-commit +1 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +48 -2
  6. package/changes.svg +3 -3
  7. package/dist/colornames.bestof.csv +6 -2
  8. package/dist/colornames.bestof.esm.js +1 -1
  9. package/dist/colornames.bestof.esm.mjs +1 -1
  10. package/dist/colornames.bestof.html +1 -1
  11. package/dist/colornames.bestof.json +1 -1
  12. package/dist/colornames.bestof.min.json +1 -1
  13. package/dist/colornames.bestof.scss +1 -1
  14. package/dist/colornames.bestof.umd.js +1 -1
  15. package/dist/colornames.bestof.xml +18 -2
  16. package/dist/colornames.bestof.yaml +14 -2
  17. package/dist/colornames.csv +9 -6
  18. package/dist/colornames.esm.js +1 -1
  19. package/dist/colornames.esm.mjs +1 -1
  20. package/dist/colornames.html +1 -1
  21. package/dist/colornames.json +1 -1
  22. package/dist/colornames.min.json +1 -1
  23. package/dist/colornames.scss +1 -1
  24. package/dist/colornames.short.csv +4 -2
  25. package/dist/colornames.short.esm.js +1 -1
  26. package/dist/colornames.short.esm.mjs +1 -1
  27. package/dist/colornames.short.html +1 -1
  28. package/dist/colornames.short.json +1 -1
  29. package/dist/colornames.short.min.json +1 -1
  30. package/dist/colornames.short.scss +1 -1
  31. package/dist/colornames.short.umd.js +1 -1
  32. package/dist/colornames.short.xml +10 -2
  33. package/dist/colornames.short.yaml +8 -2
  34. package/dist/colornames.umd.js +1 -1
  35. package/dist/colornames.xml +27 -15
  36. package/dist/colornames.yaml +21 -12
  37. package/dist/history.json +1 -1
  38. package/package.json +2 -4
  39. package/scripts/lib.js +2 -1
  40. package/scripts/sortSrc.js +1 -1
  41. package/src/colornames.csv +10 -7
  42. package/tests/duplicate-allowlist.json +2 -1
  43. package/tests/duplicates.test.js +152 -21
  44. package/tests/title-case.test.js +384 -0
  45. package/vitest.config.js +5 -0
  46. /package/tests/{formats.test.js → formats.ci.test.js} +0 -0
  47. /package/tests/{imports.test.js → imports.ci.test.js} +0 -0
@@ -0,0 +1,384 @@
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(
20
+ word,
21
+ isFirstWordOrAfterPunctuation,
22
+ isAfterPunctuation,
23
+ isLastWord = false
24
+ ) {
25
+ // Always capitalize first words, words after punctuation, and last words
26
+ if (isFirstWordOrAfterPunctuation || isAfterPunctuation || isLastWord) {
27
+ return true;
28
+ }
29
+
30
+ // Capitalize words of 4 or more letters
31
+ if (word.length >= 4) {
32
+ return true;
33
+ }
34
+
35
+ // Short words (3 letters or fewer) that should be lowercase (unless first, after punctuation, or last)
36
+ const minorWords = new Set([
37
+ // Short conjunctions
38
+ 'and',
39
+ 'as',
40
+ 'but',
41
+ 'for',
42
+ 'if',
43
+ 'nor',
44
+ 'or',
45
+ 'so',
46
+ 'yet',
47
+ // Articles
48
+ 'a',
49
+ 'an',
50
+ 'the',
51
+ // Short prepositions
52
+ 'at',
53
+ 'by',
54
+ 'in',
55
+ 'of',
56
+ 'off',
57
+ 'on',
58
+ 'per',
59
+ 'to',
60
+ 'up',
61
+ 'via',
62
+ // for short prepositions
63
+ 'de',
64
+ 'la',
65
+ 'le',
66
+ 'les',
67
+ 'un',
68
+ 'une',
69
+ 'du',
70
+ 'des',
71
+ 'et',
72
+ 'ou',
73
+ 'à',
74
+ 'au',
75
+ 'aux',
76
+ ]);
77
+
78
+ return !minorWords.has(word.toLowerCase());
79
+ }
80
+
81
+ /**
82
+ * Convert a color name to proper APA title case
83
+ * @param {string} name - The color name to convert
84
+ * @returns {string} - The properly formatted title case name
85
+ */
86
+ function toTitleCase(name) {
87
+ // Special cases that should maintain their exact capitalization
88
+ const specialCases = new Map([
89
+ // Roman numerals (case-insensitive key, exact case value)
90
+ ['ii', 'II'],
91
+ ['iii', 'III'],
92
+ ['iv', 'IV'],
93
+ ['vi', 'VI'],
94
+ ['vii', 'VII'],
95
+ ['viii', 'VIII'],
96
+ ['ix', 'IX'],
97
+ ['xi', 'XI'],
98
+ ['xii', 'XII'],
99
+
100
+ // Common abbreviations that should stay uppercase
101
+ ['ny', 'NY'],
102
+ ['nyc', 'NYC'],
103
+ ['usa', 'USA'],
104
+ ['uk', 'UK'],
105
+ ['us', 'US'],
106
+ ['bbc', 'BBC'],
107
+ ['cnn', 'CNN'],
108
+ ['fbi', 'FBI'],
109
+ ['cia', 'CIA'],
110
+ ['nasa', 'NASA'],
111
+ ['nato', 'NATO'],
112
+ ['nypd', 'NYPD'],
113
+ ['usmc', 'USMC'],
114
+ ['usc', 'USC'],
115
+ ['ucla', 'UCLA'],
116
+ ['ksu', 'KSU'],
117
+ ['ku', 'KU'],
118
+ ['ou', 'OU'],
119
+ ['msu', 'MSU'],
120
+ ['ua', 'UA'],
121
+
122
+ // Technical terms
123
+ ['cg', 'CG'],
124
+ ['cga', 'CGA'],
125
+ ['ega', 'EGA'],
126
+ ['led', 'LED'],
127
+ ['lcd', 'LCD'],
128
+ ['crt', 'CRT'],
129
+ ['vic', 'VIC'],
130
+ ['mvs', 'MVS'],
131
+ ['lua', 'LUA'],
132
+ ['php', 'PHP'],
133
+ ['sql', 'SQL'],
134
+ ['ufo', 'UFO'],
135
+ ['uv', 'UV'],
136
+ ['pcb', 'PCB'],
137
+ ['ntsc', 'NTSC'],
138
+ ['nes', 'NES'],
139
+ ['gmb', 'GMB'],
140
+ ['ocd', 'OCD'],
141
+ ['bbq', 'BBQ'],
142
+ ['ok', 'OK'],
143
+ ['ff', 'FF'],
144
+ ['po', 'PO'],
145
+
146
+ // Chemical formulas (maintain exact case)
147
+ ['co₂', 'CO₂'],
148
+ ['h₂o', 'H₂O'],
149
+ ['mos₂', 'MoS₂'],
150
+
151
+ // Proper nouns with specific capitalization
152
+ ['mckenzie', 'McKenzie'],
153
+ ['mcnuke', 'McNuke'],
154
+ ['mcquarrie', 'McQuarrie'],
155
+ ["o'brien", "O'Brien"],
156
+ ["o'neal", "O'Neal"],
157
+ ["lechuck's", "LeChuck's"],
158
+ ['davanzo', 'DaVanzo'],
159
+ ['bioshock', 'BioShock'],
160
+ ['microprose', 'MicroProse'],
161
+ ['dodgeroll', 'DodgeRoll'],
162
+ ['aurometalsaurus', 'AuroMetalSaurus'],
163
+ ['yinmn', 'YInMn'],
164
+ ['redяum', 'RedЯum'],
165
+ ['omgreen', 'OMGreen'],
166
+
167
+ // foreign words and acronyms
168
+ ['Vers De Terre', 'Vers de Terre'],
169
+ ]);
170
+
171
+ // Time pattern regex for expressions like 3AM, 12PM, etc.
172
+ const timePattern = /^\d{1,2}[AP]M$/i;
173
+
174
+ /**
175
+ * Check if "LA" in this context likely refers to Los Angeles
176
+ * @param {string} fullName - The complete color name
177
+ * @param {number} wordIndex - Index of the current word
178
+ * @param {string[]} words - Array of all words
179
+ * @returns {boolean} - True if "LA" likely refers to Los Angeles
180
+ */
181
+ function isLosAngelesContext(fullName, wordIndex, words) {
182
+ const lowerName = fullName.toLowerCase();
183
+
184
+ // Patterns that suggest Los Angeles context
185
+ const losAngelesIndicators = [
186
+ 'vibes',
187
+ 'style',
188
+ 'sunset',
189
+ 'beach',
190
+ 'hollywood',
191
+ 'california',
192
+ 'west coast',
193
+ 'city',
194
+ 'metro',
195
+ 'downtown',
196
+ 'freeway',
197
+ 'boulevard',
198
+ ];
199
+
200
+ // If the name contains any LA indicators, treat "la" as Los Angeles
201
+ if (losAngelesIndicators.some((indicator) => lowerName.includes(indicator))) {
202
+ return true;
203
+ }
204
+
205
+ // If "La" is followed by a clearly non-French word, it might be Los Angeles
206
+ const nextWord = words[wordIndex + 2]; // +2 to skip whitespace
207
+ if (nextWord) {
208
+ const nonFrenchPatterns = ['vibes', 'style', 'sunset', 'beach'];
209
+ if (nonFrenchPatterns.some((pattern) => nextWord.toLowerCase().includes(pattern))) {
210
+ return true;
211
+ }
212
+ }
213
+
214
+ return false;
215
+ }
216
+
217
+ // Split on spaces and handle punctuation
218
+ const words = name.split(/(\s+)/);
219
+ let result = '';
220
+ let isFirstWord = true;
221
+
222
+ for (let i = 0; i < words.length; i++) {
223
+ const segment = words[i];
224
+
225
+ // Skip whitespace segments
226
+ if (/^\s+$/.test(segment)) {
227
+ result += segment;
228
+ continue;
229
+ }
230
+
231
+ // Check if previous segment ended with punctuation that requires capitalization
232
+ const isAfterPunctuation = i > 0 && /[:—–-]$/.test(words[i - 2]);
233
+
234
+ // Check for alphanumeric patterns that should stay uppercase (like model numbers)
235
+ // Exclude ordinal numbers (1st, 2nd, 3rd, 18th, etc.)
236
+ const isAlphanumericPattern =
237
+ /^[0-9]+[A-Z]+$/i.test(segment) && !/^\d+(st|nd|rd|th)$/i.test(segment);
238
+
239
+ // Handle hyphenated words - both parts should follow title case rules
240
+ if (segment.includes('-')) {
241
+ const parts = segment.split('-');
242
+ const capitalizedParts = parts.map((part, partIndex) => {
243
+ if (!part) return part; // Handle cases like "--"
244
+
245
+ // Check if this is a special case
246
+ const lowerPart = part.toLowerCase();
247
+ if (specialCases.has(lowerPart)) {
248
+ return specialCases.get(lowerPart);
249
+ }
250
+
251
+ // Check for time patterns
252
+ if (timePattern.test(part)) {
253
+ return part.toUpperCase();
254
+ }
255
+
256
+ // Check for alphanumeric patterns (exclude ordinal numbers)
257
+ if (/^[0-9]+[A-Z]+$/i.test(part) && !/^\d+(st|nd|rd|th)$/i.test(part)) {
258
+ return part.toUpperCase();
259
+ }
260
+
261
+ // Check for Los Angeles context in hyphenated words
262
+ if (part.toLowerCase() === 'la' && isLosAngelesContext(name, i, words)) {
263
+ return 'LA';
264
+ }
265
+
266
+ // Check if this is the last part of the last word in the title
267
+ const isLastPart = partIndex === parts.length - 1;
268
+ const isLastWordInTitle =
269
+ i === words.length - 1 ||
270
+ (i === words.length - 2 && /^\s+$/.test(words[words.length - 1]));
271
+ const isLastWord = isLastPart && isLastWordInTitle;
272
+
273
+ const shouldCap = shouldBeCapitalized(
274
+ part,
275
+ isFirstWord || partIndex > 0,
276
+ isAfterPunctuation,
277
+ isLastWord
278
+ );
279
+ return shouldCap
280
+ ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
281
+ : part.toLowerCase();
282
+ });
283
+ result += capitalizedParts.join('-');
284
+ } else {
285
+ // Check if this is a special case
286
+ const lowerSegment = segment.toLowerCase();
287
+ if (specialCases.has(lowerSegment)) {
288
+ result += specialCases.get(lowerSegment);
289
+ } else if (timePattern.test(segment)) {
290
+ // Handle time expressions like 3AM, 12PM
291
+ result += segment.toUpperCase();
292
+ } else if (isAlphanumericPattern) {
293
+ // Handle alphanumeric patterns like "400XT"
294
+ result += segment.toUpperCase();
295
+ } else if (lowerSegment === 'la' && isLosAngelesContext(name, i, words)) {
296
+ // Handle Los Angeles context - force uppercase
297
+ result += 'LA';
298
+ } else {
299
+ // Regular word - check if this is the last non-whitespace word
300
+ const isLastWordInTitle =
301
+ i === words.length - 1 ||
302
+ (i === words.length - 2 && /^\s+$/.test(words[words.length - 1]));
303
+ const shouldCap = shouldBeCapitalized(
304
+ segment,
305
+ isFirstWord,
306
+ isAfterPunctuation,
307
+ isLastWordInTitle
308
+ );
309
+ result += shouldCap
310
+ ? segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()
311
+ : segment.toLowerCase();
312
+ }
313
+ }
314
+
315
+ isFirstWord = false;
316
+ }
317
+
318
+ return result;
319
+ }
320
+
321
+ /**
322
+ * Check if a color name follows APA title case rules
323
+ * @param {string} name - The color name to validate
324
+ * @returns {boolean} - True if the name follows proper title case
325
+ */
326
+ function isValidTitleCase(name) {
327
+ const expectedTitleCase = toTitleCase(name);
328
+ return name === expectedTitleCase;
329
+ }
330
+
331
+ it.skip('should follow APA title case capitalization rules', () => {
332
+ const invalidNames = [];
333
+
334
+ csvTestData.data.values['name'].forEach((name, index) => {
335
+ if (!isValidTitleCase(name)) {
336
+ const entry = csvTestData.data.entries[index];
337
+ const expectedTitleCase = toTitleCase(name);
338
+ invalidNames.push({
339
+ name,
340
+ expected: expectedTitleCase,
341
+ hex: entry.hex,
342
+ lineNumber: index + 2, // +2 for header and 0-based index
343
+ });
344
+ }
345
+ });
346
+
347
+ if (invalidNames.length) {
348
+ const details = invalidNames.map(
349
+ ({ name, expected, hex, lineNumber }) =>
350
+ ` * line ${lineNumber}: "${name}" -> should be "${expected}" (${hex})`
351
+ );
352
+
353
+ throw new Error(
354
+ buildFailureMessage({
355
+ title: 'Found {n} color {items} not following APA title case:',
356
+ offenders: invalidNames.map((i) => `"${i.name}"`),
357
+ offenderLabel: 'name',
358
+ details: [
359
+ ...details,
360
+ '',
361
+ 'Color names should follow APA Style title case capitalization rules:',
362
+ '• Capitalize the first word',
363
+ '• Capitalize major words (nouns, verbs, adjectives, adverbs, pronouns)',
364
+ '• Capitalize words of 4+ letters',
365
+ '• Capitalize both parts of hyphenated words',
366
+ '• 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)',
367
+ '• Always capitalize words after colons, em dashes, or end punctuation',
368
+ '',
369
+ 'Reference: https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case',
370
+ ],
371
+ tips: [
372
+ 'Edit src/colornames.csv and update the capitalization',
373
+ 'Examples: "Red and Blue" → "Red and Blue" (correct)',
374
+ 'Examples: "A Shade Of Green" → "A Shade of Green" (of should be lowercase)',
375
+ 'Examples: "Self-Report Blue" → "Self-Report Blue" (both parts capitalized)',
376
+ 'After changes, run: npm run sort-colors',
377
+ ],
378
+ })
379
+ );
380
+ }
381
+
382
+ expect(invalidNames.length).toBe(0);
383
+ });
384
+ });
package/vitest.config.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { defineConfig } from 'vitest/config';
2
2
 
3
+ // Only run heavy/dist-dependent tests in CI
4
+ const isCI = !!process.env.CI;
5
+
3
6
  export default defineConfig({
4
7
  test: {
5
8
  include: ['tests/**/*.test.js'],
9
+ // Exclude CI-only tests locally. GitHub Actions sets CI=true, so these will run in CI.
10
+ exclude: isCI ? [] : ['tests/*.ci.test.js'],
6
11
  environment: 'node',
7
12
  },
8
13
  });
File without changes
File without changes