purgetss 7.7.1 → 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.
Files changed (42) hide show
  1. package/README.md +28 -0
  2. package/bin/purgetss +23 -0
  3. package/dist/purgetss.ui.js +1 -1
  4. package/lib/templates/create/index.xml +1 -1
  5. package/lib/templates/purgetss.config.js.cjs +3 -1
  6. package/package.json +2 -2
  7. package/src/cli/commands/build.js +9 -4
  8. package/src/cli/commands/images.js +49 -2
  9. package/src/cli/commands/purge.js +31 -4
  10. package/src/cli/commands/shades.js +2 -2
  11. package/src/cli/utils/cli-helpers.js +15 -5
  12. package/src/cli/utils/unsupported-class-reporter.js +209 -0
  13. package/src/core/analyzers/class-extractor.js +54 -0
  14. package/src/core/analyzers/controller-svg-refs.js +154 -0
  15. package/src/core/branding/brand-config.js +7 -0
  16. package/src/core/branding/ensure-brand-section.js +4 -3
  17. package/src/core/branding/gen-feature-graphic.js +57 -0
  18. package/src/core/branding/index.js +28 -4
  19. package/src/core/branding/post-gen-notes.js +2 -2
  20. package/{experimental/completions2.js → src/core/builders/auto-utilities-builder.js} +74 -40
  21. package/src/core/builders/tailwind-builder.js +2 -2
  22. package/src/core/builders/tailwind-helpers.js +0 -444
  23. package/src/core/images/ensure-images-section.js +6 -4
  24. package/src/core/images/gen-scales.js +96 -13
  25. package/src/core/images/index.js +121 -9
  26. package/src/core/purger/icon-purger.js +7 -3
  27. package/src/core/purger/tailwind-purger.js +43 -5
  28. package/src/core/svg/cache.js +96 -0
  29. package/src/core/svg/derive-dimensions.js +120 -0
  30. package/src/core/svg/index.js +215 -0
  31. package/src/core/svg/resolve-classes.js +46 -0
  32. package/src/core/svg/sync-images.js +278 -0
  33. package/src/core/svg/tss-reader.js +134 -0
  34. package/src/dev/builders/tailwind-builder.js +3 -11
  35. package/src/shared/config-manager.js +72 -3
  36. package/src/shared/error-reporter.js +117 -0
  37. package/src/shared/helpers/colors.js +57 -13
  38. package/src/shared/helpers/core.js +0 -19
  39. package/src/shared/helpers/utils.js +146 -36
  40. package/src/shared/logger.js +12 -0
  41. package/src/shared/semantic-helpers.js +143 -0
  42. package/src/shared/validation/config-validator.js +167 -0
@@ -1,26 +1,18 @@
1
1
  /**
2
- * PurgeTSS v7.1 - Tailwind Builder (Development)
3
- *
4
- * Builds Tailwind CSS files for development/distribution using auto-generation.
5
- * COPIED from lib/build-tailwind.js - NO CHANGES to logic.
2
+ * PurgeTSS - Utilities Builder (Development entry point)
6
3
  *
4
+ * Thin CLI wrapper invoked by the `build:tailwind` npm script.
7
5
  * Generates: ./dist/utilities.tss
8
6
  *
9
- * @since 7.1.0 (refactored from lib/)
10
7
  * @author César Estrada
11
8
  */
12
9
 
13
- import { autoBuildUtilitiesTSS } from '../../../experimental/completions2.js'
10
+ import { autoBuildUtilitiesTSS } from '../../core/builders/auto-utilities-builder.js'
14
11
 
15
- /**
16
- * Main builder function
17
- * COPIED exactly from original constructor() function
18
- */
19
12
  export function buildTailwind() {
20
13
  autoBuildUtilitiesTSS()
21
14
  }
22
15
 
23
- // Execute if run directly
24
16
  if (import.meta.url === `file://${process.argv[1]}`) {
25
17
  buildTailwind()
26
18
  }
@@ -23,6 +23,7 @@ import {
23
23
  } from './constants.js'
24
24
  import { logger } from './logger.js'
25
25
  import { makeSureFolderExists } from './utils.js'
26
+ import { validateConfig } from './validation/config-validator.js'
26
27
 
27
28
  // Create require for ESM compatibility
28
29
  const require = createRequire(import.meta.url)
@@ -105,6 +106,71 @@ export function migrateConfigIfNeeded() {
105
106
  }
106
107
  }
107
108
 
109
+ // Tracks configs already warned about in this process so the deprecation
110
+ // notice prints once per session even if getConfigFile() is called many times.
111
+ const _warnedLegacyBrand = new Set()
112
+
113
+ /**
114
+ * Migrate the flat pre-7cb5890 `brand:` schema (padding as number, iosPadding,
115
+ * bgColor, darkBgColor, top-level notification/splash) into the grouped
116
+ * schema downstream code expects. Mutates in place; if both legacy and new
117
+ * keys coexist, the new key wins. Emits ONE warning per config path per session.
118
+ */
119
+ function normalizeLegacyBrand(configFile, sourcePath) {
120
+ const brand = configFile.brand
121
+ if (!brand || typeof brand !== 'object') return
122
+
123
+ const hits = []
124
+
125
+ if (brand.padding != null && typeof brand.padding !== 'object') {
126
+ const value = brand.padding
127
+ brand.padding = { androidLegacy: value, androidAdaptive: value }
128
+ hits.push(`brand.padding: ${JSON.stringify(value)} → brand.padding.androidLegacy + brand.padding.androidAdaptive`)
129
+ }
130
+
131
+ if ('iosPadding' in brand) {
132
+ brand.padding = (brand.padding && typeof brand.padding === 'object') ? brand.padding : {}
133
+ brand.padding.ios = brand.padding.ios ?? brand.iosPadding
134
+ hits.push('brand.iosPadding → brand.padding.ios')
135
+ delete brand.iosPadding
136
+ }
137
+
138
+ if ('bgColor' in brand) {
139
+ brand.colors = brand.colors ?? {}
140
+ brand.colors.background = brand.colors.background ?? brand.bgColor
141
+ hits.push('brand.bgColor → brand.colors.background')
142
+ delete brand.bgColor
143
+ }
144
+
145
+ if ('darkBgColor' in brand) {
146
+ brand.ios = brand.ios ?? {}
147
+ brand.ios.darkBackground = brand.ios.darkBackground ?? brand.darkBgColor
148
+ hits.push('brand.darkBgColor → brand.ios.darkBackground')
149
+ delete brand.darkBgColor
150
+ }
151
+
152
+ if ('notification' in brand) {
153
+ brand.android = brand.android ?? {}
154
+ brand.android.notification = brand.android.notification ?? brand.notification
155
+ hits.push('brand.notification → brand.android.notification')
156
+ delete brand.notification
157
+ }
158
+
159
+ if ('splash' in brand) {
160
+ brand.android = brand.android ?? {}
161
+ brand.android.splash = brand.android.splash ?? brand.splash
162
+ hits.push('brand.splash → brand.android.splash')
163
+ delete brand.splash
164
+ }
165
+
166
+ if (hits.length > 0 && !_warnedLegacyBrand.has(sourcePath)) {
167
+ _warnedLegacyBrand.add(sourcePath)
168
+ logger.warn('Legacy brand: schema detected in purgetss/config.cjs — auto-migrated in memory:')
169
+ for (const hit of hits) logger.item(` • ${hit}`)
170
+ logger.item(' Update purgetss/config.cjs to the new grouped schema to silence this warning.')
171
+ }
172
+ }
173
+
108
174
  /**
109
175
  * Get configuration file with fallback to default template
110
176
  * Maintains exact same logic as original getConfigFile()
@@ -113,9 +179,11 @@ export function migrateConfigIfNeeded() {
113
179
  */
114
180
  export function getConfigFile() {
115
181
 
116
- const configFile = (fs.existsSync(projectsConfigJS))
117
- ? require(projectsConfigJS)
118
- : require(srcConfigFile)
182
+ const sourcePath = fs.existsSync(projectsConfigJS) ? projectsConfigJS : srcConfigFile
183
+ const configFile = require(sourcePath)
184
+
185
+ validateConfig(configFile, sourcePath)
186
+ normalizeLegacyBrand(configFile, sourcePath)
119
187
 
120
188
  // Apply default values following template structure
121
189
  configFile.purge = configFile.purge ?? {}
@@ -133,6 +201,7 @@ export function getConfigFile() {
133
201
  configFile.brand.padding.ios = parsePadding(configFile.brand.padding.ios ?? 4, 'brand.padding.ios')
134
202
  configFile.brand.padding.androidLegacy = parsePadding(configFile.brand.padding.androidLegacy ?? 10, 'brand.padding.androidLegacy')
135
203
  configFile.brand.padding.androidAdaptive = parsePadding(configFile.brand.padding.androidAdaptive ?? 19, 'brand.padding.androidAdaptive')
204
+ configFile.brand.padding.featureGraphic = parsePadding(configFile.brand.padding.featureGraphic ?? 12, 'brand.padding.featureGraphic')
136
205
  configFile.brand.android = configFile.brand.android ?? {}
137
206
  configFile.brand.android.notification = configFile.brand.android.notification ?? false
138
207
  configFile.brand.android.splash = configFile.brand.android.splash ?? false
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Centralized error reporter for syntax errors detected by PurgeTSS.
3
+ *
4
+ * Visual format matches the patterns already used by:
5
+ * - XML Syntax Error (src/cli/commands/purge.js, "compact" variant)
6
+ * - Class Syntax Error (src/cli/utils/unsupported-class-reporter.js)
7
+ * - XML Syntax Error with Context (src/cli/commands/purge.js, "context" variant)
8
+ *
9
+ * Those call sites are NOT migrated yet — they keep their own inline formatters
10
+ * to avoid any chance of visual regression. New validators (config-validator)
11
+ * use this module so the look is consistent going forward.
12
+ *
13
+ * Two output paths:
14
+ * - formatSyntaxError(opts) → { header, lines } suitable for logger.block(...)
15
+ * - throwSyntaxError(opts) → throws an Error whose .message is the full
16
+ * pre-rendered string (for call sites that
17
+ * propagate errors via catch handlers).
18
+ */
19
+
20
+ import chalk from 'chalk'
21
+ import path from 'path'
22
+ import { logger } from './logger.js'
23
+
24
+ /**
25
+ * Build the displayable parts of a syntax error.
26
+ *
27
+ * @param {Object} opts
28
+ * @param {string} opts.type - Short name shown in the header, e.g. 'Config', 'XML', 'Class'.
29
+ * @param {string} [opts.file] - File path (will be made relative to cwd).
30
+ * @param {string|number} [opts.path] - Dotted JSON path (optional, for config errors).
31
+ * @param {number} [opts.line] - Line number where the error occurred.
32
+ * @param {string} [opts.content] - One-line snippet of the offending line.
33
+ * @param {string[]} [opts.contextLines] - Full file lines (1-based; pass src.split('\n') OK with 0-based,
34
+ * but `line` must point to a 1-based number; we slice ±2).
35
+ * @param {string} opts.issue - Description of what is wrong.
36
+ * @param {string} opts.fix - Suggested correction.
37
+ * @returns {{ header: string, lines: string[] }}
38
+ */
39
+ export function formatSyntaxError(opts) {
40
+ const {
41
+ type,
42
+ file,
43
+ path: jsonPath,
44
+ line,
45
+ content,
46
+ contextLines,
47
+ issue,
48
+ fix
49
+ } = opts
50
+
51
+ const lines = []
52
+
53
+ if (file) {
54
+ const relative = path.relative(process.cwd(), file) || file
55
+ lines.push(`File: ${chalk.yellow(`"${relative}"`)}`)
56
+ }
57
+ if (jsonPath) {
58
+ lines.push(`Path: ${chalk.yellow(jsonPath)}`)
59
+ }
60
+ if (line != null) {
61
+ lines.push(`Line: ${chalk.yellow(line)}`)
62
+ }
63
+
64
+ // Context block: ± 2 lines around the offending line.
65
+ if (Array.isArray(contextLines) && line) {
66
+ const total = contextLines.length
67
+ const startLine = Math.max(1, line - 2)
68
+ const endLine = Math.min(total, line + 2)
69
+
70
+ lines.push('')
71
+ lines.push(chalk.gray('Context:'))
72
+ for (let i = startLine; i <= endLine; i++) {
73
+ const isTarget = i === line
74
+ const prefix = isTarget ? chalk.red('>>>') : chalk.gray(' ')
75
+ const text = contextLines[i - 1] || ''
76
+ lines.push(`${prefix} ${chalk.gray(String(i).padStart(3, ' '))}: ${text}`)
77
+ }
78
+ } else if (content) {
79
+ lines.push(`Content: ${chalk.yellow(`"${content}"`)}`)
80
+ }
81
+
82
+ lines.push('')
83
+ lines.push(chalk.red(`Issue: ${issue}`))
84
+ lines.push(`${chalk.green('Fix:')} ${fix}`)
85
+
86
+ return {
87
+ header: chalk.red(`${type} Syntax Error`),
88
+ lines
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Log the syntax error directly via logger.block.
94
+ * Use when you want the error rendered now and execution to continue
95
+ * (or stop via a sentinel afterward).
96
+ */
97
+ export function logSyntaxError(opts) {
98
+ const { header, lines } = formatSyntaxError(opts)
99
+ logger.block(header, ...lines)
100
+ }
101
+
102
+ /**
103
+ * Throw an Error whose .message is the fully rendered report.
104
+ * Use when the error must bubble up through a catch handler that prints
105
+ * `error.message` (e.g. the top-level CLI catch in bin/purgetss).
106
+ *
107
+ * The thrown Error includes `isSyntaxError: true` so callers can distinguish
108
+ * presentation-ready errors from generic runtime failures.
109
+ */
110
+ export function throwSyntaxError(opts) {
111
+ const { header, lines } = formatSyntaxError(opts)
112
+ const text = `\n::PurgeTSS:: ${header}\n` + lines.map(l => ' ' + l).join('\n') + '\n'
113
+ const err = new Error(text)
114
+ err.isSyntaxError = true
115
+ err.syntaxErrorType = opts.type
116
+ throw err
117
+ }
@@ -1,5 +1,5 @@
1
1
  import _ from 'lodash'
2
- import { processProperties, processComments, parseValue, setModifier2, removeLastDash, addTransparencyToValue } from './utils.js'
2
+ import { processProperties, processComments, parseValue, setModifier2, removeLastDash, addTransparencyToValue, defaultModifier, camelCaseToDash } from './utils.js'
3
3
  /**
4
4
  * Active tint color for tabs
5
5
  * @param {Object} modifiersAndValues - Modifier and value pairs
@@ -646,9 +646,20 @@ export function backgroundGradient(modifiersAndValues) {
646
646
  _.each(objectPosition, (properties, rule) => {
647
647
  _.each(modifiersAndValues, (value, modifier) => {
648
648
  if (typeof value === 'object') {
649
- _.each(value, (_value, _modifier) => {
650
- convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}`)}': ` + _.replace(_.replace(properties, /{transparentValue}/g, `${addTransparencyToValue(parseValue(_value))}`), /{value}/g, parseValue(_value)) + '\n'
651
- })
649
+ const emitLeaf = (leafValue, _modifier, suffix) => {
650
+ convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}${suffix}`)}': ` + _.replace(_.replace(properties, /{transparentValue}/g, `${addTransparencyToValue(parseValue(leafValue))}`), /{value}/g, parseValue(leafValue)) + '\n'
651
+ }
652
+ const walk = (val, _modifier, suffix) => {
653
+ if (val && typeof val === 'object') {
654
+ _.each(val, (childVal, childKey) => {
655
+ const newSuffix = defaultModifier(childKey) ? suffix : `${suffix}-${camelCaseToDash(String(childKey))}`
656
+ walk(childVal, _modifier, newSuffix)
657
+ })
658
+ } else {
659
+ emitLeaf(val, _modifier, suffix)
660
+ }
661
+ }
662
+ _.each(value, (_value, _modifier) => walk(_value, _modifier, ''))
652
663
  } else {
653
664
  convertedStyles += `'.${setModifier2(rule, modifier)}${setModifier2(modifier)}': ` + _.replace(_.replace(properties, /{value}/g, parseValue(value)), /{transparentValue}/g, `${addTransparencyToValue(parseValue(value))}`) + '\n'
654
665
  }
@@ -665,9 +676,20 @@ export function backgroundGradient(modifiersAndValues) {
665
676
  _.each(objectPosition, (properties, rule) => {
666
677
  _.each(modifiersAndValues, (value, modifier) => {
667
678
  if (typeof value === 'object') {
668
- _.each(value, (_value, _modifier) => {
669
- convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}`)}': ` + _.replace(properties, /{value}/g, parseValue(_value)) + '\n'
670
- })
679
+ const emitLeaf = (leafValue, _modifier, suffix) => {
680
+ convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}${suffix}`)}': ` + _.replace(properties, /{value}/g, parseValue(leafValue)) + '\n'
681
+ }
682
+ const walk = (val, _modifier, suffix) => {
683
+ if (val && typeof val === 'object') {
684
+ _.each(val, (childVal, childKey) => {
685
+ const newSuffix = defaultModifier(childKey) ? suffix : `${suffix}-${camelCaseToDash(String(childKey))}`
686
+ walk(childVal, _modifier, newSuffix)
687
+ })
688
+ } else {
689
+ emitLeaf(val, _modifier, suffix)
690
+ }
691
+ }
692
+ _.each(value, (_value, _modifier) => walk(_value, _modifier, ''))
671
693
  } else {
672
694
  convertedStyles += `'.${setModifier2(rule, modifier)}${setModifier2(modifier)}': ` + _.replace(properties, /{value}/g, parseValue(value)) + '\n'
673
695
  }
@@ -688,9 +710,20 @@ export function backgroundSelectedGradient(modifiersAndValues) {
688
710
  _.each(objectPosition, (properties, rule) => {
689
711
  _.each(modifiersAndValues, (value, modifier) => {
690
712
  if (typeof value === 'object') {
691
- _.each(value, (_value, _modifier) => {
692
- convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}`)}': ` + _.replace(_.replace(properties, /{transparentValue}/g, `${addTransparencyToValue(parseValue(_value))}`), /{value}/g, parseValue(_value)) + '\n'
693
- })
713
+ const emitLeaf = (leafValue, _modifier, suffix) => {
714
+ convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}${suffix}`)}': ` + _.replace(_.replace(properties, /{transparentValue}/g, `${addTransparencyToValue(parseValue(leafValue))}`), /{value}/g, parseValue(leafValue)) + '\n'
715
+ }
716
+ const walk = (val, _modifier, suffix) => {
717
+ if (val && typeof val === 'object') {
718
+ _.each(val, (childVal, childKey) => {
719
+ const newSuffix = defaultModifier(childKey) ? suffix : `${suffix}-${camelCaseToDash(String(childKey))}`
720
+ walk(childVal, _modifier, newSuffix)
721
+ })
722
+ } else {
723
+ emitLeaf(val, _modifier, suffix)
724
+ }
725
+ }
726
+ _.each(value, (_value, _modifier) => walk(_value, _modifier, ''))
694
727
  } else {
695
728
  convertedStyles += `'.${setModifier2(rule, modifier)}${setModifier2(modifier)}': ` + _.replace(_.replace(properties, /{value}/g, parseValue(value)), /{transparentValue}/g, `${addTransparencyToValue(parseValue(value))}`) + '\n'
696
729
  }
@@ -707,9 +740,20 @@ export function backgroundSelectedGradient(modifiersAndValues) {
707
740
  _.each(objectPosition, (properties, rule) => {
708
741
  _.each(modifiersAndValues, (value, modifier) => {
709
742
  if (typeof value === 'object') {
710
- _.each(value, (_value, _modifier) => {
711
- convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}`)}': ` + _.replace(properties, /{value}/g, parseValue(_value)) + '\n'
712
- })
743
+ const emitLeaf = (leafValue, _modifier, suffix) => {
744
+ convertedStyles += `'.${removeLastDash(`${rule}-${setModifier2(modifier, rule)}${setModifier2(_modifier)}${suffix}`)}': ` + _.replace(properties, /{value}/g, parseValue(leafValue)) + '\n'
745
+ }
746
+ const walk = (val, _modifier, suffix) => {
747
+ if (val && typeof val === 'object') {
748
+ _.each(val, (childVal, childKey) => {
749
+ const newSuffix = defaultModifier(childKey) ? suffix : `${suffix}-${camelCaseToDash(String(childKey))}`
750
+ walk(childVal, _modifier, newSuffix)
751
+ })
752
+ } else {
753
+ emitLeaf(val, _modifier, suffix)
754
+ }
755
+ }
756
+ _.each(value, (_value, _modifier) => walk(_value, _modifier, ''))
713
757
  } else {
714
758
  convertedStyles += `'.${setModifier2(rule, modifier)}${setModifier2(modifier)}': ` + _.replace(properties, /{value}/g, parseValue(value)) + '\n'
715
759
  }
@@ -1,21 +1,2 @@
1
- // Import customRules function for resetStyles
2
- import { customRules } from './utils.js'
3
-
4
1
  // Global configurations
5
2
  export const globalOptions = {}
6
-
7
- /**
8
- * Reset styles for common Titanium components
9
- * Applies default styles to ImageView, View, and Window
10
- * @returns {string} Generated reset styles
11
- */
12
- export function resetStyles() {
13
- let convertedStyles = '\n// Custom Styles and Resets\n'
14
-
15
- convertedStyles += customRules({ ios: { hires: true } }, 'ImageView')
16
- // convertedStyles += customRules({ default: { width: 'Ti.UI.FILL', height: 'Ti.UI.SIZE' } }, 'Label');
17
- convertedStyles += customRules({ default: { width: 'Ti.UI.SIZE', height: 'Ti.UI.SIZE' } }, 'View')
18
- convertedStyles += customRules({ default: { backgroundColor: '#ffffff' } }, 'Window')
19
-
20
- return convertedStyles
21
- }
@@ -1,5 +1,13 @@
1
1
  import fs from 'fs'
2
2
  import _ from 'lodash'
3
+ import { deriveAlphaKey } from '../semantic-helpers.js'
4
+ import {
5
+ projectsFA_TSS_File,
6
+ srcFontAwesomeTSSFile,
7
+ srcMaterialIconsTSSFile,
8
+ srcMaterialSymbolsTSSFile,
9
+ srcFramework7FontTSSFile
10
+ } from '../constants.js'
3
11
 
4
12
  // Internal variables and constants
5
13
  const _applyClasses = {}
@@ -29,14 +37,25 @@ export function processProperties(info, selectorAndDeclarationBlock, selectorsAn
29
37
  _.each(rulesAndValuesPair, (value, rule) => {
30
38
  if (debug) console.log('rule:', rule, 'value:', value)
31
39
  if (typeof value === 'object') {
32
- _.each(value, (_value, key) => {
33
- if (debug) console.log('key:', key, '_value:', _value)
34
- let processedProperties = _.replace(declarationBlock, /{value}/g, parseValue(_value, minusSigns))
40
+ const emitLeaf = (leafValue, key, suffix) => {
41
+ if (debug) console.log('key:', key, 'leafValue:', leafValue, 'suffix:', suffix)
42
+ let processedProperties = _.replace(declarationBlock, /{value}/g, parseValue(leafValue, minusSigns))
35
43
  if (declarationBlock.includes('double')) {
36
- processedProperties = _.replace(processedProperties, /{double}/g, parseValue(_value, minusSigns) * 2)
44
+ processedProperties = _.replace(processedProperties, /{double}/g, parseValue(leafValue, minusSigns) * 2)
37
45
  }
38
- convertedStyles += defaultModifier(key) ? `'.${setModifier2(mainSelector, rule)}${setModifier2(rule)}${setModifier2(selector)}': ${processedProperties}\n` : `'.${setModifier2(mainSelector, rule)}${setModifier2(rule, key)}${setModifier2(key)}${setModifier2(selector)}': ${processedProperties}\n`
39
- })
46
+ convertedStyles += defaultModifier(key) ? `'.${setModifier2(mainSelector, rule)}${setModifier2(rule)}${suffix}${setModifier2(selector)}': ${processedProperties}\n` : `'.${setModifier2(mainSelector, rule)}${setModifier2(rule, key)}${setModifier2(key)}${suffix}${setModifier2(selector)}': ${processedProperties}\n`
47
+ }
48
+ const walk = (val, key, suffix) => {
49
+ if (val && typeof val === 'object') {
50
+ _.each(val, (childVal, childKey) => {
51
+ const newSuffix = defaultModifier(childKey) ? suffix : `${suffix}-${camelCaseToDash(String(childKey))}`
52
+ walk(childVal, key, newSuffix)
53
+ })
54
+ } else {
55
+ emitLeaf(val, key, suffix)
56
+ }
57
+ }
58
+ _.each(value, (_value, key) => walk(_value, key, ''))
40
59
  } else {
41
60
  let processedProperties = _.replace(declarationBlock, /{value}/g, parseValue(value, minusSigns))
42
61
  if (declarationBlock.includes('double')) {
@@ -463,6 +482,18 @@ export function compileApplyDirectives(twClasses) {
463
482
  const twClassesArray = twClasses.split(/\r?\n/)
464
483
  const fontsClassesArray = (fs.existsSync(cwd + '/purgetss/styles/fonts.tss')) ? fs.readFileSync(cwd + '/purgetss/styles/fonts.tss', 'utf8').split(/\r?\n/) : null
465
484
 
485
+ // Default icon font sources (FontAwesome, Material Icons/Symbols, Framework7).
486
+ // Project-level fontawesome.tss (Pro/Beta) takes precedence over the bundled default,
487
+ // matching the precedence used by purgeFontAwesome().
488
+ const iconClassesArrays = [
489
+ fs.existsSync(projectsFA_TSS_File) ? projectsFA_TSS_File : srcFontAwesomeTSSFile,
490
+ srcMaterialIconsTSSFile,
491
+ srcMaterialSymbolsTSSFile,
492
+ srcFramework7FontTSSFile
493
+ ]
494
+ .filter(p => fs.existsSync(p))
495
+ .map(p => fs.readFileSync(p, 'utf8').split(/\r?\n/))
496
+
466
497
  _.each(_applyClasses, (values, className) => {
467
498
  const indexOfModifier = findIndexOfClassName(`'${className}':`, twClassesArray)
468
499
 
@@ -518,6 +549,14 @@ export function compileApplyDirectives(twClasses) {
518
549
  if (!foundClass && fontsClassesArray) {
519
550
  foundClass = fontsClassesArray[findIndexOfClassName(genericClassName, fontsClassesArray)]
520
551
  }
552
+ // Last resort: search default icon font sources (FontAwesome, Material Icons/Symbols, Framework7)
553
+ // so apply: directives can use fas, fa-*, mi-*, ms-*, f7-* without requiring fonts.tss
554
+ if (!foundClass) {
555
+ for (const arr of iconClassesArrays) {
556
+ const idx = findIndexOfClassName(genericClassName, arr)
557
+ if (idx !== -1) { foundClass = arr[idx]; break }
558
+ }
559
+ }
521
560
  }
522
561
 
523
562
  if (foundClass) compoundClasses.push(justProperties(foundClass))
@@ -530,15 +569,38 @@ export function compileApplyDirectives(twClasses) {
530
569
  const opacityIndex = findIndexOfClassName(`'.${opacityValue.classNameWithTransparency}`, twClassesArray)
531
570
 
532
571
  if (opacityIndex > -1) {
533
- const defaultHexValue = (twClassesArray[opacityIndex].includes('from')) ? twClassesArray[opacityIndex].match(/#[0-9a-f]{6}/g)[1] : twClassesArray[opacityIndex].match(/#[0-9a-f]{6}/i)[0]
534
- const classWithoutDecimalOpacity = `${twClassesArray[opacityIndex].replace(new RegExp(defaultHexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `#${opacityValue.transparency}${defaultHexValue.substring(1)}`)}`
572
+ const targetLine = twClassesArray[opacityIndex]
573
+ const hexMatches = targetLine.match(/#[0-9a-f]{6}/gi)
574
+
575
+ if (!hexMatches || (targetLine.includes('from') && hexMatches.length < 2)) {
576
+ const derivedLine = tryDeriveSemanticOpacity(targetLine, opacityValue.decimalValue)
577
+ if (derivedLine) {
578
+ compoundClasses.push(justProperties(derivedLine))
579
+ return
580
+ }
581
+ throw new Error(
582
+ `Opacity "/${opacityValue.decimalValue}" can't apply to semantic color ".${opacityValue.classNameWithTransparency}" (in apply of "${className}"). Use a PurgeTSS built-in color, an arbitrary value bg-(#AARRGGBB), or set alpha via "purgetss semantic --single ... --alpha ${opacityValue.decimalValue}".`
583
+ )
584
+ }
585
+
586
+ const defaultHexValue = targetLine.includes('from') ? hexMatches[1] : hexMatches[0]
587
+ const classWithoutDecimalOpacity = `${targetLine.replace(new RegExp(defaultHexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `#${opacityValue.transparency}${defaultHexValue.substring(1)}`)}`
535
588
 
536
589
  compoundClasses.push(justProperties(classWithoutDecimalOpacity))
537
590
  }
538
591
  })
539
592
  }
540
593
 
541
- twClassesArray[indexOfModifier] = _.replace(twClassesArray[indexOfModifier], /{_applyProperties_}/, fixDuplicateKeys(compoundClasses).join(', '))
594
+ let mergedProperties
595
+ try {
596
+ mergedProperties = fixDuplicateKeys(compoundClasses).join(', ')
597
+ } catch (mergeError) {
598
+ const classList = [...values].join(' ')
599
+ throw new Error(
600
+ `Failed to merge apply directive of "${className}".\n Classes: "${classList}".\n Hint: this usually means an unsupported combination — e.g. "bg-gradient-to-X" together with "from-X to-Y" in the same apply (gradient direction is dropped on merge), or two utilities that map to the same property in incompatible shapes. Try splitting the rule or removing one of the conflicting classes.\n Internal: ${mergeError.message}`
601
+ )
602
+ }
603
+ twClassesArray[indexOfModifier] = _.replace(twClassesArray[indexOfModifier], /{_applyProperties_}/, mergedProperties)
542
604
  twClassesArray[indexOfModifier] = deduplicateLineProperties(twClassesArray[indexOfModifier])
543
605
  }
544
606
  })
@@ -546,6 +608,26 @@ export function compileApplyDirectives(twClasses) {
546
608
  return twClassesArray.join('\n')
547
609
  }
548
610
 
611
+ // Attempt to derive an alpha-applied semantic key from a TSS line whose value
612
+ // is a semantic name (e.g. `'.bg-surface': { backgroundColor: 'surfaceColor' }`).
613
+ // Returns the rewritten line with the derived key in place of the base name,
614
+ // or `null` when no candidate matches an entry in semantic.colors.json.
615
+ // Conflict errors from `deriveAlphaKey` propagate naturally.
616
+ function tryDeriveSemanticOpacity(targetLine, alphaPercent) {
617
+ const bodyMatch = targetLine.match(/\{([^}]*)\}/)
618
+ if (!bodyMatch) return null
619
+ const candidates = (bodyMatch[1].match(/'([^']+)'/g) || [])
620
+ .map(m => m.slice(1, -1))
621
+ .filter(v => !v.startsWith('#'))
622
+ for (const candidate of candidates) {
623
+ const derivedKey = deriveAlphaKey(candidate, alphaPercent)
624
+ if (derivedKey) {
625
+ return targetLine.replace(new RegExp(`'${candidate}'`, 'g'), `'${derivedKey}'`)
626
+ }
627
+ }
628
+ return null
629
+ }
630
+
549
631
  /**
550
632
  * Remove duplicate property keys in a TSS line, keeping the last occurrence.
551
633
  * This ensures apply directives override static defaults (e.g. backgroundColor).
@@ -563,8 +645,8 @@ function deduplicateLineProperties(line) {
563
645
  let depth = 0
564
646
  let current = ''
565
647
  for (const char of propsStr) {
566
- if (char === '{') depth++
567
- else if (char === '}') depth--
648
+ if (char === '{' || char === '[') depth++
649
+ else if (char === '}' || char === ']') depth--
568
650
  else if (char === ',' && depth === 0) {
569
651
  if (current.trim()) props.push(current.trim())
570
652
  current = ''
@@ -593,13 +675,20 @@ export function justProperties(_foundClass) {
593
675
 
594
676
  export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
595
677
  const sign = (arbitraryValue.startsWith('-')) ? '-' : ''
596
- const splitedContent = (arbitraryValue.startsWith('-')) ? arbitraryValue.substring(1).split('-') : arbitraryValue.split('-')
678
+ const stripped = sign ? arbitraryValue.substring(1) : arbitraryValue
679
+
680
+ // Extract the value inside the last (...) so a negative value like top-(-10)
681
+ // is not mis-split by the hyphen inside the parentheses.
682
+ const parenMatch = stripped.match(/^(.+)-\(([^()]*)\)$/)
683
+ if (!parenMatch || parenMatch[2].trim() === '') {
684
+ return (fromXMLs) ? `// Property not yet supported: ${arbitraryValue}` : null
685
+ }
686
+
687
+ const ruleParts = parenMatch[1].split('-')
688
+ let value = parenMatch[2]
597
689
 
598
- if (splitedContent.length === 1) {
599
- return ''
600
- } else if (splitedContent.length === 2) {
601
- let rule = splitedContent.slice(0, 1).join('-')
602
- let value = splitedContent[1].match(/(?<=\().*(?=\))/).pop()
690
+ if (ruleParts.length === 1) {
691
+ let rule = ruleParts[0]
603
692
 
604
693
  if (rule === 'text') {
605
694
  rule = (value.includes('#') || value.includes('rgb')) ? 'text-color' : 'text-size'
@@ -630,13 +719,12 @@ export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
630
719
  ? `'.${arbitraryValue}': { ` + _.replace(properties, /{value}/g, parseValue(value, sign)) + ' }'
631
720
  : _.replace(properties, /{value}/g, parseValue(value, sign))
632
721
  }
633
- } else if (splitedContent.length === 3) {
634
- const rule = splitedContent.slice(0, 2).join('-')
635
- const value = splitedContent[2].match(/(?<=\().*(?=\))/).pop()
722
+ } else if (ruleParts.length === 2) {
723
+ const rule = ruleParts.join('-')
636
724
  let properties = arbitraryValuesTable[rule]
637
725
 
638
726
  if (properties) {
639
- if (splitedContent[0] === 'rounded') {
727
+ if (ruleParts[0] === 'rounded') {
640
728
  if (!value.includes(',')) {
641
729
  properties = _.replace(properties, /{value1}/g, parseValue(parseValue(value) / 2, sign))
642
730
  } else {
@@ -651,9 +739,8 @@ export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
651
739
  ? `'.${arbitraryValue}': { ` + _.replace(properties, /{value}/g, parseValue(value, sign)) + ' }'
652
740
  : _.replace(properties, /{value}/g, parseValue(value, sign))
653
741
  }
654
- } else if (splitedContent.length === 4) {
655
- const rule = splitedContent.slice(0, 3).join('-')
656
- const value = splitedContent[3].match(/(?<=\().*(?=\))/).pop()
742
+ } else if (ruleParts.length === 3) {
743
+ const rule = ruleParts.join('-')
657
744
  let properties = arbitraryValuesTable[rule]
658
745
 
659
746
  if (properties) {
@@ -665,9 +752,8 @@ export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
665
752
  ? `'.${arbitraryValue}': { ` + _.replace(properties, /{value}/g, parseValue(value, sign)) + ' }'
666
753
  : _.replace(properties, /{value}/g, parseValue(value, sign))
667
754
  }
668
- } else if (splitedContent.length === 5) {
669
- const rule = splitedContent.slice(0, 4).join('-')
670
- const value = splitedContent[4].match(/(?<=\().*(?=\))/).pop()
755
+ } else if (ruleParts.length === 4) {
756
+ const rule = ruleParts.join('-')
671
757
  const properties = arbitraryValuesTable[rule]
672
758
 
673
759
  if (properties) {
@@ -726,13 +812,22 @@ export function fixDuplicateKeys(compoundClasses) {
726
812
  const cleanedStyles = []
727
813
  const paddingObject = []
728
814
  const backgroundGradientObject = []
815
+ const backgroundGradientDirection = []
729
816
 
730
817
  _.each(compoundClasses, value => {
731
818
  if (compoundClasses.length > 1) {
732
819
  if (value.includes('font:')) {
733
820
  fontObject.push(value.replace('font: ', '').replace(/{(.*)}/, '$1').trim())
734
- } else if (value.includes('backgroundGradient: { colors')) {
735
- backgroundGradientObject.push(value.replace('backgroundGradient: ', '').replace(/{(.*)}/, '$1').trim())
821
+ } else if (value.includes('backgroundGradient:')) {
822
+ // Split into 2 buckets: colors (from-X/to-X) vs direction (bg-gradient-to-X with type/startPoint/endPoint).
823
+ // Both share the same property name `backgroundGradient` so they MUST be merged into a single object,
824
+ // otherwise the later one overwrites the earlier one in the emitted JS object literal.
825
+ const inner = value.replace('backgroundGradient: ', '').replace(/{(.*)}/, '$1').trim()
826
+ if (inner.startsWith('colors')) {
827
+ backgroundGradientObject.push(inner)
828
+ } else {
829
+ backgroundGradientDirection.push(inner)
830
+ }
736
831
  } else if (value.includes('padding:')) {
737
832
  paddingObject.push(value.replace('padding: ', '').replace(/{(.*)}/, '$1').trim())
738
833
  } else {
@@ -763,13 +858,28 @@ export function fixDuplicateKeys(compoundClasses) {
763
858
  cleanedStyles.push(`font: { ${fontObject.sort().join(', ')} }`)
764
859
  }
765
860
 
766
- if (backgroundGradientObject.length === 1) {
767
- cleanedStyles.push(`backgroundGradient: { ${backgroundGradientObject} }`)
768
- } else if (backgroundGradientObject.length === 2) {
769
- const toColor = backgroundGradientObject[1].replace('colors: ', '').replace(/[[\]']+/g, '').trim().split(',')
770
- const fromToColors = backgroundGradientObject[0].replace('colors: ', '').replace(/[[\]']+/g, '').trim().split(',')
771
- fromToColors[0] = toColor[0]
772
- cleanedStyles.push(`backgroundGradient: { colors: [ '${fromToColors[0]}', '${fromToColors[1].trim()}' ] }`)
861
+ // Merge gradient direction (bg-gradient-to-X) and gradient colors (from-X/to-X)
862
+ // into a single backgroundGradient object. They share the same property name,
863
+ // so emitting them as separate entries causes the later one to overwrite the earlier.
864
+ if (backgroundGradientDirection.length || backgroundGradientObject.length) {
865
+ let colorsPart = ''
866
+ if (backgroundGradientObject.length === 1) {
867
+ colorsPart = backgroundGradientObject[0]
868
+ } else if (backgroundGradientObject.length === 2) {
869
+ // from-X emits 2 colors (placeholder + actual), to-X emits 1.
870
+ // After sort() above, indices may swap depending on color name ordering,
871
+ // so identify by array length instead of position.
872
+ const colorsA = backgroundGradientObject[0].replace('colors: ', '').replace(/[[\]']+/g, '').trim().split(',').map(c => c.trim())
873
+ const colorsB = backgroundGradientObject[1].replace('colors: ', '').replace(/[[\]']+/g, '').trim().split(',').map(c => c.trim())
874
+ const fromEntry = colorsA.length === 2 ? colorsA : colorsB
875
+ const toEntry = colorsA.length === 1 ? colorsA : colorsB
876
+ colorsPart = `colors: [ '${toEntry[0]}', '${fromEntry[1]}' ]`
877
+ }
878
+
879
+ const parts = []
880
+ if (backgroundGradientDirection.length) parts.push(backgroundGradientDirection[0])
881
+ if (colorsPart) parts.push(colorsPart)
882
+ cleanedStyles.push(`backgroundGradient: { ${parts.join(', ')} }`)
773
883
  }
774
884
 
775
885
  // Missing properties to process