fix: localize default headings in server PDF exports (#3428)

This commit is contained in:
Amruth Pillai
2026-09-05 08:31:36 -07:00
committed by GitHub
parent aada380888
commit b6842fb769
13 changed files with 1127 additions and 9 deletions
+6
View File
@@ -20,6 +20,12 @@ Once translations are submitted and approved on Crowdin, they are automatically
---
## Updating catalogs in a checkout
The PDF renderer uses a generated subset of the same translations for default section headings. Run `pnpm pdf:translations` after editing or syncing the PO catalogs. The root `pnpm lingui:extract`, `pnpm check`, and `pnpm build` commands also regenerate this file automatically.
Commit `packages/pdf/src/section-title-catalog.json` with the catalog updates. Edit the source PO files rather than the generated JSON; a tooling test checks that they stay synchronized.
## Getting started
<Steps>
@@ -74,6 +74,12 @@ When someone visits your public resume URL:
link as something you send to people directly, not as a search profile page.
</Info>
## Resume language
Set **Page → Language** in the builder's right sidebar to choose the language of default section headings. Public visitors and PDF downloads use this saved resume language, even when the visitor's app interface uses another language. Section titles you rename remain as written.
The interface language selected in Preferences is stored in the current browser. It does not change the resume's language or sync to other browsers.
## Tracking public engagement
When your resume is public, Reactive Resume counts public views, so you can tell whether people are opening the link you shared.
+5 -4
View File
@@ -20,14 +20,14 @@
"tooling"
],
"scripts": {
"build": "turbo run build",
"build": "pnpm pdf:translations && turbo run build",
"doctor": "turbo run doctor",
"check": "biome check --write --unsafe . && markdownlint-cli2 --fix && github-actionlint -shellcheck= -pyflakes=",
"check": "pnpm pdf:translations && biome check --write --unsafe . && markdownlint-cli2 --fix && github-actionlint -shellcheck= -pyflakes=",
"docs:gen": "pnpm --filter server docs:gen && pnpm --filter @reactive-resume/tooling docs:gen",
"db:generate": "turbo run db:generate --filter=@reactive-resume/db",
"db:migrate": "turbo run db:migrate --filter=@reactive-resume/db",
"db:studio": "turbo run db:studio --filter=@reactive-resume/db",
"lingui:extract": "turbo run lingui:extract --filter=web",
"lingui:extract": "turbo run lingui:extract --filter=web && pnpm pdf:translations",
"dev": "turbo run dev",
"dev:web": "turbo run dev --filter=web",
"knip": "knip",
@@ -39,7 +39,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:ci": "playwright test",
"test:agent": "turbo run test:agent --concurrency=4"
"test:agent": "turbo run test:agent --concurrency=4",
"pdf:translations": "pnpm --filter @reactive-resume/tooling pdf:translations"
},
"devDependencies": {
"@biomejs/biome": "^2.5.12",
+882
View File
@@ -0,0 +1,882 @@
{
"af-ZA": {
"Summary": "Opsomming",
"Profiles": "Profiele",
"Experience": "Ervaring",
"Education": "Kwalifikasie",
"Projects": "Projektte",
"Skills": "Vaardighede",
"Languages": "Tale",
"Interests": "Belangstellings",
"Awards": "Toekennings",
"Certifications": "Sertifiserings",
"Publications": "Publikasies",
"Volunteer": "Vrywilliger",
"References": "Verwysings",
"Cover Letter": "Dekbrief"
},
"am-ET": {
"Summary": "ማጠቃለያ",
"Profiles": "ፕሮፋይሎች",
"Experience": "ልምድ",
"Education": "ትምህርት",
"Projects": "ፕሮጀክቶች",
"Skills": "ችሎታዎች",
"Languages": "ቋንቋዎች",
"Interests": "ፍላጎቶች",
"Awards": "ሽልማቶች",
"Certifications": "የማረጋገጫዎች",
"Publications": "ህትመቶች",
"Volunteer": "በጎ ፈቃደኝነት",
"References": "ማመልከቻዎች",
"Cover Letter": "ማመልከቻ ደብዳቤ"
},
"ar-SA": {
"Summary": "الملخّص",
"Profiles": "الملفات الشخصية",
"Experience": "الخبرة",
"Education": "التعليم",
"Projects": "المشاريع",
"Skills": "المهارات",
"Languages": "اللغات",
"Interests": "الاهتمامات",
"Awards": "الجوائز",
"Certifications": "الشهادات",
"Publications": "المنشورات",
"Volunteer": "التطوع",
"References": "المراجعون",
"Cover Letter": "خطاب التقديم"
},
"az-AZ": {
"Summary": "Xülasə",
"Profiles": "Profillər",
"Experience": "Təcrübə",
"Education": "Təhsil",
"Projects": "Layihələr",
"Skills": "Bacarıqlar",
"Languages": "Dillər",
"Interests": "Maraqlar",
"Awards": "Mükafatlar",
"Certifications": "Sertifikatlar",
"Publications": "Nəşrlər",
"Volunteer": "Könüllü",
"References": "Referanslar",
"Cover Letter": "Müraciət məktubu"
},
"bg-BG": {
"Summary": "Резюме",
"Profiles": "Профили",
"Experience": "Професионален опит",
"Education": "Образование",
"Projects": "Проекти",
"Skills": "Умения",
"Languages": "Езици",
"Interests": "Интереси",
"Awards": "Награди",
"Certifications": "Сертификати",
"Publications": "Публикации",
"Volunteer": "Доброволчество",
"References": "Препоръки",
"Cover Letter": "Мотивационно писмо"
},
"bn-BD": {
"Summary": "সারাংশ",
"Profiles": "প্রোফাইলসমূহ",
"Experience": "অভিজ্ঞতা",
"Education": "শিক্ষা",
"Projects": "প্রোজেক্টসমূহ",
"Skills": "স্কিলসমূহ",
"Languages": "ভাষাসমূহ",
"Interests": "আগ্রহসমূহ",
"Awards": "পুরস্কার",
"Certifications": "সার্টিফিকেশনসমূহ",
"Publications": "প্রকাশনাসমূহ",
"Volunteer": "স্বেচ্ছাসেবী",
"References": "রেফারেন্সসমূহ",
"Cover Letter": "কভার লেটার"
},
"ca-ES": {
"Summary": "Resum",
"Profiles": "Perfils",
"Experience": "Experiència",
"Education": "Educació",
"Projects": "Projectes",
"Skills": "Habilitats",
"Languages": "Idiomes",
"Interests": "Interessos",
"Awards": "Premis",
"Certifications": "Certificacions",
"Publications": "Publicacions",
"Volunteer": "Voluntariat",
"References": "Referències",
"Cover Letter": "Carta de presentació"
},
"cs-CZ": {
"Summary": "Souhrn",
"Profiles": "Profily",
"Experience": "Zkušenosti",
"Education": "Vzdělání",
"Projects": "Projekty",
"Skills": "Dovednosti",
"Languages": "Jazyky",
"Interests": "Zájmy",
"Awards": "Ocenění",
"Certifications": "Osvědčení",
"Publications": "Publikace",
"Volunteer": "Dobrovolnictví",
"References": "Reference",
"Cover Letter": "Průvodní dopis"
},
"da-DK": {
"Summary": "Opsummering",
"Profiles": "Profiler",
"Experience": "Erfaring",
"Education": "Uddannelse",
"Projects": "Projekter",
"Skills": "Færdigheder",
"Languages": "Sprog",
"Interests": "Interesser",
"Awards": "Priser",
"Certifications": "Certificeringer",
"Publications": "Publikationer",
"Volunteer": "Frivillig",
"References": "Referencer",
"Cover Letter": "Følgebrev"
},
"de-DE": {
"Summary": "Zusammenfassung",
"Profiles": "Profile",
"Experience": "Erfahrung",
"Education": "Ausbildung",
"Projects": "Projekte",
"Skills": "Fähigkeiten",
"Languages": "Sprachen",
"Interests": "Interessen",
"Awards": "Auszeichnungen",
"Certifications": "Zertifizierungen",
"Publications": "Veröffentlichungen",
"Volunteer": "Freiwillige Arbeit",
"References": "Referenzen",
"Cover Letter": "Anschreiben"
},
"el-GR": {
"Summary": "Σύνοψη",
"Profiles": "Προφίλ",
"Experience": "Εμπειρία",
"Education": "Εκπαίδευση",
"Projects": "Έργα",
"Skills": "Δεξιότητες",
"Languages": "Γλώσσες",
"Interests": "Ενδιαφέροντα",
"Awards": "Βραβεία",
"Certifications": "Πιστοποιήσεις",
"Publications": "Δημοσιεύσεις",
"Volunteer": "Εθελοντική εργασία",
"References": "Συστάσεις",
"Cover Letter": "Συνοδευτική επιστολή"
},
"en-GB": {
"Summary": "Summary",
"Profiles": "Profiles",
"Experience": "Experience",
"Education": "Education",
"Projects": "Projects",
"Skills": "Skills",
"Languages": "Languages",
"Interests": "Interests",
"Awards": "Awards",
"Certifications": "Certifications",
"Publications": "Publications",
"Volunteer": "Volunteer",
"References": "References",
"Cover Letter": "Cover Letter"
},
"en-US": {
"Summary": "Summary",
"Profiles": "Profiles",
"Experience": "Experience",
"Education": "Education",
"Projects": "Projects",
"Skills": "Skills",
"Languages": "Languages",
"Interests": "Interests",
"Awards": "Awards",
"Certifications": "Certifications",
"Publications": "Publications",
"Volunteer": "Volunteer",
"References": "References",
"Cover Letter": "Cover Letter"
},
"es-ES": {
"Summary": "Resumen",
"Profiles": "Perfiles",
"Experience": "Experiencia",
"Education": "Formación académica",
"Projects": "Proyectos",
"Skills": "Habilidades",
"Languages": "Idiomas",
"Interests": "Intereses",
"Awards": "Premios",
"Certifications": "Certificaciones",
"Publications": "Publicaciones",
"Volunteer": "Voluntariado",
"References": "Referencias",
"Cover Letter": "Carta de presentación"
},
"fa-IR": {
"Summary": "چکیده",
"Profiles": "پروفایل‌ها",
"Experience": "تجربه",
"Education": "تحصیلات",
"Projects": "پروژه‌ها",
"Skills": "مهارت‌ها",
"Languages": "زبان‌ها",
"Interests": "علاقه‌مندی‌ها",
"Awards": "جوایز",
"Certifications": "گواهینامه‌ها",
"Publications": "انتشارها",
"Volunteer": "داوطلبانه",
"References": "معرف‌ها",
"Cover Letter": "نامه پوششی"
},
"fi-FI": {
"Summary": "Yhteenveto",
"Profiles": "Profiilit",
"Experience": "Työkokemus",
"Education": "Koulutus",
"Projects": "Projektit",
"Skills": "Taidot",
"Languages": "Kielet",
"Interests": "Kiinnostuksen kohteet",
"Awards": "Palkinnot",
"Certifications": "Sertifikaatit",
"Publications": "Julkaisut",
"Volunteer": "Vapaaehtoistyö",
"References": "Suosittelijat",
"Cover Letter": "Saatekirje"
},
"fr-FR": {
"Summary": "Résumé",
"Profiles": "Profils",
"Experience": "Expérience",
"Education": "Formation",
"Projects": "Projets",
"Skills": "Compétences",
"Languages": "Langues",
"Interests": "Centres d'intérêt",
"Awards": "Récompenses",
"Certifications": "Certifications",
"Publications": "Publications",
"Volunteer": "Bénévolat",
"References": "Références",
"Cover Letter": "Lettre de motivation"
},
"he-IL": {
"Summary": "תקציר",
"Profiles": "פרופילים",
"Experience": "ניסיון",
"Education": "השכלה",
"Projects": "פרויקטים",
"Skills": "מיומנויות",
"Languages": "שפות",
"Interests": "תחביבים",
"Awards": "פרסים",
"Certifications": "הסמכות",
"Publications": "פרסומים",
"Volunteer": "התנדבות",
"References": "ממליצים",
"Cover Letter": "מכתב מקדים"
},
"hi-IN": {
"Summary": "सारांश",
"Profiles": "प्रोफ़ाइल्स",
"Experience": "अनुभव",
"Education": "शिक्षा",
"Projects": "प्रोजेक्ट्स",
"Skills": "कौशल",
"Languages": "भाषाएँ",
"Interests": "रुचियाँ",
"Awards": "पुरस्कार",
"Certifications": "प्रमाणपत्र",
"Publications": "प्रकाशन",
"Volunteer": "स्वयंसेवा",
"References": "रेफ़रेंस",
"Cover Letter": "कवर लेटर"
},
"hu-HU": {
"Summary": "Összegzés",
"Profiles": "Profilok",
"Experience": "Tapasztalat",
"Education": "Tanulmányok",
"Projects": "Projektek",
"Skills": "Készségek",
"Languages": "Nyelvek",
"Interests": "Érdeklődési körök",
"Awards": "Díjak",
"Certifications": "Tanúsítványok",
"Publications": "Publikációk",
"Volunteer": "Önkéntesség",
"References": "Referenciák",
"Cover Letter": "Motivációs levél"
},
"id-ID": {
"Summary": "Ringkasan",
"Profiles": "Profil",
"Experience": "Pengalaman",
"Education": "Pendidikan",
"Projects": "Proyek",
"Skills": "Keahlian",
"Languages": "Bahasa",
"Interests": "Minat",
"Awards": "Penghargaan",
"Certifications": "Sertifikasi",
"Publications": "Publikasi",
"Volunteer": "Relawan",
"References": "Referensi",
"Cover Letter": "Surat Lamaran"
},
"it-IT": {
"Summary": "Riepilogo",
"Profiles": "Profili",
"Experience": "Esperienza",
"Education": "Formazione",
"Projects": "Progetti",
"Skills": "Competenze",
"Languages": "Lingue",
"Interests": "Interessi",
"Awards": "Premi",
"Certifications": "Certificazioni",
"Publications": "Pubblicazioni",
"Volunteer": "Volontariato",
"References": "Referenze",
"Cover Letter": "Lettera di presentazione"
},
"ja-JP": {
"Summary": "サマリー",
"Profiles": "プロフィール",
"Experience": "職歴",
"Education": "学歴",
"Projects": "プロジェクト",
"Skills": "スキル",
"Languages": "語学",
"Interests": "趣味・関心",
"Awards": "受賞歴",
"Certifications": "資格・認定",
"Publications": "著作物",
"Volunteer": "ボランティア",
"References": "リファレンス",
"Cover Letter": "カバーレター"
},
"km-KH": {
"Summary": "សង្ខេប",
"Profiles": "ប្រវត្តិ​សង្ខេប​បណ្ដាញ​សង្គម",
"Experience": "បទពិសោធន៍",
"Education": "ការ​សិក្សា",
"Projects": "គម្រោង",
"Skills": "ជំនាញ",
"Languages": "ភាសា",
"Interests": "ចំណាប់អារម្មណ៍",
"Awards": "រង្វាន់",
"Certifications": "វិញ្ញាបនបត្រ",
"Publications": "អត្ថបទផ្សព្វផ្សាយ",
"Volunteer": "ស្ម័គ្រចិត្ត",
"References": "យោង",
"Cover Letter": "លិខិតសម្រាប់ដាក់ពាក្យ"
},
"kn-IN": {
"Summary": "ಸಾರಾಂಶ",
"Profiles": "ಪ್ರೊಫೈಲ್‌ಗಳು",
"Experience": "ಅನುಭವ",
"Education": "ಶಿಕ್ಷಣ",
"Projects": "ಯೋಜನೆಗಳು",
"Skills": "ಕೌಶಲ್ಯಗಳು",
"Languages": "ಭಾಷೆಗಳು",
"Interests": "ಆಸಕ್ತಿಗಳು",
"Awards": "ಪ್ರಶಸ್ತಿಗಳು",
"Certifications": "ಪ್ರಮಾಣಪತ್ರಗಳು",
"Publications": "ಪ್ರಕಟನೆಗಳು",
"Volunteer": "ಸ್ವಯಂಸೇವಕ",
"References": "ಪರಿಚಯಗಳು",
"Cover Letter": "ಕವರ್ ಲೆಟರ್"
},
"ko-KR": {
"Summary": "요약",
"Profiles": "프로필",
"Experience": "경력",
"Education": "학력",
"Projects": "프로젝트",
"Skills": "기술",
"Languages": "언어",
"Interests": "관심사",
"Awards": "수상 내역",
"Certifications": "자격증",
"Publications": "출판물",
"Volunteer": "봉사",
"References": "추천인",
"Cover Letter": "자기소개서"
},
"lt-LT": {
"Summary": "Santrauka",
"Profiles": "Profiliai",
"Experience": "Patirtis",
"Education": "Išsilavinimas",
"Projects": "Projektai",
"Skills": "Įgūdžiai",
"Languages": "Kalbos",
"Interests": "Pomėgiai",
"Awards": "Apdovanojimai",
"Certifications": "Sertifikatai",
"Publications": "Publikacijos",
"Volunteer": "Savanorystė",
"References": "Rekomendacijos",
"Cover Letter": "Motyvacinis laiškas"
},
"lv-LV": {
"Summary": "Kopsavilkums",
"Profiles": "Profili",
"Experience": "Pieredze",
"Education": "Izglītība",
"Projects": "Projekti",
"Skills": "Prasmes",
"Languages": "Valodas",
"Interests": "Intereses",
"Awards": "Apbalvojumi",
"Certifications": "Sertifikāti",
"Publications": "Publikācijas",
"Volunteer": "Brīvprātīgais darbs",
"References": "Atsauksmes",
"Cover Letter": "Motivācijas vēstule"
},
"ml-IN": {
"Summary": "സമ്മരി",
"Profiles": "പ്രൊഫൈലുകൾ",
"Experience": "അനുഭവം",
"Education": "വിദ്യാഭ്യാസം",
"Projects": "പ്രോജക്ടുകൾ",
"Skills": "സ്കിൽസ്",
"Languages": "ഭാഷകൾ",
"Interests": "താൽപര്യങ്ങൾ",
"Awards": "അവാർഡുകൾ",
"Certifications": "സർട്ടിഫിക്കേഷനുകൾ",
"Publications": "പ്രസിദ്ധീകരണങ്ങൾ",
"Volunteer": "വളന്റിയർ",
"References": "റഫറൻസുകൾ",
"Cover Letter": "കവർ ലെറ്റർ"
},
"mr-IN": {
"Summary": "सारांश",
"Profiles": "प्रोफाईल्स",
"Experience": "अनुभव",
"Education": "शिक्षण",
"Projects": "प्रोजेक्ट्स",
"Skills": "कौशल्ये",
"Languages": "भाषा",
"Interests": "आवडी",
"Awards": "पुरस्कार",
"Certifications": "प्रमाणपत्रे",
"Publications": "प्रकाशने",
"Volunteer": "स्वयंसेवा",
"References": "रेफरन्सेस",
"Cover Letter": "कव्हर लेटर"
},
"ms-MY": {
"Summary": "Ringkasan",
"Profiles": "Profil",
"Experience": "Pengalaman",
"Education": "Pendidikan",
"Projects": "Projek",
"Skills": "Kemahiran",
"Languages": "Bahasa",
"Interests": "Minat",
"Awards": "Anugerah",
"Certifications": "Pensijilan",
"Publications": "Penerbitan",
"Volunteer": "Sukarelawan",
"References": "Rujukan",
"Cover Letter": "Surat Iringan"
},
"ne-NP": {
"Summary": "सारांश",
"Profiles": "प्रोफाइलहरू",
"Experience": "अनुभव",
"Education": "शिक्षा",
"Projects": "परियोजनाहरू",
"Skills": "सीपहरू",
"Languages": "भाषाहरू",
"Interests": "रुचिहरू",
"Awards": "पुरस्कारहरू",
"Certifications": "प्रमाणपत्रहरू",
"Publications": "प्रकाशनहरू",
"Volunteer": "स्वयंसेवक",
"References": "सन्दर्भहरू (References)",
"Cover Letter": "कभर लेटर"
},
"nl-NL": {
"Summary": "Overzicht",
"Profiles": "Profielen",
"Experience": "Ervaring",
"Education": "Opleiding",
"Projects": "Projecten",
"Skills": "Vaardigheden",
"Languages": "Talen",
"Interests": "Interesses",
"Awards": "Prijzen",
"Certifications": "Certificeringen",
"Publications": "Publicaties",
"Volunteer": "Vrijwilliger",
"References": "Referenties",
"Cover Letter": "Motivatiebrief"
},
"no-NO": {
"Summary": "Sammendrag",
"Profiles": "Profiler",
"Experience": "Erfaring",
"Education": "Utdanning",
"Projects": "Prosjekter",
"Skills": "Ferdigheter",
"Languages": "Språk",
"Interests": "Interesser",
"Awards": "Priser",
"Certifications": "Sertifiseringer",
"Publications": "Publikasjoner",
"Volunteer": "Frivillig",
"References": "Referanser",
"Cover Letter": "Følgebrev"
},
"or-IN": {
"Summary": "ସାରାଂଶ",
"Profiles": "ପ୍ରୋଫାଇଲ୍‌ଗୁଡିକ",
"Experience": "ଅନୁଭବ",
"Education": "ଶିକ୍ଷା",
"Projects": "ପ୍ରକଳ୍ପଗୁଡିକ",
"Skills": "କୌଶଳଗୁଡିକ",
"Languages": "ଭାଷାଗୁଡିକ",
"Interests": "ରୁଚିଗୁଡିକ",
"Awards": "ପୁରସ୍କାରଗୁଡିକ",
"Certifications": "ପ୍ରମାଣପତ୍ରଗୁଡିକ",
"Publications": "ପ୍ରକାଶନଗୁଡିକ",
"Volunteer": "ସ୍ୱେଚ୍ଛାସେବକ",
"References": "ସନ୍ଦର୍ଭଗୁଡିକ",
"Cover Letter": "କଭର ଲେଟର"
},
"pl-PL": {
"Summary": "Podsumowanie",
"Profiles": "Profile",
"Experience": "Doświadczenie",
"Education": "Wykształcenie",
"Projects": "Projekty",
"Skills": "Umiejętności",
"Languages": "Języki",
"Interests": "Zainteresowania",
"Awards": "Wyróżnienia",
"Certifications": "Certyfikaty",
"Publications": "Publikacje",
"Volunteer": "Wolontariat",
"References": "Referencje",
"Cover Letter": "List motywacyjny"
},
"pt-BR": {
"Summary": "Resumo",
"Profiles": "Perfis",
"Experience": "Experiência",
"Education": "Formação",
"Projects": "Projetos",
"Skills": "Habilidades",
"Languages": "Idiomas",
"Interests": "Interesses",
"Awards": "Prêmios",
"Certifications": "Certificações",
"Publications": "Publicações",
"Volunteer": "Voluntariado",
"References": "Referências",
"Cover Letter": "Carta de apresentação"
},
"pt-PT": {
"Summary": "Resumo",
"Profiles": "Perfis",
"Experience": "Experiência",
"Education": "Formação",
"Projects": "Projetos",
"Skills": "Competências",
"Languages": "Idiomas",
"Interests": "Interesses",
"Awards": "Prémios",
"Certifications": "Certificações",
"Publications": "Publicações",
"Volunteer": "Voluntariado",
"References": "Referências",
"Cover Letter": "Carta de apresentação"
},
"ro-RO": {
"Summary": "Sumar",
"Profiles": "Profile",
"Experience": "Experiență",
"Education": "Educație",
"Projects": "Proiecte",
"Skills": "Abilități",
"Languages": "Limbi",
"Interests": "Interese",
"Awards": "Distincții",
"Certifications": "Certificări",
"Publications": "Publicații",
"Volunteer": "Voluntariat",
"References": "Referințe",
"Cover Letter": "Scrisoare de intenție"
},
"ru-RU": {
"Summary": "Краткое содержание",
"Profiles": "Профили",
"Experience": "Опыт",
"Education": "Образование",
"Projects": "Проекты",
"Skills": "Навыки",
"Languages": "Языки",
"Interests": "Интересы",
"Awards": "Награды",
"Certifications": "Сертификаты",
"Publications": "Публикации",
"Volunteer": "Волонтерство",
"References": "Рекомендации",
"Cover Letter": "Сопроводительное письмо"
},
"sk-SK": {
"Summary": "Zhrnutie",
"Profiles": "Profily",
"Experience": "Skúsenosti",
"Education": "Vzdelanie",
"Projects": "Projekty",
"Skills": "Zručnosti",
"Languages": "Jazyky",
"Interests": "Záujmy",
"Awards": "Ocenenia",
"Certifications": "Certifikácie",
"Publications": "Publikácie",
"Volunteer": "Dobrovoľníctvo",
"References": "Referencie",
"Cover Letter": "Motivačný list"
},
"sl-SI": {
"Summary": "Povzetek",
"Profiles": "Profili",
"Experience": "Izkušnje",
"Education": "Izobrazba",
"Projects": "Projekti",
"Skills": "Spretnosti",
"Languages": "Jeziki",
"Interests": "Interesi",
"Awards": "Priznanja",
"Certifications": "Certifikati",
"Publications": "Publikacije",
"Volunteer": "Prostovoljec",
"References": "Reference",
"Cover Letter": "Spremno pismo"
},
"sq-AL": {
"Summary": "Përmbledhja",
"Profiles": "Profile",
"Experience": "Eksperienca",
"Education": "Arsimimi",
"Projects": "Projekte",
"Skills": "Aftësi",
"Languages": "Gjuhët",
"Interests": "Interesa",
"Awards": "Çmime",
"Certifications": "Certifikime",
"Publications": "Publikime",
"Volunteer": "Punë vullnetare",
"References": "Referenca",
"Cover Letter": "Letër motivuese"
},
"sr-SP": {
"Summary": "Сажетак",
"Profiles": "Профили",
"Experience": "Искуство",
"Education": "Образовање",
"Projects": "Пројекти",
"Skills": "Вештине",
"Languages": "Језици",
"Interests": "Интересовања",
"Awards": "Награде",
"Certifications": "Сертификације",
"Publications": "Публикације",
"Volunteer": "Волонтерски рад",
"References": "Референце",
"Cover Letter": "Пропратно писмо"
},
"sv-SE": {
"Summary": "Sammanfattning",
"Profiles": "Profiler",
"Experience": "Erfarenhet",
"Education": "Utbildning",
"Projects": "Projekt",
"Skills": "Färdigheter",
"Languages": "Språk",
"Interests": "Intressen",
"Awards": "Utmärkelser",
"Certifications": "Certifieringar",
"Publications": "Publikationer",
"Volunteer": "Volontärarbete",
"References": "Referenser",
"Cover Letter": "Personligt brev"
},
"ta-IN": {
"Summary": "சுருக்கம்",
"Profiles": "சுயவிவரங்கள்",
"Experience": "அனுபவம்",
"Education": "கல்வி",
"Projects": "திட்டங்கள்",
"Skills": "திறன்கள்",
"Languages": "மொழிகள்",
"Interests": "விருப்பங்கள்",
"Awards": "விருதுகள்",
"Certifications": "சான்றிதழ்கள்",
"Publications": "வெளியீடுகள்",
"Volunteer": "தன்னார்வம்",
"References": "பரிந்துரைகள்",
"Cover Letter": "கவர் லெட்டர்"
},
"te-IN": {
"Summary": "సమ్మరీ",
"Profiles": "ప్రొఫైల్స్",
"Experience": "అనుభవం",
"Education": "విద్య",
"Projects": "ప్రాజెక్ట్స్",
"Skills": "స్కిల్స్",
"Languages": "భాషలు",
"Interests": "అభిరుచులు",
"Awards": "పురస్కారాలు",
"Certifications": "ప్రామாணపత్రాలు",
"Publications": "పబ్లికేషన్స్",
"Volunteer": "వాలంటియర్",
"References": "రిఫరెన్స్లు",
"Cover Letter": "కవర్ లెటర్"
},
"th-TH": {
"Summary": "ภาพรวม",
"Profiles": "โปรไฟล์",
"Experience": "ประสบการณ์",
"Education": "การศึกษา",
"Projects": "โปรเจกต์",
"Skills": "ทักษะ",
"Languages": "ภาษา",
"Interests": "ความสนใจ",
"Awards": "รางวัล",
"Certifications": "ใบรับรอง",
"Publications": "สิ่งตีพิมพ์",
"Volunteer": "อาสาสมัคร",
"References": "บุคคลอ้างอิง",
"Cover Letter": "จดหมายสมัครงาน"
},
"tr-TR": {
"Summary": "Özet",
"Profiles": "Profiller",
"Experience": "Deneyim",
"Education": "Eğitim",
"Projects": "Projeler",
"Skills": "Yetenekler",
"Languages": "Diller",
"Interests": "İlgi Alanları",
"Awards": "Ödüller",
"Certifications": "Sertifikalar",
"Publications": "Yayınlar",
"Volunteer": "Gönüllü",
"References": "Referanslar",
"Cover Letter": "Ön Yazı"
},
"uk-UA": {
"Summary": "Загальні відомості",
"Profiles": "Профілі",
"Experience": "Досвід",
"Education": "Освіта",
"Projects": "Проєкти",
"Skills": "Навички",
"Languages": "Мови",
"Interests": "Інтереси",
"Awards": "Нагороди",
"Certifications": "Сертифікати",
"Publications": "Публікації",
"Volunteer": "Волонтерство",
"References": "Рекомендації",
"Cover Letter": "Супровідний лист"
},
"uz-UZ": {
"Summary": "Qisqacha ma'lumot",
"Profiles": "Profillar",
"Experience": "Tajriba",
"Education": "Ta'lim",
"Projects": "Loyihalar",
"Skills": "Koʻnikmalar",
"Languages": "Tillar",
"Interests": "Qiziqishlar",
"Awards": "Mukofotlar",
"Certifications": "Sertifikatlar",
"Publications": "Nashrlar",
"Volunteer": "Ko‘ngillilar",
"References": "Tavsiyalar",
"Cover Letter": "Xat"
},
"vi-VN": {
"Summary": "Tóm tắt",
"Profiles": "Hồ sơ",
"Experience": "Kinh nghiệm",
"Education": "Giáo dục",
"Projects": "Dự án",
"Skills": "Kỹ năng",
"Languages": "Ngôn ngữ",
"Interests": "Sở thích",
"Awards": "Giải thưởng",
"Certifications": "Chứng chỉ",
"Publications": "Ấn phẩm",
"Volunteer": "Tình nguyện viên",
"References": "Người tham khảo",
"Cover Letter": "Thư xin việc"
},
"zh-CN": {
"Summary": "总结",
"Profiles": "个人资料",
"Experience": "工作经历",
"Education": "教育经历",
"Projects": "项目",
"Skills": "技能",
"Languages": "语言",
"Interests": "兴趣爱好",
"Awards": "奖项",
"Certifications": "证书",
"Publications": "出版物",
"Volunteer": "志愿者经历",
"References": "推荐人",
"Cover Letter": "求职信"
},
"zh-TW": {
"Summary": "摘要",
"Profiles": "個人檔案",
"Experience": "經歷",
"Education": "學歷",
"Projects": "專案",
"Skills": "技能",
"Languages": "語言",
"Interests": "興趣",
"Awards": "獎項",
"Certifications": "證照",
"Publications": "出版品",
"Volunteer": "志工服務",
"References": "推薦人",
"Cover Letter": "求職信"
},
"zu-ZA": {
"Summary": "Summary",
"Profiles": "Profiles",
"Experience": "Experience",
"Education": "Education",
"Projects": "Projects",
"Skills": "Skills",
"Languages": "Languages",
"Interests": "Interests",
"Awards": "Awards",
"Certifications": "Certifications",
"Publications": "Publications",
"Volunteer": "Volunteer",
"References": "References",
"Cover Letter": "Cover Letter"
}
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { getResumeSectionTitle } from "./section-title";
import { createResumePdfFile } from "./server";
function fixture(locale: string) {
const data = structuredClone(defaultResumeData);
data.metadata.page.locale = locale;
data.metadata.typography.body.fontFamily = "Helvetica";
data.metadata.typography.heading.fontFamily = "Helvetica";
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
data.picture.hidden = true;
data.summary.content = "<p>Localized document content.</p>";
return data;
}
describe("default PDF section title locale", () => {
it("localizes default headings without an injected browser translator", () => {
const data = fixture("es-ES");
expect(getResumeSectionTitle(data, "summary", "")).toBe("Resumen");
expect(getResumeSectionTitle(data, "experience", "")).toBe("Experiencia");
expect(getResumeSectionTitle(data, "education", "")).toBe("Formación académica");
});
it("preserves explicit titles and caller-provided translators", () => {
const data = fixture("es-ES");
data.summary.title = "My custom title";
expect(getResumeSectionTitle(data, "summary", "")).toBe("My custom title");
expect(getResumeSectionTitle({ ...data, resolveSectionTitle: () => "Custom translation" }, "experience", "")).toBe(
"Custom translation",
);
});
it("resolves custom sections by their type and preserves unknown-section fallbacks", () => {
const data = fixture("es-ES");
data.customSections = [{ ...data.sections.experience, id: "custom-experience", type: "experience" }];
expect(getResumeSectionTitle(data, "custom-experience", "")).toBe("Experiencia");
data.customSections = [{ ...data.sections.experience, id: "custom-summary", type: "summary", items: [] }];
expect(getResumeSectionTitle(data, "custom-summary", "")).toBe("Resumen");
expect(getResumeSectionTitle(data, "unknown", "Legacy label")).toBe("Legacy label");
});
it.each([
["es-ES", "Resumen"],
["fr-FR", "Résumé"],
["unknown-locale", "Summary"],
])("includes localized default headings in the actual server PDF: %s", async (locale, heading) => {
const data = fixture(locale);
const file = await createResumePdfFile({ data, filename: "resume.pdf" });
const task = getDocument({ data: new Uint8Array(await file.arrayBuffer()) });
try {
const pdf = await task.promise;
const text = (await (await pdf.getPage(1)).getTextContent()).items
.map((item) => ("str" in item ? item.str : ""))
.join(" ");
expect(text).toContain(heading);
expect(text).toContain("Localized document content.");
} finally {
await task.destroy();
}
});
});
+14 -3
View File
@@ -1,4 +1,5 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import sectionTitleCatalog from "./section-title-catalog.json";
type SectionTitleResolverInput = {
sectionId: string;
@@ -27,9 +28,18 @@ const defaultEnglishSectionTitles: Record<"summary" | SectionType, string> = {
};
const defaultEnglishCustomSectionTitles: Record<string, string> = {
summary: "Summary",
"cover-letter": "Cover Letter",
};
const catalogs: Record<string, Record<string, string>> = sectionTitleCatalog;
const defaultSectionTitleResolver: SectionTitleResolver = ({ locale, defaultEnglishTitle }) => {
if (!defaultEnglishTitle) return "";
const translated = Object.hasOwn(catalogs, locale) ? catalogs[locale]?.[defaultEnglishTitle] : undefined;
return translated ?? defaultEnglishTitle;
};
export const resolveSectionTitle = (
title: string,
input: SectionTitleResolverInput,
@@ -52,6 +62,7 @@ type RenderData = ResumeData & {
export const getResumeSectionTitle = (data: RenderData, sectionId: string, legacyFallback?: string) => {
const locale = data.metadata.page.locale;
const resolver = data.resolveSectionTitle ?? defaultSectionTitleResolver;
if (sectionId === "summary") {
const defaultEnglishTitle = defaultEnglishSectionTitles.summary;
@@ -59,7 +70,7 @@ export const getResumeSectionTitle = (data: RenderData, sectionId: string, legac
return resolveSectionTitle(
data.summary.title,
{ sectionId, locale, sectionKind: "summary", defaultEnglishTitle },
data.resolveSectionTitle,
resolver,
legacyFallback,
);
}
@@ -71,7 +82,7 @@ export const getResumeSectionTitle = (data: RenderData, sectionId: string, legac
return resolveSectionTitle(
data.sections[sectionType].title,
{ sectionId, locale, sectionKind: "builtin", defaultEnglishTitle },
data.resolveSectionTitle,
resolver,
legacyFallback,
);
}
@@ -87,7 +98,7 @@ export const getResumeSectionTitle = (data: RenderData, sectionId: string, legac
return resolveSectionTitle(
customSection.title,
{ sectionId, locale, sectionKind: "custom", customSectionType: customSection.type, defaultEnglishTitle },
data.resolveSectionTitle,
resolver,
legacyFallback,
);
}
@@ -86,7 +86,7 @@ describe("semantic pagination cancellation", () => {
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const section = findSectionView(instance.container.document as HostNode, "Pagination sentinel");
const section = findSectionView(instance.container.document as HostNode, "SummaryPagination sentinel");
expect(flowProps(section)).toEqual({ break: breakBefore, wrap });
});
+3
View File
@@ -1316,6 +1316,9 @@ importers:
specifier: workspace:*
version: link:../packages/schema
devDependencies:
'@lingui/format-po':
specifier: ^6.6.0
version: 6.6.0
'@reactive-resume/config':
specifier: workspace:*
version: link:../packages/config
@@ -0,0 +1,82 @@
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { Pool } from "pg";
import { createSampleResumeFromDashboard } from "../fixtures/resume";
import { expect, test } from "../fixtures/test";
const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`);
async function textOf(bytes: Uint8Array) {
const { getDocument } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs"));
const task = getDocument({ data: new Uint8Array(bytes) });
try {
const pdf = await task.promise;
const pages = [];
for (let i = 1; i <= pdf.numPages; i++)
pages.push(
(await (await pdf.getPage(i)).getTextContent()).items.map((x: { str?: string }) => x.str ?? "").join(" "),
);
return pages.join("\n");
} finally {
await task.destroy();
}
}
test("uses the resume language for anonymous browser and server PDF downloads", async ({
authPage: page,
browser,
account,
}, info) => {
test.setTimeout(90_000);
await createSampleResumeFromDashboard(page, info);
const id = page.url().split("/").at(-1);
await page.goto("/dashboard/resumes");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
let slug: string;
try {
const result = await pool.query("select data,slug from resume where id=$1", [id]);
const data = result.rows[0].data;
slug = result.rows[0].slug;
data.metadata.page.locale = "es-ES";
data.picture.hidden = true;
data.metadata.typography.body.fontFamily = "Source Sans 3";
data.metadata.typography.heading.fontFamily = "Source Sans 3";
data.summary.title = "";
for (const section of Object.values(data.sections) as Array<{ title: string }>) section.title = "";
await pool.query("update resume set data=$2,is_public=true,updated_at=now() where id=$1", [id, data]);
} finally {
await pool.end();
}
const visitor = await browser.newPage();
try {
await visitor.goto(`/${account.username}/${slug}`);
await expect(visitor.locator(".textLayer").first()).toContainText("Experiencia", { timeout: 30_000 });
const dl = visitor.waitForEvent("download");
await visitor.getByRole("button", { name: "Download PDF", exact: true }).first().click();
const path = await (await dl).path();
if (!path) throw new Error("No PDF");
const browserText = await textOf(await readFile(path));
expect(browserText).toContain("Experiencia");
const response = await visitor.request.get(`/api/resumes/${account.username}/${slug}/pdf`);
expect(response.ok()).toBe(true);
const serverText = await textOf(await response.body());
expect(serverText).toContain("Experiencia");
} finally {
await visitor.close();
}
const fallback = await browser.newPage();
try {
await fallback.route("https://fonts.gstatic.com/**", (route) => route.abort());
const response = fallback.waitForResponse((response) =>
response.url().endsWith(`/api/resumes/${account.username}/${slug}/pdf`),
);
await fallback.goto(`/${account.username}/${slug}`);
expect((await response).ok()).toBe(true);
await expect(fallback.locator(".textLayer").first()).toContainText("Experiencia", { timeout: 30_000 });
const download = fallback.waitForEvent("download");
await fallback.getByRole("button", { name: "Download PDF", exact: true }).first().click();
const path = await (await download).path();
if (!path) throw new Error("No fallback PDF");
expect(await textOf(await readFile(path))).toContain("Experiencia");
} finally {
await fallback.close();
}
});
+9
View File
@@ -0,0 +1,9 @@
import { writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { generateSectionTitleCatalog } from "./section-titles";
const catalogs = fileURLToPath(new URL("../../apps/web/locales/", import.meta.url));
const output = new URL("../../packages/pdf/src/section-title-catalog.json", import.meta.url);
const translations = await generateSectionTitleCatalog(catalogs);
await writeFile(output, `${JSON.stringify(translations, null, "\t")}\n`);
console.log(`Updated PDF section titles for ${Object.keys(translations).length} locales.`);
+14
View File
@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { generateSectionTitleCatalog } from "./section-titles";
it("keeps PDF headings synchronized with the source Lingui catalogs", async () => {
const catalogs = fileURLToPath(new URL("../../apps/web/locales/", import.meta.url));
const generated = await generateSectionTitleCatalog(catalogs);
const committed = JSON.parse(
await readFile(new URL("../../packages/pdf/src/section-title-catalog.json", import.meta.url), "utf8"),
);
expect(committed, "Run pnpm pdf:translations after updating translations.").toEqual(generated);
expect(generated["es-ES"]?.Experience).toBe("Experiencia");
});
+42
View File
@@ -0,0 +1,42 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { formatter } from "@lingui/format-po";
const titles = [
"Summary",
"Profiles",
"Experience",
"Education",
"Projects",
"Skills",
"Languages",
"Interests",
"Awards",
"Certifications",
"Publications",
"Volunteer",
"References",
"Cover Letter",
];
/** Only default resume headings are needed by the PDF renderer, not the entire application catalog. */
export async function generateSectionTitleCatalog(catalogDirectory: string) {
const result: Record<string, Record<string, string>> = {};
for (const filename of (await readdir(catalogDirectory)).filter((file) => file.endsWith(".po")).sort()) {
const locale = filename.slice(0, -3);
const catalog = await formatter().parse(await readFile(join(catalogDirectory, filename), "utf8"), {
locale,
sourceLocale: "en-US",
filename,
});
const messages = Object.values(catalog).filter((message) => !message.obsolete && !message.context);
result[locale] = Object.fromEntries(
titles.map((title) => {
const message = messages.find((message) => message.message === title);
if (locale === "en-US" && !message) throw new Error(`Missing source section title: ${title}`);
return [title, message?.translation?.trim() ? message.translation : title];
}),
);
}
return result;
}
+3 -1
View File
@@ -9,12 +9,14 @@
"fonts:generate": "tsx fonts/generate.ts",
"test": "vitest run --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"pdf:translations": "tsx locales/generate.ts"
},
"dependencies": {
"@reactive-resume/schema": "workspace:*"
},
"devDependencies": {
"@lingui/format-po": "^6.6.0",
"@reactive-resume/config": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/resume": "workspace:*",