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.
- package/README.md +28 -0
- package/bin/purgetss +23 -0
- package/dist/purgetss.ui.js +1 -1
- package/lib/templates/create/index.xml +1 -1
- package/lib/templates/purgetss.config.js.cjs +3 -1
- package/package.json +2 -2
- package/src/cli/commands/build.js +9 -4
- package/src/cli/commands/images.js +49 -2
- package/src/cli/commands/purge.js +31 -4
- package/src/cli/commands/shades.js +2 -2
- package/src/cli/utils/cli-helpers.js +15 -5
- package/src/cli/utils/unsupported-class-reporter.js +209 -0
- package/src/core/analyzers/class-extractor.js +54 -0
- package/src/core/analyzers/controller-svg-refs.js +154 -0
- package/src/core/branding/brand-config.js +7 -0
- package/src/core/branding/ensure-brand-section.js +4 -3
- package/src/core/branding/gen-feature-graphic.js +57 -0
- package/src/core/branding/index.js +28 -4
- package/src/core/branding/post-gen-notes.js +2 -2
- package/{experimental/completions2.js → src/core/builders/auto-utilities-builder.js} +74 -40
- package/src/core/builders/tailwind-builder.js +2 -2
- package/src/core/builders/tailwind-helpers.js +0 -444
- package/src/core/images/ensure-images-section.js +6 -4
- package/src/core/images/gen-scales.js +96 -13
- package/src/core/images/index.js +121 -9
- package/src/core/purger/icon-purger.js +7 -3
- package/src/core/purger/tailwind-purger.js +43 -5
- package/src/core/svg/cache.js +96 -0
- package/src/core/svg/derive-dimensions.js +120 -0
- package/src/core/svg/index.js +215 -0
- package/src/core/svg/resolve-classes.js +46 -0
- package/src/core/svg/sync-images.js +278 -0
- package/src/core/svg/tss-reader.js +134 -0
- package/src/dev/builders/tailwind-builder.js +3 -11
- package/src/shared/config-manager.js +72 -3
- package/src/shared/error-reporter.js +117 -0
- package/src/shared/helpers/colors.js +57 -13
- package/src/shared/helpers/core.js +0 -19
- package/src/shared/helpers/utils.js +146 -36
- package/src/shared/logger.js +12 -0
- package/src/shared/semantic-helpers.js +143 -0
- 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')
|
package/dist/purgetss.ui.js
CHANGED
|
@@ -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">
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
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(
|
|
333
|
-
dark: wrapValue(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|