diff --git a/.github/workflows/generate-cv-pdf.yml b/.github/workflows/generate-cv-pdf.yml index eb3cc14..33e361b 100644 --- a/.github/workflows/generate-cv-pdf.yml +++ b/.github/workflows/generate-cv-pdf.yml @@ -4,7 +4,8 @@ on: push: branches: [ main ] paths-ignore: - - 'static/colin-moerbe-cv.pdf' + - 'static/colin-moerbe-cv-en.pdf' + - 'static/colin-moerbe-cv-de.pdf' workflow_dispatch: permissions: @@ -45,7 +46,7 @@ jobs: - name: Wait for preview server run: | for i in {1..30}; do - if curl -sSf http://127.0.0.1:4173/cv/ > /dev/null; then + if curl -sSf http://127.0.0.1:4173/en/cv/ > /dev/null && curl -sSf http://127.0.0.1:4173/de/cv/ > /dev/null; then echo "Preview server is up" exit 0 fi @@ -55,7 +56,7 @@ jobs: cat preview.log exit 1 - - name: Generate CV PDF + - name: Generate CV PDFs run: bun run generate:cv-pdf - name: Stop preview server @@ -66,20 +67,24 @@ jobs: - name: Install qpdf run: sudo apt-get update && sudo apt-get install -y qpdf - - name: Normalize generated PDF + - name: Normalize generated PDFs shell: bash run: | - if [ -f static/colin-moerbe-cv.pdf ]; then - qpdf --linearize static/colin-moerbe-cv.pdf static/colin-moerbe-cv.pdf.normalized - mv static/colin-moerbe-cv.pdf.normalized static/colin-moerbe-cv.pdf - fi + for file in static/colin-moerbe-cv-en.pdf static/colin-moerbe-cv-de.pdf; do + if [ ! -f "$file" ]; then + echo "Missing artifact: $file" + exit 1 + fi + qpdf --linearize "$file" "$file.normalized" + mv "$file.normalized" "$file" + done - name: Check for PDF changes id: pdf_paths shell: bash run: | - changed=$(git diff --name-only -- static/colin-moerbe-cv.pdf) - new=$(git ls-files --others --exclude-standard -- static/colin-moerbe-cv.pdf) + changed=$(git diff --name-only -- static/colin-moerbe-cv-en.pdf static/colin-moerbe-cv-de.pdf) + new=$(git ls-files --others --exclude-standard -- static/colin-moerbe-cv-en.pdf static/colin-moerbe-cv-de.pdf) if [ -z "$changed" ] && [ -z "$new" ]; then echo "changed=false" >> "$GITHUB_OUTPUT" @@ -87,13 +92,15 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create pull request for updated PDF + - name: Create pull request for updated PDFs if: steps.pdf_paths.outputs.changed == 'true' uses: peter-evans/create-pull-request@v8 with: - commit-message: "chore: regenerate CV PDF" - title: "chore: regenerate CV PDF" - body: "Automated update of the CV PDF." - branch: "chore/update-cv-pdf" + commit-message: "chore: regenerate localized CV PDFs" + title: "chore: regenerate localized CV PDFs" + body: "Automated update of EN/DE CV PDFs." + branch: "chore/update-cv-pdfs" delete-branch: true - add-paths: static/colin-moerbe-cv.pdf + add-paths: | + static/colin-moerbe-cv-en.pdf + static/colin-moerbe-cv-de.pdf diff --git a/README.md b/README.md index 1c62d72..a19dde1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ This repository contains my personal portfolio. - **Runtime:** Bun - **Hosting:** Netlify +## Bilingual Content Workflow (EN/DE) + +- Content lives in: + - `src/lib/content/en/` + - `src/lib/content/de/` +- Filenames must be identical in both locales. +- Content needs to be updated in both locales. +- `bun run check:i18n` and `bun run generate:cv-pdf:local` should be run after updating content. + ## Documentation - [Setup & Development Guide](docs/setup.md) diff --git a/package.json b/package.json index 54cf247..4fb1b45 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "preview": "vite preview", "preview:ci": "vite preview --host 127.0.0.1 --port 4173", "generate:cv-pdf": "bun scripts/generate-cv-pdf.mjs", - "generate:cv-pdf:local": "bun run build && bash -lc 'bun run preview:ci > /dev/null 2>&1 & PREVIEW_PID=$!; trap \"kill $PREVIEW_PID\" EXIT; READY=0; for i in {1..30}; do if curl -sSf http://127.0.0.1:4173/cv/ >/dev/null 2>&1; then READY=1; break; fi; sleep 1; done; if [ \"$READY\" -ne 1 ]; then echo \"Preview server did not become ready in time\"; exit 1; fi; bun run generate:cv-pdf'", + "generate:cv-pdf:local": "bun run build && bash -lc 'bun run preview:ci > /dev/null 2>&1 & PREVIEW_PID=$!; trap \"kill $PREVIEW_PID\" EXIT; READY=0; for i in {1..30}; do if curl -sSf http://127.0.0.1:4173/en/cv/ >/dev/null 2>&1 && curl -sSf http://127.0.0.1:4173/de/cv/ >/dev/null 2>&1; then READY=1; break; fi; sleep 1; done; if [ \"$READY\" -ne 1 ]; then echo \"Preview server did not become ready in time\"; exit 1; fi; bun run generate:cv-pdf'", + "check:i18n": "bun scripts/check-i18n-consistency.mjs", "prepare": "svelte-kit sync", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/scripts/check-i18n-consistency.mjs b/scripts/check-i18n-consistency.mjs new file mode 100644 index 0000000..42c860a --- /dev/null +++ b/scripts/check-i18n-consistency.mjs @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; + +const BASE = path.resolve("src/lib/content"); +const locales = ["en", "de"]; +const requiredFiles = [ + "profile.md", + "contact.md", + "skills.md", + "experience.md", + "education.md", + "languages.md", + "projects.md", + "certificates.md" +]; + +/** + * Lists all Markdown files in a given locale directory. + * @param {string} locale - The locale directory. + * @returns {string[]} List of Markdown file names. + */ +function listMarkdownFiles(locale) { + const dir = path.join(BASE, locale); + if (!fs.existsSync(dir)) { + throw new Error(`Missing locale directory: ${dir}`); + } + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith(".md")) + .map(entry => entry.name) + .sort(); +} + +/** + * Asserts that all required files exist in a given locale. + * @param {string} locale - The locale directory. + * @param {string[]} files - List of Markdown file names. + */ +function assertRequiredFilesExist(locale, files) { + const missing = requiredFiles.filter(file => !files.includes(file)); + if (missing.length > 0) { + throw new Error(`Locale "${locale}" is missing required files: ${missing.join(", ")}`); + } +} + +/** + * Main function to run the i18n consistency check. + */ +function main() { + const localeFiles = Object.fromEntries(locales.map(locale => [locale, listMarkdownFiles(locale)])); + + for (const locale of locales) { + assertRequiredFilesExist(locale, localeFiles[locale]); + } + + const [first, ...rest] = locales; + for (const locale of rest) { + const missingInLocale = localeFiles[first].filter(file => !localeFiles[locale].includes(file)); + const extraInLocale = localeFiles[locale].filter(file => !localeFiles[first].includes(file)); + + if (missingInLocale.length > 0 || extraInLocale.length > 0) { + throw new Error( + [ + `File mismatch between "${first}" and "${locale}":`, + missingInLocale.length ? `- Missing in ${locale}: ${missingInLocale.join(", ")}` : "", + extraInLocale.length ? `- Extra in ${locale}: ${extraInLocale.join(", ")}` : "" + ] + .filter(Boolean) + .join("\n") + ); + } + } + + console.log("✅ i18n content consistency check passed"); +} + +try { + main(); +} catch (error) { + console.error("❌ i18n consistency check failed"); + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/generate-cv-pdf.mjs b/scripts/generate-cv-pdf.mjs index 6141643..810c9d1 100644 --- a/scripts/generate-cv-pdf.mjs +++ b/scripts/generate-cv-pdf.mjs @@ -1,31 +1,36 @@ import { chromium } from "playwright"; const BASE_URL = "http://127.0.0.1:4173"; -const CV_URL = `${BASE_URL}/cv/`; -const outputPath = "static/colin-moerbe-cv.pdf"; +const targets = [ + { locale: "en", url: `${BASE_URL}/en/cv/`, outputPath: "static/colin-moerbe-cv-en.pdf" }, + { locale: "de", url: `${BASE_URL}/de/cv/`, outputPath: "static/colin-moerbe-cv-de.pdf" } +]; /** - * Generates the CV PDF from the /cv route and writes it to the stable output path. + * Generates one PDF from a CV route. * - * @returns {Promise} Resolves when PDF generation is complete. + * @param {{ locale: string; url: string; outputPath: string }} target + * @returns {Promise} */ -async function generatePdf() { +async function generatePdfForTarget(target) { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); - const response = await page.goto(CV_URL, { waitUntil: "networkidle" }); + const response = await page.goto(target.url, { waitUntil: "networkidle" }); if (!response || !response.ok()) { const status = response?.status() ?? "NO_RESPONSE"; - throw new Error(`Failed to load CV page for PDF generation (status: ${status}).`); + throw new Error( + `Failed to load ${target.locale.toUpperCase()} CV page for PDF generation (status: ${status}).` + ); } await page.emulateMedia({ media: "print" }); await page.pdf({ - path: outputPath, + path: target.outputPath, format: "A4", printBackground: true, displayHeaderFooter: false, @@ -37,21 +42,30 @@ async function generatePdf() { } }); - console.log(`✅ Generated ${outputPath}`); + console.log(`✅ Generated ${target.outputPath}`); } finally { await browser.close(); } } /** - * Handles PDF generation failures and exits the process with an error code. + * Generates all localized CV PDFs. * - * @param {unknown} err - The thrown error. - * @returns {never} This function always terminates the process. + * @returns {Promise} + */ +async function generateAllPdfs() { + await Promise.all(targets.map(target => generatePdfForTarget(target))); +} + +/** + * Handles PDF generation failures and exits with an error. + * + * @param {unknown} err + * @returns {never} */ function handlePdfGenerationError(err) { console.error("❌ PDF generation failed:", err); process.exit(1); } -generatePdf().catch(handlePdfGenerationError); +generateAllPdfs().catch(handlePdfGenerationError); diff --git a/src/lib/components/CertificateCard.svelte b/src/lib/components/CertificateCard.svelte index 3e151a3..0f0f67d 100644 --- a/src/lib/components/CertificateCard.svelte +++ b/src/lib/components/CertificateCard.svelte @@ -1,11 +1,20 @@
- View Certificate + {ctaLabel}
{/if} diff --git a/src/lib/content/de/certificates.md b/src/lib/content/de/certificates.md new file mode 100644 index 0000000..e237eb2 --- /dev/null +++ b/src/lib/content/de/certificates.md @@ -0,0 +1,7 @@ +# Zertifikate + +## Microsoft zertifiziert: Azure Grundlagen + +_Microsoft, 2024_ + +- [Zum Zertifikat](https://learn.microsoft.com/de-de/users/colinmoerbe/credentials/3f640b77279204a5) diff --git a/src/lib/content/de/contact.md b/src/lib/content/de/contact.md new file mode 100644 index 0000000..8b9d382 --- /dev/null +++ b/src/lib/content/de/contact.md @@ -0,0 +1,9 @@ +# Kontakt + +- **Name:** Colin Mörbe +- **Role:** Backend Software Engineer +- **Location:** 72654 Neckartenzlingen +- **Phone:** +49 1577 5842277 +- **Email:** colin@familie-moerbe.de +- **LinkedIn:** [linkedin.com/in/colin-moerbe](https://linkedin.com/in/colin-moerbe) +- **GitHub:** [github.com/Colin23](https://github.com/Colin23) diff --git a/src/lib/content/de/education.md b/src/lib/content/de/education.md new file mode 100644 index 0000000..273c530 --- /dev/null +++ b/src/lib/content/de/education.md @@ -0,0 +1,13 @@ +# Ausbildung + +## 03/2020 – 08/2023 + +### Hochschule Reutlingen + +B.Sc. Medien- und Kommunikationsinformatik (Note: 2,0) + +## 10/2018 – 02/2020 + +### Universität Stuttgart + +B.Sc. Softwaretechnik diff --git a/src/lib/content/de/experience.md b/src/lib/content/de/experience.md new file mode 100644 index 0000000..1970a80 --- /dev/null +++ b/src/lib/content/de/experience.md @@ -0,0 +1,38 @@ +# Berufserfahrung + +## 09/2023 – Heute + +### SSC-Services GmbH — Böblingen + +**Software Engineer** + +- Entwicklung und kontinuierliche Verbesserung einer containerisierten Microservice-Landschaft (~15–20 Services) in + einer unternehmensweiten Systemumgebung. +- Analyse, Design und technische Umsetzung von Geschäftsanforderungen in enger Zusammenarbeit mit dem Kunden. +- Entwicklung von ereignis- und workflowbasierten Backend-Services für die Prozessautomatisierung. +- Implementierung von hochleistungsfähigen Delta-Load-Migrationsprozessen (mehr als drei Millionen Datensätze) unter + Verwendung von Parallelisierung. +- Entwicklung von Kafka-basierten Integrationsschnittstellen mit hohem Durchsatz. +- Entwicklung, Containerisierung und Betrieb von Backend-Services auf Kubernetes (Azure) sowie Aufbau einer + AWS-Infrastruktur mit CDK (Java). +- Verbesserung der CI/CD-Pipelines und nachhaltige Reduzierung wiederkehrender Produktionsprobleme durch + Refaktorisierung und verbesserte Protokollierungs- und Überwachungsstrategien. +- Leitung einer unternehmensweiten Lerninitiative, einschließlich Design und Entwicklung eines internen Tools zur + Abbildung von Lernpfaden und Kompetenzen. + +## 11/2022 – 06/2023 + +### envite consulting GmbH — Stuttgart + +**Werkstudent DevOps Engineer** + +- Implementierung von CI/CD-Pipelines. +- Entwicklung einer Methodik zur Schätzung des Energieverbrauchs von Build- und Deployment-Pipelines. + +## 03/2022 – 08/2022 + +### pronexon GmbH — Reutlingen + +**Praktikum, System Integration** + +- Entwicklung und Implementierung von Server-, Speicher- und Backup-Lösungen. diff --git a/src/lib/content/de/languages.md b/src/lib/content/de/languages.md new file mode 100644 index 0000000..b56b6dc --- /dev/null +++ b/src/lib/content/de/languages.md @@ -0,0 +1,4 @@ +# Sprachen + +- Deutsch (Muttersprache) +- Englisch (verhandlungssicher) diff --git a/src/lib/content/de/profile.md b/src/lib/content/de/profile.md new file mode 100644 index 0000000..e60b5de --- /dev/null +++ b/src/lib/content/de/profile.md @@ -0,0 +1,5 @@ +# Profil + +Backend-Softwareentwickler mit Schwerpunkt auf modularen, ereignisbasierten Systemen in Cloud-Umgebungen. Erfahrung in +der Konzeption und Entwicklung skalierbarer Backend-Services sowie im Betrieb containerisierter Anwendungen auf +Kubernetes (Azure) mit einem starken Fokus auf Architektur, Automatisierung und Systemzuverlässigkeit. diff --git a/src/lib/content/de/projects.md b/src/lib/content/de/projects.md new file mode 100644 index 0000000..5094dbe --- /dev/null +++ b/src/lib/content/de/projects.md @@ -0,0 +1,12 @@ +# Projekte + +## Persönliches Portfolio + +_SvelteKit, Tailwind CSS, Bun, mdsvex_ + +- Eine hochperformante, SEO-freundliche Portfolio-Website. +- Automatisierte PDF-Generierung aus Markdown-Inhalten. +- Statisches Hosting auf Netlify. + +[GitHub](https://github.com/Colin23/portfolio) +[Live Demo](https://colinmoerbe.com) diff --git a/src/lib/content/de/skills.md b/src/lib/content/de/skills.md new file mode 100644 index 0000000..0facdec --- /dev/null +++ b/src/lib/content/de/skills.md @@ -0,0 +1,41 @@ +# Technische Schwerpunkte + +## Backend + +- Java (21/25) +- Spring Boot +- Hibernate +- REST +- Ereignis-basierte Architektur +- Workflow Systems + +## Cloud & Infrastruktur + +- Docker +- Kubernetes +- AWS (CDK, Fargate, EC2, S3, Secrets Manager) +- Azure (Azure Container Registry, Artifact Feeds) + +## Daten & Messaging + +- PostgreSQL +- JPA +- Liquibase +- Kafka + +## CI/CD & DevOps + +- GitLab CI +- GitHub Actions +- Terraform +- ArgoCD + +## Grundkenntnisse + +- Kotlin +- Python +- Go +- Prometheus +- Grafana +- JavaScript/TypeScript +- Vue diff --git a/src/lib/content/certificates.md b/src/lib/content/en/certificates.md similarity index 64% rename from src/lib/content/certificates.md rename to src/lib/content/en/certificates.md index 25d88f8..522b6f9 100644 --- a/src/lib/content/certificates.md +++ b/src/lib/content/en/certificates.md @@ -4,4 +4,4 @@ _Microsoft, 2024_ -- [View Certificate](https://learn.microsoft.com/de-de/users/colinmoerbe/credentials/3f640b77279204a5) +- [View Certificate](https://learn.microsoft.com/en-us/users/colinmoerbe/credentials/3f640b77279204a5) diff --git a/src/lib/content/contact.md b/src/lib/content/en/contact.md similarity index 100% rename from src/lib/content/contact.md rename to src/lib/content/en/contact.md diff --git a/src/lib/content/education.md b/src/lib/content/en/education.md similarity index 100% rename from src/lib/content/education.md rename to src/lib/content/en/education.md diff --git a/src/lib/content/experience.md b/src/lib/content/en/experience.md similarity index 100% rename from src/lib/content/experience.md rename to src/lib/content/en/experience.md diff --git a/src/lib/content/languages.md b/src/lib/content/en/languages.md similarity index 100% rename from src/lib/content/languages.md rename to src/lib/content/en/languages.md diff --git a/src/lib/content/profile.md b/src/lib/content/en/profile.md similarity index 100% rename from src/lib/content/profile.md rename to src/lib/content/en/profile.md diff --git a/src/lib/content/projects.md b/src/lib/content/en/projects.md similarity index 100% rename from src/lib/content/projects.md rename to src/lib/content/en/projects.md diff --git a/src/lib/content/skills.md b/src/lib/content/en/skills.md similarity index 100% rename from src/lib/content/skills.md rename to src/lib/content/en/skills.md diff --git a/src/lib/familiarity-titles.ts b/src/lib/familiarity-titles.ts new file mode 100644 index 0000000..337c262 --- /dev/null +++ b/src/lib/familiarity-titles.ts @@ -0,0 +1,6 @@ +import type { Locale } from "$lib/i18n"; + +export const FAMILIARITY_TITLES_BY_LOCALE: Record = { + en: ["Working Knowledge", "Familiarity", "Additional Technologies", "Basic knowledge"], + de: ["Grundkenntnisse", "Grundwissen", "Zusätzliche Technologien", "Vertrautheit"] +}; diff --git a/src/lib/i18n-copy.ts b/src/lib/i18n-copy.ts new file mode 100644 index 0000000..c940665 --- /dev/null +++ b/src/lib/i18n-copy.ts @@ -0,0 +1,122 @@ +import type { Locale } from "$lib/i18n"; + +export const copy = { + en: { + site: { + description: + "Backend Software Engineer portfolio with projects, professional experience, technical skills, certificates, and a downloadable CV." + }, + nav: { + skills: "Tech Stack", + experience: "Experience", + education: "Education", + languages: "Languages", + projects: "Projects", + certificates: "Certificates", + contact: "Contact" + }, + layout: { + portfolio: "Portfolio", + downloadCv: "Download Resume", + toggleTheme: "Toggle Dark Mode", + toggleMenu: "Toggle Menu", + switchTo: "Switch language", + skipToContent: "Skip to content" + }, + home: { + heroGreeting: "Hi, I'm", + getInTouch: "Get in touch", + viewResume: "View Resume", + technicalExpertise: "Technical Expertise", + professionalExperience: "Professional Experience", + education: "Education", + languages: "Languages", + projects: "Projects", + certificates: "Certificates", + viewCertificate: "View Certificate", + getInTouchHeading: "Get in Touch", + contactIntro: + "I'm always open to discussing new projects, creative ideas, or opportunities to be part of your vision.", + name: "Name", + email: "Email", + message: "Message", + sendMessage: "Send Message" + }, + cv: { + backToPortfolio: "Back to Portfolio", + downloadCv: "Download CV", + profile: "Profile", + technicalExpertise: "Technical Expertise", + basicKnowledge: "Basic knowledge", + languages: "Languages" + }, + success: { + title: "Thanks for your message!", + body: "I got your submission and will get back to you soon.", + backToContact: "Back to contact section" + } + }, + de: { + site: { + description: + "Backend Software Engineer Portfolio mit Projekten, Berufserfahrung, Tech-Stack, Zertifikaten und downloadbarem Lebenslauf." + }, + nav: { + skills: "Technologie", + experience: "Erfahrung", + education: "Ausbildung", + languages: "Sprachen", + projects: "Projekte", + certificates: "Zertifikate", + contact: "Kontakt" + }, + layout: { + portfolio: "Portfolio", + downloadCv: "Lebenslauf herunterladen", + toggleTheme: "Dark Mode umschalten", + toggleMenu: "Menü umschalten", + switchTo: "Sprache wechseln", + skipToContent: "Zum Inhalt springen" + }, + home: { + heroGreeting: "Hi, ich bin", + getInTouch: "Kontakt aufnehmen", + viewResume: "Lebenslauf ansehen", + technicalExpertise: "Technische Expertise", + professionalExperience: "Berufserfahrung", + education: "Ausbildung", + languages: "Sprachen", + projects: "Projekte", + certificates: "Zertifikate", + viewCertificate: "Zum Zertifikat", + getInTouchHeading: "Kontakt", + contactIntro: + "Ich bin immer offen für neue Projekte, kreative Ideen oder Möglichkeiten, Teil eurer Vision zu sein.", + name: "Name", + email: "E-Mail", + message: "Nachricht", + sendMessage: "Nachricht senden" + }, + cv: { + backToPortfolio: "Zurück zum Portfolio", + downloadCv: "Lebenslauf herunterladen", + profile: "Profil", + technicalExpertise: "Technische Expertise", + basicKnowledge: "Grundkenntnisse", + languages: "Sprachen" + }, + success: { + title: "Vielen Dank für deine Nachricht!", + body: "Ich habe deine Nachricht erhalten und melde mich bald zurück.", + backToContact: "Zurück zum Kontaktbereich" + } + } +} as const; + +/** + * Shortcut for accessing localized copy. + * @param locale 2-letter locale code + */ +export function t(locale: Locale): (typeof copy)[Locale] { + return copy[locale]; +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..4606872 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,21 @@ +export const SUPPORTED_LOCALES = ["en", "de"] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = "en"; + +/** + * Checks if a value is a valid locale. + * @param value The value to check. + */ +export function isLocale(value: string): value is Locale { + return SUPPORTED_LOCALES.includes(value as Locale); +} + +/** + * Gets the alternative locale for a given locale. + * @param locale The locale to get the alternative for. + */ +export function getAltLocale(locale: Locale): Locale { + return locale === "en" ? "de" : "en"; +} diff --git a/src/lib/server/content-loader.ts b/src/lib/server/content-loader.ts new file mode 100644 index 0000000..0dd3b54 --- /dev/null +++ b/src/lib/server/content-loader.ts @@ -0,0 +1,345 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Locale } from "$lib/i18n"; +import { FAMILIARITY_TITLES_BY_LOCALE } from "$lib/familiarity-titles"; + +type Contact = { + name?: string; + role?: string; + location?: string; + phone?: string; + email?: string; + linkedin?: string; + github?: string; +}; + +type SkillGroup = { title: string; items: string[] }; + +type ExperienceEntry = { + title: string; + period: string; + role: string; + content: string; +}; + +type EducationEntry = { + institution: string; + degree: string; + period: string; +}; + +type CertificateEntry = { + title: string; + info: string; + link: string | undefined; +}; + +type ProjectEntry = { + title: string; + tech: string; + content: string; + github: string | undefined; + liveDemo: string | undefined; +}; + +/** + * Resolves the content file path for a given locale and file name. + * @param locale 2-letter locale code + * @param fileName File name including extension + */ +async function resolveContentPathAsync(locale: Locale, fileName: string): Promise { + const localizedPath = path.resolve(`src/lib/content/${locale}/${fileName}`); + + try { + await fs.promises.access(localizedPath, fs.constants.F_OK); + return localizedPath; + } catch { + // Backward-compatible fallback while migrating files. + return path.resolve(`src/lib/content/${fileName}`); + } +} + +/** + * Reads the content of a file for a given locale. + * @param locale 2-letter locale code + * @param fileName File name including extension + */ +async function readContent(locale: Locale, fileName: string): Promise { + const resolvedPath = await resolveContentPathAsync(locale, fileName); + return fs.promises.readFile(resolvedPath, "utf-8"); +} + +/** + * Parses experience sections from Markdown content. + * @param content Markdown content. + */ +function parseExperience(content: string): ExperienceEntry[] { + const sections = content.split(/^## /m).slice(1); + return sections.map(section => { + const lines = section + .split("\n") + .map(line => line.trim()) + .filter(Boolean); + + const period = lines[0] ?? ""; + const titleLine = lines.find(line => line.startsWith("### ")) ?? ""; + const title = titleLine.replace(/^###\s+/, "").trim(); + + const roleLine = lines.find(line => line.startsWith("**") && line.endsWith("**")) ?? ""; + const role = roleLine.replaceAll("**", "").trim(); + + const listItems = lines.filter(line => line.startsWith("- ")); + const htmlContent = listItems.map(line => `
  • ${line.substring(2).trim()}
  • `).join("\n"); + + return { + title, + period, + role, + content: htmlContent ? `
      ${htmlContent}
    ` : "" + }; + }); +} + +/** + * Parses certificates sections from Markdown content. + * @param content Markdown content. + */ +function parseCertificates(content: string): CertificateEntry[] { + const sections = content + .split(/^## /m) + .slice(1) + .filter(section => section.trim().length > 0); + + return sections.map(section => { + const lines = section + .split("\n") + .map(line => line.trim()) + .filter(Boolean); + + const title = (lines[0] ?? "").trim(); + const infoLine = lines.find(l => l.startsWith("_") && l.endsWith("_")); + const info = infoLine ? infoLine.replaceAll("_", "").trim() : ""; + + const linkMatch = lines + .map(line => /\[[^\]]+]\(([^)]+)\)/.exec(line)) + .find((match): match is RegExpExecArray => match !== null); + const link = linkMatch?.[1]; + + return { title, info, link }; + }); +} + +/** + * Parses projects sections from Markdown content. + * @param content Markdown content. + */ +function parseProjects(content: string): ProjectEntry[] { + const sections = content.split(/^## /m).slice(1); + return sections.map(section => { + const lines = section.split("\n"); + const title = lines[0].trim(); + const techLine = lines.find(l => l.startsWith("_") && l.endsWith("_")); + const tech = techLine ? techLine.replaceAll("_", "").trim() : ""; + + const techIndex = techLine ? lines.indexOf(techLine) : -1; + const contentStart = techIndex >= 0 ? techIndex + 1 : 1; + const linksStart = lines.findIndex(l => l.startsWith("[GitHub]") || l.startsWith("[Live Demo]")); + const rawContentLines = lines.slice(contentStart, linksStart > -1 ? linksStart : undefined); + const rawContent = rawContentLines.join("\n").trim(); + + const htmlContent = rawContent + .split("\n") + .map(line => (line.startsWith("- ") ? `
  • ${line.substring(2)}
  • ` : line)) + .join("\n"); + + const github = lines.find(l => l.startsWith("[GitHub]"))?.match(/\(([^)]+)\)/)?.[1]; + const liveDemo = lines.find(l => l.startsWith("[Live Demo]"))?.match(/\(([^)]+)\)/)?.[1]; + + return { + title, + tech, + content: htmlContent.includes("
  • ") ? `
      ${htmlContent}
    ` : htmlContent, + github, + liveDemo + }; + }); +} + +/** + * Parses skills sections from Markdown content. + * @param content Markdown content. + */ +function parseSkills(content: string): SkillGroup[] { + const sections = content.split(/^## /m).slice(1); + return sections.map(section => { + const lines = section.split("\n"); + const title = lines[0].trim(); + const items = lines + .slice(1) + .filter(l => l.startsWith("- ")) + .map(l => l.substring(2).replaceAll("**", "").trim()); + + return { title, items }; + }); +} + +/** + * Parses the profile section from Markdown content. + * @param content Markdown content. + */ +function parseProfile(content: string): string { + return content + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith("#")) + .join(" ") + .replaceAll(/\s+/g, " ") + .trim(); +} + +/** + * Parses the contact section from Markdown content. + * @param content Markdown content. + */ +function parseContact(content: string): Contact { + const lines = content + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("- **")); + + const fields: Record = {}; + + for (const line of lines) { + const match = new RegExp(/^- \*\*(.+?):\*\*\s*(.+)$/).exec(line); + if (!match) continue; + const key = match[1].toLowerCase(); + fields[key] = match[2].replaceAll(/\[(.*?)]\((.*?)\)/g, "$2").trim(); + } + + return { + name: fields.name, + role: fields.role, + location: fields.location, + phone: fields.phone, + email: fields.email, + linkedin: fields.linkedin, + github: fields.github + }; +} + +/** + * Parses education sections from Markdown content. + * @param content Markdown content. + */ +function parseEducation(content: string): EducationEntry[] { + const sections = content.split(/^## /m).slice(1); + + return sections.map(section => { + const lines = section + .split("\n") + .map(line => line.trim()) + .filter(Boolean); + + const period = lines[0] ?? ""; + const institutionLine = lines.find(line => line.startsWith("### ")) ?? ""; + const institution = institutionLine.replace(/^###\s+/, "").trim(); + + const degreeLine = lines.find(line => line.startsWith("**") && line.endsWith("**")) ?? ""; + const degree = degreeLine.replaceAll("**", "").trim(); + + return { institution, degree, period }; + }); +} + +/** + * Parses the languages section from Markdown content. + * @param content Markdown content. + */ +function parseLanguages(content: string): string[] { + return content + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("- ")) + .map(line => line.substring(2).trim()); +} + +/** + * Loads portfolio content for a given locale. + * @param locale 2-letter locale code + */ +export async function loadPortfolioContent(locale: Locale): Promise<{ + locale: Locale; + projects: ProjectEntry[]; + skills: SkillGroup[]; + experience: ExperienceEntry[]; + certificates: CertificateEntry[]; + profile: string; + contact: Contact; + education: EducationEntry[]; + languages: string[]; +}> { + const projects = parseProjects(await readContent(locale, "projects.md")); + const skills = parseSkills(await readContent(locale, "skills.md")); + const experience = parseExperience(await readContent(locale, "experience.md")); + const certificates = parseCertificates(await readContent(locale, "certificates.md")); + const profile = parseProfile(await readContent(locale, "profile.md")); + const contact = parseContact(await readContent(locale, "contact.md")); + const education = parseEducation(await readContent(locale, "education.md")); + const languages = parseLanguages(await readContent(locale, "languages.md")); + + return { + locale, + projects, + skills, + experience, + certificates, + profile, + contact, + education, + languages + }; +} + +/** + * Loads CV content for a given locale. + * @param locale 2-letter locale code + */ +export async function loadCvContent(locale: Locale): Promise<{ + locale: Locale; + contact: Contact; + profile: string; + coreSkills: SkillGroup[]; + familiarity: string[]; + languages: string[]; +}> { + const contact = parseContact(await readContent(locale, "contact.md")); + const profile = parseProfile(await readContent(locale, "profile.md")); + const skills = parseSkills(await readContent(locale, "skills.md")); + const languages = parseLanguages(await readContent(locale, "languages.md")); + + const normalizedFamiliarityTitles = new Set( + FAMILIARITY_TITLES_BY_LOCALE[locale].map(title => title.trim().toLocaleLowerCase()) + ); + + const coreSkills = skills.filter(group => !normalizedFamiliarityTitles.has(group.title.trim().toLocaleLowerCase())); + const familiarity = skills + .filter(group => normalizedFamiliarityTitles.has(group.title.trim().toLocaleLowerCase())) + .flatMap(group => group.items); + + if (!contact.name || !contact.role || !contact.location || !contact.phone || !contact.email) { + throw new Error("contact.md is missing required fields for CV rendering."); + } + + if (!profile) { + throw new Error("profile.md is empty or invalid."); + } + + return { + locale, + contact, + profile, + coreSkills, + familiarity, + languages + }; +} diff --git a/src/params/lang.ts b/src/params/lang.ts new file mode 100644 index 0000000..db81241 --- /dev/null +++ b/src/params/lang.ts @@ -0,0 +1,8 @@ +import { isLocale } from "$lib/i18n"; + +/** + * SvelteKit route param matcher for locale prefixes. + */ +export function match(param: string): boolean { + return isLocale(param); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 459408a..64660fe 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,28 +3,56 @@ import { onMount } from "svelte"; import { page } from "$app/state"; import { browser } from "$app/environment"; + import { goto } from "$app/navigation"; + import { getAltLocale, isLocale, type Locale } from "$lib/i18n"; + import { t } from "$lib/i18n-copy"; const { children } = $props(); const siteUrl = "https://colinmoerbe.com"; - const siteTitle = "Colin Mörbe | Portfolio"; - const siteDescription = - "Colin Mörbe | Backend Software Engineer portfolio with projects, professional experience, technical skills, certificates, and a downloadable CV."; + const locale = $derived.by(() => { + const first = page.url.pathname.split("/").filter(Boolean)[0] ?? ""; + return isLocale(first) ? first : "en"; + }); + const ui = $derived(t(locale)); + const otherLocale = $derived(getAltLocale(locale)); + + const isPortfolioHomeRoute = $derived.by(() => { + const segments = page.url.pathname.split("/").filter(Boolean); + return segments.length === 0 || (segments.length === 1 && (segments[0] === "en" || segments[0] === "de")); + }); + + const localizedSiteTitle = "Colin Mörbe | Portfolio"; + const localizedSiteDescription = $derived(ui.site.description); + const canonicalUrl = $derived(`${siteUrl}${page.url.pathname}`); + const localeAgnosticPath = $derived.by(() => { + const segments = page.url.pathname.split("/").filter(Boolean); + if (segments[0] === "en" || segments[0] === "de") segments.shift(); + + const path = `/${segments.join("/")}`; + if (path === "/") return "/"; + if (page.url.pathname.endsWith("/")) return `${path}/`; + return path; + }); + + const hreflangEn = $derived(`${siteUrl}/en${localeAgnosticPath}`); + const hreflangDe = $derived(`${siteUrl}/de${localeAgnosticPath}`); + let isMenuOpen = $state(false); let mobileMenuEl = $state(null); let menuToggleButtonEl = $state(null); - const navItems = [ - { name: "Tech Stack", id: "skills" }, - { name: "Experience", id: "experience" }, - { name: "Education", id: "education" }, - { name: "Languages", id: "languages" }, - { name: "Projects", id: "projects" }, - { name: "Certificates", id: "certificates" }, - { name: "Contact", id: "contact" } - ]; + const navItems = $derived.by(() => [ + { id: "skills", label: ui.nav.skills }, + { id: "experience", label: ui.nav.experience }, + { id: "education", label: ui.nav.education }, + { id: "languages", label: ui.nav.languages }, + { id: "projects", label: ui.nav.projects }, + { id: "certificates", label: ui.nav.certificates }, + { id: "contact", label: ui.nav.contact } + ]); let scrollSection = $state(""); @@ -32,7 +60,7 @@ * Initialize state before the first paint on the client */ $effect.pre(() => { - if (!browser || page.url.pathname !== "/") { + if (!browser || !isPortfolioHomeRoute) { scrollSection = ""; return; } @@ -62,8 +90,7 @@ * Derives the active section based on the current scroll position. */ const activeSection = $derived.by(() => { - const path = page.url.pathname; - if (path !== "/") return ""; + if (!isPortfolioHomeRoute) return ""; return scrollSection; }); @@ -74,7 +101,7 @@ * Updates the active section based on which section top is closest to the header anchor line. */ function updateActiveSectionByPosition(): void { - if (!browser || page.url.pathname !== "/") return; + if (!browser || !isPortfolioHomeRoute) return; const sections = navItems .map(item => document.getElementById(item.id)) @@ -156,10 +183,6 @@ } onMount((): (() => void) => { - if (page.url.pathname === "/") { - setupObserver(); - } - const handleDocumentClick = (event: MouseEvent): void => { if (!isMenuOpen) return; @@ -177,9 +200,6 @@ document.addEventListener("click", handleDocumentClick, true); return (): void => { - observer?.disconnect(); - window.removeEventListener("scroll", scheduleActiveSectionUpdate); - if (rafId !== undefined) { window.cancelAnimationFrame(rafId); rafId = undefined; @@ -192,21 +212,57 @@ /** * Handles client-side navigation between routes by setting up observers/listeners for active section tracking. */ - $effect((): void => { - const path = page.url.pathname; + $effect(() => { + if (!browser) return; + document.documentElement.lang = locale; + }); - if (path === "/") { - setupObserver(); - } else { + $effect(() => { + if (!browser) return; + + if (!isPortfolioHomeRoute) { observer?.disconnect(); + window.removeEventListener("scroll", scheduleActiveSectionUpdate); + scrollSection = ""; + return; } + + setupObserver(); + + return (): void => { + observer?.disconnect(); + window.removeEventListener("scroll", scheduleActiveSectionUpdate); + }; }); /** - * Triggers the CV download by opening the /cv route in a hidden iframe and printing it. + * Opens the locale-specific CV PDF in a new browser tab. + * + * The downloadCV function selects the file path based on the current locale + * and calls window.open with "_blank" and "noopener,noreferrer". */ function downloadCV(): void { - window.open("/colin-moerbe-cv.pdf", "_blank", "noopener,noreferrer"); + const file = locale === "de" ? "/colin-moerbe-cv-de.pdf" : "/colin-moerbe-cv-en.pdf"; + window.open(file, "_blank", "noopener,noreferrer"); + } + + /** + * Sets the active language for the website. + * @param target - The locale to switch to. + */ + function switchLanguage(target: Locale): void { + const segments = page.url.pathname.split("/").filter(Boolean); + if (segments[0] === "en" || segments[0] === "de") { + segments[0] = target; + } else { + segments.unshift(target); + } + + const nextPath = `/${segments.join("/")}${page.url.pathname.endsWith("/") ? "/" : ""}`; + goto(`${nextPath}${page.url.search}${page.url.hash}`, { + noScroll: true, + keepFocus: true + }); } /** @@ -243,25 +299,28 @@ - {siteTitle} - + {localizedSiteTitle} + + + + - - + + - - + + - Skip to content + {ui.layout.skipToContent}