cleanup
Some checks failed
Build and Deploy / build (push) Failing after 19s

This commit is contained in:
Joakim Repomaa
2026-02-19 21:51:10 +02:00
parent 782f46f69f
commit 96171576c7
32 changed files with 487 additions and 524 deletions

View File

@@ -103,7 +103,6 @@ npm run format # Format all files with Prettier
- Dark mode via `light-dark()` CSS function - Dark mode via `light-dark()` CSS function
- Cyan accent color for interactive elements - Cyan accent color for interactive elements
- Zinc-based neutral colors - Zinc-based neutral colors
- Print styles: `.no-print` class hides elements in PDF
## Project Structure ## Project Structure

12
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"@fontsource-variable/roboto-condensed": "^5.2.8", "@fontsource-variable/roboto-condensed": "^5.2.8",
"@fontsource/iosevka": "^5.2.5", "@fontsource/iosevka": "^5.2.5",
"@sveltia/cms": "^0.140.3" "@sveltia/cms": "^0.140.3",
"remeda": "^2.33.6"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@@ -3581,6 +3582,15 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View File

@@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"@fontsource-variable/roboto-condensed": "^5.2.8", "@fontsource-variable/roboto-condensed": "^5.2.8",
"@fontsource/iosevka": "^5.2.5", "@fontsource/iosevka": "^5.2.5",
"@sveltia/cms": "^0.140.3" "@sveltia/cms": "^0.140.3",
"remeda": "^2.33.6"
}, },
"keywords": [ "keywords": [
"cv", "cv",

View File

@@ -14,6 +14,18 @@ body {
-moz-osx-font-smoothing: grayscale; -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 configuration - 5 colors per layout */
@theme { @theme {
/* Font families */ /* Font families */

View File

@@ -10,6 +10,6 @@
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
%sveltekit.body% <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </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,7 +3,11 @@
import Section from './Section.svelte'; import Section from './Section.svelte';
import TimelineItem from './TimelineItem.svelte'; import TimelineItem from './TimelineItem.svelte';
let { education }: { education: Education[] } = $props(); interface Props {
education: Education[];
}
let { education }: Props = $props();
</script> </script>
<Section title="Education"> <Section title="Education">
@@ -15,7 +19,6 @@
location={edu.location} location={edu.location}
startDate={edu.startDate} startDate={edu.startDate}
endDate={edu.endDate} endDate={edu.endDate}
current={edu.current}
description={edu.description} description={edu.description}
/> />
{/each} {/each}

View File

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

View File

@@ -1,8 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Profile } from '$lib/types.js'; import type { Profile } from '$lib/types.js';
import BracketLabel from './BracketLabel.svelte';
import EncodedEmail from './EncodedEmail.svelte'; import EncodedEmail from './EncodedEmail.svelte';
let { profile }: { profile: Profile } = $props(); interface Props {
profile: Profile;
}
let { profile }: Props = $props();
</script> </script>
<header class="border-b-2 border-fg/10 pb-8 mb-8"> <header class="border-b-2 border-fg/10 pb-8 mb-8">
@@ -28,12 +33,12 @@
<div class="flex flex-col gap-2 text-sm"> <div class="flex flex-col gap-2 text-sm">
<div class="flex items-center gap-2 text-muted hover:text-accent transition-colors"> <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" /> <EncodedEmail email={profile.email} class="hover:text-accent transition-colors" />
</div> </div>
<div class="flex items-center gap-2 text-muted"> <div class="flex items-center gap-2 text-muted">
<span class="text-accent">[L]</span> <BracketLabel label="L" />
{profile.location} {profile.location}
</div> </div>
@@ -43,7 +48,7 @@
rel="noopener noreferrer" rel="noopener noreferrer"
class="flex items-center gap-2 text-muted hover:text-accent transition-colors" 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} github.com/{profile.github}
</a> </a>
@@ -54,7 +59,7 @@
rel="noopener noreferrer" rel="noopener noreferrer"
class="flex items-center gap-2 text-muted hover:text-accent transition-colors" 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?:\/\//, '')} {profile.website.replace(/^https?:\/\//, '')}
</a> </a>
{/if} {/if}

View File

@@ -4,24 +4,18 @@
import PDFSection from './PDFSection.svelte'; import PDFSection from './PDFSection.svelte';
import PDFTags from './PDFTags.svelte'; import PDFTags from './PDFTags.svelte';
import PDFTimelineItem from './PDFTimelineItem.svelte'; import PDFTimelineItem from './PDFTimelineItem.svelte';
import profilePicture from '$lib/media/profile-picture.jpg';
import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons'; import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons';
let { interface Props {
profile,
experience,
education,
skills,
ownProjects,
contributions,
}: {
profile: Profile; profile: Profile;
experience: Experience[]; experience: Experience[];
education: Education[]; education: Education[];
skills: Skill[]; skills: Skill[];
ownProjects: Project[]; ownProjects: Project[];
contributions: Project[]; contributions: Project[];
} = $props(); }
let { profile, experience, education, skills, ownProjects, contributions }: Props = $props();
</script> </script>
<div <div
@@ -63,9 +57,15 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="rounded-full p-2 inset-shadow-sm"> {#if profile.profilePicture}
<img src={profilePicture} alt={profile.name} class="w-26 h-26 rounded-full object-cover" /> <div class="rounded-full p-2 inset-shadow-sm">
</div> <img
src={profile.profilePicture}
alt={profile.name}
class="w-26 h-26 rounded-full object-cover"
/>
</div>
{/if}
</div> </div>
</header> </header>

View File

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

View File

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

View File

@@ -3,7 +3,11 @@
import Section from './Section.svelte'; import Section from './Section.svelte';
import Tags from './Tags.svelte'; import Tags from './Tags.svelte';
let { skills }: { skills: Skill[] } = $props(); interface Props {
skills: Skill[];
}
let { skills }: Props = $props();
</script> </script>
<Section title="Skills"> <Section title="Skills">

View File

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

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

@@ -0,0 +1,132 @@
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',
},
collections: [profile, experience, education, skills],
};
export default config;

View File

@@ -1,41 +1,11 @@
import type { CVData, Profile, Experience, Education, Skill } from './types.js'; import type { Experience, Education, Skill, Profile } from './types';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
const CONTENT_DIR = join(process.cwd(), 'src', 'lib', 'content'); async function loadJsonFilesFromGlob<T>(
modules: Record<string, () => Promise<{ default: T }>>
function loadJsonFile<T>(filepath: string): T | null { ): Promise<T[]> {
try { return Promise.all(
const content = readFileSync(filepath, 'utf-8'); Object.entries(modules).map(async ([_, module]) => await module().then((m) => m.default))
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 [];
}
} }
function decodeBase64Email(email: string): string { function decodeBase64Email(email: string): string {
@@ -46,42 +16,50 @@ function decodeBase64Email(email: string): string {
} }
} }
export function loadProfile(): Profile { export async function loadProfile() {
const profile = loadJsonFile<Profile>(join(CONTENT_DIR, 'profile.json')) ?? { const profile: Profile = await import('$lib/content/profile.json');
name: 'Your Name', const media = import.meta.glob<{ default: string }>('$lib/media/*');
title: 'Developer', const profilePictureModule = await (profile.profilePicture
email: 'email@example.com', ? media[`/${profile.profilePicture}`]?.()
phone: undefined, : undefined);
location: 'Location', const avatarModule = await (profile.avatar ? media[`/${profile.avatar}`]?.() : undefined);
website: undefined,
github: 'username',
summary: 'A passionate developer.',
};
return { return {
...profile, ...profile,
email: decodeBase64Email(profile.email), email: decodeBase64Email(profile.email),
profilePicture: profilePictureModule?.default,
avatar: avatarModule?.default,
}; };
} }
export function loadExperience(): Experience[] { export async function loadExperience() {
return loadJsonFilesFromDir<Experience>(join(CONTENT_DIR, 'experience')); return loadJsonFilesFromGlob(
import.meta.glob<{ default: Experience }>('$lib/content/experience/*.json')
);
} }
export function loadEducation(): Education[] { export function loadEducation() {
return loadJsonFilesFromDir<Education>(join(CONTENT_DIR, 'education')); return loadJsonFilesFromGlob(
import.meta.glob<{ default: Education }>('$lib/content/education/*.json')
);
} }
export function loadSkills(): Skill[] { export function loadSkills() {
return loadJsonFilesFromDir<Skill>(join(CONTENT_DIR, 'skills')); 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 { return {
profile: loadProfile(), profile,
experience: loadExperience(), experience,
education: loadEducation(), education,
skills: loadSkills(), skills,
projects: [],
}; };
} }

View File

@@ -5,7 +5,7 @@
"location": "Espoo - Finnland", "location": "Espoo - Finnland",
"website": "https://joakim.repomaa.com", "website": "https://joakim.repomaa.com",
"github": "repomaa", "github": "repomaa",
"avatar": "src/lib/media/avatar.jpg", "avatar": "src/lib/media/avatar.png",
"profilePicture": "src/lib/media/profile-picture.jpg", "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." "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 { export interface Profile {
name: string; name: string;
title: string; title: string;
@@ -19,7 +17,6 @@ export interface Experience {
location?: string; location?: string;
startDate: string; startDate: string;
endDate?: string; endDate?: string;
current: boolean;
description: string; description: string;
technologies: string[]; technologies: string[];
} }
@@ -31,7 +28,6 @@ export interface Education {
location?: string; location?: string;
startDate: string; startDate: string;
endDate?: string; endDate?: string;
current: boolean;
description?: string; description?: string;
} }

View File

@@ -1,17 +1,21 @@
import { filter, map, pipe, sortBy, take, uniqueBy } from 'remeda';
import type { Project } from './content/types.js'; import type { Project } from './content/types.js';
const GITHUB_API_BASE = 'https://api.github.com'; 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; repository_url: string;
pull_request?: { pull_request?: { merged_at: string | null };
merged_at: string | null;
url: string;
};
} }
interface SearchResponse { interface SearchResponse {
items: SearchIssue[]; items: SearchItem[];
total_count: number; total_count: number;
} }
@@ -25,211 +29,167 @@ interface RepoInfo {
language: string | null; 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> { function getHeaders(): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json', Accept: 'application/vnd.github.v3+json',
}; };
// Use GitHub token if available (for higher rate limits during build)
const token = process.env.GITHUB_TOKEN; const token = process.env.GITHUB_TOKEN;
if (token) { if (token) {
headers.Authorization = `token ${token}`; headers.Authorization = `token ${token}`;
} }
return headers; return headers;
} }
// Exponential backoff retry for fetch async function fetchWithRetry(url: string, retryCount = 0): Promise<Response> {
async function fetchWithRetry(
url: string,
options: RequestInit = {},
retryCount = 0
): Promise<Response> {
try { try {
const response = await fetch(url, { ...options, headers: getHeaders() }); const response = await fetch(url, { headers: getHeaders() });
if (response.status === 429) { if (response.status === 429 && retryCount < MAX_RETRIES) {
if (retryCount < MAX_RETRIES) { const retryAfter = response.headers.get('retry-after');
const retryAfter = response.headers.get('retry-after'); const delay = retryAfter
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000
? parseInt(retryAfter, 10) * 1000 : Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
console.warn( console.warn(
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}` `Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
); );
return new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, delay));
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay) return fetchWithRetry(url, retryCount + 1);
);
}
} }
return response; return response;
} catch (error) { } catch (error) {
// Network errors (timeout, connection refused, etc.)
if (retryCount < MAX_RETRIES) { if (retryCount < MAX_RETRIES) {
const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount)); const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
console.warn( console.warn(
`Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`, `Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`,
error error
); );
return new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, delay));
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay) return fetchWithRetry(url, retryCount + 1);
);
} }
throw error; 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[]> { export async function fetchGitHubProjects(username: string): Promise<Project[]> {
try { try {
// Use search API to filter non-forks and sort by stars const query = encodeURIComponent(`user:${username} fork:false`);
const response = await fetchWithRetry( 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.ok) {
if (response.status === 403 || response.status === 429) { handleApiError(response, 'user repos');
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}`);
}
return []; return [];
} }
const searchData = await response.json(); const data = await response.json();
return data.items.map(mapRepoToProject);
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,
})
);
} catch (error) { } catch (error) {
console.error('Error fetching GitHub projects:', error); console.error('Error fetching GitHub projects:', error);
return []; 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[]> { export async function fetchContributedRepos(username: string): Promise<Project[]> {
try { try {
// Search for merged PRs by this user const query = encodeURIComponent(`type:pr author:${username} is:merged`);
const searchResponse = await fetchWithRetry( const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(`type:pr author:${username} is:merged`)}&per_page=100`, `${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100`
{}
); );
if (!searchResponse.ok) { if (!response.ok) {
if (searchResponse.status === 403 || searchResponse.status === 429) { handleApiError(response, 'search');
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}`
);
}
return []; 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) { const repoUrls = pipe(
return []; items,
} filter(isNotExcluded),
uniqueBy((item) => item.repository_url),
// Extract unique repositories from closed PRs map((item) => item.repository_url)
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;
}
})
); );
// Sort by stars descending and take top 5 if (repoUrls.length === 0) return [];
return repos
.filter((repo) => repo !== null) const projects = await Promise.all(repoUrls.map((url) => fetchRepoAsProject(url, username)));
.sort((a, b) => b.stars - a.stars)
.slice(0, 5); return pipe(
projects,
filter((p): p is Project => p !== null),
sortBy([(p) => p.stars, 'desc']),
take(6)
);
} catch (error) { } catch (error) {
console.error('Error fetching contributed repos:', error); console.error('Error fetching contributed repos:', error);
return []; return [];
} }
} }
// Helper to get top projects from array
export function getTopProjects(projects: Project[], limit: number): Project[] { export function getTopProjects(projects: Project[], limit: number): Project[] {
return projects.slice(0, limit); return projects.slice(0, limit);
} }

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,6 +1,5 @@
<script> <script>
let { children } = $props(); let { children } = $props();
import '../../app.css';
</script> </script>
<div class="font-mono bg-background text-foreground"> <div class="font-mono bg-background text-foreground">

View File

@@ -34,7 +34,7 @@
<a <a
href="/joakim-repomaa-cv.pdf" href="/joakim-repomaa-cv.pdf"
download 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%);" style="clip-path: polygon(0 0, 100% 0, 100% 100%);"
data-sveltekit-preload-data="off" data-sveltekit-preload-data="off"
aria-label="Download PDF" aria-label="Download PDF"
@@ -51,12 +51,3 @@
<Footer /> <Footer />
</main> </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,14 @@
<script>
let { children } = $props();
</script>
<main class="min-h-screen bg-pdf-bg text-pdf-fg">
{@render children()}
</main>
<style>
@page {
size: A4;
margin: 15mm;
}
</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,5 +0,0 @@
<script>
let { children } = $props();
</script>
{@render children()}

View File

@@ -1,113 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import config from '$lib/content/config';
onMount(async () => { onMount(async () => {
const CMS = await import('@sveltia/cms'); const CMS = await import('@sveltia/cms');
CMS.init({ config });
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: 'avatar', label: 'Avatar', widget: 'image', required: false },
{
name: 'profilePicture',
label: 'Profile Picture (for PDF)',
widget: 'image',
required: false,
},
{ 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' },
},
],
},
],
},
});
}); });
</script> </script>

View File

@@ -1,5 +1,6 @@
import type { RequestHandler } from './$types.js'; import type { RequestHandler } from './$types.js';
import type { LaunchOptions } from 'puppeteer'; import type { LaunchOptions } from 'puppeteer';
import { dev } from '$app/environment';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import path from 'path'; import path from 'path';
@@ -37,7 +38,22 @@ const getLaunchOptions = (): LaunchOptions => {
return options; 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 tmpDir = await mkdtemp(path.join(tmpdir(), 'cv-pdf-genration-'));
const tmpFile = (url: string) => { const tmpFile = (url: string) => {
const filename = path.basename(url); const filename = path.basename(url);
@@ -45,63 +61,70 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
return tempFile; return tempFile;
}; };
try { const printResponse = await fetch('/print/');
// Launch browser const html = await printResponse.text();
const browser = await puppeteer.launch(getLaunchOptions()); const $ = cheerio.load(html);
const page = await browser.newPage();
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) => { $('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
if (el.tagName === 'link') { if (el.tagName === 'link') {
const href = $(el).attr('href'); const href = $(el).attr('href');
if (href) { if (href) {
const tempFile = (fileDownloads[href] ||= tmpFile(href)); const tempFile = (fileDownloads[href] ||= tmpFile(href));
$(el).attr('href', `file://${tempFile}`); $(el).attr('href', `file://${tempFile}`);
}
} else {
const src = $(el).attr('src');
if (src) {
const tempFile = (fileDownloads[src] ||= tmpFile(src));
$(el).attr('src', `file://${tempFile}`);
}
} }
}); } else {
$('style[src]').each((i, el) => {
const src = $(el).attr('src'); const src = $(el).attr('src');
if (src) { if (src) {
const tempFile = (fileDownloads[src] ||= tmpFile(src)); const tempFile = (fileDownloads[src] ||= tmpFile(src));
$(el).attr('src', `file://${tempFile}`); $(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'); $('style[src]').each((i, el) => {
await writeFile(htmlFile, $.root().html() ?? ''); 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 // Navigate to the PDF page with increased timeout
// waitUntil: 'networkidle2' waits for 2 network connections to be idle // waitUntil: 'networkidle2' waits for 2 network connections to be idle
// This is more lenient than 'networkidle0' which waits for 0 connections // 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 // Wait for fonts to load
await page.evaluateHandle('document.fonts.ready'); 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>