purgetss 7.7.0 → 7.9.0

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.
@@ -73,6 +73,26 @@ export const IPHONE_SCALES = Object.freeze([
73
73
  { suffix: '@3x', factor: 3 / 4 }
74
74
  ])
75
75
 
76
+ // Resolve target dimensions for a single scale.
77
+ // `factor * 4` recovers the integer multiplier (1, 1.5, 2, 3, 4 for Android;
78
+ // 1, 2, 3 for iPhone) only because every entry in *_SCALES is normalized to
79
+ // n/4 with the largest scale (xxxhdpi/@4x) at 4/4. If a future density is
80
+ // added beyond xxxhdpi, this conversion factor needs to be revisited.
81
+ function computeScaleTarget(srcMeta, factor, baseWidth) {
82
+ if (baseWidth == null) {
83
+ return {
84
+ targetWidth: Math.max(1, Math.round(srcMeta.width * factor)),
85
+ targetHeight: Math.max(1, Math.round(srcMeta.height * factor))
86
+ }
87
+ }
88
+ const multiplier = factor * 4
89
+ const aspect = srcMeta.width > 0 ? (srcMeta.height / srcMeta.width) : 1
90
+ return {
91
+ targetWidth: Math.max(1, Math.round(baseWidth * multiplier)),
92
+ targetHeight: Math.max(1, Math.round(baseWidth * multiplier * aspect))
93
+ }
94
+ }
95
+
76
96
  /**
77
97
  * Scale a source image into all Android density variants.
78
98
  *
@@ -85,13 +105,12 @@ export const IPHONE_SCALES = Object.freeze([
85
105
  * @returns {Promise<string[]>} Paths written
86
106
  */
87
107
  export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts = {}) {
88
- const { format = null, quality = 85 } = opts
108
+ const { format = null, quality = 85, baseWidth = null } = opts
89
109
  const src = await readSource(sourceFile)
90
110
  const written = []
91
111
 
92
112
  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))
113
+ const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth)
95
114
 
96
115
  const outDir = path.join(androidBaseDir, name, path.dirname(relPath))
97
116
  fs.mkdirSync(outDir, { recursive: true })
@@ -113,7 +132,7 @@ export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts
113
132
  * @returns {Promise<string[]>} Paths written
114
133
  */
115
134
  export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts = {}) {
116
- const { format = null, quality = 85 } = opts
135
+ const { format = null, quality = 85, baseWidth = null } = opts
117
136
  const src = await readSource(sourceFile)
118
137
  const written = []
119
138
 
@@ -122,8 +141,7 @@ export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts =
122
141
  fs.mkdirSync(outDir, { recursive: true })
123
142
 
124
143
  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))
144
+ const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth)
127
145
 
128
146
  // SVG sources can't be written as SVG by Sharp — fall back to PNG if the
129
147
  // user didn't specify an explicit output format.
@@ -35,6 +35,7 @@ export async function runImages(opts) {
35
35
  iphoneOnly = false,
36
36
  format = null,
37
37
  quality = 85,
38
+ baseWidth = null,
38
39
  dryRun = false,
39
40
  yes = false,
40
41
  confirmOverwrites = true
@@ -49,6 +50,11 @@ export async function runImages(opts) {
49
50
 
50
51
  const files = collectImageFiles(source)
51
52
 
53
+ if (baseWidth == null && files.some(f => path.extname(f).toLowerCase() === '.svg')) {
54
+ logger.warning('⚠ SVG source detected without --width. Output sizes will be derived from each SVG\'s viewBox (treated as a 4× master).')
55
+ logger.warning(' For SVGs from vector editors with disproportionate viewBoxes, pass --width <n> (e.g. --width 256) to pin the @1x/mdpi width.')
56
+ }
57
+
52
58
  console.log()
53
59
  mainLogger.info('Generating multi-density image variants...')
54
60
  console.log()
@@ -60,6 +66,7 @@ export async function runImages(opts) {
60
66
  if (!androidOnly) platforms.push('iPhone (@1x, @2x, @3x)')
61
67
  logger.property('Platforms: ', platforms.join(' + '))
62
68
  if (format) logger.property('Format: ', `convert all to ${format}`)
69
+ if (baseWidth != null) logger.property('Width: ', `${baseWidth} px @1x/mdpi`)
63
70
  if (dryRun) logger.warning('DRY RUN — no files will be written')
64
71
 
65
72
  if (files.length === 0) {
@@ -115,11 +122,11 @@ export async function runImages(opts) {
115
122
  if (dryRun) continue
116
123
 
117
124
  if (!iphoneOnly) {
118
- const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, { format, quality })
125
+ const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, { format, quality, baseWidth })
119
126
  written.push(...androidFiles)
120
127
  }
121
128
  if (!androidOnly) {
122
- const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, { format, quality })
129
+ const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, { format, quality, baseWidth })
123
130
  written.push(...iphoneFiles)
124
131
  }
125
132
  }
@@ -14,6 +14,7 @@ import _ from 'lodash'
14
14
  import chalk from 'chalk'
15
15
  import * as helpers from '../../shared/helpers.js'
16
16
  import { logger } from '../../shared/logger.js'
17
+ import { deriveAlphaKey } from '../../shared/semantic-helpers.js'
17
18
  import {
18
19
  // eslint-disable-next-line camelcase
19
20
  projectsTailwind_TSS,
@@ -191,10 +192,20 @@ export function purgeTailwind(uniqueClasses, debug = false) {
191
192
  const opacityIndex = _.findIndex(tailwindClasses, line => line.startsWith(`'.${opacityValue.className}'`))
192
193
 
193
194
  const classProperties = tailwindClasses[opacityIndex]
194
- if (opacityIndex > -1 && classProperties.includes('#')) {
195
- // ! TODO: Check if color value is a hex value!! (if not, they are using rbg, rgba or semantic colors)
196
- // ! In other words, we need to validate the color value, before we can alter its opacity.
197
- const defaultHexValue = (classProperties.includes('from')) ? classProperties.match(/#[0-9a-f]{6}/g)[1] : classProperties.match(/#[0-9a-f]{6}/i)[0]
195
+ if (opacityIndex > -1 && classProperties && !classProperties.includes('#')) {
196
+ const derivedLine = tryDeriveSemanticOpacityLine(classProperties, opacityValue)
197
+ if (derivedLine) {
198
+ purgedClasses += switchPlatform(helpers.checkPlatformAndDevice(derivedLine, opacityValue.classNameWithTransparency))
199
+ } else {
200
+ console.warn('')
201
+ console.warn(chalk.yellow(` Skipping ".${opacityValue.className}/${opacityValue.decimalValue}" — semantic color, no hex to blend.`))
202
+ console.warn(chalk.yellow(` Use a PurgeTSS built-in color, bg-(#AARRGGBB), or "purgetss semantic --single ... --alpha ${opacityValue.decimalValue}".`))
203
+ console.warn('')
204
+ }
205
+ }
206
+ if (opacityIndex > -1 && classProperties && classProperties.includes('#')) {
207
+ const hexMatches = classProperties.match(/#[0-9a-f]{6}/gi)
208
+ const defaultHexValue = (classProperties.includes('from')) ? hexMatches[1] : hexMatches[0]
198
209
  let classWithoutDecimalOpacity = `${classProperties.replace(new RegExp(defaultHexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `#${opacityValue.transparency}${defaultHexValue.substring(1)}`)}`
199
210
  // Special case: #000000
200
211
  if (classProperties.includes('from') && defaultHexValue === '#000000') classWithoutDecimalOpacity = classWithoutDecimalOpacity.replace('00000000', '000000')
@@ -216,6 +227,31 @@ export function purgeTailwind(uniqueClasses, debug = false) {
216
227
  return purgedClasses
217
228
  }
218
229
 
230
+ // Auto-derive a semantic key with applied alpha and emit a TSS line for the
231
+ // `class/N` form. Returns the rewritten line (with selector renamed to include
232
+ // `/N` and the semantic value swapped for the derived key), or `null` when no
233
+ // candidate matches an entry in semantic.colors.json. Conflict errors from
234
+ // `deriveAlphaKey` propagate naturally.
235
+ function tryDeriveSemanticOpacityLine(classProperties, opacityValue) {
236
+ const bodyMatch = classProperties.match(/\{([^}]*)\}/)
237
+ if (!bodyMatch) return null
238
+ const candidates = (bodyMatch[1].match(/'([^']+)'/g) || [])
239
+ .map(m => m.slice(1, -1))
240
+ .filter(v => !v.startsWith('#'))
241
+ for (const candidate of candidates) {
242
+ const derivedKey = deriveAlphaKey(candidate, opacityValue.decimalValue)
243
+ if (derivedKey) {
244
+ let line = classProperties.replace(new RegExp(`'${candidate}'`, 'g'), `'${derivedKey}'`)
245
+ line = line.replace(
246
+ `'.${opacityValue.className}'`,
247
+ `'.${opacityValue.className}/${opacityValue.decimalValue}'`
248
+ )
249
+ return line
250
+ }
251
+ }
252
+ return null
253
+ }
254
+
219
255
  /**
220
256
  * Switch platform specific styles - COPIED exactly from original switchPlatform() function
221
257
  * NO CHANGES to logic, preserving 100% of original functionality
@@ -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,6 @@
1
1
  import fs from 'fs'
2
2
  import _ from 'lodash'
3
+ import { deriveAlphaKey } from '../semantic-helpers.js'
3
4
 
4
5
  // Internal variables and constants
5
6
  const _applyClasses = {}
@@ -530,15 +531,38 @@ export function compileApplyDirectives(twClasses) {
530
531
  const opacityIndex = findIndexOfClassName(`'.${opacityValue.classNameWithTransparency}`, twClassesArray)
531
532
 
532
533
  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)}`)}`
534
+ const targetLine = twClassesArray[opacityIndex]
535
+ const hexMatches = targetLine.match(/#[0-9a-f]{6}/gi)
536
+
537
+ if (!hexMatches || (targetLine.includes('from') && hexMatches.length < 2)) {
538
+ const derivedLine = tryDeriveSemanticOpacity(targetLine, opacityValue.decimalValue)
539
+ if (derivedLine) {
540
+ compoundClasses.push(justProperties(derivedLine))
541
+ return
542
+ }
543
+ throw new Error(
544
+ `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}".`
545
+ )
546
+ }
547
+
548
+ const defaultHexValue = targetLine.includes('from') ? hexMatches[1] : hexMatches[0]
549
+ const classWithoutDecimalOpacity = `${targetLine.replace(new RegExp(defaultHexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `#${opacityValue.transparency}${defaultHexValue.substring(1)}`)}`
535
550
 
536
551
  compoundClasses.push(justProperties(classWithoutDecimalOpacity))
537
552
  }
538
553
  })
539
554
  }
540
555
 
541
- twClassesArray[indexOfModifier] = _.replace(twClassesArray[indexOfModifier], /{_applyProperties_}/, fixDuplicateKeys(compoundClasses).join(', '))
556
+ let mergedProperties
557
+ try {
558
+ mergedProperties = fixDuplicateKeys(compoundClasses).join(', ')
559
+ } catch (mergeError) {
560
+ const classList = [...values].join(' ')
561
+ throw new Error(
562
+ `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}`
563
+ )
564
+ }
565
+ twClassesArray[indexOfModifier] = _.replace(twClassesArray[indexOfModifier], /{_applyProperties_}/, mergedProperties)
542
566
  twClassesArray[indexOfModifier] = deduplicateLineProperties(twClassesArray[indexOfModifier])
543
567
  }
544
568
  })
@@ -546,6 +570,26 @@ export function compileApplyDirectives(twClasses) {
546
570
  return twClassesArray.join('\n')
547
571
  }
548
572
 
573
+ // Attempt to derive an alpha-applied semantic key from a TSS line whose value
574
+ // is a semantic name (e.g. `'.bg-surface': { backgroundColor: 'surfaceColor' }`).
575
+ // Returns the rewritten line with the derived key in place of the base name,
576
+ // or `null` when no candidate matches an entry in semantic.colors.json.
577
+ // Conflict errors from `deriveAlphaKey` propagate naturally.
578
+ function tryDeriveSemanticOpacity(targetLine, alphaPercent) {
579
+ const bodyMatch = targetLine.match(/\{([^}]*)\}/)
580
+ if (!bodyMatch) return null
581
+ const candidates = (bodyMatch[1].match(/'([^']+)'/g) || [])
582
+ .map(m => m.slice(1, -1))
583
+ .filter(v => !v.startsWith('#'))
584
+ for (const candidate of candidates) {
585
+ const derivedKey = deriveAlphaKey(candidate, alphaPercent)
586
+ if (derivedKey) {
587
+ return targetLine.replace(new RegExp(`'${candidate}'`, 'g'), `'${derivedKey}'`)
588
+ }
589
+ }
590
+ return null
591
+ }
592
+
549
593
  /**
550
594
  * Remove duplicate property keys in a TSS line, keeping the last occurrence.
551
595
  * This ensures apply directives override static defaults (e.g. backgroundColor).
@@ -593,13 +637,20 @@ export function justProperties(_foundClass) {
593
637
 
594
638
  export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
595
639
  const sign = (arbitraryValue.startsWith('-')) ? '-' : ''
596
- const splitedContent = (arbitraryValue.startsWith('-')) ? arbitraryValue.substring(1).split('-') : arbitraryValue.split('-')
640
+ const stripped = sign ? arbitraryValue.substring(1) : arbitraryValue
597
641
 
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()
642
+ // Extract the value inside the last (...) so a negative value like top-(-10)
643
+ // is not mis-split by the hyphen inside the parentheses.
644
+ const parenMatch = stripped.match(/^(.+)-\(([^()]*)\)$/)
645
+ if (!parenMatch || parenMatch[2].trim() === '') {
646
+ return (fromXMLs) ? `// Property not yet supported: ${arbitraryValue}` : null
647
+ }
648
+
649
+ const ruleParts = parenMatch[1].split('-')
650
+ let value = parenMatch[2]
651
+
652
+ if (ruleParts.length === 1) {
653
+ let rule = ruleParts[0]
603
654
 
604
655
  if (rule === 'text') {
605
656
  rule = (value.includes('#') || value.includes('rgb')) ? 'text-color' : 'text-size'
@@ -630,13 +681,12 @@ export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
630
681
  ? `'.${arbitraryValue}': { ` + _.replace(properties, /{value}/g, parseValue(value, sign)) + ' }'
631
682
  : _.replace(properties, /{value}/g, parseValue(value, sign))
632
683
  }
633
- } else if (splitedContent.length === 3) {
634
- const rule = splitedContent.slice(0, 2).join('-')
635
- const value = splitedContent[2].match(/(?<=\().*(?=\))/).pop()
684
+ } else if (ruleParts.length === 2) {
685
+ const rule = ruleParts.join('-')
636
686
  let properties = arbitraryValuesTable[rule]
637
687
 
638
688
  if (properties) {
639
- if (splitedContent[0] === 'rounded') {
689
+ if (ruleParts[0] === 'rounded') {
640
690
  if (!value.includes(',')) {
641
691
  properties = _.replace(properties, /{value1}/g, parseValue(parseValue(value) / 2, sign))
642
692
  } else {
@@ -651,9 +701,8 @@ export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
651
701
  ? `'.${arbitraryValue}': { ` + _.replace(properties, /{value}/g, parseValue(value, sign)) + ' }'
652
702
  : _.replace(properties, /{value}/g, parseValue(value, sign))
653
703
  }
654
- } else if (splitedContent.length === 4) {
655
- const rule = splitedContent.slice(0, 3).join('-')
656
- const value = splitedContent[3].match(/(?<=\().*(?=\))/).pop()
704
+ } else if (ruleParts.length === 3) {
705
+ const rule = ruleParts.join('-')
657
706
  let properties = arbitraryValuesTable[rule]
658
707
 
659
708
  if (properties) {
@@ -665,9 +714,8 @@ export function formatArbitraryValues(arbitraryValue, fromXMLs = false) {
665
714
  ? `'.${arbitraryValue}': { ` + _.replace(properties, /{value}/g, parseValue(value, sign)) + ' }'
666
715
  : _.replace(properties, /{value}/g, parseValue(value, sign))
667
716
  }
668
- } else if (splitedContent.length === 5) {
669
- const rule = splitedContent.slice(0, 4).join('-')
670
- const value = splitedContent[4].match(/(?<=\().*(?=\))/).pop()
717
+ } else if (ruleParts.length === 4) {
718
+ const rule = ruleParts.join('-')
671
719
  const properties = arbitraryValuesTable[rule]
672
720
 
673
721
  if (properties) {
@@ -726,13 +774,22 @@ export function fixDuplicateKeys(compoundClasses) {
726
774
  const cleanedStyles = []
727
775
  const paddingObject = []
728
776
  const backgroundGradientObject = []
777
+ const backgroundGradientDirection = []
729
778
 
730
779
  _.each(compoundClasses, value => {
731
780
  if (compoundClasses.length > 1) {
732
781
  if (value.includes('font:')) {
733
782
  fontObject.push(value.replace('font: ', '').replace(/{(.*)}/, '$1').trim())
734
- } else if (value.includes('backgroundGradient: { colors')) {
735
- backgroundGradientObject.push(value.replace('backgroundGradient: ', '').replace(/{(.*)}/, '$1').trim())
783
+ } else if (value.includes('backgroundGradient:')) {
784
+ // Split into 2 buckets: colors (from-X/to-X) vs direction (bg-gradient-to-X with type/startPoint/endPoint).
785
+ // Both share the same property name `backgroundGradient` so they MUST be merged into a single object,
786
+ // otherwise the later one overwrites the earlier one in the emitted JS object literal.
787
+ const inner = value.replace('backgroundGradient: ', '').replace(/{(.*)}/, '$1').trim()
788
+ if (inner.startsWith('colors')) {
789
+ backgroundGradientObject.push(inner)
790
+ } else {
791
+ backgroundGradientDirection.push(inner)
792
+ }
736
793
  } else if (value.includes('padding:')) {
737
794
  paddingObject.push(value.replace('padding: ', '').replace(/{(.*)}/, '$1').trim())
738
795
  } else {
@@ -763,13 +820,28 @@ export function fixDuplicateKeys(compoundClasses) {
763
820
  cleanedStyles.push(`font: { ${fontObject.sort().join(', ')} }`)
764
821
  }
765
822
 
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()}' ] }`)
823
+ // Merge gradient direction (bg-gradient-to-X) and gradient colors (from-X/to-X)
824
+ // into a single backgroundGradient object. They share the same property name,
825
+ // so emitting them as separate entries causes the later one to overwrite the earlier.
826
+ if (backgroundGradientDirection.length || backgroundGradientObject.length) {
827
+ let colorsPart = ''
828
+ if (backgroundGradientObject.length === 1) {
829
+ colorsPart = backgroundGradientObject[0]
830
+ } else if (backgroundGradientObject.length === 2) {
831
+ // from-X emits 2 colors (placeholder + actual), to-X emits 1.
832
+ // After sort() above, indices may swap depending on color name ordering,
833
+ // so identify by array length instead of position.
834
+ const colorsA = backgroundGradientObject[0].replace('colors: ', '').replace(/[[\]']+/g, '').trim().split(',').map(c => c.trim())
835
+ const colorsB = backgroundGradientObject[1].replace('colors: ', '').replace(/[[\]']+/g, '').trim().split(',').map(c => c.trim())
836
+ const fromEntry = colorsA.length === 2 ? colorsA : colorsB
837
+ const toEntry = colorsA.length === 1 ? colorsA : colorsB
838
+ colorsPart = `colors: [ '${toEntry[0]}', '${fromEntry[1]}' ]`
839
+ }
840
+
841
+ const parts = []
842
+ if (backgroundGradientDirection.length) parts.push(backgroundGradientDirection[0])
843
+ if (colorsPart) parts.push(colorsPart)
844
+ cleanedStyles.push(`backgroundGradient: { ${parts.join(', ')} }`)
773
845
  }
774
846
 
775
847
  // Missing properties to process
@@ -0,0 +1,143 @@
1
+ /**
2
+ * PurgeTSS - Semantic Colors Helpers
3
+ *
4
+ * Auto-derivation of semantic color keys with applied alpha for opacity
5
+ * modifiers (e.g. `bg-surface/65`). Mutates `semantic.colors.json` idempotently
6
+ * by adding `<originalKey>_<alphaPercent>` entries with `{ color, alpha }` for
7
+ * `light` and `dark`.
8
+ *
9
+ * Lifecycle:
10
+ * - Names of semantic colors are tracked via `registerSemanticName()` while
11
+ * the utilities builder expands `theme.extend.colors`.
12
+ * - Hooks in `tailwind-purger` and `compileApplyDirectives` call
13
+ * `deriveAlphaKey()` before falling back to warn/throw on missing hex.
14
+ * - CLI commands flush the cached JSON to disk via `flushSemanticColors()`
15
+ * at the end of their run.
16
+ *
17
+ * @fileoverview Semantic color auto-derivation for opacity modifiers
18
+ * @since 7.9.0
19
+ */
20
+
21
+ import fs from 'fs'
22
+ import { getSemanticColorsPath } from '../cli/utils/project-detection.js'
23
+
24
+ const _semanticNames = new Set()
25
+ let _cache = null
26
+ let _dirty = false
27
+ let _lastMtime = 0
28
+ let _lastPath = null
29
+
30
+ /**
31
+ * Reset all in-memory state. Intended for tests; not part of the public API.
32
+ */
33
+ export function _resetSemanticHelpersState() {
34
+ _semanticNames.clear()
35
+ _cache = null
36
+ _dirty = false
37
+ _lastMtime = 0
38
+ _lastPath = null
39
+ }
40
+
41
+ /**
42
+ * Track a name that resolves to a semantic color (string, non-hex). Called by
43
+ * the utilities builder while expanding `theme.extend.colors`.
44
+ *
45
+ * @param {string} name
46
+ */
47
+ export function registerSemanticName(name) {
48
+ if (typeof name === 'string' && name.length > 0) _semanticNames.add(name)
49
+ }
50
+
51
+ /**
52
+ * @param {string} name
53
+ * @returns {boolean}
54
+ */
55
+ export function isSemanticColorName(name) {
56
+ return _semanticNames.has(name)
57
+ }
58
+
59
+ /**
60
+ * Load `semantic.colors.json` for the current project. Cached by mtime so
61
+ * repeated calls in the same build are cheap. The cache is invalidated when
62
+ * the file mtime changes on disk (relevant for watch-mode cycles where the
63
+ * JSON gets rewritten between runs).
64
+ *
65
+ * @returns {Object} parsed JSON, or `{}` when the file doesn't exist
66
+ */
67
+ export function loadSemanticColors() {
68
+ const p = getSemanticColorsPath()
69
+ if (!fs.existsSync(p)) {
70
+ _cache = {}
71
+ _lastMtime = 0
72
+ _lastPath = p
73
+ return _cache
74
+ }
75
+ const mtime = fs.statSync(p).mtimeMs
76
+ if (_cache && _lastPath === p && mtime === _lastMtime) return _cache
77
+ _cache = JSON.parse(fs.readFileSync(p, 'utf8'))
78
+ _lastMtime = mtime
79
+ _lastPath = p
80
+ return _cache
81
+ }
82
+
83
+ /**
84
+ * Derive a semantic key with the requested alpha applied. Idempotent: if the
85
+ * derived key already exists with matching values it's reused; if it exists
86
+ * with conflicting values an Error is thrown so manual edits aren't silently
87
+ * overwritten.
88
+ *
89
+ * @param {string} baseKey - The original semantic key (e.g. `surfaceColor`).
90
+ * @param {number|string} alphaPercent - Integer 0..100 (e.g. 65).
91
+ * @returns {string|null} The derived key, or `null` if `baseKey` isn't in the JSON.
92
+ */
93
+ export function deriveAlphaKey(baseKey, alphaPercent) {
94
+ const semantic = loadSemanticColors()
95
+ const base = semantic[baseKey]
96
+ if (!base) return null
97
+
98
+ const alphaStr = String(alphaPercent)
99
+ const newKey = `${baseKey}_${alphaStr}`
100
+
101
+ const lightHex = typeof base.light === 'string' ? base.light : base.light?.color
102
+ const darkHex = typeof base.dark === 'string' ? base.dark : base.dark?.color
103
+
104
+ if (semantic[newKey]) {
105
+ const existing = semantic[newKey]
106
+ const existingLightHex = typeof existing.light === 'string' ? existing.light : existing.light?.color
107
+ const existingDarkHex = typeof existing.dark === 'string' ? existing.dark : existing.dark?.color
108
+ const existingLightAlpha = typeof existing.light === 'string' ? null : existing.light?.alpha
109
+ const existingDarkAlpha = typeof existing.dark === 'string' ? null : existing.dark?.alpha
110
+
111
+ if (
112
+ existingLightHex !== lightHex ||
113
+ existingDarkHex !== darkHex ||
114
+ existingLightAlpha !== alphaStr ||
115
+ existingDarkAlpha !== alphaStr
116
+ ) {
117
+ throw new Error(
118
+ `Conflict: "${newKey}" already exists in semantic.colors.json with different values. Manual edit detected — resolve before continuing.`
119
+ )
120
+ }
121
+ return newKey
122
+ }
123
+
124
+ semantic[newKey] = {
125
+ light: { color: lightHex, alpha: alphaStr },
126
+ dark: { color: darkHex, alpha: alphaStr }
127
+ }
128
+ _dirty = true
129
+ return newKey
130
+ }
131
+
132
+ /**
133
+ * Persist any pending derivations back to `semantic.colors.json`. No-op when
134
+ * nothing changed. Writes a trailing newline for clean git diffs.
135
+ */
136
+ export function flushSemanticColors() {
137
+ if (!_dirty) return
138
+ const p = getSemanticColorsPath()
139
+ fs.writeFileSync(p, JSON.stringify(_cache, null, 2) + '\n')
140
+ _dirty = false
141
+ _lastMtime = fs.statSync(p).mtimeMs
142
+ _lastPath = p
143
+ }
@@ -1,26 +0,0 @@
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.
6
- *
7
- * Generates: ./dist/utilities.tss
8
- *
9
- * @since 7.1.0 (refactored from lib/)
10
- * @author César Estrada
11
- */
12
-
13
- import { autoBuildUtilitiesTSS } from '../../../experimental/completions2.js'
14
-
15
- /**
16
- * Main builder function
17
- * COPIED exactly from original constructor() function
18
- */
19
- export function buildTailwind() {
20
- autoBuildUtilitiesTSS()
21
- }
22
-
23
- // Execute if run directly
24
- if (import.meta.url === `file://${process.argv[1]}`) {
25
- buildTailwind()
26
- }