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 4175484d69

* fix: overlap because of language length

Co-authored-by: lrusso96 <russo.1699981@studenti.uniroma1.it>
Co-authored-by: schmelto <30869493+schmelto@users.noreply.github.com>
Co-authored-by: Anurag <hazru.anurag@gmail.com>
This commit is contained in:
Nathan Chu 2020-10-04 04:05:15 -04:00 committed by GitHub
parent 2707d07453
commit f1df178643
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 337 additions and 27 deletions

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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) => `
<g data-testid="badge" class="badge" transform="translate(320, 38)">
<g data-testid="badge" class="badge" transform="translate(320, -18)">
<rect stroke="${textColor}" stroke-width="1" width="70" height="20" x="-12" y="-14" ry="10" rx="10"></rect>
<text
x="23" y="-5"
@ -132,9 +140,9 @@ const renderRepoCard = (repo, options = {}) => {
return card.render(`
${
isTemplate
? getBadgeSVG("Template")
? getBadgeSVG(i18n.t("repocard.template"))
: isArchived
? getBadgeSVG("Archived")
? getBadgeSVG(i18n.t("repocard.archived"))
: ""
}

View File

@ -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 = ({
<text class="stat bold" ${labelOffset} y="12.5">${label}:</text>
<text
class="stat"
x="${shiftValuePos ? (showIcons ? 200 : 170) : 150}"
x="${(showIcons ? 140 : 120) + shiftValuePos}"
y="12.5"
data-testid="${id}"
>${kValue}</text>
@ -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: {

View File

@ -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: {

View File

@ -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 `
<text x="25" y="11" class="stat bold" fill="${color}">No coding activity this week</text>
<text x="25" y="11" class="stat bold" fill="${color}">${text}</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("")}

21
src/common/I18n.js Normal file
View File

@ -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;

View File

@ -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,

143
src/translations.js Normal file
View File

@ -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,
};

View File

@ -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("模板");
});
});

View File

@ -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("有助于:");
});
});

View File

@ -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(
"最常用的语言",
);
});
});

View File

@ -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("本周没有编码活动");
});
});