UNPKG

alloy

Version:

Appcelerator Titanium MVC Framework

633 lines (571 loc) 19.4 kB
var fs = require('fs'), path = require('path'), _ = require('../../lib/alloy/underscore')._, U = require('../../utils'), CU = require('./compilerUtils'), optimizer = require('./optimizer'), grammar = require('../../grammar/tss'), logger = require('../../logger'), BuildLog = require('./BuildLog'), CONST = require('../../common/constants'), deepExtend = require('node.extend'); // constants var GLOBAL_STYLE_CACHE = 'global_style_cache.json'; var STYLE_ALLOY_TYPE = '__ALLOY_TYPE__'; var STYLE_EXPR_PREFIX = exports.STYLE_EXPR_PREFIX = '__ALLOY_EXPR__--'; var STYLE_REGEX = /^\s*([\#\.]{0,1})([^\[]+)(?:\[([^\]]+)\])*\s*$/; var EXPR_REGEX = new RegExp('^' + STYLE_EXPR_PREFIX + '(.+)'); var BINDING_REGEX = /\{([^:}]+)\}(?!\})/; var VALUES = { ID: 100000, CLASS: 10000, API: 1000, TSSIF: 500, PLATFORM: 100, FORMFACTOR: 10, SUM: 1, THEME: 0.9, ORDER: 0.0001 }; var DATEFIELDS = [ 'minDate', 'value', 'maxDate' ]; var KEYBOARD_TYPES = [ 'DEFAULT', 'ASCII', 'NUMBERS_PUNCTUATION', 'URL', 'EMAIL', 'DECIMAL_PAD', 'NAMEPHONE_PAD', 'NUMBER_PAD', 'PHONE_PAD' ]; var RETURN_KEY_TYPES = [ 'DEFAULT', 'DONE', 'EMERGENCY_CALL', 'GO', 'GOOGLE', 'JOIN', 'NEXT', 'ROUTE', 'SEARCH', 'SEND', 'YAHOO' ]; var AUTOCAPITALIZATION_TYPES = [ 'ALL', 'NONE', 'SENTENCES', 'WORDS' ]; var KEYBOARD_PROPERTIES = ['keyboardType', 'returnKeyType', 'autocapitalization']; // private variables var styleOrderCounter = 1; var platform; exports.setPlatform = function(p) { platform = p; }; /* * @property {Array} globalStyle * The global style array, which contains an merged, ordered list of all * applicable global styles. This will serve as the base for all controller- * specific styles. * */ exports.globalStyle = []; /* * @property {Object} bindingsMap * Holds the collection of models/collections data-bound to UI component * properties in the Alloy app. This map is used to create the most effecient * set of event listeners possible for dynamically updating the UI based on * changes to the data model/collections. * */ exports.bindingsMap = {}; /* * @method loadGlobalStyles * Loads all global styles (app.tss) in a project and sorts them appropriately. * The order of sorting is as follows: * * 1. global * 2. global theme * 3. global platform-specific * 4. global theme platform-specific * * This function updates the global style array that will be used as a base for * all controller styling. This is executed before any other styling is * performed during the compile phase. If the style is loaded from the cache, * it returns true, otherwise it returns false. * * @param {String} Full path to the "app" folder of the target project * @param {String} The mobile platform for which to load styles * @param {Object} [opts] Additional options * * @returns {Boolean} true if cache was used, false if not */ exports.loadGlobalStyles = function(appPath, opts) { // reset the global style array exports.globalStyle = []; // validate/set arguments opts = opts || {}; var ret = false; var theme = opts.theme; var apptss = CONST.GLOBAL_STYLE; var stylesDir = path.join(appPath,CONST.DIR.STYLE); var themesDir; if (theme) { themesDir = path.join(appPath,'themes',theme,CONST.DIR.STYLE); } var buildlog = BuildLog(); var cacheFile = path.join(appPath, '..', CONST.DIR.BUILD, GLOBAL_STYLE_CACHE); // create array of global styles to load based on arguments var loadArray = []; loadArray.push({ path: path.join(stylesDir,apptss), msg: apptss }); if (theme) { loadArray.push({ path: path.join(themesDir,apptss), msg: apptss + '(theme:' + theme + ')', obj: { theme: true } }); } loadArray.push({ path: path.join(stylesDir,platform,apptss), msg: apptss + '(platform:' + platform + ')', obj: { platform: true } }); if (theme) { loadArray.push({ path: path.join(themesDir,platform,apptss), msg: apptss + '(theme:' + theme + ' platform:' + platform + ')', obj: { platform: true, theme: true } }); } // get rid of entries that don't exist var len = loadArray.length; for (var i = len - 1; i >= 0; i--) { if (!path.existsSync(loadArray[i].path)) { loadArray.splice(i, 1); } } // create hash of existing global styles var hash = U.createHash(_.pluck(loadArray, 'path')); // see if we can use the cached global style if (buildlog.data.globalStyleCacheHash === hash && fs.existsSync(cacheFile)) { // load global style object from cache logger.info('[global style] loading from cache...'); exports.globalStyle = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); ret = true; // increment the style order counter with the number of rules in the global style styleOrderCounter += exports.globalStyle.length; } else { // add new hash to the buildlog buildlog.data.globalStyleCacheHash = hash; // create the new global style object _.each(loadArray, function(g) { if (path.existsSync(g.path)) { logger.info('[' + g.msg + '] global style processing...'); exports.globalStyle = exports.loadAndSortStyle(g.path, _.extend( { existingStyle: exports.globalStyle }, g.obj || {} )); } }); // write global style object to cache logger.info('[global style] writing to cache...'); fs.writeFileSync(cacheFile, JSON.stringify(exports.globalStyle)); // simply increment the style order counter styleOrderCounter++; } return ret; }; /* * @method sortStyles * Given a parsed style from loadStyle(), sort all the style entries into an * ordered array. This is the final operations to prepare a style for usage with * a Titanium UI component in Alloy. * * @param {Object} Parsed style object from a loadStyle() call * @param {Object} [opts] Options for this function */ exports.sortStyles = function(style, opts) { var sortedStyles = []; opts = opts || {}; if (_.isObject(style) && !_.isEmpty(style)) { for (var key in style) { var obj = {}; var priority = styleOrderCounter++ * VALUES.ORDER; var match = key.match(STYLE_REGEX); if (match === null) { U.die('Invalid style specifier "' + key + '"'); } var newKey = match[2]; // skip any invalid style entries if (newKey === 'undefined' && !match[1]) { continue; } // get the style key type switch(match[1]) { case '#': obj.isId = true; priority += VALUES.ID; break; case '.': obj.isClass = true; priority += VALUES.CLASS; break; default: if (match[2]) { obj.isApi = true; priority += VALUES.API; } break; } if (match[3]) { obj.queries = {}; _.each(match[3].replace(/\s*,\s*/g, ',').split(/\s+/), function(query) { var parts = query.split('='); var q = U.trim(parts[0]); var v = U.trim(parts[1]); if (q === 'platform') { priority += VALUES.PLATFORM + VALUES.SUM; v = v.split(','); } else if (q === 'formFactor') { priority += VALUES.FORMFACTOR + VALUES.SUM; } else if (q === 'if') { priority += VALUES.TSSIF + VALUES.SUM; } else { priority += VALUES.SUM; } obj.queries[q] = v; }); } _.extend(obj, { priority: priority + (opts.platform ? VALUES.PLATFORM : 0) + (opts.theme ? VALUES.THEME : 0), key: newKey, style: style[key] }); sortedStyles.push(obj); } } var theArray = opts.existingStyle ? opts.existingStyle.concat(sortedStyles) : sortedStyles; return _.sortBy(theArray, 'priority'); }; exports.loadStyle = function(tssFile) { if (path.existsSync(tssFile)) { // read the style file var contents; try { contents = fs.readFileSync(tssFile, 'utf8'); } catch (e) { U.die('Failed to read style file "' + tssFile + '"', e); } // skip if the file is empty if (/^\s*$/gi.test(contents)) { return {}; } // Add enclosing curly braces, if necessary contents = /^\s*\{[\s\S]+\}\s*$/gi.test(contents) ? contents : '{\n' + contents + '\n}'; // [ALOY-793] double-escape '\' in tss contents = contents.replace(/(\s)(\\+)(\s)/g, '$1$2$2$3'); // Process tss file then convert to JSON var json; try { json = grammar.parse(contents); optimizer.optimizeStyle(json); } catch (e) { U.die([ 'Error processing style "' + tssFile + '"', e.message, /Expected bare word\, comment\, end of line\, string or whitespace but ".+?" found\./.test(e.message) ? 'Do you have an extra comma in your style definition?' : '', '- line: ' + e.line, '- column: ' + e.column, '- offset: ' + e.offset ]); } return json; } return {}; }; exports.loadAndSortStyle = function(tssFile, opts) { return exports.sortStyles(exports.loadStyle(tssFile), opts); }; exports.createVariableStyle = function(keyValuePairs, value) { var style = {}; if (!_.isArray(keyValuePairs)) { keyValuePairs = [[keyValuePairs, value]]; } _.each(keyValuePairs, function(pair) { var k = pair[0]; var v = pair[1]; style[k] = { value:v }; style[k][STYLE_ALLOY_TYPE] = 'var'; }); return style; }; exports.processStyle = function(_style, _state) { var theState = _state || {}; var regex = EXPR_REGEX; var code = ''; function processStyle(style, opts) { opts = opts || {}; style = opts.fromArray ? {0:style} : style; var groups = {}, sn, value; // need to add "properties" and bindIds for ListItems if (theState && theState.isListItem && opts.firstOrder && !opts.fromArray) { for (sn in style) { value = style[sn]; var prefixes = sn.split(':'); if (prefixes.length > 1) { var bindId = prefixes[0]; groups[bindId] = groups[bindId] || {}; groups[bindId][prefixes.slice(1).join(':')] = value; } else { // allow template to be specified if (sn === 'template') { groups.template = value; } else { groups.properties = groups.properties || {}; groups.properties[sn] = value; } } } style = groups; } for (sn in style) { value = style[sn]; var prefix = opts.fromArray ? '' : sn + ':'; if (_.isString(value)) { var matches = value.match(regex); if (matches !== null) { code += prefix + matches[1] + ','; // matched a JS expression } else { if(typeof style.type !== 'undefined' && typeof style.type.indexOf === 'function' && (style.type).indexOf('UI.PICKER') !== -1 && value !== 'picker') { // ALOY-263, support date/time style pickers var d = U.createDate(value); if(DATEFIELDS.indexOf(sn) !== -1) { if(U.isValidDate(d, sn)) { code += prefix + 'new Date("'+d.toString()+'"),'; } } else { code += prefix + '"' + value .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\u2028/g, '\\u2028') .replace(/\u2029/g, '\\u2029') + '",'; // just a string } } else { if(KEYBOARD_PROPERTIES.indexOf(sn) === -1) { code += prefix + '"' + value .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\u2028/g, '\\u2028') .replace(/\u2029/g, '\\u2029') + '",'; // just a string } else { // keyboard type shortcuts for TextField, TextArea // support shortcuts for keyboard type, return key type, and autocapitalization if (sn===KEYBOARD_PROPERTIES[0] && _.contains(KEYBOARD_TYPES, value.toUpperCase())) { code += prefix + 'Ti.UI.KEYBOARD_' + value.toUpperCase() + ','; } if (sn===KEYBOARD_PROPERTIES[1] && _.contains(RETURN_KEY_TYPES, value.toUpperCase())) { code += prefix + 'Ti.UI.RETURNKEY_' + value.toUpperCase() + ','; } if (sn===KEYBOARD_PROPERTIES[2] && _.contains(AUTOCAPITALIZATION_TYPES, value.toUpperCase())) { code += prefix + 'Ti.UI.TEXT_AUTOCAPITALIZATION_' + value.toUpperCase() + ','; } } } } } else if (_.isArray(value)) { code += prefix + '['; _.each(value, function(v) { processStyle(v, {fromArray:true}); }); code += '],'; } else if (_.isObject(value)) { if (value[STYLE_ALLOY_TYPE] === 'var') { code += prefix + value.value + ','; // dynamic variable value } else { // recursively process objects code += prefix + '{'; processStyle(value); code += '},'; } } else { code += prefix + JSON.stringify(value) + ','; // catch all, just stringify the value } } } processStyle(_style, {firstOrder:true}); return code; }; exports.generateStyleParams = function(styles,classes,id,apiName,extraStyle,theState) { var bindingRegex = BINDING_REGEX, styleCollection = [], lastObj = {}; // don't add an id to the generated style if we are in a local state if (theState && theState.local) { delete extraStyle.id; } // process all style items, in order _.each(styles, function(style) { var styleApi = style.key; if (style.isApi && styleApi.indexOf('.') === -1) { var ns = (CONST.IMPLICIT_NAMESPACES[styleApi] || CONST.NAMESPACE_DEFAULT); styleApi = ns + '.' + styleApi; } if ((style.isId && style.key === id) || (style.isClass && _.contains(classes, style.key)) || (style.isApi && styleApi === apiName)) { // manage potential runtime conditions for the style var conditionals = { platform: [], formFactor: '' }; if (style.queries) { // handle platform device query // - Make compile time comparison if possible // - Add runtime conditional if platform is not known var q = style.queries; if (q.platform) { if (platform) { var isForCurrentPlatform = false; _.each(q.platform.toString().split(','), function(p) { // need to account for multiple platforms and negation, such as platform=ios or // platform=ios,android or platform=!ios or platform="android,!mobileweb" if(p === platform || (p.indexOf('!') === 0 && p.substr(1) !== platform)) { isForCurrentPlatform = true; } }); if (!isForCurrentPlatform) { return; } } else { _.each(q.platform, function(p) { conditionals.platform.push(CU.CONDITION_MAP[p]['runtime']); }); } } // handle formFactor device query if (q.formFactor === 'tablet') { conditionals.formFactor = 'Alloy.isTablet'; } else if (q.formFactor === 'handheld') { conditionals.formFactor = 'Alloy.isHandheld'; } // assemble runtime query var pcond = conditionals.platform.length > 0 ? '(' + conditionals.platform.join(' || ') + ')' : ''; var joinString = (pcond && conditionals.formFactor) ? ' && ' : ''; var conditional = pcond + joinString + conditionals.formFactor; if(q.if) { // ALOY-871: handle custom TSS queries with if conditional var ffcond = conditionals.formFactor.length > 0 ? '(' + conditionals.formFactor + ')' : ''; var ffJoinString = (ffcond) ? ' && ' : ''; conditional = pcond + joinString + ffcond + ffJoinString + "(" + q.if.split(',').join(' || ')+")"; } // push styles if we need to insert a conditional if (conditional) { if (lastObj) { styleCollection.push({style:lastObj}); styleCollection.push({style:style.style, condition:conditional}); lastObj = {}; } } else if(!q.if) { lastObj = deepExtend(true, lastObj, style.style); } } else { lastObj = deepExtend(true, lastObj, style.style); } } }); // add in any final styles _.extend(lastObj, extraStyle || {}); if (!_.isEmpty(lastObj)) { styleCollection.push({style:lastObj}); } // substitutions for binding _.each(styleCollection, function(style) { _.each(style.style, function(v,k) { if (_.isString(v)) { var match = v.match(bindingRegex); if (match !== null) { var parts = match[1].split('.'), partsLen = parts.length, modelVar, templateStr = v.replace(/\{[\$\.]*/g, '<%=').replace(/\}/g, '%>'); // model binding if (parts.length > 1) { if (CU.models.length !== 0) { if (partsLen > 3 || (parts[0] !== '$' && !_.contains(CU.models, parts[0]))) { U.die([ 'Attempt to reference the deep object reference : "' + match[1] + '".', 'Instead, please map the object property to an attribute of the model.' ]); } } // are we bound to a global or controller-specific model? modelVar = parts[0] === '$' ? parts[0] + '.' + parts[1] : 'Alloy.Models.' + parts[0]; var attr = parts[0] === '$' ? parts[2] : parts[1]; // ensure that the bindings for this model have been initialized if (!_.isArray(exports.bindingsMap[modelVar])) { exports.bindingsMap[modelVar] = []; } // create the binding object var bindingObj = { id: id, prop: k, attr: attr, mname: parts[0] === '$' ? parts[1] : parts[0], tplVal: templateStr }; // make sure bindings are wrapped in any conditionals // relevant to the curent style if (theState.condition) { bindingObj.condition = theState.condition; } // add this property to the global bindings map for the // current controller component exports.bindingsMap[modelVar].push(bindingObj); // since this property is data bound, don't include it in // the style statically delete style.style[k]; } // collection binding else { modelVar = theState && theState.model ? theState.model : CONST.BIND_MODEL_VAR; var bindingStr = templateStr.replace(/<%=([\s\S]+?)%>/g, function(match, code) { var v = code.replace(/\\'/g, "'"); return "'+" + modelVar +".get('" + v.trim() + "') +'"; }); var transform = modelVar + "." + CONST.BIND_TRANSFORM_VAR + "['" + match[1].trim() + "']"; // remove the first '+ and last +' var bStr = bindingStr.match(/^\s*\'\+(.*)\+\'\s*$/), standard = (bStr) ? bStr[1] : bindingStr; var modelCheck = "typeof " + transform + " !== 'undefined' ? " + transform + " : " + standard; style.style[k] = STYLE_EXPR_PREFIX + modelCheck; } } } }); }); // Let's assemble the fastest factory method object possible based on // what we know about the style we just sorted and assembled var code = ''; if (styleCollection.length === 0) { code += '{}'; } else if (styleCollection.length === 1) { if (styleCollection[0].condition) { // check the condition and return the object code += styleCollection[0].condition + ' ? {' + exports.processStyle(styleCollection[0].style, theState) + '} : {}'; } else { // just return the object code += '{'; code += exports.processStyle(styleCollection[0].style, theState); code += '}'; } } else if (styleCollection.length > 1) { // construct self-executing function to merge styles based on runtime conditionals code += '(function(){\n'; code += 'var o = {};\n'; for (var i = 0, l = styleCollection.length; i < l; i++) { if (styleCollection[i].condition) { code += 'if (' + styleCollection[i].condition + ') '; } var tmpStyle = exports.processStyle(styleCollection[i].style, theState); if(!_.isEmpty(tmpStyle)) { code += '_.extend(o, {'; code += tmpStyle; code += '});\n'; } } code += 'return o;\n'; code += '})()'; } return code; }; function getCacheFilePath(appPath, hash) { return path.join(appPath, '..', CONST.DIR.BUILD, 'global_style_cache_' + hash + '.json'); }