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.
- package/README.md +28 -0
- package/bin/purgetss +23 -0
- package/dist/purgetss.ui.js +1 -1
- package/lib/templates/create/index.xml +1 -1
- package/lib/templates/purgetss.config.js.cjs +3 -1
- package/package.json +2 -2
- package/src/cli/commands/build.js +9 -4
- package/src/cli/commands/images.js +49 -2
- package/src/cli/commands/purge.js +31 -4
- package/src/cli/commands/shades.js +2 -2
- package/src/cli/utils/cli-helpers.js +15 -5
- package/src/cli/utils/unsupported-class-reporter.js +209 -0
- package/src/core/analyzers/class-extractor.js +54 -0
- package/src/core/analyzers/controller-svg-refs.js +154 -0
- package/src/core/branding/brand-config.js +7 -0
- package/src/core/branding/ensure-brand-section.js +4 -3
- package/src/core/branding/gen-feature-graphic.js +57 -0
- package/src/core/branding/index.js +28 -4
- package/src/core/branding/post-gen-notes.js +2 -2
- package/{experimental/completions2.js → src/core/builders/auto-utilities-builder.js} +74 -40
- package/src/core/builders/tailwind-builder.js +2 -2
- package/src/core/builders/tailwind-helpers.js +0 -444
- package/src/core/images/ensure-images-section.js +6 -4
- package/src/core/images/gen-scales.js +96 -13
- package/src/core/images/index.js +121 -9
- package/src/core/purger/icon-purger.js +7 -3
- package/src/core/purger/tailwind-purger.js +43 -5
- package/src/core/svg/cache.js +96 -0
- package/src/core/svg/derive-dimensions.js +120 -0
- package/src/core/svg/index.js +215 -0
- package/src/core/svg/resolve-classes.js +46 -0
- package/src/core/svg/sync-images.js +278 -0
- package/src/core/svg/tss-reader.js +134 -0
- package/src/dev/builders/tailwind-builder.js +3 -11
- package/src/shared/config-manager.js +72 -3
- package/src/shared/error-reporter.js +117 -0
- package/src/shared/helpers/colors.js +57 -13
- package/src/shared/helpers/core.js +0 -19
- package/src/shared/helpers/utils.js +146 -36
- package/src/shared/logger.js +12 -0
- package/src/shared/semantic-helpers.js +143 -0
- package/src/shared/validation/config-validator.js +167 -0
package/src/core/images/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'fs'
|
|
19
19
|
import path from 'path'
|
|
20
|
+
import sharp from 'sharp'
|
|
20
21
|
import { logger } from '../branding/branding-logger.js'
|
|
21
22
|
import { logger as mainLogger } from '../../shared/logger.js'
|
|
22
23
|
import { confirmWithAlways } from '../../shared/prompt.js'
|
|
@@ -35,20 +36,49 @@ export async function runImages(opts) {
|
|
|
35
36
|
iphoneOnly = false,
|
|
36
37
|
format = null,
|
|
37
38
|
quality = 85,
|
|
39
|
+
baseWidth = null,
|
|
40
|
+
opacity = null, // 0-100 or null
|
|
41
|
+
padding = null, // 0-40 (per side, %) or null
|
|
42
|
+
outputRelpath = null, // basename + subfolder relative to images root, no extension
|
|
38
43
|
dryRun = false,
|
|
39
44
|
yes = false,
|
|
40
|
-
confirmOverwrites = true
|
|
45
|
+
confirmOverwrites = true,
|
|
46
|
+
filesOverrides = [] // [{ filename: 'images/<relpath>', width, height? }, …]
|
|
41
47
|
} = opts
|
|
42
48
|
|
|
43
49
|
if (!fs.existsSync(source)) {
|
|
44
50
|
throw new Error(`Source not found: ${source}`)
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
if (outputRelpath != null && fs.statSync(source).isDirectory()) {
|
|
54
|
+
throw new Error('--output is incompatible with directory sources (one basename cannot apply to multiple files). Pass a single file as the source, or drop --output.')
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
const projectType = detectProjectType(projectRoot)
|
|
48
58
|
const { androidBaseDir, iphoneBaseDir } = resolveOutputDirs(projectRoot, projectType)
|
|
49
59
|
|
|
50
60
|
const files = collectImageFiles(source)
|
|
51
61
|
|
|
62
|
+
// Build a lookup keyed by `images/<subpath>` so per-file `width`/`height`
|
|
63
|
+
// declared in `config.cjs > images.files` can override the directory scan's
|
|
64
|
+
// default sizing. CLI `--width` still wins over both.
|
|
65
|
+
const overrides = buildOverridesMap(filesOverrides)
|
|
66
|
+
const imagesFolderForKey = projectRoot === process.cwd()
|
|
67
|
+
? projectsPurge_TSS_Images_Folder
|
|
68
|
+
: path.join(projectRoot, 'purgetss', 'images')
|
|
69
|
+
|
|
70
|
+
if (baseWidth == null) {
|
|
71
|
+
const uncoveredSvgs = files.filter(f => {
|
|
72
|
+
if (path.extname(f).toLowerCase() !== '.svg') return false
|
|
73
|
+
const key = overrideKeyFor(f, imagesFolderForKey)
|
|
74
|
+
return !overrides.has(key)
|
|
75
|
+
})
|
|
76
|
+
if (uncoveredSvgs.length > 0) {
|
|
77
|
+
logger.warning('⚠ SVG source detected without --width and no entry in config.cjs > images.files. Output sizes will be derived from each SVG\'s viewBox (treated as a 4× master).')
|
|
78
|
+
logger.warning(' For SVGs from vector editors with disproportionate viewBoxes, pass --width <n> (e.g. --width 256) or add an entry to images.files to pin the @1x/mdpi width.')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
console.log()
|
|
53
83
|
mainLogger.info('Generating multi-density image variants...')
|
|
54
84
|
console.log()
|
|
@@ -60,6 +90,10 @@ export async function runImages(opts) {
|
|
|
60
90
|
if (!androidOnly) platforms.push('iPhone (@1x, @2x, @3x)')
|
|
61
91
|
logger.property('Platforms: ', platforms.join(' + '))
|
|
62
92
|
if (format) logger.property('Format: ', `convert all to ${format}`)
|
|
93
|
+
if (baseWidth != null) logger.property('Width: ', `${baseWidth} px @1x/mdpi`)
|
|
94
|
+
if (opacity != null) logger.property('Opacity: ', `${opacity}%`)
|
|
95
|
+
if (padding != null) logger.property('Padding: ', `${padding}% per side`)
|
|
96
|
+
if (outputRelpath != null) logger.property('Output: ', `images/${outputRelpath}.<ext>`)
|
|
63
97
|
if (dryRun) logger.warning('DRY RUN — no files will be written')
|
|
64
98
|
|
|
65
99
|
if (files.length === 0) {
|
|
@@ -67,9 +101,9 @@ export async function runImages(opts) {
|
|
|
67
101
|
return { written: [] }
|
|
68
102
|
}
|
|
69
103
|
|
|
70
|
-
if (!dryRun && confirmOverwrites) {
|
|
104
|
+
if (!dryRun && confirmOverwrites && !yes) {
|
|
71
105
|
logger.warning(`⚠ Scaled images will OVERWRITE existing variants under ${androidBaseDir} and ${iphoneBaseDir}.`)
|
|
72
|
-
logger.warning(
|
|
106
|
+
logger.warning(' Commit first if you want a rollback.')
|
|
73
107
|
const choice = await confirmWithAlways('Continue? [y/N/a]', { yes })
|
|
74
108
|
if (choice === 'no') {
|
|
75
109
|
logger.info('Aborted.')
|
|
@@ -87,8 +121,8 @@ export async function runImages(opts) {
|
|
|
87
121
|
}
|
|
88
122
|
|
|
89
123
|
if (projectType === 'unknown') {
|
|
90
|
-
logger.warning(
|
|
91
|
-
logger.warning(
|
|
124
|
+
logger.warning('Could not detect project layout. Expected \'app/\' (Alloy) or \'Resources/\' (Classic).')
|
|
125
|
+
logger.warning('Assets will still be written to the detected default paths — verify the output.')
|
|
92
126
|
}
|
|
93
127
|
|
|
94
128
|
// Relative paths preserve the user's subdirectory structure inside purgetss/images/.
|
|
@@ -109,17 +143,70 @@ export async function runImages(opts) {
|
|
|
109
143
|
|
|
110
144
|
logger.section('Scaling')
|
|
111
145
|
for (const file of files) {
|
|
112
|
-
|
|
113
|
-
|
|
146
|
+
// When --output is set, override the computed relPath with the user's
|
|
147
|
+
// basename + subfolder. Append the source extension so downstream
|
|
148
|
+
// path.parse / renameWithFormat behave the same as for natural sources.
|
|
149
|
+
const relPath = outputRelpath != null
|
|
150
|
+
? outputRelpath + path.extname(file)
|
|
151
|
+
: path.relative(sourceRoot, file)
|
|
152
|
+
|
|
153
|
+
// Per-file resolution: CLI --width wins; if absent, fall back to the
|
|
154
|
+
// entry in `images.files` (if any); else null (gen-scales reads viewBox).
|
|
155
|
+
const override = overrides.get(overrideKeyFor(file, imagesFolderForKey))
|
|
156
|
+
const effectiveBaseWidth = baseWidth ?? override?.width ?? null
|
|
157
|
+
const effectiveBaseHeight = baseWidth != null ? null : (override?.height ?? null)
|
|
158
|
+
|
|
159
|
+
// SVGs listed in `images.files` are almost always referenced from views/
|
|
160
|
+
// controllers as `image="/.../foo.svg"`, and Titanium's runtime only falls
|
|
161
|
+
// back from a `.svg` reference to `.png` (verified empirically — not to
|
|
162
|
+
// .webp, .jpeg, etc.). Forcing PNG here prevents the standalone command
|
|
163
|
+
// from quietly emitting a format Titanium can't load via that fallback.
|
|
164
|
+
// Raster files in `files` and SVGs NOT in `files` still honor `format`.
|
|
165
|
+
const ext = path.extname(file).toLowerCase()
|
|
166
|
+
const isSvg = ext === '.svg'
|
|
167
|
+
const isSvgInFiles = override != null && isSvg
|
|
168
|
+
const effectiveFormat = isSvgInFiles ? null : format
|
|
169
|
+
|
|
170
|
+
// Build an informative bullet so the user can see which decisions applied
|
|
171
|
+
// per file: source of width, where it came from, and the actual output
|
|
172
|
+
// format (especially when PNG is forced for SVGs in `files`).
|
|
173
|
+
const widthSource = baseWidth != null
|
|
174
|
+
? `${baseWidth}dp (CLI --width)`
|
|
175
|
+
: override
|
|
176
|
+
? `${override.width}dp (files)`
|
|
177
|
+
: isSvg ? 'viewBox' : 'source 4×'
|
|
178
|
+
const outFormat = effectiveFormat ?? (isSvg ? 'png' : ext.slice(1))
|
|
179
|
+
const formatTag = isSvgInFiles && format && format !== 'png'
|
|
180
|
+
? `${outFormat} (forced; ignores format: ${format})`
|
|
181
|
+
: outFormat
|
|
182
|
+
logger.bullet(`${relPath} → ${widthSource} · ${formatTag}`)
|
|
114
183
|
|
|
115
184
|
if (dryRun) continue
|
|
116
185
|
|
|
186
|
+
// Quality warning: if the user pinned a width (via CLI or `files`), the
|
|
187
|
+
// source must carry at least `width × 4` pixels — that's what xxxhdpi/@4x
|
|
188
|
+
// needs. Anything smaller forces Sharp to upscale, producing blurry output.
|
|
189
|
+
// SVG sources are vector and exempt from this check.
|
|
190
|
+
if (effectiveBaseWidth != null && !isSvg) {
|
|
191
|
+
const meta = await sharp(file).metadata()
|
|
192
|
+
const requiredXxxhdpi = effectiveBaseWidth * 4
|
|
193
|
+
if (Number.isFinite(meta.width) && meta.width < requiredXxxhdpi) {
|
|
194
|
+
logger.warning(
|
|
195
|
+
`⚠ ${relPath}: source is ${meta.width}px wide but xxxhdpi needs ${requiredXxxhdpi}px (4× of declared ${effectiveBaseWidth}dp @1x). Output will be upscaled and may look blurry — provide a source ≥ ${requiredXxxhdpi}px.`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
117
200
|
if (!iphoneOnly) {
|
|
118
|
-
const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, {
|
|
201
|
+
const androidFiles = await genAndroidScales(file, relPath, androidBaseDir, {
|
|
202
|
+
format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
|
|
203
|
+
})
|
|
119
204
|
written.push(...androidFiles)
|
|
120
205
|
}
|
|
121
206
|
if (!androidOnly) {
|
|
122
|
-
const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, {
|
|
207
|
+
const iphoneFiles = await genIphoneScales(file, relPath, iphoneBaseDir, {
|
|
208
|
+
format: effectiveFormat, quality, baseWidth: effectiveBaseWidth, baseHeight: effectiveBaseHeight, opacity, padding
|
|
209
|
+
})
|
|
123
210
|
written.push(...iphoneFiles)
|
|
124
211
|
}
|
|
125
212
|
}
|
|
@@ -148,6 +235,31 @@ function resolveOutputDirs(projectRoot, projectType) {
|
|
|
148
235
|
}
|
|
149
236
|
}
|
|
150
237
|
|
|
238
|
+
// Normalize a config `images.files` entry list into a Map keyed by filename.
|
|
239
|
+
// Invalid entries (missing filename, non-numeric width) are silently skipped
|
|
240
|
+
// so a typo in config doesn't crash the whole pipeline.
|
|
241
|
+
function buildOverridesMap(entries) {
|
|
242
|
+
const map = new Map()
|
|
243
|
+
if (!Array.isArray(entries)) return map
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
if (!entry || typeof entry.filename !== 'string') continue
|
|
246
|
+
if (typeof entry.width !== 'number' || !Number.isFinite(entry.width)) continue
|
|
247
|
+
const key = entry.filename.replace(/^\/+/, '')
|
|
248
|
+
map.set(key, {
|
|
249
|
+
width: entry.width,
|
|
250
|
+
height: typeof entry.height === 'number' && Number.isFinite(entry.height) ? entry.height : null
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
return map
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Match the key shape stored in `config.cjs > images.files`:
|
|
257
|
+
// `images/<subpath>/<name>.<ext>` relative to `purgetss/images/`.
|
|
258
|
+
function overrideKeyFor(file, imagesFolder) {
|
|
259
|
+
const rel = path.relative(imagesFolder, file).split(path.sep).join('/')
|
|
260
|
+
return rel.startsWith('..') ? null : `images/${rel}`
|
|
261
|
+
}
|
|
262
|
+
|
|
151
263
|
function collectImageFiles(source) {
|
|
152
264
|
const stat = fs.statSync(source)
|
|
153
265
|
if (stat.isFile()) {
|
|
@@ -49,7 +49,7 @@ export function purgeFontAwesome(uniqueClasses, cleanUniqueClasses, debug = fals
|
|
|
49
49
|
if (fs.existsSync(projectsFA_TSS_File)) {
|
|
50
50
|
sourceFolder = projectsFA_TSS_File
|
|
51
51
|
purgedClasses = '\n// Pro/Beta Font Awesome\n'
|
|
52
|
-
purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles
|
|
52
|
+
purgingMessage = `Purging ${chalk.yellow('Pro/Beta Font Awesome')} styles...`
|
|
53
53
|
} else {
|
|
54
54
|
sourceFolder = srcFontAwesomeTSSFile
|
|
55
55
|
purgedClasses = '\n// Default Font Awesome\n'
|
|
@@ -128,9 +128,13 @@ export function purgeFontIcons(sourceFolder, uniqueClasses, message, cleanUnique
|
|
|
128
128
|
|
|
129
129
|
let purgedClasses = ''
|
|
130
130
|
const sourceTSS = fs.readFileSync(sourceFolder, 'utf8')
|
|
131
|
+
const hasMatches = cleanUniqueClasses.some(element => sourceTSS.includes(`'.${element}'`))
|
|
131
132
|
|
|
132
|
-
if (
|
|
133
|
-
|
|
133
|
+
if (hasMatches) {
|
|
134
|
+
// In debug mode the label is emitted by localFinish inline with the timing.
|
|
135
|
+
// In non-debug mode this is the progress indicator (only shown when there's
|
|
136
|
+
// actual work for this font, matching pre-existing behavior).
|
|
137
|
+
if (!debug) logger.info(message)
|
|
134
138
|
const sourceTSSFile = sourceTSS.split(/\r?\n/)
|
|
135
139
|
uniqueClasses.forEach(className => {
|
|
136
140
|
const cleanClassName = cleanClassNameFn(className)
|
|
@@ -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,
|
|
@@ -47,7 +48,9 @@ function cleanClassNameFn(className) {
|
|
|
47
48
|
export function purgeTailwind(uniqueClasses, debug = false) {
|
|
48
49
|
if (debug) localStart()
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
// In debug mode, the section label is emitted by localFinish together with
|
|
52
|
+
// the timing (inline). In non-debug mode this line is the progress indicator.
|
|
53
|
+
if (!debug) logger.info('Purging', chalk.yellow('utilities.tss'), 'styles...')
|
|
51
54
|
|
|
52
55
|
let purgedClasses = ''
|
|
53
56
|
let tailwindClasses = fs.readFileSync(projectsTailwind_TSS, 'utf8').split(/\r?\n/)
|
|
@@ -191,10 +194,20 @@ export function purgeTailwind(uniqueClasses, debug = false) {
|
|
|
191
194
|
const opacityIndex = _.findIndex(tailwindClasses, line => line.startsWith(`'.${opacityValue.className}'`))
|
|
192
195
|
|
|
193
196
|
const classProperties = tailwindClasses[opacityIndex]
|
|
194
|
-
if (opacityIndex > -1 && classProperties.includes('#')) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
if (opacityIndex > -1 && classProperties && !classProperties.includes('#')) {
|
|
198
|
+
const derivedLine = tryDeriveSemanticOpacityLine(classProperties, opacityValue)
|
|
199
|
+
if (derivedLine) {
|
|
200
|
+
purgedClasses += switchPlatform(helpers.checkPlatformAndDevice(derivedLine, opacityValue.classNameWithTransparency))
|
|
201
|
+
} else {
|
|
202
|
+
console.warn('')
|
|
203
|
+
console.warn(chalk.yellow(` Skipping ".${opacityValue.className}/${opacityValue.decimalValue}" — semantic color, no hex to blend.`))
|
|
204
|
+
console.warn(chalk.yellow(` Use a PurgeTSS built-in color, bg-(#AARRGGBB), or "purgetss semantic --single ... --alpha ${opacityValue.decimalValue}".`))
|
|
205
|
+
console.warn('')
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (opacityIndex > -1 && classProperties && classProperties.includes('#')) {
|
|
209
|
+
const hexMatches = classProperties.match(/#[0-9a-f]{6}/gi)
|
|
210
|
+
const defaultHexValue = (classProperties.includes('from')) ? hexMatches[1] : hexMatches[0]
|
|
198
211
|
let classWithoutDecimalOpacity = `${classProperties.replace(new RegExp(defaultHexValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `#${opacityValue.transparency}${defaultHexValue.substring(1)}`)}`
|
|
199
212
|
// Special case: #000000
|
|
200
213
|
if (classProperties.includes('from') && defaultHexValue === '#000000') classWithoutDecimalOpacity = classWithoutDecimalOpacity.replace('00000000', '000000')
|
|
@@ -216,6 +229,31 @@ export function purgeTailwind(uniqueClasses, debug = false) {
|
|
|
216
229
|
return purgedClasses
|
|
217
230
|
}
|
|
218
231
|
|
|
232
|
+
// Auto-derive a semantic key with applied alpha and emit a TSS line for the
|
|
233
|
+
// `class/N` form. Returns the rewritten line (with selector renamed to include
|
|
234
|
+
// `/N` and the semantic value swapped for the derived key), or `null` when no
|
|
235
|
+
// candidate matches an entry in semantic.colors.json. Conflict errors from
|
|
236
|
+
// `deriveAlphaKey` propagate naturally.
|
|
237
|
+
function tryDeriveSemanticOpacityLine(classProperties, opacityValue) {
|
|
238
|
+
const bodyMatch = classProperties.match(/\{([^}]*)\}/)
|
|
239
|
+
if (!bodyMatch) return null
|
|
240
|
+
const candidates = (bodyMatch[1].match(/'([^']+)'/g) || [])
|
|
241
|
+
.map(m => m.slice(1, -1))
|
|
242
|
+
.filter(v => !v.startsWith('#'))
|
|
243
|
+
for (const candidate of candidates) {
|
|
244
|
+
const derivedKey = deriveAlphaKey(candidate, opacityValue.decimalValue)
|
|
245
|
+
if (derivedKey) {
|
|
246
|
+
let line = classProperties.replace(new RegExp(`'${candidate}'`, 'g'), `'${derivedKey}'`)
|
|
247
|
+
line = line.replace(
|
|
248
|
+
`'.${opacityValue.className}'`,
|
|
249
|
+
`'.${opacityValue.className}/${opacityValue.decimalValue}'`
|
|
250
|
+
)
|
|
251
|
+
return line
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
219
257
|
/**
|
|
220
258
|
* Switch platform specific styles - COPIED exactly from original switchPlatform() function
|
|
221
259
|
* NO CHANGES to logic, preserving 100% of original functionality
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - SVG pipeline cache
|
|
3
|
+
*
|
|
4
|
+
* Tracks the last successful generation of each SVG so subsequent runs can
|
|
5
|
+
* skip work when nothing changed. Cached file: `purgetss/.cache/svg-images.json`.
|
|
6
|
+
*
|
|
7
|
+
* An entry is invalidated (regen) when any of these change:
|
|
8
|
+
* - The SVG file content (sha1 hash).
|
|
9
|
+
* - The resolved widthDp / heightDp.
|
|
10
|
+
* - The list of target PNG paths.
|
|
11
|
+
* - Any of the target PNGs is missing on disk.
|
|
12
|
+
*
|
|
13
|
+
* Stale entries for SVGs no longer referenced are left untouched here — the
|
|
14
|
+
* planned `purgetss clean` command is responsible for purging orphans.
|
|
15
|
+
*
|
|
16
|
+
* @fileoverview Idempotency cache for the SVG pipeline
|
|
17
|
+
* @author César Estrada
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs'
|
|
21
|
+
import path from 'path'
|
|
22
|
+
import crypto from 'crypto'
|
|
23
|
+
import { projectsPurgeTSSFolder } from '../../shared/constants.js'
|
|
24
|
+
|
|
25
|
+
const CACHE_DIR = path.join(projectsPurgeTSSFolder, '.cache')
|
|
26
|
+
const CACHE_FILE = path.join(CACHE_DIR, 'svg-images.json')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the on-disk cache as a plain object. Returns `{}` if the file is
|
|
30
|
+
* missing or unreadable — corrupted caches simply force a regen rather than
|
|
31
|
+
* halting the build.
|
|
32
|
+
*/
|
|
33
|
+
export function loadCache() {
|
|
34
|
+
try {
|
|
35
|
+
if (!fs.existsSync(CACHE_FILE)) return {}
|
|
36
|
+
const raw = fs.readFileSync(CACHE_FILE, 'utf8')
|
|
37
|
+
const parsed = JSON.parse(raw)
|
|
38
|
+
return (parsed && typeof parsed === 'object') ? parsed : {}
|
|
39
|
+
} catch {
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist the cache to disk, creating `.cache/` if needed.
|
|
46
|
+
*/
|
|
47
|
+
export function saveCache(cache) {
|
|
48
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
|
49
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2) + '\n', 'utf8')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the sha1 hash of an SVG file. Reads the bytes verbatim so
|
|
54
|
+
* whitespace-only edits also bust the cache (desired — they may change rendering).
|
|
55
|
+
*/
|
|
56
|
+
export function hashFile(absPath) {
|
|
57
|
+
const buf = fs.readFileSync(absPath)
|
|
58
|
+
return crypto.createHash('sha1').update(buf).digest('hex')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decide whether the cached entry is still valid for the given resolved inputs.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} cached - Previous cache entry (or undefined).
|
|
65
|
+
* @param {string} svgHash - sha1 of the current SVG.
|
|
66
|
+
* @param {number} widthDp - Currently resolved widthDp.
|
|
67
|
+
* @param {number} heightDp - Currently resolved heightDp.
|
|
68
|
+
* @param {string[]} targets - Absolute paths the pipeline would emit this run.
|
|
69
|
+
* @returns {boolean} True if every target PNG is present and inputs match.
|
|
70
|
+
*/
|
|
71
|
+
export function isCacheHit(cached, svgHash, widthDp, heightDp, targets) {
|
|
72
|
+
if (!cached) return false
|
|
73
|
+
if (cached.svgHash !== svgHash) return false
|
|
74
|
+
if (cached.widthDp !== widthDp) return false
|
|
75
|
+
if (cached.heightDp !== heightDp) return false
|
|
76
|
+
if (!Array.isArray(cached.targets) || cached.targets.length !== targets.length) return false
|
|
77
|
+
|
|
78
|
+
const cachedPaths = new Set(cached.targets.map(t => (typeof t === 'string' ? t : t.path)))
|
|
79
|
+
for (const t of targets) {
|
|
80
|
+
if (!cachedPaths.has(t)) return false
|
|
81
|
+
if (!fs.existsSync(t)) return false
|
|
82
|
+
}
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the cache entry shape we persist after a successful generation.
|
|
88
|
+
*/
|
|
89
|
+
export function makeCacheEntry(svgHash, widthDp, heightDp, targets) {
|
|
90
|
+
return {
|
|
91
|
+
svgHash,
|
|
92
|
+
widthDp,
|
|
93
|
+
heightDp,
|
|
94
|
+
targets: [...targets]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PurgeTSS - SVG dimension deriver
|
|
3
|
+
*
|
|
4
|
+
* Reduces all the references to a single SVG into one final `{ widthDp, heightDp }`
|
|
5
|
+
* pair that the image generator can consume as the `1×` baseline. Strategy:
|
|
6
|
+
*
|
|
7
|
+
* 1. For each reference, resolve the cascade against the purged app.tss
|
|
8
|
+
* class→props map.
|
|
9
|
+
* 2. Collect every numeric width across references; the SVG's width is the
|
|
10
|
+
* max (a single view rendered at 800dp can't share PNGs with a view at
|
|
11
|
+
* 128dp without visible blur).
|
|
12
|
+
* 3. Resolve height: explicit numeric width wins; otherwise fall back to a
|
|
13
|
+
* proportional value derived from the SVG's viewBox aspect ratio.
|
|
14
|
+
*
|
|
15
|
+
* Edge cases handled:
|
|
16
|
+
* - Every reference resolves to a non-numeric width (`Ti.UI.SIZE`, percentage,
|
|
17
|
+
* unknown class, etc.) → SVG is skipped with a warning.
|
|
18
|
+
* - SVG file missing in `purgetss/images/` → skipped with a warning.
|
|
19
|
+
* - SVG has an invalid/missing viewBox → skipped with an error.
|
|
20
|
+
*
|
|
21
|
+
* @fileoverview Per-SVG dimension reducer that feeds the image generator
|
|
22
|
+
* @author César Estrada
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'fs'
|
|
26
|
+
import path from 'path'
|
|
27
|
+
import { resolveDimensions } from './resolve-classes.js'
|
|
28
|
+
import { readSvgSafely } from '../../shared/svg-utils.js'
|
|
29
|
+
|
|
30
|
+
// Hard cap on the largest target PNG we are willing to emit, applied at the
|
|
31
|
+
// `xxxhdpi` / `@3x` step downstream. Keeps Sharp from producing absurdly large
|
|
32
|
+
// PNGs when the user accidentally pins a 4K width to a class.
|
|
33
|
+
export const MAX_DIMENSION_PX = 4096
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reduce raw SVG references into per-SVG resolved dimensions.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} args
|
|
39
|
+
* @param {Map<string, Array<{ classes: string[] }>>} args.refsBySvg - Map keyed by SVG relpath
|
|
40
|
+
* (relative to imagesFolder, normalized to forward slashes), values are the list of
|
|
41
|
+
* references seen for that SVG.
|
|
42
|
+
* @param {Map} args.tssMap - Output of parseTssMap().
|
|
43
|
+
* @param {string} args.imagesFolder - Absolute path to `purgetss/images/`.
|
|
44
|
+
* @param {Object} args.logger - Logger with `.warning(msg)` and `.info(msg)`.
|
|
45
|
+
* @returns {Promise<Map<string, { widthDp: number, heightDp: number }>>}
|
|
46
|
+
* Resolved entries only; skipped SVGs are omitted (warnings logged inline).
|
|
47
|
+
*/
|
|
48
|
+
export async function deriveDimensions({ refsBySvg, tssMap, imagesFolder, logger }) {
|
|
49
|
+
const resolved = new Map()
|
|
50
|
+
|
|
51
|
+
for (const [relPath, refs] of refsBySvg) {
|
|
52
|
+
const absPath = path.join(imagesFolder, relPath)
|
|
53
|
+
if (!fs.existsSync(absPath)) {
|
|
54
|
+
logger.warning(`⚠ SVG not found: purgetss/images/${relPath} — skipping`)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const numericWidths = []
|
|
59
|
+
const numericHeights = []
|
|
60
|
+
for (const ref of refs) {
|
|
61
|
+
const { width, height } = resolveDimensions(ref.classes, tssMap)
|
|
62
|
+
if (typeof width === 'number' && Number.isFinite(width) && width > 0) {
|
|
63
|
+
numericWidths.push(width)
|
|
64
|
+
}
|
|
65
|
+
if (typeof height === 'number' && Number.isFinite(height) && height > 0) {
|
|
66
|
+
numericHeights.push(height)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (numericWidths.length === 0 && numericHeights.length === 0) {
|
|
71
|
+
logger.warning(
|
|
72
|
+
`⚠ ${relPath}: no class resolved width or height to a number — skipping. ` +
|
|
73
|
+
'Add a w-* or h-* utility on the view, or pin the size manually in purgetss/config.cjs > images.files.'
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await readViewBox(absPath)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.warning(`⚠ ${relPath}: ${err.message} — skipping`)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Symmetric materialization: only the dimensions the developer pinned
|
|
86
|
+
// explicitly land in config.cjs. The other side is derived from the SVG
|
|
87
|
+
// aspect by gen-scales on every run, so it stays in sync with viewBox
|
|
88
|
+
// edits and class changes without stale "stuck" values getting cemented
|
|
89
|
+
// into config the way auto-derived heights used to.
|
|
90
|
+
const widthDp = numericWidths.length > 0 ? Math.max(...numericWidths) : null
|
|
91
|
+
const heightDp = numericHeights.length > 0 ? Math.max(...numericHeights) : null
|
|
92
|
+
|
|
93
|
+
resolved.set(relPath, { widthDp, heightDp })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return resolved
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readViewBox(absPath) {
|
|
100
|
+
// Plan: error when the SVG declares neither viewBox nor width+height
|
|
101
|
+
// attributes. Sharp can infer a bounding box from the rendered content,
|
|
102
|
+
// but treating that as "valid" hides authoring mistakes (e.g. an export
|
|
103
|
+
// that lost its viewBox), so we enforce the explicit declaration first.
|
|
104
|
+
const head = fs.readFileSync(absPath, 'utf8').slice(0, 4096)
|
|
105
|
+
const svgTag = head.match(/<svg\b[^>]*>/i)?.[0] ?? ''
|
|
106
|
+
const hasViewBox = /\bviewBox\s*=\s*['"][^'"]+['"]/.test(svgTag)
|
|
107
|
+
const hasWidth = /\bwidth\s*=\s*['"][^'"]+['"]/.test(svgTag)
|
|
108
|
+
const hasHeight = /\bheight\s*=\s*['"][^'"]+['"]/.test(svgTag)
|
|
109
|
+
if (!hasViewBox && !(hasWidth && hasHeight)) {
|
|
110
|
+
throw new Error('SVG has no viewBox or explicit width/height attribute')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { meta } = await readSvgSafely(absPath, {})
|
|
114
|
+
const vbW = meta.width
|
|
115
|
+
const vbH = meta.height
|
|
116
|
+
if (!Number.isFinite(vbW) || !Number.isFinite(vbH) || vbW <= 0 || vbH <= 0) {
|
|
117
|
+
throw new Error('SVG has no usable viewBox / width / height')
|
|
118
|
+
}
|
|
119
|
+
return { vbW, vbH }
|
|
120
|
+
}
|