mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-11 06:04:55 +08:00
The current Loc presubmit are doing two checks: check_localizable_resources and check_localizability (Details of what they are checking: https://docs.google.com/document/d/1L6TkT2-42MMQ72ZSBMFwUaq7M6mDgA2X0x8oHHKaV_U/edit#heading=h.w1no7qaa0mi0) This CL merge two checks into one single check. check_localizability are modified into a utils file, and check_localizable_resources will also run those localizability checks when the files are visited. By doing this, we avoid the extra call to node during presubmit, and all files and nodes will only be visited once. Also adding these fixes https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2370103 Fix: - Pass the fullpath for comparison.(itemPath) - Normalize comparison between paths - using native fs.promises instead of promisify (https://github.com/nodejs/node/pull/26581) Bug: 1116989 Change-Id: I054040d83a65b5f798a21c040096422e287bc799 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2381320 Reviewed-by: Vidal Diazleal <vidorteg@microsoft.com> Reviewed-by: Tim van der Lippe <tvanderlippe@chromium.org> Reviewed-by: Simon Zünd <szuend@chromium.org> Commit-Queue: Christy Chen <chrche@microsoft.com>
222 lines
8.9 KiB
JavaScript
222 lines
8.9 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 localizationUtils = require('./localization_utils');
|
|
const espreeTypes = localizationUtils.espreeTypes;
|
|
const escodegen = localizationUtils.escodegen;
|
|
|
|
// 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 localizabilityErrors = [];
|
|
|
|
function includesConditionalExpression(listOfElements) {
|
|
return listOfElements.filter(ele => ele !== undefined && ele.type === espreeTypes.COND_EXPR).length > 0;
|
|
}
|
|
|
|
function includesGritPlaceholders(cookedValue) {
|
|
// $[0-9] is a GRIT placeholder for Chromium l10n, unfortunately it cannot be escaped.
|
|
// https://chromium-review.googlesource.com/c/chromium/src/+/1405148
|
|
const regexPattern = /\$[0-9]+/g;
|
|
return regexPattern.test(cookedValue);
|
|
}
|
|
|
|
/**
|
|
* Matches strings like:
|
|
* - https://web.dev
|
|
* - https://web.dev/page
|
|
* - https://web.dev/page?referrer=devtools_frontend&otherParam=param
|
|
*/
|
|
function isURL(string) {
|
|
const regexPattern =
|
|
/^(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g;
|
|
return regexPattern.test(string);
|
|
}
|
|
|
|
function addError(error) {
|
|
if (!localizabilityErrors.includes(error)) {
|
|
localizabilityErrors.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) {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check espree node object that represents the AST of code
|
|
* to see if there is any localization error.
|
|
*/
|
|
function analyzeCommonUIStringNode(node, filePath, code) {
|
|
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}`);
|
|
}
|
|
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.`);
|
|
}
|
|
|
|
if (node.arguments[0].type === espreeTypes.LITERAL && includesGritPlaceholders(node.arguments[0].value)) {
|
|
addError(`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: possible placeholder(s) found in ${
|
|
code}. Please extract placeholders(s) out of the localization call.`);
|
|
}
|
|
|
|
if (node.arguments[0].type === espreeTypes.LITERAL && isURL(node.arguments[0].value)) {
|
|
addError(`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: localized URL-only string found in ${
|
|
code}. Please extract the URL out of the localization call.`);
|
|
}
|
|
}
|
|
|
|
function analyzeTaggedTemplateNode(node, filePath, code) {
|
|
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.`);
|
|
}
|
|
|
|
if (includesGritPlaceholders(node.quasi.quasis[0].value.cooked)) {
|
|
addError(`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: possible placeholder(s) found in ${
|
|
code}. Please extract placeholders(s) out of the localization call.`);
|
|
}
|
|
|
|
if (isURL(node.quasi.quasis[0].value.raw)) {
|
|
addError(`${localizationUtils.getRelativeFilePathFromSrc(filePath)}${
|
|
localizationUtils.getLocationMessage(node.loc)}: localized URL-only string found in ${
|
|
code}. Please extract the URL out of the localization call.`);
|
|
}
|
|
}
|
|
|
|
function auditGrdpFile(filePath, fileContent) {
|
|
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]}"`);
|
|
}
|
|
}
|
|
|
|
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]}"`);
|
|
}
|
|
reportMissingPlaceholderExample(match[3], lineNumber);
|
|
}
|
|
}
|
|
|
|
reportMissingDescriptionAndPlaceholderExample();
|
|
}
|
|
|
|
module.exports = {
|
|
analyzeCommonUIStringNode,
|
|
analyzeTaggedTemplateNode,
|
|
auditGrdpFile,
|
|
checkConcatenation,
|
|
localizabilityErrors,
|
|
};
|