Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/dev/i18n/__snapshots__/utils.test.js.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`i18n utils should create verbose parser error message 1`] = `
"Unexpected token, expected \\",\\" (4:19):
const object = {
object: 'with',
semicolon: '->';
};
"
`;

exports[`i18n utils should not escape linebreaks 1`] = `
"Text
with
Expand Down
40 changes: 23 additions & 17 deletions src/dev/i18n/extractors/code.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import {
} from '@babel/types';

import { extractI18nCallMessages } from './i18n_call';
import { isI18nTranslateFunction, traverseNodes } from '../utils';
import { createParserErrorMessage, isI18nTranslateFunction, traverseNodes } from '../utils';
import { extractIntlMessages, extractFormattedMessages } from './react';
import { createFailError } from '../../run';

/**
* Detect Intl.formatMessage() function call (React).
Expand All @@ -43,17 +44,11 @@ import { extractIntlMessages, extractFormattedMessages } from './react';
export function isIntlFormatMessageFunction(node) {
return (
isCallExpression(node) &&
(
isIdentifier(node.callee, { name: 'formatMessage' }) ||
(
isMemberExpression(node.callee) &&
(
isIdentifier(node.callee.object, { name: 'intl' }) ||
isIdentifier(node.callee.object.property, { name: 'intl' })
) &&
isIdentifier(node.callee.property, { name: 'formatMessage' })
)
)
(isIdentifier(node.callee, { name: 'formatMessage' }) ||
(isMemberExpression(node.callee) &&
(isIdentifier(node.callee.object, { name: 'intl' }) ||
isIdentifier(node.callee.object.property, { name: 'intl' })) &&
isIdentifier(node.callee.property, { name: 'formatMessage' })))
);
}

Expand All @@ -67,12 +62,23 @@ export function isFormattedMessageElement(node) {
}

export function* extractCodeMessages(buffer) {
const content = parse(buffer.toString(), {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'objectRestSpread', 'classProperties', 'asyncGenerators'],
});
let ast;

for (const node of traverseNodes(content.program.body)) {
try {
ast = parse(buffer.toString(), {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'objectRestSpread', 'classProperties', 'asyncGenerators'],
});
} catch (error) {
if (error instanceof SyntaxError) {
const errorWithContext = createParserErrorMessage(buffer.toString(), error);
throw createFailError(errorWithContext);
}

throw error;
}

for (const node of traverseNodes(ast.program.body)) {
if (isI18nTranslateFunction(node)) {
yield extractI18nCallMessages(node);
} else if (isIntlFormatMessageFunction(node)) {
Expand Down
44 changes: 39 additions & 5 deletions src/dev/i18n/extractors/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ import { jsdom } from 'jsdom';
import { parse } from '@babel/parser';
import { isDirectiveLiteral, isObjectExpression, isStringLiteral } from '@babel/types';

import { isPropertyWithKey, formatHTMLString, formatJSString, traverseNodes } from '../utils';
import {
isPropertyWithKey,
formatHTMLString,
formatJSString,
traverseNodes,
createParserErrorMessage,
} from '../utils';
import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants';
import { createFailError } from '../../run';

Expand All @@ -38,10 +44,23 @@ const I18N_FILTER_MARKER = '| i18n: ';
* @returns {string} Default message
*/
function parseFilterObjectExpression(expression) {
// parse an object expression instead of block statement
const nodes = parse(`+${expression}`).program.body;
let ast;

try {
// parse an object expression instead of block statement
ast = parse(`+${expression}`);
} catch (error) {
if (error instanceof SyntaxError) {
const errorWithContext = createParserErrorMessage(` ${expression}`, error);
throw createFailError(
`Couldn't parse angular expression with i18n filter:\n${errorWithContext}`
);
}

for (const node of traverseNodes(nodes)) {
throw error;
}

for (const node of traverseNodes(ast.program.body)) {
if (!isObjectExpression(node)) {
continue;
}
Expand Down Expand Up @@ -72,7 +91,22 @@ function parseFilterObjectExpression(expression) {
}

function parseIdExpression(expression) {
for (const node of traverseNodes(parse(expression).program.directives)) {
let ast;

try {
ast = parse(expression);
} catch (error) {
if (error instanceof SyntaxError) {
const errorWithContext = createParserErrorMessage(expression, error);
throw createFailError(
`Couldn't parse angular expression with i18n filter:\n${errorWithContext}`
);
}

throw error;
}

for (const node of traverseNodes(ast.program.directives)) {
if (isDirectiveLiteral(node)) {
return formatJSString(node.value);
}
Expand Down
2 changes: 0 additions & 2 deletions src/dev/i18n/extractors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,4 @@
export { extractCodeMessages } from './code';
export { extractHandlebarsMessages } from './handlebars';
export { extractHtmlMessages } from './html';
export { extractI18nCallMessages } from './i18n_call';
export { extractPugMessages } from './pug';
export { extractFormattedMessages, extractIntlMessages } from './react';
20 changes: 18 additions & 2 deletions src/dev/i18n/extractors/pug.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
import { parse } from '@babel/parser';

import { extractI18nCallMessages } from './i18n_call';
import { isI18nTranslateFunction, traverseNodes } from '../utils';
import { isI18nTranslateFunction, traverseNodes, createParserErrorMessage } from '../utils';
import { createFailError } from '../../run';

/**
* Matches `i18n(...)` in `#{i18n('id', { defaultMessage: 'Message text' })}`
Expand All @@ -34,7 +35,22 @@ export function* extractPugMessages(buffer) {
const expressions = buffer.toString().match(PUG_I18N_REGEX) || [];

for (const expression of expressions) {
for (const node of traverseNodes(parse(expression).program.body)) {
let ast;

try {
ast = parse(expression);
} catch (error) {
if (error instanceof SyntaxError) {
const errorWithContext = createParserErrorMessage(expression, error);
throw createFailError(
`Couldn't parse Pug expression with i18n(...) call:\n${errorWithContext}`
);
}

throw error;
}

for (const node of traverseNodes(ast.program.body)) {
if (isI18nTranslateFunction(node)) {
yield extractI18nCallMessages(node);
break;
Expand Down
30 changes: 30 additions & 0 deletions src/dev/i18n/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import fs from 'fs';
import glob from 'glob';
import { promisify } from 'util';
import chalk from 'chalk';

const ESCAPE_LINE_BREAK_REGEX = /(?<!\\)\\\n/g;
const HTML_LINE_BREAK_REGEX = /[\s]*\n[\s]*/g;
Expand Down Expand Up @@ -84,3 +85,32 @@ export function* traverseNodes(nodes) {
}
}
}

/**
* Forms an formatted error message for parser errors.
*
* This function returns a string which represents an error message and a place in the code where the error happened.
* In total five lines of the code are displayed: the line where the error occured, two lines before and two lines after.
*
* @param {string} content a code string where parsed error happened
* @param {{ loc: { line: number, column: number }, message: string }} error an object that contains an error message and
* the line number and the column number in the file that raised this error
* @returns {string} a formatted string representing parser error message
*/
export function createParserErrorMessage(content, error) {
const line = error.loc.line - 1;
const column = error.loc.column;

const contentLines = content.split(/\n/);
const firstLine = Math.max(line - 2, 0);
const lastLine = Math.min(line + 2, contentLines.length - 1);

contentLines[line] =
contentLines[line].substring(0, column) +
chalk.white.bgRed(contentLines[line][column] || ' ') +
contentLines[line].substring(column + 1);

const context = contentLines.slice(firstLine, lastLine + 1).join('\n');

return `${error.message}:\n${context}`;
}
30 changes: 29 additions & 1 deletion src/dev/i18n/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
import { parse } from '@babel/parser';
import { isExpressionStatement, isObjectExpression } from '@babel/types';

import { isI18nTranslateFunction, isPropertyWithKey, traverseNodes, formatJSString } from './utils';
import {
isI18nTranslateFunction,
isPropertyWithKey,
traverseNodes,
formatJSString,
createParserErrorMessage,
} from './utils';

const i18nTranslateSources = ['i18n', 'i18n.translate'].map(
callee => `
Expand All @@ -44,13 +50,15 @@ describe('i18n utils', () => {
test('should remove escaped linebreak', () => {
expect(formatJSString('Test\\\n str\\\ning')).toEqual('Test string');
});

test('should not escape linebreaks', () => {
expect(
formatJSString(`Text \n with
line-breaks
`)
).toMatchSnapshot();
});

test('should detect i18n translate function call', () => {
let source = i18nTranslateSources[0];
let expressionStatementNode = [...traverseNodes(parse(source).program.body)].find(node =>
Expand All @@ -76,4 +84,24 @@ describe('i18n utils', () => {
expect(isPropertyWithKey(objectExpresssionProperty, 'id')).toBe(true);
expect(isPropertyWithKey(objectExpresssionProperty, 'not_id')).toBe(false);
});

test('should create verbose parser error message', () => {
expect.assertions(1);

const content = `function testFunction() {
const object = {
object: 'with',
semicolon: '->';
};

return object;
}
`;

try {
parse(content);
} catch (error) {
expect(createParserErrorMessage(content, error)).toMatchSnapshot();
}
});
});