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
@@ -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
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * PurgeTSS - config.cjs sync for SVG-derived dimensions
3
+ *
4
+ * Updates `images.files` inside the user's purgetss/config.cjs to reflect the
5
+ * dimensions resolved from app.tss. Companion to setConfigProperty (which only
6
+ * handles primitives) — this one knows how to upsert into an array of objects
7
+ * while preserving the user's formatting, comments, and any manual overrides.
8
+ *
9
+ * Policy (see plan):
10
+ * - Entry missing → insert `{ filename, width [, height] }`.
11
+ * - Entry present, width < derived → bump width (and height) to derived.
12
+ * - Entry present, width >= derived → leave alone. Manual overrides win.
13
+ *
14
+ * String-based mutation chosen over `recast` to avoid adding a heavy dep —
15
+ * config.cjs has a controlled shape since `purgetss init` writes it. If we
16
+ * can't safely locate the section we no-op and warn, mirroring setConfigProperty.
17
+ *
18
+ * @fileoverview Upsert image entries in config.cjs while preserving formatting
19
+ * @author César Estrada
20
+ */
21
+
22
+ import fs from 'fs'
23
+ import { projectsConfigJS } from '../../shared/constants.js'
24
+
25
+ /**
26
+ * Apply the derived dimensions to `images.files` in the user's config.cjs and
27
+ * return the effective per-SVG dimensions after the sync (i.e. the max of the
28
+ * derived value and any pre-existing manual override). Callers should drive
29
+ * PNG generation from the returned `effective` map so manual overrides — like
30
+ * pinning a width above what the class cascade resolves — produce PNGs at the
31
+ * higher resolution.
32
+ *
33
+ * @param {Map<string, { widthDp: number, heightDp: number }>} derived
34
+ * Keyed by SVG relpath (matches the `filename` field stored in config).
35
+ * @param {Object} [opts]
36
+ * @param {Object} [opts.logger] - Logger with `.warning(msg)` / `.info(msg)`.
37
+ * @param {boolean} [opts.write=true] - When false, computes the effective map
38
+ * without mutating config.cjs (for users who keep `images.autoSync: false`
39
+ * and prefer to manage `images.files` by hand).
40
+ * @returns {{
41
+ * stats: { updated: number, inserted: number, untouched: number },
42
+ * effective: Map<string, { widthDp: number, heightDp: number }>
43
+ * }}
44
+ */
45
+ export function syncConfigImages(derived, { logger, write = true } = {}) {
46
+ const stats = { updated: 0, inserted: 0, untouched: 0 }
47
+ const effective = new Map()
48
+ if (!fs.existsSync(projectsConfigJS)) {
49
+ logger?.warning('config.cjs not found — skipping SVG dimensions sync.')
50
+ // Without config we still want PNGs generated at the derived size — copy
51
+ // the derived map through verbatim.
52
+ for (const [k, v] of derived) effective.set(k, { ...v })
53
+ return { stats, effective }
54
+ }
55
+
56
+ let source = fs.readFileSync(projectsConfigJS, 'utf8')
57
+
58
+ for (const [relPath, { widthDp, heightDp }] of derived) {
59
+ const filename = toFilename(relPath)
60
+ const existing = findEntry(source, filename)
61
+ if (existing) {
62
+ // autoSync ON (this code path): config mirrors the current run. The
63
+ // derived numbers already reflect max() across every reference to this
64
+ // SVG in this run (see derive-dimensions.js) — sync just writes them
65
+ // through. No second max against the previous run, which would freeze
66
+ // shrunk classes at their old larger size. Users who want to pin a
67
+ // value by hand should set images.autoSync: false (write=false), which
68
+ // skips this file write entirely.
69
+ const desired = {}
70
+ if (widthDp != null) desired.width = widthDp
71
+ if (heightDp != null) desired.height = heightDp
72
+
73
+ const widthSame = (existing.width ?? null) === (desired.width ?? null)
74
+ const heightSame = (existing.height ?? null) === (desired.height ?? null)
75
+ if (widthSame && heightSame) {
76
+ stats.untouched++
77
+ } else {
78
+ source = replaceEntry(source, existing, filename, desired)
79
+ stats.updated++
80
+ }
81
+ effective.set(relPath, { widthDp: desired.width ?? null, heightDp: desired.height ?? null })
82
+ } else {
83
+ const entry = {}
84
+ if (widthDp != null) entry.width = widthDp
85
+ if (heightDp != null) entry.height = heightDp
86
+ const next = insertEntry(source, filename, entry)
87
+ if (next === null) {
88
+ logger?.warning(`Could not insert ${filename} into images.files (section missing or unreadable).`)
89
+ effective.set(relPath, { widthDp, heightDp })
90
+ continue
91
+ }
92
+ source = next
93
+ stats.inserted++
94
+ effective.set(relPath, { widthDp, heightDp })
95
+ }
96
+ }
97
+
98
+ // Only write when we actually mutated `source`. The loop above only mutates
99
+ // it inside the inserted/updated branches; `untouched` leaves it byte-identical
100
+ // to disk. Skipping the write avoids touching the mtime, which other parts of
101
+ // PurgeTSS use to invalidate utilities.tss — gratuitous mtime bumps would
102
+ // trigger needless rebuilds. Derive + effective + the SVG cache still run
103
+ // either way, so generation/validation is unaffected.
104
+ if (write && (stats.inserted > 0 || stats.updated > 0)) {
105
+ fs.writeFileSync(projectsConfigJS, source, 'utf8')
106
+ }
107
+ return { stats, effective }
108
+ }
109
+
110
+ function toFilename(relPath) {
111
+ // Stored form mirrors the `purgetss images` convention: `images/<subpath>/<name>.svg`
112
+ return `images/${relPath.replace(/^\/+/, '')}`
113
+ }
114
+
115
+ // Locate an entry like `{ filename: 'images/foo.svg', width: 128 }`. Tolerates
116
+ // keys in any order and either quoting style.
117
+ function findEntry(source, filename) {
118
+ const escaped = filename.replace(/[/.[\]()*+?^$|\\]/g, '\\$&')
119
+ const re = new RegExp(
120
+ `\\{[^{}]*?\\bfilename\\s*:\\s*['"\`]${escaped}['"\`][^{}]*?\\}`,
121
+ 'g'
122
+ )
123
+ const matches = [...source.matchAll(re)]
124
+ if (matches.length === 0) return null
125
+ const match = matches[0]
126
+ const body = match[0]
127
+ const width = readNumber(body, 'width')
128
+ const height = readNumber(body, 'height')
129
+ return { match: body, index: match.index, width, height }
130
+ }
131
+
132
+ function readNumber(body, key) {
133
+ const m = body.match(new RegExp(`\\b${key}\\s*:\\s*(\\d+)`))
134
+ return m ? Number(m[1]) : null
135
+ }
136
+
137
+ function replaceEntry(source, existing, filename, desired) {
138
+ const newEntry = renderEntry(filename, desired)
139
+ return source.slice(0, existing.index) + newEntry + source.slice(existing.index + existing.match.length)
140
+ }
141
+
142
+ function renderEntry(filename, { width, height }) {
143
+ const parts = [`filename: '${filename}'`]
144
+ if (width != null) parts.push(`width: ${width}`)
145
+ if (height != null) parts.push(`height: ${height}`)
146
+ return `{ ${parts.join(', ')} }`
147
+ }
148
+
149
+ // Insert a new entry into the images.files array. Returns the mutated source
150
+ // string, or null if the array can't be located/created safely.
151
+ function insertEntry(source, filename, entry) {
152
+ // First pass: make sure the array exists; this may mutate `source` to inject
153
+ // `files: []` into the `images` section when missing.
154
+ const ensured = ensureFilesArray(source)
155
+ if (ensured === null) return null
156
+ source = ensured
157
+
158
+ const filesArr = locateFilesArray(source)
159
+ if (!filesArr) return null
160
+
161
+ const { openIdx, closeIdx, indent } = filesArr
162
+ const inner = source.slice(openIdx + 1, closeIdx)
163
+ const newEntry = renderEntry(filename, entry)
164
+
165
+ const innerTrimmed = inner.replace(/\s+$/, '')
166
+ if (innerTrimmed.trim().length === 0) {
167
+ // Empty array: rewrite as multi-line with one entry.
168
+ const replacement = `[\n${indent} ${newEntry}\n${indent}]`
169
+ return source.slice(0, openIdx) + replacement + source.slice(closeIdx + 1)
170
+ }
171
+
172
+ // Non-empty: ensure the previous entry has a trailing comma and append a
173
+ // new line with the same indent as siblings (heuristic: 2-space extra).
174
+ const lines = innerTrimmed.split('\n')
175
+ const lastIdx = lines.length - 1
176
+ const lastLine = lines[lastIdx]
177
+ const stripped = lastLine.replace(/\s+$/, '')
178
+ if (!stripped.endsWith(',') && !stripped.endsWith('[')) {
179
+ lines[lastIdx] = stripped + ','
180
+ }
181
+ const newInner = lines.join('\n') + `\n${indent} ${newEntry}\n${indent}`
182
+ return source.slice(0, openIdx + 1) + newInner + source.slice(closeIdx)
183
+ }
184
+
185
+ // If `images.files` is missing, inject an empty `files: []` immediately before
186
+ // the section's closing brace, preserving the user's indentation.
187
+ function ensureFilesArray(source) {
188
+ const section = matchImagesSection(source)
189
+ if (!section) return null
190
+ if (/(\n\s*)files\s*:\s*\[/.test(section.body)) return source
191
+
192
+ const insertion = `\n${section.indent} files: []`
193
+ const closingIdx = section.end - 1 // position of '}'
194
+ const before = source.slice(0, closingIdx)
195
+ const after = source.slice(closingIdx)
196
+ // Drop trailing horizontal whitespace + newlines so the new line lands flush
197
+ // against the previous sibling line.
198
+ const trimmed = before.replace(/[ \t]+$/, '').replace(/\n+$/, '')
199
+ return appendTrailingCommaIfNeeded(trimmed) + insertion + '\n' + section.indent + after
200
+ }
201
+
202
+ // Ensure the previous property line is followed by a comma so the appended
203
+ // line parses. Honors trailing `// comments` by placing the comma between the
204
+ // value and the comment, never inside the comment itself.
205
+ function appendTrailingCommaIfNeeded(text) {
206
+ const lastLineStart = text.lastIndexOf('\n') + 1
207
+ const head = text.slice(0, lastLineStart)
208
+ const lastLine = text.slice(lastLineStart)
209
+ // Split off a trailing `// comment` (with optional leading whitespace).
210
+ const commentMatch = lastLine.match(/^(.*?)(\s*\/\/.*)$/)
211
+ const valuePart = (commentMatch ? commentMatch[1] : lastLine).replace(/[ \t]+$/, '')
212
+ const commentPart = commentMatch ? commentMatch[2] : ''
213
+ if (!valuePart || valuePart.endsWith(',') || valuePart.endsWith('{')) return text
214
+ return head + valuePart + ',' + commentPart
215
+ }
216
+
217
+ function matchImagesSection(source) {
218
+ // Locate the `images:` key, then bracket-balance to its matching `}`.
219
+ // The previous lazy-regex approach mis-identified the closing brace whenever
220
+ // `images: { ... }` was written on a single line (no `\n indent }` to anchor
221
+ // on) — it swallowed sibling sections and dropped `files: []` into whichever
222
+ // nested block happened to close first. Bracket-balancing works regardless
223
+ // of formatting, matching what the rest of PurgeTSS expects from config.cjs.
224
+ const m = source.match(/^([ \t]*)images\s*:\s*\{/m)
225
+ if (!m) return null
226
+
227
+ const openIdx = m.index + m[0].length - 1
228
+ const closeIdx = matchBracket(source, openIdx, '{', '}')
229
+ if (closeIdx === -1) return null
230
+
231
+ return {
232
+ start: m.index,
233
+ end: closeIdx + 1,
234
+ indent: m[1],
235
+ body: source.slice(openIdx + 1, closeIdx)
236
+ }
237
+ }
238
+
239
+ // Find the `files: [ ... ]` array inside the `images: { ... }` section.
240
+ function locateFilesArray(source) {
241
+ const section = matchImagesSection(source)
242
+ if (!section) return null
243
+
244
+ const filesMatch = section.body.match(/(\n\s*)files\s*:\s*\[/)
245
+ if (!filesMatch) return null
246
+
247
+ const arrayKeyStart = section.start + section.body.indexOf(filesMatch[0]) + filesMatch[1].length
248
+ const openIdx = source.indexOf('[', arrayKeyStart)
249
+ if (openIdx === -1) return null
250
+ const closeIdx = matchBracket(source, openIdx, '[', ']')
251
+ if (closeIdx === -1) return null
252
+
253
+ const lineStart = source.lastIndexOf('\n', arrayKeyStart) + 1
254
+ const indent = source.slice(lineStart, arrayKeyStart).match(/^[ \t]*/)[0]
255
+
256
+ return { openIdx, closeIdx, indent }
257
+ }
258
+
259
+ function matchBracket(source, startIdx, open, close) {
260
+ let depth = 0
261
+ let inSingle = false
262
+ let inDouble = false
263
+ let inBacktick = false
264
+ for (let i = startIdx; i < source.length; i++) {
265
+ const c = source[i]
266
+ if (c === '\'' && !inDouble && !inBacktick) inSingle = !inSingle
267
+ else if (c === '"' && !inSingle && !inBacktick) inDouble = !inDouble
268
+ else if (c === '`' && !inSingle && !inDouble) inBacktick = !inBacktick
269
+ else if (!inSingle && !inDouble && !inBacktick) {
270
+ if (c === open) depth++
271
+ else if (c === close) {
272
+ depth--
273
+ if (depth === 0) return i
274
+ }
275
+ }
276
+ }
277
+ return -1
278
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * PurgeTSS - TSS reader for the SVG pipeline
3
+ *
4
+ * Lightweight parser for the controlled TSS output PurgeTSS generates. Used by
5
+ * the SVG image pipeline to resolve final width/height per class after a
6
+ * regular purge run. NOT a general-purpose TSS parser — only the shapes the
7
+ * purger emits are recognized.
8
+ *
9
+ * Recognized line shape:
10
+ * '.classname': { prop: value, prop: value }
11
+ *
12
+ * Tag selectors (e.g. 'View': { ... }, 'ImageView[platform=ios]': { ... }) and
13
+ * '#id': { ... } selectors are skipped — the SVG pipeline resolves only by
14
+ * class cascade in V1.
15
+ *
16
+ * @fileoverview Class → properties map extracted from purged app.tss
17
+ * @author César Estrada
18
+ */
19
+
20
+ const CLASS_LINE = /^\s*'\.([^']+)'\s*:\s*\{([^}]*)\}\s*$/
21
+
22
+ /**
23
+ * Parse the controlled TSS string emitted by the purger into a class → props
24
+ * map. Values are returned in a normalized form:
25
+ *
26
+ * - number → finite numeric value (e.g. 128)
27
+ * - 'auto' → Ti.UI.SIZE
28
+ * - 'fill' → Ti.UI.FILL
29
+ * - 'percent' → percentage or any other non-resolvable value
30
+ *
31
+ * Only the `width` and `height` properties are normalized; other props are
32
+ * captured verbatim as the raw RHS string (callers don't currently need them).
33
+ *
34
+ * @param {string} tssContent - The full purged TSS (in-memory string).
35
+ * @returns {Map<string, { width?: number|'auto'|'fill'|'percent', height?: number|'auto'|'fill'|'percent', _raw: string }>}
36
+ */
37
+ export function parseTssMap(tssContent) {
38
+ const map = new Map()
39
+ if (typeof tssContent !== 'string' || !tssContent) return map
40
+
41
+ const lines = tssContent.split(/\r?\n/)
42
+ for (const line of lines) {
43
+ const stripped = stripLineComment(line)
44
+ const match = stripped.match(CLASS_LINE)
45
+ if (!match) continue
46
+
47
+ const className = match[1]
48
+ const body = match[2]
49
+ const props = parsePropBody(body)
50
+ map.set(className, { ...props, _raw: body.trim() })
51
+ }
52
+ return map
53
+ }
54
+
55
+ // Drop trailing `// comment` so it doesn't break the brace-matching regex.
56
+ function stripLineComment(line) {
57
+ let inSingle = false
58
+ let inDouble = false
59
+ for (let i = 0; i < line.length - 1; i++) {
60
+ const c = line[i]
61
+ if (c === '\'' && !inDouble) inSingle = !inSingle
62
+ else if (c === '"' && !inSingle) inDouble = !inDouble
63
+ else if (c === '/' && line[i + 1] === '/' && !inSingle && !inDouble) {
64
+ return line.slice(0, i)
65
+ }
66
+ }
67
+ return line
68
+ }
69
+
70
+ function parsePropBody(body) {
71
+ const out = {}
72
+ for (const pair of splitTopLevelCommas(body)) {
73
+ const colon = findTopLevelColon(pair)
74
+ if (colon === -1) continue
75
+ const key = pair.slice(0, colon).trim()
76
+ const value = pair.slice(colon + 1).trim()
77
+ if (key === 'width' || key === 'height') {
78
+ out[key] = normalizeDimensionValue(value)
79
+ }
80
+ }
81
+ return out
82
+ }
83
+
84
+ function splitTopLevelCommas(body) {
85
+ const out = []
86
+ let depth = 0
87
+ let inSingle = false
88
+ let inDouble = false
89
+ let last = 0
90
+ for (let i = 0; i < body.length; i++) {
91
+ const c = body[i]
92
+ if (c === '\'' && !inDouble) inSingle = !inSingle
93
+ else if (c === '"' && !inSingle) inDouble = !inDouble
94
+ else if (!inSingle && !inDouble) {
95
+ if (c === '(' || c === '[' || c === '{') depth++
96
+ else if (c === ')' || c === ']' || c === '}') depth--
97
+ else if (c === ',' && depth === 0) {
98
+ out.push(body.slice(last, i))
99
+ last = i + 1
100
+ }
101
+ }
102
+ }
103
+ out.push(body.slice(last))
104
+ return out.filter(s => s.trim().length > 0)
105
+ }
106
+
107
+ function findTopLevelColon(pair) {
108
+ let inSingle = false
109
+ let inDouble = false
110
+ let depth = 0
111
+ for (let i = 0; i < pair.length; i++) {
112
+ const c = pair[i]
113
+ if (c === '\'' && !inDouble) inSingle = !inSingle
114
+ else if (c === '"' && !inSingle) inDouble = !inDouble
115
+ else if (!inSingle && !inDouble) {
116
+ if (c === '(' || c === '[' || c === '{') depth++
117
+ else if (c === ')' || c === ']' || c === '}') depth--
118
+ else if (c === ':' && depth === 0) return i
119
+ }
120
+ }
121
+ return -1
122
+ }
123
+
124
+ // Recognize: numeric literals, Ti.UI.SIZE/FILL, quoted percentages or other
125
+ // non-numeric strings. Anything else falls into 'percent' (the catch-all for
126
+ // "this can't be turned into a dp number"). The label is historic — it
127
+ // originally only meant "string with %" — keeping it avoids ripple changes.
128
+ function normalizeDimensionValue(raw) {
129
+ const trimmed = raw.trim()
130
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
131
+ if (/^Ti\.UI\.SIZE$/i.test(trimmed)) return 'auto'
132
+ if (/^Ti\.UI\.FILL$/i.test(trimmed)) return 'fill'
133
+ return 'percent'
134
+ }