Compare commits
23 Commits
209770e8aa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f0783a43
|
||
|
|
1970350916
|
||
|
|
e2c7b86cf0
|
||
|
|
66dd269601
|
||
|
|
45b7903b59
|
||
|
|
f604e50172
|
||
|
|
96171576c7
|
||
|
|
782f46f69f
|
||
|
|
1744466b2b
|
||
|
|
60567717eb
|
||
|
|
2f76f1ce38
|
||
|
|
aeecf3b87d
|
||
|
|
b5b5b6139e
|
||
|
|
80ecfa6e0f
|
||
|
|
49a4d21d49
|
||
|
|
b31d0f144a
|
||
|
|
1e3c13902e
|
||
|
|
afd9f08da6
|
||
|
|
2f30124d19
|
||
|
|
d1ad7888ac
|
||
|
|
09475f090e
|
||
|
|
95839dab62
|
||
|
|
50af9cae3e
|
@@ -12,18 +12,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
chrome-version: stable
|
||||
- name: Install firefox runtime dependencies
|
||||
run: apt-get update && apt-get install -y libgtk-3-0 libasound2 libx11-xcb1
|
||||
|
||||
- name: Setup Firefox
|
||||
id: setup-firefox
|
||||
uses: browser-actions/setup-firefox@v1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -31,10 +32,12 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
PUPPETEER_BROWSER: firefox
|
||||
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-firefox.outputs.firefox-path }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: build/
|
||||
29
AGENTS.md
@@ -84,8 +84,8 @@ npm run format # Format all files with Prettier
|
||||
|
||||
### Content Loading
|
||||
|
||||
- YAML files in `/content/` directory
|
||||
- Use `loadYamlFile<T>()` pattern from `src/lib/content/loader.ts`
|
||||
- JSON files in `/src/lib/content/` directory
|
||||
- Use `loadJsonFile<T>()` pattern from `src/lib/content/loader.ts`
|
||||
- Provide default/fallback values for missing content
|
||||
- Async data fetching in `+page.server.ts` with `prerender = true`
|
||||
|
||||
@@ -99,11 +99,10 @@ npm run format # Format all files with Prettier
|
||||
|
||||
### Styling Patterns
|
||||
|
||||
- Terminal aesthetic with JetBrains Mono font
|
||||
- Terminal aesthetic with Iosevka font (monospace)
|
||||
- Dark mode via `light-dark()` CSS function
|
||||
- Cyan accent color for interactive elements
|
||||
- Zinc-based neutral colors
|
||||
- Print styles: `.no-print` class hides elements in PDF
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -111,15 +110,17 @@ npm run format # Format all files with Prettier
|
||||
src/
|
||||
lib/
|
||||
components/ # Svelte components (PascalCase)
|
||||
content/ # Content loading utilities
|
||||
types.ts # TypeScript type definitions
|
||||
github.ts # GitHub API integration
|
||||
content/ # Content loading utilities + JSON data
|
||||
types.ts # TypeScript type re-exports
|
||||
utils/ # Utility functions (date formatting, etc.)
|
||||
routes/
|
||||
(web)/ # Web layout group
|
||||
+page.svelte # Main CV page
|
||||
+page.server.ts # Server-side data loading
|
||||
pdf/ # Print-optimized PDF version
|
||||
+page.server.ts
|
||||
+layout.svelte
|
||||
print/ # Print-optimized PDF version
|
||||
admin/ # Sveltia CMS admin panel
|
||||
app.css # Global styles with @theme
|
||||
content/ # YAML content files
|
||||
static/ # Static assets (admin/, fonts)
|
||||
build/ # Output directory (static + cv.pdf)
|
||||
```
|
||||
@@ -138,3 +139,11 @@ This project has no test framework configured. To add tests, consider:
|
||||
- Use `devbox shell` on NixOS for Chrome support
|
||||
- PDF generation gracefully skips if Chrome unavailable (CI)
|
||||
- Static site deployable to any host (outputs to `/build/`)
|
||||
|
||||
## Devbox (NixOS)
|
||||
|
||||
```bash
|
||||
devbox shell # Enter devbox environment
|
||||
devbox run dev # Run dev server via devbox
|
||||
devbox run build # Build via devbox
|
||||
```
|
||||
|
||||
374
package-lock.json
generated
@@ -9,14 +9,17 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"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",
|
||||
"@sveltejs/kit": "^2.51.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"http-server": "^14.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -25,10 +28,12 @@
|
||||
"puppeteer": "^24.37.3",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"svelte-feather-icons": "^4.2.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-devtools-json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -498,6 +503,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/roboto-condensed": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/roboto-condensed/-/roboto-condensed-5.2.8.tgz",
|
||||
"integrity": "sha512-aIZ2kYSoJHkTI4z8x/PRgKX6Zb9TTtSE/u+fUYeiwL+5trP9rhYYEEeNjRttaMqRgoDHcSueArdRZ43wf/i2Kw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/iosevka": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/iosevka/-/iosevka-5.2.5.tgz",
|
||||
@@ -1604,6 +1618,13 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
@@ -1672,6 +1693,50 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -1794,6 +1859,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||
@@ -1871,6 +1966,65 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
@@ -1906,6 +2060,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer/node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -1930,6 +2112,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -2386,6 +2581,39 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||
@@ -2952,6 +3180,19 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -3062,6 +3303,59 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -3288,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",
|
||||
@@ -3672,6 +3975,26 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-feather-icons": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-feather-icons/-/svelte-feather-icons-4.2.0.tgz",
|
||||
"integrity": "sha512-KuMTDrL6sA8aCxBv3RMgmmnnyIaAXaYcmWkmNa2r2Qj70vi+An2T6ZBAdiZr6wjx+a3eZJy+FRseeRkzQFGHPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"svelte": "^3.38.2"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-feather-icons/node_modules/svelte": {
|
||||
"version": "3.59.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz",
|
||||
"integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
@@ -3785,6 +4108,16 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -3812,6 +4145,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@@ -3887,6 +4234,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-devtools-json": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-devtools-json/-/vite-plugin-devtools-json-1.0.0.tgz",
|
||||
"integrity": "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
|
||||
@@ -3928,6 +4288,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
11
package.json
@@ -5,14 +5,16 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && node scripts/generate-pdf.js",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"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",
|
||||
@@ -28,6 +30,7 @@
|
||||
"@sveltejs/kit": "^2.51.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"http-server": "^14.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -36,9 +39,11 @@
|
||||
"puppeteer": "^24.37.3",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"svelte-feather-icons": "^4.2.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-devtools-json": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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, 'joakim-repomaa-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();
|
||||
21
src/app.css
@@ -3,6 +3,7 @@
|
||||
/* Iosevka font - self-hosted via npm */
|
||||
@import '@fontsource/iosevka/400.css';
|
||||
@import '@fontsource/iosevka/700.css';
|
||||
@import '@fontsource-variable/roboto-condensed';
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
@@ -13,18 +14,30 @@ 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 */
|
||||
--font-mono: 'Iosevka', ui-monospace, monospace;
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: 'Roboto Condensed Variable', ui-sans-serif, 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);
|
||||
--color-muted: light-dark(#56565d, #a1a1aa);
|
||||
--color-accent: light-dark(#0e7490, #22d3ee);
|
||||
--color-hot: light-dark(#c2410c, #fb923c);
|
||||
|
||||
/* PDF layout - 4 colors (print-optimized) */
|
||||
--color-pdf-bg: #ffffff;
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="%sveltekit.assets%/icon-192x192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Professional CV - Developer Portfolio" />
|
||||
<meta name="description" content="Developer Portfolio" />
|
||||
<meta name="theme-color" content="#0891b2" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#22d3ee" media="(prefers-color-scheme: dark)" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
%sveltekit.body%
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
9
src/lib/components/BracketLabel.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
}
|
||||
|
||||
let { label }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="text-accent before:content-['['] after:content-[']']">{label}</span>
|
||||
@@ -3,10 +3,14 @@
|
||||
import Section from './Section.svelte';
|
||||
import TimelineItem from './TimelineItem.svelte';
|
||||
|
||||
let { education }: { education: Education[] } = $props();
|
||||
interface Props {
|
||||
education: Education[];
|
||||
}
|
||||
|
||||
let { education }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Section title="education">
|
||||
<Section title="Education">
|
||||
<div class="space-y-6">
|
||||
{#each education as edu}
|
||||
<TimelineItem
|
||||
@@ -15,7 +19,6 @@
|
||||
location={edu.location}
|
||||
startDate={edu.startDate}
|
||||
endDate={edu.endDate}
|
||||
current={edu.current}
|
||||
description={edu.description}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import Section from './Section.svelte';
|
||||
import TimelineItem from './TimelineItem.svelte';
|
||||
|
||||
let { experience }: { experience: Experience[] } = $props();
|
||||
interface Props {
|
||||
experience: Experience[];
|
||||
}
|
||||
|
||||
let { experience }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Section title="experience">
|
||||
<Section title="Experience">
|
||||
<div class="space-y-8">
|
||||
{#each experience as job}
|
||||
<TimelineItem
|
||||
@@ -15,7 +19,6 @@
|
||||
location={job.location}
|
||||
startDate={job.startDate}
|
||||
endDate={job.endDate}
|
||||
current={job.current}
|
||||
description={job.description}
|
||||
tags={job.technologies}
|
||||
/>
|
||||
|
||||
31
src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted">
|
||||
<p class="font-mono">
|
||||
Built with
|
||||
<a
|
||||
href="https://svelte.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:text-accent/80 transition-colors">SvelteKit</a
|
||||
>
|
||||
+
|
||||
<a
|
||||
href="https://sveltiacms.app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:text-accent/80 transition-colors">Sveltia CMS</a
|
||||
>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a href="/admin" class="text-accent hover:text-accent/80 transition-colors">edit</a>
|
||||
<span class="mx-2 text-muted" aria-hidden="true">//</span>
|
||||
<a
|
||||
href="https://git.freun.dev/repomaa/cv"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:text-accent/80 transition-colors">src</a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
@@ -1,19 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Profile } from '$lib/types.js';
|
||||
import BracketLabel from './BracketLabel.svelte';
|
||||
import EncodedEmail from './EncodedEmail.svelte';
|
||||
|
||||
let { profile }: { profile: Profile } = $props();
|
||||
interface Props {
|
||||
profile: Profile;
|
||||
}
|
||||
|
||||
let { profile }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="border-b-2 border-fg/10 pb-8 mb-8">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start gap-6 mb-4">
|
||||
{#if profile.avatar}
|
||||
<img src={profile.avatar} alt={profile.name} class="w-20 h-20 object-cover" />
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold mb-2 tracking-tight text-fg">
|
||||
{profile.name}
|
||||
</h1>
|
||||
<p class="text-xl text-muted mb-4 font-medium">
|
||||
<p class="text-xl text-muted font-medium">
|
||||
{profile.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted leading-relaxed max-w-2xl">
|
||||
{profile.summary}
|
||||
</p>
|
||||
@@ -21,12 +33,12 @@
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<div class="flex items-center gap-2 text-muted hover:text-accent transition-colors">
|
||||
<span class="text-accent">[E]</span>
|
||||
<BracketLabel label="E" />
|
||||
<EncodedEmail email={profile.email} class="hover:text-accent transition-colors" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-muted">
|
||||
<span class="text-accent">[L]</span>
|
||||
<BracketLabel label="L" />
|
||||
{profile.location}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +48,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="text-accent">[G]</span>
|
||||
<BracketLabel label="G" />
|
||||
github.com/{profile.github}
|
||||
</a>
|
||||
|
||||
@@ -47,7 +59,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="text-accent">[W]</span>
|
||||
<BracketLabel label="W" />
|
||||
{profile.website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -4,75 +4,65 @@
|
||||
import PDFSection from './PDFSection.svelte';
|
||||
import PDFTags from './PDFTags.svelte';
|
||||
import PDFTimelineItem from './PDFTimelineItem.svelte';
|
||||
import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons';
|
||||
|
||||
let {
|
||||
profile,
|
||||
experience,
|
||||
education,
|
||||
skills,
|
||||
ownProjects,
|
||||
contributions,
|
||||
}: {
|
||||
interface Props {
|
||||
profile: Profile;
|
||||
experience: Experience[];
|
||||
education: Education[];
|
||||
skills: Skill[];
|
||||
ownProjects: Project[];
|
||||
contributions: Project[];
|
||||
} = $props();
|
||||
}
|
||||
|
||||
let { profile, experience, education, skills, ownProjects, contributions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="font-sans max-w-[210mm] mx-auto px-[20mm] py-[18mm] bg-pdf-bg text-pdf-fg leading-relaxed text-sm"
|
||||
>
|
||||
<div class="font-sans max-w-[210mm] mx-auto bg-pdf-bg text-pdf-fg leading-relaxed text-sm">
|
||||
<!-- Header -->
|
||||
<header class="text-center mb-6 pb-4 border-b-2 border-pdf-fg">
|
||||
<header class="mb-6 pb-4 border-b-2 border-pdf-fg">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-pdf-fg leading-tight m-0">
|
||||
{profile.name}
|
||||
</h1>
|
||||
<p class="text-base font-medium text-pdf-muted mt-1.5 mb-3">{profile.title}</p>
|
||||
</div>
|
||||
<div class="flex justify-center flex-wrap gap-x-6 gap-y-3 text-xs mb-2">
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs text-pdf-accent">
|
||||
{#if profile.email}
|
||||
<a class="inline-flex items-center gap-1.5 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>
|
||||
<a class="inline-flex items-center gap-1.5" href="mailto:{profile.email}">
|
||||
<MailIcon class="w-3.5 h-3.5" />
|
||||
{profile.email}
|
||||
</a>
|
||||
{/if}
|
||||
<span class="inline-flex items-center gap-1.5 text-pdf-muted">
|
||||
<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>
|
||||
<MapPinIcon class="w-3.5 h-3.5" />
|
||||
{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
|
||||
<a
|
||||
href="https://github.com/{profile.github}"
|
||||
class="no-underline inline-flex items-center gap-1.5"
|
||||
>
|
||||
<GithubIcon class="w-3.5 h-3.5" />
|
||||
github.com/{profile.github}
|
||||
</a>
|
||||
{/if}
|
||||
{#if profile.website}
|
||||
<a href={profile.website} class="no-underline"
|
||||
>{profile.website.replace(/^https?:\/\//, '')}</a
|
||||
>
|
||||
<a href={profile.website} class="no-underline inline-flex items-center gap-1.5">
|
||||
<GlobeIcon class="w-3.5 h-3.5" />
|
||||
{profile.website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if profile.profilePicture}
|
||||
<div class="rounded-full p-2 inset-shadow-sm">
|
||||
<img
|
||||
src={profile.profilePicture}
|
||||
alt={profile.name}
|
||||
class="w-26 h-26 rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
let { title, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0">
|
||||
<section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0 break-inside-avoid">
|
||||
<h2 class="text-sm font-bold text-pdf-fg flex items-center uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="text-xs text-pdf-muted">
|
||||
<div class="text-xs text-pdf-muted flex flex-wrap gap-0.5">
|
||||
{#each tags as tag}
|
||||
<span class="first:before:content-[''] before:content-['_•_']">{tag}</span>
|
||||
<span class="first:before:content-[''] before:content-['_•_'] whitespace-nowrap">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block p-4 border border-accent rounded-lg transition-shadow hover:shadow-glow"
|
||||
aria-label={project.name}
|
||||
>
|
||||
<article class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
@@ -21,8 +22,10 @@
|
||||
{project.name}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 text-sm text-hot">
|
||||
<span class="flex items-center gap-1" aria-label="{project.stars} stars">
|
||||
<span class="text-xl">★</span>
|
||||
<span
|
||||
class="flex items-center gap-1 before:content-['★'] before:text-xl"
|
||||
aria-label="{project.stars} stars"
|
||||
>
|
||||
{project.stars}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
}
|
||||
|
||||
let { title, projects, seeAllLink }: Props = $props();
|
||||
$effect(() => console.log({ seeAllLink }));
|
||||
</script>
|
||||
|
||||
{#if projects.length > 0}
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import ProjectList from './ProjectList.svelte';
|
||||
import Section from './Section.svelte';
|
||||
|
||||
let {
|
||||
ownProjects,
|
||||
contributions,
|
||||
username,
|
||||
}: { ownProjects: Project[]; contributions: Project[]; username: string } = $props();
|
||||
interface Props {
|
||||
ownProjects: Project[];
|
||||
contributions: Project[];
|
||||
username: string;
|
||||
}
|
||||
|
||||
let { ownProjects, contributions, username }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Section title="projects">
|
||||
<Section title="Projects">
|
||||
<ProjectList
|
||||
title="Original Projects"
|
||||
projects={ownProjects}
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
</script>
|
||||
|
||||
<section class="pb-8 mb-10 border-b border-fg/20 last:border-b-0" aria-labelledby={headingId}>
|
||||
<h2 id={headingId} class="text-3xl font-bold mb-6 pb-2 text-fg">
|
||||
<span class="text-accent">$></span>
|
||||
<h2
|
||||
id={headingId}
|
||||
class="text-3xl lowercase font-bold mb-6 pb-2 text-fg before:content-['$>_'] before:text-accent"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import Section from './Section.svelte';
|
||||
import Tags from './Tags.svelte';
|
||||
|
||||
let { skills }: { skills: Skill[] } = $props();
|
||||
interface Props {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
let { skills }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Section title="skills">
|
||||
<Section title="Skills">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{#each skills as skillCategory}
|
||||
<div>
|
||||
|
||||
@@ -8,21 +8,11 @@
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current?: boolean;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
location,
|
||||
startDate,
|
||||
endDate,
|
||||
current = false,
|
||||
description,
|
||||
tags = [],
|
||||
}: Props = $props();
|
||||
let { title, subtitle, location, startDate, endDate, description, tags = [] }: Props = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
@@ -41,7 +31,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<time class="text-sm text-muted font-mono whitespace-nowrap">
|
||||
{formatDate(startDate)} — {current || !endDate ? 'Present' : formatDate(endDate)}
|
||||
{formatDate(startDate)} — {!endDate ? 'Present' : formatDate(endDate)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
|
||||
134
src/lib/content/config.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { CmsConfig, FileCollection, Field, EntryCollection } from '@sveltia/cms';
|
||||
import type { Profile, Experience, Education, Skill } from './types';
|
||||
|
||||
type RequiredKeys<T> = keyof {
|
||||
[key in keyof T as undefined extends T[key] ? never : key]: T[key];
|
||||
};
|
||||
|
||||
type StringWidget = 'string' | 'image' | 'datetime' | 'markdown' | 'text';
|
||||
type ListWidget = 'list';
|
||||
|
||||
type Fields<T> = {
|
||||
[key in keyof T]: Field & {
|
||||
name: key;
|
||||
widget: string extends T[key] ? StringWidget : T[key] extends string[] ? ListWidget : never;
|
||||
} & (key extends RequiredKeys<T> ? { required?: true } : { required: false });
|
||||
};
|
||||
|
||||
const profileFields: Fields<Profile> = {
|
||||
name: { name: 'name', label: 'Name', widget: 'string' },
|
||||
title: { name: 'title', label: 'Title', widget: 'string' },
|
||||
email: { name: 'email', label: 'Email', widget: 'string' },
|
||||
phone: { name: 'phone', label: 'Phone', widget: 'string', required: false },
|
||||
location: { name: 'location', label: 'Location', widget: 'string' },
|
||||
website: { name: 'website', label: 'Website', widget: 'string', required: false },
|
||||
github: { name: 'github', label: 'GitHub', widget: 'string' },
|
||||
avatar: { name: 'avatar', label: 'Avatar', widget: 'image', required: false },
|
||||
profilePicture: {
|
||||
name: 'profilePicture',
|
||||
label: 'Profile Picture (for PDF)',
|
||||
widget: 'image',
|
||||
required: false,
|
||||
},
|
||||
summary: { name: 'summary', label: 'Summary', widget: 'text', required: true },
|
||||
};
|
||||
|
||||
const experienceFields: Fields<Experience> = {
|
||||
company: { name: 'company', label: 'Company', widget: 'string' },
|
||||
position: { name: 'position', label: 'Position', widget: 'string' },
|
||||
location: { name: 'location', label: 'Location', widget: 'string', required: false },
|
||||
startDate: { name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
||||
endDate: {
|
||||
name: 'endDate',
|
||||
label: 'End Date',
|
||||
widget: 'datetime',
|
||||
time_format: false,
|
||||
required: false,
|
||||
},
|
||||
description: { name: 'description', label: 'Description', widget: 'markdown' },
|
||||
technologies: {
|
||||
name: 'technologies',
|
||||
label: 'Technologies',
|
||||
widget: 'list',
|
||||
field: { label: 'Technology', widget: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const educationFields: Fields<Education> = {
|
||||
institution: { name: 'institution', label: 'Institution', widget: 'string' },
|
||||
degree: { name: 'degree', label: 'Degree', widget: 'string' },
|
||||
field: { name: 'field', label: 'Field', widget: 'string', required: false },
|
||||
location: { name: 'location', label: 'Location', widget: 'string', required: false },
|
||||
startDate: { name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
||||
endDate: {
|
||||
name: 'endDate',
|
||||
label: 'End Date',
|
||||
widget: 'datetime',
|
||||
time_format: false,
|
||||
required: false,
|
||||
},
|
||||
description: { name: 'description', label: 'Description', widget: 'markdown', required: false },
|
||||
};
|
||||
|
||||
const skillFields: Fields<Skill> = {
|
||||
category: { name: 'category', label: 'Category', widget: 'string' },
|
||||
items: {
|
||||
name: 'items',
|
||||
label: 'Items',
|
||||
widget: 'list',
|
||||
field: { label: 'Item', widget: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const profile: FileCollection = {
|
||||
name: 'profile',
|
||||
label: 'Profile',
|
||||
format: 'json',
|
||||
files: [
|
||||
{
|
||||
name: 'profile',
|
||||
label: 'Profile',
|
||||
file: 'src/lib/content/profile.json',
|
||||
fields: Object.values(profileFields),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const experience: EntryCollection = {
|
||||
name: 'experience',
|
||||
label: 'Experience',
|
||||
format: 'json',
|
||||
folder: 'src/lib/content/experience',
|
||||
fields: Object.values(experienceFields),
|
||||
};
|
||||
|
||||
const education: EntryCollection = {
|
||||
name: 'education',
|
||||
label: 'Education',
|
||||
format: 'json',
|
||||
folder: 'src/lib/content/education',
|
||||
fields: Object.values(educationFields),
|
||||
};
|
||||
|
||||
const skills: EntryCollection = {
|
||||
name: 'skills',
|
||||
label: 'Skills',
|
||||
format: 'json',
|
||||
folder: 'src/lib/content/skills',
|
||||
fields: Object.values(skillFields),
|
||||
};
|
||||
|
||||
const config: CmsConfig = {
|
||||
load_config_file: false,
|
||||
media_folder: 'src/lib/media',
|
||||
backend: {
|
||||
name: 'gitea',
|
||||
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
|
||||
repo: 'repomaa/cv',
|
||||
base_url: 'https://git.freun.dev',
|
||||
api_root: 'https://git.freun.dev/api/v1',
|
||||
},
|
||||
collections: [profile, experience, education, skills],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -5,5 +5,5 @@
|
||||
"location": "Wiesbaden, Germany",
|
||||
"startDate": "2010",
|
||||
"endDate": "2016",
|
||||
"description": "Bachelor thesis: 'Collaborative Content Creation' - built a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux"
|
||||
"description": "Bachelor thesis: 'Collaborative Content Creation' - a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"company": "hundertzehn GmbH",
|
||||
"position": "Senior Software Engineer",
|
||||
"location": "Zürich, Schweiz",
|
||||
"location": "Zürich, Schweiz (remote)",
|
||||
"startDate": "2021",
|
||||
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Develop Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analyze large datasets, evaluate new technologies, integrate external APIs (Paywise), direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.",
|
||||
"endDate": "",
|
||||
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analysis of large datasets, evaluation of new technologies, integration of external APIs, direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.",
|
||||
"technologies": [
|
||||
"Ruby on Rails",
|
||||
"TypeScript",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"location": "Frankfurt, Germany",
|
||||
"startDate": "2014",
|
||||
"endDate": "2021",
|
||||
"description": "Developed software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - experience platform and CMS for CES showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.",
|
||||
"description": "Software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - CMS for CES experience platform showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.",
|
||||
"technologies": [
|
||||
"Crystal",
|
||||
"Ruby on Rails",
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
import type { CVData, Profile, Experience, Education, Skill } from './types.js';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Experience, Education, Skill, Profile } from './types';
|
||||
|
||||
const CONTENT_DIR = join(process.cwd(), 'src', 'lib', 'content');
|
||||
|
||||
function loadJsonFile<T>(filepath: string): T | null {
|
||||
try {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
console.warn(`Could not load ${filepath}:`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadJsonFilesFromDir<T>(dirPath: string): T[] {
|
||||
try {
|
||||
const files = readdirSync(dirPath, { withFileTypes: true });
|
||||
const items: T[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isFile() && file.name.endsWith('.json')) {
|
||||
const item = loadJsonFile<T>(join(dirPath, file.name));
|
||||
if (item) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Could not read directory ${dirPath}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
async function loadJsonFilesFromGlob<T>(
|
||||
modules: Record<string, () => Promise<{ default: T }>>
|
||||
): Promise<T[]> {
|
||||
return Promise.all(
|
||||
Object.entries(modules).map(async ([_, module]) => await module().then((m) => m.default))
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Email(email: string): string {
|
||||
@@ -46,42 +16,50 @@ function decodeBase64Email(email: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProfile(): Profile {
|
||||
const profile = loadJsonFile<Profile>(join(CONTENT_DIR, 'profile.json')) ?? {
|
||||
name: 'Your Name',
|
||||
title: 'Developer',
|
||||
email: 'email@example.com',
|
||||
phone: undefined,
|
||||
location: 'Location',
|
||||
website: undefined,
|
||||
github: 'username',
|
||||
summary: 'A passionate developer.',
|
||||
};
|
||||
export async function loadProfile() {
|
||||
const profile: Profile = await import('$lib/content/profile.json');
|
||||
const media = import.meta.glob<{ default: string }>('$lib/media/*');
|
||||
const profilePictureModule = await (profile.profilePicture
|
||||
? media[`/${profile.profilePicture}`]?.()
|
||||
: undefined);
|
||||
const avatarModule = await (profile.avatar ? media[`/${profile.avatar}`]?.() : undefined);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
email: decodeBase64Email(profile.email),
|
||||
profilePicture: profilePictureModule?.default,
|
||||
avatar: avatarModule?.default,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadExperience(): Experience[] {
|
||||
return loadJsonFilesFromDir<Experience>(join(CONTENT_DIR, 'experience'));
|
||||
export async function loadExperience() {
|
||||
return loadJsonFilesFromGlob(
|
||||
import.meta.glob<{ default: Experience }>('$lib/content/experience/*.json')
|
||||
);
|
||||
}
|
||||
|
||||
export function loadEducation(): Education[] {
|
||||
return loadJsonFilesFromDir<Education>(join(CONTENT_DIR, 'education'));
|
||||
export function loadEducation() {
|
||||
return loadJsonFilesFromGlob(
|
||||
import.meta.glob<{ default: Education }>('$lib/content/education/*.json')
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSkills(): Skill[] {
|
||||
return loadJsonFilesFromDir<Skill>(join(CONTENT_DIR, 'skills'));
|
||||
export function loadSkills() {
|
||||
return loadJsonFilesFromGlob(import.meta.glob<{ default: Skill }>('$lib/content/skills/*.json'));
|
||||
}
|
||||
|
||||
export function loadAllContent(): CVData {
|
||||
export async function loadAllContent() {
|
||||
const [profile, experience, education, skills] = await Promise.all([
|
||||
loadProfile(),
|
||||
loadExperience(),
|
||||
loadEducation(),
|
||||
loadSkills(),
|
||||
]);
|
||||
|
||||
return {
|
||||
profile: loadProfile(),
|
||||
experience: loadExperience(),
|
||||
education: loadEducation(),
|
||||
skills: loadSkills(),
|
||||
projects: [],
|
||||
profile,
|
||||
experience,
|
||||
education,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,5 +5,7 @@
|
||||
"location": "Espoo - Finnland",
|
||||
"website": "https://joakim.repomaa.com",
|
||||
"github": "repomaa",
|
||||
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, external API partnerships, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
|
||||
"avatar": "src/lib/media/avatar.png",
|
||||
"profilePicture": "src/lib/media/profile-picture.jpg",
|
||||
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, third-party API integrations, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Content data types - clean interface definitions
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -9,6 +7,8 @@ export interface Profile {
|
||||
website?: string;
|
||||
github: string;
|
||||
summary: string;
|
||||
avatar?: string;
|
||||
profilePicture?: string;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
@@ -17,7 +17,6 @@ export interface Experience {
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current: boolean;
|
||||
description: string;
|
||||
technologies: string[];
|
||||
}
|
||||
@@ -29,7 +28,6 @@ export interface Education {
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { filter, map, pipe, sortBy, take, uniqueBy } from 'remeda';
|
||||
import type { Project } from './content/types.js';
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_RETRY_DELAY = 1000;
|
||||
const MAX_RETRY_DELAY = 10000;
|
||||
const RATE_LIMIT_DELAY = 60000;
|
||||
|
||||
interface SearchIssue {
|
||||
const EXCLUDED_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']);
|
||||
|
||||
interface SearchItem {
|
||||
repository_url: string;
|
||||
pull_request?: {
|
||||
merged_at: string | null;
|
||||
url: string;
|
||||
};
|
||||
pull_request?: { merged_at: string | null };
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
items: SearchIssue[];
|
||||
items: SearchItem[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
@@ -25,40 +29,22 @@ interface RepoInfo {
|
||||
language: string | null;
|
||||
}
|
||||
|
||||
// Retry configuration
|
||||
const MAX_RETRIES = 3;
|
||||
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
||||
const MAX_RETRY_DELAY = 10000; // 10 seconds
|
||||
const RATE_LIMIT_DELAY = 60000; // 1 minute for rate limit (403/429)
|
||||
|
||||
// Owners to exclude from contributed repos
|
||||
const EXCLUDED_REPO_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']);
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
};
|
||||
|
||||
// Use GitHub token if available (for higher rate limits during build)
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Exponential backoff retry for fetch
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<Response> {
|
||||
async function fetchWithRetry(url: string, retryCount = 0): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers: getHeaders() });
|
||||
const response = await fetch(url, { headers: getHeaders() });
|
||||
|
||||
if (response.status === 429) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
if (response.status === 429 && retryCount < MAX_RETRIES) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
@@ -67,169 +53,143 @@ async function fetchWithRetry(
|
||||
console.warn(
|
||||
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
|
||||
);
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(url, retryCount + 1);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network errors (timeout, connection refused, etc.)
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
||||
console.warn(
|
||||
`Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`,
|
||||
error
|
||||
);
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(url, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
|
||||
try {
|
||||
// Use search API to filter non-forks and sort by stars
|
||||
const response = await fetchWithRetry(
|
||||
`${GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(`user:${username} fork:false`)}&sort=stars&order=desc&per_page=100`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
function handleApiError(response: Response, context: string): null {
|
||||
if (response.status === 403 || response.status === 429) {
|
||||
console.warn(
|
||||
`GitHub API rate limit exceeded for user repos. Set GITHUB_TOKEN env var for higher limits.`
|
||||
`GitHub API rate limit exceeded for ${context}. Set GITHUB_TOKEN env var for higher limits.`
|
||||
);
|
||||
} else {
|
||||
console.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchData = await response.json();
|
||||
|
||||
return searchData.items.map(
|
||||
(repo: {
|
||||
function mapRepoToProject(repo: {
|
||||
name: string;
|
||||
full_name?: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
language: string | null;
|
||||
pushed_at: string;
|
||||
}) => ({
|
||||
name: repo.name,
|
||||
}): Project {
|
||||
return {
|
||||
name: repo.full_name ?? repo.name,
|
||||
description: repo.description ?? '',
|
||||
url: repo.html_url,
|
||||
stars: repo.stargazers_count,
|
||||
forks: repo.forks_count,
|
||||
language: repo.language ?? undefined,
|
||||
isFork: false,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
|
||||
try {
|
||||
const query = encodeURIComponent(`user:${username} fork:false`);
|
||||
const response = await fetchWithRetry(
|
||||
`${GITHUB_API_BASE}/search/repositories?q=${query}&sort=stars&order=desc&per_page=6`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
handleApiError(response, 'user repos');
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.items.map(mapRepoToProject);
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub projects:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchContributedRepos(username: string): Promise<Project[]> {
|
||||
try {
|
||||
// Search for merged PRs by this user
|
||||
const searchResponse = await fetchWithRetry(
|
||||
`${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(`type:pr author:${username} is:merged`)}&per_page=100`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
if (searchResponse.status === 403 || searchResponse.status === 429) {
|
||||
console.warn(
|
||||
`GitHub Search API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits.`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`GitHub Search API error: ${searchResponse.status} ${searchResponse.statusText}`
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchData: SearchResponse = await searchResponse.json();
|
||||
|
||||
if (searchData.total_count === 0 || !searchData.items || searchData.items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract unique repositories from closed PRs
|
||||
const repoData = new Map<string, { owner: string; name: string; stars: number }>();
|
||||
for (const item of searchData.items) {
|
||||
if (item.pull_request?.merged_at && item.repository_url) {
|
||||
const repoUrl = item.repository_url;
|
||||
if (!repoData.has(repoUrl)) {
|
||||
// Parse owner and repo name from API URL: https://api.github.com/repos/owner/name
|
||||
function getRepoOwner(repoUrl: string): string | null {
|
||||
const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/);
|
||||
if (match) {
|
||||
const [, owner, name] = match;
|
||||
// Skip repos from excluded owners
|
||||
if (!EXCLUDED_REPO_OWNERS.has(owner)) {
|
||||
repoData.set(repoUrl, { owner, name, stars: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
if (repoData.size === 0) {
|
||||
return [];
|
||||
}
|
||||
function isNotExcluded(item: SearchItem): boolean {
|
||||
const owner = getRepoOwner(item.repository_url);
|
||||
return owner !== null && !EXCLUDED_OWNERS.has(owner);
|
||||
}
|
||||
|
||||
const repos = await Promise.all(
|
||||
[...repoData.entries()].map(async ([repoUrl, data]) => {
|
||||
async function fetchRepoAsProject(repoUrl: string, username: string): Promise<Project | null> {
|
||||
try {
|
||||
const repoResponse = await fetchWithRetry(repoUrl, {});
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
console.warn(`Could not fetch repo ${repoUrl}: ${repoResponse.status}`);
|
||||
const response = await fetchWithRetry(repoUrl);
|
||||
if (!response.ok) {
|
||||
console.warn(`Could not fetch repo ${repoUrl}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const repo: RepoInfo = await repoResponse.json();
|
||||
const repo: RepoInfo = await response.json();
|
||||
const [owner, name] = repo.full_name.split('/');
|
||||
const prsUrl = `https://github.com/${owner}/${name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`;
|
||||
|
||||
// Create URL to user's closed PRs in this repo
|
||||
const prsUrl = `https://github.com/${data.owner}/${data.name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`;
|
||||
|
||||
return {
|
||||
name: repo.full_name,
|
||||
description: repo.description ?? '',
|
||||
url: prsUrl,
|
||||
stars: repo.stargazers_count,
|
||||
forks: repo.forks_count,
|
||||
language: repo.language ?? undefined,
|
||||
isFork: false,
|
||||
};
|
||||
return { ...mapRepoToProject(repo), url: prsUrl };
|
||||
} catch (error) {
|
||||
console.warn(`Error fetching repo details for ${repoUrl}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchContributedRepos(username: string): Promise<Project[]> {
|
||||
try {
|
||||
const query = encodeURIComponent(`type:pr author:${username} is:merged`);
|
||||
const response = await fetchWithRetry(
|
||||
`${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100`
|
||||
);
|
||||
|
||||
// Sort by stars descending and take top 5
|
||||
return repos
|
||||
.filter((repo) => repo !== null)
|
||||
.sort((a, b) => b.stars - a.stars)
|
||||
.slice(0, 5);
|
||||
if (!response.ok) {
|
||||
handleApiError(response, 'search');
|
||||
return [];
|
||||
}
|
||||
|
||||
const { total_count, items }: SearchResponse = await response.json();
|
||||
if (!total_count || !items?.length) return [];
|
||||
|
||||
const repoUrls = pipe(
|
||||
items,
|
||||
filter(isNotExcluded),
|
||||
uniqueBy((item) => item.repository_url),
|
||||
map((item) => item.repository_url)
|
||||
);
|
||||
|
||||
if (repoUrls.length === 0) return [];
|
||||
|
||||
const projects = await Promise.all(repoUrls.map((url) => fetchRepoAsProject(url, username)));
|
||||
|
||||
return pipe(
|
||||
projects,
|
||||
filter((p): p is Project => p !== null),
|
||||
sortBy([(p) => p.stars, 'desc']),
|
||||
take(6)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching contributed repos:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get top projects from array
|
||||
export function getTopProjects(projects: Project[], limit: number): Project[] {
|
||||
return projects.slice(0, limit);
|
||||
}
|
||||
|
||||
BIN
src/lib/media/avatar.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/lib/media/profile-picture.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -1,11 +1,8 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
import '../app.css';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="font-mono bg-background text-foreground dark:bg-background-dark dark:text-shadow-foreground-dark"
|
||||
>
|
||||
<div class="font-mono bg-background text-foreground">
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute">
|
||||
Skip to main content
|
||||
</a>
|
||||
@@ -4,6 +4,7 @@
|
||||
import Education from '$lib/components/Education.svelte';
|
||||
import Skills from '$lib/components/Skills.svelte';
|
||||
import Projects from '$lib/components/Projects.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
@@ -33,7 +34,7 @@
|
||||
<a
|
||||
href="/joakim-repomaa-cv.pdf"
|
||||
download
|
||||
class="no-print fixed top-0 right-0 w-24 h-24 z-50 group"
|
||||
class="fixed top-0 right-0 w-24 h-24 z-50 group"
|
||||
style="clip-path: polygon(0 0, 100% 0, 100% 100%);"
|
||||
data-sveltekit-preload-data="off"
|
||||
aria-label="Download PDF"
|
||||
@@ -48,24 +49,5 @@
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted">
|
||||
<p class="font-mono">
|
||||
<span class="text-accent">$</span>
|
||||
Built with SvelteKit + Sveltia CMS
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a href="/admin/index.html" class="text-accent hover:text-accent/80 transition-colors">
|
||||
Edit Content →
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* Ensure proper print styling */
|
||||
@media print {
|
||||
main {
|
||||
background: white !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/routes/(content)/+layout.server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { loadAllContent } from '$lib/content/loader';
|
||||
import { fetchContributedRepos, fetchGitHubProjects } from '$lib/github';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
const content = await loadAllContent();
|
||||
|
||||
const [ownProjects, contributions] = await Promise.all([
|
||||
fetchGitHubProjects(content.profile.github),
|
||||
fetchContributedRepos(content.profile.github),
|
||||
]);
|
||||
|
||||
return {
|
||||
profile: content.profile,
|
||||
experience: content.experience,
|
||||
education: content.education,
|
||||
skills: content.skills,
|
||||
ownProjects,
|
||||
contributions,
|
||||
};
|
||||
};
|
||||
6
src/routes/(content)/+layout.svelte
Normal file
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
import '../../app.css';
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
13
src/routes/(content)/print/+layout.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<main class="min-h-screen bg-pdf-bg text-pdf-fg">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
}
|
||||
</style>
|
||||
18
src/routes/(content)/print/+page.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PDFContent from '$lib/components/PDFContent.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.name} — CV</title>
|
||||
</svelte:head>
|
||||
|
||||
<PDFContent
|
||||
profile={data.profile}
|
||||
experience={data.experience}
|
||||
education={data.education}
|
||||
skills={data.skills}
|
||||
ownProjects={data.ownProjects}
|
||||
contributions={data.contributions}
|
||||
/>
|
||||
2
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const trailingSlash = 'always';
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const ssr = false;
|
||||
@@ -1,102 +1,10 @@
|
||||
<script lang="ts">
|
||||
import CMS from '@sveltia/cms';
|
||||
import { onMount } from 'svelte';
|
||||
import config from '$lib/content/config';
|
||||
|
||||
CMS.init({
|
||||
config: {
|
||||
load_config_file: false,
|
||||
media_folder: 'src/lib/media',
|
||||
backend: {
|
||||
name: 'gitea',
|
||||
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
|
||||
repo: 'repomaa/cv',
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
name: 'profile',
|
||||
label: 'Profile',
|
||||
format: 'json',
|
||||
files: [
|
||||
{
|
||||
name: 'profile',
|
||||
label: 'Profile',
|
||||
file: 'src/lib/content/profile.json',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', widget: 'string' },
|
||||
{ name: 'title', label: 'Title', widget: 'string' },
|
||||
{ name: 'email', label: 'Email', widget: 'string' },
|
||||
{ name: 'phone', label: 'Phone', widget: 'string', required: false },
|
||||
{ name: 'location', label: 'Location', widget: 'string' },
|
||||
{ name: 'website', label: 'Website', widget: 'string', required: false },
|
||||
{ name: 'github', label: 'GitHub', widget: 'string' },
|
||||
{ name: '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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
onMount(async () => {
|
||||
const CMS = await import('@sveltia/cms');
|
||||
CMS.init({ config });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,79 +1,130 @@
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import type { LaunchOptions } from 'puppeteer';
|
||||
import type { LaunchOptions, PDFOptions } from 'puppeteer';
|
||||
import { dev } from '$app/environment';
|
||||
import puppeteer from 'puppeteer';
|
||||
import * as cheerio from 'cheerio';
|
||||
import path from 'path';
|
||||
import { opendir, writeFile, mkdtemp, copyFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
// This is a dynamic endpoint - not prerenderable
|
||||
export const prerender = false;
|
||||
export const prerender = true;
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
// PDF generation configuration
|
||||
const PDF_CONFIG = {
|
||||
format: 'A4' as const,
|
||||
const PDF_CONFIG: PDFOptions = {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
top: '15mm',
|
||||
right: '20mm',
|
||||
bottom: '20mm',
|
||||
bottom: '15mm',
|
||||
left: '20mm',
|
||||
},
|
||||
};
|
||||
|
||||
// Browser launch options for different environments
|
||||
const getLaunchOptions = (): LaunchOptions => {
|
||||
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
const browserPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
const browser = process.env.PUPPETEER_BROWSER || 'chrome';
|
||||
const options: LaunchOptions = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--disable-gpu',
|
||||
],
|
||||
browser: browser as 'chrome' | 'firefox',
|
||||
};
|
||||
if (chromePath) {
|
||||
options.executablePath = chromePath;
|
||||
if (browserPath) {
|
||||
options.executablePath = browserPath;
|
||||
}
|
||||
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' },
|
||||
/**
|
||||
* Prepares the print page HTML and returns the URL to use for PDF generation.
|
||||
* During dev server, returns the dev server URL directly.
|
||||
* During build time, copies assets to temp dir and returns file:// URL.
|
||||
*/
|
||||
const preparePrintPageUrl = async (
|
||||
requestUrl: URL,
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<string> => {
|
||||
// In dev mode, just use the dev server URL
|
||||
if (dev) {
|
||||
const devUrl = new URL('/print/', requestUrl);
|
||||
return devUrl.toString();
|
||||
}
|
||||
|
||||
// Build time: prepare files in temp directory
|
||||
const tmpDir = await mkdtemp(path.join(tmpdir(), 'cv-pdf-genration-'));
|
||||
const tmpFile = (url: string) => {
|
||||
const filename = path.basename(url);
|
||||
const tempFile = path.join(tmpDir, filename);
|
||||
return tempFile;
|
||||
};
|
||||
|
||||
const printResponse = await fetch('/print/');
|
||||
const html = await printResponse.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const fileDownloads: Record<string, string> = {};
|
||||
|
||||
$('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
|
||||
if (el.tagName === 'link') {
|
||||
const href = $(el).attr('href');
|
||||
if (href) {
|
||||
const tempFile = (fileDownloads[href] ||= tmpFile(href));
|
||||
$(el).attr('href', `file://${tempFile}`);
|
||||
}
|
||||
} else {
|
||||
const src = $(el).attr('src');
|
||||
if (src) {
|
||||
const tempFile = (fileDownloads[src] ||= tmpFile(src));
|
||||
$(el).attr('src', `file://${tempFile}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('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));
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the base URL for the current request
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
const pdfUrl = `${baseUrl}/print/`;
|
||||
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(pdfUrl, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: 120000, // Increased from 30s to 120s to handle slow GitHub API calls
|
||||
});
|
||||
|
||||
await page.goto(pageUrl, { waitUntil: 'networkidle2' });
|
||||
// Wait for fonts to load
|
||||
await page.evaluateHandle('document.fonts.ready');
|
||||
|
||||
@@ -84,7 +135,7 @@ export const GET: RequestHandler = async ({ url }: { url: URL }) => {
|
||||
await browser.close();
|
||||
|
||||
// Create Blob from buffer for Response
|
||||
const pdfBlob = new Blob([pdfBuffer as unknown as ArrayBuffer], { type: 'application/pdf' });
|
||||
const pdfBlob = new Blob([Buffer.from(pdfBuffer).buffer], { type: 'application/pdf' });
|
||||
|
||||
// Return PDF with appropriate headers
|
||||
return new Response(pdfBlob, {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import PDFContent from '$lib/components/PDFContent.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.name} — CV (Print Version)</title>
|
||||
<meta name="description" content="Printable CV for {data.profile.name}" />
|
||||
|
||||
<!-- Print-specific styles -->
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Explicitly hide skip link in PDF */
|
||||
.skip-link,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<main class="min-h-screen bg-pdf-bg">
|
||||
<PDFContent
|
||||
profile={data.profile}
|
||||
experience={data.experience}
|
||||
education={data.education}
|
||||
skills={data.skills}
|
||||
ownProjects={data.ownProjects}
|
||||
contributions={data.contributions}
|
||||
/>
|
||||
</main>
|
||||
BIN
static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 70 B |
BIN
static/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,10 +1,9 @@
|
||||
image: alpine
|
||||
command: |
|
||||
apk add --no-cache curl jq unzip &&
|
||||
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/repomaa/cv/actions/artifacts?per_page=1" | \
|
||||
jq -r '.artifacts[0].archive_download_url' | \
|
||||
xargs -I {} curl -s -L -H "Authorization: token $GITHUB_TOKEN" -o artifact.zip {} &&
|
||||
curl -s 'https://git.freun.dev/api/v1/repos/repomaa/cv/actions/artifacts?name=build' | \
|
||||
jq -r '.artifacts[-1].archive_download_url' | \
|
||||
xargs -I {} curl -s -L -o artifact.zip {} &&
|
||||
unzip -o artifact.zip -d public
|
||||
public: public
|
||||
domains:
|
||||
|
||||
@@ -5,26 +5,7 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
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;
|
||||
},
|
||||
},
|
||||
adapter: adapter({}),
|
||||
alias: {
|
||||
$lib: './src/lib',
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import devtoolsJson from 'vite-plugin-devtools-json';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
@@ -10,12 +11,8 @@ export default defineConfig(({ mode }) => {
|
||||
process.env = { ...process.env, ...env };
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||
css: { transformer: 'lightningcss' },
|
||||
build: { target: 'esnext' },
|
||||
};
|
||||
});
|
||||
|
||||