purgetss 7.9.0 → 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 (36) hide show
  1. package/README.md +21 -1
  2. package/bin/purgetss +13 -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 +1 -1
  7. package/src/cli/commands/images.js +41 -2
  8. package/src/cli/commands/purge.js +15 -2
  9. package/src/cli/utils/cli-helpers.js +15 -5
  10. package/src/cli/utils/unsupported-class-reporter.js +3 -3
  11. package/src/core/analyzers/class-extractor.js +54 -0
  12. package/src/core/analyzers/controller-svg-refs.js +154 -0
  13. package/src/core/branding/brand-config.js +7 -0
  14. package/src/core/branding/ensure-brand-section.js +4 -3
  15. package/src/core/branding/gen-feature-graphic.js +57 -0
  16. package/src/core/branding/index.js +28 -4
  17. package/src/core/branding/post-gen-notes.js +2 -2
  18. package/src/core/builders/auto-utilities-builder.js +20 -15
  19. package/src/core/images/ensure-images-section.js +6 -4
  20. package/src/core/images/gen-scales.js +82 -17
  21. package/src/core/images/index.js +117 -12
  22. package/src/core/purger/icon-purger.js +7 -3
  23. package/src/core/purger/tailwind-purger.js +3 -1
  24. package/src/core/svg/cache.js +96 -0
  25. package/src/core/svg/derive-dimensions.js +120 -0
  26. package/src/core/svg/index.js +215 -0
  27. package/src/core/svg/resolve-classes.js +46 -0
  28. package/src/core/svg/sync-images.js +278 -0
  29. package/src/core/svg/tss-reader.js +134 -0
  30. package/src/dev/builders/tailwind-builder.js +18 -0
  31. package/src/shared/config-manager.js +72 -3
  32. package/src/shared/error-reporter.js +117 -0
  33. package/src/shared/helpers/colors.js +57 -13
  34. package/src/shared/helpers/utils.js +46 -8
  35. package/src/shared/logger.js +12 -0
  36. package/src/shared/validation/config-validator.js +167 -0
@@ -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 {
@@ -199,7 +199,20 @@ function scaffoldGlossary() {
199
199
  }
200
200
  }
201
201
 
202
- 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
+ }
203
216
  configFile.purge = configFile.purge ?? { mode: 'all' }
204
217
  configFile.theme = configFile.theme ?? {}
205
218
  configFile.theme.extend = configFile.theme.extend ?? {}
@@ -837,15 +850,14 @@ function generateCombinedClasses(key, data) {
837
850
  const comments = processComments(key, data)
838
851
 
839
852
  if (Object.entries(data.base).length) {
840
- _.each(data.base, (value, _key) => {
841
- if (typeof value === 'object') {
842
- _.each(value, (_value, __key) => {
843
- myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(key + '-' + _key + '-' + __key)))}': { ${key}: ${helpers.parseValue(_value)} }\n`
844
- })
853
+ const walk = (value, segments) => {
854
+ if (value && typeof value === 'object') {
855
+ _.each(value, (childValue, childKey) => walk(childValue, [...segments, childKey]))
845
856
  } else {
846
- myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(key + '-' + _key)))}': { ${key}: ${helpers.parseValue(value)} }\n`
857
+ myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(segments.join('-'))))}': { ${key}: ${helpers.parseValue(value)} }\n`
847
858
  }
848
- })
859
+ }
860
+ walk(data.base, [key])
849
861
  } else {
850
862
  _.each(data.values, (_value, _key) => {
851
863
  if (!_value.includes('deprecated')) myClasses += formatClass(key, _value, data.type === 'Array')
@@ -857,13 +869,6 @@ function generateCombinedClasses(key, data) {
857
869
  return false
858
870
  }
859
871
 
860
- function saveAutoTSS(key, classes) {
861
- if (fs.existsSync(projectsConfigJS) && saveGlossary) {
862
- makeSureFolderExists(cwd + '/purgetss/glossary/tailwind-classes/')
863
- saveFile(cwd + `/purgetss/glossary/tailwind-classes/${key}.tss`, classes)
864
- }
865
- }
866
-
867
872
  // inputType is marked as Array in completions but accepts a single value
868
873
  const nonArrayOverrides = new Set(['inputType'])
869
874
 
@@ -22,7 +22,9 @@ import { logger } from '../branding/branding-logger.js'
22
22
  const IMAGES_BLOCK = ` images: {
23
23
  quality: 85, // JPEG/WebP/AVIF quality (0-100)
24
24
  format: null, // null = keep original; 'webp' | 'jpeg' | 'png' to convert every image
25
- confirmOverwrites: true // prompt before overwriting files (set false to skip)
25
+ autoSync: true, // false = SVG pipeline computes dims but doesn't write to images.files
26
+ confirmOverwrites: true, // prompt before overwriting files (set false to skip)
27
+ files: [] // per-file overrides: [{ filename: 'images/<sub>/<name>.<ext>', width, height? }]
26
28
  },
27
29
  `
28
30
 
@@ -47,11 +49,11 @@ export function ensureImagesSection() {
47
49
  fs.writeFileSync(projectsConfigJS, patched, 'utf8')
48
50
  console.log()
49
51
  logger.success(`Added ${chalk.cyan('images:')} section to ${chalk.cyan('./purgetss/config.cjs')} with default values.`)
50
- console.log(` Edit that block to customize defaults (quality, format).`)
51
- console.log(` CLI flags always win over config values.`)
52
+ console.log(' Edit that block to customize defaults (quality, format).')
53
+ console.log(' CLI flags always win over config values.')
52
54
  console.log()
53
55
  } catch (err) {
54
56
  logger.warning(`Could not auto-add images: section to config.cjs (${err.message}).`)
55
- logger.warning(`The command will still run using built-in defaults.`)
57
+ logger.warning('The command will still run using built-in defaults.')
56
58
  }
57
59
  }
@@ -78,21 +78,44 @@ export const IPHONE_SCALES = Object.freeze([
78
78
  // 1, 2, 3 for iPhone) only because every entry in *_SCALES is normalized to
79
79
  // n/4 with the largest scale (xxxhdpi/@4x) at 4/4. If a future density is
80
80
  // added beyond xxxhdpi, this conversion factor needs to be revisited.
81
- function computeScaleTarget(srcMeta, factor, baseWidth) {
82
- if (baseWidth == null) {
81
+ //
82
+ // `baseHeight` pins the height explicitly (e.g. SVG pipeline resolved both
83
+ // w-* and h-* to numbers); when omitted, height follows the source aspect.
84
+ function computeScaleTarget(srcMeta, factor, baseWidth, baseHeight) {
85
+ // No pin in either direction → fall back to the source as the 4× master.
86
+ if (baseWidth == null && baseHeight == null) {
83
87
  return {
84
88
  targetWidth: Math.max(1, Math.round(srcMeta.width * factor)),
85
89
  targetHeight: Math.max(1, Math.round(srcMeta.height * factor))
86
90
  }
87
91
  }
88
92
  const multiplier = factor * 4
89
- const aspect = srcMeta.width > 0 ? (srcMeta.height / srcMeta.width) : 1
90
- return {
91
- targetWidth: Math.max(1, Math.round(baseWidth * multiplier)),
92
- targetHeight: Math.max(1, Math.round(baseWidth * multiplier * aspect))
93
+ const widthOverHeight = srcMeta.width > 0 && srcMeta.height > 0
94
+ ? srcMeta.width / srcMeta.height
95
+ : 1
96
+ const heightOverWidth = srcMeta.width > 0 && srcMeta.height > 0
97
+ ? srcMeta.height / srcMeta.width
98
+ : 1
99
+
100
+ if (baseWidth != null) {
101
+ const targetWidth = Math.max(1, Math.round(baseWidth * multiplier))
102
+ const targetHeight = baseHeight != null
103
+ ? Math.max(1, Math.round(baseHeight * multiplier))
104
+ : Math.max(1, Math.round(baseWidth * multiplier * heightOverWidth))
105
+ return { targetWidth, targetHeight }
93
106
  }
107
+
108
+ // Height pinned, width derived from inverse aspect.
109
+ const targetHeight = Math.max(1, Math.round(baseHeight * multiplier))
110
+ const targetWidth = Math.max(1, Math.round(baseHeight * multiplier * widthOverHeight))
111
+ return { targetWidth, targetHeight }
94
112
  }
95
113
 
114
+ // Hard ceiling for any individual PNG output. Mirrors the constant exported
115
+ // from the SVG pipeline; centralizing it here would create a cycle, so we keep
116
+ // a local copy and rely on derive-dimensions to enforce the dp-side budget.
117
+ const MAX_OUTPUT_PIXELS = 4096
118
+
96
119
  /**
97
120
  * Scale a source image into all Android density variants.
98
121
  *
@@ -105,18 +128,19 @@ function computeScaleTarget(srcMeta, factor, baseWidth) {
105
128
  * @returns {Promise<string[]>} Paths written
106
129
  */
107
130
  export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts = {}) {
108
- const { format = null, quality = 85, baseWidth = null } = opts
131
+ const { format = null, quality = 85, baseWidth = null, baseHeight = null, opacity = null, padding = null } = opts
109
132
  const src = await readSource(sourceFile)
110
133
  const written = []
111
134
 
112
135
  for (const { name, factor } of ANDROID_SCALES) {
113
- const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth)
136
+ const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth, baseHeight)
137
+ assertWithinCap(targetWidth, targetHeight, sourceFile, name)
114
138
 
115
139
  const outDir = path.join(androidBaseDir, name, path.dirname(relPath))
116
140
  fs.mkdirSync(outDir, { recursive: true })
117
141
 
118
142
  const outPath = path.join(outDir, renameWithFormat(path.basename(relPath), format, src.isSvg))
119
- await writeScaled(src, outPath, targetWidth, targetHeight, format, quality)
143
+ await writeScaled(src, outPath, targetWidth, targetHeight, format, quality, opacity, padding)
120
144
  written.push(outPath)
121
145
  }
122
146
  return written
@@ -132,7 +156,7 @@ export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts
132
156
  * @returns {Promise<string[]>} Paths written
133
157
  */
134
158
  export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts = {}) {
135
- const { format = null, quality = 85, baseWidth = null } = opts
159
+ const { format = null, quality = 85, baseWidth = null, baseHeight = null, opacity = null, padding = null } = opts
136
160
  const src = await readSource(sourceFile)
137
161
  const written = []
138
162
 
@@ -141,7 +165,8 @@ export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts =
141
165
  fs.mkdirSync(outDir, { recursive: true })
142
166
 
143
167
  for (const { suffix, factor } of IPHONE_SCALES) {
144
- const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth)
168
+ const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth, baseHeight)
169
+ assertWithinCap(targetWidth, targetHeight, sourceFile, suffix || '@1x')
145
170
 
146
171
  // SVG sources can't be written as SVG by Sharp — fall back to PNG if the
147
172
  // user didn't specify an explicit output format.
@@ -149,12 +174,21 @@ export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts =
149
174
  const outName = `${parsed.name}${suffix}${ext}`
150
175
  const outPath = path.join(outDir, outName)
151
176
 
152
- await writeScaled(src, outPath, targetWidth, targetHeight, format, quality)
177
+ await writeScaled(src, outPath, targetWidth, targetHeight, format, quality, opacity, padding)
153
178
  written.push(outPath)
154
179
  }
155
180
  return written
156
181
  }
157
182
 
183
+ function assertWithinCap(width, height, sourceFile, label) {
184
+ if (width > MAX_OUTPUT_PIXELS || height > MAX_OUTPUT_PIXELS) {
185
+ throw new Error(
186
+ `${path.basename(sourceFile)} at ${label} would render ${width}×${height}px, ` +
187
+ `which exceeds the ${MAX_OUTPUT_PIXELS}px cap. Reduce the resolved width or override it manually in config.cjs > images.files.`
188
+ )
189
+ }
190
+ }
191
+
158
192
  function renameWithFormat(filename, format, isSvg = false) {
159
193
  if (format) {
160
194
  const parsed = path.parse(filename)
@@ -168,15 +202,46 @@ function renameWithFormat(filename, format, isSvg = false) {
168
202
  return filename
169
203
  }
170
204
 
171
- async function writeScaled(src, outPath, width, height, format, quality) {
172
- const targetMax = Math.max(width, height)
205
+ async function writeScaled(src, outPath, width, height, format, quality, opacity, paddingPct) {
206
+ // Padding shrinks the rendered image inside the same canvas so each density
207
+ // gets symmetric transparent borders. Computed from the canvas dimensions so
208
+ // the visual ratio (e.g. 15%) is identical across every density variant.
209
+ const padX = paddingPct ? Math.floor(width * paddingPct / 100) : 0
210
+ const padY = paddingPct ? Math.floor(height * paddingPct / 100) : 0
211
+ const innerW = Math.max(1, width - 2 * padX)
212
+ const innerH = Math.max(1, height - 2 * padY)
213
+ const targetMax = Math.max(innerW, innerH)
214
+
173
215
  let pipeline = buildScalePipeline(src, targetMax).resize({
174
- width,
175
- height,
216
+ width: innerW,
217
+ height: innerH,
176
218
  fit: 'contain',
177
219
  background: { r: 0, g: 0, b: 0, alpha: 0 }
178
220
  })
179
221
 
222
+ if (padX > 0 || padY > 0) {
223
+ pipeline = pipeline.extend({
224
+ top: padY,
225
+ bottom: padY,
226
+ left: padX,
227
+ right: padX,
228
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
229
+ })
230
+ }
231
+
232
+ // Apply opacity by multiplying the dest alpha against a uniform-alpha tile.
233
+ // `dest-in` keeps RGB and multiplies dest alpha by source alpha (opacity/100).
234
+ if (opacity != null && opacity < 100) {
235
+ pipeline = pipeline
236
+ .ensureAlpha()
237
+ .composite([{
238
+ input: Buffer.from([255, 255, 255, Math.round(255 * opacity / 100)]),
239
+ raw: { width: 1, height: 1, channels: 4 },
240
+ tile: true,
241
+ blend: 'dest-in'
242
+ }])
243
+ }
244
+
180
245
  // For SVG sources without an explicit format, coerce output to PNG
181
246
  // (Sharp cannot write SVG).
182
247
  const fallbackExt = src.isSvg ? 'png' : path.extname(src.path).slice(1).toLowerCase()
@@ -188,7 +253,7 @@ async function writeScaled(src, outPath, width, height, format, quality) {
188
253
 
189
254
  function applyFormat(pipeline, format, quality) {
190
255
  switch (format) {
191
- case 'png': return pipeline.png({ quality, compressionLevel: 9 })
256
+ case 'png': return pipeline.png({ compressionLevel: 9 })
192
257
  case 'webp': return pipeline.webp({ quality })
193
258
  case 'avif': return pipeline.avif({ quality })
194
259
  case 'tiff': return pipeline.tiff({ quality, compression: 'lzw' })
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'fs'
19
19
  import path from 'path'
20
+ import sharp from 'sharp'
20
21
  import { logger } from '../branding/branding-logger.js'
21
22
  import { logger as mainLogger } from '../../shared/logger.js'
22
23
  import { confirmWithAlways } from '../../shared/prompt.js'
@@ -36,23 +37,46 @@ export async function runImages(opts) {
36
37
  format = null,
37
38
  quality = 85,
38
39
  baseWidth = null,
40
+ opacity = null, // 0-100 or null
41
+ padding = null, // 0-40 (per side, %) or null
42
+ outputRelpath = null, // basename + subfolder relative to images root, no extension
39
43
  dryRun = false,
40
44
  yes = false,
41
- confirmOverwrites = true
45
+ confirmOverwrites = true,
46
+ filesOverrides = [] // [{ filename: 'images/<relpath>', width, height? }, …]
42
47
  } = opts
43
48
 
44
49
  if (!fs.existsSync(source)) {
45
50
  throw new Error(`Source not found: ${source}`)
46
51
  }
47
52
 
53
+ if (outputRelpath != null && fs.statSync(source).isDirectory()) {
54
+ throw new Error('--output is incompatible with directory sources (one basename cannot apply to multiple files). Pass a single file as the source, or drop --output.')
55
+ }
56
+
48
57
  const projectType = detectProjectType(projectRoot)
49
58
  const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(projectRoot, projectType)
50
59
 
51
60
  const files = collectImageFiles(source)
52
61
 
53
- if (baseWidth == null && files.some(f => path.extname(f).toLowerCase() === '.svg')) {
54
- logger.warning('⚠ SVG source detected without --width. Output sizes will be derived from each SVG\'s viewBox (treated as a 4× master).')
55
- logger.warning(' For SVGs from vector editors with disproportionate viewBoxes, pass --width <n> (e.g. --width 256) to pin the @1x/mdpi width.')
62
+ // Build a lookup keyed by `images/<subpath>` so per-file `width`/`height`
63
+ // declared in `config.cjs > images.files` can override the directory scan's
64
+ // default sizing. CLI `--width` still wins over both.
65
+ const overrides = buildOverridesMap(filesOverrides)
66
+ const imagesFolderForKey = projectRoot === process.cwd()
67
+ ? projectsPurge_TSS_Images_Folder
68
+ : path.join(projectRoot, 'purgetss', 'images')
69
+
70
+ if (baseWidth == null) {
71
+ const uncoveredSvgs = files.filter(f => {
72
+ if (path.extname(f).toLowerCase() !== '.svg') return false
73
+ const key = overrideKeyFor(f, imagesFolderForKey)
74
+ return !overrides.has(key)
75
+ })
76
+ if (uncoveredSvgs.length > 0) {
77
+ logger.warning('⚠ SVG source detected without --width and no entry in config.cjs > images.files. Output sizes will be derived from each SVG\'s viewBox (treated as a 4× master).')
78
+ logger.warning(' For SVGs from vector editors with disproportionate viewBoxes, pass --width <n> (e.g. --width 256) or add an entry to images.files to pin the @1x/mdpi width.')
79
+ }
56
80
  }
57
81
 
58
82
  console.log()
@@ -67,6 +91,9 @@ export async function runImages(opts) {
67
91
  logger.property('Platforms: ', platforms.join(' + '))
68
92
  if (format) logger.property('Format: ', `convert all to ${format}`)
69
93
  if (baseWidth != null) logger.property('Width: ', `${baseWidth} px @1x/mdpi`)
94
+ if (opacity != null) logger.property('Opacity: ', `${opacity}%`)
95
+ if (padding != null) logger.property('Padding: ', `${padding}% per side`)
96
+ if (outputRelpath != null) logger.property('Output: ', `images/${outputRelpath}.<ext>`)
70
97
  if (dryRun) logger.warning('DRY RUN — no files will be written')
71
98
 
72
99
  if (files.length === 0) {
@@ -74,9 +101,9 @@ export async function runImages(opts) {
74
101
  return { written: [] }
75
102
  }
76
103
 
77
- if (!dryRun && confirmOverwrites) {
104
+ if (!dryRun && confirmOverwrites && !yes) {
78
105
  logger.warning(`⚠ Scaled images will OVERWRITE existing variants under ${androidBaseDir} and ${iphoneBaseDir}.`)
79
- logger.warning(` Commit first if you want a rollback.`)
106
+ logger.warning(' Commit first if you want a rollback.')
80
107
  const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
81
108
  if (choice === 'no') {
82
109
  logger.info('Aborted.')
@@ -94,8 +121,8 @@ export async function runImages(opts) {
94
121
  }
95
122
 
96
123
  if (projectType === 'unknown') {
97
- logger.warning(`Could not detect project layout. Expected 'app/' (Alloy) or 'Resources/' (Classic).`)
98
- logger.warning(`Assets will still be written to the detected default paths — verify the output.`)
124
+ logger.warning('Could not detect project layout. Expected \'app/\' (Alloy) or \'Resources/\' (Classic).')
125
+ logger.warning('Assets will still be written to the detected default paths — verify the output.')
99
126
  }
100
127
 
101
128
  // Relative paths preserve the user's subdirectory structure inside purgetss/images/.
@@ -116,17 +143,70 @@ export async function runImages(opts) {
116
143
 
117
144
  logger.section('Scaling')
118
145
  for (const file of files) {
119
- const relPath = path.relative(sourceRoot, file)
120
- logger.bullet(relPath)
146
+ // When --output is set, override the computed relPath with the user's
147
+ // basename + subfolder. Append the source extension so downstream
148
+ // path.parse / renameWithFormat behave the same as for natural sources.
149
+ const relPath = outputRelpath != null
150
+ ? outputRelpath + path.extname(file)
151
+ : path.relative(sourceRoot, file)
152
+
153
+ // Per-file resolution: CLI --width wins; if absent, fall back to the
154
+ // entry in `images.files` (if any); else null (gen-scales reads viewBox).
155
+ const override = overrides.get(overrideKeyFor(file, imagesFolderForKey))
156
+ const effectiveBaseWidth = baseWidth ?? override?.width ?? null
157
+ const effectiveBaseHeight = baseWidth != null ? null : (override?.height ?? null)
158
+
159
+ // SVGs listed in `images.files` are almost always referenced from views/
160
+ // controllers as `image="/.../foo.svg"`, and Titanium's runtime only falls
161
+ // back from a `.svg` reference to `.png` (verified empirically — not to
162
+ // .webp, .jpeg, etc.). Forcing PNG here prevents the standalone command
163
+ // from quietly emitting a format Titanium can't load via that fallback.
164
+ // Raster files in `files` and SVGs NOT in `files` still honor `format`.
165
+ const ext = path.extname(file).toLowerCase()
166
+ const isSvg = ext === '.svg'
167
+ const isSvgInFiles = override != null && isSvg
168
+ const effectiveFormat = isSvgInFiles ? null : format
169
+
170
+ // Build an informative bullet so the user can see which decisions applied
171
+ // per file: source of width, where it came from, and the actual output
172
+ // format (especially when PNG is forced for SVGs in `files`).
173
+ const widthSource = baseWidth != null
174
+ ? `${baseWidth}dp (CLI --width)`
175
+ : override
176
+ ? `${override.width}dp (files)`
177
+ : isSvg ? 'viewBox' : 'source 4×'
178
+ const outFormat = effectiveFormat ?? (isSvg ? 'png' : ext.slice(1))
179
+ const formatTag = isSvgInFiles && format && format !== 'png'
180
+ ? `${outFormat} (forced; ignores format: ${format})`
181
+ : outFormat
182
+ logger.bullet(`${relPath} → ${widthSource} · ${formatTag}`)
121
183
 
122
184
  if (dryRun) continue
123
185
 
186
+ // Quality warning: if the user pinned a width (via CLI or `files`), the
187
+ // source must carry at least `width × 4` pixels — that's what xxxhdpi/@4x
188
+ // needs. Anything smaller forces Sharp to upscale, producing blurry output.
189
+ // SVG sources are vector and exempt from this check.
190
+ if (effectiveBaseWidth != null && !isSvg) {
191
+ const meta = await sharp(file).metadata()
192
+ const requiredXxxhdpi = effectiveBaseWidth * 4
193
+ if (Number.isFinite(meta.width) && meta.width < requiredXxxhdpi) {
194
+ logger.warning(
195
+ `⚠ ${relPath}: source is ${meta.width}px wide but xxxhdpi needs ${requiredXxxhdpi}px (4× of declared ${effectiveBaseWidth}dp @1x). Output will be upscaled and may look blurry — provide a source ≥ ${requiredXxxhdpi}px.`
196
+ )
197
+ }
198
+ }
199
+
124
200
  if (!iphoneOnly) {
125
- const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, { format, quality, baseWidth })
201
+ const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, {
202
+ format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
203
+ })
126
204
  written.push(...androidFiles)
127
205
  }
128
206
  if (!androidOnly) {
129
- const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, { format, quality, baseWidth })
207
+ const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, {
208
+ format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
209
+ })
130
210
  written.push(...iphoneFiles)
131
211
  }
132
212
  }
@@ -155,6 +235,31 @@ function resolveOutputDirs(projectRoot, projectType) {
155
235
  }
156
236
  }
157
237
 
238
+ // Normalize a config `images.files` entry list into a Map keyed by filename.
239
+ // Invalid entries (missing filename, non-numeric width) are silently skipped
240
+ // so a typo in config doesn't crash the whole pipeline.
241
+ function buildOverridesMap(entries) {
242
+ const map = new Map()
243
+ if (!Array.isArray(entries)) return map
244
+ for (const entry of entries) {
245
+ if (!entry || typeof entry.filename !== 'string') continue
246
+ if (typeof entry.width !== 'number' || !Number.isFinite(entry.width)) continue
247
+ const key = entry.filename.replace(/^\/+/, '')
248
+ map.set(key, {
249
+ width: entry.width,
250
+ height: typeof entry.height === 'number' && Number.isFinite(entry.height) ? entry.height : null
251
+ })
252
+ }
253
+ return map
254
+ }
255
+
256
+ // Match the key shape stored in `config.cjs > images.files`:
257
+ // `images/<subpath>/<name>.<ext>` relative to `purgetss/images/`.
258
+ function overrideKeyFor(file, imagesFolder) {
259
+ const rel = path.relative(imagesFolder, file).split(path.sep).join('/')
260
+ return rel.startsWith('..') ? null : `images/${rel}`
261
+ }
262
+
158
263
  function collectImageFiles(source) {
159
264
  const stat = fs.statSync(source)
160
265
  if (stat.isFile()) {
@@ -49,7 +49,7 @@ export function purgeFontAwesome(uniqueClasses, cleanUniqueClasses, debug = fals
49
49
  if (fs.existsSync(projectsFA_TSS_File)) {
50
50
  sourceFolder = projectsFA_TSS_File
51
51
  purgedClasses = '\n// Pro/Beta Font Awesome\n'
52
- purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles...')`
52
+ purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles...`
53
53
  } else {
54
54
  sourceFolder = srcFontAwesomeTSSFile
55
55
  purgedClasses = '\n// Default Font Awesome\n'
@@ -128,9 +128,13 @@ export function purgeFontIcons(sourceFolder, uniqueClasses, message, cleanUnique
128
128
 
129
129
  let purgedClasses = ''
130
130
  const sourceTSS = fs.readFileSync(sourceFolder, 'utf8')
131
+ const hasMatches = cleanUniqueClasses.some(element => sourceTSS.includes(`'.${element}'`))
131
132
 
132
- if (cleanUniqueClasses.some(element => sourceTSS.includes(`'.${element}'`))) {
133
- logger.info(message)
133
+ if (hasMatches) {
134
+ // In debug mode the label is emitted by localFinish inline with the timing.
135
+ // In non-debug mode this is the progress indicator (only shown when there's
136
+ // actual work for this font, matching pre-existing behavior).
137
+ if (!debug) logger.info(message)
134
138
  const sourceTSSFile = sourceTSS.split(/\r?\n/)
135
139
  uniqueClasses.forEach(className => {
136
140
  const cleanClassName = cleanClassNameFn(className)
@@ -48,7 +48,9 @@ function cleanClassNameFn(className) {
48
48
  export function purgeTailwind(uniqueClasses, debug = false) {
49
49
  if (debug) localStart()
50
50
 
51
- logger.info('Purging', chalk.yellow('utilities.tss'), 'styles...')
51
+ // In debug mode, the section label is emitted by localFinish together with
52
+ // the timing (inline). In non-debug mode this line is the progress indicator.
53
+ if (!debug) logger.info('Purging', chalk.yellow('utilities.tss'), 'styles...')
52
54
 
53
55
  let purgedClasses = ''
54
56
  let tailwindClasses = fs.readFileSync(projectsTailwind_TSS, 'utf8').split(/\r?\n/)