From 0e71527cb0548b851ecf22a3e1cafd7f65a043b5 Mon Sep 17 00:00:00 2001 From: Arvind Date: Fri, 12 Jun 2026 12:35:39 +0530 Subject: [PATCH] feat(i18n): add German (de) language to the UI language switcher --- context/TranslationContext.tsx | 5 +- locales/de.json | 254 +++++++++++++++++++++++++++++++++ locales/de.test.ts | 59 ++++++++ 3 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 locales/de.json create mode 100644 locales/de.test.ts diff --git a/context/TranslationContext.tsx b/context/TranslationContext.tsx index cf9afc164..9c0c8a051 100644 --- a/context/TranslationContext.tsx +++ b/context/TranslationContext.tsx @@ -16,8 +16,9 @@ import fr from '@/locales/fr.json'; import zh from '@/locales/zh.json'; import ja from '@/locales/ja.json'; import ko from '@/locales/ko.json'; +import de from '@/locales/de.json'; -export type Language = 'en' | 'es' | 'hi' | 'fr' | 'zh' | 'ja' | 'ko'; +export type Language = 'en' | 'es' | 'hi' | 'fr' | 'zh' | 'ja' | 'ko' | 'de'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const translations: Record = { @@ -28,6 +29,7 @@ const translations: Record = { zh, ja, ko, + de, }; export const LANGUAGE_LABELS: Record = { @@ -38,6 +40,7 @@ export const LANGUAGE_LABELS: Record = { zh: '\u7b80\u4f53\u4e2d\u6587', ja: '\u65e5\u672c\u8a9e', ko: '\ud55c\uad6d\uc5b4', + de: 'Deutsch', }; interface TranslationContextType { diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 000000000..2534b56fa --- /dev/null +++ b/locales/de.json @@ -0,0 +1,254 @@ +{ + "navbar": { + "home": "Zur Startseite", + "repo": "GitHub-Repo", + "contributors": "Mitwirkende", + "theme_toggle": "Theme umschalten", + "menu_open": "Menü öffnen", + "menu_close": "Menü schließen", + "compare": "Vergleichen", + "customization_studio": "Anpassungsstudio", + "generator": "Generator", + "burnout_radar": "Burnout-Radar" + }, + "footer": { + "tagline": "Entwickelt für die Elite-Builder-Community.", + "contributors": "Mitwirkende", + "documentation": "Dokumentation", + "creator": "Ersteller", + "copyright": "© {{year}} CommitPulse. Alle Rechte vorbehalten.", + "navigation": "Navigation", + "resources": "Ressourcen", + "connect": "Verbinden", + "made_with": "Mit ❤️ für Entwickler erstellt", + "home": "Startseite", + "compare": "Vergleichen", + "customization": "Anpassung", + "github_repo": "GitHub-Repository", + "github": "GitHub", + "creator_github": "Ersteller auf GitHub", + "discord": "Discord", + "twitter": "Twitter", + "linkedin": "LinkedIn", + "generator": "Generator" + }, + "landing": { + "empty_username_warning": "Bitte gib einen GitHub-Benutzernamen ein, um deinen Badge-Link zu kopieren.", + "max_length_warning": "GitHub-Benutzername-Limit erreicht (maximal 39 Zeichen)", + "title": "Werte deine\n{Contribution}-Story auf.", + "subtitle": "Gib dich nicht mehr mit flachen Rastern zufrieden. Erzeuge hochauflösende, isometrische 3D-Monolithen, die deinen Coding-Rhythmus mit professioneller Präzision visualisieren.", + "watch_dashboard": "Dashboard ansehen", + "discord_community": "Tritt der Core-Community auf Discord bei", + "input_placeholder": "GitHub-Benutzernamen eingeben", + "clear_input": "Eingabe löschen", + "copy_link": "Link kopieren", + "copied": "Kopiert", + "recent": "Kürzlich:", + "clear": "Löschen", + "preview_placeholder_title": "Bereit, deinen Rhythmus zu visualisieren?", + "preview_placeholder_desc": "Gib oben einen GitHub-Benutzernamen ein, um sofort dein Streak-Badge zu erzeugen.", + "user_not_found": "GitHub-Benutzer nicht gefunden", + "user_not_found_desc": "Bitte überprüfe den Benutzernamen und versuche es erneut.", + "preview_auto_theme": "Zufälliges Theme ändert sich bei jedem Seitenaufruf und deaktiviert das Caching", + "preview_caching_tip": "Vorschau aktualisiert sich bei jeder Änderung. Das gehostete Badge wird um UTC-Mitternacht zwischengespeichert", + "preview_empty_tip": "Füge einen Benutzernamen hinzu, um Live-Vorschau und Export-Snippets zu aktivieren", + "features": { + "sync_title": "Echtzeit-Sync", + "sync_desc": "Direkt von der GitHub-GraphQL-API abgerufen. Dein Streak aktualisiert sich so schnell, wie du Code pushst.", + "theme_title": "Theme-Engine", + "theme_desc": "Wechsle zwischen Neon, Dracula oder benutzerdefinierten HEX-Modi über einfache URL-Verwaltung.", + "isometric_title": "Isometrische Mathematik", + "isometric_desc": "Ausgefeilte 3D-Projektionsformeln verwandeln 2D-Daten in digitale Architektur." + }, + "generate_badge": "Badge erzeugen", + "verified_profile": "Verifiziertes Profil", + "verifying": "Wird verifiziert ...", + "preview_monolith": "MONOLITH-VORSCHAU", + "interactive_preview_title": "Interaktive Monolith-Vorschau", + "interactive_preview_desc": "CommitPulse fasst deinen öffentlichen GitHub-Beitragsverlauf zu einer anpassbaren 3D-Stadt zusammen. Je höher die Türme, desto mehr hast du an diesem Tag committet. Gib oben einen GitHub-Benutzernamen ein, um sofort dein Streak-Badge zu erzeugen.", + "input_aria_label": "GitHub-Benutzernamen eingeben, um ein Badge zu erzeugen", + "unable_to_load_stats": "Statistiken konnten nicht geladen werden" + }, + "success_guide": { + "title": "Dein Monolith ist bereit – setze ihn in 4 Schritten ein", + "markdown_copied": "Markdown kopiert", + "dismiss_aria": "Anleitung schließen", + "step_1_title": "Öffne dein Profil-Repo", + "step_1_body": "Gehe zu github.com/DEIN_BENUTZERNAME/DEIN_BENUTZERNAME – deinem speziellen Profil-Repository.", + "step_2_title": "README.md bearbeiten", + "step_2_body": "Klicke auf das Stift-Symbol, um die Datei im integrierten Editor von GitHub zu öffnen.", + "step_3_title": "Snippet einfügen", + "step_3_body": "Setze den Cursor an die Stelle, an der der Monolith erscheinen soll, und füge ihn dann ein (Strg+V / Cmd+V).", + "step_4_title": "Speichern & veröffentlichen", + "step_4_body": "Klicke auf „Commit changes“ und besuche dein Profil. Dein 3D-Streak ist jetzt live.", + "copied_snippet_label": "Dein kopiertes Snippet", + "color_tip": "Tipp: Füge der URL ?accent=808080 hinzu, um die Farbpalette deines Monolithen zu ändern.", + "watch_dashboard_btn": "Dein Dashboard ansehen", + "customize_more_btn": "Möchtest du mehr anpassen?" + }, + "customize_cta": { + "studio_badge": "Anpassungsstudio", + "title": "Möchtest du deinen Monolithen feinabstimmen?", + "desc": "Stelle jedes Pixel genau ein – tausche Akzentfarben, probiere ein dunkles oder Neon-Theme, schalte die logarithmische Höhenskalierung um und sieh dir Änderungen live an, bevor du eine einzige Zeile einfügst.", + "btn": "Anpassungsstudio öffnen" + }, + "customize": { + "back_to_home": "Zurück zur Startseite", + "title": "Stimme deinen Monolithen fein ab.", + "desc": "Jede Änderung unten aktualisiert die Vorschau in Echtzeit. Kopiere das Export-Snippet, wenn du fertig bist. Keine weiteren Schritte nötig.", + "live_preview": "Live-Vorschau", + "active_params": "Aktive Parameter", + "empty_preview_desc": "Die Live-Badge-Vorschau erscheint hier, sobald ein Benutzername hinzugefügt wurde.", + "controls": { + "username": "GitHub-Benutzername", + "username_placeholder": "Benutzernamen eingeben ...", + "select_theme": "Theme auswählen", + "theme_placeholder": "Voreingestelltes Theme auswählen", + "theme_mode": "Theme-Modus", + "custom_bg": "Benutzerdefinierter Hintergrund", + "custom_accent": "Benutzerdefinierter Akzent", + "custom_text": "Benutzerdefinierter Text", + "log_scaling": "Logarithmische Höhenskalierung", + "speed": "Animationsgeschwindigkeit", + "radius": "Eckenradius", + "badge_size": "Badge-Größe", + "sync_year": "Sync-Jahr", + "clear_custom": "Benutzerdefinierte Farben zurücksetzen", + "theme_presets": "Theme-Voreinstellung", + "color_overrides": "Benutzerdefinierte Farbüberschreibungen", + "font": "Schriftart", + "custom_font_option": "Benutzerdefinierte Google-Schriftart ...", + "custom_font_placeholder": "z. B. Orbitron, Space Mono, Inter" + }, + "export": { + "snippet_title": "Export-Snippet", + "snippet_desc": "Wechsle Formate, ohne die Live-Badge-Konfiguration zu ändern.", + "markdown": "Markdown", + "html": "HTML", + "action": "GitHub Action", + "download_badge": "Badge herunterladen", + "downloading": "Wird heruntergeladen ...", + "download_not_available": "Download nicht verfügbar", + "copy_workflow": "Workflow kopieren", + "copy_format": "{{format}} kopieren", + "copied": "Kopiert!", + "copy_aria_disabled": "Füge einen GitHub-Benutzernamen hinzu, um das Kopieren des {{format}}-Export-Snippets zu aktivieren", + "copy_aria_enabled": "{{format}}-Export-Snippet in die Zwischenablage kopieren", + "footer_tip": "Füge dies in die README.md deines GitHub-Profils ein. Das Badge wird serverseitig gerendert, kein Skript erforderlich.", + "tsx": "React TSX", + "download_svg": "SVG herunterladen", + "download_png": "PNG herunterladen" + } + }, + "dashboard": { + "generate_btn": "Erstelle dein eigenes Dashboard", + "refresh_btn": "Daten aktualisieren", + "refreshing": "Wird aktualisiert ...", + "refreshed_toast": "Dashboard erfolgreich aktualisiert", + "profile": { + "score": "Entwickler-Score", + "repos": "Repositories", + "followers": "Follower", + "following": "Folgt", + "stars": "Sterne", + "joined": "Beigetreten", + "pro_badge": "PRO", + "pro": "PRO", + "share": "Teile deinen Pulse" + }, + "achievements": { + "title": "Erfolge", + "see_all": "Alle Erfolge ansehen", + "show_less": "Weniger anzeigen" + }, + "activity": { + "title": "Aktivitätslandschaft", + "year": "Jahr", + "month": "Monat", + "week": "Woche", + "commits": "Commits", + "intensity": "Commit-Intensität", + "loc": "Codezeilen", + "loc_desc": "Im Zeitverlauf geänderte Codezeilen", + "commits_desc": "Commit-Häufigkeit im Zeitverlauf", + "lines_modified": "{{count}} Zeilen geändert", + "aria_range": "von {{start}} bis {{end}}", + "aria_single": "am {{date}}" + }, + "languages": { + "title": "Top-Sprachen", + "primary": "Hauptsprache", + "no_data": "Keine Sprachdaten gefunden" + }, + "clock": { + "title": "Commit-Uhr", + "active_days": "Wöchentlicher Aktivitätszyklus", + "tooltip_no_commits": "Keine Commits für diesen Wochentag erfasst", + "tooltip_peak": "Aktivster Wochentag in diesem Zyklus", + "tooltip_activity": "Wöchentlicher Aktivitätspunkt" + }, + "heatmap": { + "title": "Beitrags-Heatmap", + "less": "Weniger", + "more": "Mehr", + "last_365": "Letzte 365 Tage", + "tooltip_single": "{{count}} Beitrag am {{date}}", + "tooltip_plural": "{{count}} Beiträge am {{date}}", + "no_activity": "Keine Aktivität erfasst", + "peak_activity": "Aktivster Tag", + "high_activity": "Tag mit hoher Aktivität", + "steady_contribution": "Tag mit gleichmäßigen Beiträgen", + "light_activity": "Tag mit geringer Aktivität", + "no_active_streak": "Keine aktive Serie", + "active_streak": "{{streak}}-tägige aktive Serie", + "empty": "Keine aktuelle Aktivität zum Anzeigen", + "code_activity": "Code-Aktivität erfasst", + "no_code_changes": "Keine Code-Änderungen erfasst" + }, + "insights": { + "title": "KI-Einblicke" + }, + "stats": { + "current_streak": "Aktuelle Serie", + "peak_streak": "Höchste Serie", + "contributions": "Beiträge", + "days": "Tage", + "last_year": "Letztes Jahr", + "utc_disclaimer": "Serie in UTC-Zeitzone berechnet" + }, + "share": { + "title": "Pulse teilen", + "close_aria": "Teilen-Panel schließen", + "copy_link": "Link kopieren", + "copy_link_desc": "Profil-URL in die Zwischenablage kopieren", + "link_copied": "Link kopiert", + "share_x": "Auf X teilen", + "share_x_desc": "Teile deinen Pulse mit der Welt", + "share_linkedin": "LinkedIn", + "share_linkedin_desc": "Teile deine Entwickler-Aktivität mit deinem Netzwerk", + "copy_markdown": "README-Markdown kopieren", + "copy_markdown_desc": "Markdown-Snippet für deine README kopieren", + "download_png": "PNG-Snapshot herunterladen", + "download_png_desc": "Speichere einen Snapshot deines Dashboards", + "download_svg": "Vektor-SVG-Monolith herunterladen", + "download_svg_desc": "Lade das rohe Monolith-SVG herunter", + "download_json": "Strukturierte JSON-Daten exportieren", + "download_json_desc": "Exportiere rohe Streak- und Sprachdaten", + "share_os": "System-Teilen", + "share_os_desc": "AirDrop, WhatsApp, Messages & mehr", + "share_os_fallback": "Weitere Optionen", + "share_os_fallback_desc": "Öffne den System-Teilen-Dialog", + "share_reddit": "Reddit", + "share_reddit_desc": "Auf Reddit teilen", + "downloaded": "Asset gespeichert!", + "svg_downloaded": "SVG heruntergeladen!", + "json_downloaded": "JSON heruntergeladen!", + "failed": "Fehlgeschlagen – versuche es erneut", + "social_channels": "Social-Media-Kanäle", + "export_options": "Export-Optionen", + "github_wrapped": "GitHub Wrapped", + "download_webp": "Optimiertes WebP herunterladen", + "download_stl": "Druckbares 3D-STL herunterladen (Demnächst)" + } + } +} diff --git a/locales/de.test.ts b/locales/de.test.ts new file mode 100644 index 000000000..33fbfcec1 --- /dev/null +++ b/locales/de.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { LANGUAGE_LABELS } from '@/context/TranslationContext'; +import en from './en.json'; +import de from './de.json'; + +// Flatten a nested locale object into dot-paths -> string leaves. We compare the +// *entire* key surface (not just the top level) because a single missing nested +// key — e.g. one dashboard.share.* entry — silently falls back to English in the +// UI via the t() helper, which is exactly the kind of gap a reviewer can't spot +// by eye across 200+ strings. +function flatten(obj: Record, prefix = ''): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object') { + Object.assign(out, flatten(value as Record, path)); + } else { + out[path] = String(value); + } + } + return out; +} + +// Collect the {{placeholder}} tokens a string exposes to t()'s param replacement, +// sorted so order differences between languages don't cause false failures. +function placeholders(value: string): string[] { + return (value.match(/{{\s*\w+\s*}}/g) ?? []).sort(); +} + +const enFlat = flatten(en); +const deFlat = flatten(de); + +describe('German (de) locale', () => { + it('is registered in the language switcher as Deutsch', () => { + expect(LANGUAGE_LABELS.de).toBe('Deutsch'); + }); + + it('defines exactly the same keys as the English source of truth', () => { + // Sorted comparison surfaces both missing keys (dropped translations) and + // extra keys (typos/renames that would never be read) in one assertion. + expect(Object.keys(deFlat).sort()).toEqual(Object.keys(enFlat).sort()); + }); + + it('has a non-empty translation for every key', () => { + const blank = Object.entries(deFlat) + .filter(([, value]) => value.trim().length === 0) + .map(([path]) => path); + expect(blank).toEqual([]); + }); + + it('preserves the same {{placeholder}} tokens as English in every value', () => { + // A streak count or year that loses its {{token}} in translation renders a + // literal "{{count}}" to the user, so token parity is checked per key. + const mismatched = Object.keys(enFlat).filter( + (path) => placeholders(enFlat[path]).join(',') !== placeholders(deFlat[path] ?? '').join(',') + ); + expect(mismatched).toEqual([]); + }); +});