From 7c2aa8c4171ccc8e60a3e382a8763f31c21bcc4c Mon Sep 17 00:00:00 2001 From: zhaozhiming Date: Sat, 14 Jan 2023 01:42:49 +0800 Subject: [PATCH] feat: add i18n in login page (#5947) * feat: add i18n in login page * fix: add word space and put the app name into the title * fix: remove duplicate replace title * fix: prettier format code * fix: fix typescript check warning * fix: add zh-cn locale file code owner * fix: use existing flag locale to the login page Co-authored-by: Joe Previte --- .github/CODEOWNERS | 2 ++ package.json | 1 + src/browser/pages/login.html | 8 ++++---- src/node/cli.ts | 9 ++++++++- src/node/i18n/index.ts | 21 +++++++++++++++++++++ src/node/i18n/locales/en.json | 13 +++++++++++++ src/node/i18n/locales/zh-cn.json | 13 +++++++++++++ src/node/routes/login.ts | 22 ++++++++++++++-------- test/unit/node/routes/login.test.ts | 11 +++++++++++ tsconfig.json | 3 ++- yarn.lock | 19 +++++++++++++++++++ 11 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 src/node/i18n/index.ts create mode 100644 src/node/i18n/locales/en.json create mode 100644 src/node/i18n/locales/zh-cn.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6b890c501..04718d97b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,3 +3,5 @@ ci/helm-chart/ @Matthew-Beckett @alexgorbatchev docs/install.md @GNUxeava + +src/node/i18n/locales/zh-cn.json @zhaozhiming diff --git a/package.json b/package.json index d9d10fc59..8d2c70c0a 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "express": "5.0.0-alpha.8", "http-proxy": "^1.18.0", "httpolyglot": "^0.1.2", + "i18next": "^22.4.6", "js-yaml": "^4.0.0", "limiter": "^1.1.5", "pem": "^1.14.2", diff --git a/src/browser/pages/login.html b/src/browser/pages/login.html index c10a599af..e7663cb78 100644 --- a/src/browser/pages/login.html +++ b/src/browser/pages/login.html @@ -10,7 +10,7 @@ http-equiv="Content-Security-Policy" content="style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:; font-src 'self' data:;" /> - {{APP_NAME}} login + {{I18N_LOGIN_TITLE}} @@ -25,7 +25,7 @@

{{WELCOME_TEXT}}

-
Please log in below. {{PASSWORD_MSG}}
+
{{I18N_LOGIN_BELOW}} {{PASSWORD_MSG}}
{{ERROR}} diff --git a/src/node/cli.ts b/src/node/cli.ts index 754ef6469..520d0a554 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -180,7 +180,14 @@ export const options: Options> = { enable: { type: "string[]" }, help: { type: "boolean", short: "h", description: "Show this output." }, json: { type: "boolean" }, - locale: { type: "string" }, // The preferred way to set the locale is via the UI. + locale: { + // The preferred way to set the locale is via the UI. + type: "string", + description: ` + Set vscode display language and language to show on the login page, more info see + https://en.wikipedia.org/wiki/IETF_language_tag + `, + }, open: { type: "boolean", description: "Open in browser on startup. Does not work remotely." }, "bind-addr": { diff --git a/src/node/i18n/index.ts b/src/node/i18n/index.ts new file mode 100644 index 000000000..b3b280b4d --- /dev/null +++ b/src/node/i18n/index.ts @@ -0,0 +1,21 @@ +import i18next, { init } from "i18next" +import * as en from "./locales/en.json" +import * as zhCn from "./locales/zh-cn.json" + +init({ + lng: "en", + fallbackLng: "en", // language to use if translations in user language are not available. + returnNull: false, + lowerCaseLng: true, + debug: process.env.NODE_ENV === "development", + resources: { + en: { + translation: en, + }, + "zh-cn": { + translation: zhCn, + }, + }, +}) + +export default i18next diff --git a/src/node/i18n/locales/en.json b/src/node/i18n/locales/en.json new file mode 100644 index 000000000..14e8d1525 --- /dev/null +++ b/src/node/i18n/locales/en.json @@ -0,0 +1,13 @@ +{ + "LOGIN_TITLE": "{{app}} login", + "LOGIN_BELOW": "Please log in below.", + "WELCOME": "Welcome to {{app}}", + "LOGIN_PASSWORD": "Check the config file at {{configFile}} for the password.", + "LOGIN_USING_ENV_PASSWORD": "Password was set from $PASSWORD.", + "LOGIN_USING_HASHED_PASSWORD": "Password was set from $HASHED_PASSWORD.", + "SUBMIT": "SUBMIT", + "PASSWORD_PLACEHOLDER": "PASSWORD", + "LOGIN_RATE_LIMIT": "Login rate limited!", + "MISS_PASSWORD": "Missing password", + "INCORRECT_PASSWORD": "Incorrect password" +} diff --git a/src/node/i18n/locales/zh-cn.json b/src/node/i18n/locales/zh-cn.json new file mode 100644 index 000000000..9f28b6669 --- /dev/null +++ b/src/node/i18n/locales/zh-cn.json @@ -0,0 +1,13 @@ +{ + "LOGIN_TITLE": "{{app}} 登录", + "LOGIN_BELOW": "请在下面登录。", + "WELCOME": "欢迎来到 {{app}}", + "LOGIN_PASSWORD": "查看配置文件 {{configFile}} 中的密码。", + "LOGIN_USING_ENV_PASSWORD": "密码在 $PASSWORD 中设置。", + "LOGIN_USING_HASHED_PASSWORD": "密码在 $HASHED_PASSWORD 中设置。", + "SUBMIT": "提交", + "PASSWORD_PLACEHOLDER": "密码", + "LOGIN_RATE_LIMIT": "登录速率限制!", + "MISS_PASSWORD": "缺少密码", + "INCORRECT_PASSWORD": "密码不正确" +} diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index 633c34ba4..786b89c7f 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -7,6 +7,7 @@ import { CookieKeys } from "../../common/http" import { rootPath } from "../constants" import { authenticated, getCookieOptions, redirect, replaceTemplates } from "../http" import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util" +import i18n from "../i18n" // RateLimiter wraps around the limiter library for logins. // It allows 2 logins every minute plus 12 logins every hour. @@ -28,21 +29,26 @@ export class RateLimiter { const getRoot = async (req: Request, error?: Error): Promise => { const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8") + const locale = req.args["locale"] || "en" + i18n.changeLanguage(locale) const appName = req.args["app-name"] || "code-server" - const welcomeText = req.args["welcome-text"] || `Welcome to ${appName}` - let passwordMsg = `Check the config file at ${humanPath(os.homedir(), req.args.config)} for the password.` + const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string) + let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: humanPath(os.homedir(), req.args.config) }) if (req.args.usingEnvPassword) { - passwordMsg = "Password was set from $PASSWORD." + passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD") } else if (req.args.usingEnvHashedPassword) { - passwordMsg = "Password was set from $HASHED_PASSWORD." + passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD") } return replaceTemplates( req, content - .replace(/{{APP_NAME}}/g, appName) + .replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName })) .replace(/{{WELCOME_TEXT}}/g, welcomeText) .replace(/{{PASSWORD_MSG}}/g, passwordMsg) + .replace(/{{I18N_LOGIN_BELOW}}/g, i18n.t("LOGIN_BELOW")) + .replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, i18n.t("PASSWORD_PLACEHOLDER")) + .replace(/{{I18N_SUBMIT}}/g, i18n.t("SUBMIT")) .replace(/{{ERROR}}/, error ? `
${escapeHtml(error.message)}
` : ""), ) } @@ -70,11 +76,11 @@ router.post<{}, string, { password: string; base?: string }, { to?: string }>("/ try { // Check to see if they exceeded their login attempts if (!limiter.canTry()) { - throw new Error("Login rate limited!") + throw new Error(i18n.t("LOGIN_RATE_LIMIT") as string) } if (!password) { - throw new Error("Missing password") + throw new Error(i18n.t("MISS_PASSWORD") as string) } const passwordMethod = getPasswordMethod(hashedPasswordFromArgs) @@ -108,7 +114,7 @@ router.post<{}, string, { password: string; base?: string }, { to?: string }>("/ }), ) - throw new Error("Incorrect password") + throw new Error(i18n.t("INCORRECT_PASSWORD") as string) } catch (error: any) { const renderedHtml = await getRoot(req, error) res.send(renderedHtml) diff --git a/test/unit/node/routes/login.test.ts b/test/unit/node/routes/login.test.ts index b2cf44651..f2f38fedc 100644 --- a/test/unit/node/routes/login.test.ts +++ b/test/unit/node/routes/login.test.ts @@ -138,5 +138,16 @@ describe("login", () => { expect(resp.status).toBe(200) expect(htmlContent).toContain(`Welcome to ${appName}`) }) + + it("should return correct welcome text when locale is set to non-English", async () => { + process.env.PASSWORD = previousEnvPassword + const locale = "zh-cn" + const codeServer = await integration.setup([`--locale=${locale}`], "") + const resp = await codeServer.fetch("/login", { method: "GET" }) + + const htmlContent = await resp.text() + expect(resp.status).toBe(200) + expect(htmlContent).toContain(`欢迎来到 code-server`) + }) }) }) diff --git a/tsconfig.json b/tsconfig.json index 3a591aac2..6ff95ccca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ "./test/node_modules/@types", "./lib/vscode/src/vs/server/@types" ], - "downlevelIteration": true + "downlevelIteration": true, + "resolveJsonModule": true }, "include": ["./src/**/*"], "exclude": ["/test", "/lib", "/ci", "/doc"] diff --git a/yarn.lock b/yarn.lock index 76034dc14..99fbd2c66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@babel/runtime@^7.20.6": + version "7.20.7" + resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd" + integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ== + dependencies: + regenerator-runtime "^0.13.11" + "@coder/logger@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@coder/logger/-/logger-3.0.0.tgz#fd4d2332ca375412c75cb5ba7767d3290b106dec" @@ -1814,6 +1821,13 @@ https-proxy-agent@5, https-proxy-agent@^5.0.0: agent-base "6" debug "4" +i18next@^22.4.6: + version "22.4.6" + resolved "https://registry.npmmirror.com/i18next/-/i18next-22.4.6.tgz#876352c3ba81bdfedc38eeda124e2bbd05f46988" + integrity sha512-9Tm1ezxWyzV+306CIDMBbYBitC1jedQyYuuLtIv7oxjp2ohh8eyxP9xytIf+2bbQfhH784IQKPSYp+Zq9+YSbw== + dependencies: + "@babel/runtime" "^7.20.6" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -2873,6 +2887,11 @@ readline-transform@1.0.0: resolved "https://registry.yarnpkg.com/readline-transform/-/readline-transform-1.0.0.tgz#3157f97428acaec0f05a5c1ff2c3120f4e6d904b" integrity sha512-7KA6+N9IGat52d83dvxnApAWN+MtVb1MiVuMR/cf1O4kYsJG+g/Aav0AHcHKsb6StinayfPLne0+fMX2sOzAKg== +regenerator-runtime@^0.13.11: + version "0.13.11" + resolved "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" + integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== + regexp.prototype.flags@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"