diff --git a/docs/FAQ.md b/docs/FAQ.md index c46c003b8800..cba4a400f97a 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -81,6 +81,7 @@ You can change the config file's location using the `--config` flag or The default location respects `$XDG_CONFIG_HOME`. + ## How do I make my keyboard shortcuts work? Many shortcuts will not work by default, since they'll be "caught" by the browser. diff --git a/docs/README.md b/docs/README.md index 5724c804c087..d607ba5ab232 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,7 @@ code-server. We also have an in-depth [setup and configuration](https://coder.com/docs/code-server/latest/guide) guide. + ## Questions? See answers to [frequently asked diff --git a/docs/guide.md b/docs/guide.md index 2835aac1567c..588b65b30005 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -458,3 +458,38 @@ By default, if you have auth enabled, code-server will authenticate all proxied requests including preflight requests. This can cause issues because preflight requests do not typically include credentials. To allow all preflight requests through the proxy without authentication, use `--skip-auth-preflight`. + +## Internationalization and customization + +code-server allows you to provide a language file or JSON to configure certain strings. This can be used for both internationalization and customization. + +For example: + +```shell +code-server --i18n /custom-strings.json +code-server --i18n '{"WELCOME": "{{app}} ログイン"}' +``` + +Or this can be done in the config file: + +```yaml +i18n: | + { + "WELCOME": "Welcome to the {{app}} Development Portal" + } +``` + +You can combine this with the `--locale` flag to configure language support for both code-server and VS Code in cases where code-server has no support but VS Code does. If you are using this for internationalization, please consider sending us a pull request to contribute it to `src/node/i18n/locales`. + +### Available keys and placeholders + +Refer to [../src/node/i18n/locales/en.json](../src/node/i18n/locales/en.json) for a full list of the available keys for translations. Note that the only placeholders supported for each key are the ones used in the default string. + +The `--app-name` flag controls the `{{app}}` placeholder in templates. If you want to change the name, you can either: + +1. Set `--app-name` (potentially alongside `--i18n`) +2. Use `--i18n` and hardcode the name in your strings + +### Legacy flag + +The `--welcome-text` flag is now deprecated. Use the `WELCOME` key instead. diff --git a/docs/install.md b/docs/install.md index e2dd905f9401..c60127a1022f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -287,6 +287,7 @@ docker run -it --name code-server -p 127.0.0.1:8080:8080 \ codercom/code-server:latest ``` + Our official image supports `amd64` and `arm64`. For `arm32` support, you can use a [community-maintained code-server alternative](https://hub.docker.com/r/linuxserver/code-server). diff --git a/src/node/cli.ts b/src/node/cli.ts index a29ec591e0a4..40b99c9e4321 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -93,6 +93,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs { "app-name"?: string "welcome-text"?: string "abs-proxy-base-path"?: string + i18n?: string /* Positional arguments. */ _?: string[] } @@ -284,17 +285,22 @@ export const options: Options> = { "app-name": { type: "string", short: "an", - description: "The name to use in branding. Will be shown in titlebar and welcome message", + description: "Will replace the {{app}} placeholder in any strings, which by default includes the title bar and welcome message", }, "welcome-text": { type: "string", short: "w", description: "Text to show on login page", + deprecated: true, }, "abs-proxy-base-path": { type: "string", description: "The base path to prefix to all absproxy requests", }, + i18n: { + type: "string", + description: "Path to JSON file or raw JSON string with custom translations. Merges with default strings and supports all i18n keys.", + }, } export const optionDescriptions = (opts: Partial>> = options): string[] => { @@ -459,6 +465,7 @@ export const parse = ( throw new Error("--cert-key is missing") } + logger.debug(() => [`parsed ${opts?.configFile ? "config" : "command line"}`, field("args", redactArgs(args))]) return args @@ -593,6 +600,7 @@ export async function setDefaults(cliArgs: UserProvidedArgs, configArgs?: Config args["disable-proxy"] = true } + const usingEnvHashedPassword = !!process.env.HASHED_PASSWORD if (process.env.HASHED_PASSWORD) { args["hashed-password"] = process.env.HASHED_PASSWORD diff --git a/src/node/i18n/index.ts b/src/node/i18n/index.ts index 4ee718e13aa2..69807247ddae 100644 --- a/src/node/i18n/index.ts +++ b/src/node/i18n/index.ts @@ -1,33 +1,60 @@ import i18next, { init } from "i18next" +import { promises as fs } from "fs" import * as en from "./locales/en.json" import * as ja from "./locales/ja.json" import * as th from "./locales/th.json" import * as ur from "./locales/ur.json" import * as zhCn from "./locales/zh-cn.json" +const defaultResources = { + en: { + translation: en, + }, + "zh-cn": { + translation: zhCn, + }, + th: { + translation: th, + }, + ja: { + translation: ja, + }, + ur: { + translation: ur, + }, +} + + +export async function loadCustomStrings(customStringsArg: string): Promise { + + try { + let customStringsData: Record + + // Try to parse as JSON first + try { + customStringsData = JSON.parse(customStringsArg) + } catch { + // If JSON parsing fails, treat as file path + const fileContent = await fs.readFile(customStringsArg, "utf8") + customStringsData = JSON.parse(fileContent) + } + + // User-provided strings override all languages. + Object.keys(defaultResources).forEach((locale) => { + i18next.addResourceBundle(locale, "translation", customStringsData) + }) + } catch (error) { + throw new Error(`Failed to load custom strings: ${error instanceof Error ? error.message : String(error)}`) + } +} + 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, - }, - th: { - translation: th, - }, - ja: { - translation: ja, - }, - ur: { - translation: ur, - }, - }, + resources: defaultResources, }) export default i18next diff --git a/src/node/main.ts b/src/node/main.ts index 470ddeb25cc7..caff79535461 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -7,6 +7,7 @@ import { plural } from "../common/util" import { createApp, ensureAddress } from "./app" import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli" import { commit, version, vsRootPath } from "./constants" +import { loadCustomStrings } from "./i18n" import { register } from "./routes" import { VSCodeModule } from "./routes/vscode" import { isDirectory, open } from "./util" @@ -122,6 +123,12 @@ export const runCodeServer = async ( ): Promise<{ dispose: Disposable["dispose"]; server: http.Server }> => { logger.info(`code-server ${version} ${commit}`) + // Load custom strings if provided + if (args.i18n) { + await loadCustomStrings(args.i18n) + logger.info("Loaded custom strings") + } + logger.info(`Using user-data-dir ${args["user-data-dir"]}`) logger.debug(`Using extensions-dir ${args["extensions-dir"]}`) diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index 29d51a59d13b..511d4817455e 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -31,23 +31,32 @@ const getRoot = async (req: Request, error?: Error): Promise => { const locale = req.args["locale"] || "en" i18n.changeLanguage(locale) const appName = req.args["app-name"] || "code-server" - const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string) + const welcomeText = escapeHtml(req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)) + + // Determine password message using i18n let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: req.args.config }) if (req.args.usingEnvPassword) { passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD") } else if (req.args.usingEnvHashedPassword) { passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD") } + passwordMsg = escapeHtml(passwordMsg) + + // Get messages from i18n (with HTML escaping for security) + const loginTitle = escapeHtml(i18n.t("LOGIN_TITLE", { app: appName })) + const loginBelow = escapeHtml(i18n.t("LOGIN_BELOW")) + const passwordPlaceholder = escapeHtml(i18n.t("PASSWORD_PLACEHOLDER")) + const submitText = escapeHtml(i18n.t("SUBMIT")) return replaceTemplates( req, content - .replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName })) + .replace(/{{I18N_LOGIN_TITLE}}/g, loginTitle) .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(/{{I18N_LOGIN_BELOW}}/g, loginBelow) + .replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, passwordPlaceholder) + .replace(/{{I18N_SUBMIT}}/g, submitText) .replace(/{{ERROR}}/, error ? `
${escapeHtml(error.message)}
` : ""), ) } diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index d62edb840464..44f75193478a 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -75,6 +75,7 @@ describe("parser", () => { "--verbose", ["--app-name", "custom instance name"], ["--welcome-text", "welcome to code"], + ["--i18n", '{"LOGIN_TITLE": "Custom Portal"}'], "2", ["--locale", "ja"], @@ -145,6 +146,7 @@ describe("parser", () => { verbose: true, "app-name": "custom instance name", "welcome-text": "welcome to code", + i18n: '{"LOGIN_TITLE": "Custom Portal"}', version: true, "bind-addr": "192.169.0.1:8080", "session-socket": "/tmp/override-code-server-ipc-socket", @@ -347,6 +349,31 @@ describe("parser", () => { }) }) + it("should parse i18n flag", async () => { + // Test with JSON string + const jsonString = '{"WELCOME": "Custom Welcome", "LOGIN_TITLE": "My App"}' + const args = parse(["--i18n", jsonString]) + expect(args).toEqual({ + i18n: jsonString, + }) + }) + + it("should parse i18n file paths and JSON", async () => { + // Test with valid JSON that looks like a file path + expect(() => parse(["--i18n", "/path/to/file.json"])).not.toThrow() + + // Test with JSON string (no validation at CLI level) + expect(() => parse(["--i18n", '{"valid": "json"}'])).not.toThrow() + }) + + it("should support app-name and deprecated welcome-text flags", async () => { + const args = parse(["--app-name", "My App", "--welcome-text", "Welcome!"]) + expect(args).toEqual({ + "app-name": "My App", + "welcome-text": "Welcome!", + }) + }) + it("should use env var github token", async () => { process.env.GITHUB_TOKEN = "ga-foo" const args = parse([]) diff --git a/test/unit/node/routes/login.test.ts b/test/unit/node/routes/login.test.ts index 2835bad82354..e947b9e41a36 100644 --- a/test/unit/node/routes/login.test.ts +++ b/test/unit/node/routes/login.test.ts @@ -146,5 +146,6 @@ describe("login", () => { expect(resp.status).toBe(200) expect(htmlContent).toContain(`欢迎来到 code-server`) }) + }) })