purgetss 7.9.0 → 7.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/bin/purgetss +13 -0
- package/dist/purgetss.ui.js +1 -1
- package/lib/templates/create/index.xml +1 -1
- package/lib/templates/purgetss.config.js.cjs +3 -1
- package/package.json +1 -1
- package/src/cli/commands/images.js +41 -2
- package/src/cli/commands/purge.js +15 -2
- package/src/cli/utils/cli-helpers.js +15 -5
- package/src/cli/utils/unsupported-class-reporter.js +3 -3
- package/src/core/analyzers/class-extractor.js +54 -0
- package/src/core/analyzers/controller-svg-refs.js +154 -0
- package/src/core/branding/brand-config.js +7 -0
- package/src/core/branding/ensure-brand-section.js +4 -3
- package/src/core/branding/gen-feature-graphic.js +57 -0
- package/src/core/branding/index.js +28 -4
- package/src/core/branding/post-gen-notes.js +2 -2
- package/src/core/builders/auto-utilities-builder.js +20 -15
- package/src/core/images/ensure-images-section.js +6 -4
- package/src/core/images/gen-scales.js +82 -17
- package/src/core/images/index.js +117 -12
- package/src/core/purger/icon-purger.js +7 -3
- package/src/core/purger/tailwind-purger.js +3 -1
- package/src/core/svg/cache.js +96 -0
- package/src/core/svg/derive-dimensions.js +120 -0
- package/src/core/svg/index.js +215 -0
- package/src/core/svg/resolve-classes.js +46 -0
- package/src/core/svg/sync-images.js +278 -0
- package/src/core/svg/tss-reader.js +134 -0
- package/src/dev/builders/tailwind-builder.js +18 -0
- package/src/shared/config-manager.js +72 -3
- package/src/shared/error-reporter.js +117 -0
- package/src/shared/helpers/colors.js +57 -13
- package/src/shared/helpers/utils.js +46 -8
- package/src/shared/logger.js +12 -0
- package/src/shared/validation/config-validator.js +167 -0
|
@@ -48,6 +48,7 @@ import { genAndroidAdaptive } from './gen-android-adaptive.js'
|
|
|
48
48
|
import { genAndroidLegacy } from './gen-android-legacy.js'
|
|
49
49
|
import { genAndroidDefault } from './gen-android-default.js'
|
|
50
50
|
import { genMarketplace } from './gen-marketplace.js'
|
|
51
|
+
import { genFeatureGraphic } from './gen-feature-graphic.js'
|
|
51
52
|
import { genNotification } from './gen-notification.js'
|
|
52
53
|
import { genSplash } from './gen-splash.js'
|
|
53
54
|
import { genIcLauncherXml } from './gen-ic-launcher-xml.js'
|
|
@@ -60,6 +61,7 @@ export async function runBranding(opts) {
|
|
|
60
61
|
logo,
|
|
61
62
|
iconLogo = null,
|
|
62
63
|
splashLogo = null,
|
|
64
|
+
featureLogo = null,
|
|
63
65
|
monochromeLogo = null,
|
|
64
66
|
darkLogo = null,
|
|
65
67
|
darkBgColor = null,
|
|
@@ -71,6 +73,7 @@ export async function runBranding(opts) {
|
|
|
71
73
|
androidAdaptivePadding = 19,
|
|
72
74
|
androidLegacyPadding = 10,
|
|
73
75
|
iosPadding = 4,
|
|
76
|
+
featureGraphicPadding = 12,
|
|
74
77
|
notification = false,
|
|
75
78
|
splash = false,
|
|
76
79
|
cleanupLegacy: runCleanup = false,
|
|
@@ -84,7 +87,7 @@ export async function runBranding(opts) {
|
|
|
84
87
|
confirmOverwrites = true
|
|
85
88
|
} = opts
|
|
86
89
|
|
|
87
|
-
validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, cleanupLegacy: runCleanup })
|
|
90
|
+
validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, featureGraphicPadding, cleanupLegacy: runCleanup })
|
|
88
91
|
|
|
89
92
|
const projectType = detectProjectType(projectRoot)
|
|
90
93
|
const isInPlace = inPlace && !output
|
|
@@ -97,7 +100,7 @@ export async function runBranding(opts) {
|
|
|
97
100
|
if (logo) {
|
|
98
101
|
logger.property('Logo: ', logo)
|
|
99
102
|
logger.property('Background: ', bgColor)
|
|
100
|
-
logger.property('Padding: ', `Android adaptive ${androidAdaptivePadding}% / Android legacy ${androidLegacyPadding}% / iOS ${iosPadding}% per side`)
|
|
103
|
+
logger.property('Padding: ', `Android adaptive ${androidAdaptivePadding}% / Android legacy ${androidLegacyPadding}% / iOS ${iosPadding}% per side / Feature Graphic ${featureGraphicPadding}% vertical`)
|
|
101
104
|
console.log()
|
|
102
105
|
logger.property(isInPlace ? 'Writing IN PLACE to: ' : 'Staging: ', isInPlace ? projectRoot : stagingRoot)
|
|
103
106
|
}
|
|
@@ -159,6 +162,8 @@ export async function runBranding(opts) {
|
|
|
159
162
|
lines.push(`${stagingRoot}/DefaultIcon-Tinted.png (${tintedSrc})`)
|
|
160
163
|
}
|
|
161
164
|
lines.push(`${stagingRoot}/iTunesConnect.png + MarketplaceArtwork.png`)
|
|
165
|
+
const featureSrc = featureLogo ? `from ${featureLogo}` : 'from main logo'
|
|
166
|
+
lines.push(`${stagingRoot}/MarketplaceArtworkFeature.png (${featureSrc}, ${featureGraphicPadding}% vertical padding)`)
|
|
162
167
|
lines.push(`${androidResStaging}/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher_{foreground,background,monochrome}.png`)
|
|
163
168
|
lines.push(`${androidResStaging}/mipmap-{...}/ic_launcher.png (legacy)`)
|
|
164
169
|
lines.push(`${androidResStaging}/mipmap-anydpi-v26/ic_launcher.xml`)
|
|
@@ -265,6 +270,20 @@ export async function runBranding(opts) {
|
|
|
265
270
|
})
|
|
266
271
|
generated.push(mkt.itunesConnect, mkt.marketplaceArtwork)
|
|
267
272
|
|
|
273
|
+
let featureMaster = tight
|
|
274
|
+
if (featureLogo) {
|
|
275
|
+
if (!fs.existsSync(featureLogo)) {
|
|
276
|
+
throw new Error(`Feature Graphic logo not found: ${featureLogo}`)
|
|
277
|
+
}
|
|
278
|
+
const featureBase = path.join(tempDir, '_logo_feature')
|
|
279
|
+
const featureResult = await prepareMaster(featureLogo, featureBase)
|
|
280
|
+
featureMaster = featureResult.tight
|
|
281
|
+
}
|
|
282
|
+
const featureSrcLabel = featureLogo ? 'from --feature-logo' : 'from main logo'
|
|
283
|
+
logger.bullet(`MarketplaceArtworkFeature.png (1024×500, ${featureSrcLabel}, ${featureGraphicPadding}% vertical padding, flattened on ${bgColor})`)
|
|
284
|
+
const featurePath = await genFeatureGraphic(featureMaster, featureGraphicPadding, stagingRoot, { bgColor })
|
|
285
|
+
generated.push(featurePath)
|
|
286
|
+
|
|
268
287
|
// ---- Section: Android --------------------------------------------------
|
|
269
288
|
logger.section('Android')
|
|
270
289
|
|
|
@@ -323,7 +342,9 @@ export async function runBranding(opts) {
|
|
|
323
342
|
path.join(tempDir, '_logo_tinted_square.png'),
|
|
324
343
|
path.join(tempDir, '_logo_tinted_tight.png'),
|
|
325
344
|
path.join(tempDir, '_logo_splash_square.png'),
|
|
326
|
-
path.join(tempDir, '_logo_splash_tight.png')
|
|
345
|
+
path.join(tempDir, '_logo_splash_tight.png'),
|
|
346
|
+
path.join(tempDir, '_logo_feature_square.png'),
|
|
347
|
+
path.join(tempDir, '_logo_feature_tight.png')
|
|
327
348
|
]
|
|
328
349
|
for (const tmp of tmpFiles) {
|
|
329
350
|
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
|
|
@@ -385,7 +406,7 @@ function getStagingAndroidAssetsRoot(stagingRoot, projectType) {
|
|
|
385
406
|
return null
|
|
386
407
|
}
|
|
387
408
|
|
|
388
|
-
function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, cleanupLegacy }) {
|
|
409
|
+
function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, androidLegacyPadding, iosPadding, featureGraphicPadding, cleanupLegacy }) {
|
|
389
410
|
if (!logo && !cleanupLegacy) {
|
|
390
411
|
throw new Error('Logo image path is required (unless using --cleanup-legacy alone).')
|
|
391
412
|
}
|
|
@@ -404,4 +425,7 @@ function validateOptions({ logo, bgColor, darkBgColor, androidAdaptivePadding, a
|
|
|
404
425
|
if (iosPadding < 0 || iosPadding > 40) {
|
|
405
426
|
throw new Error(`--ios-padding must be between 0 and 40 (got: ${iosPadding}).`)
|
|
406
427
|
}
|
|
428
|
+
if (featureGraphicPadding < 0 || featureGraphicPadding > 40) {
|
|
429
|
+
throw new Error(`--feature-graphic-padding must be between 0 and 40 (got: ${featureGraphicPadding}).`)
|
|
430
|
+
}
|
|
407
431
|
}
|
|
@@ -37,12 +37,12 @@ function printCompactSummary(opts) {
|
|
|
37
37
|
logger.bullet(`Rebuild: ${chalk.gray('ti clean && ti build -p android -T emulator')}`)
|
|
38
38
|
} else if (projectType === 'alloy') {
|
|
39
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}/`))
|
|
40
|
+
console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork,MarketplaceArtworkFeature}.png ${projectRoot}/`))
|
|
41
41
|
console.log(chalk.gray(` cp -R ${stagingRoot}/app/platform/android/res/. ${projectRoot}/app/platform/android/res/`))
|
|
42
42
|
logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
|
|
43
43
|
} else if (projectType === 'classic') {
|
|
44
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}/`))
|
|
45
|
+
console.log(chalk.gray(` cp ${stagingRoot}/{DefaultIcon,DefaultIcon-ios,DefaultIcon-Dark,DefaultIcon-Tinted,iTunesConnect,MarketplaceArtwork,MarketplaceArtworkFeature}.png ${projectRoot}/`))
|
|
46
46
|
console.log(chalk.gray(` cp -R ${stagingRoot}/platform/android/res/. ${projectRoot}/platform/android/res/`))
|
|
47
47
|
logger.bullet(`Cleanup staging: ${chalk.gray('rm -rf ' + stagingRoot)}`)
|
|
48
48
|
} else {
|
|
@@ -199,7 +199,20 @@ function scaffoldGlossary() {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
// getConfigFile() runs at module import time, before bin/purgetss's catch
|
|
203
|
+
// handler is wired up. If the config validator throws a presentable error
|
|
204
|
+
// (isSyntaxError), print it cleanly and exit instead of letting Node surface
|
|
205
|
+
// the raw stack on top of an already-formatted report.
|
|
206
|
+
let configFile
|
|
207
|
+
try {
|
|
208
|
+
configFile = getConfigFile()
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (err && err.isSyntaxError) {
|
|
211
|
+
console.error(err.message)
|
|
212
|
+
process.exit(1)
|
|
213
|
+
}
|
|
214
|
+
throw err
|
|
215
|
+
}
|
|
203
216
|
configFile.purge = configFile.purge ?? { mode: 'all' }
|
|
204
217
|
configFile.theme = configFile.theme ?? {}
|
|
205
218
|
configFile.theme.extend = configFile.theme.extend ?? {}
|
|
@@ -837,15 +850,14 @@ function generateCombinedClasses(key, data) {
|
|
|
837
850
|
const comments = processComments(key, data)
|
|
838
851
|
|
|
839
852
|
if (Object.entries(data.base).length) {
|
|
840
|
-
|
|
841
|
-
if (typeof value === 'object') {
|
|
842
|
-
_.each(value, (
|
|
843
|
-
myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(key + '-' + _key + '-' + __key)))}': { ${key}: ${helpers.parseValue(_value)} }\n`
|
|
844
|
-
})
|
|
853
|
+
const walk = (value, segments) => {
|
|
854
|
+
if (value && typeof value === 'object') {
|
|
855
|
+
_.each(value, (childValue, childKey) => walk(childValue, [...segments, childKey]))
|
|
845
856
|
} else {
|
|
846
|
-
myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(
|
|
857
|
+
myClasses += `'.${setModifier(removeUneededVariablesFromPropertyName(camelCaseToDash(segments.join('-'))))}': { ${key}: ${helpers.parseValue(value)} }\n`
|
|
847
858
|
}
|
|
848
|
-
}
|
|
859
|
+
}
|
|
860
|
+
walk(data.base, [key])
|
|
849
861
|
} else {
|
|
850
862
|
_.each(data.values, (_value, _key) => {
|
|
851
863
|
if (!_value.includes('deprecated')) myClasses += formatClass(key, _value, data.type === 'Array')
|
|
@@ -857,13 +869,6 @@ function generateCombinedClasses(key, data) {
|
|
|
857
869
|
return false
|
|
858
870
|
}
|
|
859
871
|
|
|
860
|
-
function saveAutoTSS(key, classes) {
|
|
861
|
-
if (fs.existsSync(projectsConfigJS) && saveGlossary) {
|
|
862
|
-
makeSureFolderExists(cwd + '/purgetss/glossary/tailwind-classes/')
|
|
863
|
-
saveFile(cwd + `/purgetss/glossary/tailwind-classes/${key}.tss`, classes)
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
872
|
// inputType is marked as Array in completions but accepts a single value
|
|
868
873
|
const nonArrayOverrides = new Set(['inputType'])
|
|
869
874
|
|
|
@@ -22,7 +22,9 @@ import { logger } from '../branding/branding-logger.js'
|
|
|
22
22
|
const IMAGES_BLOCK = ` images: {
|
|
23
23
|
quality: 85, // JPEG/WebP/AVIF quality (0-100)
|
|
24
24
|
format: null, // null = keep original; 'webp' | 'jpeg' | 'png' to convert every image
|
|
25
|
-
|
|
25
|
+
autoSync: true, // false = SVG pipeline computes dims but doesn't write to images.files
|
|
26
|
+
confirmOverwrites: true, // prompt before overwriting files (set false to skip)
|
|
27
|
+
files: [] // per-file overrides: [{ filename: 'images/<sub>/<name>.<ext>', width, height? }]
|
|
26
28
|
},
|
|
27
29
|
`
|
|
28
30
|
|
|
@@ -47,11 +49,11 @@ export function ensureImagesSection() {
|
|
|
47
49
|
fs.writeFileSync(projectsConfigJS, patched, 'utf8')
|
|
48
50
|
console.log()
|
|
49
51
|
logger.success(`Added ${chalk.cyan('images:')} section to ${chalk.cyan('./purgetss/config.cjs')} with default values.`)
|
|
50
|
-
console.log(
|
|
51
|
-
console.log(
|
|
52
|
+
console.log(' Edit that block to customize defaults (quality, format).')
|
|
53
|
+
console.log(' CLI flags always win over config values.')
|
|
52
54
|
console.log()
|
|
53
55
|
} catch (err) {
|
|
54
56
|
logger.warning(`Could not auto-add images: section to config.cjs (${err.message}).`)
|
|
55
|
-
logger.warning(
|
|
57
|
+
logger.warning('The command will still run using built-in defaults.')
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -78,21 +78,44 @@ export const IPHONE_SCALES = Object.freeze([
|
|
|
78
78
|
// 1, 2, 3 for iPhone) only because every entry in *_SCALES is normalized to
|
|
79
79
|
// n/4 with the largest scale (xxxhdpi/@4x) at 4/4. If a future density is
|
|
80
80
|
// added beyond xxxhdpi, this conversion factor needs to be revisited.
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
//
|
|
82
|
+
// `baseHeight` pins the height explicitly (e.g. SVG pipeline resolved both
|
|
83
|
+
// w-* and h-* to numbers); when omitted, height follows the source aspect.
|
|
84
|
+
function computeScaleTarget(srcMeta, factor, baseWidth, baseHeight) {
|
|
85
|
+
// No pin in either direction → fall back to the source as the 4× master.
|
|
86
|
+
if (baseWidth == null && baseHeight == null) {
|
|
83
87
|
return {
|
|
84
88
|
targetWidth: Math.max(1, Math.round(srcMeta.width * factor)),
|
|
85
89
|
targetHeight: Math.max(1, Math.round(srcMeta.height * factor))
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
const multiplier = factor * 4
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const widthOverHeight = srcMeta.width > 0 && srcMeta.height > 0
|
|
94
|
+
? srcMeta.width / srcMeta.height
|
|
95
|
+
: 1
|
|
96
|
+
const heightOverWidth = srcMeta.width > 0 && srcMeta.height > 0
|
|
97
|
+
? srcMeta.height / srcMeta.width
|
|
98
|
+
: 1
|
|
99
|
+
|
|
100
|
+
if (baseWidth != null) {
|
|
101
|
+
const targetWidth = Math.max(1, Math.round(baseWidth * multiplier))
|
|
102
|
+
const targetHeight = baseHeight != null
|
|
103
|
+
? Math.max(1, Math.round(baseHeight * multiplier))
|
|
104
|
+
: Math.max(1, Math.round(baseWidth * multiplier * heightOverWidth))
|
|
105
|
+
return { targetWidth, targetHeight }
|
|
93
106
|
}
|
|
107
|
+
|
|
108
|
+
// Height pinned, width derived from inverse aspect.
|
|
109
|
+
const targetHeight = Math.max(1, Math.round(baseHeight * multiplier))
|
|
110
|
+
const targetWidth = Math.max(1, Math.round(baseHeight * multiplier * widthOverHeight))
|
|
111
|
+
return { targetWidth, targetHeight }
|
|
94
112
|
}
|
|
95
113
|
|
|
114
|
+
// Hard ceiling for any individual PNG output. Mirrors the constant exported
|
|
115
|
+
// from the SVG pipeline; centralizing it here would create a cycle, so we keep
|
|
116
|
+
// a local copy and rely on derive-dimensions to enforce the dp-side budget.
|
|
117
|
+
const MAX_OUTPUT_PIXELS = 4096
|
|
118
|
+
|
|
96
119
|
/**
|
|
97
120
|
* Scale a source image into all Android density variants.
|
|
98
121
|
*
|
|
@@ -105,18 +128,19 @@ function computeScaleTarget(srcMeta, factor, baseWidth) {
|
|
|
105
128
|
* @returns {Promise<string[]>} Paths written
|
|
106
129
|
*/
|
|
107
130
|
export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts = {}) {
|
|
108
|
-
const { format = null, quality = 85, baseWidth = null } = opts
|
|
131
|
+
const { format = null, quality = 85, baseWidth = null, baseHeight = null, opacity = null, padding = null } = opts
|
|
109
132
|
const src = await readSource(sourceFile)
|
|
110
133
|
const written = []
|
|
111
134
|
|
|
112
135
|
for (const { name, factor } of ANDROID_SCALES) {
|
|
113
|
-
const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth)
|
|
136
|
+
const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth, baseHeight)
|
|
137
|
+
assertWithinCap(targetWidth, targetHeight, sourceFile, name)
|
|
114
138
|
|
|
115
139
|
const outDir = path.join(androidBaseDir, name, path.dirname(relPath))
|
|
116
140
|
fs.mkdirSync(outDir, { recursive: true })
|
|
117
141
|
|
|
118
142
|
const outPath = path.join(outDir, renameWithFormat(path.basename(relPath), format, src.isSvg))
|
|
119
|
-
await writeScaled(src, outPath, targetWidth, targetHeight, format, quality)
|
|
143
|
+
await writeScaled(src, outPath, targetWidth, targetHeight, format, quality, opacity, padding)
|
|
120
144
|
written.push(outPath)
|
|
121
145
|
}
|
|
122
146
|
return written
|
|
@@ -132,7 +156,7 @@ export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts
|
|
|
132
156
|
* @returns {Promise<string[]>} Paths written
|
|
133
157
|
*/
|
|
134
158
|
export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts = {}) {
|
|
135
|
-
const { format = null, quality = 85, baseWidth = null } = opts
|
|
159
|
+
const { format = null, quality = 85, baseWidth = null, baseHeight = null, opacity = null, padding = null } = opts
|
|
136
160
|
const src = await readSource(sourceFile)
|
|
137
161
|
const written = []
|
|
138
162
|
|
|
@@ -141,7 +165,8 @@ export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts =
|
|
|
141
165
|
fs.mkdirSync(outDir, { recursive: true })
|
|
142
166
|
|
|
143
167
|
for (const { suffix, factor } of IPHONE_SCALES) {
|
|
144
|
-
const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth)
|
|
168
|
+
const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth, baseHeight)
|
|
169
|
+
assertWithinCap(targetWidth, targetHeight, sourceFile, suffix || '@1x')
|
|
145
170
|
|
|
146
171
|
// SVG sources can't be written as SVG by Sharp — fall back to PNG if the
|
|
147
172
|
// user didn't specify an explicit output format.
|
|
@@ -149,12 +174,21 @@ export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts =
|
|
|
149
174
|
const outName = `${parsed.name}${suffix}${ext}`
|
|
150
175
|
const outPath = path.join(outDir, outName)
|
|
151
176
|
|
|
152
|
-
await writeScaled(src, outPath, targetWidth, targetHeight, format, quality)
|
|
177
|
+
await writeScaled(src, outPath, targetWidth, targetHeight, format, quality, opacity, padding)
|
|
153
178
|
written.push(outPath)
|
|
154
179
|
}
|
|
155
180
|
return written
|
|
156
181
|
}
|
|
157
182
|
|
|
183
|
+
function assertWithinCap(width, height, sourceFile, label) {
|
|
184
|
+
if (width > MAX_OUTPUT_PIXELS || height > MAX_OUTPUT_PIXELS) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${path.basename(sourceFile)} at ${label} would render ${width}×${height}px, ` +
|
|
187
|
+
`which exceeds the ${MAX_OUTPUT_PIXELS}px cap. Reduce the resolved width or override it manually in config.cjs > images.files.`
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
function renameWithFormat(filename, format, isSvg = false) {
|
|
159
193
|
if (format) {
|
|
160
194
|
const parsed = path.parse(filename)
|
|
@@ -168,15 +202,46 @@ function renameWithFormat(filename, format, isSvg = false) {
|
|
|
168
202
|
return filename
|
|
169
203
|
}
|
|
170
204
|
|
|
171
|
-
async function writeScaled(src, outPath, width, height, format, quality) {
|
|
172
|
-
|
|
205
|
+
async function writeScaled(src, outPath, width, height, format, quality, opacity, paddingPct) {
|
|
206
|
+
// Padding shrinks the rendered image inside the same canvas so each density
|
|
207
|
+
// gets symmetric transparent borders. Computed from the canvas dimensions so
|
|
208
|
+
// the visual ratio (e.g. 15%) is identical across every density variant.
|
|
209
|
+
const padX = paddingPct ? Math.floor(width * paddingPct / 100) : 0
|
|
210
|
+
const padY = paddingPct ? Math.floor(height * paddingPct / 100) : 0
|
|
211
|
+
const innerW = Math.max(1, width - 2 * padX)
|
|
212
|
+
const innerH = Math.max(1, height - 2 * padY)
|
|
213
|
+
const targetMax = Math.max(innerW, innerH)
|
|
214
|
+
|
|
173
215
|
let pipeline = buildScalePipeline(src, targetMax).resize({
|
|
174
|
-
width,
|
|
175
|
-
height,
|
|
216
|
+
width: innerW,
|
|
217
|
+
height: innerH,
|
|
176
218
|
fit: 'contain',
|
|
177
219
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
178
220
|
})
|
|
179
221
|
|
|
222
|
+
if (padX > 0 || padY > 0) {
|
|
223
|
+
pipeline = pipeline.extend({
|
|
224
|
+
top: padY,
|
|
225
|
+
bottom: padY,
|
|
226
|
+
left: padX,
|
|
227
|
+
right: padX,
|
|
228
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Apply opacity by multiplying the dest alpha against a uniform-alpha tile.
|
|
233
|
+
// `dest-in` keeps RGB and multiplies dest alpha by source alpha (opacity/100).
|
|
234
|
+
if (opacity != null && opacity < 100) {
|
|
235
|
+
pipeline = pipeline
|
|
236
|
+
.ensureAlpha()
|
|
237
|
+
.composite([{
|
|
238
|
+
input: Buffer.from([255, 255, 255, Math.round(255 * opacity / 100)]),
|
|
239
|
+
raw: { width: 1, height: 1, channels: 4 },
|
|
240
|
+
tile: true,
|
|
241
|
+
blend: 'dest-in'
|
|
242
|
+
}])
|
|
243
|
+
}
|
|
244
|
+
|
|
180
245
|
// For SVG sources without an explicit format, coerce output to PNG
|
|
181
246
|
// (Sharp cannot write SVG).
|
|
182
247
|
const fallbackExt = src.isSvg ? 'png' : path.extname(src.path).slice(1).toLowerCase()
|
|
@@ -188,7 +253,7 @@ async function writeScaled(src, outPath, width, height, format, quality) {
|
|
|
188
253
|
|
|
189
254
|
function applyFormat(pipeline, format, quality) {
|
|
190
255
|
switch (format) {
|
|
191
|
-
case 'png': return pipeline.png({
|
|
256
|
+
case 'png': return pipeline.png({ compressionLevel: 9 })
|
|
192
257
|
case 'webp': return pipeline.webp({ quality })
|
|
193
258
|
case 'avif': return pipeline.avif({ quality })
|
|
194
259
|
case 'tiff': return pipeline.tiff({ quality, compression: 'lzw' })
|
package/src/core/images/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'fs'
|
|
19
19
|
import path from 'path'
|
|
20
|
+
import sharp from 'sharp'
|
|
20
21
|
import { logger } from '../branding/branding-logger.js'
|
|
21
22
|
import { logger as mainLogger } from '../../shared/logger.js'
|
|
22
23
|
import { confirmWithAlways } from '../../shared/prompt.js'
|
|
@@ -36,23 +37,46 @@ export async function runImages(opts) {
|
|
|
36
37
|
format = null,
|
|
37
38
|
quality = 85,
|
|
38
39
|
baseWidth = null,
|
|
40
|
+
opacity = null, // 0-100 or null
|
|
41
|
+
padding = null, // 0-40 (per side, %) or null
|
|
42
|
+
outputRelpath = null, // basename + subfolder relative to images root, no extension
|
|
39
43
|
dryRun = false,
|
|
40
44
|
yes = false,
|
|
41
|
-
confirmOverwrites = true
|
|
45
|
+
confirmOverwrites = true,
|
|
46
|
+
filesOverrides = [] // [{ filename: 'images/<relpath>', width, height? }, …]
|
|
42
47
|
} = opts
|
|
43
48
|
|
|
44
49
|
if (!fs.existsSync(source)) {
|
|
45
50
|
throw new Error(`Source not found: ${source}`)
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
if (outputRelpath != null && fs.statSync(source).isDirectory()) {
|
|
54
|
+
throw new Error('--output is incompatible with directory sources (one basename cannot apply to multiple files). Pass a single file as the source, or drop --output.')
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
const projectType = detectProjectType(projectRoot)
|
|
49
58
|
const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(projectRoot, projectType)
|
|
50
59
|
|
|
51
60
|
const files = collectImageFiles(source)
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
// Build a lookup keyed by `images/<subpath>` so per-file `width`/`height`
|
|
63
|
+
// declared in `config.cjs > images.files` can override the directory scan's
|
|
64
|
+
// default sizing. CLI `--width` still wins over both.
|
|
65
|
+
const overrides = buildOverridesMap(filesOverrides)
|
|
66
|
+
const imagesFolderForKey = projectRoot === process.cwd()
|
|
67
|
+
? projectsPurge_TSS_Images_Folder
|
|
68
|
+
: path.join(projectRoot, 'purgetss', 'images')
|
|
69
|
+
|
|
70
|
+
if (baseWidth == null) {
|
|
71
|
+
const uncoveredSvgs = files.filter(f => {
|
|
72
|
+
if (path.extname(f).toLowerCase() !== '.svg') return false
|
|
73
|
+
const key = overrideKeyFor(f, imagesFolderForKey)
|
|
74
|
+
return !overrides.has(key)
|
|
75
|
+
})
|
|
76
|
+
if (uncoveredSvgs.length > 0) {
|
|
77
|
+
logger.warning('⚠ SVG source detected without --width and no entry in config.cjs > images.files. Output sizes will be derived from each SVG\'s viewBox (treated as a 4× master).')
|
|
78
|
+
logger.warning(' For SVGs from vector editors with disproportionate viewBoxes, pass --width <n> (e.g. --width 256) or add an entry to images.files to pin the @1x/mdpi width.')
|
|
79
|
+
}
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
console.log()
|
|
@@ -67,6 +91,9 @@ export async function runImages(opts) {
|
|
|
67
91
|
logger.property('Platforms: ', platforms.join(' + '))
|
|
68
92
|
if (format) logger.property('Format: ', `convert all to ${format}`)
|
|
69
93
|
if (baseWidth != null) logger.property('Width: ', `${baseWidth} px @1x/mdpi`)
|
|
94
|
+
if (opacity != null) logger.property('Opacity: ', `${opacity}%`)
|
|
95
|
+
if (padding != null) logger.property('Padding: ', `${padding}% per side`)
|
|
96
|
+
if (outputRelpath != null) logger.property('Output: ', `images/${outputRelpath}.<ext>`)
|
|
70
97
|
if (dryRun) logger.warning('DRY RUN — no files will be written')
|
|
71
98
|
|
|
72
99
|
if (files.length === 0) {
|
|
@@ -74,9 +101,9 @@ export async function runImages(opts) {
|
|
|
74
101
|
return { written: [] }
|
|
75
102
|
}
|
|
76
103
|
|
|
77
|
-
if (!dryRun && confirmOverwrites) {
|
|
104
|
+
if (!dryRun && confirmOverwrites && !yes) {
|
|
78
105
|
logger.warning(`⚠ Scaled images will OVERWRITE existing variants under ${androidBaseDir} and ${iphoneBaseDir}.`)
|
|
79
|
-
logger.warning(
|
|
106
|
+
logger.warning(' Commit first if you want a rollback.')
|
|
80
107
|
const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
|
|
81
108
|
if (choice === 'no') {
|
|
82
109
|
logger.info('Aborted.')
|
|
@@ -94,8 +121,8 @@ export async function runImages(opts) {
|
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
if (projectType === 'unknown') {
|
|
97
|
-
logger.warning(
|
|
98
|
-
logger.warning(
|
|
124
|
+
logger.warning('Could not detect project layout. Expected \'app/\' (Alloy) or \'Resources/\' (Classic).')
|
|
125
|
+
logger.warning('Assets will still be written to the detected default paths — verify the output.')
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
// Relative paths preserve the user's subdirectory structure inside purgetss/images/.
|
|
@@ -116,17 +143,70 @@ export async function runImages(opts) {
|
|
|
116
143
|
|
|
117
144
|
logger.section('Scaling')
|
|
118
145
|
for (const file of files) {
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
// When --output is set, override the computed relPath with the user's
|
|
147
|
+
// basename + subfolder. Append the source extension so downstream
|
|
148
|
+
// path.parse / renameWithFormat behave the same as for natural sources.
|
|
149
|
+
const relPath = outputRelpath != null
|
|
150
|
+
? outputRelpath + path.extname(file)
|
|
151
|
+
: path.relative(sourceRoot, file)
|
|
152
|
+
|
|
153
|
+
// Per-file resolution: CLI --width wins; if absent, fall back to the
|
|
154
|
+
// entry in `images.files` (if any); else null (gen-scales reads viewBox).
|
|
155
|
+
const override = overrides.get(overrideKeyFor(file, imagesFolderForKey))
|
|
156
|
+
const effectiveBaseWidth = baseWidth ?? override?.width ?? null
|
|
157
|
+
const effectiveBaseHeight = baseWidth != null ? null : (override?.height ?? null)
|
|
158
|
+
|
|
159
|
+
// SVGs listed in `images.files` are almost always referenced from views/
|
|
160
|
+
// controllers as `image="/.../foo.svg"`, and Titanium's runtime only falls
|
|
161
|
+
// back from a `.svg` reference to `.png` (verified empirically — not to
|
|
162
|
+
// .webp, .jpeg, etc.). Forcing PNG here prevents the standalone command
|
|
163
|
+
// from quietly emitting a format Titanium can't load via that fallback.
|
|
164
|
+
// Raster files in `files` and SVGs NOT in `files` still honor `format`.
|
|
165
|
+
const ext = path.extname(file).toLowerCase()
|
|
166
|
+
const isSvg = ext === '.svg'
|
|
167
|
+
const isSvgInFiles = override != null && isSvg
|
|
168
|
+
const effectiveFormat = isSvgInFiles ? null : format
|
|
169
|
+
|
|
170
|
+
// Build an informative bullet so the user can see which decisions applied
|
|
171
|
+
// per file: source of width, where it came from, and the actual output
|
|
172
|
+
// format (especially when PNG is forced for SVGs in `files`).
|
|
173
|
+
const widthSource = baseWidth != null
|
|
174
|
+
? `${baseWidth}dp (CLI --width)`
|
|
175
|
+
: override
|
|
176
|
+
? `${override.width}dp (files)`
|
|
177
|
+
: isSvg ? 'viewBox' : 'source 4×'
|
|
178
|
+
const outFormat = effectiveFormat ?? (isSvg ? 'png' : ext.slice(1))
|
|
179
|
+
const formatTag = isSvgInFiles && format && format !== 'png'
|
|
180
|
+
? `${outFormat} (forced; ignores format: ${format})`
|
|
181
|
+
: outFormat
|
|
182
|
+
logger.bullet(`${relPath} → ${widthSource} · ${formatTag}`)
|
|
121
183
|
|
|
122
184
|
if (dryRun) continue
|
|
123
185
|
|
|
186
|
+
// Quality warning: if the user pinned a width (via CLI or `files`), the
|
|
187
|
+
// source must carry at least `width × 4` pixels — that's what xxxhdpi/@4x
|
|
188
|
+
// needs. Anything smaller forces Sharp to upscale, producing blurry output.
|
|
189
|
+
// SVG sources are vector and exempt from this check.
|
|
190
|
+
if (effectiveBaseWidth != null && !isSvg) {
|
|
191
|
+
const meta = await sharp(file).metadata()
|
|
192
|
+
const requiredXxxhdpi = effectiveBaseWidth * 4
|
|
193
|
+
if (Number.isFinite(meta.width) && meta.width < requiredXxxhdpi) {
|
|
194
|
+
logger.warning(
|
|
195
|
+
`⚠ ${relPath}: source is ${meta.width}px wide but xxxhdpi needs ${requiredXxxhdpi}px (4× of declared ${effectiveBaseWidth}dp @1x). Output will be upscaled and may look blurry — provide a source ≥ ${requiredXxxhdpi}px.`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
124
200
|
if (!iphoneOnly) {
|
|
125
|
-
const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, {
|
|
201
|
+
const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, {
|
|
202
|
+
format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
|
|
203
|
+
})
|
|
126
204
|
written.push(...androidFiles)
|
|
127
205
|
}
|
|
128
206
|
if (!androidOnly) {
|
|
129
|
-
const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, {
|
|
207
|
+
const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, {
|
|
208
|
+
format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
|
|
209
|
+
})
|
|
130
210
|
written.push(...iphoneFiles)
|
|
131
211
|
}
|
|
132
212
|
}
|
|
@@ -155,6 +235,31 @@ function resolveOutputDirs(projectRoot, projectType) {
|
|
|
155
235
|
}
|
|
156
236
|
}
|
|
157
237
|
|
|
238
|
+
// Normalize a config `images.files` entry list into a Map keyed by filename.
|
|
239
|
+
// Invalid entries (missing filename, non-numeric width) are silently skipped
|
|
240
|
+
// so a typo in config doesn't crash the whole pipeline.
|
|
241
|
+
function buildOverridesMap(entries) {
|
|
242
|
+
const map = new Map()
|
|
243
|
+
if (!Array.isArray(entries)) return map
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
if (!entry || typeof entry.filename !== 'string') continue
|
|
246
|
+
if (typeof entry.width !== 'number' || !Number.isFinite(entry.width)) continue
|
|
247
|
+
const key = entry.filename.replace(/^\/+/, '')
|
|
248
|
+
map.set(key, {
|
|
249
|
+
width: entry.width,
|
|
250
|
+
height: typeof entry.height === 'number' && Number.isFinite(entry.height) ? entry.height : null
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
return map
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Match the key shape stored in `config.cjs > images.files`:
|
|
257
|
+
// `images/<subpath>/<name>.<ext>` relative to `purgetss/images/`.
|
|
258
|
+
function overrideKeyFor(file, imagesFolder) {
|
|
259
|
+
const rel = path.relative(imagesFolder, file).split(path.sep).join('/')
|
|
260
|
+
return rel.startsWith('..') ? null : `images/${rel}`
|
|
261
|
+
}
|
|
262
|
+
|
|
158
263
|
function collectImageFiles(source) {
|
|
159
264
|
const stat = fs.statSync(source)
|
|
160
265
|
if (stat.isFile()) {
|
|
@@ -49,7 +49,7 @@ export function purgeFontAwesome(uniqueClasses, cleanUniqueClasses, debug = fals
|
|
|
49
49
|
if (fs.existsSync(projectsFA_TSS_File)) {
|
|
50
50
|
sourceFolder = projectsFA_TSS_File
|
|
51
51
|
purgedClasses = '\n// Pro/Beta Font Awesome\n'
|
|
52
|
-
purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles
|
|
52
|
+
purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles...`
|
|
53
53
|
} else {
|
|
54
54
|
sourceFolder = srcFontAwesomeTSSFile
|
|
55
55
|
purgedClasses = '\n// Default Font Awesome\n'
|
|
@@ -128,9 +128,13 @@ export function purgeFontIcons(sourceFolder, uniqueClasses, message, cleanUnique
|
|
|
128
128
|
|
|
129
129
|
let purgedClasses = ''
|
|
130
130
|
const sourceTSS = fs.readFileSync(sourceFolder, 'utf8')
|
|
131
|
+
const hasMatches = cleanUniqueClasses.some(element => sourceTSS.includes(`'.${element}'`))
|
|
131
132
|
|
|
132
|
-
if (
|
|
133
|
-
|
|
133
|
+
if (hasMatches) {
|
|
134
|
+
// In debug mode the label is emitted by localFinish inline with the timing.
|
|
135
|
+
// In non-debug mode this is the progress indicator (only shown when there's
|
|
136
|
+
// actual work for this font, matching pre-existing behavior).
|
|
137
|
+
if (!debug) logger.info(message)
|
|
134
138
|
const sourceTSSFile = sourceTSS.split(/\r?\n/)
|
|
135
139
|
uniqueClasses.forEach(className => {
|
|
136
140
|
const cleanClassName = cleanClassNameFn(className)
|
|
@@ -48,7 +48,9 @@ function cleanClassNameFn(className) {
|
|
|
48
48
|
export function purgeTailwind(uniqueClasses, debug = false) {
|
|
49
49
|
if (debug) localStart()
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// In debug mode, the section label is emitted by localFinish together with
|
|
52
|
+
// the timing (inline). In non-debug mode this line is the progress indicator.
|
|
53
|
+
if (!debug) logger.info('Purging', chalk.yellow('utilities.tss'), 'styles...')
|
|
52
54
|
|
|
53
55
|
let purgedClasses = ''
|
|
54
56
|
let tailwindClasses = fs.readFileSync(projectsTailwind_TSS, 'utf8').split(/\r?\n/)
|