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
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'fs'
19
19
  import path from 'path'
20
+ import sharp from 'sharp'
20
21
  import { logger } from '../branding/branding-logger.js'
21
22
  import { logger as mainLogger } from '../../shared/logger.js'
22
23
  import { confirmWithAlways } from '../../shared/prompt.js'
@@ -35,20 +36,49 @@ export async function runImages(opts) {
35
36
  iphoneOnly = false,
36
37
  format = null,
37
38
  quality = 85,
39
+ baseWidth = null,
40
+ opacity = null, // 0-100 or null
41
+ padding = null, // 0-40 (per side, %) or null
42
+ outputRelpath = null, // basename + subfolder relative to images root, no extension
38
43
  dryRun = false,
39
44
  yes = false,
40
- confirmOverwrites = true
45
+ confirmOverwrites = true,
46
+ filesOverrides = [] // [{ filename: 'images/<relpath>', width, height? }, …]
41
47
  } = opts
42
48
 
43
49
  if (!fs.existsSync(source)) {
44
50
  throw new Error(`Source not found: ${source}`)
45
51
  }
46
52
 
53
+ if (outputRelpath != null && fs.statSync(source).isDirectory()) {
54
+ throw new Error('--output is incompatible with directory sources (one basename cannot apply to multiple files). Pass a single file as the source, or drop --output.')
55
+ }
56
+
47
57
  const projectType = detectProjectType(projectRoot)
48
58
  const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(projectRoot, projectType)
49
59
 
50
60
  const files = collectImageFiles(source)
51
61
 
62
+ // Build a lookup keyed by `images/<subpath>` so per-file `width`/`height`
63
+ // declared in `config.cjs > images.files` can override the directory scan's
64
+ // default sizing. CLI `--width` still wins over both.
65
+ const overrides = buildOverridesMap(filesOverrides)
66
+ const imagesFolderForKey = projectRoot === process.cwd()
67
+ ? projectsPurge_TSS_Images_Folder
68
+ : path.join(projectRoot, 'purgetss', 'images')
69
+
70
+ if (baseWidth == null) {
71
+ const uncoveredSvgs = files.filter(f => {
72
+ if (path.extname(f).toLowerCase() !== '.svg') return false
73
+ const key = overrideKeyFor(f, imagesFolderForKey)
74
+ return !overrides.has(key)
75
+ })
76
+ if (uncoveredSvgs.length > 0) {
77
+ logger.warning('⚠ SVG source detected without --width and no entry in config.cjs > images.files. Output sizes will be derived from each SVG\'s viewBox (treated as a 4× master).')
78
+ logger.warning(' For SVGs from vector editors with disproportionate viewBoxes, pass --width <n> (e.g. --width 256) or add an entry to images.files to pin the @1x/mdpi width.')
79
+ }
80
+ }
81
+
52
82
  console.log()
53
83
  mainLogger.info('Generating multi-density image variants...')
54
84
  console.log()
@@ -60,6 +90,10 @@ export async function runImages(opts) {
60
90
  if (!androidOnly) platforms.push('iPhone (@1x, @2x, @3x)')
61
91
  logger.property('Platforms: ', platforms.join(' + '))
62
92
  if (format) logger.property('Format: ', `convert all to ${format}`)
93
+ if (baseWidth != null) logger.property('Width: ', `${baseWidth} px @1x/mdpi`)
94
+ if (opacity != null) logger.property('Opacity: ', `${opacity}%`)
95
+ if (padding != null) logger.property('Padding: ', `${padding}% per side`)
96
+ if (outputRelpath != null) logger.property('Output: ', `images/${outputRelpath}.<ext>`)
63
97
  if (dryRun) logger.warning('DRY RUN — no files will be written')
64
98
 
65
99
  if (files.length === 0) {
@@ -67,9 +101,9 @@ export async function runImages(opts) {
67
101
  return { written: [] }
68
102
  }
69
103
 
70
- if (!dryRun && confirmOverwrites) {
104
+ if (!dryRun && confirmOverwrites && !yes) {
71
105
  logger.warning(`⚠ Scaled images will OVERWRITE existing variants under ${androidBaseDir} and ${iphoneBaseDir}.`)
72
- logger.warning(` Commit first if you want a rollback.`)
106
+ logger.warning(' Commit first if you want a rollback.')
73
107
  const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
74
108
  if (choice === 'no') {
75
109
  logger.info('Aborted.')
@@ -87,8 +121,8 @@ export async function runImages(opts) {
87
121
  }
88
122
 
89
123
  if (projectType === 'unknown') {
90
- logger.warning(`Could not detect project layout. Expected 'app/' (Alloy) or 'Resources/' (Classic).`)
91
- logger.warning(`Assets will still be written to the detected default paths — verify the output.`)
124
+ logger.warning('Could not detect project layout. Expected \'app/\' (Alloy) or \'Resources/\' (Classic).')
125
+ logger.warning('Assets will still be written to the detected default paths — verify the output.')
92
126
  }
93
127
 
94
128
  // Relative paths preserve the user's subdirectory structure inside purgetss/images/.
@@ -109,17 +143,70 @@ export async function runImages(opts) {
109
143
 
110
144
  logger.section('Scaling')
111
145
  for (const file of files) {
112
- const relPath = path.relative(sourceRoot, file)
113
- logger.bullet(relPath)
146
+ // When --output is set, override the computed relPath with the user's
147
+ // basename + subfolder. Append the source extension so downstream
148
+ // path.parse / renameWithFormat behave the same as for natural sources.
149
+ const relPath = outputRelpath != null
150
+ ? outputRelpath + path.extname(file)
151
+ : path.relative(sourceRoot, file)
152
+
153
+ // Per-file resolution: CLI --width wins; if absent, fall back to the
154
+ // entry in `images.files` (if any); else null (gen-scales reads viewBox).
155
+ const override = overrides.get(overrideKeyFor(file, imagesFolderForKey))
156
+ const effectiveBaseWidth = baseWidth ?? override?.width ?? null
157
+ const effectiveBaseHeight = baseWidth != null ? null : (override?.height ?? null)
158
+
159
+ // SVGs listed in `images.files` are almost always referenced from views/
160
+ // controllers as `image="/.../foo.svg"`, and Titanium's runtime only falls
161
+ // back from a `.svg` reference to `.png` (verified empirically — not to
162
+ // .webp, .jpeg, etc.). Forcing PNG here prevents the standalone command
163
+ // from quietly emitting a format Titanium can't load via that fallback.
164
+ // Raster files in `files` and SVGs NOT in `files` still honor `format`.
165
+ const ext = path.extname(file).toLowerCase()
166
+ const isSvg = ext === '.svg'
167
+ const isSvgInFiles = override != null && isSvg
168
+ const effectiveFormat = isSvgInFiles ? null : format
169
+
170
+ // Build an informative bullet so the user can see which decisions applied
171
+ // per file: source of width, where it came from, and the actual output
172
+ // format (especially when PNG is forced for SVGs in `files`).
173
+ const widthSource = baseWidth != null
174
+ ? `${baseWidth}dp (CLI --width)`
175
+ : override
176
+ ? `${override.width}dp (files)`
177
+ : isSvg ? 'viewBox' : 'source 4×'
178
+ const outFormat = effectiveFormat ?? (isSvg ? 'png' : ext.slice(1))
179
+ const formatTag = isSvgInFiles && format && format !== 'png'
180
+ ? `${outFormat} (forced; ignores format: ${format})`
181
+ : outFormat
182
+ logger.bullet(`${relPath} → ${widthSource} · ${formatTag}`)
114
183
 
115
184
  if (dryRun) continue
116
185
 
186
+ // Quality warning: if the user pinned a width (via CLI or `files`), the
187
+ // source must carry at least `width × 4` pixels — that's what xxxhdpi/@4x
188
+ // needs. Anything smaller forces Sharp to upscale, producing blurry output.
189
+ // SVG sources are vector and exempt from this check.
190
+ if (effectiveBaseWidth != null && !isSvg) {
191
+ const meta = await sharp(file).metadata()
192
+ const requiredXxxhdpi = effectiveBaseWidth * 4
193
+ if (Number.isFinite(meta.width) && meta.width < requiredXxxhdpi) {
194
+ logger.warning(
195
+ `⚠ ${relPath}: source is ${meta.width}px wide but xxxhdpi needs ${requiredXxxhdpi}px (4× of declared ${effectiveBaseWidth}dp @1x). Output will be upscaled and may look blurry — provide a source ≥ ${requiredXxxhdpi}px.`
196
+ )
197
+ }
198
+ }
199
+
117
200
  if (!iphoneOnly) {
118
- const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, { format, quality })
201
+ const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, {
202
+ format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
203
+ })
119
204
  written.push(...androidFiles)
120
205
  }
121
206
  if (!androidOnly) {
122
- const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, { format, quality })
207
+ const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, {
208
+ format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
209
+ })
123
210
  written.push(...iphoneFiles)
124
211
  }
125
212
  }
@@ -148,6 +235,31 @@ function resolveOutputDirs(projectRoot, projectType) {
148
235
  }
149
236
  }
150
237
 
238
+ // Normalize a config `images.files` entry list into a Map keyed by filename.
239
+ // Invalid entries (missing filename, non-numeric width) are silently skipped
240
+ // so a typo in config doesn't crash the whole pipeline.
241
+ function buildOverridesMap(entries) {
242
+ const map = new Map()
243
+ if (!Array.isArray(entries)) return map
244
+ for (const entry of entries) {
245
+ if (!entry || typeof entry.filename !== 'string') continue
246
+ if (typeof entry.width !== 'number' || !Number.isFinite(entry.width)) continue
247
+ const key = entry.filename.replace(/^\/+/, '')
248
+ map.set(key, {
249
+ width: entry.width,
250
+ height: typeof entry.height === 'number' && Number.isFinite(entry.height) ? entry.height : null
251
+ })
252
+ }
253
+ return map
254
+ }
255
+
256
+ // Match the key shape stored in `config.cjs > images.files`:
257
+ // `images/<subpath>/<name>.<ext>` relative to `purgetss/images/`.
258
+ function overrideKeyFor(file, imagesFolder) {
259
+ const rel = path.relative(imagesFolder, file).split(path.sep).join('/')
260
+ return rel.startsWith('..') ? null : `images/${rel}`
261
+ }
262
+
151
263
  function collectImageFiles(source) {
152
264
  const stat = fs.statSync(source)
153
265
  if (stat.isFile()) {
@@ -49,7 +49,7 @@ export function purgeFontAwesome(uniqueClasses, cleanUniqueClasses, debug = fals
49
49
  if (fs.existsSync(projectsFA_TSS_File)) {
50
50
  sourceFolder = projectsFA_TSS_File
51
51
  purgedClasses = '\n// Pro/Beta Font Awesome\n'
52
- purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles...')`
52
+ purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles...`
53
53
  } else {
54
54
  sourceFolder = srcFontAwesomeTSSFile
55
55
  purgedClasses = '\n// Default Font Awesome\n'
@@ -128,9 +128,13 @@ export function purgeFontIcons(sourceFolder, uniqueClasses, message, cleanUnique
128
128
 
129
129
  let purgedClasses = ''
130
130
  const sourceTSS = fs.readFileSync(sourceFolder, 'utf8')
131
+ const hasMatches = cleanUniqueClasses.some(element => sourceTSS.includes(`'.${element}'`))
131
132
 
132
- if (cleanUniqueClasses.some(element => sourceTSS.includes(`'.${element}'`))) {
133
- logger.info(message)
133
+ if (hasMatches) {
134
+ // In debug mode the label is emitted by localFinish inline with the timing.
135
+ // In non-debug mode this is the progress indicator (only shown when there's
136
+ // actual work for this font, matching pre-existing behavior).
137
+ if (!debug) logger.info(message)
134
138
  const sourceTSSFile = sourceTSS.split(/\r?\n/)
135
139
  uniqueClasses.forEach(className => {
136
140
  const cleanClassName = cleanClassNameFn(className)
@@ -14,6 +14,7 @@ import _ from 'lodash'
14
14
  import chalk from 'chalk'
15
15
  import * as helpers from '../../shared/helpers.js'
16
16
  import { logger } from '../../shared/logger.js'
17
+ import { deriveAlphaKey } from '../../shared/semantic-helpers.js'
17
18
  import {
18
19
  // eslint-disable-next-line camelcase
19
20
  projectsTailwind_TSS,
@@ -47,7 +48,9 @@ function cleanClassNameFn(className) {
47
48
  export function purgeTailwind(uniqueClasses, debug = false) {
48
49
  if (debug) localStart()
49
50
 
50
- logger.info('Purging', chalk.yellow('utilities.tss'), 'styles...')
51
+ // In debug mode, the section label is emitted by localFinish together with
52
+ // the timing (inline). In non-debug mode this line is the progress indicator.
53
+ if (!debug) logger.info('Purging', chalk.yellow('utilities.tss'), 'styles...')
51
54
 
52
55
  let purgedClasses = ''
53
56
  let tailwindClasses = fs.readFileSync(projectsTailwind_TSS, 'utf8').split(/\r?\n/)
@@ -191,10 +194,20 @@ export function purgeTailwind(uniqueClasses, debug = false) {
191
194
  const opacityIndex = _.findIndex(tailwindClasses, line => line.startsWith(`'.${opacityValue.className}'`))
192
195
 
193
196
  const classProperties = tailwindClasses[opacityIndex]
194
- if (opacityIndex > -1 && classProperties.includes('#')) {
195
- // ! TODO: Check if color value is a hex value!! (if not, they are using rbg, rgba or semantic colors)
196
- // ! In other words, we need to validate the color value, before we can alter its opacity.
197
- const defaultHexValue = (classProperties.includes('from')) ? classProperties.match(/#[0-9a-f]{6}/g)[1] : classProperties.match(/#[0-9a-f]{6}/i)[0]
197
+ if (opacityIndex > -1 && classProperties && !classProperties.includes('#')) {
198
+ const derivedLine = tryDeriveSemanticOpacityLine(classProperties, opacityValue)
199
+ if (derivedLine) {
200
+ purgedClasses += switchPlatform(helpers.checkPlatformAndDevice(derivedLine, opacityValue.classNameWithTransparency))
201
+ } else {
202
+ console.warn('')
203
+ console.warn(chalk.yellow(` Skipping ".${opacityValue.className}/${opacityValue.decimalValue}" — semantic color, no hex to blend.`))
204
+ console.warn(chalk.yellow(` Use a PurgeTSS built-in color, bg-(#AARRGGBB), or "purgetss semantic --single ... --alpha ${opacityValue.decimalValue}".`))
205
+ console.warn('')
206
+ }
207
+ }
208
+ if (opacityIndex > -1 && classProperties && classProperties.includes('#')) {
209
+ const hexMatches = classProperties.match(/#[0-9a-f]{6}/gi)
210
+ const defaultHexValue = (classProperties.includes('from')) ? hexMatches[1] : hexMatches[0]
198
211
  let classWithoutDecimalOpacity = `${classProperties.replace(new RegExp(defaultHexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `#${opacityValue.transparency}${defaultHexValue.substring(1)}`)}`
199
212
  // Special case: #000000
200
213
  if (classProperties.includes('from') && defaultHexValue === '#000000') classWithoutDecimalOpacity = classWithoutDecimalOpacity.replace('00000000', '000000')
@@ -216,6 +229,31 @@ export function purgeTailwind(uniqueClasses, debug = false) {
216
229
  return purgedClasses
217
230
  }
218
231
 
232
+ // Auto-derive a semantic key with applied alpha and emit a TSS line for the
233
+ // `class/N` form. Returns the rewritten line (with selector renamed to include
234
+ // `/N` and the semantic value swapped for the derived key), or `null` when no
235
+ // candidate matches an entry in semantic.colors.json. Conflict errors from
236
+ // `deriveAlphaKey` propagate naturally.
237
+ function tryDeriveSemanticOpacityLine(classProperties, opacityValue) {
238
+ const bodyMatch = classProperties.match(/\{([^}]*)\}/)
239
+ if (!bodyMatch) return null
240
+ const candidates = (bodyMatch[1].match(/'([^']+)'/g) || [])
241
+ .map(m => m.slice(1, -1))
242
+ .filter(v => !v.startsWith('#'))
243
+ for (const candidate of candidates) {
244
+ const derivedKey = deriveAlphaKey(candidate, opacityValue.decimalValue)
245
+ if (derivedKey) {
246
+ let line = classProperties.replace(new RegExp(`'${candidate}'`, 'g'), `'${derivedKey}'`)
247
+ line = line.replace(
248
+ `'.${opacityValue.className}'`,
249
+ `'.${opacityValue.className}/${opacityValue.decimalValue}'`
250
+ )
251
+ return line
252
+ }
253
+ }
254
+ return null
255
+ }
256
+
219
257
  /**
220
258
  * Switch platform specific styles - COPIED exactly from original switchPlatform() function
221
259
  * NO CHANGES to logic, preserving 100% of original functionality
@@ -0,0 +1,96 @@
1
+ /**
2
+ * PurgeTSS - SVG pipeline cache
3
+ *
4
+ * Tracks the last successful generation of each SVG so subsequent runs can
5
+ * skip work when nothing changed. Cached file: `purgetss/.cache/svg-images.json`.
6
+ *
7
+ * An entry is invalidated (regen) when any of these change:
8
+ * - The SVG file content (sha1 hash).
9
+ * - The resolved widthDp / heightDp.
10
+ * - The list of target PNG paths.
11
+ * - Any of the target PNGs is missing on disk.
12
+ *
13
+ * Stale entries for SVGs no longer referenced are left untouched here — the
14
+ * planned `purgetss clean` command is responsible for purging orphans.
15
+ *
16
+ * @fileoverview Idempotency cache for the SVG pipeline
17
+ * @author César Estrada
18
+ */
19
+
20
+ import fs from 'fs'
21
+ import path from 'path'
22
+ import crypto from 'crypto'
23
+ import { projectsPurgeTSSFolder } from '../../shared/constants.js'
24
+
25
+ const CACHE_DIR = path.join(projectsPurgeTSSFolder, '.cache')
26
+ const CACHE_FILE = path.join(CACHE_DIR, 'svg-images.json')
27
+
28
+ /**
29
+ * Load the on-disk cache as a plain object. Returns `{}` if the file is
30
+ * missing or unreadable — corrupted caches simply force a regen rather than
31
+ * halting the build.
32
+ */
33
+ export function loadCache() {
34
+ try {
35
+ if (!fs.existsSync(CACHE_FILE)) return {}
36
+ const raw = fs.readFileSync(CACHE_FILE, 'utf8')
37
+ const parsed = JSON.parse(raw)
38
+ return (parsed && typeof parsed === 'object') ? parsed : {}
39
+ } catch {
40
+ return {}
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Persist the cache to disk, creating `.cache/` if needed.
46
+ */
47
+ export function saveCache(cache) {
48
+ fs.mkdirSync(CACHE_DIR, { recursive: true })
49
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2) + '\n', 'utf8')
50
+ }
51
+
52
+ /**
53
+ * Compute the sha1 hash of an SVG file. Reads the bytes verbatim so
54
+ * whitespace-only edits also bust the cache (desired — they may change rendering).
55
+ */
56
+ export function hashFile(absPath) {
57
+ const buf = fs.readFileSync(absPath)
58
+ return crypto.createHash('sha1').update(buf).digest('hex')
59
+ }
60
+
61
+ /**
62
+ * Decide whether the cached entry is still valid for the given resolved inputs.
63
+ *
64
+ * @param {Object} cached - Previous cache entry (or undefined).
65
+ * @param {string} svgHash - sha1 of the current SVG.
66
+ * @param {number} widthDp - Currently resolved widthDp.
67
+ * @param {number} heightDp - Currently resolved heightDp.
68
+ * @param {string[]} targets - Absolute paths the pipeline would emit this run.
69
+ * @returns {boolean} True if every target PNG is present and inputs match.
70
+ */
71
+ export function isCacheHit(cached, svgHash, widthDp, heightDp, targets) {
72
+ if (!cached) return false
73
+ if (cached.svgHash !== svgHash) return false
74
+ if (cached.widthDp !== widthDp) return false
75
+ if (cached.heightDp !== heightDp) return false
76
+ if (!Array.isArray(cached.targets) || cached.targets.length !== targets.length) return false
77
+
78
+ const cachedPaths = new Set(cached.targets.map(t => (typeof t === 'string' ? t : t.path)))
79
+ for (const t of targets) {
80
+ if (!cachedPaths.has(t)) return false
81
+ if (!fs.existsSync(t)) return false
82
+ }
83
+ return true
84
+ }
85
+
86
+ /**
87
+ * Build the cache entry shape we persist after a successful generation.
88
+ */
89
+ export function makeCacheEntry(svgHash, widthDp, heightDp, targets) {
90
+ return {
91
+ svgHash,
92
+ widthDp,
93
+ heightDp,
94
+ targets: [...targets]
95
+ }
96
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * PurgeTSS - SVG dimension deriver
3
+ *
4
+ * Reduces all the references to a single SVG into one final `{ widthDp, heightDp }`
5
+ * pair that the image generator can consume as the `1×` baseline. Strategy:
6
+ *
7
+ * 1. For each reference, resolve the cascade against the purged app.tss
8
+ * class→props map.
9
+ * 2. Collect every numeric width across references; the SVG's width is the
10
+ * max (a single view rendered at 800dp can't share PNGs with a view at
11
+ * 128dp without visible blur).
12
+ * 3. Resolve height: explicit numeric width wins; otherwise fall back to a
13
+ * proportional value derived from the SVG's viewBox aspect ratio.
14
+ *
15
+ * Edge cases handled:
16
+ * - Every reference resolves to a non-numeric width (`Ti.UI.SIZE`, percentage,
17
+ * unknown class, etc.) → SVG is skipped with a warning.
18
+ * - SVG file missing in `purgetss/images/` → skipped with a warning.
19
+ * - SVG has an invalid/missing viewBox → skipped with an error.
20
+ *
21
+ * @fileoverview Per-SVG dimension reducer that feeds the image generator
22
+ * @author César Estrada
23
+ */
24
+
25
+ import fs from 'fs'
26
+ import path from 'path'
27
+ import { resolveDimensions } from './resolve-classes.js'
28
+ import { readSvgSafely } from '../../shared/svg-utils.js'
29
+
30
+ // Hard cap on the largest target PNG we are willing to emit, applied at the
31
+ // `xxxhdpi` / `@3x` step downstream. Keeps Sharp from producing absurdly large
32
+ // PNGs when the user accidentally pins a 4K width to a class.
33
+ export const MAX_DIMENSION_PX = 4096
34
+
35
+ /**
36
+ * Reduce raw SVG references into per-SVG resolved dimensions.
37
+ *
38
+ * @param {Object} args
39
+ * @param {Map<string, Array<{ classes: string[] }>>} args.refsBySvg - Map keyed by SVG relpath
40
+ * (relative to imagesFolder, normalized to forward slashes), values are the list of
41
+ * references seen for that SVG.
42
+ * @param {Map} args.tssMap - Output of parseTssMap().
43
+ * @param {string} args.imagesFolder - Absolute path to `purgetss/images/`.
44
+ * @param {Object} args.logger - Logger with `.warning(msg)` and `.info(msg)`.
45
+ * @returns {Promise<Map<string, { widthDp: number, heightDp: number }>>}
46
+ * Resolved entries only; skipped SVGs are omitted (warnings logged inline).
47
+ */
48
+ export async function deriveDimensions({ refsBySvg, tssMap, imagesFolder, logger }) {
49
+ const resolved = new Map()
50
+
51
+ for (const [relPath, refs] of refsBySvg) {
52
+ const absPath = path.join(imagesFolder, relPath)
53
+ if (!fs.existsSync(absPath)) {
54
+ logger.warning(`⚠ SVG not found: purgetss/images/${relPath} — skipping`)
55
+ continue
56
+ }
57
+
58
+ const numericWidths = []
59
+ const numericHeights = []
60
+ for (const ref of refs) {
61
+ const { width, height } = resolveDimensions(ref.classes, tssMap)
62
+ if (typeof width === 'number' && Number.isFinite(width) && width > 0) {
63
+ numericWidths.push(width)
64
+ }
65
+ if (typeof height === 'number' && Number.isFinite(height) && height > 0) {
66
+ numericHeights.push(height)
67
+ }
68
+ }
69
+
70
+ if (numericWidths.length === 0 && numericHeights.length === 0) {
71
+ logger.warning(
72
+ `⚠ ${relPath}: no class resolved width or height to a number — skipping. ` +
73
+ 'Add a w-* or h-* utility on the view, or pin the size manually in purgetss/config.cjs > images.files.'
74
+ )
75
+ continue
76
+ }
77
+
78
+ try {
79
+ await readViewBox(absPath)
80
+ } catch (err) {
81
+ logger.warning(`⚠ ${relPath}: ${err.message} — skipping`)
82
+ continue
83
+ }
84
+
85
+ // Symmetric materialization: only the dimensions the developer pinned
86
+ // explicitly land in config.cjs. The other side is derived from the SVG
87
+ // aspect by gen-scales on every run, so it stays in sync with viewBox
88
+ // edits and class changes without stale "stuck" values getting cemented
89
+ // into config the way auto-derived heights used to.
90
+ const widthDp = numericWidths.length > 0 ? Math.max(...numericWidths) : null
91
+ const heightDp = numericHeights.length > 0 ? Math.max(...numericHeights) : null
92
+
93
+ resolved.set(relPath, { widthDp, heightDp })
94
+ }
95
+
96
+ return resolved
97
+ }
98
+
99
+ async function readViewBox(absPath) {
100
+ // Plan: error when the SVG declares neither viewBox nor width+height
101
+ // attributes. Sharp can infer a bounding box from the rendered content,
102
+ // but treating that as "valid" hides authoring mistakes (e.g. an export
103
+ // that lost its viewBox), so we enforce the explicit declaration first.
104
+ const head = fs.readFileSync(absPath, 'utf8').slice(0, 4096)
105
+ const svgTag = head.match(/<svg\b[^>]*>/i)?.[0] ?? ''
106
+ const hasViewBox = /\bviewBox\s*=\s*['"][^'"]+['"]/.test(svgTag)
107
+ const hasWidth = /\bwidth\s*=\s*['"][^'"]+['"]/.test(svgTag)
108
+ const hasHeight = /\bheight\s*=\s*['"][^'"]+['"]/.test(svgTag)
109
+ if (!hasViewBox && !(hasWidth && hasHeight)) {
110
+ throw new Error('SVG has no viewBox or explicit width/height attribute')
111
+ }
112
+
113
+ const { meta } = await readSvgSafely(absPath, {})
114
+ const vbW = meta.width
115
+ const vbH = meta.height
116
+ if (!Number.isFinite(vbW) || !Number.isFinite(vbH) || vbW <= 0 || vbH <= 0) {
117
+ throw new Error('SVG has no usable viewBox / width / height')
118
+ }
119
+ return { vbW, vbH }
120
+ }