purgetss
Version:
A package that simplifies mobile app creation for Titanium developers.
279 lines (250 loc) • 11.8 kB
JavaScript
/**
* PurgeTSS - config.cjs sync for SVG-derived dimensions
*
* Updates `images.files` inside the user's purgetss/config.cjs to reflect the
* dimensions resolved from app.tss. Companion to setConfigProperty (which only
* handles primitives) — this one knows how to upsert into an array of objects
* while preserving the user's formatting, comments, and any manual overrides.
*
* Policy (see plan):
* - Entry missing → insert `{ filename, width [, height] }`.
* - Entry present, width < derived → bump width (and height) to derived.
* - Entry present, width >= derived → leave alone. Manual overrides win.
*
* String-based mutation chosen over `recast` to avoid adding a heavy dep —
* config.cjs has a controlled shape since `purgetss init` writes it. If we
* can't safely locate the section we no-op and warn, mirroring setConfigProperty.
*
* @fileoverview Upsert image entries in config.cjs while preserving formatting
* @author César Estrada
*/
import fs from 'fs'
import { projectsConfigJS } from '../../shared/constants.js'
/**
* Apply the derived dimensions to `images.files` in the user's config.cjs and
* return the effective per-SVG dimensions after the sync (i.e. the max of the
* derived value and any pre-existing manual override). Callers should drive
* PNG generation from the returned `effective` map so manual overrides — like
* pinning a width above what the class cascade resolves — produce PNGs at the
* higher resolution.
*
* @param {Map<string, { widthDp: number, heightDp: number }>} derived
* Keyed by SVG relpath (matches the `filename` field stored in config).
* @param {Object} [opts]
* @param {Object} [opts.logger] - Logger with `.warning(msg)` / `.info(msg)`.
* @param {boolean} [opts.write=true] - When false, computes the effective map
* without mutating config.cjs (for users who keep `images.autoSync: false`
* and prefer to manage `images.files` by hand).
* @returns {{
* stats: { updated: number, inserted: number, untouched: number },
* effective: Map<string, { widthDp: number, heightDp: number }>
* }}
*/
export function syncConfigImages(derived, { logger, write = true } = {}) {
const stats = { updated: 0, inserted: 0, untouched: 0 }
const effective = new Map()
if (!fs.existsSync(projectsConfigJS)) {
logger?.warning('config.cjs not found — skipping SVG dimensions sync.')
// Without config we still want PNGs generated at the derived size — copy
// the derived map through verbatim.
for (const [k, v] of derived) effective.set(k, { ...v })
return { stats, effective }
}
let source = fs.readFileSync(projectsConfigJS, 'utf8')
for (const [relPath, { widthDp, heightDp }] of derived) {
const filename = toFilename(relPath)
const existing = findEntry(source, filename)
if (existing) {
// autoSync ON (this code path): config mirrors the current run. The
// derived numbers already reflect max() across every reference to this
// SVG in this run (see derive-dimensions.js) — sync just writes them
// through. No second max against the previous run, which would freeze
// shrunk classes at their old larger size. Users who want to pin a
// value by hand should set images.autoSync: false (write=false), which
// skips this file write entirely.
const desired = {}
if (widthDp != null) desired.width = widthDp
if (heightDp != null) desired.height = heightDp
const widthSame = (existing.width ?? null) === (desired.width ?? null)
const heightSame = (existing.height ?? null) === (desired.height ?? null)
if (widthSame && heightSame) {
stats.untouched++
} else {
source = replaceEntry(source, existing, filename, desired)
stats.updated++
}
effective.set(relPath, { widthDp: desired.width ?? null, heightDp: desired.height ?? null })
} else {
const entry = {}
if (widthDp != null) entry.width = widthDp
if (heightDp != null) entry.height = heightDp
const next = insertEntry(source, filename, entry)
if (next === null) {
logger?.warning(`Could not insert ${filename} into images.files (section missing or unreadable).`)
effective.set(relPath, { widthDp, heightDp })
continue
}
source = next
stats.inserted++
effective.set(relPath, { widthDp, heightDp })
}
}
// Only write when we actually mutated `source`. The loop above only mutates
// it inside the inserted/updated branches; `untouched` leaves it byte-identical
// to disk. Skipping the write avoids touching the mtime, which other parts of
// PurgeTSS use to invalidate utilities.tss — gratuitous mtime bumps would
// trigger needless rebuilds. Derive + effective + the SVG cache still run
// either way, so generation/validation is unaffected.
if (write && (stats.inserted > 0 || stats.updated > 0)) {
fs.writeFileSync(projectsConfigJS, source, 'utf8')
}
return { stats, effective }
}
function toFilename(relPath) {
// Stored form mirrors the `purgetss images` convention: `images/<subpath>/<name>.svg`
return `images/${relPath.replace(/^\/+/, '')}`
}
// Locate an entry like `{ filename: 'images/foo.svg', width: 128 }`. Tolerates
// keys in any order and either quoting style.
function findEntry(source, filename) {
const escaped = filename.replace(/[/.[\]()*+?^$|\\]/g, '\\$&')
const re = new RegExp(
`\\{[^{}]*?\\bfilename\\s*:\\s*['"\`]${escaped}['"\`][^{}]*?\\}`,
'g'
)
const matches = [...source.matchAll(re)]
if (matches.length === 0) return null
const match = matches[0]
const body = match[0]
const width = readNumber(body, 'width')
const height = readNumber(body, 'height')
return { match: body, index: match.index, width, height }
}
function readNumber(body, key) {
const m = body.match(new RegExp(`\\b${key}\\s*:\\s*(\\d+)`))
return m ? Number(m[1]) : null
}
function replaceEntry(source, existing, filename, desired) {
const newEntry = renderEntry(filename, desired)
return source.slice(0, existing.index) + newEntry + source.slice(existing.index + existing.match.length)
}
function renderEntry(filename, { width, height }) {
const parts = [`filename: '${filename}'`]
if (width != null) parts.push(`width: ${width}`)
if (height != null) parts.push(`height: ${height}`)
return `{ ${parts.join(', ')} }`
}
// Insert a new entry into the images.files array. Returns the mutated source
// string, or null if the array can't be located/created safely.
function insertEntry(source, filename, entry) {
// First pass: make sure the array exists; this may mutate `source` to inject
// `files: []` into the `images` section when missing.
const ensured = ensureFilesArray(source)
if (ensured === null) return null
source = ensured
const filesArr = locateFilesArray(source)
if (!filesArr) return null
const { openIdx, closeIdx, indent } = filesArr
const inner = source.slice(openIdx + 1, closeIdx)
const newEntry = renderEntry(filename, entry)
const innerTrimmed = inner.replace(/\s+$/, '')
if (innerTrimmed.trim().length === 0) {
// Empty array: rewrite as multi-line with one entry.
const replacement = `[\n${indent} ${newEntry}\n${indent}]`
return source.slice(0, openIdx) + replacement + source.slice(closeIdx + 1)
}
// Non-empty: ensure the previous entry has a trailing comma and append a
// new line with the same indent as siblings (heuristic: 2-space extra).
const lines = innerTrimmed.split('\n')
const lastIdx = lines.length - 1
const lastLine = lines[lastIdx]
const stripped = lastLine.replace(/\s+$/, '')
if (!stripped.endsWith(',') && !stripped.endsWith('[')) {
lines[lastIdx] = stripped + ','
}
const newInner = lines.join('\n') + `\n${indent} ${newEntry}\n${indent}`
return source.slice(0, openIdx + 1) + newInner + source.slice(closeIdx)
}
// If `images.files` is missing, inject an empty `files: []` immediately before
// the section's closing brace, preserving the user's indentation.
function ensureFilesArray(source) {
const section = matchImagesSection(source)
if (!section) return null
if (/(\n\s*)files\s*:\s*\[/.test(section.body)) return source
const insertion = `\n${section.indent} files: []`
const closingIdx = section.end - 1 // position of '}'
const before = source.slice(0, closingIdx)
const after = source.slice(closingIdx)
// Drop trailing horizontal whitespace + newlines so the new line lands flush
// against the previous sibling line.
const trimmed = before.replace(/[ \t]+$/, '').replace(/\n+$/, '')
return appendTrailingCommaIfNeeded(trimmed) + insertion + '\n' + section.indent + after
}
// Ensure the previous property line is followed by a comma so the appended
// line parses. Honors trailing `// comments` by placing the comma between the
// value and the comment, never inside the comment itself.
function appendTrailingCommaIfNeeded(text) {
const lastLineStart = text.lastIndexOf('\n') + 1
const head = text.slice(0, lastLineStart)
const lastLine = text.slice(lastLineStart)
// Split off a trailing `// comment` (with optional leading whitespace).
const commentMatch = lastLine.match(/^(.*?)(\s*\/\/.*)$/)
const valuePart = (commentMatch ? commentMatch[1] : lastLine).replace(/[ \t]+$/, '')
const commentPart = commentMatch ? commentMatch[2] : ''
if (!valuePart || valuePart.endsWith(',') || valuePart.endsWith('{')) return text
return head + valuePart + ',' + commentPart
}
function matchImagesSection(source) {
// Locate the `images:` key, then bracket-balance to its matching `}`.
// The previous lazy-regex approach mis-identified the closing brace whenever
// `images: { ... }` was written on a single line (no `\n indent }` to anchor
// on) — it swallowed sibling sections and dropped `files: []` into whichever
// nested block happened to close first. Bracket-balancing works regardless
// of formatting, matching what the rest of PurgeTSS expects from config.cjs.
const m = source.match(/^([ \t]*)images\s*:\s*\{/m)
if (!m) return null
const openIdx = m.index + m[0].length - 1
const closeIdx = matchBracket(source, openIdx, '{', '}')
if (closeIdx === -1) return null
return {
start: m.index,
end: closeIdx + 1,
indent: m[1],
body: source.slice(openIdx + 1, closeIdx)
}
}
// Find the `files: [ ... ]` array inside the `images: { ... }` section.
function locateFilesArray(source) {
const section = matchImagesSection(source)
if (!section) return null
const filesMatch = section.body.match(/(\n\s*)files\s*:\s*\[/)
if (!filesMatch) return null
const arrayKeyStart = section.start + section.body.indexOf(filesMatch[0]) + filesMatch[1].length
const openIdx = source.indexOf('[', arrayKeyStart)
if (openIdx === -1) return null
const closeIdx = matchBracket(source, openIdx, '[', ']')
if (closeIdx === -1) return null
const lineStart = source.lastIndexOf('\n', arrayKeyStart) + 1
const indent = source.slice(lineStart, arrayKeyStart).match(/^[ \t]*/)[0]
return { openIdx, closeIdx, indent }
}
function matchBracket(source, startIdx, open, close) {
let depth = 0
let inSingle = false
let inDouble = false
let inBacktick = false
for (let i = startIdx; i < source.length; i++) {
const c = source[i]
if (c === '\'' && !inDouble && !inBacktick) inSingle = !inSingle
else if (c === '"' && !inSingle && !inBacktick) inDouble = !inDouble
else if (c === '`' && !inSingle && !inDouble) inBacktick = !inBacktick
else if (!inSingle && !inDouble && !inBacktick) {
if (c === open) depth++
else if (c === close) {
depth--
if (depth === 0) return i
}
}
}
return -1
}