Compare commits

..

15 Commits

Author SHA1 Message Date
Joakim Repomaa
83f0783a43 fix hover effect for footer links
All checks were successful
Build and Deploy / build (push) Successful in 1m44s
2026-02-20 10:47:18 +02:00
Joakim Repomaa
1970350916 improve contrast of muted text in light mode
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-02-20 10:45:09 +02:00
Joakim Repomaa
e2c7b86cf0 update gitea url
All checks were successful
Build and Deploy / build (push) Successful in 1m42s
2026-02-19 22:41:41 +02:00
Joakim Repomaa
66dd269601 update meta
All checks were successful
Build and Deploy / build (push) Successful in 1m44s
2026-02-19 22:34:01 +02:00
Joakim Repomaa
45b7903b59 update favicon
All checks were successful
Build and Deploy / build (push) Successful in 1m42s
2026-02-19 22:29:53 +02:00
Joakim Repomaa
f604e50172 fix pdf layout
All checks were successful
Build and Deploy / build (push) Successful in 1m43s
2026-02-19 22:21:44 +02:00
Joakim Repomaa
96171576c7 cleanup
Some checks failed
Build and Deploy / build (push) Failing after 19s
2026-02-19 22:10:44 +02:00
Joakim Repomaa
782f46f69f improve accessibility 2026-02-19 19:16:26 +02:00
Joakim Repomaa
1744466b2b improve pdf layout and add profile pics 2026-02-19 19:16:09 +02:00
Joakim Repomaa
60567717eb add vite devtools json 2026-02-19 17:39:45 +02:00
Joakim Repomaa
2f76f1ce38 improve content 2026-02-19 17:36:43 +02:00
Joakim Repomaa
aeecf3b87d improve layout and accessibility 2026-02-19 17:35:54 +02:00
Joakim Repomaa
b5b5b6139e update AGENTS.md 2026-02-19 16:47:21 +02:00
Joakim Repomaa
80ecfa6e0f trigger deployment from ci
All checks were successful
Build and Deploy / build (push) Successful in 1m40s
2026-02-19 16:36:00 +02:00
Joakim Repomaa
49a4d21d49 update statichost config 2026-02-19 16:35:22 +02:00
47 changed files with 694 additions and 631 deletions

View File

@@ -43,7 +43,7 @@ jobs:
path: build/
retention-days: 7
#- name: Deploy to statichost.eu
# if: github.ref == 'refs/heads/main'
# run: |
# curl -XPOST https://builder.statichost.eu/repomaa-cv
- name: Deploy to statichost.eu
if: github.ref == 'refs/heads/main'
run: |
curl -XPOST https://builder.statichost.eu/repomaa-cv

View File

@@ -84,8 +84,8 @@ npm run format # Format all files with Prettier
### Content Loading
- YAML files in `/content/` directory
- Use `loadYamlFile<T>()` pattern from `src/lib/content/loader.ts`
- JSON files in `/src/lib/content/` directory
- Use `loadJsonFile<T>()` pattern from `src/lib/content/loader.ts`
- Provide default/fallback values for missing content
- Async data fetching in `+page.server.ts` with `prerender = true`
@@ -99,11 +99,10 @@ npm run format # Format all files with Prettier
### Styling Patterns
- Terminal aesthetic with JetBrains Mono font
- Terminal aesthetic with Iosevka font (monospace)
- Dark mode via `light-dark()` CSS function
- Cyan accent color for interactive elements
- Zinc-based neutral colors
- Print styles: `.no-print` class hides elements in PDF
## Project Structure
@@ -111,17 +110,19 @@ npm run format # Format all files with Prettier
src/
lib/
components/ # Svelte components (PascalCase)
content/ # Content loading utilities
types.ts # TypeScript type definitions
github.ts # GitHub API integration
content/ # Content loading utilities + JSON data
types.ts # TypeScript type re-exports
utils/ # Utility functions (date formatting, etc.)
routes/
+page.svelte # Main CV page
+page.server.ts # Server-side data loading
pdf/ # Print-optimized PDF version
app.css # Global styles with @theme
content/ # YAML content files
static/ # Static assets (admin/, fonts)
build/ # Output directory (static + cv.pdf)
(web)/ # Web layout group
+page.svelte # Main CV page
+page.server.ts
+layout.svelte
print/ # Print-optimized PDF version
admin/ # Sveltia CMS admin panel
app.css # Global styles with @theme
static/ # Static assets (admin/, fonts)
build/ # Output directory (static + cv.pdf)
```
## Testing
@@ -138,3 +139,11 @@ This project has no test framework configured. To add tests, consider:
- Use `devbox shell` on NixOS for Chrome support
- PDF generation gracefully skips if Chrome unavailable (CI)
- Static site deployable to any host (outputs to `/build/`)
## Devbox (NixOS)
```bash
devbox shell # Enter devbox environment
devbox run dev # Run dev server via devbox
devbox run build # Build via devbox
```

63
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"dependencies": {
"@fontsource-variable/roboto-condensed": "^5.2.8",
"@fontsource/iosevka": "^5.2.5",
"@sveltia/cms": "^0.140.3"
"@sveltia/cms": "^0.140.3",
"remeda": "^2.33.6"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@@ -27,10 +28,12 @@
"puppeteer": "^24.37.3",
"svelte": "^5.51.0",
"svelte-check": "^4.1.0",
"svelte-feather-icons": "^4.2.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0"
}
},
"node_modules/@babel/code-frame": {
@@ -3579,6 +3582,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/remeda": {
"version": "2.33.6",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.6.tgz",
"integrity": "sha512-tazDGH7s75kUPGBKLvhgBEHMgW+TdDFhjUAMdQj57IoWz6HsGa5D2RX5yDUz6IIqiRRvZiaEHzCzWdTeixc/Kg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/remeda"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3963,6 +3975,26 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-feather-icons": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/svelte-feather-icons/-/svelte-feather-icons-4.2.0.tgz",
"integrity": "sha512-KuMTDrL6sA8aCxBv3RMgmmnnyIaAXaYcmWkmNa2r2Qj70vi+An2T6ZBAdiZr6wjx+a3eZJy+FRseeRkzQFGHPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"svelte": "^3.38.2"
}
},
"node_modules/svelte-feather-icons/node_modules/svelte": {
"version": "3.59.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz",
"integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -4113,6 +4145,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -4188,6 +4234,19 @@
}
}
},
"node_modules/vite-plugin-devtools-json": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/vite-plugin-devtools-json/-/vite-plugin-devtools-json-1.0.0.tgz",
"integrity": "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"uuid": "^11.1.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/vitefu": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",

View File

@@ -13,7 +13,8 @@
"dependencies": {
"@fontsource-variable/roboto-condensed": "^5.2.8",
"@fontsource/iosevka": "^5.2.5",
"@sveltia/cms": "^0.140.3"
"@sveltia/cms": "^0.140.3",
"remeda": "^2.33.6"
},
"keywords": [
"cv",
@@ -38,9 +39,11 @@
"puppeteer": "^24.37.3",
"svelte": "^5.51.0",
"svelte-check": "^4.1.0",
"svelte-feather-icons": "^4.2.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0"
}
}

View File

@@ -14,6 +14,18 @@ body {
-moz-osx-font-smoothing: grayscale;
}
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
/* Theme configuration - 5 colors per layout */
@theme {
/* Font families */
@@ -23,9 +35,9 @@ body {
/* Web layout - 5 colors using light-dark() for dark mode */
--color-bg: light-dark(#fafafa, #0c0c0e);
--color-fg: light-dark(#18181b, #fafafa);
--color-muted: light-dark(#71717a, #a1a1aa);
--color-accent: light-dark(#0891b2, #22d3ee);
--color-hot: light-dark(#ea580c, #fb923c);
--color-muted: light-dark(#56565d, #a1a1aa);
--color-accent: light-dark(#0e7490, #22d3ee);
--color-hot: light-dark(#c2410c, #fb923c);
/* PDF layout - 4 colors (print-optimized) */
--color-pdf-bg: #ffffff;

View File

@@ -2,14 +2,17 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="%sveltekit.assets%/icon-192x192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Professional CV - Developer Portfolio" />
<meta name="description" content="Developer Portfolio" />
<meta name="theme-color" content="#0891b2" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#22d3ee" media="(prefers-color-scheme: dark)" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
%sveltekit.body%
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
interface Props {
label: string;
}
let { label }: Props = $props();
</script>
<span class="text-accent before:content-['['] after:content-[']']">{label}</span>

View File

@@ -3,10 +3,14 @@
import Section from './Section.svelte';
import TimelineItem from './TimelineItem.svelte';
let { education }: { education: Education[] } = $props();
interface Props {
education: Education[];
}
let { education }: Props = $props();
</script>
<Section title="education">
<Section title="Education">
<div class="space-y-6">
{#each education as edu}
<TimelineItem
@@ -15,7 +19,6 @@
location={edu.location}
startDate={edu.startDate}
endDate={edu.endDate}
current={edu.current}
description={edu.description}
/>
{/each}

View File

@@ -3,10 +3,14 @@
import Section from './Section.svelte';
import TimelineItem from './TimelineItem.svelte';
let { experience }: { experience: Experience[] } = $props();
interface Props {
experience: Experience[];
}
let { experience }: Props = $props();
</script>
<Section title="experience">
<Section title="Experience">
<div class="space-y-8">
{#each experience as job}
<TimelineItem
@@ -15,7 +19,6 @@
location={job.location}
startDate={job.startDate}
endDate={job.endDate}
current={job.current}
description={job.description}
tags={job.technologies}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
</script>
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted">
<p class="font-mono">
Built with
<a
href="https://svelte.dev"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:text-accent/80 transition-colors">SvelteKit</a
>
+
<a
href="https://sveltiacms.app/"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:text-accent/80 transition-colors">Sveltia CMS</a
>
</p>
<p class="mt-2">
<a href="/admin" class="text-accent hover:text-accent/80 transition-colors">edit</a>
<span class="mx-2 text-muted" aria-hidden="true">//</span>
<a
href="https://git.freun.dev/repomaa/cv"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:text-accent/80 transition-colors">src</a
>
</p>
</footer>

View File

@@ -1,19 +1,31 @@
<script lang="ts">
import type { Profile } from '$lib/types.js';
import BracketLabel from './BracketLabel.svelte';
import EncodedEmail from './EncodedEmail.svelte';
let { profile }: { profile: Profile } = $props();
interface Props {
profile: Profile;
}
let { profile }: Props = $props();
</script>
<header class="border-b-2 border-fg/10 pb-8 mb-8">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div class="flex-1">
<h1 class="text-4xl font-bold mb-2 tracking-tight text-fg">
{profile.name}
</h1>
<p class="text-xl text-muted mb-4 font-medium">
{profile.title}
</p>
<div class="flex items-start gap-6 mb-4">
{#if profile.avatar}
<img src={profile.avatar} alt={profile.name} class="w-20 h-20 object-cover" />
{/if}
<div>
<h1 class="text-4xl font-bold mb-2 tracking-tight text-fg">
{profile.name}
</h1>
<p class="text-xl text-muted font-medium">
{profile.title}
</p>
</div>
</div>
<p class="text-muted leading-relaxed max-w-2xl">
{profile.summary}
</p>
@@ -21,12 +33,12 @@
<div class="flex flex-col gap-2 text-sm">
<div class="flex items-center gap-2 text-muted hover:text-accent transition-colors">
<span class="text-accent">[E]</span>
<BracketLabel label="E" />
<EncodedEmail email={profile.email} class="hover:text-accent transition-colors" />
</div>
<div class="flex items-center gap-2 text-muted">
<span class="text-accent">[L]</span>
<BracketLabel label="L" />
{profile.location}
</div>
@@ -36,7 +48,7 @@
rel="noopener noreferrer"
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
>
<span class="text-accent">[G]</span>
<BracketLabel label="G" />
github.com/{profile.github}
</a>
@@ -47,7 +59,7 @@
rel="noopener noreferrer"
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
>
<span class="text-accent">[W]</span>
<BracketLabel label="W" />
{profile.website.replace(/^https?:\/\//, '')}
</a>
{/if}

View File

@@ -4,75 +4,65 @@
import PDFSection from './PDFSection.svelte';
import PDFTags from './PDFTags.svelte';
import PDFTimelineItem from './PDFTimelineItem.svelte';
import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons';
let {
profile,
experience,
education,
skills,
ownProjects,
contributions,
}: {
interface Props {
profile: Profile;
experience: Experience[];
education: Education[];
skills: Skill[];
ownProjects: Project[];
contributions: Project[];
} = $props();
}
let { profile, experience, education, skills, ownProjects, contributions }: Props = $props();
</script>
<div
class="font-sans max-w-[210mm] mx-auto px-[20mm] py-[18mm] bg-pdf-bg text-pdf-fg leading-relaxed text-sm"
>
<div class="font-sans max-w-[210mm] mx-auto bg-pdf-bg text-pdf-fg leading-relaxed text-sm">
<!-- Header -->
<header class="text-center mb-6 pb-4 border-b-2 border-pdf-fg">
<div>
<h1 class="text-3xl font-extrabold tracking-tight text-pdf-fg leading-tight m-0">
{profile.name}
</h1>
<p class="text-base font-medium text-pdf-muted mt-1.5 mb-3">{profile.title}</p>
</div>
<div class="flex justify-center flex-wrap gap-x-6 gap-y-3 text-xs mb-2">
{#if profile.email}
<a class="inline-flex items-center gap-1.5 text-pdf-accent" href="mailto:{profile.email}">
<svg
class="w-3.5 h-3.5 text-pdf-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
{profile.email}
</a>
{/if}
<span class="inline-flex items-center gap-1.5 text-pdf-muted">
<svg
class="w-3.5 h-3.5 text-pdf-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
{profile.location}
</span>
</div>
<div class="flex justify-center flex-wrap gap-x-5 gap-y-2 text-xs text-pdf-accent">
{#if profile.github}
<a href="https://github.com/{profile.github}" class="no-underline"
>github.com/{profile.github}</a
>
{/if}
{#if profile.website}
<a href={profile.website} class="no-underline"
>{profile.website.replace(/^https?:\/\//, '')}</a
>
<header class="mb-6 pb-4 border-b-2 border-pdf-fg">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-extrabold tracking-tight text-pdf-fg leading-tight m-0">
{profile.name}
</h1>
<p class="text-base font-medium text-pdf-muted mt-1.5 mb-3">{profile.title}</p>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs text-pdf-accent">
{#if profile.email}
<a class="inline-flex items-center gap-1.5" href="mailto:{profile.email}">
<MailIcon class="w-3.5 h-3.5" />
{profile.email}
</a>
{/if}
<span class="inline-flex items-center gap-1.5 text-pdf-muted">
<MapPinIcon class="w-3.5 h-3.5" />
{profile.location}
</span>
{#if profile.github}
<a
href="https://github.com/{profile.github}"
class="no-underline inline-flex items-center gap-1.5"
>
<GithubIcon class="w-3.5 h-3.5" />
github.com/{profile.github}
</a>
{/if}
{#if profile.website}
<a href={profile.website} class="no-underline inline-flex items-center gap-1.5">
<GlobeIcon class="w-3.5 h-3.5" />
{profile.website.replace(/^https?:\/\//, '')}
</a>
{/if}
</div>
</div>
{#if profile.profilePicture}
<div class="rounded-full p-2 inset-shadow-sm">
<img
src={profile.profilePicture}
alt={profile.name}
class="w-26 h-26 rounded-full object-cover"
/>
</div>
{/if}
</div>
</header>

View File

@@ -9,7 +9,7 @@
let { title, children }: Props = $props();
</script>
<section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0">
<section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0 break-inside-avoid">
<h2 class="text-sm font-bold text-pdf-fg flex items-center uppercase tracking-wide">
{title}
</h2>

View File

@@ -13,6 +13,7 @@
target="_blank"
rel="noopener noreferrer"
class="block p-4 border border-accent rounded-lg transition-shadow hover:shadow-glow"
aria-label={project.name}
>
<article class="flex flex-col justify-between h-full">
<div>
@@ -21,8 +22,10 @@
{project.name}
</span>
<div class="flex items-center gap-3 text-sm text-hot">
<span class="flex items-center gap-1" aria-label="{project.stars} stars">
<span class="text-xl"></span>
<span
class="flex items-center gap-1 before:content-['★'] before:text-xl"
aria-label="{project.stars} stars"
>
{project.stars}
</span>
</div>

View File

@@ -9,7 +9,6 @@
}
let { title, projects, seeAllLink }: Props = $props();
$effect(() => console.log({ seeAllLink }));
</script>
{#if projects.length > 0}

View File

@@ -3,14 +3,16 @@
import ProjectList from './ProjectList.svelte';
import Section from './Section.svelte';
let {
ownProjects,
contributions,
username,
}: { ownProjects: Project[]; contributions: Project[]; username: string } = $props();
interface Props {
ownProjects: Project[];
contributions: Project[];
username: string;
}
let { ownProjects, contributions, username }: Props = $props();
</script>
<Section title="projects">
<Section title="Projects">
<ProjectList
title="Original Projects"
projects={ownProjects}

View File

@@ -11,8 +11,10 @@
</script>
<section class="pb-8 mb-10 border-b border-fg/20 last:border-b-0" aria-labelledby={headingId}>
<h2 id={headingId} class="text-3xl font-bold mb-6 pb-2 text-fg">
<span class="text-accent">$></span>
<h2
id={headingId}
class="text-3xl lowercase font-bold mb-6 pb-2 text-fg before:content-['$>_'] before:text-accent"
>
{title}
</h2>

View File

@@ -3,10 +3,14 @@
import Section from './Section.svelte';
import Tags from './Tags.svelte';
let { skills }: { skills: Skill[] } = $props();
interface Props {
skills: Skill[];
}
let { skills }: Props = $props();
</script>
<Section title="skills">
<Section title="Skills">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each skills as skillCategory}
<div>

View File

@@ -8,21 +8,11 @@
location?: string;
startDate: string;
endDate?: string;
current?: boolean;
description?: string;
tags?: string[];
}
let {
title,
subtitle,
location,
startDate,
endDate,
current = false,
description,
tags = [],
}: Props = $props();
let { title, subtitle, location, startDate, endDate, description, tags = [] }: Props = $props();
</script>
<article>
@@ -41,7 +31,7 @@
{/if}
</div>
<time class="text-sm text-muted font-mono whitespace-nowrap">
{formatDate(startDate)}{current || !endDate ? 'Present' : formatDate(endDate)}
{formatDate(startDate)}{!endDate ? 'Present' : formatDate(endDate)}
</time>
</div>

134
src/lib/content/config.ts Normal file
View File

@@ -0,0 +1,134 @@
import type { CmsConfig, FileCollection, Field, EntryCollection } from '@sveltia/cms';
import type { Profile, Experience, Education, Skill } from './types';
type RequiredKeys<T> = keyof {
[key in keyof T as undefined extends T[key] ? never : key]: T[key];
};
type StringWidget = 'string' | 'image' | 'datetime' | 'markdown' | 'text';
type ListWidget = 'list';
type Fields<T> = {
[key in keyof T]: Field & {
name: key;
widget: string extends T[key] ? StringWidget : T[key] extends string[] ? ListWidget : never;
} & (key extends RequiredKeys<T> ? { required?: true } : { required: false });
};
const profileFields: Fields<Profile> = {
name: { name: 'name', label: 'Name', widget: 'string' },
title: { name: 'title', label: 'Title', widget: 'string' },
email: { name: 'email', label: 'Email', widget: 'string' },
phone: { name: 'phone', label: 'Phone', widget: 'string', required: false },
location: { name: 'location', label: 'Location', widget: 'string' },
website: { name: 'website', label: 'Website', widget: 'string', required: false },
github: { name: 'github', label: 'GitHub', widget: 'string' },
avatar: { name: 'avatar', label: 'Avatar', widget: 'image', required: false },
profilePicture: {
name: 'profilePicture',
label: 'Profile Picture (for PDF)',
widget: 'image',
required: false,
},
summary: { name: 'summary', label: 'Summary', widget: 'text', required: true },
};
const experienceFields: Fields<Experience> = {
company: { name: 'company', label: 'Company', widget: 'string' },
position: { name: 'position', label: 'Position', widget: 'string' },
location: { name: 'location', label: 'Location', widget: 'string', required: false },
startDate: { name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
endDate: {
name: 'endDate',
label: 'End Date',
widget: 'datetime',
time_format: false,
required: false,
},
description: { name: 'description', label: 'Description', widget: 'markdown' },
technologies: {
name: 'technologies',
label: 'Technologies',
widget: 'list',
field: { label: 'Technology', widget: 'string' },
},
};
const educationFields: Fields<Education> = {
institution: { name: 'institution', label: 'Institution', widget: 'string' },
degree: { name: 'degree', label: 'Degree', widget: 'string' },
field: { name: 'field', label: 'Field', widget: 'string', required: false },
location: { name: 'location', label: 'Location', widget: 'string', required: false },
startDate: { name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
endDate: {
name: 'endDate',
label: 'End Date',
widget: 'datetime',
time_format: false,
required: false,
},
description: { name: 'description', label: 'Description', widget: 'markdown', required: false },
};
const skillFields: Fields<Skill> = {
category: { name: 'category', label: 'Category', widget: 'string' },
items: {
name: 'items',
label: 'Items',
widget: 'list',
field: { label: 'Item', widget: 'string' },
},
};
const profile: FileCollection = {
name: 'profile',
label: 'Profile',
format: 'json',
files: [
{
name: 'profile',
label: 'Profile',
file: 'src/lib/content/profile.json',
fields: Object.values(profileFields),
},
],
};
const experience: EntryCollection = {
name: 'experience',
label: 'Experience',
format: 'json',
folder: 'src/lib/content/experience',
fields: Object.values(experienceFields),
};
const education: EntryCollection = {
name: 'education',
label: 'Education',
format: 'json',
folder: 'src/lib/content/education',
fields: Object.values(educationFields),
};
const skills: EntryCollection = {
name: 'skills',
label: 'Skills',
format: 'json',
folder: 'src/lib/content/skills',
fields: Object.values(skillFields),
};
const config: CmsConfig = {
load_config_file: false,
media_folder: 'src/lib/media',
backend: {
name: 'gitea',
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
repo: 'repomaa/cv',
base_url: 'https://git.freun.dev',
api_root: 'https://git.freun.dev/api/v1',
},
collections: [profile, experience, education, skills],
};
export default config;

View File

@@ -5,5 +5,5 @@
"location": "Wiesbaden, Germany",
"startDate": "2010",
"endDate": "2016",
"description": "Bachelor thesis: 'Collaborative Content Creation' - built a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux"
"description": "Bachelor thesis: 'Collaborative Content Creation' - a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux"
}

View File

@@ -1,9 +1,10 @@
{
"company": "hundertzehn GmbH",
"position": "Senior Software Engineer",
"location": "Zürich, Schweiz",
"location": "Zürich, Schweiz (remote)",
"startDate": "2021",
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Develop Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analyze large datasets, evaluate new technologies, integrate external APIs (Paywise), direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.",
"endDate": "",
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analysis of large datasets, evaluation of new technologies, integration of external APIs, direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.",
"technologies": [
"Ruby on Rails",
"TypeScript",

View File

@@ -4,7 +4,7 @@
"location": "Frankfurt, Germany",
"startDate": "2014",
"endDate": "2021",
"description": "Developed software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - experience platform and CMS for CES showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.",
"description": "Software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - CMS for CES experience platform showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.",
"technologies": [
"Crystal",
"Ruby on Rails",

View File

@@ -1,41 +1,11 @@
import type { CVData, Profile, Experience, Education, Skill } from './types.js';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import type { Experience, Education, Skill, Profile } from './types';
const CONTENT_DIR = join(process.cwd(), 'src', 'lib', 'content');
function loadJsonFile<T>(filepath: string): T | null {
try {
const content = readFileSync(filepath, 'utf-8');
return JSON.parse(content) as T;
} catch (error) {
console.warn(`Could not load ${filepath}:`, error instanceof Error ? error.message : error);
return null;
}
}
function loadJsonFilesFromDir<T>(dirPath: string): T[] {
try {
const files = readdirSync(dirPath, { withFileTypes: true });
const items: T[] = [];
for (const file of files) {
if (file.isFile() && file.name.endsWith('.json')) {
const item = loadJsonFile<T>(join(dirPath, file.name));
if (item) {
items.push(item);
}
}
}
return items;
} catch (error) {
console.warn(
`Could not read directory ${dirPath}:`,
error instanceof Error ? error.message : error
);
return [];
}
async function loadJsonFilesFromGlob<T>(
modules: Record<string, () => Promise<{ default: T }>>
): Promise<T[]> {
return Promise.all(
Object.entries(modules).map(async ([_, module]) => await module().then((m) => m.default))
);
}
function decodeBase64Email(email: string): string {
@@ -46,42 +16,50 @@ function decodeBase64Email(email: string): string {
}
}
export function loadProfile(): Profile {
const profile = loadJsonFile<Profile>(join(CONTENT_DIR, 'profile.json')) ?? {
name: 'Your Name',
title: 'Developer',
email: 'email@example.com',
phone: undefined,
location: 'Location',
website: undefined,
github: 'username',
summary: 'A passionate developer.',
};
export async function loadProfile() {
const profile: Profile = await import('$lib/content/profile.json');
const media = import.meta.glob<{ default: string }>('$lib/media/*');
const profilePictureModule = await (profile.profilePicture
? media[`/${profile.profilePicture}`]?.()
: undefined);
const avatarModule = await (profile.avatar ? media[`/${profile.avatar}`]?.() : undefined);
return {
...profile,
email: decodeBase64Email(profile.email),
profilePicture: profilePictureModule?.default,
avatar: avatarModule?.default,
};
}
export function loadExperience(): Experience[] {
return loadJsonFilesFromDir<Experience>(join(CONTENT_DIR, 'experience'));
export async function loadExperience() {
return loadJsonFilesFromGlob(
import.meta.glob<{ default: Experience }>('$lib/content/experience/*.json')
);
}
export function loadEducation(): Education[] {
return loadJsonFilesFromDir<Education>(join(CONTENT_DIR, 'education'));
export function loadEducation() {
return loadJsonFilesFromGlob(
import.meta.glob<{ default: Education }>('$lib/content/education/*.json')
);
}
export function loadSkills(): Skill[] {
return loadJsonFilesFromDir<Skill>(join(CONTENT_DIR, 'skills'));
export function loadSkills() {
return loadJsonFilesFromGlob(import.meta.glob<{ default: Skill }>('$lib/content/skills/*.json'));
}
export function loadAllContent(): CVData {
export async function loadAllContent() {
const [profile, experience, education, skills] = await Promise.all([
loadProfile(),
loadExperience(),
loadEducation(),
loadSkills(),
]);
return {
profile: loadProfile(),
experience: loadExperience(),
education: loadEducation(),
skills: loadSkills(),
projects: [],
profile,
experience,
education,
skills,
};
}

View File

@@ -5,5 +5,7 @@
"location": "Espoo - Finnland",
"website": "https://joakim.repomaa.com",
"github": "repomaa",
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, external API partnerships, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
"avatar": "src/lib/media/avatar.png",
"profilePicture": "src/lib/media/profile-picture.jpg",
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, third-party API integrations, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
}

View File

@@ -1,5 +1,3 @@
// Content data types - clean interface definitions
export interface Profile {
name: string;
title: string;
@@ -9,6 +7,8 @@ export interface Profile {
website?: string;
github: string;
summary: string;
avatar?: string;
profilePicture?: string;
}
export interface Experience {
@@ -17,7 +17,6 @@ export interface Experience {
location?: string;
startDate: string;
endDate?: string;
current: boolean;
description: string;
technologies: string[];
}
@@ -29,7 +28,6 @@ export interface Education {
location?: string;
startDate: string;
endDate?: string;
current: boolean;
description?: string;
}

View File

@@ -1,17 +1,21 @@
import { filter, map, pipe, sortBy, take, uniqueBy } from 'remeda';
import type { Project } from './content/types.js';
const GITHUB_API_BASE = 'https://api.github.com';
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000;
const MAX_RETRY_DELAY = 10000;
const RATE_LIMIT_DELAY = 60000;
interface SearchIssue {
const EXCLUDED_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']);
interface SearchItem {
repository_url: string;
pull_request?: {
merged_at: string | null;
url: string;
};
pull_request?: { merged_at: string | null };
}
interface SearchResponse {
items: SearchIssue[];
items: SearchItem[];
total_count: number;
}
@@ -25,211 +29,167 @@ interface RepoInfo {
language: string | null;
}
// Retry configuration
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000; // 1 second
const MAX_RETRY_DELAY = 10000; // 10 seconds
const RATE_LIMIT_DELAY = 60000; // 1 minute for rate limit (403/429)
// Owners to exclude from contributed repos
const EXCLUDED_REPO_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']);
function getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
};
// Use GitHub token if available (for higher rate limits during build)
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `token ${token}`;
}
return headers;
}
// Exponential backoff retry for fetch
async function fetchWithRetry(
url: string,
options: RequestInit = {},
retryCount = 0
): Promise<Response> {
async function fetchWithRetry(url: string, retryCount = 0): Promise<Response> {
try {
const response = await fetch(url, { ...options, headers: getHeaders() });
const response = await fetch(url, { headers: getHeaders() });
if (response.status === 429) {
if (retryCount < MAX_RETRIES) {
const retryAfter = response.headers.get('retry-after');
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
if (response.status === 429 && retryCount < MAX_RETRIES) {
const retryAfter = response.headers.get('retry-after');
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
console.warn(
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
);
return new Promise((resolve) =>
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
);
}
console.warn(
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, retryCount + 1);
}
return response;
} catch (error) {
// Network errors (timeout, connection refused, etc.)
if (retryCount < MAX_RETRIES) {
const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
console.warn(
`Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`,
error
);
return new Promise((resolve) =>
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, retryCount + 1);
}
throw error;
}
}
function handleApiError(response: Response, context: string): null {
if (response.status === 403 || response.status === 429) {
console.warn(
`GitHub API rate limit exceeded for ${context}. Set GITHUB_TOKEN env var for higher limits.`
);
} else {
console.error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return null;
}
function mapRepoToProject(repo: {
name: string;
full_name?: string;
description: string | null;
html_url: string;
stargazers_count: number;
forks_count: number;
language: string | null;
}): Project {
return {
name: repo.full_name ?? repo.name,
description: repo.description ?? '',
url: repo.html_url,
stars: repo.stargazers_count,
forks: repo.forks_count,
language: repo.language ?? undefined,
isFork: false,
};
}
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
try {
// Use search API to filter non-forks and sort by stars
const query = encodeURIComponent(`user:${username} fork:false`);
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(`user:${username} fork:false`)}&sort=stars&order=desc&per_page=100`,
{}
`${GITHUB_API_BASE}/search/repositories?q=${query}&sort=stars&order=desc&per_page=6`
);
if (!response.ok) {
if (response.status === 403 || response.status === 429) {
console.warn(
`GitHub API rate limit exceeded for user repos. Set GITHUB_TOKEN env var for higher limits.`
);
} else {
console.error(`GitHub API error: ${response.status} ${response.statusText}`);
}
handleApiError(response, 'user repos');
return [];
}
const searchData = await response.json();
return searchData.items.map(
(repo: {
name: string;
description: string | null;
html_url: string;
stargazers_count: number;
forks_count: number;
language: string | null;
pushed_at: string;
}) => ({
name: repo.name,
description: repo.description ?? '',
url: repo.html_url,
stars: repo.stargazers_count,
forks: repo.forks_count,
language: repo.language ?? undefined,
isFork: false,
})
);
const data = await response.json();
return data.items.map(mapRepoToProject);
} catch (error) {
console.error('Error fetching GitHub projects:', error);
return [];
}
}
function getRepoOwner(repoUrl: string): string | null {
const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/);
return match?.[1] ?? null;
}
function isNotExcluded(item: SearchItem): boolean {
const owner = getRepoOwner(item.repository_url);
return owner !== null && !EXCLUDED_OWNERS.has(owner);
}
async function fetchRepoAsProject(repoUrl: string, username: string): Promise<Project | null> {
try {
const response = await fetchWithRetry(repoUrl);
if (!response.ok) {
console.warn(`Could not fetch repo ${repoUrl}: ${response.status}`);
return null;
}
const repo: RepoInfo = await response.json();
const [owner, name] = repo.full_name.split('/');
const prsUrl = `https://github.com/${owner}/${name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`;
return { ...mapRepoToProject(repo), url: prsUrl };
} catch (error) {
console.warn(`Error fetching repo details for ${repoUrl}:`, error);
return null;
}
}
export async function fetchContributedRepos(username: string): Promise<Project[]> {
try {
// Search for merged PRs by this user
const searchResponse = await fetchWithRetry(
`${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(`type:pr author:${username} is:merged`)}&per_page=100`,
{}
const query = encodeURIComponent(`type:pr author:${username} is:merged`);
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100`
);
if (!searchResponse.ok) {
if (searchResponse.status === 403 || searchResponse.status === 429) {
console.warn(
`GitHub Search API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits.`
);
} else {
console.error(
`GitHub Search API error: ${searchResponse.status} ${searchResponse.statusText}`
);
}
if (!response.ok) {
handleApiError(response, 'search');
return [];
}
const searchData: SearchResponse = await searchResponse.json();
const { total_count, items }: SearchResponse = await response.json();
if (!total_count || !items?.length) return [];
if (searchData.total_count === 0 || !searchData.items || searchData.items.length === 0) {
return [];
}
// Extract unique repositories from closed PRs
const repoData = new Map<string, { owner: string; name: string; stars: number }>();
for (const item of searchData.items) {
if (item.pull_request?.merged_at && item.repository_url) {
const repoUrl = item.repository_url;
if (!repoData.has(repoUrl)) {
// Parse owner and repo name from API URL: https://api.github.com/repos/owner/name
const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/);
if (match) {
const [, owner, name] = match;
// Skip repos from excluded owners
if (!EXCLUDED_REPO_OWNERS.has(owner)) {
repoData.set(repoUrl, { owner, name, stars: 0 });
}
}
}
}
}
if (repoData.size === 0) {
return [];
}
const repos = await Promise.all(
[...repoData.entries()].map(async ([repoUrl, data]) => {
try {
const repoResponse = await fetchWithRetry(repoUrl, {});
if (!repoResponse.ok) {
console.warn(`Could not fetch repo ${repoUrl}: ${repoResponse.status}`);
return null;
}
const repo: RepoInfo = await repoResponse.json();
// Create URL to user's closed PRs in this repo
const prsUrl = `https://github.com/${data.owner}/${data.name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`;
return {
name: repo.full_name,
description: repo.description ?? '',
url: prsUrl,
stars: repo.stargazers_count,
forks: repo.forks_count,
language: repo.language ?? undefined,
isFork: false,
};
} catch (error) {
console.warn(`Error fetching repo details for ${repoUrl}:`, error);
return null;
}
})
const repoUrls = pipe(
items,
filter(isNotExcluded),
uniqueBy((item) => item.repository_url),
map((item) => item.repository_url)
);
// Sort by stars descending and take top 5
return repos
.filter((repo) => repo !== null)
.sort((a, b) => b.stars - a.stars)
.slice(0, 5);
if (repoUrls.length === 0) return [];
const projects = await Promise.all(repoUrls.map((url) => fetchRepoAsProject(url, username)));
return pipe(
projects,
filter((p): p is Project => p !== null),
sortBy([(p) => p.stars, 'desc']),
take(6)
);
} catch (error) {
console.error('Error fetching contributed repos:', error);
return [];
}
}
// Helper to get top projects from array
export function getTopProjects(projects: Project[], limit: number): Project[] {
return projects.slice(0, limit);
}

BIN
src/lib/media/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,11 +1,8 @@
<script>
let { children } = $props();
import '../../app.css';
</script>
<div
class="font-mono bg-background text-foreground dark:bg-background-dark dark:text-shadow-foreground-dark"
>
<div class="font-mono bg-background text-foreground">
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute">
Skip to main content
</a>

View File

@@ -4,6 +4,7 @@
import Education from '$lib/components/Education.svelte';
import Skills from '$lib/components/Skills.svelte';
import Projects from '$lib/components/Projects.svelte';
import Footer from '$lib/components/Footer.svelte';
let { data } = $props();
</script>
@@ -33,7 +34,7 @@
<a
href="/joakim-repomaa-cv.pdf"
download
class="no-print fixed top-0 right-0 w-24 h-24 z-50 group"
class="fixed top-0 right-0 w-24 h-24 z-50 group"
style="clip-path: polygon(0 0, 100% 0, 100% 100%);"
data-sveltekit-preload-data="off"
aria-label="Download PDF"
@@ -48,24 +49,5 @@
</span>
</a>
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted">
<p class="font-mono">
<span class="text-accent">$</span>
Built with SvelteKit + Sveltia CMS
</p>
<p class="mt-2">
<a href="/admin" class="text-accent hover:text-accent/80 transition-colors">
Edit Content →
</a>
</p>
</footer>
<Footer />
</main>
<style>
/* Ensure proper print styling */
@media print {
main {
background: white !important;
}
}
</style>

View File

@@ -0,0 +1,21 @@
import { loadAllContent } from '$lib/content/loader';
import { fetchContributedRepos, fetchGitHubProjects } from '$lib/github';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async () => {
const content = await loadAllContent();
const [ownProjects, contributions] = await Promise.all([
fetchGitHubProjects(content.profile.github),
fetchContributedRepos(content.profile.github),
]);
return {
profile: content.profile,
experience: content.experience,
education: content.education,
skills: content.skills,
ownProjects,
contributions,
};
};

View File

@@ -0,0 +1,13 @@
<script>
let { children } = $props();
</script>
<main class="min-h-screen bg-pdf-bg text-pdf-fg">
{@render children()}
</main>
<style>
@page {
size: A4;
}
</style>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import PDFContent from '$lib/components/PDFContent.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.profile.name} — CV</title>
</svelte:head>
<PDFContent
profile={data.profile}
experience={data.experience}
education={data.education}
skills={data.skills}
ownProjects={data.ownProjects}
contributions={data.contributions}
/>

View File

@@ -1,24 +0,0 @@
import type { PageServerLoad } from './$types.js';
import { loadAllContent } from '$lib/content/loader.js';
import { fetchGitHubProjects, fetchContributedRepos } from '$lib/github.js';
export const prerender = true;
export const load: PageServerLoad = async () => {
const content = loadAllContent();
// Fetch GitHub projects and contributions in parallel
const [ownProjects, contributions] = await Promise.all([
fetchGitHubProjects(content.profile.github),
fetchContributedRepos(content.profile.github),
]);
return {
profile: content.profile,
experience: content.experience,
education: content.education,
skills: content.skills,
ownProjects: ownProjects.slice(0, 6),
contributions: contributions.slice(0, 5),
};
};

View File

@@ -1,106 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import config from '$lib/content/config';
onMount(async () => {
const CMS = await import('@sveltia/cms');
CMS.init({
config: {
load_config_file: false,
media_folder: 'src/lib/media',
backend: {
name: 'gitea',
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
repo: 'repomaa/cv',
},
collections: [
{
name: 'profile',
label: 'Profile',
format: 'json',
files: [
{
name: 'profile',
label: 'Profile',
file: 'src/lib/content/profile.json',
fields: [
{ name: 'name', label: 'Name', widget: 'string' },
{ name: 'title', label: 'Title', widget: 'string' },
{ name: 'email', label: 'Email', widget: 'string' },
{ name: 'phone', label: 'Phone', widget: 'string', required: false },
{ name: 'location', label: 'Location', widget: 'string' },
{ name: 'website', label: 'Website', widget: 'string', required: false },
{ name: 'github', label: 'GitHub', widget: 'string' },
{ name: 'summary', label: 'Summary', widget: 'text' },
],
},
],
},
{
name: 'experience',
label: 'Experience',
format: 'json',
folder: 'src/lib/content/experience',
fields: [
{ name: 'company', label: 'Company', widget: 'string' },
{ name: 'position', label: 'Position', widget: 'string' },
{ name: 'location', label: 'Location', widget: 'string', required: false },
{ name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
{
name: 'endDate',
label: 'End Date',
widget: 'datetime',
time_format: false,
required: false,
},
{ name: 'description', label: 'Description', widget: 'markdown' },
{
name: 'technologies',
label: 'Technologies',
widget: 'list',
field: { label: 'Technology', widget: 'string' },
},
],
},
{
name: 'education',
label: 'Education',
format: 'json',
folder: 'src/lib/content/education',
fields: [
{ name: 'institution', label: 'Institution', widget: 'string' },
{ name: 'degree', label: 'Degree', widget: 'string' },
{ name: 'field', label: 'Field', widget: 'string', required: false },
{ name: 'location', label: 'Location', widget: 'string', required: false },
{ name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
{
name: 'endDate',
label: 'End Date',
widget: 'datetime',
time_format: false,
required: false,
},
{ name: 'description', label: 'Description', widget: 'markdown', required: false },
],
},
{
name: 'skills',
label: 'Skills',
format: 'json',
folder: 'src/lib/content/skills',
fields: [
{ name: 'category', label: 'Category', widget: 'string' },
{
name: 'items',
label: 'Skills',
widget: 'list',
field: { label: 'Skill', widget: 'string' },
},
],
},
],
},
});
CMS.init({ config });
});
</script>

View File

@@ -1,5 +1,6 @@
import type { RequestHandler } from './$types.js';
import type { LaunchOptions } from 'puppeteer';
import type { LaunchOptions, PDFOptions } from 'puppeteer';
import { dev } from '$app/environment';
import puppeteer from 'puppeteer';
import * as cheerio from 'cheerio';
import path from 'path';
@@ -11,14 +12,14 @@ export const prerender = true;
const cwd = process.cwd();
// PDF generation configuration
const PDF_CONFIG = {
format: 'A4' as const,
const PDF_CONFIG: PDFOptions = {
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: {
top: '20mm',
top: '15mm',
right: '20mm',
bottom: '20mm',
bottom: '15mm',
left: '20mm',
},
};
@@ -37,7 +38,22 @@ const getLaunchOptions = (): LaunchOptions => {
return options;
};
export const GET: RequestHandler = async ({ url, fetch }) => {
/**
* Prepares the print page HTML and returns the URL to use for PDF generation.
* During dev server, returns the dev server URL directly.
* During build time, copies assets to temp dir and returns file:// URL.
*/
const preparePrintPageUrl = async (
requestUrl: URL,
fetch: typeof globalThis.fetch
): Promise<string> => {
// In dev mode, just use the dev server URL
if (dev) {
const devUrl = new URL('/print/', requestUrl);
return devUrl.toString();
}
// Build time: prepare files in temp directory
const tmpDir = await mkdtemp(path.join(tmpdir(), 'cv-pdf-genration-'));
const tmpFile = (url: string) => {
const filename = path.basename(url);
@@ -45,63 +61,70 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
return tempFile;
};
try {
// Launch browser
const browser = await puppeteer.launch(getLaunchOptions());
const page = await browser.newPage();
const printResponse = await fetch('/print/');
const html = await printResponse.text();
const $ = cheerio.load(html);
const printResponse = await fetch('/print/');
const html = await printResponse.text();
const $ = cheerio.load(html);
const fileDownloads: Record<string, string> = {};
const fileDownloads: Record<string, string> = {};
$('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
if (el.tagName === 'link') {
const href = $(el).attr('href');
if (href) {
const tempFile = (fileDownloads[href] ||= tmpFile(href));
$(el).attr('href', `file://${tempFile}`);
}
} else {
const src = $(el).attr('src');
if (src) {
const tempFile = (fileDownloads[src] ||= tmpFile(src));
$(el).attr('src', `file://${tempFile}`);
}
$('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
if (el.tagName === 'link') {
const href = $(el).attr('href');
if (href) {
const tempFile = (fileDownloads[href] ||= tmpFile(href));
$(el).attr('href', `file://${tempFile}`);
}
});
$('style[src]').each((i, el) => {
} else {
const src = $(el).attr('src');
if (src) {
const tempFile = (fileDownloads[src] ||= tmpFile(src));
$(el).attr('src', `file://${tempFile}`);
}
});
$('style:not([src])').each((i, el) => {
const content = $(el).text();
$(el).text(
content.replaceAll(/(?<=url\(".+?)(?=")/g, (match) => {
const url = match[0];
const tempFile = (fileDownloads[url] ||= tmpFile(url));
return `file://${tempFile}`;
})
);
});
const dir = await opendir('.svelte-kit/output/client/_app/immutable/assets');
for await (const file of dir) {
await copyFile(path.join(file.parentPath, file.name), tmpFile(file.name));
}
});
const htmlFile = path.join(tmpDir, 'index.html');
await writeFile(htmlFile, $.root().html() ?? '');
$('style[src]').each((i, el) => {
const src = $(el).attr('src');
if (src) {
const tempFile = (fileDownloads[src] ||= tmpFile(src));
$(el).attr('src', `file://${tempFile}`);
}
});
$('style:not([src])').each((i, el) => {
const content = $(el).text();
$(el).text(
content.replaceAll(/(?<=url\(".+?)(?=")/g, (match) => {
const url = match[0];
const tempFile = (fileDownloads[url] ||= tmpFile(url));
return `file://${tempFile}`;
})
);
});
const dir = await opendir('.svelte-kit/output/client/_app/immutable/assets');
for await (const file of dir) {
await copyFile(path.join(file.parentPath, file.name), tmpFile(file.name));
}
const htmlFile = path.join(tmpDir, 'index.html');
await writeFile(htmlFile, $.root().html() ?? '');
return `file://${htmlFile}`;
};
export const GET: RequestHandler = async ({ url, fetch }) => {
try {
// Launch browser
const browser = await puppeteer.launch(getLaunchOptions());
const page = await browser.newPage();
const pageUrl = await preparePrintPageUrl(url, fetch);
// Navigate to the PDF page with increased timeout
// waitUntil: 'networkidle2' waits for 2 network connections to be idle
// This is more lenient than 'networkidle0' which waits for 0 connections
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle2' });
await page.goto(pageUrl, { waitUntil: 'networkidle2' });
// Wait for fonts to load
await page.evaluateHandle('document.fonts.ready');

View File

@@ -1,24 +0,0 @@
import type { PageServerLoad } from './$types.js';
import { loadAllContent } from '$lib/content/loader.js';
import { fetchGitHubProjects, fetchContributedRepos } from '$lib/github.js';
export const prerender = true;
export const load: PageServerLoad = async () => {
const content = loadAllContent();
// Fetch GitHub projects and contributions at build time
const [ownProjects, contributions] = await Promise.all([
fetchGitHubProjects(content.profile.github),
fetchContributedRepos(content.profile.github),
]);
return {
profile: content.profile,
experience: content.experience,
education: content.education,
skills: content.skills,
ownProjects: ownProjects.slice(0, 6),
contributions: contributions.slice(0, 5),
};
};

View File

@@ -1,50 +0,0 @@
<script lang="ts">
import '../../app.css';
import PDFContent from '$lib/components/PDFContent.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.profile.name} — CV (Print Version)</title>
<meta name="description" content="Printable CV for {data.profile.name}" />
<!-- Print-specific styles -->
<style>
@page {
size: A4;
margin: 15mm;
}
@media print {
body {
background: white;
color: black;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Explicitly hide skip link in PDF */
.skip-link,
.no-print {
display: none !important;
}
}
</style>
</svelte:head>
<main class="min-h-screen bg-pdf-bg">
<PDFContent
profile={data.profile}
experience={data.experience}
education={data.education}
skills={data.skills}
ownProjects={data.ownProjects}
contributions={data.contributions}
/>
</main>

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

BIN
static/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,10 +1,9 @@
image: alpine
command: |
apk add --no-cache curl jq unzip &&
curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/repomaa/cv/actions/artifacts?per_page=1" | \
jq -r '.artifacts[0].archive_download_url' | \
xargs -I {} curl -s -L -H "Authorization: token $GITHUB_TOKEN" -o artifact.zip {} &&
curl -s 'https://git.freun.dev/api/v1/repos/repomaa/cv/actions/artifacts?name=build' | \
jq -r '.artifacts[-1].archive_download_url' | \
xargs -I {} curl -s -L -o artifact.zip {} &&
unzip -o artifact.zip -d public
public: public
domains:

View File

@@ -1,3 +1,4 @@
import devtoolsJson from 'vite-plugin-devtools-json';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig, loadEnv } from 'vite';
import tailwindcss from '@tailwindcss/vite';
@@ -10,12 +11,8 @@ export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...env };
return {
plugins: [tailwindcss(), sveltekit()],
css: {
transformer: 'lightningcss',
},
build: {
target: 'esnext',
},
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
css: { transformer: 'lightningcss' },
build: { target: 'esnext' },
};
});