purgetss 7.9.0 → 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 (36) hide show
  1. package/README.md +21 -1
  2. package/bin/purgetss +13 -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 +1 -1
  7. package/src/cli/commands/images.js +41 -2
  8. package/src/cli/commands/purge.js +15 -2
  9. package/src/cli/utils/cli-helpers.js +15 -5
  10. package/src/cli/utils/unsupported-class-reporter.js +3 -3
  11. package/src/core/analyzers/class-extractor.js +54 -0
  12. package/src/core/analyzers/controller-svg-refs.js +154 -0
  13. package/src/core/branding/brand-config.js +7 -0
  14. package/src/core/branding/ensure-brand-section.js +4 -3
  15. package/src/core/branding/gen-feature-graphic.js +57 -0
  16. package/src/core/branding/index.js +28 -4
  17. package/src/core/branding/post-gen-notes.js +2 -2
  18. package/src/core/builders/auto-utilities-builder.js +20 -15
  19. package/src/core/images/ensure-images-section.js +6 -4
  20. package/src/core/images/gen-scales.js +82 -17
  21. package/src/core/images/index.js +117 -12
  22. package/src/core/purger/icon-purger.js +7 -3
  23. package/src/core/purger/tailwind-purger.js +3 -1
  24. package/src/core/svg/cache.js +96 -0
  25. package/src/core/svg/derive-dimensions.js +120 -0
  26. package/src/core/svg/index.js +215 -0
  27. package/src/core/svg/resolve-classes.js +46 -0
  28. package/src/core/svg/sync-images.js +278 -0
  29. package/src/core/svg/tss-reader.js +134 -0
  30. package/src/dev/builders/tailwind-builder.js +18 -0
  31. package/src/shared/config-manager.js +72 -3
  32. package/src/shared/error-reporter.js +117 -0
  33. package/src/shared/helpers/colors.js +57 -13
  34. package/src/shared/helpers/utils.js +46 -8
  35. package/src/shared/logger.js +12 -0
  36. package/src/shared/validation/config-validator.js +167 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * PurgeTSS - config.cjs sync for SVG-derived dimensions
3
+ *
4
+ * Updates `images.files` inside the user's purgetss/config.cjs to reflect the
5
+ * dimensions resolved from app.tss. Companion to setConfigProperty (which only
6
+ * handles primitives) — this one knows how to upsert into an array of objects
7
+ * while preserving the user's formatting, comments, and any manual overrides.
8
+ *
9
+ * Policy (see plan):
10
+ * - Entry missing → insert `{ filename, width [, height] }`.
11
+ * - Entry present, width < derived → bump width (and height) to derived.
12
+ * - Entry present, width >= derived → leave alone. Manual overrides win.
13
+ *
14
+ * String-based mutation chosen over `recast` to avoid adding a heavy dep —
15
+ * config.cjs has a controlled shape since `purgetss init` writes it. If we
16
+ * can't safely locate the section we no-op and warn, mirroring setConfigProperty.
17
+ *
18
+ * @fileoverview Upsert image entries in config.cjs while preserving formatting
19
+ * @author César Estrada
20
+ */
21
+
22
+ import fs from 'fs'
23
+ import { projectsConfigJS } from '../../shared/constants.js'
24
+
25
+ /**
26
+ * Apply the derived dimensions to `images.files` in the user's config.cjs and
27
+ * return the effective per-SVG dimensions after the sync (i.e. the max of the
28
+ * derived value and any pre-existing manual override). Callers should drive
29
+ * PNG generation from the returned `effective` map so manual overrides — like
30
+ * pinning a width above what the class cascade resolves — produce PNGs at the
31
+ * higher resolution.
32
+ *
33
+ * @param {Map<string, { widthDp: number, heightDp: number }>} derived
34
+ * Keyed by SVG relpath (matches the `filename` field stored in config).
35
+ * @param {Object} [opts]
36
+ * @param {Object} [opts.logger] - Logger with `.warning(msg)` / `.info(msg)`.
37
+ * @param {boolean} [opts.write=true] - When false, computes the effective map
38
+ * without mutating config.cjs (for users who keep `images.autoSync: false`
39
+ * and prefer to manage `images.files` by hand).
40
+ * @returns {{
41
+ * stats: { updated: number, inserted: number, untouched: number },
42
+ * effective: Map<string, { widthDp: number, heightDp: number }>
43
+ * }}
44
+ */
45
+ export function syncConfigImages(derived, { logger, write = true } = {}) {
46
+ const stats = { updated: 0, inserted: 0, untouched: 0 }
47
+ const effective = new Map()
48
+ if (!fs.existsSync(projectsConfigJS)) {
49
+ logger?.warning('config.cjs not found — skipping SVG dimensions sync.')
50
+ // Without config we still want PNGs generated at the derived size — copy
51
+ // the derived map through verbatim.
52
+ for (const [k, v] of derived) effective.set(k, { ...v })
53
+ return { stats, effective }
54
+ }
55
+
56
+ let source = fs.readFileSync(projectsConfigJS, 'utf8')
57
+
58
+ for (const [relPath, { widthDp, heightDp }] of derived) {
59
+ const filename = toFilename(relPath)
60
+ const existing = findEntry(source, filename)
61
+ if (existing) {
62
+ // autoSync ON (this code path): config mirrors the current run. The
63
+ // derived numbers already reflect max() across every reference to this
64
+ // SVG in this run (see derive-dimensions.js) — sync just writes them
65
+ // through. No second max against the previous run, which would freeze
66
+ // shrunk classes at their old larger size. Users who want to pin a
67
+ // value by hand should set images.autoSync: false (write=false), which
68
+ // skips this file write entirely.
69
+ const desired = {}
70
+ if (widthDp != null) desired.width = widthDp
71
+ if (heightDp != null) desired.height = heightDp
72
+
73
+ const widthSame = (existing.width ?? null) === (desired.width ?? null)
74
+ const heightSame = (existing.height ?? null) === (desired.height ?? null)
75
+ if (widthSame && heightSame) {
76
+ stats.untouched++
77
+ } else {
78
+ source = replaceEntry(source, existing, filename, desired)
79
+ stats.updated++
80
+ }
81
+ effective.set(relPath, { widthDp: desired.width ?? null, heightDp: desired.height ?? null })
82
+ } else {
83
+ const entry = {}
84
+ if (widthDp != null) entry.width = widthDp
85
+ if (heightDp != null) entry.height = heightDp
86
+ const next = insertEntry(source, filename, entry)
87
+ if (next === null) {
88
+ logger?.warning(`Could not insert ${filename} into images.files (section missing or unreadable).`)
89
+ effective.set(relPath, { widthDp, heightDp })
90
+ continue
91
+ }
92
+ source = next
93
+ stats.inserted++
94
+ effective.set(relPath, { widthDp, heightDp })
95
+ }
96
+ }
97
+
98
+ // Only write when we actually mutated `source`. The loop above only mutates
99
+ // it inside the inserted/updated branches; `untouched` leaves it byte-identical
100
+ // to disk. Skipping the write avoids touching the mtime, which other parts of
101
+ // PurgeTSS use to invalidate utilities.tss — gratuitous mtime bumps would
102
+ // trigger needless rebuilds. Derive + effective + the SVG cache still run
103
+ // either way, so generation/validation is unaffected.
104
+ if (write && (stats.inserted > 0 || stats.updated > 0)) {
105
+ fs.writeFileSync(projectsConfigJS, source, 'utf8')
106
+ }
107
+ return { stats, effective }
108
+ }
109
+
110
+ function toFilename(relPath) {
111
+ // Stored form mirrors the `purgetss images` convention: `images/<subpath>/<name>.svg`
112
+ return `images/${relPath.replace(/^\/+/, '')}`
113
+ }
114
+
115
+ // Locate an entry like `{ filename: 'images/foo.svg', width: 128 }`. Tolerates
116
+ // keys in any order and either quoting style.
117
+ function findEntry(source, filename) {
118
+ const escaped = filename.replace(/[/.[\]()*+?^$|\\]/g, '\\$&')
119
+ const re = new RegExp(
120
+ `\\{[^{}]*?\\bfilename\\s*:\\s*['"\`]${escaped}['"\`][^{}]*?\\}`,
121
+ 'g'
122
+ )
123
+ const matches = [...source.matchAll(re)]
124
+ if (matches.length === 0) return null
125
+ const match = matches[0]
126
+ const body = match[0]
127
+ const width = readNumber(body, 'width')
128
+ const height = readNumber(body, 'height')
129
+ return { match: body, index: match.index, width, height }
130
+ }
131
+
132
+ function readNumber(body, key) {
133
+ const m = body.match(new RegExp(`\\b${key}\\s*:\\s*(\\d+)`))
134
+ return m ? Number(m[1]) : null
135
+ }
136
+
137
+ function replaceEntry(source, existing, filename, desired) {
138
+ const newEntry = renderEntry(filename, desired)
139
+ return source.slice(0, existing.index) + newEntry + source.slice(existing.index + existing.match.length)
140
+ }
141
+
142
+ function renderEntry(filename, { width, height }) {
143
+ const parts = [`filename: '${filename}'`]
144
+ if (width != null) parts.push(`width: ${width}`)
145
+ if (height != null) parts.push(`height: ${height}`)
146
+ return `{ ${parts.join(', ')} }`
147
+ }
148
+
149
+ // Insert a new entry into the images.files array. Returns the mutated source
150
+ // string, or null if the array can't be located/created safely.
151
+ function insertEntry(source, filename, entry) {
152
+ // First pass: make sure the array exists; this may mutate `source` to inject
153
+ // `files: []` into the `images` section when missing.
154
+ const ensured = ensureFilesArray(source)
155
+ if (ensured === null) return null
156
+ source = ensured
157
+
158
+ const filesArr = locateFilesArray(source)
159
+ if (!filesArr) return null
160
+
161
+ const { openIdx, closeIdx, indent } = filesArr
162
+ const inner = source.slice(openIdx + 1, closeIdx)
163
+ const newEntry = renderEntry(filename, entry)
164
+
165
+ const innerTrimmed = inner.replace(/\s+$/, '')
166
+ if (innerTrimmed.trim().length === 0) {
167
+ // Empty array: rewrite as multi-line with one entry.
168
+ const replacement = `[\n${indent} ${newEntry}\n${indent}]`
169
+ return source.slice(0, openIdx) + replacement + source.slice(closeIdx + 1)
170
+ }
171
+
172
+ // Non-empty: ensure the previous entry has a trailing comma and append a
173
+ // new line with the same indent as siblings (heuristic: 2-space extra).
174
+ const lines = innerTrimmed.split('\n')
175
+ const lastIdx = lines.length - 1
176
+ const lastLine = lines[lastIdx]
177
+ const stripped = lastLine.replace(/\s+$/, '')
178
+ if (!stripped.endsWith(',') && !stripped.endsWith('[')) {
179
+ lines[lastIdx] = stripped + ','
180
+ }
181
+ const newInner = lines.join('\n') + `\n${indent} ${newEntry}\n${indent}`
182
+ return source.slice(0, openIdx + 1) + newInner + source.slice(closeIdx)
183
+ }
184
+
185
+ // If `images.files` is missing, inject an empty `files: []` immediately before
186
+ // the section's closing brace, preserving the user's indentation.
187
+ function ensureFilesArray(source) {
188
+ const section = matchImagesSection(source)
189
+ if (!section) return null
190
+ if (/(\n\s*)files\s*:\s*\[/.test(section.body)) return source
191
+
192
+ const insertion = `\n${section.indent} files: []`
193
+ const closingIdx = section.end - 1 // position of '}'
194
+ const before = source.slice(0, closingIdx)
195
+ const after = source.slice(closingIdx)
196
+ // Drop trailing horizontal whitespace + newlines so the new line lands flush
197
+ // against the previous sibling line.
198
+ const trimmed = before.replace(/[ \t]+$/, '').replace(/\n+$/, '')
199
+ return appendTrailingCommaIfNeeded(trimmed) + insertion + '\n' + section.indent + after
200
+ }
201
+
202
+ // Ensure the previous property line is followed by a comma so the appended
203
+ // line parses. Honors trailing `// comments` by placing the comma between the
204
+ // value and the comment, never inside the comment itself.
205
+ function appendTrailingCommaIfNeeded(text) {
206
+ const lastLineStart = text.lastIndexOf('\n') + 1
207
+ const head = text.slice(0, lastLineStart)
208
+ const lastLine = text.slice(lastLineStart)
209
+ // Split off a trailing `// comment` (with optional leading whitespace).
210
+ const commentMatch = lastLine.match(/^(.*?)(\s*\/\/.*)$/)
211
+ const valuePart = (commentMatch ? commentMatch[1] : lastLine).replace(/[ \t]+$/, '')
212
+ const commentPart = commentMatch ? commentMatch[2] : ''
213
+ if (!valuePart || valuePart.endsWith(',') || valuePart.endsWith('{')) return text
214
+ return head + valuePart + ',' + commentPart
215
+ }
216
+
217
+ function matchImagesSection(source) {
218
+ // Locate the `images:` key, then bracket-balance to its matching `}`.
219
+ // The previous lazy-regex approach mis-identified the closing brace whenever
220
+ // `images: { ... }` was written on a single line (no `\n indent }` to anchor
221
+ // on) — it swallowed sibling sections and dropped `files: []` into whichever
222
+ // nested block happened to close first. Bracket-balancing works regardless
223
+ // of formatting, matching what the rest of PurgeTSS expects from config.cjs.
224
+ const m = source.match(/^([ \t]*)images\s*:\s*\{/m)
225
+ if (!m) return null
226
+
227
+ const openIdx = m.index + m[0].length - 1
228
+ const closeIdx = matchBracket(source, openIdx, '{', '}')
229
+ if (closeIdx === -1) return null
230
+
231
+ return {
232
+ start: m.index,
233
+ end: closeIdx + 1,
234
+ indent: m[1],
235
+ body: source.slice(openIdx + 1, closeIdx)
236
+ }
237
+ }
238
+
239
+ // Find the `files: [ ... ]` array inside the `images: { ... }` section.
240
+ function locateFilesArray(source) {
241
+ const section = matchImagesSection(source)
242
+ if (!section) return null
243
+
244
+ const filesMatch = section.body.match(/(\n\s*)files\s*:\s*\[/)
245
+ if (!filesMatch) return null
246
+
247
+ const arrayKeyStart = section.start + section.body.indexOf(filesMatch[0]) + filesMatch[1].length
248
+ const openIdx = source.indexOf('[', arrayKeyStart)
249
+ if (openIdx === -1) return null
250
+ const closeIdx = matchBracket(source, openIdx, '[', ']')
251
+ if (closeIdx === -1) return null
252
+
253
+ const lineStart = source.lastIndexOf('\n', arrayKeyStart) + 1
254
+ const indent = source.slice(lineStart, arrayKeyStart).match(/^[ \t]*/)[0]
255
+
256
+ return { openIdx, closeIdx, indent }
257
+ }
258
+
259
+ function matchBracket(source, startIdx, open, close) {
260
+ let depth = 0
261
+ let inSingle = false
262
+ let inDouble = false
263
+ let inBacktick = false
264
+ for (let i = startIdx; i < source.length; i++) {
265
+ const c = source[i]
266
+ if (c === '\'' && !inDouble && !inBacktick) inSingle = !inSingle
267
+ else if (c === '"' && !inSingle && !inBacktick) inDouble = !inDouble
268
+ else if (c === '`' && !inSingle && !inDouble) inBacktick = !inBacktick
269
+ else if (!inSingle && !inDouble && !inBacktick) {
270
+ if (c === open) depth++
271
+ else if (c === close) {
272
+ depth--
273
+ if (depth === 0) return i
274
+ }
275
+ }
276
+ }
277
+ return -1
278
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * PurgeTSS - TSS reader for the SVG pipeline
3
+ *
4
+ * Lightweight parser for the controlled TSS output PurgeTSS generates. Used by
5
+ * the SVG image pipeline to resolve final width/height per class after a
6
+ * regular purge run. NOT a general-purpose TSS parser — only the shapes the
7
+ * purger emits are recognized.
8
+ *
9
+ * Recognized line shape:
10
+ * '.classname': { prop: value, prop: value }
11
+ *
12
+ * Tag selectors (e.g. 'View': { ... }, 'ImageView[platform=ios]': { ... }) and
13
+ * '#id': { ... } selectors are skipped — the SVG pipeline resolves only by
14
+ * class cascade in V1.
15
+ *
16
+ * @fileoverview Class → properties map extracted from purged app.tss
17
+ * @author César Estrada
18
+ */
19
+
20
+ const CLASS_LINE = /^\s*'\.([^']+)'\s*:\s*\{([^}]*)\}\s*$/
21
+
22
+ /**
23
+ * Parse the controlled TSS string emitted by the purger into a class → props
24
+ * map. Values are returned in a normalized form:
25
+ *
26
+ * - number → finite numeric value (e.g. 128)
27
+ * - 'auto' → Ti.UI.SIZE
28
+ * - 'fill' → Ti.UI.FILL
29
+ * - 'percent' → percentage or any other non-resolvable value
30
+ *
31
+ * Only the `width` and `height` properties are normalized; other props are
32
+ * captured verbatim as the raw RHS string (callers don't currently need them).
33
+ *
34
+ * @param {string} tssContent - The full purged TSS (in-memory string).
35
+ * @returns {Map<string, { width?: number|'auto'|'fill'|'percent', height?: number|'auto'|'fill'|'percent', _raw: string }>}
36
+ */
37
+ export function parseTssMap(tssContent) {
38
+ const map = new Map()
39
+ if (typeof tssContent !== 'string' || !tssContent) return map
40
+
41
+ const lines = tssContent.split(/\r?\n/)
42
+ for (const line of lines) {
43
+ const stripped = stripLineComment(line)
44
+ const match = stripped.match(CLASS_LINE)
45
+ if (!match) continue
46
+
47
+ const className = match[1]
48
+ const body = match[2]
49
+ const props = parsePropBody(body)
50
+ map.set(className, { ...props, _raw: body.trim() })
51
+ }
52
+ return map
53
+ }
54
+
55
+ // Drop trailing `// comment` so it doesn't break the brace-matching regex.
56
+ function stripLineComment(line) {
57
+ let inSingle = false
58
+ let inDouble = false
59
+ for (let i = 0; i < line.length - 1; i++) {
60
+ const c = line[i]
61
+ if (c === '\'' && !inDouble) inSingle = !inSingle
62
+ else if (c === '"' && !inSingle) inDouble = !inDouble
63
+ else if (c === '/' && line[i + 1] === '/' && !inSingle && !inDouble) {
64
+ return line.slice(0, i)
65
+ }
66
+ }
67
+ return line
68
+ }
69
+
70
+ function parsePropBody(body) {
71
+ const out = {}
72
+ for (const pair of splitTopLevelCommas(body)) {
73
+ const colon = findTopLevelColon(pair)
74
+ if (colon === -1) continue
75
+ const key = pair.slice(0, colon).trim()
76
+ const value = pair.slice(colon + 1).trim()
77
+ if (key === 'width' || key === 'height') {
78
+ out[key] = normalizeDimensionValue(value)
79
+ }
80
+ }
81
+ return out
82
+ }
83
+
84
+ function splitTopLevelCommas(body) {
85
+ const out = []
86
+ let depth = 0
87
+ let inSingle = false
88
+ let inDouble = false
89
+ let last = 0
90
+ for (let i = 0; i < body.length; i++) {
91
+ const c = body[i]
92
+ if (c === '\'' && !inDouble) inSingle = !inSingle
93
+ else if (c === '"' && !inSingle) inDouble = !inDouble
94
+ else if (!inSingle && !inDouble) {
95
+ if (c === '(' || c === '[' || c === '{') depth++
96
+ else if (c === ')' || c === ']' || c === '}') depth--
97
+ else if (c === ',' && depth === 0) {
98
+ out.push(body.slice(last, i))
99
+ last = i + 1
100
+ }
101
+ }
102
+ }
103
+ out.push(body.slice(last))
104
+ return out.filter(s => s.trim().length > 0)
105
+ }
106
+
107
+ function findTopLevelColon(pair) {
108
+ let inSingle = false
109
+ let inDouble = false
110
+ let depth = 0
111
+ for (let i = 0; i < pair.length; i++) {
112
+ const c = pair[i]
113
+ if (c === '\'' && !inDouble) inSingle = !inSingle
114
+ else if (c === '"' && !inSingle) inDouble = !inDouble
115
+ else if (!inSingle && !inDouble) {
116
+ if (c === '(' || c === '[' || c === '{') depth++
117
+ else if (c === ')' || c === ']' || c === '}') depth--
118
+ else if (c === ':' && depth === 0) return i
119
+ }
120
+ }
121
+ return -1
122
+ }
123
+
124
+ // Recognize: numeric literals, Ti.UI.SIZE/FILL, quoted percentages or other
125
+ // non-numeric strings. Anything else falls into 'percent' (the catch-all for
126
+ // "this can't be turned into a dp number"). The label is historic — it
127
+ // originally only meant "string with %" — keeping it avoids ripple changes.
128
+ function normalizeDimensionValue(raw) {
129
+ const trimmed = raw.trim()
130
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
131
+ if (/^Ti\.UI\.SIZE$/i.test(trimmed)) return 'auto'
132
+ if (/^Ti\.UI\.FILL$/i.test(trimmed)) return 'fill'
133
+ return 'percent'
134
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * PurgeTSS - Utilities Builder (Development entry point)
3
+ *
4
+ * Thin CLI wrapper invoked by the `build:tailwind` npm script.
5
+ * Generates: ./dist/utilities.tss
6
+ *
7
+ * @author César Estrada
8
+ */
9
+
10
+ import { autoBuildUtilitiesTSS } from '../../core/builders/auto-utilities-builder.js'
11
+
12
+ export function buildTailwind() {
13
+ autoBuildUtilitiesTSS()
14
+ }
15
+
16
+ if (import.meta.url === `file://${process.argv[1]}`) {
17
+ buildTailwind()
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
+ }