purgetss 7.7.1 → 7.11.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 (42) hide show
  1. package/README.md +28 -0
  2. package/bin/purgetss +23 -0
  3. package/dist/purgetss.ui.js +1 -1
  4. package/lib/templates/create/index.xml +1 -1
  5. package/lib/templates/purgetss.config.js.cjs +3 -1
  6. package/package.json +2 -2
  7. package/src/cli/commands/build.js +9 -4
  8. package/src/cli/commands/images.js +49 -2
  9. package/src/cli/commands/purge.js +31 -4
  10. package/src/cli/commands/shades.js +2 -2
  11. package/src/cli/utils/cli-helpers.js +15 -5
  12. package/src/cli/utils/unsupported-class-reporter.js +209 -0
  13. package/src/core/analyzers/class-extractor.js +54 -0
  14. package/src/core/analyzers/controller-svg-refs.js +154 -0
  15. package/src/core/branding/brand-config.js +7 -0
  16. package/src/core/branding/ensure-brand-section.js +4 -3
  17. package/src/core/branding/gen-feature-graphic.js +57 -0
  18. package/src/core/branding/index.js +28 -4
  19. package/src/core/branding/post-gen-notes.js +2 -2
  20. package/{experimental/completions2.js → src/core/builders/auto-utilities-builder.js} +74 -40
  21. package/src/core/builders/tailwind-builder.js +2 -2
  22. package/src/core/builders/tailwind-helpers.js +0 -444
  23. package/src/core/images/ensure-images-section.js +6 -4
  24. package/src/core/images/gen-scales.js +96 -13
  25. package/src/core/images/index.js +121 -9
  26. package/src/core/purger/icon-purger.js +7 -3
  27. package/src/core/purger/tailwind-purger.js +43 -5
  28. package/src/core/svg/cache.js +96 -0
  29. package/src/core/svg/derive-dimensions.js +120 -0
  30. package/src/core/svg/index.js +215 -0
  31. package/src/core/svg/resolve-classes.js +46 -0
  32. package/src/core/svg/sync-images.js +278 -0
  33. package/src/core/svg/tss-reader.js +134 -0
  34. package/src/dev/builders/tailwind-builder.js +3 -11
  35. package/src/shared/config-manager.js +72 -3
  36. package/src/shared/error-reporter.js +117 -0
  37. package/src/shared/helpers/colors.js +57 -13
  38. package/src/shared/helpers/core.js +0 -19
  39. package/src/shared/helpers/utils.js +146 -36
  40. package/src/shared/logger.js +12 -0
  41. package/src/shared/semantic-helpers.js +143 -0
  42. package/src/shared/validation/config-validator.js +167 -0
@@ -71,6 +71,60 @@ export function extractClasses(currentText, currentFile) {
71
71
  }
72
72
  }
73
73
 
74
+ /**
75
+ * Extract SVG image references from XML content.
76
+ *
77
+ * For each XML node whose `image` or `backgroundImage` attribute ends in `.svg`,
78
+ * capture the SVG src alongside the same node's `class` attribute (split into
79
+ * tokens). Powers the SVG image pipeline so it can pair each reference with the
80
+ * classes that determine its rendered size.
81
+ *
82
+ * Multiple references to the same SVG from different nodes are returned as
83
+ * separate entries — the caller is responsible for de-duplicating and reducing
84
+ * to a single resolved dimension.
85
+ *
86
+ * @param {string} currentText - XML content to parse.
87
+ * @param {string} currentFile - File path for error reporting.
88
+ * @returns {Array<{ src: string, classes: string[] }>} References found.
89
+ */
90
+ export function extractSvgRefsFromXml(currentText, currentFile) {
91
+ try {
92
+ const jsontext = convert.xml2json(encodeHTML(currentText), { compact: true })
93
+ const json = JSON.parse(jsontext)
94
+ const refs = []
95
+ walkXmlForSvgRefs(json, refs)
96
+ return refs
97
+ } catch (error) {
98
+ throw chalk.red(`::PurgeTSS:: Error processing: "${currentFile}"\n`, error)
99
+ }
100
+ }
101
+
102
+ function walkXmlForSvgRefs(node, out) {
103
+ if (!node || typeof node !== 'object') return
104
+ if (Array.isArray(node)) {
105
+ for (const item of node) walkXmlForSvgRefs(item, out)
106
+ return
107
+ }
108
+
109
+ const attrs = node._attributes
110
+ if (attrs && typeof attrs === 'object') {
111
+ const candidates = []
112
+ if (typeof attrs.image === 'string') candidates.push(attrs.image)
113
+ if (typeof attrs.backgroundImage === 'string') candidates.push(attrs.backgroundImage)
114
+ for (const src of candidates) {
115
+ if (!src.toLowerCase().endsWith('.svg')) continue
116
+ const cls = typeof attrs.class === 'string' ? attrs.class : ''
117
+ const classes = cls.split(/\s+/).filter(Boolean)
118
+ out.push({ src, classes })
119
+ }
120
+ }
121
+
122
+ for (const key of Object.keys(node)) {
123
+ if (key === '_attributes') continue
124
+ walkXmlForSvgRefs(node[key], out)
125
+ }
126
+ }
127
+
74
128
  /**
75
129
  * Extract only classes from XML content - COPIED exactly from original extractClassesOnly() function
76
130
  * NO CHANGES to logic, preserving 100% of original functionality
@@ -0,0 +1,154 @@
1
+ /**
2
+ * PurgeTSS - SVG reference extractor for controllers
3
+ *
4
+ * Companion to class-extractor.js for the SVG image pipeline. Walks the AST of
5
+ * each controller file looking for ObjectExpressions that pair an image (or
6
+ * backgroundImage) property pointing to an .svg with a sibling `classes`
7
+ * property. Typical shape:
8
+ *
9
+ * $.UI.create('ImageView', {
10
+ * image: '/images/logos/logo.svg',
11
+ * classes: 'w-32 h-auto'
12
+ * })
13
+ *
14
+ * Only the in-place shape counts — references built from concatenated/dynamic
15
+ * strings cannot be detected statically and must be declared manually in
16
+ * config.cjs > images.files (per the plan).
17
+ *
18
+ * @fileoverview Extract SVG references from controller .js files
19
+ * @author César Estrada
20
+ */
21
+
22
+ import * as acorn from 'acorn'
23
+
24
+ const AST_META_KEYS = new Set(['type', 'loc', 'range', 'start', 'end', 'sourceType', 'comments'])
25
+
26
+ /**
27
+ * Parse a controller's source and return SVG references paired with their
28
+ * `classes` siblings inside the same object literal.
29
+ *
30
+ * Falls back to a conservative regex scan if the parser rejects the source.
31
+ *
32
+ * @param {string} data - Controller file content.
33
+ * @returns {Array<{ src: string, classes: string[] }>}
34
+ */
35
+ export function extractSvgRefsFromController(data) {
36
+ try {
37
+ const ast = acorn.parse(data, {
38
+ ecmaVersion: 'latest',
39
+ sourceType: 'script',
40
+ allowReturnOutsideFunction: true,
41
+ allowAwaitOutsideFunction: true,
42
+ allowImportExportEverywhere: true,
43
+ allowHashBang: true
44
+ })
45
+ const out = []
46
+ walkAST(ast, out)
47
+ return out
48
+ } catch {
49
+ return extractSvgRefsRegex(data)
50
+ }
51
+ }
52
+
53
+ function walkAST(node, out) {
54
+ if (!node || typeof node !== 'object') return
55
+ if (Array.isArray(node)) {
56
+ for (const child of node) walkAST(child, out)
57
+ return
58
+ }
59
+ if (!node.type) return
60
+
61
+ if (node.type === 'ObjectExpression') {
62
+ inspectObject(node, out)
63
+ }
64
+
65
+ for (const key of Object.keys(node)) {
66
+ if (AST_META_KEYS.has(key)) continue
67
+ walkAST(node[key], out)
68
+ }
69
+ }
70
+
71
+ function inspectObject(obj, out) {
72
+ let svgSrc = null
73
+ let classes = null
74
+
75
+ for (const prop of obj.properties) {
76
+ if (!prop || prop.type !== 'Property' || prop.computed || prop.shorthand) continue
77
+ const keyName = propKeyName(prop)
78
+ if (!keyName) continue
79
+
80
+ if (keyName === 'image' || keyName === 'backgroundImage') {
81
+ const literal = stringLiteralValue(prop.value)
82
+ if (literal && literal.toLowerCase().endsWith('.svg')) {
83
+ svgSrc = literal
84
+ }
85
+ } else if (keyName === 'classes') {
86
+ classes = collectClassTokens(prop.value)
87
+ }
88
+ }
89
+
90
+ if (svgSrc) {
91
+ out.push({ src: svgSrc, classes: classes || [] })
92
+ }
93
+ }
94
+
95
+ function propKeyName(prop) {
96
+ if (prop.key.type === 'Identifier') return prop.key.name
97
+ if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') return prop.key.value
98
+ return null
99
+ }
100
+
101
+ function stringLiteralValue(node) {
102
+ if (!node) return null
103
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value
104
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
105
+ const cooked = node.quasis[0].value.cooked
106
+ return typeof cooked === 'string' ? cooked : null
107
+ }
108
+ return null
109
+ }
110
+
111
+ function collectClassTokens(node) {
112
+ if (!node) return []
113
+ if (node.type === 'Literal' && typeof node.value === 'string') {
114
+ return node.value.split(/\s+/).filter(Boolean)
115
+ }
116
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
117
+ const cooked = node.quasis[0].value.cooked
118
+ return typeof cooked === 'string' ? cooked.split(/\s+/).filter(Boolean) : []
119
+ }
120
+ if (node.type === 'ArrayExpression') {
121
+ const tokens = []
122
+ for (const el of node.elements) {
123
+ if (el && el.type === 'Literal' && typeof el.value === 'string') {
124
+ tokens.push(...el.value.split(/\s+/).filter(Boolean))
125
+ }
126
+ }
127
+ return tokens
128
+ }
129
+ return []
130
+ }
131
+
132
+ // Conservative regex fallback: look for objects that pair both keys on the
133
+ // same {...} chunk. Misses anything spread across complex expressions, but the
134
+ // AST path already covers the realistic cases — this is just a safety net.
135
+ function extractSvgRefsRegex(data) {
136
+ const out = []
137
+ const objRegex = /\{[^{}]*\}/g
138
+ for (const match of data.matchAll(objRegex)) {
139
+ const chunk = match[0]
140
+ const imgMatch = chunk.match(/\b(?:image|backgroundImage)\s*:\s*['"`]([^'"`]+\.svg)['"`]/i)
141
+ if (!imgMatch) continue
142
+ const classesMatch = chunk.match(/\bclasses\s*:\s*(?:['"`]([^'"`]+)['"`]|\[([^\]]+)\])/)
143
+ let classes = []
144
+ if (classesMatch) {
145
+ const raw = classesMatch[1] || classesMatch[2] || ''
146
+ classes = raw
147
+ .split(/[,\s]+/)
148
+ .map(t => t.trim().replace(/^['"`]|['"`]$/g, ''))
149
+ .filter(Boolean)
150
+ }
151
+ out.push({ src: imgMatch[1], classes })
152
+ }
153
+ return out
154
+ }
@@ -66,6 +66,7 @@ export function resolveBrandConfig(cliOptions, cliLogo, projectRoot) {
66
66
  ?? 19
67
67
 
68
68
  const androidLegacyPadding = cliOptions.androidLegacyPadding
69
+ ?? cliOptions.padding
69
70
  ?? padding.androidLegacy
70
71
  ?? 10
71
72
 
@@ -73,6 +74,10 @@ export function resolveBrandConfig(cliOptions, cliLogo, projectRoot) {
73
74
  ?? padding.ios
74
75
  ?? 4
75
76
 
77
+ const featureGraphicPadding = cliOptions.featureGraphicPadding
78
+ ?? padding.featureGraphic
79
+ ?? 12
80
+
76
81
  const bgColor = cliOptions.bgColor
77
82
  ?? colors.background
78
83
  ?? '#FFFFFF'
@@ -88,6 +93,7 @@ export function resolveBrandConfig(cliOptions, cliLogo, projectRoot) {
88
93
  darkLogo: pickLogo(cliOptions.darkLogo, logos.iosDark, brandDir, 'logo-dark', projectRoot),
89
94
  tintedLogo: pickLogo(cliOptions.tintedLogo, logos.iosTinted, brandDir, 'logo-tinted', projectRoot),
90
95
  splashLogo: pickLogo(cliOptions.splashLogo, logos.androidSplash, brandDir, 'logo-splash', projectRoot),
96
+ featureLogo: pickLogo(cliOptions.featureLogo, logos.featureGraphic, brandDir, 'logo-feature', projectRoot),
91
97
 
92
98
  bgColor,
93
99
  bgColorExplicit: Boolean(cliOptions.bgColor ?? colors.background),
@@ -95,6 +101,7 @@ export function resolveBrandConfig(cliOptions, cliLogo, projectRoot) {
95
101
  androidAdaptivePadding,
96
102
  androidLegacyPadding,
97
103
  iosPadding,
104
+ featureGraphicPadding,
98
105
 
99
106
  // Kitchen-sink defaults: adaptive + marketplace are always generated; only
100
107
  // notification and splash are opt-in. Config can pre-enable them.
@@ -25,9 +25,10 @@ import { logger } from './branding-logger.js'
25
25
  const BRAND_BLOCK = ` brand: {
26
26
  logos: {}, // empty = auto-discovers from purgetss/brand/
27
27
  padding: {
28
- ios: '4%', // iOS aesthetic. Range: 2% bold — 8% conservative. No launcher mask.
29
- androidLegacy: '10%', // legacy ic_launcher.png padding
30
- androidAdaptive: '19%' // adaptive foreground padding near the Android safe-zone
28
+ ios: '4%', // iOS aesthetic. Range: 2% bold — 8% conservative. No launcher mask.
29
+ androidLegacy: '10%', // legacy ic_launcher.png padding
30
+ androidAdaptive: '19%', // adaptive foreground padding near the Android safe-zone
31
+ featureGraphic: '12%' // Google Play Feature Graphic vertical padding (1024×500)
31
32
  },
32
33
  android: {
33
34
  splash: false, // also generate splash_icon.png × 5
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PurgeTSS - gen-feature-graphic
3
+ *
4
+ * Google Play Feature Graphic:
5
+ * MarketplaceArtworkFeature.png 1024×500 (Play Store listing top banner)
6
+ *
7
+ * Always flattened on bgColor — Google Play requires opaque artwork.
8
+ *
9
+ * Layout: a square logo block centered both horizontally and vertically inside
10
+ * the 1024×500 canvas. Padding is vertical-driven (top/bottom) — the inner
11
+ * box becomes side = 500 - 2*pad. The logo is scaled with `fit: 'inside'`
12
+ * so wide/tall logos preserve aspect ratio inside that square.
13
+ *
14
+ * @fileoverview Google Play Feature Graphic for Titanium branding
15
+ * @author César Estrada
16
+ */
17
+
18
+ import fs from 'fs'
19
+ import path from 'path'
20
+ import sharp from 'sharp'
21
+
22
+ const CANVAS_WIDTH = 1024
23
+ const CANVAS_HEIGHT = 500
24
+
25
+ export async function genFeatureGraphic(featureMaster, paddingPct, outRoot, opts = {}) {
26
+ const { bgColor = '#FFFFFF' } = opts
27
+ fs.mkdirSync(outRoot, { recursive: true })
28
+
29
+ const padPx = Math.floor((CANVAS_HEIGHT * paddingPct) / 100)
30
+ const inner = CANVAS_HEIGHT - 2 * padPx
31
+ const outPath = path.join(outRoot, 'MarketplaceArtworkFeature.png')
32
+
33
+ const resized = await sharp(featureMaster)
34
+ .resize({
35
+ width: inner,
36
+ height: inner,
37
+ fit: 'inside',
38
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
39
+ })
40
+ .toBuffer()
41
+
42
+ await sharp({
43
+ create: {
44
+ width: CANVAS_WIDTH,
45
+ height: CANVAS_HEIGHT,
46
+ channels: 4,
47
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
48
+ }
49
+ })
50
+ .composite([{ input: resized, gravity: 'center' }])
51
+ .flatten({ background: bgColor })
52
+ .removeAlpha()
53
+ .png({ compressionLevel: 9 })
54
+ .toFile(outPath)
55
+
56
+ return outPath
57
+ }
@@ -48,6 +48,7 @@ import { genAndroidAdaptive } from './gen-android-adaptive.js'
48
48
  import { genAndroidLegacy } from './gen-android-legacy.js'
49
49
  import { genAndroidDefault } from './gen-android-default.js'
50
50
  import { genMarketplace } from './gen-marketplace.js'
51
+ import { genFeatureGraphic } from './gen-feature-graphic.js'
51
52
  import { genNotification } from './gen-notification.js'
52
53
  import { genSplash } from './gen-splash.js'
53
54
  import { genIcLauncherXml } from './gen-ic-launcher-xml.js'
@@ -60,6 +61,7 @@ export async function runBranding(opts) {
60
61
  logo,
61
62
  iconLogo = null,
62
63
  splashLogo = null,
64
+ featureLogo = null,
63
65
  monochromeLogo = null,
64
66
  darkLogo = null,
65
67
  darkBgColor = null,
@@ -71,6 +73,7 @@ export async function runBranding(opts) {
71
73
  androidAdaptivePadding = 19,
72
74
  androidLegacyPadding = 10,
73
75
  iosPadding = 4,
76
+ featureGraphicPadding = 12,
74
77
  notification = false,
75
78
  splash = false,
76
79
  cleanupLegacy: runCleanup = false,
@@ -84,7 +87,7 @@ export async function runBranding(opts) {
84
87
  confirmOverwrites = true
85
88
  } = opts
86
89
 
87
- validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, cleanupLegacy: runCleanup })
90
+ validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, featureGraphicPadding, cleanupLegacy: runCleanup })
88
91
 
89
92
  const projectType = detectProjectType(projectRoot)
90
93
  const isInPlace = inPlace && !output
@@ -97,7 +100,7 @@ export async function runBranding(opts) {
97
100
  if (logo) {
98
101
  logger.property('Logo: ', logo)
99
102
  logger.property('Background: ', bgColor)
100
- logger.property('Padding: ', `Android adaptive ${androidAdaptivePadding}% / Android legacy ${androidLegacyPadding}% / iOS ${iosPadding}% per side`)
103
+ logger.property('Padding: ', `Android adaptive ${androidAdaptivePadding}% / Android legacy ${androidLegacyPadding}% / iOS ${iosPadding}% per side / Feature Graphic ${featureGraphicPadding}% vertical`)
101
104
  console.log()
102
105
  logger.property(isInPlace ? 'Writing IN PLACE to: ' : 'Staging: ', isInPlace ? projectRoot : stagingRoot)
103
106
  }
@@ -159,6 +162,8 @@ export async function runBranding(opts) {
159
162
  lines.push(`${stagingRoot}/DefaultIcon-Tinted.png (${tintedSrc})`)
160
163
  }
161
164
  lines.push(`${stagingRoot}/iTunesConnect.png + MarketplaceArtwork.png`)
165
+ const featureSrc = featureLogo ? `from ${featureLogo}` : 'from main logo'
166
+ lines.push(`${stagingRoot}/MarketplaceArtworkFeature.png (${featureSrc}, ${featureGraphicPadding}% vertical padding)`)
162
167
  lines.push(`${androidResStaging}/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher_{foreground,background,monochrome}.png`)
163
168
  lines.push(`${androidResStaging}/mipmap-{...}/ic_launcher.png (legacy)`)
164
169
  lines.push(`${androidResStaging}/mipmap-anydpi-v26/ic_launcher.xml`)
@@ -265,6 +270,20 @@ export async function runBranding(opts) {
265
270
  })
266
271
  generated.push(mkt.itunesConnect, mkt.marketplaceArtwork)
267
272
 
273
+ let featureMaster = tight
274
+ if (featureLogo) {
275
+ if (!fs.existsSync(featureLogo)) {
276
+ throw new Error(`Feature Graphic logo not found: ${featureLogo}`)
277
+ }
278
+ const featureBase = path.join(tempDir, '_logo_feature')
279
+ const featureResult = await prepareMaster(featureLogo, featureBase)
280
+ featureMaster = featureResult.tight
281
+ }
282
+ const featureSrcLabel = featureLogo ? 'from --feature-logo' : 'from main logo'
283
+ logger.bullet(`MarketplaceArtworkFeature.png (1024×500, ${featureSrcLabel}, ${featureGraphicPadding}% vertical padding, flattened on ${bgColor})`)
284
+ const featurePath = await genFeatureGraphic(featureMaster, featureGraphicPadding, stagingRoot, { bgColor })
285
+ generated.push(featurePath)
286
+
268
287
  // ---- Section: Android --------------------------------------------------
269
288
  logger.section('Android')
270
289
 
@@ -323,7 +342,9 @@ export async function runBranding(opts) {
323
342
  path.join(tempDir, '_logo_tinted_square.png'),
324
343
  path.join(tempDir, '_logo_tinted_tight.png'),
325
344
  path.join(tempDir, '_logo_splash_square.png'),
326
- path.join(tempDir, '_logo_splash_tight.png')
345
+ path.join(tempDir, '_logo_splash_tight.png'),
346
+ path.join(tempDir, '_logo_feature_square.png'),
347
+ path.join(tempDir, '_logo_feature_tight.png')
327
348
  ]
328
349
  for (const tmp of tmpFiles) {
329
350
  if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
@@ -385,7 +406,7 @@ function getStagingAndroidAssetsRoot(stagingRoot, projectType) {
385
406
  return null
386
407
  }
387
408
 
388
- function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, cleanupLegacy }) {
409
+ function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, featureGraphicPadding, cleanupLegacy }) {
389
410
  if (!logo && !cleanupLegacy) {
390
411
  throw new Error('Logo image path is required (unless using --cleanup-legacy alone).')
391
412
  }
@@ -404,4 +425,7 @@ function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, a
404
425
  if (iosPadding < 0 || iosPadding > 40) {
405
426
  throw new Error(`--ios-padding must be between 0 and 40 (got: ${iosPadding}).`)
406
427
  }
428
+ if (featureGraphicPadding < 0 || featureGraphicPadding > 40) {
429
+ throw new Error(`--feature-graphic-padding must be between 0 and 40 (got: ${featureGraphicPadding}).`)
430
+ }
407
431
  }
@@ -37,12 +37,12 @@ function printCompactSummary(opts) {
37
37
  logger.bullet(`Rebuild: ${chalk.gray('ti clean && ti build -p android -T emulator')}`)
38
38
  } else if (projectType === 'alloy') {
39
39
  logger.bullet(`Preview in ${chalk.yellow('Preview.app')}, then copy to project:`)
40
- console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork}.png ${projectRoot}/`))
40
+ console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork,MarketplaceArtworkFeature}.png ${projectRoot}/`))
41
41
  console.log(chalk.gray(` cp -R ${stagingRoot}/app/platform/android/res/. ${projectRoot}/app/platform/android/res/`))
42
42
  logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
43
43
  } else if (projectType === 'classic') {
44
44
  logger.bullet(`Preview in ${chalk.yellow('Preview.app')}, then copy to project:`)
45
- console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork}.png ${projectRoot}/`))
45
+ console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork,MarketplaceArtworkFeature}.png ${projectRoot}/`))
46
46
  console.log(chalk.gray(` cp -R ${stagingRoot}/platform/android/res/. ${projectRoot}/platform/android/res/`))
47
47
  logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
48
48
  } else {
@@ -7,28 +7,21 @@ import path from 'path'
7
7
  import { fileURLToPath } from 'url'
8
8
  import { createRequire } from 'module'
9
9
  import _ from 'lodash'
10
- import chalk from 'chalk'
11
10
  let saveGlossary = false
12
11
 
13
12
  const __filename = fileURLToPath(import.meta.url)
14
13
  const __dirname = path.dirname(__filename)
15
14
  const require = createRequire(import.meta.url)
16
15
  const cwd = process.cwd()
17
- import { colores } from '../src/shared/brand-colors.js'
16
+ import { colores } from '../../shared/brand-colors.js'
18
17
  export { colores }
19
- const purgeLabel = colores.purgeLabel
20
18
 
21
- import * as helpers from '../src/shared/helpers.js'
22
- import { getConfigFile } from '../src/shared/config-manager.js'
23
- import { projectsConfigJS } from '../src/shared/constants.js'
24
- const tiCompletionsFile = require('../lib/completions/titanium/completions-v3.json')
25
-
26
- const logger = {
27
- info: (...args) => console.log(purgeLabel, args.join(' ')),
28
- warn: (...args) => console.log(purgeLabel, chalk.yellow(args.join(' '))),
29
- error: (...args) => console.log(purgeLabel, chalk.red(args.join(' '))),
30
- file: (...args) => console.log(purgeLabel, chalk.yellow(args.join(' ')), 'file created!')
31
- }
19
+ import * as helpers from '../../shared/helpers.js'
20
+ import { getConfigFile } from '../../shared/config-manager.js'
21
+ import { projectsConfigJS } from '../../shared/constants.js'
22
+ import { logger } from '../../shared/logger.js'
23
+ import { registerSemanticName } from '../../shared/semantic-helpers.js'
24
+ const tiCompletionsFile = require('../../../lib/completions/titanium/completions-v3.json')
32
25
 
33
26
  // Keys whose numeric values are interpreted with `ti.ui.defaultunit` from tiapp.xml.
34
27
  // The glossary .md files for these keys receive an inline "// Unit: ..." note
@@ -174,7 +167,7 @@ function buildSubfolderIndex(folder) {
174
167
  }
175
168
 
176
169
  function glossaryBaseFolder() {
177
- if (!fs.existsSync(projectsConfigJS)) return path.resolve(__dirname, '../dist/glossary/')
170
+ if (!fs.existsSync(projectsConfigJS)) return path.resolve(__dirname, '../../../dist/glossary/')
178
171
  if (saveGlossary) return cwd + '/purgetss/glossary/'
179
172
  return ''
180
173
  }
@@ -206,7 +199,20 @@ function scaffoldGlossary() {
206
199
  }
207
200
  }
208
201
 
209
- let configFile = getConfigFile()
202
+ // getConfigFile() runs at module import time, before bin/purgetss's catch
203
+ // handler is wired up. If the config validator throws a presentable error
204
+ // (isSyntaxError), print it cleanly and exit instead of letting Node surface
205
+ // the raw stack on top of an already-formatted report.
206
+ let configFile
207
+ try {
208
+ configFile = getConfigFile()
209
+ } catch (err) {
210
+ if (err && err.isSyntaxError) {
211
+ console.error(err.message)
212
+ process.exit(1)
213
+ }
214
+ throw err
215
+ }
210
216
  configFile.purge = configFile.purge ?? { mode: 'all' }
211
217
  configFile.theme = configFile.theme ?? {}
212
218
  configFile.theme.extend = configFile.theme.extend ?? {}
@@ -236,7 +242,7 @@ function autoBuildUtilitiesTSS(options = {}) {
236
242
 
237
243
  saveGlossary = options.glossary ?? false
238
244
  scaffoldGlossary()
239
- let tailwindStyles = fs.readFileSync(path.resolve(__dirname, '../lib/templates/tailwind/custom-template.tss'), 'utf8')
245
+ let tailwindStyles = fs.readFileSync(path.resolve(__dirname, '../../../lib/templates/tailwind/custom-template.tss'), 'utf8')
240
246
  tailwindStyles += (fs.existsSync(projectsConfigJS)) ? `// config.js file updated on: ${getFileUpdatedDate(projectsConfigJS)}\n` : '// default config.js file\n'
241
247
 
242
248
  const baseValues = combineDefaultThemeWithConfigFile()
@@ -255,7 +261,7 @@ function autoBuildUtilitiesTSS(options = {}) {
255
261
  saveFile(cwd + '/purgetss/styles/utilities.tss', tailwindStyles)
256
262
  logger.file('./purgetss/styles/utilities.tss')
257
263
  } else {
258
- saveFile(path.resolve(__dirname, '../dist/utilities.tss'), tailwindStyles)
264
+ saveFile(path.resolve(__dirname, '../../../dist/utilities.tss'), tailwindStyles)
259
265
  logger.file('./dist/utilities.tss')
260
266
  }
261
267
  }
@@ -311,7 +317,7 @@ function processCompletionsClasses(_completionsWithBaseValues) {
311
317
 
312
318
  function generateGlossary(_key, _theClasses, _keyName = null) {
313
319
  let baseDestinationFolder = ''
314
- if (!fs.existsSync(projectsConfigJS)) baseDestinationFolder = path.resolve(__dirname, '../dist/glossary/')
320
+ if (!fs.existsSync(projectsConfigJS)) baseDestinationFolder = path.resolve(__dirname, '../../../dist/glossary/')
315
321
  else if (saveGlossary) baseDestinationFolder = cwd + '/purgetss/glossary/'
316
322
 
317
323
  if (baseDestinationFolder !== '') {
@@ -378,7 +384,7 @@ function getTiUIComponents(_base) {
378
384
 
379
385
  function processCompoundClasses({ ..._base }) {
380
386
  let compoundClasses = ''
381
- const compoundTemplate = require('../lib/templates/tailwind/compoundTemplate.json')
387
+ const compoundTemplate = require('../../../lib/templates/tailwind/compoundTemplate.json')
382
388
 
383
389
  _.each(compoundTemplate, (value, key) => {
384
390
  compoundClasses += generateGlossary(key, helpers.processProperties(value.description, value.template, value.base ?? { default: _base[key] }))
@@ -534,6 +540,11 @@ function combineDefaultThemeWithConfigFile() {
534
540
  }
535
541
 
536
542
  _.merge(base.colors, themeOrDefaultValues.colors, configFile.theme.extend.colors)
543
+ // Track semantic color names so opacity modifiers (bg-X/65) can later
544
+ // auto-derive an alpha-applied entry in semantic.colors.json. A value is
545
+ // "semantic" when it's a string that isn't a hex literal or a Ti reserved
546
+ // keyword.
547
+ _.each(base.colors, value => collectSemanticReferences(value))
537
548
  _.merge(base.size, themeOrDefaultValues.spacing, configFile.theme.extend.spacing)
538
549
  _.merge(base.spacing, themeOrDefaultValues.spacing, configFile.theme.extend.spacing)
539
550
 
@@ -572,8 +583,16 @@ function combineDefaultThemeWithConfigFile() {
572
583
  delete base.zIndex.auto
573
584
 
574
585
  // ! Process custom Window, View and ImageView
575
- // Merge extend values into theme (same as colors, spacing, etc.)
586
+ // Track whether the user defined each Ti Element at the theme.X (replace) level
587
+ // BEFORE merging extend into theme. This mirrors the Tailwind convention:
588
+ // theme.X → REPLACE the framework's defaults entirely
589
+ // theme.extend.X → MERGE with the framework's defaults
590
+ // Without this distinction, presets (like Window's backgroundColor: '#FFFFFF')
591
+ // leak into a strict-replace config and surface as ghost properties in app.tss.
592
+ const userReplaced = {}
576
593
  _.each(['Window', 'View', 'ImageView'], comp => {
594
+ userReplaced[comp] = !!configFile.theme[comp] && !configFile.theme.extend[comp]
595
+
577
596
  if (configFile.theme.extend[comp]) {
578
597
  configFile.theme[comp] = _.merge({}, configFile.theme[comp], configFile.theme.extend[comp])
579
598
  delete configFile.theme.extend[comp]
@@ -584,11 +603,18 @@ function combineDefaultThemeWithConfigFile() {
584
603
  }
585
604
  })
586
605
 
587
- // Merge user config WITH defaults, then write back to configFile.theme
588
- // so that getTiUIComponents/combineKeys picks up the full merged object
589
- configFile.theme.Window = _.merge({ default: { backgroundColor: '#FFFFFF' } }, configFile.theme.Window)
590
- configFile.theme.ImageView = _.merge({ ios: { hires: true } }, configFile.theme.ImageView)
591
- configFile.theme.View = _.merge({ default: { width: 'Ti.UI.SIZE', height: 'Ti.UI.SIZE' } }, configFile.theme.View)
606
+ // Apply framework defaults only when NOT in strict-replace mode. In replace
607
+ // mode the user's config is the single source of truth — presets must not be
608
+ // silently re-injected.
609
+ if (!userReplaced.Window) {
610
+ configFile.theme.Window = _.merge({ default: { backgroundColor: '#FFFFFF' } }, configFile.theme.Window)
611
+ }
612
+ if (!userReplaced.ImageView) {
613
+ configFile.theme.ImageView = _.merge({ ios: { hires: true } }, configFile.theme.ImageView)
614
+ }
615
+ if (!userReplaced.View) {
616
+ configFile.theme.View = _.merge({ default: { width: 'Ti.UI.SIZE', height: 'Ti.UI.SIZE' } }, configFile.theme.View)
617
+ }
592
618
 
593
619
  base.Window = configFile.theme.Window
594
620
  base.ImageView = configFile.theme.ImageView
@@ -610,6 +636,22 @@ function checkDeletePlugins() {
610
636
  return Array.isArray(deletePlugins) ? deletePlugins : Object.keys(deletePlugins).map(key => key)
611
637
  }
612
638
 
639
+ // Walk a color config value (possibly nested object of shades) and register
640
+ // any leaf string that points to a semantic color name in
641
+ // `semantic.colors.json` (e.g. `surface: 'surfaceColor'`,
642
+ // `brand: { DEFAULT: 'brandColor' }`).
643
+ const _semanticReservedValues = new Set(['transparent', 'currentColor', 'inherit'])
644
+ function collectSemanticReferences(value) {
645
+ if (typeof value === 'string') {
646
+ if (value.startsWith('#') || _semanticReservedValues.has(value)) return
647
+ registerSemanticName(value)
648
+ return
649
+ }
650
+ if (value && typeof value === 'object') {
651
+ _.each(value, v => collectSemanticReferences(v))
652
+ }
653
+ }
654
+
613
655
  // ! Helper Functions
614
656
  function removeDeprecatedColors(theObject) {
615
657
  delete theObject.blueGray
@@ -808,15 +850,14 @@ function generateCombinedClasses(key, data) {
808
850
  const comments = processComments(key, data)
809
851
 
810
852
  if (Object.entries(data.base).length) {
811
- _.each(data.base, (value, _key) => {
812
- if (typeof value === 'object') {
813
- _.each(value, (_value, __key) => {
814
- myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(key + '-' + _key + '-' + __key)))}': { ${key}: ${helpers.parseValue(_value)} }\n`
815
- })
853
+ const walk = (value, segments) => {
854
+ if (value && typeof value === 'object') {
855
+ _.each(value, (childValue, childKey) => walk(childValue, [...segments, childKey]))
816
856
  } else {
817
- myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(key + '-' + _key)))}': { ${key}: ${helpers.parseValue(value)} }\n`
857
+ myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(segments.join('-'))))}': { ${key}: ${helpers.parseValue(value)} }\n`
818
858
  }
819
- })
859
+ }
860
+ walk(data.base, [key])
820
861
  } else {
821
862
  _.each(data.values, (_value, _key) => {
822
863
  if (!_value.includes('deprecated')) myClasses += formatClass(key, _value, data.type === 'Array')
@@ -828,13 +869,6 @@ function generateCombinedClasses(key, data) {
828
869
  return false
829
870
  }
830
871
 
831
- function saveAutoTSS(key, classes) {
832
- if (fs.existsSync(projectsConfigJS) && saveGlossary) {
833
- makeSureFolderExists(cwd + '/purgetss/experimental/tailwind-classes/')
834
- saveFile(cwd + `/purgetss/experimental/tailwind-classes/${key}.tss`, classes)
835
- }
836
- }
837
-
838
872
  // inputType is marked as Array in completions but accepts a single value
839
873
  const nonArrayOverrides = new Set(['inputType'])
840
874
 
@@ -8,10 +8,10 @@
8
8
 
9
9
  // Import functions from their new modular locations
10
10
  import * as helpers from '../../shared/helpers.js'
11
- import { autoBuildUtilitiesTSS } from '../../../experimental/completions2.js'
11
+ import { autoBuildUtilitiesTSS } from './auto-utilities-builder.js'
12
12
 
13
13
  /**
14
- * Build Tailwind (AUTO mode using experimental completions engine)
14
+ * Build Tailwind (Auto-builds utilities.tss from config.cjs (active production path))
15
15
  * @param {Object} options - Build options
16
16
  */
17
17
  export function buildTailwind(options) {