assetgraph-builder-esprima
Version:
Build system for web sites and applications
552 lines (522 loc) • 27.9 kB
JavaScript
var _ = require('lodash'),
memoizeSync = require('memoizesync'),
esmangle = require('esmangle'),
esanimate = require('esanimate'),
escodegen = require('escodegen'),
estraverse = require('estraverse'),
replaceDescendantNode = require('assetgraph/lib/replaceDescendantNode'),
i18nTools = {};
// Replace - with _ and convert to lower case: en-GB => en_gb
i18nTools.normalizeLocaleId = function (localeId) {
return localeId && localeId.replace(/-/g, '_').toLowerCase();
};
// Helper for getting a prioritized list of relevant locale ids from a specific locale id.
// For instance, "en_US" produces ["en_US", "en"]
i18nTools.expandLocaleIdToPrioritizedList = memoizeSync(function (localeId) {
var localeIds = [localeId];
while (/_[^_]+$/.test(localeId)) {
localeId = localeId.replace(/_[^_]+$/, '');
localeIds.push(localeId);
}
return localeIds;
});
i18nTools.tokenizePattern = function (pattern) {
if (typeof pattern !== 'string') {
var valueString = pattern;
try {
valueString = JSON.stringify(pattern);
} catch (e) {}
throw new Error('i18nTools.tokenizePattern: Value must be a string: ' + valueString);
}
var tokens = [],
fragments = pattern.split(/(\{\d+\})/);
for (var i = 0 ; i < fragments.length ; i += 1) {
var fragment = fragments[i];
if (fragment.length > 0) {
var matchPlaceHolder = fragment.match(/^\{(\d+)\}$/);
if (matchPlaceHolder) {
tokens.push({
type: 'placeHolder',
value: parseInt(matchPlaceHolder[1], 10)
});
} else {
tokens.push({
type: 'text',
value: fragment
});
}
}
}
return tokens;
};
i18nTools.patternToAst = function (pattern, placeHolderAsts) {
var ast;
i18nTools.tokenizePattern(pattern).forEach(function (token) {
var term;
if (token.type === 'placeHolder') {
term = placeHolderAsts[token.value];
} else {
term = { type: 'Literal', value: token.value };
}
if (ast) {
ast = { type: 'BinaryExpression', operator: '+', left: ast, right: term };
} else {
ast = term;
}
});
return ast || { type: 'Literal', value: '' };
};
i18nTools.eachI18nTagInHtmlDocument = function (document, lambda, nestedTemplateLambda) {
var ELEMENT_NODE = 1,
TEXT_NODE = 3,
queue = [document],
i;
while (queue.length) {
var node = queue.shift(),
parentNode = node.parentNode,
nodeStillInDocument = true;
if (parentNode && node.nodeType === ELEMENT_NODE) {
if (node.hasAttribute && node.hasAttribute('data-i18n')) { // In IE7 the HTML node doesn't have a hasAttribute method?
var i18nStr = node.getAttribute('data-i18n'),
i18nObj;
if (i18nStr.indexOf(':') !== -1) {
try {
/*jshint evil:true */
i18nObj = eval('({' + i18nStr + '})');
} catch (e) {
throw new Error('i18nTools.eachI18nTagInHtmlDocument: Error evaluating data-i18n attribute: ' + i18nStr + '\n' + e.stack);
}
} else {
i18nObj = {text: i18nStr};
}
if (i18nObj.attr) {
var attributeNames = Object.keys(i18nObj.attr);
for (i = 0 ; i < attributeNames.length ; i += 1) {
var attributeName = attributeNames[i],
key = i18nObj.attr[attributeName] || null;
if (lambda({type: 'i18nTagAttribute', attributeName: attributeName, node: node, key: key, defaultValue: node.getAttribute(attributeName)}) === false) {
return;
}
}
}
if (typeof i18nObj.text !== 'undefined') {
var defaultValue = '',
placeHolders = [],
nextPlaceHolderNumber = 0;
for (i = 0 ; i < node.childNodes.length ; i += 1) {
var childNode = node.childNodes[i];
if (childNode.nodeType === TEXT_NODE) {
defaultValue += childNode.nodeValue;
} else {
defaultValue += '{' + nextPlaceHolderNumber + '}';
nextPlaceHolderNumber += 1;
placeHolders.push(childNode);
}
}
defaultValue = defaultValue.replace(/^[ \n\t]+|[ \n\t]+$/g, ''); // Trim leading and trailing whitespace, except non-breaking space chars
defaultValue = defaultValue.replace(/[ \n\t]+/g, ' '); // Compress and normalize sequences of 1+ spaces to one ' '
if (lambda({type: 'i18nTagText', node: node, key: i18nObj.text || null, defaultValue: defaultValue, placeHolders: placeHolders}) === false) {
return;
}
} else {
// A tag with a data-i18n tag, but no language key for the text contents.
// Give the lambda a chance to clean up the tag anyway:
lambda({node: node});
}
if (!node.parentNode) {
nodeStillInDocument = false;
queue.unshift(parentNode);
}
}
// Give the caller a chance to do something about nested <script type="text/html">...</script> templates (used by TRHTML in the browser):
if (nestedTemplateLambda && node.nodeName.toLowerCase() === 'script' && node.getAttribute('type') === 'text/html') {
nestedTemplateLambda(node);
}
}
if (nodeStillInDocument && node.childNodes) {
for (i = node.childNodes.length - 1 ; i >= 0 ; i -= 1) {
queue.unshift(node.childNodes[i]);
}
}
}
};
i18nTools.createI18nTagReplacer = function (options) {
var TEXT_NODE = 3,
allKeysForLocale = options.allKeysForLocale,
keepI18nAttributes = options.keepI18nAttributes,
keepSpans = options.keepSpans;
return function i18nTagReplacer(options) {
var key = options.key,
node = options.node,
value = allKeysForLocale[key],
removeNode = !keepSpans && options.type !== 'i18nTagAttribute' && node.nodeName.toLowerCase() === 'span' && node.attributes.length === 1;
if (key !== null) { // An empty string or null means explicitly "do not translate"
if (/^i18nTag/.test(options.type) && value === null || typeof value === 'undefined') {
value = options.defaultValue || '[!' + key + '!]';
}
if (options.type === 'i18nTagAttribute') {
node.setAttribute(options.attributeName, value);
} else if (options.type === 'i18nTagText') {
while (node.childNodes.length) {
node.removeChild(node.firstChild);
}
i18nTools.tokenizePattern(value).forEach(function (token) {
var nodeToInsert;
if (token.type === 'text') {
nodeToInsert = node.ownerDocument.createTextNode(token.value);
} else {
var placeHolder = options.placeHolders[token.value];
if (placeHolder) {
nodeToInsert = placeHolder;
if (nodeToInsert.parentNode) {
nodeToInsert = nodeToInsert.cloneNode(true);
}
} else {
nodeToInsert = node.ownerDocument.createTextNode('[!{' + token.value + '}!]');
}
}
if (removeNode) {
if (nodeToInsert.nodeType === TEXT_NODE && node.previousSibling && node.previousSibling.nodeType === TEXT_NODE) {
// Splice with previous text node
node.previousSibling.nodeValue += nodeToInsert.nodeValue;
} else {
node.parentNode.insertBefore(nodeToInsert, node);
}
} else {
node.appendChild(nodeToInsert);
}
});
}
}
if (removeNode) {
node.parentNode.removeChild(node);
} else if (!keepI18nAttributes && options.type !== 'i18nTagAttribute') {
node.removeAttribute('data-i18n');
}
};
};
function foldConstant(node) {
if (node.type === 'Literal') {
return node;
} else {
var wrappedNode = {
type: 'Program',
body: [
{
type: 'VariableDeclaration',
kind: 'var',
declarations: [
{
type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'foo' },
init: node
}
]
}
]
};
var foldedNode = esmangle.optimize(wrappedNode);
var valueNode = foldedNode.body[0].declarations[0].init;
if (valueNode.type === 'Literal' && typeof valueNode.value === 'string') {
return valueNode;
} else {
return node;
}
}
}
function extractKeyAndDefaultValueFromCallNode(callNode) {
var argumentAsts = callNode.arguments;
if (argumentAsts.length === 0) {
console.warn('Invalid ' + escodegen.generate(callNode.callee) + ' syntax: ' + escodegen.generate(callNode));
} else {
var keyNameAst = argumentAsts.length > 0 && foldConstant(argumentAsts[0]),
defaultValueAst = argumentAsts.length > 1 && foldConstant(argumentAsts[1]),
keyAndDefaultValue = {};
if (keyNameAst && keyNameAst.type === 'Literal' && typeof keyNameAst.value === 'string') {
keyAndDefaultValue.key = keyNameAst.value;
if (defaultValueAst) {
try {
keyAndDefaultValue.defaultValue = esanimate.objectify(foldConstant(defaultValueAst));
} catch (e) {
console.warn('i18nTools.eachTrInAst: Invalid ' + escodegen.generate(callNode.expression) + ' default value syntax: ' + escodegen.generate(callNode));
}
}
return keyAndDefaultValue;
} else {
console.warn('i18nTools.eachTrInAst: Invalid ' + escodegen.generate(callNode.expression) + ' key name syntax: ' + escodegen.generate(callNode));
}
}
}
i18nTools.eachTrInAst = function (ast, lambda) {
estraverse.traverse(ast, {
enter: function (node, parentNode) {
var keyAndDefaultValue;
if (node.type === 'CallExpression' &&
node.callee.type === 'CallExpression' &&
node.callee.callee.type === 'Identifier' &&
node.callee.callee.name === 'TRPAT') {
keyAndDefaultValue = extractKeyAndDefaultValueFromCallNode(node.callee);
if (keyAndDefaultValue) {
if (lambda(_.extend(keyAndDefaultValue, {type: 'callTRPAT', node: node, parentNode: parentNode})) === false) {
return this.break();
}
}
} else if (node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'TR' || node.callee.name === 'TRPAT')) {
keyAndDefaultValue = extractKeyAndDefaultValueFromCallNode(node);
if (keyAndDefaultValue) {
if (lambda(_.extend(keyAndDefaultValue, {type: node.callee.name, node: node, parentNode: parentNode})) === false) {
return this.break();
}
}
}
}
});
};
i18nTools.eachOccurrenceInAsset = function (asset, lambda) {
if (asset.type === 'JavaScript') {
i18nTools.eachTrInAst(asset.parseTree, lambda);
} else if (asset.isHtml || asset.type === 'Svg') {
i18nTools.eachI18nTagInHtmlDocument(asset.parseTree, lambda);
}
};
i18nTools.extractAllKeys = function (assetGraph) {
var allKeys = {};
assetGraph.findAssets({type: 'I18n'}).forEach(function (i18nAsset) {
Object.keys(i18nAsset.parseTree).forEach(function (key) {
allKeys[key] = allKeys[key] || {};
Object.keys(i18nAsset.parseTree[key]).forEach(function (localeId) {
allKeys[key][i18nTools.normalizeLocaleId(localeId)] = i18nAsset.parseTree[key][localeId];
});
});
});
return allKeys;
};
// initialAsset must be Html or JavaScript
i18nTools.extractAllKeysForLocale = function (assetGraph, localeId) {
localeId = i18nTools.normalizeLocaleId(localeId);
var allKeys = i18nTools.extractAllKeys(assetGraph),
prioritizedLocaleIds = i18nTools.expandLocaleIdToPrioritizedList(localeId),
allKeysForLocale = {};
Object.keys(allKeys).forEach(function (key) {
var found = false;
for (var i = 0 ; i < prioritizedLocaleIds.length ; i += 1) {
if (prioritizedLocaleIds[i] in allKeys[key]) {
allKeysForLocale[key] = allKeys[key][prioritizedLocaleIds[i]];
found = true;
break;
}
}
});
return allKeysForLocale;
};
i18nTools.createTrReplacer = function (options) {
var allKeysForLocale = options.allKeysForLocale;
return function trReplacer(options) {
var node = options.node,
parentNode = options.parentNode,
type = options.type,
key = options.key,
value = allKeysForLocale[key],
valueAst;
if (value === null || typeof value === 'undefined') {
if (options.defaultValue) {
valueAst = esanimate.astify(options.defaultValue);
} else {
valueAst = { type: 'Literal', value: '[!' + key + '!]' };
}
} else {
valueAst = esanimate.astify(value);
}
if (type === 'callTRPAT') {
// Replace TRPAT('keyName')(placeHolderValue, ...) with a string concatenation:
if (valueAst.type !== 'Literal' || typeof valueAst.value !== 'string') {
console.warn('trReplacer: Invalid TRPAT syntax: ' + escodegen.generate(node));
return;
}
replaceDescendantNode(parentNode, node, i18nTools.patternToAst(valueAst.value, node.arguments));
} else if (type === 'TR') {
replaceDescendantNode(parentNode, node, valueAst);
} else if (type === 'TRPAT') {
if (valueAst.type !== 'Literal' || typeof valueAst.value !== 'string') {
console.warn('trReplacer: Invalid TRPAT syntax: ' + value);
return;
}
var highestPlaceHolderNumber;
i18nTools.tokenizePattern(valueAst.value).forEach(function (token) {
if (token.type === 'placeHolder' && (!highestPlaceHolderNumber || token.value > highestPlaceHolderNumber)) {
highestPlaceHolderNumber = token.value;
}
});
var argumentNameAsts = [],
placeHolderAsts = [];
for (var j = 0 ; j <= highestPlaceHolderNumber ; j += 1) {
var argumentName = 'a' + j;
placeHolderAsts.push({ type: 'Identifier', name: argumentName });
argumentNameAsts.push({ type: 'Identifier', name: argumentName });
}
replaceDescendantNode(parentNode, node, {
type: 'FunctionExpression',
params: argumentNameAsts,
body: {
type: 'BlockStatement',
body: [
{ type: 'ReturnStatement', argument: i18nTools.patternToAst(valueAst.value, placeHolderAsts)}
]
}
});
}
};
};
function isBootstrapperRelation(relation) {
return relation.type === 'HtmlScript' && relation.node && relation.node.getAttribute('id') === 'bootstrapper';
}
// Get a object: key => array of "occurrence" objects that can either represent TR or TRPAT expressions:
// {asset: ..., type: 'TR'|'TRPAT', node, ..., defaultValue: <ast>}
// or <span data-i18n="keyName">...</span> tags:
// {asset: ..., type: 'i18nTag', node: ..., placeHolders: [...], defaultValue: <string>)
i18nTools.findOccurrences = function (assetGraph, initialAssets) {
var trOccurrencesByKey = {};
initialAssets.forEach(function (htmlAsset) {
assetGraph.collectAssetsPostOrder(htmlAsset, {type: assetGraph.query.not(['HtmlAnchor', 'HtmlMetaRefresh', 'SvgAnchor'])}).forEach(function (asset) {
if (asset.isLoaded) {
if (asset.type === 'JavaScript') {
if (asset.incomingRelations.length === 0 || !asset.incomingRelations.every(isBootstrapperRelation)) {
i18nTools.eachTrInAst(asset.parseTree, function (occurrence) {
occurrence.asset = asset;
(trOccurrencesByKey[occurrence.key] = trOccurrencesByKey[occurrence.key] || []).push(occurrence);
});
}
} else if (asset.type === 'Html') {
i18nTools.eachI18nTagInHtmlDocument(asset.parseTree, function (occurrence) {
if (occurrence.key) {
occurrence.asset = asset;
(trOccurrencesByKey[occurrence.key] = trOccurrencesByKey[occurrence.key] || []).push(occurrence);
}
});
}
}
});
});
return trOccurrencesByKey;
};
i18nTools.getOrCreateI18nAssetForKey = function (assetGraph, key, occurrencesByKey) {
var i18nAssetsWithTheKey = [];
assetGraph.findAssets({type: 'I18n'}).forEach(function (i18nAsset) {
if (key in i18nAsset.parseTree) {
i18nAssetsWithTheKey.push(i18nAsset);
}
});
if (i18nAssetsWithTheKey.length > 1) {
throw new Error('i18nTools.getOrCreateI18nAssetForKey (' + key + '): Key is found in multiple I18n assets, cannot proceed\n' + i18nAssetsWithTheKey.map(function (asset) { return asset.toString(); }).join('\n'));
}
if (i18nAssetsWithTheKey.length === 1) {
return i18nAssetsWithTheKey[0];
} else {
// Key isn't present in any I18n asset, try to work out from the TR/TRPAT/data-i18n occurrences where to put it
var addToI18nForJavaScriptAsset,
includingAssetsById = {};
if (!(key in occurrencesByKey)) {
throw new Error('i18nTools.getOrCreateI18nAssetForKey (' + key + '): Key isn\'t used anywhere, cannot work out which .i18n file to add it to!');
}
occurrencesByKey[key].forEach(function (occurrence) {
includingAssetsById[occurrence.asset.id] = occurrence.asset;
});
var includingAssets = _.values(includingAssetsById);
if (includingAssets.length === 0) {
throw new Error('i18nTools.getOrCreateI18nAssetForKey (' + key + '): Key isn\'t found in any I18n asset and isn\'t used in any TR statements or data-i18n attributes');
}
if (includingAssets.length === 1) {
addToI18nForJavaScriptAsset = includingAssets[0];
} else {
// Multiple including assets, prefer JavaScript assets:
var includingJavaScriptAssets = includingAssets.filter(function (asset) { return asset.type === 'JavaScript'; });
if (includingJavaScriptAssets.length === 1) {
addToI18nForJavaScriptAsset = includingJavaScriptAssets[0];
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): Key occurs in multiple assets, choosing the only JavaScript asset among them: ' + addToI18nForJavaScriptAsset);
} else if (includingJavaScriptAssets.length > 1) {
addToI18nForJavaScriptAsset = includingJavaScriptAssets[includingJavaScriptAssets.length - 1];
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): Key occurs in multiple JavaScript assets, arbitrarily choosing the last one seen: ' + addToI18nForJavaScriptAsset);
} else {
// Only non-JavaScript assets
addToI18nForJavaScriptAsset = includingAssets[includingAssets.length - 1];
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): Key occurs in multiple assets, arbitrarily choosing the last one seen: ' + addToI18nForJavaScriptAsset);
}
}
if (addToI18nForJavaScriptAsset.type === 'Html' && !addToI18nForJavaScriptAsset.isFragment) {
// See if any referenced JavaScript assets has an outgoing I18n relation:
var referencedJavaScriptAssets = assetGraph.findAssets({type: 'JavaScript', incoming: {from: addToI18nForJavaScriptAsset}});
if (referencedJavaScriptAssets.length === 0) {
throw new Error(addToI18nForJavaScriptAsset + ' doesn\'t reference any JavaScript, giving up');
}
var referencedJavaScriptAssetsWithI18n = referencedJavaScriptAssets.filter(function (asset) {
return assetGraph.findRelations({to: {type: 'I18n'}, from: asset}).length > 0;
});
if (referencedJavaScriptAssetsWithI18n.length === 1) {
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' references a single JavaScript with I18n, choosing that one: ' + referencedJavaScriptAssetsWithI18n[0]);
addToI18nForJavaScriptAsset = referencedJavaScriptAssetsWithI18n[0];
} else if (referencedJavaScriptAssetsWithI18n.length > 1) {
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' references multiple JavaScript assets with I18n, arbitrarily choosing the last one seen: ' + referencedJavaScriptAssetsWithI18n[referencedJavaScriptAssetsWithI18n.length - 1]);
addToI18nForJavaScriptAsset = referencedJavaScriptAssetsWithI18n[referencedJavaScriptAssetsWithI18n.length - 1];
} else if (referencedJavaScriptAssets.length === 1) {
addToI18nForJavaScriptAsset = referencedJavaScriptAssets[0];
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' references a single JavaScript, choosing that one: ' + referencedJavaScriptAssets[0]);
} else {
// referencedJavaScriptAssets.length > 1
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' references multiple JavaScript, arbitrarily choosing the last one seen: ' + referencedJavaScriptAssets[referencedJavaScriptAssets.length - 1]);
addToI18nForJavaScriptAsset = referencedJavaScriptAssets[referencedJavaScriptAssets.length - 1];
}
} else if (addToI18nForJavaScriptAsset.type === 'Html' && addToI18nForJavaScriptAsset.isFragment) {
var referringJavaScriptAssets = assetGraph.findAssets({outgoing: {to: addToI18nForJavaScriptAsset}});
if (referringJavaScriptAssets.length === 0) {
throw new Error(addToI18nForJavaScriptAsset + ' isn\'t referenced from any JavaScript assets, giving up (key: ' + key + ')');
}
var referringJavaScriptAssetsWithI18n = referringJavaScriptAssets.filter(function (asset) {
return assetGraph.findRelations({to: {type: 'I18n'}, from: asset}).length > 0;
});
if (referringJavaScriptAssetsWithI18n.length === 1) {
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' is referenced by a single JavaScript with I18n, choosing that one: ' + referringJavaScriptAssetsWithI18n[0]);
addToI18nForJavaScriptAsset = referringJavaScriptAssetsWithI18n[0];
} else if (referringJavaScriptAssetsWithI18n.length > 1) {
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' is referenced by multiple JavaScript assets with I18n, arbitrarily choosing the last one seen: ' + referringJavaScriptAssetsWithI18n[referringJavaScriptAssetsWithI18n.length - 1]);
addToI18nForJavaScriptAsset = referringJavaScriptAssetsWithI18n[referringJavaScriptAssetsWithI18n.length - 1];
} else if (referringJavaScriptAssets.length === 1) {
addToI18nForJavaScriptAsset = referringJavaScriptAssets[0];
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' is referenced by a single JavaScript, choosing that one: ' + referringJavaScriptAssets[0]);
} else {
// referringJavaScriptAssets.length > 1
console.warn('i18nTools.getOrCreateI18nAssetForKey (' + key + '): ' + addToI18nForJavaScriptAsset + ' is referenced by multiple JavaScript assets with I18n, arbitrarily choosing the last one seen: ' + referringJavaScriptAssets[referringJavaScriptAssets.length - 1]);
addToI18nForJavaScriptAsset = referringJavaScriptAssets[referringJavaScriptAssets.length - 1];
}
}
var existingI18nRelations = assetGraph.findRelations({from: addToI18nForJavaScriptAsset, to: {type: 'I18n'}}),
i18nAsset;
if (existingI18nRelations.length === 0) {
i18nAsset = new assetGraph.I18n({
isDirty: true,
parseTree: {}
});
var relation = new assetGraph.JavaScriptInclude({
from: addToI18nForJavaScriptAsset,
to: i18nAsset
});
i18nAsset.url = (addToI18nForJavaScriptAsset.url || relation.baseAsset).replace(/(?:\.js|\.html)?$/, '.i18n');
console.warn('i18nTools.getOrCreateI18nAssetForKey: Creating new I18n asset: ' + i18nAsset.url);
assetGraph.addAsset(i18nAsset);
var existingJavaScriptIncludeRelations = assetGraph.findRelations({from: addToI18nForJavaScriptAsset, type: 'JavaScriptInclude'});
if (existingJavaScriptIncludeRelations.length > 0) {
relation.attach(addToI18nForJavaScriptAsset, 'after', existingJavaScriptIncludeRelations[existingJavaScriptIncludeRelations.length - 1]);
} else {
relation.attach(addToI18nForJavaScriptAsset, 'first');
}
} else {
i18nAsset = existingI18nRelations[0].to;
if (existingI18nRelations.length > 1) {
console.warn('i18nTools.getOrCreateI18nAssetForKey: ' + addToI18nForJavaScriptAsset + ' has multiple I18n relations, choosing the first one pointing at ' + i18nAsset);
}
}
return i18nAsset;
}
};
_.extend(exports, i18nTools);