mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
import {AbsoluteFsPath, getFileSystem, PathManipulation} from '@angular/compiler-cli/private/localize';
|
|
import {ɵisMissingTranslationError, ɵmakeTemplateObject, ɵParsedTranslation, ɵSourceLocation, ɵtranslate} from '@angular/localize';
|
|
import {NodePath} from '@babel/traverse';
|
|
|
|
import {types as t} from './babel_core';
|
|
import {DiagnosticHandlingStrategy, Diagnostics} from './diagnostics';
|
|
|
|
/**
|
|
* Is the given `expression` the global `$localize` identifier?
|
|
*
|
|
* @param expression The expression to check.
|
|
* @param localizeName The configured name of `$localize`.
|
|
*/
|
|
export function isLocalize(
|
|
expression: NodePath, localizeName: string): expression is NodePath<t.Identifier> {
|
|
return isNamedIdentifier(expression, localizeName) && isGlobalIdentifier(expression);
|
|
}
|
|
|
|
/**
|
|
* Is the given `expression` an identifier with the correct `name`?
|
|
*
|
|
* @param expression The expression to check.
|
|
* @param name The name of the identifier we are looking for.
|
|
*/
|
|
export function isNamedIdentifier(
|
|
expression: NodePath, name: string): expression is NodePath<t.Identifier> {
|
|
return expression.isIdentifier() && expression.node.name === name;
|
|
}
|
|
|
|
/**
|
|
* Is the given `identifier` declared globally.
|
|
*
|
|
* @param identifier The identifier to check.
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function isGlobalIdentifier(identifier: NodePath<t.Identifier>) {
|
|
return !identifier.scope || !identifier.scope.hasBinding(identifier.node.name);
|
|
}
|
|
|
|
/**
|
|
* Build a translated expression to replace the call to `$localize`.
|
|
* @param messageParts The static parts of the message.
|
|
* @param substitutions The expressions to substitute into the message.
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function buildLocalizeReplacement(
|
|
messageParts: TemplateStringsArray, substitutions: readonly t.Expression[]): t.Expression {
|
|
let mappedString: t.Expression = t.stringLiteral(messageParts[0]);
|
|
for (let i = 1; i < messageParts.length; i++) {
|
|
mappedString =
|
|
t.binaryExpression('+', mappedString, wrapInParensIfNecessary(substitutions[i - 1]));
|
|
mappedString = t.binaryExpression('+', mappedString, t.stringLiteral(messageParts[i]));
|
|
}
|
|
return mappedString;
|
|
}
|
|
|
|
/**
|
|
* Extract the message parts from the given `call` (to `$localize`).
|
|
*
|
|
* The message parts will either by the first argument to the `call` or it will be wrapped in call
|
|
* to a helper function like `__makeTemplateObject`.
|
|
*
|
|
* @param call The AST node of the call to process.
|
|
* @param fs The file system to use when computing source-map paths. If not provided then it uses
|
|
* the "current" FileSystem.
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function unwrapMessagePartsFromLocalizeCall(
|
|
call: NodePath<t.CallExpression>,
|
|
fs: PathManipulation = getFileSystem(),
|
|
): [TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
|
|
let cooked = call.get('arguments')[0];
|
|
|
|
if (cooked === undefined) {
|
|
throw new BabelParseError(call.node, '`$localize` called without any arguments.');
|
|
}
|
|
if (!cooked.isExpression()) {
|
|
throw new BabelParseError(
|
|
cooked.node, 'Unexpected argument to `$localize` (expected an array).');
|
|
}
|
|
|
|
// If there is no call to `__makeTemplateObject(...)`, then `raw` must be the same as `cooked`.
|
|
let raw = cooked;
|
|
|
|
// Check for a memoized form: `x || x = ...`
|
|
if (cooked.isLogicalExpression() && cooked.node.operator === '||' &&
|
|
cooked.get('left').isIdentifier()) {
|
|
const right = cooked.get('right');
|
|
if (right.isAssignmentExpression()) {
|
|
cooked = right.get('right');
|
|
if (!cooked.isExpression()) {
|
|
throw new BabelParseError(
|
|
cooked.node, 'Unexpected "makeTemplateObject()" function (expected an expression).');
|
|
}
|
|
} else if (right.isSequenceExpression()) {
|
|
const expressions = right.get('expressions');
|
|
if (expressions.length > 2) {
|
|
// This is a minified sequence expression, where the first two expressions in the sequence
|
|
// are assignments of the cooked and raw arrays respectively.
|
|
const [first, second] = expressions;
|
|
if (first.isAssignmentExpression()) {
|
|
cooked = first.get('right');
|
|
if (!cooked.isExpression()) {
|
|
throw new BabelParseError(
|
|
first.node, 'Unexpected cooked value, expected an expression.');
|
|
}
|
|
if (second.isAssignmentExpression()) {
|
|
raw = second.get('right');
|
|
if (!raw.isExpression()) {
|
|
throw new BabelParseError(
|
|
second.node, 'Unexpected raw value, expected an expression.');
|
|
}
|
|
} else {
|
|
// If the second expression is not an assignment then it is probably code to take a copy
|
|
// of the cooked array. For example: `raw || (raw=cooked.slice(0))`.
|
|
raw = cooked;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for `__makeTemplateObject(cooked, raw)` or `__templateObject()` calls.
|
|
if (cooked.isCallExpression()) {
|
|
let call = cooked;
|
|
if (call.get('arguments').length === 0) {
|
|
// No arguments so perhaps it is a `__templateObject()` call.
|
|
// Unwrap this to get the `_taggedTemplateLiteral(cooked, raw)` call.
|
|
call = unwrapLazyLoadHelperCall(call);
|
|
}
|
|
|
|
cooked = call.get('arguments')[0];
|
|
if (!cooked.isExpression()) {
|
|
throw new BabelParseError(
|
|
cooked.node,
|
|
'Unexpected `cooked` argument to the "makeTemplateObject()" function (expected an expression).');
|
|
}
|
|
const arg2 = call.get('arguments')[1];
|
|
if (arg2 && !arg2.isExpression()) {
|
|
throw new BabelParseError(
|
|
arg2.node,
|
|
'Unexpected `raw` argument to the "makeTemplateObject()" function (expected an expression).');
|
|
}
|
|
// If there is no second argument then assume that raw and cooked are the same
|
|
raw = arg2 !== undefined ? arg2 : cooked;
|
|
}
|
|
|
|
const [cookedStrings] = unwrapStringLiteralArray(cooked, fs);
|
|
const [rawStrings, rawLocations] = unwrapStringLiteralArray(raw, fs);
|
|
return [ɵmakeTemplateObject(cookedStrings, rawStrings), rawLocations];
|
|
}
|
|
|
|
/**
|
|
* Parse the localize call expression to extract the arguments that hold the substitution
|
|
* expressions.
|
|
*
|
|
* @param call The AST node of the call to process.
|
|
* @param fs The file system to use when computing source-map paths. If not provided then it uses
|
|
* the "current" FileSystem.
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function unwrapSubstitutionsFromLocalizeCall(
|
|
call: NodePath<t.CallExpression>,
|
|
fs: PathManipulation = getFileSystem()): [t.Expression[], (ɵSourceLocation | undefined)[]] {
|
|
const expressions = call.get('arguments').splice(1);
|
|
if (!isArrayOfExpressions(expressions)) {
|
|
const badExpression = expressions.find(expression => !expression.isExpression())!;
|
|
throw new BabelParseError(
|
|
badExpression.node,
|
|
'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).');
|
|
}
|
|
return [
|
|
expressions.map(path => path.node), expressions.map(expression => getLocation(fs, expression))
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse the tagged template literal to extract the message parts.
|
|
*
|
|
* @param elements The elements of the template literal to process.
|
|
* @param fs The file system to use when computing source-map paths. If not provided then it uses
|
|
* the "current" FileSystem.
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function unwrapMessagePartsFromTemplateLiteral(
|
|
elements: NodePath<t.TemplateElement>[], fs: PathManipulation = getFileSystem()):
|
|
[TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
|
|
const cooked = elements.map(q => {
|
|
if (q.node.value.cooked === undefined) {
|
|
throw new BabelParseError(
|
|
q.node,
|
|
`Unexpected undefined message part in "${elements.map(q => q.node.value.cooked)}"`);
|
|
}
|
|
return q.node.value.cooked;
|
|
});
|
|
const raw = elements.map(q => q.node.value.raw);
|
|
const locations = elements.map(q => getLocation(fs, q));
|
|
return [ɵmakeTemplateObject(cooked, raw), locations];
|
|
}
|
|
|
|
/**
|
|
* Parse the tagged template literal to extract the interpolation expressions.
|
|
*
|
|
* @param quasi The AST node of the template literal to process.
|
|
* @param fs The file system to use when computing source-map paths. If not provided then it uses
|
|
* the "current" FileSystem.
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function unwrapExpressionsFromTemplateLiteral(
|
|
quasi: NodePath<t.TemplateLiteral>,
|
|
fs: PathManipulation = getFileSystem()): [t.Expression[], (ɵSourceLocation | undefined)[]] {
|
|
return [
|
|
quasi.node.expressions as t.Expression[], quasi.get('expressions').map(e => getLocation(fs, e))
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Wrap the given `expression` in parentheses if it is a binary expression.
|
|
*
|
|
* This ensures that this expression is evaluated correctly if it is embedded in another expression.
|
|
*
|
|
* @param expression The expression to potentially wrap.
|
|
*/
|
|
export function wrapInParensIfNecessary(expression: t.Expression): t.Expression {
|
|
if (t.isBinaryExpression(expression)) {
|
|
return t.parenthesizedExpression(expression);
|
|
} else {
|
|
return expression;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the string values from an `array` of string literals.
|
|
*
|
|
* @param array The array to unwrap.
|
|
* @param fs The file system to use when computing source-map paths. If not provided then it uses
|
|
* the "current" FileSystem.
|
|
*/
|
|
export function unwrapStringLiteralArray(
|
|
array: NodePath<t.Expression>,
|
|
fs: PathManipulation = getFileSystem()): [string[], (ɵSourceLocation | undefined)[]] {
|
|
if (!isStringLiteralArray(array.node)) {
|
|
throw new BabelParseError(
|
|
array.node, 'Unexpected messageParts for `$localize` (expected an array of strings).');
|
|
}
|
|
const elements = array.get('elements') as NodePath<t.StringLiteral>[];
|
|
return [elements.map(str => str.node.value), elements.map(str => getLocation(fs, str))];
|
|
}
|
|
|
|
/**
|
|
* This expression is believed to be a call to a "lazy-load" template object helper function.
|
|
* This is expected to be of the form:
|
|
*
|
|
* ```ts
|
|
* function _templateObject() {
|
|
* var e = _taggedTemplateLiteral(['cooked string', 'raw string']);
|
|
* return _templateObject = function() { return e }, e
|
|
* }
|
|
* ```
|
|
*
|
|
* We unwrap this to return the call to `_taggedTemplateLiteral()`.
|
|
*
|
|
* @param call the call expression to unwrap
|
|
* @returns the call expression
|
|
*/
|
|
export function unwrapLazyLoadHelperCall(call: NodePath<t.CallExpression>):
|
|
NodePath<t.CallExpression> {
|
|
const callee = call.get('callee');
|
|
if (!callee.isIdentifier()) {
|
|
throw new BabelParseError(
|
|
callee.node,
|
|
'Unexpected lazy-load helper call (expected a call of the form `_templateObject()`).');
|
|
}
|
|
const lazyLoadBinding = call.scope.getBinding(callee.node.name);
|
|
if (!lazyLoadBinding) {
|
|
throw new BabelParseError(callee.node, 'Missing declaration for lazy-load helper function');
|
|
}
|
|
const lazyLoadFn = lazyLoadBinding.path;
|
|
if (!lazyLoadFn.isFunctionDeclaration()) {
|
|
throw new BabelParseError(
|
|
lazyLoadFn.node, 'Unexpected expression (expected a function declaration');
|
|
}
|
|
const returnedNode = getReturnedExpression(lazyLoadFn);
|
|
|
|
if (returnedNode.isCallExpression()) {
|
|
return returnedNode;
|
|
}
|
|
|
|
if (returnedNode.isIdentifier()) {
|
|
const identifierName = returnedNode.node.name;
|
|
const declaration = returnedNode.scope.getBinding(identifierName);
|
|
if (declaration === undefined) {
|
|
throw new BabelParseError(
|
|
returnedNode.node, 'Missing declaration for return value from helper.');
|
|
}
|
|
if (!declaration.path.isVariableDeclarator()) {
|
|
throw new BabelParseError(
|
|
declaration.path.node,
|
|
'Unexpected helper return value declaration (expected a variable declaration).');
|
|
}
|
|
const initializer = declaration.path.get('init');
|
|
if (!initializer.isCallExpression()) {
|
|
throw new BabelParseError(
|
|
declaration.path.node,
|
|
'Unexpected return value from helper (expected a call expression).');
|
|
}
|
|
|
|
// Remove the lazy load helper if this is the only reference to it.
|
|
if (lazyLoadBinding.references === 1) {
|
|
lazyLoadFn.remove();
|
|
}
|
|
|
|
return initializer;
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function getReturnedExpression(fn: NodePath<t.FunctionDeclaration>): NodePath<t.Expression> {
|
|
const bodyStatements = fn.get('body').get('body');
|
|
for (const statement of bodyStatements) {
|
|
if (statement.isReturnStatement()) {
|
|
const argument = statement.get('argument');
|
|
if (argument.isSequenceExpression()) {
|
|
const expressions = argument.get('expressions');
|
|
return Array.isArray(expressions) ? expressions[expressions.length - 1] : expressions;
|
|
} else if (argument.isExpression()) {
|
|
return argument;
|
|
} else {
|
|
throw new BabelParseError(
|
|
statement.node, 'Invalid return argument in helper function (expected an expression).');
|
|
}
|
|
}
|
|
}
|
|
throw new BabelParseError(fn.node, 'Missing return statement in helper function.');
|
|
}
|
|
|
|
/**
|
|
* Is the given `node` an array of literal strings?
|
|
*
|
|
* @param node The node to test.
|
|
*/
|
|
export function isStringLiteralArray(node: t.Node): node is t.Expression&
|
|
{elements: t.StringLiteral[]} {
|
|
return t.isArrayExpression(node) && node.elements.every(element => t.isStringLiteral(element));
|
|
}
|
|
|
|
/**
|
|
* Are all the given `nodes` expressions?
|
|
* @param nodes The nodes to test.
|
|
*/
|
|
export function isArrayOfExpressions(paths: NodePath<t.Node>[]): paths is NodePath<t.Expression>[] {
|
|
return paths.every(element => element.isExpression());
|
|
}
|
|
|
|
/** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */
|
|
export interface TranslatePluginOptions {
|
|
missingTranslation?: DiagnosticHandlingStrategy;
|
|
localizeName?: string;
|
|
}
|
|
|
|
/**
|
|
* Translate the text of the given message, using the given translations.
|
|
*
|
|
* Logs as warning if the translation is not available
|
|
* @publicApi used by CLI
|
|
*/
|
|
export function translate(
|
|
diagnostics: Diagnostics, translations: Record<string, ɵParsedTranslation>,
|
|
messageParts: TemplateStringsArray, substitutions: readonly any[],
|
|
missingTranslation: DiagnosticHandlingStrategy): [TemplateStringsArray, readonly any[]] {
|
|
try {
|
|
return ɵtranslate(translations, messageParts, substitutions);
|
|
} catch (e: any) {
|
|
if (ɵisMissingTranslationError(e)) {
|
|
diagnostics.add(missingTranslation, e.message);
|
|
// Return the parsed message because this will have the meta blocks stripped
|
|
return [
|
|
ɵmakeTemplateObject(e.parsedMessage.messageParts, e.parsedMessage.messageParts),
|
|
substitutions
|
|
];
|
|
} else {
|
|
diagnostics.error(e.message);
|
|
return [messageParts, substitutions];
|
|
}
|
|
}
|
|
}
|
|
|
|
export class BabelParseError extends Error {
|
|
private readonly type = 'BabelParseError';
|
|
constructor(public node: t.Node, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export function isBabelParseError(e: any): e is BabelParseError {
|
|
return e.type === 'BabelParseError';
|
|
}
|
|
|
|
export function buildCodeFrameError(
|
|
fs: PathManipulation, path: NodePath, e: BabelParseError): string {
|
|
let filename = path.hub.file.opts.filename;
|
|
if (filename) {
|
|
filename = fs.resolve(filename);
|
|
let cwd = path.hub.file.opts.cwd;
|
|
if (cwd) {
|
|
cwd = fs.resolve(cwd);
|
|
filename = fs.relative(cwd, filename);
|
|
}
|
|
} else {
|
|
filename = '(unknown file)';
|
|
}
|
|
const message = path.hub.file.buildCodeFrameError(e.node, e.message).message;
|
|
return `${filename}: ${message}`;
|
|
}
|
|
|
|
export function getLocation(
|
|
fs: PathManipulation, startPath: NodePath, endPath?: NodePath): ɵSourceLocation|undefined {
|
|
const startLocation = startPath.node.loc;
|
|
const file = getFileFromPath(fs, startPath);
|
|
if (!startLocation || !file) {
|
|
return undefined;
|
|
}
|
|
|
|
const endLocation =
|
|
endPath && getFileFromPath(fs, endPath) === file && endPath.node.loc || startLocation;
|
|
|
|
return {
|
|
start: getLineAndColumn(startLocation.start),
|
|
end: getLineAndColumn(endLocation.end),
|
|
file,
|
|
text: getText(startPath),
|
|
};
|
|
}
|
|
|
|
export function serializeLocationPosition(location: ɵSourceLocation): string {
|
|
const endLineString = location.end !== undefined && location.end.line !== location.start.line ?
|
|
`,${location.end.line + 1}` :
|
|
'';
|
|
return `${location.start.line + 1}${endLineString}`;
|
|
}
|
|
|
|
function getFileFromPath(fs: PathManipulation, path: NodePath|undefined): AbsoluteFsPath|null {
|
|
const opts = path?.hub.file.opts;
|
|
const filename = opts?.filename;
|
|
if (!filename || !opts.cwd) {
|
|
return null;
|
|
}
|
|
const relativePath = fs.relative(opts.cwd, filename);
|
|
const root = opts.generatorOpts?.sourceRoot ?? opts.cwd;
|
|
const absPath = fs.resolve(root, relativePath);
|
|
return absPath;
|
|
}
|
|
|
|
function getLineAndColumn(loc: {line: number, column: number}): {line: number, column: number} {
|
|
// Note we want 0-based line numbers but Babel returns 1-based.
|
|
return {line: loc.line - 1, column: loc.column};
|
|
}
|
|
|
|
function getText(path: NodePath): string|undefined {
|
|
if (path.node.start == null || path.node.end == null) {
|
|
return undefined;
|
|
}
|
|
return path.hub.file.code.substring(path.node.start, path.node.end);
|
|
}
|