Skip to content

Commit 92fca0d

Browse files
authored
Add language customization flag (#7374)
This allows you to customize any string (that has a translation) or add your own translations.
1 parent 8b3d9b9 commit 92fca0d

File tree

8 files changed

+471
-23
lines changed

8 files changed

+471
-23
lines changed

docs/guide.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
- [Proxying to a Svelte app](#proxying-to-a-svelte-app)
2323
- [Prefixing `/absproxy/<port>` with a path](#prefixing-absproxyport-with-a-path)
2424
- [Preflight requests](#preflight-requests)
25+
- [Internationalization and customization](#internationalization-and-customization)
26+
- [Available keys and placeholders](#available-keys-and-placeholders)
27+
- [Legacy flag](#legacy-flag)
2528

2629
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
2730
<!-- prettier-ignore-end -->
@@ -458,3 +461,45 @@ By default, if you have auth enabled, code-server will authenticate all proxied
458461
requests including preflight requests. This can cause issues because preflight
459462
requests do not typically include credentials. To allow all preflight requests
460463
through the proxy without authentication, use `--skip-auth-preflight`.
464+
465+
## Internationalization and customization
466+
467+
code-server allows you to provide a JSON file to configure certain strings. This can be used for both internationalization and customization.
468+
469+
Create a JSON file with your custom strings:
470+
471+
```json
472+
{
473+
"WELCOME": "Welcome to {{app}}",
474+
"LOGIN_TITLE": "{{app}} Access Portal",
475+
"LOGIN_BELOW": "Please log in to continue",
476+
"PASSWORD_PLACEHOLDER": "Enter Password"
477+
}
478+
```
479+
480+
Then reference the file:
481+
482+
```shell
483+
code-server --i18n /path/to/custom-strings.json
484+
```
485+
486+
Or this can be done in the config file:
487+
488+
```yaml
489+
i18n: /path/to/custom-strings.json
490+
```
491+
492+
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`.
493+
494+
### Available keys and placeholders
495+
496+
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.
497+
498+
The `--app-name` flag controls the `{{app}}` placeholder in templates. If you want to change the name, you can either:
499+
500+
1. Set `--app-name` (potentially alongside `--i18n`)
501+
2. Use `--i18n` and hardcode the name in your strings
502+
503+
### Legacy flag
504+
505+
The `--welcome-text` flag is now deprecated. Use the `WELCOME` key instead.

src/node/cli.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs {
9393
"app-name"?: string
9494
"welcome-text"?: string
9595
"abs-proxy-base-path"?: string
96+
i18n?: string
9697
/* Positional arguments. */
9798
_?: string[]
9899
}
@@ -284,17 +285,24 @@ export const options: Options<Required<UserProvidedArgs>> = {
284285
"app-name": {
285286
type: "string",
286287
short: "an",
287-
description: "The name to use in branding. Will be shown in titlebar and welcome message",
288+
description:
289+
"Will replace the {{app}} placeholder in any strings, which by default includes the title bar and welcome message",
288290
},
289291
"welcome-text": {
290292
type: "string",
291293
short: "w",
292294
description: "Text to show on login page",
295+
deprecated: true,
293296
},
294297
"abs-proxy-base-path": {
295298
type: "string",
296299
description: "The base path to prefix to all absproxy requests",
297300
},
301+
i18n: {
302+
type: "string",
303+
path: true,
304+
description: "Path to JSON file with custom translations. Merges with default strings and supports all i18n keys.",
305+
},
298306
}
299307

300308
export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {

src/node/i18n/index.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,59 @@
1+
import { promises as fs } from "fs"
12
import i18next, { init } from "i18next"
23
import * as en from "./locales/en.json"
34
import * as ja from "./locales/ja.json"
45
import * as th from "./locales/th.json"
56
import * as ur from "./locales/ur.json"
67
import * as zhCn from "./locales/zh-cn.json"
78

9+
const defaultResources = {
10+
en: {
11+
translation: en,
12+
},
13+
"zh-cn": {
14+
translation: zhCn,
15+
},
16+
th: {
17+
translation: th,
18+
},
19+
ja: {
20+
translation: ja,
21+
},
22+
ur: {
23+
translation: ur,
24+
},
25+
}
26+
27+
export async function loadCustomStrings(filePath: string): Promise<void> {
28+
try {
29+
// Read custom strings from file path only
30+
const fileContent = await fs.readFile(filePath, "utf8")
31+
const customStringsData = JSON.parse(fileContent)
32+
33+
// User-provided strings override all languages.
34+
Object.keys(defaultResources).forEach((locale) => {
35+
i18next.addResourceBundle(locale, "translation", customStringsData)
36+
})
37+
} catch (error) {
38+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
39+
throw new Error(`Custom strings file not found: ${filePath}\nPlease ensure the file exists and is readable.`)
40+
} else if (error instanceof SyntaxError) {
41+
throw new Error(`Invalid JSON in custom strings file: ${filePath}\n${error.message}`)
42+
} else {
43+
throw new Error(
44+
`Failed to load custom strings from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
45+
)
46+
}
47+
}
48+
}
49+
850
init({
951
lng: "en",
1052
fallbackLng: "en", // language to use if translations in user language are not available.
1153
returnNull: false,
1254
lowerCaseLng: true,
1355
debug: process.env.NODE_ENV === "development",
14-
resources: {
15-
en: {
16-
translation: en,
17-
},
18-
"zh-cn": {
19-
translation: zhCn,
20-
},
21-
th: {
22-
translation: th,
23-
},
24-
ja: {
25-
translation: ja,
26-
},
27-
ur: {
28-
translation: ur,
29-
},
30-
},
56+
resources: defaultResources,
3157
})
3258

3359
export default i18next

src/node/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { plural } from "../common/util"
77
import { createApp, ensureAddress } from "./app"
88
import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli"
99
import { commit, version, vsRootPath } from "./constants"
10+
import { loadCustomStrings } from "./i18n"
1011
import { register } from "./routes"
1112
import { VSCodeModule } from "./routes/vscode"
1213
import { isDirectory, open } from "./util"
@@ -122,6 +123,12 @@ export const runCodeServer = async (
122123
): Promise<{ dispose: Disposable["dispose"]; server: http.Server }> => {
123124
logger.info(`code-server ${version} ${commit}`)
124125

126+
// Load custom strings if provided
127+
if (args.i18n) {
128+
await loadCustomStrings(args.i18n)
129+
logger.info("Loaded custom strings")
130+
}
131+
125132
logger.info(`Using user-data-dir ${args["user-data-dir"]}`)
126133
logger.debug(`Using extensions-dir ${args["extensions-dir"]}`)
127134

src/node/routes/login.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,32 @@ const getRoot = async (req: Request, error?: Error): Promise<string> => {
3131
const locale = req.args["locale"] || "en"
3232
i18n.changeLanguage(locale)
3333
const appName = req.args["app-name"] || "code-server"
34-
const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)
34+
const welcomeText = escapeHtml(req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string))
35+
36+
// Determine password message using i18n
3537
let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: req.args.config })
3638
if (req.args.usingEnvPassword) {
3739
passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
3840
} else if (req.args.usingEnvHashedPassword) {
3941
passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
4042
}
43+
passwordMsg = escapeHtml(passwordMsg)
44+
45+
// Get messages from i18n (with HTML escaping for security)
46+
const loginTitle = escapeHtml(i18n.t("LOGIN_TITLE", { app: appName }))
47+
const loginBelow = escapeHtml(i18n.t("LOGIN_BELOW"))
48+
const passwordPlaceholder = escapeHtml(i18n.t("PASSWORD_PLACEHOLDER"))
49+
const submitText = escapeHtml(i18n.t("SUBMIT"))
4150

4251
return replaceTemplates(
4352
req,
4453
content
45-
.replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName }))
54+
.replace(/{{I18N_LOGIN_TITLE}}/g, loginTitle)
4655
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
4756
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
48-
.replace(/{{I18N_LOGIN_BELOW}}/g, i18n.t("LOGIN_BELOW"))
49-
.replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, i18n.t("PASSWORD_PLACEHOLDER"))
50-
.replace(/{{I18N_SUBMIT}}/g, i18n.t("SUBMIT"))
57+
.replace(/{{I18N_LOGIN_BELOW}}/g, loginBelow)
58+
.replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, passwordPlaceholder)
59+
.replace(/{{I18N_SUBMIT}}/g, submitText)
5160
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
5261
)
5362
}

test/unit/node/cli.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe("parser", () => {
7575
"--verbose",
7676
["--app-name", "custom instance name"],
7777
["--welcome-text", "welcome to code"],
78+
["--i18n", "path/to/custom-strings.json"],
7879
"2",
7980

8081
["--locale", "ja"],
@@ -145,6 +146,7 @@ describe("parser", () => {
145146
verbose: true,
146147
"app-name": "custom instance name",
147148
"welcome-text": "welcome to code",
149+
i18n: path.resolve("path/to/custom-strings.json"),
148150
version: true,
149151
"bind-addr": "192.169.0.1:8080",
150152
"session-socket": "/tmp/override-code-server-ipc-socket",
@@ -347,6 +349,28 @@ describe("parser", () => {
347349
})
348350
})
349351

352+
it("should parse i18n flag with file path", async () => {
353+
// Test with file path (no validation at CLI parsing level)
354+
const args = parse(["--i18n", "/path/to/custom-strings.json"])
355+
expect(args).toEqual({
356+
i18n: "/path/to/custom-strings.json",
357+
})
358+
})
359+
360+
it("should parse i18n flag with relative file path", async () => {
361+
// Test with relative file path
362+
expect(() => parse(["--i18n", "./custom-strings.json"])).not.toThrow()
363+
expect(() => parse(["--i18n", "strings.json"])).not.toThrow()
364+
})
365+
366+
it("should support app-name and deprecated welcome-text flags", async () => {
367+
const args = parse(["--app-name", "My App", "--welcome-text", "Welcome!"])
368+
expect(args).toEqual({
369+
"app-name": "My App",
370+
"welcome-text": "Welcome!",
371+
})
372+
})
373+
350374
it("should use env var github token", async () => {
351375
process.env.GITHUB_TOKEN = "ga-foo"
352376
const args = parse([])

0 commit comments

Comments
 (0)