UNPKG

purgetss

Version:

A package that simplifies mobile app creation for Titanium developers.

767 lines (643 loc) 24.9 kB
/** * PurgeTSS v7.1 - Purge Command * * Main CLI command for purging unused classes from Titanium Alloy projects. * COPIED from src/index.js during refactorization - NO CHANGES to logic. * * @fileoverview Main purging command - the core functionality of PurgeTSS * @version 7.1.0 * @author César Estrada * @since 2025-06-15 */ import fs from 'fs' import _ from 'lodash' import chalk from 'chalk' import { globSync } from 'glob' import convert from 'xml-js' import traverse from 'traverse' import { alloyProject, cleanClasses } from '../../shared/utils.js' import { projectsAppTSS, projects_AppTSS, srcReset_TSS_File, PurgeTSSPackageJSON, cwd } from '../../shared/constants.js' import { logger, setDebugMode } from '../../shared/logger.js' import { start, finish, localStart, localFinish } from '../utils/cli-helpers.js' import { init } from './init.js' import { getConfigOptions, getConfigFile, ensureConfig } from '../../shared/config-manager.js' // Import purger functions from core modules import { processControllers } from '../../core/analyzers/class-extractor.js' import { runSvgPipeline } from '../../core/svg/index.js' import { purgeTailwind } from '../../core/purger/tailwind-purger.js' import { flushSemanticColors } from '../../shared/semantic-helpers.js' import { purgeFontAwesome, purgeMaterialIcons, purgeMaterialSymbols, purgeFramework7 } from '../../core/purger/icon-purger.js' import { purgeFonts } from '../../core/purger/fonts-purger.js' import { validateClassSyntax } from '../utils/unsupported-class-reporter.js' // Global variables (EXACT copies from original src/index.js) let configOptions = {} let purgingDebug = false /** * Make sure a file exists, create it if it doesn't * COPIED exactly from original makeSureFileExists() function * * @param {string} file - File path * @returns {boolean} - True if file was created */ function makeSureFileExists(file) { if (!fs.existsSync(file)) { fs.writeFileSync(file, '') return true } } /** * Backup original app.tss to _app.tss * COPIED exactly from original backupOriginalAppTss() function */ function backupOriginalAppTss() { if (!fs.existsSync(projects_AppTSS) && fs.existsSync(projectsAppTSS)) { logger.warn('Backing up app.tss into _app.tss\n FROM NOW ON, add, update or delete your original classes in _app.tss') fs.copyFileSync(projectsAppTSS, projects_AppTSS) } else if (!fs.existsSync(projects_AppTSS)) { fs.appendFileSync(projects_AppTSS, '// Empty _app.tss\n') } } /** * Get view file paths from the project * COPIED exactly from original getViewPaths() function */ function getViewPaths() { const viewPaths = [] // ! Parse Views from App viewPaths.push(...globSync(`${cwd}/app/views/**/*.xml`)) // ! Parse Views from Widgets if (configOptions.widgets && fs.existsSync(`${cwd}/app/widgets/`)) { viewPaths.push(...globSync(`${cwd}/app/widgets/**/views/*.xml`)) } // ! Parse Views from Themes if (fs.existsSync(`${cwd}/app/themes/`)) { viewPaths.push(...globSync(`${cwd}/app/themes/**/views/*.xml`)) } return viewPaths } /** * Get controller file paths from the project * COPIED exactly from original getControllerPaths() function */ function getControllerPaths() { const controllerPaths = [] // ! Parse Controllers from App controllerPaths.push(...globSync(`${cwd}/app/controllers/**/*.js`)) // ! Parse Controllers from Widgets if (configOptions.widgets && fs.existsSync(`${cwd}/app/widgets/`)) { controllerPaths.push(...globSync(`${cwd}/app/widgets/**/controllers/*.js`)) } return controllerPaths } /** * Encode HTML entities in string * COPIED exactly from original encodeHTML() function */ function encodeHTML(str) { const code = { '&': '&amp;' } return str.replace(/&/gm, i => code[i]) } /** * Validate XML syntax before processing * Returns true if valid, throws error if invalid */ function validateXML(xmlText, filePath) { // Pre-validate: check for common Alloy XML malformations // preValidateXML will throw a special error that should be caught at a higher level const preValidationError = preValidateXML(xmlText, filePath) if (preValidationError) { throw preValidationError } try { convert.xml2json(encodeHTML(xmlText), { compact: true }) return true } catch (error) { // Parse line/column from error message const lineMatch = error.message.match(/Line:\s*(\d+)/) const columnMatch = error.message.match(/Column:\s*(\d+)/) const charMatch = error.message.match(/Char:\s*(.+)/) const lines = xmlText.split('\n') const parserLine = lineMatch ? parseInt(lineMatch[1]) : null // Try to find the real source of the error by scanning for suspicious tags. // xml2json often reports errors far from the actual problem (e.g. at EOF) // because it only notices the mismatch when nesting fails to close. const suspectLine = findSuspectLine(lines) const reportLine = suspectLine || parserLine let errorMessage = chalk.red(`\n::PurgeTSS:: XML Syntax Error\n`) + chalk.yellow(`File: "${filePath}"\n`) if (reportLine) { errorMessage += chalk.yellow(`Error near line: ${reportLine}\n\n`) // Extract and show context: line before, error line, and line after const startLine = Math.max(0, reportLine - 2) const endLine = Math.min(lines.length, reportLine + 2) errorMessage += chalk.gray('Context:\n') for (let i = startLine; i < endLine; i++) { const lineNumDisplay = i + 1 const isTargetLine = (lineNumDisplay === reportLine) const prefix = isTargetLine ? chalk.red('>>>') : chalk.gray(' ') const lineContent = lines[i] || '' errorMessage += `${prefix} ${chalk.gray(String(lineNumDisplay).padStart(3, ' '))}: ${lineContent}\n` } errorMessage += '\n' errorMessage += chalk.red(`Error: Unmatched or malformed tag (missing < or >)\n`) } else { errorMessage += chalk.yellow(`Error details: ${error.message}\n`) } errorMessage += chalk.gray('\nTip: Check for tags missing opening < or closing >\n') throw new Error(errorMessage) } } // Scan XML lines for common malformations that xml2json can't pinpoint. // Returns the 1-based line number of the first suspect, or null. function findSuspectLine(lines) { for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim() if (!trimmed) continue // "--" inside XML comments (illegal in XML spec) if (trimmed.includes('<!--')) { const commentBody = trimmed.replace(/<!--/, '').replace(/-->.*$/, '') if (/--/.test(commentBody)) return i + 1 } if (trimmed.startsWith('<!--')) continue // Opening tag without tag name: "< class=..." if (/^<\s+\w+=/.test(trimmed)) return i + 1 // Double slash in closing tag: "<//View>" if (/^<\/\/\w+>/.test(trimmed)) return i + 1 // Closing tag with extra characters: "</View/>" or "</View >>" etc. if (/^<\/[a-zA-Z0-9_]+[^>]+>/.test(trimmed) && !/^<\/[a-zA-Z0-9_:]+\s*>/.test(trimmed)) return i + 1 // Opening < immediately followed by non-alpha, non-slash, non-! (e.g. "< >" or "<=>") if (/^<[^a-zA-Z/!?\s]/.test(trimmed)) return i + 1 // Space between < and tag name: "< View" if (/^<\s+[A-Z]/.test(trimmed)) return i + 1 // Backslash instead of forward slash: \> if (/\\>/.test(trimmed)) return i + 1 // Double closing bracket: >> if (/>>/.test(trimmed)) return i + 1 // Unclosed tag: starts with < but doesn't end with > and next line starts a new tag if (trimmed.startsWith('<') && !trimmed.startsWith('</') && !trimmed.endsWith('>')) { const nextTrimmed = (lines[i + 1] || '').trim() if (nextTrimmed.startsWith('<')) return i + 1 } } return null } /** * Pre-validate XML for common Alloy malformations * Returns Error if problem found, null if OK */ function preValidateXML(xmlText, filePath) { const lines = xmlText.split('\n') const relativePath = filePath.replace(process.cwd() + '/', '') for (let i = 0; i < lines.length; i++) { const line = lines[i] const trimmed = line.trim() // Skip empty lines and Alloy root tag if (!trimmed || trimmed.startsWith('<Alloy')) { continue } // Check for "--" inside XML comments (illegal in XML spec) // e.g. <!-- Section: --modules Option --> is invalid because of the "--" before "modules" if (trimmed.includes('<!--')) { const commentBody = trimmed.replace(/<!--/, '').replace(/-->.*$/, '') if (/--/.test(commentBody)) { const dashMatch = commentBody.match(/--(\S*)/) const offender = dashMatch ? `--${dashMatch[1]}` : '--' throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `XML comment contains illegal "--" sequence ("${offender}")`, fix: `Replace "--" with "—" (em-dash) or reword the comment to avoid double dashes` }) } continue } // Check for opening tag without tag name: "< class=..." or "< id=..." if (/^<\s+(class|id|onClick|onOpen|onClose|height|width|backgroundColor|color|font|text|hintText|imageUrl)=/.test(trimmed)) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: 'Opening tag is missing its tag name', fix: `Add the tag name after "<", e.g. "<View ${trimmed.slice(1).trim()}"` }) } // Check for double slash in closing tags: "<//View>" const doubleSlash = trimmed.match(/^<\/\/([a-zA-Z0-9_]+)>/) if (doubleSlash) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Closing tag "</${doubleSlash[1]}>" has an extra "/"`, fix: `Change "${trimmed}" to "</${doubleSlash[1]}>"` }) } // Check for tags without opening < (common mistake: Label, View, etc. without <) // Pattern: "Label id=", "View class=","Button onClick=", etc. WITHOUT opening < if (/^[A-Z][a-zA-Z0-9_]+\s+(id|class|onClick|onOpen|onClose|height|width|backgroundColor|color|font|text|hintText|imageUrl)=/.test(trimmed)) { const tagName = trimmed.split(/\s+|[>=]/)[0] throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Tag "<${tagName}>" is missing opening "<"`, fix: `Change "${tagName}" to "<${tagName}>"` }) } // Check for closing tag with extra slash: "</View/>" const closingExtraSlash = trimmed.match(/^<\/([a-zA-Z0-9_]+)\/>/) if (closingExtraSlash) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Closing tag "</${closingExtraSlash[1]}>" has an extra "/" at the end`, fix: `Change "${trimmed}" to "</${closingExtraSlash[1]}>"` }) } // Check for space between < and tag name: "< View class=..." const spaceBeforeName = trimmed.match(/^<\s+([A-Z][a-zA-Z0-9_]+)\s/) if (spaceBeforeName) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Extra space between "<" and tag name "${spaceBeforeName[1]}"`, fix: `Change "< ${spaceBeforeName[1]}" to "<${spaceBeforeName[1]}"` }) } // Check for attribute without = sign: <View class"foo"> const attrNoEquals = trimmed.match(/^<([a-zA-Z0-9_]+)\s+[a-zA-Z]+"/) if (attrNoEquals && !trimmed.includes('=')) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Attribute is missing "=" sign`, fix: `Check attributes in this tag — each one needs an "=" before its value` }) } // Check for backslash in self-closing tag: <Label text="hi" \> if (/\\>/.test(trimmed)) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Tag has a backslash "\\>" instead of forward slash "/>"`, fix: `Change "\\>" to "/>"` }) } // Check for double closing bracket: <View class="foo">> if (/>>/.test(trimmed) && !trimmed.includes('<!--')) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Tag has a double ">>" closing bracket`, fix: `Remove the extra ">"` }) } // Check for unclosed opening tag (line ends without > and next line starts a new tag) if (trimmed.startsWith('<') && !trimmed.startsWith('</') && !trimmed.startsWith('<!--') && !trimmed.endsWith('>') && !trimmed.endsWith('-->')) { const nextTrimmed = (lines[i + 1] || '').trim() if (nextTrimmed.startsWith('<')) { throwPreValidationError({ relativePath, lineNumber: i + 1, lineContent: trimmed, message: `Tag is missing its closing ">"`, fix: `Add ">" at the end of this tag` }) } } } return false } function throwPreValidationError({ relativePath, lineNumber, lineContent, message, fix }) { const error = new Error(`XML Syntax Error in ${relativePath}:${lineNumber}`) error.isPreValidationError = true error.filePath = relativePath error.lineNumber = lineNumber error.lineContent = lineContent logger.block( chalk.red('XML Syntax Error'), `File: ${chalk.yellow(`"${relativePath}"`)}`, `Line: ${chalk.yellow(lineNumber)}`, `Content: ${chalk.yellow(`"${lineContent}"`)}`, '', chalk.red(message), '', `${chalk.green('Fix:')} ${fix}` ) throw error } /** * Extract classes from file content * COPIED exactly from original extractClasses() function */ function extractClasses(currentText, currentFile) { try { const jsontext = convert.xml2json(encodeHTML(currentText), { compact: true }) return traverse(JSON.parse(jsontext)).reduce(function (acc, value) { if (this.key === 'class' || this.key === 'id') acc.push(value.split(' ')) return acc }, []) } catch (error) { throw chalk.red(`::PurgeTSS:: Error processing: "${currentFile}"\n`, error) } } /** * Filter invalid characters from class names * COPIED exactly from original filterCharacters() function */ function filterCharacters(uniqueClass) { if (uniqueClass.length === 1 && !/^[a-zA-Z_]/.test(uniqueClass)) { return false } return uniqueClass !== '(' && isNaN(uniqueClass.charAt(0)) && !uniqueClass.startsWith('--') && !uniqueClass.startsWith(',') && !uniqueClass.startsWith('!') && !uniqueClass.startsWith('(') && !uniqueClass.startsWith('[') && !uniqueClass.startsWith('{') && !uniqueClass.startsWith('/') && !uniqueClass.startsWith('\\') && !uniqueClass.startsWith('#') && !uniqueClass.startsWith('$') && !uniqueClass.startsWith('Ti.') && !uniqueClass.includes('\\n') && !uniqueClass.includes('=') && !uniqueClass.includes('http') && !uniqueClass.includes('L(') && !uniqueClass.includes('www') && !uniqueClass.endsWith(',') && !uniqueClass.endsWith('.') && !uniqueClass.endsWith('/') } /** * Get unique classes from all project files * COPIED exactly from original getUniqueClasses() function */ function getUniqueClasses() { localStart() const allClasses = [] const viewPaths = getViewPaths() const configFile = getConfigFile() _.each(viewPaths, viewPath => { const file = fs.readFileSync(viewPath, 'utf8') if (file) { // Validate XML syntax first, regardless of mode validateXML(file, viewPath) // Then extract classes based on mode if (configFile.purge.mode === 'all') { allClasses.push(file.match(/[^<>"'`\s]*[^<>"'`\s:]/g)) } else { allClasses.push(extractClasses(file, viewPath)) } } }) const controllerPaths = getControllerPaths() _.each(controllerPaths, controllerPath => { const data = fs.readFileSync(controllerPath, 'utf8') if (data) allClasses.push(processControllers(data)) }) if (configOptions.safelist) _.each(configOptions.safelist, safe => allClasses.push(safe)) const uniqueClasses = [] _.each(_.uniq(_.flattenDeep(allClasses)).sort(), uniqueClass => { if (filterCharacters(uniqueClass)) uniqueClasses.push(uniqueClass) }) localFinish('Get Unique Classes') return uniqueClasses.sort() } /** * Copy reset template and _app.tss content * COPIED exactly from original copyResetTemplateAnd_appTSS() function */ function copyResetTemplateAnd_appTSS() { localStart() logger.info('Copying Reset styles...') let tempPurged = `// PurgeTSS v${PurgeTSSPackageJSON.version}\n` + fs.readFileSync(srcReset_TSS_File, 'utf8') if (fs.existsSync(projects_AppTSS)) { const appTSSContent = fs.readFileSync(projects_AppTSS, 'utf8') if (appTSSContent.length) { logger.info('Copying', chalk.yellow('_app.tss'), 'styles...') tempPurged += '\n// _app.tss styles\n' + appTSSContent } } localFinish('Copying Reset and ' + chalk.yellow('_app.tss') + ' styles...') return tempPurged } /** * Get all files recursively from a directory * COPIED exactly from original getFiles() function */ function getFiles(dir) { return fs.readdirSync(dir).flatMap((item) => { const thePath = `${dir}/${item}` if (fs.statSync(thePath).isDirectory()) { return getFiles(thePath) } return thePath }) } /** * Extract classes only (for missing classes detection) * COPIED exactly from original extractClassesOnly() function */ function extractClassesOnly(currentText, currentFile) { try { const jsontext = convert.xml2json(encodeHTML(currentText), { compact: true }) return traverse(JSON.parse(jsontext)).reduce(function (acc, value) { if (this.key === 'class' || this.key === 'classes' || this.key === 'icon' || this.key === 'activeIcon') acc.push(value.split(' ')) return acc }, []) } catch (error) { throw chalk.red(`::PurgeTSS:: Error processing: "${currentFile}"\n`, error) } } /** * Get classes only from XML files * COPIED exactly from original getClassesOnlyFromXMLFiles() function */ function getClassesOnlyFromXMLFiles() { const allClasses = [] const viewPaths = getViewPaths() _.each(viewPaths, viewPath => { const file = fs.readFileSync(viewPath, 'utf8') // Validate XML before processing validateXML(file, viewPath) allClasses.push(extractClassesOnly(file, viewPath)) }) const uniqueClasses = [] _.each(_.uniq(_.flattenDeep(allClasses)).sort(), uniqueClass => { if (filterCharacters(uniqueClass)) uniqueClasses.push(uniqueClass) }) return uniqueClasses.sort() } /** * Find missing classes in the purged output * COPIED exactly from original findMissingClasses() function */ function findMissingClasses(tempPurged) { // ! Get Styles from App - Minus `app.tss` if (fs.existsSync(`${cwd}/app/styles`)) { _.each(getFiles(`${cwd}/app/styles`).filter(file => file.endsWith('.tss') && !file.endsWith('app.tss') && !file.endsWith('_app.tss')), file => { tempPurged += '\n' + fs.readFileSync(file, 'utf8') }) } // ! Get Styles from Widgets if (configOptions.widgets && fs.existsSync(`${cwd}/app/widgets/`)) { _.each(getFiles(`${cwd}/app/widgets`).filter(file => file.endsWith('.tss')), file => { tempPurged += '\n' + fs.readFileSync(file, 'utf8') }) } // ! Get Views from Themes if (fs.existsSync(`${cwd}/app/themes/`)) { _.each(getFiles(`${cwd}/app/themes`).filter(file => file.endsWith('.tss')), file => { tempPurged += '\n' + fs.readFileSync(file, 'utf8') }) } if (configOptions.safelist) { _.each(configOptions.safelist, safe => { tempPurged += safe + '\n' }) } const classesFromXmlFiles = getClassesOnlyFromXMLFiles().filter(item => !tempPurged.includes(item)) let classesFromJsFiles = [] const controllerPaths = getControllerPaths() _.each(controllerPaths, controllerPath => { const data = fs.readFileSync(controllerPath, 'utf8') if (data) classesFromJsFiles.push(processControllers(data)) }) const reservedWords = 'Alloy.isTablet Alloy.isHandheld ? ,' classesFromJsFiles = [...new Set([...classesFromJsFiles.flat().filter(item => !reservedWords.includes(item))])] classesFromJsFiles = classesFromJsFiles.filter(item => !reservedWords.includes(item)) return [...new Set([...classesFromJsFiles.filter(item => !tempPurged.includes(item)), ...classesFromXmlFiles])] } /** * Process missing classes and add them as comments * COPIED exactly from original processMissingClasses() function */ function processMissingClasses(tempPurged) { let unusedOrMissingClasses = '' if (configOptions.missing) { const missingClasses = findMissingClasses(tempPurged) if (missingClasses.length > 0) { unusedOrMissingClasses += '\n' unusedOrMissingClasses += '// Unused or unsupported classes\n' _.each(missingClasses, (missingClass) => { unusedOrMissingClasses += `// '.${missingClass}': { }\n` }) } } return unusedOrMissingClasses } /** * Save file to disk * COPIED exactly from original saveFile() function * * @param {string} file - File path * @param {string} data - File content */ function saveFile(file, data) { localStart() fs.writeFileSync(file, data, err => { throw err }) localFinish(`Saving ${chalk.yellow('app.tss')}...`) } /** * Main purge command - removes unused classes from Titanium Alloy projects * COPIED exactly from original purgeClasses() function * * @param {Object} options - Command options * @returns {boolean} - Success status */ export async function purgeClasses(options) { // Initialize configOptions first (includes auto-migration) configOptions = getConfigOptions() if (!alloyProject()) { return false } purgingDebug = options.debug // Propagate to the logger module so localFinish (cli-helpers.js) sees it. // The local `purgingDebug` is still needed for the purger callees below. setDebugMode(options.debug) const recentlyCreated = makeSureFileExists(projectsAppTSS) if (Date.now() > (fs.statSync(projectsAppTSS).mtimeMs + 2000) || recentlyCreated) { start() logger.startSection() try { // Explicit header so every purge run shows which project is being // processed — mirrors the Auto-Purging line emitted by the alloy.jmk hook. logger.info('Purging', chalk.yellow(cwd)) init(options) backupOriginalAppTss() let uniqueClasses try { uniqueClasses = getUniqueClasses() // Pre-validate class syntax. Halts on inverted negatives, square // brackets, empty parens, etc. — but NOT on generic unknown classes // (those fall through silently to "// Unused or unsupported classes"). validateClassSyntax({ classes: uniqueClasses, viewPaths: getViewPaths(), controllerPaths: getControllerPaths() }) } catch (error) { // Handle pre-validation errors (XML syntax errors detected before // parsing) and class-syntax errors (authoring mistakes detected // before purging). In both cases the error block was already // printed; just exit cleanly. if (error.isPreValidationError || error.isClassSyntaxError) { // eslint-disable-next-line n/no-process-exit process.exit(1) } // Re-throw other errors throw error } let tempPurged = copyResetTemplateAnd_appTSS() tempPurged += purgeTailwind(uniqueClasses, purgingDebug) const cleanUniqueClasses = cleanClasses(uniqueClasses) tempPurged += purgeFontAwesome(uniqueClasses, cleanUniqueClasses, purgingDebug) tempPurged += purgeMaterialIcons(uniqueClasses, cleanUniqueClasses, purgingDebug) tempPurged += purgeMaterialSymbols(uniqueClasses, cleanUniqueClasses, purgingDebug) tempPurged += purgeFramework7(uniqueClasses, cleanUniqueClasses, purgingDebug) tempPurged += purgeFonts(uniqueClasses, cleanUniqueClasses, purgingDebug) tempPurged += processMissingClasses(tempPurged) saveFile(projectsAppTSS, tempPurged) logger.file('app.tss') // Post-step: compile SVG references found in views/controllers into // multi-density PNGs sized from the just-resolved app.tss classes. await runSvgPipeline({ tssContent: tempPurged, viewPaths: getViewPaths(), controllerPaths: getControllerPaths(), logger }) finish() } finally { flushSemanticColors() logger.endSection() } return true } else { logger.warn('Project purged less than 2 seconds ago!') return true } }