Back to projects
Exhibition of paintings as a portfolio representation

title: Portfolio date: 2026-09-23T12:00:00 excerpt: A multilingual portfolio and blog powered by a git-based CMS and a fully static content-rendering pipeline. banner: /uploads/Gemini_Generated_Image_uqebh2uqebh2uqeb.jpg bannerAlt: A hand-drawn sketch on a napkin reading "The Art of Programming", surrounded by drawings of a binary tree, pseudocode and logic gates bigBanner: /uploads/Gemini_Generated_Image_uqebh2uqebh2uqeb.jpg bigBannerAlt: A hand-drawn sketch on a napkin reading "The Art of Programming", surrounded by drawings of a binary tree, pseudocode and logic gates github_link: ''

🗂️ Portfolio — the site you're reading right now

A multilingual portfolio and technical blog in SvelteKit, edited like a code repository — because it is one.

SvelteKitTypeScriptCloudflarei18nTests


What this is

This is the site you're reading right now — my portfolio and technical blog, built from scratch in SvelteKit (Svelte 5, runes mode). It's deliberately not another "business card" listing skills; it's a place for in-depth, considered write-ups of my projects (this one included) and notes on process — which is what sets it apart from repos like AI Log Assistant, which exist to show code, while this site exists to show how I think about it.

Content — posts, project write-ups, work experience, page copy — doesn't live in a database; it's Markdown/YAML files version-controlled alongside the code, in the same repository. I edit it through a built-in Sveltia CMS panel wired directly to GitHub — a commit from the panel is a commit in the repo is a redeploy.

Architecture

Request handling — SSR on Cloudflare Workers, with locale resolution at the middleware layer:

flowchart TB
    Visitor((Visitor)) -->|"GET /projects/portfolio"| Hooks

    subgraph Worker["Cloudflare Worker — adapter-cloudflare"]
        Hooks["hooks.server.ts<br/>paraglideMiddleware → locale"]
        Reroute["hooks.ts: reroute()<br/>deLocalizeUrl"]
        Load["+page.server.ts<br/>load()"]
        Hooks --> Reroute --> Load
    end

    Load --> Content["contentService.ts"]
    Content --> HTML["rendered HTML"]
    HTML --> Client["hydration in the browser"]
    Client -->|"lazy import"| MermaidJS["mermaid.js renders diagrams<br/>(including this one)"]

    style Worker fill:#f38020,color:#fff,stroke:#00000000

Content authoring — from a commit in the CMS panel to Markdown rendered in the browser:

flowchart LR
    Editor["Sveltia CMS<br/>/portfolio-content-management-system"] -->|commit| Repo[("GitHub<br/>content/*.md, *.yml")]
    Repo -->|"push → redeploy"| Glob["import.meta.glob<br/>(eager, build-time)"]
    Glob --> GM["gray-matter<br/>frontmatter + content"]
    GM --> Marked["marked + custom renderer"]
    Marked -->|"code block"| HLJS["highlight.js"]
    Marked -->|"mermaid-lang block"| Tag["&lt;pre class='mermaid'&gt;"]

All content is pulled into the bundle via import.meta.glob in eager mode, so the site never queries any database at runtime — it's plain files, parsed once, at build time.

Key decisions

A few choices I consider more valuable than the code itself:

  • Dropping the custom backend. Content was originally meant to be served by a separate FastAPI module. After building a first version of the CMS, I concluded there was no good reason to keep maintaining my own API for CRUD-ing markdown, when Git is already a database with full change history — Sveltia CMS does exactly the same thing, committing straight to the repo, without an extra service to host, monitor and secure. The best code is often the code you don't write.
  • No public comments. A deliberate decision, not an oversight — the site is meant to be a safe space for publishing still-unpolished thoughts, without the pressure of public criticism under every post.
  • Static content, dynamic routing. Content is fully static (bundled in), but locale routing and detail pages are server-rendered — a trade-off between the simplicity of prerendering and the need for correct Content-Language handling and locale-aware redirects.

Multilingual support and design system

The site runs in two languages (pl as default, en) via Paraglide (inlang), with URL prefixes (/pl/..., /en/...) instead of cookies — easier to index and debug than a language kept in session state.

Instead of Tailwind or a ready-made UI kit, the light/dark theme is built on its own set of CSS tokens based on color-mix() in oklab space — dark-mode background and text colors are derived from the same base variables, rather than duplicated as a separate palette:

--neutral-lightest: color-mix(in oklab, var(--neutral) 12%, white 88%);
--neutral-darkest: color-mix(in oklab, var(--neutral) 28%, black 72%);

[data-theme='dark'] {
	--surface: var(--neutral-darkest);
	--ink: var(--neutral-lightest);
}

Breakpoints are defined once, in breakpoints.css, and exposed to CSS via postcss-custom-media — a single source of truth instead of magic numbers scattered across components.

Tests

The site's code is tested, even though it's "just a portfolio" — I treat that as a discipline exercise, not a formality:

  • Vitest, split into two profiles: Svelte components running in a real browser (@vitest/browser-playwright, headless Chromium), and unit/server tests running in a Node environment.
  • Playwright for end-to-end navigation tests.
  • Accessibility tests (including aria-label on the hamburger menu) written alongside the components, not bolted on afterwards.

What went well

  • From an empty repo to a deployed, bilingual, tested site with its own CMS — solo, in about three weeks.
  • Dropping the plan for a custom backend in favor of a git-based CMS meaningfully shrank the maintenance surface without losing editing convenience.
  • The Markdown → marked → highlight.js / mermaid pipeline (the very one rendering this text) delivered real, "blog-grade" technical writing quality with zero server infrastructure.
  • The color-token system built on color-mix/oklab turned out to be far easier to maintain than a separate dark-mode palette.
  • My first solo desktop-to-mobile layout transformation — done from the ground up, with no CSS framework, just custom breakpoints and tokens.

What I learned

The biggest challenge wasn't any single feature — it was the interaction between i18n and server-side rendering. After switching from adapter-static to adapter-cloudflare (needed so that locale-dependent routing could be fully dynamic rather than just prerendered), routes with a language prefix started looping on redirects. The commit history is an honest record of how I worked through it — not a flash of insight, but methodically adding logging at every layer of the chain (handle → paraglideMiddleware → reroute), until I could narrow the problem down to a conflict between a leftover export const prerender and dynamic locale resolution under SSR, plus an unnecessary cookie strategy in paraglideVitePlugin that introduced a second, competing source of truth for the language. Removing both fixed it.

The lesson I'm keeping for next time: the adapter and i18n strategy need to be settled before the routing grows, not mid-flight — changing it under load cost more than spending half a day on it upfront would have.

What's next

The site will keep growing naturally as I add more project write-ups and posts through the CMS.

Tech stack

Frontend: SvelteKit 2, Svelte 5 (runes), TypeScript, Vite Content: Markdown + YAML in the repo, gray-matter, marked, highlight.js, mermaid i18n: Paraglide (inlang), URL-based routing (pl / en) CMS: Sveltia CMS (git-based, backend: GitHub) Tests: Vitest (@vitest/browser-playwright + Node), Playwright Infrastructure: Cloudflare Workers, Wrangler, Bun, Docker (dev)