feat(i18n): add internationalization support for Chinese, Japanese, and Korean#87
feat(i18n): add internationalization support for Chinese, Japanese, and Korean#87miclle wants to merge 5 commits intogoplus:mainfrom
Conversation
- Add URL path prefix routing (/zh/) for language selection - Add locale files (locales/en.json, locales/zh.json) for UI text and title translations - Add Chinese translation .md files for all 112 tutorial sections - Add language switcher dropdown in breadcrumb area - Add per-language caching for parsed examples and rendered index pages - Add gen-translation.go skeleton generator tool - Keep original .gop source files untouched; translations overlay via .md files
Add localized aria-label text for the language switcher and keep the page templates aligned with the existing translation flow.
… switcher - Register ja/ko in supportedLangs - Add langEntry type and allLangs ordered list for template rendering - Add currentLangName and allLangs template functions - Refactor language switcher in index.tmpl and example.tmpl from hardcoded to data-driven
| docText = strings.TrimPrefix(trimmed[2:], " ") | ||
| } else if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#!") { | ||
| isDoc = true | ||
| docText = "##" + trimmed |
There was a problem hiding this comment.
Bug: double # prefix produces malformed headings
trimmed already starts with #, so "##" + trimmed yields ## # Title — a level-2 heading whose display text is the literal string # Title. The same pattern exists in the production main.go parsing path, so the skeleton files and the rendered output will be consistent, but the heading levels will be wrong (H2 for content that was a bare # comment in the source).
If the intent is to promote .gop #-lines to Markdown H2s, strip the leading # first:
docText = "## " + strings.TrimPrefix(strings.TrimPrefix(trimmed, "#"), " ")Or if the transformation in main.go should be the canonical one, keep both files in sync with the same logic.
| if fi.IsDir() { | ||
| name := fi.Name() | ||
| if len(name) > (chNumLen+1) && name[chNumLen] == '-' { | ||
| if _, e := strconv.Atoi(name[:1]); e == nil { |
There was a problem hiding this comment.
Only the first character of the chapter prefix is validated as numeric
name[:1] is a single character. A directory named 1AB-Something would pass this check even though its prefix is not a valid three-digit number. Use name[:chNumLen] to validate the full prefix:
if _, e := strconv.Atoi(name[:chNumLen]); e == nil {| mdPath := filepath.Join("locales", lang, dir, mdName) | ||
|
|
||
| // Skip if already exists | ||
| if _, err := os.Stat(mdPath); err == nil { |
There was a problem hiding this comment.
os.Stat error is not distinguished from "file not found"
If os.Stat fails for a reason other than the file not existing (e.g. a permissions issue), the code silently proceeds to write a new file. Prefer:
if _, err := os.Stat(mdPath); err == nil {
skipped++
continue
} else if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Warning: cannot stat %s: %v\n", mdPath, err)
continue
}| } | ||
|
|
||
| // Write skeleton .md | ||
| skeleton := strings.Join(docs, "\n---\n") |
There was a problem hiding this comment.
--- without surrounding blank lines is parsed as a setext H2 heading
In CommonMark, a line of --- immediately following text is interpreted as a setext-style H2 heading for that text, not as a thematic break. This means the last line of each doc segment will be rendered as a heading rather than as a divider. Add blank lines around the separator:
skeleton := strings.Join(docs, "\n\n---\n\n")| } | ||
|
|
||
| // Create lang directory | ||
| langDir := filepath.Join("locales", lang, dir) |
There was a problem hiding this comment.
os.MkdirAll called once per source file rather than once per directory
All .gop/.xgo files in the same dir share the same langDir. Moving this call outside the inner fi loop (or hoisting it just before the inner loop begins) avoids the redundant syscall for every file after the first in each directory.
| if len(os.Args) < 2 { | ||
| fmt.Fprintln(os.Stderr, "Usage: go run gen-translation.go <lang> [tutorial-dir...]") | ||
| os.Exit(1) | ||
| } |
There was a problem hiding this comment.
Path traversal via unsanitized lang argument
lang is used directly in filepath.Join("locales", lang, ...) for both os.MkdirAll and os.WriteFile. While filepath.Join cleans the path, a value like ../../etc resolves outside locales/. Even for a //go:build ignore tool, validating the argument is a low-cost safeguard:
if !regexp.MustCompile(`^[a-z]{2,10}$`).MatchString(lang) {
fmt.Fprintln(os.Stderr, "Error: lang must be a simple language code (e.g. zh, ja, ko)")
os.Exit(1)
}The same concern applies to directory values passed explicitly via os.Args[2:] — each entry should be checked to contain no path separators.
| "no_content_after": ".", | ||
| "next_example": "Next example:", | ||
| "lang_switcher_label": "Switch language" | ||
| } |
There was a problem hiding this comment.
en.json is missing the titles object present in all other locale files
zh.json, ja.json, and ko.json all include a "titles" map of English tutorial names → localized names. en.json omits it entirely, making it structurally inconsistent with the other locale files. Since en.json is the canonical schema reference for contributors adding new locales, the missing key is likely to cause incomplete locale files in the future.
Consider adding identity mappings (English → English) or at minimum a comment/schema file in locales/ documenting the expected structure.
There was a problem hiding this comment.
Code Review
This pull request introduces a gen-translation.go utility script to automate the creation of translation placeholders, alongside a comprehensive set of initial localized content for Japanese, Korean, and Chinese. The review feedback identifies a logic error in the script's directory prefix parsing and highlights several consistency issues in the translation files, specifically regarding the branding of 'XGo' and missing bilingual titles for the 'Hello World' tutorial.
| if fi.IsDir() { | ||
| name := fi.Name() | ||
| if len(name) > (chNumLen+1) && name[chNumLen] == '-' { | ||
| if _, e := strconv.Atoi(name[:1]); e == nil { |
There was a problem hiding this comment.
The logic strconv.Atoi(name[:1]) is fragile and potentially incorrect given that chNumLen is set to 3. If the directory naming convention uses a 3-digit prefix (e.g., 101-), it is safer to validate the entire prefix.
| if _, e := strconv.Atoi(name[:1]); e == nil { | |
| if _, e := strconv.Atoi(name[:chNumLen]); e == nil { |
| @@ -0,0 +1,10 @@ | |||
| ### スライスへの要素追加 | |||
| スライスに新しい要素を追加するのは一般的な操作なので、Go は組み込みの append 関数を提供しています。組み込みパッケージのドキュメントに append の使い方が記載されています。 | |||
There was a problem hiding this comment.
The text refers to "Go" instead of "XGo". To maintain consistency with the rest of the documentation and the project's branding, this should be updated.
| スライスに新しい要素を追加するのは一般的な操作なので、Go は組み込みの append 関数を提供しています。組み込みパッケージのドキュメントに append の使い方が記載されています。 | |
| ### スライスへの要素追加 | |
| スライスに新しい要素を追加するのは一般的な操作なので、XGo は組み込みの append 関数を提供しています。組み込みパッケージのドキュメントに append の使い方が記載されています。 |
| @@ -0,0 +1,10 @@ | |||
| ### 슬라이스에 요소 추가하기 | |||
| 슬라이스에 새 요소를 추가하는 것은 매우 일반적인 작업이므로, Go는 내장 append 함수를 제공합니다. 내장 패키지 문서에서 append의 사용법을 설명합니다. | |||
There was a problem hiding this comment.
The text refers to "Go" instead of "XGo". This should be updated for consistency.
| 슬라이스에 새 요소를 추가하는 것은 매우 일반적인 작업이므로, Go는 내장 append 함수를 제공합니다. 내장 패키지 문서에서 append의 사용법을 설명합니다. | |
| ### 슬라이스에 요소 추가하기 | |
| 슬라이스에 새 요소를 추가하는 것은 매우 일반적인 작업이므로, XGo는 내장 append 함수를 제공합니다. 내장 패키지 문서에서 append의 사용법을 설명합니다. |
| @@ -0,0 +1,10 @@ | |||
| ### 向切片追加元素 | |||
| 向切片追加新元素是很常见的操作,因此 Go 提供了内置的 append 函数。内置包的文档中描述了 append 的用法。 | |||
| "titles": { | ||
| "Sequential programming": "Sequential Programming 順次プログラミング", | ||
| "Structured programming": "Structured Programming 構造化プログラミング", | ||
| "Hello world": "Hello World", |
| "titles": { | ||
| "Sequential programming": "Sequential Programming 순차 프로그래밍", | ||
| "Structured programming": "Structured Programming 구조적 프로그래밍", | ||
| "Hello world": "Hello World", |
| "titles": { | ||
| "Sequential programming": "Sequential Programming 顺序编程", | ||
| "Structured programming": "Structured Programming 结构化编程", | ||
| "Hello world": "Hello World", |
- gen-translation.go: validate lang argument against path traversal - gen-translation.go: validate full 3-digit prefix instead of first char - gen-translation.go: distinguish os.Stat errors from file-not-found - gen-translation.go: hoist os.MkdirAll outside inner file loop - gen-translation.go: fix --- separator with blank lines for CommonMark - gen-translation.go: fix heading prefix to avoid ## # malformed headings - Fix "Go" → "XGo" in slices-10.md for zh/ja/ko - Add empty titles object to en.json for structural consistency
Summary
/zh/,/ja/,/ko/), locale JSON files, translation template functions, per-language cachinggen-translation.gotool for generating translation skeleton files.gopsource files untouched; translations overlay via.mdfiles underlocales/How it works
/hello-world→ English (default),/zh/hello-world→ Chinese,/ja/hello-world→ Japanese,/ko/hello-world→ Koreanlocales/<lang>/<tutorial-dir>/<file>.mdwith---separators matching.gopdoc segmentslocales/<lang>.jsonwith UI strings and bilingual title translations (e.g., "Functions 関数").mdfile is missing, the original English content is shownFiles changed
main.gotemplates/index.tmpl,templates/example.tmplpublic/site.css(language switcher dropdown)locales/en.json,locales/zh.json,locales/ja.json,locales/ko.jsonlocales/zh/**/*.md(112 files),locales/ja/**/*.md(112 files),locales/ko/**/*.md(112 files)gen-translation.go(skeleton generator)Test plan
go build .compiles successfully/shows English index page (unchanged)/zh/shows Chinese index page with translated titles/ja/shows Japanese index page/ko/shows Korean index page/zh/hello-worldshows Chinese tutorial content/ja/hello-worldshows Japanese tutorial content/ko/hello-worldshows Korean tutorial content