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.
- package/README.md +21 -1
- package/bin/purgetss +13 -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 +1 -1
- package/src/cli/commands/images.js +41 -2
- package/src/cli/commands/purge.js +15 -2
- package/src/cli/utils/cli-helpers.js +15 -5
- package/src/cli/utils/unsupported-class-reporter.js +3 -3
- 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/src/core/builders/auto-utilities-builder.js +20 -15
- package/src/core/images/ensure-images-section.js +6 -4
- package/src/core/images/gen-scales.js +82 -17
- package/src/core/images/index.js +117 -12
- package/src/core/purger/icon-purger.js +7 -3
- package/src/core/purger/tailwind-purger.js +3 -1
- 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 +18 -0
- 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/utils.js +46 -8
- package/src/shared/logger.js +12 -0
- 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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
}
|