UNPKG

purgetss

Version:

A package that simplifies mobile app creation for Titanium developers.

514 lines (460 loc) 19.8 kB
/** * PurgeTSS v7.1 - Shades Commands * * CLI commands for color shade generation and management. * Extracted from src/index.js during refactorization. * * @fileoverview Color shades and colorModule commands * @version 7.1.0 * @author César Estrada * @since 2025-06-15 */ import fs from 'fs' import path from 'path' import chalk from 'chalk' import { createRequire } from 'module' import { alloyProject, makeSureFolderExists } from '../../shared/utils.js' import { projectsConfigJS, projectsLibFolder } from '../../shared/constants.js' import { getSemanticColorsPath, getSemanticColorsRelPath } from '../utils/project-detection.js' import { logger } from '../../shared/logger.js' import { ensureConfig, getConfigFile } from '../../shared/config-manager.js' import { cleanDoubleQuotes } from '../utils/file-operations.js' // Create require for ESM compatibility const require = createRequire(import.meta.url) /** * Create color module with all colors from config * Maintains exact same logic as original colorModule() function * * @returns {boolean} Success status */ export function colorModule() { if (!alloyProject()) { return false } ensureConfig() const colorModuleConfigFile = require(projectsConfigJS) makeSureFolderExists(projectsLibFolder) const mainColors = { ...colorModuleConfigFile.theme.colors, ...colorModuleConfigFile.theme.extend.colors } fs.writeFileSync( `${projectsLibFolder}/purgetss.colors.js`, 'module.exports = ' + cleanDoubleQuotes(mainColors, {}), 'utf8', err => { throw err } ) logger.info(`All colors copied to ${chalk.yellow('lib/purgetss.colors.js')}`) return true } /** * Check if color module exists and update if needed * Maintains exact same logic as original checkIfColorModule() function */ export function checkIfColorModule() { if (fs.existsSync(`${projectsLibFolder}/purgetss.colors.js`)) { colorModule() } } /** * Build the "missing hex" error message shown when the user invokes `shades` * or `semantic` without a hex argument and without `--random`. The common * cause is an unquoted `#` on the command line — bash/zsh treat everything * from `#` onward as a comment, so `pt shades #6A2489` reaches the CLI as * `pt shades` with no argument. We surface the shell behavior explicitly so * the user isn't mystified by a silent random color. */ export function missingHexMessage(commandName) { return [ chalk.red('No hex color provided.'), `If you typed ${chalk.yellow(`pt ${commandName} #6A2489`)} (unquoted), your shell stripped ${chalk.yellow('#6A2489')}`, 'as a comment — the CLI never received it.', '', 'Try one of these instead:', ` ${chalk.green(`pt ${commandName} '#6A2489'`)} ${chalk.gray('(quoted)')}`, ` ${chalk.green(`pt ${commandName} 6A2489`)} ${chalk.gray('(no hash)')}`, ` ${chalk.green(`pt ${commandName} --random`)} ${chalk.gray('(random color)')}` ] } /** * Main shades command - generates color shades from hex codes * Maintains exact same logic as original shades() function * * @param {Object} args - Command arguments * @param {string} args.hexcode - Hex color code * @param {string} args.name - Color name * @param {Object} options - Command options * @param {boolean} options.random - Generate random color * @param {string} options.name - Color name from options * @param {boolean} options.override - Override existing colors * @param {boolean} options.tailwind - Tailwind output format * @param {boolean} options.json - JSON output format * @param {boolean} options.log - Log output * @param {boolean} options.quotes - Use quotes in output * @param {boolean} options.single - Single color format * @returns {Promise<boolean>} Success status */ export async function shades(args, options) { if (!args.hexcode && !options.random) { logger.block(...missingHexMessage('shades')) return false } const chroma = (await import('chroma-js')).default const referenceColorFamilies = (await import('../../../lib/color-shades/tailwindColors.js')).default const generateColorShades = (await import('../../../lib/color-shades/generateColorShades.js')).default const colorFamily = options.random ? generateColorShades(chroma.random(), referenceColorFamilies) : generateColorShades(args.hexcode, referenceColorFamilies) if (args.name) colorFamily.name = args.name else if (options.name) colorFamily.name = options.name colorFamily.name = colorFamily.name.replace(/'/g, '').replace(/\//g, '').replace(/\s+/g, ' ') const silent = options.tailwind || options.json || options.log const inAlloyProject = !silent && alloyProject(silent) const colorObject = createColorObject(colorFamily, colorFamily.hexcode, options) if (inAlloyProject) { ensureConfig() const configFile = getConfigFile() if (options.override) { if (!configFile.theme.colors) configFile.theme.colors = {} configFile.theme.colors[colorObject.name] = colorObject.shades if (configFile.theme.extend.colors) { if (configFile.theme.extend.colors[colorObject.name]) delete configFile.theme.extend.colors[colorObject.name] if (Object.keys(configFile.theme.extend.colors).length === 0) delete configFile.theme.extend.colors } } else { if (!configFile.theme.extend.colors) configFile.theme.extend.colors = {} configFile.theme.extend.colors[colorObject.name] = colorObject.shades if (configFile.theme.colors) { if (configFile.theme.colors[colorObject.name]) delete configFile.theme.colors[colorObject.name] if (Object.keys(configFile.theme.colors).length === 0) delete configFile.theme.colors } } fs.writeFileSync(projectsConfigJS, 'module.exports = ' + cleanDoubleQuotes(configFile, options), 'utf8', err => { throw err }) checkIfColorModule() logger.info(`${chalk.hex(colorFamily.hexcode).bold(`"${colorFamily.name}"`)} (${chalk.bgHex(colorFamily.hexcode)(colorFamily.hexcode)}) saved in`, chalk.yellow('config.js')) } else if (options.json) { logger.info(`${chalk.hex(colorFamily.hexcode).bold(`"${colorFamily.name}"`)} (${chalk.bgHex(colorFamily.hexcode)(colorFamily.hexcode)})\n${JSON.stringify(colorObject, null, 2)}`) } else { if (options.tailwind) delete colorObject.shades.default logger.info(`${chalk.hex(colorFamily.hexcode).bold(`"${colorFamily.name}"`)} (${chalk.bgHex(colorFamily.hexcode)(colorFamily.hexcode)})\n${cleanDoubleQuotes({ colors: { [colorObject.name]: colorObject.shades } }, options)}`) } return true } /** * Create color object from color family and options * Maintains exact same logic as original createColorObject() function * * @param {Object} family - Color family object * @param {string} hexcode - Hex color code * @param {Object} options - Command options * @returns {Object} Color object */ function createColorObject(family, hexcode, options) { const colors = {} const name = family.name.toLowerCase().split(' ').join('-') if (options.json) { const shades = {} colors.global = {} shades[name] = hexcode family.shades.forEach((shade) => { shades[`${name}-${shade.number}`] = shade.hexcode }) colors.global.colors = (options.single) ? { [name]: hexcode } : shades } else if (options.single) { colors.name = name colors.shades = hexcode } else { const shades = { default: hexcode } family.shades.forEach((shade) => { shades[shade.number] = shade.hexcode }) colors.name = name colors.shades = shades } return colors } /** * Convert a kebab-case color name to camelCase for semantic JSON keys. * Example: 'amazon-green' → 'amazonGreen'. Leaves single-word names untouched. * Exported because the `semantic` command imports it for shade-conflict detection. * * @param {string} kebabName - kebab-case color name * @returns {string} camelCase name */ export function toCamelCase(kebabName) { return kebabName.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()) } /** * Validate and normalize the --alpha CLI input. * Returns undefined when no alpha was supplied; throws on invalid input. * Per the Titanium semantic.colors.json spec, alpha is a string in 0.0-100.0 * (integer or float). See ti-expert/references/theming.md. * * @param {string|number|undefined} input - raw CLI value * @returns {string|undefined} normalized alpha as string, or undefined */ export function normalizeAlpha(input) { if (input === undefined || input === null || input === '') return undefined const n = Number(input) if (!Number.isFinite(n) || n < 0 || n > 100) { throw new Error(`--alpha must be a number between 0 and 100 (got "${input}")`) } return String(n) } /** * Wrap a hex value in the Titanium semantic-color extended form when alpha is * present, or return the bare hex string otherwise. Both forms are valid per * the Titanium spec (a single semantic.colors.json may mix them). * * @param {string} hex - hex color * @param {string|undefined} alpha - normalized alpha string, or undefined * @returns {string|{color: string, alpha: string}} */ function wrapValue(hex, alpha) { return alpha === undefined ? hex : { color: hex, alpha } } export const SHADE_NUMBERS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'] const SHADE_SUFFIX_RE = new RegExp(`^(.+?)(${SHADE_NUMBERS.join('|')})$`) /** * Detect when a name like 'amazon50' refers to an existing palette shade. * The `semantic --single` command uses this to decide between in-place update * (when the name matches a shade of a palette already declared in config) and * the normal full write flow (when the name is independent). Returns the * conflict descriptor (parentName + shadeNum + camelKey) or null. * * Checks BOTH the kebab and camel forms — kebab matches config.cjs keys, * camel matches semantic.colors.json keys for multi-word families like * `amazonGreen500`. * * @param {Object} configFile - parsed purgetss/config.cjs * @param {string} kebabName - kebab-case name (config.cjs key style) * @param {string} camelName - camelCase name (semantic.colors.json key style) * @returns {{ parentName: string, shadeNum: string, camelKey: string }|null} */ export function detectFamilyShadeConflict(configFile, kebabName, camelName) { for (const candidate of [kebabName, camelName]) { const match = candidate.match(SHADE_SUFFIX_RE) if (!match) continue const [, parentName, shadeNum] = match const extend = configFile?.theme?.extend?.colors?.[parentName] const main = configFile?.theme?.colors?.[parentName] const parentMapping = extend ?? main if (parentMapping && typeof parentMapping === 'object' && parentMapping[shadeNum] !== undefined) { return { parentName, shadeNum, camelKey: candidate === kebabName ? camelName : candidate } } } return null } /** * Remove all keys in `existing` that belong to the named semantic family — * the bare camelName (single form) plus camelName + each of the 11 shade * numbers (palette form). Keys outside the family are left untouched, so * unrelated palettes and manually-defined entries (`surfaceColor`, etc.) * survive a re-run. This makes `shades --semantic` consistent with the * non-semantic flow: regenerating a family fully replaces it. * * @param {Object} existing - parsed semantic.colors.json * @param {string} camelName - camelCase family name (e.g., 'amazon', 'glassSurface') * @returns {Object} new object without the family's keys */ export function stripFamilyKeys(existing, camelName) { const familyKeys = new Set([camelName, ...SHADE_NUMBERS.map(n => `${camelName}${n}`)]) const cleaned = {} for (const [key, value] of Object.entries(existing)) { if (!familyKeys.has(key)) cleaned[key] = value } return cleaned } /** * Build a semantic palette with mirror-by-index light/dark inversion, * anchored at shade 500. Pure function — no I/O. * * For each slot at index i in the sorted (50→950) shade list: * light = shade[last - i].hexcode (inverted) * dark = shade[i].hexcode (original) * Slot 500 is the anchor (same light/dark). * * When alpha is provided, every value is wrapped as { color, alpha }. * * @param {Object} family - Color family from generateColorShades() * @param {string} kebabName - Normalized color name (e.g., 'amazon-green') * @param {string|undefined} alpha - Optional alpha string (0-100) * @returns {{ semanticEntries: Object, configMapping: Object }} */ export function buildSemanticPalette(family, kebabName, alpha) { const camelName = toCamelCase(kebabName) const sorted = [...family.shades].sort((a, b) => a.number - b.number) const semanticEntries = {} const configMapping = {} sorted.forEach((shade, i) => { const mirror = sorted[sorted.length - 1 - i] const key = `${camelName}${shade.number}` semanticEntries[key] = { light: wrapValue(shade.hexcode, alpha), dark: wrapValue(mirror.hexcode, alpha) } configMapping[shade.number] = key }) return { semanticEntries, configMapping } } /** * Build a single-entry purpose-based semantic color from explicit per-mode * hex values. Used by `pt semantic --single`. Light and dark are independent; * if `darkHex` is omitted the same value is used for both modes (useful for * overlays/glass surfaces where alpha is the variation, not hue). * * The JSON key is taken VERBATIM — caller is responsible for passing the exact * camelCase form expected in semantic.colors.json (e.g. 'surfaceColor'), since * Titanium's conventions are case-sensitive and the design-layer class name * (config.cjs) is a separate decision that the caller owns. * * @param {string} camelKey - exact JSON key (e.g. 'surfaceColor', 'overlay') * @param {string} lightHex - hex for light mode (required) * @param {string|undefined} darkHex - hex for dark mode (defaults to lightHex) * @param {string|undefined} alpha - normalized alpha string (0-100), wraps both modes * @returns {{ semanticEntries: Object }} */ export function buildSingleSemantic(camelKey, lightHex, darkHex, alpha) { const dark = darkHex ?? lightHex return { semanticEntries: { [camelKey]: { light: wrapValue(lightHex, alpha), dark: wrapValue(dark, alpha) } } } } /** * Wrap a hex value with optional alpha — exposed so the `semantic` command * can format individual entries without re-importing the building blocks. * * @param {string} hex * @param {string|undefined} alpha * @returns {string|{color:string,alpha:string}} */ export function wrapHexWithAlpha(hex, alpha) { return wrapValue(hex, alpha) } /** * Read + parse semantic.colors.json if present. Returns {} for missing/empty. * Rethrows on invalid JSON after logging, to protect user data. */ function readSemanticJSON() { const semanticPath = getSemanticColorsPath() if (!fs.existsSync(semanticPath)) return {} const raw = fs.readFileSync(semanticPath, 'utf8') if (!raw.trim()) return {} try { return JSON.parse(raw) } catch (err) { logger.info(`${chalk.red('Warning:')} ${chalk.yellow(getSemanticColorsRelPath())} is not valid JSON. Aborting to avoid data loss.`) throw err } } /** * Write the semantic-colors JSON (caller provides the fully-merged object). * Ensures the parent folder (app/assets/ for Alloy, Resources/ for Classic) * exists first. */ function persistSemanticJSON(data) { const semanticPath = getSemanticColorsPath() makeSureFolderExists(path.dirname(semanticPath)) fs.writeFileSync(semanticPath, JSON.stringify(data, null, 2) + '\n', 'utf8') } /** * Write new entries into semantic.colors.json, replacing any prior keys that * belong to the same camelName family (single form + 11 shade form). Unrelated * keys survive untouched. Used by both palette and single-mode fresh writes. * * @param {Object} semanticEntries - new entries to merge in * @param {string} camelName - family name (for stripping) */ export function writeSemanticJSON(semanticEntries, camelName) { const existing = readSemanticJSON() const cleaned = stripFamilyKeys(existing, camelName) const merged = { ...cleaned, ...semanticEntries } persistSemanticJSON(merged) } /** * Update one entry in semantic.colors.json in place, preserving the existing * key order. Used when `pt semantic --single` is invoked with a name that * matches an existing palette shade — the user is editing a shade value, not * creating a new top-level color, so we must NOT touch config.cjs (the * palette already maps to this key) and we must NOT shift the key to the end. * * Spread-merge with an existing key updates the value while keeping the * original insertion position (V8/spec object key ordering). * * @param {string} camelKey - the JSON key to update (already camelCase) * @param {string|{color:string,alpha:string}} value - new value (bare hex or wrapped form) */ export function updateSemanticEntry(camelKey, value) { const existing = readSemanticJSON() const updated = { ...existing, [camelKey]: value } persistSemanticJSON(updated) } /** * Write the family-name → configMapping entry into purgetss/config.cjs under * theme.extend.colors (or theme.colors when --override is set). Cleans up the * opposite branch so the mapping doesn't duplicate. * * @param {string} kebabName - key in theme.extend.colors / theme.colors * @param {Object|string} configMapping - palette object or single string * @param {Object} options - CLI options (reads .override and .quotes) */ export function writeConfigMapping(kebabName, configMapping, options) { const configFile = getConfigFile() if (options.override) { if (!configFile.theme.colors) configFile.theme.colors = {} configFile.theme.colors[kebabName] = configMapping if (configFile.theme.extend.colors) { if (configFile.theme.extend.colors[kebabName]) delete configFile.theme.extend.colors[kebabName] if (Object.keys(configFile.theme.extend.colors).length === 0) delete configFile.theme.extend.colors } } else { if (!configFile.theme.extend.colors) configFile.theme.extend.colors = {} configFile.theme.extend.colors[kebabName] = configMapping if (configFile.theme.colors) { if (configFile.theme.colors[kebabName]) delete configFile.theme.colors[kebabName] if (Object.keys(configFile.theme.colors).length === 0) delete configFile.theme.colors } } fs.writeFileSync(projectsConfigJS, 'module.exports = ' + cleanDoubleQuotes(configFile, options), 'utf8', err => { throw err }) checkIfColorModule() } /** * Combined write for palette mode (JSON entries + config mapping in one call). * Single mode uses writeSemanticJSON alone — class naming is a design-layer * decision that belongs to the user, not auto-derived from the semantic key. * * @param {Object} semanticEntries - Output of buildSemanticPalette().semanticEntries * @param {string} kebabName - Color family name as config.cjs key * @param {Object} configMapping - palette's 11-shade mapping * @param {Object} options - CLI options (reads .override and .quotes) */ export function writeSemanticColors(semanticEntries, kebabName, configMapping, options) { const camelName = toCamelCase(kebabName) writeSemanticJSON(semanticEntries, camelName) writeConfigMapping(kebabName, configMapping, options) } /** * Export for CLI usage */ export default { colorModule, checkIfColorModule, shades, missingHexMessage, toCamelCase, buildSemanticPalette, buildSingleSemantic, detectFamilyShadeConflict, updateSemanticEntry, writeSemanticJSON, writeConfigMapping, wrapHexWithAlpha, normalizeAlpha, stripFamilyKeys, writeSemanticColors }