Skip to content

Commit 3369800

Browse files
authored
fix: ko language falls back to ko-KR (#2102)
* test: test i18n languages and fallbacks * test(i18n): use available port * chore: add all local files for ko-KR english has 8 locale files: `ls -lhatr public/locales/en/*.json | wc -l # 8` before this change, ko-KR only had 4 `ls -lhatr public/locales/ko-KR/*.json | wc -l # 4` so I copied them over using `cp -n public/locales/en/*.json public/locales/ko-KR/` after this change `ls -lhatr public/locales/ko-KR/*.json | wc -l # 8` * chore(i18n): ensure app:actions.add has ko translation * fix: only send i18n requests for current language Sends only a single request for lang via i18n-http-backend see i18next/i18next-http-backend#61 * fix: current language displays correctly for fallbacks * test(lib/i18n): test getLanguage function * test(lib/i18n): add getCurrentLanguage test * test(i18n): add test for naming languages in languages.json * fix(i18n): add parser for getting valid locale codes * fix(lib/i18n): use i18n-localeParser * fix(i18n): prevent the lookup of invalid locales fixes #2097 * test(e2e:settings): test language selector * test(e2e/settings): validate that language files are requested * chore: remove untranslated ko-KR files
1 parent cbabac3 commit 3369800

File tree

12 files changed

+459
-24
lines changed

12 files changed

+459
-24
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
"@svgr/cli": "^5.4.0",
141141
"@types/esm": "^3.2.0",
142142
"@types/jest": "^29.4.0",
143-
"@types/node": "^14.0.27",
143+
"@types/node": "^14.18.36",
144144
"@types/path-browserify": "^1.0.0",
145145
"@typescript-eslint/eslint-plugin": "^5.30.7",
146146
"@typescript-eslint/parser": "^5.30.7",

public/locales/ko-KR/app.json

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"actions": {
3+
"add": "추가하다",
4+
"apply": "Apply",
5+
"browse": "Browse",
6+
"cancel": "Cancel",
7+
"change": "Change",
8+
"clear": "Clear",
9+
"close": "Close",
10+
"copy": "Copy",
11+
"create": "Create",
12+
"remove": "Remove",
13+
"download": "Download",
14+
"edit": "Edit",
15+
"import": "Import",
16+
"inspect": "Inspect",
17+
"more": "More",
18+
"moreInfo": "More info",
19+
"noThanks": "No thanks",
20+
"ok": "OK",
21+
"pinVerb": "Pin",
22+
"rename": "Rename",
23+
"reset": "Reset",
24+
"save": "Save",
25+
"saving": "Saving…",
26+
"selectAll": "Select all",
27+
"setPinning": "Set pinning",
28+
"submit": "Submit",
29+
"unpin": "Unpin",
30+
"unselectAll": "Unselect all",
31+
"generate": "Generate",
32+
"publish": "Publish",
33+
"downloadCar": "Download as CAR",
34+
"done": "Done"
35+
},
36+
"cliModal": {
37+
"description": "Paste the following into your terminal to do this task in IPFS via the command line. Remember that you'll need to replace placeholders with your specific parameters."
38+
},
39+
"nav": {
40+
"bugsLink": "Report a bug",
41+
"codeLink": "See the code"
42+
},
43+
"status": {
44+
"connectedToIpfs": "Connected to IPFS",
45+
"connectingToIpfs": "Connecting to IPFS…",
46+
"couldNotConnect": "Could not connect to the IPFS API"
47+
},
48+
"apiAddressForm": {
49+
"placeholder": "Enter a URL (http://127.0.0.1:5001) or a Multiaddr (/ip4/127.0.0.1/tcp/5001)"
50+
},
51+
"publicGatewayForm": {
52+
"placeholder": "Enter a URL (https://dweb.link)"
53+
},
54+
"terms": {
55+
"address": "Address",
56+
"addresses": "Addresses",
57+
"advanced": "Advanced",
58+
"agent": "Agent",
59+
"api": "API",
60+
"apiAddress": "API address",
61+
"blocks": "Blocks",
62+
"connection": "Connection",
63+
"downSpeed": "Incoming",
64+
"example": "Example:",
65+
"file": "File",
66+
"files": "Files",
67+
"folder": "Folder",
68+
"folders": "Folders",
69+
"gateway": "Gateway",
70+
"in": "In",
71+
"latency": "Latency",
72+
"loading": "Loading",
73+
"location": "Location",
74+
"name": "Name",
75+
"node": "Node",
76+
"out": "Out",
77+
"peer": "Peer",
78+
"peerId": "Peer ID",
79+
"id": "ID",
80+
"peers": "Peers",
81+
"pinNoun": "Pin",
82+
"pins": "Pins",
83+
"pinStatus": "Pin Status",
84+
"publicKey": "Public key",
85+
"publicGateway": "Public Gateway",
86+
"rateIn": "Rate in",
87+
"rateOut": "Rate out",
88+
"repo": "Repo",
89+
"size": "Size",
90+
"totalIn": "Total in",
91+
"totalOut": "Total out",
92+
"unknown": "Unknown",
93+
"ui": "UI",
94+
"upSpeed": "Outgoing",
95+
"revision": "Revision"
96+
},
97+
"tour": {
98+
"back": "Back",
99+
"close": "Close",
100+
"finish": "Finish",
101+
"next": "Next",
102+
"skip": "Skip",
103+
"tooltip": "Click this button any time for a guided tour on the current page."
104+
},
105+
"startTourHelper": "Start tour"
106+
}

src/components/language-selector/LanguageSelector.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ class LanguageSelector extends Component {
1919
return (
2020
<Fragment>
2121
<div className='flex'>
22-
<div className='pr4 flex items-center lh-copy charcoal f5 fw5' style={{ height: 40 }}>
22+
<div className='pr4 flex items-center lh-copy charcoal f5 fw5 e2e-languageSelector-current' style={{ height: 40 }}>
2323
{getCurrentLanguage()}
2424
</div>
25-
<Button className="tc" bg='bg-teal' minWidth={100} onClick={this.onLanguageEditOpen}>
25+
<Button className="tc e2e-languageSelector-changeBtn" bg='bg-teal' minWidth={100} onClick={this.onLanguageEditOpen}>
2626
{t('app:actions.change')}
2727
</Button>
2828
</div>
2929

3030
<Overlay show={this.state.isLanguageModalOpen} onLeave={this.onLanguageEditClose} >
31-
<LanguageModal className='outline-0' onLeave={this.onLanguageEditClose} t={t} />
31+
<LanguageModal className='outline-0 e2e-languageModal' onLeave={this.onLanguageEditClose} t={t} />
3232
</Overlay>
3333
</Fragment>
3434
)

src/components/language-selector/language-modal/LanguageModal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const LanguageModal = ({ t, tReady, onLeave, link, className, isIpfsDesktop, doD
2525
{ localesList.map((lang) =>
2626
<button
2727
key={`lang-${lang.locale}`}
28-
className='pa2 w-33 flex nowrap bg-transparent bn outline-0 blue justify-center'
28+
className={`pa2 w-33 flex nowrap bg-transparent bn outline-0 blue justify-center e2e-languageModal-lang e2e-languageModal-lang_${lang.locale}`}
2929
onClick={() => handleClick(lang.locale)}>
3030
{ lang.nativeName }
3131
</button>

src/i18n.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import LanguageDetector from 'i18next-browser-languagedetector'
77
import pkgJson from '../package.json'
88

99
import locales from './lib/languages.json'
10+
import getValidLocaleCode from './lib/i18n-localeParser.js'
1011

1112
const { version } = pkgJson
1213
export const localesList = Object.values(locales)
@@ -16,6 +17,7 @@ i18n
1617
.use(Backend)
1718
.use(LanguageDetector)
1819
.init({
20+
load: 'currentOnly', // see https://github.com/i18next/i18next-http-backend/issues/61
1921
backend: {
2022
backends: [
2123
LocalStorageBackend,
@@ -27,8 +29,11 @@ i18n
2729
expirationTime: (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') ? 1 : 7 * 24 * 60 * 60 * 1000
2830
},
2931
{ // HttpBackend
30-
// ensure a relative path is used to look up the locales, so it works when loaded from /ipfs/<cid>
31-
loadPath: 'locales/{{lng}}/{{ns}}.json'
32+
loadPath: (lngs, namespaces) => {
33+
const locale = getValidLocaleCode({ i18n, localeCode: lngs[0], languages: locales })
34+
// ensure a relative path is used to look up the locales, so it works when loaded from /ipfs/<cid>
35+
return `locales/${locale}/${namespaces}.json`
36+
}
3237
}
3338
]
3439
},

src/i18n.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/* global describe, it, expect, beforeAll, afterAll */
2+
// @ts-check
3+
import { createServer } from 'http-server'
4+
import i18n, { localesList } from './i18n.js'
5+
import getPort from 'get-port'
6+
7+
const backendListenerPort = await getPort({ port: getPort.makeRange(3000, 4000) })
8+
9+
const allLanguages = localesList.map(({ locale }) => locale)
10+
11+
/**
12+
* @type {import('http-server').HTTPServer}
13+
*/
14+
let httpServer
15+
beforeAll(async function () {
16+
httpServer = createServer({
17+
root: './public',
18+
cors: true
19+
})
20+
await httpServer.listen(backendListenerPort)
21+
22+
// initialize i18n
23+
await i18n.init({
24+
backend: {
25+
...i18n.options?.backend,
26+
backendOptions: [
27+
i18n.options?.backend?.backendOptions?.[0],
28+
{
29+
loadPath: `http://localhost:${backendListenerPort}/locales/{{lng}}/{{ns}}.json`
30+
}
31+
]
32+
}
33+
})
34+
})
35+
36+
afterAll(async function () {
37+
await httpServer.close()
38+
})
39+
describe('i18n', function () {
40+
it('should have a default language', function () {
41+
expect(i18n.language).toBe('en-US')
42+
expect(i18n.isInitialized).toBe(true)
43+
})
44+
45+
it('should return key for non-existent language', function () {
46+
expect(i18n.t('app:actions.add', { lng: 'xx' })).toBe('actions.add')
47+
})
48+
49+
allLanguages.concat('ko').forEach((lang) => {
50+
describe(`lang=${lang}`, function () {
51+
it(`should be able to switch to ${lang}`, async function () {
52+
await i18n.changeLanguage(lang)
53+
54+
expect(i18n.language).toBe(lang)
55+
})
56+
57+
it(`should have a key for ${lang}`, async function () {
58+
// key and namespace that don't exist return the key without the leading namespace
59+
expect(await i18n.t('someNs:that.doesnt.exist', { lng: lang })).toBe('that.doesnt.exist')
60+
// missing key on existing namespace returns that key
61+
expect(await i18n.t('app:that.doesnt.exist', { lng: lang })).toBe('that.doesnt.exist')
62+
const langResult = await i18n.t('app:actions.add', { lng: lang })
63+
expect(langResult).not.toBe('actions.add')
64+
})
65+
})
66+
})
67+
68+
describe('fallback languages', function () {
69+
/**
70+
* @type {import('i18next').FallbackLngObjList}
71+
*/
72+
const fallbackLanguages = /** @type {import('i18next').FallbackLngObjList} */(i18n.options.fallbackLng)
73+
for (const lng in fallbackLanguages) {
74+
if (lng === 'default') {
75+
continue
76+
}
77+
const fallbackArr = fallbackLanguages[lng]
78+
fallbackArr.forEach((fallbackLang) => {
79+
it(`fallback '${fallbackLang}' (for '${lng}') is valid`, async function () {
80+
expect(allLanguages).toContain(fallbackLang)
81+
})
82+
})
83+
it(`language ${lng} should fallback to ${fallbackArr[0]}`, async function () {
84+
const result = await i18n.t('app:actions.add', { lng })
85+
const englishResult = await i18n.t('app:actions.add', { lng: 'en' })
86+
const fallbackResult = await i18n.t('app:actions.add', { lng: fallbackArr[0] })
87+
expect(result).toBe(fallbackResult)
88+
expect(result).not.toBe(englishResult)
89+
})
90+
}
91+
})
92+
})

src/lib/i18n-localeParser.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
*
3+
* @param {object} options
4+
* @param {import('i18next').i18n} options.i18n
5+
* @param {string} options.localeCode
6+
* @param {Record<string, { locale: string, nativeName: string, englishName: string }>} options.languages
7+
*
8+
* @returns {string}
9+
*/
10+
export default function getValidLocaleCode ({ i18n, localeCode, languages }) {
11+
const info = languages[localeCode]
12+
13+
if (info != null) {
14+
return localeCode
15+
}
16+
17+
const fallbackLanguages = i18n.options.fallbackLng[localeCode]
18+
if (info == null && fallbackLanguages != null) {
19+
/**
20+
* check fallback languages before attempting to split a 'lang-COUNTRY' code
21+
* fixed issue with displaying 'English' when i18nLng is set to 'ko'
22+
* discovered when looking into https://github.com/ipfs/ipfs-webui/issues/2097
23+
*/
24+
const fallback = fallbackLanguages
25+
for (const locale of fallback) {
26+
const fallbackInfo = languages[locale]
27+
28+
if (fallbackInfo != null) {
29+
return fallbackInfo.locale
30+
}
31+
}
32+
}
33+
34+
// if we haven't got the info in the `languages.json` we split it to get the language
35+
const langOnly = localeCode.split('-')[0]
36+
if (languages[langOnly]) {
37+
return langOnly
38+
}
39+
// if the provided localeCode doesn't have country, but we have a supported language for a specific country, we return that
40+
const langWithCountry = Object.keys(languages).find((key) => key.startsWith(localeCode))
41+
if (langWithCountry) {
42+
return langWithCountry
43+
}
44+
45+
return 'en'
46+
}

0 commit comments

Comments
 (0)