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.
- package/README.md +93 -11
- package/bin/purgetss +140 -1
- package/dist/purgetss.ui.js +65 -26
- package/dist/utilities.tss +21 -4
- package/experimental/completions2.js +1 -1
- package/lib/completions/titanium/completions-v3.json +62 -1
- package/lib/templates/purgetss.config.js.cjs +15 -1
- package/lib/templates/purgetss.ui.js.cjs +64 -25
- package/package.json +3 -1
- package/src/cli/commands/brand.js +69 -0
- package/src/cli/commands/create.js +11 -7
- package/src/cli/commands/fonts.js +9 -9
- package/src/cli/commands/icon-library.js +18 -16
- package/src/cli/commands/images.js +116 -0
- package/src/cli/commands/init.js +4 -0
- package/src/cli/commands/module.js +4 -2
- package/src/cli/commands/purge.js +77 -101
- package/src/cli/commands/semantic.js +180 -0
- package/src/cli/commands/shades.js +332 -13
- package/src/cli/utils/project-detection.js +4 -2
- package/src/core/analyzers/class-extractor.js +110 -3
- package/src/core/branding/brand-config.js +111 -0
- package/src/core/branding/branding-logger.js +40 -0
- package/src/core/branding/cleanup-legacy.js +220 -0
- package/src/core/branding/ensure-brand-section.js +80 -0
- package/src/core/branding/gen-android-adaptive.js +116 -0
- package/src/core/branding/gen-android-legacy.js +63 -0
- package/src/core/branding/gen-ic-launcher-xml.js +29 -0
- package/src/core/branding/gen-ios-dark.js +70 -0
- package/src/core/branding/gen-ios-tinted.js +55 -0
- package/src/core/branding/gen-ios.js +69 -0
- package/src/core/branding/gen-marketplace.js +71 -0
- package/src/core/branding/gen-notification.js +76 -0
- package/src/core/branding/gen-splash.js +64 -0
- package/src/core/branding/index.js +336 -0
- package/src/core/branding/post-gen-notes.js +145 -0
- package/src/core/branding/prepare-master.js +108 -0
- package/src/core/branding/tiapp-reader.js +110 -0
- package/src/core/builders/tailwind-helpers.js +1 -1
- package/src/core/images/ensure-images-section.js +57 -0
- package/src/core/images/gen-scales.js +181 -0
- package/src/core/images/index.js +171 -0
- package/src/shared/config-manager.js +46 -0
- package/src/shared/config-writer.js +84 -0
- package/src/shared/constants.js +3 -0
- package/src/shared/helpers/typography.js +38 -3
- package/src/shared/logger.js +69 -4
- package/src/shared/prompt.js +64 -0
- package/src/shared/svg-utils.js +80 -0
- 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,
|
|
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
|
+
}
|