initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Automatically sets up your devbox environment whenever you cd into this
|
||||
# directory via our direnv integration:
|
||||
|
||||
eval "$(devbox generate direnv --print-envrc)"
|
||||
|
||||
# check out https://www.jetify.com/docs/devbox/ide_configuration/direnv/
|
||||
# for more details
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
PUPPETEER_EXECUTABLE_PATH: chrome
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: build/
|
||||
retention-days: 7
|
||||
|
||||
# Uncomment and configure for deployment:
|
||||
# - name: Deploy to server
|
||||
# if: github.ref == 'refs/heads/main'
|
||||
# run: |
|
||||
# # Add your deployment commands here
|
||||
# # Example using rsync:
|
||||
# # rsync -avz --delete build/ user@your-server:/var/www/cv/
|
||||
# env:
|
||||
# SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
node_modules
|
||||
/.svelte-kit
|
||||
/build
|
||||
/dist
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.vercel
|
||||
.netlify
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Devbox
|
||||
.devbox/
|
||||
devbox.lock
|
||||
|
||||
# Puppeteer
|
||||
.puppeteer
|
||||
|
||||
# CI/CD (don't ignore .github directory)
|
||||
!.github/
|
||||
node_modules/
|
||||
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
plugins: ['prettier-plugin-svelte'],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.svelte',
|
||||
options: {
|
||||
parser: 'svelte',
|
||||
},
|
||||
},
|
||||
],
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 100,
|
||||
svelteSortOrder: 'options-scripts-markup-styles',
|
||||
svelteStrictMode: false,
|
||||
svelteBracketNewLine: true,
|
||||
svelteAllowShorthand: true,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
# Agent Instructions
|
||||
|
||||
This is a SvelteKit CV website with TypeScript, Tailwind CSS v4, and Sveltia CMS.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server (Vite)
|
||||
devbox run dev # Using devbox (NixOS with Chrome)
|
||||
|
||||
# Production
|
||||
npm run build # Build static site + generate PDF
|
||||
npm run preview # Preview production build
|
||||
|
||||
# Quality
|
||||
npm run check # Type check with svelte-check
|
||||
npm run format # Format all files with Prettier
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode enabled** - all strict compiler options active
|
||||
- Always use explicit types for function parameters and return types
|
||||
- Import types with `.js` extension: `import type { Foo } from './types.js'`
|
||||
- Use interface for object shapes, type for unions/complex types
|
||||
- Export types from `src/lib/types.ts`
|
||||
|
||||
### Svelte 5 (Runes)
|
||||
|
||||
- Use `$props()` for component props with explicit interface
|
||||
- Example:
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Profile } from '$lib/types.js';
|
||||
interface Props {
|
||||
profile: Profile;
|
||||
}
|
||||
let { profile }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
- Components in `src/lib/components/` use PascalCase
|
||||
- Pages in `src/routes/` use SvelteKit conventions (`+page.svelte`)
|
||||
|
||||
### Imports
|
||||
|
||||
- Order: types first, then node modules, then $lib aliases, then relative
|
||||
- Use single quotes
|
||||
- Use `$lib/` alias for imports from `src/lib/`
|
||||
- Always include `.js` extension on TypeScript imports
|
||||
|
||||
### Formatting (Prettier)
|
||||
|
||||
- Semi-colons: required
|
||||
- Single quotes
|
||||
- Tab width: 2 spaces
|
||||
- Trailing commas: es5 style
|
||||
- Print width: 100
|
||||
- Svelte files: `options-scripts-markup-styles` order
|
||||
|
||||
### Tailwind CSS v4
|
||||
|
||||
- Use `@theme` block for custom colors/fonts in `app.css`
|
||||
- Colors use `light-dark()` CSS function for dark mode
|
||||
- Custom properties in theme: `--font-mono`, `--color-bg`, etc.
|
||||
- Utility classes use Tailwind v4 syntax (no arbitrary values when possible)
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Components: PascalCase (`Header.svelte`)
|
||||
- Utilities: camelCase (`loadProfile()`)
|
||||
- Types/Interfaces: PascalCase (`Profile`, `CVData`)
|
||||
- Constants: UPPER_SNAKE_CASE for module-level
|
||||
- Files: kebab-case for non-component files
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use try/catch with specific error messages
|
||||
- Log warnings for recoverable errors: `console.warn()`
|
||||
- Return fallback values for content loading failures
|
||||
- In server loads, always return data even if partial
|
||||
|
||||
### Content Loading
|
||||
|
||||
- YAML files in `/content/` directory
|
||||
- Use `loadYamlFile<T>()` pattern from `src/lib/content/loader.ts`
|
||||
- Provide default/fallback values for missing content
|
||||
- Async data fetching in `+page.server.ts` with `prerender = true`
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Semantic HTML elements (header, main, section, etc.)
|
||||
- Use `prefers-reduced-motion` for animations
|
||||
- Focus visible with 2px outline
|
||||
- Color contrast compliant (WCAG AA)
|
||||
- Skip links for keyboard navigation
|
||||
|
||||
### Styling Patterns
|
||||
|
||||
- Terminal aesthetic with JetBrains Mono font
|
||||
- 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
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
components/ # Svelte components (PascalCase)
|
||||
content/ # Content loading utilities
|
||||
types.ts # TypeScript type definitions
|
||||
github.ts # GitHub API integration
|
||||
routes/
|
||||
+page.svelte # Main CV page
|
||||
+page.server.ts # Server-side data loading
|
||||
pdf/ # Print-optimized PDF version
|
||||
app.css # Global styles with @theme
|
||||
content/ # YAML content files
|
||||
static/ # Static assets (admin/, fonts)
|
||||
build/ # Output directory (static + cv.pdf)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This project has no test framework configured. To add tests, consider:
|
||||
|
||||
- Vitest for unit tests
|
||||
- Playwright for E2E tests
|
||||
|
||||
## Environment Notes
|
||||
|
||||
- **Node.js 20+** required
|
||||
- Chrome/Chromium needed for PDF generation via Puppeteer
|
||||
- Use `devbox shell` on NixOS for Chrome support
|
||||
- PDF generation gracefully skips if Chrome unavailable (CI)
|
||||
- Static site deployable to any host (outputs to `/build/`)
|
||||
@@ -0,0 +1,60 @@
|
||||
# CV Website
|
||||
|
||||
A SvelteKit CV with Sveltia CMS, Tailwind CSS, and automatic PDF generation.
|
||||
|
||||
## Features
|
||||
|
||||
- Static site generation (adapter-static)
|
||||
- Git-based CMS (Sveltia CMS)
|
||||
- GitHub project fetching
|
||||
- Automatic PDF generation (Puppeteer)
|
||||
- Terminal aesthetic with dark mode
|
||||
- WCAG compliant accessibility
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Development server
|
||||
npm run build # Build static site + PDF
|
||||
```
|
||||
|
||||
## Content
|
||||
|
||||
Edit YAML files in `/content/`:
|
||||
|
||||
```yaml
|
||||
# content/profile.yml
|
||||
name: Your Name
|
||||
title: Your Title
|
||||
email: your@email.com
|
||||
github: username
|
||||
```
|
||||
|
||||
Or use the CMS at `/admin/`.
|
||||
|
||||
## NixOS / Devbox
|
||||
|
||||
For Chrome/PDF support on NixOS:
|
||||
|
||||
```bash
|
||||
devbox shell
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Upload `/build/` to any static host (GitHub Pages, Netlify, Vercel, etc.).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server
|
||||
npm run check # Type check
|
||||
npm run format # Format code
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/main/.schema/devbox.schema.json",
|
||||
"packages": ["nodejs@20", "chromium@latest"],
|
||||
"env": {
|
||||
"PUPPETEER_SKIP_DOWNLOAD": "true",
|
||||
"PUPPETEER_EXECUTABLE_PATH": "chromium"
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
"echo '✅ Devbox environment loaded'",
|
||||
"echo '🌐 Chromium path:' $(which chromium 2>/dev/null || echo 'not found in PATH')",
|
||||
"export PUPPETEER_EXECUTABLE_PATH=$(which chromium 2>/dev/null || echo '/usr/bin/chromium')"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build",
|
||||
"dev": "npm run dev",
|
||||
"preview": "npm run preview",
|
||||
"setup": "npm install"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "cv-website",
|
||||
"version": "1.0.0",
|
||||
"description": "Professional CV website with Sveltia CMS and SvelteKit",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && node scripts/generate-pdf.js",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltia/cms": "^0.140.3"
|
||||
},
|
||||
"keywords": [
|
||||
"cv",
|
||||
"resume",
|
||||
"sveltekit",
|
||||
"sveltia"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.51.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"dotenv": "^17.3.1",
|
||||
"http-server": "^14.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"puppeteer": "^24.37.3",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import httpServer from 'http-server';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const BUILD_DIR = join(__dirname, '..', 'build');
|
||||
const OUTPUT_PDF = join(BUILD_DIR, 'cv.pdf');
|
||||
|
||||
async function generatePDF() {
|
||||
console.log('🚀 Starting PDF generation...');
|
||||
|
||||
// Check if we're in an environment that can run Chrome
|
||||
const isCI = process.env.CI || process.env.CONTINUOUS_INTEGRATION;
|
||||
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
|
||||
if (chromePath) {
|
||||
console.log('🔍 Using Chrome from:', chromePath);
|
||||
}
|
||||
|
||||
// Start static server
|
||||
console.log('📡 Starting static file server...');
|
||||
const server = httpServer.createServer({
|
||||
root: BUILD_DIR,
|
||||
port: 3456,
|
||||
cache: -1, // Disable caching
|
||||
cors: true,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
server.listen(3456, 'localhost', () => {
|
||||
console.log('✅ Server running at http://localhost:3456');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// Launch browser options
|
||||
const launchOptions = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
],
|
||||
};
|
||||
|
||||
// Use custom Chrome path if available (e.g., from devbox or CI)
|
||||
if (chromePath) {
|
||||
launchOptions.executablePath = chromePath;
|
||||
}
|
||||
|
||||
// Launch browser
|
||||
console.log('🌐 Launching browser...');
|
||||
const browser = await puppeteer.launch(launchOptions);
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Navigate to print route for HTML rendering
|
||||
console.log('📄 Loading CV page...');
|
||||
await page.goto('http://localhost:3456/print/', {
|
||||
waitUntil: 'networkidle0',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Wait for fonts to load
|
||||
await page.evaluateHandle('document.fonts.ready');
|
||||
|
||||
// Generate PDF
|
||||
console.log('📝 Generating PDF...');
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '20mm',
|
||||
bottom: '20mm',
|
||||
left: '20mm',
|
||||
},
|
||||
});
|
||||
|
||||
// Save PDF
|
||||
await writeFile(OUTPUT_PDF, pdfBuffer);
|
||||
console.log('✅ PDF saved to:', OUTPUT_PDF);
|
||||
|
||||
// Close browser
|
||||
await browser.close();
|
||||
} catch (error) {
|
||||
console.error('⚠️ Error generating PDF:', error.message);
|
||||
|
||||
if (isCI) {
|
||||
console.log('📝 CI environment detected, but Chrome may not be available.');
|
||||
console.log(' The PDF will need to be generated in a post-deployment step');
|
||||
console.log(' or in a CI environment with Chrome/Chromium installed.');
|
||||
} else {
|
||||
console.log('📝 To generate the PDF:');
|
||||
console.log(' Option 1: Run in devbox (NixOS): devbox run npm run build');
|
||||
console.log(' Option 2: Deploy to a hosting platform with Chrome');
|
||||
}
|
||||
|
||||
// Don't exit with error - the static site is still usable
|
||||
console.log('⚠️ Continuing without PDF...');
|
||||
} finally {
|
||||
// Stop server
|
||||
server.close();
|
||||
console.log('🛑 Server stopped');
|
||||
}
|
||||
|
||||
console.log('🎉 Build complete!');
|
||||
}
|
||||
|
||||
generatePDF();
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Custom font imports */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.woff2')
|
||||
format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.woff2')
|
||||
format('woff2');
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Theme configuration - 5 colors per layout */
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Web layout - 5 colors using light-dark() for dark mode */
|
||||
--color-bg: light-dark(#fafafa, #0c0c0e);
|
||||
--color-fg: light-dark(#18181b, #fafafa);
|
||||
--color-muted: light-dark(#71717a, #a1a1aa);
|
||||
--color-accent: light-dark(#0891b2, #22d3ee);
|
||||
--color-hot: light-dark(#ea580c, #fb923c);
|
||||
|
||||
/* PDF layout - 4 colors (print-optimized) */
|
||||
--color-pdf-bg: #ffffff;
|
||||
--color-pdf-fg: #000000;
|
||||
--color-pdf-muted: #525252;
|
||||
--color-pdf-accent: #0284c7;
|
||||
|
||||
--shadow-glow: 0 0 10px
|
||||
light-dark(
|
||||
color-mix(in oklab, var(--color-accent) 30%, transparent 70%),
|
||||
color-mix(in oklab, var(--color-accent) 70%, transparent 30%)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Professional CV - Developer Portfolio" />
|
||||
<meta name="theme-color" content="#0891b2" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#22d3ee" media="(prefers-color-scheme: dark)" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
%sveltekit.body%
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { Education } from '$lib/types.js';
|
||||
import Section from './Section.svelte';
|
||||
import TimelineItem from './TimelineItem.svelte';
|
||||
|
||||
let { education }: { education: Education[] } = $props();
|
||||
</script>
|
||||
|
||||
<Section title="education">
|
||||
<div class="space-y-6">
|
||||
{#each education as edu}
|
||||
<TimelineItem
|
||||
title={edu.degree}
|
||||
subtitle={edu.field}
|
||||
location={edu.location}
|
||||
startDate={edu.startDate}
|
||||
endDate={edu.endDate}
|
||||
current={edu.current}
|
||||
description={edu.description}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
email: string;
|
||||
class?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { email, class: className = '', label }: Props = $props();
|
||||
|
||||
let encodedEmail = $state('');
|
||||
let isEncoding = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
encodedEmail = btoa(email);
|
||||
} catch {
|
||||
encodedEmail = '';
|
||||
}
|
||||
isEncoding = false;
|
||||
}
|
||||
});
|
||||
|
||||
function decodeEmail(): string {
|
||||
try {
|
||||
return atob(encodedEmail);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isEncoding && encodedEmail}
|
||||
<a href="mailto:{decodeEmail()}" class={className}>
|
||||
{label || decodeEmail()}
|
||||
</a>
|
||||
{:else}
|
||||
<span class={className}>{label || '[email]'}</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { Experience } from '$lib/types.js';
|
||||
import Section from './Section.svelte';
|
||||
import TimelineItem from './TimelineItem.svelte';
|
||||
|
||||
let { experience }: { experience: Experience[] } = $props();
|
||||
</script>
|
||||
|
||||
<Section title="experience">
|
||||
<div class="space-y-8">
|
||||
{#each experience as job}
|
||||
<TimelineItem
|
||||
title={job.position}
|
||||
subtitle={job.company}
|
||||
location={job.location}
|
||||
startDate={job.startDate}
|
||||
endDate={job.endDate}
|
||||
current={job.current}
|
||||
description={job.description}
|
||||
tags={job.technologies}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { Profile } from '$lib/types.js';
|
||||
import EncodedEmail from './EncodedEmail.svelte';
|
||||
|
||||
let { profile }: { profile: Profile } = $props();
|
||||
</script>
|
||||
|
||||
<header class="border-b-2 border-fg/10 pb-8 mb-8">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold mb-2 tracking-tight text-fg">
|
||||
{profile.name}
|
||||
</h1>
|
||||
<p class="text-xl text-muted mb-4 font-medium">
|
||||
{profile.title}
|
||||
</p>
|
||||
<p class="text-muted leading-relaxed max-w-2xl">
|
||||
{profile.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
{profile.location}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/{profile.github}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="text-accent">[G]</span>
|
||||
github.com/{profile.github}
|
||||
</a>
|
||||
|
||||
{#if profile.website}
|
||||
<a
|
||||
href={profile.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="text-accent">[W]</span>
|
||||
{profile.website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import type { Profile, Experience, Education, Skill, Project } from '$lib/types.js';
|
||||
import PDFProjectItem from './PDFProjectItem.svelte';
|
||||
import PDFSection from './PDFSection.svelte';
|
||||
import PDFTags from './PDFTags.svelte';
|
||||
import PDFTimelineItem from './PDFTimelineItem.svelte';
|
||||
|
||||
let {
|
||||
profile,
|
||||
experience,
|
||||
education,
|
||||
skills,
|
||||
ownProjects,
|
||||
contributions,
|
||||
}: {
|
||||
profile: Profile;
|
||||
experience: Experience[];
|
||||
education: Education[];
|
||||
skills: Skill[];
|
||||
ownProjects: Project[];
|
||||
contributions: Project[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="font-sans max-w-[210mm] mx-auto px-[20mm] py-[18mm] bg-pdf-bg text-pdf-fg leading-relaxed text-sm"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="text-center mb-6 pb-4 border-b-2 border-pdf-fg">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-pdf-fg leading-tight m-0">
|
||||
{profile.name}
|
||||
</h1>
|
||||
<p class="text-base font-medium text-pdf-muted mt-1.5 mb-3">{profile.title}</p>
|
||||
</div>
|
||||
<div class="flex justify-center flex-wrap gap-x-6 gap-y-3 text-xs mb-2">
|
||||
{#if profile.email}
|
||||
<a class="inline-flex items-center gap-1.5 text-pdf-accent" href="mailto:{profile.email}">
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-pdf-accent"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
{profile.email}
|
||||
</a>
|
||||
{/if}
|
||||
<span class="inline-flex items-center gap-1.5 text-pdf-muted">
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-pdf-accent"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
{profile.location}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-center flex-wrap gap-x-5 gap-y-2 text-xs text-pdf-accent">
|
||||
{#if profile.github}
|
||||
<a href="https://github.com/{profile.github}" class="no-underline"
|
||||
>github.com/{profile.github}</a
|
||||
>
|
||||
{/if}
|
||||
{#if profile.website}
|
||||
<a href={profile.website} class="no-underline"
|
||||
>{profile.website.replace(/^https?:\/\//, '')}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Summary -->
|
||||
<section class="mb-6 pb-3 text-sm leading-relaxed text-pdf-muted border-b border-pdf-muted/30">
|
||||
<p>{profile.summary}</p>
|
||||
</section>
|
||||
|
||||
<!-- Experience -->
|
||||
<PDFSection title="Professional Experience">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each experience as job}
|
||||
<PDFTimelineItem
|
||||
title={job.position}
|
||||
subtitle={job.company}
|
||||
location={job.location}
|
||||
startDate={job.startDate}
|
||||
endDate={job.endDate}
|
||||
description={job.description}
|
||||
tags={job.technologies}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</PDFSection>
|
||||
|
||||
<!-- Education -->
|
||||
<PDFSection title="Education">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each education as edu}
|
||||
<PDFTimelineItem
|
||||
title={edu.degree}
|
||||
subtitle={edu.field}
|
||||
location={edu.institution}
|
||||
startDate={edu.startDate}
|
||||
endDate={edu.endDate}
|
||||
description={edu.description}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</PDFSection>
|
||||
|
||||
<!-- Skills -->
|
||||
<PDFSection title="Skills & Technologies">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
{#each skills as skillCategory}
|
||||
<div class="grid items-baseline gap-3" style="grid-template-columns: 130px 1fr;">
|
||||
<span class="text-xs font-semibold text-pdf-fg text-left">{skillCategory.category}</span>
|
||||
<PDFTags tags={skillCategory.items} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</PDFSection>
|
||||
|
||||
<!-- Projects -->
|
||||
{#if ownProjects.length > 0}
|
||||
<PDFSection title="Original Projects">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
{#each ownProjects as project}
|
||||
<PDFProjectItem {project} />
|
||||
{/each}
|
||||
</div>
|
||||
</PDFSection>
|
||||
{/if}
|
||||
|
||||
<!-- Contributions -->
|
||||
{#if contributions.length > 0}
|
||||
<PDFSection title="Contributions">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
{#each contributions as project}
|
||||
<PDFProjectItem {project} />
|
||||
{/each}
|
||||
</div>
|
||||
</PDFSection>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { Project } from '$lib/types.js';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
let { project }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="break-inside-avoid">
|
||||
<a href={project.url} class="font-bold text-pdf-accent no-underline">
|
||||
{project.name}
|
||||
</a>
|
||||
<p class="text-pdf-muted leading-snug text-xs">{project.description}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end text-xs">
|
||||
<span class="text-pdf-fg font-medium">★ {project.stars}</span>
|
||||
{#if project.language}
|
||||
<span class="bg-pdf-fg/5 text-pdf-muted px-1 py-0.5 rounded font-medium">
|
||||
{project.language}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0">
|
||||
<h2 class="text-sm font-bold text-pdf-fg flex items-center uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mt-3">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
let { tags }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="text-xs text-pdf-muted">
|
||||
{#each tags as tag}
|
||||
<span class="first:before:content-[''] before:content-['_•_']">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
import PDFTags from './PDFTags.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
let { title, subtitle, location, startDate, endDate, description, tags = [] }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="break-inside-avoid">
|
||||
<div class="flex justify-between items-start mb-1 gap-4">
|
||||
<div class="flex flex-wrap items-baseline gap-1.5">
|
||||
<h3 class="text-sm font-bold text-pdf-fg m-0">{title}</h3>
|
||||
{#if subtitle}
|
||||
<span class="text-xs font-medium text-pdf-muted">{subtitle}</span>
|
||||
{/if}
|
||||
{#if location}
|
||||
<span class="text-xs text-pdf-muted italic">• {location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-pdf-muted whitespace-nowrap tabular-nums">
|
||||
{formatDate(startDate)} — {endDate ? formatDate(endDate) : 'Present'}
|
||||
</div>
|
||||
</div>
|
||||
{#if description}
|
||||
<p class="my-1 text-xs leading-relaxed text-pdf-muted">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-1.5">
|
||||
<PDFTags {tags} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Project } from '$lib/types.js';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
let { project }: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={project.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block p-4 border border-accent rounded-lg transition-shadow hover:shadow-glow"
|
||||
>
|
||||
<article class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<span class="text-lg font-semibold text-fg">
|
||||
{project.name}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 text-sm text-hot">
|
||||
<span class="flex items-center gap-1" aria-label="{project.stars} stars">
|
||||
<span class="text-xl">★</span>
|
||||
{project.stars}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted text-sm mb-3 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
{#if project.language}
|
||||
<span class="font-mono text-muted">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-accent mr-1"></span>
|
||||
{project.language}
|
||||
</span>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Project } from '$lib/types.js';
|
||||
import ProjectCard from './ProjectCard.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
projects: Project[];
|
||||
seeAllLink: string;
|
||||
}
|
||||
|
||||
let { title, projects, seeAllLink }: Props = $props();
|
||||
$effect(() => console.log({ seeAllLink }));
|
||||
</script>
|
||||
|
||||
{#if projects.length > 0}
|
||||
<div class="mb-8 last:mb-0">
|
||||
<h3 class="font-semibold mb-4 text-fg/70 text-2xl">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-3">
|
||||
{#each projects as project}
|
||||
<ProjectCard {project} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="text-accent hover:text-accent/80 transition-colors cursor-pointer"
|
||||
href={seeAllLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See all
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { Project } from '$lib/types.js';
|
||||
import ProjectList from './ProjectList.svelte';
|
||||
import Section from './Section.svelte';
|
||||
|
||||
let {
|
||||
ownProjects,
|
||||
contributions,
|
||||
username,
|
||||
}: { ownProjects: Project[]; contributions: Project[]; username: string } = $props();
|
||||
</script>
|
||||
|
||||
<Section title="projects">
|
||||
<ProjectList
|
||||
title="Original Projects"
|
||||
projects={ownProjects}
|
||||
seeAllLink="https://github.com/{username}?tab=repositories&type=source&sort=stargazers"
|
||||
/>
|
||||
<ProjectList
|
||||
title="Contributions"
|
||||
projects={contributions}
|
||||
seeAllLink="https://github.com/{username}?tab=repositories&type=fork"
|
||||
/>
|
||||
</Section>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
id?: string;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { title, id, children }: Props = $props();
|
||||
|
||||
const headingId = $derived(id ?? `${title.toLowerCase().replace(/\s+/g, '-')}-heading`);
|
||||
</script>
|
||||
|
||||
<section class="pb-8 mb-10 border-b border-fg/20 last:border-b-0" aria-labelledby={headingId}>
|
||||
<h2 id={headingId} class="text-3xl font-bold mb-6 pb-2 text-fg">
|
||||
<span class="text-accent">$></span>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{@render children()}
|
||||
</section>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Skill } from '$lib/types.js';
|
||||
import Section from './Section.svelte';
|
||||
import Tags from './Tags.svelte';
|
||||
|
||||
let { skills }: { skills: Skill[] } = $props();
|
||||
</script>
|
||||
|
||||
<Section title="skills">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{#each skills as skillCategory}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-accent uppercase tracking-wider mb-3">
|
||||
{skillCategory.category}
|
||||
</h3>
|
||||
<Tags tags={skillCategory.items} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
let { tags }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each tags as tag}
|
||||
<span class="px-2 py-1 text-xs font-mono bg-fg/5 text-fg/70 rounded border border-fg/10">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
import Tags from './Tags.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current?: boolean;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
location,
|
||||
startDate,
|
||||
endDate,
|
||||
current = false,
|
||||
description,
|
||||
tags = [],
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 mb-2">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-xl font-semibold text-fg">{title}</h3>
|
||||
{#if subtitle || location}
|
||||
<p class="text-fg font-medium">
|
||||
{subtitle}
|
||||
{#if subtitle && location}
|
||||
<span class="text-muted font-normal">• {location}</span>
|
||||
{:else if location}
|
||||
{location}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<time class="text-sm text-muted font-mono whitespace-nowrap">
|
||||
{formatDate(startDate)} — {current || !endDate ? 'Present' : formatDate(endDate)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="text-muted leading-relaxed mb-3">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Tags {tags} />
|
||||
</article>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"institution": "Hochschule RheinMain",
|
||||
"degree": "Bachelor of Science",
|
||||
"field": "Applied Computer Science",
|
||||
"location": "Wiesbaden, Germany",
|
||||
"startDate": "2010",
|
||||
"endDate": "2016",
|
||||
"description": "Bachelor thesis: 'Collaborative Content Creation' - built a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"company": "hundertzehn GmbH",
|
||||
"position": "Senior Software Engineer",
|
||||
"location": "Zürich, Schweiz",
|
||||
"startDate": "2021",
|
||||
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Develop Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analyze large datasets, evaluate new technologies, integrate external APIs (Paywise), direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.",
|
||||
"technologies": [
|
||||
"Ruby on Rails",
|
||||
"TypeScript",
|
||||
"React",
|
||||
"PostgreSQL",
|
||||
"GraphQL",
|
||||
"REST APIs",
|
||||
"Grafana"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"company": "MESO Digital Services GmbH",
|
||||
"position": "Software Engineer",
|
||||
"location": "Frankfurt, Germany",
|
||||
"startDate": "2014",
|
||||
"endDate": "2021",
|
||||
"description": "Developed software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - experience platform and CMS for CES showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.",
|
||||
"technologies": [
|
||||
"Crystal",
|
||||
"Ruby on Rails",
|
||||
"JavaScript",
|
||||
"MongoDB",
|
||||
"REST APIs",
|
||||
"IoT",
|
||||
"Big Data"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CVData, Profile, Experience, Education, Skill } from './types.js';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const CONTENT_DIR = join(process.cwd(), 'src', 'lib', 'content');
|
||||
|
||||
function loadJsonFile<T>(filepath: string): T | null {
|
||||
try {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
console.warn(`Could not load ${filepath}:`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadJsonFilesFromDir<T>(dirPath: string): T[] {
|
||||
try {
|
||||
const files = readdirSync(dirPath, { withFileTypes: true });
|
||||
const items: T[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isFile() && file.name.endsWith('.json')) {
|
||||
const item = loadJsonFile<T>(join(dirPath, file.name));
|
||||
if (item) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Could not read directory ${dirPath}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Email(email: string): string {
|
||||
try {
|
||||
return Buffer.from(email, 'base64').toString('utf-8');
|
||||
} catch {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
};
|
||||
|
||||
return {
|
||||
...profile,
|
||||
email: decodeBase64Email(profile.email),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadExperience(): Experience[] {
|
||||
return loadJsonFilesFromDir<Experience>(join(CONTENT_DIR, 'experience'));
|
||||
}
|
||||
|
||||
export function loadEducation(): Education[] {
|
||||
return loadJsonFilesFromDir<Education>(join(CONTENT_DIR, 'education'));
|
||||
}
|
||||
|
||||
export function loadSkills(): Skill[] {
|
||||
return loadJsonFilesFromDir<Skill>(join(CONTENT_DIR, 'skills'));
|
||||
}
|
||||
|
||||
export function loadAllContent(): CVData {
|
||||
return {
|
||||
profile: loadProfile(),
|
||||
experience: loadExperience(),
|
||||
education: loadEducation(),
|
||||
skills: loadSkills(),
|
||||
projects: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Joakim Repomaa",
|
||||
"title": "Senior Software Engineer",
|
||||
"email": "am9ha2ltQHJlcG9tYWEuY29t",
|
||||
"location": "Espoo - Finnland",
|
||||
"website": "https://joakim.repomaa.com",
|
||||
"github": "repomaa",
|
||||
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, external API partnerships, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"category": "Backend",
|
||||
"items": ["Ruby on Rails", "Node.js", "PostgreSQL", "Redis", "GraphQL", "REST APIs"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"category": "DevOps & Tools",
|
||||
"items": ["Docker", "Grafana", "CI/CD", "Git", "Linux", "NixOS", "Data Analysis"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"category": "Frontend",
|
||||
"items": ["React", "TypeScript", "SvelteKit", "Tailwind CSS", "HTML5/CSS3"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"category": "Languages",
|
||||
"items": ["Ruby", "TypeScript", "JavaScript", "SQL"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"category": "Practices",
|
||||
"items": [
|
||||
"System Architecture",
|
||||
"API Design",
|
||||
"AI Integration",
|
||||
"Code Review",
|
||||
"Technical Writing",
|
||||
"Mentoring"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"category": "Spoken Languages",
|
||||
"items": ["German (fluent)", "Finnish (fluent)", "English (fluent)", "Swedish (intermediate)"]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Content data types - clean interface definitions
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
title: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
location: string;
|
||||
website?: string;
|
||||
github: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
company: string;
|
||||
position: string;
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current: boolean;
|
||||
description: string;
|
||||
technologies: string[];
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
institution: string;
|
||||
degree: string;
|
||||
field?: string;
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
category: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
stars: number;
|
||||
forks: number;
|
||||
language?: string;
|
||||
isFork: boolean;
|
||||
}
|
||||
|
||||
export interface CVData {
|
||||
profile: Profile;
|
||||
experience: Experience[];
|
||||
education: Education[];
|
||||
skills: Skill[];
|
||||
projects: Project[];
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Project } from './content/types.js';
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
|
||||
interface SearchIssue {
|
||||
repository_url: string;
|
||||
pull_request?: {
|
||||
merged_at: string | null;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
items: SearchIssue[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
interface RepoInfo {
|
||||
full_name: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
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> {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, 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));
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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}`);
|
||||
}
|
||||
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,
|
||||
})
|
||||
);
|
||||
} 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
|
||||
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
|
||||
return repos
|
||||
.filter((repo) => repo !== null)
|
||||
.sort((a, b) => b.stars - a.stars)
|
||||
.slice(0, 5);
|
||||
} 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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Re-export content types for convenient access
|
||||
export type { Profile, Experience, Education, Skill, Project, CVData } from './content/types.js';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Format a year-month date string to display just the year
|
||||
* @param dateStr - Date string in YYYY-MM format, or undefined for 'Present'
|
||||
* @returns Formatted year or 'Present'
|
||||
*/
|
||||
export function formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'Present';
|
||||
const date = new Date(dateStr + '-01');
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric' });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="font-mono bg-background text-foreground dark:bg-background-dark dark:text-shadow-foreground-dark"
|
||||
>
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute">
|
||||
Skip to main content
|
||||
</a>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Experience from '$lib/components/Experience.svelte';
|
||||
import Education from '$lib/components/Education.svelte';
|
||||
import Skills from '$lib/components/Skills.svelte';
|
||||
import Projects from '$lib/components/Projects.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.name} — {data.profile.title}</title>
|
||||
<meta name="description" content={data.profile.summary.substring(0, 160)} />
|
||||
</svelte:head>
|
||||
|
||||
<main id="main-content" class="min-h-screen bg-bg">
|
||||
<div class="max-w-5xl mx-auto px-6 py-12">
|
||||
<Header profile={data.profile} />
|
||||
|
||||
<Experience experience={data.experience} />
|
||||
|
||||
<Education education={data.education} />
|
||||
|
||||
<Skills skills={data.skills} />
|
||||
|
||||
<Projects
|
||||
ownProjects={data.ownProjects}
|
||||
contributions={data.contributions}
|
||||
username={data.profile.github}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/joakim-repomaa-cv.pdf"
|
||||
download
|
||||
class="no-print 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"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-0 h-0 border-t-80 border-t-accent border-l-80 border-l-transparent group-hover:opacity-80 transition-opacity"
|
||||
></div>
|
||||
<span
|
||||
class="absolute inset-0 flex items-start justify-end p-4 text-bg font-mono text-sm font-bold"
|
||||
>
|
||||
<span class="inline-block rotate-45 origin-center group-focus:ring ring-fg">PDF</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted">
|
||||
<p class="font-mono">
|
||||
<span class="text-accent">$</span>
|
||||
Built with SvelteKit + Sveltia CMS
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a href="/admin/index.html" class="text-accent hover:text-accent/80 transition-colors">
|
||||
Edit Content →
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* Ensure proper print styling */
|
||||
@media print {
|
||||
main {
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import CMS from '@sveltia/cms';
|
||||
|
||||
CMS.init({
|
||||
config: {
|
||||
load_config_file: false,
|
||||
media_folder: 'src/lib/media',
|
||||
backend: {
|
||||
name: 'gitea',
|
||||
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
|
||||
repo: 'repomaa/cv',
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
name: 'profile',
|
||||
label: 'Profile',
|
||||
format: 'json',
|
||||
files: [
|
||||
{
|
||||
name: 'profile',
|
||||
label: 'Profile',
|
||||
file: 'src/lib/content/profile.json',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', widget: 'string' },
|
||||
{ name: 'title', label: 'Title', widget: 'string' },
|
||||
{ name: 'email', label: 'Email', widget: 'string' },
|
||||
{ name: 'phone', label: 'Phone', widget: 'string', required: false },
|
||||
{ name: 'location', label: 'Location', widget: 'string' },
|
||||
{ name: 'website', label: 'Website', widget: 'string', required: false },
|
||||
{ name: 'github', label: 'GitHub', widget: 'string' },
|
||||
{ name: 'summary', label: 'Summary', widget: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'experience',
|
||||
label: 'Experience',
|
||||
format: 'json',
|
||||
folder: 'src/lib/content/experience',
|
||||
fields: [
|
||||
{ name: 'company', label: 'Company', widget: 'string' },
|
||||
{ name: 'position', label: 'Position', widget: 'string' },
|
||||
{ name: 'location', label: 'Location', widget: 'string', required: false },
|
||||
{ name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
||||
{
|
||||
name: 'endDate',
|
||||
label: 'End Date',
|
||||
widget: 'datetime',
|
||||
time_format: false,
|
||||
required: false,
|
||||
},
|
||||
{ name: 'description', label: 'Description', widget: 'markdown' },
|
||||
{
|
||||
name: 'technologies',
|
||||
label: 'Technologies',
|
||||
widget: 'list',
|
||||
field: { label: 'Technology', widget: 'string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'education',
|
||||
label: 'Education',
|
||||
format: 'json',
|
||||
folder: 'src/lib/content/education',
|
||||
fields: [
|
||||
{ name: 'institution', label: 'Institution', widget: 'string' },
|
||||
{ name: 'degree', label: 'Degree', widget: 'string' },
|
||||
{ name: 'field', label: 'Field', widget: 'string', required: false },
|
||||
{ name: 'location', label: 'Location', widget: 'string', required: false },
|
||||
{ name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
||||
{
|
||||
name: 'endDate',
|
||||
label: 'End Date',
|
||||
widget: 'datetime',
|
||||
time_format: false,
|
||||
required: false,
|
||||
},
|
||||
{ name: 'description', label: 'Description', widget: 'markdown', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
label: 'Skills',
|
||||
format: 'json',
|
||||
folder: 'src/lib/content/skills',
|
||||
fields: [
|
||||
{ name: 'category', label: 'Category', widget: 'string' },
|
||||
{
|
||||
name: 'items',
|
||||
label: 'Skills',
|
||||
widget: 'list',
|
||||
field: { label: 'Skill', widget: 'string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Content Manager</title>
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import type { LaunchOptions } from 'puppeteer';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
// This is a dynamic endpoint - not prerenderable
|
||||
export const prerender = false;
|
||||
|
||||
// PDF generation configuration
|
||||
const PDF_CONFIG = {
|
||||
format: 'A4' as const,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '20mm',
|
||||
bottom: '20mm',
|
||||
left: '20mm',
|
||||
},
|
||||
};
|
||||
|
||||
// Browser launch options for different environments
|
||||
const getLaunchOptions = (): LaunchOptions => {
|
||||
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
const options: LaunchOptions = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
],
|
||||
};
|
||||
if (chromePath) {
|
||||
options.executablePath = chromePath;
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ url }: { url: URL }) => {
|
||||
// Check if Chrome/Puppeteer is available
|
||||
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
const isCI = process.env.CI || process.env.CONTINUOUS_INTEGRATION;
|
||||
|
||||
if (!chromePath && !isCI) {
|
||||
// Return 503 if Chrome is not available
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'PDF generation not available',
|
||||
message:
|
||||
'Chrome/Chromium is not configured. Set PUPPETEER_EXECUTABLE_PATH environment variable.',
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the base URL for the current request
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
const pdfUrl = `${baseUrl}/print/`;
|
||||
|
||||
// Launch browser
|
||||
const browser = await puppeteer.launch(getLaunchOptions());
|
||||
const page = await browser.newPage();
|
||||
|
||||
// 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(pdfUrl, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: 120000, // Increased from 30s to 120s to handle slow GitHub API calls
|
||||
});
|
||||
|
||||
// Wait for fonts to load
|
||||
await page.evaluateHandle('document.fonts.ready');
|
||||
|
||||
// Generate PDF
|
||||
const pdfBuffer = await page.pdf(PDF_CONFIG);
|
||||
|
||||
// Close browser
|
||||
await browser.close();
|
||||
|
||||
// Create Blob from buffer for Response
|
||||
const pdfBlob = new Blob([pdfBuffer as unknown as ArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Return PDF with appropriate headers
|
||||
return new Response(pdfBlob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PDF generation error:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Failed to generate PDF',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -0,0 +1,34 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
precompress: false,
|
||||
strict: false,
|
||||
}),
|
||||
prerender: {
|
||||
entries: ['/', '/print'],
|
||||
handleHttpError: ({ path, message }) => {
|
||||
// Ignore 404s for cv.pdf (generated after build) and static files
|
||||
if (path === '/cv.pdf' || path.startsWith('/admin/')) {
|
||||
return;
|
||||
}
|
||||
console.warn(`Prerender error for ${path}: ${message}`);
|
||||
},
|
||||
handleMissingId: () => {
|
||||
// Ignore missing IDs for hash links
|
||||
return;
|
||||
},
|
||||
},
|
||||
alias: {
|
||||
$lib: './src/lib',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/**/*", "src/lib/types/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on mode, with '' prefix to load all vars (not just VITE_*)
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
// Merge into process.env for server-side code access
|
||||
process.env = { ...process.env, ...env };
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user