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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - Branding Logger
|
|
3
|
+
*
|
|
4
|
+
* Chalk-based presentation helpers used exclusively by the branding pipeline.
|
|
5
|
+
* Separate from the main PurgeTSS logger because the branding output style
|
|
6
|
+
* (sections, bullets, property rows) is much richer than the one-line logging
|
|
7
|
+
* used elsewhere in the tool.
|
|
8
|
+
*
|
|
9
|
+
* @fileoverview Logger helpers for the branding pipeline
|
|
10
|
+
* @author César Estrada
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import chalk from 'chalk'
|
|
14
|
+
|
|
15
|
+
let debugMode = false
|
|
16
|
+
|
|
17
|
+
export const logger = {
|
|
18
|
+
setDebugMode(enabled) { debugMode = enabled },
|
|
19
|
+
|
|
20
|
+
info(message) { console.log(chalk.blue(message)) },
|
|
21
|
+
|
|
22
|
+
section(name) {
|
|
23
|
+
console.log()
|
|
24
|
+
console.log(chalk.cyan(`▸ ${name}`))
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
bullet(message) { console.log(` ${chalk.cyan('•')} ${message}`) },
|
|
28
|
+
|
|
29
|
+
property(label, value) { console.log(`${chalk.blue(label)}${value}`) },
|
|
30
|
+
|
|
31
|
+
success(message) { console.log(chalk.green(message)) },
|
|
32
|
+
|
|
33
|
+
warning(message) { console.log(chalk.yellow(message)) },
|
|
34
|
+
|
|
35
|
+
error(message) { console.error(chalk.red(message)) },
|
|
36
|
+
|
|
37
|
+
debug(message) {
|
|
38
|
+
if (debugMode) console.log(chalk.cyan(message))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - cleanup-legacy
|
|
3
|
+
*
|
|
4
|
+
* Context-aware removal of legacy branding artifacts.
|
|
5
|
+
*
|
|
6
|
+
* Reads tiapp.xml via tiapp-reader and categorizes targets into:
|
|
7
|
+
*
|
|
8
|
+
* SAFE always deleted — universally obsolete (dead qualifiers)
|
|
9
|
+
* CONDITIONAL deleted only when project config guarantees they're unused
|
|
10
|
+
* (e.g. iOS legacy launch images when storyboard is enabled)
|
|
11
|
+
* AGGRESSIVE behind --aggressive — strongly defensible but some edge
|
|
12
|
+
* cases (e.g. ldpi drawables on <1% of devices)
|
|
13
|
+
*
|
|
14
|
+
* Always prints the plan before acting. Respects dryRun.
|
|
15
|
+
*
|
|
16
|
+
* @fileoverview Legacy-artifact cleanup for Titanium branding
|
|
17
|
+
* @author César Estrada
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs'
|
|
21
|
+
import path from 'path'
|
|
22
|
+
import { logger } from './branding-logger.js'
|
|
23
|
+
import { readTiapp, hasAdaptiveIcons } from './tiapp-reader.js'
|
|
24
|
+
|
|
25
|
+
export async function cleanupLegacy({ projectRoot, projectType, aggressive = false, dryRun = false }) {
|
|
26
|
+
const tiappPath = path.join(projectRoot, 'tiapp.xml')
|
|
27
|
+
const tiapp = readTiapp(tiappPath)
|
|
28
|
+
const adaptive = hasAdaptiveIcons(projectRoot)
|
|
29
|
+
|
|
30
|
+
const layout = getLayoutPaths(projectRoot, projectType)
|
|
31
|
+
|
|
32
|
+
const safe = []
|
|
33
|
+
const conditional = []
|
|
34
|
+
const aggressiveTargets = []
|
|
35
|
+
|
|
36
|
+
// ---- SAFE -----------------------------------------------------------
|
|
37
|
+
if (layout.androidImages && fs.existsSync(layout.androidImages)) {
|
|
38
|
+
const reason = 'Android long/notlong qualifier (dead since Android 3.0, 2011)'
|
|
39
|
+
for (const entry of listChildDirs(layout.androidImages, /^res-(long|notlong)-/)) {
|
|
40
|
+
safe.push({ path: entry, reason })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- CONDITIONAL ----------------------------------------------------
|
|
45
|
+
if (tiapp.storyboardEnabled && layout.iphoneDir && fs.existsSync(layout.iphoneDir)) {
|
|
46
|
+
const reason = 'iOS legacy launch image (storyboard enabled → not consulted)'
|
|
47
|
+
for (const entry of listChildFiles(layout.iphoneDir, /^Default(-.+)?(@2x)?\.png$/)) {
|
|
48
|
+
conditional.push({ path: entry, reason })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (tiapp.portraitOnly && layout.androidImages && fs.existsSync(layout.androidImages)) {
|
|
53
|
+
const reason = 'Landscape variant (app is portrait-only)'
|
|
54
|
+
for (const entry of listChildDirs(layout.androidImages, /(^res-.+-land-|^res-land-)/)) {
|
|
55
|
+
conditional.push({ path: entry, reason })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (adaptive && layout.androidAssets) {
|
|
60
|
+
const defaultPng = path.join(layout.androidAssets, 'default.png')
|
|
61
|
+
if (fs.existsSync(defaultPng)) {
|
|
62
|
+
conditional.push({
|
|
63
|
+
path: defaultPng,
|
|
64
|
+
reason: 'Legacy Android splash PNG (adaptive launcher handles splash now)'
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
const appicon = path.join(layout.androidAssets, 'appicon.png')
|
|
68
|
+
if (fs.existsSync(appicon)) {
|
|
69
|
+
conditional.push({
|
|
70
|
+
path: appicon,
|
|
71
|
+
reason: 'Legacy appicon.png (adaptive launcher takes precedence)'
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- AGGRESSIVE -----------------------------------------------------
|
|
77
|
+
if (aggressive) {
|
|
78
|
+
const reason = 'ldpi density (<1% global market)'
|
|
79
|
+
if (layout.androidImages && fs.existsSync(layout.androidImages)) {
|
|
80
|
+
for (const entry of listChildDirs(layout.androidImages, /(^res-ldpi$|^res-.+-ldpi$)/)) {
|
|
81
|
+
aggressiveTargets.push({ path: entry, reason })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const ldpiDirs = [
|
|
85
|
+
path.join(projectRoot, 'app', 'platform', 'android', 'res', 'drawable-ldpi'),
|
|
86
|
+
path.join(projectRoot, 'app', 'platform', 'android', 'res', 'values-ldpi'),
|
|
87
|
+
path.join(projectRoot, 'app', 'platform', 'android', 'res', 'mipmap-ldpi'),
|
|
88
|
+
path.join(projectRoot, 'platform', 'android', 'res', 'drawable-ldpi'),
|
|
89
|
+
path.join(projectRoot, 'platform', 'android', 'res', 'values-ldpi'),
|
|
90
|
+
path.join(projectRoot, 'platform', 'android', 'res', 'mipmap-ldpi')
|
|
91
|
+
]
|
|
92
|
+
for (const d of ldpiDirs) {
|
|
93
|
+
if (fs.existsSync(d)) {
|
|
94
|
+
aggressiveTargets.push({ path: d, reason: 'ldpi resource folder (<1% global market)' })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
printPlan({
|
|
100
|
+
projectRoot, projectType, tiapp, adaptive, aggressive,
|
|
101
|
+
safe, conditional, aggressiveTargets
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const total = safe.length + conditional.length + aggressiveTargets.length
|
|
105
|
+
if (total === 0) {
|
|
106
|
+
logger.success('No legacy artifacts detected. Project is already clean.')
|
|
107
|
+
return { removed: 0, bytes: 0 }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
logger.info('Dry-run mode — nothing deleted. Re-run without --dry-run to apply.')
|
|
112
|
+
return { removed: 0, bytes: 0 }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let removed = 0
|
|
116
|
+
let bytes = 0
|
|
117
|
+
for (const bucket of [safe, conditional, aggressiveTargets]) {
|
|
118
|
+
for (const { path: target } of bucket) {
|
|
119
|
+
const size = getSizeBytes(target)
|
|
120
|
+
fs.rmSync(target, { recursive: true, force: true })
|
|
121
|
+
bytes += size
|
|
122
|
+
removed += 1
|
|
123
|
+
logger.success(`Removed ${path.relative(projectRoot, target)}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { removed, bytes }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getLayoutPaths(projectRoot, projectType) {
|
|
130
|
+
if (projectType === 'alloy') {
|
|
131
|
+
return {
|
|
132
|
+
androidImages: path.join(projectRoot, 'app', 'assets', 'android', 'images'),
|
|
133
|
+
iphoneDir: path.join(projectRoot, 'app', 'assets', 'iphone'),
|
|
134
|
+
androidAssets: path.join(projectRoot, 'app', 'assets', 'android')
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (projectType === 'classic') {
|
|
138
|
+
return {
|
|
139
|
+
androidImages: path.join(projectRoot, 'Resources', 'android', 'images'),
|
|
140
|
+
iphoneDir: path.join(projectRoot, 'Resources', 'iphone'),
|
|
141
|
+
androidAssets: path.join(projectRoot, 'Resources', 'android')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { androidImages: null, iphoneDir: null, androidAssets: null }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function listChildDirs(parent, regex) {
|
|
148
|
+
if (!fs.existsSync(parent)) return []
|
|
149
|
+
return fs.readdirSync(parent)
|
|
150
|
+
.filter((name) => regex.test(name))
|
|
151
|
+
.map((name) => path.join(parent, name))
|
|
152
|
+
.filter((p) => fs.statSync(p).isDirectory())
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function listChildFiles(parent, regex) {
|
|
156
|
+
if (!fs.existsSync(parent)) return []
|
|
157
|
+
return fs.readdirSync(parent)
|
|
158
|
+
.filter((name) => regex.test(name))
|
|
159
|
+
.map((name) => path.join(parent, name))
|
|
160
|
+
.filter((p) => fs.statSync(p).isFile())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getSizeBytes(target) {
|
|
164
|
+
try {
|
|
165
|
+
const stat = fs.statSync(target)
|
|
166
|
+
if (stat.isFile()) return stat.size
|
|
167
|
+
let total = 0
|
|
168
|
+
for (const name of fs.readdirSync(target)) {
|
|
169
|
+
total += getSizeBytes(path.join(target, name))
|
|
170
|
+
}
|
|
171
|
+
return total
|
|
172
|
+
} catch {
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatKb(bytes) {
|
|
178
|
+
return `${Math.round(bytes / 1024)}K`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function printPlan({ projectRoot, projectType, tiapp, adaptive, aggressive, safe, conditional, aggressiveTargets }) {
|
|
182
|
+
console.log()
|
|
183
|
+
logger.warning('⚠ WARNING — --cleanup-legacy deletes files permanently.')
|
|
184
|
+
logger.warning(' Recommended: commit your project with git before running without --dry-run.')
|
|
185
|
+
console.log()
|
|
186
|
+
console.log('Cleanup plan')
|
|
187
|
+
console.log(` Project: ${projectRoot} (${projectType})`)
|
|
188
|
+
console.log(` Storyboard: ${tiapp.storyboardEnabled ? 'enabled' : 'disabled'}`)
|
|
189
|
+
console.log(` Orientation: ${tiapp.portraitOnly ? 'portrait-only' : 'landscape allowed'}`)
|
|
190
|
+
console.log(` Adaptive icons: ${adaptive ? 'present' : 'not detected'}`)
|
|
191
|
+
console.log(` Aggressive mode: ${aggressive ? 'on (includes ldpi)' : 'off'}`)
|
|
192
|
+
|
|
193
|
+
if (safe.length) {
|
|
194
|
+
console.log()
|
|
195
|
+
logger.success('SAFE — universally obsolete')
|
|
196
|
+
for (const { path: p, reason } of safe) {
|
|
197
|
+
console.log(` ${formatKb(getSizeBytes(p)).padEnd(6)} ${path.relative(projectRoot, p)}`)
|
|
198
|
+
console.log(` ${reason}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (conditional.length) {
|
|
202
|
+
console.log()
|
|
203
|
+
logger.warning('CONDITIONAL — safe given your project config')
|
|
204
|
+
for (const { path: p, reason } of conditional) {
|
|
205
|
+
console.log(` ${formatKb(getSizeBytes(p)).padEnd(6)} ${path.relative(projectRoot, p)}`)
|
|
206
|
+
console.log(` ${reason}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (aggressiveTargets.length) {
|
|
210
|
+
console.log()
|
|
211
|
+
logger.error('AGGRESSIVE — --aggressive enabled')
|
|
212
|
+
for (const { path: p, reason } of aggressiveTargets) {
|
|
213
|
+
console.log(` ${formatKb(getSizeBytes(p)).padEnd(6)} ${path.relative(projectRoot, p)}`)
|
|
214
|
+
console.log(` ${reason}`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const total = safe.length + conditional.length + aggressiveTargets.length
|
|
218
|
+
console.log()
|
|
219
|
+
console.log(` Total: ${total} item(s) to remove`)
|
|
220
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - Ensure `brand:` section exists in purgetss/config.cjs
|
|
3
|
+
*
|
|
4
|
+
* When a project was initialized before `brand` was introduced, its
|
|
5
|
+
* config.cjs won't have a `brand:` section. On first invocation of
|
|
6
|
+
* `purgetss brand`, we patch the file to insert the default block
|
|
7
|
+
* between `purge:` and `theme:`. The user's existing keys are untouched.
|
|
8
|
+
*
|
|
9
|
+
* Rationale:
|
|
10
|
+
* - Keeps the config self-documenting — the user sees the defaults in
|
|
11
|
+
* their own file rather than having to look them up in docs.
|
|
12
|
+
* - Mirrors how `ensureConfig()` creates the file from the template when
|
|
13
|
+
* it's missing altogether.
|
|
14
|
+
* - Non-destructive: only adds if absent, never overwrites existing values.
|
|
15
|
+
*
|
|
16
|
+
* @fileoverview Auto-injects the `brand:` section on first brand run
|
|
17
|
+
* @author César Estrada
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs'
|
|
21
|
+
import chalk from 'chalk'
|
|
22
|
+
import { projectsConfigJS, projectsPurge_TSS_Brand_Folder } from '../../shared/constants.js'
|
|
23
|
+
import { logger } from './branding-logger.js'
|
|
24
|
+
|
|
25
|
+
const BRAND_BLOCK = ` brand: {
|
|
26
|
+
splash: false, // also generate splash_icon.png × 5
|
|
27
|
+
padding: '15%', // Android safe-zone. Range: 12% tight (mature logos) — 20% conservative. Spec floor 19.44%.
|
|
28
|
+
iosPadding: '4%', // iOS aesthetic. Range: 2% bold — 8% conservative. No launcher mask.
|
|
29
|
+
darkBgColor: null, // opaque dark bg for DefaultIcon-Dark.png (null = transparent per Apple HIG)
|
|
30
|
+
bgColor: '#FFFFFF', // Android adaptive bg + iOS/marketplace flatten
|
|
31
|
+
notification: false, // also generate ic_stat_notify.png × 5
|
|
32
|
+
confirmOverwrites: true // prompt before overwriting files (set false to skip)
|
|
33
|
+
},
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* If the project's config.cjs exists but has no `brand:` key, inject the
|
|
38
|
+
* default block between `purge:` and `theme:`. Prints a notice when it does.
|
|
39
|
+
*
|
|
40
|
+
* Silently skips if:
|
|
41
|
+
* - config.cjs doesn't exist (will be created by ensureConfig() elsewhere)
|
|
42
|
+
* - `brand:` is already present
|
|
43
|
+
* - file can't be patched safely (structure doesn't match expectation)
|
|
44
|
+
*/
|
|
45
|
+
export function ensureBrandSection() {
|
|
46
|
+
// Always make sure the logos folder exists — mirrors how init creates
|
|
47
|
+
// `purgetss/fonts/` empty so the user can see where assets go.
|
|
48
|
+
if (!fs.existsSync(projectsPurge_TSS_Brand_Folder)) {
|
|
49
|
+
fs.mkdirSync(projectsPurge_TSS_Brand_Folder, { recursive: true })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!fs.existsSync(projectsConfigJS)) return
|
|
53
|
+
|
|
54
|
+
const original = fs.readFileSync(projectsConfigJS, 'utf8')
|
|
55
|
+
|
|
56
|
+
// Already present — nothing to do.
|
|
57
|
+
if (/^\s*brand\s*:/m.test(original)) return
|
|
58
|
+
|
|
59
|
+
// Insert before the `theme:` key. Regex captures the indentation so we
|
|
60
|
+
// preserve whatever the user's style is (2-space, 4-space, etc.).
|
|
61
|
+
const match = original.match(/(^\s*)theme\s*:/m)
|
|
62
|
+
if (!match) {
|
|
63
|
+
// Non-standard layout — don't risk corrupting it. User can add manually.
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const patched = original.replace(match[0], `${BRAND_BLOCK}${match[0]}`)
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
fs.writeFileSync(projectsConfigJS, patched, 'utf8')
|
|
71
|
+
console.log()
|
|
72
|
+
logger.success(`Added ${chalk.cyan('brand:')} section to ${chalk.cyan('./purgetss/config.cjs')} with default values.`)
|
|
73
|
+
console.log(' Edit that block to customize brand defaults (bgColor, padding, etc.).')
|
|
74
|
+
console.log(' CLI flags always win over config values.')
|
|
75
|
+
console.log()
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger.warning(`Could not auto-add brand: section to config.cjs (${err.message}).`)
|
|
78
|
+
logger.warning('The command will still run using built-in defaults.')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - gen-android-adaptive
|
|
3
|
+
*
|
|
4
|
+
* Adaptive icon triplet (foreground + background + monochrome) at 5 densities.
|
|
5
|
+
*
|
|
6
|
+
* Android adaptive icon: 108×108dp canvas, 66×66dp safe-zone.
|
|
7
|
+
* Densities: mdpi=108, hdpi=162, xhdpi=216, xxhdpi=324, xxxhdpi=432.
|
|
8
|
+
*
|
|
9
|
+
* foreground: logo centered inside safe-zone, transparent outside
|
|
10
|
+
* background: solid color filling the full canvas
|
|
11
|
+
* monochrome: foreground silhouette in white, alpha preserved
|
|
12
|
+
* (Android applies themed tint at runtime on API 31+ — this
|
|
13
|
+
* layer is Android's dark/light mode mechanism for app icons)
|
|
14
|
+
*
|
|
15
|
+
* @fileoverview Android adaptive icons × 5 densities
|
|
16
|
+
* @author César Estrada
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs'
|
|
20
|
+
import path from 'path'
|
|
21
|
+
import sharp from 'sharp'
|
|
22
|
+
|
|
23
|
+
export const DENSITIES = [
|
|
24
|
+
{ name: 'mdpi', size: 108 },
|
|
25
|
+
{ name: 'hdpi', size: 162 },
|
|
26
|
+
{ name: 'xhdpi', size: 216 },
|
|
27
|
+
{ name: 'xxhdpi', size: 324 },
|
|
28
|
+
{ name: 'xxxhdpi', size: 432 }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export async function genAndroidAdaptive(tightMaster, bgColor, paddingPct, resRoot, opts = {}) {
|
|
32
|
+
const { monoTight = null } = opts
|
|
33
|
+
const generated = []
|
|
34
|
+
|
|
35
|
+
for (const { name, size } of DENSITIES) {
|
|
36
|
+
const inner = Math.floor((size * (100 - 2 * paddingPct)) / 100)
|
|
37
|
+
const dir = path.join(resRoot, `mipmap-${name}`)
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
39
|
+
|
|
40
|
+
const foregroundPath = path.join(dir, 'ic_launcher_foreground.png')
|
|
41
|
+
const backgroundPath = path.join(dir, 'ic_launcher_background.png')
|
|
42
|
+
const monochromePath = path.join(dir, 'ic_launcher_monochrome.png')
|
|
43
|
+
|
|
44
|
+
// Foreground: logo sized to inner, centered on transparent canvas
|
|
45
|
+
const innerLogo = await sharp(tightMaster)
|
|
46
|
+
.resize({
|
|
47
|
+
width: inner,
|
|
48
|
+
height: inner,
|
|
49
|
+
fit: 'inside',
|
|
50
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
51
|
+
})
|
|
52
|
+
.toBuffer()
|
|
53
|
+
|
|
54
|
+
await sharp({
|
|
55
|
+
create: {
|
|
56
|
+
width: size,
|
|
57
|
+
height: size,
|
|
58
|
+
channels: 4,
|
|
59
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.composite([{ input: innerLogo, gravity: 'center' }])
|
|
63
|
+
.png({ compressionLevel: 9 })
|
|
64
|
+
.toFile(foregroundPath)
|
|
65
|
+
|
|
66
|
+
// Background: solid color
|
|
67
|
+
await sharp({
|
|
68
|
+
create: {
|
|
69
|
+
width: size,
|
|
70
|
+
height: size,
|
|
71
|
+
channels: 4,
|
|
72
|
+
background: bgColor
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
.png({ compressionLevel: 9 })
|
|
76
|
+
.toFile(backgroundPath)
|
|
77
|
+
|
|
78
|
+
// Monochrome: white silhouette with preserved alpha.
|
|
79
|
+
if (monoTight) {
|
|
80
|
+
const innerMono = await sharp(monoTight)
|
|
81
|
+
.resize({
|
|
82
|
+
width: inner,
|
|
83
|
+
height: inner,
|
|
84
|
+
fit: 'inside',
|
|
85
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
86
|
+
})
|
|
87
|
+
.toBuffer()
|
|
88
|
+
|
|
89
|
+
await sharp({
|
|
90
|
+
create: {
|
|
91
|
+
width: size,
|
|
92
|
+
height: size,
|
|
93
|
+
channels: 4,
|
|
94
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
.composite([{ input: innerMono, gravity: 'center' }])
|
|
98
|
+
.ensureAlpha()
|
|
99
|
+
.linear([0, 0, 0, 1], [255, 255, 255, 0])
|
|
100
|
+
.png({ compressionLevel: 9 })
|
|
101
|
+
.toFile(monochromePath)
|
|
102
|
+
} else {
|
|
103
|
+
await sharp(foregroundPath)
|
|
104
|
+
.ensureAlpha()
|
|
105
|
+
.linear([0, 0, 0, 1], [255, 255, 255, 0])
|
|
106
|
+
.png({ compressionLevel: 9 })
|
|
107
|
+
.toFile(monochromePath)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
generated.push(foregroundPath, backgroundPath, monochromePath)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fs.mkdirSync(path.join(resRoot, 'mipmap-anydpi-v26'), { recursive: true })
|
|
114
|
+
|
|
115
|
+
return generated
|
|
116
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - gen-android-legacy
|
|
3
|
+
*
|
|
4
|
+
* Flat ic_launcher.png for pre-adaptive Android (API 21–25, ~3% of users in 2026).
|
|
5
|
+
* Composites foreground over background, scaled to legacy launcher sizes.
|
|
6
|
+
*
|
|
7
|
+
* Legacy densities: mdpi=48, hdpi=72, xhdpi=96, xxhdpi=144, xxxhdpi=192.
|
|
8
|
+
*
|
|
9
|
+
* @fileoverview Legacy ic_launcher.png × 5 densities
|
|
10
|
+
* @author César Estrada
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs'
|
|
14
|
+
import path from 'path'
|
|
15
|
+
import sharp from 'sharp'
|
|
16
|
+
|
|
17
|
+
export const LEGACY_DENSITIES = [
|
|
18
|
+
{ name: 'mdpi', size: 48 },
|
|
19
|
+
{ name: 'hdpi', size: 72 },
|
|
20
|
+
{ name: 'xhdpi', size: 96 },
|
|
21
|
+
{ name: 'xxhdpi', size: 144 },
|
|
22
|
+
{ name: 'xxxhdpi', size: 192 }
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export async function genAndroidLegacy(tightMaster, bgColor, paddingPct, resRoot) {
|
|
26
|
+
const generated = []
|
|
27
|
+
// Legacy icons have no adaptive mask — they render as drawn. Use ~60% of
|
|
28
|
+
// the adaptive padding so the logo fills more of the canvas.
|
|
29
|
+
const legacyPadding = Math.floor((paddingPct * 60) / 100)
|
|
30
|
+
|
|
31
|
+
for (const { name, size } of LEGACY_DENSITIES) {
|
|
32
|
+
const inner = Math.floor((size * (100 - 2 * legacyPadding)) / 100)
|
|
33
|
+
const dir = path.join(resRoot, `mipmap-${name}`)
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
35
|
+
|
|
36
|
+
const outPath = path.join(dir, 'ic_launcher.png')
|
|
37
|
+
|
|
38
|
+
const innerLogo = await sharp(tightMaster)
|
|
39
|
+
.resize({
|
|
40
|
+
width: inner,
|
|
41
|
+
height: inner,
|
|
42
|
+
fit: 'inside',
|
|
43
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
44
|
+
})
|
|
45
|
+
.toBuffer()
|
|
46
|
+
|
|
47
|
+
await sharp({
|
|
48
|
+
create: {
|
|
49
|
+
width: size,
|
|
50
|
+
height: size,
|
|
51
|
+
channels: 4,
|
|
52
|
+
background: bgColor
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.composite([{ input: innerLogo, gravity: 'center' }])
|
|
56
|
+
.png({ compressionLevel: 9 })
|
|
57
|
+
.toFile(outPath)
|
|
58
|
+
|
|
59
|
+
generated.push(outPath)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return generated
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - gen-ic-launcher-xml
|
|
3
|
+
*
|
|
4
|
+
* Writes the adaptive-icon XML binder to mipmap-anydpi-v26/ic_launcher.xml.
|
|
5
|
+
* Titanium/Android loads this file on API 26+ to pick up the adaptive icon triplet
|
|
6
|
+
* (foreground + background + monochrome).
|
|
7
|
+
*
|
|
8
|
+
* @fileoverview Adaptive-icon XML binder
|
|
9
|
+
* @author César Estrada
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs'
|
|
13
|
+
import path from 'path'
|
|
14
|
+
|
|
15
|
+
export const IC_LAUNCHER_XML = `<?xml version="1.0" encoding="utf-8"?>
|
|
16
|
+
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
17
|
+
<background android:drawable="@mipmap/ic_launcher_background"/>
|
|
18
|
+
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
19
|
+
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
|
20
|
+
</adaptive-icon>
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
export function genIcLauncherXml(resRoot) {
|
|
24
|
+
const dir = path.join(resRoot, 'mipmap-anydpi-v26')
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
26
|
+
const outPath = path.join(dir, 'ic_launcher.xml')
|
|
27
|
+
fs.writeFileSync(outPath, IC_LAUNCHER_XML, 'utf8')
|
|
28
|
+
return outPath
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - gen-ios-dark
|
|
3
|
+
*
|
|
4
|
+
* Produces Titanium's iOS 18+ dark-mode root icon:
|
|
5
|
+
*
|
|
6
|
+
* DefaultIcon-Dark.png 1024×1024, transparent (default) or flattened on dark bg
|
|
7
|
+
*
|
|
8
|
+
* Per Apple HIG (iOS 18+): the dark variant may either (a) omit the background
|
|
9
|
+
* so the system applies its own dark gradient, or (b) use an opaque dark tint.
|
|
10
|
+
* Default here is (a) — transparent — per Apple's recommended approach.
|
|
11
|
+
*
|
|
12
|
+
* Titanium SDK 13.1+ is expected to read this file from the project root, but
|
|
13
|
+
* upstream issue tidev/titanium-sdk#14122 is not yet merged — until then, the
|
|
14
|
+
* user may need to manually place the PNG inside Assets.xcassets/AppIcon.appiconset.
|
|
15
|
+
*
|
|
16
|
+
* @fileoverview iOS 18+ dark icon variant
|
|
17
|
+
* @author César Estrada
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs'
|
|
21
|
+
import path from 'path'
|
|
22
|
+
import sharp from 'sharp'
|
|
23
|
+
|
|
24
|
+
const CANVAS = 1024
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} tightMaster - Main tight master, or --dark-master output
|
|
28
|
+
* @param {string|null} bgColor - Hex color for opaque flatten, or null for transparent
|
|
29
|
+
* @param {number} paddingPct - Padding per side (0-40)
|
|
30
|
+
* @param {string} outRoot - Output directory
|
|
31
|
+
* @returns {Promise<string>} Path to DefaultIcon-Dark.png
|
|
32
|
+
*/
|
|
33
|
+
export async function genIosDark(tightMaster, bgColor, paddingPct, outRoot) {
|
|
34
|
+
fs.mkdirSync(outRoot, { recursive: true })
|
|
35
|
+
|
|
36
|
+
const inner = Math.floor((CANVAS * (100 - 2 * paddingPct)) / 100)
|
|
37
|
+
const outPath = path.join(outRoot, 'DefaultIcon-Dark.png')
|
|
38
|
+
|
|
39
|
+
const resized = await sharp(tightMaster)
|
|
40
|
+
.resize({
|
|
41
|
+
width: inner,
|
|
42
|
+
height: inner,
|
|
43
|
+
fit: 'inside',
|
|
44
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
45
|
+
})
|
|
46
|
+
.toBuffer()
|
|
47
|
+
|
|
48
|
+
const pipeline = sharp({
|
|
49
|
+
create: {
|
|
50
|
+
width: CANVAS,
|
|
51
|
+
height: CANVAS,
|
|
52
|
+
channels: 4,
|
|
53
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
54
|
+
}
|
|
55
|
+
}).composite([{ input: resized, gravity: 'center' }])
|
|
56
|
+
|
|
57
|
+
if (bgColor) {
|
|
58
|
+
// Opaque variant: flatten on dark tint, strip alpha entirely.
|
|
59
|
+
await pipeline
|
|
60
|
+
.flatten({ background: bgColor })
|
|
61
|
+
.removeAlpha()
|
|
62
|
+
.png({ compressionLevel: 9 })
|
|
63
|
+
.toFile(outPath)
|
|
64
|
+
} else {
|
|
65
|
+
// Apple-recommended default: preserve alpha. System paints its own dark gradient.
|
|
66
|
+
await pipeline.png({ compressionLevel: 9 }).toFile(outPath)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return outPath
|
|
70
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - gen-ios-tinted
|
|
3
|
+
*
|
|
4
|
+
* Produces Titanium's iOS 18+ tinted root icon:
|
|
5
|
+
*
|
|
6
|
+
* DefaultIcon-Tinted.png 1024×1024, grayscale flattened on black
|
|
7
|
+
*
|
|
8
|
+
* Per Apple HIG (iOS 18+): the tinted variant must be a fully opaque grayscale
|
|
9
|
+
* image over a BLACK (#000000) background. iOS composites its own gradient
|
|
10
|
+
* background and accent color on top of the grayscale luminance at runtime.
|
|
11
|
+
*
|
|
12
|
+
* @fileoverview iOS 18+ tinted icon variant
|
|
13
|
+
* @author César Estrada
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs'
|
|
17
|
+
import path from 'path'
|
|
18
|
+
import sharp from 'sharp'
|
|
19
|
+
|
|
20
|
+
const CANVAS = 1024
|
|
21
|
+
|
|
22
|
+
export async function genIosTinted(tightMaster, paddingPct, outRoot) {
|
|
23
|
+
fs.mkdirSync(outRoot, { recursive: true })
|
|
24
|
+
|
|
25
|
+
const inner = Math.floor((CANVAS * (100 - 2 * paddingPct)) / 100)
|
|
26
|
+
const outPath = path.join(outRoot, 'DefaultIcon-Tinted.png')
|
|
27
|
+
|
|
28
|
+
// Grayscale the master while keeping alpha — iOS needs luminance info,
|
|
29
|
+
// not color, to apply the system accent tint at runtime.
|
|
30
|
+
const resized = await sharp(tightMaster)
|
|
31
|
+
.grayscale()
|
|
32
|
+
.resize({
|
|
33
|
+
width: inner,
|
|
34
|
+
height: inner,
|
|
35
|
+
fit: 'inside',
|
|
36
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
37
|
+
})
|
|
38
|
+
.toBuffer()
|
|
39
|
+
|
|
40
|
+
await sharp({
|
|
41
|
+
create: {
|
|
42
|
+
width: CANVAS,
|
|
43
|
+
height: CANVAS,
|
|
44
|
+
channels: 4,
|
|
45
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.composite([{ input: resized, gravity: 'center' }])
|
|
49
|
+
.flatten({ background: '#000000' })
|
|
50
|
+
.removeAlpha()
|
|
51
|
+
.png({ compressionLevel: 9 })
|
|
52
|
+
.toFile(outPath)
|
|
53
|
+
|
|
54
|
+
return outPath
|
|
55
|
+
}
|