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
- Cyan accent color for interactive elements
- Zinc-based neutral colors
- Print styles: `.no-print` class hides elements in PDF
## Project Structure

12
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",
@@ -3581,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",

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",

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 */

View File

@@ -10,6 +10,6 @@
%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,7 +3,11 @@
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">
@@ -15,7 +19,6 @@
location={edu.location}
startDate={edu.startDate}
endDate={edu.endDate}
current={edu.current}
description={edu.description}
/>
{/each}

View File

@@ -3,7 +3,11 @@
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">
@@ -15,7 +19,6 @@
location={job.location}
startDate={job.startDate}
endDate={job.endDate}
current={job.current}
description={job.description}
tags={job.technologies}
/>

View File

@@ -1,8 +1,13 @@
<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">
@@ -28,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>
@@ -43,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>
@@ -54,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,24 +4,18 @@
import PDFSection from './PDFSection.svelte';
import PDFTags from './PDFTags.svelte';
import PDFTimelineItem from './PDFTimelineItem.svelte';
import profilePicture from '$lib/media/profile-picture.jpg';
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
@@ -63,9 +57,15 @@
{/if}
</div>
</div>
{#if profile.profilePicture}
<div class="rounded-full p-2 inset-shadow-sm">
<img src={profilePicture} alt={profile.name} class="w-26 h-26 rounded-full object-cover" />
<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,6 @@
}
let { title, projects, seeAllLink }: Props = $props();
$effect(() => console.log({ seeAllLink }));
</script>
{#if projects.length > 0}

View File

@@ -3,11 +3,13 @@
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">

View File

@@ -3,7 +3,11 @@
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">

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>

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 { 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
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))
);
return [];
}
}
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,7 +5,7 @@
"location": "Espoo - Finnland",
"website": "https://joakim.repomaa.com",
"github": "repomaa",
"avatar": "src/lib/media/avatar.jpg",
"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;
@@ -19,7 +17,6 @@ export interface Experience {
location?: string;
startDate: string;
endDate?: string;
current: boolean;
description: string;
technologies: string[];
}
@@ -31,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,40 +29,22 @@ 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) {
if (response.status === 429 && retryCount < MAX_RETRIES) {
const retryAfter = response.headers.get('retry-after');
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
@@ -67,169 +53,143 @@ async function fetchWithRetry(
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)
);
}
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;
}
}
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
try {
// Use search API to filter non-forks and sort by stars
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(`user:${username} fork:false`)}&sort=stars&order=desc&per_page=100`,
{}
);
if (!response.ok) {
function handleApiError(response: Response, context: string): null {
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.`
`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 [];
return null;
}
const searchData = await response.json();
return searchData.items.map(
(repo: {
function mapRepoToProject(repo: {
name: string;
full_name?: string;
description: string | null;
html_url: string;
stargazers_count: number;
forks_count: number;
language: string | null;
pushed_at: string;
}) => ({
name: repo.name,
}): 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 {
const query = encodeURIComponent(`user:${username} fork:false`);
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/repositories?q=${query}&sort=stars&order=desc&per_page=6`
);
if (!response.ok) {
handleApiError(response, 'user repos');
return [];
}
const data = await response.json();
return data.items.map(mapRepoToProject);
} catch (error) {
console.error('Error fetching GitHub projects:', error);
return [];
}
}
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`,
{}
);
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}`
);
}
return [];
}
const searchData: SearchResponse = await searchResponse.json();
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
function getRepoOwner(repoUrl: string): string | null {
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 });
}
}
}
}
return match?.[1] ?? null;
}
if (repoData.size === 0) {
return [];
function isNotExcluded(item: SearchItem): boolean {
const owner = getRepoOwner(item.repository_url);
return owner !== null && !EXCLUDED_OWNERS.has(owner);
}
const repos = await Promise.all(
[...repoData.entries()].map(async ([repoUrl, data]) => {
async function fetchRepoAsProject(repoUrl: string, username: string): Promise<Project | null> {
try {
const repoResponse = await fetchWithRetry(repoUrl, {});
if (!repoResponse.ok) {
console.warn(`Could not fetch repo ${repoUrl}: ${repoResponse.status}`);
const response = await fetchWithRetry(repoUrl);
if (!response.ok) {
console.warn(`Could not fetch repo ${repoUrl}: ${response.status}`);
return null;
}
const repo: RepoInfo = await repoResponse.json();
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`;
// 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,
};
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 {
const query = encodeURIComponent(`type:pr author:${username} is:merged`);
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100`
);
// 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 (!response.ok) {
handleApiError(response, 'search');
return [];
}
const { total_count, items }: SearchResponse = await response.json();
if (!total_count || !items?.length) return [];
const repoUrls = pipe(
items,
filter(isNotExcluded),
uniqueBy((item) => item.repository_url),
map((item) => item.repository_url)
);
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);
}

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

@@ -34,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"
@@ -51,12 +51,3 @@
<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,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">
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: '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' },
},
],
},
],
},
});
CMS.init({ config });
});
</script>

View File

@@ -1,5 +1,6 @@
import type { RequestHandler } from './$types.js';
import type { LaunchOptions } from 'puppeteer';
import { dev } from '$app/environment';
import puppeteer from 'puppeteer';
import * as cheerio from 'cheerio';
import path from 'path';
@@ -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,10 +61,6 @@ 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);
@@ -98,10 +110,21 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
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>