assetgraph-i18n
Version:
i18n plugin for assetgraph-builder
391 lines (368 loc) • 12.4 kB
JavaScript
const _ = require('lodash');
const memoizeSync = require('memoizesync');
const esmangle = require('esmangle');
const esanimate = require('esanimate');
const escodegen = require('escodegen');
const estraverse = require('estraverse-fb');
const i18nTools = {};
function replaceDescendantNode(ancestorNode, oldNode, newNode) {
estraverse.replace(ancestorNode, {
enter: function (node) {
if (node === oldNode) {
this.break();
return newNode;
}
},
// Avoid crashing on node types supported by the parser, but not estraverse(-fb)
// https://github.com/estools/estraverse/issues/97#issuecomment-438632003
fallback: 'iteration',
});
return newNode;
}
// 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) {
const localeIds = [localeId];
while (/_[^_]+$/.test(localeId)) {
localeId = localeId.replace(/_[^_]+$/, '');
localeIds.push(localeId);
}
return localeIds;
});
i18nTools.tokenizePattern = require('./tokenizePattern');
i18nTools.patternToAst = function (pattern, placeHolderAsts) {
let ast;
i18nTools.tokenizePattern(pattern).forEach(function (token) {
let 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 = require('./eachI18nTagInHtmlDocument');
i18nTools.createI18nTagReplacer = require('./createI18nTagReplacer');
function foldConstant(node) {
if (node.type === 'Literal') {
return node;
} else {
const wrappedNode = {
type: 'Program',
body: [
{
type: 'VariableDeclaration',
kind: 'var',
declarations: [
{
type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'foo' },
init: node,
},
],
},
],
};
const foldedNode = esmangle.optimize(wrappedNode);
const valueNode = foldedNode.body[0].declarations[0].init;
if (valueNode.type === 'Literal' && typeof valueNode.value === 'string') {
return valueNode;
} else {
return node;
}
}
}
function extractKeyAndDefaultValueFromCallNode(callNode) {
const argumentAsts = callNode.arguments;
if (argumentAsts.length === 0) {
console.warn(
'Invalid ' +
escodegen.generate(callNode.callee) +
' syntax: ' +
escodegen.generate(callNode)
);
} else {
const keyNameAst = argumentAsts.length > 0 && foldConstant(argumentAsts[0]);
const defaultValueAst =
argumentAsts.length > 1 && foldConstant(argumentAsts[1]);
const 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.callee) +
' default value syntax: ' +
escodegen.generate(callNode)
);
}
}
return keyAndDefaultValue;
} else {
console.warn(
'i18nTools.eachTrInAst: Invalid ' +
escodegen.generate(callNode.callee) +
' key name syntax: ' +
escodegen.generate(callNode)
);
}
}
}
i18nTools.eachTrInAst = function (ast, lambda) {
estraverse.traverse(ast, {
enter: function (node, parentNode) {
let keyAndDefaultValue;
if (
node.type === 'CallExpression' &&
node.callee.type === 'CallExpression' &&
node.callee.callee.type === 'MemberExpression' &&
node.callee.callee.object.type === 'Identifier' &&
node.callee.callee.object.name === 'TR' &&
node.callee.callee.property.type === 'Identifier' &&
node.callee.callee.property.name === 'PAT'
) {
keyAndDefaultValue = extractKeyAndDefaultValueFromCallNode(node.callee);
if (keyAndDefaultValue) {
if (
lambda(
_.extend(keyAndDefaultValue, {
type: 'callTR.PAT',
node: node,
parentNode: parentNode,
})
) === false
) {
return this.break();
}
}
} else if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'TR'
) {
keyAndDefaultValue = extractKeyAndDefaultValueFromCallNode(node);
if (keyAndDefaultValue) {
if (
lambda(
_.extend(keyAndDefaultValue, {
type: 'TR',
node: node,
parentNode: parentNode,
})
) === false
) {
return this.break();
}
}
} else if (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'TR' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'PAT'
) {
keyAndDefaultValue = extractKeyAndDefaultValueFromCallNode(node);
if (keyAndDefaultValue) {
if (
lambda(
_.extend(keyAndDefaultValue, {
type: 'TR.PAT',
node: node,
parentNode: parentNode,
})
) === false
) {
return this.break();
}
}
}
},
// Avoid crashing on node types supported by the parser, but not estraverse(-fb)
// https://github.com/estools/estraverse/issues/97#issuecomment-438632003
fallback: 'iteration',
});
};
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) {
const allKeys = {};
assetGraph
.findAssets({ type: 'I18n', isLoaded: true })
.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);
const allKeys = i18nTools.extractAllKeys(assetGraph);
const prioritizedLocaleIds = i18nTools.expandLocaleIdToPrioritizedList(
localeId
);
const allKeysForLocale = {};
Object.keys(allKeys).forEach(function (key) {
for (let i = 0; i < prioritizedLocaleIds.length; i += 1) {
if (prioritizedLocaleIds[i] in allKeys[key]) {
allKeysForLocale[key] = allKeys[key][prioritizedLocaleIds[i]];
break;
}
}
});
return allKeysForLocale;
};
i18nTools.createTrReplacer = function (options) {
const allKeysForLocale = options.allKeysForLocale;
return function trReplacer(options) {
const node = options.node;
const parentNode = options.parentNode;
const type = options.type;
const key = options.key;
const value = allKeysForLocale[key];
let 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 === 'callTR.PAT') {
// Replace TR.PAT('keyName')(placeHolderValue, ...) with a string concatenation:
if (valueAst.type !== 'Literal' || typeof valueAst.value !== 'string') {
console.warn(
'trReplacer: Invalid TR.PAT 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 === 'TR.PAT') {
if (valueAst.type !== 'Literal' || typeof valueAst.value !== 'string') {
console.warn('trReplacer: Invalid TR.PAT syntax: ' + value);
return;
}
let highestPlaceHolderNumber;
i18nTools.tokenizePattern(valueAst.value).forEach(function (token) {
if (
token.type === 'placeHolder' &&
(!highestPlaceHolderNumber || token.value > highestPlaceHolderNumber)
) {
highestPlaceHolderNumber = token.value;
}
});
const argumentNameAsts = [];
const placeHolderAsts = [];
for (let j = 0; j <= highestPlaceHolderNumber; j += 1) {
const 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 TR.PAT expressions:
// {asset: ..., type: 'TR'|'TR.PAT', node, ..., defaultValue: <ast>}
// or <span data-i18n="keyName">...</span> tags:
// {asset: ..., type: 'i18nTag', node: ..., placeHolders: [...], defaultValue: <string>)
i18nTools.findOccurrences = function (assetGraph, initialAssets) {
const trOccurrencesByKey = {};
initialAssets.forEach(function (htmlAsset) {
assetGraph
.collectAssetsPostOrder(htmlAsset, {
type: { $nin: ['HtmlAnchor', 'HtmlMetaRefresh', 'SvgAnchor'] },
})
.forEach(function (asset) {
// Hack: Prevent system.js bundles from being written to disc:
if (
asset.isLoaded &&
assetGraph.findRelations({ from: asset, to: { type: 'SourceMap' } })
.length === 0
) {
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;
};
_.extend(exports, i18nTools);