alloy
Version:
TiDev Titanium MVC Framework
722 lines (637 loc) • 21.4 kB
JavaScript
var fs = require('fs'),
path = require('path'),
_ = require('lodash'),
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_SPLIT_REGEX = /(\{[^:}]+\}(?!\}))/;
var BINDING_REFERENCE_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(_.map(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;
var originalContents;
var addedBraces;
try {
contents = fs.readFileSync(tssFile, 'utf8');
// Store the originalContents in case we hit a parse error,
// this allows us to show the correct contents of the .tss file
originalContents = contents;
} 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
if (!/^\s*\{[\s\S]+\}\s*$/gi.test(contents)) {
contents = '{\n' + contents + '\n}';
addedBraces = true;
}
// [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) {
// If we added braces to the contents then the actual line number
// on the original contents is one less than the error reports
if (addedBraces) {
e.line--;
}
U.dieWithCodeFrame(
'Error processing style "' + tssFile + '"',
{ line: e.line, column: e.column },
originalContents,
/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?'
: ''
);
}
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') {
if (DATEFIELDS.indexOf(sn) !== -1) {
// ALOY-263, support date/time style pickers
var d = U.createDate(value);
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] && _.includes(KEYBOARD_TYPES, value.toUpperCase())) {
code += prefix + 'Ti.UI.KEYBOARD_' + value.toUpperCase() + ',';
}
if (sn === KEYBOARD_PROPERTIES[1] && _.includes(RETURN_KEY_TYPES, value.toUpperCase())) {
code += prefix + 'Ti.UI.RETURNKEY_' + value.toUpperCase() + ',';
}
if (sn === KEYBOARD_PROPERTIES[2] && _.includes(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 styleCollection = [],
lastObj = {},
elementName = apiName.split('.').pop();
// 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) {
if ((style.isId && style.key === id) ||
(style.isClass && _.includes(classes, style.key)) ||
(style.isApi && elementName === style.key)) {
// 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 {
// remove old style
_.each(style.style, function(val, key) {
if (_.isArray(val) && Object.prototype.hasOwnProperty.call(lastObj, key)) {
delete lastObj[key];
}
});
lastObj = deepExtend(true, lastObj, style.style);
}
}
});
// add in any final styles
// ALOY-1363: deep merge necessary to properly merge children
lastObj = deepExtend(true, 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)) {
return;
}
var bindingStrParts = v.split(BINDING_SPLIT_REGEX);
if (bindingStrParts.length <= 1) {
return;
}
var collectionModelVar = theState && theState.model ? theState.model : CONST.BIND_MODEL_VAR;
var bindsModels = [];
var bindsCollection = false;
var bindingExpParts = [];
bindingStrParts.forEach(function(part, i) {
// empty string
if (part === '') {
return;
}
var partMatch = part.match(BINDING_REFERENCE_REGEX);
// regular string
if (!partMatch) {
// escape single quote: ALOY-1478
bindingExpParts.push("'" + part.replace(/'/g, "\\'") + "'");
return;
}
var reference = partMatch[1].trim(); // trim: ALOY-716
var referencePath = toPath(reference);
var attribute, modelVar;
// collection binding
if (referencePath.length === 1) {
bindsCollection = true;
} else if (referencePath[0] === '$') {
// instance model binding
attribute = referencePath.splice(2, referencePath.length);
} else if (collectionModelVar !== CONST.BIND_MODEL_VAR) {
// collection binding (deep)
bindsCollection = true;
} else {
// default to global model binding
attribute = referencePath.splice(1, referencePath.length);
referencePath.unshift('Alloy', 'Models');
}
// model binding
if (attribute !== undefined) {
modelVar = fromPath(referencePath);
reference = fromPath(referencePath.concat(CONST.BIND_TRANSFORM_VAR, attribute));
if (!_.includes(bindsModels, modelVar)) {
bindsModels.push(modelVar);
}
} else {
// collection binding
reference = collectionModelVar + '.' + CONST.BIND_TRANSFORM_VAR + (reference[0] === '[' ? '' : '.') + reference;
}
bindingExpParts.push(reference);
});
if (!bindsCollection && bindsModels.length === 0) {
return;
}
if (bindsCollection && bindsModels.length > 0) {
U.die('Attempt to mix model (' + bindsModels.join(', ') + ') and collection (' + collectionModelVar + ') binding in: ' + v);
}
var bindingExp = bindingExpParts.join(' + ');
if (bindsCollection) {
style.style[k] = STYLE_EXPR_PREFIX + bindingExp;
}
if (bindsModels.length > 0) {
bindsModels.forEach(function(modelVar) {
// ensure that the bindings for this model have been initialized
if (!exports.bindingsMap[modelVar]) {
exports.bindingsMap[modelVar] = {
models: bindsModels,
bindings: []
};
} else {
// ensure that mix use of models contains all
exports.bindingsMap[modelVar].models = _.union(exports.bindingsMap[modelVar].models, bindsModels);
}
// create the binding object
var bindingObj = {
id: id,
prop: k,
val: bindingExp
};
// 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].bindings.push(bindingObj);
});
// since this property is data bound, don't include it in
// the style statically
delete style.style[k];
}
});
});
// 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++) {
var tmpStyle = exports.processStyle(styleCollection[i].style, theState);
if (!_.isEmpty(tmpStyle)) {
if (styleCollection[i].condition) {
code += 'if (' + styleCollection[i].condition + ') ';
}
code += 'Alloy.deepExtend(true, 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');
}
// source: https://github.com/lodash/lodash/blob/3.8.1-npm-packages/lodash._topath/index.js
function toPath(value) {
var result = [];
value.replace(/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\n\\]|\\.)*?)\2)\]/g, function(match, number, quote, string) {
result.push(quote ? string.replace(/\\(\\)?/g, '$1') : (number || match));
});
return result;
}
function fromPath(path) {
var result = path[0];
if (path.length > 1) {
result += '[' + path.slice(1).map(function(string) {
return "'" + string.replace(/'/g, "\\'") + "'";
}).join('][') + ']';
}
return result;
}