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

Add language localization support #141

Merged
merged 4 commits into from
Jan 24, 2024
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"test": "craco test",
"test:e2e--open": "cypress open",
"test:e2e": "cypress run",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"generate-locales": "node scripts/generate-locales.mjs"
},
"eslintConfig": {
"extends": [
Expand Down
113 changes: 113 additions & 0 deletions scripts/generate-locales.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import parser from '@babel/parser'
import traverse from '@babel/traverse'
import * as types from '@babel/types'
import fs from 'fs/promises'
import globby from 'globby'
import path, { resolve } from 'path'
import prettier from 'prettier'

const __dirname = path.dirname(new URL(import.meta.url).pathname)
const ProjectSourceRoot = path.resolve(__dirname, '..')

async function formatJSON(jsonContent) {
const prettierConfig = JSON.parse(
await fs.readFile(path.resolve(ProjectSourceRoot, '.prettierrc.json'))
)
return prettier.format(JSON.stringify(jsonContent), {
...prettierConfig,
parser: 'json',
})
}

async function getMessages(filePath) {
const content = await fs.readFile(filePath, 'utf8')
const plugins = ['typescript', 'decorators-legacy']
if (filePath.endsWith('tsx')) {
plugins.push('jsx')
}
const result = []

try {
const ast = parser.parse(content, {
plugins: plugins,
sourceType: 'unambiguous',
})
traverse.default(ast, {
enter(path) {
const node = path.node
if (types.isCallExpression(node)) {
const callee = node.callee
if (
types.isIdentifier(callee) &&
callee.name === 'localize'
) {
const args = node.arguments.map((o) => {
if (types.isStringLiteral(o)) {
return o.value
}
})
const [key, message] = args
result.push([key, message])
}
}
},
})
} catch (error) {
console.log(filePath)
}

return result
}

async function generate() {
const sourceCode = await globby(['**/**.ts', '**/**.tsx'], {
absolute: true,
cwd: resolve(ProjectSourceRoot, 'src'),
})
const localsPath = resolve(ProjectSourceRoot, 'src/locales')
const files = (await fs.readdir(localsPath)).filter((p) =>
p.endsWith('.json')
)
const enUSMessages = {}
for (const iterator of sourceCode) {
const messages = await getMessages(iterator)
messages.forEach(([key, message]) => {
if (enUSMessages[key]) {
if (enUSMessages[key] !== message) {
throw new Error(key)
}
} else {
enUSMessages[key] = message
}
})
}

const sortedKeys = Object.keys(enUSMessages).sort((a, b) =>
a.localeCompare(b)
)

files
.filter((file) => path.extname(file) === '.json')
.filter((file) => file !== 'en-US.json')
.map((file) => path.resolve(localsPath, file))
.forEach(async (file) => {
const messages = JSON.parse(await fs.readFile(file, 'utf-8'))
const result = {}
sortedKeys.forEach((key) => {
result[key] = messages[key] || ''
})
await fs.writeFile(file, await formatJSON(result))
})

const sortedData = {}
sortedKeys.forEach((key) => {
sortedData[key] = enUSMessages[key]
})

await fs.writeFile(
path.resolve(localsPath, 'en-US.json'),
await formatJSON(sortedData)
)
}

generate()
10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConfigProvider } from 'antd'
import { Component } from 'react'
import {
ThemeSwitcherProvider,
Expand All @@ -11,6 +12,7 @@ import Login from './containers/Login'
import PageRoot from './containers/PageRoot'
import reducers from './redux/reducers'
import CrashReporter from './utils/CrashReporter'
import { currentLanguageOption } from './utils/Language'
import StorageHelper from './utils/StorageHelper'

CrashReporter.getInstance().init()
Expand Down Expand Up @@ -61,9 +63,11 @@ class App extends Component<{}, AppState> {
defaultTheme={this.state.isDarkMode ? 'dark' : 'light'}
insertionPoint="styles-insertion-point"
>
<Provider store={store}>
<MainComponent />
</Provider>
<ConfigProvider locale={currentLanguageOption.antdLocale}>
<Provider store={store}>
<MainComponent />
</Provider>
</ConfigProvider>
</ThemeSwitcherProvider>
)
}
Expand Down
54 changes: 46 additions & 8 deletions src/containers/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { LockOutlined } from '@ant-design/icons'
import { Button, Card, Collapse, Input, Radio, Row } from 'antd'
import { Button, Card, Collapse, Input, Radio, Row, Select } from 'antd'
import React, { ReactComponentElement } from 'react'
import { Redirect, RouteComponentProps } from 'react-router'
import ApiManager from '../api/ApiManager'
import ErrorFactory from '../utils/ErrorFactory'
import {
currentLanguageOption,
languagesOptions,
localize,
} from '../utils/Language'
import StorageHelper from '../utils/StorageHelper'
import Toaster from '../utils/Toaster'
import Utils from '../utils/Utils'
Expand Down Expand Up @@ -76,7 +81,25 @@ export default class Login extends ApiComponent<RouteComponentProps<any>, any> {
transform: 'translate(-50%,-50%)',
}}
>
<Card title="CapRover Login" style={{ width: 380 }}>
<Card
title={localize(
'login_form.cap_rover',
'CapRover Login'
)}
style={{ width: 380 }}
extra={
<Select
options={languagesOptions}
value={currentLanguageOption.value}
onChange={(value) => {
StorageHelper.setLanguageInLocalStorage(
value
)
window.location.reload()
}}
/>
}
>
<NormalLoginForm
onLoginRequested={(
password: string,
Expand Down Expand Up @@ -154,7 +177,7 @@ class NormalLoginForm extends React.Component<
onChange={(e) => {
self.setState({ passwordEntered: `${e.target.value}` })
}}
placeholder="Password"
placeholder={localize('login_form.password', 'Password')}
autoFocus
/>
{self.props.hasOtp ? (
Expand Down Expand Up @@ -193,12 +216,18 @@ class NormalLoginForm extends React.Component<
htmlType="submit"
className="login-form-button"
>
Login
{localize('login_form.login', 'Login')}
</Button>
</Row>
</div>
<Collapse>
<Collapse.Panel header="Remember Me" key="1">
<Collapse.Panel
header={localize(
'login_form.remember_me',
'Remember Me'
)}
key="1"
>
<Radio.Group
onChange={(e) => {
console.log(e.target.value)
Expand All @@ -209,13 +238,22 @@ class NormalLoginForm extends React.Component<
value={self.state.loginOption}
>
<Radio style={radioStyle} value={NO_SESSION}>
No session persistence (Most Secure)
{localize(
'login_form.no_session_persistence',
'No session persistence (Most Secure)'
)}
</Radio>
<Radio style={radioStyle} value={SESSION_STORAGE}>
Use sessionStorage
{localize(
'login_form.use_session_storage',
'Use sessionStorage'
)}
</Radio>
<Radio style={radioStyle} value={LOCAL_STORAGE}>
Use localStorage (Most Persistent)
{localize(
'login_form.use_local_storage',
'Use localStorage (Most Persistent)'
)}
</Radio>
</Radio.Group>
</Collapse.Panel>
Expand Down
9 changes: 9 additions & 0 deletions src/locales/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"login_form.cap_rover": "CapRover Login",
"login_form.login": "Login",
"login_form.no_session_persistence": "No session persistence (Most Secure)",
"login_form.password": "Password",
"login_form.remember_me": "Remember Me",
"login_form.use_local_storage": "Use localStorage (Most Persistent)",
"login_form.use_session_storage": "Use sessionStorage"
}
9 changes: 9 additions & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"login_form.cap_rover": "CapRover 登录",
"login_form.login": "登录",
"login_form.no_session_persistence": "不储存会话(最安全)",
"login_form.password": "密码",
"login_form.remember_me": "记住我",
"login_form.use_local_storage": "使用 LocalStorage 存储(最持久)",
"login_form.use_session_storage": "使用 SessionStorage 存储"
}
48 changes: 48 additions & 0 deletions src/utils/Language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Locale } from 'antd/es/locale-provider'
import enUS from 'antd/es/locale/en_US'
import zhCN from 'antd/es/locale/zh_CN'
import enUSMessages from '../locales/en-US.json'
import zhCNMessages from '../locales/zh-CN.json'

import StorageHelper from './StorageHelper'

export interface LanguageOption {
label: string
value: string
alias?: string[]
antdLocale: Locale
messages: Record<string, string>
}

const languagesOptions: LanguageOption[] = [
// en-US should be the first option
{
label: 'English',
value: 'en-US',
alias: ['en'],
antdLocale: enUS,
messages: enUSMessages,
},
{
label: '简体中文',
value: 'zh-CN',
alias: ['zh'],
antdLocale: zhCN,
messages: zhCNMessages,
},
]

const defaultLanguageOptions = languagesOptions[0]

const currentLanguage = StorageHelper.getLanguageFromLocalStorage()

const currentLanguageOption: LanguageOption =
languagesOptions.find((o) => {
return o.value === currentLanguage || o.alias?.includes(currentLanguage)
}) || defaultLanguageOptions

export function localize(key: string, message: string) {
return currentLanguageOption.messages[key] || message
}

export { currentLanguageOption, languagesOptions }
11 changes: 11 additions & 0 deletions src/utils/StorageHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const sessionStorage = window.sessionStorage
const AUTH_KEY = 'CAPROVER_AUTH_KEY'
const SIDER_COLLAPSED_STATE = 'CAPROVER_SIDER_COLLAPSED_STATE'
const DARK_MODE = 'CAPROVER_DARK_MODE'
const LANGUAGE = 'CAPROVER_LANGUAGE'

class StorageHelper {
getAuthKeyFromStorage() {
const localStorageAuth = localStorage.getItem(AUTH_KEY)
Expand Down Expand Up @@ -59,6 +61,15 @@ class StorageHelper {
: window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
}

setLanguageInLocalStorage(language: string) {
localStorage.setItem(LANGUAGE, language)
}

getLanguageFromLocalStorage(): string {
const language = localStorage.getItem(LANGUAGE)
return language ? language : navigator.language
}
}

const instance = new StorageHelper()
Expand Down
Loading