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
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * PurgeTSS - Branding Logger
3
+ *
4
+ * Chalk-based presentation helpers used exclusively by the branding pipeline.
5
+ * Separate from the main PurgeTSS logger because the branding output style
6
+ * (sections, bullets, property rows) is much richer than the one-line logging
7
+ * used elsewhere in the tool.
8
+ *
9
+ * @fileoverview Logger helpers for the branding pipeline
10
+ * @author César Estrada
11
+ */
12
+
13
+ import chalk from 'chalk'
14
+
15
+ let debugMode = false
16
+
17
+ export const logger = {
18
+ setDebugMode(enabled) { debugMode = enabled },
19
+
20
+ info(message) { console.log(chalk.blue(message)) },
21
+
22
+ section(name) {
23
+ console.log()
24
+ console.log(chalk.cyan(`▸ ${name}`))
25
+ },
26
+
27
+ bullet(message) { console.log(` ${chalk.cyan('•')} ${message}`) },
28
+
29
+ property(label, value) { console.log(`${chalk.blue(label)}${value}`) },
30
+
31
+ success(message) { console.log(chalk.green(message)) },
32
+
33
+ warning(message) { console.log(chalk.yellow(message)) },
34
+
35
+ error(message) { console.error(chalk.red(message)) },
36
+
37
+ debug(message) {
38
+ if (debugMode) console.log(chalk.cyan(message))
39
+ }
40
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * PurgeTSS - cleanup-legacy
3
+ *
4
+ * Context-aware removal of legacy branding artifacts.
5
+ *
6
+ * Reads tiapp.xml via tiapp-reader and categorizes targets into:
7
+ *
8
+ * SAFE always deleted — universally obsolete (dead qualifiers)
9
+ * CONDITIONAL deleted only when project config guarantees they're unused
10
+ * (e.g. iOS legacy launch images when storyboard is enabled)
11
+ * AGGRESSIVE behind --aggressive — strongly defensible but some edge
12
+ * cases (e.g. ldpi drawables on <1% of devices)
13
+ *
14
+ * Always prints the plan before acting. Respects dryRun.
15
+ *
16
+ * @fileoverview Legacy-artifact cleanup for Titanium branding
17
+ * @author César Estrada
18
+ */
19
+
20
+ import fs from 'fs'
21
+ import path from 'path'
22
+ import { logger } from './branding-logger.js'
23
+ import { readTiapp, hasAdaptiveIcons } from './tiapp-reader.js'
24
+
25
+ export async function cleanupLegacy({ projectRoot, projectType, aggressive = false, dryRun = false }) {
26
+ const tiappPath = path.join(projectRoot, 'tiapp.xml')
27
+ const tiapp = readTiapp(tiappPath)
28
+ const adaptive = hasAdaptiveIcons(projectRoot)
29
+
30
+ const layout = getLayoutPaths(projectRoot, projectType)
31
+
32
+ const safe = []
33
+ const conditional = []
34
+ const aggressiveTargets = []
35
+
36
+ // ---- SAFE -----------------------------------------------------------
37
+ if (layout.androidImages && fs.existsSync(layout.androidImages)) {
38
+ const reason = 'Android long/notlong qualifier (dead since Android 3.0, 2011)'
39
+ for (const entry of listChildDirs(layout.androidImages, /^res-(long|notlong)-/)) {
40
+ safe.push({ path: entry, reason })
41
+ }
42
+ }
43
+
44
+ // ---- CONDITIONAL ----------------------------------------------------
45
+ if (tiapp.storyboardEnabled && layout.iphoneDir && fs.existsSync(layout.iphoneDir)) {
46
+ const reason = 'iOS legacy launch image (storyboard enabled → not consulted)'
47
+ for (const entry of listChildFiles(layout.iphoneDir, /^Default(-.+)?(@2x)?\.png$/)) {
48
+ conditional.push({ path: entry, reason })
49
+ }
50
+ }
51
+
52
+ if (tiapp.portraitOnly && layout.androidImages && fs.existsSync(layout.androidImages)) {
53
+ const reason = 'Landscape variant (app is portrait-only)'
54
+ for (const entry of listChildDirs(layout.androidImages, /(^res-.+-land-|^res-land-)/)) {
55
+ conditional.push({ path: entry, reason })
56
+ }
57
+ }
58
+
59
+ if (adaptive && layout.androidAssets) {
60
+ const defaultPng = path.join(layout.androidAssets, 'default.png')
61
+ if (fs.existsSync(defaultPng)) {
62
+ conditional.push({
63
+ path: defaultPng,
64
+ reason: 'Legacy Android splash PNG (adaptive launcher handles splash now)'
65
+ })
66
+ }
67
+ const appicon = path.join(layout.androidAssets, 'appicon.png')
68
+ if (fs.existsSync(appicon)) {
69
+ conditional.push({
70
+ path: appicon,
71
+ reason: 'Legacy appicon.png (adaptive launcher takes precedence)'
72
+ })
73
+ }
74
+ }
75
+
76
+ // ---- AGGRESSIVE -----------------------------------------------------
77
+ if (aggressive) {
78
+ const reason = 'ldpi density (<1% global market)'
79
+ if (layout.androidImages && fs.existsSync(layout.androidImages)) {
80
+ for (const entry of listChildDirs(layout.androidImages, /(^res-ldpi$|^res-.+-ldpi$)/)) {
81
+ aggressiveTargets.push({ path: entry, reason })
82
+ }
83
+ }
84
+ const ldpiDirs = [
85
+ path.join(projectRoot, 'app', 'platform', 'android', 'res', 'drawable-ldpi'),
86
+ path.join(projectRoot, 'app', 'platform', 'android', 'res', 'values-ldpi'),
87
+ path.join(projectRoot, 'app', 'platform', 'android', 'res', 'mipmap-ldpi'),
88
+ path.join(projectRoot, 'platform', 'android', 'res', 'drawable-ldpi'),
89
+ path.join(projectRoot, 'platform', 'android', 'res', 'values-ldpi'),
90
+ path.join(projectRoot, 'platform', 'android', 'res', 'mipmap-ldpi')
91
+ ]
92
+ for (const d of ldpiDirs) {
93
+ if (fs.existsSync(d)) {
94
+ aggressiveTargets.push({ path: d, reason: 'ldpi resource folder (<1% global market)' })
95
+ }
96
+ }
97
+ }
98
+
99
+ printPlan({
100
+ projectRoot, projectType, tiapp, adaptive, aggressive,
101
+ safe, conditional, aggressiveTargets
102
+ })
103
+
104
+ const total = safe.length + conditional.length + aggressiveTargets.length
105
+ if (total === 0) {
106
+ logger.success('No legacy artifacts detected. Project is already clean.')
107
+ return { removed: 0, bytes: 0 }
108
+ }
109
+
110
+ if (dryRun) {
111
+ logger.info('Dry-run mode — nothing deleted. Re-run without --dry-run to apply.')
112
+ return { removed: 0, bytes: 0 }
113
+ }
114
+
115
+ let removed = 0
116
+ let bytes = 0
117
+ for (const bucket of [safe, conditional, aggressiveTargets]) {
118
+ for (const { path: target } of bucket) {
119
+ const size = getSizeBytes(target)
120
+ fs.rmSync(target, { recursive: true, force: true })
121
+ bytes += size
122
+ removed += 1
123
+ logger.success(`Removed ${path.relative(projectRoot, target)}`)
124
+ }
125
+ }
126
+ return { removed, bytes }
127
+ }
128
+
129
+ function getLayoutPaths(projectRoot, projectType) {
130
+ if (projectType === 'alloy') {
131
+ return {
132
+ androidImages: path.join(projectRoot, 'app', 'assets', 'android', 'images'),
133
+ iphoneDir: path.join(projectRoot, 'app', 'assets', 'iphone'),
134
+ androidAssets: path.join(projectRoot, 'app', 'assets', 'android')
135
+ }
136
+ }
137
+ if (projectType === 'classic') {
138
+ return {
139
+ androidImages: path.join(projectRoot, 'Resources', 'android', 'images'),
140
+ iphoneDir: path.join(projectRoot, 'Resources', 'iphone'),
141
+ androidAssets: path.join(projectRoot, 'Resources', 'android')
142
+ }
143
+ }
144
+ return { androidImages: null, iphoneDir: null, androidAssets: null }
145
+ }
146
+
147
+ function listChildDirs(parent, regex) {
148
+ if (!fs.existsSync(parent)) return []
149
+ return fs.readdirSync(parent)
150
+ .filter((name) => regex.test(name))
151
+ .map((name) => path.join(parent, name))
152
+ .filter((p) => fs.statSync(p).isDirectory())
153
+ }
154
+
155
+ function listChildFiles(parent, regex) {
156
+ if (!fs.existsSync(parent)) return []
157
+ return fs.readdirSync(parent)
158
+ .filter((name) => regex.test(name))
159
+ .map((name) => path.join(parent, name))
160
+ .filter((p) => fs.statSync(p).isFile())
161
+ }
162
+
163
+ function getSizeBytes(target) {
164
+ try {
165
+ const stat = fs.statSync(target)
166
+ if (stat.isFile()) return stat.size
167
+ let total = 0
168
+ for (const name of fs.readdirSync(target)) {
169
+ total += getSizeBytes(path.join(target, name))
170
+ }
171
+ return total
172
+ } catch {
173
+ return 0
174
+ }
175
+ }
176
+
177
+ function formatKb(bytes) {
178
+ return `${Math.round(bytes / 1024)}K`
179
+ }
180
+
181
+ function printPlan({ projectRoot, projectType, tiapp, adaptive, aggressive, safe, conditional, aggressiveTargets }) {
182
+ console.log()
183
+ logger.warning('⚠ WARNING — --cleanup-legacy deletes files permanently.')
184
+ logger.warning(' Recommended: commit your project with git before running without --dry-run.')
185
+ console.log()
186
+ console.log('Cleanup plan')
187
+ console.log(` Project: ${projectRoot} (${projectType})`)
188
+ console.log(` Storyboard: ${tiapp.storyboardEnabled ? 'enabled' : 'disabled'}`)
189
+ console.log(` Orientation: ${tiapp.portraitOnly ? 'portrait-only' : 'landscape allowed'}`)
190
+ console.log(` Adaptive icons: ${adaptive ? 'present' : 'not detected'}`)
191
+ console.log(` Aggressive mode: ${aggressive ? 'on (includes ldpi)' : 'off'}`)
192
+
193
+ if (safe.length) {
194
+ console.log()
195
+ logger.success('SAFE — universally obsolete')
196
+ for (const { path: p, reason } of safe) {
197
+ console.log(` ${formatKb(getSizeBytes(p)).padEnd(6)} ${path.relative(projectRoot, p)}`)
198
+ console.log(` ${reason}`)
199
+ }
200
+ }
201
+ if (conditional.length) {
202
+ console.log()
203
+ logger.warning('CONDITIONAL — safe given your project config')
204
+ for (const { path: p, reason } of conditional) {
205
+ console.log(` ${formatKb(getSizeBytes(p)).padEnd(6)} ${path.relative(projectRoot, p)}`)
206
+ console.log(` ${reason}`)
207
+ }
208
+ }
209
+ if (aggressiveTargets.length) {
210
+ console.log()
211
+ logger.error('AGGRESSIVE — --aggressive enabled')
212
+ for (const { path: p, reason } of aggressiveTargets) {
213
+ console.log(` ${formatKb(getSizeBytes(p)).padEnd(6)} ${path.relative(projectRoot, p)}`)
214
+ console.log(` ${reason}`)
215
+ }
216
+ }
217
+ const total = safe.length + conditional.length + aggressiveTargets.length
218
+ console.log()
219
+ console.log(` Total: ${total} item(s) to remove`)
220
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * PurgeTSS - Ensure `brand:` section exists in purgetss/config.cjs
3
+ *
4
+ * When a project was initialized before `brand` was introduced, its
5
+ * config.cjs won't have a `brand:` section. On first invocation of
6
+ * `purgetss brand`, we patch the file to insert the default block
7
+ * between `purge:` and `theme:`. The user's existing keys are untouched.
8
+ *
9
+ * Rationale:
10
+ * - Keeps the config self-documenting — the user sees the defaults in
11
+ * their own file rather than having to look them up in docs.
12
+ * - Mirrors how `ensureConfig()` creates the file from the template when
13
+ * it's missing altogether.
14
+ * - Non-destructive: only adds if absent, never overwrites existing values.
15
+ *
16
+ * @fileoverview Auto-injects the `brand:` section on first brand run
17
+ * @author César Estrada
18
+ */
19
+
20
+ import fs from 'fs'
21
+ import chalk from 'chalk'
22
+ import { projectsConfigJS, projectsPurge_TSS_Brand_Folder } from '../../shared/constants.js'
23
+ import { logger } from './branding-logger.js'
24
+
25
+ const BRAND_BLOCK = ` brand: {
26
+ splash: false, // also generate splash_icon.png × 5
27
+ padding: '15%', // Android safe-zone. Range: 12% tight (mature logos) — 20% conservative. Spec floor 19.44%.
28
+ iosPadding: '4%', // iOS aesthetic. Range: 2% bold — 8% conservative. No launcher mask.
29
+ darkBgColor: null, // opaque dark bg for DefaultIcon-Dark.png (null = transparent per Apple HIG)
30
+ bgColor: '#FFFFFF', // Android adaptive bg + iOS/marketplace flatten
31
+ notification: false, // also generate ic_stat_notify.png × 5
32
+ confirmOverwrites: true // prompt before overwriting files (set false to skip)
33
+ },
34
+ `
35
+
36
+ /**
37
+ * If the project's config.cjs exists but has no `brand:` key, inject the
38
+ * default block between `purge:` and `theme:`. Prints a notice when it does.
39
+ *
40
+ * Silently skips if:
41
+ * - config.cjs doesn't exist (will be created by ensureConfig() elsewhere)
42
+ * - `brand:` is already present
43
+ * - file can't be patched safely (structure doesn't match expectation)
44
+ */
45
+ export function ensureBrandSection() {
46
+ // Always make sure the logos folder exists — mirrors how init creates
47
+ // `purgetss/fonts/` empty so the user can see where assets go.
48
+ if (!fs.existsSync(projectsPurge_TSS_Brand_Folder)) {
49
+ fs.mkdirSync(projectsPurge_TSS_Brand_Folder, { recursive: true })
50
+ }
51
+
52
+ if (!fs.existsSync(projectsConfigJS)) return
53
+
54
+ const original = fs.readFileSync(projectsConfigJS, 'utf8')
55
+
56
+ // Already present — nothing to do.
57
+ if (/^\s*brand\s*:/m.test(original)) return
58
+
59
+ // Insert before the `theme:` key. Regex captures the indentation so we
60
+ // preserve whatever the user's style is (2-space, 4-space, etc.).
61
+ const match = original.match(/(^\s*)theme\s*:/m)
62
+ if (!match) {
63
+ // Non-standard layout — don't risk corrupting it. User can add manually.
64
+ return
65
+ }
66
+
67
+ const patched = original.replace(match[0], `${BRAND_BLOCK}${match[0]}`)
68
+
69
+ try {
70
+ fs.writeFileSync(projectsConfigJS, patched, 'utf8')
71
+ console.log()
72
+ logger.success(`Added ${chalk.cyan('brand:')} section to ${chalk.cyan('./purgetss/config.cjs')} with default values.`)
73
+ console.log(' Edit that block to customize brand defaults (bgColor, padding, etc.).')
74
+ console.log(' CLI flags always win over config values.')
75
+ console.log()
76
+ } catch (err) {
77
+ logger.warning(`Could not auto-add brand: section to config.cjs (${err.message}).`)
78
+ logger.warning('The command will still run using built-in defaults.')
79
+ }
80
+ }