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
package/README.md CHANGED
@@ -377,10 +377,30 @@ Button: {
377
377
 
378
378
  ## Recent changes
379
379
 
380
+ ### v7.11.0
381
+
382
+ - **SVG-aware compile-time image pipeline as a post-step of `purgetss`.** When views or controllers reference `image="/images/<sub>/<name>.svg"` (or `backgroundImage="..."`) alongside utility classes that resolve to numeric width/height (`w-32`, `w-(300)`, `h-auto`, …), purge now compiles those SVGs into the 8 Titanium density variants (5 Android + 3 iPhone PNGs) using dimensions resolved from `app.tss` after the regular purge finishes. Titanium loads the generated `.png` automatically at runtime when the XML/Controller references `.svg`. Cache lives at `purgetss/.cache/svg-images.json` (add to `.gitignore`). The SVG attribute stays untouched in your source — never rewritten.
383
+ - **`images.files` array in `config.cjs` as per-file override.** Pin width/height for individual files in `purgetss/images/`: `[{ filename: 'images/logos/logo.png', width: 128, height: 52 }, ...]`. When `purgetss images` runs, entries override the source's natural dimensions; CLI `--width` still wins over both. For SVGs detected by the purge SVG pipeline, entries populate automatically (subject to `images.autoSync`). Raster entries you add by hand survive subsequent runs untouched.
384
+ - **`images.autoSync` boolean (default `true`).** Opt-out for devs who manage `images.files` by hand — when `false`, purge still computes dimensions and generates PNGs, but never writes back to `config.cjs`.
385
+ - **Quality warning when a raster source is too small for its declared width.** Sources smaller than `width × 4` (the xxxhdpi/@4x requirement) trigger a non-blocking warning with exact numbers. SVGs are exempt (vector, no upscale penalty).
386
+
387
+ ### The 4× source convention
388
+
389
+ Both `purgetss images` and `purgetss brand` treat each source file as the **xxxhdpi/@4x master**: your file's pixel dimensions ARE the largest density, and smaller densities derive at 1/4, 1.5/4, 2/4, 3/4. A 256 px PNG produces a 64 px `@1x/mdpi`. If you want 64 dp at `@1x`, drop a 256 px source.
390
+
391
+ Override per file via `images.files` in `config.cjs` (or the brand-specific fields under `brand.logos`/`brand.padding`). The convention is the default fallback — entries override it on a per-file basis. CLI flags like `--width` always win over both.
392
+
393
+ ### v7.10.0
394
+
395
+ - **`MarketplaceArtworkFeature.png` (Google Play Feature Graphic) auto-generated by `purgetss brand`.** Every brand run now writes a 1024×500 banner to the project root alongside the existing `iTunesConnect.png` and `MarketplaceArtwork.png` submission assets. Master logo centered both axes inside the rectangular canvas with 12% vertical padding default, flattened on `brand.colors.background`. Override paths: CLI `--feature-graphic-padding <n>`, config `brand.padding.featureGraphic`, and optional `purgetss/brand/logo-feature.{svg,png}` (or CLI `--feature-logo` / `brand.logos.featureGraphic`) for a dedicated source. Submission artwork only — file goes to the project root for upload to the Play Console, not bundled into the APK.
396
+ - **`--opacity <n>`, `--padding <n>`, and `--output <relpath>` flags for `purgetss images`.** Three CLI-only per-asset transformations. `--opacity` multiplies the alpha channel of every generated density (0–100, integer). `--padding` shrinks the rendered image inside each density canvas with symmetric transparent borders (0–40%). `--output` overrides basename + subpath relative to the images output root (single-file source only; no extension, no absolute paths, no `..`). All three preserve the multi-density plural-output convention. Combines naturally for placeholder images: `purgetss images purgetss/brand/logo.svg --opacity 30 --padding 15 --output 'logos/default' --format png`.
397
+ - **`brand --padding <n>` shortcut bug fix.** Pre-existing bug: help text and docs described `--padding` as a shortcut that "sets BOTH Android paddings to the same value", but only `androidAdaptivePadding` read the shortcut from the fallback chain — `androidLegacyPadding` skipped it. `purgetss brand --padding 17` actually produced `androidAdaptive=17, androidLegacy=10` silently. Fix adds the missing fallback so `--padding` now sets both Android paddings to the same value as documented.
398
+ - **`purgetss images --format png` now writes truecolor RGBA, not palette-quantized PNG-8.** Passing `quality` to Sharp's `.png()` was silently triggering palette mode, banding subtle gradients in logos, placeholders, and any image with smooth color transitions. Drop of one parameter (`quality` is meaningless for PNG anyway — only `compressionLevel` matters, and that stays at 9 lossless). Brand outputs (icons, splash, marketplace) were never affected because their generators don't pass `quality` to `.png()`.
399
+
380
400
  ### v7.8.0
381
401
 
382
402
  - **`--width <n>` flag for `images`.** Pin Android `mdpi` / iPhone `@1x` to a specific width in pixels (e.g. `purgetss images logo.svg --width 256`); larger scales derive at ×1.5, ×2, ×3, ×4 with height staying proportional. Recommended when the source is an SVG export from Affinity / Illustrator with a disproportionate viewBox — the legacy 4× master convention produces unpredictable sizes there, and the new flag pins the result exactly. When you pass an SVG without `--width`, `images` now prints a one-time hint suggesting the flag, then falls back to the legacy behavior.
383
- - **Class syntax pre-validation.** `purgetss` now halts with a structured `Class Syntax Error` block (file + line + suggested fix) when it detects known authoring mistakes in your class names: inverted negative sign (`top-(-10)` → `-top-(10)`), Tailwind-style brackets (`top-[10px]` → `top-(10px)`), empty parentheses (`wh-()`), whitespace inside parentheses (`wh-( 200 )`), and redundant `px` unit (`top-(10px)` → `top-(10)`).
403
+ - **Class syntax pre-validation.** `purgetss` now halts with a structured `Class Syntax Error` block (file + line + suggested fix) when it detects known authoring mistakes in your class names: inverted negative sign (`top-(-10)` → `-top-(10)`), square brackets (`top-[10px]` → `top-(10px)`), empty parentheses (`wh-()`), whitespace inside parentheses (`wh-( 200 )`), and redundant `px` unit (`top-(10px)` → `top-(10)`).
384
404
  - All offenders are reported in a single run, so you can fix them in one pass.
385
405
  - Generic unknown classes (typos, vendor utilities not enabled, custom classes not yet declared) are NOT flagged by the validator — they continue to flow silently into the `// Unused or unsupported classes` block in `app.tss`, exactly as in previous versions.
386
406
  - **Arbitrary-value parser no longer crashes on negative values inside parentheses.** Classes like `top-(-10)`, `mt-(-5)`, or `origin-(-10,-20)` used to trigger a `Cannot read properties of null (reading 'pop')` exception. The parser was rewritten to extract the `(...)` portion first, so a `-` inside the value never breaks the split — and the new pre-validator catches the inverted-sign form before it gets that far.
package/bin/purgetss CHANGED
@@ -76,6 +76,7 @@ program
76
76
  }
77
77
  } catch (error) {
78
78
  console.error(chalk.red('Error running command:'), error.message)
79
+ if (process.env.PURGETSS_DEBUG) console.error(error.stack)
79
80
  process.exit(1)
80
81
  }
81
82
  })
@@ -267,6 +268,7 @@ Logos auto-discovered from ${chalk.cyan('purgetss/brand/')}:
267
268
  ${chalk.yellow('logo-dark.{svg,png}')} optional — iOS 18+ dark variant
268
269
  ${chalk.yellow('logo-tinted.{svg,png}')} optional — iOS 18+ tinted variant
269
270
  ${chalk.yellow('logo-splash.{svg,png}')} optional — Android 12+ splash icon override
271
+ ${chalk.yellow('logo-feature.{svg,png}')} optional — Google Play Feature Graphic override
270
272
 
271
273
  Defaults come from the ${chalk.cyan('brand:')} section in ${chalk.cyan('purgetss/config.cjs')}.
272
274
  The recommended workflow is: put files in ${chalk.cyan('purgetss/brand/')}, then only use config/CLI for overrides.
@@ -276,6 +278,7 @@ Generates:
276
278
  ${chalk.yellow('DefaultIcon.png')}/${chalk.yellow('DefaultIcon-ios.png')} Root icons (alpha + flattened)
277
279
  ${chalk.yellow('DefaultIcon-Dark.png')}/${chalk.yellow('DefaultIcon-Tinted.png')} iOS 18+ variants (Apple HIG specs)
278
280
  ${chalk.yellow('iTunesConnect.png')}/${chalk.yellow('MarketplaceArtwork.png')} App Store + Play Store artwork
281
+ ${chalk.yellow('MarketplaceArtworkFeature.png')} Google Play Feature Graphic (1024×500)
279
282
  ${chalk.yellow('mipmap-*/ic_launcher_{foreground,background,monochrome}.png')} Android adaptive × 5
280
283
  ${chalk.yellow('mipmap-*/ic_launcher.png')} Android legacy × 5
281
284
  ${chalk.yellow('mipmap-anydpi-v26/ic_launcher.xml')} Adaptive icon binder
@@ -298,6 +301,8 @@ Examples:
298
301
  ${chalk.cyan('purgetss brand')} --bg-color "#0B1326" # override config bg
299
302
  ${chalk.cyan('purgetss brand')} --android-adaptive-padding 22 # more breathing room for adaptive icons
300
303
  ${chalk.cyan('purgetss brand')} --icon-logo app-icon.svg # separate square Android icon
304
+ ${chalk.cyan('purgetss brand')} --feature-logo banner.svg # custom Feature Graphic logo
305
+ ${chalk.cyan('purgetss brand')} --feature-graphic-padding 15 # more breathing room on Feature Graphic
301
306
  ${chalk.cyan('purgetss brand')} --dark-bg-color "#1C1C1E" --no-tinted # customize dark, skip tinted
302
307
  ${chalk.cyan('purgetss brand')} --dry-run # preview without writing
303
308
  ${chalk.cyan('purgetss brand')} --cleanup-legacy --dry-run # preview legacy cleanup
@@ -307,11 +312,13 @@ Examples:
307
312
  .option('--android-adaptive-padding <n>', 'Adaptive icon safe-zone % (default: 19)', (v) => parseInt(v, 10))
308
313
  .option('--android-legacy-padding <n>', 'Legacy ic_launcher.png padding % (default: 10)', (v) => parseInt(v, 10))
309
314
  .option('--ios-padding <n>', 'iOS aesthetic % (typical 2-6, default: 4)', (v) => parseInt(v, 10))
315
+ .option('--feature-graphic-padding <n>', 'Feature Graphic vertical % (default: 12)', (v) => parseInt(v, 10))
310
316
  .option('--notification', 'Also generate ic_stat_notify.png × 5 densities')
311
317
  .option('--splash', 'Also generate Android 12+ splash_icon.png × 5 densities')
312
318
  .option('--icon-logo <path>', 'Override the Android launcher icon logo (otherwise purgetss/brand/logo-icon.{svg,png})')
313
319
  .option('--monochrome-logo <path>', 'Override the monochrome logo (otherwise purgetss/brand/logo-mono.{svg,png})')
314
320
  .option('--splash-logo <path>', 'Override the Android 12+ splash icon logo (otherwise purgetss/brand/logo-splash.{svg,png})')
321
+ .option('--feature-logo <path>', 'Override the Feature Graphic logo (otherwise purgetss/brand/logo-feature.{svg,png})')
315
322
  .option('--dark-bg-color <hex>', 'Opt into opaque dark bg for DefaultIcon-Dark.png (default: transparent per Apple HIG)')
316
323
  .option('--dark-logo <path>', 'Override the dark logo (otherwise purgetss/brand/logo-dark.{svg,png})')
317
324
  .option('--tinted-logo <path>', 'Override the tinted logo (otherwise purgetss/brand/logo-tinted.{svg,png})')
@@ -370,6 +377,9 @@ Examples:
370
377
  ${chalk.cyan('purgetss images')} --format png --quality 95
371
378
  ${chalk.cyan('purgetss images')} logo.svg --width 256 # pin @1x/mdpi to 256 px wide
372
379
  ${chalk.cyan('purgetss images')} banner.svg --width 512 --format webp
380
+ ${chalk.cyan('purgetss images')} logo.svg --opacity 50 --format png # semi-transparent placeholder, all densities
381
+ ${chalk.cyan('purgetss images')} purgetss/brand/logo.svg --opacity 40 --output 'logos/loading' --format png
382
+ ${chalk.cyan('purgetss images')} purgetss/brand/logo.png --opacity 30 --padding 15 --output 'logos/default' --format png
373
383
  ${chalk.cyan('purgetss images')} --dry-run
374
384
  `)
375
385
  .option('--android', 'Generate only Android density variants (skip iPhone)')
@@ -377,6 +387,9 @@ Examples:
377
387
  .option('--format <ext>', 'Convert all outputs to this format: webp|jpeg|png|avif|gif|tiff')
378
388
  .option('--quality <n>', 'JPEG/WebP/AVIF quality 0-100 (default: 85)', (v) => parseInt(v, 10))
379
389
  .option('--width <n>', 'Target width in px for @1x/mdpi (other scales derive: ×1.5, ×2, ×3, ×4)', (v) => parseInt(v, 10))
390
+ .option('--opacity <n>', 'Apply alpha 0-100 to all generated densities (e.g. 50 = 50% transparent)', (v) => parseInt(v, 10))
391
+ .option('--padding <n>', 'Symmetric padding 0-40% inside each density canvas (shrinks the logo, adds transparent borders)', (v) => parseInt(v, 10))
392
+ .option('--output <relpath>', 'Override basename + subfolder relative to images/ (single source only, no extension; --format decides ext)')
380
393
  .option('--project <path>', 'Project root (default: cwd)')
381
394
  .option('--dry-run', 'Preview without writing any files')
382
395
  .option('-y, --yes', 'Skip the overwrite confirmation prompt')
@@ -1,4 +1,4 @@
1
- // PurgeTSS v7.8.0
1
+ // PurgeTSS v7.11.1
2
2
  // Created by César Estrada
3
3
  // https://purgetss.com
4
4
 
@@ -3,7 +3,7 @@
3
3
  <ScrollView class="vertical mx-4">
4
4
  <!-- Header -->
5
5
  <Label class="text-center text-3xl font-bold text-white">Welcome to PurgeTSS</Label>
6
- <Label class="text-center text-sm text-white">Tailwind-inspired utility classes for Titanium/Alloy</Label>
6
+ <Label class="text-center text-sm text-white">Utility-first styling for Titanium/Alloy</Label>
7
7
 
8
8
  <!-- Typography Examples -->
9
9
  <Label class="mt-4 text-center text-xl font-semibold text-white">Typography</Label>
@@ -36,7 +36,9 @@ module.exports = {
36
36
  images: {
37
37
  quality: 85, // JPEG/WebP/AVIF quality (0-100)
38
38
  format: null, // null = keep original; 'webp' | 'jpeg' | 'png' to convert every image
39
- confirmOverwrites: true // prompt before overwriting files (set false to skip)
39
+ autoSync: true, // false = SVG pipeline computes dims but doesn't write to images.files
40
+ confirmOverwrites: true, // prompt before overwriting files (set false to skip)
41
+ files: [] // per-file overrides: [{ filename: 'images/<sub>/<name>.<ext>', width, height? }]
40
42
  },
41
43
  theme: {
42
44
  extend: {}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "purgetss",
4
- "version": "7.9.0",
4
+ "version": "7.11.1",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "purgetss": "bin/purgetss"
@@ -44,6 +44,41 @@ export async function images(cliSource, options = {}) {
44
44
  }
45
45
  }
46
46
 
47
+ let opacity = null
48
+ if (options.opacity !== undefined) {
49
+ if (!Number.isFinite(options.opacity) || !Number.isInteger(options.opacity) || options.opacity < 0 || options.opacity > 100) {
50
+ logger.error(`Invalid --opacity '${options.opacity}'. Must be an integer between 0 and 100.`)
51
+ process.exit(1)
52
+ }
53
+ opacity = options.opacity
54
+ }
55
+
56
+ let padding = null
57
+ if (options.padding !== undefined) {
58
+ if (!Number.isFinite(options.padding) || !Number.isInteger(options.padding) || options.padding < 0 || options.padding > 40) {
59
+ logger.error(`Invalid --padding '${options.padding}'. Must be an integer between 0 and 40.`)
60
+ process.exit(1)
61
+ }
62
+ padding = options.padding
63
+ }
64
+
65
+ let outputRelpath = null
66
+ if (options.output !== undefined && options.output !== null && options.output !== '') {
67
+ const raw = String(options.output)
68
+ if (path.isAbsolute(raw)) {
69
+ logger.error(`Invalid --output '${raw}'. Must be a relative path inside the project images folder, not absolute.`)
70
+ process.exit(1)
71
+ }
72
+ const segments = raw.split(/[\\/]/)
73
+ if (segments.includes('..')) {
74
+ logger.error(`Invalid --output '${raw}'. '..' segments are not allowed (must stay inside the project images folder).`)
75
+ process.exit(1)
76
+ }
77
+ // Strip any trailing extension — --format (or source ext) decides actual extension.
78
+ const parsed = path.parse(raw)
79
+ outputRelpath = parsed.dir ? path.join(parsed.dir, parsed.name) : parsed.name
80
+ }
81
+
47
82
  const format = options.format ?? cfg.format ?? null
48
83
  if (format && !VALID_FORMATS.has(format.toLowerCase())) {
49
84
  logger.error(`Invalid --format '${format}'. Valid: ${[...VALID_FORMATS].join(', ')}`)
@@ -65,9 +100,13 @@ export async function images(cliSource, options = {}) {
65
100
  format: format ? format.toLowerCase() : null,
66
101
  quality: options.quality ?? cfg.quality ?? 85,
67
102
  baseWidth: options.width ?? null,
103
+ opacity,
104
+ padding,
105
+ outputRelpath,
68
106
  dryRun: Boolean(options.dryRun),
69
107
  yes: Boolean(options.yes),
70
- confirmOverwrites: cfg.confirmOverwrites !== false
108
+ confirmOverwrites: cfg.confirmOverwrites !== false,
109
+ filesOverrides: Array.isArray(cfg.files) ? cfg.files : []
71
110
  })
72
111
  } catch (err) {
73
112
  logger.error(err.message)
@@ -113,7 +152,7 @@ function printMissingSourceHelp(projectRoot) {
113
152
  logger.error('No source images found.')
114
153
  console.log()
115
154
  console.log(` Expected images inside ${chalk.cyan(rel(imagesDir) + '/')}.`)
116
- console.log(` The folder already exists — drop your images into it (subdirectories are preserved):`)
155
+ console.log(' The folder already exists — drop your images into it (subdirectories are preserved):')
117
156
  console.log(` ${chalk.cyan('cp my-ui-asset.png ' + rel(imagesDir) + '/')}`)
118
157
  console.log()
119
158
  console.log(' Alternatives:')
@@ -32,6 +32,7 @@ import { getConfigOptions, getConfigFile, ensureConfig } from '../../shared/conf
32
32
 
33
33
  // Import purger functions from core modules
34
34
  import { processControllers } from '../../core/analyzers/class-extractor.js'
35
+ import { runSvgPipeline } from '../../core/svg/index.js'
35
36
  import { purgeTailwind } from '../../core/purger/tailwind-purger.js'
36
37
  import { flushSemanticColors } from '../../shared/semantic-helpers.js'
37
38
  import {
@@ -667,7 +668,7 @@ function saveFile(file, data) {
667
668
  * @param {Object} options - Command options
668
669
  * @returns {boolean} - Success status
669
670
  */
670
- export function purgeClasses(options) {
671
+ export async function purgeClasses(options) {
671
672
  // Initialize configOptions first (includes auto-migration)
672
673
  configOptions = getConfigOptions()
673
674
 
@@ -676,6 +677,9 @@ export function purgeClasses(options) {
676
677
  }
677
678
 
678
679
  purgingDebug = options.debug
680
+ // Propagate to the logger module so localFinish (cli-helpers.js) sees it.
681
+ // The local `purgingDebug` is still needed for the purger callees below.
682
+ setDebugMode(options.debug)
679
683
 
680
684
  const recentlyCreated = makeSureFileExists(projectsAppTSS)
681
685
 
@@ -696,7 +700,7 @@ export function purgeClasses(options) {
696
700
  try {
697
701
  uniqueClasses = getUniqueClasses()
698
702
 
699
- // Pre-validate class syntax. Halts on inverted negatives, Tailwind
703
+ // Pre-validate class syntax. Halts on inverted negatives, square
700
704
  // brackets, empty parens, etc. — but NOT on generic unknown classes
701
705
  // (those fall through silently to "// Unused or unsupported classes").
702
706
  validateClassSyntax({
@@ -739,6 +743,15 @@ export function purgeClasses(options) {
739
743
 
740
744
  logger.file('app.tss')
741
745
 
746
+ // Post-step: compile SVG references found in views/controllers into
747
+ // multi-density PNGs sized from the just-resolved app.tss classes.
748
+ await runSvgPipeline({
749
+ tssContent: tempPurged,
750
+ viewPaths: getViewPaths(),
751
+ controllerPaths: getControllerPaths(),
752
+ logger
753
+ })
754
+
742
755
  finish()
743
756
  } finally {
744
757
  flushSemanticColors()
@@ -45,15 +45,25 @@ export function localStart() {
45
45
  }
46
46
 
47
47
  /**
48
- * Finish local timer and log execution time (only in debug mode)
49
- * Maintains exact same logic as original localFinish() function
48
+ * Finish local timer and log execution time (only in debug mode).
50
49
  *
51
- * @param {string} customMessage - Custom message to display
50
+ * Pass an empty string to print only the timing (without re-emitting the
51
+ * label) — useful when the caller already showed the section title via
52
+ * `logger.info` before calling `localStart()`. Anything else is printed
53
+ * verbatim with the timing appended.
54
+ *
55
+ * @param {string} customMessage - Label to print alongside the timing, or
56
+ * `''` for timing-only mode.
52
57
  */
53
58
  export function localFinish(customMessage = 'Finished purging in') {
54
59
  const localEndTime = new Date(new Date() - localStartTime)
55
- if (getDebugMode()) {
56
- logger.info(customMessage, chalk.green(`${localEndTime.getSeconds()}s ${localEndTime.getMilliseconds()}ms`))
60
+ if (!getDebugMode()) return
61
+ const timing = chalk.green(`${localEndTime.getSeconds()}s ${localEndTime.getMilliseconds()}ms`)
62
+ if (customMessage === '') {
63
+ // Timing-only mode: caller already printed the label.
64
+ logger.info(timing)
65
+ } else {
66
+ logger.info(customMessage, timing)
57
67
  }
58
68
  }
59
69
 
@@ -6,7 +6,7 @@
6
6
  * authoring mistakes:
7
7
  *
8
8
  * - Inverted negative sign: top-(-10) → -top-(10)
9
- * - Tailwind-style brackets: top-[10px] → top-(10px)
9
+ * - Square brackets: top-[10px] → top-(10px)
10
10
  * - Empty parentheses: wh-() → wh-(<value>)
11
11
  * - Whitespace in parens: wh-( 200 ) → wh-(200)
12
12
  * - Redundant px units: top-(10px) → top-(10)
@@ -47,11 +47,11 @@ const detectors = [
47
47
  },
48
48
 
49
49
  // top-[10px] → top-(10)
50
- function detectTailwindBrackets(className) {
50
+ function detectSquareBrackets(className) {
51
51
  if (!className.includes('[') && !className.includes(']')) return null
52
52
  const fixed = className.replace(/\[/g, '(').replace(/\]/g, ')')
53
53
  return {
54
- issue: 'Tailwind-style brackets "[ ]" are not supported',
54
+ issue: 'Square brackets "[ ]" are not supported',
55
55
  suggestion: `Use parentheses instead: ${chalk.green(`"${fixed}"`)}`
56
56
  }
57
57
  },
@@ -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
+ }