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
|
@@ -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
|
+
}
|