Add new eslint plugin to check for valid i18n keys
This commit is contained in:
parent
465b4cb0fb
commit
569b6e14a6
39 changed files with 447 additions and 78 deletions
183
.eslint/rules/valid-i18n-keys.js
Normal file
183
.eslint/rules/valid-i18n-keys.js
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const messages = require('../../_locales/en/messages.json');
|
||||||
|
const messageKeys = Object.keys(messages).sort((a, b) => {
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashSum = crypto.createHash('sha256');
|
||||||
|
hashSum.update(messageKeys.join('\n'));
|
||||||
|
const messagesCacheKey = hashSum.digest('hex');
|
||||||
|
|
||||||
|
function isI18nCall(node) {
|
||||||
|
return (
|
||||||
|
node.type === 'CallExpression' &&
|
||||||
|
node.callee.type === 'Identifier' &&
|
||||||
|
node.callee.name === 'i18n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIntlElement(node) {
|
||||||
|
return (
|
||||||
|
node.type === 'JSXOpeningElement' &&
|
||||||
|
node.name.type === 'JSXIdentifier' &&
|
||||||
|
node.name.name === 'Intl'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringLiteral(node) {
|
||||||
|
return node.type === 'Literal' && typeof node.value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToMessageKey(node) {
|
||||||
|
if (isStringLiteral(node)) {
|
||||||
|
return node.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type !== 'TemplateLiteral') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.quasis.length === 1) {
|
||||||
|
return node.quasis[0].value.cooked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = node.quasis.map(element => {
|
||||||
|
return element.value.cooked;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new RegExp(`^${parts.join('(.*)')}$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getI18nCallMessageKey(node) {
|
||||||
|
if (node.arguments.length < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let arg1 = node.arguments[0];
|
||||||
|
if (arg1 == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueToMessageKey(arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntlElementMessageKey(node) {
|
||||||
|
let idAttribute = node.attributes.find(attribute => {
|
||||||
|
return (
|
||||||
|
attribute.type === 'JSXAttribute' &&
|
||||||
|
attribute.name.type === 'JSXIdentifier' &&
|
||||||
|
attribute.name.name === 'id'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (idAttribute == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = idAttribute.value;
|
||||||
|
|
||||||
|
if (value.type === 'JSXExpressionContainer') {
|
||||||
|
value = value.expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueToMessageKey(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidMessageKey(key) {
|
||||||
|
if (typeof key === 'string') {
|
||||||
|
if (Object.hasOwn(messages, key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (key instanceof RegExp) {
|
||||||
|
if (messageKeys.some(k => key.test(k))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
messagesCacheKey,
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
hasSuggestions: false,
|
||||||
|
fixable: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
messagesCacheKey: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['messagesCacheKey'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const messagesCacheKeyOption = context.options[0].messagesCacheKey;
|
||||||
|
if (messagesCacheKeyOption !== messagesCacheKey) {
|
||||||
|
throw new Error(
|
||||||
|
`The cache key for the i18n rule does not match the current messages.json file (expected: ${messagesCacheKey}, received: ${messagesCacheKeyOption})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
JSXOpeningElement(node) {
|
||||||
|
if (!isIntlElement(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = getIntlElementMessageKey(node);
|
||||||
|
|
||||||
|
if (key == null) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message:
|
||||||
|
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidMessageKey(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `<Intl> id "${key}" not found in _locales/en/messages.json`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
CallExpression(node) {
|
||||||
|
if (!isI18nCall(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = getI18nCallMessageKey(node);
|
||||||
|
|
||||||
|
if (key == null) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message:
|
||||||
|
"i18n()'s first argument should always be a literal string",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidMessageKey(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `i18n() key "${key}" not found in _locales/en/messages.json`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
116
.eslint/rules/valid-i18n-keys.test.js
Normal file
116
.eslint/rules/valid-i18n-keys.test.js
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
const rule = require('./valid-i18n-keys');
|
||||||
|
const RuleTester = require('eslint').RuleTester;
|
||||||
|
|
||||||
|
const messagesCacheKey = rule.messagesCacheKey;
|
||||||
|
|
||||||
|
// Need to load so mocha doesn't complain about polluting the global namespace
|
||||||
|
require('@typescript-eslint/parser');
|
||||||
|
|
||||||
|
const ruleTester = new RuleTester({
|
||||||
|
parser: require.resolve('@typescript-eslint/parser'),
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2018,
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ruleTester.run('valid-i18n-keys', rule, {
|
||||||
|
valid: [
|
||||||
|
{
|
||||||
|
code: `i18n("AddCaptionModal__title")`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'i18n(`AddCaptionModal__${title}`)',
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `let jsx = <Intl id="AddCaptionModal__title"/>`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `let jsx = <Intl id={"AddCaptionModal__title"}/>`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'let jsx = <Intl id={`AddCaptionModal__title`}/>',
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'let jsx = <Intl id={`AddCaptionModal__${title}`}/>',
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
code: `i18n("THIS_KEY_SHOULD_NEVER_EXIST")`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'i18n() key "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json',
|
||||||
|
type: 'CallExpression',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `i18n(cond ? "AddCaptionModal__title" : "AddCaptionModal__title")`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: "i18n()'s first argument should always be a literal string",
|
||||||
|
type: 'CallExpression',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `i18n(42)`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: "i18n()'s first argument should always be a literal string",
|
||||||
|
type: 'CallExpression',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `let jsx = <Intl id="THIS_KEY_SHOULD_NEVER_EXIST"/>`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'<Intl> id "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json',
|
||||||
|
type: 'JSXOpeningElement',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `let jsx = <Intl id={cond ? "AddCaptionModal__title" : "AddCaptionModal__title"}/>`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||||
|
type: 'JSXOpeningElement',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: `let jsx = <Intl id={42}/>`,
|
||||||
|
options: [{ messagesCacheKey }],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||||
|
type: 'JSXOpeningElement',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright 2018 Signal Messenger, LLC
|
// Copyright 2018 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
const { messagesCacheKey } = require('./.eslint/rules/valid-i18n-keys');
|
||||||
|
|
||||||
// For reference: https://github.com/airbnb/javascript
|
// For reference: https://github.com/airbnb/javascript
|
||||||
|
|
||||||
|
@ -215,6 +216,8 @@ const typescriptRules = {
|
||||||
|
|
||||||
// TODO: DESKTOP-4655
|
// TODO: DESKTOP-4655
|
||||||
'import/no-cycle': 'off',
|
'import/no-cycle': 'off',
|
||||||
|
|
||||||
|
'local-rules/valid-i18n-keys': ['error', { messagesCacheKey }],
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
@ -228,7 +231,7 @@ module.exports = {
|
||||||
|
|
||||||
extends: ['airbnb-base', 'prettier'],
|
extends: ['airbnb-base', 'prettier'],
|
||||||
|
|
||||||
plugins: ['mocha', 'more'],
|
plugins: ['mocha', 'more', 'local-rules'],
|
||||||
|
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
|
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
@ -81,6 +81,7 @@ jobs:
|
||||||
- run: yarn test-release
|
- run: yarn test-release
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
- run: yarn test-eslint
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
needs: lint
|
needs: lint
|
||||||
|
|
|
@ -2276,6 +2276,10 @@
|
||||||
"message": "This message was deleted.",
|
"message": "This message was deleted.",
|
||||||
"description": "Shown in a message's bubble when the message has been deleted for everyone."
|
"description": "Shown in a message's bubble when the message has been deleted for everyone."
|
||||||
},
|
},
|
||||||
|
"giftBadge--missing": {
|
||||||
|
"message": "Unable to fetch gift badge details",
|
||||||
|
"description": "Aria label for gift badge when we can't fetch the details"
|
||||||
|
},
|
||||||
"message--giftBadge--unopened--incoming": {
|
"message--giftBadge--unopened--incoming": {
|
||||||
"message": "View this message on mobile to open it",
|
"message": "View this message on mobile to open it",
|
||||||
"description": "Shown in a message's bubble when you've received a gift badge from a contact"
|
"description": "Shown in a message's bubble when you've received a gift badge from a contact"
|
||||||
|
|
7
eslint-local-rules.js
Normal file
7
eslint-local-rules.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable global-require */
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
'valid-i18n-keys': require('./.eslint/rules/valid-i18n-keys'),
|
||||||
|
};
|
|
@ -34,11 +34,12 @@
|
||||||
"prepare-staging-build": "node scripts/prepare_staging_build.js",
|
"prepare-staging-build": "node scripts/prepare_staging_build.js",
|
||||||
"prepare-windows-cert": "node scripts/prepare_windows_cert.js",
|
"prepare-windows-cert": "node scripts/prepare_windows_cert.js",
|
||||||
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
|
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
|
||||||
"test": "yarn test-node && yarn test-electron && yarn test-lint-intl",
|
"test": "yarn test-node && yarn test-electron && yarn test-lint-intl && yarn test-eslint",
|
||||||
"test-electron": "node ts/scripts/test-electron.js",
|
"test-electron": "node ts/scripts/test-electron.js",
|
||||||
"test-release": "node ts/scripts/test-release.js",
|
"test-release": "node ts/scripts/test-release.js",
|
||||||
"test-node": "electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive test/modules ts/test-node ts/test-both",
|
"test-node": "electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive test/modules ts/test-node ts/test-both",
|
||||||
"test-mock": "mocha ts/test-mock/**/*_test.js",
|
"test-mock": "mocha ts/test-mock/**/*_test.js",
|
||||||
|
"test-eslint": "mocha .eslint/rules/**/*.test.js",
|
||||||
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/modules ts/test-node ts/test-both",
|
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/modules ts/test-node ts/test-both",
|
||||||
"test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test",
|
"test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test",
|
||||||
"eslint": "eslint --cache . --max-warnings 0",
|
"eslint": "eslint --cache . --max-warnings 0",
|
||||||
|
@ -276,6 +277,7 @@
|
||||||
"eslint-config-airbnb-typescript-prettier": "5.0.0",
|
"eslint-config-airbnb-typescript-prettier": "5.0.0",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
"eslint-plugin-import": "2.26.0",
|
"eslint-plugin-import": "2.26.0",
|
||||||
|
"eslint-plugin-local-rules": "1.3.2",
|
||||||
"eslint-plugin-mocha": "10.1.0",
|
"eslint-plugin-mocha": "10.1.0",
|
||||||
"eslint-plugin-more": "1.0.5",
|
"eslint-plugin-more": "1.0.5",
|
||||||
"eslint-plugin-react": "7.31.10",
|
"eslint-plugin-react": "7.31.10",
|
||||||
|
|
|
@ -95,6 +95,7 @@ export function AppStage(props: Props): JSX.Element {
|
||||||
className={styles.toaster}
|
className={styles.toaster}
|
||||||
loaf={toasts.map((slice, id) => ({
|
loaf={toasts.map((slice, id) => ({
|
||||||
id,
|
id,
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
text: i18n(slice.key, slice.subs),
|
text: i18n(slice.key, slice.subs),
|
||||||
}))}
|
}))}
|
||||||
onDismiss={dismissToast}
|
onDismiss={dismissToast}
|
||||||
|
|
|
@ -91,19 +91,19 @@ export function CallingHeader({
|
||||||
{isGroupCall && participantCount > 2 && toggleSpeakerView && (
|
{isGroupCall && participantCount > 2 && toggleSpeakerView && (
|
||||||
<div className="module-calling-tools__button">
|
<div className="module-calling-tools__button">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={i18n(
|
content={
|
||||||
isInSpeakerView
|
isInSpeakerView
|
||||||
? 'calling__switch-view--to-grid'
|
? i18n('calling__switch-view--to-grid')
|
||||||
: 'calling__switch-view--to-speaker'
|
: i18n('calling__switch-view--to-speaker')
|
||||||
)}
|
}
|
||||||
theme={Theme.Dark}
|
theme={Theme.Dark}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-label={i18n(
|
aria-label={
|
||||||
isInSpeakerView
|
isInSpeakerView
|
||||||
? 'calling__switch-view--to-grid'
|
? i18n('calling__switch-view--to-grid')
|
||||||
: 'calling__switch-view--to-speaker'
|
: i18n('calling__switch-view--to-speaker')
|
||||||
)}
|
}
|
||||||
className={
|
className={
|
||||||
isInSpeakerView
|
isInSpeakerView
|
||||||
? 'CallingButton__grid-view'
|
? 'CallingButton__grid-view'
|
||||||
|
|
|
@ -374,8 +374,10 @@ export function ConversationList({
|
||||||
result = (
|
result = (
|
||||||
<div
|
<div
|
||||||
className="module-conversation-list__item--header"
|
className="module-conversation-list__item--header"
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
aria-label={i18n(row.i18nKey)}
|
aria-label={i18n(row.i18nKey)}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
{i18n(row.i18nKey)}
|
{i18n(row.i18nKey)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -65,11 +65,9 @@ export function DisappearingTimerSelect(props: Props): JSX.Element {
|
||||||
...expirationTimerOptions,
|
...expirationTimerOptions,
|
||||||
{
|
{
|
||||||
value: DurationInSeconds.fromSeconds(-1),
|
value: DurationInSeconds.fromSeconds(-1),
|
||||||
text: i18n(
|
text: isCustomTimeSelected
|
||||||
isCustomTimeSelected
|
? i18n('selectedCustomDisappearingTimeOption')
|
||||||
? 'selectedCustomDisappearingTimeOption'
|
: i18n('customDisappearingTimeOption'),
|
||||||
: 'customDisappearingTimeOption'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -154,6 +154,7 @@ function renderMembers({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
|
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
|
||||||
<GroupDialog.Contacts
|
<GroupDialog.Contacts
|
||||||
contacts={sortByTitle(members)}
|
contacts={sortByTitle(members)}
|
||||||
|
|
|
@ -188,9 +188,9 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
|
||||||
case CallMode.Direct:
|
case CallMode.Direct:
|
||||||
({ isVideoCall } = props);
|
({ isVideoCall } = props);
|
||||||
headerNode = <ContactName title={title} />;
|
headerNode = <ContactName title={title} />;
|
||||||
messageNode = i18n(
|
messageNode = isVideoCall
|
||||||
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
|
? i18n('incomingVideoCall')
|
||||||
);
|
: i18n('incomingAudioCall');
|
||||||
break;
|
break;
|
||||||
case CallMode.Group: {
|
case CallMode.Group: {
|
||||||
const { otherMembersRung, ringer } = props;
|
const { otherMembersRung, ringer } = props;
|
||||||
|
|
|
@ -23,7 +23,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
renderText: overrideProps.renderText,
|
renderText: overrideProps.renderText,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line max-len
|
||||||
|
// eslint-disable-next-line react/function-component-definition, local-rules/valid-i18n-keys
|
||||||
const Template: Story<Props> = args => <Intl {...args} />;
|
const Template: Story<Props> = args => <Intl {...args} />;
|
||||||
|
|
||||||
export const NoReplacements = Template.bind({});
|
export const NoReplacements = Template.bind({});
|
||||||
|
|
|
@ -88,6 +88,7 @@ export class Intl extends React.Component<Props> {
|
||||||
return intl.formatMessage({ id }, components);
|
return intl.formatMessage({ id }, components);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
const text = i18n(id);
|
const text = i18n(id);
|
||||||
const results: Array<
|
const results: Array<
|
||||||
string | JSX.Element | Array<string | JSX.Element> | null
|
string | JSX.Element | Array<string | JSX.Element> | null
|
||||||
|
|
|
@ -87,7 +87,7 @@ export function LeftPaneSearchInput({
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const label = i18n(searchConversation ? 'searchIn' : 'search');
|
const label = searchConversation ? i18n('searchIn') : i18n('search');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
|
|
@ -418,6 +418,7 @@ export function ProfileEditor({
|
||||||
<Emoji shortName={defaultBio.shortName} size={24} />
|
<Emoji shortName={defaultBio.shortName} size={24} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
label={i18n(defaultBio.i18nLabel)}
|
label={i18n(defaultBio.i18nLabel)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const emojiData = getEmojiData(defaultBio.shortName, skinTone);
|
const emojiData = getEmojiData(defaultBio.shortName, skinTone);
|
||||||
|
@ -425,6 +426,7 @@ export function ProfileEditor({
|
||||||
setStagedProfile(profileData => ({
|
setStagedProfile(profileData => ({
|
||||||
...profileData,
|
...profileData,
|
||||||
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
aboutEmoji: unifiedToEmoji(emojiData.unified),
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
aboutText: i18n(defaultBio.i18nLabel),
|
aboutText: i18n(defaultBio.i18nLabel),
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -64,7 +64,6 @@ export function SafetyNumberViewer({
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isVerified } = contact;
|
const { isVerified } = contact;
|
||||||
const verifiedStatusKey = isVerified ? 'isVerified' : 'isNotVerified';
|
|
||||||
const verifyButtonText = isVerified ? i18n('unverify') : i18n('verify');
|
const verifyButtonText = isVerified ? i18n('unverify') : i18n('verify');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -79,7 +78,12 @@ export function SafetyNumberViewer({
|
||||||
) : (
|
) : (
|
||||||
<span className="module-SafetyNumberViewer__icon--shield" />
|
<span className="module-SafetyNumberViewer__icon--shield" />
|
||||||
)}
|
)}
|
||||||
<Intl i18n={i18n} id={verifiedStatusKey} components={[boldName]} />
|
{}
|
||||||
|
{isVerified ? (
|
||||||
|
<Intl i18n={i18n} id="isVerified" components={[boldName]} />
|
||||||
|
) : (
|
||||||
|
<Intl i18n={i18n} id="isNotVerified" components={[boldName]} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="module-SafetyNumberViewer__button">
|
<div className="module-SafetyNumberViewer__button">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -319,6 +319,7 @@ function renderShortcut(
|
||||||
return (
|
return (
|
||||||
<div key={index} className="module-shortcut-guide__shortcut">
|
<div key={index} className="module-shortcut-guide__shortcut">
|
||||||
<div className="module-shortcut-guide__shortcut__description">
|
<div className="module-shortcut-guide__shortcut__description">
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
{i18n(shortcut.description)}
|
{i18n(shortcut.description)}
|
||||||
</div>
|
</div>
|
||||||
<div className="module-shortcut-guide__shortcut__key-container">
|
<div className="module-shortcut-guide__shortcut__key-container">
|
||||||
|
|
|
@ -815,6 +815,7 @@ export function EditMyStoryPrivacy({
|
||||||
}: EditMyStoryPrivacyPropsType): JSX.Element {
|
}: EditMyStoryPrivacyPropsType): JSX.Element {
|
||||||
const disclaimerElement = (
|
const disclaimerElement = (
|
||||||
<div className="StoriesSettingsModal__disclaimer">
|
<div className="StoriesSettingsModal__disclaimer">
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
<Intl
|
<Intl
|
||||||
components={{
|
components={{
|
||||||
learnMore: (
|
learnMore: (
|
||||||
|
|
|
@ -114,6 +114,7 @@ export function StoryDetailsModal({
|
||||||
return (
|
return (
|
||||||
<div key={i18nKey} className="StoryDetailsModal__contact-group">
|
<div key={i18nKey} className="StoryDetailsModal__contact-group">
|
||||||
<div className="StoryDetailsModal__contact-group__header">
|
<div className="StoryDetailsModal__contact-group__header">
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
{i18n(i18nKey)}
|
{i18n(i18nKey)}
|
||||||
</div>
|
</div>
|
||||||
{sortedContacts.map(status => {
|
{sortedContacts.map(status => {
|
||||||
|
|
|
@ -47,6 +47,7 @@ export function WhatsNewModal({
|
||||||
const { key, components } = releaseNotes.features[0];
|
const { key, components } = releaseNotes.features[0];
|
||||||
contentNode = (
|
contentNode = (
|
||||||
<p>
|
<p>
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
<Intl
|
<Intl
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={key}
|
id={key}
|
||||||
|
@ -60,6 +61,7 @@ export function WhatsNewModal({
|
||||||
<ul>
|
<ul>
|
||||||
{releaseNotes.features.map(({ key, components }) => (
|
{releaseNotes.features.map(({ key, components }) => (
|
||||||
<li key={key}>
|
<li key={key}>
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
<Intl
|
<Intl
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={key}
|
id={key}
|
||||||
|
|
|
@ -24,10 +24,6 @@ export type PropsType = {
|
||||||
export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
|
export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
|
||||||
const { i18n, inGroup, sender, onClose } = props;
|
const { i18n, inGroup, sender, onClose } = props;
|
||||||
|
|
||||||
const key = inGroup
|
|
||||||
? 'DeliveryIssue--summary--group'
|
|
||||||
: 'DeliveryIssue--summary';
|
|
||||||
|
|
||||||
// Focus first button after initial render, restore focus on teardown
|
// Focus first button after initial render, restore focus on teardown
|
||||||
const [focusRef] = useRestoreFocus();
|
const [focusRef] = useRestoreFocus();
|
||||||
|
|
||||||
|
@ -56,6 +52,10 @@ export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const intlComponents = {
|
||||||
|
sender: <Emojify text={sender.title} />,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
modalName="DeliveryIssueDialog"
|
modalName="DeliveryIssueDialog"
|
||||||
|
@ -77,13 +77,19 @@ export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
|
||||||
{i18n('DeliveryIssue--title')}
|
{i18n('DeliveryIssue--title')}
|
||||||
</div>
|
</div>
|
||||||
<div className="module-delivery-issue-dialog__description">
|
<div className="module-delivery-issue-dialog__description">
|
||||||
<Intl
|
{inGroup ? (
|
||||||
id={key}
|
<Intl
|
||||||
components={{
|
id="DeliveryIssue--summary--group"
|
||||||
sender: <Emojify text={sender.title} />,
|
components={intlComponents}
|
||||||
}}
|
i18n={i18n}
|
||||||
i18n={i18n}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<Intl
|
||||||
|
id="DeliveryIssue--summary"
|
||||||
|
components={intlComponents}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -75,20 +75,24 @@ export class GroupNotification extends React.Component<Props> {
|
||||||
throw new Error('Group update is missing contacts');
|
throw new Error('Group update is missing contacts');
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-case-declarations
|
|
||||||
const otherPeopleNotifMsg =
|
|
||||||
otherPeople.length === 1
|
|
||||||
? 'joinedTheGroup'
|
|
||||||
: 'multipleJoinedTheGroup';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{otherPeople.length > 0 && (
|
{otherPeople.length > 0 && (
|
||||||
<Intl
|
<>
|
||||||
i18n={i18n}
|
{otherPeople.length === 1 ? (
|
||||||
id={otherPeopleNotifMsg}
|
<Intl
|
||||||
components={[otherPeopleWithCommas]}
|
i18n={i18n}
|
||||||
/>
|
id="joinedTheGroup"
|
||||||
|
components={[otherPeopleWithCommas]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Intl
|
||||||
|
i18n={i18n}
|
||||||
|
id="multipleJoinedTheGroup"
|
||||||
|
components={[otherPeopleWithCommas]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{contactsIncludesMe && (
|
{contactsIncludesMe && (
|
||||||
<div className="module-group-notification__change">
|
<div className="module-group-notification__change">
|
||||||
|
@ -106,12 +110,18 @@ export class GroupNotification extends React.Component<Props> {
|
||||||
throw new Error('Group update is missing contacts');
|
throw new Error('Group update is missing contacts');
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-case-declarations
|
return contacts.length > 1 ? (
|
||||||
const leftKey =
|
<Intl
|
||||||
contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
|
id="multipleLeftTheGroup"
|
||||||
|
i18n={i18n}
|
||||||
return (
|
components={[otherPeopleWithCommas]}
|
||||||
<Intl i18n={i18n} id={leftKey} components={[otherPeopleWithCommas]} />
|
/>
|
||||||
|
) : (
|
||||||
|
<Intl
|
||||||
|
id="leftTheGroup"
|
||||||
|
i18n={i18n}
|
||||||
|
components={[otherPeopleWithCommas]}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
case 'general':
|
case 'general':
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -57,6 +57,7 @@ function renderStringToIntl(
|
||||||
i18n: LocalizerType,
|
i18n: LocalizerType,
|
||||||
components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType>
|
components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType>
|
||||||
): FullJSXType {
|
): FullJSXType {
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
return <Intl id={id} i18n={i18n} components={components} />;
|
return <Intl id={id} i18n={i18n} components={components} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -189,6 +189,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
onMouseUp={() => setIsDown(false)}
|
onMouseUp={() => setIsDown(false)}
|
||||||
onMouseLeave={() => setIsDown(false)}
|
onMouseLeave={() => setIsDown(false)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
aria-label={i18n(label)}
|
aria-label={i18n(label)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -225,6 +225,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
`module-message-detail__contact-group__header--${sendStatus}`
|
`module-message-detail__contact-group__header--${sendStatus}`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
{i18n(i18nKey)}
|
{i18n(i18nKey)}
|
||||||
</div>
|
</div>
|
||||||
{sortedContacts.map(contact => this.renderContact(contact))}
|
{sortedContacts.map(contact => this.renderContact(contact))}
|
||||||
|
|
|
@ -26,11 +26,13 @@ export function RemoveGroupMemberConfirmationDialog({
|
||||||
onClose,
|
onClose,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const descriptionKey = isAccessControlEnabled(
|
const accessControlEnabled = isAccessControlEnabled(
|
||||||
group.accessControlAddFromInviteLink
|
group.accessControlAddFromInviteLink
|
||||||
)
|
);
|
||||||
? 'RemoveGroupMemberConfirmation__description__with-link'
|
|
||||||
: 'RemoveGroupMemberConfirmation__description';
|
const intlComponents = {
|
||||||
|
name: <ContactName title={conversation.title} />,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
|
@ -45,13 +47,19 @@ export function RemoveGroupMemberConfirmationDialog({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={
|
title={
|
||||||
<Intl
|
accessControlEnabled ? (
|
||||||
i18n={i18n}
|
<Intl
|
||||||
id={descriptionKey}
|
i18n={i18n}
|
||||||
components={{
|
id="RemoveGroupMemberConfirmation__description__with-link"
|
||||||
name: <ContactName title={conversation.title} />,
|
components={intlComponents}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<Intl
|
||||||
|
i18n={i18n}
|
||||||
|
id="RemoveGroupMemberConfirmation__description"
|
||||||
|
components={intlComponents}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -43,6 +43,7 @@ export function SafetyNumberNotification({
|
||||||
<SystemMessage
|
<SystemMessage
|
||||||
icon="safety-number"
|
icon="safety-number"
|
||||||
contents={
|
contents={
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
<Intl
|
<Intl
|
||||||
id={changeKey}
|
id={changeKey}
|
||||||
components={[
|
components={[
|
||||||
|
|
|
@ -55,6 +55,7 @@ export function TimerNotification(props: Props): JSX.Element {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'fromOther':
|
case 'fromOther':
|
||||||
message = (
|
message = (
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
<Intl
|
<Intl
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={changeKey}
|
id={changeKey}
|
||||||
|
|
|
@ -50,6 +50,7 @@ export function UnsupportedMessage({
|
||||||
<SystemMessage
|
<SystemMessage
|
||||||
icon={icon}
|
icon={icon}
|
||||||
contents={
|
contents={
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
<Intl
|
<Intl
|
||||||
id={stringId}
|
id={stringId}
|
||||||
components={[
|
components={[
|
||||||
|
|
|
@ -47,6 +47,7 @@ export class VerificationNotification extends React.Component<Props> {
|
||||||
const id = this.getStringId();
|
const id = this.getStringId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
<Intl
|
<Intl
|
||||||
id={id}
|
id={id}
|
||||||
components={[
|
components={[
|
||||||
|
|
|
@ -415,11 +415,13 @@ export function ConversationDetails({
|
||||||
icon={IconType.timer}
|
icon={IconType.timer}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
info={i18n(
|
info={
|
||||||
isGroup
|
isGroup
|
||||||
? 'ConversationDetails--disappearing-messages-info--group'
|
? i18n('ConversationDetails--disappearing-messages-info--group')
|
||||||
: 'ConversationDetails--disappearing-messages-info--direct'
|
: i18n(
|
||||||
)}
|
'ConversationDetails--disappearing-messages-info--direct'
|
||||||
|
)
|
||||||
|
}
|
||||||
label={i18n('ConversationDetails--disappearing-messages-label')}
|
label={i18n('ConversationDetails--disappearing-messages-label')}
|
||||||
right={
|
right={
|
||||||
<DisappearingTimerSelect
|
<DisappearingTimerSelect
|
||||||
|
@ -621,6 +623,7 @@ function ConversationDetailsCallButton({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
variant={ButtonVariant.Details}
|
variant={ButtonVariant.Details}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
|
||||||
{i18n(type)}
|
{i18n(type)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -285,14 +285,12 @@ function getConfirmationMessage({
|
||||||
|
|
||||||
// Requesting a membership since they weren't added by anyone
|
// Requesting a membership since they weren't added by anyone
|
||||||
if (membershipType === StageType.DENY_REQUEST) {
|
if (membershipType === StageType.DENY_REQUEST) {
|
||||||
const key = isAccessControlEnabled(
|
const params = {
|
||||||
conversation.accessControlAddFromInviteLink
|
|
||||||
)
|
|
||||||
? 'PendingRequests--deny-for--with-link'
|
|
||||||
: 'PendingRequests--deny-for';
|
|
||||||
return i18n(key, {
|
|
||||||
name: firstMembership.member.title,
|
name: firstMembership.member.title,
|
||||||
});
|
};
|
||||||
|
return isAccessControlEnabled(conversation.accessControlAddFromInviteLink)
|
||||||
|
? i18n('PendingRequests--deny-for--with-link', params)
|
||||||
|
: i18n('PendingRequests--deny-for', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (membershipType === StageType.APPROVE_REQUEST) {
|
if (membershipType === StageType.APPROVE_REQUEST) {
|
||||||
|
|
|
@ -74,7 +74,8 @@ function MediaSection({
|
||||||
const header =
|
const header =
|
||||||
section.type === 'yearMonth'
|
section.type === 'yearMonth'
|
||||||
? date.format(MONTH_FORMAT)
|
? date.format(MONTH_FORMAT)
|
||||||
: i18n(section.type);
|
: // eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
|
i18n(section.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AttachmentSection
|
<AttachmentSection
|
||||||
|
|
|
@ -15,6 +15,7 @@ describe('setupI18n', () => {
|
||||||
|
|
||||||
describe('i18n', () => {
|
describe('i18n', () => {
|
||||||
it('returns empty string for unknown string', () => {
|
it('returns empty string for unknown string', () => {
|
||||||
|
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||||
assert.strictEqual(i18n('random'), '');
|
assert.strictEqual(i18n('random'), '');
|
||||||
});
|
});
|
||||||
it('returns message for given string', () => {
|
it('returns message for given string', () => {
|
||||||
|
|
|
@ -35,7 +35,7 @@ export function format(
|
||||||
): string {
|
): string {
|
||||||
let seconds = Math.abs(dirtySeconds || 0);
|
let seconds = Math.abs(dirtySeconds || 0);
|
||||||
if (!seconds) {
|
if (!seconds) {
|
||||||
return i18n(capitalizeOff ? 'off' : 'disappearingMessages__off');
|
return capitalizeOff ? i18n('off') : i18n('disappearingMessages__off');
|
||||||
}
|
}
|
||||||
seconds = Math.max(Math.floor(seconds), 1);
|
seconds = Math.max(Math.floor(seconds), 1);
|
||||||
|
|
||||||
|
|
|
@ -141,11 +141,10 @@ export function formatDate(
|
||||||
|
|
||||||
const m = moment(rawTimestamp);
|
const m = moment(rawTimestamp);
|
||||||
|
|
||||||
const formatI18nKey =
|
const rawFormatString =
|
||||||
Math.abs(m.diff(Date.now())) < 6 * MONTH
|
Math.abs(m.diff(Date.now())) < 6 * MONTH
|
||||||
? 'TimelineDateHeader--date-in-last-6-months'
|
? i18n('TimelineDateHeader--date-in-last-6-months')
|
||||||
: 'TimelineDateHeader--date-older-than-6-months';
|
: i18n('TimelineDateHeader--date-older-than-6-months');
|
||||||
const rawFormatString = i18n(formatI18nKey);
|
|
||||||
const formatString = sanitizeFormatString(rawFormatString, 'LL');
|
const formatString = sanitizeFormatString(rawFormatString, 'LL');
|
||||||
|
|
||||||
return m.format(formatString);
|
return m.format(formatString);
|
||||||
|
|
|
@ -8262,6 +8262,11 @@ eslint-plugin-jsx-a11y@^6.5.1:
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
semver "^6.3.0"
|
semver "^6.3.0"
|
||||||
|
|
||||||
|
eslint-plugin-local-rules@1.3.2:
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/eslint-plugin-local-rules/-/eslint-plugin-local-rules-1.3.2.tgz#b9c9522915faeb9e430309fb909fc1dbcd7aedb3"
|
||||||
|
integrity sha512-X4ziX+cjlCYnZa+GB1ly3mmj44v2PeIld3tQVAxelY6AMrhHSjz6zsgsT6nt0+X5b7eZnvL/O7Q3pSSK2kF/+Q==
|
||||||
|
|
||||||
eslint-plugin-mocha@10.1.0:
|
eslint-plugin-mocha@10.1.0:
|
||||||
version "10.1.0"
|
version "10.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.1.0.tgz#69325414f875be87fb2cb00b2ef33168d4eb7c8d"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.1.0.tgz#69325414f875be87fb2cb00b2ef33168d4eb7c8d"
|
||||||
|
|
Loading…
Reference in a new issue