Refactor i18n/intl utils, support icu only, remove renderText

This commit is contained in:
Jamie Kyle 2023-06-14 16:26:05 -07:00 committed by GitHub
parent e154d98688
commit b76c7269f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 361 additions and 6478 deletions

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import { deepEqual } from 'assert';
import type { Rule } from './utils/rule';
import icuPrefix from './rules/icuPrefix';
import wrapEmoji from './rules/wrapEmoji';
import onePlural from './rules/onePlural';
import noLegacyVariables from './rules/noLegacyVariables';
import noNestedChoice from './rules/noNestedChoice';
@ -24,6 +25,7 @@ import pluralPound from './rules/pluralPound';
const RULES = [
icuPrefix,
wrapEmoji,
noLegacyVariables,
noNestedChoice,
noOffset,
@ -74,6 +76,26 @@ const tests: Record<string, Test> = {
messageformat: '$a$',
expectErrors: ['noLegacyVariables'],
},
'icu:wrapEmoji:1': {
messageformat: '👩',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:2': {
messageformat: '<emoji>👩 extra</emoji>',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:3': {
messageformat: '<emoji>👩👩</emoji>',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:4': {
messageformat: '<emoji>{emoji}</emoji>',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:5': {
messageformat: '<emoji>👩</emoji>',
expectErrors: [],
},
};
type Report = {

View file

@ -0,0 +1,73 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import getEmojiRegex from 'emoji-regex';
import type {
MessageFormatElement,
TagElement,
} from '@formatjs/icu-messageformat-parser';
import {
isTagElement,
isLiteralElement,
} from '@formatjs/icu-messageformat-parser';
import { rule } from '../utils/rule';
function isEmojiTag(
element: MessageFormatElement | null
): element is TagElement {
return element != null && isTagElement(element) && element.value === 'emoji';
}
export default rule('wrapEmoji', context => {
const emojiRegex = getEmojiRegex();
return {
enterTag(element) {
if (!isEmojiTag(element)) {
return;
}
if (element.children.length !== 1) {
// multiple children
context.report(
'Only use a single literal emoji in <emoji> tags with no additional text.',
element.location
);
return;
}
const child = element.children[0];
if (!isLiteralElement(child)) {
// non-literal
context.report(
'Only use a single literal emoji in <emoji> tags with no additional text.',
child.location
);
}
},
enterLiteral(element, parent) {
const match = element.value.match(emojiRegex);
if (match == null) {
// no emoji
return;
}
if (!isEmojiTag(parent)) {
// unwrapped
context.report(
'Use <emoji> to wrap emoji in translation strings.',
element.location
);
return;
}
const emoji = match[0];
if (emoji !== element.value) {
// extra text other than emoji
context.report(
'Only use a single literal emoji in <emoji> tags with no additional text.',
element.location
);
}
},
};
});

View file

@ -277,12 +277,14 @@
"eslint-plugin-mocha": "10.1.0",
"eslint-plugin-more": "1.0.5",
"eslint-plugin-react": "7.31.10",
"execa": "5.1.1",
"html-webpack-plugin": "5.3.1",
"json-to-ast": "2.1.0",
"mocha": "9.1.3",
"node-gyp": "9.0.0",
"npm-run-all": "4.1.5",
"nyc": "11.4.1",
"p-limit": "3.1.0",
"patch-package": "6.4.7",
"playwright": "1.33.0",
"prettier": "2.8.0",

View file

@ -20,7 +20,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
id: overrideProps.id || '',
components: overrideProps.components,
renderText: overrideProps.renderText,
});
// eslint-disable-next-line max-len
@ -29,18 +28,18 @@ const Template: Story<Props> = args => <Intl {...args} />;
export const NoReplacements = Template.bind({});
NoReplacements.args = createProps({
id: 'deleteAndRestart',
id: 'icu:deleteAndRestart',
});
export const SingleStringReplacement = Template.bind({});
SingleStringReplacement.args = createProps({
id: 'leftTheGroup',
id: 'icu:leftTheGroup',
components: { name: 'Theodora' },
});
export const SingleTagReplacement = Template.bind({});
SingleTagReplacement.args = createProps({
id: 'leftTheGroup',
id: 'icu:leftTheGroup',
components: {
name: (
<button type="button" key="a-button">
@ -52,7 +51,7 @@ SingleTagReplacement.args = createProps({
export const MultipleStringReplacement = Template.bind({});
MultipleStringReplacement.args = createProps({
id: 'changedRightAfterVerify',
id: 'icu:changedRightAfterVerify',
components: {
name1: 'Fred',
name2: 'The Fredster',
@ -61,19 +60,22 @@ MultipleStringReplacement.args = createProps({
export const MultipleTagReplacement = Template.bind({});
MultipleTagReplacement.args = createProps({
id: 'changedRightAfterVerify',
id: 'icu:changedRightAfterVerify',
components: {
name1: <b>Fred</b>,
name2: <b>The Fredster</b>,
},
});
export const CustomRender = Template.bind({});
CustomRender.args = createProps({
id: 'deleteAndRestart',
renderText: ({ text: theText, key }) => (
<div style={{ backgroundColor: 'purple', color: 'orange' }} key={key}>
{theText}
</div>
),
});
export function Emoji(): JSX.Element {
const customI18n = setupI18n('en', {
'icu:emoji': {
messageformat: '<emoji>👋</emoji> Hello, world!',
},
});
return (
// eslint-disable-next-line local-rules/valid-i18n-keys
<Intl i18n={customI18n} id="icu:emoji" />
);
}

View file

@ -5,7 +5,7 @@ import React from 'react';
import type { ReactNode } from 'react';
import type { FormatXMLElementFn } from 'intl-messageformat';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
import type { ReplacementValuesType } from '../types/I18N';
import * as log from '../logging/log';
import { strictAssert } from '../util/assert';
@ -23,118 +23,29 @@ export type Props = {
id: string;
i18n: LocalizerType;
components?: IntlComponentsType;
renderText?: RenderTextCallbackType;
};
const defaultRenderText: RenderTextCallbackType = ({ text, key }) => (
<React.Fragment key={key}>{text}</React.Fragment>
);
export class Intl extends React.Component<Props> {
public getComponent(
index: number,
placeholderName: string,
key: number
): JSX.Element | null {
const { id, components } = this.props;
if (!components) {
log.error(
`Error: Intl component prop not provided; Metadata: id '${id}', index ${index}, placeholder '${placeholderName}'`
);
return null;
}
if (Array.isArray(components)) {
if (!components || !components.length || components.length <= index) {
log.error(
`Error: Intl missing provided component for id '${id}', index ${index}`
);
return null;
}
return <React.Fragment key={key}>{components[index]}</React.Fragment>;
}
const value = components[placeholderName];
if (!value) {
log.error(
`Error: Intl missing provided component for id '${id}', placeholder '${placeholderName}'`
);
return null;
}
return <React.Fragment key={key}>{value}</React.Fragment>;
export function Intl({
components,
id,
// Indirection for linter/migration tooling
i18n: localizer,
}: Props): JSX.Element | null {
if (!id) {
log.error('Error: Intl id prop not provided');
return null;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public override render() {
const {
components,
id,
// Indirection for linter/migration tooling
i18n: localizer,
renderText = defaultRenderText,
} = this.props;
strictAssert(
!localizer.isLegacyFormat(id),
`Legacy message format is no longer supported ${id}`
);
if (!id) {
log.error('Error: Intl id prop not provided');
return null;
}
strictAssert(
!Array.isArray(components),
`components cannot be an array for ICU message ${id}`
);
if (!localizer.isLegacyFormat(id)) {
strictAssert(
!Array.isArray(components),
`components cannot be an array for ICU message ${id}`
);
const intl = localizer.getIntl();
return intl.formatMessage({ id }, components);
}
const text = localizer(id);
const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null
> = [];
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
if (Array.isArray(components) && components.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
let componentIndex = 0;
let key = 0;
let lastTextIndex = 0;
let match = FIND_REPLACEMENTS.exec(text);
if (!match) {
return renderText({ text, key: 0 });
}
while (match) {
if (lastTextIndex < match.index) {
const textWithNoReplacements = text.slice(lastTextIndex, match.index);
results.push(renderText({ text: textWithNoReplacements, key }));
key += 1;
}
const placeholderName = match[1];
results.push(this.getComponent(componentIndex, placeholderName, key));
componentIndex += 1;
key += 1;
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(text);
}
if (lastTextIndex < text.length) {
results.push(renderText({ text: text.slice(lastTextIndex), key }));
key += 1;
}
return results;
}
const intl = localizer.getIntl();
return <>{intl.formatMessage({ id }, components, {})}</>;
}

View file

@ -7,8 +7,7 @@ import moment from 'moment';
import { Modal } from './Modal';
import { Intl } from './Intl';
import { Emojify } from './conversation/Emojify';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
export type PropsType = {
hideWhatsNewModal: () => unknown;
@ -21,10 +20,6 @@ type ReleaseNotesType = {
features: Array<JSX.Element>;
};
const renderText: RenderTextCallbackType = ({ key, text }) => (
<Emojify key={key} text={text} />
);
export function WhatsNewModal({
i18n,
hideWhatsNewModal,
@ -35,11 +30,10 @@ export function WhatsNewModal({
date: new Date(window.getBuildCreation?.() || Date.now()),
version: window.getVersion?.(),
features: [
<Intl i18n={i18n} id="icu:WhatsNew__v6.21--0" renderText={renderText} />,
<Intl i18n={i18n} id="icu:WhatsNew__v6.21--0" />,
<Intl
i18n={i18n}
id="icu:WhatsNew__v6.21--1"
renderText={renderText}
components={{
complexspaces: (
<a href="https://github.com/complexspaces">@complexspaces</a>

View file

@ -1,66 +1,104 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import fs from 'fs';
import chalk from 'chalk';
import execa from 'execa';
import fs from 'fs/promises';
import pLimit from 'p-limit';
import path from 'path';
import { spawnSync } from 'child_process';
import type { StdioOptions } from 'child_process';
import { MONTH } from '../util/durations';
import { isOlderThan } from '../util/timestamp';
const ROOT_DIR = path.join(__dirname, '..', '..');
const MESSAGES_FILE = path.join(ROOT_DIR, '_locales', 'en', 'messages.json');
const SPAWN_OPTS = {
cwd: ROOT_DIR,
stdio: [null, 'pipe', 'inherit'] as StdioOptions,
};
const messages = JSON.parse(fs.readFileSync(MESSAGES_FILE).toString());
const limitter = pLimit(10);
const stillUsed = new Set<string>();
async function main() {
const messages = JSON.parse(await fs.readFile(MESSAGES_FILE, 'utf-8'));
for (const [key, value] of Object.entries(messages)) {
const match = (value as Record<string, string>).description?.match(
/\(\s*deleted\s+(\d{4}\/\d{2}\/\d{2})\s*\)/
);
if (!match) {
continue;
}
const stillUsed = new Set<string>();
const deletedAt = new Date(match[1]).getTime();
if (!isOlderThan(deletedAt, MONTH)) {
continue;
}
await Promise.all(
Object.keys(messages).map(key =>
limitter(async () => {
const value = messages[key];
// Find uses in either:
// - `i18n('key')`
// - `<Intl id="key"/>`
const { status, stdout } = spawnSync(
'git',
['grep', '--extended-regexp', `'${key}'|id="${key}"`],
SPAWN_OPTS
const match = (value as Record<string, string>).description?.match(
/\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/
);
if (!match) {
return;
}
const deletedAt = new Date(match[1]).getTime();
if (!isOlderThan(deletedAt, MONTH)) {
return;
}
// Find uses in either:
// - `i18n('key')`
// - `<Intl id="key"/>`
try {
const result = await execa(
'git',
// prettier-ignore
[
'grep',
'--extended-regexp',
`'${key}'|id="${key}"`,
'--',
'**',
':!\\_locales/**',
':!\\sticker-creator/**',
],
{
cwd: ROOT_DIR,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'inherit',
}
);
// Match found
console.error(
chalk.red(
`ERROR: String is still used: "${key}", deleted on ${match[1]}`
)
);
console.error(result.stdout.trim());
console.error('');
stillUsed.add(key);
} catch (error) {
if (error.exitCode === 1) {
console.log(
chalk.dim(`Removing string: "${key}", deleted on ${match[1]}`)
);
delete messages[key];
} else {
throw error;
}
}
})
)
);
// Match found
if (status === 0) {
if (stillUsed.size !== 0) {
console.error(
`ERROR: String is still used: "${key}", deleted on ${match[1]}`
`ERROR: Didn't remove ${stillUsed.size} strings because of errors above`,
Array.from(stillUsed)
.map(str => `- ${str}`)
.join('\n')
);
console.error(stdout.toString().trim());
console.error('');
stillUsed.add(key);
} else {
console.log(`Removing string: "${key}", deleted on ${match[1]}`);
delete messages[key];
console.error('ERROR: Not saving changes');
process.exit(1);
}
await fs.writeFile(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
}
if (stillUsed.size !== 0) {
console.error(
`ERROR: Didn't remove ${[...stillUsed]} strings because of errors above`
);
console.error('ERROR: Not saving changes');
main().catch(err => {
console.error(err);
process.exit(1);
}
fs.writeFileSync(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
});

View file

@ -14,9 +14,18 @@ describe('setupI18n', () => {
});
describe('i18n', () => {
it('returns empty string for unknown string', () => {
// eslint-disable-next-line local-rules/valid-i18n-keys
assert.strictEqual(i18n('random'), '');
it('throws an error for legacy strings', () => {
assert.throws(() => {
// eslint-disable-next-line local-rules/valid-i18n-keys
i18n('legacystring');
}, /Legacy message format is no longer supported/);
});
it('throws an error for unknown string', () => {
assert.throws(() => {
// eslint-disable-next-line local-rules/valid-i18n-keys
assert.strictEqual(i18n('icu:random'), '');
}, /missing translation/);
});
it('returns message for given string', () => {
assert.strictEqual(i18n('icu:reportIssue'), 'Contact Support');

View file

@ -16,7 +16,6 @@ type SmartlingConfigType = {
};
export type LocaleMessageType = {
message?: string;
messageformat?: string;
description?: string;
};

View file

@ -1,181 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import memoize from '@formatjs/fast-memoize';
import type { IntlShape } from 'react-intl';
import { createIntl, createIntlCache } from 'react-intl';
import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N';
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import * as log from '../logging/log';
import { strictAssert } from './assert';
export const formatters = {
getNumberFormat: memoize((locale, opts) => {
return new Intl.NumberFormat(locale, opts);
}),
getDateTimeFormat: memoize((locale, opts) => {
return new Intl.DateTimeFormat(locale, opts);
}),
getPluralRules: memoize((locale, opts) => {
return new Intl.PluralRules(locale, opts);
}),
};
export function isLocaleMessageType(
value: unknown
): value is LocaleMessageType {
return (
typeof value === 'object' &&
value != null &&
(Object.hasOwn(value, 'message') || Object.hasOwn(value, 'messageformat'))
);
}
export function classifyMessages(messages: LocaleMessagesType): {
icuMessages: Record<string, string>;
legacyMessages: Record<string, string>;
} {
const icuMessages: Record<string, string> = {};
const legacyMessages: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (isLocaleMessageType(value)) {
if (value.messageformat != null) {
icuMessages[key] = value.messageformat;
} else if (value.message != null) {
legacyMessages[key] = value.message;
}
}
}
return { icuMessages, legacyMessages };
}
export function createCachedIntl(
locale: string,
icuMessages: Record<string, string>
): IntlShape {
const intlCache = createIntlCache();
const intl = createIntl(
{
locale: locale.replace('_', '-'), // normalize supported locales to browser format
messages: icuMessages,
},
intlCache
);
return intl;
}
export function formatIcuMessage(
intl: IntlShape,
id: string,
substitutions: ReplacementValuesType | undefined
): string {
strictAssert(
!Array.isArray(substitutions),
`substitutions must be an object for ICU message ${id}`
);
const result = intl.formatMessage({ id }, substitutions);
strictAssert(
typeof result === 'string',
'i18n: formatted translation result must be a string, must use <Intl/> component to render JSX'
);
return result;
}
export function setupI18n(
locale: string,
messages: LocaleMessagesType
): LocalizerType {
if (!locale) {
throw new Error('i18n: locale parameter is required');
}
if (!messages) {
throw new Error('i18n: messages parameter is required');
}
const { icuMessages, legacyMessages } = classifyMessages(messages);
const intl = createCachedIntl(locale, icuMessages);
const getMessage: LocalizerType = (key, substitutions) => {
const messageformat = icuMessages[key];
if (messageformat != null) {
return formatIcuMessage(intl, key, substitutions);
}
const message = legacyMessages[key];
if (message == null) {
log.error(
`i18n: Attempted to get translation for nonexistent key '${key}'`
);
return '';
}
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
if (
typeof substitutions === 'string' ||
typeof substitutions === 'number'
) {
throw new Error('You must provide either a map or an array');
}
if (!substitutions) {
return message;
}
if (Array.isArray(substitutions)) {
return substitutions.reduce(
(result, substitution) => result.replace(/\$.+?\$/, substitution),
message
);
}
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
let match = FIND_REPLACEMENTS.exec(message);
let builder = '';
let lastTextIndex = 0;
while (match) {
if (lastTextIndex < match.index) {
builder += message.slice(lastTextIndex, match.index);
}
const placeholderName = match[1];
let value = substitutions[placeholderName];
if (value == null) {
log.error(
`i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
);
value = '';
}
builder += value;
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(message);
}
if (lastTextIndex < message.length) {
builder += message.slice(lastTextIndex);
}
return builder;
};
getMessage.getIntl = () => {
return intl;
};
getMessage.isLegacyFormat = (key: string) => {
return legacyMessages[key] != null;
};
getMessage.getLocale = () => locale;
getMessage.getLocaleMessages = () => messages;
getMessage.getLocaleDirection = () => {
return window.getResolvedMessagesLocaleDirection();
};
return getMessage;
}

110
ts/util/setupI18n.tsx Normal file
View file

@ -0,0 +1,110 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { IntlShape } from 'react-intl';
import { createIntl, createIntlCache } from 'react-intl';
import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N';
import type { LocalizerType } from '../types/Util';
import { strictAssert } from './assert';
import { Emojify } from '../components/conversation/Emojify';
export function isLocaleMessageType(
value: unknown
): value is LocaleMessageType {
return (
typeof value === 'object' &&
value != null &&
Object.hasOwn(value, 'messageformat')
);
}
function filterLegacyMessages(
messages: LocaleMessagesType
): Record<string, string> {
const icuMessages: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (isLocaleMessageType(value) && value.messageformat != null) {
icuMessages[key] = value.messageformat;
}
}
return icuMessages;
}
export function renderEmoji(parts: ReadonlyArray<unknown>): JSX.Element {
strictAssert(parts.length === 1, '<emoji> must contain only one child');
const text = parts[0];
strictAssert(typeof text === 'string', '<emoji> must contain only text');
return <Emojify text={text} />;
}
export function createCachedIntl(
locale: string,
icuMessages: Record<string, string>
): IntlShape {
const intlCache = createIntlCache();
const intl = createIntl(
{
locale: locale.replace('_', '-'), // normalize supported locales to browser format
messages: icuMessages,
defaultRichTextElements: {
emoji: renderEmoji,
},
},
intlCache
);
return intl;
}
export function setupI18n(
locale: string,
messages: LocaleMessagesType
): LocalizerType {
if (!locale) {
throw new Error('i18n: locale parameter is required');
}
if (!messages) {
throw new Error('i18n: messages parameter is required');
}
const intl = createCachedIntl(locale, filterLegacyMessages(messages));
const localizer: LocalizerType = (key, substitutions) => {
strictAssert(
!localizer.isLegacyFormat(key),
`i18n: Legacy message format is no longer supported "${key}"`
);
strictAssert(
!Array.isArray(substitutions),
`i18n: Substitutions must be an object for ICU message "${key}"`
);
const result = intl.formatMessage({ id: key }, substitutions);
strictAssert(
typeof result === 'string',
'i18n: Formatted translation result must be a string, must use <Intl/> component to render JSX'
);
strictAssert(result !== key, `i18n: missing translation for "${key}"`);
return result;
};
localizer.getIntl = () => {
return intl;
};
localizer.isLegacyFormat = (key: string) => {
return !key.startsWith('icu:');
};
localizer.getLocale = () => locale;
localizer.getLocaleMessages = () => messages;
localizer.getLocaleDirection = () => {
return window.getResolvedMessagesLocaleDirection();
};
return localizer;
}

View file

@ -8949,6 +8949,21 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
md5.js "^1.3.4"
safe-buffer "^5.1.1"
execa@5.1.1, execa@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
dependencies:
cross-spawn "^7.0.3"
get-stream "^6.0.0"
human-signals "^2.1.0"
is-stream "^2.0.0"
merge-stream "^2.0.0"
npm-run-path "^4.0.1"
onetime "^5.1.2"
signal-exit "^3.0.3"
strip-final-newline "^2.0.0"
execa@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
@ -8991,21 +9006,6 @@ execa@^5.0.0:
signal-exit "^3.0.3"
strip-final-newline "^2.0.0"
execa@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
dependencies:
cross-spawn "^7.0.3"
get-stream "^6.0.0"
human-signals "^2.1.0"
is-stream "^2.0.0"
merge-stream "^2.0.0"
npm-run-path "^4.0.1"
onetime "^5.1.2"
signal-exit "^3.0.3"
strip-final-newline "^2.0.0"
expand-brackets@^0.1.4:
version "0.1.5"
resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
@ -14003,6 +14003,13 @@ p-finally@^2.0.0:
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561"
integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==
p-limit@3.1.0, p-limit@^3.0.2, p-limit@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
dependencies:
yocto-queue "^0.1.0"
p-limit@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc"
@ -14021,13 +14028,6 @@ p-limit@^2.1.0:
dependencies:
p-try "^2.0.0"
p-limit@^3.0.2, p-limit@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
dependencies:
yocto-queue "^0.1.0"
p-locate@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"