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.
- package/README.md +38 -17
- package/bin/purgetss +140 -1
- package/dist/purgetss.ui.js +23 -26
- package/dist/utilities.tss +13 -1
- package/lib/completions/titanium/completions-v3.json +62 -1
- package/lib/templates/purgetss.config.js.cjs +15 -1
- package/lib/templates/purgetss.ui.js.cjs +22 -25
- package/package.json +3 -1
- package/src/cli/commands/brand.js +69 -0
- package/src/cli/commands/create.js +11 -7
- package/src/cli/commands/fonts.js +9 -9
- package/src/cli/commands/icon-library.js +18 -16
- package/src/cli/commands/images.js +116 -0
- package/src/cli/commands/init.js +4 -0
- package/src/cli/commands/module.js +4 -2
- package/src/cli/commands/purge.js +48 -98
- package/src/cli/commands/semantic.js +180 -0
- package/src/cli/commands/shades.js +332 -13
- package/src/cli/utils/project-detection.js +4 -2
- package/src/core/analyzers/class-extractor.js +110 -3
- package/src/core/branding/brand-config.js +111 -0
- package/src/core/branding/branding-logger.js +40 -0
- package/src/core/branding/cleanup-legacy.js +220 -0
- package/src/core/branding/ensure-brand-section.js +80 -0
- package/src/core/branding/gen-android-adaptive.js +116 -0
- package/src/core/branding/gen-android-legacy.js +63 -0
- package/src/core/branding/gen-ic-launcher-xml.js +29 -0
- package/src/core/branding/gen-ios-dark.js +70 -0
- package/src/core/branding/gen-ios-tinted.js +55 -0
- package/src/core/branding/gen-ios.js +69 -0
- package/src/core/branding/gen-marketplace.js +71 -0
- package/src/core/branding/gen-notification.js +76 -0
- package/src/core/branding/gen-splash.js +64 -0
- package/src/core/branding/index.js +336 -0
- package/src/core/branding/post-gen-notes.js +145 -0
- package/src/core/branding/prepare-master.js +108 -0
- package/src/core/branding/tiapp-reader.js +110 -0
- package/src/core/images/ensure-images-section.js +57 -0
- package/src/core/images/gen-scales.js +181 -0
- package/src/core/images/index.js +171 -0
- package/src/shared/config-manager.js +46 -0
- package/src/shared/config-writer.js +84 -0
- package/src/shared/constants.js +3 -0
- package/src/shared/logger.js +69 -4
- package/src/shared/prompt.js +64 -0
- package/src/shared/svg-utils.js +80 -0
- 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 =
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
68
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
+
}
|