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.
Files changed (47) hide show
  1. package/README.md +38 -17
  2. package/bin/purgetss +140 -1
  3. package/dist/purgetss.ui.js +23 -26
  4. package/dist/utilities.tss +13 -1
  5. package/lib/completions/titanium/completions-v3.json +62 -1
  6. package/lib/templates/purgetss.config.js.cjs +15 -1
  7. package/lib/templates/purgetss.ui.js.cjs +22 -25
  8. package/package.json +3 -1
  9. package/src/cli/commands/brand.js +69 -0
  10. package/src/cli/commands/create.js +11 -7
  11. package/src/cli/commands/fonts.js +9 -9
  12. package/src/cli/commands/icon-library.js +18 -16
  13. package/src/cli/commands/images.js +116 -0
  14. package/src/cli/commands/init.js +4 -0
  15. package/src/cli/commands/module.js +4 -2
  16. package/src/cli/commands/purge.js +48 -98
  17. package/src/cli/commands/semantic.js +180 -0
  18. package/src/cli/commands/shades.js +332 -13
  19. package/src/cli/utils/project-detection.js +4 -2
  20. package/src/core/analyzers/class-extractor.js +110 -3
  21. package/src/core/branding/brand-config.js +111 -0
  22. package/src/core/branding/branding-logger.js +40 -0
  23. package/src/core/branding/cleanup-legacy.js +220 -0
  24. package/src/core/branding/ensure-brand-section.js +80 -0
  25. package/src/core/branding/gen-android-adaptive.js +116 -0
  26. package/src/core/branding/gen-android-legacy.js +63 -0
  27. package/src/core/branding/gen-ic-launcher-xml.js +29 -0
  28. package/src/core/branding/gen-ios-dark.js +70 -0
  29. package/src/core/branding/gen-ios-tinted.js +55 -0
  30. package/src/core/branding/gen-ios.js +69 -0
  31. package/src/core/branding/gen-marketplace.js +71 -0
  32. package/src/core/branding/gen-notification.js +76 -0
  33. package/src/core/branding/gen-splash.js +64 -0
  34. package/src/core/branding/index.js +336 -0
  35. package/src/core/branding/post-gen-notes.js +145 -0
  36. package/src/core/branding/prepare-master.js +108 -0
  37. package/src/core/branding/tiapp-reader.js +110 -0
  38. package/src/core/images/ensure-images-section.js +57 -0
  39. package/src/core/images/gen-scales.js +181 -0
  40. package/src/core/images/index.js +171 -0
  41. package/src/shared/config-manager.js +46 -0
  42. package/src/shared/config-writer.js +84 -0
  43. package/src/shared/constants.js +3 -0
  44. package/src/shared/logger.js +69 -4
  45. package/src/shared/prompt.js +64 -0
  46. package/src/shared/svg-utils.js +80 -0
  47. 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
+ }