diff --git a/README.md b/README.md index ba91e93b..6358051e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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`. diff --git a/package-lock.json b/package-lock.json index c8f84560..b2a9c507 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,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", @@ -4209,6 +4210,15 @@ } } }, + "node_modules/i18next-resources-to-backend": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz", + "integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", diff --git a/package.json b/package.json index 7f2561de..f0abba13 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/i18n-utils.ts b/scripts/i18n-utils.ts index 3e222274..6d3ce70a 100644 --- a/scripts/i18n-utils.ts +++ b/scripts/i18n-utils.ts @@ -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 = "git@github.com: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") @@ -41,6 +115,7 @@ async function queryCards() { } as any).cards, ); } + function listTraits(cards: Card[]) { return Array.from( cards.reduce>((acc, card) => { diff --git a/src/components/card-list/card-list-nav.module.css b/src/components/card-list/card-list-nav.module.css index e2dba82a..a380f9a5 100644 --- a/src/components/card-list/card-list-nav.module.css +++ b/src/components/card-list/card-list-nav.module.css @@ -27,6 +27,7 @@ } .nav-stats { + flex: 0 0 auto; display: inline-flex; flex-flow: row wrap; align-items: center; diff --git a/src/components/card-name.tsx b/src/components/card-name.tsx index a169fb81..860eabcd 100644 --- a/src/components/card-name.tsx +++ b/src/components/card-name.tsx @@ -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"; @@ -17,7 +21,7 @@ export function CardName(props: Props) { {!!level && cardLevelDisplay === "dots" && } diff --git a/src/components/card/card-back.tsx b/src/components/card/card-back.tsx index f1b5e56f..9fe81dec 100644 --- a/src/components/card/card-back.tsx +++ b/src/components/card/card-back.tsx @@ -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"; @@ -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], ); @@ -61,9 +63,9 @@ export function CardBack(props: Props) {
{showMeta && } diff --git a/src/components/card/card-details.tsx b/src/components/card/card-details.tsx index d2bc867f..27b150e4 100644 --- a/src/components/card/card-details.tsx +++ b/src/components/card/card-details.tsx @@ -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"; @@ -28,7 +29,9 @@ export function CardDetails(props: Props) {

)} {card.real_traits && ( -

{card.real_traits}

+

+ {displayAttribute(card, "traits")} +

)} {!!card.doom && (

diff --git a/src/components/card/card-face.tsx b/src/components/card/card-face.tsx index 3d5b2241..961c1552 100644 --- a/src/components/card/card-face.tsx +++ b/src/components/card/card-face.tsx @@ -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"; @@ -61,9 +61,9 @@ export function CardFace(props: Props) {

diff --git a/src/components/card/card-meta.tsx b/src/components/card/card-meta.tsx index 86d24599..4bc9a75d 100644 --- a/src/components/card/card-meta.tsx +++ b/src/components/card/card-meta.tsx @@ -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"; @@ -62,13 +63,14 @@ function PlayerEntry(props: Props) { {size === "full" && duplicates?.map((duplicate) => (

- {duplicate.pack.real_name} {" "} + {displayPackName(duplicate.pack)}{" "} + {" "} {duplicate.card.pack_position}{" "} ×{duplicate.card.quantity}

))}

- {displayPack.real_name} {" "} + {displayPackName(displayPack)} {" "} {card.pack_position} × {card.quantity}

@@ -89,7 +91,7 @@ function EncounterEntry(props: Props) { {getEncounterPositions(card.encounter_position ?? 1, card.quantity)}

- {displayPack.real_name} {" "} + {displayPackName(displayPack)} {" "} {card.pack_position}

diff --git a/src/components/card/card-names.tsx b/src/components/card/card-names.tsx index c5299646..86e72b55 100644 --- a/src/components/card/card-names.tsx +++ b/src/components/card/card-names.tsx @@ -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"; @@ -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")), }} /> )} diff --git a/src/components/card/card-taboo-text.tsx b/src/components/card/card-taboo-text.tsx index aff43e0e..b69314d8 100644 --- a/src/components/card/card-taboo-text.tsx +++ b/src/components/card/card-taboo-text.tsx @@ -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"; @@ -29,14 +29,16 @@ export function CardTabooText(props: Props) { } >

- Taboo List{" "} -
+ {" "} + {t("common.taboo")}
{t("common.taboo_mutated")}.

diff --git a/src/components/cards-combobox.tsx b/src/components/cards-combobox.tsx index 09854c10..d88015f2 100644 --- a/src/components/cards-combobox.tsx +++ b/src/components/cards-combobox.tsx @@ -1,4 +1,5 @@ import type { Card } from "@/store/services/queries.types"; +import { displayAttribute } from "@/utils/card-utils"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { ListCard } from "./list-card/list-card"; @@ -22,12 +23,12 @@ export function CardsCombobox(props: Props) { ); const resultRenderer = useCallback((item: Card) => { - const name = item.real_name; + const name = displayAttribute(item, "name"); return item.xp ? `${name} (${item.xp})` : name; }, []); const itemToString = useCallback((item: Card) => { - return item.real_name.toLowerCase(); + return displayAttribute(item, "name").toLowerCase(); }, []); return ( diff --git a/src/components/cardset.module.css b/src/components/cardset.module.css index 26be51a4..ad0fc903 100644 --- a/src/components/cardset.module.css +++ b/src/components/cardset.module.css @@ -26,6 +26,5 @@ font-size: var(--text-sm); font-weight: 700; line-height: 1; - text-transform: uppercase; width: 100%; } diff --git a/src/components/collection/collection-pack.tsx b/src/components/collection/collection-pack.tsx index c63139a1..2157e2f6 100644 --- a/src/components/collection/collection-pack.tsx +++ b/src/components/collection/collection-pack.tsx @@ -4,6 +4,7 @@ import type { CollectionCounts } from "@/store/selectors/collection"; 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 { CollectionCount } from "./collection-count"; import css from "./collection.module.css"; @@ -60,7 +61,7 @@ export function CollectionPack(props: Props) { className={css["pack-label"]} htmlFor={`collection-${cycle.code}-${pack.code}`} > - {pack.real_name} + {displayPackName(pack)}
) : ( @@ -73,7 +74,7 @@ export function CollectionPack(props: Props) { label={
- {pack.real_name} + {displayPackName(pack)}
} name={pack.code} diff --git a/src/components/collection/collection.tsx b/src/components/collection/collection.tsx index 4500bde6..47bf0399 100644 --- a/src/components/collection/collection.tsx +++ b/src/components/collection/collection.tsx @@ -5,6 +5,7 @@ import { selectCycleCardCounts } from "@/store/selectors/collection"; import { selectCyclesAndPacks } from "@/store/selectors/lists"; import type { SettingsState } from "@/store/slices/settings.types"; import { CYCLES_WITH_STANDALONE_PACKS } from "@/utils/constants"; +import { displayPackName } from "@/utils/formatting"; import { isEmpty } from "@/utils/is-empty"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -98,7 +99,7 @@ export function CollectionSettings(props: Props) {
{`Cycle @@ -106,7 +107,7 @@ export function CollectionSettings(props: Props) {
- {cycle.real_name} + {displayPackName(cycle)}
{canEdit && !cycle.reprintPacks.length && @@ -184,7 +185,7 @@ export function CollectionSettings(props: Props) {

- {t("collection.card_count")} + {t("settings.collection.card_count")}

{ - if (!acc.names.has(card.real_name)) { + if (!acc.names.has(displayAttribute(card, "name"))) { acc.cards.push(card); - acc.names.add(card.real_name); + acc.names.add(displayAttribute(card, "name")); } return acc; }, diff --git a/src/components/customizations/customizations-editor.tsx b/src/components/customizations/customizations-editor.tsx index 87919731..691b42f2 100644 --- a/src/components/customizations/customizations-editor.tsx +++ b/src/components/customizations/customizations-editor.tsx @@ -2,7 +2,7 @@ import { useStore } from "@/store"; import type { ResolvedDeck } from "@/store/lib/types"; import type { Card } from "@/store/services/queries.types"; import type { CustomizationEdit } from "@/store/slices/deck-edits.types"; -import { getCardColor } from "@/utils/card-utils"; +import { displayAttribute, getCardColor } from "@/utils/card-utils"; import { cx } from "@/utils/cx"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; @@ -25,7 +25,7 @@ export function CustomizationsEditor(props: Props) { const choices = deck?.customizations?.[card.code]; const options = card.customization_options; - const text = card.real_customization_text?.split("\n"); + const text = displayAttribute(card, "customization_text")?.split("\n"); const onChangeCustomization = useCallback( (index: number, edit: CustomizationEdit) => { diff --git a/src/components/customizations/customizations.module.css b/src/components/customizations/customizations.module.css index b38c8292..63c24067 100644 --- a/src/components/customizations/customizations.module.css +++ b/src/components/customizations/customizations.module.css @@ -9,7 +9,6 @@ .header { border-radius: calc(var(--rounded-md) - 2px) calc(var(--rounded-md) - 2px) 0 0; padding: 0.5rem 1rem; - color: var(--palette-6); } .text { diff --git a/src/components/deck-collection/deck-summary.tsx b/src/components/deck-collection/deck-summary.tsx index 62120b14..2d01d4bb 100644 --- a/src/components/deck-collection/deck-summary.tsx +++ b/src/components/deck-collection/deck-summary.tsx @@ -4,7 +4,7 @@ import { extendedDeckTags } from "@/store/lib/resolve-deck"; import type { ResolvedDeck } from "@/store/lib/types"; import { selectConnectionLockForDeck } from "@/store/selectors/shared"; import type { Id } from "@/store/slices/data.types"; -import { getCardColor } from "@/utils/card-utils"; +import { displayAttribute, getCardColor } from "@/utils/card-utils"; import { cx } from "@/utils/cx"; import { CircleAlertIcon, @@ -130,7 +130,7 @@ export function DeckSummary(props: Props) { className={css["sub"]} data-testid="deck-summary-investigator" > - {card.real_name} + {displayAttribute(card, "name")}
diff --git a/src/components/deck-tools/skill-icons-chart.tsx b/src/components/deck-tools/skill-icons-chart.tsx index 84e987f8..dc90db59 100644 --- a/src/components/deck-tools/skill-icons-chart.tsx +++ b/src/components/deck-tools/skill-icons-chart.tsx @@ -53,7 +53,7 @@ export function SkillIconsChart({ data }: Props) { labelComponent={ } diff --git a/src/components/decklist/decklist-groups.tsx b/src/components/decklist/decklist-groups.tsx index b01dbb60..bfc4b3a8 100644 --- a/src/components/decklist/decklist-groups.tsx +++ b/src/components/decklist/decklist-groups.tsx @@ -16,7 +16,7 @@ import { import { customizationSheetUrl } from "@/store/services/queries"; import type { Card } from "@/store/services/queries.types"; import type { Slots } from "@/store/slices/data.types"; -import { sideways } from "@/utils/card-utils"; +import { displayAttribute, sideways } from "@/utils/card-utils"; import { cx } from "@/utils/cx"; import { range } from "@/utils/range"; import { Fragment, useCallback, useMemo } from "react"; @@ -246,7 +246,9 @@ function CustomizationScan(props: { onClick={openModal} crossOrigin="anonymous" url={customizationSheetUrl(card, deck)} - alt={t("deck.customization_sheet", { name: card.real_name })} + alt={t("deck.customization_sheet", { + name: displayAttribute(card, "name"), + })} style={ { "--scan-level": 0, diff --git a/src/components/decklist/decklist-validation.tsx b/src/components/decklist/decklist-validation.tsx index b7701e8b..22071205 100644 --- a/src/components/decklist/decklist-validation.tsx +++ b/src/components/decklist/decklist-validation.tsx @@ -9,6 +9,7 @@ import { isTooFewCardsError, isTooManyCardsError, } from "@/store/lib/deck-validation"; +import { displayAttribute } from "@/utils/card-utils"; import { InfoIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -78,8 +79,8 @@ export function DecklistValidation(props: Props) {
    {error.details.map((detail) => (
  1. - {cards[detail.code].real_name} ({detail.quantity}/ - {detail.limit}) + {displayAttribute(cards[detail.code], "name")} ( + {detail.quantity}/{detail.limit})
  2. ))}
@@ -91,7 +92,7 @@ export function DecklistValidation(props: Props) {
    {error.details.map((detail) => (
  1. - {cards[detail.code].real_name} + {displayAttribute(cards[detail.code], "name")}
  2. ))}
@@ -103,8 +104,8 @@ export function DecklistValidation(props: Props) {
    {error.details.map((detail) => (
  1. - {cards[detail.code].real_name} ({detail.quantity}/ - {detail.required}) + {displayAttribute(cards[detail.code], "name")} ( + {detail.quantity}/{detail.required})
  2. ))}
diff --git a/src/components/filters/investigator-filter.tsx b/src/components/filters/investigator-filter.tsx index d35f0884..79ee1284 100644 --- a/src/components/filters/investigator-filter.tsx +++ b/src/components/filters/investigator-filter.tsx @@ -7,6 +7,7 @@ import { import type { Card } from "@/store/services/queries.types"; import { isInvestigatorFilterObject } from "@/store/slices/lists.type-guards"; import { assert } from "@/utils/assert"; +import { displayAttribute } from "@/utils/card-utils"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { FilterProps } from "./filters.types"; @@ -33,7 +34,7 @@ export function InvestigatorFilter({ id }: FilterProps) { const renderOption = useCallback( (card: Card) => (