mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-28 17:57:48 +02:00
refactor(v2): i18n cleanups / refactors (#4405)
* chore: fix various intl stuff - remove intl-locales-supported & intl since they're deprecated and only needed for IE11 - add new polyfills for node 12 - clean up babel intl extractor - reset jest test timezone to UTC so it passes even for East Coast contributor * chore: change build to include Node 14 * docs: update i18n reqs
This commit is contained in:
parent
c3968e2d8f
commit
1078341b22
15 changed files with 162 additions and 124 deletions
2
.github/workflows/e2e-test.yml
vendored
2
.github/workflows/e2e-test.yml
vendored
|
@ -13,7 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['12']
|
||||
node: ['12', '14']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node }}
|
||||
|
|
2
.github/workflows/migration-cli-e2e-test.yml
vendored
2
.github/workflows/migration-cli-e2e-test.yml
vendored
|
@ -23,7 +23,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['12']
|
||||
node: ['12', '14']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node }}
|
||||
|
|
2
.github/workflows/nodejs-windows.yml
vendored
2
.github/workflows/nodejs-windows.yml
vendored
|
@ -10,7 +10,7 @@ jobs:
|
|||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['12']
|
||||
node: ['12', '14']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: dorny/paths-filter@v2
|
||||
|
|
2
.nvmrc
2
.nvmrc
|
@ -1 +1 @@
|
|||
12.13.0
|
||||
14.16.0
|
||||
|
|
|
@ -39,7 +39,7 @@ module.exports = {
|
|||
transform: {
|
||||
'^.+\\.[jt]sx?$': 'babel-jest',
|
||||
},
|
||||
setupFiles: ['./jest/stylelint-rule-test.js'],
|
||||
setupFiles: ['./jest/stylelint-rule-test.js', './jest/polyfills.js'],
|
||||
moduleNameMapper: {
|
||||
'@docusaurus/router': 'react-router-dom',
|
||||
},
|
||||
|
|
19
jest/polyfills.js
Normal file
19
jest/polyfills.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
require('@formatjs/intl-pluralrules/polyfill');
|
||||
require('@formatjs/intl-pluralrules/locale-data/en');
|
||||
require('@formatjs/intl-pluralrules/locale-data/fr');
|
||||
|
||||
require('@formatjs/intl-numberformat/polyfill');
|
||||
require('@formatjs/intl-numberformat/locale-data/en');
|
||||
require('@formatjs/intl-numberformat/locale-data/fr');
|
||||
|
||||
require('@formatjs/intl-datetimeformat/polyfill');
|
||||
require('@formatjs/intl-datetimeformat/add-all-tz');
|
||||
require('@formatjs/intl-datetimeformat/locale-data/en');
|
||||
require('@formatjs/intl-datetimeformat/locale-data/fr');
|
|
@ -46,7 +46,7 @@
|
|||
"lint:js": "eslint --cache \"**/*.{js,jsx,ts,tsx}\"",
|
||||
"lint:style": "stylelint \"**/*.css\"",
|
||||
"lerna": "lerna",
|
||||
"test": "jest",
|
||||
"test": "cross-env TZ=UTC jest",
|
||||
"test:build:v2": "./admin/scripts/test-release.sh",
|
||||
"watch": "yarn lerna run --parallel --no-private watch",
|
||||
"clear": "yarn workspace docusaurus-2-website clear && yarn lerna exec --ignore docusaurus yarn rimraf lib",
|
||||
|
@ -71,6 +71,9 @@
|
|||
"@babel/plugin-transform-modules-commonjs": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.12.16",
|
||||
"@crowdin/cli": "^3.5.3",
|
||||
"@formatjs/intl-datetimeformat": "^3.2.12",
|
||||
"@formatjs/intl-numberformat": "^6.2.2",
|
||||
"@formatjs/intl-pluralrules": "^4.0.11",
|
||||
"@types/express": "^4.17.2",
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.20",
|
||||
|
|
|
@ -26,7 +26,6 @@ import {
|
|||
getEditUrl,
|
||||
getFolderContainingFile,
|
||||
posixPath,
|
||||
getDateTimeFormat,
|
||||
} from '@docusaurus/utils';
|
||||
import {LoadContext} from '@docusaurus/types';
|
||||
import {replaceMarkdownLinks} from '@docusaurus/utils/lib/markdownLinks';
|
||||
|
@ -177,14 +176,11 @@ export async function generateBlogPosts(
|
|||
|
||||
// Use file create time for blog.
|
||||
date = date || (await fs.stat(source)).birthtime;
|
||||
const formattedDate = getDateTimeFormat(i18n.currentLocale)(
|
||||
i18n.currentLocale,
|
||||
{
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
},
|
||||
).format(date);
|
||||
const formattedDate = new Intl.DateTimeFormat(i18n.currentLocale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
|
||||
const slug =
|
||||
frontMatter.slug || (match ? toUrl({date, link: linkName}) : linkName);
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
normalizeUrl,
|
||||
parseMarkdownString,
|
||||
posixPath,
|
||||
getDateTimeFormat,
|
||||
} from '@docusaurus/utils';
|
||||
import {LoadContext} from '@docusaurus/types';
|
||||
|
||||
|
@ -212,7 +211,7 @@ export function processDocMetadata({
|
|||
lastUpdatedBy: lastUpdate.lastUpdatedBy,
|
||||
lastUpdatedAt: lastUpdate.lastUpdatedAt,
|
||||
formattedLastUpdatedAt: lastUpdate.lastUpdatedAt
|
||||
? getDateTimeFormat(i18n.currentLocale)(i18n.currentLocale).format(
|
||||
? new Intl.DateTimeFormat(i18n.currentLocale).format(
|
||||
lastUpdate.lastUpdatedAt * 1000,
|
||||
)
|
||||
: undefined,
|
||||
|
|
|
@ -65,7 +65,8 @@ function useLocalePluralForms(): LocalePluralForms {
|
|||
i18n: {currentLocale},
|
||||
} = useDocusaurusContext();
|
||||
return useMemo(() => {
|
||||
if (Intl && Intl.PluralRules) {
|
||||
// @ts-expect-error checking Intl.PluralRules in case browser doesn't have it (e.g Safari 12-)
|
||||
if (Intl.PluralRules) {
|
||||
try {
|
||||
return createLocalePluralForms(currentLocale);
|
||||
} catch (e) {
|
||||
|
|
|
@ -24,8 +24,6 @@
|
|||
"escape-string-regexp": "^4.0.0",
|
||||
"fs-extra": "^9.1.0",
|
||||
"gray-matter": "^4.0.2",
|
||||
"intl": "^1.2.5",
|
||||
"intl-locales-supported": "1.8.11",
|
||||
"lodash": "^4.17.20",
|
||||
"resolve-pathname": "^3.0.0"
|
||||
},
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
|
||||
// @ts-expect-error: no typedefs :s
|
||||
import resolvePathnameUnsafe from 'resolve-pathname';
|
||||
import areIntlLocalesSupported from 'intl-locales-supported';
|
||||
|
||||
const fileHash = new Map();
|
||||
export async function generate(
|
||||
|
@ -634,13 +633,6 @@ export async function readDefaultCodeTranslationMessages({
|
|||
return {};
|
||||
}
|
||||
|
||||
export function getDateTimeFormat(locale: string) {
|
||||
return areIntlLocalesSupported([locale])
|
||||
? global.Intl.DateTimeFormat
|
||||
: // eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require('intl').DateTimeFormat;
|
||||
}
|
||||
|
||||
// Input: ## Some heading {#some-heading}
|
||||
// Output: {text: "## Some heading", id: "some-heading"}
|
||||
export function parseMarkdownHeadingId(
|
||||
|
|
|
@ -166,7 +166,7 @@ Need help understanding this?
|
|||
|
||||
Useful resources:
|
||||
https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md
|
||||
https://github.com/formatjs/formatjs/blob/main/packages/babel-plugin-react-intl/index.ts
|
||||
https://github.com/formatjs/formatjs/blob/main/packages/babel-plugin-formatjs/index.ts
|
||||
https://github.com/pugjs/babel-walk
|
||||
*/
|
||||
function extractSourceCodeAstTranslations(
|
||||
|
@ -190,20 +190,31 @@ function extractSourceCodeAstTranslations(
|
|||
|
||||
traverse(ast, {
|
||||
JSXElement(path) {
|
||||
if (
|
||||
!path
|
||||
.get('openingElement')
|
||||
.get('name')
|
||||
.isJSXIdentifier({name: 'Translate'})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
function evaluateJSXProp(propName: string): string | undefined {
|
||||
const attributePath = path
|
||||
.get('openingElement.attributes')
|
||||
.find(
|
||||
(attr) => attr.isJSXAttribute() && attr.node.name.name === propName,
|
||||
(attr) =>
|
||||
attr.isJSXAttribute() &&
|
||||
(attr as NodePath<t.JSXAttribute>)
|
||||
.get('name')
|
||||
.isJSXIdentifier({name: propName}),
|
||||
);
|
||||
|
||||
if (attributePath) {
|
||||
const attributeValue = attributePath.get('value') as NodePath;
|
||||
|
||||
const attributeValueEvaluated =
|
||||
attributeValue.node.type === 'JSXExpressionContainer'
|
||||
? (attributeValue.get('expression') as NodePath).evaluate()
|
||||
: attributeValue.evaluate();
|
||||
const attributeValueEvaluated = attributeValue.isJSXExpressionContainer()
|
||||
? (attributeValue.get('expression') as NodePath).evaluate()
|
||||
: attributeValue.evaluate();
|
||||
|
||||
if (
|
||||
attributeValueEvaluated.confident &&
|
||||
|
@ -222,100 +233,92 @@ function extractSourceCodeAstTranslations(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
path.node.openingElement.name.type === 'JSXIdentifier' &&
|
||||
path.node.openingElement.name.name === 'Translate'
|
||||
// We only handle the optimistic case where we have a single non-empty content
|
||||
const singleChildren = path
|
||||
.get('children')
|
||||
// Remove empty/useless text nodes that might be around our translation!
|
||||
// Makes the translation system more reliable to JSX formatting issues
|
||||
.filter(
|
||||
(childrenPath) =>
|
||||
!(
|
||||
childrenPath.isJSXText() &&
|
||||
childrenPath.node.value.replace('\n', '').trim() === ''
|
||||
),
|
||||
)
|
||||
.pop();
|
||||
|
||||
if (singleChildren && singleChildren.isJSXText()) {
|
||||
const message = singleChildren.node.value.trim().replace(/\s+/g, ' ');
|
||||
|
||||
const id = evaluateJSXProp('id');
|
||||
const description = evaluateJSXProp('description');
|
||||
|
||||
translations[id ?? message] = {
|
||||
message,
|
||||
...(description && {description}),
|
||||
};
|
||||
} else if (
|
||||
singleChildren &&
|
||||
singleChildren.isJSXExpressionContainer() &&
|
||||
(singleChildren.get('expression') as NodePath).evaluate().confident
|
||||
) {
|
||||
// We only handle the optimistic case where we have a single non-empty content
|
||||
const singleChildren = path
|
||||
.get('children')
|
||||
// Remove empty/useless text nodes that might be around our translation!
|
||||
// Makes the translation system more reliable to JSX formatting issues
|
||||
.filter(
|
||||
(childrenPath) =>
|
||||
!(
|
||||
t.isJSXText(childrenPath.node) &&
|
||||
childrenPath.node.value.replace('\n', '').trim() === ''
|
||||
),
|
||||
)
|
||||
.pop();
|
||||
const message = (singleChildren.get(
|
||||
'expression',
|
||||
) as NodePath).evaluate().value;
|
||||
|
||||
if (singleChildren && t.isJSXText(singleChildren.node)) {
|
||||
const message = singleChildren.node.value.trim().replace(/\s+/g, ' ');
|
||||
const id = evaluateJSXProp('id');
|
||||
const description = evaluateJSXProp('description');
|
||||
|
||||
const id = evaluateJSXProp('id');
|
||||
const description = evaluateJSXProp('description');
|
||||
|
||||
translations[id ?? message] = {
|
||||
message,
|
||||
...(description && {description}),
|
||||
};
|
||||
} else if (
|
||||
singleChildren &&
|
||||
t.isJSXExpressionContainer(singleChildren) &&
|
||||
(singleChildren.get('expression') as NodePath).evaluate().confident
|
||||
) {
|
||||
const message = (singleChildren.get(
|
||||
'expression',
|
||||
) as NodePath).evaluate().value;
|
||||
|
||||
const id = evaluateJSXProp('id');
|
||||
const description = evaluateJSXProp('description');
|
||||
|
||||
translations[id ?? message] = {
|
||||
message,
|
||||
...(description && {description}),
|
||||
};
|
||||
} else {
|
||||
warnings.push(
|
||||
`${staticTranslateJSXWarningPart}\n${sourceFileWarningPart(
|
||||
path.node,
|
||||
)}\n${generateCode(path.node)}`,
|
||||
);
|
||||
}
|
||||
translations[id ?? message] = {
|
||||
message,
|
||||
...(description && {description}),
|
||||
};
|
||||
} else {
|
||||
warnings.push(
|
||||
`${staticTranslateJSXWarningPart}\n${sourceFileWarningPart(
|
||||
path.node,
|
||||
)}\n${generateCode(path.node)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(path) {
|
||||
if (
|
||||
path.node.callee.type === 'Identifier' &&
|
||||
path.node.callee.name === 'translate'
|
||||
) {
|
||||
// console.log('CallExpression', path.node);
|
||||
if (!path.get('callee').isIdentifier({name: 'translate'})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log('CallExpression', path.node);
|
||||
const args = path.get('arguments');
|
||||
if (args.length === 1 || args.length === 2) {
|
||||
const firstArgPath = args[0];
|
||||
|
||||
// evaluation allows translate("x" + "y"); to be considered as translate("xy");
|
||||
const firstArgEvaluated = firstArgPath.evaluate();
|
||||
|
||||
// console.log('firstArgEvaluated', firstArgEvaluated);
|
||||
|
||||
if (
|
||||
path.node.arguments.length === 1 ||
|
||||
path.node.arguments.length === 2
|
||||
firstArgEvaluated.confident &&
|
||||
typeof firstArgEvaluated.value === 'object'
|
||||
) {
|
||||
const firstArgPath = path.get('arguments.0') as NodePath;
|
||||
|
||||
// evaluation allows translate("x" + "y"); to be considered as translate("xy");
|
||||
const firstArgEvaluated = firstArgPath.evaluate();
|
||||
|
||||
// console.log('firstArgEvaluated', firstArgEvaluated);
|
||||
|
||||
if (
|
||||
firstArgEvaluated.confident &&
|
||||
typeof firstArgEvaluated.value === 'object'
|
||||
) {
|
||||
const {message, id, description} = firstArgEvaluated.value;
|
||||
translations[id ?? message] = {
|
||||
message,
|
||||
...(description && {description}),
|
||||
};
|
||||
} else {
|
||||
warnings.push(
|
||||
`translate() first arg should be a statically evaluable object.\nExample: translate({message: "text",id: "optional.id",description: "optional description"}\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceFileWarningPart(
|
||||
path.node,
|
||||
)}\n${generateCode(path.node)}`,
|
||||
);
|
||||
}
|
||||
const {message, id, description} = firstArgEvaluated.value;
|
||||
translations[id ?? message] = {
|
||||
message,
|
||||
...(description && {description}),
|
||||
};
|
||||
} else {
|
||||
warnings.push(
|
||||
`translate() function only takes 1 or 2 args\n${sourceFileWarningPart(
|
||||
`translate() first arg should be a statically evaluable object.\nExample: translate({message: "text",id: "optional.id",description: "optional description"}\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceFileWarningPart(
|
||||
path.node,
|
||||
)}\n${generateCode(path.node)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warnings.push(
|
||||
`translate() function only takes 1 or 2 args\n${sourceFileWarningPart(
|
||||
path.node,
|
||||
)}\n${generateCode(path.node)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -13,6 +13,12 @@ i18n is a new feature (released early 2021), please report any bug you find.
|
|||
|
||||
:::
|
||||
|
||||
:::caution
|
||||
|
||||
i18n requires Node 13+ to build or Node 12 with [full-icu](https://www.npmjs.com/package/full-icu).
|
||||
|
||||
:::
|
||||
|
||||
## Goals
|
||||
|
||||
It is important to understand the **design decisions** behind the Docusaurus i18n support.
|
||||
|
|
43
yarn.lock
43
yarn.lock
|
@ -1393,6 +1393,37 @@
|
|||
unique-filename "^1.1.1"
|
||||
which "^1.3.1"
|
||||
|
||||
"@formatjs/ecma402-abstract@1.6.2":
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.6.2.tgz#9d064a2cf790769aa6721e074fb5d5c357084bb9"
|
||||
integrity sha512-aLBODrSRhHaL/0WdQ0T2UsGqRbdtRRHqqrs4zwNQoRsGBEtEAvlj/rgr6Uea4PSymVJrbZBoAyECM2Z3Pq4i0g==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
|
||||
"@formatjs/intl-datetimeformat@^3.2.12":
|
||||
version "3.2.12"
|
||||
resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.2.12.tgz#c9b2e85f0267ee13ea615a8991995da3075e3b13"
|
||||
integrity sha512-qvY5+dl3vlgH0iWRXwl8CG9UkSVB5uP2+HH//fyZZ01G4Ww5rxMJmia1SbUqatpoe/dX+Z+aLejCqUUyugyL2g==
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract" "1.6.2"
|
||||
tslib "^2.1.0"
|
||||
|
||||
"@formatjs/intl-numberformat@^6.2.2":
|
||||
version "6.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-6.2.2.tgz#a75ea1d18ac3c89c2a28d9d9ac35f612699963e8"
|
||||
integrity sha512-mM6dwNizz8GS62kVS7yj0avz+6HDvIJldsAv495XCTFxxcSmP4iTREG+0J+ex8puBTJRESgJx4MBcXJPj0K76Q==
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract" "1.6.2"
|
||||
tslib "^2.1.0"
|
||||
|
||||
"@formatjs/intl-pluralrules@^4.0.11":
|
||||
version "4.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.11.tgz#c7f09879a30550a6599f1b128021fcb7115a7c87"
|
||||
integrity sha512-NkQl6eBJKMSMmNCan2gzpErqbw7j1SiHzqAQF3+coAKwB74E89hAGTigvnCKEGpMf+hHysL4+wThx5IJutoT5Q==
|
||||
dependencies:
|
||||
"@formatjs/ecma402-abstract" "1.6.2"
|
||||
tslib "^2.1.0"
|
||||
|
||||
"@hapi/address@^2.1.2":
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
|
||||
|
@ -10984,16 +11015,6 @@ interpret@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
|
||||
integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
|
||||
|
||||
intl-locales-supported@1.8.11:
|
||||
version "1.8.11"
|
||||
resolved "https://registry.yarnpkg.com/intl-locales-supported/-/intl-locales-supported-1.8.11.tgz#a8488b2998b524754e020fef0c0a67fa7b87bd5b"
|
||||
integrity sha512-J+RhLNDxEvaNPdsoWz2KKlLSqU0MfV4Pd6zS1Yx/tN/KGN5QYe4Z2+ifJod95LlaA4K6qogwrSzm/WyTNOV6RA==
|
||||
|
||||
intl@^1.2.5:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde"
|
||||
integrity sha1-giRKIZDE5Bn4Nx9ao02qNCDiq94=
|
||||
|
||||
into-stream@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6"
|
||||
|
@ -19700,7 +19721,7 @@ tslib@^1, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
|
|||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
|
||||
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
|
||||
|
||||
tslib@^2.0.0, tslib@^2.0.1:
|
||||
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
|
||||
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
|
||||
|
|
Loading…
Add table
Reference in a new issue