From f1df1786439486166b1ee8f2e9ac4db497e7c031 Mon Sep 17 00:00:00 2001 From: Nathan Chu <63111210+nthnchu@users.noreply.github.com> Date: Sun, 4 Oct 2020 04:05:15 -0400 Subject: [PATCH] feat: card locale translations (#509) * Add Card Translations * Add tests and documentation for `?lang` option * Card Translations: update Italian * Run Prettier * Correct German Translations. Co-authored-by: schmelto <30869493+schmelto@users.noreply.github.com> * refactor: added i18n class to manage translation logic & improved code * Make the new src/translations.js more concise * Update translations.js Co-authored-by: schmelto <30869493+schmelto@users.noreply.github.com> * Revert 4175484d69289e4ee7283ab968b8e71c3c5d77df * fix: overlap because of language length Co-authored-by: lrusso96 Co-authored-by: schmelto <30869493+schmelto@users.noreply.github.com> Co-authored-by: Anurag --- api/index.js | 7 ++ api/pin.js | 7 ++ api/top-langs.js | 7 ++ api/wakatime.js | 7 ++ readme.md | 1 + src/cards/repo-card.js | 18 ++-- src/cards/stats-card.js | 40 +++++---- src/cards/top-languages-card.js | 10 ++- src/cards/wakatime-card.js | 21 ++++- src/common/I18n.js | 21 +++++ src/common/utils.js | 7 ++ src/translations.js | 143 +++++++++++++++++++++++++++++++ tests/renderRepoCard.test.js | 25 ++++++ tests/renderStatsCard.test.js | 32 +++++++ tests/renderTopLanguages.test.js | 7 ++ tests/renderWakatimeCard.test.js | 11 +++ 16 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 src/common/I18n.js create mode 100644 src/translations.js diff --git a/api/index.js b/api/index.js index b09a87fe..1e580389 100644 --- a/api/index.js +++ b/api/index.js @@ -5,6 +5,7 @@ const { parseArray, clampValue, CONSTANTS, + isLocaleAvailable, } = require("../src/common/utils"); const fetchStats = require("../src/fetchers/stats-fetcher"); const renderStatsCard = require("../src/cards/stats-card"); @@ -28,6 +29,7 @@ module.exports = async (req, res) => { theme, cache_seconds, custom_title, + locale, } = req.query; let stats; @@ -37,6 +39,10 @@ module.exports = async (req, res) => { return res.send(renderError("Something went wrong")); } + if (locale && !isLocaleAvailable(locale)) { + return res.send(renderError("Something went wrong", "Language not found")); + } + try { stats = await fetchStats( username, @@ -67,6 +73,7 @@ module.exports = async (req, res) => { bg_color, theme, custom_title, + locale: locale ? locale.toLowerCase() : null, }), ); } catch (err) { diff --git a/api/pin.js b/api/pin.js index 3486e70d..3169b90c 100644 --- a/api/pin.js +++ b/api/pin.js @@ -4,6 +4,7 @@ const { parseBoolean, clampValue, CONSTANTS, + isLocaleAvailable, } = require("../src/common/utils"); const fetchRepo = require("../src/fetchers/repo-fetcher"); const renderRepoCard = require("../src/cards/repo-card"); @@ -21,6 +22,7 @@ module.exports = async (req, res) => { theme, show_owner, cache_seconds, + locale, } = req.query; let repoData; @@ -31,6 +33,10 @@ module.exports = async (req, res) => { return res.send(renderError("Something went wrong")); } + if (locale && !isLocaleAvailable(locale)) { + return res.send(renderError("Something went wrong", "Language not found")); + } + try { repoData = await fetchRepo(username, repo); @@ -64,6 +70,7 @@ module.exports = async (req, res) => { bg_color, theme, show_owner: parseBoolean(show_owner), + locale: locale ? locale.toLowerCase() : null, }), ); } catch (err) { diff --git a/api/top-langs.js b/api/top-langs.js index 77bcc19b..2eefd531 100644 --- a/api/top-langs.js +++ b/api/top-langs.js @@ -5,6 +5,7 @@ const { parseBoolean, parseArray, CONSTANTS, + isLocaleAvailable, } = require("../src/common/utils"); const fetchTopLanguages = require("../src/fetchers/top-languages-fetcher"); const renderTopLanguages = require("../src/cards/top-languages-card"); @@ -26,6 +27,7 @@ module.exports = async (req, res) => { langs_count, exclude_repo, custom_title, + locale, } = req.query; let topLangs; @@ -35,6 +37,10 @@ module.exports = async (req, res) => { return res.send(renderError("Something went wrong")); } + if (locale && !isLocaleAvailable(locale)) { + return res.send(renderError("Something went wrong", "Language not found")); + } + try { topLangs = await fetchTopLanguages( username, @@ -62,6 +68,7 @@ module.exports = async (req, res) => { bg_color, theme, layout, + locale: locale ? locale.toLowerCase() : null, }), ); } catch (err) { diff --git a/api/wakatime.js b/api/wakatime.js index 7277c9d9..c626cacb 100644 --- a/api/wakatime.js +++ b/api/wakatime.js @@ -4,6 +4,7 @@ const { parseBoolean, clampValue, CONSTANTS, + isLocaleAvailable, } = require("../src/common/utils"); const { fetchLast7Days } = require("../src/fetchers/wakatime-fetcher"); const wakatimeCard = require("../src/cards/wakatime-card"); @@ -22,10 +23,15 @@ module.exports = async (req, res) => { hide_title, hide_progress, custom_title, + locale, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); + if (locale && !isLocaleAvailable(locale)) { + return res.send(renderError("Something went wrong", "Language not found")); + } + try { const last7Days = await fetchLast7Days({ username }); @@ -53,6 +59,7 @@ module.exports = async (req, res) => { bg_color, theme, hide_progress, + locale: locale ? locale.toLowerCase() : null, }), ); } catch (err) { diff --git a/readme.md b/readme.md index 25c51ba8..c7e8aca5 100644 --- a/readme.md +++ b/readme.md @@ -136,6 +136,7 @@ You can customize the appearance of your `Stats Card` or `Repo Card` however you - `hide_border` - Hides the card's border _(boolean)_ - `theme` - name of the theme, choose from [all available themes](./themes/README.md) - `cache_seconds` - set the cache header manually _(min: 1800, max: 86400)_ +- `lang` - set the language in the card _(e.g. cn, de, es, etc.)_ ##### Gradient in bg_color diff --git a/src/cards/repo-card.js b/src/cards/repo-card.js index 7b021ca0..0e736d25 100644 --- a/src/cards/repo-card.js +++ b/src/cards/repo-card.js @@ -1,3 +1,4 @@ +const toEmoji = require("emoji-name-map"); const { kFormatter, encodeHTML, @@ -5,9 +6,10 @@ const { FlexLayout, wrapTextMultiline, } = require("../common/utils"); -const icons = require("../common/icons"); +const I18n = require("../common/I18n"); const Card = require("../common/Card"); -const toEmoji = require("emoji-name-map"); +const icons = require("../common/icons"); +const { repoCardLocales } = require("../translations"); const renderRepoCard = (repo, options = {}) => { const { @@ -28,6 +30,7 @@ const renderRepoCard = (repo, options = {}) => { bg_color, show_owner, theme = "default_repocard", + locale, } = options; const header = show_owner ? nameWithOwner : name; @@ -50,6 +53,11 @@ const renderRepoCard = (repo, options = {}) => { const height = (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; + const i18n = new I18n({ + locale, + translations: repoCardLocales, + }); + // returns theme based colors with proper overrides and defaults const { titleColor, textColor, iconColor, bgColor } = getCardColors({ title_color, @@ -63,7 +71,7 @@ const renderRepoCard = (repo, options = {}) => { const totalForks = kFormatter(forkCount); const getBadgeSVG = (label) => ` - + { return card.render(` ${ isTemplate - ? getBadgeSVG("Template") + ? getBadgeSVG(i18n.t("repocard.template")) : isArchived - ? getBadgeSVG("Archived") + ? getBadgeSVG(i18n.t("repocard.archived")) : "" } diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js index 4954c719..28f7ac00 100644 --- a/src/cards/stats-card.js +++ b/src/cards/stats-card.js @@ -1,12 +1,9 @@ -const { - kFormatter, - getCardColors, - FlexLayout, - encodeHTML, -} = require("../common/utils"); -const { getStyles } = require("../getStyles"); -const icons = require("../common/icons"); +const I18n = require("../common/I18n"); const Card = require("../common/Card"); +const icons = require("../common/icons"); +const { getStyles } = require("../getStyles"); +const { statCardLocales } = require("../translations"); +const { kFormatter, getCardColors, FlexLayout } = require("../common/utils"); const createTextNode = ({ icon, @@ -34,7 +31,7 @@ const createTextNode = ({ ${label}: ${kValue} @@ -66,6 +63,7 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { bg_color, theme = "default", custom_title, + locale, } = options; const lheight = parseInt(line_height, 10); @@ -79,17 +77,23 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { theme, }); + const apostrophe = ["x", "s"].includes(name.slice(-1)) ? "" : "s"; + const i18n = new I18n({ + locale, + translations: statCardLocales({ name, apostrophe }), + }); + // Meta data for creating text nodes with createTextNode function const STATS = { stars: { icon: icons.star, - label: "Total Stars", + label: i18n.t("statcard.totalstars"), value: totalStars, id: "stars", }, commits: { icon: icons.commits, - label: `Total Commits${ + label: `${i18n.t("statcard.commits")}${ include_all_commits ? "" : ` (${new Date().getFullYear()})` }`, value: totalCommits, @@ -97,24 +101,26 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { }, prs: { icon: icons.prs, - label: "Total PRs", + label: i18n.t("statcard.prs"), value: totalPRs, id: "prs", }, issues: { icon: icons.issues, - label: "Total Issues", + label: i18n.t("statcard.issues"), value: totalIssues, id: "issues", }, contribs: { icon: icons.contribs, - label: "Contributed to", + label: i18n.t("statcard.contribs"), value: contributedTo, id: "contribs", }, }; + const isLongLocale = ["fr", "pt-br", "es"].includes(locale) === true; + // filter out hidden stats defined by user & create the text nodes const statItems = Object.keys(STATS) .filter((key) => !hide.includes(key)) @@ -124,7 +130,8 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { ...STATS[key], index, showIcons: show_icons, - shiftValuePos: !include_all_commits, + shiftValuePos: + (!include_all_commits ? 50 : 20) + (isLongLocale ? 50 : 0), }), ); @@ -166,10 +173,9 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => { progress, }); - const apostrophe = ["x", "s"].includes(name.slice(-1)) ? "" : "s"; const card = new Card({ customTitle: custom_title, - defaultTitle: `${encodeHTML(name)}'${apostrophe} GitHub Stats`, + defaultTitle: i18n.t("statcard.title"), width: 495, height, colors: { diff --git a/src/cards/top-languages-card.js b/src/cards/top-languages-card.js index 8e85b72a..215076b9 100644 --- a/src/cards/top-languages-card.js +++ b/src/cards/top-languages-card.js @@ -1,6 +1,8 @@ const Card = require("../common/Card"); const { getCardColors, FlexLayout } = require("../common/utils"); const { createProgressNode } = require("../common/createProgressNode"); +const { langCardLocales } = require("../translations"); +const I18n = require("../common/I18n"); const createProgressTextNode = ({ width, color, name, progress }) => { const paddingRight = 95; @@ -70,8 +72,14 @@ const renderTopLanguages = (topLangs, options = {}) => { theme, layout, custom_title, + locale, } = options; + const i18n = new I18n({ + locale, + translations: langCardLocales, + }); + let langs = Object.values(topLangs); let langsToHide = {}; @@ -172,7 +180,7 @@ const renderTopLanguages = (topLangs, options = {}) => { const card = new Card({ customTitle: custom_title, - defaultTitle: "Most Used Languages", + defaultTitle: i18n.t("langcard.title"), width, height, colors: { diff --git a/src/cards/wakatime-card.js b/src/cards/wakatime-card.js index bf103f50..d6470758 100644 --- a/src/cards/wakatime-card.js +++ b/src/cards/wakatime-card.js @@ -1,11 +1,13 @@ const Card = require("../common/Card"); +const I18n = require("../common/I18n"); const { getStyles } = require("../getStyles"); +const { wakatimeCardLocales } = require("../translations"); const { getCardColors, FlexLayout } = require("../common/utils"); const { createProgressNode } = require("../common/createProgressNode"); -const noCodingActivityNode = ({ color }) => { +const noCodingActivityNode = ({ color, text }) => { return ` - No coding activity this week + ${text} `; }; @@ -60,8 +62,14 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { theme = "default", hide_progress, custom_title, + locale, } = options; + const i18n = new I18n({ + locale, + translations: wakatimeCardLocales, + }); + const lheight = parseInt(line_height, 10); // returns theme based colors with proper overrides and defaults @@ -101,7 +109,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { const card = new Card({ customTitle: custom_title, - defaultTitle: "Wakatime Week Stats", + defaultTitle: i18n.t("wakatimecard.title"), width: 495, height, colors: { @@ -126,7 +134,12 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { ${FlexLayout({ items: statItems.length ? statItems - : [noCodingActivityNode({ color: textColor })], + : [ + noCodingActivityNode({ + color: textColor, + text: i18n.t("wakatimecard.nocodingactivity"), + }), + ], gap: lheight, direction: "column", }).join("")} diff --git a/src/common/I18n.js b/src/common/I18n.js new file mode 100644 index 00000000..e28f6050 --- /dev/null +++ b/src/common/I18n.js @@ -0,0 +1,21 @@ +class I18n { + constructor({ locale, translations }) { + this.locale = locale; + this.translations = translations; + this.fallbackLocale = "en"; + } + + t(str) { + if (!this.translations[str]) { + throw new Error(`${str} Translation string not found`); + } + + if (!this.translations[str][this.locale || this.fallbackLocale]) { + throw new Error(`${str} Translation locale not found`); + } + + return this.translations[str][this.locale || this.fallbackLocale]; + } +} + +module.exports = I18n; diff --git a/src/common/utils.js b/src/common/utils.js index d0911721..6349af2b 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -188,6 +188,12 @@ class CustomError extends Error { static USER_NOT_FOUND = "USER_NOT_FOUND"; } +function isLocaleAvailable(locale) { + return ["cn", "de", "en", "es", "fr", "it", "ja", "kr", "pt-br"].includes( + locale.toLowerCase(), + ); +} + module.exports = { renderError, kFormatter, @@ -201,6 +207,7 @@ module.exports = { getCardColors, clampValue, wrapTextMultiline, + isLocaleAvailable, logger, CONSTANTS, CustomError, diff --git a/src/translations.js b/src/translations.js new file mode 100644 index 00000000..daea98a3 --- /dev/null +++ b/src/translations.js @@ -0,0 +1,143 @@ +const { encodeHTML } = require("./common/utils"); + +const statCardLocales = ({ name, apostrophe }) => { + return { + "statcard.title": { + cn: `${encodeHTML(name)}的GitHub统计`, + de: `${encodeHTML(name) + apostrophe} GitHub-Statistiken`, + en: `${encodeHTML(name)}'${apostrophe} GitHub Stats`, + es: `Estadísticas de GitHub de ${encodeHTML(name)}`, + fr: `Statistiques GitHub de ${encodeHTML(name)}`, + it: `Statistiche GitHub di ${encodeHTML(name)}`, + ja: `${encodeHTML(name)}のGitHub統計`, + kr: `${encodeHTML(name)}의 GitHub 통계`, + "pt-br": `Estatísticas do GitHub de ${encodeHTML(name)}`, + }, + "statcard.totalstars": { + cn: "总星数", + de: "Sterne Insgesamt", + en: "Total Stars", + es: "Estrellas totales", + fr: "Total d'étoiles", + it: "Stelle totali", + ja: "星の合計", + kr: "총 별", + "pt-br": "Total de estrelas", + }, + "statcard.commits": { + cn: "总承诺", + de: "Anzahl Commits", + en: "Total Commits", + es: "Compromisos totales", + fr: "Total des engagements", + it: "Commit totali", + ja: "総コミット", + kr: "총 커밋", + "pt-br": "Total de compromissos", + }, + "statcard.prs": { + cn: "总公关", + de: "PRs Insgesamt", + en: "Total PRs", + es: "RP totales", + fr: "Total des PR", + it: "PR totali", + ja: "合計PR", + kr: "총 PR", + "pt-br": "Total de PRs", + }, + "statcard.issues": { + cn: "总发行量", + de: "Anzahl Issues", + en: "Total Issues", + es: "Problemas totales", + fr: "Nombre total de problèmes", + it: "Segnalazioni totali", + ja: "総問題", + kr: "총 문제", + "pt-br": "Total de problemas", + }, + "statcard.contribs": { + cn: "有助于", + de: "Beigetragen zu", + en: "Contributed to", + es: "Contribuido a", + fr: "Contribué à", + it: "Ha contribuito a", + ja: "に貢献しました", + kr: "에 기여하다", + "pt-br": "Contribuiu para", + }, + }; +}; + +const repoCardLocales = { + "repocard.template": { + cn: "模板", + de: "Vorlage", + en: "Template", + es: "Modelo", + fr: "Modèle", + it: "Template", + ja: "テンプレート", + kr: "주형", + "pt-br": "Modelo", + }, + "repocard.archived": { + cn: "已封存", + de: "Archiviert", + en: "Archived", + es: "Archivé", + fr: "Archivé", + it: "Archiviata", + ja: "アーカイブ済み", + kr: "보관 됨", + "pt-br": "Arquivada", + }, +}; + +const langCardLocales = { + "langcard.title": { + cn: "最常用的语言", + de: "Meist verwendete Sprachen", + en: "Most Used Languages", + es: "Idiomas más usados", + fr: "Langues les plus utilisées", + it: "Linguaggi più utilizzati", + ja: "最もよく使われる言語", + kr: "가장 많이 사용되는 언어", + "pt-br": "Línguas Mais Usadas", + }, +}; + +const wakatimeCardLocales = { + "wakatimecard.title": { + cn: "Wakatime周统计", + de: "Wakatime Wochen Status", + en: "Wakatime Week Stats", + es: "Estadísticas de la semana de Wakatime", + fr: "Statistiques de la semaine Wakatime", + it: "Statistiche della settimana di Wakatime", + ja: "ワカタイムウィーク統計", + kr: "Wakatime 주간 통계", + "pt-br": "Estatísticas da semana Wakatime", + }, + "wakatimecard.nocodingactivity": { + cn: "本周没有编码活动", + de: "Keine Aktivitäten in dieser Woche", + en: "No coding activity this week", + es: "No hay actividad de codificación esta semana", + fr: "Aucune activité de codage cette semaine", + it: "Nessuna attività in questa settimana", + ja: "今週のコーディング活動はありません", + kr: "이번 주 코딩 활동 없음", + "pt-br": "Nenhuma atividade de codificação esta semana", + }, +}; + +module.exports = { + statCardLocales, + repoCardLocales, + langCardLocales, + wakatimeCardLocales, +}; diff --git a/tests/renderRepoCard.test.js b/tests/renderRepoCard.test.js index 6bef75a3..dc9b0a26 100644 --- a/tests/renderRepoCard.test.js +++ b/tests/renderRepoCard.test.js @@ -307,4 +307,29 @@ describe("Test renderRepoCard", () => { }); expect(queryByTestId(document.body, "badge")).toBeNull(); }); + + it("should render translated badges", () => { + document.body.innerHTML = renderRepoCard( + { + ...data_repo.repository, + isArchived: true, + }, + { + locale: "cn", + }, + ); + + expect(queryByTestId(document.body, "badge")).toHaveTextContent("已封存"); + + document.body.innerHTML = renderRepoCard( + { + ...data_repo.repository, + isTemplate: true, + }, + { + locale: "cn", + }, + ); + expect(queryByTestId(document.body, "badge")).toHaveTextContent("模板"); + }); }); diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js index 8a17de79..d6daf10c 100644 --- a/tests/renderStatsCard.test.js +++ b/tests/renderStatsCard.test.js @@ -209,4 +209,36 @@ describe("Test renderStatsCard", () => { queryByTestId(document.body, "stars").previousElementSibling, // the label ).not.toHaveAttribute("x"); }); + + it("should render translations", () => { + document.body.innerHTML = renderStatsCard(stats, { locale: "cn" }); + expect(document.getElementsByClassName("header")[0].textContent).toBe( + "Anurag Hazra的GitHub统计", + ); + expect( + document.querySelector( + 'g[transform="translate(0, 0)"]>.stagger>.stat.bold', + ).textContent, + ).toBe("总星数:"); + expect( + document.querySelector( + 'g[transform="translate(0, 25)"]>.stagger>.stat.bold', + ).textContent, + ).toBe("总承诺 (2020):"); + expect( + document.querySelector( + 'g[transform="translate(0, 50)"]>.stagger>.stat.bold', + ).textContent, + ).toBe("总公关:"); + expect( + document.querySelector( + 'g[transform="translate(0, 75)"]>.stagger>.stat.bold', + ).textContent, + ).toBe("总发行量:"); + expect( + document.querySelector( + 'g[transform="translate(0, 100)"]>.stagger>.stat.bold', + ).textContent, + ).toBe("有助于:"); + }); }); diff --git a/tests/renderTopLanguages.test.js b/tests/renderTopLanguages.test.js index dc512dd8..a12f3e77 100644 --- a/tests/renderTopLanguages.test.js +++ b/tests/renderTopLanguages.test.js @@ -216,4 +216,11 @@ describe("Test renderTopLanguages", () => { "60.00", ); }); + + it("should render a translated title", () => { + document.body.innerHTML = renderTopLanguages(langs, { locale: "cn" }); + expect(document.getElementsByClassName("header")[0].textContent).toBe( + "最常用的语言", + ); + }); }); diff --git a/tests/renderWakatimeCard.test.js b/tests/renderWakatimeCard.test.js index 2fc4982c..01291ec6 100644 --- a/tests/renderWakatimeCard.test.js +++ b/tests/renderWakatimeCard.test.js @@ -113,4 +113,15 @@ describe("Test Render Wakatime Card", () => { " `); }); + + it("should render translations", () => { + document.body.innerHTML = renderWakatimeCard({}, { locale: "cn" }); + expect(document.getElementsByClassName("header")[0].textContent).toBe( + "Wakatime周统计", + ); + expect( + document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold') + .textContent, + ).toBe("本周没有编码活动"); + }); });