= ({ onFlashFirmware }) =>
return (
<>
- {i18n.translate(I18nId.NoHubMessage)}
+ {i18n.translate('noHub.message')}
- {i18n.translate(I18nId.NoHubSuggestion1, {
+ {i18n.translate('noHub.suggestion1', {
appName,
buttonName: (
-
- {i18n.translate(I18nId.NoHubFlashFirmwareButton)}
-
+ {i18n.translate('noHub.flashFirmwareButton')}
),
})}
- {i18n.translate(I18nId.NoHubSuggestion2)}
+ {i18n.translate('noHub.suggestion2')}
- {i18n.translate(I18nId.NoHubTroubleshootButton)}
+ {i18n.translate('noHub.troubleshootButton')}
diff --git a/src/ble/alerts/NoWebBluetooth.tsx b/src/ble/alerts/NoWebBluetooth.tsx
index 175cd7844..cb1c82ebf 100644
--- a/src/ble/alerts/NoWebBluetooth.tsx
+++ b/src/ble/alerts/NoWebBluetooth.tsx
@@ -5,19 +5,19 @@ import { Button, Intent } from '@blueprintjs/core';
import React from 'react';
import { CreateToast } from '../../i18nToaster';
import { isIOS, isLinux } from '../../utils/os';
-import { I18nId, useI18n } from './i18n';
+import { useI18n } from './i18n';
const NoWebBluetooth: React.VoidFunctionComponent = () => {
const i18n = useI18n();
return (
<>
- {i18n.translate(I18nId.NoWebBluetoothMessage)}
+ {i18n.translate('noWebBluetooth.message')}
{!isLinux() && !isIOS() && (
- {i18n.translate(I18nId.NoWebBluetoothSuggestion)}
+ {i18n.translate('noWebBluetooth.suggestion')}
)}
{isLinux() && (
<>
- {i18n.translate(I18nId.NoWebBluetoothLinux)}
+ {i18n.translate('noWebBluetooth.linux')}
chrome://flags/#enable-experimental-web-platform-features
diff --git a/src/ble/alerts/OldFirmware.tsx b/src/ble/alerts/OldFirmware.tsx
index a396bbc00..6f3a1173d 100644
--- a/src/ble/alerts/OldFirmware.tsx
+++ b/src/ble/alerts/OldFirmware.tsx
@@ -5,7 +5,7 @@ import './index.scss';
import { Button, Intent } from '@blueprintjs/core';
import React from 'react';
import { CreateToast } from '../../i18nToaster';
-import { I18nId, useI18n } from './i18n';
+import { useI18n } from './i18n';
type OldFirmwareProps = {
onFlashFirmware: () => void;
@@ -18,10 +18,10 @@ const OldFirmware: React.VoidFunctionComponent = ({
return (
<>
- {i18n.translate(I18nId.OldFirmwareMessage)}
+ {i18n.translate('oldFirmware.message')}
>
diff --git a/src/ble/alerts/i18n.test.ts b/src/ble/alerts/i18n.test.ts
deleted file mode 100644
index e706ba281..000000000
--- a/src/ble/alerts/i18n.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-License-Identifier: MIT
-// Copyright (c) 2022 The Pybricks Authors
-
-import { lookup } from '../../../test';
-import { I18nId } from './i18n';
-import en from './translations/en.json';
-
-describe('Ensure .json file has matches for I18nId', () => {
- test.each(Object.values(I18nId))('%s', (id) => {
- expect(lookup(en, id)).toBeDefined();
- });
-});
diff --git a/src/ble/alerts/i18n.ts b/src/ble/alerts/i18n.ts
index 20fca0a10..eb8dc4868 100644
--- a/src/ble/alerts/i18n.ts
+++ b/src/ble/alerts/i18n.ts
@@ -1,29 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
-import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import type { TypedI18n } from '../../i18n';
+import type translations from './translations/en.json';
-export function useI18n(): I18n {
+export function useI18n(): TypedI18n {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
-
-export enum I18nId {
- NoWebBluetoothMessage = 'noWebBluetooth.message',
- NoWebBluetoothSuggestion = 'noWebBluetooth.suggestion',
- NoWebBluetoothLinux = 'noWebBluetooth.linux',
- BluetoothNotAvailableMessage = 'bluetoothNotAvailable.message',
- BluetoothNotAvailableSuggestion = 'bluetoothNotAvailable.suggestion',
- NoGattMessage = 'noGatt.message',
- MissingServiceMessage = 'missingService.message',
- MissingServiceSuggestion1 = 'missingService.suggestion1',
- MissingServiceSuggestion2 = 'missingService.suggestion2',
- NoHubMessage = 'noHub.message',
- NoHubSuggestion1 = 'noHub.suggestion1',
- NoHubSuggestion2 = 'noHub.suggestion2',
- NoHubFlashFirmwareButton = 'noHub.flashFirmwareButton',
- NoHubTroubleshootButton = 'noHub.troubleshootButton',
- OldFirmwareMessage = 'oldFirmware.message',
- OldFirmwareFlashFirmwareLabel = 'oldFirmware.flashFirmware.label',
-}
diff --git a/src/components/HelpButton.tsx b/src/components/HelpButton.tsx
index 8235a7e6d..ca9a55364 100644
--- a/src/components/HelpButton.tsx
+++ b/src/components/HelpButton.tsx
@@ -7,7 +7,7 @@ import { OverlayContainer } from 'react-aria';
import { useBoolean } from 'usehooks-ts';
import { Button } from './Button';
import HelpDialog from './HelpDialog';
-import { I18nId, useI18n } from './i18n';
+import { useI18n } from './i18n';
type HelpButtonProps = {
/** The label of the control this button provides help for. */
@@ -49,9 +49,9 @@ const HelpButton: React.VoidFunctionComponent = ({
return (
<>
diff --git a/src/explorer/newFileWizard/i18n.test.ts b/src/explorer/newFileWizard/i18n.test.ts
deleted file mode 100644
index e706ba281..000000000
--- a/src/explorer/newFileWizard/i18n.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-License-Identifier: MIT
-// Copyright (c) 2022 The Pybricks Authors
-
-import { lookup } from '../../../test';
-import { I18nId } from './i18n';
-import en from './translations/en.json';
-
-describe('Ensure .json file has matches for I18nId', () => {
- test.each(Object.values(I18nId))('%s', (id) => {
- expect(lookup(en, id)).toBeDefined();
- });
-});
diff --git a/src/explorer/newFileWizard/i18n.ts b/src/explorer/newFileWizard/i18n.ts
index d15943b94..eb8dc4868 100644
--- a/src/explorer/newFileWizard/i18n.ts
+++ b/src/explorer/newFileWizard/i18n.ts
@@ -1,16 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
-import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import type { TypedI18n } from '../../i18n';
+import type translations from './translations/en.json';
-export function useI18n(): I18n {
+export function useI18n(): TypedI18n {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
-
-export enum I18nId {
- Title = 'title',
- SmartHubLabel = 'smartHub.label',
- ActionCreate = 'action.create',
-}
diff --git a/src/explorer/renameFileDialog/RenameFileDialog.tsx b/src/explorer/renameFileDialog/RenameFileDialog.tsx
index 2b6459760..36ce76fd6 100644
--- a/src/explorer/renameFileDialog/RenameFileDialog.tsx
+++ b/src/explorer/renameFileDialog/RenameFileDialog.tsx
@@ -12,7 +12,7 @@ import {
import { useSelector } from '../../reducers';
import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup';
import { renameFileDialogDidAccept, renameFileDialogDidCancel } from './actions';
-import { I18nId, useI18n } from './i18n';
+import { useI18n } from './i18n';
const RenameFileDialog: React.VFC = () => {
const i18n = useI18n();
@@ -46,7 +46,7 @@ const RenameFileDialog: React.VFC = () => {
return (
},
)}
checked={includeProgram}
@@ -280,7 +278,7 @@ const ConfigureOptionsPanel: React.VoidFunctionComponent
}
@@ -294,7 +292,7 @@ const ConfigureOptionsPanel: React.VoidFunctionComponent
return {
button: i18n.translate(
hubHasBluetoothButton(hubType)
- ? I18nId.BootloaderPanelButtonBluetooth
- : I18nId.BootloaderPanelButtonPower,
+ ? 'bootloaderPanel.button.bluetooth'
+ : 'bootloaderPanel.button.power',
),
light: i18n.translate(
hubHasBluetoothButton(hubType)
- ? I18nId.BootloaderPanelLightBluetooth
- : I18nId.BootloaderPanelLightStatus,
+ ? 'bootloaderPanel.light.bluetooth'
+ : 'bootloaderPanel.light.status',
),
lightPattern: i18n.translate(
hubHasBluetoothButton(hubType)
- ? I18nId.BootloaderPanelLightPatternBluetooth
- : I18nId.BootloaderPanelLightPatternStatus,
+ ? 'bootloaderPanel.lightPattern.bluetooth'
+ : 'bootloaderPanel.lightPattern.status',
),
};
}, [i18n, hubType]);
return (
-
{i18n.translate(I18nId.BootloaderPanelInstruction1)}
+
{i18n.translate('bootloaderPanel.instruction1')}
{hubHasUSB(hubType) && (
- - {i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}
+ - {i18n.translate('bootloaderPanel.step.disconnectUsb')}
)}
- - {i18n.translate(I18nId.BootloaderPanelStepPowerOff)}
+ - {i18n.translate('bootloaderPanel.step.powerOff')}
{/* City hub has power issues and requires disconnecting motors/sensors */}
{hubType === Hub.City && (
- - {i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}
+ - {i18n.translate('bootloaderPanel.step.disconnectIo')}
)}
- -
- {i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })}
-
+ - {i18n.translate('bootloaderPanel.step.holdButton', { button })}
{hubHasUSB(hubType) && (
- - {i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}
+ - {i18n.translate('bootloaderPanel.step.connectUsb')}
)}
-
- {i18n.translate(I18nId.BootloaderPanelStepWaitForLight, {
+ {i18n.translate('bootloaderPanel.step.waitForLight', {
button,
light,
lightPattern,
@@ -383,8 +379,8 @@ const BootloaderModePanel: React.VoidFunctionComponent
{i18n.translate(
/* hubs with USB will keep the power on, but other hubs won't */
hubHasUSB(hubType)
- ? I18nId.BootloaderPanelStepReleaseButton
- : I18nId.BootloaderPanelStepKeepHolding,
+ ? 'bootloaderPanel.step.releaseButton'
+ : 'bootloaderPanel.step.keepHolding',
{
button,
},
@@ -392,11 +388,9 @@ const BootloaderModePanel: React.VoidFunctionComponent
- {i18n.translate(I18nId.BootloaderPanelInstruction2, {
+ {i18n.translate('bootloaderPanel.instruction2', {
flashFirmware: (
-
- {i18n.translate(I18nId.FlashFirmwareButtonLabel)}
-
+ {i18n.translate('flashFirmwareButton.label')}
),
})}
@@ -419,11 +413,11 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
return (
dispatch(firmwareInstallPybricksDialogCancel())}
finalButtonProps={{
- text: i18n.translate(I18nId.FlashFirmwareButtonLabel),
+ text: i18n.translate('flashFirmwareButton.label'),
onClick: () =>
dispatch(
firmwareInstallPybricksDialogAccept(
@@ -437,13 +431,13 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
>
}
- nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
+ nextButtonProps={{ text: i18n.translate('nextButton.label') }}
/>
{
onLicenseAcceptedChanged={setLicenseAccepted}
/>
}
- backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
+ backButtonProps={{ text: i18n.translate('backButton.label') }}
nextButtonProps={{
disabled: !licenseAccepted,
- text: i18n.translate(I18nId.NextButtonLabel),
+ text: i18n.translate('nextButton.label'),
}}
/>
{
onChangeSelectedIncludeFile={setSelectedIncludeFile}
/>
}
- backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
- nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
+ backButtonProps={{ text: i18n.translate('backButton.label') }}
+ nextButtonProps={{ text: i18n.translate('nextButton.label') }}
/>
}
- backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
+ backButtonProps={{ text: i18n.translate('backButton.label') }}
/>
);
diff --git a/src/firmware/installPybricksDialog/i18n.en.test.ts b/src/firmware/installPybricksDialog/i18n.en.test.ts
deleted file mode 100644
index 3b098b20f..000000000
--- a/src/firmware/installPybricksDialog/i18n.en.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-License-Identifier: MIT
-// Copyright (c) 2021-2022 The Pybricks Authors
-
-import { lookup } from '../../../test';
-import { I18nId } from './i18n';
-import en from './translations/en.json';
-
-describe('Ensure .json file has matches for I18nId', () => {
- test.each(Object.values(I18nId))('%s', (id) => {
- expect(lookup(en, id)).toBeDefined();
- });
-});
diff --git a/src/firmware/installPybricksDialog/i18n.ts b/src/firmware/installPybricksDialog/i18n.ts
index d7a314713..eb8dc4868 100644
--- a/src/firmware/installPybricksDialog/i18n.ts
+++ b/src/firmware/installPybricksDialog/i18n.ts
@@ -1,63 +1,12 @@
// SPDX-License-Identifier: MIT
-// Copyright (c) 2021-2022 The Pybricks Authors
-//
-// Settings translation keys.
+// Copyright (c) 2022 The Pybricks Authors
-import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import type { TypedI18n } from '../../i18n';
+import type translations from './translations/en.json';
-export function useI18n(): I18n {
+export function useI18n(): TypedI18n
{
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
-
-export enum I18nId {
- Title = 'title',
- SelectHubPanelTitle = 'selectHubPanel.title',
- SelectHubPanelMessage = 'selectHubPanel.message',
- SelectHubPanelNotOnListButtonLabel = 'selectHubPanel.notOnListButton.label',
- SelectHubPanelNotOnListButtonInfoMindstormsTitle = 'selectHubPanel.notOnListButton.info.mindstorms.title',
- SelectHubPanelNotOnListButtonInfoMindstormsRcx = 'selectHubPanel.notOnListButton.info.mindstorms.rcx',
- SelectHubPanelNotOnListButtonInfoMindstormsNxt = 'selectHubPanel.notOnListButton.info.mindstorms.nxt',
- SelectHubPanelNotOnListButtonInfoMindstormsEv3 = 'selectHubPanel.notOnListButton.info.mindstorms.ev3',
- SelectHubPanelNotOnListButtonInfoPoweredUpTitle = 'selectHubPanel.notOnListButton.info.poweredUp.title',
- SelectHubPanelNotOnListButtonInfoPoweredUpWedo2 = 'selectHubPanel.notOnListButton.info.poweredUp.wedo2',
- SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain = 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain',
- SelectHubPanelNotOnListButtonInfoPoweredUpMario = 'selectHubPanel.notOnListButton.info.poweredUp.mario',
- SelectHubPanelNotOnListButtonInfoPoweredUpFootnote = 'selectHubPanel.notOnListButton.info.poweredUp.footnote',
- LicensePanelTitle = 'licensePanel.title',
- LicensePanelLicenseTextError = 'licensePanel.licenseText.error',
- LicensePanelAcceptCheckboxLabel = 'licensePanel.acceptCheckbox.label',
- OptionsPanelTitle = 'optionsPanel.title',
- OptionsPanelHubNameLabel = 'optionsPanel.hubName.label',
- OptionsPanelHubNameLabelInfo = 'optionsPanel.hubName.labelInfo',
- OptionsPanelHubNameHelp = 'optionsPanel.hubName.help',
- OptionsPanelHubNameError = 'optionsPanel.hubName.error',
- OptionsPanelCustomMainLabel = 'optionsPanel.customMain.label',
- OptionsPanelCustomMainLabelInfo = 'optionsPanel.customMain.labelInfo',
- OptionsPanelCustomMainNotApplicableMessage = 'optionsPanel.customMain.notApplicable.message',
- OptionsPanelCustomMainIncludeLabel = 'optionsPanel.customMain.include.label',
- OptionsPanelCustomMainIncludeNoSelection = 'optionsPanel.customMain.include.noSelection',
- OptionsPanelCustomMainIncludeNoFiles = 'optionsPanel.customMain.include.noFiles',
- OptionsPanelCustomMainIncludeHelp = 'optionsPanel.customMain.include.help',
- BootloaderPanelTitle = 'bootloaderPanel.title',
- BootloaderPanelInstruction1 = 'bootloaderPanel.instruction1',
- BootloaderPanelButtonBluetooth = 'bootloaderPanel.button.bluetooth',
- BootloaderPanelButtonPower = 'bootloaderPanel.button.power',
- BootloaderPanelLightBluetooth = 'bootloaderPanel.light.bluetooth',
- BootloaderPanelLightStatus = 'bootloaderPanel.light.status',
- BootloaderPanelLightPatternBluetooth = 'bootloaderPanel.lightPattern.bluetooth',
- BootloaderPanelLightPatternStatus = 'bootloaderPanel.lightPattern.status',
- BootloaderPanelStepDisconnectUsb = 'bootloaderPanel.step.disconnectUsb',
- BootloaderPanelStepPowerOff = 'bootloaderPanel.step.powerOff',
- BootloaderPanelStepDisconnectIo = 'bootloaderPanel.step.disconnectIo',
- BootloaderPanelStepHoldButton = 'bootloaderPanel.step.holdButton',
- BootloaderPanelStepConnectUsb = 'bootloaderPanel.step.connectUsb',
- BootloaderPanelStepWaitForLight = 'bootloaderPanel.step.waitForLight',
- BootloaderPanelStepReleaseButton = 'bootloaderPanel.step.releaseButton',
- BootloaderPanelStepKeepHolding = 'bootloaderPanel.step.keepHolding',
- BootloaderPanelInstruction2 = 'bootloaderPanel.instruction2',
- NextButtonLabel = 'nextButton.label',
- BackButtonLabel = 'backButton.label',
- FlashFirmwareButtonLabel = 'flashFirmwareButton.label',
-}
diff --git a/src/i18n.ts b/src/i18n.ts
index 2731a64e3..b8171bd99 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -1,7 +1,12 @@
// SPDX-License-Identifier: MIT
-// Copyright (c) 2020-2021 The Pybricks Authors
+// Copyright (c) 2020-2022 The Pybricks Authors
-import { I18nManager } from '@shopify/react-i18n';
+import { I18n, I18nManager, TranslateOptions } from '@shopify/react-i18n';
+import type {
+ ComplexReplacementDictionary,
+ PrimitiveReplacementDictionary,
+ TranslationDictionary,
+} from '@shopify/react-i18n/build/ts/types';
// TODO: add locale setting and use browser preferred language as default
@@ -15,3 +20,61 @@ export const i18nManager = new I18nManager({
export function pseudolocalize(pseudolocalize: boolean): void {
i18nManager.update({ ...i18nManager.details, pseudolocalize });
}
+
+// brilliant magic to turn nested json keys into types
+// https://newbedev.com/typescript-deep-keyof-of-a-nested-object
+
+type Join = K extends string | number
+ ? P extends string | number
+ ? `${K}${'' extends P ? '' : '.'}${P}`
+ : never
+ : never;
+
+// prettier-ignore
+type Prev = [
+ never,
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
+ ...0[],
+];
+
+type Paths = [D] extends [never]
+ ? never
+ : T extends object
+ ? {
+ [K in keyof T]-?: K extends string | number
+ ? `${K}` | Join>
+ : never;
+ }[keyof T]
+ : '';
+
+/**
+ * Interface for {@link I18n} that provides strong type checking for
+ * translations ids.
+ *
+ * @typeParam T - The type of the translation json file.
+ */
+export type TypedI18n = Omit<
+ I18n,
+ 'translate' | 'getTranslationTree' | 'translationKeyExists'
+> & {
+ translate(
+ id: Paths,
+ options: TranslateOptions,
+ replacements?: PrimitiveReplacementDictionary,
+ ): string;
+ translate(
+ id: Paths,
+ options: TranslateOptions,
+ replacements?: ComplexReplacementDictionary,
+ ): React.ReactElement;
+ translate(id: Paths, replacements?: PrimitiveReplacementDictionary): string;
+ translate(
+ id: Paths,
+ replacements?: ComplexReplacementDictionary,
+ ): React.ReactElement;
+ getTranslationTree(
+ id: Paths,
+ replacements?: PrimitiveReplacementDictionary | ComplexReplacementDictionary,
+ ): string | TranslationDictionary;
+ translationKeyExists(id: Paths): boolean;
+};
diff --git a/src/licenses/LicenseDialog.tsx b/src/licenses/LicenseDialog.tsx
index ca6805510..aaa851579 100644
--- a/src/licenses/LicenseDialog.tsx
+++ b/src/licenses/LicenseDialog.tsx
@@ -21,7 +21,7 @@ import React, { useCallback, useState } from 'react';
import { mergeProps, useFocusRing, useListBox, useOption } from 'react-aria';
import { useFetch } from 'usehooks-ts';
import { appName } from '../app/constants';
-import { I18nId, useI18n } from './i18n';
+import { useI18n } from './i18n';
interface LicenseInfo {
readonly name: string;
@@ -158,11 +158,11 @@ const LicenseListPanel: React.VoidFunctionComponent = ({
{data === undefined ? (
- {error ? i18n.translate(I18nId.ErrorFetchFailed) : }
+ {error ? i18n.translate('error.fetchFailed') : }
) : (
{licenseInfo === undefined ? (
- {i18n.translate(I18nId.SelectPackageHelp)}
+ {i18n.translate('help.selectPackage')}
) : (
- {i18n.translate(I18nId.PackageLabel)}{' '}
+ {i18n.translate('packageLabel')}{' '}
{licenseInfo.name}{' '}
v{licenseInfo.version}
@@ -201,14 +201,12 @@ const LicenseInfoPanel = React.forwardRef
{licenseInfo.author && (
-
- {i18n.translate(I18nId.AuthorLabel)}
- {' '}
+ {i18n.translate('authorLabel')}{' '}
{licenseInfo.author}
)}
- {i18n.translate(I18nId.LicenseLabel)}{' '}
+ {i18n.translate('licenseLabel')}{' '}
{licenseInfo.license}
@@ -240,13 +238,13 @@ const LicenseDialog: React.VoidFunctionComponent
= ({
return (