purgetss 7.5.3 → 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 +38 -17
- package/bin/purgetss +140 -1
- package/dist/purgetss.ui.js +23 -26
- package/dist/utilities.tss +13 -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 +22 -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 +48 -98
- 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/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/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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - post-gen-notes
|
|
3
|
+
*
|
|
4
|
+
* Prints guidance after a successful branding run. Two modes:
|
|
5
|
+
* - default (compact): one-line per category + "Next steps" block
|
|
6
|
+
* - `--notes` (full): adds brand color reminder, padding tips, and all
|
|
7
|
+
* tiapp.xml snippets (iOS launch, Android launcher,
|
|
8
|
+
* Android 12+ splash theme, FCM notification tint)
|
|
9
|
+
*
|
|
10
|
+
* @fileoverview Post-generation guidance output
|
|
11
|
+
* @author César Estrada
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import chalk from 'chalk'
|
|
15
|
+
import { logger } from './branding-logger.js'
|
|
16
|
+
|
|
17
|
+
export function printPostGenNotes(opts) {
|
|
18
|
+
if (opts.fullNotes) {
|
|
19
|
+
printFullNotes(opts)
|
|
20
|
+
} else {
|
|
21
|
+
printCompactSummary(opts)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function printCompactSummary(opts) {
|
|
26
|
+
const { projectType, projectRoot, stagingRoot, bgColor, padding, iosPadding, inPlace } = opts
|
|
27
|
+
|
|
28
|
+
logger.section('Summary')
|
|
29
|
+
logger.bullet(`Background: ${chalk.cyan(bgColor)}`)
|
|
30
|
+
logger.bullet(`Padding: Android ${chalk.cyan(padding + '%')} / iOS ${chalk.cyan(iosPadding + '%')}`)
|
|
31
|
+
logger.bullet(`${inPlace ? 'Written in place to' : 'Staged at'}: ${chalk.cyan(inPlace ? projectRoot : stagingRoot)}`)
|
|
32
|
+
|
|
33
|
+
logger.section('Next steps')
|
|
34
|
+
if (inPlace) {
|
|
35
|
+
logger.bullet(`Preview the new icons in ${chalk.yellow('Preview.app')}.`)
|
|
36
|
+
logger.bullet(`If something looks wrong: ${chalk.gray('git checkout -- .')}`)
|
|
37
|
+
logger.bullet(`Rebuild: ${chalk.gray('ti clean && ti build -p android -T emulator')}`)
|
|
38
|
+
} else if (projectType === 'alloy') {
|
|
39
|
+
logger.bullet(`Preview in ${chalk.yellow('Preview.app')}, then copy to project:`)
|
|
40
|
+
console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork}.png ${projectRoot}/`))
|
|
41
|
+
console.log(chalk.gray(` cp -R ${stagingRoot}/app/platform/android/res/. ${projectRoot}/app/platform/android/res/`))
|
|
42
|
+
logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
|
|
43
|
+
} else if (projectType === 'classic') {
|
|
44
|
+
logger.bullet(`Preview in ${chalk.yellow('Preview.app')}, then copy to project:`)
|
|
45
|
+
console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork}.png ${projectRoot}/`))
|
|
46
|
+
console.log(chalk.gray(` cp -R ${stagingRoot}/platform/android/res/. ${projectRoot}/platform/android/res/`))
|
|
47
|
+
logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
|
|
48
|
+
} else {
|
|
49
|
+
logger.bullet(`Review ${chalk.cyan(stagingRoot + '/')} and copy files to their final paths manually.`)
|
|
50
|
+
}
|
|
51
|
+
console.log()
|
|
52
|
+
console.log(`Pass ${chalk.yellow('--notes')} to print tiapp.xml snippets + padding tuning guide.`)
|
|
53
|
+
console.log()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function printFullNotes(opts) {
|
|
57
|
+
const {
|
|
58
|
+
projectType, projectRoot, stagingRoot,
|
|
59
|
+
bgColor, padding, iosPadding, withSplash, withNotification, inPlace
|
|
60
|
+
} = opts
|
|
61
|
+
|
|
62
|
+
const code = (s) => chalk.gray(s)
|
|
63
|
+
const flag = (s) => chalk.yellow(s)
|
|
64
|
+
const num = (n) => chalk.cyan(n)
|
|
65
|
+
|
|
66
|
+
logger.section('Notes on what was generated')
|
|
67
|
+
logger.bullet(`Brand color ${chalk.cyan(bgColor)} was baked into Android adaptive background layer`)
|
|
68
|
+
console.log(' and iOS/marketplace flattened masters (Apple rejects alpha).')
|
|
69
|
+
logger.bullet(`Android padding: ${chalk.cyan(padding + '%')} (logo fills ${100 - 2 * padding}% of each mipmap canvas)`)
|
|
70
|
+
logger.bullet(`iOS padding: ${chalk.cyan(iosPadding + '%')} (logo fills ${100 - 2 * iosPadding}% of DefaultIcon-ios and marketplace art)`)
|
|
71
|
+
|
|
72
|
+
console.log()
|
|
73
|
+
console.log(' If the logo looks cramped: re-run with higher padding')
|
|
74
|
+
console.log(` ${flag('--padding 25-30')} (Android)`)
|
|
75
|
+
console.log(` ${flag('--ios-padding 10-14')} (iOS)`)
|
|
76
|
+
console.log()
|
|
77
|
+
console.log(' If the logo looks too small: re-run with lower padding')
|
|
78
|
+
console.log(` ${flag('--padding 19')} (Android spec floor)`)
|
|
79
|
+
console.log(` ${flag('--ios-padding 2-3')} (matches first-party apps like Mail, Safari)`)
|
|
80
|
+
|
|
81
|
+
logger.section('Configuration reminders')
|
|
82
|
+
console.log(' The tool does NOT auto-edit tiapp.xml. Snippets below are optional —')
|
|
83
|
+
console.log(' paste only what you need, after reviewing.')
|
|
84
|
+
console.log()
|
|
85
|
+
console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('tiapp.xml <application> tag may be self-closing')}`)
|
|
86
|
+
console.log(' If yours looks like:')
|
|
87
|
+
console.log(code(' <application android:icon="@mipmap/ic_launcher" .../>'))
|
|
88
|
+
console.log(' You must expand it BEFORE adding children:')
|
|
89
|
+
console.log(code(' <application android:icon="@mipmap/ic_launcher" ...>'))
|
|
90
|
+
console.log(code(' </application>'))
|
|
91
|
+
|
|
92
|
+
console.log()
|
|
93
|
+
console.log(` ${num('1.')} ${chalk.cyan('iOS launch background')} — under ${flag('<ios>')} in tiapp.xml:`)
|
|
94
|
+
console.log(code(' <ios>'))
|
|
95
|
+
console.log(code(' <enable-launch-screen-storyboard>true</enable-launch-screen-storyboard>'))
|
|
96
|
+
console.log(code(` <default-background-color>${bgColor}</default-background-color>`))
|
|
97
|
+
console.log(code(' </ios>'))
|
|
98
|
+
|
|
99
|
+
console.log()
|
|
100
|
+
console.log(` ${num('2.')} ${chalk.cyan('Android launcher icon')} — under ${flag('<android><manifest><application>')}:`)
|
|
101
|
+
console.log(code(' <application android:icon="@mipmap/ic_launcher"'))
|
|
102
|
+
console.log(code(' android:usesCleartextTraffic="false"/>'))
|
|
103
|
+
|
|
104
|
+
if (withSplash) {
|
|
105
|
+
console.log()
|
|
106
|
+
console.log(` ${num('3.')} ${chalk.cyan('Android 12+ splash screen')} — ${chalk.yellow('OPTIONAL, advanced')}`)
|
|
107
|
+
console.log()
|
|
108
|
+
console.log(' Titanium SDK 13.x shows a system splash automatically using your')
|
|
109
|
+
console.log(' launcher icon. For most apps THE DEFAULT IS ENOUGH — do nothing.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (withNotification) {
|
|
113
|
+
const colorsDir = projectType === 'classic'
|
|
114
|
+
? 'platform/android/res/values'
|
|
115
|
+
: 'app/platform/android/res/values'
|
|
116
|
+
|
|
117
|
+
console.log()
|
|
118
|
+
console.log(` ${num('4.')} ${chalk.cyan('FCM notification icon + tint')}`)
|
|
119
|
+
console.log(' Only needed if you use firebase.cloudmessaging for push.')
|
|
120
|
+
console.log()
|
|
121
|
+
console.log(` Create ${flag(colorsDir + '/colors.xml')} (or merge):`)
|
|
122
|
+
console.log(code(' <?xml version="1.0" encoding="utf-8"?>'))
|
|
123
|
+
console.log(code(' <resources>'))
|
|
124
|
+
console.log(code(` <color name="notification_tint">${bgColor}</color>`))
|
|
125
|
+
console.log(code(' </resources>'))
|
|
126
|
+
console.log()
|
|
127
|
+
console.log(' Then under <application> in tiapp.xml:')
|
|
128
|
+
console.log(code(' <meta-data android:name="com.google.firebase.messaging.default_notification_icon"'))
|
|
129
|
+
console.log(code(' android:resource="@drawable/ic_stat_notify"/>'))
|
|
130
|
+
console.log(code(' <meta-data android:name="com.google.firebase.messaging.default_notification_color"'))
|
|
131
|
+
console.log(code(' android:resource="@color/notification_tint"/>'))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logger.section('Next steps')
|
|
135
|
+
if (inPlace) {
|
|
136
|
+
console.log(` ${num('1.')} Preview in ${flag('Preview.app')} — files were overwritten directly.`)
|
|
137
|
+
console.log(` ${num('2.')} If something looks wrong: ${code('git checkout -- .')}`)
|
|
138
|
+
console.log(` ${num('3.')} Rebuild: ${code('ti clean && ti build -p android -T emulator')}`)
|
|
139
|
+
} else {
|
|
140
|
+
console.log(` ${num('1.')} Preview the generated icons, then copy to project (see Summary).`)
|
|
141
|
+
console.log(` ${num('2.')} Cleanup staging: ${code('rm -rf ' + stagingRoot)}`)
|
|
142
|
+
console.log(` ${num('3.')} Rebuild: ${code('ti clean && ti build -p android -T emulator')}`)
|
|
143
|
+
}
|
|
144
|
+
console.log()
|
|
145
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - prepare-master
|
|
3
|
+
*
|
|
4
|
+
* Produces two normalized masters from a single input:
|
|
5
|
+
*
|
|
6
|
+
* 1. <base>_square.png — 1024×1024 PNG, logo centered in a transparent square
|
|
7
|
+
* canvas. Used for iOS DefaultIcon + marketplace artwork.
|
|
8
|
+
*
|
|
9
|
+
* 2. <base>_tight.png — logo rasterized at 1024-px max dimension with native
|
|
10
|
+
* aspect preserved (no padding). Used for Android adaptive icons so a
|
|
11
|
+
* horizontal wordmark fills the safe-zone by width instead of being
|
|
12
|
+
* double-padded inside a square.
|
|
13
|
+
*
|
|
14
|
+
* Accepts SVG or PNG/JPG/WebP. SVG is rasterized by Sharp at high density,
|
|
15
|
+
* then downsampled to 1024 for clean high-DPI output.
|
|
16
|
+
*
|
|
17
|
+
* @fileoverview Master-image preparation for the branding pipeline
|
|
18
|
+
* @author César Estrada
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs'
|
|
22
|
+
import path from 'path'
|
|
23
|
+
import sharp from 'sharp'
|
|
24
|
+
import { logger } from './branding-logger.js'
|
|
25
|
+
import { computeSvgDensity, readSvgSafely } from '../../shared/svg-utils.js'
|
|
26
|
+
|
|
27
|
+
const MAX_DIMENSION = 1024
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prepare dual masters (square + tight) from a single input.
|
|
31
|
+
* @param {string} inputPath - Path to SVG or PNG master
|
|
32
|
+
* @param {string} basePath - Output base path (no extension, e.g. /tmp/foo/_master)
|
|
33
|
+
* @returns {Promise<{square: string, tight: string}>} Paths to both outputs
|
|
34
|
+
*/
|
|
35
|
+
export async function prepareMaster(inputPath, basePath) {
|
|
36
|
+
const ext = path.extname(inputPath).toLowerCase().slice(1)
|
|
37
|
+
const squarePath = `${basePath}_square.png`
|
|
38
|
+
const tightPath = `${basePath}_tight.png`
|
|
39
|
+
|
|
40
|
+
fs.mkdirSync(path.dirname(basePath), { recursive: true })
|
|
41
|
+
|
|
42
|
+
if (ext === 'svg') {
|
|
43
|
+
await rasterizeSvgToTight(inputPath, tightPath)
|
|
44
|
+
} else if (ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'webp') {
|
|
45
|
+
await downsamplePngToTight(inputPath, tightPath)
|
|
46
|
+
} else {
|
|
47
|
+
throw new Error(`Unsupported master format: .${ext} (expected .svg or .png)`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await padTightToSquare(tightPath, squarePath)
|
|
51
|
+
|
|
52
|
+
return { square: squarePath, tight: tightPath }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function rasterizeSvgToTight(svgPath, outPath) {
|
|
56
|
+
const { buffer: svgBuffer, naturalMax } = await readSvgSafely(svgPath, {
|
|
57
|
+
logger,
|
|
58
|
+
withAdvice: true
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Supersample to ~4× MAX_DIMENSION so the final downsample yields clean edges.
|
|
62
|
+
const density = computeSvgDensity(naturalMax, MAX_DIMENSION * 4)
|
|
63
|
+
const hiRes = await sharp(svgBuffer, { density, limitInputPixels: false })
|
|
64
|
+
.png()
|
|
65
|
+
.toBuffer()
|
|
66
|
+
|
|
67
|
+
const meta = await sharp(hiRes).metadata()
|
|
68
|
+
const { width: w, height: h } = meta
|
|
69
|
+
|
|
70
|
+
await sharp(hiRes)
|
|
71
|
+
.resize({
|
|
72
|
+
width: w >= h ? MAX_DIMENSION : null,
|
|
73
|
+
height: h > w ? MAX_DIMENSION : null,
|
|
74
|
+
fit: 'inside',
|
|
75
|
+
withoutEnlargement: false
|
|
76
|
+
})
|
|
77
|
+
.png({ compressionLevel: 9 })
|
|
78
|
+
.toFile(outPath)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function downsamplePngToTight(inputPath, outPath) {
|
|
82
|
+
const meta = await sharp(inputPath).metadata()
|
|
83
|
+
const { width: w, height: h } = meta
|
|
84
|
+
|
|
85
|
+
await sharp(inputPath)
|
|
86
|
+
.resize({
|
|
87
|
+
width: w >= h ? MAX_DIMENSION : null,
|
|
88
|
+
height: h > w ? MAX_DIMENSION : null,
|
|
89
|
+
fit: 'inside',
|
|
90
|
+
withoutEnlargement: true
|
|
91
|
+
})
|
|
92
|
+
.png({ compressionLevel: 9 })
|
|
93
|
+
.toFile(outPath)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function padTightToSquare(tightPath, squarePath) {
|
|
97
|
+
await sharp(tightPath)
|
|
98
|
+
.resize({
|
|
99
|
+
width: MAX_DIMENSION,
|
|
100
|
+
height: MAX_DIMENSION,
|
|
101
|
+
fit: 'contain',
|
|
102
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
103
|
+
})
|
|
104
|
+
.png({ compressionLevel: 9 })
|
|
105
|
+
.toFile(squarePath)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { MAX_DIMENSION }
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|