purgetss 7.5.2 → 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 (50) hide show
  1. package/README.md +93 -11
  2. package/bin/purgetss +140 -1
  3. package/dist/purgetss.ui.js +65 -26
  4. package/dist/utilities.tss +21 -4
  5. package/experimental/completions2.js +1 -1
  6. package/lib/completions/titanium/completions-v3.json +62 -1
  7. package/lib/templates/purgetss.config.js.cjs +15 -1
  8. package/lib/templates/purgetss.ui.js.cjs +64 -25
  9. package/package.json +3 -1
  10. package/src/cli/commands/brand.js +69 -0
  11. package/src/cli/commands/create.js +11 -7
  12. package/src/cli/commands/fonts.js +9 -9
  13. package/src/cli/commands/icon-library.js +18 -16
  14. package/src/cli/commands/images.js +116 -0
  15. package/src/cli/commands/init.js +4 -0
  16. package/src/cli/commands/module.js +4 -2
  17. package/src/cli/commands/purge.js +77 -101
  18. package/src/cli/commands/semantic.js +180 -0
  19. package/src/cli/commands/shades.js +332 -13
  20. package/src/cli/utils/project-detection.js +4 -2
  21. package/src/core/analyzers/class-extractor.js +110 -3
  22. package/src/core/branding/brand-config.js +111 -0
  23. package/src/core/branding/branding-logger.js +40 -0
  24. package/src/core/branding/cleanup-legacy.js +220 -0
  25. package/src/core/branding/ensure-brand-section.js +80 -0
  26. package/src/core/branding/gen-android-adaptive.js +116 -0
  27. package/src/core/branding/gen-android-legacy.js +63 -0
  28. package/src/core/branding/gen-ic-launcher-xml.js +29 -0
  29. package/src/core/branding/gen-ios-dark.js +70 -0
  30. package/src/core/branding/gen-ios-tinted.js +55 -0
  31. package/src/core/branding/gen-ios.js +69 -0
  32. package/src/core/branding/gen-marketplace.js +71 -0
  33. package/src/core/branding/gen-notification.js +76 -0
  34. package/src/core/branding/gen-splash.js +64 -0
  35. package/src/core/branding/index.js +336 -0
  36. package/src/core/branding/post-gen-notes.js +145 -0
  37. package/src/core/branding/prepare-master.js +108 -0
  38. package/src/core/branding/tiapp-reader.js +110 -0
  39. package/src/core/builders/tailwind-helpers.js +1 -1
  40. package/src/core/images/ensure-images-section.js +57 -0
  41. package/src/core/images/gen-scales.js +181 -0
  42. package/src/core/images/index.js +171 -0
  43. package/src/shared/config-manager.js +46 -0
  44. package/src/shared/config-writer.js +84 -0
  45. package/src/shared/constants.js +3 -0
  46. package/src/shared/helpers/typography.js +38 -3
  47. package/src/shared/logger.js +69 -4
  48. package/src/shared/prompt.js +64 -0
  49. package/src/shared/svg-utils.js +80 -0
  50. package/src/shared/utils.js +8 -4
@@ -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 }
@@ -15,7 +15,7 @@ import fs from 'fs'
15
15
  import chalk from 'chalk'
16
16
  import { createRequire } from 'module'
17
17
  import { alloyProject, makeSureFolderExists } from '../../shared/utils.js'
18
- import { projectsConfigJS, projectsLibFolder } from '../../shared/constants.js'
18
+ import { projectsConfigJS, projectsLibFolder, projectsSemanticColorsJSON } from '../../shared/constants.js'
19
19
  import { logger } from '../../shared/logger.js'
20
20
  import { ensureConfig, getConfigFile } from '../../shared/config-manager.js'
21
21
  import { cleanDoubleQuotes } from '../utils/file-operations.js'
@@ -67,6 +67,27 @@ export function checkIfColorModule() {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * Build the "missing hex" error message shown when the user invokes `shades`
72
+ * or `semantic` without a hex argument and without `--random`. The common
73
+ * cause is an unquoted `#` on the command line — bash/zsh treat everything
74
+ * from `#` onward as a comment, so `pt shades #6A2489` reaches the CLI as
75
+ * `pt shades` with no argument. We surface the shell behavior explicitly so
76
+ * the user isn't mystified by a silent random color.
77
+ */
78
+ export function missingHexMessage(commandName) {
79
+ return [
80
+ chalk.red('No hex color provided.'),
81
+ `If you typed ${chalk.yellow(`pt ${commandName} #6A2489`)} (unquoted), your shell stripped ${chalk.yellow('#6A2489')}`,
82
+ 'as a comment — the CLI never received it.',
83
+ '',
84
+ 'Try one of these instead:',
85
+ ` ${chalk.green(`pt ${commandName} '#6A2489'`)} ${chalk.gray('(quoted)')}`,
86
+ ` ${chalk.green(`pt ${commandName} 6A2489`)} ${chalk.gray('(no hash)')}`,
87
+ ` ${chalk.green(`pt ${commandName} --random`)} ${chalk.gray('(random color)')}`
88
+ ]
89
+ }
90
+
70
91
  /**
71
92
  * Main shades command - generates color shades from hex codes
72
93
  * Maintains exact same logic as original shades() function
@@ -86,11 +107,16 @@ export function checkIfColorModule() {
86
107
  * @returns {Promise<boolean>} Success status
87
108
  */
88
109
  export async function shades(args, options) {
110
+ if (!args.hexcode && !options.random) {
111
+ logger.block(...missingHexMessage('shades'))
112
+ return false
113
+ }
114
+
89
115
  const chroma = (await import('chroma-js')).default
90
116
  const referenceColorFamilies = (await import('../../../lib/color-shades/tailwindColors.js')).default
91
117
  const generateColorShades = (await import('../../../lib/color-shades/generateColorShades.js')).default
92
118
 
93
- const colorFamily = (options.random || !args.hexcode)
119
+ const colorFamily = options.random
94
120
  ? generateColorShades(chroma.random(), referenceColorFamilies)
95
121
  : generateColorShades(args.hexcode, referenceColorFamilies)
96
122
 
@@ -99,19 +125,14 @@ export async function shades(args, options) {
99
125
 
100
126
  colorFamily.name = colorFamily.name.replace(/'/g, '').replace(/\//g, '').replace(/\s+/g, ' ')
101
127
 
102
- const colorObject = createColorObject(colorFamily, colorFamily.hexcode, options)
103
-
104
128
  const silent = options.tailwind || options.json || options.log
129
+ const inAlloyProject = !silent && alloyProject(silent)
105
130
 
106
- // Ensure config migration happens before getting config file
107
- if (alloyProject(silent) && !silent) {
108
- ensureConfig()
109
- }
110
-
111
- // Get config file (after potential migration)
112
- const configFile = getConfigFile()
131
+ const colorObject = createColorObject(colorFamily, colorFamily.hexcode, options)
113
132
 
114
- if (alloyProject(silent) && !silent) {
133
+ if (inAlloyProject) {
134
+ ensureConfig()
135
+ const configFile = getConfigFile()
115
136
 
116
137
  if (options.override) {
117
138
  if (!configFile.theme.colors) configFile.theme.colors = {}
@@ -180,11 +201,309 @@ function createColorObject(family, hexcode, options) {
180
201
  return colors
181
202
  }
182
203
 
204
+ /**
205
+ * Convert a kebab-case color name to camelCase for semantic JSON keys.
206
+ * Example: 'amazon-green' → 'amazonGreen'. Leaves single-word names untouched.
207
+ * Exported because the `semantic` command imports it for shade-conflict detection.
208
+ *
209
+ * @param {string} kebabName - kebab-case color name
210
+ * @returns {string} camelCase name
211
+ */
212
+ export function toCamelCase(kebabName) {
213
+ return kebabName.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
214
+ }
215
+
216
+ /**
217
+ * Validate and normalize the --alpha CLI input.
218
+ * Returns undefined when no alpha was supplied; throws on invalid input.
219
+ * Per the Titanium semantic.colors.json spec, alpha is a string in 0.0-100.0
220
+ * (integer or float). See ti-expert/references/theming.md.
221
+ *
222
+ * @param {string|number|undefined} input - raw CLI value
223
+ * @returns {string|undefined} normalized alpha as string, or undefined
224
+ */
225
+ export function normalizeAlpha(input) {
226
+ if (input === undefined || input === null || input === '') return undefined
227
+ const n = Number(input)
228
+ if (!Number.isFinite(n) || n < 0 || n > 100) {
229
+ throw new Error(`--alpha must be a number between 0 and 100 (got "${input}")`)
230
+ }
231
+ return String(n)
232
+ }
233
+
234
+ /**
235
+ * Wrap a hex value in the Titanium semantic-color extended form when alpha is
236
+ * present, or return the bare hex string otherwise. Both forms are valid per
237
+ * the Titanium spec (a single semantic.colors.json may mix them).
238
+ *
239
+ * @param {string} hex - hex color
240
+ * @param {string|undefined} alpha - normalized alpha string, or undefined
241
+ * @returns {string|{color: string, alpha: string}}
242
+ */
243
+ function wrapValue(hex, alpha) {
244
+ return alpha === undefined ? hex : { color: hex, alpha }
245
+ }
246
+
247
+ export const SHADE_NUMBERS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950']
248
+ const SHADE_SUFFIX_RE = new RegExp(`^(.+?)(${SHADE_NUMBERS.join('|')})$`)
249
+
250
+ /**
251
+ * Detect when a name like 'amazon50' refers to an existing palette shade.
252
+ * The `semantic --single` command uses this to decide between in-place update
253
+ * (when the name matches a shade of a palette already declared in config) and
254
+ * the normal full write flow (when the name is independent). Returns the
255
+ * conflict descriptor (parentName + shadeNum + camelKey) or null.
256
+ *
257
+ * Checks BOTH the kebab and camel forms — kebab matches config.cjs keys,
258
+ * camel matches semantic.colors.json keys for multi-word families like
259
+ * `amazonGreen500`.
260
+ *
261
+ * @param {Object} configFile - parsed purgetss/config.cjs
262
+ * @param {string} kebabName - kebab-case name (config.cjs key style)
263
+ * @param {string} camelName - camelCase name (semantic.colors.json key style)
264
+ * @returns {{ parentName: string, shadeNum: string, camelKey: string }|null}
265
+ */
266
+ export function detectFamilyShadeConflict(configFile, kebabName, camelName) {
267
+ for (const candidate of [kebabName, camelName]) {
268
+ const match = candidate.match(SHADE_SUFFIX_RE)
269
+ if (!match) continue
270
+ const [, parentName, shadeNum] = match
271
+
272
+ const extend = configFile?.theme?.extend?.colors?.[parentName]
273
+ const main = configFile?.theme?.colors?.[parentName]
274
+ const parentMapping = extend ?? main
275
+
276
+ if (parentMapping && typeof parentMapping === 'object' && parentMapping[shadeNum] !== undefined) {
277
+ return { parentName, shadeNum, camelKey: candidate === kebabName ? camelName : candidate }
278
+ }
279
+ }
280
+ return null
281
+ }
282
+
283
+ /**
284
+ * Remove all keys in `existing` that belong to the named semantic family —
285
+ * the bare camelName (single form) plus camelName + each of the 11 shade
286
+ * numbers (palette form). Keys outside the family are left untouched, so
287
+ * unrelated palettes and manually-defined entries (`surfaceColor`, etc.)
288
+ * survive a re-run. This makes `shades --semantic` consistent with the
289
+ * non-semantic flow: regenerating a family fully replaces it.
290
+ *
291
+ * @param {Object} existing - parsed semantic.colors.json
292
+ * @param {string} camelName - camelCase family name (e.g., 'amazon', 'glassSurface')
293
+ * @returns {Object} new object without the family's keys
294
+ */
295
+ export function stripFamilyKeys(existing, camelName) {
296
+ const familyKeys = new Set([camelName, ...SHADE_NUMBERS.map(n => `${camelName}${n}`)])
297
+ const cleaned = {}
298
+ for (const [key, value] of Object.entries(existing)) {
299
+ if (!familyKeys.has(key)) cleaned[key] = value
300
+ }
301
+ return cleaned
302
+ }
303
+
304
+ /**
305
+ * Build a semantic palette with mirror-by-index light/dark inversion,
306
+ * anchored at shade 500. Pure function — no I/O.
307
+ *
308
+ * For each slot at index i in the sorted (50→950) shade list:
309
+ * light = shade[last - i].hexcode (inverted)
310
+ * dark = shade[i].hexcode (original)
311
+ * Slot 500 is the anchor (same light/dark).
312
+ *
313
+ * When alpha is provided, every value is wrapped as { color, alpha }.
314
+ *
315
+ * @param {Object} family - Color family from generateColorShades()
316
+ * @param {string} kebabName - Normalized color name (e.g., 'amazon-green')
317
+ * @param {string|undefined} alpha - Optional alpha string (0-100)
318
+ * @returns {{ semanticEntries: Object, configMapping: Object }}
319
+ */
320
+ export function buildSemanticPalette(family, kebabName, alpha) {
321
+ const camelName = toCamelCase(kebabName)
322
+ const sorted = [...family.shades].sort((a, b) => a.number - b.number)
323
+ const semanticEntries = {}
324
+ const configMapping = {}
325
+
326
+ sorted.forEach((shade, i) => {
327
+ const mirror = sorted[sorted.length - 1 - i]
328
+ const key = `${camelName}${shade.number}`
329
+ semanticEntries[key] = {
330
+ light: wrapValue(mirror.hexcode, alpha),
331
+ dark: wrapValue(shade.hexcode, alpha)
332
+ }
333
+ configMapping[shade.number] = key
334
+ })
335
+
336
+ return { semanticEntries, configMapping }
337
+ }
338
+
339
+ /**
340
+ * Build a single-entry purpose-based semantic color from explicit per-mode
341
+ * hex values. Used by `pt semantic --single`. Light and dark are independent;
342
+ * if `darkHex` is omitted the same value is used for both modes (useful for
343
+ * overlays/glass surfaces where alpha is the variation, not hue).
344
+ *
345
+ * The JSON key is taken VERBATIM — caller is responsible for passing the exact
346
+ * camelCase form expected in semantic.colors.json (e.g. 'surfaceColor'), since
347
+ * Titanium's conventions are case-sensitive and the design-layer class name
348
+ * (config.cjs) is a separate decision that the caller owns.
349
+ *
350
+ * @param {string} camelKey - exact JSON key (e.g. 'surfaceColor', 'overlay')
351
+ * @param {string} lightHex - hex for light mode (required)
352
+ * @param {string|undefined} darkHex - hex for dark mode (defaults to lightHex)
353
+ * @param {string|undefined} alpha - normalized alpha string (0-100), wraps both modes
354
+ * @returns {{ semanticEntries: Object }}
355
+ */
356
+ export function buildSingleSemantic(camelKey, lightHex, darkHex, alpha) {
357
+ const dark = darkHex ?? lightHex
358
+ return {
359
+ semanticEntries: {
360
+ [camelKey]: {
361
+ light: wrapValue(lightHex, alpha),
362
+ dark: wrapValue(dark, alpha)
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Wrap a hex value with optional alpha — exposed so the `semantic` command
370
+ * can format individual entries without re-importing the building blocks.
371
+ *
372
+ * @param {string} hex
373
+ * @param {string|undefined} alpha
374
+ * @returns {string|{color:string,alpha:string}}
375
+ */
376
+ export function wrapHexWithAlpha(hex, alpha) {
377
+ return wrapValue(hex, alpha)
378
+ }
379
+
380
+ /**
381
+ * Read + parse semantic.colors.json if present. Returns {} for missing/empty.
382
+ * Rethrows on invalid JSON after logging, to protect user data.
383
+ */
384
+ function readSemanticJSON() {
385
+ if (!fs.existsSync(projectsSemanticColorsJSON)) return {}
386
+ const raw = fs.readFileSync(projectsSemanticColorsJSON, 'utf8')
387
+ if (!raw.trim()) return {}
388
+ try {
389
+ return JSON.parse(raw)
390
+ } catch (err) {
391
+ logger.info(`${chalk.red('Warning:')} ${chalk.yellow('app/assets/semantic.colors.json')} is not valid JSON. Aborting to avoid data loss.`)
392
+ throw err
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Write the semantic-colors JSON (caller provides the fully-merged object).
398
+ * Ensures app/assets/ exists first.
399
+ */
400
+ function persistSemanticJSON(data) {
401
+ const assetsFolder = projectsSemanticColorsJSON.replace(/\/semantic\.colors\.json$/, '')
402
+ makeSureFolderExists(assetsFolder)
403
+ fs.writeFileSync(projectsSemanticColorsJSON, JSON.stringify(data, null, 2) + '\n', 'utf8')
404
+ }
405
+
406
+ /**
407
+ * Write new entries into semantic.colors.json, replacing any prior keys that
408
+ * belong to the same camelName family (single form + 11 shade form). Unrelated
409
+ * keys survive untouched. Used by both palette and single-mode fresh writes.
410
+ *
411
+ * @param {Object} semanticEntries - new entries to merge in
412
+ * @param {string} camelName - family name (for stripping)
413
+ */
414
+ export function writeSemanticJSON(semanticEntries, camelName) {
415
+ const existing = readSemanticJSON()
416
+ const cleaned = stripFamilyKeys(existing, camelName)
417
+ const merged = { ...cleaned, ...semanticEntries }
418
+ persistSemanticJSON(merged)
419
+ }
420
+
421
+ /**
422
+ * Update one entry in semantic.colors.json in place, preserving the existing
423
+ * key order. Used when `pt semantic --single` is invoked with a name that
424
+ * matches an existing palette shade — the user is editing a shade value, not
425
+ * creating a new top-level color, so we must NOT touch config.cjs (the
426
+ * palette already maps to this key) and we must NOT shift the key to the end.
427
+ *
428
+ * Spread-merge with an existing key updates the value while keeping the
429
+ * original insertion position (V8/spec object key ordering).
430
+ *
431
+ * @param {string} camelKey - the JSON key to update (already camelCase)
432
+ * @param {string|{color:string,alpha:string}} value - new value (bare hex or wrapped form)
433
+ */
434
+ export function updateSemanticEntry(camelKey, value) {
435
+ const existing = readSemanticJSON()
436
+ const updated = { ...existing, [camelKey]: value }
437
+ persistSemanticJSON(updated)
438
+ }
439
+
440
+ /**
441
+ * Write the family-name → configMapping entry into purgetss/config.cjs under
442
+ * theme.extend.colors (or theme.colors when --override is set). Cleans up the
443
+ * opposite branch so the mapping doesn't duplicate.
444
+ *
445
+ * @param {string} kebabName - key in theme.extend.colors / theme.colors
446
+ * @param {Object|string} configMapping - palette object or single string
447
+ * @param {Object} options - CLI options (reads .override and .quotes)
448
+ */
449
+ export function writeConfigMapping(kebabName, configMapping, options) {
450
+ const configFile = getConfigFile()
451
+
452
+ if (options.override) {
453
+ if (!configFile.theme.colors) configFile.theme.colors = {}
454
+ configFile.theme.colors[kebabName] = configMapping
455
+
456
+ if (configFile.theme.extend.colors) {
457
+ if (configFile.theme.extend.colors[kebabName]) delete configFile.theme.extend.colors[kebabName]
458
+ if (Object.keys(configFile.theme.extend.colors).length === 0) delete configFile.theme.extend.colors
459
+ }
460
+ } else {
461
+ if (!configFile.theme.extend.colors) configFile.theme.extend.colors = {}
462
+ configFile.theme.extend.colors[kebabName] = configMapping
463
+
464
+ if (configFile.theme.colors) {
465
+ if (configFile.theme.colors[kebabName]) delete configFile.theme.colors[kebabName]
466
+ if (Object.keys(configFile.theme.colors).length === 0) delete configFile.theme.colors
467
+ }
468
+ }
469
+
470
+ fs.writeFileSync(projectsConfigJS, 'module.exports = ' + cleanDoubleQuotes(configFile, options), 'utf8', err => { throw err })
471
+ checkIfColorModule()
472
+ }
473
+
474
+ /**
475
+ * Combined write for palette mode (JSON entries + config mapping in one call).
476
+ * Single mode uses writeSemanticJSON alone — class naming is a design-layer
477
+ * decision that belongs to the user, not auto-derived from the semantic key.
478
+ *
479
+ * @param {Object} semanticEntries - Output of buildSemanticPalette().semanticEntries
480
+ * @param {string} kebabName - Color family name as config.cjs key
481
+ * @param {Object} configMapping - palette's 11-shade mapping
482
+ * @param {Object} options - CLI options (reads .override and .quotes)
483
+ */
484
+ export function writeSemanticColors(semanticEntries, kebabName, configMapping, options) {
485
+ const camelName = toCamelCase(kebabName)
486
+ writeSemanticJSON(semanticEntries, camelName)
487
+ writeConfigMapping(kebabName, configMapping, options)
488
+ }
489
+
183
490
  /**
184
491
  * Export for CLI usage
185
492
  */
186
493
  export default {
187
494
  colorModule,
188
495
  checkIfColorModule,
189
- shades
496
+ shades,
497
+ missingHexMessage,
498
+ toCamelCase,
499
+ buildSemanticPalette,
500
+ buildSingleSemantic,
501
+ detectFamilyShadeConflict,
502
+ updateSemanticEntry,
503
+ writeSemanticJSON,
504
+ writeConfigMapping,
505
+ wrapHexWithAlpha,
506
+ normalizeAlpha,
507
+ stripFamilyKeys,
508
+ writeSemanticColors
190
509
  }
@@ -64,8 +64,10 @@ export function validateProject(silent = false) {
64
64
 
65
65
  if (projectType === 'unknown') {
66
66
  if (!silent) {
67
- logger.info('Please make sure you are running purgetss within an Alloy or Classic Project.')
68
- logger.info('For more information, visit https://purgetss.com')
67
+ logger.block(
68
+ 'Please make sure you are running purgetss within an Alloy or Classic Project.',
69
+ 'For more information, visit https://purgetss.com'
70
+ )
69
71
  }
70
72
  return false
71
73
  }