commit c8df66d3da80d0966d5ce62059b6af4c4dede2ce Author: Harlekin Date: Sun Oct 25 16:02:12 2026 +0100 Personal Website, vers. 1 (pushed far to late) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b06fce8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +node_modules +dist +cache +test +.env +*.log diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e385e69 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Öffentliche Adresse, wird für Canonical-Links, Sitemap und OpenGraph benutzt +SITE_URL=https://lux.harlelux.de + +# Docker-Netzwerk des Reverse Proxy (Nginx Proxy Manager: meist nginx_default) +PROXY_NETWORK=nginx_default + +# SMTP für das Kontaktformular. Ohne SMTP_HOST wird statt des Formulars nur die +# E-Mail-Adresse aus content/site.yml angezeigt. +SMTP_HOST= +SMTP_PORT=587 +# true bei Port 465, sonst false (STARTTLS) +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +MAIL_FROM="Website " +MAIL_TO= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..676d040 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +cache/ +.env +*.log +.DS_Store diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97eab79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build && npm prune --omit=dev + +FROM node:24-alpine +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 + +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json ./ +COPY src ./src +COPY locales ./locales +COPY public ./public +COPY content ./content +RUN mkdir cache && chown node:node cache + +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1 + +CMD ["node", "src/server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2e6cf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lux + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..97b33bd --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# lux.harlelux.de + +Meine persönliche Website mit Portfolio. Node.js und Express, die Seiten werden mit Nunjucks auf dem Server gerendert. Inhalte liegen als YAML und Markdown in `content/`, ein CMS gibt es nicht. + +## Entwicklung + +Node 24 (siehe `.nvmrc`). + +```sh +npm install +npm run dev # http://localhost:3000, baut CSS/JS bei Änderungen neu +npm test +``` + +Im Dev-Modus sind Einträge mit `draft: true` sichtbar und bekommen ein Entwurf-Badge. Ohne SMTP-Konfiguration landen Mails aus dem Kontaktformular in der Konsole. + +## Inhalte + +``` +content/ + site.yml Name, Pronomen, Links, Skills, "Gerade dran" + home.yml Texte und Kacheln der Startseite + journey.yml Stationen für den Werdegang + homelab.yml Hardware und Dienste + pages/ Werdegang, Homelab, Datenschutz als .de.md / .en.md + projects// ein Ordner pro Projekt +``` + +Texte, die in beiden Sprachen gleich sind, stehen einfach als String. Sonst: + +```yaml +title: + de: Videoschnitt + en: Video editing +``` + +### Neues Projekt + +`content/projects/mein-projekt/meta.yml`: + +```yaml +date: 2026-09 # Jahr, Monat oder Tag +tags: [homelab, docker] +featured: true # auf der Startseite zeigen +draft: true # online erst sichtbar, wenn das weg ist +cover: cover.jpg +video: https://youtu.be/... # oder eine Datei aus dem Ordner, z.B. clip.mp4 +gallery: + - screenshot.png + - src: rack.jpg + caption: + de: Das Rack + en: The rack +links: + - label: Repo + url: https://git.harlelux.de/Harlekin/... +``` + +Dazu `de.md` und `en.md` mit `title` und `summary` im Frontmatter und dem Text darunter. Fehlt eine Sprache, wird die andere angezeigt. Bilder liegen im selben Ordner und werden relativ eingebunden (`![Beschreibung](bild.jpg)`), die Seite erzeugt automatisch kleinere WebP-Versionen. + +## Deployment + +Läuft als Docker-Stack in Dockge hinter Nginx Proxy Manager. + +```sh +git clone https://git.harlelux.de/Harlekin/Personal-Website.git /opt/stacks/personal-website +cd /opt/stacks/personal-website +cp .env.example .env # SMTP usw. eintragen +docker compose up -d --build +``` + +Im Nginx Proxy Manager einen Proxy Host für `lux.harlelux.de` auf `personal-website:3000` anlegen, mit Let's-Encrypt-Zertifikat und Force SSL. Der Container hängt im Netzwerk `nginx_default`, ein anderer Name lässt sich über `PROXY_NETWORK` in der `.env` setzen. + +Updates: + +- **Nur Inhalte geändert:** `git pull` reicht. `content/` ist in den Container gemountet und wird automatisch neu geladen. +- **Code geändert:** `git pull` und `docker compose up -d --build`. + +Neu laden lässt sich der Inhalt auch von Hand mit `docker kill -s HUP personal-website`. + +## Lizenz + +Der Code steht unter der MIT-Lizenz (siehe `LICENSE`). Texte und Bilder in `content/` sind davon ausgenommen. diff --git a/client/css/base.css b/client/css/base.css new file mode 100644 index 0000000..4d4255c --- /dev/null +++ b/client/css/base.css @@ -0,0 +1,173 @@ +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } + + body, + h1, + h2, + h3, + h4, + p, + figure, + blockquote, + dl, + dd { + margin: 0; + } + + img, + svg, + video, + canvas { + display: block; + max-width: 100%; + } + + img, + video { + height: auto; + } + + input, + button, + textarea, + select { + font: inherit; + color: inherit; + } + + button { + cursor: pointer; + } + + [hidden] { + display: none !important; + } +} + +@layer base { + html { + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + font-size: 100%; + line-height: 1.6; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-padding-top: calc(var(--header-h) + 1rem); + hanging-punctuation: first; + } + + @media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } + } + + body { + min-height: 100vh; + min-height: 100svh; + display: flex; + flex-direction: column; + background: var(--bg); + font-size: var(--step-0); + overflow-x: clip; + } + + main { + flex: 1; + padding-bottom: var(--section); + } + + main:focus { + outline: none; + } + + h1, + h2, + h3, + h4 { + font-family: var(--font-display); + font-weight: 650; + line-height: 1.05; + letter-spacing: -0.025em; + text-wrap: balance; + overflow-wrap: break-word; + } + + p, + li { + text-wrap: pretty; + } + + a { + color: inherit; + text-underline-offset: 0.2em; + text-decoration-thickness: 1px; + } + + :focus-visible { + outline: 2px solid var(--pink-text); + outline-offset: 3px; + border-radius: 4px; + } + + ::selection { + background: var(--pink); + color: var(--bg); + } + + .icon { + width: 1.15em; + height: 1.15em; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + + .icon--play { + fill: currentColor; + stroke: none; + } +} + +@layer utilities { + .visually-hidden { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + + .skip-link { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 100; + padding: 0.75rem 1rem; + border-radius: var(--radius-s); + background: var(--text); + color: var(--bg); + font-weight: 500; + text-decoration: none; + translate: 0 -200%; + transition: translate 0.2s; + + &:focus { + translate: none; + } + } +} diff --git a/client/css/components.css b/client/css/components.css new file mode 100644 index 0000000..873560a --- /dev/null +++ b/client/css/components.css @@ -0,0 +1,740 @@ +@layer components { + .btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + min-height: 3rem; + padding-inline: 1.4rem; + border: 1px solid transparent; + border-radius: 99px; + font-size: 0.98rem; + font-weight: 500; + line-height: 1.2; + text-decoration: none; + white-space: nowrap; + transition: + background-color 0.25s, + border-color 0.25s, + color 0.25s, + translate 0.3s var(--ease-out); + + & .icon { + transition: translate 0.3s var(--ease-out); + } + + &:hover .icon--arrow-right { + translate: 3px 0; + } + } + + .btn--primary { + background: var(--text); + color: var(--bg); + + &:hover { + background: var(--pink-text); + } + } + + .btn--ghost { + border-color: var(--line-strong); + background: rgb(12 10 18 / 0.35); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + + &:hover { + border-color: var(--text); + } + } + + .badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 99px; + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .badge--draft { + border: 1px dashed rgb(255 141 180 / 0.6); + background: rgb(255 118 164 / 0.12); + color: var(--pink-text); + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; + } + + .tag { + display: inline-block; + padding: 0.2rem 0.65rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--muted); + text-decoration: none; + } + + a.tag { + transition: color 0.2s, border-color 0.2s; + + &:hover { + border-color: var(--pink-text); + color: var(--text); + } + } + + .note { + margin-top: 1.5rem; + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--pink-text); + } + + .empty { + padding: 3rem 0; + color: var(--muted); + } + + /* Pointer-following border glow, the position comes from pointer.js */ + [data-glow] { + --mx: 50%; + --my: 50%; + position: relative; + + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + padding: 1px; + border-radius: inherit; + background: radial-gradient(26rem circle at var(--mx) var(--my), rgb(255 141 180 / 0.7), rgb(192 17 215 / 0.35) 40%, transparent 65%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; + transition: opacity 0.4s; + pointer-events: none; + } + } + + @media (hover: hover) and (pointer: fine) { + [data-glow]:hover::before { + opacity: 1; + } + } + + /* Project cards */ + .project-grid { + display: grid; + gap: clamp(1rem, 2vw, 1.5rem); + margin: 0; + padding: 0; + list-style: none; + + @media (min-width: 40em) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + .card { + height: 100%; + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: var(--surface); + transition: border-color 0.3s, translate 0.4s var(--ease-out); + } + + .card__link { + display: flex; + flex-direction: column; + height: 100%; + border-radius: inherit; + color: inherit; + text-decoration: none; + + &:focus-visible { + outline-offset: 4px; + border-radius: var(--radius-l); + } + } + + .card__media { + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: calc(var(--radius-l) - 1px) calc(var(--radius-l) - 1px) 0 0; + background: var(--surface-2); + } + + .card__img { + width: 100%; + height: 100%; + object-fit: cover; + transition: scale 0.9s var(--ease-out); + } + + .card__placeholder { + width: 100%; + height: 100%; + background: + radial-gradient(80% 70% at 20% 20%, rgb(255 118 164 / 0.45), transparent 70%), + radial-gradient(70% 70% at 80% 80%, rgb(47 60 190 / 0.6), transparent 70%), + radial-gradient(60% 60% at 60% 30%, rgb(192 17 215 / 0.4), transparent 70%), + var(--surface-2); + } + + .card__body { + display: grid; + align-content: start; + gap: 0.6rem; + padding: 1.25rem 1.4rem 1.6rem; + } + + .card__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + min-height: 1.4rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--muted); + } + + .card__title { + font-size: var(--step-2); + } + + .card__summary { + color: var(--muted); + } + + .card .tags { + margin-top: 0.35rem; + } + + @media (hover: hover) and (pointer: fine) { + .card:hover { + border-color: transparent; + + & .card__img { + scale: 1.045; + } + } + } + + /* Filter chips */ + .filter { + display: flex; + gap: 0.5rem; + margin-bottom: clamp(1.5rem, 3vw, 2.25rem); + + @media (max-width: 39.99em) { + margin-inline: calc(-1 * var(--gutter)); + padding-inline: var(--gutter); + padding-bottom: 0.25rem; + overflow-x: auto; + scroll-snap-type: x proximity; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + @media (min-width: 40em) { + flex-wrap: wrap; + } + } + + .chip { + display: inline-flex; + flex: none; + align-items: center; + gap: 0.5rem; + min-height: 2.75rem; + padding-inline: 1.1rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + color: var(--muted); + font-size: 0.92rem; + text-decoration: none; + white-space: nowrap; + scroll-snap-align: start; + transition: color 0.2s, border-color 0.2s, background-color 0.2s; + + &:hover { + border-color: var(--text); + color: var(--text); + } + + &[aria-current="true"] { + border-color: transparent; + background: var(--text); + color: var(--bg); + + & .chip__count { + color: rgb(12 10 18 / 0.6); + } + } + } + + .chip__count { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--faint); + } + + /* Link buttons (project links, contact) */ + .link-button { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 3rem; + padding: 0.6rem 0.95rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: rgb(243 238 248 / 0.03); + color: var(--text); + text-decoration: none; + transition: border-color 0.2s, background-color 0.2s; + + & span { + overflow-wrap: anywhere; + } + + & .icon { + color: var(--pink-text); + } + + &:hover { + border-color: rgb(255 141 180 / 0.6); + background: rgb(255 118 164 / 0.08); + } + } + + /* Long-form text from Markdown */ + .prose { + max-width: 42rem; + color: rgb(243 238 248 / 0.88); + + & > * + * { + margin-top: 1.1em; + } + + & > :first-child { + margin-top: 0; + } + + & h2 { + margin-top: 2em; + font-size: var(--step-3); + color: var(--text); + } + + & h3 { + margin-top: 1.75em; + font-size: var(--step-2); + color: var(--text); + } + + & h2 + *, + & h3 + * { + margin-top: 0.75em; + } + + & a { + color: var(--pink-text); + transition: color 0.2s; + + &:hover { + color: var(--text); + } + } + + & strong { + font-weight: 600; + color: var(--text); + } + + & ul, + & ol { + padding-left: 1.25em; + } + + & li + li { + margin-top: 0.4em; + } + + & li::marker { + color: var(--pink-text); + } + + & code { + padding: 0.12em 0.4em; + border-radius: 0.35rem; + background: rgb(243 238 248 / 0.08); + font-family: var(--font-mono); + font-size: 0.86em; + } + + & pre { + padding: 1.1rem 1.25rem; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: #0f0c16; + font-size: 0.875rem; + line-height: 1.6; + + & code { + padding: 0; + background: none; + font-size: inherit; + } + } + + & img { + border: 1px solid var(--line); + border-radius: var(--radius); + } + + & blockquote { + padding-left: 1.25rem; + border-left: 2px solid var(--violet-text); + color: var(--muted); + } + + & hr { + height: 1px; + border: 0; + background: var(--line); + } + + & table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; + } + + & th, + & td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--line); + text-align: left; + } + } + + .prose--wide { + max-width: 52rem; + } + + /* Forms */ + .field { + display: grid; + gap: 0.45rem; + } + + .field__label { + font-size: 0.92rem; + font-weight: 500; + color: var(--muted); + } + + .field__input { + width: 100%; + min-height: 3.1rem; + padding: 0.8rem 1rem; + border: 1px solid var(--line-strong); + border-radius: 0.8rem; + background: var(--surface); + font-size: max(16px, 1rem); + transition: border-color 0.2s, box-shadow 0.2s; + + &:hover { + border-color: rgb(243 238 248 / 0.32); + } + + &:focus { + outline: none; + border-color: var(--pink-text); + box-shadow: 0 0 0 4px rgb(255 118 164 / 0.16); + } + } + + .field__input--area { + min-height: 11rem; + resize: vertical; + } + + .field--invalid .field__input { + border-color: var(--error); + } + + .field__error { + font-size: 0.875rem; + color: var(--error); + } + + .field--trap { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; + } + + .form-status { + padding: 1rem 1.25rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: var(--surface); + } + + .form-status--ok { + border-color: rgb(143 152 255 / 0.5); + background: rgb(47 60 190 / 0.18); + } + + .form-status--error { + border-color: rgb(255 154 169 / 0.5); + background: rgb(255 107 129 / 0.1); + } + + /* Video */ + .video { + position: relative; + aspect-ratio: 16 / 9; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: + radial-gradient(60% 80% at 15% 20%, rgb(255 118 164 / 0.3), transparent 70%), + radial-gradient(60% 80% at 85% 80%, rgb(47 60 190 / 0.45), transparent 70%), + var(--surface); + + & iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + } + } + + .video--file { + width: 100%; + height: auto; + aspect-ratio: auto; + } + + .video__poster { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: brightness(0.55) saturate(0.9); + } + + .video__overlay { + position: absolute; + inset: 0; + display: grid; + place-content: center; + justify-items: center; + gap: 1rem; + padding: 1rem; + text-align: center; + } + + .video__play { + display: inline-flex; + align-items: center; + gap: 0.85rem; + padding: 0.45rem 1.3rem 0.45rem 0.45rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + background: rgb(12 10 18 / 0.7); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + font-weight: 500; + text-decoration: none; + transition: border-color 0.2s; + + &:hover { + border-color: var(--text); + + & .video__play-icon { + scale: 1.06; + } + } + } + + .video__play-icon { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background: var(--text); + color: var(--bg); + transition: scale 0.3s var(--ease-out); + } + + .video__notice { + max-width: 34ch; + font-size: 0.8rem; + color: rgb(243 238 248 / 0.75); + } + + /* Gallery + lightbox */ + .gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + + @media (min-width: 60em) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + & figcaption { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--muted); + } + } + + .gallery__link { + display: block; + aspect-ratio: 4 / 3; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + cursor: zoom-in; + } + + .gallery__img { + width: 100%; + height: 100%; + object-fit: cover; + transition: scale 0.7s var(--ease-out); + } + + @media (hover: hover) and (pointer: fine) { + .gallery__link:hover .gallery__img { + scale: 1.05; + } + } + + .lightbox { + width: 100vw; + max-width: none; + height: 100vh; + height: 100dvh; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: rgb(8 6 12 / 0.94); + color: var(--text); + + &::backdrop { + background: rgb(8 6 12 / 0.6); + } + + &[open] { + animation: fade-in 0.3s var(--ease-out); + } + } + + .lightbox__figure { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: calc(4.5rem + env(safe-area-inset-top)) 1rem calc(4rem + env(safe-area-inset-bottom)); + touch-action: pan-y pinch-zoom; + + @media (min-width: 48em) { + padding-inline: 5.5rem; + } + } + + .lightbox__img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + border-radius: 0.5rem; + object-fit: contain; + user-select: none; + } + + .lightbox__btn { + position: absolute; + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 50%; + background: rgb(243 238 248 / 0.08); + transition: background-color 0.2s; + + &:hover { + background: rgb(243 238 248 / 0.16); + } + } + + .lightbox__close { + top: calc(1rem + env(safe-area-inset-top)); + right: 1rem; + } + + .lightbox__prev, + .lightbox__next { + top: 50%; + translate: 0 -50%; + + @media (max-width: 47.99em) { + top: auto; + bottom: calc(1rem + env(safe-area-inset-bottom)); + translate: none; + } + } + + .lightbox__prev { + left: 1rem; + } + + .lightbox__next { + right: 1rem; + } + + .lightbox__count { + position: absolute; + bottom: calc(1.6rem + env(safe-area-inset-bottom)); + left: 50%; + translate: -50% 0; + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--muted); + } +} diff --git a/client/css/home.css b/client/css/home.css new file mode 100644 index 0000000..6e83050 --- /dev/null +++ b/client/css/home.css @@ -0,0 +1,469 @@ +@layer pages { + /* Hero */ + .hero { + position: relative; + isolation: isolate; + display: grid; + align-items: end; + min-height: 100vh; + min-height: 100svh; + padding-block: calc(var(--header-h) + 3rem) clamp(2.5rem, 7vh, 5rem); + overflow: hidden; + } + + .hero__bg { + position: absolute; + inset: 0; + z-index: -1; + background: + radial-gradient(60% 50% at 80% 18%, rgb(192 17 215 / 0.55), transparent 70%), + radial-gradient(50% 45% at 25% 8%, rgb(255 118 164 / 0.45), transparent 70%), + radial-gradient(70% 55% at 75% 75%, rgb(47 60 190 / 0.6), transparent 70%), + var(--bg); + + /* readability: darken towards the text and fade into the page */ + &::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + background: + linear-gradient(180deg, rgb(12 10 18 / 0.25) 0%, rgb(12 10 18 / 0) 25%, rgb(12 10 18 / 0.45) 62%, var(--bg) 100%), + linear-gradient(90deg, rgb(12 10 18 / 0.6) 0%, rgb(12 10 18 / 0.15) 60%, transparent 100%); + } + + /* film grain */ + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.12; + mix-blend-mode: overlay; + pointer-events: none; + } + } + + .hero__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 1.6s var(--ease-out); + + &.is-ready { + opacity: 1; + } + } + + .hero__inner { + display: grid; + gap: clamp(1.25rem, 3vh, 2rem); + } + + .hero__kicker { + display: flex; + align-items: center; + gap: 0.75rem; + font-family: var(--font-mono); + font-size: clamp(0.72rem, 0.68rem + 0.2vw, 0.82rem); + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(243 238 248 / 0.8); + + &::before { + content: ""; + flex: none; + width: 2rem; + height: 1px; + background: var(--fluid); + } + } + + .hero__title { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem 1.25rem; + font-weight: 700; + } + + .hero__name { + display: block; + margin-left: -0.05em; + padding-bottom: 0.04em; + font-size: clamp(6rem, 2rem + 24vw, 19rem); + line-height: 0.8; + letter-spacing: -0.045em; + color: var(--text); + text-shadow: 0 0 60px rgb(12 10 18 / 0.35); + } + + .hero__pronouns { + margin-top: clamp(0.25rem, 1.5vw, 1.5rem); + padding: 0.4rem 0.85rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + background: rgb(12 10 18 / 0.45); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 400; + letter-spacing: 0.02em; + line-height: 1.4; + } + + .hero__bottom { + display: grid; + gap: 2rem; + + @media (min-width: 60em) { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + } + } + + .hero__lead { + max-width: 40ch; + font-size: var(--step-1); + color: rgb(243 238 248 / 0.88); + } + + .hero__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.75rem; + } + + .now { + width: min(100%, 22rem); + padding: 1.1rem 1.25rem 1.2rem; + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: rgb(12 10 18 / 0.55); + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + } + + .now__title { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.6rem; + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + } + + .now__pulse { + position: relative; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--pink); + + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--pink); + animation: ping 2.2s var(--ease-out) infinite; + } + } + + .now__list { + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.95rem; + } + + /* Bento */ + .bento { + display: grid; + gap: clamp(0.75rem, 1.5vw, 1.25rem); + + @media (min-width: 40em) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: + "server server" + "homelab homelab" + "scripts study" + "video video"; + } + + @media (min-width: 64em) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-areas: + "server server homelab homelab" + "server server scripts study" + "video video video video"; + } + } + + .tile { + display: flex; + flex-direction: column; + gap: 1.5rem; + min-height: 16rem; + padding: clamp(1.25rem, 2.5vw, 1.75rem); + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: linear-gradient(180deg, var(--surface), var(--bg-raised)); + } + + /* only where the grid actually defines these areas, otherwise all tiles end up in one cell */ + @media (min-width: 40em) { + .tile--server { grid-area: server; } + .tile--homelab { grid-area: homelab; } + .tile--scripts { grid-area: scripts; } + .tile--study { grid-area: study; } + .tile--video { grid-area: video; } + } + + .tile__visual { + flex: 1; + display: grid; + place-items: center; + min-height: 8rem; + } + + .tile__body { + display: grid; + gap: 0.6rem; + } + + .tile__title { + font-size: var(--step-2); + } + + .tile__text { + max-width: 48ch; + color: var(--muted); + } + + .tile__link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + width: fit-content; + margin-top: 0.35rem; + font-size: 0.92rem; + font-weight: 500; + color: var(--pink-text); + text-decoration: none; + + /* the whole tile is clickable */ + &::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + } + + &:focus-visible { + outline: none; + + &::after { + outline: 2px solid var(--pink-text); + outline-offset: 3px; + } + } + + & .icon { + transition: translate 0.3s var(--ease-out); + } + } + + .tile:hover .tile__link .icon { + translate: 3px 0; + } + + @media (min-width: 64em) { + .tile--server .tile__visual { + min-height: 14rem; + } + + .tile--video { + flex-direction: row; + align-items: center; + min-height: 0; + + & .tile__visual { + order: 2; + flex: 1.4; + } + + & .tile__body { + flex: 1; + } + } + } + + /* Featured projects: first card gets the full width */ + .project-grid--featured > li:first-child { + @media (min-width: 40em) { + grid-column: 1 / -1; + } + + @media (min-width: 64em) { + & .card__link { + flex-direction: row; + } + + & .card__media { + flex: 1.35; + aspect-ratio: auto; + min-height: 24rem; + border-radius: calc(var(--radius-l) - 1px) 0 0 calc(var(--radius-l) - 1px); + } + + & .card__body { + flex: 1; + align-content: center; + padding: clamp(1.5rem, 3vw, 3rem); + } + + & .card__title { + font-size: var(--step-3); + } + } + } + + /* Tools marquee */ + .marquee { + display: flex; + overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); + user-select: none; + + &:hover .marquee__track { + animation-play-state: paused; + } + } + + .marquee__track { + display: flex; + flex: none; + align-items: center; + margin: 0; + padding: 0; + list-style: none; + animation: marquee 45s linear infinite; + } + + .marquee__item { + display: flex; + align-items: center; + gap: clamp(1.25rem, 3vw, 2.5rem); + padding-right: clamp(1.25rem, 3vw, 2.5rem); + font-family: var(--font-display); + font-size: var(--step-4); + font-weight: 600; + letter-spacing: -0.03em; + white-space: nowrap; + color: transparent; + -webkit-text-stroke: 1px rgb(243 238 248 / 0.45); + + &:nth-child(3n + 1) { + color: var(--text); + -webkit-text-stroke: 0; + } + + &::after { + content: ""; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--pink); + } + + &:nth-child(3n + 2)::after { + background: var(--violet-text); + } + + &:nth-child(3n)::after { + background: var(--blue-text); + } + } + + @media (prefers-reduced-motion: reduce) { + .marquee { + -webkit-mask-image: none; + mask-image: none; + padding-inline: var(--gutter); + } + + .marquee__track { + flex-wrap: wrap; + flex-shrink: 1; + gap: 0.5rem 0; + animation: none; + + &[aria-hidden] { + display: none; + } + } + + .marquee__item { + font-size: var(--step-2); + } + } + + /* Contact teaser */ + .cta__inner { + position: relative; + overflow: hidden; + padding: clamp(2.25rem, 6vw, 5rem) clamp(1.25rem, 6vw, 5rem); + border: 1px solid var(--line); + border-radius: clamp(1.25rem, 3vw, 2rem); + background: + radial-gradient(70% 90% at 100% 0%, rgb(192 17 215 / 0.32), transparent 60%), + radial-gradient(60% 90% at 0% 100%, rgb(47 60 190 / 0.42), transparent 60%), + radial-gradient(40% 60% at 60% 110%, rgb(255 118 164 / 0.25), transparent 70%), + var(--surface); + } + + .cta__title { + max-width: 18ch; + font-size: var(--step-4); + } + + .cta__text { + max-width: 52ch; + margin-top: 1.1rem; + font-size: var(--step-1); + color: rgb(243 238 248 / 0.8); + } + + .cta__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 1.75rem; + margin-top: 2rem; + } + + .cta__mail { + font-family: var(--font-mono); + font-size: 0.95rem; + overflow-wrap: anywhere; + transition: color 0.2s; + + &:hover { + color: var(--pink-text); + } + } +} diff --git a/client/css/layout.css b/client/css/layout.css new file mode 100644 index 0000000..bb780eb --- /dev/null +++ b/client/css/layout.css @@ -0,0 +1,523 @@ +@layer layout { + .container { + width: 100%; + max-width: calc(var(--container) + 2 * var(--gutter)); + margin-inline: auto; + padding-inline: max(var(--gutter), env(safe-area-inset-left)) max(var(--gutter), env(safe-area-inset-right)); + } + + .container--wide { + max-width: calc(92rem + 2 * var(--gutter)); + } + + .section { + padding-top: var(--section); + } + + .section--flush { + padding-top: 0; + } + + .section--tight { + padding-top: calc(var(--section) * 0.75); + } + + .section-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 1.25rem; + margin-bottom: clamp(1.75rem, 4vw, 3.25rem); + padding-top: 1.25rem; + border-top: 1px solid var(--line); + } + + .section-head__index { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--pink-text); + } + + .section-head__title { + flex: 1 1 auto; + font-size: var(--step-4); + } + + .section-head__more { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.75rem; + color: var(--muted); + font-size: 0.95rem; + text-decoration: none; + transition: color 0.2s; + + &:hover { + color: var(--text); + } + } + + /* Header. The header itself is never transformed, otherwise it would become + the containing block for the fixed mobile menu inside it. */ + .site-header { + position: fixed; + inset: 0 0 auto; + z-index: 50; + height: calc(var(--header-h) + env(safe-area-inset-top)); + padding-top: env(safe-area-inset-top); + + &::before { + content: ""; + position: absolute; + inset: 0; + background: rgb(12 10 18 / 0.72); + -webkit-backdrop-filter: blur(14px) saturate(140%); + backdrop-filter: blur(14px) saturate(140%); + border-bottom: 1px solid var(--line); + opacity: 0; + transition: opacity 0.3s, translate 0.45s var(--ease-out); + } + + &.is-scrolled::before { + opacity: 1; + } + + &.is-hidden { + pointer-events: none; + + &::before, + .site-header__inner { + translate: 0 -110%; + } + } + } + + .site-header__inner { + position: relative; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + transition: translate 0.45s var(--ease-out); + } + + .brand { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 0.6rem; + min-height: 2.75rem; + text-decoration: none; + } + + .brand__mark { + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + box-shadow: 0 0 0 1px var(--line-strong), 0 0 20px rgb(255 118 164 / 0.3); + transition: rotate 0.4s var(--ease-out), scale 0.4s var(--ease-out); + } + + .brand:hover .brand__mark { + rotate: -8deg; + scale: 1.06; + } + + .brand__name { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1; + } + + .menu-toggle { + position: relative; + z-index: 1; + display: none; + align-items: center; + gap: 0.75rem; + min-height: 2.75rem; + padding: 0 0.25rem 0 0.9rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + background: rgb(12 10 18 / 0.5); + font-size: 0.9rem; + } + + .menu-toggle__icon { + position: relative; + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + + & span { + position: absolute; + width: 1rem; + height: 1.5px; + border-radius: 2px; + background: currentColor; + transition: translate 0.35s var(--ease-out), rotate 0.35s var(--ease-out); + } + + & span:first-child { + translate: 0 -3px; + } + + & span:last-child { + translate: 0 3px; + } + } + + .menu-open .menu-toggle__icon { + & span:first-child { + translate: none; + rotate: 45deg; + } + + & span:last-child { + translate: none; + rotate: -45deg; + } + } + + .site-nav { + display: flex; + align-items: center; + gap: 2rem; + } + + .site-nav__list { + display: flex; + gap: 1.75rem; + margin: 0; + padding: 0; + list-style: none; + } + + .site-nav__link { + position: relative; + display: inline-flex; + align-items: center; + min-height: 2.75rem; + color: var(--muted); + font-size: 0.95rem; + text-decoration: none; + transition: color 0.2s; + + &::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0.55rem; + height: 1px; + background: var(--fluid); + scale: 0 1; + transform-origin: right; + transition: scale 0.4s var(--ease-out); + } + + &:hover, + &[aria-current="page"] { + color: var(--text); + + &::after { + scale: 1 1; + transform-origin: left; + } + } + } + + .lang-switch { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-height: 2.5rem; + padding-inline: 0.85rem; + border: 1px solid var(--line-strong); + border-radius: 99px; + font-family: var(--font-mono); + font-size: 0.78rem; + text-decoration: none; + transition: border-color 0.2s, background-color 0.2s; + + &:hover { + border-color: var(--text); + background: rgb(243 238 248 / 0.06); + } + } + + .lang-switch__current, + .lang-switch__sep { + color: var(--faint); + } + + @media (max-width: 47.99em) { + .menu-toggle { + display: inline-flex; + } + + .site-nav { + position: fixed; + inset: 0; + z-index: -1; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2.5rem; + padding: calc(var(--header-h) + env(safe-area-inset-top) + 1rem) var(--gutter) calc(2.5rem + env(safe-area-inset-bottom)); + overflow-y: auto; + background: + radial-gradient(90% 60% at 100% 0%, rgb(192 17 215 / 0.28), transparent 70%), + radial-gradient(80% 60% at 0% 100%, rgb(47 60 190 / 0.35), transparent 70%), + var(--bg); + clip-path: circle(0 at calc(100% - 2.75rem) 2.1rem); + visibility: hidden; + transition: clip-path 0.55s var(--ease-in-out), visibility 0s 0.55s; + } + + .menu-open .site-nav { + clip-path: circle(150% at calc(100% - 2.75rem) 2.1rem); + visibility: visible; + transition: clip-path 0.7s var(--ease-out), visibility 0s; + } + + .menu-open .site-header::before { + opacity: 0; + } + + .site-nav__list { + flex-direction: column; + gap: 0.25rem; + } + + .site-nav__link { + font-family: var(--font-display); + font-size: clamp(2.25rem, 11vw, 3.25rem); + font-weight: 600; + letter-spacing: -0.03em; + color: var(--text); + opacity: 0; + translate: 0 1rem; + transition: opacity 0.4s, translate 0.5s var(--ease-out); + + &::after { + bottom: 0.4rem; + } + } + + .menu-open .site-nav__link { + opacity: 1; + translate: none; + } + + .menu-open .site-nav__item:nth-child(1) .site-nav__link { transition-delay: 0.12s; } + .menu-open .site-nav__item:nth-child(2) .site-nav__link { transition-delay: 0.18s; } + .menu-open .site-nav__item:nth-child(3) .site-nav__link { transition-delay: 0.24s; } + .menu-open .site-nav__item:nth-child(4) .site-nav__link { transition-delay: 0.3s; } + .menu-open .site-nav__item:nth-child(5) .site-nav__link { transition-delay: 0.36s; } + + .lang-switch { + min-height: 2.75rem; + font-size: 0.85rem; + } + } + + /* Without JS the menu button can't do anything, so show the links inline */ + @media (max-width: 47.99em) and (scripting: none) { + .site-header { + position: absolute; + height: auto; + } + + .site-header__inner { + flex-wrap: wrap; + padding-block: 0.75rem; + } + + .menu-toggle { + display: none; + } + + .site-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem 1rem; + padding: 0; + background: none; + clip-path: none; + visibility: visible; + } + + .site-nav__list { + flex-direction: row; + flex-wrap: wrap; + gap: 0 1rem; + } + + .site-nav__link { + font-family: var(--font-body); + font-size: 0.95rem; + opacity: 1; + translate: none; + } + } + + .menu-open, + .lightbox-open { + overflow: hidden; + } + + /* Subpage heads */ + .page-head { + position: relative; + isolation: isolate; + padding-block: calc(var(--header-h) + clamp(3rem, 9vw, 7.5rem)) clamp(2.5rem, 5vw, 4.5rem); + } + + .page-head__glow { + position: absolute; + top: 0; + right: 0; + z-index: -1; + width: min(62rem, 100%); + height: min(36rem, 90%); + background: + radial-gradient(closest-side at 72% 32%, rgb(192 17 215 / 0.32), transparent), + radial-gradient(closest-side at 92% 12%, rgb(255 118 164 / 0.28), transparent), + radial-gradient(closest-side at 46% 4%, rgb(47 60 190 / 0.4), transparent); + filter: blur(28px); + pointer-events: none; + animation: drift 20s ease-in-out infinite alternate; + } + + .page-head__kicker { + margin-bottom: 1rem; + font-family: var(--font-mono); + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pink-text); + } + + /* long German compounds like "Datenschutzerklärung" don't fit on phones otherwise */ + .page-head__title, + .project-head__title { + hyphens: auto; + } + + .page-head__title { + max-width: 16ch; + font-size: var(--step-5); + letter-spacing: -0.045em; + line-height: 0.98; + } + + .page-head__lead { + max-width: 56ch; + margin-top: 1.25rem; + font-size: var(--step-1); + color: var(--muted); + } + + /* Footer */ + .site-footer { + padding-block: clamp(3rem, 6vw, 5rem) calc(2rem + env(safe-area-inset-bottom)); + border-top: 1px solid var(--line); + background: linear-gradient(180deg, transparent, rgb(47 60 190 / 0.07)); + font-size: 0.95rem; + + & ul { + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; + } + + & a { + display: inline-block; + padding-block: 0.2rem; + color: var(--muted); + text-decoration: none; + overflow-wrap: anywhere; + transition: color 0.2s; + + &:hover { + color: var(--text); + } + } + } + + .site-footer__grid { + display: grid; + gap: 2.25rem; + + @media (min-width: 40em) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (min-width: 60em) { + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr); + } + } + + .site-footer__intro { + @media (min-width: 40em) and (max-width: 59.99em) { + grid-column: 1 / -1; + } + } + + .site-footer .brand { + display: inline-flex; + padding: 0; + color: var(--text); + } + + .site-footer__note { + max-width: 36ch; + margin-top: 0.75rem; + color: var(--muted); + } + + .site-footer__heading { + margin-bottom: 0.75rem; + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--faint); + } + + .site-footer__bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.5rem; + margin-top: clamp(2.5rem, 5vw, 4rem); + padding-top: 1.5rem; + border-top: 1px solid var(--line); + font-size: 0.875rem; + color: var(--faint); + } + + .site-footer .site-footer__legal { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.5rem; + } + + .to-top { + display: inline-flex !important; + align-items: center; + gap: 0.35rem; + } +} diff --git a/client/css/main.css b/client/css/main.css new file mode 100644 index 0000000..b139ba8 --- /dev/null +++ b/client/css/main.css @@ -0,0 +1,18 @@ +@layer reset, base, layout, components, pages, motion, utilities; + +@import "@fontsource-variable/bricolage-grotesque/opsz.css"; +@import "@fontsource/ibm-plex-sans/latin-400.css"; +@import "@fontsource/ibm-plex-sans/latin-400-italic.css"; +@import "@fontsource/ibm-plex-sans/latin-500.css"; +@import "@fontsource/ibm-plex-sans/latin-600.css"; +@import "@fontsource/ibm-plex-mono/latin-400.css"; +@import "@fontsource/ibm-plex-mono/latin-500.css"; + +@import "./tokens.css"; +@import "./base.css"; +@import "./layout.css"; +@import "./components.css"; +@import "./visuals.css"; +@import "./home.css"; +@import "./pages.css"; +@import "./motion.css"; diff --git a/client/css/motion.css b/client/css/motion.css new file mode 100644 index 0000000..0a4761d --- /dev/null +++ b/client/css/motion.css @@ -0,0 +1,134 @@ +@layer motion { + /* Content only starts hidden when JS runs and nobody asked for less motion */ + @media (scripting: enabled) and (prefers-reduced-motion: no-preference) { + [data-reveal] { + opacity: 0; + translate: 0 1.75rem; + transition: + opacity 0.9s var(--ease-out) var(--delay, 0s), + translate 0.9s var(--ease-out) var(--delay, 0s); + } + + [data-reveal="mask"] { + opacity: 1; + translate: none; + clip-path: inset(0 -0.2em 100% -0.2em); + transform: translateY(0.35em); + transition: + clip-path 1.1s var(--ease-out) var(--delay, 0s), + transform 1.1s var(--ease-out) var(--delay, 0s); + } + + [data-reveal].is-visible { + opacity: 1; + translate: none; + } + + [data-reveal="mask"].is-visible { + clip-path: inset(-0.2em -0.2em -0.3em -0.2em); + transform: none; + } + } + + @supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .timeline::after { + animation: grow linear both; + animation-timeline: view(); + animation-range: entry 20% cover 70%; + } + } + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0s !important; + animation-iteration-count: 1 !important; + transition-duration: 0s !important; + scroll-behavior: auto !important; + } + } + + @keyframes drift { + from { translate: 0 0; scale: 1; } + to { translate: -6% 4%; scale: 1.08; } + } + + @keyframes ping { + 0% { scale: 1; opacity: 0.7; } + 80%, 100% { scale: 3; opacity: 0; } + } + + @keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } + } + + @keyframes load { + from { translate: -30% 0; } + to { translate: 60% 0; } + } + + @keyframes flow { + from { stroke-dashoffset: 106; } + to { stroke-dashoffset: 0; } + } + + @keyframes sort { + 0%, 12% { height: var(--from); } + 40%, 75% { height: var(--to); } + 100% { height: var(--from); } + } + + @keyframes playhead { + from { left: calc(0.9rem + var(--label)); } + to { left: calc(100% - 0.9rem); } + } + + @keyframes marquee { + to { translate: -100% 0; } + } + + @keyframes grow { + from { scale: 1 0; } + to { scale: 1 1; } + } + + @keyframes fade-in { + from { opacity: 0; } + } + + @keyframes vt-out { + to { opacity: 0; translate: 0 -0.75rem; } + } + + @keyframes vt-in { + from { opacity: 0; translate: 0 1.25rem; } + } +} + +/* Cross-document page transitions, outside of the layers on purpose */ +@view-transition { + navigation: auto; +} + +@media (prefers-reduced-motion: reduce) { + @view-transition { + navigation: none; + } +} + +::view-transition-old(root) { + animation: 0.3s var(--ease-in-out) both vt-out; +} + +::view-transition-new(root) { + animation: 0.55s var(--ease-out) both vt-in; +} + +::view-transition-group(project-cover) { + animation-duration: 0.6s; + animation-timing-function: var(--ease-out); +} diff --git a/client/css/pages.css b/client/css/pages.css new file mode 100644 index 0000000..540071e --- /dev/null +++ b/client/css/pages.css @@ -0,0 +1,553 @@ +@layer pages { + /* Project detail */ + .project-head { + position: relative; + isolation: isolate; + padding-block: calc(var(--header-h) + clamp(2rem, 6vw, 5rem)) clamp(2rem, 4vw, 3rem); + } + + .back-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 2.75rem; + margin-bottom: 1.25rem; + color: var(--muted); + font-size: 0.95rem; + text-decoration: none; + transition: color 0.2s; + + & .icon { + transition: translate 0.3s var(--ease-out); + } + + &:hover { + color: var(--text); + + & .icon { + translate: -3px 0; + } + } + } + + .project-head__title { + max-width: 20ch; + font-size: var(--step-5); + letter-spacing: -0.045em; + line-height: 0.98; + } + + .project-meta { + display: flex; + flex-wrap: wrap; + gap: 1.25rem 3rem; + margin-top: 2rem; + + & dt { + margin-bottom: 0.4rem; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--faint); + } + } + + .project-cover { + margin-bottom: clamp(2.5rem, 5vw, 4rem); + } + + .project-cover__img { + width: 100%; + max-height: 80vh; + max-height: 80svh; + border: 1px solid var(--line); + border-radius: var(--radius-l); + object-fit: cover; + view-transition-name: project-cover; + } + + .project-body { + display: grid; + gap: 3rem; + + @media (min-width: 60em) { + grid-template-columns: minmax(0, 1fr) 18rem; + align-items: start; + } + } + + .project-links { + padding: 1.25rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + + @media (min-width: 60em) { + position: sticky; + top: calc(var(--header-h) + 1.5rem); + } + + & ul { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + } + } + + .project-links__title, + .contact__aside-title { + margin-bottom: 1rem; + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--faint); + } + + .project-section { + padding-top: clamp(3rem, 6vw, 5rem); + } + + .project-section__title { + margin-bottom: 1.5rem; + font-size: var(--step-3); + } + + .pager { + display: grid; + gap: 1rem; + padding-top: var(--section); + + @media (min-width: 40em) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + .pager__link { + display: grid; + gap: 0.5rem; + padding: 1.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-l); + text-decoration: none; + transition: border-color 0.2s, background-color 0.2s; + + &:hover { + border-color: var(--line-strong); + background: var(--surface); + } + } + + .pager__link--older { + text-align: right; + + @media (min-width: 40em) { + grid-column: 2; + } + + & .pager__label { + justify-content: flex-end; + } + } + + .pager__label { + display: flex; + align-items: center; + gap: 0.4rem; + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + } + + .pager__title { + font-family: var(--font-display); + font-size: var(--step-2); + font-weight: 600; + line-height: 1.15; + } + + /* Homelab */ + .hardware, + .services, + .skills { + display: grid; + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; + } + + .hardware { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr)); + } + + .services, + .skills { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); + } + + .machine, + .service-group, + .skill-group { + padding: 1.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: linear-gradient(180deg, var(--surface), var(--bg-raised)); + } + + .machine__role { + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pink-text); + } + + .machine__name { + margin-top: 0.4rem; + font-size: var(--step-2); + } + + .machine__specs { + display: grid; + gap: 0.35rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; + color: var(--muted); + font-size: 0.95rem; + + & li { + display: flex; + gap: 0.6rem; + + &::before { + content: ""; + flex: none; + width: 0.35rem; + height: 0.35rem; + margin-top: 0.6em; + border-radius: 50%; + background: var(--violet-text); + } + } + } + + .service-group__title, + .skill-group__title { + margin-bottom: 1rem; + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + } + + .service-group__list { + display: grid; + gap: 0.9rem; + margin: 0; + padding: 0; + list-style: none; + } + + .service { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: baseline; + column-gap: 0.75rem; + } + + .service__dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--pink); + box-shadow: 0 0 0 4px rgb(255 118 164 / 0.14); + } + + .service__name { + font-weight: 600; + + & a { + color: inherit; + text-decoration-color: rgb(255 141 180 / 0.6); + + &:hover { + color: var(--pink-text); + } + } + } + + .service__text { + grid-column: 2; + font-size: 0.92rem; + color: var(--muted); + } + + /* Journey */ + .about { + display: grid; + gap: 2rem; + + @media (min-width: 60em) { + grid-template-columns: 18rem minmax(0, 1fr); + column-gap: 4rem; + } + } + + .about__head { + grid-column: 1 / -1; + margin-bottom: 0; + } + + .about__portrait { + width: min(100%, 18rem); + } + + .about__img { + width: 100%; + aspect-ratio: 4 / 5; + border: 1px solid var(--line); + border-radius: var(--radius-l); + object-fit: cover; + } + + .about__text { + font-size: var(--step-1); + line-height: 1.55; + + .about__head + & { + grid-column: 1 / -1; + max-width: 48rem; + } + } + + .timeline { + --line-x: 0.45rem; + position: relative; + display: grid; + gap: 1.25rem; + margin: 0; + padding: 0 0 0 2.25rem; + list-style: none; + + &::before, + &::after { + content: ""; + position: absolute; + top: 0.5rem; + bottom: 0.5rem; + left: var(--line-x); + width: 2px; + border-radius: 2px; + } + + &::before { + background: var(--line); + } + + &::after { + background: linear-gradient(180deg, var(--pink), var(--violet-text), var(--blue-text)); + transform-origin: top; + scale: 1 var(--progress, 1); + } + + @media (min-width: 60em) { + --line-x: calc(50% - 1px); + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 5rem; + padding-left: 0; + } + } + + .timeline__entry { + position: relative; + + @media (min-width: 60em) { + &:nth-child(odd) { + grid-column: 1; + text-align: right; + } + + &:nth-child(even) { + grid-column: 2; + margin-top: 3rem; + } + + &:nth-child(even) + .timeline__entry { + margin-top: -3rem; + } + } + } + + .timeline__dot { + position: absolute; + top: 1.5rem; + left: calc(-2.25rem + 0.45rem + 1px - 0.45rem); + width: 0.9rem; + height: 0.9rem; + border: 2px solid currentColor; + border-radius: 50%; + background: var(--bg); + box-shadow: 0 0 0 4px var(--bg); + + @media (min-width: 60em) { + .timeline__entry:nth-child(odd) & { + left: auto; + right: calc(-2.5rem - 0.45rem); + } + + .timeline__entry:nth-child(even) & { + left: calc(-2.5rem - 0.45rem); + } + } + } + + .timeline__entry--edu { color: var(--blue-text); } + .timeline__entry--work { color: var(--pink-text); } + .timeline__entry--project, + .timeline__entry--other { color: var(--violet-text); } + + .timeline__card { + padding: 1.25rem 1.4rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + } + + .timeline__period { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--muted); + + @media (min-width: 60em) { + .timeline__entry:nth-child(odd) & { + justify-content: flex-end; + } + } + } + + .timeline__title { + margin-top: 0.4rem; + font-size: var(--step-2); + } + + .timeline__place { + margin-top: 0.25rem; + color: var(--muted); + font-size: 0.95rem; + } + + .timeline__text { + margin-top: 0.6rem; + color: var(--muted); + } + + .skill-group__list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + } + + .skill { + padding: 0.35rem 0.8rem; + border: 1px solid var(--line); + border-radius: 99px; + background: rgb(243 238 248 / 0.05); + font-size: 0.92rem; + } + + /* Contact */ + .contact { + display: grid; + gap: clamp(2rem, 5vw, 4rem); + + @media (min-width: 60em) { + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + align-items: start; + } + } + + .contact-form { + position: relative; + display: grid; + gap: 1.25rem; + } + + .contact-form__footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 1.5rem; + margin-top: 0.5rem; + } + + .contact-form__note { + max-width: 42ch; + font-size: 0.85rem; + color: var(--muted); + } + + .contact__aside { + padding: 1.5rem; + border: 1px solid var(--line); + border-radius: var(--radius-l); + background: var(--surface); + } + + .contact__links { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + } + + /* Errors */ + .error-page { + position: relative; + isolation: isolate; + display: grid; + align-items: center; + min-height: 80vh; + min-height: 80svh; + padding-block: calc(var(--header-h) + 3rem) 2rem; + + & .btn { + margin-top: 2rem; + } + } + + .error-page__code { + font-family: var(--font-display); + font-size: clamp(7rem, 4rem + 18vw, 16rem); + font-weight: 700; + line-height: 0.85; + letter-spacing: -0.06em; + background: var(--fluid); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + + .error-page__title { + margin-top: 1.25rem; + font-size: var(--step-4); + } +} diff --git a/client/css/tokens.css b/client/css/tokens.css new file mode 100644 index 0000000..6e89c8d --- /dev/null +++ b/client/css/tokens.css @@ -0,0 +1,53 @@ +:root { + color-scheme: dark; + + --bg: #0c0a12; + --bg-raised: #120f1a; + --surface: #16131f; + --surface-2: #1e1a2a; + --line: rgb(243 238 248 / 0.1); + --line-strong: rgb(243 238 248 / 0.2); + + --text: #f3eef8; + --muted: #a8a0bb; + --faint: #857d99; + + /* genderfluid flag */ + --pink: #ff76a4; + --white: #ffffff; + --violet: #c011d7; + --black: #000000; + --blue: #2f3cbe; + + /* lighter versions that are readable on the dark background */ + --pink-text: #ff8db4; + --violet-text: #da7ff2; + --blue-text: #8f98ff; + --error: #ff9aa9; + + --fluid: linear-gradient(110deg, var(--pink) 0%, var(--violet-text) 48%, var(--blue-text) 100%); + + --font-display: "Bricolage Grotesque Variable", "Bricolage Grotesque", system-ui, sans-serif; + --font-body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + + --step--1: clamp(0.84rem, 0.82rem + 0.1vw, 0.9rem); + --step-0: clamp(1rem, 0.97rem + 0.15vw, 1.1rem); + --step-1: clamp(1.15rem, 1.07rem + 0.4vw, 1.4rem); + --step-2: clamp(1.35rem, 1.2rem + 0.75vw, 1.85rem); + --step-3: clamp(1.7rem, 1.4rem + 1.5vw, 2.6rem); + --step-4: clamp(2.2rem, 1.6rem + 3vw, 4.25rem); + --step-5: clamp(2.8rem, 1.7rem + 5.5vw, 6.5rem); + + --radius-s: 0.6rem; + --radius: 1rem; + --radius-l: 1.5rem; + + --gutter: clamp(1rem, 4vw, 2.5rem); + --container: 78rem; + --section: clamp(4.5rem, 3rem + 7vw, 9rem); + --header-h: 4.25rem; + + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); +} diff --git a/client/css/visuals.css b/client/css/visuals.css new file mode 100644 index 0000000..d4a7b73 --- /dev/null +++ b/client/css/visuals.css @@ -0,0 +1,274 @@ +/* Small decorative animations inside the homepage tiles */ +@layer components { + /* Server rack */ + .rack { + display: grid; + gap: 0.5rem; + width: min(100%, 24rem); + + @media (min-width: 64em) { + .tile--server & { + gap: 0.7rem; + width: min(100%, 30rem); + } + } + } + + .rack__unit { + display: grid; + position: relative; + grid-template-columns: auto auto minmax(0, 1fr) 4.5rem; + align-items: center; + gap: 0.55rem; + height: 2.4rem; + padding-inline: 0.9rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: linear-gradient(180deg, #231e33, #1a1626); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05); + } + + .rack__led { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--pink); + box-shadow: 0 0 8px var(--pink); + animation: blink 1.6s steps(2, jump-none) infinite; + } + + .rack__led--b1, + .rack__led--b2, + .rack__led--b3, + .rack__led--b4, + .rack__led--b5 { + background: var(--blue-text); + box-shadow: 0 0 8px var(--blue-text); + } + + .rack__led--2 { animation-duration: 0.9s; } + .rack__led--3 { animation-duration: 2.3s; animation-delay: -0.6s; } + .rack__led--4 { animation-duration: 1.2s; animation-delay: -0.3s; } + .rack__led--b1 { animation-duration: 0.7s; animation-delay: -0.2s; } + .rack__led--b2 { animation-duration: 3.1s; } + .rack__led--b3 { animation-duration: 1.1s; animation-delay: -0.5s; } + .rack__led--b4 { animation-duration: 0.5s; } + .rack__led--5 { animation-duration: 1.9s; animation-delay: -1s; } + .rack__led--b5 { animation-duration: 0.8s; animation-delay: -0.1s; } + + .rack__vents { + height: 0.9rem; + border-radius: 2px; + background: repeating-linear-gradient(90deg, rgb(255 255 255 / 0.09) 0 2px, transparent 2px 6px); + } + + .rack__bar { + height: 0.3rem; + overflow: hidden; + border-radius: 9px; + background: rgb(255 255 255 / 0.08); + } + + .rack__load { + display: block; + width: 45%; + height: 100%; + border-radius: inherit; + background: var(--fluid); + animation: load 5s var(--ease-in-out) infinite alternate; + } + + .rack__load--2 { width: 70%; animation-duration: 3.4s; animation-delay: -1s; } + .rack__load--3 { width: 25%; animation-duration: 6s; animation-delay: -2.5s; } + .rack__load--4 { width: 55%; animation-duration: 4.2s; animation-delay: -0.4s; } + .rack__load--5 { width: 35%; animation-duration: 5.5s; animation-delay: -3s; } + + @media (min-width: 64em) { + .tile--server .rack__unit { + height: 3.1rem; + grid-template-columns: auto auto minmax(0, 1fr) 6rem; + } + } + + /* Network graph */ + .net { + width: min(100%, 24rem); + height: auto; + overflow: visible; + } + + .net__links path { + fill: none; + stroke: rgb(255 255 255 / 0.14); + stroke-width: 1.5; + } + + .net__flow path { + fill: none; + stroke: var(--pink); + stroke-width: 2.5; + stroke-linecap: round; + stroke-dasharray: 6 100; + animation: flow 3s linear infinite; + } + + .net__flow path:nth-child(2) { stroke: var(--violet-text); animation-duration: 2.4s; animation-delay: -1.2s; } + .net__flow path:nth-child(3) { stroke: var(--white); animation-duration: 3.6s; animation-delay: -0.4s; } + .net__flow path:nth-child(4) { stroke: var(--blue-text); animation-duration: 2.8s; animation-delay: -2s; } + .net__flow path:nth-child(5) { animation-duration: 4s; animation-delay: -3s; } + + .net__nodes rect { + fill: #1e1a2a; + stroke: rgb(255 255 255 / 0.25); + stroke-width: 1.2; + } + + .net__core { + fill: var(--bg); + stroke: var(--pink); + stroke-width: 2; + filter: drop-shadow(0 0 8px rgb(255 118 164 / 0.7)); + } + + /* Shell script */ + .code { + width: 100%; + margin: 0; + padding: 1rem 1.1rem; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: #0f0c16; + color: #d9d1e7; + font-family: var(--font-mono); + font-size: 0.74rem; + line-height: 1.65; + white-space: pre; + + & .c { color: var(--faint); } + & .k { color: var(--pink-text); } + & .s { color: var(--blue-text); } + } + + .code__caret { + display: inline-block; + width: 0.55em; + height: 1.15em; + margin-left: 0.2em; + vertical-align: text-bottom; + background: var(--pink-text); + animation: blink 1.1s steps(2, jump-none) infinite; + } + + /* Bars that keep sorting themselves */ + .bars { + display: flex; + align-items: flex-end; + gap: 0.4rem; + width: min(100%, 15rem); + height: 7rem; + } + + .bars__bar { + flex: 1; + height: var(--to); + border-radius: 0.3rem 0.3rem 0.15rem 0.15rem; + background: linear-gradient(180deg, var(--violet-text), var(--blue)); + animation: sort 7s var(--ease-in-out) infinite; + } + + .bars__bar--1 { --from: 70%; --to: 18%; } + .bars__bar--2 { --from: 30%; --to: 28%; animation-delay: 0.08s; } + .bars__bar--3 { --from: 95%; --to: 38%; animation-delay: 0.16s; } + .bars__bar--4 { --from: 18%; --to: 48%; animation-delay: 0.24s; } + .bars__bar--5 { --from: 58%; --to: 58%; animation-delay: 0.32s; } + .bars__bar--6 { --from: 40%; --to: 70%; animation-delay: 0.4s; } + .bars__bar--7 { --from: 82%; --to: 84%; animation-delay: 0.48s; } + .bars__bar--8 { --from: 48%; --to: 100%; animation-delay: 0.56s; } + + .bars__bar--3, + .bars__bar--8 { + background: linear-gradient(180deg, var(--pink), var(--violet)); + } + + /* Video editor timeline */ + .edit { + --label: 2.2rem; + position: relative; + display: grid; + gap: 0.45rem; + width: 100%; + padding: 0.9rem; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: #0f0c16; + } + + .edit__ruler { + height: 0.6rem; + margin-left: var(--label); + background: + repeating-linear-gradient(90deg, rgb(255 255 255 / 0.22) 0 1px, transparent 1px 2.5rem), + repeating-linear-gradient(90deg, rgb(255 255 255 / 0.09) 0 1px, transparent 1px 0.5rem); + } + + .edit__track { + position: relative; + height: 1.9rem; + margin-left: var(--label); + border-radius: 0.35rem; + background: rgb(255 255 255 / 0.03); + } + + .edit__label { + position: absolute; + top: 50%; + left: calc(-1 * var(--label)); + translate: 0 -50%; + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--faint); + } + + .edit__clip { + position: absolute; + top: 3px; + bottom: 3px; + border-radius: 0.3rem; + } + + .edit__clip--title { left: 30%; width: 22%; background: linear-gradient(90deg, rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.55)); } + .edit__clip--a { left: 0; width: 34%; background: var(--pink); } + .edit__clip--b { left: 34.6%; width: 28%; background: var(--violet); } + .edit__clip--c { left: 63.2%; width: 36.8%; background: var(--blue); } + + .edit__clip--wave { + left: 0; + width: 100%; + background: repeating-linear-gradient(90deg, var(--violet-text) 0 2px, transparent 2px 4px); + clip-path: polygon(0.0% 39%, 2.5% 26%, 5.0% 12%, 7.5% 14%, 10.0% 15%, 12.5% 28%, 15.0% 37%, 17.5% 21%, 20.0% 18%, 22.5% 16%, 25.0% 26%, 27.5% 36%, 30.0% 33%, 32.5% 23%, 35.0% 27%, 37.5% 29%, 40.0% 30%, 42.5% 33%, 45.0% 33%, 47.5% 33%, 50.0% 28%, 52.5% 36%, 55.0% 32%, 57.5% 39%, 60.0% 37%, 62.5% 34%, 65.0% 31%, 67.5% 28%, 70.0% 37%, 72.5% 34%, 75.0% 26%, 77.5% 23%, 80.0% 20%, 82.5% 28%, 85.0% 38%, 87.5% 31%, 90.0% 16%, 92.5% 12%, 95.0% 15%, 97.5% 23%, 100.0% 38%, 100.0% 62%, 97.5% 77%, 95.0% 85%, 92.5% 88%, 90.0% 84%, 87.5% 69%, 85.0% 62%, 82.5% 72%, 80.0% 80%, 77.5% 77%, 75.0% 74%, 72.5% 66%, 70.0% 63%, 67.5% 72%, 65.0% 69%, 62.5% 66%, 60.0% 63%, 57.5% 61%, 55.0% 68%, 52.5% 64%, 50.0% 72%, 47.5% 67%, 45.0% 67%, 42.5% 67%, 40.0% 70%, 37.5% 71%, 35.0% 73%, 32.5% 77%, 30.0% 67%, 27.5% 64%, 25.0% 74%, 22.5% 84%, 20.0% 82%, 17.5% 79%, 15.0% 63%, 12.5% 72%, 10.0% 85%, 7.5% 86%, 5.0% 88%, 2.5% 74%, 0.0% 61%); + } + + .edit__playhead { + position: absolute; + top: 0.5rem; + bottom: 0.5rem; + left: calc(0.9rem + var(--label) + 30%); + width: 2px; + background: var(--pink-text); + box-shadow: 0 0 12px var(--pink); + animation: playhead 8s linear infinite; + + &::before { + content: ""; + position: absolute; + top: -2px; + left: -5px; + width: 12px; + height: 10px; + border-radius: 2px 2px 6px 6px; + background: var(--pink-text); + } + } +} diff --git a/client/js/contact.js b/client/js/contact.js new file mode 100644 index 0000000..8ea34b6 --- /dev/null +++ b/client/js/contact.js @@ -0,0 +1,66 @@ +export function initContactForm(form) { + const status = form.querySelector('[data-form-status]'); + const submit = form.querySelector('[type="submit"]'); + const submitLabel = submit.innerHTML; + + function setFieldError(name, message) { + const input = form.elements[name]; + const error = form.querySelector(`[data-error-for="${name}"]`); + if (!input || !error) return; + input.closest('.field').classList.toggle('field--invalid', Boolean(message)); + error.textContent = message || ''; + error.hidden = !message; + if (message) { + input.setAttribute('aria-invalid', 'true'); + input.setAttribute('aria-describedby', error.id); + } else { + input.removeAttribute('aria-invalid'); + input.removeAttribute('aria-describedby'); + } + } + + function showStatus(message, kind) { + status.textContent = message; + status.className = `form-status form-status--${kind}`; + status.hidden = false; + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + form.querySelector('[role="alert"]')?.remove(); + for (const name of ['name', 'email', 'message']) setFieldError(name, null); + status.hidden = true; + + submit.disabled = true; + submit.textContent = form.dataset.sending; + + try { + const response = await fetch(form.action, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: new URLSearchParams(new FormData(form)), + }); + const data = await response.json(); + + if (data.ok) { + const done = document.createElement('p'); + done.className = 'form-status form-status--ok'; + done.setAttribute('role', 'status'); + done.tabIndex = -1; + done.textContent = data.message; + form.replaceWith(done); + done.focus(); + return; + } + + for (const [name, message] of Object.entries(data.errors ?? {})) setFieldError(name, message); + showStatus(data.message, 'error'); + form.querySelector('[aria-invalid="true"]')?.focus(); + } catch { + showStatus(form.dataset.failed, 'error'); + } finally { + submit.disabled = false; + submit.innerHTML = submitLabel; + } + }); +} diff --git a/client/js/filter.js b/client/js/filter.js new file mode 100644 index 0000000..772fb0c --- /dev/null +++ b/client/js/filter.js @@ -0,0 +1,45 @@ +// Portfolio tag filter. The chips are plain links (?tag=...), the server filters too, +// so this only makes it instant. +export function initFilter(nav) { + const list = document.querySelector('[data-filter-list]'); + const empty = document.querySelector('[data-filter-empty]'); + if (!list) return; + const items = [...list.children]; + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); + + function apply(tag) { + for (const chip of nav.querySelectorAll('[data-tag]')) { + if (chip.dataset.tag === tag) chip.setAttribute('aria-current', 'true'); + else chip.removeAttribute('aria-current'); + } + let shown = 0; + for (const item of items) { + const match = !tag || item.dataset.tags.split(' ').includes(tag); + item.hidden = !match; + if (match) { + item.classList.add('is-visible'); + shown++; + } + } + if (empty) empty.hidden = shown > 0; + } + + nav.addEventListener('click', (event) => { + const chip = event.target.closest('[data-tag]'); + if (!chip || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + history.replaceState(null, '', chip.href); + + if (!document.startViewTransition || reducedMotion.matches) { + apply(chip.dataset.tag); + return; + } + + // names only for the duration of the transition, so they don't clash with page navigation + items.forEach((item, index) => (item.style.viewTransitionName = `project-${index}`)); + const transition = document.startViewTransition(() => apply(chip.dataset.tag)); + transition.finished.finally(() => { + for (const item of items) item.style.viewTransitionName = ''; + }); + }); +} diff --git a/client/js/fluid.js b/client/js/fluid.js new file mode 100644 index 0000000..ff7d25b --- /dev/null +++ b/client/js/fluid.js @@ -0,0 +1,195 @@ +// The hero background: the five stripes of the genderfluid flag, bent by layered noise. + +const vertexSource = ` +attribute vec2 position; +void main() { + gl_Position = vec4(position, 0.0, 1.0); +} +`; + +const fragmentSource = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif + +uniform vec2 resolution; +uniform float time; +uniform vec2 pointer; + +vec3 stripe(float i) { + if (i < 0.5) return vec3(1.0, 0.463, 0.643); + if (i < 1.5) return vec3(0.88, 0.85, 0.92); + if (i < 2.5) return vec3(0.753, 0.067, 0.843); + if (i < 3.5) return vec3(0.05, 0.04, 0.08); + return vec3(0.184, 0.235, 0.745); +} + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); +} + +float fbm(vec2 p) { + float value = 0.0; + float amp = 0.5; + for (int i = 0; i < 4; i++) { + value += amp * noise(p); + p = p * 2.02 + vec2(1.7, 9.2); + amp *= 0.5; + } + return value; +} + +void main() { + vec2 uv = gl_FragCoord.xy / resolution; + vec2 p = uv * vec2(resolution.x / resolution.y, 1.0); + float t = time * 0.035; + + vec2 q = vec2(fbm(p * 0.9 + vec2(0.0, t)), fbm(p * 0.9 + vec2(5.2, -t))); + vec2 r = vec2( + fbm(p * 0.8 + 2.2 * q + vec2(1.7, 9.2) + t * 0.6), + fbm(p * 0.8 + 2.2 * q + vec2(8.3, 2.8) - t * 0.4) + ); + + float d = distance(uv, pointer); + float bend = 0.1 * exp(-d * d * 14.0); + + float y = uv.y + (r.y - 0.5) * 0.9 + (q.x - 0.5) * 0.4 + bend; + float s = clamp(1.0 - y, 0.0, 0.9999) * 5.0; + float i = floor(s); + float f = fract(s); + vec3 col = mix(stripe(i), stripe(min(i + 1.0, 4.0)), smoothstep(0.3, 1.0, f)); + + col *= 0.55 + 0.45 * smoothstep(0.15, 0.85, r.x); + col = mix(vec3(0.047, 0.039, 0.071), col, 0.8); + gl_FragColor = vec4(col, 1.0); +} +`; + +function compile(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.warn(gl.getShaderInfoLog(shader)); + return null; + } + return shader; +} + +export function initFluid(canvas) { + const gl = canvas.getContext('webgl', { antialias: false, depth: false, powerPreference: 'low-power' }); + if (!gl) return; + + const vertex = compile(gl, gl.VERTEX_SHADER, vertexSource); + const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource); + if (!vertex || !fragment) return; + + const program = gl.createProgram(); + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return; + gl.useProgram(program); + + // one triangle that covers the whole viewport + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + const position = gl.getAttribLocation(program, 'position'); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + + const uniforms = { + resolution: gl.getUniformLocation(program, 'resolution'), + time: gl.getUniformLocation(program, 'time'), + pointer: gl.getUniformLocation(program, 'pointer'), + }; + + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); + const finePointer = matchMedia('(pointer: fine)').matches; + // The noise is soft anyway, rendering at a fraction of the size is barely visible and saves a lot + const scale = Math.min(devicePixelRatio || 1, 2) * (finePointer ? 0.5 : 0.35); + const offset = Math.random() * 200; + const pointer = { x: 0.7, y: 0.6, targetX: 0.7, targetY: 0.6 }; + + let visible = true; + let frame = 0; + let start = performance.now(); + + function resize() { + const width = Math.max(1, Math.round(canvas.clientWidth * scale)); + const height = Math.max(1, Math.round(canvas.clientHeight * scale)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + gl.viewport(0, 0, width, height); + } + } + + function draw(now) { + resize(); + pointer.x += (pointer.targetX - pointer.x) * 0.04; + pointer.y += (pointer.targetY - pointer.y) * 0.04; + gl.uniform2f(uniforms.resolution, canvas.width, canvas.height); + gl.uniform1f(uniforms.time, offset + (now - start) / 1000); + gl.uniform2f(uniforms.pointer, pointer.x, pointer.y); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + const animated = () => !reducedMotion.matches; + + function loop(now) { + frame = 0; + if (!visible || document.hidden || !animated()) return; + draw(now); + frame = requestAnimationFrame(loop); + } + + function play() { + if (!frame && visible && !document.hidden && animated()) frame = requestAnimationFrame(loop); + } + + draw(start); + canvas.classList.add('is-ready'); + + new ResizeObserver(() => { + if (!animated()) draw(start); + }).observe(canvas); + + new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + play(); + }).observe(canvas); + + document.addEventListener('visibilitychange', play); + reducedMotion.addEventListener('change', () => { + start = performance.now(); + play(); + }); + + if (finePointer) { + addEventListener( + 'pointermove', + (event) => { + const rect = canvas.getBoundingClientRect(); + pointer.targetX = (event.clientX - rect.left) / rect.width; + pointer.targetY = 1 - (event.clientY - rect.top) / rect.height; + }, + { passive: true }, + ); + } + + play(); +} diff --git a/client/js/lightbox.js b/client/js/lightbox.js new file mode 100644 index 0000000..ddc109d --- /dev/null +++ b/client/js/lightbox.js @@ -0,0 +1,98 @@ +const paths = { + close: 'M6 6l12 12M18 6 6 18', + prev: 'M15 5l-7 7 7 7', + next: 'M9 5l7 7-7 7', +}; + +function button(className, name, label) { + const element = document.createElement('button'); + element.type = 'button'; + element.className = `lightbox__btn ${className}`; + element.setAttribute('aria-label', label); + element.innerHTML = ``; + return element; +} + +export function initLightbox(gallery) { + const links = [...gallery.querySelectorAll('.gallery__link')]; + if (!links.length || typeof HTMLDialogElement !== 'function') return; + const { labelPrev, labelNext, labelClose } = gallery.dataset; + + const dialog = document.createElement('dialog'); + dialog.className = 'lightbox'; + const figure = document.createElement('figure'); + figure.className = 'lightbox__figure'; + const image = document.createElement('img'); + image.className = 'lightbox__img'; + image.decoding = 'async'; + figure.append(image); + const count = document.createElement('p'); + count.className = 'lightbox__count'; + const close = button('lightbox__close', 'close', labelClose); + const prev = button('lightbox__prev', 'prev', labelPrev); + const next = button('lightbox__next', 'next', labelNext); + dialog.append(figure, count, close, prev, next); + document.body.append(dialog); + + if (links.length < 2) { + prev.hidden = true; + next.hidden = true; + count.hidden = true; + } + + let current = 0; + + function show(index) { + current = (index + links.length) % links.length; + const link = links[current]; + image.src = link.dataset.full || link.href; + image.width = Number(link.dataset.width) || 0; + image.height = Number(link.dataset.height) || 0; + image.alt = link.querySelector('img')?.alt ?? ''; + count.textContent = `${current + 1} / ${links.length}`; + } + + function open(index) { + show(index); + dialog.showModal(); + document.documentElement.classList.add('lightbox-open'); + } + + links.forEach((link, index) => { + link.addEventListener('click', (event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + open(index); + }); + }); + + dialog.addEventListener('close', () => { + document.documentElement.classList.remove('lightbox-open'); + links[current].focus(); + }); + close.addEventListener('click', () => dialog.close()); + prev.addEventListener('click', () => show(current - 1)); + next.addEventListener('click', () => show(current + 1)); + + // a click on the dark area around the image closes it + figure.addEventListener('click', (event) => { + if (event.target === figure) dialog.close(); + }); + + dialog.addEventListener('keydown', (event) => { + if (event.key === 'ArrowLeft') show(current - 1); + if (event.key === 'ArrowRight') show(current + 1); + }); + + let startX = null; + figure.addEventListener('pointerdown', (event) => { + if (event.pointerType !== 'mouse') startX = event.clientX; + }); + figure.addEventListener('pointerup', (event) => { + if (startX === null) return; + const distance = event.clientX - startX; + startX = null; + if (Math.abs(distance) > 50 && links.length > 1) show(current + (distance < 0 ? 1 : -1)); + }); + figure.addEventListener('pointercancel', () => (startX = null)); +} diff --git a/client/js/main.js b/client/js/main.js new file mode 100644 index 0000000..bcc08ef --- /dev/null +++ b/client/js/main.js @@ -0,0 +1,27 @@ +import { initContactForm } from './contact.js'; +import { initFilter } from './filter.js'; +import { initFluid } from './fluid.js'; +import { initLightbox } from './lightbox.js'; +import { initNav } from './nav.js'; +import { initPointerEffects } from './pointer.js'; +import { initReveal, initTimeline } from './reveal.js'; +import { initCoverTransitions } from './transitions.js'; +import { initVideos } from './video.js'; + +initNav(); +initReveal(); +initTimeline(); +initPointerEffects(); +initCoverTransitions(); +initVideos(); + +const canvas = document.querySelector('[data-fluid]'); +if (canvas) initFluid(canvas); + +const filter = document.querySelector('[data-filter]'); +if (filter) initFilter(filter); + +for (const gallery of document.querySelectorAll('[data-gallery]')) initLightbox(gallery); + +const form = document.querySelector('[data-contact-form]'); +if (form) initContactForm(form); diff --git a/client/js/nav.js b/client/js/nav.js new file mode 100644 index 0000000..61afb69 --- /dev/null +++ b/client/js/nav.js @@ -0,0 +1,61 @@ +export function initNav() { + const header = document.querySelector('[data-header]'); + if (!header) return; + const root = document.documentElement; + const toggle = header.querySelector('[data-menu-toggle]'); + const nav = header.querySelector('[data-nav]'); + const label = toggle.querySelector('.menu-toggle__label'); + const outside = [document.querySelector('main'), document.querySelector('.site-footer')]; + + let lastY = scrollY; + let ticking = false; + + function onScroll() { + const y = scrollY; + header.classList.toggle('is-scrolled', y > 24); + if (!root.classList.contains('menu-open')) { + // hide while scrolling down, come back as soon as the user scrolls up + if (y > lastY + 4 && y > 240) header.classList.add('is-hidden'); + else if (y < lastY - 4 || y <= 240) header.classList.remove('is-hidden'); + } + lastY = y; + ticking = false; + } + + addEventListener( + 'scroll', + () => { + if (!ticking) requestAnimationFrame(onScroll); + ticking = true; + }, + { passive: true }, + ); + onScroll(); + + header.addEventListener('focusin', () => header.classList.remove('is-hidden')); + + function setOpen(open) { + toggle.setAttribute('aria-expanded', String(open)); + root.classList.toggle('menu-open', open); + label.textContent = open ? label.dataset.labelClose : label.dataset.labelOpen; + for (const element of outside) if (element) element.inert = open; + if (open) header.classList.remove('is-hidden'); + } + + toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true')); + + nav.addEventListener('click', (event) => { + if (event.target.closest('a')) setOpen(false); + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && root.classList.contains('menu-open')) { + setOpen(false); + toggle.focus(); + } + }); + + matchMedia('(min-width: 48em)').addEventListener('change', (event) => { + if (event.matches) setOpen(false); + }); +} diff --git a/client/js/pointer.js b/client/js/pointer.js new file mode 100644 index 0000000..f54d4c7 --- /dev/null +++ b/client/js/pointer.js @@ -0,0 +1,30 @@ +// Mouse-only effects: border glow that follows the cursor and buttons that lean towards it +export function initPointerEffects() { + if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return; + + document.addEventListener( + 'pointermove', + (event) => { + const element = event.target.closest?.('[data-glow]'); + if (!element) return; + const rect = element.getBoundingClientRect(); + element.style.setProperty('--mx', `${event.clientX - rect.left}px`); + element.style.setProperty('--my', `${event.clientY - rect.top}px`); + }, + { passive: true }, + ); + + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + for (const element of document.querySelectorAll('[data-magnetic]')) { + element.addEventListener('pointermove', (event) => { + const rect = element.getBoundingClientRect(); + const x = (event.clientX - rect.left - rect.width / 2) * 0.2; + const y = (event.clientY - rect.top - rect.height / 2) * 0.35; + element.style.translate = `${x.toFixed(1)}px ${y.toFixed(1)}px`; + }); + element.addEventListener('pointerleave', () => { + element.style.translate = ''; + }); + } +} diff --git a/client/js/reveal.js b/client/js/reveal.js new file mode 100644 index 0000000..e5667a8 --- /dev/null +++ b/client/js/reveal.js @@ -0,0 +1,58 @@ +export function initReveal() { + const items = document.querySelectorAll('[data-reveal]'); + if (!('IntersectionObserver' in window)) { + for (const item of items) item.classList.add('is-visible'); + return; + } + + const groups = new Map(); + const observer = new IntersectionObserver( + (entries) => { + // things that come into view together get a small stagger + let index = 0; + for (const entry of entries) { + if (!entry.isIntersecting) continue; + for (const item of groups.get(entry.target)) { + item.style.setProperty('--delay', `${Math.min(index++ * 90, 450)}ms`); + item.classList.add('is-visible'); + } + observer.unobserve(entry.target); + } + }, + { rootMargin: '0px 0px -6% 0px' }, + ); + + for (const item of items) { + // Chrome applies the element's own clip-path, so a fully clipped heading never + // counts as intersecting. Watch the parent for those instead. + const target = item.dataset.reveal === 'mask' ? item.parentElement : item; + if (!groups.has(target)) groups.set(target, []); + groups.get(target).push(item); + observer.observe(target); + } +} + +// Fallback for browsers without scroll-driven animations +export function initTimeline() { + const timeline = document.querySelector('[data-timeline]'); + if (!timeline || CSS.supports('animation-timeline: view()')) return; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + let ticking = false; + const update = () => { + const rect = timeline.getBoundingClientRect(); + const progress = (innerHeight * 0.75 - rect.top) / rect.height; + timeline.style.setProperty('--progress', Math.min(1, Math.max(0, progress)).toFixed(3)); + ticking = false; + }; + + addEventListener( + 'scroll', + () => { + if (!ticking) requestAnimationFrame(update); + ticking = true; + }, + { passive: true }, + ); + update(); +} diff --git a/client/js/transitions.js b/client/js/transitions.js new file mode 100644 index 0000000..693cbcf --- /dev/null +++ b/client/js/transitions.js @@ -0,0 +1,17 @@ +// Cover image morphs into the project page (cross-document view transition) +export function initCoverTransitions() { + const clear = () => { + for (const img of document.querySelectorAll('.card__img')) img.style.viewTransitionName = ''; + }; + + document.addEventListener('click', (event) => { + const link = event.target.closest('[data-cover-link]'); + if (!link || event.metaKey || event.ctrlKey || event.shiftKey) return; + clear(); + const img = link.querySelector('.card__img'); + if (img) img.style.viewTransitionName = 'project-cover'; + }); + + // coming back via bfcache would otherwise leave the name on the old card + addEventListener('pageshow', clear); +} diff --git a/client/js/video.js b/client/js/video.js new file mode 100644 index 0000000..bad2d27 --- /dev/null +++ b/client/js/video.js @@ -0,0 +1,19 @@ +// YouTube is only contacted after a click, until then it's just a link with a poster +export function initVideos() { + for (const box of document.querySelectorAll('[data-youtube]')) { + const play = box.querySelector('[data-video-play]'); + play?.addEventListener('click', (event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + const iframe = document.createElement('iframe'); + iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(box.dataset.youtube)}?autoplay=1&rel=0`; + iframe.title = box.dataset.title || 'YouTube'; + iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'; + iframe.allowFullscreen = true; + // YouTube refuses to play embeds without a referrer, helmet sets no-referrer for the page + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + box.replaceChildren(iframe); + iframe.focus(); + }); + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1c3ac32 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,34 @@ +services: + web: + build: . + image: personal-website:latest + container_name: personal-website + restart: unless-stopped + env_file: + - path: .env + required: false + environment: + SITE_URL: ${SITE_URL:-https://lux.harlelux.de} + volumes: + # Inhalte kommen direkt aus dem Repo, nach einem git pull lädt die Seite sie neu + - ./content:/app/content:ro + - cache:/app/cache + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + networks: + - proxy + +networks: + # Netzwerk des Reverse Proxy, muss schon existieren (Nginx Proxy Manager: nginx_default). + # Im Proxy Host dann personal-website:3000 als Ziel eintragen. + proxy: + external: true + name: ${PROXY_NETWORK:-nginx_default} + +volumes: + cache: diff --git a/content/home.yml b/content/home.yml new file mode 100644 index 0000000..4d3338d --- /dev/null +++ b/content/home.yml @@ -0,0 +1,61 @@ +kicker: + de: Informatik · Server-Administration · Homelab + en: Computer science · Server administration · Homelab + +lead: + de: Ich studiere Informatik und helfe bei der Administration eines Roleplay-Servers. Zu Hause betreibe ich ein Homelab und schreibe kleine Skripte für alles, was ich nicht ständig von Hand machen will. + en: I study computer science and help run the servers of a roleplay community. At home I keep a homelab going and write small scripts for anything I don't want to keep doing by hand. + +# visual: server, homelab, scripts, study oder video (die Grafik in der Kachel) +# link: eine Seite (homelab, journey, contact) oder portfolio?tag=... +areas: + - visual: server + title: + de: RP-Server-Administration + en: Roleplay server admin + text: + de: Ich helfe dabei, einen Roleplay-Server zu betreiben. Dazu gehören Updates, Fehlersuche und Absprachen mit dem restlichen Team, wenn etwas nicht so läuft wie geplant. + en: I help run a roleplay server. That means updates, troubleshooting and working things out with the rest of the team when something doesn't behave. + link: portfolio?tag=server + + - visual: homelab + title: Homelab + text: + de: Ein paar Rechner, Docker und ein Reverse Proxy. Hier probiere ich neue Dienste aus und mache meine Fehler dort, wo sie niemandem schaden. + en: A few machines, Docker and a reverse proxy. This is where I try out new services and make my mistakes where they don't hurt anyone. + link: homelab + + - visual: scripts + title: + de: Skripte & Tools + en: Scripts & tools + text: + de: Kleine Helfer für wiederkehrende Aufgaben. Meistens unspektakulär, aber sie nehmen mir jede Woche ein bisschen Arbeit ab. + en: Small helpers for recurring chores. Mostly unspectacular, but they take a bit of work off my plate every week. + link: portfolio?tag=scripts + + - visual: study + title: + de: Informatik-Studium + en: Computer science + text: + de: Theorie, Mathe und viel Programmierung. Einiges davon begegnet mir im Homelab wieder, nur mit echten Fehlermeldungen. + en: Theory, maths and a lot of programming. Some of it shows up again in my homelab, just with real error messages. + link: journey + + - visual: video + title: + de: Videoschnitt + en: Video editing + text: + de: Nebenbei versuche ich mich am Videoschnitt. Ich lerne noch, aber an Timing und Ton zu feilen, bis ein Schnitt sitzt, macht mir Spaß. + en: On the side I'm learning video editing. I'm still at it, but tweaking timing and sound until a cut feels right is fun. + link: portfolio?tag=video + +contact: + title: + de: Suchst du jemanden für die IT? + en: Looking for someone in IT? + text: + de: Ich suche eine Stelle in der IT, am liebsten in der Administration, im Support oder dort, wo sich etwas automatisieren lässt. Schreib mir, wenn das zu euch passt. + en: I'm looking for a job in IT, ideally in administration, support or anywhere things can be automated. Get in touch if that fits your team. diff --git a/content/homelab.yml b/content/homelab.yml new file mode 100644 index 0000000..4b8aa6a --- /dev/null +++ b/content/homelab.yml @@ -0,0 +1,44 @@ +# Solange draft: true gesetzt ist, ist die Homelab-Seite nur lokal sichtbar +draft: true + +hardware: + - name: Server + role: + de: Docker-Host + en: Docker host + specs: + - CPU + - RAM + - de: Speicher + en: Storage + +services: + - group: + de: Infrastruktur + en: Infrastructure + items: + - name: Dockge + text: + de: Verwaltet die Docker-Compose-Stacks + en: Manages the Docker Compose stacks + - name: Nginx Proxy Manager + text: + de: Reverse Proxy und TLS-Zertifikate + en: Reverse proxy and TLS certificates + + - group: + de: Entwicklung + en: Development + items: + - name: Git-Server + url: https://git.harlelux.de + text: + de: Meine Repositories, auch das dieser Seite + en: My repositories, including the one for this site + + - group: Web + items: + - name: lux.harlelux.de + text: + de: Diese Seite + en: This site diff --git a/content/journey.yml b/content/journey.yml new file mode 100644 index 0000000..0504faa --- /dev/null +++ b/content/journey.yml @@ -0,0 +1,29 @@ +# Neueste Einträge zuerst. +# from/to: Jahr (2024) oder Monat (2024-10). Für laufende Sachen ongoing: true statt to. +# type: edu, work oder project (bestimmt nur die Farbe des Punkts) +# draft: true blendet einen Eintrag online aus + +entries: + - title: + de: Informatik-Studium + en: Computer science degree + type: edu + from: 2024 + ongoing: true + draft: true + + - title: + de: Administration eines Roleplay-Servers + en: Administration of a roleplay server + type: work + from: 2024 + ongoing: true + draft: true + + - title: + de: Eigenes Homelab + en: My own homelab + type: project + from: 2023 + ongoing: true + draft: true diff --git a/content/pages/homelab.de.md b/content/pages/homelab.de.md new file mode 100644 index 0000000..18ebe05 --- /dev/null +++ b/content/pages/homelab.de.md @@ -0,0 +1,8 @@ +--- +title: Homelab +lead: Was bei mir zu Hause läuft und wofür. Das meiste davon bräuchte ich nicht, aber ich lerne dabei mehr als aus jedem Tutorial. +--- + +## Was ich dabei gelernt habe + +Hier kommt rein, was im Homelab schiefgegangen ist und was ich daraus mitgenommen habe. Konkrete Beispiele sind besser als allgemeine Aussagen, zum Beispiel ein Update, das etwas kaputt gemacht hat, und wie ich es wieder hinbekommen habe. diff --git a/content/pages/homelab.en.md b/content/pages/homelab.en.md new file mode 100644 index 0000000..9f0fba1 --- /dev/null +++ b/content/pages/homelab.en.md @@ -0,0 +1,8 @@ +--- +title: Homelab +lead: What runs at my place and what it's for. I wouldn't strictly need most of it, but I learn more from it than from any tutorial. +--- + +## What I learned along the way + +This is where the things that went wrong in the homelab go, and what I took away from them. Concrete examples work better than general statements, for example an update that broke something and how I got it running again. diff --git a/content/pages/journey.de.md b/content/pages/journey.de.md new file mode 100644 index 0000000..fe851c9 --- /dev/null +++ b/content/pages/journey.de.md @@ -0,0 +1,10 @@ +--- +title: Werdegang +lead: Wer ich bin, was ich bisher gemacht habe und womit ich mich auskenne. +--- + +Ich bin Lux und studiere Informatik. Neben dem Studium helfe ich bei der Administration eines Roleplay-Servers und betreibe zu Hause ein Homelab, in dem ich neue Dienste ausprobiere, bevor ich ihnen vertraue. + +Am meisten Spaß macht mir Arbeit, bei der am Ende etwas zuverlässig läuft. Ein Dienst läuft oder er läuft nicht, und wenn nicht, gibt es einen Grund, den man finden kann. + +Ab und zu schneide ich Videos. Das ist eine andere Art von Basteln, bei der es ebenfalls um Details geht, die am Ende niemandem auffallen sollen. diff --git a/content/pages/journey.en.md b/content/pages/journey.en.md new file mode 100644 index 0000000..69e14f7 --- /dev/null +++ b/content/pages/journey.en.md @@ -0,0 +1,10 @@ +--- +title: Journey +lead: Who I am, what I've done so far and what I know my way around. +--- + +I'm Lux and I study computer science. Alongside my degree I help administer a roleplay server, and at home I run a homelab where I try out new services before I trust them. + +I enjoy work most when something runs reliably at the end of it. A service either works or it doesn't, and if it doesn't, there's a reason you can track down. + +Every now and then I edit videos. It's a different kind of tinkering, but it's also about details nobody should notice in the end. diff --git a/content/pages/privacy.de.md b/content/pages/privacy.de.md new file mode 100644 index 0000000..b14ef1c --- /dev/null +++ b/content/pages/privacy.de.md @@ -0,0 +1,37 @@ +--- +title: Datenschutzerklärung +lead: Wer hier welche Daten verarbeitet und warum. Kurz gesagt so wenig wie möglich. +--- + +## Verantwortlich + +Vorname Nachname
+Straße Hausnummer
+PLZ Ort
+E-Mail: name@example.org + +## Beim Aufruf der Seite + +Wenn du diese Seite aufrufst, verarbeitet der Server technisch notwendige Daten: deine IP-Adresse, Datum und Uhrzeit, die aufgerufene Adresse, den Browser-Typ (User-Agent) und den HTTP-Statuscode. Das ist nötig, um die Seite auszuliefern und den Betrieb abzusichern. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Die Protokolle werden nach [Anzahl] Tagen gelöscht. + +## Cookies, Tracking und Drittanbieter + +Diese Seite setzt keine Cookies und verwendet keine Tracking- oder Analyse-Werkzeuge. Schriftarten, Skripte und Bilder kommen von diesem Server, nicht von Drittanbietern. Einzige Ausnahme sind YouTube-Videos, siehe unten. + +## Kontaktformular + +Wenn du das Kontaktformular nutzt, werden Name, E-Mail-Adresse und Nachricht per E-Mail an mich weitergeleitet. Ich nutze die Angaben nur, um deine Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn es um eine Bewerbung oder eine mögliche Zusammenarbeit geht, ansonsten Art. 6 Abs. 1 lit. f DSGVO. Auf dem Webserver selbst wird die Nachricht nicht gespeichert. Die E-Mail lösche ich, sobald sie nicht mehr gebraucht wird. + +Für den Versand nutze ich den E-Mail-Dienst [Anbieter]. + +Zum Schutz vor Missbrauch zählt der Server, wie viele Nachrichten von einer IP-Adresse kommen. Dieser Zähler liegt nur im Arbeitsspeicher und wird nach einer Stunde verworfen. + +## YouTube-Videos + +Manche Projektseiten enthalten Videos von YouTube. Sie werden erst geladen, wenn du auf „Video laden“ klickst. Erst dann wird eine Verbindung zu YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland) aufgebaut, dabei werden unter anderem deine IP-Adresse und gegebenenfalls Cookies übertragen. Eingebunden wird der erweiterte Datenschutzmodus über youtube-nocookie.com. Rechtsgrundlage ist deine Einwilligung durch den Klick (Art. 6 Abs. 1 lit. a DSGVO). Details stehen in der [Datenschutzerklärung von Google](https://policies.google.com/privacy). + +## Deine Rechte + +Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21). Außerdem kannst du dich bei einer Datenschutz-Aufsichtsbehörde beschweren, zum Beispiel bei der deines Bundeslandes. + +Stand: Oktober 2026 diff --git a/content/pages/privacy.en.md b/content/pages/privacy.en.md new file mode 100644 index 0000000..00fbaf1 --- /dev/null +++ b/content/pages/privacy.en.md @@ -0,0 +1,39 @@ +--- +title: Privacy policy +lead: Who processes which data here and why. In short, as little as possible. +--- + +The German version of this policy is the binding one. This translation is provided for convenience. + +## Controller + +First name Last name
+Street and number
+Postcode, city
+Email: name@example.org + +## When you visit this site + +When you open this site, the server processes technically necessary data: your IP address, date and time, the requested address, your browser type (user agent) and the HTTP status code. This is needed to deliver the site and keep it secure. The legal basis is Art. 6(1)(f) GDPR. Logs are deleted after [number] days. + +## Cookies, tracking and third parties + +This site doesn't set cookies and doesn't use any tracking or analytics tools. Fonts, scripts and images are served from this server, not from third parties. The only exception are YouTube videos, see below. + +## Contact form + +If you use the contact form, your name, email address and message are forwarded to me by email. I only use them to reply to your request. The legal basis is Art. 6(1)(b) GDPR when it's about a job application or a possible collaboration, otherwise Art. 6(1)(f) GDPR. The message is not stored on the web server itself. I delete the email once it's no longer needed. + +Emails are sent through [provider]. + +To prevent abuse, the server counts how many messages come from an IP address. This counter only lives in memory and is discarded after one hour. + +## YouTube videos + +Some project pages contain YouTube videos. They are only loaded after you click "Load video". Only then does your browser connect to YouTube (Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Ireland), which transfers your IP address and possibly cookies, among other things. Videos are embedded in privacy-enhanced mode via youtube-nocookie.com. The legal basis is your consent given by clicking (Art. 6(1)(a) GDPR). See [Google's privacy policy](https://policies.google.com/privacy) for details. + +## Your rights + +You have the right to access (Art. 15 GDPR), rectification (Art. 16), erasure (Art. 17), restriction of processing (Art. 18), data portability (Art. 20) and to object (Art. 21). You can also lodge a complaint with a data protection supervisory authority. + +Last updated: October 2026 diff --git a/content/projects/homelab/cover.jpg b/content/projects/homelab/cover.jpg new file mode 100644 index 0000000..e1f6e3d Binary files /dev/null and b/content/projects/homelab/cover.jpg differ diff --git a/content/projects/homelab/de.md b/content/projects/homelab/de.md new file mode 100644 index 0000000..16d048a --- /dev/null +++ b/content/projects/homelab/de.md @@ -0,0 +1,8 @@ +--- +title: Mein Homelab +summary: Docker-Host mit Dockge, Nginx Proxy Manager und eigenem Git-Server. +--- + +Hier beschreiben, wie das Homelab aufgebaut ist: welche Hardware, welches Betriebssystem, wie die Dienste verteilt sind und wie Updates und Backups laufen. + +Bilder einfach in diesen Ordner legen und mit `![Beschreibung](foto.jpg)` einbinden. diff --git a/content/projects/homelab/en.md b/content/projects/homelab/en.md new file mode 100644 index 0000000..97a8e75 --- /dev/null +++ b/content/projects/homelab/en.md @@ -0,0 +1,6 @@ +--- +title: My homelab +summary: Docker host with Dockge, Nginx Proxy Manager and a self-hosted Git server. +--- + +Describe how the homelab is set up: hardware, operating system, how services are split up and how updates and backups work. diff --git a/content/projects/homelab/meta.yml b/content/projects/homelab/meta.yml new file mode 100644 index 0000000..f4bf84d --- /dev/null +++ b/content/projects/homelab/meta.yml @@ -0,0 +1,11 @@ +draft: true +date: 2026-09 +tags: [homelab, docker, linux] +featured: true +cover: cover.jpg +# gallery: +# - rack.jpg +# - src: grafana.png +# caption: +# de: Dashboard mit den wichtigsten Diensten +# en: Dashboard with the most important services diff --git a/content/projects/personal-website/bento.jpg b/content/projects/personal-website/bento.jpg new file mode 100644 index 0000000..c26ef48 Binary files /dev/null and b/content/projects/personal-website/bento.jpg differ diff --git a/content/projects/personal-website/cover.jpg b/content/projects/personal-website/cover.jpg new file mode 100644 index 0000000..bffdf87 Binary files /dev/null and b/content/projects/personal-website/cover.jpg differ diff --git a/content/projects/personal-website/de.md b/content/projects/personal-website/de.md new file mode 100644 index 0000000..6abb615 --- /dev/null +++ b/content/projects/personal-website/de.md @@ -0,0 +1,19 @@ +--- +title: Diese Website +summary: Eigene Seite mit Node.js und Express, Inhalte als Markdown-Dateien, ausgeliefert als Docker-Container. +--- + +Ich wollte eine Seite, die ich selbst verstehe und ohne CMS pflegen kann. Deshalb ist sie bewusst einfach aufgebaut. + +## Aufbau + +- **Server:** Node.js mit Express, die Seiten rendert Nunjucks auf dem Server. +- **Inhalte:** Jedes Projekt ist ein Ordner mit einer YAML-Datei für die Metadaten und einer Markdown-Datei pro Sprache. Neue Einträge kommen per `git pull` auf den Server und werden ohne Neustart übernommen. +- **Bilder:** sharp rechnet sie beim ersten Aufruf in passende Größen um und legt sie in einem Cache ab. +- **Betrieb:** Ein Docker-Container in Dockge, davor Nginx Proxy Manager für HTTPS. Der Container läuft mit schreibgeschütztem Dateisystem und ohne zusätzliche Rechte. + +## Worauf ich geachtet habe + +Die Seite setzt keine Cookies und lädt nichts von fremden Servern, auch die Schriftarten liegen lokal. Ohne JavaScript funktioniert alles weiterhin, die Animationen sind nur eine Ergänzung und halten sich an die Systemeinstellung für reduzierte Bewegung. + +Der Hintergrund auf der Startseite ist ein kleiner WebGL-Shader, der die Streifen der Genderfluid-Flagge ineinanderfließen lässt. diff --git a/content/projects/personal-website/en.md b/content/projects/personal-website/en.md new file mode 100644 index 0000000..81ca197 --- /dev/null +++ b/content/projects/personal-website/en.md @@ -0,0 +1,19 @@ +--- +title: This website +summary: My own site built with Node.js and Express, content as Markdown files, shipped as a Docker container. +--- + +I wanted a site I fully understand and can maintain without a CMS, so it's deliberately simple. + +## How it's built + +- **Server:** Node.js with Express, pages are rendered on the server with Nunjucks. +- **Content:** Every project is a folder with a YAML file for metadata and one Markdown file per language. New entries reach the server with `git pull` and are picked up without a restart. +- **Images:** sharp resizes them on first request and keeps the results in a cache. +- **Hosting:** A Docker container managed in Dockge, with Nginx Proxy Manager in front for HTTPS. The container runs with a read-only filesystem and no extra privileges. + +## What I paid attention to + +The site sets no cookies and doesn't load anything from third-party servers, fonts included. Everything still works without JavaScript, the animations are an extra and respect the system setting for reduced motion. + +The background on the homepage is a small WebGL shader that lets the stripes of the genderfluid flag flow into each other. diff --git a/content/projects/personal-website/meta.yml b/content/projects/personal-website/meta.yml new file mode 100644 index 0000000..612e79e --- /dev/null +++ b/content/projects/personal-website/meta.yml @@ -0,0 +1,27 @@ +date: 2026-10 +tags: [web, docker] +featured: true +cover: cover.jpg +gallery: + - src: mobile.jpg + alt: + de: Startseite auf dem Smartphone + en: Homepage on a phone + caption: + de: Startseite auf dem Smartphone + en: Homepage on a phone + - src: bento.jpg + caption: + de: Übersicht auf der Startseite + en: Overview on the homepage + - src: portfolio.jpg + caption: + de: Portfolio mit Filter nach Themen + en: Portfolio with topic filter +links: + - label: + de: Quelltext + en: Source code + url: https://git.harlelux.de/Harlekin/Personal-Website + - label: lux.harlelux.de + url: https://lux.harlelux.de diff --git a/content/projects/personal-website/mobile.jpg b/content/projects/personal-website/mobile.jpg new file mode 100644 index 0000000..11ec62e Binary files /dev/null and b/content/projects/personal-website/mobile.jpg differ diff --git a/content/projects/personal-website/portfolio.jpg b/content/projects/personal-website/portfolio.jpg new file mode 100644 index 0000000..243bb8d Binary files /dev/null and b/content/projects/personal-website/portfolio.jpg differ diff --git a/content/projects/rp-server/cover.jpg b/content/projects/rp-server/cover.jpg new file mode 100644 index 0000000..e1f6e3d Binary files /dev/null and b/content/projects/rp-server/cover.jpg differ diff --git a/content/projects/rp-server/de.md b/content/projects/rp-server/de.md new file mode 100644 index 0000000..61b8805 --- /dev/null +++ b/content/projects/rp-server/de.md @@ -0,0 +1,6 @@ +--- +title: Administration eines Roleplay-Servers +summary: Updates, Fehlersuche und Betrieb für eine Roleplay-Community. +--- + +Hier beschreiben, um welches Spiel und welchen Server es geht, wie groß die Community ist und was genau deine Aufgaben im Team sind. Gut sind konkrete Beispiele, etwa ein Problem, das du gelöst hast, oder etwas, das du automatisiert hast. diff --git a/content/projects/rp-server/en.md b/content/projects/rp-server/en.md new file mode 100644 index 0000000..038295f --- /dev/null +++ b/content/projects/rp-server/en.md @@ -0,0 +1,6 @@ +--- +title: Running a roleplay server +summary: Updates, troubleshooting and day-to-day operations for a roleplay community. +--- + +Describe the game and server, the size of the community and what exactly your tasks in the team are. diff --git a/content/projects/rp-server/meta.yml b/content/projects/rp-server/meta.yml new file mode 100644 index 0000000..993a172 --- /dev/null +++ b/content/projects/rp-server/meta.yml @@ -0,0 +1,5 @@ +draft: true +date: 2026-08 +tags: [server, linux] +featured: true +cover: cover.jpg diff --git a/content/projects/scripts/cover.jpg b/content/projects/scripts/cover.jpg new file mode 100644 index 0000000..e1f6e3d Binary files /dev/null and b/content/projects/scripts/cover.jpg differ diff --git a/content/projects/scripts/de.md b/content/projects/scripts/de.md new file mode 100644 index 0000000..5490bc3 --- /dev/null +++ b/content/projects/scripts/de.md @@ -0,0 +1,11 @@ +--- +title: Skripte +summary: Kleine Helfer für wiederkehrende Aufgaben. +--- + +Hier ein oder mehrere Skripte vorstellen: welches Problem sie lösen, in welcher Sprache sie geschrieben sind und was du dabei gelernt hast. Code lässt sich direkt einbinden: + +```bash +#!/usr/bin/env bash +echo "Hallo" +``` diff --git a/content/projects/scripts/en.md b/content/projects/scripts/en.md new file mode 100644 index 0000000..fa2a427 --- /dev/null +++ b/content/projects/scripts/en.md @@ -0,0 +1,6 @@ +--- +title: Scripts +summary: Small helpers for recurring chores. +--- + +Introduce one or more scripts: what problem they solve, what they're written in and what you learned. diff --git a/content/projects/scripts/meta.yml b/content/projects/scripts/meta.yml new file mode 100644 index 0000000..d0fa27a --- /dev/null +++ b/content/projects/scripts/meta.yml @@ -0,0 +1,7 @@ +draft: true +date: 2026-07 +tags: [scripts, automation] +cover: cover.jpg +# links: +# - label: Repo +# url: https://git.harlelux.de/Harlekin/... diff --git a/content/projects/video-editing/cover.jpg b/content/projects/video-editing/cover.jpg new file mode 100644 index 0000000..e1f6e3d Binary files /dev/null and b/content/projects/video-editing/cover.jpg differ diff --git a/content/projects/video-editing/de.md b/content/projects/video-editing/de.md new file mode 100644 index 0000000..301eaee --- /dev/null +++ b/content/projects/video-editing/de.md @@ -0,0 +1,6 @@ +--- +title: Videoschnitt +summary: Erste Schnittprojekte. +--- + +Hier beschreiben, was du geschnitten hast, mit welchem Programm und worauf du beim Schnitt geachtet hast. diff --git a/content/projects/video-editing/en.md b/content/projects/video-editing/en.md new file mode 100644 index 0000000..3b35120 --- /dev/null +++ b/content/projects/video-editing/en.md @@ -0,0 +1,6 @@ +--- +title: Video editing +summary: First editing projects. +--- + +Describe what you edited, which software you used and what you paid attention to. diff --git a/content/projects/video-editing/meta.yml b/content/projects/video-editing/meta.yml new file mode 100644 index 0000000..29c3352 --- /dev/null +++ b/content/projects/video-editing/meta.yml @@ -0,0 +1,6 @@ +draft: true +date: 2026-06 +tags: [video] +cover: cover.jpg +# YouTube-Link oder eine Datei aus diesem Ordner (clip.mp4) +# video: https://youtu.be/... diff --git a/content/site.yml b/content/site.yml new file mode 100644 index 0000000..f9bc486 --- /dev/null +++ b/content/site.yml @@ -0,0 +1,45 @@ +name: Lux +pronouns: + de: alle Pronomen + en: any pronouns +description: + de: Lux studiert Informatik, hilft bei der Administration eines Roleplay-Servers und betreibt ein Homelab. Portfolio, Werdegang und Kontakt. + en: Lux studies computer science, helps run a roleplay server and keeps a homelab going. Portfolio, background and contact. + +# Leer lassen, wenn die Adresse nicht öffentlich sein soll +email: '' + +links: + - label: git.harlelux.de + url: https://git.harlelux.de/Harlekin + # - label: LinkedIn + # url: https://www.linkedin.com/in/... + +source: https://git.harlelux.de/Harlekin/Personal-Website + +footer: + de: Läuft als Docker-Container hinter Nginx Proxy Manager. Ohne Cookies, ohne Tracking. + en: Runs as a Docker container behind Nginx Proxy Manager. No cookies, no tracking. + +# Foto für den Werdegang, Pfad relativ zu content/ +# avatar: images/portrait.jpg + +now: + - de: Diese Website bauen und mit Inhalten füllen + en: Building this website and filling it with content + +skills: + - group: + de: Betrieb + en: Operations + items: [Linux, Docker, Docker Compose, Dockge, Nginx Proxy Manager, Git] + - group: + de: Entwicklung + en: Development + items: [JavaScript, Node.js, HTML, CSS] + - group: + de: Medien + en: Media + items: + - de: Videoschnitt + en: Video editing diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 0000000..f53a2cf --- /dev/null +++ b/locales/de.json @@ -0,0 +1,117 @@ +{ + "nav": { + "skip": "Zum Inhalt springen", + "label": "Hauptnavigation", + "menu": "Menü", + "close": "Schließen", + "home": "Startseite", + "portfolio": "Portfolio", + "homelab": "Homelab", + "journey": "Werdegang", + "contact": "Kontakt", + "switchLanguage": "English version" + }, + "home": { + "now": "Gerade dran", + "ctaPortfolio": "Zum Portfolio", + "ctaContact": "Kontakt aufnehmen", + "areas": "Was ich mache", + "featured": "Ausgewählte Projekte", + "allProjects": "Alle Projekte", + "tools": "Werkzeuge", + "more": "Mehr dazu" + }, + "portfolio": { + "title": "Portfolio", + "lead": "Projekte aus Homelab, Server-Administration, Studium und Videoschnitt. Manche groß, manche nur ein Skript, das mir Arbeit abnimmt.", + "filterLabel": "Nach Thema filtern", + "all": "Alle", + "empty": "Zu diesem Thema gibt es noch nichts.", + "back": "Alle Projekte", + "links": "Links", + "video": "Video", + "gallery": "Bilder", + "date": "Zeitraum", + "tags": "Themen", + "fallbackNote": "Diesen Eintrag gibt es bisher nur auf Englisch.", + "newer": "Neuer", + "older": "Älter", + "draft": "Entwurf", + "loadVideo": "Video laden", + "videoNotice": "Beim Abspielen wird eine Verbindung zu YouTube hergestellt.", + "openOnYoutube": "Auf YouTube ansehen", + "openVideo": "Video öffnen", + "showImage": "Bild vergrößern", + "prevImage": "Vorheriges Bild", + "nextImage": "Nächstes Bild", + "closeImage": "Schließen" + }, + "homelab": { + "hardware": "Hardware", + "services": "Dienste" + }, + "journey": { + "about": "Über mich", + "timeline": "Stationen", + "skills": "Kenntnisse", + "present": "heute", + "since": "seit {from}" + }, + "contact": { + "title": "Kontakt", + "lead": "Du hast eine Stelle, eine Frage zu einem Projekt oder willst einfach Hallo sagen? Schreib mir, ich melde mich.", + "name": "Name", + "email": "E-Mail", + "message": "Nachricht", + "send": "Nachricht senden", + "sending": "Wird gesendet …", + "success": "Danke, deine Nachricht ist angekommen. Ich antworte so bald wie möglich.", + "privacyNote": "Deine Angaben nutze ich nur, um dir zu antworten. Details stehen in der", + "privacyLink": "Datenschutzerklärung", + "direct": "Oder direkt", + "mailOnly": "Das Formular ist gerade nicht aktiv. Schreib mir einfach eine Mail:", + "honeypot": "Dieses Feld bitte leer lassen", + "errors": { + "check": "Bitte prüf deine Eingaben.", + "rateLimited": "Zu viele Nachrichten in kurzer Zeit. Versuch es später noch einmal oder schreib mir direkt eine Mail.", + "sendFailed": "Die Nachricht konnte gerade nicht verschickt werden. Versuch es später noch einmal oder schreib mir direkt.", + "unavailable": "Das Kontaktformular ist gerade nicht verfügbar." + }, + "fieldErrors": { + "required": "Bitte ausfüllen.", + "invalid": "Das sieht nicht nach einer E-Mail-Adresse aus.", + "tooShort": "Ein bisschen mehr Text bitte (mindestens 10 Zeichen).", + "tooLong": "Das ist leider zu lang." + } + }, + "footer": { + "pages": "Seiten", + "elsewhere": "Woanders", + "source": "Quelltext dieser Seite", + "privacy": "Datenschutz", + "top": "Nach oben" + }, + "error": { + "404": { + "title": "Seite nicht gefunden", + "text": "Unter dieser Adresse gibt es nichts. Vielleicht hat sich der Link geändert." + }, + "500": { + "title": "Da ist etwas schiefgelaufen", + "text": "Der Fehler liegt bei mir, nicht bei dir. Versuch es gleich noch einmal." + }, + "home": "Zur Startseite" + }, + "tags": { + "homelab": "Homelab", + "server": "Server", + "scripts": "Skripte", + "video": "Video", + "web": "Web", + "uni": "Studium", + "linux": "Linux", + "docker": "Docker", + "network": "Netzwerk", + "automation": "Automatisierung" + } +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..af44651 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,117 @@ +{ + "nav": { + "skip": "Skip to content", + "label": "Main navigation", + "menu": "Menu", + "close": "Close", + "home": "Home", + "portfolio": "Portfolio", + "homelab": "Homelab", + "journey": "Journey", + "contact": "Contact", + "switchLanguage": "Deutsche Version" + }, + "home": { + "now": "Currently", + "ctaPortfolio": "See the portfolio", + "ctaContact": "Get in touch", + "areas": "What I do", + "featured": "Selected projects", + "allProjects": "All projects", + "tools": "Tools", + "more": "Read more" + }, + "portfolio": { + "title": "Portfolio", + "lead": "Projects from my homelab, server administration, university and video editing. Some are big, some are just a script that saves me time.", + "filterLabel": "Filter by topic", + "all": "All", + "empty": "Nothing here yet.", + "back": "All projects", + "links": "Links", + "video": "Video", + "gallery": "Images", + "date": "When", + "tags": "Topics", + "fallbackNote": "This entry is only available in German so far.", + "newer": "Newer", + "older": "Older", + "draft": "Draft", + "loadVideo": "Load video", + "videoNotice": "Playing the video connects to YouTube.", + "openOnYoutube": "Watch on YouTube", + "openVideo": "Open video", + "showImage": "Enlarge image", + "prevImage": "Previous image", + "nextImage": "Next image", + "closeImage": "Close" + }, + "homelab": { + "hardware": "Hardware", + "services": "Services" + }, + "journey": { + "about": "About me", + "timeline": "Milestones", + "skills": "Skills", + "present": "today", + "since": "since {from}" + }, + "contact": { + "title": "Contact", + "lead": "Got a job opening, a question about one of my projects or just want to say hi? Send me a message and I'll get back to you.", + "name": "Name", + "email": "Email", + "message": "Message", + "send": "Send message", + "sending": "Sending …", + "success": "Thanks, your message arrived. I'll reply as soon as I can.", + "privacyNote": "I only use your details to reply to you. More in the", + "privacyLink": "privacy policy", + "direct": "Or directly", + "mailOnly": "The form is currently switched off. Just send me an email:", + "honeypot": "Please leave this field empty", + "errors": { + "check": "Please check your input.", + "rateLimited": "Too many messages in a short time. Please try again later or email me directly.", + "sendFailed": "The message couldn't be sent right now. Please try again later or email me directly.", + "unavailable": "The contact form is currently unavailable." + }, + "fieldErrors": { + "required": "Please fill this in.", + "invalid": "That doesn't look like an email address.", + "tooShort": "A little more text please (at least 10 characters).", + "tooLong": "That's too long, sorry." + } + }, + "footer": { + "pages": "Pages", + "elsewhere": "Elsewhere", + "source": "Source code of this site", + "privacy": "Privacy", + "top": "Back to top" + }, + "error": { + "404": { + "title": "Page not found", + "text": "There's nothing at this address. Maybe the link changed." + }, + "500": { + "title": "Something went wrong", + "text": "That's on me, not you. Please try again in a moment." + }, + "home": "Back to the homepage" + }, + "tags": { + "homelab": "Homelab", + "server": "Server", + "scripts": "Scripts", + "video": "Video", + "web": "Web", + "uni": "University", + "linux": "Linux", + "docker": "Docker", + "network": "Networking", + "automation": "Automation" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5489927 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2359 @@ +{ + "name": "personal-website", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "personal-website", + "version": "1.0.0", + "dependencies": { + "compression": "^1.8.2", + "express": "^5.3.0", + "express-rate-limit": "^8.7.1", + "helmet": "^8.3.0", + "marked": "^18.1.1", + "nodemailer": "^10.2.1", + "nunjucks": "^3.2.4", + "sharp": "^0.35.5", + "yaml": "^2.9.1" + }, + "devDependencies": { + "@fontsource-variable/bricolage-grotesque": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", + "esbuild": "^0.28.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource-variable/bricolage-grotesque": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz", + "integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz", + "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz", + "integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0188c24 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "personal-website", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "node scripts/dev.js", + "build": "node scripts/build.js", + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "compression": "^1.8.2", + "express": "^5.3.0", + "express-rate-limit": "^8.7.1", + "helmet": "^8.3.0", + "marked": "^18.1.1", + "nodemailer": "^10.2.1", + "nunjucks": "^3.2.4", + "sharp": "^0.35.5", + "yaml": "^2.9.1" + }, + "devDependencies": { + "@fontsource-variable/bricolage-grotesque": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", + "esbuild": "^0.28.2" + }, + "allowScripts": { + "esbuild@0.28.2": true + } +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..bfd19cd Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/avatar.png b/public/avatar.png new file mode 100644 index 0000000..6771286 Binary files /dev/null and b/public/avatar.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..b14dbb6 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/og.jpg b/public/og.jpg new file mode 100644 index 0000000..4a8780d Binary files /dev/null and b/public/og.jpg differ diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..654cc58 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,45 @@ +import * as esbuild from 'esbuild'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export const outdir = 'dist/assets'; + +export function buildOptions({ dev = false } = {}) { + return { + entryPoints: [ + { in: 'client/js/main.js', out: 'app' }, + { in: 'client/css/main.css', out: 'app' }, + ], + outdir, + bundle: true, + format: 'esm', + minify: !dev, + sourcemap: dev ? 'linked' : false, + metafile: true, + entryNames: '[name]-[hash]', + assetNames: '[name]-[hash]', + loader: { '.woff2': 'file', '.woff': 'file' }, + // Old enough that esbuild lowers CSS nesting for Safari 15/16 + target: ['chrome109', 'firefox115', 'safari15.4'], + logLevel: 'info', + }; +} + +// Maps "app.js" -> "/assets/app-HASH.js" so templates don't need to know the hash. +export async function writeManifest(metafile) { + const manifest = {}; + for (const [file, output] of Object.entries(metafile.outputs)) { + if (!output.entryPoint) continue; + const base = path.basename(file); + const key = base.replace(/-[A-Z0-9]{8}(\.\w+)$/, '$1'); + manifest[key] = '/assets/' + base; + } + await writeFile('dist/manifest.json', JSON.stringify(manifest, null, 2)); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await rm('dist', { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + const result = await esbuild.build(buildOptions()); + await writeManifest(result.metafile); +} diff --git a/scripts/dev.js b/scripts/dev.js new file mode 100644 index 0000000..8bb1e11 --- /dev/null +++ b/scripts/dev.js @@ -0,0 +1,62 @@ +import * as esbuild from 'esbuild'; +import { spawn } from 'node:child_process'; +import { existsSync, watch } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { buildOptions, outdir, writeManifest } from './build.js'; + +await rm('dist', { recursive: true, force: true }); +await mkdir(outdir, { recursive: true }); + +const ctx = await esbuild.context({ + ...buildOptions({ dev: true }), + plugins: [ + { + name: 'manifest', + setup(build) { + build.onEnd((result) => result.metafile && writeManifest(result.metafile)); + }, + }, + ], +}); +await ctx.watch(); + +// Restarting the server ourselves instead of node --watch-path: on Node 24 the +// watch-mode parent sometimes crashes on shutdown and leaves the server running. +const args = [...(existsSync('.env') ? ['--env-file=.env'] : []), 'src/server.js']; +let server; + +function start() { + server = spawn(process.execPath, args, { stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } }); +} + +function restart() { + if (!server || server.exitCode !== null) return start(); + server.once('exit', start); + server.kill(); +} + +let timer; +for (const dir of ['src', 'locales']) { + watch(dir, { recursive: true }, (event, file) => { + if (file?.endsWith('.njk')) return; // templates are re-read on every request anyway + clearTimeout(timer); + timer = setTimeout(() => { + console.log(`[dev] ${dir}/${file} changed, restarting`); + restart(); + }, 150); + }); +} + +start(); + +async function stop() { + await ctx.dispose(); + if (server?.exitCode === null) { + server.once('exit', () => process.exit(0)); + server.kill(); + } else { + process.exit(0); + } +} +process.on('SIGINT', stop); +process.on('SIGTERM', stop); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..314db78 --- /dev/null +++ b/src/app.js @@ -0,0 +1,121 @@ +import compression from 'compression'; +import express from 'express'; +import helmet from 'helmet'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import nunjucks from 'nunjucks'; +import { loadConfig, rootDir } from './config.js'; +import { createContentStore } from './content.js'; +import { formatDate, localize, locales, pickLocale } from './i18n.js'; +import { mediaHandler } from './images.js'; +import { createMailer } from './mail.js'; +import { localeRouter, renderError } from './routes/pages.js'; +import { seoRouter } from './routes/seo.js'; + +function manifestReader(production) { + const file = path.join(rootDir, 'dist', 'manifest.json'); + const read = () => { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return {}; + } + }; + if (!production) return read; + const manifest = read(); + if (!Object.keys(manifest).length) console.warn('[assets] dist/manifest.json missing, run npm run build'); + return () => manifest; +} + +function setupViews(app, config) { + const env = nunjucks.configure(path.join(rootDir, 'src', 'views'), { + autoescape: true, + express: app, + noCache: !config.production, + throwOnUndefined: false, + }); + app.set('view engine', 'njk'); + + const manifest = manifestReader(config.production); + env.addGlobal('asset', (name) => manifest()[name] ?? `/assets/${name}`); + + // Filters run with the template context as `this`, which carries the current language + env.addFilter('l', function (value) { + return localize(value, this.ctx.lang); + }); + env.addFilter('date', function (value, style) { + return formatDate(value, this.ctx.lang, style); + }); + env.addFilter('tag', function (tag) { + const label = this.ctx.t(`tags.${tag}`); + return label === `tags.${tag}` ? tag : label; + }); + return env; +} + +export async function createApp(options = {}) { + const config = options.config ?? loadConfig(); + const content = options.content ?? createContentStore(config); + if (!content.get()) await content.reload(); + const mailer = options.mailer ?? createMailer(config); + + const app = express(); + app.set('trust proxy', 'loopback, uniquelocal'); + setupViews(app, config); + + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'"], + imgSrc: ["'self'", 'data:'], + fontSrc: ["'self'"], + mediaSrc: ["'self'"], + connectSrc: ["'self'"], + frameSrc: ['https://www.youtube-nocookie.com'], + formAction: ["'self'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: config.production ? [] : null, + }, + }, + // HSTS is set by the reverse proxy + strictTransportSecurity: false, + }), + ); + app.use(compression()); + + app.get('/healthz', (req, res) => res.type('text').send('ok')); + app.use( + '/assets', + express.static(path.join(rootDir, 'dist', 'assets'), { immutable: true, maxAge: '365d', index: false }), + ); + app.use(express.static(path.join(rootDir, 'public'), { maxAge: '1d', index: false })); + app.get('/media/*file', mediaHandler(config)); + + const deps = { config, content, mailer }; + app.use(seoRouter(deps)); + + app.get('/', (req, res) => { + res.vary('Accept-Language'); + res.redirect(302, `/${pickLocale(req.get('accept-language'))}/`); + }); + for (const lang of locales) app.use(`/${lang}`, localeRouter(lang, deps)); + + app.use((req, res) => { + const lang = locales.find((l) => req.path.startsWith(`/${l}/`)) ?? pickLocale(req.get('accept-language')); + renderError(req, res, 404, lang, deps); + }); + + app.use((err, req, res, next) => { + console.error(err); + if (res.headersSent) return; + const lang = locales.find((l) => req.path.startsWith(`/${l}/`)) ?? 'de'; + renderError(req, res, err.status && err.status < 500 ? err.status : 500, lang, deps); + }); + + return app; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..0e26bf7 --- /dev/null +++ b/src/config.js @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const env = process.env; + +export function loadConfig(overrides = {}) { + const production = env.NODE_ENV === 'production'; + const smtp = env.SMTP_HOST + ? { + host: env.SMTP_HOST, + port: Number(env.SMTP_PORT) || 587, + secure: env.SMTP_SECURE === 'true', + auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined, + } + : null; + + return { + production, + port: Number(env.PORT) || 3000, + siteUrl: (env.SITE_URL || 'http://localhost:3000').replace(/\/+$/, ''), + contentDir: path.resolve(rootDir, env.CONTENT_DIR || 'content'), + cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'), + showDrafts: !production, + smtp, + mailFrom: env.MAIL_FROM || '', + mailTo: env.MAIL_TO || '', + ...overrides, + }; +} diff --git a/src/content.js b/src/content.js new file mode 100644 index 0000000..5465e20 --- /dev/null +++ b/src/content.js @@ -0,0 +1,343 @@ +import { watch } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { Marked } from 'marked'; +import sharp from 'sharp'; +import YAML from 'yaml'; +import { locales, otherLocale } from './i18n.js'; +import { isImage, isResizable, mediaUrl, resolveMedia, widths } from './images.js'; + +const textPages = ['homelab', 'journey', 'privacy']; + +export function parseFrontmatter(source) { + const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source); + if (!match) return { data: {}, body: source }; + return { data: YAML.parse(match[1]) ?? {}, body: source.slice(match[0].length) }; +} + +export function youtubeId(url) { + const match = + /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/))([\w-]{11})/.exec( + url, + ); + return match ? match[1] : null; +} + +const isRelative = (href) => href && !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(href); + +const escapeAttr = (value) => + String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); + +class Loader { + constructor(dir, { showDrafts }) { + this.dir = dir; + this.showDrafts = showDrafts; + this.warnings = []; + } + + warn(message) { + this.warnings.push(message); + } + + rel(file) { + return path.relative(this.dir, file).split(path.sep).join('/'); + } + + async readText(file) { + try { + return await readFile(file, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } + } + + async readYaml(file) { + const source = await this.readText(file); + if (source === null) return null; + try { + return YAML.parse(source) ?? {}; + } catch (err) { + throw new Error(`${this.rel(file)}: ${err.message.split('\n')[0].replace(/:$/, '')}`); + } + } + + // Relative path from a content file -> served URL, or null if it points somewhere we don't serve. + async media(baseDir, href) { + const file = path.resolve(baseDir, decodeURI(href)); + const rel = this.rel(file); + if (!resolveMedia(this.dir, rel)) { + this.warn(`${rel}: outside of content/ or unsupported file type`); + return null; + } + try { + const info = await stat(file); + return { file, rel, version: Math.round(info.mtimeMs).toString(36) }; + } catch { + this.warn(`${rel}: file not found`); + return null; + } + } + + async image(baseDir, href, alt = '') { + if (!href) return null; + const media = await this.media(baseDir, href); + if (!media) return null; + if (!isImage(media.file)) { + this.warn(`${media.rel}: not an image`); + return null; + } + + let width; + let height; + try { + const meta = await sharp(media.file).metadata(); + width = meta.autoOrient?.width ?? meta.width; + height = meta.autoOrient?.height ?? meta.height; + } catch (err) { + this.warn(`${media.rel}: ${err.message}`); + return null; + } + + const { rel, version } = media; + if (!isResizable(media.file) || width <= widths[0]) { + return { src: mediaUrl(rel, { version }), width, height, alt }; + } + + const srcset = []; + for (const w of widths) { + // withoutEnlargement keeps the original size, so describe what actually comes back + srcset.push(`${mediaUrl(rel, { width: w, version })} ${Math.min(w, width)}w`); + if (w >= width) break; + } + const src = srcset.at(-1).split(' ')[0]; + return { src, srcset: srcset.join(', '), width, height, alt }; + } + + async markdown(body, baseDir) { + const loader = this; + const marked = new Marked({ + async: true, + gfm: true, + async walkTokens(token) { + if (token.type === 'image' && isRelative(token.href)) { + token.media = await loader.image(baseDir, token.href, token.text); + } else if (token.type === 'link' && isRelative(token.href)) { + const media = await loader.media(baseDir, token.href); + if (media) token.href = mediaUrl(media.rel, { version: media.version }); + } + }, + renderer: { + image({ href, text, media }) { + if (!media) return `${escapeAttr(text)}`; + const srcset = media.srcset + ? ` srcset="${escapeAttr(media.srcset)}" sizes="(min-width: 60em) 42rem, 100vw"` + : ''; + return ( + `${escapeAttr(text)}` + ); + }, + }, + }); + return marked.parse(body); + } + + // Reads .de.md / .en.md and fills a missing language from the other one. + async localizedMarkdown(baseDir, name) { + const texts = {}; + for (const lang of locales) { + const source = await this.readText(path.join(baseDir, `${name}.${lang}.md`)); + if (source === null) continue; + const { data, body } = parseFrontmatter(source); + texts[lang] = { ...data, html: await this.markdown(body, baseDir) }; + } + for (const lang of locales) { + const other = otherLocale(lang); + if (!texts[lang] && texts[other]) texts[lang] = { ...texts[other], fallback: other }; + } + return Object.keys(texts).length ? texts : null; + } + + async video(baseDir, value) { + if (!value) return null; + const { src, poster } = typeof value === 'object' ? value : { src: value }; + const id = youtubeId(src ?? ''); + if (id) { + return { + type: 'youtube', + id, + url: `https://www.youtube.com/watch?v=${id}`, + poster: await this.image(baseDir, poster), + }; + } + if (/^https?:\/\//.test(src)) return { type: 'link', url: src }; + const media = await this.media(baseDir, src); + if (!media) return null; + return { + type: 'file', + src: mediaUrl(media.rel, { version: media.version }), + mime: media.file.endsWith('.webm') ? 'video/webm' : 'video/mp4', + poster: await this.image(baseDir, poster), + }; + } + + async project(slug) { + const dir = path.join(this.dir, 'projects', slug); + const meta = await this.readYaml(path.join(dir, 'meta.yml')); + if (!meta) { + this.warn(`projects/${slug}: meta.yml missing, skipped`); + return null; + } + + const texts = {}; + for (const lang of locales) { + const source = await this.readText(path.join(dir, `${lang}.md`)); + if (source === null) continue; + const { data, body } = parseFrontmatter(source); + if (!data.title) this.warn(`projects/${slug}/${lang}.md: no title`); + texts[lang] = { + title: data.title ?? slug, + summary: data.summary ?? '', + html: await this.markdown(body, dir), + }; + } + if (!texts.de && !texts.en) { + this.warn(`projects/${slug}: neither de.md nor en.md found, skipped`); + return null; + } + for (const lang of locales) { + const other = otherLocale(lang); + if (!texts[lang]) texts[lang] = { ...texts[other], fallback: other }; + } + + const gallery = []; + for (const item of meta.gallery ?? []) { + const entry = typeof item === 'string' ? { src: item } : item; + const image = await this.image(dir, entry.src, entry.alt ?? ''); + if (image) gallery.push({ ...image, caption: entry.caption }); + } + + const links = []; + for (const link of meta.links ?? []) { + if (!link?.url) continue; + let url = link.url; + if (isRelative(url)) { + const media = await this.media(dir, url); + if (!media) continue; + url = mediaUrl(media.rel, { version: media.version }); + } + links.push({ label: link.label ?? url, url }); + } + + return { + slug, + date: meta.date ? String(meta.date) : '', + tags: (meta.tags ?? []).map((tag) => String(tag).toLowerCase()), + featured: Boolean(meta.featured), + draft: Boolean(meta.draft), + cover: await this.image(dir, meta.cover), + gallery, + links, + video: await this.video(dir, meta.video), + text: texts, + }; + } + + async projects() { + let entries = []; + try { + entries = await readdir(path.join(this.dir, 'projects'), { withFileTypes: true }); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + + const projects = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue; + try { + const project = await this.project(entry.name); + if (project && (this.showDrafts || !project.draft)) projects.push(project); + } catch (err) { + this.warn(`${err.message}, skipped`); + } + } + return projects.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug)); + } + + async load() { + const site = (await this.readYaml(path.join(this.dir, 'site.yml'))) ?? {}; + if (!site.name) this.warn('site.yml: name missing'); + site.avatar = await this.image(this.dir, site.avatar, site.name ?? ''); + + const home = (await this.readYaml(path.join(this.dir, 'home.yml'))) ?? {}; + + const journey = ((await this.readYaml(path.join(this.dir, 'journey.yml')))?.entries ?? []).filter( + (entry) => entry && (this.showDrafts || !entry.draft), + ); + + let homelab = (await this.readYaml(path.join(this.dir, 'homelab.yml'))) ?? null; + if (homelab?.draft && !this.showDrafts) homelab = null; + + const pages = {}; + for (const name of textPages) { + pages[name] = await this.localizedMarkdown(path.join(this.dir, 'pages'), name); + } + + const projects = await this.projects(); + const tagCounts = new Map(); + for (const project of projects) { + for (const tag of project.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); + } + const tags = [...tagCounts] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + + return { site, home, journey, homelab, pages, projects, tags, loadedAt: new Date() }; + } +} + +export async function loadContent(dir, options = {}) { + const loader = new Loader(dir, { showDrafts: false, ...options }); + const content = await loader.load(); + return { content, warnings: loader.warnings }; +} + +export function createContentStore({ contentDir, showDrafts }) { + let current = null; + let timer = null; + let watcher = null; + + async function reload() { + const { content, warnings } = await loadContent(contentDir, { showDrafts }); + for (const warning of warnings) console.warn(`[content] ${warning}`); + current = content; + return content; + } + + // git pull touches a bunch of files at once, so wait until things settle + function schedule() { + clearTimeout(timer); + timer = setTimeout(() => { + reload().then( + (content) => console.log(`[content] reloaded, ${content.projects.length} projects`), + (err) => console.error(`[content] reload failed, keeping the previous version: ${err.message}`), + ); + }, 300); + } + + return { + get: () => current, + reload, + watch() { + watcher = watch(contentDir, { recursive: true }, schedule); + watcher.on('error', (err) => console.error(`[content] watcher: ${err.message}`)); + process.on('SIGHUP', schedule); + }, + close() { + clearTimeout(timer); + watcher?.close(); + process.off('SIGHUP', schedule); + }, + }; +} diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..bc5fbaf --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { rootDir } from './config.js'; + +export const locales = ['de', 'en']; +export const defaultLocale = 'de'; + +// Page id -> URL segment per language. The portfolio detail pages hang off "portfolio". +export const routes = { + home: { de: '', en: '' }, + portfolio: { de: 'portfolio', en: 'portfolio' }, + homelab: { de: 'homelab', en: 'homelab' }, + journey: { de: 'werdegang', en: 'journey' }, + contact: { de: 'kontakt', en: 'contact' }, + privacy: { de: 'datenschutz', en: 'privacy' }, +}; + +const strings = Object.fromEntries( + locales.map((lang) => [ + lang, + JSON.parse(readFileSync(path.join(rootDir, 'locales', `${lang}.json`), 'utf8')), + ]), +); + +export function otherLocale(lang) { + return lang === 'de' ? 'en' : 'de'; +} + +export function pathFor(lang, page, slug) { + const segment = routes[page][lang]; + let url = `/${lang}/` + segment; + if (slug) url += '/' + encodeURIComponent(slug); + return url; +} + +export function translator(lang) { + const dict = strings[lang]; + return (key, vars) => { + let value = key.split('.').reduce((obj, part) => obj?.[part], dict); + if (typeof value !== 'string') return key; + if (vars) value = value.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''); + return value; + }; +} + +// YAML fields may be a plain value or { de, en }. +export function localize(value, lang) { + if (value && typeof value === 'object' && !Array.isArray(value) && locales.some((l) => l in value)) { + return value[lang] ?? value[otherLocale(lang)]; + } + return value; +} + +export function pickLocale(acceptLanguage = '') { + const ranked = acceptLanguage + .split(',') + .map((part) => { + const [tag, ...params] = part.trim().split(';'); + const q = params.find((p) => p.trim().startsWith('q=')); + return { lang: tag.slice(0, 2).toLowerCase(), q: q ? Number(q.trim().slice(2)) : 1 }; + }) + .filter((entry) => entry.q > 0) + .sort((a, b) => b.q - a.q); + return ranked.find((entry) => locales.includes(entry.lang))?.lang ?? defaultLocale; +} + +// Accepts what people actually type in YAML: 2024, 2024-10 or 2024-10-01. +export function formatDate(value, lang, style = 'month') { + if (value === undefined || value === null || value === '') return ''; + const text = String(value); + if (/^\d{4}$/.test(text)) return text; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return text; + const options = style === 'year' ? { year: 'numeric' } : { month: 'long', year: 'numeric' }; + return new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-GB', { ...options, timeZone: 'UTC' }).format(date); +} diff --git a/src/images.js b/src/images.js new file mode 100644 index 0000000..403f248 --- /dev/null +++ b/src/images.js @@ -0,0 +1,95 @@ +import { createHash } from 'node:crypto'; +import { mkdir, rename, stat, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; + +// Only these widths are allowed, so nobody can fill the cache with ?w=1,2,3... +export const widths = [480, 960, 1600]; + +const resizable = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']); +const allowed = new Set([...resizable, '.gif', '.svg', '.mp4', '.webm', '.pdf']); + +export function isResizable(file) { + return resizable.has(path.extname(file).toLowerCase()); +} + +export function isImage(file) { + return isResizable(file) || ['.gif', '.svg'].includes(path.extname(file).toLowerCase()); +} + +export function mediaUrl(rel, { width, version } = {}) { + const encoded = rel.split('/').map(encodeURIComponent).join('/'); + const params = new URLSearchParams(); + if (width) params.set('w', width); + if (version) params.set('v', version); + const query = params.toString(); + return `/media/${encoded}${query ? '?' + query : ''}`; +} + +// Returns an absolute path inside contentDir, or null for anything we don't serve. +export function resolveMedia(contentDir, rel) { + if (!rel || rel.split('/').some((part) => !part || part.startsWith('.'))) return null; + if (!allowed.has(path.extname(rel).toLowerCase())) return null; + const file = path.resolve(contentDir, rel); + return file.startsWith(contentDir + path.sep) ? file : null; +} + +export function mediaHandler({ contentDir, cacheDir }) { + const pending = new Map(); + + return async (req, res, next) => { + const rel = [].concat(req.params.file).join('/'); + const file = resolveMedia(contentDir, rel); + if (!file) return next(); + + let info; + try { + info = await stat(file); + } catch { + return next(); + } + if (!info.isFile()) return next(); + + // URLs built by the content loader carry ?v=, those can be cached for good + const versioned = 'v' in req.query; + const cache = { maxAge: versioned ? '365d' : '1h', immutable: versioned }; + + const width = Number(req.query.w); + if (!req.query.w || !isResizable(file)) return res.sendFile(file, cache); + if (!widths.includes(width)) return next(); + + const key = createHash('sha1').update(`${rel}:${info.mtimeMs}:${width}`).digest('hex'); + const target = path.join(cacheDir, key.slice(0, 2), key + '.webp'); + if (!pending.has(key)) { + pending.set(key, resize(file, target, width).finally(() => pending.delete(key))); + } + try { + await pending.get(key); + } catch (err) { + return next(err); + } + res.sendFile(target, { ...cache, headers: { 'Content-Type': 'image/webp' } }); + }; +} + +async function resize(file, target, width) { + try { + await stat(target); + return; + } catch { + // not cached yet + } + await mkdir(path.dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.tmp`; + try { + await sharp(file) + .rotate() + .resize({ width, withoutEnlargement: true }) + .webp({ quality: 80 }) + .toFile(tmp); + await rename(tmp, target); + } catch (err) { + await unlink(tmp).catch(() => {}); + throw err; + } +} diff --git a/src/mail.js b/src/mail.js new file mode 100644 index 0000000..1576f9d --- /dev/null +++ b/src/mail.js @@ -0,0 +1,32 @@ +import nodemailer from 'nodemailer'; + +export function createMailer(config) { + if (config.smtp && config.mailTo) { + const transport = nodemailer.createTransport(config.smtp); + return { + enabled: true, + send: (message) => transport.sendMail({ from: config.mailFrom || config.smtp.auth?.user, to: config.mailTo, ...message }), + }; + } + + if (config.smtp) console.warn('[mail] SMTP_HOST is set but MAIL_TO is empty, contact form disabled'); + + // Without SMTP in development, print the mail instead of sending it + if (!config.production) { + const transport = nodemailer.createTransport({ jsonTransport: true }); + return { + enabled: true, + send: async (message) => { + const info = await transport.sendMail({ from: 'dev@localhost', to: 'dev@localhost', ...message }); + console.log('[mail]', JSON.parse(info.message)); + }, + }; + } + + return { + enabled: false, + send: async () => { + throw new Error('mail is not configured'); + }, + }; +} diff --git a/src/routes/contact.js b/src/routes/contact.js new file mode 100644 index 0000000..08dc3f7 --- /dev/null +++ b/src/routes/contact.js @@ -0,0 +1,118 @@ +import express from 'express'; +import { rateLimit } from 'express-rate-limit'; +import { render } from './pages.js'; + +const maxLength = { name: 100, email: 200, message: 5000 }; +const minMessage = 10; +// Humans need a few seconds to fill in three fields, most bots don't bother waiting +const minFillTime = 3000; + +export function validate(body) { + const values = { + name: String(body.name ?? '').trim(), + email: String(body.email ?? '').trim(), + message: String(body.message ?? '').trim(), + }; + const errors = {}; + if (!values.name) errors.name = 'required'; + else if (values.name.length > maxLength.name) errors.name = 'tooLong'; + + if (!values.email) errors.email = 'required'; + else if (values.email.length > maxLength.email) errors.email = 'tooLong'; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = 'invalid'; + + if (values.message.length < minMessage) errors.message = 'tooShort'; + else if (values.message.length > maxLength.message) errors.message = 'tooLong'; + + return { values, errors }; +} + +export function looksLikeBot(body, now = Date.now()) { + if (body.website) return true; + const started = Number(body.ts); + return !started || now - started < minFillTime; +} + +function sameOrigin(req, res, next) { + const origin = req.get('origin'); + if (!origin) return next(); + try { + if (new URL(origin).host === req.get('host')) return next(); + } catch { + // malformed Origin header, treat as foreign + } + res.status(403).type('text').send('Forbidden'); +} + +const wantsJson = (req) => req.accepts(['html', 'json']) === 'json'; + +export function contactRouter(lang, deps) { + const router = express.Router(); + + // One limiter for both languages, otherwise /de and /en would each get their own budget + deps.contactLimiter ??= rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 5, + standardHeaders: 'draft-8', + legacyHeaders: false, + handler: (req, res) => respond(req, res, 429, { error: 'rateLimited' }), + }); + + function respond(req, res, status, { values = {}, errors = {}, error = null, sent = false }) { + const { t } = res.locals; + res.status(status); + if (wantsJson(req)) { + return res.json({ + ok: sent, + message: sent ? t('contact.success') : error ? t(`contact.errors.${error}`) : t('contact.errors.check'), + errors: Object.fromEntries(Object.entries(errors).map(([field, code]) => [field, t(`contact.fieldErrors.${code}`)])), + }); + } + render(res, 'pages/contact.njk', 'contact', { + mailEnabled: deps.mailer.enabled, + form: { values, errors, error, sent }, + startedAt: Date.now(), + }); + } + + router.get('/', (req, res) => { + render(res, 'pages/contact.njk', 'contact', { + mailEnabled: deps.mailer.enabled, + form: { values: {}, errors: {} }, + startedAt: Date.now(), + }); + }); + + router.post( + '/', + express.urlencoded({ extended: false, limit: '20kb' }), + express.json({ limit: '20kb' }), + sameOrigin, + deps.contactLimiter, + async (req, res) => { + if (!deps.mailer.enabled) return respond(req, res, 503, { error: 'unavailable' }); + + const body = req.body ?? {}; + const { values, errors } = validate(body); + if (Object.keys(errors).length) return respond(req, res, 422, { values, errors }); + + // Pretend it worked so bots don't learn anything + if (looksLikeBot(body)) return respond(req, res, 200, { sent: true }); + + const name = values.name.replace(/\s+/g, ' '); + try { + await deps.mailer.send({ + replyTo: { name, address: values.email }, + subject: `Kontaktformular: ${name}`, + text: `${values.message}\n\n-- \n${name} <${values.email}>\n${deps.config.siteUrl}/${lang}/`, + }); + } catch (err) { + console.error(`[mail] sending failed: ${err.message}`); + return respond(req, res, 502, { values, error: 'sendFailed' }); + } + respond(req, res, 200, { sent: true }); + }, + ); + + return router; +} diff --git a/src/routes/pages.js b/src/routes/pages.js new file mode 100644 index 0000000..f8b326c --- /dev/null +++ b/src/routes/pages.js @@ -0,0 +1,123 @@ +import express from 'express'; +import { otherLocale, pathFor, routes, translator } from '../i18n.js'; +import { contactRouter } from './contact.js'; + +function locals(lang, deps) { + const content = deps.content.get(); + return { + lang, + altLang: otherLocale(lang), + t: translator(lang), + content, + site: content.site, + siteUrl: deps.config.siteUrl, + showDrafts: deps.config.showDrafts, + year: new Date().getFullYear(), + url: (page, slug, inLang = lang) => pathFor(inLang, page, slug), + // Content files can link to a page id ("homelab", "portfolio?tag=video") or a full URL. + // Returns null for targets that don't exist yet, e.g. a draft page or a tag without projects. + link: (value) => { + if (!value) return null; + const [page, query] = String(value).split('?'); + if (!routes[page]) return value; + if (page === 'homelab' && !content.homelab) return null; + if (page === 'privacy' && !content.pages.privacy) return null; + const tag = new URLSearchParams(query).get('tag'); + if (tag && !content.tags.some((entry) => entry.name === tag)) return null; + return pathFor(lang, page) + (query ? `?${query}` : ''); + }, + }; +} + +function navItems(lang, t, content, current) { + return [ + ['portfolio', t('nav.portfolio')], + content.homelab ? ['homelab', t('nav.homelab')] : null, + ['journey', t('nav.journey')], + ['contact', t('nav.contact')], + ] + .filter(Boolean) + .map(([page, label]) => ({ page, label, href: pathFor(lang, page), current: page === current })); +} + +export function render(res, view, page, data = {}) { + const { lang, t, content } = res.locals; + const routed = page in routes; + res.render(view, { + page, + nav: navItems(lang, t, content, page), + currentPath: routed ? pathFor(lang, page, data.slug) : undefined, + altPath: routed ? pathFor(otherLocale(lang), page, data.slug) : undefined, + ...data, + }); +} + +export function renderError(req, res, status, lang, deps) { + Object.assign(res.locals, locals(lang, deps)); + res.status(status); + render(res, 'pages/error.njk', 'error', { + status, + currentPath: req.path, + altPath: pathFor(otherLocale(lang), 'home'), + noindex: true, + }); +} + +export function localeRouter(lang, deps) { + const router = express.Router(); + const segment = (page) => `/${routes[page][lang]}`; + + router.use((req, res, next) => { + Object.assign(res.locals, locals(lang, deps)); + next(); + }); + + router.get('/', (req, res) => { + const { projects } = res.locals.content; + let featured = projects.filter((project) => project.featured); + if (!featured.length) featured = projects; + render(res, 'pages/home.njk', 'home', { featured: featured.slice(0, 3) }); + }); + + router.get(segment('portfolio'), (req, res) => { + const { projects, tags } = res.locals.content; + const tag = typeof req.query.tag === 'string' ? req.query.tag.toLowerCase() : ''; + const activeTag = tags.some((entry) => entry.name === tag) ? tag : null; + render(res, 'pages/portfolio.njk', 'portfolio', { projects, tags, activeTag }); + }); + + router.get(`${segment('portfolio')}/:slug`, (req, res, next) => { + const { projects } = res.locals.content; + const index = projects.findIndex((project) => project.slug === req.params.slug); + if (index === -1) return next(); + const project = projects[index]; + render(res, 'pages/project.njk', 'portfolio', { + project, + text: project.text[lang], + slug: project.slug, + newer: projects[index - 1], + older: projects[index + 1], + }); + }); + + router.get(segment('homelab'), (req, res, next) => { + const { homelab, pages } = res.locals.content; + if (!homelab) return next(); + render(res, 'pages/homelab.njk', 'homelab', { homelab, text: pages.homelab?.[lang] ?? {} }); + }); + + router.get(segment('journey'), (req, res) => { + const { journey, pages } = res.locals.content; + render(res, 'pages/journey.njk', 'journey', { journey, text: pages.journey?.[lang] ?? {} }); + }); + + router.get(segment('privacy'), (req, res, next) => { + const text = res.locals.content.pages.privacy?.[lang]; + if (!text) return next(); + render(res, 'pages/privacy.njk', 'privacy', { text }); + }); + + router.use(segment('contact'), contactRouter(lang, deps)); + + return router; +} diff --git a/src/routes/seo.js b/src/routes/seo.js new file mode 100644 index 0000000..e5f6064 --- /dev/null +++ b/src/routes/seo.js @@ -0,0 +1,42 @@ +import express from 'express'; +import { locales, pathFor } from '../i18n.js'; + +const escapeXml = (value) => + String(value).replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c]); + +function urlEntry(siteUrl, page, slug) { + const alternates = locales + .map((lang) => ` `) + .join('\n'); + return locales + .map((lang) => ` \n ${escapeXml(siteUrl + pathFor(lang, page, slug))}\n${alternates}\n `) + .join('\n'); +} + +export function seoRouter({ config, content }) { + const router = express.Router(); + + router.get('/robots.txt', (req, res) => { + res.type('text').send(`User-agent: *\nAllow: /\n\nSitemap: ${config.siteUrl}/sitemap.xml\n`); + }); + + router.get('/sitemap.xml', (req, res) => { + const { homelab, projects, pages } = content.get(); + const ids = ['home', 'portfolio', 'journey', 'contact']; + if (homelab) ids.push('homelab'); + if (pages.privacy) ids.push('privacy'); + + const entries = [ + ...ids.map((page) => urlEntry(config.siteUrl, page)), + ...projects.map((project) => urlEntry(config.siteUrl, 'portfolio', project.slug)), + ]; + res.type('application/xml').send( + '\n' + + '\n' + + entries.join('\n') + + '\n\n', + ); + }); + + return router; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..0f23d6e --- /dev/null +++ b/src/server.js @@ -0,0 +1,27 @@ +import { createApp } from './app.js'; +import { loadConfig } from './config.js'; +import { createContentStore } from './content.js'; + +const config = loadConfig(); +const content = createContentStore(config); + +try { + await content.reload(); +} catch (err) { + console.error(`[content] could not load ${config.contentDir}: ${err.message}`); + process.exit(1); +} +content.watch(); + +const app = await createApp({ config, content }); +const server = app.listen(config.port, () => { + console.log(`listening on http://localhost:${config.port} (${config.production ? 'production' : 'development'})`); +}); + +function shutdown() { + content.close(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5000).unref(); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/src/views/layout.njk b/src/views/layout.njk new file mode 100644 index 0000000..338b014 --- /dev/null +++ b/src/views/layout.njk @@ -0,0 +1,40 @@ + + + + + + {%- set fullTitle = (pageTitle + ' · ' + site.name) if pageTitle else site.name %} + {%- set description = pageDescription or (site.description | l) %} + {{ fullTitle }} + + + + {% if noindex %}{% endif %} + + {% if not noindex %} + + + + {% endif %} + + + + + + + + + + + + + + + + {% include "partials/header.njk" %} +
+ {% block content %}{% endblock %} +
+ {% include "partials/footer.njk" %} + + diff --git a/src/views/pages/contact.njk b/src/views/pages/contact.njk new file mode 100644 index 0000000..8db2968 --- /dev/null +++ b/src/views/pages/contact.njk @@ -0,0 +1,72 @@ +{% extends "layout.njk" %} +{% from "partials/macros.njk" import icon %} +{% set pageTitle = t('contact.title') %} +{% set pageDescription = t('contact.lead') %} + +{% block content %} +{% set headTitle = t('contact.title') %} +{% set headLead = t('contact.lead') %} +{% include "partials/page-head.njk" %} + +
+
+ {% if form.sent %} +

{{ t('contact.success') }}

+ {% elif mailEnabled %} +
+ {% if form.error or form.errors | length %} + + {% endif %} + + + {% for field in ['name', 'email'] %} +
+ + +

{% if form.errors[field] %}{{ t('contact.fieldErrors.' + form.errors[field]) }}{% endif %}

+
+ {% endfor %} + +
+ + +

{% if form.errors.message %}{{ t('contact.fieldErrors.' + form.errors.message) }}{% endif %}

+
+ + + + + +
+ {% else %} +

{{ t('contact.mailOnly') }}

+ {% if site.email %}

{{ site.email }}

{% endif %} + {% endif %} +
+ + {% if site.email or site.links.length %} + + {% endif %} +
+{% endblock %} diff --git a/src/views/pages/error.njk b/src/views/pages/error.njk new file mode 100644 index 0000000..4ed9548 --- /dev/null +++ b/src/views/pages/error.njk @@ -0,0 +1,16 @@ +{% extends "layout.njk" %} +{% from "partials/macros.njk" import icon %} +{% set key = '404' if status == 404 else '500' %} +{% set pageTitle = t('error.' + key + '.title') %} + +{% block content %} +
+ +
+ +

{{ t('error.' + key + '.title') }}

+

{{ t('error.' + key + '.text') }}

+ {{ t('error.home') }} {{ icon('arrow-right') }} +
+
+{% endblock %} diff --git a/src/views/pages/home.njk b/src/views/pages/home.njk new file mode 100644 index 0000000..3e48c7c --- /dev/null +++ b/src/views/pages/home.njk @@ -0,0 +1,119 @@ +{% extends "layout.njk" %} +{% from "partials/macros.njk" import icon %} +{% set home = content.home %} + +{% block content %} +
+ + +
+

{{ home.kicker | l }}

+

+ {{ site.name }} + {% if site.pronouns %}{{ site.pronouns | l }}{% endif %} +

+
+ + + {% if site.now and site.now.length %} + + {% endif %} +
+
+
+ +{% if home.areas and home.areas.length %} +
+
+
+ +

{{ t('home.areas') }}

+
+ +
+ {% for area in home.areas %} + + {% endfor %} +
+
+
+{% endif %} + +{% if featured.length %} +
+
+
+ + + {{ t('home.allProjects') }} {{ icon('arrow-right') }} +
+ + +
+
+{% endif %} + +{% if site.skills and site.skills.length %} +
+
+
+ +

{{ t('home.tools') }}

+
+
+
+ {% for pass in [1, 2] %} +
    + {% for group in site.skills %}{% for item in group.items %} +
  • {{ item | l }}
  • + {% endfor %}{% endfor %} +
+ {% endfor %} +
+
+{% endif %} + +{% if home.contact %} +
+
+
+

{{ home.contact.title | l }}

+

{{ home.contact.text | l }}

+ +
+
+
+{% endif %} +{% endblock %} diff --git a/src/views/pages/homelab.njk b/src/views/pages/homelab.njk new file mode 100644 index 0000000..d08aa5a --- /dev/null +++ b/src/views/pages/homelab.njk @@ -0,0 +1,72 @@ +{% extends "layout.njk" %} +{% set pageTitle = text.title or t('nav.homelab') %} +{% set pageDescription = text.lead %} + +{% block content %} +{% set headTitle = text.title or t('nav.homelab') %} +{% set headLead = text.lead %} +{% include "partials/page-head.njk" %} + +{% if homelab.draft %} +

{{ t('portfolio.draft') }}

+{% endif %} + +{% if homelab.hardware and homelab.hardware.length %} +
+
+
+ +

{{ t('homelab.hardware') }}

+
+
    + {% for machine in homelab.hardware %} +
  • +

    {{ machine.role | l }}

    +

    {{ machine.name }}

    + {% if machine.specs %} +
      + {% for spec in machine.specs %}
    • {{ spec | l }}
    • {% endfor %} +
    + {% endif %} +
  • + {% endfor %} +
+
+
+{% endif %} + +{% if homelab.services and homelab.services.length %} +
+
+
+ +

{{ t('homelab.services') }}

+
+
+ {% for group in homelab.services %} +
+

{{ group.group | l }}

+
    + {% for item in group.items %} +
  • + + {% if item.url %}{{ item.name }}{% else %}{{ item.name }}{% endif %} + {% if item.text %}{{ item.text | l }}{% endif %} +
  • + {% endfor %} +
+
+ {% endfor %} +
+
+
+{% endif %} + +{% if text.html %} +
+
+
{{ text.html | safe }}
+
+
+{% endif %} +{% endblock %} diff --git a/src/views/pages/journey.njk b/src/views/pages/journey.njk new file mode 100644 index 0000000..d13b2bc --- /dev/null +++ b/src/views/pages/journey.njk @@ -0,0 +1,75 @@ +{% extends "layout.njk" %} +{% from "partials/macros.njk" import picture %} +{% set pageTitle = text.title or t('nav.journey') %} +{% set pageDescription = text.lead %} + +{% block content %} +{% set headTitle = text.title or t('nav.journey') %} +{% set headLead = text.lead %} +{% include "partials/page-head.njk" %} + +{% if text.html %} +
+
+
+ +

{{ t('journey.about') }}

+
+ {% if site.avatar %} +
{{ picture(site.avatar, '(min-width: 60em) 20rem, 60vw', 'about__img') }}
+ {% endif %} +
{{ text.html | safe }}
+
+
+{% endif %} + +{% if journey.length %} +
+
+
+ +

{{ t('journey.timeline') }}

+
+
    + {% for entry in journey %} +
  1. + +
    +

    + {% if entry.to %}{{ entry.from | date('year') }} – {{ entry.to | date('year') }} + {% elif entry.ongoing %}{{ t('journey.since', { from: entry.from | date('year') }) }} + {% else %}{{ entry.from | date('year') }}{% endif %} + {% if entry.draft %}{{ t('portfolio.draft') }}{% endif %} +

    +

    {{ entry.title | l }}

    + {% if entry.place %}

    {{ entry.place | l }}

    {% endif %} + {% if entry.text %}

    {{ entry.text | l }}

    {% endif %} +
    +
  2. + {% endfor %} +
+
+
+{% endif %} + +{% if site.skills and site.skills.length %} +
+
+
+ +

{{ t('journey.skills') }}

+
+
+ {% for group in site.skills %} +
+

{{ group.group | l }}

+
    + {% for item in group.items %}
  • {{ item | l }}
  • {% endfor %} +
+
+ {% endfor %} +
+
+
+{% endif %} +{% endblock %} diff --git a/src/views/pages/portfolio.njk b/src/views/pages/portfolio.njk new file mode 100644 index 0000000..05abbf4 --- /dev/null +++ b/src/views/pages/portfolio.njk @@ -0,0 +1,33 @@ +{% extends "layout.njk" %} +{% set pageTitle = t('portfolio.title') %} +{% set pageDescription = t('portfolio.lead') %} + +{% block content %} +{% set headTitle = t('portfolio.title') %} +{% set headLead = t('portfolio.lead') %} +{% include "partials/page-head.njk" %} + +
+ {% if tags.length > 1 %} + + {% endif %} + +
    + {% for project in projects %} +
  • + {% set level = 2 %}{% include "partials/project-card.njk" %} +
  • + {% endfor %} +
+

{{ t('portfolio.empty') }}

+
+{% endblock %} diff --git a/src/views/pages/privacy.njk b/src/views/pages/privacy.njk new file mode 100644 index 0000000..a79fd8d --- /dev/null +++ b/src/views/pages/privacy.njk @@ -0,0 +1,14 @@ +{% extends "layout.njk" %} +{% set pageTitle = text.title %} + +{% block content %} +{% set headTitle = text.title %} +{% set headLead = text.lead %} +{% include "partials/page-head.njk" %} + +
+
+ {{ text.html | safe }} +
+
+{% endblock %} diff --git a/src/views/pages/project.njk b/src/views/pages/project.njk new file mode 100644 index 0000000..76bba42 --- /dev/null +++ b/src/views/pages/project.njk @@ -0,0 +1,111 @@ +{% extends "layout.njk" %} +{% from "partials/macros.njk" import icon, picture, linkIcon %} +{% set pageTitle = text.title %} +{% set pageDescription = text.summary %} + +{% block content %} +
+
+ +
+ {{ icon('arrow-left') }} {{ t('portfolio.back') }} +

{{ text.title }}

+ {% if text.summary %}

{{ text.summary }}

{% endif %} +
+ {% if project.date %} +
{{ t('portfolio.date') }}
+ {% endif %} + {% if project.tags.length %} +
{{ t('portfolio.tags') }}
+ {% endif %} + {% if project.draft %}
Status
{{ t('portfolio.draft') }}
{% endif %} +
+ {% if text.fallback %}

{{ t('portfolio.fallbackNote') }}

{% endif %} +
+
+ + {% if project.cover %} +
+ {{ picture(project.cover, '(min-width: 90em) 86rem, 100vw', 'project-cover__img', eager=true, alt='') }} +
+ {% endif %} + +
+
+ {{ text.html | safe }} +
+ + {% if project.links.length %} + + {% endif %} +
+ + {% if project.video %} +
+

{{ t('portfolio.video') }}

+ {% set video = project.video %} + {% if video.type == 'youtube' %} +
+ {% if video.poster or project.cover %}{{ picture(video.poster or project.cover, '(min-width: 75em) 72rem, 100vw', 'video__poster', alt='') }}{% endif %} +
+ + {{ icon('play') }} + {{ t('portfolio.loadVideo') }} + +

{{ t('portfolio.videoNotice') }}

+
+
+ {% elif video.type == 'file' %} + + {% else %} + {{ icon('play') }}{{ t('portfolio.openVideo') }} + {% endif %} +
+ {% endif %} + + {% if project.gallery.length %} +
+ + +
+ {% endif %} + + {% if newer or older %} + + {% endif %} +
+{% endblock %} diff --git a/src/views/partials/footer.njk b/src/views/partials/footer.njk new file mode 100644 index 0000000..b57dad7 --- /dev/null +++ b/src/views/partials/footer.njk @@ -0,0 +1,42 @@ +{% from "partials/macros.njk" import icon %} + diff --git a/src/views/partials/header.njk b/src/views/partials/header.njk new file mode 100644 index 0000000..3c96b95 --- /dev/null +++ b/src/views/partials/header.njk @@ -0,0 +1,29 @@ + diff --git a/src/views/partials/macros.njk b/src/views/partials/macros.njk new file mode 100644 index 0000000..5181eab --- /dev/null +++ b/src/views/partials/macros.njk @@ -0,0 +1,26 @@ +{% macro icon(name) -%} +{%- set paths = { + 'arrow-right': 'M5 12h14M13 6l6 6-6 6', + 'arrow-left': 'M19 12H5M11 6l-6 6 6 6', + 'arrow-up': 'M12 19V5M6 11l6-6 6 6', + 'external': 'M7 17 17 7M8 7h9v9', + 'mail': 'M3.5 6.5h17v11h-17zM4 7l8 6.5L20 7', + 'branch': 'M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9', + 'close': 'M6 6l12 12M18 6 6 18', + 'prev': 'M15 5l-7 7 7 7', + 'next': 'M9 5l7 7-7 7', + 'file': 'M14 3H7a1.5 1.5 0 0 0-1.5 1.5v15A1.5 1.5 0 0 0 7 21h10a1.5 1.5 0 0 0 1.5-1.5V7.5zM14 3v4.5h4.5', + 'play': 'M8 5.5v13l10.5-6.5z' +} -%} + +{%- endmacro %} + +{% macro picture(image, sizes='100vw', class='', eager=false, alt=undefined) -%} +{%- if image -%} + +{%- endif -%} +{%- endmacro %} + +{% macro linkIcon(url) -%} +{%- if url.indexOf('mailto:') == 0 %}mail{% elif url.indexOf('/media/') == 0 %}file{% elif url.indexOf('git') != -1 %}branch{% else %}external{% endif -%} +{%- endmacro %} diff --git a/src/views/partials/page-head.njk b/src/views/partials/page-head.njk new file mode 100644 index 0000000..b537b8f --- /dev/null +++ b/src/views/partials/page-head.njk @@ -0,0 +1,8 @@ +
+ +
+ {% if headKicker %}

{{ headKicker }}

{% endif %} +

{{ headTitle }}

+ {% if headLead %}

{{ headLead }}

{% endif %} +
+
diff --git a/src/views/partials/project-card.njk b/src/views/partials/project-card.njk new file mode 100644 index 0000000..c72f50c --- /dev/null +++ b/src/views/partials/project-card.njk @@ -0,0 +1,26 @@ +{% from "partials/macros.njk" import picture %} +{% set text = project.text[lang] %} + diff --git a/src/views/partials/visuals/homelab.njk b/src/views/partials/visuals/homelab.njk new file mode 100644 index 0000000..aab665d --- /dev/null +++ b/src/views/partials/visuals/homelab.njk @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/partials/visuals/scripts.njk b/src/views/partials/visuals/scripts.njk new file mode 100644 index 0000000..c7d6710 --- /dev/null +++ b/src/views/partials/visuals/scripts.njk @@ -0,0 +1,6 @@ +
#!/usr/bin/env bash
+set -euo pipefail
+
+for h in $(cat hosts); do
+  ssh "$h" uptime
+done
diff --git a/src/views/partials/visuals/server.njk b/src/views/partials/visuals/server.njk new file mode 100644 index 0000000..c73ab1e --- /dev/null +++ b/src/views/partials/visuals/server.njk @@ -0,0 +1,10 @@ +
+ {% for unit in [1, 2, 3, 4, 5] %} +
+ + + + +
+ {% endfor %} +
diff --git a/src/views/partials/visuals/study.njk b/src/views/partials/visuals/study.njk new file mode 100644 index 0000000..7463964 --- /dev/null +++ b/src/views/partials/visuals/study.njk @@ -0,0 +1,3 @@ +
+ {% for i in range(1, 9) %}{% endfor %} +
diff --git a/src/views/partials/visuals/video.njk b/src/views/partials/visuals/video.njk new file mode 100644 index 0000000..b79cf3f --- /dev/null +++ b/src/views/partials/visuals/video.njk @@ -0,0 +1,18 @@ +
+
+
+ V2 + +
+
+ V1 + + + +
+
+ A1 + +
+ +
diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 0000000..5d20654 --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; +import { createApp } from '../src/app.js'; +import { loadConfig } from '../src/config.js'; +import { createContentStore } from '../src/content.js'; +import { createFixture } from './fixtures.js'; + +let dir; +let server; +let base; +const sent = []; + +before(async () => { + dir = await createFixture(); + const config = loadConfig({ + production: true, + showDrafts: false, + contentDir: dir, + cacheDir: path.join(os.tmpdir(), `site-cache-${process.pid}`), + siteUrl: 'https://example.org', + }); + const content = createContentStore(config); + const mailer = { enabled: true, send: async (message) => sent.push(message) }; + const app = await createApp({ config, content, mailer }); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + server.close(); + await rm(dir, { recursive: true, force: true }); + await rm(path.join(os.tmpdir(), `site-cache-${process.pid}`), { recursive: true, force: true }); +}); + +const get = (url, options) => fetch(base + url, { redirect: 'manual', ...options }); + +test('pages render in both languages', async () => { + for (const url of [ + '/de/', + '/en/', + '/de/portfolio', + '/en/portfolio', + '/de/portfolio/alpha', + '/en/portfolio/alpha', + '/de/werdegang', + '/en/journey', + '/de/kontakt', + '/en/contact', + '/de/datenschutz', + '/en/privacy', + ]) { + const res = await get(url); + assert.equal(res.status, 200, url); + assert.match(res.headers.get('content-security-policy'), /script-src 'self'/); + } +}); + +test('the root redirects by Accept-Language', async () => { + let res = await get('/', { headers: { 'Accept-Language': 'en-GB,en;q=0.9' } }); + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), '/en/'); + res = await get('/', { headers: { 'Accept-Language': 'fr' } }); + assert.equal(res.headers.get('location'), '/de/'); +}); + +test('drafts, hidden pages and wrong slugs are 404', async () => { + for (const url of ['/de/portfolio/beta', '/de/homelab', '/de/journey', '/en/werdegang', '/de/nope', '/xx']) { + const res = await get(url); + assert.equal(res.status, 404, url); + } +}); + +test('homepage hides links to pages that do not exist yet', async () => { + const html = await (await get('/de/')).text(); + assert.doesNotMatch(html, /href="\/de\/homelab"/); + assert.match(html, /

{ + const html = await (await get('/de/portfolio?tag=nothing')).text(); + assert.doesNotMatch(html, /data-tags="web" hidden/); +}); + +test('media: resized images, no traversal, no source files', async () => { + let res = await get('/media/projects/alpha/cover.png?w=480'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/webp'); + + res = await get('/media/projects/alpha/cover.png?w=480&v=abc'); + assert.match(res.headers.get('cache-control'), /immutable/); + + res = await get('/media/projects/alpha/cover.png?w=123'); + assert.equal(res.status, 404); + + for (const url of ['/media/site.yml', '/media/projects/alpha/de.md', '/media/..%2Fpackage.json', '/media/projects/%2e%2e/%2e%2e/site.yml']) { + res = await get(url); + assert.equal(res.status, 404, url); + } +}); + +test('sitemap and robots', async () => { + const sitemap = await (await get('/sitemap.xml')).text(); + assert.match(sitemap, /https:\/\/example\.org\/en\/portfolio\/alpha<\/loc>/); + assert.doesNotMatch(sitemap, /beta|homelab/); + assert.match(await (await get('/robots.txt')).text(), /Sitemap: https:\/\/example\.org\/sitemap\.xml/); +}); + +const form = (fields) => + new URLSearchParams({ name: 'Ada', email: 'ada@example.org', message: 'Hallo, ich habe eine Frage.', ts: String(Date.now() - 10_000), ...fields }); + +const post = (body, headers = {}) => + get('/de/kontakt', { method: 'POST', body, headers: { Accept: 'application/json', ...headers } }); + +test('contact form', async (t) => { + await t.test('sends valid messages', async () => { + const res = await post(form()); + assert.equal(res.status, 200); + assert.equal((await res.json()).ok, true); + assert.equal(sent.length, 1); + assert.equal(sent[0].replyTo.address, 'ada@example.org'); + }); + + await t.test('rejects invalid input with field errors', async () => { + const res = await post(form({ email: 'nope', message: 'kurz' })); + assert.equal(res.status, 422); + const data = await res.json(); + assert.ok(data.errors.email); + assert.ok(data.errors.message); + }); + + await t.test('quietly drops bots', async () => { + let res = await post(form({ website: 'http://spam' })); + assert.equal((await res.json()).ok, true); + res = await post(form({ ts: String(Date.now()) })); + assert.equal((await res.json()).ok, true); + assert.equal(sent.length, 1); + }); + + await t.test('blocks foreign origins', async () => { + const res = await post(form(), { Origin: 'https://evil.example' }); + assert.equal(res.status, 403); + }); + + await t.test('rate limits', async () => { + // 4 requests above counted, plus this one = 5, the next one is too many + assert.equal((await post(form())).status, 200); + const res = await post(form()); + assert.equal(res.status, 429); + assert.match((await res.json()).message, /Zu viele/); + }); + + await t.test('works as a plain form post', async () => { + const res = await get('/en/contact', { method: 'POST', body: form() }); + assert.equal(res.status, 429); + assert.match(await res.text(), /Too many messages/); + }); +}); diff --git a/test/content.test.js b/test/content.test.js new file mode 100644 index 0000000..2870993 --- /dev/null +++ b/test/content.test.js @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; +import { createContentStore, loadContent, parseFrontmatter, youtubeId } from '../src/content.js'; +import { formatDate, localize, pickLocale } from '../src/i18n.js'; +import { createFixture } from './fixtures.js'; + +let dir; +before(async () => { + dir = await createFixture(); +}); +after(() => rm(dir, { recursive: true, force: true })); + +test('parseFrontmatter splits data and body', () => { + const { data, body } = parseFrontmatter('---\ntitle: Hi\n---\nBody\n'); + assert.equal(data.title, 'Hi'); + assert.equal(body, 'Body\n'); + assert.deepEqual(parseFrontmatter('no frontmatter').data, {}); +}); + +test('youtubeId understands the usual URL shapes', () => { + for (const url of [ + 'https://youtu.be/dQw4w9WgXcQ', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://www.youtube.com/watch?feature=share&v=dQw4w9WgXcQ', + 'https://www.youtube.com/shorts/dQw4w9WgXcQ', + 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ', + ]) { + assert.equal(youtubeId(url), 'dQw4w9WgXcQ', url); + } + assert.equal(youtubeId('https://vimeo.com/123'), null); +}); + +test('i18n helpers', () => { + assert.equal(localize({ de: 'Hallo', en: 'Hello' }, 'en'), 'Hello'); + assert.equal(localize({ de: 'Nur Deutsch' }, 'en'), 'Nur Deutsch'); + assert.equal(localize('plain', 'de'), 'plain'); + assert.equal(pickLocale('en-US,en;q=0.9,de;q=0.8'), 'en'); + assert.equal(pickLocale('fr-FR,de;q=0.5'), 'de'); + assert.equal(pickLocale('fr'), 'de'); + assert.equal(formatDate(2024, 'de'), '2024'); + assert.equal(formatDate('2026-10', 'de'), 'Oktober 2026'); + assert.equal(formatDate('2026-10-01', 'en'), 'October 2026'); +}); + +test('drafts are hidden unless asked for', async () => { + const { content } = await loadContent(dir); + assert.deepEqual(content.projects.map((p) => p.slug), ['alpha']); + assert.equal(content.homelab, null); + assert.deepEqual(content.journey.map((e) => e.title), ['Live']); + + const { content: withDrafts } = await loadContent(dir, { showDrafts: true }); + assert.deepEqual(withDrafts.projects.map((p) => p.slug), ['alpha', 'beta']); + assert.ok(withDrafts.homelab); +}); + +test('projects: fallback language, images, video', async () => { + const { content, warnings } = await loadContent(dir, { showDrafts: true }); + const alpha = content.projects.find((p) => p.slug === 'alpha'); + assert.equal(alpha.text.de.title, 'Alpha'); + assert.equal(alpha.text.en.title, 'Alpha'); + assert.equal(alpha.text.en.fallback, 'de'); + assert.equal(alpha.text.de.fallback, undefined); + + assert.equal(alpha.cover.width, 1200); + assert.match(alpha.cover.srcset, /w=480&v=\w+ 480w, .*w=960.* 960w, .*w=1600.* 1200w$/); + assert.match(alpha.text.de.html, / p.slug === 'beta'); + assert.equal(beta.text.de.fallback, 'en'); + + assert.ok(warnings.some((w) => w.startsWith('projects/broken/meta.yml:')), 'broken project is reported'); + assert.ok(!content.projects.some((p) => p.slug === 'broken')); +}); + +test('a broken site.yml keeps the previous content', async () => { + const store = createContentStore({ contentDir: dir, showDrafts: false }); + await store.reload(); + const before = store.get(); + + const file = path.join(dir, 'site.yml'); + await writeFile(file, 'name: [unclosed\n'); + await assert.rejects(store.reload()); + assert.equal(store.get(), before); + await writeFile(file, 'name: Test\n'); +}); diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 0000000..be6f477 --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,35 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import sharp from 'sharp'; + +// Builds a small content directory in /tmp so the tests don't depend on the real content. +export async function createFixture() { + const dir = await mkdtemp(path.join(os.tmpdir(), 'site-content-')); + const write = async (file, text) => { + await mkdir(path.dirname(path.join(dir, file)), { recursive: true }); + await writeFile(path.join(dir, file), text); + }; + + await write('site.yml', 'name: Test\npronouns:\n de: alle Pronomen\n en: any pronouns\nemail: test@example.org\n'); + await write('home.yml', 'lead:\n de: Hallo\n en: Hello\nareas:\n - visual: homelab\n title: Homelab\n link: homelab\n'); + await write('journey.yml', 'entries:\n - title: Live\n from: 2024\n - title: Hidden\n from: 2023\n draft: true\n'); + await write('homelab.yml', 'draft: true\nservices:\n - group: Web\n items:\n - name: nginx\n'); + await write('pages/journey.de.md', '---\ntitle: Werdegang\n---\nÜber mich'); + await write('pages/privacy.de.md', '---\ntitle: Datenschutz\n---\nText'); + await write('pages/privacy.en.md', '---\ntitle: Privacy\n---\nText'); + + await write('projects/alpha/meta.yml', 'date: 2026-05\ntags: [web]\nfeatured: true\ncover: cover.png\nvideo: https://youtu.be/dQw4w9WgXcQ\n'); + await write('projects/alpha/de.md', '---\ntitle: Alpha\nsummary: Erstes Projekt\n---\nText mit Bild\n\n![Ein Bild](cover.png)\n'); + await sharp({ create: { width: 1200, height: 750, channels: 3, background: '#c011d7' } }) + .png() + .toFile(path.join(dir, 'projects/alpha/cover.png')); + + await write('projects/beta/meta.yml', 'date: 2026-04\ntags: [video]\ndraft: true\n'); + await write('projects/beta/en.md', '---\ntitle: Beta\n---\nDraft'); + + await write('projects/broken/meta.yml', 'tags: [oops\n'); + await write('projects/broken/de.md', '---\ntitle: Kaputt\n---\n'); + + return dir; +}