purgetss 7.9.0 → 7.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +21 -1
  2. package/bin/purgetss +13 -0
  3. package/dist/purgetss.ui.js +1 -1
  4. package/lib/templates/create/index.xml +1 -1
  5. package/lib/templates/purgetss.config.js.cjs +3 -1
  6. package/package.json +1 -1
  7. package/src/cli/commands/images.js +41 -2
  8. package/src/cli/commands/purge.js +15 -2
  9. package/src/cli/utils/cli-helpers.js +15 -5
  10. package/src/cli/utils/unsupported-class-reporter.js +3 -3
  11. package/src/core/analyzers/class-extractor.js +54 -0
  12. package/src/core/analyzers/controller-svg-refs.js +154 -0
  13. package/src/core/branding/brand-config.js +7 -0
  14. package/src/core/branding/ensure-brand-section.js +4 -3
  15. package/src/core/branding/gen-feature-graphic.js +57 -0
  16. package/src/core/branding/index.js +28 -4
  17. package/src/core/branding/post-gen-notes.js +2 -2
  18. package/src/core/builders/auto-utilities-builder.js +20 -15
  19. package/src/core/images/ensure-images-section.js +6 -4
  20. package/src/core/images/gen-scales.js +82 -17
  21. package/src/core/images/index.js +117 -12
  22. package/src/core/purger/icon-purger.js +7 -3
  23. package/src/core/purger/tailwind-purger.js +3 -1
  24. package/src/core/svg/cache.js +96 -0
  25. package/src/core/svg/derive-dimensions.js +120 -0
  26. package/src/core/svg/index.js +215 -0
  27. package/src/core/svg/resolve-classes.js +46 -0
  28. package/src/core/svg/sync-images.js +278 -0
  29. package/src/core/svg/tss-reader.js +134 -0
  30. package/src/dev/builders/tailwind-builder.js +18 -0
  31. package/src/shared/config-manager.js +72 -3
  32. package/src/shared/error-reporter.js +117 -0
  33. package/src/shared/helpers/colors.js +57 -13
  34. package/src/shared/helpers/utils.js +46 -8
  35. package/src/shared/logger.js +12 -0
  36. package/src/shared/validation/config-validator.js +167 -0
@@ -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
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * PurgeTSS - SVG image pipeline (orchestrator)
3
+ *
4
+ * Post-step of the regular purge. Once `app.tss` is finalized, this module:
5
+ *
6
+ * 1. Parses app.tss into a class → props map.
7
+ * 2. Scans every view (.xml) and controller (.js) for SVG references paired
8
+ * with their classes / `class=""` attribute.
9
+ * 3. Reduces each SVG to a single resolved `{ widthDp, heightDp }` (max of
10
+ * every reference; falls back to viewBox aspect for height when `h-*` is
11
+ * missing or non-numeric).
12
+ * 4. Upserts `images.files` in config.cjs (never decreases — manual overrides
13
+ * always win).
14
+ * 5. Generates the iOS @1x/@2x/@3x and Android mdpi…xxxhdpi PNGs from the
15
+ * same SVG master, using a hash-based cache to skip unchanged inputs.
16
+ *
17
+ * The pipeline never rewrites XML/Controller files. Titanium falls back to the
18
+ * generated PNGs at runtime when an `image="/.../foo.svg"` reference resolves
19
+ * against a `.png` with the same basename in the platform assets folder.
20
+ *
21
+ * @fileoverview Compile-time SVG → multi-density PNG pipeline for Titanium
22
+ * @author César Estrada
23
+ */
24
+
25
+ import fs from 'fs'
26
+ import path from 'path'
27
+ import {
28
+ cwd,
29
+ projectsPurge_TSS_Images_Folder
30
+ } from '../../shared/constants.js'
31
+ import { detectProjectType } from '../branding/tiapp-reader.js'
32
+ import { genAndroidScales, genIphoneScales, ANDROID_SCALES, IPHONE_SCALES } from '../images/gen-scales.js'
33
+ import { parseTssMap } from './tss-reader.js'
34
+ import { deriveDimensions } from './derive-dimensions.js'
35
+ import { syncConfigImages } from './sync-images.js'
36
+ import { loadCache, saveCache, hashFile, isCacheHit, makeCacheEntry } from './cache.js'
37
+ import { extractSvgRefsFromXml } from '../analyzers/class-extractor.js'
38
+ import { extractSvgRefsFromController } from '../analyzers/controller-svg-refs.js'
39
+ import { getConfigFile } from '../../shared/config-manager.js'
40
+
41
+ /**
42
+ * Run the SVG image pipeline as a post-step of `purgetss` (purge command).
43
+ *
44
+ * Silent (no-op) when no SVG references are found. Logs progress at each step
45
+ * so the user can trace cache hits, dimension changes, and skipped SVGs.
46
+ *
47
+ * @param {Object} args
48
+ * @param {string} args.tssContent - Final purged TSS string (in memory).
49
+ * @param {string[]} args.viewPaths - Absolute paths to view .xml files.
50
+ * @param {string[]} args.controllerPaths - Absolute paths to controller .js files.
51
+ * @param {Object} args.logger - Logger with `.info/.warning/.success/.file`.
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function runSvgPipeline({ tssContent, viewPaths, controllerPaths, logger }) {
55
+
56
+ const imagesFolder = projectsPurge_TSS_Images_Folder
57
+ if (!fs.existsSync(imagesFolder)) return
58
+
59
+ const refsBySvg = collectRefs({ viewPaths, controllerPaths })
60
+ if (refsBySvg.size === 0) return
61
+
62
+ logger.info('Resolving SVG dimensions from app.tss...')
63
+ const tssMap = parseTssMap(tssContent)
64
+ const derived = await deriveDimensions({ refsBySvg, tssMap, imagesFolder, logger })
65
+ if (derived.size === 0) return
66
+
67
+ // Sync config.cjs first so external runs (`purgetss images`) reflect the
68
+ // current resolution even if the cache short-circuits actual generation.
69
+ // The returned `effective` map merges derived values with any pre-existing
70
+ // manual overrides (config wins when its width is higher). The actual file
71
+ // write is gated by `images.autoSync` so devs who prefer hand-managed config
72
+ // can opt out.
73
+ const autoSync = readAutoSyncFlag()
74
+ const { stats, effective } = syncConfigImages(derived, { logger, write: autoSync })
75
+ if (autoSync && (stats.inserted || stats.updated)) {
76
+ logger.info(
77
+ `config.cjs > images.files: ${stats.inserted} inserted, ${stats.updated} updated, ${stats.untouched} untouched`
78
+ )
79
+ } else if (!autoSync && (stats.inserted || stats.updated)) {
80
+ logger.info(
81
+ `images.autoSync is off — would have ${stats.inserted ? `inserted ${stats.inserted}` : ''}${stats.inserted && stats.updated ? ', ' : ''}${stats.updated ? `updated ${stats.updated}` : ''} entry/entries in config.cjs > images.files`
82
+ )
83
+ }
84
+
85
+ await generatePngs({ derived: effective, imagesFolder, logger })
86
+ }
87
+
88
+ function collectRefs({ viewPaths, controllerPaths }) {
89
+ const refsBySvg = new Map()
90
+ const push = (src, classes) => {
91
+ const relPath = normalizeSvgSrc(src)
92
+ if (!relPath) return
93
+ if (!refsBySvg.has(relPath)) refsBySvg.set(relPath, [])
94
+ refsBySvg.get(relPath).push({ classes })
95
+ }
96
+
97
+ for (const file of viewPaths) {
98
+ const text = fs.readFileSync(file, 'utf8')
99
+ if (!text) continue
100
+ let refs
101
+ try {
102
+ refs = extractSvgRefsFromXml(text, file)
103
+ } catch {
104
+ // Malformed XML is reported by the regular purge path already; the SVG
105
+ // pipeline silently skips so we don't double-error on the same file.
106
+ continue
107
+ }
108
+ for (const ref of refs) push(ref.src, ref.classes)
109
+ }
110
+
111
+ for (const file of controllerPaths) {
112
+ const text = fs.readFileSync(file, 'utf8')
113
+ if (!text) continue
114
+ const refs = extractSvgRefsFromController(text)
115
+ for (const ref of refs) push(ref.src, ref.classes)
116
+ }
117
+
118
+ return refsBySvg
119
+ }
120
+
121
+ // Map `/images/logos/foo.svg` → `logos/foo.svg`. Anything outside the
122
+ // `/images/` namespace is unknown to this pipeline and returns null.
123
+ function normalizeSvgSrc(src) {
124
+ if (typeof src !== 'string') return null
125
+ const stripped = src.replace(/^\/+/, '')
126
+ if (!stripped.startsWith('images/')) return null
127
+ return stripped.slice('images/'.length)
128
+ }
129
+
130
+ async function generatePngs({ derived, imagesFolder, logger }) {
131
+ const projectType = detectProjectType(cwd)
132
+ const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(cwd, projectType)
133
+ const cache = loadCache()
134
+ let generated = 0
135
+ let cached = 0
136
+
137
+ // The SVG pipeline always emits PNG, even if `images.format` is set to
138
+ // 'webp' / 'jpeg' / etc. for the standalone `purgetss images` command.
139
+ // Verified empirically: Titanium's `image="/.../foo.svg"` runtime fallback
140
+ // resolves to `.png` only — `.webp` and other formats are not picked up.
141
+ // Honoring images.format here would silently generate files Titanium can't
142
+ // load. The standalone command keeps respecting format for raster sources
143
+ // (where the reference uses the actual extension).
144
+ for (const [relPath, { widthDp, heightDp }] of derived) {
145
+ const absSvg = path.join(imagesFolder, relPath)
146
+ const relForOutput = swapExt(relPath, '.png')
147
+ const targets = enumerateTargets({ relForOutput, androidBaseDir, iphoneBaseDir })
148
+
149
+ const svgHash = hashFile(absSvg)
150
+ if (isCacheHit(cache[relPath], svgHash, widthDp, heightDp, targets)) {
151
+ cached++
152
+ continue
153
+ }
154
+
155
+ try {
156
+ await genAndroidScales(absSvg, relPath, androidBaseDir, { baseWidth: widthDp, baseHeight: heightDp })
157
+ await genIphoneScales(absSvg, relPath, iphoneBaseDir, { baseWidth: widthDp, baseHeight: heightDp })
158
+ } catch (err) {
159
+ logger.warning(`✗ ${relPath}: ${err.message}`)
160
+ continue
161
+ }
162
+
163
+ cache[relPath] = makeCacheEntry(svgHash, widthDp, heightDp, targets)
164
+ generated++
165
+ logger.info(`✓ ${relPath} → ${widthDp}×${heightDp}dp`)
166
+ }
167
+
168
+ saveCache(cache)
169
+ if (generated || cached) {
170
+ logger.info(`SVG pipeline: ${generated} generated, ${cached} cached.`)
171
+ }
172
+ }
173
+
174
+ function resolveOutputDirs(projectRoot, projectType) {
175
+ if (projectType === 'classic') {
176
+ return {
177
+ androidBaseDir: path.join(projectRoot, 'Resources', 'android', 'images'),
178
+ iphoneBaseDir: path.join(projectRoot, 'Resources', 'iphone', 'images')
179
+ }
180
+ }
181
+ return {
182
+ androidBaseDir: path.join(projectRoot, 'app', 'assets', 'android', 'images'),
183
+ iphoneBaseDir: path.join(projectRoot, 'app', 'assets', 'iphone', 'images')
184
+ }
185
+ }
186
+
187
+ function enumerateTargets({ relForOutput, androidBaseDir, iphoneBaseDir }) {
188
+ const out = []
189
+ const parsed = path.parse(relForOutput)
190
+ for (const { name } of ANDROID_SCALES) {
191
+ out.push(path.join(androidBaseDir, name, relForOutput))
192
+ }
193
+ for (const { suffix } of IPHONE_SCALES) {
194
+ out.push(path.join(iphoneBaseDir, parsed.dir, `${parsed.name}${suffix}${parsed.ext}`))
195
+ }
196
+ return out
197
+ }
198
+
199
+ function swapExt(relPath, newExt) {
200
+ const parsed = path.parse(relPath)
201
+ return path.join(parsed.dir, parsed.name + newExt)
202
+ }
203
+
204
+ // Read `images.autoSync` from config; defaults to true. The SVG pipeline
205
+ // ignores `images.format` / `images.quality` on purpose — see the comment in
206
+ // generatePngs about Titanium's .svg → .png-only fallback.
207
+ function readAutoSyncFlag() {
208
+ try {
209
+ const cfg = getConfigFile()
210
+ if (cfg && typeof cfg.images === 'object' && cfg.images.autoSync === false) {
211
+ return false
212
+ }
213
+ } catch { /* fall through to default */ }
214
+ return true
215
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * PurgeTSS - Class cascade resolver for the SVG pipeline
3
+ *
4
+ * Given an ordered list of classes (as they appear in `class=""` / `classes:`)
5
+ * and the class→props map produced by tss-reader, apply later-wins cascading
6
+ * to return the final width and height.
7
+ *
8
+ * V1 limitations (out of scope, see plan):
9
+ * - Tag selectors (`'View': { ... }`) not applied
10
+ * - ID selectors (`'#foo': { ... }`) not applied
11
+ * - Platform/device modifiers (`'[platform=ios]'`) not applied
12
+ *
13
+ * If a project needs any of these, add the SVG to `images.files` manually.
14
+ *
15
+ * @fileoverview Cascade resolver: classes[] + tssMap → { width, height }
16
+ * @author César Estrada
17
+ */
18
+
19
+ /**
20
+ * Resolve width/height for a list of classes by applying later-wins cascading.
21
+ * Classes that don't exist in the map are skipped silently — they may be
22
+ * unknown utilities, typos, or classes registered via tag/ID selectors that
23
+ * V1 doesn't see.
24
+ *
25
+ * Returned value shape per dimension:
26
+ * - number → resolved to a finite dp value
27
+ * - 'auto' → resolved to Ti.UI.SIZE
28
+ * - 'fill' → resolved to Ti.UI.FILL
29
+ * - 'percent' → resolved to a percentage / non-numeric (can't size a PNG)
30
+ * - null → no class in the list set this dimension
31
+ *
32
+ * @param {string[]} classes - Class tokens in source order.
33
+ * @param {Map} tssMap - Output of parseTssMap().
34
+ * @returns {{ width: number|'auto'|'fill'|'percent'|null, height: number|'auto'|'fill'|'percent'|null }}
35
+ */
36
+ export function resolveDimensions(classes, tssMap) {
37
+ let width = null
38
+ let height = null
39
+ for (const cls of classes) {
40
+ const entry = tssMap.get(cls)
41
+ if (!entry) continue
42
+ if (entry.width !== undefined) width = entry.width
43
+ if (entry.height !== undefined) height = entry.height
44
+ }
45
+ return { width, height }
46
+ }