diff --git a/TODO.md b/TODO.md index e65dc59..f735e32 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,3 @@ - [x] service worker 是否能提升性能 -- [ ] 设置多语言支持,根据系统语言设定默认语言 +- [x] 设置多语言支持,根据系统语言设定默认语言 - [ ] graphql 替换 rest api 访问提升性能 diff --git a/build.mjs b/build.mjs index 718f1d3..0bb815a 100644 --- a/build.mjs +++ b/build.mjs @@ -8,6 +8,8 @@ mkdirSync("dist", { recursive: true }); // Copy static files to dist cpSync("static", "dist", { recursive: true }); cpSync("src/styles", "dist/styles", { recursive: true }); +// Chrome reads _locales from the extension root for manifest __MSG__ resolution. +cpSync("src/_locales", "dist/_locales", { recursive: true }); const sharedOptions = { bundle: true, diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json new file mode 100644 index 0000000..986ec37 --- /dev/null +++ b/src/_locales/en/messages.json @@ -0,0 +1,193 @@ +{ + "appName": { + "message": "Better GitHub", + "description": "Extension name, shown in the Chrome Web Store and extension manager." + }, + "appDesc": { + "message": "Improve usability of GitHub PR, issue, and other pages", + "description": "Extension description, shown in the Chrome Web Store and extension manager." + }, + "actionTitle": { + "message": "Better GitHub - Click to view settings", + "description": "Toolbar button tooltip." + }, + + "settingsTitle": { "message": "Settings" }, + "language": { "message": "Language" }, + "langFollowBrowser": { "message": "Follow browser" }, + "openSourced": { "message": "It's open sourced!" }, + "viewOnGithub": { "message": "View on GitHub" }, + "tokenLabel": { "message": "GitHub Personal Access Token" }, + "save": { "message": "Save" }, + "tokenHintIntro": { + "message": "⬆️ A token is required for accessing private repositories. The plugin itself will never store your token or access your data." + }, + "tokenClassicTitle": { "message": "Create a classic token with scopes" }, + "tokenFineTitle": { "message": "Create a fine-grained token with permissions" }, + "tokenOr": { "message": "or" }, + + "features": { "message": "Features" }, + "searchFeatures": { "message": "Search features" }, + "searchFeaturesPlaceholder": { "message": "Search features..." }, + "closeSearch": { "message": "Close search" }, + + "groupHome": { "message": "Home" }, + "groupPRsIssues": { "message": "PRs and issues" }, + "groupPRDetails": { "message": "PR details" }, + "groupCommits": { "message": "Commits" }, + "groupRepository": { "message": "Repository" }, + + "featBetterTopReposName": { "message": "Better Top Repositories" }, + "featBetterTopReposDesc": { + "message": "Auto-expand the \"Top repositories\" list and pin your favorite repos to the top." + }, + "featDefaultSortName": { "message": "Default Sort by Updated" }, + "featDefaultSortDesc": { + "message": "Sort PR and issue lists by recently updated instead of creation time." + }, + "featPrBranchNamesName": { "message": "PR Branch Names" }, + "featPrBranchNamesDesc": { + "message": "Display source branch name next to each PR title. Click the badge to copy." + }, + "featPrReviewStatusName": { "message": "PR Review Status" }, + "featPrReviewStatusDesc": { + "message": "Show review thread resolution status (resolved / unresolved) on the PR list. Requires a token." + }, + "featPrDiffStatsName": { "message": "PR Diff Stats" }, + "featPrDiffStatsDesc": { + "message": "Show additions, deletions, and file count (e.g. +223 −114 · 5 files) on the PR list. Requires a token." + }, + "featPrLabelPositionName": { "message": "PR Label Position" }, + "featPrLabelPositionDesc": { + "message": "Move labels to the front of PR titles for better visibility and scanning." + }, + "featPrApproveNowName": { "message": "PR Approve Now" }, + "featPrApproveNowDesc": { + "message": "Add an \"approve now\" shortcut to the Reviewers sidebar on PR detail pages. Requires a token." + }, + "featPrCollapseExpandName": { "message": "Collapse/Expand All Files" }, + "featPrCollapseExpandDesc": { + "message": "Add a button to collapse or expand all file diffs on PR, commit, and compare pages." + }, + "featCommitTagsName": { "message": "Commit Tags" }, + "featCommitTagsDesc": { + "message": "Show git tags on the commits list page for easy identification." + }, + "featCommitDiffStatsName": { "message": "Commit Diff Stats" }, + "featCommitDiffStatsDesc": { + "message": "Show additions, deletions, and file count (e.g. +223 −114 · 5 files) on the commits list page. Requires a token." + }, + "featReleaseTabName": { "message": "Releases Tab" }, + "featReleaseTabDesc": { + "message": "Add a Releases tab to the repository navigation bar for quick access." + }, + "featWatchForkStarName": { "message": "Watch/Fork/Star Popup" }, + "featWatchForkStarDesc": { + "message": "Hover over Watch, Fork, or Star counts to preview the list in a popup." + }, + + "validatingToken": { "message": "Validating token…" }, + "tokenValid": { + "message": "Valid — authenticated as $user$", + "placeholders": { "user": { "content": "$1", "example": "octocat" } } + }, + "tokenInvalid": { "message": "Invalid token — authentication failed" }, + "tokenValidationFailed": { + "message": "Validation failed (HTTP $status$)", + "placeholders": { "status": { "content": "$1", "example": "500" } } + }, + "tokenNetworkError": { "message": "Network error — could not reach GitHub API" }, + "saveFailed": { "message": "Save failed" }, + "saved": { "message": "Saved!" }, + + "releases": { "message": "Releases" }, + "approveNow": { "message": "approve now" }, + "approveDialogTitle": { "message": "Approve this pull request?" }, + "approveCommentPlaceholder": { "message": "Leave a comment (optional)" }, + "cancel": { "message": "Cancel" }, + "approve": { "message": "Approve" }, + "approving": { "message": "Approving..." }, + "approveFailed": { + "message": "Failed to approve PR: $error$", + "placeholders": { "error": { "content": "$1", "example": "Not Found" } } + }, + + "commitTagTitle": { + "message": "Tag: $name$", + "placeholders": { "name": { "content": "$1", "example": "v1.0.0" } } + }, + "copied": { "message": "Copied!" }, + "branchCopyTitle": { "message": "Click to copy branch name" }, + + "pinRepository": { "message": "Pin repository" }, + "unpinRepository": { "message": "Unpin repository" }, + + "watchers": { "message": "Watchers" }, + "forks": { "message": "Forks" }, + "stargazers": { "message": "Stargazers" }, + "viewAll": { "message": "View all" }, + "noWatchers": { "message": "No watchers yet" }, + "noStargazers": { "message": "No stargazers yet" }, + "noForks": { "message": "No forks yet" }, + "failedToLoad": { "message": "Failed to load" }, + + "collapseTree": { "message": "Collapse tree" }, + "expandTree": { "message": "Expand tree" }, + "collapseTreeTitle": { "message": "Collapse all folders in file tree" }, + "expandTreeTitle": { "message": "Expand all folders in file tree" }, + "expandAllFiles": { "message": "Expand all files" }, + "collapseAllFiles": { "message": "Collapse all files" }, + "expandAllFilesTitle": { "message": "Expand all file diffs" }, + "collapseAllFilesTitle": { "message": "Collapse all file diffs" }, + + "reviewAllResolved": { "message": "✓ All resolved" }, + "reviewAllResolvedTitle": { + "message": "$count$ review thread(s), all resolved", + "placeholders": { "count": { "content": "$1", "example": "4" } } + }, + "reviewUnresolved": { + "message": "$count$ unresolved", + "placeholders": { "count": { "content": "$1", "example": "3" } } + }, + "reviewHeaderOne": { + "message": "$count$ unresolved thread", + "placeholders": { "count": { "content": "$1", "example": "1" } } + }, + "reviewHeaderOther": { + "message": "$count$ unresolved threads", + "placeholders": { "count": { "content": "$1", "example": "3" } } + }, + "reviewOutdated": { "message": "outdated" }, + "reviewGeneralComment": { "message": "general comment" }, + "reviewNoComment": { "message": "(no comment)" }, + "reviewMore": { + "message": "+$count$ more", + "placeholders": { "count": { "content": "$1", "example": "5" } } + }, + "reviewLoadFailed": { "message": "Couldn't load thread details." }, + "loading": { "message": "Loading…" }, + + "diffFilesOne": { + "message": "$count$ file", + "placeholders": { "count": { "content": "$1", "example": "1" } } + }, + "diffFilesOther": { + "message": "$count$ files", + "placeholders": { "count": { "content": "$1", "example": "5" } } + }, + "diffStatsTitle": { + "message": "$add$ additions, $del$ deletions", + "placeholders": { + "add": { "content": "$1", "example": "223" }, + "del": { "content": "$2", "example": "114" } + } + }, + "diffStatsTitleWithFiles": { + "message": "$add$ additions, $del$ deletions across $files$", + "placeholders": { + "add": { "content": "$1", "example": "223" }, + "del": { "content": "$2", "example": "114" }, + "files": { "content": "$3", "example": "5 files" } + } + } +} diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json new file mode 100644 index 0000000..57c124f --- /dev/null +++ b/src/_locales/zh_CN/messages.json @@ -0,0 +1,193 @@ +{ + "appName": { + "message": "Better GitHub", + "description": "Extension name, shown in the Chrome Web Store and extension manager." + }, + "appDesc": { + "message": "改善 GitHub PR、Issue 等页面的使用体验", + "description": "Extension description, shown in the Chrome Web Store and extension manager." + }, + "actionTitle": { + "message": "Better GitHub - 点击查看设置", + "description": "Toolbar button tooltip." + }, + + "settingsTitle": { "message": "设置" }, + "language": { "message": "语言" }, + "langFollowBrowser": { "message": "跟随浏览器" }, + "openSourced": { "message": "本项目已开源!" }, + "viewOnGithub": { "message": "在 GitHub 上查看" }, + "tokenLabel": { "message": "GitHub 个人访问令牌" }, + "save": { "message": "保存" }, + "tokenHintIntro": { + "message": "⬆️ 访问私有仓库需要令牌。插件本身绝不会存储你的令牌或访问你的数据。" + }, + "tokenClassicTitle": { "message": "创建带 scopes 的经典令牌" }, + "tokenFineTitle": { "message": "创建带 permissions 的细粒度令牌" }, + "tokenOr": { "message": "或" }, + + "features": { "message": "功能" }, + "searchFeatures": { "message": "搜索功能" }, + "searchFeaturesPlaceholder": { "message": "搜索功能…" }, + "closeSearch": { "message": "关闭搜索" }, + + "groupHome": { "message": "首页" }, + "groupPRsIssues": { "message": "PR 和 Issue" }, + "groupPRDetails": { "message": "PR 详情" }, + "groupCommits": { "message": "提交" }, + "groupRepository": { "message": "仓库" }, + + "featBetterTopReposName": { "message": "增强的热门仓库" }, + "featBetterTopReposDesc": { + "message": "自动展开「热门仓库」列表,并将你喜爱的仓库置顶。" + }, + "featDefaultSortName": { "message": "默认按更新时间排序" }, + "featDefaultSortDesc": { + "message": "PR 和 Issue 列表按最近更新排序,而非创建时间。" + }, + "featPrBranchNamesName": { "message": "PR 分支名" }, + "featPrBranchNamesDesc": { + "message": "在每个 PR 标题旁显示源分支名,点击徽标即可复制。" + }, + "featPrReviewStatusName": { "message": "PR 评审状态" }, + "featPrReviewStatusDesc": { + "message": "在 PR 列表上显示评审对话的解决状态(已解决 / 未解决)。需要令牌。" + }, + "featPrDiffStatsName": { "message": "PR 差异统计" }, + "featPrDiffStatsDesc": { + "message": "在 PR 列表上显示新增、删除行数和文件数(如 +223 −114 · 5 files)。需要令牌。" + }, + "featPrLabelPositionName": { "message": "PR 标签位置" }, + "featPrLabelPositionDesc": { + "message": "将标签移到 PR 标题前面,便于查看和扫读。" + }, + "featPrApproveNowName": { "message": "PR 快速批准" }, + "featPrApproveNowDesc": { + "message": "在 PR 详情页的 Reviewers 侧边栏添加「立即批准」快捷入口。需要令牌。" + }, + "featPrCollapseExpandName": { "message": "折叠/展开所有文件" }, + "featPrCollapseExpandDesc": { + "message": "在 PR、提交和对比页面添加一个折叠或展开所有文件差异的按钮。" + }, + "featCommitTagsName": { "message": "提交标签" }, + "featCommitTagsDesc": { + "message": "在提交列表页显示 git 标签,便于识别。" + }, + "featCommitDiffStatsName": { "message": "提交差异统计" }, + "featCommitDiffStatsDesc": { + "message": "在提交列表页显示新增、删除行数和文件数(如 +223 −114 · 5 files)。需要令牌。" + }, + "featReleaseTabName": { "message": "发布标签页" }, + "featReleaseTabDesc": { + "message": "在仓库导航栏添加一个 Releases 标签页,方便快速访问。" + }, + "featWatchForkStarName": { "message": "Watch/Fork/Star 弹窗" }, + "featWatchForkStarDesc": { + "message": "悬停在 Watch、Fork 或 Star 数字上,即可在弹窗中预览列表。" + }, + + "validatingToken": { "message": "正在验证令牌…" }, + "tokenValid": { + "message": "有效 — 已认证为 $user$", + "placeholders": { "user": { "content": "$1", "example": "octocat" } } + }, + "tokenInvalid": { "message": "无效令牌 — 认证失败" }, + "tokenValidationFailed": { + "message": "验证失败(HTTP $status$)", + "placeholders": { "status": { "content": "$1", "example": "500" } } + }, + "tokenNetworkError": { "message": "网络错误 — 无法连接 GitHub API" }, + "saveFailed": { "message": "保存失败" }, + "saved": { "message": "已保存!" }, + + "releases": { "message": "发布" }, + "approveNow": { "message": "立即批准" }, + "approveDialogTitle": { "message": "批准此 Pull Request?" }, + "approveCommentPlaceholder": { "message": "添加评论(可选)" }, + "cancel": { "message": "取消" }, + "approve": { "message": "批准" }, + "approving": { "message": "批准中…" }, + "approveFailed": { + "message": "批准 PR 失败:$error$", + "placeholders": { "error": { "content": "$1", "example": "Not Found" } } + }, + + "commitTagTitle": { + "message": "标签:$name$", + "placeholders": { "name": { "content": "$1", "example": "v1.0.0" } } + }, + "copied": { "message": "已复制!" }, + "branchCopyTitle": { "message": "点击复制分支名" }, + + "pinRepository": { "message": "置顶仓库" }, + "unpinRepository": { "message": "取消置顶" }, + + "watchers": { "message": "关注者" }, + "forks": { "message": "复刻" }, + "stargazers": { "message": "星标用户" }, + "viewAll": { "message": "查看全部" }, + "noWatchers": { "message": "暂无关注者" }, + "noStargazers": { "message": "暂无星标用户" }, + "noForks": { "message": "暂无复刻" }, + "failedToLoad": { "message": "加载失败" }, + + "collapseTree": { "message": "折叠目录树" }, + "expandTree": { "message": "展开目录树" }, + "collapseTreeTitle": { "message": "折叠文件树中的所有文件夹" }, + "expandTreeTitle": { "message": "展开文件树中的所有文件夹" }, + "expandAllFiles": { "message": "展开所有文件" }, + "collapseAllFiles": { "message": "折叠所有文件" }, + "expandAllFilesTitle": { "message": "展开所有文件差异" }, + "collapseAllFilesTitle": { "message": "折叠所有文件差异" }, + + "reviewAllResolved": { "message": "✓ 全部已解决" }, + "reviewAllResolvedTitle": { + "message": "$count$ 条评审对话,全部已解决", + "placeholders": { "count": { "content": "$1", "example": "4" } } + }, + "reviewUnresolved": { + "message": "$count$ 条未解决", + "placeholders": { "count": { "content": "$1", "example": "3" } } + }, + "reviewHeaderOne": { + "message": "$count$ 条未解决的对话", + "placeholders": { "count": { "content": "$1", "example": "1" } } + }, + "reviewHeaderOther": { + "message": "$count$ 条未解决的对话", + "placeholders": { "count": { "content": "$1", "example": "3" } } + }, + "reviewOutdated": { "message": "已过时" }, + "reviewGeneralComment": { "message": "总体评论" }, + "reviewNoComment": { "message": "(无评论)" }, + "reviewMore": { + "message": "还有 $count$ 条", + "placeholders": { "count": { "content": "$1", "example": "5" } } + }, + "reviewLoadFailed": { "message": "无法加载对话详情。" }, + "loading": { "message": "加载中…" }, + + "diffFilesOne": { + "message": "$count$ 个文件", + "placeholders": { "count": { "content": "$1", "example": "1" } } + }, + "diffFilesOther": { + "message": "$count$ 个文件", + "placeholders": { "count": { "content": "$1", "example": "5" } } + }, + "diffStatsTitle": { + "message": "新增 $add$ 行,删除 $del$ 行", + "placeholders": { + "add": { "content": "$1", "example": "223" }, + "del": { "content": "$2", "example": "114" } + } + }, + "diffStatsTitleWithFiles": { + "message": "新增 $add$ 行,删除 $del$ 行,共 $files$", + "placeholders": { + "add": { "content": "$1", "example": "223" }, + "del": { "content": "$2", "example": "114" }, + "files": { "content": "$3", "example": "5 个文件" } + } + } +} diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json new file mode 100644 index 0000000..12e2575 --- /dev/null +++ b/src/_locales/zh_TW/messages.json @@ -0,0 +1,193 @@ +{ + "appName": { + "message": "Better GitHub", + "description": "Extension name, shown in the Chrome Web Store and extension manager." + }, + "appDesc": { + "message": "改善 GitHub PR、Issue 等頁面的使用體驗", + "description": "Extension description, shown in the Chrome Web Store and extension manager." + }, + "actionTitle": { + "message": "Better GitHub - 點擊查看設定", + "description": "Toolbar button tooltip." + }, + + "settingsTitle": { "message": "設定" }, + "language": { "message": "語言" }, + "langFollowBrowser": { "message": "跟隨瀏覽器" }, + "openSourced": { "message": "本專案已開源!" }, + "viewOnGithub": { "message": "在 GitHub 上查看" }, + "tokenLabel": { "message": "GitHub 個人存取權杖" }, + "save": { "message": "儲存" }, + "tokenHintIntro": { + "message": "⬆️ 存取私有儲存庫需要權杖。擴充功能本身絕不會儲存你的權杖或存取你的資料。" + }, + "tokenClassicTitle": { "message": "建立帶有 scopes 的傳統權杖" }, + "tokenFineTitle": { "message": "建立帶有 permissions 的細粒度權杖" }, + "tokenOr": { "message": "或" }, + + "features": { "message": "功能" }, + "searchFeatures": { "message": "搜尋功能" }, + "searchFeaturesPlaceholder": { "message": "搜尋功能…" }, + "closeSearch": { "message": "關閉搜尋" }, + + "groupHome": { "message": "首頁" }, + "groupPRsIssues": { "message": "PR 和 Issue" }, + "groupPRDetails": { "message": "PR 詳情" }, + "groupCommits": { "message": "提交" }, + "groupRepository": { "message": "儲存庫" }, + + "featBetterTopReposName": { "message": "增強的熱門儲存庫" }, + "featBetterTopReposDesc": { + "message": "自動展開「熱門儲存庫」清單,並將你喜愛的儲存庫置頂。" + }, + "featDefaultSortName": { "message": "預設依更新時間排序" }, + "featDefaultSortDesc": { + "message": "PR 和 Issue 清單依最近更新排序,而非建立時間。" + }, + "featPrBranchNamesName": { "message": "PR 分支名稱" }, + "featPrBranchNamesDesc": { + "message": "在每個 PR 標題旁顯示來源分支名稱,點擊徽章即可複製。" + }, + "featPrReviewStatusName": { "message": "PR 審查狀態" }, + "featPrReviewStatusDesc": { + "message": "在 PR 清單上顯示審查對話的解決狀態(已解決 / 未解決)。需要權杖。" + }, + "featPrDiffStatsName": { "message": "PR 差異統計" }, + "featPrDiffStatsDesc": { + "message": "在 PR 清單上顯示新增、刪除行數和檔案數(如 +223 −114 · 5 files)。需要權杖。" + }, + "featPrLabelPositionName": { "message": "PR 標籤位置" }, + "featPrLabelPositionDesc": { + "message": "將標籤移到 PR 標題前面,便於查看和掃讀。" + }, + "featPrApproveNowName": { "message": "PR 快速核准" }, + "featPrApproveNowDesc": { + "message": "在 PR 詳情頁的 Reviewers 側邊欄新增「立即核准」快速入口。需要權杖。" + }, + "featPrCollapseExpandName": { "message": "摺疊/展開所有檔案" }, + "featPrCollapseExpandDesc": { + "message": "在 PR、提交和比較頁面新增一個摺疊或展開所有檔案差異的按鈕。" + }, + "featCommitTagsName": { "message": "提交標籤" }, + "featCommitTagsDesc": { + "message": "在提交清單頁顯示 git 標籤,便於識別。" + }, + "featCommitDiffStatsName": { "message": "提交差異統計" }, + "featCommitDiffStatsDesc": { + "message": "在提交清單頁顯示新增、刪除行數和檔案數(如 +223 −114 · 5 files)。需要權杖。" + }, + "featReleaseTabName": { "message": "發佈分頁" }, + "featReleaseTabDesc": { + "message": "在儲存庫導覽列新增一個 Releases 分頁,方便快速存取。" + }, + "featWatchForkStarName": { "message": "Watch/Fork/Star 彈出視窗" }, + "featWatchForkStarDesc": { + "message": "將游標停在 Watch、Fork 或 Star 數字上,即可在彈出視窗中預覽清單。" + }, + + "validatingToken": { "message": "正在驗證權杖…" }, + "tokenValid": { + "message": "有效 — 已驗證為 $user$", + "placeholders": { "user": { "content": "$1", "example": "octocat" } } + }, + "tokenInvalid": { "message": "無效權杖 — 驗證失敗" }, + "tokenValidationFailed": { + "message": "驗證失敗(HTTP $status$)", + "placeholders": { "status": { "content": "$1", "example": "500" } } + }, + "tokenNetworkError": { "message": "網路錯誤 — 無法連線 GitHub API" }, + "saveFailed": { "message": "儲存失敗" }, + "saved": { "message": "已儲存!" }, + + "releases": { "message": "發佈" }, + "approveNow": { "message": "立即核准" }, + "approveDialogTitle": { "message": "核准此 Pull Request?" }, + "approveCommentPlaceholder": { "message": "新增留言(選填)" }, + "cancel": { "message": "取消" }, + "approve": { "message": "核准" }, + "approving": { "message": "核准中…" }, + "approveFailed": { + "message": "核准 PR 失敗:$error$", + "placeholders": { "error": { "content": "$1", "example": "Not Found" } } + }, + + "commitTagTitle": { + "message": "標籤:$name$", + "placeholders": { "name": { "content": "$1", "example": "v1.0.0" } } + }, + "copied": { "message": "已複製!" }, + "branchCopyTitle": { "message": "點擊複製分支名稱" }, + + "pinRepository": { "message": "置頂儲存庫" }, + "unpinRepository": { "message": "取消置頂" }, + + "watchers": { "message": "關注者" }, + "forks": { "message": "復刻" }, + "stargazers": { "message": "星標使用者" }, + "viewAll": { "message": "查看全部" }, + "noWatchers": { "message": "尚無關注者" }, + "noStargazers": { "message": "尚無星標使用者" }, + "noForks": { "message": "尚無復刻" }, + "failedToLoad": { "message": "載入失敗" }, + + "collapseTree": { "message": "摺疊目錄樹" }, + "expandTree": { "message": "展開目錄樹" }, + "collapseTreeTitle": { "message": "摺疊檔案樹中的所有資料夾" }, + "expandTreeTitle": { "message": "展開檔案樹中的所有資料夾" }, + "expandAllFiles": { "message": "展開所有檔案" }, + "collapseAllFiles": { "message": "摺疊所有檔案" }, + "expandAllFilesTitle": { "message": "展開所有檔案差異" }, + "collapseAllFilesTitle": { "message": "摺疊所有檔案差異" }, + + "reviewAllResolved": { "message": "✓ 全部已解決" }, + "reviewAllResolvedTitle": { + "message": "$count$ 則審查對話,全部已解決", + "placeholders": { "count": { "content": "$1", "example": "4" } } + }, + "reviewUnresolved": { + "message": "$count$ 則未解決", + "placeholders": { "count": { "content": "$1", "example": "3" } } + }, + "reviewHeaderOne": { + "message": "$count$ 則未解決的對話", + "placeholders": { "count": { "content": "$1", "example": "1" } } + }, + "reviewHeaderOther": { + "message": "$count$ 則未解決的對話", + "placeholders": { "count": { "content": "$1", "example": "3" } } + }, + "reviewOutdated": { "message": "已過時" }, + "reviewGeneralComment": { "message": "整體留言" }, + "reviewNoComment": { "message": "(無留言)" }, + "reviewMore": { + "message": "還有 $count$ 則", + "placeholders": { "count": { "content": "$1", "example": "5" } } + }, + "reviewLoadFailed": { "message": "無法載入對話詳情。" }, + "loading": { "message": "載入中…" }, + + "diffFilesOne": { + "message": "$count$ 個檔案", + "placeholders": { "count": { "content": "$1", "example": "1" } } + }, + "diffFilesOther": { + "message": "$count$ 個檔案", + "placeholders": { "count": { "content": "$1", "example": "5" } } + }, + "diffStatsTitle": { + "message": "新增 $add$ 行,刪除 $del$ 行", + "placeholders": { + "add": { "content": "$1", "example": "223" }, + "del": { "content": "$2", "example": "114" } + } + }, + "diffStatsTitleWithFiles": { + "message": "新增 $add$ 行,刪除 $del$ 行,共 $files$", + "placeholders": { + "add": { "content": "$1", "example": "223" }, + "del": { "content": "$2", "example": "114" }, + "files": { "content": "$3", "example": "5 個檔案" } + } + } +} diff --git a/src/content.ts b/src/content.ts index c12831c..133f7c4 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,4 +1,5 @@ import { onPageReady, startNavigation } from "./lib/navigation"; +import { initLocale, setLocale, LOCALE_KEY, type LocalePref } from "./lib/i18n"; import { injectPRBranchNames } from "./features/pr-branch-names"; import { injectPRReviewStatus } from "./features/pr-review-status"; import { injectPRDiffStats } from "./features/pr-diff-stats"; @@ -125,6 +126,10 @@ function injectFeature(key: FeatureKey): void { if (isExtensionValid()) { chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; + // Picked-up on the next injection/navigation; a refresh re-renders all text. + if (LOCALE_KEY in changes) { + setLocale((changes[LOCALE_KEY].newValue as LocalePref) ?? "auto"); + } for (const key of FEATURE_KEYS) { if (!(key in changes)) continue; const enabled = changes[key].newValue !== false; @@ -149,6 +154,9 @@ onPageReady(async () => { // skeleton-reserve.css matches the right row selector after SPA navs. applyPageMarker(); + // Resolve the stored language preference before any UI text is injected. + await initLocale(); + // Always-on features injectFileAgeColor(); diff --git a/src/features/better-top-repos.ts b/src/features/better-top-repos.ts index b126275..d26ffd2 100644 --- a/src/features/better-top-repos.ts +++ b/src/features/better-top-repos.ts @@ -1,3 +1,5 @@ +import { t } from "../lib/i18n"; + const MIN_REPOS = 20; const MAX_SHOW_MORE_CLICKS = 5; const PIN_INJECTED_ATTR = "data-better-github-pin-injected"; @@ -186,7 +188,7 @@ function createPinButton(isPinned: boolean): HTMLButtonElement { btn.className = "better-github-pin-btn"; if (isPinned) btn.classList.add("pinned"); btn.innerHTML = isPinned ? PIN_SVG_FILLED : PIN_SVG_OUTLINE; - btn.title = isPinned ? "Unpin repository" : "Pin repository"; + btn.title = isPinned ? t("unpinRepository") : t("pinRepository"); btn.type = "button"; return btn; } @@ -199,12 +201,12 @@ async function togglePin(repoName: string, btn: HTMLButtonElement, list: RepoLis pinned.splice(index, 1); btn.classList.remove("pinned"); btn.innerHTML = PIN_SVG_OUTLINE; - btn.title = "Pin repository"; + btn.title = t("pinRepository"); } else { pinned.push(repoName); btn.classList.add("pinned"); btn.innerHTML = PIN_SVG_FILLED; - btn.title = "Unpin repository"; + btn.title = t("unpinRepository"); } savePinnedRepos(pinned); diff --git a/src/features/commit-tags.ts b/src/features/commit-tags.ts index ccf72b2..4c7d5d5 100644 --- a/src/features/commit-tags.ts +++ b/src/features/commit-tags.ts @@ -2,6 +2,7 @@ import { isCommitsListPage, getRepoInfo } from "../lib/page-detect"; import { fetchRepoTags } from "../lib/github-api"; import { escapeHtml } from "../lib/utils"; import { collectCommitRows, MAIN_CONTENT_INNER_SELECTOR } from "../lib/commit-dom"; +import { t } from "../lib/i18n"; const TAG_CLASS = "better-github-commit-tag"; const TAG_ROW_CLASS = "better-github-commit-tag-row"; @@ -61,7 +62,7 @@ export async function injectCommitTags(): Promise { const badge = document.createElement("a"); badge.className = TAG_CLASS; badge.href = `/${info.owner}/${info.repo}/releases/tag/${encodeURIComponent(tagName)}`; - badge.title = `Tag: ${tagName}`; + badge.title = t("commitTagTitle", tagName); badge.innerHTML = `${TAG_ICON}${escapeHtml(tagName)}`; tagRow.appendChild(badge); } diff --git a/src/features/pr-approve-now.ts b/src/features/pr-approve-now.ts index be05856..c124fa0 100644 --- a/src/features/pr-approve-now.ts +++ b/src/features/pr-approve-now.ts @@ -1,5 +1,6 @@ import { getRepoInfo, isPRDetailPage, getPRNumber } from "../lib/page-detect"; import { approvePR } from "../lib/github-api"; +import { t } from "../lib/i18n"; const APPROVE_BTN_CLASS = "better-github-approve-now"; const DIALOG_OVERLAY_CLASS = "better-github-approve-dialog-overlay"; @@ -21,7 +22,7 @@ export async function injectPRApproveNow(): Promise { const link = document.createElement("a"); link.className = APPROVE_BTN_CLASS; - link.textContent = "approve now"; + link.textContent = t("approveNow"); link.href = "#"; link.addEventListener("click", (e) => { e.preventDefault(); @@ -77,23 +78,23 @@ function showApproveDialog(owner: string, repo: string, prNumber: number): void dialog.className = "better-github-approve-dialog"; const title = document.createElement("h3"); - title.textContent = "Approve this pull request?"; + title.textContent = t("approveDialogTitle"); const input = document.createElement("input"); input.type = "text"; input.className = "better-github-approve-input"; - input.placeholder = "Leave a comment (optional)"; + input.placeholder = t("approveCommentPlaceholder"); const actions = document.createElement("div"); actions.className = "better-github-approve-actions"; const cancelBtn = document.createElement("button"); cancelBtn.className = "better-github-approve-cancel"; - cancelBtn.textContent = "Cancel"; + cancelBtn.textContent = t("cancel"); const submitBtn = document.createElement("button"); submitBtn.className = "better-github-approve-submit"; - submitBtn.textContent = "Approve"; + submitBtn.textContent = t("approve"); actions.append(cancelBtn, submitBtn); dialog.append(title, input, actions); @@ -112,7 +113,7 @@ function showApproveDialog(owner: string, repo: string, prNumber: number): void const handleSubmit = async () => { const body = input.value.trim(); submitBtn.disabled = true; - submitBtn.textContent = "Approving..."; + submitBtn.textContent = t("approving"); const result = await approvePR(owner, repo, prNumber, body); if (result.success) { @@ -120,8 +121,8 @@ function showApproveDialog(owner: string, repo: string, prNumber: number): void location.reload(); } else { submitBtn.disabled = false; - submitBtn.textContent = "Approve"; - alert(`Failed to approve PR: ${result.error}`); + submitBtn.textContent = t("approve"); + alert(t("approveFailed", result.error ?? "")); } }; diff --git a/src/features/pr-branch-names.ts b/src/features/pr-branch-names.ts index 8990d59..0a93423 100644 --- a/src/features/pr-branch-names.ts +++ b/src/features/pr-branch-names.ts @@ -2,6 +2,7 @@ import { isPRListPage, getRepoInfo, getPRListParams } from "../lib/page-detect"; import { fetchPRBranches } from "../lib/github-api"; import { getOrCreateInfoRow } from "../lib/info-row"; import { clearSkeletons } from "../lib/info-row-skeleton"; +import { t } from "../lib/i18n"; const BADGE_CLASS = "better-github-branch-badge"; const COPIED_CLASS = "better-github-branch-copied"; @@ -26,7 +27,7 @@ function attachDelegatedClickHandler(): void { try { await navigator.clipboard.writeText(branchName); badge.classList.add(COPIED_CLASS); - badge.textContent = "Copied!"; + badge.textContent = t("copied"); setTimeout(() => { badge.textContent = badge.dataset.branch || branchName; badge.classList.remove(COPIED_CLASS); @@ -75,7 +76,7 @@ export async function injectPRBranchNames(): Promise { badge.className = BADGE_CLASS; badge.textContent = branchName; badge.dataset.branch = branchName; - badge.title = "Click to copy branch name"; + badge.title = t("branchCopyTitle"); infoRow.appendChild(badge); } diff --git a/src/features/pr-collapse-expand.ts b/src/features/pr-collapse-expand.ts index 34debdf..a618f44 100644 --- a/src/features/pr-collapse-expand.ts +++ b/src/features/pr-collapse-expand.ts @@ -1,4 +1,5 @@ import { isDiffPage, isPRFilesChangedPage } from "../lib/page-detect"; +import { t } from "../lib/i18n"; const TREE_BTN_CLASS = "better-github-toggle-tree"; const DIFF_BTN_CLASS = "better-github-collapse-expand"; @@ -111,11 +112,9 @@ function injectTreeToggle(): void { function updateLabel(): void { const expanded = areMajorityExpanded(); btn.innerHTML = expanded - ? `${makeIcon(ICON_FOLD_DOWN)} Collapse tree` - : `${makeIcon(ICON_UNFOLD)} Expand tree`; - btn.title = expanded - ? "Collapse all folders in file tree" - : "Expand all folders in file tree"; + ? `${makeIcon(ICON_FOLD_DOWN)} ${t("collapseTree")}` + : `${makeIcon(ICON_UNFOLD)} ${t("expandTree")}`; + btn.title = expanded ? t("collapseTreeTitle") : t("expandTreeTitle"); } updateLabel(); @@ -303,9 +302,7 @@ function updateDiffButtonLabel(btn: HTMLButtonElement): void { const collapsed = diffIntent !== null ? diffIntent === "collapsed" : getMajorityCollapsed(); btn.innerHTML = collapsed - ? `${makeIcon(ICON_UNFOLD)} Expand all files` - : `${makeIcon(ICON_FOLD_DOWN)} Collapse all files`; - btn.title = collapsed - ? "Expand all file diffs" - : "Collapse all file diffs"; + ? `${makeIcon(ICON_UNFOLD)} ${t("expandAllFiles")}` + : `${makeIcon(ICON_FOLD_DOWN)} ${t("collapseAllFiles")}`; + btn.title = collapsed ? t("expandAllFilesTitle") : t("collapseAllFilesTitle"); } diff --git a/src/features/pr-review-status.ts b/src/features/pr-review-status.ts index 9b343df..5e4816c 100644 --- a/src/features/pr-review-status.ts +++ b/src/features/pr-review-status.ts @@ -2,6 +2,8 @@ import { isPRListPage, getRepoInfo } from "../lib/page-detect"; import { fetchPRReviewStatuses, fetchReviewThreadDetails } from "../lib/github-api"; import type { ReviewThreadDetail } from "../lib/messages"; import { getOrCreateInfoRow } from "../lib/info-row"; +// Aliased to `i18n` because this module already uses `t` as a thread loop var. +import { t as i18n } from "../lib/i18n"; const STATUS_CLASS = "better-github-review-status"; const POPOVER_CLASS = "better-github-review-popover"; @@ -137,7 +139,10 @@ function buildThreadRows(threads: ReviewThreadDetail[]): Node[] { icon.className = "better-github-review-popover-header-icon"; icon.innerHTML = COMMENT_ICON; const headerLabel = document.createElement("span"); - headerLabel.textContent = `${threads.length} unresolved thread${threads.length === 1 ? "" : "s"}`; + headerLabel.textContent = i18n( + threads.length === 1 ? "reviewHeaderOne" : "reviewHeaderOther", + String(threads.length), + ); header.append(icon, headerLabel); nodes.push(header); @@ -152,7 +157,7 @@ function buildThreadRows(threads: ReviewThreadDetail[]): Node[] { const head = document.createElement("div"); head.className = "better-github-review-popover-loc"; - const file = t.path ? basename(t.path) : "general comment"; + const file = t.path ? basename(t.path) : i18n("reviewGeneralComment"); const locText = document.createElement("span"); locText.className = "better-github-review-popover-loc-text"; locText.textContent = t.line != null ? `${file}:${t.line}` : file; @@ -163,7 +168,7 @@ function buildThreadRows(threads: ReviewThreadDetail[]): Node[] { if (t.isOutdated) { const tag = document.createElement("span"); tag.className = "better-github-review-popover-outdated"; - tag.textContent = "outdated"; + tag.textContent = i18n("reviewOutdated"); head.appendChild(tag); } @@ -171,7 +176,8 @@ function buildThreadRows(threads: ReviewThreadDetail[]): Node[] { body.className = "better-github-review-popover-body"; const author = t.author ? `@${t.author}` : ""; const snippet = t.snippet.trim(); - body.textContent = author && snippet ? `${author}: ${snippet}` : author || snippet || "(no comment)"; + body.textContent = + author && snippet ? `${author}: ${snippet}` : author || snippet || i18n("reviewNoComment"); item.appendChild(head); item.appendChild(body); @@ -181,7 +187,7 @@ function buildThreadRows(threads: ReviewThreadDetail[]): Node[] { if (threads.length > MAX_POPOVER_ITEMS) { const more = document.createElement("div"); more.className = "better-github-review-popover-more"; - more.textContent = `+${threads.length - MAX_POPOVER_ITEMS} more`; + more.textContent = i18n("reviewMore", String(threads.length - MAX_POPOVER_ITEMS)); nodes.push(more); } @@ -209,7 +215,7 @@ function setupPopover( const load = async () => { if (state !== "idle") return; // in flight or already populated state = "loading"; - setPopoverBody(popover, buildMessage("Loading…")); + setPopoverBody(popover, buildMessage(i18n("loading"))); let details: ReviewThreadDetail[] = []; try { @@ -222,7 +228,7 @@ function setupPopover( // thread, so an empty result means the detail fetch failed (or the threads // were resolved since the list query). Either way, let a later click retry. if (details.length === 0) { - setPopoverBody(popover, buildMessage("Couldn't load thread details.")); + setPopoverBody(popover, buildMessage(i18n("reviewLoadFailed"))); state = "idle"; return; } @@ -281,11 +287,11 @@ export async function injectPRReviewStatus(): Promise { if (allResolved) { badge.classList.add("better-github-review-resolved"); - badge.textContent = `✓ All resolved`; - badge.title = `${status.totalThreads} review thread(s), all resolved`; + badge.textContent = i18n("reviewAllResolved"); + badge.title = i18n("reviewAllResolvedTitle", String(status.totalThreads)); } else { badge.classList.add("better-github-review-unresolved"); - badge.textContent = `${unresolved} unresolved`; + badge.textContent = i18n("reviewUnresolved", String(unresolved)); // The click popover is the richer summary and is loaded lazily on open; // we deliberately set no native `title` here, as it would hover-overlap // the open popover. diff --git a/src/features/release-tab.ts b/src/features/release-tab.ts index cb13b98..6d2d546 100644 --- a/src/features/release-tab.ts +++ b/src/features/release-tab.ts @@ -1,4 +1,5 @@ import { isRepoPage, getRepoInfo, isReleasesPage } from "../lib/page-detect"; +import { t } from "../lib/i18n"; const TAB_CLASS = "better-github-releases-tab"; @@ -64,16 +65,17 @@ export function injectReleasesTab(): void { } // Update text content - find the text span + const releasesLabel = t("releases"); const textSpan = releasesTab.querySelector("[data-content]") as HTMLElement; if (textSpan) { - textSpan.textContent = "Releases"; - textSpan.setAttribute("data-content", "Releases"); + textSpan.textContent = releasesLabel; + textSpan.setAttribute("data-content", releasesLabel); } else { // Fallback: find span that contains text const spans = releasesTab.querySelectorAll("span"); for (const span of spans) { if (span.children.length === 0 && span.textContent?.trim()) { - span.textContent = "Releases"; + span.textContent = releasesLabel; break; } } diff --git a/src/features/watch-fork-star-popup.ts b/src/features/watch-fork-star-popup.ts index 9249943..89d94ee 100644 --- a/src/features/watch-fork-star-popup.ts +++ b/src/features/watch-fork-star-popup.ts @@ -1,6 +1,7 @@ import { getRepoInfo } from "../lib/page-detect"; import { fetchStargazers, fetchWatchers, fetchForks } from "../lib/github-api"; import { escapeHtml } from "../lib/utils"; +import { t } from "../lib/i18n"; import type { ForkInfo } from "../lib/github-api"; const WRAP_CLASS = "bg-wfs-counter-wrap"; @@ -61,7 +62,7 @@ function createPopupElement(config: PopupConfig): HTMLDivElement { ${createSkeletonRows()} `; return popup; @@ -89,7 +90,7 @@ function renderUserList( function renderForks(list: HTMLElement, items: ForkInfo[]): void { if (items.length === 0) { - list.innerHTML = `
No forks yet
`; + list.innerHTML = `
${t("noForks")}
`; return; } list.innerHTML = items.map((fork) => ` @@ -122,7 +123,7 @@ function setupHover( loaded = true; loadData().catch(() => { const list = popup.querySelector(".bg-wfs-popup-list"); - if (list) list.innerHTML = `
Failed to load
`; + if (list) list.innerHTML = `
${t("failedToLoad")}
`; }); } } @@ -209,12 +210,12 @@ export function injectWatchForkStarPopup(): void { if (watchCounter) { const countText = watchCounter.textContent?.trim() || "0"; attachPopup(watchCounter, { - title: "Watchers", + title: t("watchers"), countText, viewAllUrl: `/${owner}/${repo}/watchers`, }, async (list) => { const data = await fetchWatchers(owner, repo); - renderUserList(list, data, "No watchers yet"); + renderUserList(list, data, t("noWatchers")); }); } @@ -223,7 +224,7 @@ export function injectWatchForkStarPopup(): void { if (forkCounter) { const countText = forkCounter.textContent?.trim() || "0"; attachPopup(forkCounter, { - title: "Forks", + title: t("forks"), countText, viewAllUrl: `/${owner}/${repo}/forks`, }, async (list) => { @@ -240,12 +241,12 @@ export function injectWatchForkStarPopup(): void { for (const starCounter of starCounters) { const countText = starCounter.textContent?.trim() || "0"; attachPopup(starCounter as HTMLElement, { - title: "Stargazers", + title: t("stargazers"), countText, viewAllUrl: `/${owner}/${repo}/stargazers`, }, async (list) => { if (!starData) starData = await fetchStargazers(owner, repo); - renderUserList(list, starData, "No stargazers yet"); + renderUserList(list, starData, t("noStargazers")); }); } } diff --git a/src/lib/diff-stats-badge.ts b/src/lib/diff-stats-badge.ts index 7b284be..036ced6 100644 --- a/src/lib/diff-stats-badge.ts +++ b/src/lib/diff-stats-badge.ts @@ -1,4 +1,4 @@ -import { pluralize } from "./utils"; +import { t } from "./i18n"; export interface DiffStats { additions: number; @@ -11,14 +11,14 @@ export function buildDiffStatsBadge(stats: DiffStats, wrapperClass: string): HTM const delStr = stats.deletions.toLocaleString(); const filesStr = stats.changedFiles !== null - ? `${stats.changedFiles.toLocaleString()} ${pluralize(stats.changedFiles, "file")}` + ? t(stats.changedFiles === 1 ? "diffFilesOne" : "diffFilesOther", stats.changedFiles.toLocaleString()) : null; const badge = document.createElement("span"); badge.className = wrapperClass; badge.title = filesStr - ? `${addStr} additions, ${delStr} deletions across ${filesStr}` - : `${addStr} additions, ${delStr} deletions`; + ? t("diffStatsTitleWithFiles", [addStr, delStr, filesStr]) + : t("diffStatsTitle", [addStr, delStr]); const add = document.createElement("span"); add.className = "better-github-diff-stats-add"; diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts new file mode 100644 index 0000000..1749791 --- /dev/null +++ b/src/lib/i18n.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { t, setLocale, localizePage } from "./i18n"; + +describe("i18n", () => { + afterEach(() => setLocale("en")); + + it("resolves English by default", () => { + setLocale("en"); + expect(t("approveNow")).toBe("approve now"); + expect(t("saved")).toBe("Saved!"); + expect(t("settingsTitle")).toBe("Settings"); + }); + + it("switches to Simplified Chinese via setLocale", () => { + setLocale("zh_CN"); + expect(t("approveNow")).toBe("立即批准"); + expect(t("settingsTitle")).toBe("设置"); + expect(t("langFollowBrowser")).toBe("跟随浏览器"); + }); + + it("switches to Traditional Chinese via setLocale", () => { + setLocale("zh_TW"); + expect(t("approveNow")).toBe("立即核准"); + expect(t("settingsTitle")).toBe("設定"); + expect(t("featPrCollapseExpandName")).toBe("摺疊/展開所有檔案"); + }); + + it("substitutes positional placeholders in both locales", () => { + setLocale("en"); + expect(t("tokenValid", "octocat")).toBe("Valid — authenticated as octocat"); + expect(t("diffStatsTitleWithFiles", ["1,234", "56", "3 files"])).toBe( + "1,234 additions, 56 deletions across 3 files", + ); + setLocale("zh_CN"); + expect(t("commitTagTitle", "v1.0.0")).toBe("标签:v1.0.0"); + }); + + it("returns empty string for an unknown key", () => { + setLocale("zh_CN"); + expect(t("__does_not_exist__")).toBe(""); + }); + + it("localizePage swaps text/title/placeholder by data attribute", () => { + setLocale("zh_CN"); + document.body.innerHTML = ` +

Settings

+ x + + + `; + localizePage(); + expect(document.querySelector("h2")?.textContent).toBe("设置"); + expect(document.querySelector("span")?.innerHTML).toContain("scopes"); + expect(document.querySelector("button")?.getAttribute("title")).toBe("关闭搜索"); + expect(document.querySelector("input")?.getAttribute("placeholder")).toBe("搜索功能…"); + document.body.innerHTML = ""; + }); +}); diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..928389c --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,117 @@ +// Lightweight i18n that, unlike chrome.i18n (which is locked to the browser UI +// locale), supports an in-extension manual language override stored in +// chrome.storage.local. Catalogs are bundled so t() stays synchronous; the +// active locale is resolved once from the stored preference (default: follow +// the browser locale) and can be changed at runtime via setLocale(). +import enMessages from "../_locales/en/messages.json"; +import zhCNMessages from "../_locales/zh_CN/messages.json"; +import zhTWMessages from "../_locales/zh_TW/messages.json"; + +interface MessageEntry { + message: string; + placeholders?: Record; +} +type Catalog = Record; + +const CATALOGS: Record = { + en: enMessages as unknown as Catalog, + zh_CN: zhCNMessages as unknown as Catalog, + zh_TW: zhTWMessages as unknown as Catalog, +}; + +/** Locale preference: "auto" follows the browser, or a concrete catalog id. */ +export type LocalePref = "auto" | "en" | "zh_CN" | "zh_TW"; +/** Order shown in the language picker. */ +export const LOCALE_OPTIONS: LocalePref[] = ["auto", "en", "zh_CN", "zh_TW"]; +/** chrome.storage.local key holding the LocalePref. */ +export const LOCALE_KEY = "locale"; + +/** Map the browser UI language to one of our catalogs (en fallback). */ +function resolveAuto(): string { + let ui = "en"; + try { + const g = globalThis as unknown as { chrome?: { i18n?: { getUILanguage?: () => string } } }; + ui = g.chrome?.i18n?.getUILanguage?.() || "en"; + } catch { + ui = "en"; + } + const lc = ui.toLowerCase(); + if (lc.startsWith("zh")) { + // Traditional for Taiwan / Hong Kong / Macau / explicit Hant script. + return /hant|-tw|-hk|-mo/.test(lc) ? "zh_TW" : "zh_CN"; + } + return "en"; +} + +let currentLocale = resolveAuto(); + +/** Apply a preference immediately (synchronous). */ +export function setLocale(pref: LocalePref): void { + currentLocale = pref === "auto" ? resolveAuto() : pref; +} + +/** + * Load the stored preference and apply it. Resolves to the stored pref + * ("auto" when unset or storage is unavailable) so callers can reflect it in a + * picker. Safe to call repeatedly. + */ +export function initLocale(): Promise { + return new Promise((resolve) => { + try { + chrome.storage.local.get([LOCALE_KEY], (result) => { + const pref = (result?.[LOCALE_KEY] as LocalePref) || "auto"; + setLocale(pref); + resolve(pref); + }); + } catch { + resolve("auto"); + } + }); +} + +function substitute(entry: MessageEntry, substitutions?: string | string[]): string { + let msg = entry.message; + if (entry.placeholders) { + for (const [name, def] of Object.entries(entry.placeholders)) { + msg = msg.split(`$${name}$`).join(def.content); + } + } + const subs = + substitutions == null ? [] : Array.isArray(substitutions) ? substitutions : [substitutions]; + return msg.replace(/\$(\d)/g, (_match, digit: string) => subs[Number(digit) - 1] ?? ""); +} + +/** Resolve a message key for the active locale, falling back to English. */ +export function t(key: string, substitutions?: string | string[]): string { + const entry = CATALOGS[currentLocale]?.[key] ?? CATALOGS.en[key]; + if (!entry) return ""; + return substitute(entry, substitutions); +} + +/** + * Localize a static HTML subtree in place. Elements opt in via data attributes: + * data-i18n → element.textContent + * data-i18n-html → element.innerHTML (for messages with inline markup) + * data-i18n-title → title attribute + * data-i18n-placeholder → placeholder attribute + * The English copy stays in the HTML as the no-JS default; this swaps in the + * active locale string and can be re-run after setLocale() to switch live. + */ +export function localizePage(root: ParentNode = document): void { + root.querySelectorAll("[data-i18n]").forEach((el) => { + const key = el.dataset.i18n; + if (key) el.textContent = t(key); + }); + root.querySelectorAll("[data-i18n-html]").forEach((el) => { + const key = el.dataset.i18nHtml; + if (key) el.innerHTML = t(key); + }); + root.querySelectorAll("[data-i18n-title]").forEach((el) => { + const key = el.dataset.i18nTitle; + if (key) el.setAttribute("title", t(key)); + }); + root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + const key = el.dataset.i18nPlaceholder; + if (key) el.setAttribute("placeholder", t(key)); + }); +} diff --git a/src/options.ts b/src/options.ts index 7f5b2c6..87e7e85 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,3 +1,21 @@ +import { t, localizePage, initLocale, setLocale, LOCALE_KEY, type LocalePref } from "./lib/i18n"; + +const langSelect = document.getElementById("langSelect") as HTMLSelectElement | null; + +// Resolve the stored locale preference, reflect it in the picker, then localize. +initLocale().then((pref) => { + if (langSelect) langSelect.value = pref; + localizePage(); +}); + +// Manual override: persist the choice and re-localize the page in place. +langSelect?.addEventListener("change", () => { + const pref = langSelect.value as LocalePref; + chrome.storage.local.set({ [LOCALE_KEY]: pref }); + setLocale(pref); + localizePage(); +}); + const tokenInput = document.getElementById("token") as HTMLInputElement; const tokenStatus = document.getElementById("tokenStatus") as HTMLDivElement; const saveBtn = document.getElementById("save") as HTMLButtonElement; @@ -48,7 +66,7 @@ async function validateToken(token: string) { if (token === lastValidatedToken) return; tokenStatus.className = "token-status checking"; - tokenStatus.textContent = "Validating token…"; + tokenStatus.textContent = t("validatingToken"); try { const response = await fetch("https://api.github.com/user", { @@ -58,20 +76,20 @@ async function validateToken(token: string) { if (response.ok) { const user = await response.json(); tokenStatus.className = "token-status valid"; - tokenStatus.textContent = `Valid — authenticated as ${user.login}`; + tokenStatus.textContent = t("tokenValid", user.login); lastValidatedToken = token; } else if (response.status === 401) { tokenStatus.className = "token-status invalid"; - tokenStatus.textContent = "Invalid token — authentication failed"; + tokenStatus.textContent = t("tokenInvalid"); lastValidatedToken = ""; } else { tokenStatus.className = "token-status invalid"; - tokenStatus.textContent = `Validation failed (HTTP ${response.status})`; + tokenStatus.textContent = t("tokenValidationFailed", String(response.status)); lastValidatedToken = ""; } } catch { tokenStatus.className = "token-status invalid"; - tokenStatus.textContent = "Network error — could not reach GitHub API"; + tokenStatus.textContent = t("tokenNetworkError"); lastValidatedToken = ""; } } @@ -104,9 +122,9 @@ saveBtn.addEventListener("click", () => { chrome.storage.local.set(settings, () => { if (chrome.runtime.lastError) { - showStatus("error", chrome.runtime.lastError.message ?? "Save failed"); + showStatus("error", chrome.runtime.lastError.message ?? t("saveFailed")); } else { - showStatus("success", "Saved!"); + showStatus("success", t("saved")); } }); }); diff --git a/static/manifest.json b/static/manifest.json index 57a4dd5..2f55e3c 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,14 +1,15 @@ { "manifest_version": 3, - "name": "Better GitHub", + "name": "__MSG_appName__", "version": "1.9.0", - "description": "Improve usability of GitHub PR, issue, and other pages", + "description": "__MSG_appDesc__", + "default_locale": "en", "permissions": ["storage"], "background": { "service_worker": "service-worker.js" }, "action": { - "default_title": "Better GitHub - Click to view settings" + "default_title": "__MSG_actionTitle__" }, "options_ui": { "page": "options.html", diff --git a/static/options.html b/static/options.html index 55e9b1c..a2371e0 100644 --- a/static/options.html +++ b/static/options.html @@ -52,6 +52,32 @@ .brand-link:hover { color: #24292f; } + + /* Language picker */ + .lang-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + } + .lang-label { + font-weight: 600; + } + .lang-select { + margin-left: auto; + padding: 5px 8px; + border: 1px solid #d0d7de; + border-radius: 6px; + font-size: 13px; + background: #fff; + color: #24292f; + cursor: pointer; + } + .lang-select:focus { + outline: none; + border-color: #0969da; + box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.3); + } h2 { margin: 0 0 10px; font-size: 16px; @@ -414,14 +440,15 @@ - +
+ Language + +
+ +
- +
-

+

⬆️ A token is required for accessing private repositories. The plugin itself will never store your token or access your data.

@@ -449,7 +486,9 @@

Settings

target="_blank" >
- Create a classic token with scopes + Create a classic token with scopes Settings -
or
+
or
Settings target="_blank" >
- Create a fine-grained token with permissions + Create a fine-grained token with permissions Settings
-
Features
-
- Home + Home
  • -
    Better Top Repositories
    -
    +
    Better Top Repositories
    +
    Auto-expand the "Top repositories" list and pin your favorite repos to the top.
    @@ -542,12 +588,12 @@

    Settings

- PRs and issues + PRs and issues
  • -
    Default Sort by Updated
    -
    +
    Default Sort by Updated
    +
    Sort PR and issue lists by recently updated instead of creation time.
    @@ -558,8 +604,8 @@

    Settings

  • -
    PR Branch Names
    -
    +
    PR Branch Names
    +
    Display source branch name next to each PR title. Click the badge to copy.
    @@ -570,8 +616,8 @@

    Settings

  • -
    PR Review Status
    -
    +
    PR Review Status
    +
    Show review thread resolution status (resolved / unresolved) on the PR list. Requires a token.
    @@ -583,8 +629,8 @@

    Settings

  • -
    PR Diff Stats
    -
    +
    PR Diff Stats
    +
    Show additions, deletions, and file count (e.g. +223 −114 · 5 files) on the PR list. Requires a token.
    @@ -596,8 +642,8 @@

    Settings

  • -
    PR Label Position
    -
    +
    PR Label Position
    +
    Move labels to the front of PR titles for better visibility and scanning.
    @@ -610,12 +656,12 @@

    Settings

- PR details + PR details
  • -
    PR Approve Now
    -
    +
    PR Approve Now
    +
    Add an "approve now" shortcut to the Reviewers sidebar on PR detail pages. Requires a token.
    @@ -627,8 +673,8 @@

    Settings

  • -
    Collapse/Expand All Files
    -
    +
    Collapse/Expand All Files
    +
    Add a button to collapse or expand all file diffs on PR, commit, and compare pages.
    @@ -641,12 +687,12 @@

    Settings

- Commits + Commits
  • -
    Commit Tags
    -
    +
    Commit Tags
    +
    Show git tags on the commits list page for easy identification.
    @@ -657,8 +703,8 @@

    Settings

  • -
    Commit Diff Stats
    -
    +
    Commit Diff Stats
    +
    Show additions, deletions, and file count (e.g. +223 −114 · 5 files) on the commits list page. Requires a token.
    @@ -672,12 +718,12 @@

    Settings

- Repository + Repository
  • -
    Releases Tab
    -
    +
    Releases Tab
    +
    Add a Releases tab to the repository navigation bar for quick access.
    @@ -688,8 +734,8 @@

    Settings

  • -
    Watch/Fork/Star Popup
    -
    +
    Watch/Fork/Star Popup
    +
    Hover over Watch, Fork, or Star counts to preview the list in a popup.
    diff --git a/tsconfig.json b/tsconfig.json index 4f6ca37..2977f86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "resolveJsonModule": true, "strict": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "outDir": "dist",