diff --git a/AGENTS.md b/AGENTS.md index 4289337..7eea209 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 56cb2c9..a315f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9aa5acb..9ffacfc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.css b/src/app.css index 92ab48d..ee7fd0c 100644 --- a/src/app.css +++ b/src/app.css @@ -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 */ diff --git a/src/app.html b/src/app.html index 51536a9..8a67530 100644 --- a/src/app.html +++ b/src/app.html @@ -10,6 +10,6 @@ %sveltekit.head% - %sveltekit.body% +
%sveltekit.body%
diff --git a/src/lib/components/BracketLabel.svelte b/src/lib/components/BracketLabel.svelte new file mode 100644 index 0000000..abd97f7 --- /dev/null +++ b/src/lib/components/BracketLabel.svelte @@ -0,0 +1,9 @@ + + +{label} diff --git a/src/lib/components/Education.svelte b/src/lib/components/Education.svelte index 05ff861..ba0d3af 100644 --- a/src/lib/components/Education.svelte +++ b/src/lib/components/Education.svelte @@ -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();
@@ -15,7 +19,6 @@ location={edu.location} startDate={edu.startDate} endDate={edu.endDate} - current={edu.current} description={edu.description} /> {/each} diff --git a/src/lib/components/Experience.svelte b/src/lib/components/Experience.svelte index 865df0e..fee7f4b 100644 --- a/src/lib/components/Experience.svelte +++ b/src/lib/components/Experience.svelte @@ -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();
@@ -15,7 +19,6 @@ location={job.location} startDate={job.startDate} endDate={job.endDate} - current={job.current} description={job.description} tags={job.technologies} /> diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 47b41a7..2a3d30c 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,8 +1,13 @@
@@ -28,12 +33,12 @@
- [E] +
- [L] + {profile.location}
@@ -43,7 +48,7 @@ rel="noopener noreferrer" class="flex items-center gap-2 text-muted hover:text-accent transition-colors" > - [G] + github.com/{profile.github} @@ -54,7 +59,7 @@ rel="noopener noreferrer" class="flex items-center gap-2 text-muted hover:text-accent transition-colors" > - [W] + {profile.website.replace(/^https?:\/\//, '')} {/if} diff --git a/src/lib/components/PDFContent.svelte b/src/lib/components/PDFContent.svelte index a02b2db..be4beb3 100644 --- a/src/lib/components/PDFContent.svelte +++ b/src/lib/components/PDFContent.svelte @@ -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();
-
- {profile.name} -
+ {#if profile.profilePicture} +
+ {profile.name} +
+ {/if}
diff --git a/src/lib/components/ProjectList.svelte b/src/lib/components/ProjectList.svelte index af4bf15..039312e 100644 --- a/src/lib/components/ProjectList.svelte +++ b/src/lib/components/ProjectList.svelte @@ -9,7 +9,6 @@ } let { title, projects, seeAllLink }: Props = $props(); - $effect(() => console.log({ seeAllLink })); {#if projects.length > 0} diff --git a/src/lib/components/Projects.svelte b/src/lib/components/Projects.svelte index 4db8b6b..1de1d1d 100644 --- a/src/lib/components/Projects.svelte +++ b/src/lib/components/Projects.svelte @@ -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();
diff --git a/src/lib/components/Skills.svelte b/src/lib/components/Skills.svelte index df1c7d6..40b804b 100644 --- a/src/lib/components/Skills.svelte +++ b/src/lib/components/Skills.svelte @@ -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();
diff --git a/src/lib/components/TimelineItem.svelte b/src/lib/components/TimelineItem.svelte index b46f1db..a4478b6 100644 --- a/src/lib/components/TimelineItem.svelte +++ b/src/lib/components/TimelineItem.svelte @@ -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();
@@ -41,7 +31,7 @@ {/if} diff --git a/src/lib/content/config.ts b/src/lib/content/config.ts new file mode 100644 index 0000000..9f1e5f3 --- /dev/null +++ b/src/lib/content/config.ts @@ -0,0 +1,132 @@ +import type { CmsConfig, FileCollection, Field, EntryCollection } from '@sveltia/cms'; +import type { Profile, Experience, Education, Skill } from './types'; + +type RequiredKeys = 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 = { + [key in keyof T]: Field & { + name: key; + widget: string extends T[key] ? StringWidget : T[key] extends string[] ? ListWidget : never; + } & (key extends RequiredKeys ? { required?: true } : { required: false }); +}; + +const profileFields: Fields = { + 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 = { + 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 = { + 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 = { + 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; diff --git a/src/lib/content/loader.ts b/src/lib/content/loader.ts index 79d88c5..b2d8346 100644 --- a/src/lib/content/loader.ts +++ b/src/lib/content/loader.ts @@ -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(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(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(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( + modules: Record Promise<{ default: T }>> +): Promise { + 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(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(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(join(CONTENT_DIR, 'education')); +export function loadEducation() { + return loadJsonFilesFromGlob( + import.meta.glob<{ default: Education }>('$lib/content/education/*.json') + ); } -export function loadSkills(): Skill[] { - return loadJsonFilesFromDir(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, }; } diff --git a/src/lib/content/profile.json b/src/lib/content/profile.json index c44b38c..f0e38f4 100644 --- a/src/lib/content/profile.json +++ b/src/lib/content/profile.json @@ -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." } diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index cb26dfc..6d6421f 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -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; } diff --git a/src/lib/github.ts b/src/lib/github.ts index 9691ad2..4829c58 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -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 { const headers: Record = { 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 { +async function fetchWithRetry(url: string, retryCount = 0): Promise { 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 { 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 { + 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 { 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(); - 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); } diff --git a/src/lib/media/avatar.jpg b/src/lib/media/avatar.png similarity index 100% rename from src/lib/media/avatar.jpg rename to src/lib/media/avatar.png diff --git a/src/routes/(web)/+layout.svelte b/src/routes/(content)/(web)/+layout.svelte similarity index 90% rename from src/routes/(web)/+layout.svelte rename to src/routes/(content)/(web)/+layout.svelte index bfea6e3..67f0c12 100644 --- a/src/routes/(web)/+layout.svelte +++ b/src/routes/(content)/(web)/+layout.svelte @@ -1,6 +1,5 @@
diff --git a/src/routes/(web)/+page.svelte b/src/routes/(content)/(web)/+page.svelte similarity index 89% rename from src/routes/(web)/+page.svelte rename to src/routes/(content)/(web)/+page.svelte index 0fe9250..cfcfe15 100644 --- a/src/routes/(web)/+page.svelte +++ b/src/routes/(content)/(web)/+page.svelte @@ -34,7 +34,7 @@ - - diff --git a/src/routes/(content)/+layout.server.ts b/src/routes/(content)/+layout.server.ts new file mode 100644 index 0000000..96c5bff --- /dev/null +++ b/src/routes/(content)/+layout.server.ts @@ -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, + }; +}; diff --git a/src/routes/print/+layout.svelte b/src/routes/(content)/+layout.svelte similarity index 100% rename from src/routes/print/+layout.svelte rename to src/routes/(content)/+layout.svelte diff --git a/src/routes/(content)/print/+layout.svelte b/src/routes/(content)/print/+layout.svelte new file mode 100644 index 0000000..888a0eb --- /dev/null +++ b/src/routes/(content)/print/+layout.svelte @@ -0,0 +1,14 @@ + + +
+ {@render children()} +
+ + diff --git a/src/routes/(content)/print/+page.svelte b/src/routes/(content)/print/+page.svelte new file mode 100644 index 0000000..649c7b8 --- /dev/null +++ b/src/routes/(content)/print/+page.svelte @@ -0,0 +1,18 @@ + + + + {data.profile.name} — CV + + + diff --git a/src/routes/(web)/+page.server.ts b/src/routes/(web)/+page.server.ts deleted file mode 100644 index b329699..0000000 --- a/src/routes/(web)/+page.server.ts +++ /dev/null @@ -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), - }; -}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte deleted file mode 100644 index 4b7cb06..0000000 --- a/src/routes/+layout.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -{@render children()} diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 1d71334..4f50b2c 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -1,113 +1,10 @@ diff --git a/src/routes/joakim-repomaa-cv.pdf/+server.ts b/src/routes/joakim-repomaa-cv.pdf/+server.ts index 2ae7d53..e6037fb 100644 --- a/src/routes/joakim-repomaa-cv.pdf/+server.ts +++ b/src/routes/joakim-repomaa-cv.pdf/+server.ts @@ -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 => { + // 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 = {}; + const fileDownloads: Record = {}; - $('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'); diff --git a/src/routes/print/+page.server.ts b/src/routes/print/+page.server.ts deleted file mode 100644 index f7486e7..0000000 --- a/src/routes/print/+page.server.ts +++ /dev/null @@ -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), - }; -}; diff --git a/src/routes/print/+page.svelte b/src/routes/print/+page.svelte deleted file mode 100644 index dca3761..0000000 --- a/src/routes/print/+page.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - {data.profile.name} — CV (Print Version) - - - - - - -
- -