mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-11-03 03:39:20 +08:00
This CL moves the implementation of inspect_tool_highlight.html to DevTools, sets up a separate resource build target containing code bundles produced by rollup. Also, it adds size checks and sets up an example test. The future CLs will cover the rest of inspector tools and better tests. A follow-up CL for Chromium to use the newly generated resource is prepared[1]. [1] https://crrev.com/c/2194865 Bug: 1078267 Change-Id: I1c66e3c2aaec04c2f07fab8623d674ada95d8b24 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2192892 Commit-Queue: Alex Rudenko <alexrudenko@chromium.org> Reviewed-by: Tim van der Lippe <tvanderlippe@chromium.org> Reviewed-by: Mathias Bynens <mathias@chromium.org> Reviewed-by: Peter Marshall <petermarshall@chromium.org>
296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
// Copyright 2019 The Chromium Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
'use strict';
|
|
|
|
// Description: Scans for localizability violations in the DevTools front-end.
|
|
// Checks all .grdp files and reports messages without descriptions and placeholder examples.
|
|
// Audits all Common.UIString(), UI.formatLocalized(), and ls`` calls and
|
|
// checks for misuses of concatenation and conditionals. It also looks for
|
|
// specific arguments to functions that are expected to be a localized string.
|
|
// Since the check scans for common error patterns, it might misidentify something.
|
|
// In this case, add it to the excluded errors at the top of the script.
|
|
|
|
const fs = require('fs');
|
|
const {promisify} = require('util');
|
|
const readFileAsync = promisify(fs.readFile);
|
|
const path = require('path');
|
|
const localizationUtils = require('./utils/localization_utils');
|
|
const espreeTypes = localizationUtils.espreeTypes;
|
|
const escodegen = localizationUtils.escodegen;
|
|
const espree = localizationUtils.espree;
|
|
|
|
// Exclude known errors
|
|
const excludeErrors = [
|
|
'Common.UIString.UIString(view.title())', 'Common.UIString.UIString(setting.title() || \'\')',
|
|
'Common.UIString.UIString(option.text)', 'Common.UIString.UIString(experiment.title)',
|
|
'Common.UIString.UIString(phase.message)', 'Common.UIString.UIString(Help.latestReleaseNote().header)',
|
|
'Common.UIString.UIString(conditions.title)', 'Common.UIString.UIString(extension.title())',
|
|
'Common.UIString.UIString(this._currentValueLabel, value)',
|
|
|
|
'Common.UIString(view.title())', 'Common.UIString(setting.title() || \'\')', 'Common.UIString(option.text)',
|
|
'Common.UIString(experiment.title)', 'Common.UIString(phase.message)',
|
|
'Common.UIString(Help.latestReleaseNote().header)', 'Common.UIString(conditions.title)',
|
|
'Common.UIString(extension.title())', 'Common.UIString(this._currentValueLabel, value)'
|
|
];
|
|
|
|
const usage = `Usage: node ${path.basename(process.argv[0])} [-a | <.js file path>*]
|
|
|
|
-a: If present, check all devtools frontend .js files
|
|
<.js file path>*: List of .js files with absolute paths separated by a space
|
|
`;
|
|
|
|
async function main() {
|
|
if (process.argv.length < 3 || process.argv[2] === '--help') {
|
|
console.log(usage);
|
|
process.exit(0);
|
|
}
|
|
|
|
const errors = [];
|
|
|
|
try {
|
|
let filePaths = [];
|
|
const frontendPath = path.resolve(__dirname, '..', '..', 'front_end');
|
|
const filePathPromises = [localizationUtils.getFilesFromDirectory(frontendPath, filePaths, ['.grdp'])];
|
|
if (process.argv[2] === '-a') {
|
|
filePathPromises.push(localizationUtils.getFilesFromDirectory(frontendPath, filePaths, ['.js']));
|
|
} else {
|
|
if (process.argv[2] === '--file-list') {
|
|
const fileContent = await readFileAsync(process.argv[3]);
|
|
// convert to a list of filenames, remove the last empty string
|
|
filePaths = fileContent.toString().split(/\r?\n/g).slice(0, -1);
|
|
} else {
|
|
filePaths = process.argv.slice(2);
|
|
}
|
|
// espree has a bug parsing a valid JSON format, so exclude them.
|
|
filePaths = filePaths.filter(file => {
|
|
return (path.extname(file) !== '.json') && localizationUtils.shouldParseDirectory(file);
|
|
});
|
|
}
|
|
await Promise.all(filePathPromises);
|
|
|
|
filePaths.push(localizationUtils.SHARED_STRINGS_PATH);
|
|
const auditFilePromises = filePaths.map(filePath => auditFileForLocalizability(filePath, errors));
|
|
await Promise.all(auditFilePromises);
|
|
} catch (err) {
|
|
console.log(err);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
console.log(`DevTools localization checker detected errors!\n${errors.join('\n')}`);
|
|
process.exit(1);
|
|
}
|
|
console.log('DevTools localization checker passed');
|
|
}
|
|
|
|
main();
|
|
|
|
function includesConditionalExpression(listOfElements) {
|
|
return listOfElements.filter(ele => ele !== undefined && ele.type === espreeTypes.COND_EXPR).length > 0;
|
|
}
|
|
|
|
function addError(error, errors) {
|
|
if (!errors.includes(error)) {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
|
|
function buildConcatenatedNodesList(node, nodes) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
if (node.left === undefined && node.right === undefined) {
|
|
nodes.push(node);
|
|
return;
|
|
}
|
|
buildConcatenatedNodesList(node.left, nodes);
|
|
buildConcatenatedNodesList(node.right, nodes);
|
|
}
|
|
|
|
/**
|
|
* Recursively check if there is concatenation to localization call.
|
|
* Concatenation is allowed between localized strings and strings that
|
|
* don't contain letters.
|
|
* Example (allowed): ls`Status code: ${statusCode}`
|
|
* Example (allowed): ls`Status code` + ': '
|
|
* Example (disallowed): ls`Status code: ` + statusCode
|
|
* Example (disallowed): ls`Status ` + 'code'
|
|
*/
|
|
function checkConcatenation(parentNode, node, filePath, errors) {
|
|
function isConcatenationDisallowed(node) {
|
|
if (node.type !== espreeTypes.LITERAL && node.type !== espreeTypes.TEMP_LITERAL) {
|
|
return true;
|
|
}
|
|
|
|
let value;
|
|
if (node.type === espreeTypes.LITERAL) {
|
|
value = node.value;
|
|
} else if (node.type === espreeTypes.TEMP_LITERAL && node.expressions.length === 0) {
|
|
value = node.quasis[0].value.cooked;
|
|
}
|
|
|
|
if (!value || typeof value !== 'string') {
|
|
return true;
|
|
}
|
|
|
|
return value.match(/[a-z]/i) !== null;
|
|
}
|
|
|
|
function isConcatenation(node) {
|
|
return (node !== undefined && node.type === espreeTypes.BI_EXPR && node.operator === '+');
|
|
}
|
|
|
|
if (isConcatenation(parentNode)) {
|
|
return;
|
|
}
|
|
|
|
if (isConcatenation(node)) {
|
|
const concatenatedNodes = [];
|
|
buildConcatenatedNodesList(node, concatenatedNodes);
|
|
const nonLocalizationCalls = concatenatedNodes.filter(node => !localizationUtils.isLocalizationCall(node));
|
|
const hasLocalizationCall = nonLocalizationCalls.length !== concatenatedNodes.length;
|
|
if (hasLocalizationCall) {
|
|
// concatenation with localization call
|
|
const hasConcatenationViolation = nonLocalizationCalls.some(isConcatenationDisallowed);
|
|
if (hasConcatenationViolation) {
|
|
const code = escodegen.generate(node);
|
|
addError(
|
|
`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(
|
|
node.loc)}: string concatenation should be changed to variable substitution with ls: ${code}`,
|
|
errors);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check espree node object that represents the AST of code
|
|
* to see if there is any localization error.
|
|
*/
|
|
function analyzeNode(parentNode, node, filePath, errors) {
|
|
if (node === undefined || node === null) {
|
|
return;
|
|
}
|
|
|
|
if (node instanceof Array) {
|
|
for (const child of node) {
|
|
analyzeNode(node, child, filePath, errors);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const keys = Object.keys(node);
|
|
const objKeys = keys.filter(key => {
|
|
return typeof node[key] === 'object' && key !== 'loc';
|
|
});
|
|
if (objKeys.length === 0) {
|
|
// base case: all values are non-objects -> node is a leaf
|
|
return;
|
|
}
|
|
|
|
const locCase = localizationUtils.getLocalizationCase(node);
|
|
const code = escodegen.generate(node);
|
|
switch (locCase) {
|
|
case 'Common.UIString':
|
|
case 'UI.formatLocalized': {
|
|
const firstArgType = node.arguments[0].type;
|
|
if (firstArgType !== espreeTypes.LITERAL && firstArgType !== espreeTypes.TEMP_LITERAL &&
|
|
firstArgType !== espreeTypes.IDENTIFIER && !excludeErrors.includes(code)) {
|
|
addError(
|
|
`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: first argument to call should be a string: ${code}`,
|
|
errors);
|
|
}
|
|
if (includesConditionalExpression(node.arguments.slice(1))) {
|
|
addError(
|
|
`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${
|
|
code}. Please extract conditional(s) out of the localization call.`,
|
|
errors);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'Tagged Template': {
|
|
if (includesConditionalExpression(node.quasi.expressions)) {
|
|
addError(
|
|
`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: conditional(s) found in ${
|
|
code}. Please extract conditional(s) out of the localization call.`,
|
|
errors);
|
|
}
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
// String concatenation to localization call(s) should be changed
|
|
checkConcatenation(parentNode, node, filePath, errors);
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const key of objKeys) {
|
|
// recursively parse all the child nodes
|
|
analyzeNode(node, node[key], filePath, errors);
|
|
}
|
|
}
|
|
|
|
function auditGrdpFile(filePath, fileContent, errors) {
|
|
function reportMissingPlaceholderExample(messageContent, lineNumber) {
|
|
const phRegex = /<ph[^>]*name="([^"]*)">\$\d(s|d|\.\df)(?!<ex>)<\/ph>/gms;
|
|
let match;
|
|
// ph tag that contains $1.2f format placeholder without <ex>
|
|
// match[0]: full match
|
|
// match[1]: ph name
|
|
while ((match = phRegex.exec(messageContent)) !== null) {
|
|
addError(
|
|
`${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${
|
|
lineNumber +
|
|
localizationUtils.lineNumberOfIndex(
|
|
messageContent, match.index)}: missing <ex> in <ph> tag with the name "${match[1]}"`,
|
|
errors);
|
|
}
|
|
}
|
|
|
|
function reportMissingDescriptionAndPlaceholderExample() {
|
|
const messageRegex = /<message[^>]*name="([^"]*)"[^>]*desc="([^"]*)"[^>]*>\s*\n(.*?)<\/message>/gms;
|
|
let match;
|
|
// match[0]: full match
|
|
// match[1]: message IDS_ key
|
|
// match[2]: description
|
|
// match[3]: message content
|
|
while ((match = messageRegex.exec(fileContent)) !== null) {
|
|
const lineNumber = localizationUtils.lineNumberOfIndex(fileContent, match.index);
|
|
if (match[2].trim() === '') {
|
|
addError(
|
|
`${localizationUtils.getRelativeFilePathFromSrc(filePath)} Line ${
|
|
lineNumber}: missing description for message with the name "${match[1]}"`,
|
|
errors);
|
|
}
|
|
reportMissingPlaceholderExample(match[3], lineNumber);
|
|
}
|
|
}
|
|
|
|
reportMissingDescriptionAndPlaceholderExample();
|
|
}
|
|
|
|
async function auditFileForLocalizability(filePath, errors) {
|
|
const fileContent = await localizationUtils.parseFileContent(filePath);
|
|
if (path.extname(filePath) === '.grd') {
|
|
return;
|
|
}
|
|
if (path.extname(filePath) === '.grdp') {
|
|
return auditGrdpFile(filePath, fileContent, errors);
|
|
}
|
|
|
|
const ast = espree.parse(fileContent, {ecmaVersion: 11, sourceType: 'module', range: true, loc: true});
|
|
|
|
const relativeFilePath = localizationUtils.getRelativeFilePathFromSrc(filePath);
|
|
for (const node of ast.body) {
|
|
analyzeNode(undefined, node, relativeFilePath, errors);
|
|
}
|
|
}
|