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
@@ -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
  }
@@ -14,6 +14,7 @@ import _ from 'lodash'
14
14
  import chalk from 'chalk'
15
15
  import convert from 'xml-js'
16
16
  import traverse from 'traverse'
17
+ import * as acorn from 'acorn'
17
18
 
18
19
  /**
19
20
  * Get unique classes from all XML and controller files - COPIED exactly from original getUniqueClasses() function
@@ -98,7 +99,7 @@ export function extractClassesOnly(currentText, currentFile) {
98
99
  * @param {string} line - Line of code from controller file
99
100
  * @returns {Array} Array of class names found in line
100
101
  */
101
- function extractWordsFromLine(line) {
102
+ function extractWordsFromLineRegex(line) {
102
103
  const patterns = [
103
104
  {
104
105
  // apply: 'classes'
@@ -155,12 +156,12 @@ function extractWordsFromLine(line) {
155
156
  * @param {string} data - Controller file content
156
157
  * @returns {Array} Array of class names found in controller
157
158
  */
158
- function processControllers(data) {
159
+ function processControllersRegex(data) {
159
160
  const allWords = []
160
161
  const lines = data.split(/\r?\n/)
161
162
 
162
163
  lines.forEach(line => {
163
- const words = extractWordsFromLine(line)
164
+ const words = extractWordsFromLineRegex(line)
164
165
  if (words.length > 0) {
165
166
  allWords.push(...words)
166
167
  }
@@ -169,6 +170,112 @@ function processControllers(data) {
169
170
  return allWords
170
171
  }
171
172
 
173
+ const AST_META_KEYS = new Set(['type', 'loc', 'range', 'start', 'end', 'sourceType', 'comments'])
174
+
175
+ function collectLiterals(node, out) {
176
+ if (!node || typeof node !== 'object' || !node.type) return
177
+
178
+ switch (node.type) {
179
+ case 'Literal':
180
+ if (typeof node.value === 'string') {
181
+ node.value.split(/\s+/).forEach(token => { if (token) out.push(token) })
182
+ }
183
+ return
184
+
185
+ case 'TemplateLiteral':
186
+ if (node.expressions.length === 0 && node.quasis.length > 0) {
187
+ const cooked = node.quasis[0].value.cooked
188
+ if (typeof cooked === 'string') {
189
+ cooked.split(/\s+/).forEach(token => { if (token) out.push(token) })
190
+ }
191
+ }
192
+ return
193
+
194
+ case 'ArrayExpression':
195
+ for (const element of node.elements) {
196
+ if (element && element.type !== 'SpreadElement') collectLiterals(element, out)
197
+ }
198
+ return
199
+
200
+ case 'ConditionalExpression':
201
+ collectLiterals(node.consequent, out)
202
+ collectLiterals(node.alternate, out)
203
+ return
204
+
205
+ default:
206
+ return
207
+ }
208
+ }
209
+
210
+ function walkAST(node, out) {
211
+ if (!node || typeof node !== 'object') return
212
+
213
+ if (Array.isArray(node)) {
214
+ for (const child of node) walkAST(child, out)
215
+ return
216
+ }
217
+
218
+ if (!node.type) return
219
+
220
+ if (node.type === 'Property' && !node.computed && !node.shorthand && node.key) {
221
+ const keyName = node.key.type === 'Identifier'
222
+ ? node.key.name
223
+ : (node.key.type === 'Literal' ? node.key.value : undefined)
224
+ if (keyName === 'classes' || keyName === 'apply') {
225
+ collectLiterals(node.value, out)
226
+ }
227
+ }
228
+
229
+ if (node.type === 'CallExpression' && node.callee && node.arguments.length >= 2) {
230
+ const callee = node.callee
231
+ let match = false
232
+ if (
233
+ callee.type === 'MemberExpression' &&
234
+ !callee.computed &&
235
+ callee.property &&
236
+ callee.property.type === 'Identifier' &&
237
+ /Class$/.test(callee.property.name)
238
+ ) {
239
+ match = true
240
+ } else if (callee.type === 'Identifier' && callee.name === 'resetClass') {
241
+ match = true
242
+ }
243
+ if (match) collectLiterals(node.arguments[1], out)
244
+ }
245
+
246
+ for (const key of Object.keys(node)) {
247
+ if (AST_META_KEYS.has(key)) continue
248
+ walkAST(node[key], out)
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Process a controller file's source and return utility classes referenced by
254
+ * the whitelisted expression shapes (`classes:`, `apply:`, `.xxxClass(target, value)`,
255
+ * `resetClass(target, value)`). Falls back to the per-line regex scanner when
256
+ * the parser rejects the source.
257
+ *
258
+ * @param {string} data - Controller file content
259
+ * @returns {Array} Array of class name tokens
260
+ */
261
+ export function processControllers(data) {
262
+ try {
263
+ const ast = acorn.parse(data, {
264
+ ecmaVersion: 'latest',
265
+ sourceType: 'script',
266
+ allowReturnOutsideFunction: true,
267
+ allowAwaitOutsideFunction: true,
268
+ allowImportExportEverywhere: true,
269
+ allowHashBang: true
270
+ })
271
+ const out = []
272
+ walkAST(ast, out)
273
+ return out
274
+ } catch {
275
+ return processControllersRegex(data)
276
+ }
277
+ }
278
+
172
279
  /**
173
280
  * Encode HTML entities - COPIED exactly from original encodeHTML() function
174
281
  * NO CHANGES to logic, preserving 100% of original functionality
@@ -0,0 +1,111 @@
1
+ /**
2
+ * PurgeTSS - Brand command config resolver
3
+ *
4
+ * Merges three sources of configuration, in this precedence order:
5
+ * 1. CLI flags (highest priority)
6
+ * 2. `brand: { ... }` section from purgetss/config.cjs
7
+ * 3. Built-in defaults (lowest priority)
8
+ *
9
+ * Also auto-discovers logo images inside `./purgetss/brand/` following the
10
+ * project convention:
11
+ * logo.{svg,png} → required (main logo)
12
+ * logo-mono.{svg,png} → optional (monochrome layer + notifications)
13
+ * logo-dark.{svg,png} → optional (iOS 18+ dark variant)
14
+ * logo-tinted.{svg,png} → optional (iOS 18+ tinted variant)
15
+ *
16
+ * CLI --monochrome-logo / --dark-logo / --tinted-logo always override
17
+ * discovery, and the positional argument overrides the main logo.
18
+ *
19
+ * @fileoverview Brand config + logo discovery
20
+ * @author César Estrada
21
+ */
22
+
23
+ import fs from 'fs'
24
+ import path from 'path'
25
+ import { getConfigFile } from '../../shared/config-manager.js'
26
+
27
+ const BRAND_DIR = 'purgetss/brand'
28
+ const SUPPORTED_EXTS = ['svg', 'png']
29
+
30
+ /**
31
+ * Find the first existing file matching <baseName>.<ext> for each supported ext.
32
+ * @param {string} baseDir - Absolute path to the search directory
33
+ * @param {string} baseName - Filename without extension (e.g. 'logo-mono')
34
+ * @returns {string|null} Absolute path to the first match, or null
35
+ */
36
+ function findLogoFile(baseDir, baseName) {
37
+ for (const ext of SUPPORTED_EXTS) {
38
+ const candidate = path.join(baseDir, `${baseName}.${ext}`)
39
+ if (fs.existsSync(candidate)) return candidate
40
+ }
41
+ return null
42
+ }
43
+
44
+ /**
45
+ * Build the final opts object for runBranding().
46
+ *
47
+ * @param {Object} cliOptions - Raw options from Commander
48
+ * @param {string|undefined} cliLogo - Positional logo arg from Commander
49
+ * @param {string} projectRoot - Absolute project root
50
+ * @returns {Object} Resolved options
51
+ */
52
+ export function resolveBrandConfig(cliOptions, cliLogo, projectRoot) {
53
+ const brandConfig = loadBrandSection()
54
+ const brandDir = path.join(projectRoot, BRAND_DIR)
55
+
56
+ const resolved = {
57
+ logo: pickLogo(cliLogo, brandConfig.logo, brandDir, 'logo', projectRoot),
58
+ monochromeLogo: pickLogo(cliOptions.monochromeLogo, brandConfig.monochromeLogo, brandDir, 'logo-mono', projectRoot),
59
+ darkLogo: pickLogo(cliOptions.darkLogo, brandConfig.darkLogo, brandDir, 'logo-dark', projectRoot),
60
+ tintedLogo: pickLogo(cliOptions.tintedLogo, brandConfig.tintedLogo, brandDir, 'logo-tinted', projectRoot),
61
+
62
+ bgColor: cliOptions.bgColor ?? brandConfig.bgColor ?? '#FFFFFF',
63
+ bgColorExplicit: Boolean(cliOptions.bgColor ?? brandConfig.bgColor),
64
+ darkBgColor: cliOptions.darkBgColor ?? brandConfig.darkBgColor ?? null,
65
+ padding: cliOptions.padding ?? brandConfig.padding ?? 15,
66
+ iosPadding: cliOptions.iosPadding ?? brandConfig.iosPadding ?? 4,
67
+
68
+ // Kitchen-sink defaults: adaptive + marketplace are always generated; only
69
+ // notification and splash are opt-in. Config can pre-enable them.
70
+ notification: Boolean(cliOptions.notification ?? brandConfig.notification ?? false),
71
+ splash: Boolean(cliOptions.splash ?? brandConfig.splash ?? false),
72
+
73
+ withDark: cliOptions.dark !== false && (brandConfig.dark ?? true),
74
+ withTinted: cliOptions.tinted !== false && (brandConfig.tinted ?? true),
75
+
76
+ cleanupLegacy: Boolean(cliOptions.cleanupLegacy),
77
+ aggressive: Boolean(cliOptions.aggressive),
78
+ projectRoot,
79
+ output: cliOptions.output || null,
80
+ dryRun: Boolean(cliOptions.dryRun),
81
+ notes: Boolean(cliOptions.notes),
82
+ confirmOverwrites: brandConfig.confirmOverwrites !== false
83
+ }
84
+
85
+ return resolved
86
+ }
87
+
88
+ /**
89
+ * @returns {Object} The `brand` section of the resolved config, or {} if missing/invalid.
90
+ */
91
+ function loadBrandSection() {
92
+ try {
93
+ const cfg = getConfigFile()
94
+ if (cfg && typeof cfg === 'object' && cfg.brand && typeof cfg.brand === 'object') {
95
+ return cfg.brand
96
+ }
97
+ } catch {
98
+ // Config file missing or invalid — fall back to empty defaults.
99
+ }
100
+ return {}
101
+ }
102
+
103
+ /**
104
+ * Resolve a logo path with proper precedence.
105
+ * Config-relative paths are resolved against projectRoot.
106
+ */
107
+ function pickLogo(cliValue, configValue, brandDir, baseName, projectRoot) {
108
+ if (cliValue) return path.resolve(cliValue)
109
+ if (configValue) return path.isAbsolute(configValue) ? configValue : path.resolve(projectRoot, configValue)
110
+ return findLogoFile(brandDir, baseName)
111
+ }