Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: german translation #164

Merged
merged 10 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ In this example, the sealed deck contains two copies of _Deduction_, _Perception
2. `npm install`
3. `npm run dev`

## Translations

The app and its data are fully translatable and PRs with new translations are _welcome_.

Translations for the user-interface are handled with [react-i18next](https://react.i18next.com/) and live in the `./src/locales/` folder as JSON files.

Translations for cards and metadata are sourced from the [arkhamdb-json-data](https://github.com/Kamalisk/arkhamdb-json-data) and the [arkham-cards-data](https://github.com/zzorba/arkham-cards-data) and assembled by our API.

### Creating translations

1. Create a copy of `en.json` in the `./src/locales` folder and rename it to your locale's ISO 639 code.
2. Add your locale to the `LOCALES` array in `./src/utils/constants`.
3. Run `npm run i18n:pull-data` to pull in some translations (traits, deck options) from ArkhamCards automatically.
4. (if your local has translated card data) Create an issue to get the card data added to the card data backend.
5. Translate and open a PR.

### Updating translations

1. Run `npm run i18n:sync-keys` to sync newly added translation keys to your locale.
2. Run `npm run i18n:pull-data` to sync translations from ArkhamCards.
3. Update the translation file and open a PR.

## Architecture

arkham.build is a SPA app that, by default, stores data locally in an IndexedDB database. The SPA has several backend components that it uses to enrich functionality.
Expand All @@ -64,11 +86,6 @@ We leverage a few Cloudflare Pages functions for rewriting the HTML we serve to

The API is a separate, private git project.

### Opengraph previews

Opengraph previews are generated by


### Icons

Arkham-related SVG icons are sourced from ArkhamCards's [icomoon project](https://github.com/zzorba/ArkhamCards/blob/master/assets/icomoon/project.json) and loaded as webfonts. Additional icons are bundled as SVG via `vite-plugin-svgr` or imported from `lucide-react`.
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"dev": "vite",
"dev:functions": "wrangler pages dev",
"fmt": "biome check --write",
"i18n:sync-keys": "npx i18next-json-sync --files '**/src/locales/*.json' --primary en && npm run fmt",
"i18n:pull-data": "npx tsx ./scripts/i18n-utils && npm run fmt",
"lint": "biome check",
"knip": "npx knip",
"postinstall": "patch-package",
Expand Down Expand Up @@ -48,6 +50,7 @@
"@radix-ui/react-toggle-group": "1.1.1",
"dompurify": "3.2.4",
"i18next": "24.2.2",
"i18next-resources-to-backend": "1.2.1",
"idb-keyval": "6.2.1",
"lucide-react": "0.456.0",
"marked": "14.1.0",
Expand Down
85 changes: 80 additions & 5 deletions scripts/i18n-utils.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,107 @@
import { execSync } from "node:child_process";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { applyLocalData } from "../src/store/lib/local-data";
import type { Card } from "../src/store/services/queries.types";
import { cardUses } from "../src/utils/card-utils";
import { capitalize } from "../src/utils/formatting";

const [cards, locale] = await Promise.all([queryCards(), readLocale("en")]);
const [cards, en] = await Promise.all([queryCards(), readLocale("en")]);

const uses = listUses(cards);
const traits = listTraits(cards);

const deckOptions = listDeckOptions(cards);

locale.translation.common.uses = uses.reduce((acc, curr) => {
en.translation.common.uses = uses.reduce((acc, curr) => {
acc[curr] = capitalize(curr);
return acc;
}, {});

locale.translation.common.traits = traits.reduce((acc, curr) => {
en.translation.common.traits = traits.reduce((acc, curr) => {
acc[curr] = curr;
return acc;
}, {});

locale.translation.common.deck_options = deckOptions.reduce((acc, curr) => {
en.translation.common.deck_options = deckOptions.reduce((acc, curr) => {
acc[curr] = curr;
return acc;
}, {});

await writeLocale("en", locale);
await writeLocale("en", en);

const repoPath = await cloneRepo();

const translations = (await fs.readdir("./src/locales"))
.filter((file) => file !== "en.json")
.map((file) => path.basename(file, ".json"));

for (const lng of translations) {
const arkhamCardsLocale = JSON.parse(
await fs.readFile(
path.join(repoPath, "assets/i18n", `${lng}.po.json`),
"utf-8",
),
);

const locale = await readLocale(lng);

for (const key of Object.keys(en.translation.common.uses)) {
const arkhamCardsTranslation =
arkhamCardsLocale.translations[""][capitalize(key)]?.msgstr[0];

if (arkhamCardsTranslation) {
locale.translation.common.uses[key] = arkhamCardsTranslation;
} else {
console.log(`[${lng}] ArkhamCards missing translation for ${key}`);
}
}

for (const key of Object.keys(en.translation.common.traits)) {
const arkhamCardsTranslation =
arkhamCardsLocale.translations["trait"][key]?.msgstr[0];

if (arkhamCardsTranslation) {
locale.translation.common.traits[key] = arkhamCardsTranslation;
} else {
console.log(`[${lng}] ArkhamCards missing translation for ${key}`);
}
}

for (const key of Object.keys(en.translation.common.deck_options)) {
const arkhamCardsTranslation =
arkhamCardsLocale.translations[""][key]?.msgstr[0];

if (arkhamCardsTranslation) {
locale.translation.common.deck_options[key] = arkhamCardsTranslation;
} else {
console.log(`[${lng}] ArkhamCards missing translation for ${key}`);
}
}

await writeLocale(lng, locale);
}

async function cloneRepo() {
const repo = "[email protected]:zzorba/ArkhamCards.git";
const localPath = path.join(tmpdir(), "arkham-cards");

try {
if ((await fs.stat(localPath)).isDirectory()) {
return localPath;
}
} catch {}

await fs.mkdir(localPath);

execSync(
`git clone --filter=blob:none ${repo} ${localPath} && cd ${localPath} && git sparse-checkout init --cone && git sparse-checkout set assets/i18n && git checkout master`,
{ stdio: "inherit" },
);

return localPath;
}

async function queryCards() {
const apiCards = await fetch("https://api.arkham.build/v1/cache/cards")
Expand All @@ -41,6 +115,7 @@ async function queryCards() {
} as any).cards,
);
}

function listTraits(cards: Card[]) {
return Array.from(
cards.reduce<Set<string>>((acc, card) => {
Expand Down
1 change: 1 addition & 0 deletions src/components/card-list/card-list-nav.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
}

.nav-stats {
flex: 0 0 auto;
display: inline-flex;
flex-flow: row wrap;
align-items: center;
Expand Down
8 changes: 6 additions & 2 deletions src/components/card-name.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Card } from "@/store/services/queries.types";
import { cardLevel, parseCardTextHtml } from "@/utils/card-utils";
import {
cardLevel,
displayAttribute,
parseCardTextHtml,
} from "@/utils/card-utils";
import css from "./card-name.module.css";
import { ExperienceDots } from "./experience-dots";

Expand All @@ -17,7 +21,7 @@ export function CardName(props: Props) {
<span
// biome-ignore lint/security/noDangerouslySetInnerHtml: safe.
dangerouslySetInnerHTML={{
__html: parseCardTextHtml(card.real_name),
__html: parseCardTextHtml(displayAttribute(card, "name")),
}}
/>
{!!level && cardLevelDisplay === "dots" && <ExperienceDots xp={level} />}
Expand Down
14 changes: 8 additions & 6 deletions src/components/card/card-back.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ResolvedCard } from "@/store/lib/types";
import type { Card as CardType } from "@/store/services/queries.types";
import { sideways } from "@/utils/card-utils";
import { displayAttribute, sideways } from "@/utils/card-utils";
import { cx } from "@/utils/cx";
import { useMemo } from "react";
import { CardScan } from "../card-scan";
Expand All @@ -22,11 +22,13 @@ export function CardBack(props: Props) {
const backCard: CardType = useMemo(
() => ({
...card,
real_name: card.real_back_name ?? `${card.real_name} - Back`,
real_name:
displayAttribute(card, "back_name") ||
`${displayAttribute(card, "name")} - Back`,
real_subname: undefined,
real_flavor: card.real_back_flavor,
real_flavor: displayAttribute(card, "back_flavor"),
illustrator: card.back_illustrator,
real_text: card.real_back_text,
real_text: displayAttribute(card, "back_text"),
}),
[card],
);
Expand Down Expand Up @@ -61,9 +63,9 @@ export function CardBack(props: Props) {

<div className={css["content"]}>
<CardText
flavor={card.real_back_flavor}
flavor={displayAttribute(card, "back_flavor")}
size={size}
text={card.real_back_text}
text={displayAttribute(card, "back_text")}
typeCode={card.type_code}
/>
{showMeta && <CardMetaBack illustrator={backCard.illustrator} />}
Expand Down
5 changes: 4 additions & 1 deletion src/components/card/card-details.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Card } from "@/store/services/queries.types";
import { displayAttribute } from "@/utils/card-utils";
import { formatSlots } from "@/utils/formatting";
import { useTranslation } from "react-i18next";
import { CardSlots } from "../card-slots";
Expand Down Expand Up @@ -28,7 +29,9 @@ export function CardDetails(props: Props) {
</p>
)}
{card.real_traits && (
<p className={css["details-traits"]}>{card.real_traits}</p>
<p className={css["details-traits"]}>
{displayAttribute(card, "traits")}
</p>
)}
{!!card.doom && (
<p>
Expand Down
6 changes: 3 additions & 3 deletions src/components/card/card-face.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CardWithRelations, ResolvedCard } from "@/store/lib/types";
import { sideways } from "@/utils/card-utils";
import { displayAttribute, sideways } from "@/utils/card-utils";
import { cx } from "@/utils/cx";
import { CardScan } from "../card-scan";
import { CardThumbnail } from "../card-thumbnail";
Expand Down Expand Up @@ -61,9 +61,9 @@ export function CardFace(props: Props) {

<div className={css["content"]}>
<CardText
flavor={card.real_flavor}
flavor={displayAttribute(card, "flavor")}
size={size}
text={card.real_text}
text={displayAttribute(card, "text")}
typeCode={card.type_code}
victory={card.victory}
/>
Expand Down
8 changes: 5 additions & 3 deletions src/components/card/card-meta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isCardWithRelations } from "@/store/lib/types";
import type { Cycle, Pack } from "@/store/services/queries.types";
import { CYCLES_WITH_STANDALONE_PACKS } from "@/utils/constants";
import { cx } from "@/utils/cx";
import { displayPackName } from "@/utils/formatting";
import EncounterIcon from "../icons/encounter-icon";
import PackIcon from "../icons/pack-icon";
import css from "./card.module.css";
Expand Down Expand Up @@ -62,13 +63,14 @@ function PlayerEntry(props: Props) {
{size === "full" &&
duplicates?.map((duplicate) => (
<p className={css["meta-property"]} key={duplicate.card.code}>
{duplicate.pack.real_name} <PackIcon code={duplicate.pack.code} />{" "}
{displayPackName(duplicate.pack)}{" "}
<PackIcon code={duplicate.pack.code} />{" "}
{duplicate.card.pack_position}{" "}
<i className="icon-card-outline-bold" /> ×{duplicate.card.quantity}
</p>
))}
<p className={css["meta-property"]}>
{displayPack.real_name} <PackIcon code={displayPack.code} />{" "}
{displayPackName(displayPack)} <PackIcon code={displayPack.code} />{" "}
{card.pack_position} <i className="icon-card-outline-bold" /> ×
{card.quantity}
</p>
Expand All @@ -89,7 +91,7 @@ function EncounterEntry(props: Props) {
{getEncounterPositions(card.encounter_position ?? 1, card.quantity)}
</p>
<p className={css["meta-property"]}>
{displayPack.real_name} <PackIcon code={displayPack.code} />{" "}
{displayPackName(displayPack)} <PackIcon code={displayPack.code} />{" "}
{card.pack_position}
</p>
</>
Expand Down
4 changes: 2 additions & 2 deletions src/components/card/card-names.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Unique from "@/assets/icons/icon_unique.svg?react";
import { useStore } from "@/store";
import { selectCardLevelDisplaySetting } from "@/store/selectors/settings";
import type { Card } from "@/store/services/queries.types";
import { parseCardTitle } from "@/utils/card-utils";
import { displayAttribute, parseCardTitle } from "@/utils/card-utils";
import { cx } from "@/utils/cx";
import { Link } from "wouter";
import { useCardModalContextChecked } from "../card-modal/card-modal-context";
Expand Down Expand Up @@ -56,7 +56,7 @@ export function CardNames(props: Props) {
className={css["sub"]}
// biome-ignore lint/security/noDangerouslySetInnerHtml: safe and necessary.
dangerouslySetInnerHTML={{
__html: parseCardTitle(card.real_subname),
__html: parseCardTitle(displayAttribute(card, "subname")),
}}
/>
)}
Expand Down
10 changes: 6 additions & 4 deletions src/components/card/card-taboo-text.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Card } from "@/store/services/queries.types";
import { parseCardTextHtml } from "@/utils/card-utils";
import { displayAttribute, parseCardTextHtml } from "@/utils/card-utils";
import { cx } from "@/utils/cx";
import { useTranslation } from "react-i18next";
import { DefaultTooltip } from "../ui/tooltip";
Expand Down Expand Up @@ -29,14 +29,16 @@ export function CardTabooText(props: Props) {
<span
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted origin.
dangerouslySetInnerHTML={{
__html: parseCardTextHtml(real_taboo_text_change),
__html: parseCardTextHtml(
displayAttribute(props.card, "taboo_text_change"),
),
}}
/>
}
>
<p>
<i className="icon-tablet color-taboo icon-text" /> Taboo List{" "}
<br />
<i className="icon-tablet color-taboo icon-text" />{" "}
{t("common.taboo")} <br />
{t("common.taboo_mutated")}.
</p>
</DefaultTooltip>
Expand Down
Loading
Loading