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,171 @@
1
+ /**
2
+ * PurgeTSS - Images pipeline orchestrator
3
+ *
4
+ * Discovers source images (auto from `purgetss/images/` or from a user-provided
5
+ * path) and generates Titanium multi-density variants for Alloy or Classic
6
+ * projects.
7
+ *
8
+ * Layouts:
9
+ * Alloy: app/assets/android/images/res-{density}/ + app/assets/iphone/images/
10
+ * Classic: Resources/android/images/res-{density}/ + Resources/iphone/images/
11
+ *
12
+ * Subdirectories of `purgetss/images/` are preserved in the output paths.
13
+ *
14
+ * @fileoverview Orchestrator for `purgetss images`
15
+ * @author César Estrada
16
+ */
17
+
18
+ import fs from 'fs'
19
+ import path from 'path'
20
+ import { logger } from '../branding/branding-logger.js'
21
+ import { logger as mainLogger } from '../../shared/logger.js'
22
+ import { confirmWithAlways } from '../../shared/prompt.js'
23
+ import { setConfigProperty } from '../../shared/config-writer.js'
24
+ import { detectProjectType } from '../branding/tiapp-reader.js'
25
+ import { genAndroidScales, genIphoneScales } from './gen-scales.js'
26
+ import { projectsPurge_TSS_Images_Folder } from '../../shared/constants.js'
27
+
28
+ const SUPPORTED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'])
29
+
30
+ export async function runImages(opts) {
31
+ const {
32
+ source, // resolved absolute path (file or directory)
33
+ projectRoot = process.cwd(),
34
+ androidOnly = false,
35
+ iphoneOnly = false,
36
+ format = null,
37
+ quality = 85,
38
+ dryRun = false,
39
+ yes = false,
40
+ confirmOverwrites = true
41
+ } = opts
42
+
43
+ if (!fs.existsSync(source)) {
44
+ throw new Error(`Source not found: ${source}`)
45
+ }
46
+
47
+ const projectType = detectProjectType(projectRoot)
48
+ const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(projectRoot, projectType)
49
+
50
+ const files = collectImageFiles(source)
51
+
52
+ console.log()
53
+ mainLogger.info('Generating multi-density image variants...')
54
+ console.log()
55
+ logger.property('Project: ', `${projectRoot} (${projectType})`)
56
+ logger.property('Source: ', source)
57
+ logger.property('Images: ', `${files.length} file${files.length === 1 ? '' : 's'}`)
58
+ const platforms = []
59
+ if (!iphoneOnly) platforms.push('Android (5 densities)')
60
+ if (!androidOnly) platforms.push('iPhone (@1x, @2x, @3x)')
61
+ logger.property('Platforms: ', platforms.join(' + '))
62
+ if (format) logger.property('Format: ', `convert all to ${format}`)
63
+ if (dryRun) logger.warning('DRY RUN — no files will be written')
64
+
65
+ if (files.length === 0) {
66
+ logger.warning('No images found. Put your source files inside purgetss/images/ (svg, png, jpg, jpeg, webp, gif).')
67
+ return { written: [] }
68
+ }
69
+
70
+ if (!dryRun && confirmOverwrites) {
71
+ logger.warning(`⚠ Scaled images will OVERWRITE existing variants under ${androidBaseDir} and ${iphoneBaseDir}.`)
72
+ logger.warning(` Commit first if you want a rollback.`)
73
+ const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
74
+ if (choice === 'no') {
75
+ logger.info('Aborted.')
76
+ // eslint-disable-next-line n/no-process-exit
77
+ process.exit(0)
78
+ }
79
+ if (choice === 'always') {
80
+ const saved = setConfigProperty('images', 'confirmOverwrites', false)
81
+ if (saved) {
82
+ logger.success('Saved images.confirmOverwrites = false to purgetss/config.cjs — you won\'t be asked again.')
83
+ } else {
84
+ logger.warning('Could not persist preference (config.cjs missing or unreadable). Proceeding anyway.')
85
+ }
86
+ }
87
+ }
88
+
89
+ if (projectType === 'unknown') {
90
+ logger.warning(`Could not detect project layout. Expected 'app/' (Alloy) or 'Resources/' (Classic).`)
91
+ logger.warning(`Assets will still be written to the detected default paths — verify the output.`)
92
+ }
93
+
94
+ // Relative paths preserve the user's subdirectory structure inside purgetss/images/.
95
+ // If the source is inside purgetss/images/, compute relPath from that folder
96
+ // so subdirectories are always preserved in the output — regardless of whether
97
+ // the user passed the full folder, a subfolder, or a single file.
98
+ const imagesFolder = projectRoot === process.cwd()
99
+ ? projectsPurge_TSS_Images_Folder
100
+ : path.join(projectRoot, 'purgetss', 'images')
101
+ const sourceIsInsideImagesFolder = source === imagesFolder
102
+ || source.startsWith(imagesFolder + path.sep)
103
+
104
+ const sourceRoot = sourceIsInsideImagesFolder
105
+ ? imagesFolder
106
+ : (fs.statSync(source).isDirectory() ? source : path.dirname(source))
107
+
108
+ const written = []
109
+
110
+ logger.section('Scaling')
111
+ for (const file of files) {
112
+ const relPath = path.relative(sourceRoot, file)
113
+ logger.bullet(relPath)
114
+
115
+ if (dryRun) continue
116
+
117
+ if (!iphoneOnly) {
118
+ const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, { format, quality })
119
+ written.push(...androidFiles)
120
+ }
121
+ if (!androidOnly) {
122
+ const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, { format, quality })
123
+ written.push(...iphoneFiles)
124
+ }
125
+ }
126
+
127
+ if (!dryRun) {
128
+ console.log()
129
+ logger.success(`${written.length} file${written.length === 1 ? '' : 's'} written.`)
130
+ logger.property('Android: ', androidBaseDir)
131
+ logger.property('iPhone: ', iphoneBaseDir)
132
+ }
133
+
134
+ return { written }
135
+ }
136
+
137
+ function resolveOutputDirs(projectRoot, projectType) {
138
+ if (projectType === 'classic') {
139
+ return {
140
+ androidBaseDir: path.join(projectRoot, 'Resources', 'android', 'images'),
141
+ iphoneBaseDir: path.join(projectRoot, 'Resources', 'iphone', 'images')
142
+ }
143
+ }
144
+ // Alloy (or unknown fallback uses Alloy convention)
145
+ return {
146
+ androidBaseDir: path.join(projectRoot, 'app', 'assets', 'android', 'images'),
147
+ iphoneBaseDir: path.join(projectRoot, 'app', 'assets', 'iphone', 'images')
148
+ }
149
+ }
150
+
151
+ function collectImageFiles(source) {
152
+ const stat = fs.statSync(source)
153
+ if (stat.isFile()) {
154
+ return SUPPORTED_EXTS.has(path.extname(source).toLowerCase()) ? [source] : []
155
+ }
156
+ // Directory — recurse
157
+ return walk(source)
158
+ }
159
+
160
+ function walk(dir) {
161
+ const out = []
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const full = path.join(dir, entry.name)
164
+ if (entry.isDirectory()) {
165
+ out.push(...walk(full))
166
+ } else if (entry.isFile() && SUPPORTED_EXTS.has(path.extname(entry.name).toLowerCase())) {
167
+ out.push(full)
168
+ }
169
+ }
170
+ return out
171
+ }
@@ -16,6 +16,9 @@ import defaultTheme from 'tailwindcss/defaultTheme.js'
16
16
  import {
17
17
  projectsConfigJS,
18
18
  projectsPurgeTSSFolder,
19
+ projectsPurge_TSS_Fonts_Folder,
20
+ projectsPurge_TSS_Brand_Folder,
21
+ projectsPurge_TSS_Images_Folder,
19
22
  srcConfigFile
20
23
  } from './constants.js'
21
24
  import { logger } from './logger.js'
@@ -24,6 +27,29 @@ import { makeSureFolderExists } from './utils.js'
24
27
  // Create require for ESM compatibility
25
28
  const require = createRequire(import.meta.url)
26
29
 
30
+ /**
31
+ * Parse a padding value from either a number or a percentage string.
32
+ *
33
+ * 20 → 20
34
+ * '20' → 20
35
+ * '20%' → 20
36
+ *
37
+ * Used for `brand.padding` and `brand.iosPadding` so users can write
38
+ * self-documenting values like `padding: '25%'` in their config.
39
+ *
40
+ * @param {number|string} value
41
+ * @param {string} fieldName - Config path for error messages (e.g. 'brand.padding')
42
+ * @returns {number} Integer 0-40
43
+ */
44
+ function parsePadding(value, fieldName) {
45
+ if (typeof value === 'number') return value
46
+ if (typeof value === 'string') {
47
+ const match = value.trim().match(/^(\d+)%?$/)
48
+ if (match) return parseInt(match[1], 10)
49
+ }
50
+ throw new Error(`Invalid ${fieldName}: expected number or '<N>%' string, got ${JSON.stringify(value)}`)
51
+ }
52
+
27
53
  /**
28
54
  * Ensure config file exists - SIMPLE logic
29
55
  * 1. If config.cjs exists → use it
@@ -31,6 +57,14 @@ const require = createRequire(import.meta.url)
31
57
  * 3. If nothing exists → create config.cjs
32
58
  */
33
59
  export function ensureConfig() {
60
+ // Ensure the full purgetss/ subfolder layout exists on every init — keeps
61
+ // fonts/, brand/, and images/ discoverable from day one instead of
62
+ // appearing lazily on first use of their respective commands.
63
+ makeSureFolderExists(projectsPurgeTSSFolder)
64
+ makeSureFolderExists(projectsPurge_TSS_Fonts_Folder)
65
+ makeSureFolderExists(projectsPurge_TSS_Brand_Folder)
66
+ makeSureFolderExists(projectsPurge_TSS_Images_Folder)
67
+
34
68
  // 1. ¿Existe config.cjs? → Úsalo
35
69
  if (fs.existsSync(projectsConfigJS)) {
36
70
  return
@@ -93,6 +127,18 @@ export function getConfigFile() {
93
127
  configFile.purge.options.safelist = configFile.purge.options.safelist ?? []
94
128
  configFile.purge.options.plugins = configFile.purge.options.plugins ?? []
95
129
 
130
+ configFile.brand = configFile.brand ?? {}
131
+ configFile.brand.bgColor = configFile.brand.bgColor ?? '#FFFFFF'
132
+ configFile.brand.padding = parsePadding(configFile.brand.padding ?? 15, 'brand.padding')
133
+ configFile.brand.iosPadding = parsePadding(configFile.brand.iosPadding ?? 4, 'brand.iosPadding')
134
+ configFile.brand.darkBgColor = configFile.brand.darkBgColor ?? null
135
+ configFile.brand.notification = configFile.brand.notification ?? false
136
+ configFile.brand.splash = configFile.brand.splash ?? false
137
+
138
+ configFile.images = configFile.images ?? {}
139
+ configFile.images.quality = configFile.images.quality ?? 85
140
+ configFile.images.format = configFile.images.format ?? null
141
+
96
142
  configFile.theme = configFile.theme ?? {}
97
143
  configFile.theme.extend = configFile.theme.extend ?? {}
98
144
 
@@ -0,0 +1,84 @@
1
+ /**
2
+ * PurgeTSS - config.cjs patcher
3
+ *
4
+ * Small helper for writing a single property into an existing top-level
5
+ * section (e.g. `brand: { ... }` or `images: { ... }`) of the user's
6
+ * purgetss/config.cjs. Used by the interactive "always" confirmation option
7
+ * to persist the user's preference.
8
+ *
9
+ * Deliberately narrow: only touches the target property, preserves the
10
+ * user's indentation style, and leaves every other line byte-identical.
11
+ * If the target section or key can't be located safely, it no-ops rather
12
+ * than risking a corrupted config — the caller falls back to the one-shot
13
+ * `--yes` / PURGETSS_YES behavior.
14
+ *
15
+ * @fileoverview Non-destructive single-property writer for config.cjs
16
+ * @author César Estrada
17
+ */
18
+
19
+ import fs from 'fs'
20
+ import { projectsConfigJS } from './constants.js'
21
+
22
+ /**
23
+ * Set `section.key = value` inside the user's purgetss/config.cjs, preserving
24
+ * the rest of the file. If the key already exists in that section, its value
25
+ * is replaced in place; otherwise a new line is appended before the section's
26
+ * closing brace.
27
+ *
28
+ * @param {string} section - Top-level section name (e.g. 'brand', 'images').
29
+ * @param {string} key - Property key to set inside the section.
30
+ * @param {*} value - JSON-serializable value (booleans, numbers, strings, null).
31
+ * @returns {boolean} True on success; false if config is missing or the
32
+ * section couldn't be located.
33
+ */
34
+ export function setConfigProperty(section, key, value) {
35
+ if (!fs.existsSync(projectsConfigJS)) return false
36
+
37
+ const original = fs.readFileSync(projectsConfigJS, 'utf8')
38
+
39
+ // Capture the entire section: `<indent>section: { <body> \n<closeIndent>}`.
40
+ // Non-greedy body match keeps us from swallowing sibling sections.
41
+ const sectionRegex = new RegExp(
42
+ `^(\\s*)${section}\\s*:\\s*\\{([\\s\\S]*?)\\n(\\s*)\\}`,
43
+ 'm'
44
+ )
45
+ const match = original.match(sectionRegex)
46
+ if (!match) return false
47
+
48
+ const [wholeMatch, sectionIndent, body, closeIndent] = match
49
+ const propIndent = closeIndent + ' '
50
+ const valueLiteral = JSON.stringify(value)
51
+
52
+ // If the key already exists inside the body, replace its value in place.
53
+ // Preserves any trailing comment on the same line.
54
+ const keyRegex = new RegExp(`(\\n\\s+${key}\\s*:\\s*)([^,\\n]+?)(\\s*(?:,|(?=\\n|$)))`)
55
+ if (keyRegex.test(body)) {
56
+ const newBody = body.replace(keyRegex, `$1${valueLiteral}$3`)
57
+ const replaced = `${sectionIndent}${section}: {${newBody}\n${closeIndent}}`
58
+ fs.writeFileSync(projectsConfigJS, original.replace(wholeMatch, replaced), 'utf8')
59
+ return true
60
+ }
61
+
62
+ // Key missing — append a new line before the closing brace. Ensure the
63
+ // previous property line has a trailing comma so the appended property
64
+ // parses. If that line ends in a // comment, the comma goes between the
65
+ // value and the comment (not after it).
66
+ const lines = body.replace(/\s+$/, '').split('\n')
67
+ const lastIdx = lines.length - 1
68
+ const lastLine = lines[lastIdx]
69
+ const commentMatch = lastLine.match(/^(.*?)(\s*\/\/.*)$/)
70
+ const valuePart = (commentMatch ? commentMatch[1] : lastLine).replace(/\s+$/, '')
71
+ const commentPart = commentMatch ? commentMatch[2] : ''
72
+ const needsComma =
73
+ valuePart &&
74
+ !valuePart.endsWith(',') &&
75
+ !valuePart.endsWith('{')
76
+ lines[lastIdx] = (needsComma ? valuePart + ',' : valuePart) + commentPart
77
+
78
+ let newBody = lines.join('\n')
79
+ newBody += `\n${propIndent}${key}: ${valueLiteral}`
80
+
81
+ const replaced = `${sectionIndent}${section}: {${newBody}\n${closeIndent}}`
82
+ fs.writeFileSync(projectsConfigJS, original.replace(wholeMatch, replaced), 'utf8')
83
+ return true
84
+ }
@@ -29,6 +29,7 @@ export const projectsAppTSS = `${cwd}/app/styles/app.tss`
29
29
  export const projects_AppTSS = `${cwd}/app/styles/_app.tss`
30
30
  export const projectsAlloyJMKFile = `${cwd}/app/alloy.jmk`
31
31
  export const projectsFontsFolder = `${cwd}/app/assets/fonts`
32
+ export const projectsSemanticColorsJSON = `${cwd}/app/assets/semantic.colors.json`
32
33
  export const projectsFontAwesomeJS = `${cwd}/app/lib/fontawesome.js`
33
34
 
34
35
  // ============================================================================
@@ -39,6 +40,8 @@ export const projectsPurgeTSSFolder = `${cwd}/purgetss`
39
40
  export const projectsConfigJS = `${cwd}/purgetss/config.cjs`
40
41
  export const projectsTailwind_TSS = `${cwd}/purgetss/styles/utilities.tss`
41
42
  export const projectsPurge_TSS_Fonts_Folder = `${cwd}/purgetss/fonts`
43
+ export const projectsPurge_TSS_Brand_Folder = `${cwd}/purgetss/brand`
44
+ export const projectsPurge_TSS_Images_Folder = `${cwd}/purgetss/images`
42
45
  export const projectsPurge_TSS_Styles_Folder = `${cwd}/purgetss/styles`
43
46
  export const projectsFA_TSS_File = `${cwd}/purgetss/styles/fontawesome.tss`
44
47
 
@@ -19,6 +19,27 @@ const purgeLabel = colores.purgeLabel
19
19
  // Debug mode flag (can be set externally)
20
20
  let purgingDebug = false
21
21
 
22
+ // Section mode: when active, the first info/warn/error/file call acts as a
23
+ // ::PurgeTSS:: header and every subsequent call prints indented 3 spaces
24
+ // without the prefix. Used by long-running flows (e.g. purge) that want a
25
+ // single signed line per run instead of one per step. Always wrap with
26
+ // try/finally in the caller to guarantee endSection() runs.
27
+ let _sectionMode = false
28
+ let _sectionHeaderEmitted = false
29
+
30
+ function _emit(text) {
31
+ if (_sectionMode) {
32
+ if (_sectionHeaderEmitted) {
33
+ console.log(' ' + text)
34
+ } else {
35
+ console.log(purgeLabel, text)
36
+ _sectionHeaderEmitted = true
37
+ }
38
+ } else {
39
+ console.log(purgeLabel, text)
40
+ }
41
+ }
42
+
22
43
  /**
23
44
  * Main logger object with different log levels
24
45
  * Maintains exact same API as original for compatibility
@@ -29,7 +50,7 @@ export const logger = {
29
50
  * @param {...any} args - Arguments to log
30
51
  */
31
52
  info: function(...args) {
32
- console.log(purgeLabel, args.join(' '))
53
+ _emit(args.join(' '))
33
54
  },
34
55
 
35
56
  /**
@@ -37,7 +58,7 @@ export const logger = {
37
58
  * @param {...any} args - Arguments to log
38
59
  */
39
60
  warn: function(...args) {
40
- console.log(purgeLabel, chalk.yellow(args.join(' ')))
61
+ _emit(chalk.yellow(args.join(' ')))
41
62
  },
42
63
 
43
64
  /**
@@ -45,7 +66,7 @@ export const logger = {
45
66
  * @param {...any} args - Arguments to log
46
67
  */
47
68
  error: function(...args) {
48
- console.log(purgeLabel, chalk.red(args.join(' ')))
69
+ _emit(chalk.red(args.join(' ')))
49
70
  },
50
71
 
51
72
  /**
@@ -53,7 +74,51 @@ export const logger = {
53
74
  * @param {...any} args - Arguments to log
54
75
  */
55
76
  file: function(...args) {
56
- console.log(purgeLabel, chalk.yellow(args.join(' ')), 'file created!')
77
+ _emit(chalk.yellow(args.join(' ')) + ' file created!')
78
+ },
79
+
80
+ /**
81
+ * Enable section mode. The next info/warn/error/file call becomes the
82
+ * ::PurgeTSS:: header; subsequent calls print indented without prefix.
83
+ * MUST be paired with endSection() via try/finally to avoid state leaks.
84
+ */
85
+ startSection: function() {
86
+ _sectionMode = true
87
+ _sectionHeaderEmitted = false
88
+ },
89
+
90
+ /**
91
+ * Exit section mode. Safe to call even if section wasn't started.
92
+ */
93
+ endSection: function() {
94
+ _sectionMode = false
95
+ _sectionHeaderEmitted = false
96
+ },
97
+
98
+ /**
99
+ * Log a multi-line block with a single ::PurgeTSS:: header.
100
+ * First arg is the header (printed next to purgeLabel); remaining args are
101
+ * continuation lines indented 3 spaces. Pass '' for a blank separator line.
102
+ *
103
+ * @param {string} header - Header line (printed after purgeLabel)
104
+ * @param {...string} lines - Continuation lines (indented, or '' for blank)
105
+ */
106
+ block: function(header, ...lines) {
107
+ console.log(purgeLabel, header)
108
+ for (const line of lines) {
109
+ console.log(line === '' ? '' : ' ' + line)
110
+ }
111
+ },
112
+
113
+ /**
114
+ * Log a sub-line without the ::PurgeTSS:: prefix, indented 3 spaces.
115
+ * Use inside sequential flows where a prior logger.info emitted the header.
116
+ * Prints synchronously, preserving timing with logger.info.
117
+ *
118
+ * @param {...any} args - Arguments to log
119
+ */
120
+ item: function(...args) {
121
+ console.log(' ' + args.join(' '))
57
122
  }
58
123
  }
59
124
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * PurgeTSS - Interactive prompt helpers
3
+ *
4
+ * Small wrapper around Node's readline for yes/no confirmations on destructive
5
+ * operations (in-place brand/images writes). Auto-skips when:
6
+ * - the caller sets `yes: true` (e.g. from --yes flag)
7
+ * - the PURGETSS_YES=1 environment variable is set
8
+ * - stdin is not a TTY (CI, hooks, piped input)
9
+ *
10
+ * Default answer is NO on empty input — requires explicit "y" / "yes" to proceed.
11
+ *
12
+ * @fileoverview TTY-aware confirmation prompts
13
+ * @author César Estrada
14
+ */
15
+
16
+ import readline from 'node:readline/promises'
17
+
18
+ /**
19
+ * Ask the user for a yes/no confirmation.
20
+ *
21
+ * @param {string} message - Prompt text shown to the user. A trailing space is
22
+ * added automatically, so pass something like "Continue? [y/N]".
23
+ * @param {Object} [opts]
24
+ * @param {boolean} [opts.yes=false] - Skip prompt and answer yes (e.g. --yes flag).
25
+ * @returns {Promise<boolean>} True when confirmed or skipped; false otherwise.
26
+ */
27
+ export async function confirm(message, { yes = false } = {}) {
28
+ if (yes) return true
29
+ if (process.env.PURGETSS_YES === '1') return true
30
+ if (!process.stdin.isTTY) return true
31
+
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
33
+ try {
34
+ const answer = await rl.question(`${message} `)
35
+ return /^y(es)?$/i.test(answer.trim())
36
+ } finally {
37
+ rl.close()
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Three-way confirmation: yes / no / always. Same skip rules as confirm()
43
+ * (--yes flag, PURGETSS_YES env, non-TTY all resolve to 'yes').
44
+ *
45
+ * @param {string} message - Prompt text (a trailing space is added).
46
+ * @param {Object} [opts]
47
+ * @param {boolean} [opts.yes=false] - Skip prompt, answer 'yes'.
48
+ * @returns {Promise<'yes'|'no'|'always'>} The user's choice.
49
+ */
50
+ export async function confirmWithAlways(message, { yes = false } = {}) {
51
+ if (yes) return 'yes'
52
+ if (process.env.PURGETSS_YES === '1') return 'yes'
53
+ if (!process.stdin.isTTY) return 'yes'
54
+
55
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
56
+ try {
57
+ const answer = (await rl.question(`${message} `)).trim().toLowerCase()
58
+ if (answer === 'a' || answer === 'always') return 'always'
59
+ if (answer === 'y' || answer === 'yes') return 'yes'
60
+ return 'no'
61
+ } finally {
62
+ rl.close()
63
+ }
64
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * PurgeTSS - Shared SVG/Sharp utilities
3
+ *
4
+ * Centralizes the logic for safely rasterizing SVGs with Sharp — specifically,
5
+ * handling SVGs that come out of vector editors (Affinity, Illustrator) with
6
+ * absurdly large viewBoxes that would otherwise trigger Sharp's pixel limit.
7
+ *
8
+ * Shared by:
9
+ * - src/core/branding/prepare-master.js (brand pipeline)
10
+ * - src/core/images/gen-scales.js (images pipeline)
11
+ *
12
+ * @fileoverview SVG rasterization helpers for the Sharp pipeline
13
+ * @author César Estrada
14
+ */
15
+
16
+ import fs from 'fs'
17
+ import path from 'path'
18
+ import sharp from 'sharp'
19
+
20
+ // SVGs with natural dimensions above this (in points) are almost always the
21
+ // result of a vector editor baking in transforms — e.g. Affinity exporting a
22
+ // "pliego" as 29559×13542 pt. Callers compensate with adaptive density; this
23
+ // threshold only controls the user-facing warning.
24
+ export const VIEWBOX_WARN_THRESHOLD = 4096
25
+
26
+ /**
27
+ * Compute a Sharp density (DPI) so that rasterization of an SVG lands at
28
+ * approximately `targetSupersampledMax` pixels on the longest side, regardless
29
+ * of the SVG's intrinsic viewBox. Clamped to Sharp's valid range (1..2400).
30
+ *
31
+ * @param {number} naturalMax - Largest SVG dimension in points (at 72 DPI).
32
+ * @param {number} targetSupersampledMax - Desired raster size on the longest side.
33
+ * @returns {number} A safe density value in [1, 2400].
34
+ */
35
+ export function computeSvgDensity(naturalMax, targetSupersampledMax) {
36
+ return Math.min(
37
+ 2400,
38
+ Math.max(1, Math.round((targetSupersampledMax / naturalMax) * 72))
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Read an SVG safely — buffer + metadata with the pixel-limit check disabled
44
+ * (Sharp enforces it even during header parsing for SVG inputs). Optionally
45
+ * emits a warning via the provided logger when the viewBox is disproportionate.
46
+ *
47
+ * The caller is responsible for computing a bounded density (via
48
+ * computeSvgDensity) before actually rendering — this keeps the real pixel
49
+ * output inside Sharp's limit even though the limit was disabled at read time.
50
+ *
51
+ * @param {string} svgPath - Absolute path to the SVG file.
52
+ * @param {Object} [opts]
53
+ * @param {Object} [opts.logger] - Logger exposing a .warning(msg) method. If
54
+ * omitted, no warning is emitted (silent mode, for unit tests).
55
+ * @param {boolean} [opts.withAdvice=false] - Include the "re-export" advice
56
+ * line. Enable for the brand pipeline (where the logo is central); keep
57
+ * false for the images pipeline (which may process many files).
58
+ * @returns {Promise<{buffer: Buffer, meta: Object, naturalMax: number}>}
59
+ */
60
+ export async function readSvgSafely(svgPath, { logger, withAdvice = false } = {}) {
61
+ const buffer = fs.readFileSync(svgPath)
62
+ const meta = await sharp(buffer, { limitInputPixels: false }).metadata()
63
+ const naturalMax = Math.max(meta.width, meta.height)
64
+
65
+ if (logger && naturalMax > VIEWBOX_WARN_THRESHOLD) {
66
+ logger.warning(
67
+ `⚠ ${path.basename(svgPath)} has a disproportionate viewBox (${meta.width}×${meta.height} pt).`
68
+ )
69
+ if (withAdvice) {
70
+ logger.warning(
71
+ ` Re-export from your vector editor with a canvas-sized viewBox if possible.`
72
+ )
73
+ }
74
+ logger.warning(
75
+ ` Adapting rasterization density to compensate.`
76
+ )
77
+ }
78
+
79
+ return { buffer, meta, naturalMax }
80
+ }
@@ -73,8 +73,10 @@ export function alloyProject(silent = false) {
73
73
 
74
74
  if (!fs.existsSync(`${cwd}/app/views`)) {
75
75
  if (!silent) {
76
- logger.info(`Please make sure you are running ${chalk.green('purgetss')} within an Alloy Project.`)
77
- logger.info(`For more information, visit ${chalk.green('https://purgetss.com')}`)
76
+ logger.block(
77
+ `Please make sure you are running ${chalk.green('purgetss')} within an Alloy Project.`,
78
+ `For more information, visit ${chalk.green('https://purgetss.com')}`
79
+ )
78
80
  }
79
81
  return false
80
82
  }
@@ -93,8 +95,10 @@ export function classicProject(silent = false) {
93
95
 
94
96
  if (!fs.existsSync(`${cwd}/Resources`)) {
95
97
  if (!silent) {
96
- logger.info(`Please make sure you are running ${chalk.green('purgetss')} within a Titanium's Classic Project.`)
97
- logger.info(`For more information, visit ${chalk.green('https://purgetss.com')}`)
98
+ logger.block(
99
+ `Please make sure you are running ${chalk.green('purgetss')} within a Titanium's Classic Project.`,
100
+ `For more information, visit ${chalk.green('https://purgetss.com')}`
101
+ )
98
102
  }
99
103
  return false
100
104
  }