purgetss 7.5.3 → 7.6.1

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/README.md +38 -17
  2. package/bin/purgetss +140 -1
  3. package/dist/purgetss.ui.js +23 -26
  4. package/dist/utilities.tss +13 -1
  5. package/lib/completions/titanium/completions-v3.json +62 -1
  6. package/lib/templates/purgetss.config.js.cjs +15 -1
  7. package/lib/templates/purgetss.ui.js.cjs +22 -25
  8. package/package.json +3 -1
  9. package/src/cli/commands/brand.js +69 -0
  10. package/src/cli/commands/create.js +11 -7
  11. package/src/cli/commands/fonts.js +9 -9
  12. package/src/cli/commands/icon-library.js +18 -16
  13. package/src/cli/commands/images.js +116 -0
  14. package/src/cli/commands/init.js +4 -0
  15. package/src/cli/commands/module.js +4 -2
  16. package/src/cli/commands/purge.js +48 -98
  17. package/src/cli/commands/semantic.js +180 -0
  18. package/src/cli/commands/shades.js +332 -13
  19. package/src/cli/utils/project-detection.js +4 -2
  20. package/src/core/analyzers/class-extractor.js +110 -3
  21. package/src/core/branding/brand-config.js +111 -0
  22. package/src/core/branding/branding-logger.js +40 -0
  23. package/src/core/branding/cleanup-legacy.js +220 -0
  24. package/src/core/branding/ensure-brand-section.js +80 -0
  25. package/src/core/branding/gen-android-adaptive.js +116 -0
  26. package/src/core/branding/gen-android-legacy.js +63 -0
  27. package/src/core/branding/gen-ic-launcher-xml.js +29 -0
  28. package/src/core/branding/gen-ios-dark.js +70 -0
  29. package/src/core/branding/gen-ios-tinted.js +55 -0
  30. package/src/core/branding/gen-ios.js +69 -0
  31. package/src/core/branding/gen-marketplace.js +71 -0
  32. package/src/core/branding/gen-notification.js +76 -0
  33. package/src/core/branding/gen-splash.js +64 -0
  34. package/src/core/branding/index.js +336 -0
  35. package/src/core/branding/post-gen-notes.js +145 -0
  36. package/src/core/branding/prepare-master.js +108 -0
  37. package/src/core/branding/tiapp-reader.js +110 -0
  38. package/src/core/images/ensure-images-section.js +57 -0
  39. package/src/core/images/gen-scales.js +181 -0
  40. package/src/core/images/index.js +171 -0
  41. package/src/shared/config-manager.js +46 -0
  42. package/src/shared/config-writer.js +84 -0
  43. package/src/shared/constants.js +3 -0
  44. package/src/shared/logger.js +69 -4
  45. package/src/shared/prompt.js +64 -0
  46. package/src/shared/svg-utils.js +80 -0
  47. package/src/shared/utils.js +8 -4
@@ -100,7 +100,7 @@ function copyFreeFonts() {
100
100
  fs.copyFile(srcFonts_Folder + '/FontAwesome7Free-Regular.ttf', projectsFontsFolder + '/FontAwesome7Free-Regular.ttf', callback)
101
101
  fs.copyFile(srcFonts_Folder + '/FontAwesome7Free-Solid.ttf', projectsFontsFolder + '/FontAwesome7Free-Solid.ttf', callback)
102
102
 
103
- logger.warn(' - Font Awesome Free')
103
+ logger.item(chalk.green('Font Awesome Free'))
104
104
  }
105
105
 
106
106
  /**
@@ -111,7 +111,7 @@ function copyFreeFonts() {
111
111
  function copyProFonts(fontFamilies, webFonts) {
112
112
  _.each(fontFamilies, (dest, src) => {
113
113
  if (copyFile(`${webFonts}/${src}`, dest)) {
114
- logger.warn(` - ${dest} Font copied to`, chalk.yellow('./app/assets/fonts'), 'folder')
114
+ logger.item(`${dest} copied to ${chalk.yellow('./app/assets/fonts')}`)
115
115
  }
116
116
  })
117
117
  }
@@ -133,7 +133,7 @@ function copyMaterialIconsFonts() {
133
133
  copyFile(`${srcFonts_Folder}/${familyName}`, familyName)
134
134
  })
135
135
 
136
- logger.warn(' - Material Icons')
136
+ logger.item(chalk.green('Material Icons'))
137
137
  }
138
138
 
139
139
  /**
@@ -151,7 +151,7 @@ function copyMaterialSymbolsFonts() {
151
151
  copyFile(`${srcFonts_Folder}/${familyName}`, familyName)
152
152
  })
153
153
 
154
- logger.warn(' - Material Symbols')
154
+ logger.item(chalk.green('Material Symbols'))
155
155
  }
156
156
 
157
157
  /**
@@ -160,7 +160,7 @@ function copyMaterialSymbolsFonts() {
160
160
  function copyFramework7IconsFonts() {
161
161
  // Framework7 Font
162
162
  copyFile(srcFonts_Folder + '/Framework7-Icons.ttf', 'Framework7-Icons.ttf')
163
- logger.warn(' - Framework 7')
163
+ logger.item(chalk.green('Framework 7'))
164
164
  }
165
165
 
166
166
  /**
@@ -170,7 +170,7 @@ function copyFramework7IconsFonts() {
170
170
  function buildFontAwesomeJS() {
171
171
  // This function should be imported from the fonts module
172
172
  // For now, just log that it would be called
173
- logger.warn(' - Font Awesome JS module would be built')
173
+ logger.item(chalk.yellow('Font Awesome JS module would be built'))
174
174
  }
175
175
 
176
176
  /**
@@ -218,23 +218,23 @@ function copyFontLibrary(vendor) {
218
218
  buildFontAwesomeJS()
219
219
  } else {
220
220
  fs.copyFileSync(srcLibFA, projectsLibFolder + '/fontawesome.js')
221
- logger.warn(' - fontawesome.js')
221
+ logger.item(chalk.yellow('fontawesome.js'))
222
222
  }
223
223
  break
224
224
  case 'mi':
225
225
  case 'materialicons':
226
226
  fs.copyFileSync(srcLibMI, projectsLibFolder + '/materialicons.js')
227
- logger.warn(' - materialicons.js')
227
+ logger.item(chalk.yellow('materialicons.js'))
228
228
  break
229
229
  case 'ms':
230
230
  case 'materialsymbol':
231
231
  fs.copyFileSync(srcLibMS, projectsLibFolder + '/materialsymbols.js')
232
- logger.warn(' - materialsymbols.js')
232
+ logger.item(chalk.yellow('materialsymbols.js'))
233
233
  break
234
234
  case 'f7':
235
235
  case 'framework7':
236
236
  fs.copyFileSync(srcLibF7, projectsLibFolder + '/framework7icons.js')
237
- logger.warn(' - framework7icons.js')
237
+ logger.item(chalk.yellow('framework7icons.js'))
238
238
  break
239
239
  }
240
240
  }
@@ -251,23 +251,23 @@ function copyFontStyle(vendor) {
251
251
  buildFontAwesomeJS()
252
252
  } else {
253
253
  fs.copyFileSync(srcFontAwesomeTSSFile, projectsPurge_TSS_Styles_Folder + '/fontawesome.tss')
254
- logger.warn(' - fontawesome.tss')
254
+ logger.item(chalk.yellow('fontawesome.tss'))
255
255
  }
256
256
  break
257
257
  case 'mi':
258
258
  case 'materialicons':
259
259
  fs.copyFileSync(srcMaterialIconsTSSFile, projectsPurge_TSS_Styles_Folder + '/materialicons.tss')
260
- logger.warn(' - materialicons.tss')
260
+ logger.item(chalk.yellow('materialicons.tss'))
261
261
  break
262
262
  case 'ms':
263
263
  case 'materialsymbol':
264
264
  fs.copyFileSync(srcMaterialSymbolsTSSFile, projectsPurge_TSS_Styles_Folder + '/materialsymbols.tss')
265
- logger.warn(' - materialsymbols.tss')
265
+ logger.item(chalk.yellow('materialsymbols.tss'))
266
266
  break
267
267
  case 'f7':
268
268
  case 'framework7':
269
269
  fs.copyFileSync(srcFramework7FontTSSFile, projectsPurge_TSS_Styles_Folder + '/framework7icons.tss')
270
- logger.warn(' - framework7icons.tss')
270
+ logger.item(chalk.yellow('framework7icons.tss'))
271
271
  break
272
272
  }
273
273
  }
@@ -391,8 +391,10 @@ export async function copyModulesLibrary() {
391
391
  logger.info(chalk.yellow('purgetss.ui'), 'module copied to', chalk.yellow('./Resources/lib'), 'folder')
392
392
  return true
393
393
  } else {
394
- logger.info(`Please make sure you are running ${chalk.green('purgetss')} within an Alloy or Classic Project.`)
395
- logger.info(`For more information, visit ${chalk.green('https://purgetss.com')}`)
394
+ logger.block(
395
+ `Please make sure you are running ${chalk.green('purgetss')} within an Alloy or Classic Project.`,
396
+ `For more information, visit ${chalk.green('https://purgetss.com')}`
397
+ )
396
398
  return false
397
399
  }
398
400
  } catch (error) {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * PurgeTSS - Images Command
3
+ *
4
+ * Generates multi-density variants of UI images for Titanium Alloy or Classic
5
+ * projects. Auto-discovers sources in `./purgetss/images/` by default; accepts
6
+ * a path argument to override (file or directory).
7
+ *
8
+ * Precedence for every option: CLI flag > `images:` section in config > default.
9
+ *
10
+ * @fileoverview Assets command entry point
11
+ * @author César Estrada
12
+ */
13
+
14
+ import fs from 'fs'
15
+ import path from 'path'
16
+ import chalk from 'chalk'
17
+ import { runImages } from '../../core/images/index.js'
18
+ import { logger } from '../../core/branding/branding-logger.js'
19
+ import { ensureImagesSection } from '../../core/images/ensure-images-section.js'
20
+ import { getConfigFile } from '../../shared/config-manager.js'
21
+ import { projectsPurge_TSS_Images_Folder } from '../../shared/constants.js'
22
+
23
+ const VALID_FORMATS = new Set(['webp', 'jpeg', 'jpg', 'png', 'avif', 'gif', 'tiff'])
24
+
25
+ export async function images(cliSource, options = {}) {
26
+ if (options.debug) logger.setDebugMode(true)
27
+
28
+ const projectRoot = options.project ? path.resolve(options.project) : process.cwd()
29
+
30
+ if (!options.project) ensureImagesSection()
31
+
32
+ const cfg = loadImagesSection()
33
+
34
+ // --android and --ios are mutually exclusive.
35
+ if (options.android && options.ios) {
36
+ logger.error('--android and --ios are mutually exclusive. Pass neither to generate both, or pick one.')
37
+ process.exit(1)
38
+ }
39
+
40
+ const format = options.format ?? cfg.format ?? null
41
+ if (format && !VALID_FORMATS.has(format.toLowerCase())) {
42
+ logger.error(`Invalid --format '${format}'. Valid: ${[...VALID_FORMATS].join(', ')}`)
43
+ process.exit(1)
44
+ }
45
+
46
+ const source = resolveSource(cliSource, projectRoot)
47
+ if (!source) {
48
+ printMissingSourceHelp(projectRoot)
49
+ process.exit(1)
50
+ }
51
+
52
+ try {
53
+ await runImages({
54
+ source,
55
+ projectRoot,
56
+ androidOnly: Boolean(options.android),
57
+ iphoneOnly: Boolean(options.ios),
58
+ format: format ? format.toLowerCase() : null,
59
+ quality: options.quality ?? cfg.quality ?? 85,
60
+ dryRun: Boolean(options.dryRun),
61
+ yes: Boolean(options.yes),
62
+ confirmOverwrites: cfg.confirmOverwrites !== false
63
+ })
64
+ } catch (err) {
65
+ logger.error(err.message)
66
+ if (options.debug) console.error(err.stack)
67
+ process.exit(1)
68
+ }
69
+ }
70
+
71
+ function loadImagesSection() {
72
+ try {
73
+ const cfg = getConfigFile()
74
+ if (cfg && typeof cfg.images === 'object') return cfg.images
75
+ } catch {}
76
+ return {}
77
+ }
78
+
79
+ function resolveSource(cliSource, projectRoot) {
80
+ const imagesFolder = projectRoot === process.cwd()
81
+ ? projectsPurge_TSS_Images_Folder
82
+ : path.join(projectRoot, 'purgetss', 'images')
83
+
84
+ if (cliSource) {
85
+ if (path.isAbsolute(cliSource)) {
86
+ return fs.existsSync(cliSource) ? cliSource : null
87
+ }
88
+ // Relative paths: try purgetss/images/ first (convention), then cwd.
89
+ // Lets users write short paths like `background/pink.png` without the prefix.
90
+ const insideImages = path.resolve(imagesFolder, cliSource)
91
+ if (fs.existsSync(insideImages)) return insideImages
92
+
93
+ const cwdResolved = path.resolve(projectRoot, cliSource)
94
+ if (fs.existsSync(cwdResolved)) return cwdResolved
95
+
96
+ return null
97
+ }
98
+ return fs.existsSync(imagesFolder) ? imagesFolder : null
99
+ }
100
+
101
+ function printMissingSourceHelp(projectRoot) {
102
+ const rel = (p) => path.relative(projectRoot, p) || '.'
103
+ const imagesDir = path.join(projectRoot, 'purgetss', 'images')
104
+
105
+ logger.error('No source images found.')
106
+ console.log()
107
+ console.log(` Expected images inside ${chalk.cyan(rel(imagesDir) + '/')}.`)
108
+ console.log(` The folder already exists — drop your images into it (subdirectories are preserved):`)
109
+ console.log(` ${chalk.cyan('cp my-ui-asset.png ' + rel(imagesDir) + '/')}`)
110
+ console.log()
111
+ console.log(' Alternatives:')
112
+ console.log(` ${chalk.gray('•')} Pass a file or directory explicitly:`)
113
+ console.log(` ${chalk.cyan('purgetss images ./docs/screenshots')}`)
114
+ console.log(` ${chalk.cyan('purgetss images ./logo.png')}`)
115
+ console.log()
116
+ }
@@ -21,6 +21,8 @@ import {
21
21
  projectsAlloyJMKFile,
22
22
  projectsPurgeTSSFolder,
23
23
  projectsPurge_TSS_Fonts_Folder,
24
+ projectsPurge_TSS_Brand_Folder,
25
+ projectsPurge_TSS_Images_Folder,
24
26
  srcConfigFile,
25
27
  projectsFA_TSS_File,
26
28
  srcFontAwesomeTSSFile,
@@ -70,6 +72,8 @@ export function createConfigFile() {
70
72
  if (alloyProject()) {
71
73
  makeSureFolderExists(projectsPurgeTSSFolder)
72
74
  makeSureFolderExists(projectsPurge_TSS_Fonts_Folder)
75
+ makeSureFolderExists(projectsPurge_TSS_Brand_Folder)
76
+ makeSureFolderExists(projectsPurge_TSS_Images_Folder)
73
77
 
74
78
  if (fs.existsSync(projectsConfigJS)) {
75
79
  logger.warn('./purgetss/config.cjs', chalk.red('file already exists!'))
@@ -41,8 +41,10 @@ export function copyModulesLibrary() {
41
41
  return true
42
42
  } else {
43
43
  // Not in a valid project
44
- logger.info(`Please make sure you are running ${chalk.green('purgetss')} within an Alloy or Classic Project.`)
45
- logger.info(`For more information, visit ${chalk.green('https://purgetss.com')}`)
44
+ logger.block(
45
+ `Please make sure you are running ${chalk.green('purgetss')} within an Alloy or Classic Project.`,
46
+ `For more information, visit ${chalk.green('https://purgetss.com')}`
47
+ )
46
48
  return false
47
49
  }
48
50
  }
@@ -31,6 +31,7 @@ import { init } from './init.js'
31
31
  import { getConfigOptions, getConfigFile, ensureConfig } from '../../shared/config-manager.js'
32
32
 
33
33
  // Import purger functions from core modules
34
+ import { processControllers } from '../../core/analyzers/class-extractor.js'
34
35
  import { purgeTailwind } from '../../core/purger/tailwind-purger.js'
35
36
  import {
36
37
  purgeFontAwesome,
@@ -385,12 +386,16 @@ function throwPreValidationError({ relativePath, lineNumber, lineContent, messag
385
386
  error.lineNumber = lineNumber
386
387
  error.lineContent = lineContent
387
388
 
388
- logger.error('XML Syntax Error\n')
389
- logger.info(`File: "${relativePath}"`)
390
- logger.info(`Line: ${lineNumber}`)
391
- logger.info(`Content: "${lineContent}"\n`)
392
- logger.error(message)
393
- logger.info(chalk.green(`Fix: ${fix}\n`))
389
+ logger.block(
390
+ chalk.red('XML Syntax Error'),
391
+ `File: ${chalk.yellow(`"${relativePath}"`)}`,
392
+ `Line: ${chalk.yellow(lineNumber)}`,
393
+ `Content: ${chalk.yellow(`"${lineContent}"`)}`,
394
+ '',
395
+ chalk.red(message),
396
+ '',
397
+ `${chalk.green('Fix:')} ${fix}`
398
+ )
394
399
 
395
400
  throw error
396
401
  }
@@ -412,69 +417,6 @@ function extractClasses(currentText, currentFile) {
412
417
  }
413
418
  }
414
419
 
415
- /**
416
- * Process controller files for classes
417
- * COPIED exactly from original processControllers() function
418
- */
419
- function processControllers(data) {
420
- const allWords = []
421
- const lines = data.split(/\r?\n/)
422
-
423
- lines.forEach(line => {
424
- const words = extractWordsFromLine(line)
425
- if (words.length > 0) {
426
- allWords.push(...words)
427
- }
428
- })
429
-
430
- return allWords
431
- }
432
-
433
- /**
434
- * Extract words from a line of controller code
435
- * COPIED exactly from original extractWordsFromLine() function
436
- */
437
- function extractWordsFromLine(line) {
438
- const patterns = [
439
- {
440
- // apply: 'classes'
441
- regex: /apply:\s*'([^']+)'/,
442
- process: match => match[1].split(/\s+/)
443
- },
444
- {
445
- // classes: ['class1', 'class2'] o classes: ['class1 class2']
446
- regex: /classes:\s*\[([^\]]+)\]/,
447
- process: match => match[1].split(',').map(item => item.trim().replace(/['"]/g, ''))
448
- },
449
- {
450
- // classes: 'class1 class2'
451
- regex: /classes:\s*'([^']+)'/,
452
- process: match => match[1].split(/\s+/)
453
- }
454
- ]
455
-
456
- // Process simple patterns
457
- const words = patterns.reduce((acc, { regex, process }) => {
458
- const match = regex.exec(line)
459
- return match ? [...acc, ...process(match)] : acc
460
- }, [])
461
-
462
- // Process addClass, removeClass, resetClass
463
- const classFunctionRegex = /(?:\.\w+Class|resetClass)\([^,]+,\s*(?:'([^']+)'|\[([^\]]+)\])/g
464
- let classFunctionMatch
465
- while ((classFunctionMatch = classFunctionRegex.exec(line)) !== null) {
466
- const content = classFunctionMatch[1] || classFunctionMatch[2]
467
- if (content) {
468
- const classes = content.includes(',')
469
- ? content.split(',').map(item => item.trim().replace(/['"]/g, ''))
470
- : content.replace(/['"]/g, '').split(/\s+/)
471
- words.push(...classes)
472
- }
473
- }
474
-
475
- return words
476
- }
477
-
478
420
  /**
479
421
  * Filter invalid characters from class names
480
422
  * COPIED exactly from original filterCharacters() function
@@ -737,49 +679,57 @@ export function purgeClasses(options) {
737
679
 
738
680
  if (Date.now() > (fs.statSync(projectsAppTSS).mtimeMs + 2000) || recentlyCreated) {
739
681
  start()
740
-
741
- init(options)
742
-
743
- backupOriginalAppTss()
744
-
745
- let uniqueClasses
746
-
682
+ logger.startSection()
747
683
  try {
748
- uniqueClasses = getUniqueClasses()
749
- } catch (error) {
750
- // Handle pre-validation errors (XML syntax errors detected before parsing)
751
- if (error.isPreValidationError) {
752
- // Error already printed by preValidateXML, exit cleanly
753
- // eslint-disable-next-line n/no-process-exit
754
- process.exit(1)
684
+ // Explicit header so every purge run shows which project is being
685
+ // processed mirrors the Auto-Purging line emitted by the alloy.jmk hook.
686
+ logger.info('Purging', chalk.yellow(cwd))
687
+
688
+ init(options)
689
+
690
+ backupOriginalAppTss()
691
+
692
+ let uniqueClasses
693
+
694
+ try {
695
+ uniqueClasses = getUniqueClasses()
696
+ } catch (error) {
697
+ // Handle pre-validation errors (XML syntax errors detected before parsing)
698
+ if (error.isPreValidationError) {
699
+ // Error already printed by preValidateXML, exit cleanly
700
+ // eslint-disable-next-line n/no-process-exit
701
+ process.exit(1)
702
+ }
703
+ // Re-throw other errors
704
+ throw error
755
705
  }
756
- // Re-throw other errors
757
- throw error
758
- }
759
706
 
760
- let tempPurged = copyResetTemplateAnd_appTSS()
707
+ let tempPurged = copyResetTemplateAnd_appTSS()
761
708
 
762
- tempPurged += purgeTailwind(uniqueClasses, purgingDebug)
709
+ tempPurged += purgeTailwind(uniqueClasses, purgingDebug)
763
710
 
764
- const cleanUniqueClasses = cleanClasses(uniqueClasses)
711
+ const cleanUniqueClasses = cleanClasses(uniqueClasses)
765
712
 
766
- tempPurged += purgeFontAwesome(uniqueClasses, cleanUniqueClasses, purgingDebug)
713
+ tempPurged += purgeFontAwesome(uniqueClasses, cleanUniqueClasses, purgingDebug)
767
714
 
768
- tempPurged += purgeMaterialIcons(uniqueClasses, cleanUniqueClasses, purgingDebug)
715
+ tempPurged += purgeMaterialIcons(uniqueClasses, cleanUniqueClasses, purgingDebug)
769
716
 
770
- tempPurged += purgeMaterialSymbols(uniqueClasses, cleanUniqueClasses, purgingDebug)
717
+ tempPurged += purgeMaterialSymbols(uniqueClasses, cleanUniqueClasses, purgingDebug)
771
718
 
772
- tempPurged += purgeFramework7(uniqueClasses, cleanUniqueClasses, purgingDebug)
719
+ tempPurged += purgeFramework7(uniqueClasses, cleanUniqueClasses, purgingDebug)
773
720
 
774
- tempPurged += purgeFonts(uniqueClasses, cleanUniqueClasses, purgingDebug)
721
+ tempPurged += purgeFonts(uniqueClasses, cleanUniqueClasses, purgingDebug)
775
722
 
776
- tempPurged += processMissingClasses(tempPurged)
723
+ tempPurged += processMissingClasses(tempPurged)
777
724
 
778
- saveFile(projectsAppTSS, tempPurged)
725
+ saveFile(projectsAppTSS, tempPurged)
779
726
 
780
- logger.file('app.tss')
727
+ logger.file('app.tss')
781
728
 
782
- finish()
729
+ finish()
730
+ } finally {
731
+ logger.endSection()
732
+ }
783
733
 
784
734
  return true
785
735
  } else {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * PurgeTSS - `semantic` command
3
+ *
4
+ * Generates Titanium semantic colors (app/assets/semantic.colors.json) in two
5
+ * modes, dispatched by --single:
6
+ *
7
+ * Palette mode (no --single):
8
+ * One base hex → 11-step tonal palette with mirror-by-index Light/Dark
9
+ * inversion (anchored at shade 500). Writes JSON + config mapping (the
10
+ * 11 shade keys are mechanically derived from the family name, so the
11
+ * class mapping is unambiguous).
12
+ *
13
+ * Single mode (--single):
14
+ * Explicit light + optional dark + optional alpha → one purpose-based
15
+ * semantic color. Writes JSON ONLY. The class name mapping in config.cjs
16
+ * is a design-layer decision (designers pick `bg-surface` to point at
17
+ * `surfaceColor`, `text-on-surface` to point at `textColor`, etc.) and
18
+ * should not be auto-derived from the semantic key. The command tells
19
+ * the user how to add the mapping.
20
+ *
21
+ * If the name matches an existing palette shade (e.g. `amazon50` while
22
+ * palette `amazon` exists), the operation narrows to an in-place JSON
23
+ * value edit — no new top-level entry, no config touch.
24
+ *
25
+ * @author César Estrada
26
+ */
27
+
28
+ import chalk from 'chalk'
29
+ import { alloyProject } from '../../shared/utils.js'
30
+ import { logger } from '../../shared/logger.js'
31
+ import { ensureConfig, getConfigFile } from '../../shared/config-manager.js'
32
+ import {
33
+ toCamelCase,
34
+ buildSemanticPalette,
35
+ buildSingleSemantic,
36
+ writeSemanticColors,
37
+ writeSemanticJSON,
38
+ writeConfigMapping,
39
+ updateSemanticEntry,
40
+ wrapHexWithAlpha,
41
+ detectFamilyShadeConflict,
42
+ normalizeAlpha,
43
+ checkIfColorModule,
44
+ missingHexMessage
45
+ } from './shades.js'
46
+
47
+ /**
48
+ * Normalize a user-supplied name into both kebab and camel forms while
49
+ * preserving the user's case. Handles three input shapes:
50
+ * - camelCase ('surfaceColor') → { kebab: 'surface-color', camel: 'surfaceColor' }
51
+ * - kebab ('surface-color') → { kebab: 'surface-color', camel: 'surfaceColor' }
52
+ * - natural ('Surface Color') → { kebab: 'surface-color', camel: 'surfaceColor' }
53
+ */
54
+ function parseName(raw) {
55
+ const clean = String(raw).replace(/'/g, '').replace(/\//g, '').trim()
56
+ if (/\s/.test(clean)) {
57
+ const kebab = clean.toLowerCase().split(/\s+/).join('-')
58
+ return { kebab, camel: toCamelCase(kebab) }
59
+ }
60
+ if (clean.includes('-')) {
61
+ const kebab = clean.toLowerCase()
62
+ return { kebab, camel: toCamelCase(kebab) }
63
+ }
64
+ // No space, no hyphen: treat as camelCase or single word, preserve case
65
+ const camel = clean
66
+ const kebab = camel.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
67
+ return { kebab, camel }
68
+ }
69
+
70
+ export async function semantic(args, options) {
71
+ const silent = options.log
72
+
73
+ if (options.single) {
74
+ return runSingle(args, options, silent)
75
+ }
76
+
77
+ return runPalette(args, options, silent)
78
+ }
79
+
80
+ async function runPalette(args, options, silent) {
81
+ if (!args.hexcode && !options.random) {
82
+ logger.block(...missingHexMessage('semantic'))
83
+ return false
84
+ }
85
+
86
+ const chroma = (await import('chroma-js')).default
87
+ const referenceColorFamilies = (await import('../../../lib/color-shades/tailwindColors.js')).default
88
+ const generateColorShades = (await import('../../../lib/color-shades/generateColorShades.js')).default
89
+
90
+ const colorFamily = options.random
91
+ ? generateColorShades(chroma.random(), referenceColorFamilies)
92
+ : generateColorShades(args.hexcode, referenceColorFamilies)
93
+
94
+ if (args.name) colorFamily.name = args.name
95
+ else if (options.name) colorFamily.name = options.name
96
+
97
+ colorFamily.name = colorFamily.name.replace(/'/g, '').replace(/\//g, '').replace(/\s+/g, ' ')
98
+
99
+ const { kebab: kebabName } = parseName(colorFamily.name)
100
+ const { semanticEntries, configMapping } = buildSemanticPalette(colorFamily, kebabName)
101
+
102
+ if (alloyProject(silent) && !silent) {
103
+ ensureConfig()
104
+ writeSemanticColors(semanticEntries, kebabName, configMapping, options)
105
+ logger.info(`${chalk.hex(colorFamily.hexcode).bold(`"${colorFamily.name}"`)} palette (11 shades) saved to`, chalk.yellow('app/assets/semantic.colors.json'))
106
+ } else {
107
+ logger.info(`${chalk.hex(colorFamily.hexcode).bold(`"${colorFamily.name}"`)} palette preview:\n${JSON.stringify(semanticEntries, null, 2)}`)
108
+ }
109
+
110
+ return true
111
+ }
112
+
113
+ function runSingle(args, options, silent) {
114
+ const lightHex = args.hexcode
115
+ const rawName = args.name || options.name
116
+ if (!lightHex) {
117
+ logger.info(`${chalk.red('Missing light hex.')} Usage: ${chalk.green("pt semantic --single '#F9FAFB' surfaceColor [--dark '#0f172a'] [--alpha 50]")}`)
118
+ return false
119
+ }
120
+ if (!rawName) {
121
+ logger.info(`${chalk.red('Missing name.')} Usage: ${chalk.green("pt semantic --single '#F9FAFB' surfaceColor [--dark '#0f172a'] [--alpha 50]")}`)
122
+ return false
123
+ }
124
+
125
+ const darkHex = options.dark || lightHex
126
+ const alpha = normalizeAlpha(options.alpha)
127
+ const { kebab: kebabName, camel: camelName } = parseName(rawName)
128
+
129
+ // Preview mode: log the JSON, no writes, no Alloy check
130
+ if (silent) {
131
+ const { semanticEntries } = buildSingleSemantic(camelName, lightHex, darkHex, alpha)
132
+ logger.info(`${chalk.hex(lightHex).bold(`"${camelName}"`)} preview:\n${JSON.stringify(semanticEntries, null, 2)}`)
133
+ return true
134
+ }
135
+
136
+ if (!alloyProject(silent)) return false
137
+ ensureConfig()
138
+
139
+ // If the name matches an existing palette shade (e.g. `amazon50` when palette
140
+ // `amazon` is already declared), interpret as an in-place value edit. Update
141
+ // the JSON entry, leave config.cjs alone (palette already maps to this key).
142
+ const conflict = detectFamilyShadeConflict(getConfigFile(), kebabName, camelName)
143
+ if (conflict) {
144
+ const value = {
145
+ light: wrapHexWithAlpha(lightHex, alpha),
146
+ dark: wrapHexWithAlpha(darkHex, alpha)
147
+ }
148
+ updateSemanticEntry(conflict.camelKey, value)
149
+ checkIfColorModule()
150
+ logger.info(`${chalk.hex(lightHex).bold(conflict.camelKey)} updated in ${chalk.yellow('app/assets/semantic.colors.json')} — palette ${chalk.yellow(conflict.parentName)} already references this key, config.cjs left unchanged.`)
151
+ return true
152
+ }
153
+
154
+ // Fresh single entry: write JSON + auto-map config.cjs to a sensible class name.
155
+ // Convention: strip the trailing 'Color' suffix (Titanium's semantic naming
156
+ // pattern) and kebab-case the rest. Users who want a different class name
157
+ // (`on-surface` for `textColor`, etc.) edit config.cjs after the fact —
158
+ // overriding the auto-mapping is one keystroke; typing it from scratch is many.
159
+ const { semanticEntries } = buildSingleSemantic(camelName, lightHex, darkHex, alpha)
160
+ const className = suggestClassName(camelName)
161
+ writeSemanticJSON(semanticEntries, camelName)
162
+ writeConfigMapping(className, camelName, options)
163
+ logger.info(`${chalk.hex(lightHex).bold(`"${camelName}"`)} saved to ${chalk.yellow('app/assets/semantic.colors.json')} and mapped to class ${chalk.green(className)} in ${chalk.yellow('config.cjs')}.`)
164
+ return true
165
+ }
166
+
167
+ /**
168
+ * Derive a class name from a Titanium semantic key by stripping the
169
+ * conventional `Color` suffix and kebab-casing the result.
170
+ * 'surfaceColor' → 'surface'
171
+ * 'surfaceHighColor' → 'surface-high'
172
+ * 'borderColor' → 'border'
173
+ * 'overlay' → 'overlay' (no Color suffix → unchanged)
174
+ */
175
+ function suggestClassName(camelKey) {
176
+ const stripped = camelKey.replace(/Color$/, '') || camelKey
177
+ return stripped.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
178
+ }
179
+
180
+ export default { semantic }