Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Provide loader function for dynamically imported i18n messages #1369

Merged
merged 4 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 50 additions & 14 deletions build-tools/tasks/generate-i18n-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,24 @@ const { parse } = require('@formatjs/icu-messageformat-parser');
const { targetPath } = require('../utils/workspace');
const { writeFile } = require('../utils/files');

const namespace = '@cloudscape-design/components';
const sourceI18nDir = path.resolve(__dirname, '../../src/i18n');
const sourceMessagesDir = path.resolve(sourceI18nDir, 'messages');
const targetI18nDir = path.resolve(targetPath, 'components/i18n');
const targetMessagesDir = path.resolve(targetI18nDir, 'messages');

const destinationDir = path.join(targetPath, 'components/i18n/messages');
const declarationFile = `import { I18nProviderProps } from "../provider";
const namespace = '@cloudscape-design/components';
const messagesDeclarationFile = `import { I18nProviderProps } from "../provider";
declare const messages: I18nProviderProps.Messages;
export default messages;
`;

module.exports = function generateI18nMessages() {
const messagesDir = path.resolve(__dirname, '../../src/i18n/messages');
const files = fs.readdirSync(messagesDir);

const files = fs.readdirSync(sourceMessagesDir);
const allParsedMessages = {};

// Generate individual locale messages files.
for (const fileName of files) {
const filePath = path.join(messagesDir, fileName);
const filePath = path.join(sourceMessagesDir, fileName);
const messages = require(filePath);
const [subset, locale] = fileName.split('.');

Expand All @@ -35,16 +37,50 @@ module.exports = function generateI18nMessages() {
);
allParsedMessages[locale] = { ...(allParsedMessages[locale] ?? {}), ...parsedMessages };
const resultFormat = { [namespace]: { [locale]: parsedMessages } };
writeFile(path.join(destinationDir, `${subset}.${locale}.json`), JSON.stringify(resultFormat));
writeFile(path.join(destinationDir, `${subset}.${locale}.d.ts`), declarationFile);
writeFile(path.join(destinationDir, `${subset}.${locale}.js`), `export default ${JSON.stringify(resultFormat)}`);

writeFile(path.join(targetMessagesDir, `${subset}.${locale}.json`), JSON.stringify(resultFormat));
writeFile(path.join(targetMessagesDir, `${subset}.${locale}.d.ts`), messagesDeclarationFile);
writeFile(path.join(targetMessagesDir, `${subset}.${locale}.js`), `export default ${JSON.stringify(resultFormat)}`);
}

// Generate a ".all" file containing all locales.
const resultFormat = { [namespace]: allParsedMessages };
writeFile(path.join(destinationDir, `all.all.json`), JSON.stringify(resultFormat));
writeFile(path.join(destinationDir, `all.all.d.ts`), declarationFile);
writeFile(path.join(destinationDir, `all.all.js`), `export default ${JSON.stringify(resultFormat)}`);
const allResultFormat = { [namespace]: allParsedMessages };
writeFile(path.join(targetMessagesDir, 'all.all.json'), JSON.stringify(allResultFormat));
writeFile(path.join(targetMessagesDir, 'all.all.d.ts'), messagesDeclarationFile);
writeFile(path.join(targetMessagesDir, 'all.all.js'), `export default ${JSON.stringify(allResultFormat)}`);

// Generate a dynamic provider function for automatic bundler splitting and imports.
const dynamicFile = [
`import { warnOnce } from '@cloudscape-design/component-toolkit/internal';
import { isDevelopment } from '../internal/is-development';
import { getMatchableLocales } from './get-matchable-locales';

export function importMessages(locale) {
for (const matchableLocale of getMatchableLocales(locale)) {
switch (matchableLocale.toLowerCase()) {`,
...files.flatMap(fileName => {
const [subset, locale] = fileName.split('.');
if (subset !== 'all') {
return []; // For now, this only supports loading all messages for the locale.
}
return [
` case "${locale.toLowerCase()}":
return import("./messages/${subset}.${locale}.js").then(mod => [mod.default]);`,
];
}),
` }
}

if (isDevelopment) {
warnOnce('importMessages', \`Unknown locale "\${locale}" provided to importMessages\`)
}

return Promise.resolve([]);
}`,
].join('\n');

fs.copyFileSync(path.join(sourceI18nDir, 'dynamic.d.ts'), path.join(targetI18nDir, 'dynamic.d.ts'));
writeFile(path.join(targetI18nDir, 'dynamic.js'), dynamicFile);

return Promise.resolve();
};
2 changes: 1 addition & 1 deletion pages/alert/simple.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import SpaceBetween from '~components/space-between';
import styles from './styles.scss';

import { I18nProvider } from '~components/i18n';
import messages from '~components/i18n/messages/all.all';
import messages from '~components/i18n/messages/all.en';

export default function AlertScenario() {
const [visible, setVisible] = useState(true);
Expand Down
31 changes: 31 additions & 0 deletions pages/i18n/dynamic.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';
import { TagEditor } from '~components';
import { I18nProvider, I18nProviderProps, importMessages } from '~components/i18n';

const LOCALE = 'ja';

export default function I18nDynamicPage() {
const [messages, setMessages] = useState<ReadonlyArray<I18nProviderProps.Messages> | null>(null);
useEffect(() => {
importMessages(LOCALE).then(setMessages);
}, []);

if (messages === null) {
return 'Loading...';
}

return (
<I18nProvider locale={LOCALE} messages={messages}>
<h1>Dynamically imported messages</h1>
<TagEditor
tags={[
{ key: 'Tag 1', value: 'Value 1', existing: false },
{ key: 'Tag 2', value: 'Value 2', existing: true, markedForRemoval: true },
{ key: 'Tag 2', value: '', existing: false },
]}
/>
</I18nProvider>
);
}
2 changes: 1 addition & 1 deletion pages/property-filter/hooks.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { columnDefinitions, i18nStrings, filteringProperties } from './common-pr
import { useCollection } from '@cloudscape-design/collection-hooks';

import { I18nProvider } from '~components/i18n';
import messages from '~components/i18n/messages/all.all';
import messages from '~components/i18n/messages/all.en';

export default function () {
const [locale, setLocale] = useState('en');
Expand Down
3 changes: 2 additions & 1 deletion pages/webpack.config.base.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ module.exports = ({
awsui: {
test: module =>
module.resource &&
(module.resource.includes(componentsPath) || module.resource.includes(designTokensPath)),
(module.resource.includes(componentsPath) || module.resource.includes(designTokensPath)) &&
!module.resource.includes(path.resolve(componentsPath, 'i18n/messages')),
name: 'awsui',
chunks: 'all',
},
Expand Down
26 changes: 26 additions & 0 deletions src/i18n/__integ__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
import createWrapper from '../../../lib/components/test-utils/selectors';
import jaStaticMessages from '../../../lib/components/i18n/messages/all.ja.json';

const wrapper = createWrapper().findTagEditor();

const setupTest = (testFn: (page: BasePageObject) => Promise<void>) => {
return useBrowser(async browser => {
const page = new BasePageObject(browser);
await browser.url('/#/light/i18n/dynamic');
await testFn(page);
});
};

test(
'dynamic messages are loaded correctly',
setupTest(async page => {
const text = jaStaticMessages['@cloudscape-design/components'].ja['tag-editor']['i18nStrings.addButton'][0].value;
await page.waitForVisible(wrapper.findAddButton().toSelector());
await expect(page.getText(wrapper.findAddButton().toSelector())).resolves.toBe(text);
})
);
15 changes: 15 additions & 0 deletions src/i18n/__tests__/dynamic.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { importMessages } from '../../../lib/components/i18n';

afterEach(() => {
jest.restoreAllMocks();
});

it('logs a warning if an unknown locale was provided', async () => {
jest.spyOn(console, 'warn');
const messages = await importMessages('klh');
expect(messages).toEqual([]);
expect(console.warn).toHaveBeenCalledWith(`[AwsUi] [importMessages] Unknown locale "klh" provided to importMessages`);
});
9 changes: 9 additions & 0 deletions src/i18n/dynamic.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// It's fine for importMessages to raise TypeScript requirements because
// it will usually be accompanied by the I18nProvider anyway.
// eslint-disable-next-line @cloudscape-design/ban-files
import { I18nProviderProps } from './provider';

export function importMessages(locale: string): Promise<ReadonlyArray<I18nProviderProps.Messages>>;
15 changes: 15 additions & 0 deletions src/i18n/get-matchable-locales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export function getMatchableLocales(ietfLanguageTag: string): string[] {
const parts = ietfLanguageTag.split('-');
if (parts.length === 1) {
return [ietfLanguageTag];
}

const localeStrings: string[] = [];
for (let i = parts.length; i > 0; i--) {
localeStrings.push(parts.slice(0, i).join('-'));
}
return localeStrings;
}
1 change: 1 addition & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
// SPDX-License-Identifier: Apache-2.0

export { I18nProvider as default, I18nProvider, I18nProviderProps } from './provider';
export { importMessages } from './dynamic';
14 changes: 1 addition & 13 deletions src/i18n/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal';
import { useTelemetry } from '../internal/hooks/use-telemetry';
import { applyDisplayName } from '../internal/utils/apply-display-name';
import { InternalI18nContext, FormatFunction, CustomHandler } from './context';
import { getMatchableLocales } from './get-matchable-locales';

export interface I18nProviderProps {
messages: ReadonlyArray<I18nProviderProps.Messages>;
Expand Down Expand Up @@ -137,16 +138,3 @@ function mergeMessages(sources: ReadonlyArray<I18nProviderProps.Messages>): I18n
}
return result;
}

function getMatchableLocales(ietfLanguageTag: string): string[] {
const parts = ietfLanguageTag.split('-');
if (parts.length === 1) {
return [ietfLanguageTag];
}

const localeStrings: string[] = [];
for (let i = parts.length; i > 0; i--) {
localeStrings.push(parts.slice(0, i).join('-'));
}
return localeStrings;
}
Loading