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
package/README.md CHANGED
@@ -377,6 +377,34 @@ 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
+
400
+ ### v7.8.0
401
+
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.
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)`).
404
+ - All offenders are reported in a single run, so you can fix them in one pass.
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.
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.
407
+
380
408
  ### v7.7.0
381
409
 
382
410
  - `brand` now uses grouped config sections: `brand.logos`, `brand.padding`, `brand.android`, `brand.ios`, and `brand.colors`.
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})')
@@ -353,6 +360,13 @@ Writes directly to the project (auto-detects Alloy vs Classic):
353
360
  Defaults come from the ${chalk.cyan('images:')} section in ${chalk.cyan('purgetss/config.cjs')}.
354
361
  CLI flags always win over config values.
355
362
 
363
+ Sizing:
364
+ By default, sources are treated as 4× masters (xxxhdpi/@4x); all other
365
+ scales derive at 1/4, 1.5/4, 2/4, 3/4 of the source's natural pixels.
366
+ Pass ${chalk.cyan('--width <n>')} to pin Android mdpi (= iPhone @1x) to a specific width;
367
+ larger scales derive as ×1.5, ×2, ×3, ×4 (height stays proportional).
368
+ Recommended for SVGs from vector editors with oversized viewBoxes.
369
+
356
370
  Examples:
357
371
  ${chalk.cyan('purgetss images')} # uses purgetss/images/ + config
358
372
  ${chalk.cyan('purgetss images')} ./docs/screenshots # scope to one folder
@@ -361,12 +375,21 @@ Examples:
361
375
  ${chalk.cyan('purgetss images')} --ios # iPhone scales only
362
376
  ${chalk.cyan('purgetss images')} --format webp # convert all outputs to WebP
363
377
  ${chalk.cyan('purgetss images')} --format png --quality 95
378
+ ${chalk.cyan('purgetss images')} logo.svg --width 256 # pin @1x/mdpi to 256 px wide
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
364
383
  ${chalk.cyan('purgetss images')} --dry-run
365
384
  `)
366
385
  .option('--android', 'Generate only Android density variants (skip iPhone)')
367
386
  .option('--ios', 'Generate only iPhone scale variants (skip Android)')
368
387
  .option('--format <ext>', 'Convert all outputs to this format: webp|jpeg|png|avif|gif|tiff')
369
388
  .option('--quality <n>', 'JPEG/WebP/AVIF quality 0-100 (default: 85)', (v) => parseInt(v, 10))
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)')
370
393
  .option('--project <path>', 'Project root (default: cwd)')
371
394
  .option('--dry-run', 'Preview without writing any files')
372
395
  .option('-y, --yes', 'Skip the overwrite confirmation prompt')
@@ -1,4 +1,4 @@
1
- // PurgeTSS v7.7.1
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.7.1",
4
+ "version": "7.11.1",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "purgetss": "bin/purgetss"
@@ -17,7 +17,7 @@
17
17
  "dist/*.tss",
18
18
  "dist/configs/",
19
19
  "assets/fonts/",
20
- "experimental/completions2.js"
20
+ "src/core/builders/auto-utilities-builder.js"
21
21
  ],
22
22
  "publishConfig": {
23
23
  "access": "public"
@@ -14,6 +14,7 @@ import { alloyProject } from '../../shared/utils.js'
14
14
  import { ensureConfig } from '../../shared/config-manager.js'
15
15
  import { buildTailwindBasedOnConfigOptions } from '../../core/builders/tailwind-builder.js'
16
16
  import { createDefinitionsFile } from './init.js'
17
+ import { flushSemanticColors } from '../../shared/semantic-helpers.js'
17
18
 
18
19
  // Import FontAwesome functions from their new modular location
19
20
  import { buildFontAwesome, buildFontAwesomeJS } from '../../dev/builders/fontawesome-builder.js'
@@ -28,10 +29,14 @@ import { buildFontAwesome, buildFontAwesomeJS } from '../../dev/builders/fontawe
28
29
  export function build(options) {
29
30
  if (alloyProject()) {
30
31
  ensureConfig()
31
- buildTailwindBasedOnConfigOptions(options)
32
- buildFontAwesome()
33
- buildFontAwesomeJS()
34
- createDefinitionsFile()
32
+ try {
33
+ buildTailwindBasedOnConfigOptions(options)
34
+ buildFontAwesome()
35
+ buildFontAwesomeJS()
36
+ createDefinitionsFile()
37
+ } finally {
38
+ flushSemanticColors()
39
+ }
35
40
  return true
36
41
  }
37
42
  return false
@@ -37,6 +37,48 @@ export async function images(cliSource, options = {}) {
37
37
  process.exit(1)
38
38
  }
39
39
 
40
+ if (options.width !== undefined) {
41
+ if (!Number.isFinite(options.width) || !Number.isInteger(options.width) || options.width < 1 || options.width > 8192) {
42
+ logger.error(`Invalid --width '${options.width}'. Must be an integer between 1 and 8192.`)
43
+ process.exit(1)
44
+ }
45
+ }
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
+
40
82
  const format = options.format ?? cfg.format ?? null
41
83
  if (format && !VALID_FORMATS.has(format.toLowerCase())) {
42
84
  logger.error(`Invalid --format '${format}'. Valid: ${[...VALID_FORMATS].join(', ')}`)
@@ -57,9 +99,14 @@ export async function images(cliSource, options = {}) {
57
99
  iphoneOnly: Boolean(options.ios),
58
100
  format: format ? format.toLowerCase() : null,
59
101
  quality: options.quality ?? cfg.quality ?? 85,
102
+ baseWidth: options.width ?? null,
103
+ opacity,
104
+ padding,
105
+ outputRelpath,
60
106
  dryRun: Boolean(options.dryRun),
61
107
  yes: Boolean(options.yes),
62
- confirmOverwrites: cfg.confirmOverwrites !== false
108
+ confirmOverwrites: cfg.confirmOverwrites !== false,
109
+ filesOverrides: Array.isArray(cfg.files) ? cfg.files : []
63
110
  })
64
111
  } catch (err) {
65
112
  logger.error(err.message)
@@ -105,7 +152,7 @@ function printMissingSourceHelp(projectRoot) {
105
152
  logger.error('No source images found.')
106
153
  console.log()
107
154
  console.log(` Expected images inside ${chalk.cyan(rel(imagesDir) + '/')}.`)
108
- 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):')
109
156
  console.log(` ${chalk.cyan('cp my-ui-asset.png ' + rel(imagesDir) + '/')}`)
110
157
  console.log()
111
158
  console.log(' Alternatives:')
@@ -32,7 +32,9 @@ 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'
37
+ import { flushSemanticColors } from '../../shared/semantic-helpers.js'
36
38
  import {
37
39
  purgeFontAwesome,
38
40
  purgeMaterialIcons,
@@ -40,6 +42,7 @@ import {
40
42
  purgeFramework7
41
43
  } from '../../core/purger/icon-purger.js'
42
44
  import { purgeFonts } from '../../core/purger/fonts-purger.js'
45
+ import { validateClassSyntax } from '../utils/unsupported-class-reporter.js'
43
46
 
44
47
  // Global variables (EXACT copies from original src/index.js)
45
48
  let configOptions = {}
@@ -665,7 +668,7 @@ function saveFile(file, data) {
665
668
  * @param {Object} options - Command options
666
669
  * @returns {boolean} - Success status
667
670
  */
668
- export function purgeClasses(options) {
671
+ export async function purgeClasses(options) {
669
672
  // Initialize configOptions first (includes auto-migration)
670
673
  configOptions = getConfigOptions()
671
674
 
@@ -674,6 +677,9 @@ export function purgeClasses(options) {
674
677
  }
675
678
 
676
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)
677
683
 
678
684
  const recentlyCreated = makeSureFileExists(projectsAppTSS)
679
685
 
@@ -693,10 +699,21 @@ export function purgeClasses(options) {
693
699
 
694
700
  try {
695
701
  uniqueClasses = getUniqueClasses()
702
+
703
+ // Pre-validate class syntax. Halts on inverted negatives, square
704
+ // brackets, empty parens, etc. — but NOT on generic unknown classes
705
+ // (those fall through silently to "// Unused or unsupported classes").
706
+ validateClassSyntax({
707
+ classes: uniqueClasses,
708
+ viewPaths: getViewPaths(),
709
+ controllerPaths: getControllerPaths()
710
+ })
696
711
  } catch (error) {
697
- // Handle pre-validation errors (XML syntax errors detected before parsing)
698
- if (error.isPreValidationError) {
699
- // Error already printed by preValidateXML, exit cleanly
712
+ // Handle pre-validation errors (XML syntax errors detected before
713
+ // parsing) and class-syntax errors (authoring mistakes detected
714
+ // before purging). In both cases the error block was already
715
+ // printed; just exit cleanly.
716
+ if (error.isPreValidationError || error.isClassSyntaxError) {
700
717
  // eslint-disable-next-line n/no-process-exit
701
718
  process.exit(1)
702
719
  }
@@ -726,8 +743,18 @@ export function purgeClasses(options) {
726
743
 
727
744
  logger.file('app.tss')
728
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
+
729
755
  finish()
730
756
  } finally {
757
+ flushSemanticColors()
731
758
  logger.endSection()
732
759
  }
733
760
 
@@ -329,8 +329,8 @@ export function buildSemanticPalette(family, kebabName, alpha) {
329
329
  const mirror = sorted[sorted.length - 1 - i]
330
330
  const key = `${camelName}${shade.number}`
331
331
  semanticEntries[key] = {
332
- light: wrapValue(mirror.hexcode, alpha),
333
- dark: wrapValue(shade.hexcode, alpha)
332
+ light: wrapValue(shade.hexcode, alpha),
333
+ dark: wrapValue(mirror.hexcode, alpha)
334
334
  }
335
335
  configMapping[shade.number] = key
336
336
  })
@@ -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
 
@@ -0,0 +1,209 @@
1
+ /**
2
+ * PurgeTSS - Class Syntax Validator
3
+ *
4
+ * Pre-validation helper that runs BEFORE the purge starts. Scans every unique
5
+ * class name pulled from XML views (and JS controllers) for well-known
6
+ * authoring mistakes:
7
+ *
8
+ * - Inverted negative sign: top-(-10) → -top-(10)
9
+ * - Square brackets: top-[10px] → top-(10px)
10
+ * - Empty parentheses: wh-() → wh-(<value>)
11
+ * - Whitespace in parens: wh-( 200 ) → wh-(200)
12
+ * - Redundant px units: top-(10px) → top-(10)
13
+ *
14
+ * On any match the validator collects every offender, prints one block per
15
+ * offender (file + line + fix, mirroring throwPreValidationError) and throws
16
+ * an `isClassSyntaxError` so the CLI exits cleanly without running the purge.
17
+ *
18
+ * Generic unknown classes (typos, vendor utilities not enabled, custom
19
+ * classes not yet defined) are intentionally left alone — they are still
20
+ * captured silently in the "// Unused or unsupported classes" section of
21
+ * app.tss, but never trigger warnings or halts. That's the only way to keep
22
+ * the workflow usable on projects with dozens of in-progress class names.
23
+ *
24
+ * @author César Estrada
25
+ */
26
+
27
+ import fs from 'fs'
28
+ import chalk from 'chalk'
29
+ import { logger } from '../../shared/logger.js'
30
+
31
+ const cwd = process.cwd()
32
+
33
+ /**
34
+ * Pattern detectors. Each detector receives the className and returns
35
+ * { issue, suggestion } when it recognises the problem, or null otherwise.
36
+ * Order matters: more specific detectors first.
37
+ */
38
+ const detectors = [
39
+ // top-(-10) → -top-(10)
40
+ function detectInvertedNegative(className) {
41
+ const m = className.match(/^([a-zA-Z][\w-]*?)-\(-([^()]+)\)$/)
42
+ if (!m) return null
43
+ return {
44
+ issue: 'Negative sign is inside the parentheses',
45
+ suggestion: `Use ${chalk.green(`"-${m[1]}-(${m[2]})"`)} — PurgeTSS expects the "-" prefix BEFORE the rule, not inside the value`
46
+ }
47
+ },
48
+
49
+ // top-[10px] → top-(10)
50
+ function detectSquareBrackets(className) {
51
+ if (!className.includes('[') && !className.includes(']')) return null
52
+ const fixed = className.replace(/\[/g, '(').replace(/\]/g, ')')
53
+ return {
54
+ issue: 'Square brackets "[ ]" are not supported',
55
+ suggestion: `Use parentheses instead: ${chalk.green(`"${fixed}"`)}`
56
+ }
57
+ },
58
+
59
+ // wh-() → "Add a value"
60
+ function detectEmptyParens(className) {
61
+ const m = className.match(/^([\w-]+)-\(\s*\)$/)
62
+ if (!m) return null
63
+ return {
64
+ issue: 'Empty value inside parentheses',
65
+ suggestion: `Add a value, e.g. ${chalk.green(`"${m[1]}-(10)"`)}`
66
+ }
67
+ },
68
+
69
+ // wh-( 200 ) → wh-(200)
70
+ function detectSpacesInParens(className) {
71
+ const m = className.match(/^([\w-]+)-\(\s+([^()]*?)\s*\)$|^([\w-]+)-\(([^()]*?)\s+\)$/)
72
+ if (!m) return null
73
+ const rule = m[1] || m[3]
74
+ const value = (m[2] || m[4] || '').trim()
75
+ if (!value) return null
76
+ return {
77
+ issue: 'Whitespace inside parentheses',
78
+ suggestion: `Remove the spaces: ${chalk.green(`"${rule}-(${value})"`)}`
79
+ }
80
+ },
81
+
82
+ // top-(10px) — units inside parens that PurgeTSS would strip anyway.
83
+ // Only flag when value is purely numeric+unit; never blanket-flag because
84
+ // some properties (durations, percentages) accept units.
85
+ function detectRedundantUnits(className) {
86
+ const m = className.match(/^([\w-]+)-\((-?\d+(?:\.\d+)?)px\)$/)
87
+ if (!m) return null
88
+ return {
89
+ issue: 'Explicit "px" unit is redundant',
90
+ suggestion: `PurgeTSS treats unit-less values as pixels: ${chalk.green(`"${m[1]}-(${m[2]})"`)}`
91
+ }
92
+ }
93
+ ]
94
+
95
+ function detectIssue(className) {
96
+ for (const detector of detectors) {
97
+ const result = detector(className)
98
+ if (result) return result
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Find every (file, line) where `className` appears as a whole token inside a
105
+ * class="..." attribute. Uses an attribute-then-split strategy (rather than
106
+ * matching the class name directly) so arbitrary-value class names like
107
+ * "top-(-10)" — which contain regex-special characters — do not need escaping
108
+ * and cannot false-match a partial substring.
109
+ */
110
+ function findLocations(className, paths) {
111
+ const locations = []
112
+ const attrRe = /class\s*=\s*["']([^"']*)["']/g
113
+
114
+ for (const filePath of paths) {
115
+ let content
116
+ try {
117
+ content = fs.readFileSync(filePath, 'utf8')
118
+ } catch {
119
+ continue
120
+ }
121
+ const lines = content.split(/\r?\n/)
122
+ for (let i = 0; i < lines.length; i++) {
123
+ attrRe.lastIndex = 0
124
+ let match
125
+ while ((match = attrRe.exec(lines[i])) !== null) {
126
+ const tokens = match[1].split(/\s+/).filter(Boolean)
127
+ if (tokens.includes(className)) {
128
+ locations.push({
129
+ filePath,
130
+ lineNumber: i + 1,
131
+ lineContent: lines[i].trim()
132
+ })
133
+ break
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return locations
139
+ }
140
+
141
+ function relativePath(filePath) {
142
+ return filePath.startsWith(cwd + '/') ? filePath.slice(cwd.length + 1) : filePath
143
+ }
144
+
145
+ /**
146
+ * Pre-validate every unique class against the syntax detectors. Collects all
147
+ * offenders (so the dev sees the full list in a single run instead of
148
+ * one-fix-per-attempt), prints a block per offender, then throws.
149
+ *
150
+ * Generic unknown classes are NOT flagged — they fall through silently to
151
+ * the "// Unused or unsupported classes" comment block in app.tss.
152
+ *
153
+ * @param {Object} args
154
+ * @param {string[]} args.classes Unique classes pulled from views/controllers.
155
+ * @param {string[]} args.viewPaths Absolute paths to XML view files.
156
+ * @param {string[]} [args.controllerPaths] Absolute paths to JS controller files.
157
+ * @throws {Error} with `isClassSyntaxError === true` if any class matches a detector.
158
+ */
159
+ export function validateClassSyntax({ classes, viewPaths, controllerPaths = [] }) {
160
+ if (!classes || classes.length === 0) return
161
+
162
+ const allPaths = [...viewPaths, ...controllerPaths]
163
+ const offenders = []
164
+
165
+ for (const className of classes) {
166
+ const issue = detectIssue(className)
167
+ if (!issue) continue
168
+ offenders.push({
169
+ className,
170
+ issue,
171
+ locations: findLocations(className, allPaths)
172
+ })
173
+ }
174
+
175
+ if (offenders.length === 0) return
176
+
177
+ for (const offender of offenders) {
178
+ const lines = []
179
+ lines.push(`Class: ${chalk.yellow(`"${offender.className}"`)}`)
180
+
181
+ if (offender.locations.length > 0) {
182
+ const first = offender.locations[0]
183
+ lines.push(`File: ${chalk.yellow(`"${relativePath(first.filePath)}"`)}`)
184
+ lines.push(`Line: ${chalk.yellow(first.lineNumber)}`)
185
+ lines.push(`Content: ${chalk.gray(first.lineContent)}`)
186
+ if (offender.locations.length > 1) {
187
+ const more = offender.locations.length - 1
188
+ lines.push(chalk.gray(`(also used in ${more} other location${more === 1 ? '' : 's'})`))
189
+ }
190
+ } else {
191
+ lines.push(chalk.gray('Location: not found in views — likely from a controller or safelist'))
192
+ }
193
+
194
+ lines.push('')
195
+ lines.push(chalk.red(`Issue: ${offender.issue.issue}`))
196
+ lines.push(`${chalk.green('Fix:')} ${offender.issue.suggestion}`)
197
+
198
+ logger.block(chalk.red('Class Syntax Error'), ...lines)
199
+ }
200
+
201
+ const word = offenders.length === 1 ? 'class syntax error' : 'class syntax errors'
202
+ logger.block(
203
+ chalk.red(`Found ${offenders.length} ${word} — fix the ${offenders.length === 1 ? 'class' : 'classes'} above and re-run purgetss`)
204
+ )
205
+
206
+ const error = new Error('Class syntax errors detected')
207
+ error.isClassSyntaxError = true
208
+ throw error
209
+ }