mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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:
parent
d7028ca9e0
commit
6fcf8c9efe
22 changed files with 779 additions and 0 deletions
|
@ -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
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -8,6 +8,7 @@ module.exports = {
|
|||
'@kbn/eslint-plugin-eslint',
|
||||
'@kbn/eslint-plugin-imports',
|
||||
'@kbn/eslint-plugin-telemetry',
|
||||
'@kbn/eslint-plugin-i18n',
|
||||
'prettier',
|
||||
],
|
||||
|
||||
|
|
17
packages/kbn-eslint-plugin-i18n/README.mdx
Normal file
17
packages/kbn-eslint-plugin-i18n/README.mdx
Normal 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.
|
35
packages/kbn-eslint-plugin-i18n/helpers/get_function_name.ts
Normal file
35
packages/kbn-eslint-plugin-i18n/helpers/get_function_name.ts
Normal 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 '';
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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';
|
||||
}
|
|
@ -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],
|
||||
};
|
||||
}
|
|
@ -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`;
|
||||
}
|
19
packages/kbn-eslint-plugin-i18n/helpers/utils.ts
Normal file
19
packages/kbn-eslint-plugin-i18n/helpers/utils.ts
Normal 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;
|
||||
}
|
20
packages/kbn-eslint-plugin-i18n/index.ts
Normal file
20
packages/kbn-eslint-plugin-i18n/index.ts
Normal 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,
|
||||
};
|
13
packages/kbn-eslint-plugin-i18n/jest.config.js
Normal file
13
packages/kbn-eslint-plugin-i18n/jest.config.js
Normal 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'],
|
||||
};
|
6
packages/kbn-eslint-plugin-i18n/kibana.jsonc
Normal file
6
packages/kbn-eslint-plugin-i18n/kibana.jsonc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/eslint-plugin-i18n",
|
||||
"owner": "@elastic/actionable-observability",
|
||||
"devOnly": true
|
||||
}
|
6
packages/kbn-eslint-plugin-i18n/package.json
Normal file
6
packages/kbn-eslint-plugin-i18n/package.json
Normal 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"
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
};
|
11
packages/kbn-eslint-plugin-i18n/tsconfig.json
Normal file
11
packages/kbn-eslint-plugin-i18n/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node"],
|
||||
"lib": ["es2021"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue