Tech stack: React, Next.js, next-i18next
import { appWithTranslation } from 'next-i18next';
const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />;
export default appWithTranslation(MyApp);
module.exports = {
i18n: {
defaultLocale: 'en',
locales: ['cn', 'ar', 'en', 'ms', 'id', 'vi', 'zh'],
localePath: './src/i18n/locales'
},
react: {
useSuspense: false
}
};
/** Files to modify locales
* next-i18next.config.js
* */
/** Please use ISO codes to name locales as possible
* https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
* */
import nextI18next from '~/next-i18next.config';
export type Locales = 'cn' | 'ar' | 'en' | 'ms' | 'id' | 'vi' | 'zh';
export const locales: Locales[] = nextI18next.i18n.locales as Locales[];
type localesOptions = {
code: Locales;
value: string;
};
export const localesOptions: localesOptions[] = [
{ code: 'cn', value: '简体' },
{ code: 'ar', value: 'بالعربية' },
{ code: 'en', value: 'English' },
{ code: 'ms', value: 'Malay' },
{ code: 'id', value: 'Indonesia' },
{ code: 'vi', value: 'Việt Nam' },
{ code: 'zh', value: '繁體' }
];
Because we wanna have type intelligence in our project, but next-i18next yet not support TypeScript, so we write our i18n files in TypeScript, and convert files to json ourselves.
I first wrote this in shell script, but shell commands are different in Linux, Windows and IOS, so I wrote JavaScript version then it's compatible in all above OS.
Basically, it coverts all ts files in src\i18n\localests to json files, and puts all files in src\i18n\locales, you can modify the folder name.
const des = 'locales';
const src = 'localests';
const outputFormat = '.json';
const desPath = `./${des}`;
const srcPath = `./${src}`;
const npmScript = 'format';
const {
promises: fs,
readdirSync,
rename,
readFile,
writeFile
} = require('fs-extra');
const { exec } = require('child_process');
/* eslint-disable */
(async () => {
// Remove old folder if exist
await fs.rmdir(desPath, { recursive: true }, (err) => {
if (err) return console.error(err);
});
console.log(`1. Remove ${des} folder if exist`);
// Copy folder exclude '@types'
await copyDir(srcPath, desPath, '@types');
console.log(`2. Copy folder`);
let filenames = readdirSync(desPath);
for (const file of filenames) {
const path = `${desPath}/${file}`;
let jsonFiles = readdirSync(path);
for (const jsonFile of jsonFiles) {
const jsonFilePath = `${path}/${jsonFile}`;
// Read file
readFile(jsonFilePath, (err, fileBuffer) => {
if (err) {
console.error(err.message);
}
const fileContent = fileBuffer.toString();
const contentWeWant = fileContent.match(/(?<= = {)(.*)[^}]*/);
const outputContent = `{${contentWeWant[0]}}`;
if (contentWeWant[0]) {
// Rewrite the file with content we want
writeFile(jsonFilePath, outputContent, (err) => {
if (err) {
return console.log(err);
} else {
const oldPath = jsonFilePath;
const newPath = jsonFilePath.replace(/\.[^.]+$/, outputFormat);
// Rename file from whatever to "outputFormat"
rename(oldPath, newPath, (err) => {
if (err) {
return console.log(err);
}
});
}
});
}
});
}
}
console.log('3. Rewrite/Rename file from whatever to .json');
exec(`npm run ${npmScript}`);
console.log('4. Format json');
})();
const path = require('path');
const copyDir = async (src, dest, escapeFolder = undefined) => {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (!entry.name.includes(escapeFolder)) {
entry.isDirectory()
? await copyDir(srcPath, destPath)
: await fs.copyFile(srcPath, destPath);
}
}
};
{
...
"scripts": {
...
"locales": "npx prettier --write ./src/i18n/**/*.json"
}
...
}
The points we should concern
First time or no data in cookie
Have data in cookie
Codes
import { locales, Locales, localesOptions } from '@/i18n/config';
import { getCookie, isUsingMobile, setCookie } from '@/utils';
import { ChevronDownIcon } from '@chakra-ui/icons';
import {
Button,
Image,
Menu,
MenuButton,
MenuItem,
MenuList,
Text
} from '@chakra-ui/react';
import { i18n } from 'next-i18next';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
const LangSelector: React.FC<{}> = () => {
useEffect(() => {
getCurrentLanguage();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const [language, setLanguage] = useState(i18n?.languages[0]);
const router = useRouter();
const currentLang = router.locale as Locales;
const getCurrentLanguage = () => {
const langInCookie = getCookie('lang') as Locales;
if (
langInCookie &&
locales.includes(langInCookie) &&
langInCookie !== currentLang
) {
handleSetLanguage(langInCookie);
} else {
let lang = i18n?.languages[0] as Locales;
// Check browser language manually
const isCN =
(window as any)?.navigator.languages.includes('zh-CN') ||
(window as any)?.navigator.languages.includes('zh-cn') ||
null;
if (isCN) {
lang = 'cn';
} else if (isUsingMobile()) {
// If it's a mobile, use substr(0, 2) to get lang ISO code, if it matches any of our app locales, set it as language
const matchLang = navigator.languages
.find((x) => x.substr(0, 2) === navigator.language.substr(0, 2))
.substr(0, 2) as Locales;
if (i18n?.languages.includes(matchLang)) {
lang = matchLang;
}
}
// to save locale in cookie
setCookie('lang', lang, 30);
}
};
const handleLanguageChange = (locale: Locales) => {
handleSetLanguage(locale);
};
const handleSetLanguage = (locale: Locales) => {
// to show option in select
setLanguage(locale);
// to change router locale
router.push(router.asPath, router.asPath, { locale: locale });
// to save locale in cookie
setCookie('lang', locale, 30);
};
return (
<Menu>
<MenuButton minW="90px" as={Button} rightIcon={<ChevronDownIcon />}>
<Image src={`../assets/images/${language}.png`} alt={language}></Image>
</MenuButton>
<MenuList minW={{ base: '90px', md: '150px' }} zIndex={3}>
{locales.map((locale: Locales) => (
<MenuItem
key={locale}
onClick={() => handleLanguageChange(locale)}
_hover={{
bgColor: '#CBD5E0'
}}
>
<Image
border="1px"
src={`../assets/images/${locale}.png`}
alt={locale}
></Image>
<Text ml={4} display={{ base: 'none', md: 'block' }}>
{localesOptions.find((x) => x.code === locale)?.value || locale}
</Text>
</MenuItem>
))}
</MenuList>
</Menu>
);
};
export default LangSelector;
export const isIOS = () => {
if (process.browser) {
return (
navigator.userAgent.match(/iPhone/i) ||
navigator.userAgent.match(/iPad/i) ||
navigator.userAgent.match(/iPod/i)
);
}
};
export const isUsingMobile = () => {
if (process.browser) {
return (
isIOS() ||
navigator.userAgent.match(/Android/i) ||
navigator.userAgent.match(/webOS/i) ||
navigator.userAgent.match(/BlackBerry/i) ||
navigator.userAgent.match(/Windows Phone/i)
);
}
};
export function setCookie(cname: string, cvalue: string, exdays = -1) {
const d = new Date();
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
const expires = 'expires=' + d.toUTCString();
document.cookie = cname + '=' + cvalue + ';' + expires + ';path=/';
}
export function getCookie(cname: string) {
const name = cname + '=';
const decodedCookie = decodeURIComponent(document.cookie);
const ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i += 1) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return '';
}
TypeScript magic!!
srouce
import IResources from '../src/i18n/localests/@types';
// react-i18next versions higher than 11.11.0
declare module 'react-i18next' {
// eslint-disable-next-line
interface CustomTypeOptions {
defaultNS: 'en';
resources: IResources;
}
}