Add ESLint rule for Translations (#168001)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Jon <jon@budzenski.me>
This commit is contained in:
Coen Warmer 2023-10-16 18:34:50 +02:00 committed by GitHub
parent d7028ca9e0
commit 6fcf8c9efe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 779 additions and 0 deletions

View file

@ -924,6 +924,8 @@ module.exports = {
],
rules: {
'@kbn/telemetry/event_generating_elements_should_be_instrumented': 'error',
'@kbn/i18n/strings_should_be_translated_with_i18n': 'warn',
'@kbn/i18n/strings_should_be_translated_with_formatted_message': 'warn',
},
},
{

1
.github/CODEOWNERS vendored
View file

@ -360,6 +360,7 @@ src/plugins/es_ui_shared @elastic/platform-deployment-management
packages/kbn-eslint-config @elastic/kibana-operations
packages/kbn-eslint-plugin-disable @elastic/kibana-operations
packages/kbn-eslint-plugin-eslint @elastic/kibana-operations
packages/kbn-eslint-plugin-i18n @elastic/actionable-observability
packages/kbn-eslint-plugin-imports @elastic/kibana-operations
packages/kbn-eslint-plugin-telemetry @elastic/actionable-observability
x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin @elastic/kibana-security

View file

@ -1196,6 +1196,7 @@
"@kbn/eslint-config": "link:packages/kbn-eslint-config",
"@kbn/eslint-plugin-disable": "link:packages/kbn-eslint-plugin-disable",
"@kbn/eslint-plugin-eslint": "link:packages/kbn-eslint-plugin-eslint",
"@kbn/eslint-plugin-i18n": "link:packages/kbn-eslint-plugin-i18n",
"@kbn/eslint-plugin-imports": "link:packages/kbn-eslint-plugin-imports",
"@kbn/eslint-plugin-telemetry": "link:packages/kbn-eslint-plugin-telemetry",
"@kbn/expect": "link:packages/kbn-expect",

View file

@ -8,6 +8,7 @@ module.exports = {
'@kbn/eslint-plugin-eslint',
'@kbn/eslint-plugin-imports',
'@kbn/eslint-plugin-telemetry',
'@kbn/eslint-plugin-i18n',
'prettier',
],

View file

@ -0,0 +1,17 @@
---
id: kibDevDocsOpsEslintPluginI18N
slug: /kibana-dev-docs/ops/kbn-eslint-plugin-i18n
title: '@kbn/eslint-plugin-i18n'
description: Custom ESLint rules to support translations in the Kibana repository
tags: ['kibana', 'dev', 'contributor', 'operations', 'eslint', 'i18n']
---
`@kbn/eslint-plugin-i18n` is an ESLint plugin providing custom rules for validating JSXCode in the Kibana repo to make sure they are translated.
## `@kbn/i18n/strings_should_be_translated_with_i18n`
This rule warns engineers to translate their strings by using i18n.translate from the '@kbn/i18n' package. It provides an autofix that takes into account the context of the translatable string in the JSX tree to generate a translation ID.
## `@kbn/i18n/strings_should_be_translated_with_formatted_message`
This rule warns engineers to translate their strings by using `<FormattedMessage>` from the '@kbn/i18n-react' package. It provides an autofix that takes into account the context of the translatable string in the JSX tree and to generate a translation ID.

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree';
import { lowerCaseFirstLetter } from './utils';
export function getFunctionName(func: TSESTree.FunctionDeclaration | TSESTree.Node): string {
if (
'id' in func &&
func.id &&
func.type === AST_NODE_TYPES.FunctionDeclaration &&
func.id.type === AST_NODE_TYPES.Identifier
) {
return lowerCaseFirstLetter(func.id.name);
}
if (
func.parent &&
(func.parent.type !== AST_NODE_TYPES.VariableDeclarator ||
func.parent.id.type !== AST_NODE_TYPES.Identifier)
) {
return getFunctionName(func.parent);
}
if (func.parent?.id && 'name' in func.parent.id) {
return lowerCaseFirstLetter(func.parent.id.name);
}
return '';
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getI18nIdentifierFromFilePath } from './get_i18n_identifier_from_file_path';
const SYSTEMPATH = 'systemPath';
const testMap = [
['x-pack/plugins/observability/foo/bar/baz/header_actions.tsx', 'xpack.observability'],
['x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx', 'xpack.apm'],
['x-pack/plugins/cases/public/components/foo.tsx', 'xpack.cases'],
[
'packages/kbn-alerts-ui-shared/src/alert_lifecycle_status_badge/index.tsx',
'app_not_found_in_i18nrc',
],
];
describe('Get i18n Identifier for file', () => {
test.each(testMap)(
'should get the right i18n identifier for a file inside an x-pack plugin',
(path, expectedValue) => {
const appName = getI18nIdentifierFromFilePath(`${SYSTEMPATH}/${path}`, SYSTEMPATH);
expect(appName).toBe(expectedValue);
}
);
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import fs from 'fs';
import { join, parse, resolve } from 'path';
import { findKey } from 'lodash';
export function getI18nIdentifierFromFilePath(fileName: string, cwd: string) {
const { dir } = parse(fileName);
const relativePathToFile = dir.replace(cwd, '');
const relativePathArray = relativePathToFile.split('/');
const path = `${relativePathArray[2]}/${relativePathArray[3]}`;
const xpackRC = resolve(join(__dirname, '../../../'), 'x-pack/.i18nrc.json');
const i18nrcFile = fs.readFileSync(xpackRC, 'utf8');
const i18nrc = JSON.parse(i18nrcFile);
return i18nrc && i18nrc.paths
? findKey(i18nrc.paths, (v) => v === path) ?? 'app_not_found_in_i18nrc'
: 'app_not_found_in_i18nrc';
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SourceCode } from 'eslint';
const KBN_I18N_I18N_IMPORT = "import { i18n } from '@kbn/i18n';" as const;
const KBN_I18N_REACT_FORMATTED_MESSAGE_IMPORT =
"import { FormattedMessage } from '@kbn/i18n-react';" as const;
export function getI18nImportFixer({
sourceCode,
mode,
}: {
sourceCode: SourceCode;
mode: 'i18n.translate' | 'FormattedMessage';
}) {
const hasI18nImportLine = Boolean(
sourceCode.lines.find((l) =>
mode === 'i18n.translate'
? l === KBN_I18N_I18N_IMPORT
: l === KBN_I18N_REACT_FORMATTED_MESSAGE_IMPORT
)
);
if (hasI18nImportLine) return { hasI18nImportLine };
// Translation package has not been imported yet so we need to add it.
// Pretty safe bet to add it underneath the React import.
const reactImportLineIndex = sourceCode.lines.findIndex((l) => l.includes("from 'react'"));
const targetLine = sourceCode.lines[reactImportLineIndex];
const column = targetLine.length;
const start = sourceCode.getIndexFromLoc({ line: reactImportLineIndex + 1, column: 0 });
const end = sourceCode.getIndexFromLoc({
line: reactImportLineIndex + 1,
column,
});
return {
hasI18nImportLine,
i18nPackageImportLine:
mode === 'i18n.translate' ? KBN_I18N_I18N_IMPORT : KBN_I18N_REACT_FORMATTED_MESSAGE_IMPORT,
rangeToAddI18nImportLine: [start, end] as [number, number],
};
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { TSESTree } from '@typescript-eslint/typescript-estree';
import { lowerCaseFirstLetter, upperCaseFirstLetter } from './utils';
export function getIntentFromNode(originalNode: TSESTree.JSXText): string {
const value = lowerCaseFirstLetter(
originalNode.value
.replace(/[?!@#$%^&*()_+\][{}|/<>,'"]/g, '')
.trim()
.split(' ')
.filter((v, i) => i < 4)
.map(upperCaseFirstLetter)
.join('')
);
const { parent } = originalNode;
if (
parent &&
'openingElement' in parent &&
'name' in parent.openingElement &&
'name' in parent.openingElement.name
) {
const parentTagName = String(parent.openingElement.name.name);
if (parentTagName.includes('Eui')) {
return `${value}${parentTagName.replace('Eui', '')}Label`;
}
return `${lowerCaseFirstLetter(parentTagName)}.${value}Label`;
}
return `${value}Label`;
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export function lowerCaseFirstLetter(str: string) {
return str.charAt(0).toLowerCase() + str.slice(1);
}
export function upperCaseFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function isTruthy<T>(value: T): value is NonNullable<T> {
return value != null;
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { StringsShouldBeTranslatedWithI18n } from './rules/strings_should_be_translated_with_i18n';
import { StringsShouldBeTranslatedWithFormattedMessage } from './rules/strings_should_be_translated_with_formatted_message';
/**
* Custom ESLint rules, add `'@kbn/eslint-plugin-telemetry'` to your eslint config to use them
* @internal
*/
export const rules = {
strings_should_be_translated_with_i18n: StringsShouldBeTranslatedWithI18n,
strings_should_be_translated_with_formatted_message:
StringsShouldBeTranslatedWithFormattedMessage,
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-eslint-plugin-i18n'],
};

View file

@ -0,0 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/eslint-plugin-i18n",
"owner": "@elastic/actionable-observability",
"devOnly": true
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/eslint-plugin-i18n",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RuleTester } from 'eslint';
import { StringsShouldBeTranslatedWithFormattedMessage } from './strings_should_be_translated_with_formatted_message';
const tsTester = [
'@typescript-eslint/parser',
new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
}),
] as const;
const babelTester = [
'@babel/eslint-parser',
new RuleTester({
parser: require.resolve('@babel/eslint-parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
requireConfigFile: false,
babelOptions: {
presets: ['@kbn/babel-preset/node_preset'],
},
},
}),
] as const;
const valid = [
{
filename: 'x-pack/plugins/observability/public/test_component.tsx',
code: `
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
function TestComponent() {
return (
<div>
<FormattedMessage
id="app_not_found_in_i18nrc.testComponent.div.thisIsATestLabel"
defaultMessage="This is a test"
/></div>
)
}`,
},
{
filename: 'x-pack/plugins/observability/public/another_component.tsx',
code: `
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
function AnotherComponent() {
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton>
<FormattedMessage
id="app_not_found_in_i18nrc.anotherComponent.thisIsATestButtonLabel"
defaultMessage="This is a test"
/></EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
)
}`,
},
{
filename: 'x-pack/plugins/observability/public/yet_another_component.tsx',
code: `
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
function YetAnotherComponent() {
return (
<div>
<EuiSelect>
<FormattedMessage
id="app_not_found_in_i18nrc.yetAnotherComponent.selectMeSelectLabel"
defaultMessage="Select me"
/></EuiSelect>
</div>
)
}`,
},
];
const invalid = [
{
filename: valid[0].filename,
code: `
import React from 'react';
function TestComponent() {
return (
<div>This is a test</div>
)
}`,
errors: [
{
line: 6,
message: `Strings should be translated with <FormattedMessage />. Use the autofix suggestion or add your own.`,
},
],
output: valid[0].code,
},
{
filename: valid[1].filename,
code: `
import React from 'react';
function AnotherComponent() {
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton>This is a test</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
)
}`,
errors: [
{
line: 9,
message: `Strings should be translated with <FormattedMessage />. Use the autofix suggestion or add your own.`,
},
],
output: valid[1].code,
},
{
filename: valid[2].filename,
code: `
import React from 'react';
function YetAnotherComponent() {
return (
<div>
<EuiSelect>Select me</EuiSelect>
</div>
)
}`,
errors: [
{
line: 7,
message: `Strings should be translated with <FormattedMessage />. Use the autofix suggestion or add your own.`,
},
],
output: valid[2].code,
},
];
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run(
'@kbn/event_generating_elements_should_be_instrumented',
StringsShouldBeTranslatedWithFormattedMessage,
{
valid,
invalid,
}
);
});
}

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { TSESTree } from '@typescript-eslint/typescript-estree';
import type { Rule } from 'eslint';
import { getIntentFromNode } from '../helpers/get_intent_from_node';
import { getI18nIdentifierFromFilePath } from '../helpers/get_i18n_identifier_from_file_path';
import { getFunctionName } from '../helpers/get_function_name';
import { getI18nImportFixer } from '../helpers/get_i18n_import_fixer';
import { isTruthy } from '../helpers/utils';
export const StringsShouldBeTranslatedWithFormattedMessage: Rule.RuleModule = {
meta: {
type: 'suggestion',
fixable: 'code',
},
create(context) {
const { cwd, filename, getScope, sourceCode, report } = context;
return {
JSXText: (node: TSESTree.JSXText) => {
const value = node.value.trim();
// If the JSXText element is empty we don't need to do anything
if (!value) return;
// Get the whitespaces before the string so we can add them to the autofix suggestion
const regex = /^(\s*)(\S)(.*)/;
const whiteSpaces = node.value.match(regex)?.[1] ?? '';
// Start building the translation ID suggestion
const i18nAppId = getI18nIdentifierFromFilePath(filename, cwd);
const functionDeclaration = getScope().block as TSESTree.FunctionDeclaration;
const functionName = getFunctionName(functionDeclaration);
const intent = getIntentFromNode(node);
const translationIdSuggestion = `${i18nAppId}.${functionName}.${intent}`; // 'xpack.observability.overview.logs.loadMoreLabel'
// Check if i18n has already been imported into the file.
const {
hasI18nImportLine,
i18nPackageImportLine: i18nImportLine,
rangeToAddI18nImportLine,
} = getI18nImportFixer({
sourceCode,
mode: 'FormattedMessage',
});
// Show warning to developer and offer autofix suggestion
report({
node: node as any,
message:
'Strings should be translated with <FormattedMessage />. Use the autofix suggestion or add your own.',
fix(fixer) {
return [
fixer.replaceText(
node,
`${whiteSpaces}\n<FormattedMessage
id="${translationIdSuggestion}"
defaultMessage="${value}"
/>`
),
!hasI18nImportLine
? fixer.insertTextAfterRange(rangeToAddI18nImportLine, `\n${i18nImportLine}`)
: null,
].filter(isTruthy);
},
});
},
} as Rule.RuleListener;
},
};

View file

@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RuleTester } from 'eslint';
import { StringsShouldBeTranslatedWithI18n } from './strings_should_be_translated_with_i18n';
const tsTester = [
'@typescript-eslint/parser',
new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
}),
] as const;
const babelTester = [
'@babel/eslint-parser',
new RuleTester({
parser: require.resolve('@babel/eslint-parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
requireConfigFile: false,
babelOptions: {
presets: ['@kbn/babel-preset/node_preset'],
},
},
}),
] as const;
const valid = [
{
filename: 'x-pack/plugins/observability/public/test_component.tsx',
code: `
import React from 'react';
import { i18n } from '@kbn/i18n';
function TestComponent() {
return (
<div>{i18n.translate('app_not_found_in_i18nrc.testComponent.div.thisIsATestLabel', { defaultMessage: "This is a test"})}</div>
)
}`,
},
{
filename: 'x-pack/plugins/observability/public/another_component.tsx',
code: `
import React from 'react';
import { i18n } from '@kbn/i18n';
function AnotherComponent() {
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton>{i18n.translate('app_not_found_in_i18nrc.anotherComponent.thisIsATestButtonLabel', { defaultMessage: "This is a test"})}</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
)
}`,
},
{
filename: 'x-pack/plugins/observability/public/yet_another_component.tsx',
code: `
import React from 'react';
import { i18n } from '@kbn/i18n';
function YetAnotherComponent() {
return (
<div>
<EuiSelect>{i18n.translate('app_not_found_in_i18nrc.yetAnotherComponent.selectMeSelectLabel', { defaultMessage: "Select me"})}</EuiSelect>
</div>
)
}`,
},
];
const invalid = [
{
filename: valid[0].filename,
code: `
import React from 'react';
function TestComponent() {
return (
<div>This is a test</div>
)
}`,
errors: [
{
line: 6,
message: `Strings should be translated with i18n. Use the autofix suggestion or add your own.`,
},
],
output: valid[0].code,
},
{
filename: valid[1].filename,
code: `
import React from 'react';
function AnotherComponent() {
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton>This is a test</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
)
}`,
errors: [
{
line: 9,
message: `Strings should be translated with i18n. Use the autofix suggestion or add your own.`,
},
],
output: valid[1].code,
},
{
filename: valid[2].filename,
code: `
import React from 'react';
function YetAnotherComponent() {
return (
<div>
<EuiSelect>Select me</EuiSelect>
</div>
)
}`,
errors: [
{
line: 7,
message: `Strings should be translated with i18n. Use the autofix suggestion or add your own.`,
},
],
output: valid[2].code,
},
];
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run(
'@kbn/event_generating_elements_should_be_instrumented',
StringsShouldBeTranslatedWithI18n,
{
valid,
invalid,
}
);
});
}

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { TSESTree } from '@typescript-eslint/typescript-estree';
import type { Rule } from 'eslint';
import { getIntentFromNode } from '../helpers/get_intent_from_node';
import { getI18nIdentifierFromFilePath } from '../helpers/get_i18n_identifier_from_file_path';
import { getFunctionName } from '../helpers/get_function_name';
import { getI18nImportFixer } from '../helpers/get_i18n_import_fixer';
import { isTruthy } from '../helpers/utils';
export const StringsShouldBeTranslatedWithI18n: Rule.RuleModule = {
meta: {
type: 'suggestion',
fixable: 'code',
},
create(context) {
const { cwd, filename, getScope, sourceCode, report } = context;
return {
JSXText: (node: TSESTree.JSXText) => {
const value = node.value.trim();
// If the JSXText element is empty we don't need to do anything
if (!value) return;
// Get the whitespaces before the string so we can add them to the autofix suggestion
const regex = /^(\s*)(\S)(.*)/;
const whiteSpaces = node.value.match(regex)?.[1] ?? '';
// Start building the translation ID suggestion
const i18nAppId = getI18nIdentifierFromFilePath(filename, cwd);
const functionDeclaration = getScope().block as TSESTree.FunctionDeclaration;
const functionName = getFunctionName(functionDeclaration);
const intent = getIntentFromNode(node);
const translationIdSuggestion = `${i18nAppId}.${functionName}.${intent}`; // 'xpack.observability.overview.logs.loadMoreLabel'
// Check if i18n has already been imported into the file.
const {
hasI18nImportLine,
i18nPackageImportLine: i18nImportLine,
rangeToAddI18nImportLine,
} = getI18nImportFixer({
sourceCode,
mode: 'i18n.translate',
});
// Show warning to developer and offer autofix suggestion
report({
node: node as any,
message:
'Strings should be translated with i18n. Use the autofix suggestion or add your own.',
fix(fixer) {
return [
fixer.replaceText(
node,
`${whiteSpaces}{i18n.translate('${translationIdSuggestion}', { defaultMessage: "${value}"})}`
),
!hasI18nImportLine
? fixer.insertTextAfterRange(rangeToAddI18nImportLine, `\n${i18nImportLine}`)
: null,
].filter(isTruthy);
},
});
},
} as Rule.RuleListener;
},
};

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": ["jest", "node"],
"lib": ["es2021"]
},
"include": ["**/*.ts"],
"exclude": ["target/**/*"],
"kbn_references": []
}

View file

@ -714,6 +714,8 @@
"@kbn/eslint-plugin-disable/*": ["packages/kbn-eslint-plugin-disable/*"],
"@kbn/eslint-plugin-eslint": ["packages/kbn-eslint-plugin-eslint"],
"@kbn/eslint-plugin-eslint/*": ["packages/kbn-eslint-plugin-eslint/*"],
"@kbn/eslint-plugin-i18n": ["packages/kbn-eslint-plugin-i18n"],
"@kbn/eslint-plugin-i18n/*": ["packages/kbn-eslint-plugin-i18n/*"],
"@kbn/eslint-plugin-imports": ["packages/kbn-eslint-plugin-imports"],
"@kbn/eslint-plugin-imports/*": ["packages/kbn-eslint-plugin-imports/*"],
"@kbn/eslint-plugin-telemetry": ["packages/kbn-eslint-plugin-telemetry"],

View file

@ -4366,6 +4366,10 @@
version "0.0.0"
uid ""
"@kbn/eslint-plugin-i18n@link:packages/kbn-eslint-plugin-i18n":
version "0.0.0"
uid ""
"@kbn/eslint-plugin-imports@link:packages/kbn-eslint-plugin-imports":
version "0.0.0"
uid ""