purgetss 7.5.2 → 7.6.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 (50) hide show
  1. package/README.md +93 -11
  2. package/bin/purgetss +140 -1
  3. package/dist/purgetss.ui.js +65 -26
  4. package/dist/utilities.tss +21 -4
  5. package/experimental/completions2.js +1 -1
  6. package/lib/completions/titanium/completions-v3.json +62 -1
  7. package/lib/templates/purgetss.config.js.cjs +15 -1
  8. package/lib/templates/purgetss.ui.js.cjs +64 -25
  9. package/package.json +3 -1
  10. package/src/cli/commands/brand.js +69 -0
  11. package/src/cli/commands/create.js +11 -7
  12. package/src/cli/commands/fonts.js +9 -9
  13. package/src/cli/commands/icon-library.js +18 -16
  14. package/src/cli/commands/images.js +116 -0
  15. package/src/cli/commands/init.js +4 -0
  16. package/src/cli/commands/module.js +4 -2
  17. package/src/cli/commands/purge.js +77 -101
  18. package/src/cli/commands/semantic.js +180 -0
  19. package/src/cli/commands/shades.js +332 -13
  20. package/src/cli/utils/project-detection.js +4 -2
  21. package/src/core/analyzers/class-extractor.js +110 -3
  22. package/src/core/branding/brand-config.js +111 -0
  23. package/src/core/branding/branding-logger.js +40 -0
  24. package/src/core/branding/cleanup-legacy.js +220 -0
  25. package/src/core/branding/ensure-brand-section.js +80 -0
  26. package/src/core/branding/gen-android-adaptive.js +116 -0
  27. package/src/core/branding/gen-android-legacy.js +63 -0
  28. package/src/core/branding/gen-ic-launcher-xml.js +29 -0
  29. package/src/core/branding/gen-ios-dark.js +70 -0
  30. package/src/core/branding/gen-ios-tinted.js +55 -0
  31. package/src/core/branding/gen-ios.js +69 -0
  32. package/src/core/branding/gen-marketplace.js +71 -0
  33. package/src/core/branding/gen-notification.js +76 -0
  34. package/src/core/branding/gen-splash.js +64 -0
  35. package/src/core/branding/index.js +336 -0
  36. package/src/core/branding/post-gen-notes.js +145 -0
  37. package/src/core/branding/prepare-master.js +108 -0
  38. package/src/core/branding/tiapp-reader.js +110 -0
  39. package/src/core/builders/tailwind-helpers.js +1 -1
  40. package/src/core/images/ensure-images-section.js +57 -0
  41. package/src/core/images/gen-scales.js +181 -0
  42. package/src/core/images/index.js +171 -0
  43. package/src/shared/config-manager.js +46 -0
  44. package/src/shared/config-writer.js +84 -0
  45. package/src/shared/constants.js +3 -0
  46. package/src/shared/helpers/typography.js +38 -3
  47. package/src/shared/logger.js +69 -4
  48. package/src/shared/prompt.js +64 -0
  49. package/src/shared/svg-utils.js +80 -0
  50. package/src/shared/utils.js +8 -4
@@ -0,0 +1,110 @@
1
+ /**
2
+ * PurgeTSS - tiapp-reader
3
+ *
4
+ * Parses tiapp.xml and exposes just enough config for context-aware cleanup
5
+ * decisions. Uses fast-xml-parser under the hood when available, falls back
6
+ * to regex when the dep is unavailable.
7
+ *
8
+ * @fileoverview tiapp.xml parser for branding cleanup
9
+ * @author César Estrada
10
+ */
11
+
12
+ import fs from 'fs'
13
+ import path from 'path'
14
+
15
+ let XMLParser = null
16
+ try {
17
+ const mod = await import('fast-xml-parser')
18
+ XMLParser = mod.XMLParser
19
+ } catch {
20
+ XMLParser = null
21
+ }
22
+
23
+ export function readTiapp(tiappPath) {
24
+ const result = {
25
+ exists: false,
26
+ storyboardEnabled: false,
27
+ portraitOnly: false,
28
+ defaultBgColor: null
29
+ }
30
+
31
+ if (!fs.existsSync(tiappPath)) return result
32
+
33
+ result.exists = true
34
+ const xml = fs.readFileSync(tiappPath, 'utf8')
35
+
36
+ if (XMLParser) return parseWithFastXml(xml, result)
37
+ return parseWithRegex(xml, result)
38
+ }
39
+
40
+ function parseWithFastXml(xml, result) {
41
+ try {
42
+ const parser = new XMLParser({
43
+ ignoreAttributes: false,
44
+ attributeNamePrefix: '@_',
45
+ parseAttributeValue: false,
46
+ parseTagValue: false,
47
+ trimValues: true
48
+ })
49
+ const doc = parser.parse(xml)
50
+
51
+ const ios = doc?.['ti:app']?.ios || doc?.ti?.app?.ios
52
+ if (ios) {
53
+ const sb = ios['enable-launch-screen-storyboard']
54
+ if (typeof sb === 'string' && sb.trim().toLowerCase() === 'true') {
55
+ result.storyboardEnabled = true
56
+ }
57
+ const bg = ios['default-background-color']
58
+ if (typeof bg === 'string' && bg.trim()) {
59
+ result.defaultBgColor = bg.trim()
60
+ }
61
+
62
+ const orientations = ios.orientations
63
+ if (orientations) {
64
+ const iphoneBlock = orientations.iphone
65
+ if (iphoneBlock !== undefined) {
66
+ const flat = JSON.stringify(iphoneBlock)
67
+ result.portraitOnly = !/Landscape/i.test(flat)
68
+ }
69
+ }
70
+ }
71
+ return result
72
+ } catch {
73
+ return parseWithRegex(xml, result)
74
+ }
75
+ }
76
+
77
+ function parseWithRegex(xml, result) {
78
+ if (/<enable-launch-screen-storyboard>\s*true\s*</i.test(xml)) {
79
+ result.storyboardEnabled = true
80
+ }
81
+
82
+ const bgMatch = xml.match(/<default-background-color>\s*(#[0-9A-Fa-f]{6,8})\s*</)
83
+ if (bgMatch) result.defaultBgColor = bgMatch[1]
84
+
85
+ if (/<orientations\b/i.test(xml) && !/UIInterfaceOrientationLandscape/i.test(xml)) {
86
+ result.portraitOnly = true
87
+ }
88
+
89
+ return result
90
+ }
91
+
92
+ export function detectProjectType(projectRoot) {
93
+ if (fs.existsSync(path.join(projectRoot, 'app'))) return 'alloy'
94
+ if (fs.existsSync(path.join(projectRoot, 'Resources'))) return 'classic'
95
+ return 'unknown'
96
+ }
97
+
98
+ export function resolveAndroidResRoot(projectRoot, projectType) {
99
+ if (projectType === 'alloy') return path.join(projectRoot, 'app', 'platform', 'android', 'res')
100
+ if (projectType === 'classic') return path.join(projectRoot, 'platform', 'android', 'res')
101
+ return null
102
+ }
103
+
104
+ export function hasAdaptiveIcons(projectRoot) {
105
+ const candidates = [
106
+ path.join(projectRoot, 'app', 'platform', 'android', 'res', 'mipmap-anydpi-v26'),
107
+ path.join(projectRoot, 'platform', 'android', 'res', 'mipmap-anydpi-v26')
108
+ ]
109
+ return candidates.some((c) => fs.existsSync(c))
110
+ }
@@ -383,7 +383,7 @@ export function combineAllValues(base, defaultTheme) {
383
383
  allValues.contentWidth = combineKeys(configFile.theme, base.width, 'contentWidth')
384
384
  allValues.countDownDuration = combineKeys(configFile.theme, base.spacing, 'countDownDuration')
385
385
  allValues.elevation = combineKeys(configFile.theme, base.spacing, 'elevation')
386
- allValues.fontFamily = combineKeys(configFile.theme, defaultTheme.fontFamily, 'fontFamily')
386
+ allValues.fontFamily = combineKeys(configFile.theme, {}, 'fontFamily')
387
387
  allValues.fontSize = combineKeys(configFile.theme, base.fontSize, 'fontSize')
388
388
  allValues.fontWeight = combineKeys(configFile.theme, defaultTheme.fontWeight, 'fontWeight')
389
389
  allValues.gap = combineKeys(configFile.theme, base.spacing, 'gap')
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PurgeTSS - Ensure `images:` section exists in purgetss/config.cjs
3
+ *
4
+ * Parallel to ensure-brand-section.js. When a project was initialized before
5
+ * the `images` command was introduced, its config.cjs won't have an `images:`
6
+ * key. On first invocation of `purgetss images`, we patch the file to insert
7
+ * the default block between `brand:` and `theme:` (or before `theme:` if
8
+ * `brand:` is not present yet). The user's existing keys are untouched.
9
+ *
10
+ * Also ensures `purgetss/images/` exists so the user can see where sources go,
11
+ * mirroring the `purgetss/fonts/` and `purgetss/brand/` conventions.
12
+ *
13
+ * @fileoverview Auto-injects the `images:` section on first `images` run
14
+ * @author César Estrada
15
+ */
16
+
17
+ import fs from 'fs'
18
+ import chalk from 'chalk'
19
+ import { projectsConfigJS, projectsPurge_TSS_Images_Folder } from '../../shared/constants.js'
20
+ import { logger } from '../branding/branding-logger.js'
21
+
22
+ const IMAGES_BLOCK = ` images: {
23
+ quality: 85, // JPEG/WebP/AVIF quality (0-100)
24
+ format: null, // null = keep original; 'webp' | 'jpeg' | 'png' to convert every image
25
+ confirmOverwrites: true // prompt before overwriting files (set false to skip)
26
+ },
27
+ `
28
+
29
+ export function ensureImagesSection() {
30
+ if (!fs.existsSync(projectsPurge_TSS_Images_Folder)) {
31
+ fs.mkdirSync(projectsPurge_TSS_Images_Folder, { recursive: true })
32
+ }
33
+
34
+ if (!fs.existsSync(projectsConfigJS)) return
35
+
36
+ const original = fs.readFileSync(projectsConfigJS, 'utf8')
37
+
38
+ if (/^\s*images\s*:/m.test(original)) return
39
+
40
+ // Insert before the `theme:` key so the order stays purge → brand → images → theme.
41
+ const match = original.match(/(^\s*)theme\s*:/m)
42
+ if (!match) return
43
+
44
+ const patched = original.replace(match[0], `${IMAGES_BLOCK}${match[0]}`)
45
+
46
+ try {
47
+ fs.writeFileSync(projectsConfigJS, patched, 'utf8')
48
+ console.log()
49
+ logger.success(`Added ${chalk.cyan('images:')} section to ${chalk.cyan('./purgetss/config.cjs')} with default values.`)
50
+ console.log(` Edit that block to customize defaults (quality, format).`)
51
+ console.log(` CLI flags always win over config values.`)
52
+ console.log()
53
+ } catch (err) {
54
+ logger.warning(`Could not auto-add images: section to config.cjs (${err.message}).`)
55
+ logger.warning(`The command will still run using built-in defaults.`)
56
+ }
57
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * PurgeTSS - gen-scales
3
+ *
4
+ * For a single source image, generate the 8 Titanium multi-density variants:
5
+ *
6
+ * Android (5 densities, each in its own mipmap/drawable folder):
7
+ * res-mdpi = 1/4 of source (1× baseline)
8
+ * res-hdpi = 1.5/4 of source
9
+ * res-xhdpi = 2/4 of source (2×)
10
+ * res-xxhdpi = 3/4 of source (3×)
11
+ * res-xxxhdpi = full source (4×, maximum density)
12
+ *
13
+ * iPhone (3 scales in one folder, suffix in filename):
14
+ * @1x = 1/4 of source
15
+ * @2x = 2/4 of source
16
+ * @3x = 3/4 of source
17
+ *
18
+ * Convention inherited from Titanium Alloy: source images are treated as
19
+ * 4× (xxxhdpi/@4x) masters, and all other scales are derived from them.
20
+ *
21
+ * @fileoverview Scale a source image into the 8 Titanium density variants
22
+ * @author César Estrada
23
+ */
24
+
25
+ import fs from 'fs'
26
+ import path from 'path'
27
+ import sharp from 'sharp'
28
+ import { logger } from '../branding/branding-logger.js'
29
+ import { computeSvgDensity, readSvgSafely } from '../../shared/svg-utils.js'
30
+
31
+ /**
32
+ * Read source image: for SVGs, uses readSvgSafely (buffer + metadata + viewBox
33
+ * warning); for raster images, reads metadata directly from the file path.
34
+ *
35
+ * @returns {Promise<{path: string, meta: Object, isSvg: boolean, buffer: Buffer|null}>}
36
+ */
37
+ async function readSource(sourceFile) {
38
+ const isSvg = path.extname(sourceFile).toLowerCase() === '.svg'
39
+ if (isSvg) {
40
+ const { buffer, meta } = await readSvgSafely(sourceFile, { logger })
41
+ return { path: sourceFile, meta, isSvg: true, buffer }
42
+ }
43
+ const meta = await sharp(sourceFile).metadata()
44
+ return { path: sourceFile, meta, isSvg: false, buffer: null }
45
+ }
46
+
47
+ /**
48
+ * Build a Sharp pipeline for the requested scale. For SVG sources, density is
49
+ * computed so the rasterization lands at ~2× target — avoiding the pixel limit
50
+ * regardless of viewBox size.
51
+ */
52
+ function buildScalePipeline(src, targetMax) {
53
+ if (src.isSvg) {
54
+ const naturalMax = Math.max(src.meta.width, src.meta.height)
55
+ // 2× target for antialiasing headroom before Sharp's downsample.
56
+ const density = computeSvgDensity(naturalMax, targetMax * 2)
57
+ return sharp(src.buffer, { density, limitInputPixels: false })
58
+ }
59
+ return sharp(src.path)
60
+ }
61
+
62
+ export const ANDROID_SCALES = Object.freeze([
63
+ { name: 'res-mdpi', factor: 1 / 4 },
64
+ { name: 'res-hdpi', factor: 1.5 / 4 },
65
+ { name: 'res-xhdpi', factor: 2 / 4 },
66
+ { name: 'res-xxhdpi', factor: 3 / 4 },
67
+ { name: 'res-xxxhdpi', factor: 4 / 4 }
68
+ ])
69
+
70
+ export const IPHONE_SCALES = Object.freeze([
71
+ { suffix: '', factor: 1 / 4 },
72
+ { suffix: '@2x', factor: 2 / 4 },
73
+ { suffix: '@3x', factor: 3 / 4 }
74
+ ])
75
+
76
+ /**
77
+ * Scale a source image into all Android density variants.
78
+ *
79
+ * @param {string} sourceFile - Absolute path to source image
80
+ * @param {string} relPath - Path inside the source root (e.g. 'buttons/btn.png')
81
+ * @param {string} androidBaseDir - e.g. <project>/app/assets/android/images
82
+ * @param {Object} opts
83
+ * @param {string|null} [opts.format] - 'webp'|'jpeg'|'png'|null (null = keep original)
84
+ * @param {number} [opts.quality=85]
85
+ * @returns {Promise<string[]>} Paths written
86
+ */
87
+ export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts = {}) {
88
+ const { format = null, quality = 85 } = opts
89
+ const src = await readSource(sourceFile)
90
+ const written = []
91
+
92
+ for (const { name, factor } of ANDROID_SCALES) {
93
+ const targetWidth = Math.max(1, Math.round(src.meta.width * factor))
94
+ const targetHeight = Math.max(1, Math.round(src.meta.height * factor))
95
+
96
+ const outDir = path.join(androidBaseDir, name, path.dirname(relPath))
97
+ fs.mkdirSync(outDir, { recursive: true })
98
+
99
+ const outPath = path.join(outDir, renameWithFormat(path.basename(relPath), format, src.isSvg))
100
+ await writeScaled(src, outPath, targetWidth, targetHeight, format, quality)
101
+ written.push(outPath)
102
+ }
103
+ return written
104
+ }
105
+
106
+ /**
107
+ * Scale a source image into all iPhone scale variants.
108
+ *
109
+ * @param {string} sourceFile - Absolute path to source image
110
+ * @param {string} relPath - Path inside the source root (e.g. 'buttons/btn.png')
111
+ * @param {string} iphoneBaseDir - e.g. <project>/app/assets/iphone/images
112
+ * @param {Object} opts - Same shape as genAndroidScales
113
+ * @returns {Promise<string[]>} Paths written
114
+ */
115
+ export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts = {}) {
116
+ const { format = null, quality = 85 } = opts
117
+ const src = await readSource(sourceFile)
118
+ const written = []
119
+
120
+ const parsed = path.parse(relPath)
121
+ const outDir = path.join(iphoneBaseDir, parsed.dir)
122
+ fs.mkdirSync(outDir, { recursive: true })
123
+
124
+ for (const { suffix, factor } of IPHONE_SCALES) {
125
+ const targetWidth = Math.max(1, Math.round(src.meta.width * factor))
126
+ const targetHeight = Math.max(1, Math.round(src.meta.height * factor))
127
+
128
+ // SVG sources can't be written as SVG by Sharp — fall back to PNG if the
129
+ // user didn't specify an explicit output format.
130
+ const ext = format ? `.${format}` : (src.isSvg ? '.png' : parsed.ext)
131
+ const outName = `${parsed.name}${suffix}${ext}`
132
+ const outPath = path.join(outDir, outName)
133
+
134
+ await writeScaled(src, outPath, targetWidth, targetHeight, format, quality)
135
+ written.push(outPath)
136
+ }
137
+ return written
138
+ }
139
+
140
+ function renameWithFormat(filename, format, isSvg = false) {
141
+ if (format) {
142
+ const parsed = path.parse(filename)
143
+ return `${parsed.name}.${format}`
144
+ }
145
+ // SVG masters can't be written back as SVG by Sharp — coerce to PNG.
146
+ if (isSvg) {
147
+ const parsed = path.parse(filename)
148
+ return `${parsed.name}.png`
149
+ }
150
+ return filename
151
+ }
152
+
153
+ async function writeScaled(src, outPath, width, height, format, quality) {
154
+ const targetMax = Math.max(width, height)
155
+ let pipeline = buildScalePipeline(src, targetMax).resize({
156
+ width,
157
+ height,
158
+ fit: 'contain',
159
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
160
+ })
161
+
162
+ // For SVG sources without an explicit format, coerce output to PNG
163
+ // (Sharp cannot write SVG).
164
+ const fallbackExt = src.isSvg ? 'png' : path.extname(src.path).slice(1).toLowerCase()
165
+ const fmt = format || fallbackExt
166
+ pipeline = applyFormat(pipeline, fmt === 'jpg' ? 'jpeg' : fmt, quality)
167
+
168
+ await pipeline.toFile(outPath)
169
+ }
170
+
171
+ function applyFormat(pipeline, format, quality) {
172
+ switch (format) {
173
+ case 'png': return pipeline.png({ quality, compressionLevel: 9 })
174
+ case 'webp': return pipeline.webp({ quality })
175
+ case 'avif': return pipeline.avif({ quality })
176
+ case 'tiff': return pipeline.tiff({ quality, compression: 'lzw' })
177
+ case 'gif': return pipeline.gif()
178
+ case 'jpeg': return pipeline.flatten({ background: '#ffffff' }).jpeg({ quality })
179
+ default: return pipeline
180
+ }
181
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * PurgeTSS - Images pipeline orchestrator
3
+ *
4
+ * Discovers source images (auto from `purgetss/images/` or from a user-provided
5
+ * path) and generates Titanium multi-density variants for Alloy or Classic
6
+ * projects.
7
+ *
8
+ * Layouts:
9
+ * Alloy: app/assets/android/images/res-{density}/ + app/assets/iphone/images/
10
+ * Classic: Resources/android/images/res-{density}/ + Resources/iphone/images/
11
+ *
12
+ * Subdirectories of `purgetss/images/` are preserved in the output paths.
13
+ *
14
+ * @fileoverview Orchestrator for `purgetss images`
15
+ * @author César Estrada
16
+ */
17
+
18
+ import fs from 'fs'
19
+ import path from 'path'
20
+ import { logger } from '../branding/branding-logger.js'
21
+ import { logger as mainLogger } from '../../shared/logger.js'
22
+ import { confirmWithAlways } from '../../shared/prompt.js'
23
+ import { setConfigProperty } from '../../shared/config-writer.js'
24
+ import { detectProjectType } from '../branding/tiapp-reader.js'
25
+ import { genAndroidScales, genIphoneScales } from './gen-scales.js'
26
+ import { projectsPurge_TSS_Images_Folder } from '../../shared/constants.js'
27
+
28
+ const SUPPORTED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'])
29
+
30
+ export async function runImages(opts) {
31
+ const {
32
+ source, // resolved absolute path (file or directory)
33
+ projectRoot = process.cwd(),
34
+ androidOnly = false,
35
+ iphoneOnly = false,
36
+ format = null,
37
+ quality = 85,
38
+ dryRun = false,
39
+ yes = false,
40
+ confirmOverwrites = true
41
+ } = opts
42
+
43
+ if (!fs.existsSync(source)) {
44
+ throw new Error(`Source not found: ${source}`)
45
+ }
46
+
47
+ const projectType = detectProjectType(projectRoot)
48
+ const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(projectRoot, projectType)
49
+
50
+ const files = collectImageFiles(source)
51
+
52
+ console.log()
53
+ mainLogger.info('Generating multi-density image variants...')
54
+ console.log()
55
+ logger.property('Project: ', `${projectRoot} (${projectType})`)
56
+ logger.property('Source: ', source)
57
+ logger.property('Images: ', `${files.length} file${files.length === 1 ? '' : 's'}`)
58
+ const platforms = []
59
+ if (!iphoneOnly) platforms.push('Android (5 densities)')
60
+ if (!androidOnly) platforms.push('iPhone (@1x, @2x, @3x)')
61
+ logger.property('Platforms: ', platforms.join(' + '))
62
+ if (format) logger.property('Format: ', `convert all to ${format}`)
63
+ if (dryRun) logger.warning('DRY RUN — no files will be written')
64
+
65
+ if (files.length === 0) {
66
+ logger.warning('No images found. Put your source files inside purgetss/images/ (svg, png, jpg, jpeg, webp, gif).')
67
+ return { written: [] }
68
+ }
69
+
70
+ if (!dryRun && confirmOverwrites) {
71
+ logger.warning(`⚠ Scaled images will OVERWRITE existing variants under ${androidBaseDir} and ${iphoneBaseDir}.`)
72
+ logger.warning(` Commit first if you want a rollback.`)
73
+ const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
74
+ if (choice === 'no') {
75
+ logger.info('Aborted.')
76
+ // eslint-disable-next-line n/no-process-exit
77
+ process.exit(0)
78
+ }
79
+ if (choice === 'always') {
80
+ const saved = setConfigProperty('images', 'confirmOverwrites', false)
81
+ if (saved) {
82
+ logger.success('Saved images.confirmOverwrites = false to purgetss/config.cjs — you won\'t be asked again.')
83
+ } else {
84
+ logger.warning('Could not persist preference (config.cjs missing or unreadable). Proceeding anyway.')
85
+ }
86
+ }
87
+ }
88
+
89
+ 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.`)
92
+ }
93
+
94
+ // Relative paths preserve the user's subdirectory structure inside purgetss/images/.
95
+ // If the source is inside purgetss/images/, compute relPath from that folder
96
+ // so subdirectories are always preserved in the output — regardless of whether
97
+ // the user passed the full folder, a subfolder, or a single file.
98
+ const imagesFolder = projectRoot === process.cwd()
99
+ ? projectsPurge_TSS_Images_Folder
100
+ : path.join(projectRoot, 'purgetss', 'images')
101
+ const sourceIsInsideImagesFolder = source === imagesFolder
102
+ || source.startsWith(imagesFolder + path.sep)
103
+
104
+ const sourceRoot = sourceIsInsideImagesFolder
105
+ ? imagesFolder
106
+ : (fs.statSync(source).isDirectory() ? source : path.dirname(source))
107
+
108
+ const written = []
109
+
110
+ logger.section('Scaling')
111
+ for (const file of files) {
112
+ const relPath = path.relative(sourceRoot, file)
113
+ logger.bullet(relPath)
114
+
115
+ if (dryRun) continue
116
+
117
+ if (!iphoneOnly) {
118
+ const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, { format, quality })
119
+ written.push(...androidFiles)
120
+ }
121
+ if (!androidOnly) {
122
+ const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, { format, quality })
123
+ written.push(...iphoneFiles)
124
+ }
125
+ }
126
+
127
+ if (!dryRun) {
128
+ console.log()
129
+ logger.success(`${written.length} file${written.length === 1 ? '' : 's'} written.`)
130
+ logger.property('Android: ', androidBaseDir)
131
+ logger.property('iPhone: ', iphoneBaseDir)
132
+ }
133
+
134
+ return { written }
135
+ }
136
+
137
+ function resolveOutputDirs(projectRoot, projectType) {
138
+ if (projectType === 'classic') {
139
+ return {
140
+ androidBaseDir: path.join(projectRoot, 'Resources', 'android', 'images'),
141
+ iphoneBaseDir: path.join(projectRoot, 'Resources', 'iphone', 'images')
142
+ }
143
+ }
144
+ // Alloy (or unknown fallback uses Alloy convention)
145
+ return {
146
+ androidBaseDir: path.join(projectRoot, 'app', 'assets', 'android', 'images'),
147
+ iphoneBaseDir: path.join(projectRoot, 'app', 'assets', 'iphone', 'images')
148
+ }
149
+ }
150
+
151
+ function collectImageFiles(source) {
152
+ const stat = fs.statSync(source)
153
+ if (stat.isFile()) {
154
+ return SUPPORTED_EXTS.has(path.extname(source).toLowerCase()) ? [source] : []
155
+ }
156
+ // Directory — recurse
157
+ return walk(source)
158
+ }
159
+
160
+ function walk(dir) {
161
+ const out = []
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const full = path.join(dir, entry.name)
164
+ if (entry.isDirectory()) {
165
+ out.push(...walk(full))
166
+ } else if (entry.isFile() && SUPPORTED_EXTS.has(path.extname(entry.name).toLowerCase())) {
167
+ out.push(full)
168
+ }
169
+ }
170
+ return out
171
+ }
@@ -16,6 +16,9 @@ import defaultTheme from 'tailwindcss/defaultTheme.js'
16
16
  import {
17
17
  projectsConfigJS,
18
18
  projectsPurgeTSSFolder,
19
+ projectsPurge_TSS_Fonts_Folder,
20
+ projectsPurge_TSS_Brand_Folder,
21
+ projectsPurge_TSS_Images_Folder,
19
22
  srcConfigFile
20
23
  } from './constants.js'
21
24
  import { logger } from './logger.js'
@@ -24,6 +27,29 @@ import { makeSureFolderExists } from './utils.js'
24
27
  // Create require for ESM compatibility
25
28
  const require = createRequire(import.meta.url)
26
29
 
30
+ /**
31
+ * Parse a padding value from either a number or a percentage string.
32
+ *
33
+ * 20 → 20
34
+ * '20' → 20
35
+ * '20%' → 20
36
+ *
37
+ * Used for `brand.padding` and `brand.iosPadding` so users can write
38
+ * self-documenting values like `padding: '25%'` in their config.
39
+ *
40
+ * @param {number|string} value
41
+ * @param {string} fieldName - Config path for error messages (e.g. 'brand.padding')
42
+ * @returns {number} Integer 0-40
43
+ */
44
+ function parsePadding(value, fieldName) {
45
+ if (typeof value === 'number') return value
46
+ if (typeof value === 'string') {
47
+ const match = value.trim().match(/^(\d+)%?$/)
48
+ if (match) return parseInt(match[1], 10)
49
+ }
50
+ throw new Error(`Invalid ${fieldName}: expected number or '<N>%' string, got ${JSON.stringify(value)}`)
51
+ }
52
+
27
53
  /**
28
54
  * Ensure config file exists - SIMPLE logic
29
55
  * 1. If config.cjs exists → use it
@@ -31,6 +57,14 @@ const require = createRequire(import.meta.url)
31
57
  * 3. If nothing exists → create config.cjs
32
58
  */
33
59
  export function ensureConfig() {
60
+ // Ensure the full purgetss/ subfolder layout exists on every init — keeps
61
+ // fonts/, brand/, and images/ discoverable from day one instead of
62
+ // appearing lazily on first use of their respective commands.
63
+ makeSureFolderExists(projectsPurgeTSSFolder)
64
+ makeSureFolderExists(projectsPurge_TSS_Fonts_Folder)
65
+ makeSureFolderExists(projectsPurge_TSS_Brand_Folder)
66
+ makeSureFolderExists(projectsPurge_TSS_Images_Folder)
67
+
34
68
  // 1. ¿Existe config.cjs? → Úsalo
35
69
  if (fs.existsSync(projectsConfigJS)) {
36
70
  return
@@ -93,6 +127,18 @@ export function getConfigFile() {
93
127
  configFile.purge.options.safelist = configFile.purge.options.safelist ?? []
94
128
  configFile.purge.options.plugins = configFile.purge.options.plugins ?? []
95
129
 
130
+ configFile.brand = configFile.brand ?? {}
131
+ configFile.brand.bgColor = configFile.brand.bgColor ?? '#FFFFFF'
132
+ configFile.brand.padding = parsePadding(configFile.brand.padding ?? 15, 'brand.padding')
133
+ configFile.brand.iosPadding = parsePadding(configFile.brand.iosPadding ?? 4, 'brand.iosPadding')
134
+ configFile.brand.darkBgColor = configFile.brand.darkBgColor ?? null
135
+ configFile.brand.notification = configFile.brand.notification ?? false
136
+ configFile.brand.splash = configFile.brand.splash ?? false
137
+
138
+ configFile.images = configFile.images ?? {}
139
+ configFile.images.quality = configFile.images.quality ?? 85
140
+ configFile.images.format = configFile.images.format ?? null
141
+
96
142
  configFile.theme = configFile.theme ?? {}
97
143
  configFile.theme.extend = configFile.theme.extend ?? {}
98
144
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * PurgeTSS - config.cjs patcher
3
+ *
4
+ * Small helper for writing a single property into an existing top-level
5
+ * section (e.g. `brand: { ... }` or `images: { ... }`) of the user's
6
+ * purgetss/config.cjs. Used by the interactive "always" confirmation option
7
+ * to persist the user's preference.
8
+ *
9
+ * Deliberately narrow: only touches the target property, preserves the
10
+ * user's indentation style, and leaves every other line byte-identical.
11
+ * If the target section or key can't be located safely, it no-ops rather
12
+ * than risking a corrupted config — the caller falls back to the one-shot
13
+ * `--yes` / PURGETSS_YES behavior.
14
+ *
15
+ * @fileoverview Non-destructive single-property writer for config.cjs
16
+ * @author César Estrada
17
+ */
18
+
19
+ import fs from 'fs'
20
+ import { projectsConfigJS } from './constants.js'
21
+
22
+ /**
23
+ * Set `section.key = value` inside the user's purgetss/config.cjs, preserving
24
+ * the rest of the file. If the key already exists in that section, its value
25
+ * is replaced in place; otherwise a new line is appended before the section's
26
+ * closing brace.
27
+ *
28
+ * @param {string} section - Top-level section name (e.g. 'brand', 'images').
29
+ * @param {string} key - Property key to set inside the section.
30
+ * @param {*} value - JSON-serializable value (booleans, numbers, strings, null).
31
+ * @returns {boolean} True on success; false if config is missing or the
32
+ * section couldn't be located.
33
+ */
34
+ export function setConfigProperty(section, key, value) {
35
+ if (!fs.existsSync(projectsConfigJS)) return false
36
+
37
+ const original = fs.readFileSync(projectsConfigJS, 'utf8')
38
+
39
+ // Capture the entire section: `<indent>section: { <body> \n<closeIndent>}`.
40
+ // Non-greedy body match keeps us from swallowing sibling sections.
41
+ const sectionRegex = new RegExp(
42
+ `^(\\s*)${section}\\s*:\\s*\\{([\\s\\S]*?)\\n(\\s*)\\}`,
43
+ 'm'
44
+ )
45
+ const match = original.match(sectionRegex)
46
+ if (!match) return false
47
+
48
+ const [wholeMatch, sectionIndent, body, closeIndent] = match
49
+ const propIndent = closeIndent + ' '
50
+ const valueLiteral = JSON.stringify(value)
51
+
52
+ // If the key already exists inside the body, replace its value in place.
53
+ // Preserves any trailing comment on the same line.
54
+ const keyRegex = new RegExp(`(\\n\\s+${key}\\s*:\\s*)([^,\\n]+?)(\\s*(?:,|(?=\\n|$)))`)
55
+ if (keyRegex.test(body)) {
56
+ const newBody = body.replace(keyRegex, `$1${valueLiteral}$3`)
57
+ const replaced = `${sectionIndent}${section}: {${newBody}\n${closeIndent}}`
58
+ fs.writeFileSync(projectsConfigJS, original.replace(wholeMatch, replaced), 'utf8')
59
+ return true
60
+ }
61
+
62
+ // Key missing — append a new line before the closing brace. Ensure the
63
+ // previous property line has a trailing comma so the appended property
64
+ // parses. If that line ends in a // comment, the comma goes between the
65
+ // value and the comment (not after it).
66
+ const lines = body.replace(/\s+$/, '').split('\n')
67
+ const lastIdx = lines.length - 1
68
+ const lastLine = lines[lastIdx]
69
+ const commentMatch = lastLine.match(/^(.*?)(\s*\/\/.*)$/)
70
+ const valuePart = (commentMatch ? commentMatch[1] : lastLine).replace(/\s+$/, '')
71
+ const commentPart = commentMatch ? commentMatch[2] : ''
72
+ const needsComma =
73
+ valuePart &&
74
+ !valuePart.endsWith(',') &&
75
+ !valuePart.endsWith('{')
76
+ lines[lastIdx] = (needsComma ? valuePart + ',' : valuePart) + commentPart
77
+
78
+ let newBody = lines.join('\n')
79
+ newBody += `\n${propIndent}${key}: ${valueLiteral}`
80
+
81
+ const replaced = `${sectionIndent}${section}: {${newBody}\n${closeIndent}}`
82
+ fs.writeFileSync(projectsConfigJS, original.replace(wholeMatch, replaced), 'utf8')
83
+ return true
84
+ }