Initial commit: Astro 6 static blog site
All checks were successful
Deploy / deploy (push) Successful in 49s
- German (default) and English i18n support - Categories and tags - Blog posts with hero images - Dark/light theme switcher - View Transitions removed to fix reload ghost images - Webmentions integration - RSS feeds per locale Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "astro-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 4321
|
||||
}
|
||||
]
|
||||
}
|
||||
63
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
DEPLOY_DIR: /opt/websites/adrian-altner.de
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync to deploy directory
|
||||
run: |
|
||||
sudo rsync -a --delete \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.production' \
|
||||
--exclude='.git/' \
|
||||
--exclude='node_modules/' \
|
||||
./ "${DEPLOY_DIR}/"
|
||||
|
||||
- name: Secret check
|
||||
env:
|
||||
WEBMENTION_TOKEN: ${{ secrets.WEBMENTION_TOKEN }}
|
||||
WEBMENTION_APP_TOKEN: ${{ secrets.WEBMENTION_APP_TOKEN }}
|
||||
run: |
|
||||
echo "WEBMENTION_TOKEN length: ${#WEBMENTION_TOKEN}"
|
||||
echo "WEBMENTION_APP_TOKEN length: ${#WEBMENTION_APP_TOKEN}"
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd "${DEPLOY_DIR}"
|
||||
sudo podman build \
|
||||
--no-cache \
|
||||
--build-arg WEBMENTION_TOKEN="${{ secrets.WEBMENTION_TOKEN }}" \
|
||||
-t localhost/adrian-altner.de:latest .
|
||||
|
||||
- name: Restart service
|
||||
run: sudo systemctl restart podman-compose@adrian-altner.de.service
|
||||
|
||||
- name: Prune
|
||||
run: |
|
||||
sudo podman container prune -f 2>/dev/null || true
|
||||
sudo podman image prune --external -f 2>/dev/null || true
|
||||
sudo podman image prune -f 2>/dev/null || true
|
||||
sudo podman builder prune -af 2>/dev/null || true
|
||||
|
||||
- name: Send webmentions
|
||||
env:
|
||||
WEBMENTION_APP_TOKEN: ${{ secrets.WEBMENTION_APP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$WEBMENTION_APP_TOKEN" ]; then
|
||||
echo "No WEBMENTION_APP_TOKEN — skipping."
|
||||
exit 0
|
||||
fi
|
||||
for feed in rss.xml en/rss.xml; do
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/${feed}&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
done
|
||||
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
4
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
CLAUDE.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run dev` — local dev server at `localhost:4321`
|
||||
- `npm run build` — production build to `./dist/`
|
||||
- `npm run preview` — preview production build
|
||||
- `npm run astro -- --help` — Astro CLI (e.g. `astro check` for type-checking)
|
||||
|
||||
Node >= 22.12.0 required.
|
||||
|
||||
## Path aliases
|
||||
|
||||
[tsconfig.json](tsconfig.json) defines a single `~/*` → `src/*` alias. Prefer it over relative imports (`~/components/Foo.astro` instead of `../../components/Foo.astro`) in `.astro`, `.ts`, `.js`, `.mjs`, and MDX files. Frontmatter `heroImage` paths and inline markdown `` image refs stay relative — Astro's image resolver expects those relative to the content file.
|
||||
|
||||
## Architecture
|
||||
|
||||
Astro 6 static site, based on the `blog` starter template. No tests, no linter configured. Site URL: `https://adrian-altner.de`.
|
||||
|
||||
### Internationalisation (de default, en secondary)
|
||||
|
||||
- Astro i18n is configured in [astro.config.mjs](astro.config.mjs) with `defaultLocale: 'de'`, `locales: ['de', 'en']`, `prefixDefaultLocale: false`. German lives at `/`, English at `/en/`.
|
||||
- Posts are organised per locale under `src/content/posts/<locale>/…`. Post `id` is `<locale>/<slug>`; helpers in [src/i18n/posts.ts](src/i18n/posts.ts) (`entryLocale`, `entrySlug`, `getPostsByLocale`, `getCategoriesByLocale`, `getPostsByCategory`, `categoryHref`, `categorySegment`) split and filter them. The content schema in [src/content.config.ts](src/content.config.ts) globs `{de,en}/**/*.{md,mdx}`. Collection name is `posts` — access via `getCollection('posts')` / `CollectionEntry<'posts'>`.
|
||||
- A second collection `categories` lives under `src/content/categories/<locale>/*.md` (schema: `name`, optional `description`, optional `translationKey`). Blog posts reference a category via `category: reference('categories')` in the schema; frontmatter values are fully-qualified entry ids, e.g. `category: de/technik` or `category: en/tech`. Resolve with `getEntry(post.data.category)`.
|
||||
- **Translation linking**: both collections support an optional `translationKey` string. Entries in different locales that represent the same logical content share one key (e.g. `de/technik` and `en/tech` both set `translationKey: tech`). The language switcher resolves this via `findTranslation(entry, target)` ([src/i18n/posts.ts](src/i18n/posts.ts)) to produce the correct target-locale URL. Pages that render a specific content entry should pass it to `Header` as `entry={…}` (see [src/pages/[...slug].astro](src/pages/[...slug].astro), [CategoryDetailPage](src/components/CategoryDetailPage.astro)); the `BlogPost` layout forwards an `entry` prop. When an entry has no translation, the switcher falls back to the other locale's home rather than producing a 404.
|
||||
- Category routes are localised in the URL: `/kategorie/<slug>` (de) and `/en/category/<slug>` (en), driven by [src/pages/kategorie/[slug].astro](src/pages/kategorie/[slug].astro) and [src/pages/en/category/[slug].astro](src/pages/en/category/[slug].astro). Both use shared UI components ([CategoryIndexPage](src/components/CategoryIndexPage.astro), [CategoryDetailPage](src/components/CategoryDetailPage.astro)). `categorySegment(locale)` returns the right URL segment.
|
||||
- Because posts sit two levels deep, hero images and component imports inside MD/MDX use `../../../assets/…` / `../../../components/…` relative paths.
|
||||
- UI strings and path helpers live in [src/i18n/ui.ts](src/i18n/ui.ts). `t(locale, key)` for translations; `localizePath(path, locale)` prefixes `/en` when needed; `switchLocalePath(pathname, target)` rewrites the current URL to the other locale (used by the header language switcher and hreflang alternates in [BaseHead.astro](src/components/BaseHead.astro)).
|
||||
- Site titles/descriptions per locale live in [src/consts.ts](src/consts.ts) (`SITE.de`, `SITE.en`). The `SITE[locale]` map is the single source of truth — update when rebranding.
|
||||
- Pages: German under `src/pages/` (`index.astro`, `about.astro`, `[...slug].astro`, `rss.xml.js`), English mirrors under `src/pages/en/`. The shared home UI lives in [src/components/HomePage.astro](src/components/HomePage.astro); both `index.astro` files are thin wrappers that pass `locale="de"` / `locale="en"`.
|
||||
- Layouts: [BaseLayout.astro](src/layouts/BaseLayout.astro) is the common skeleton (`<html>` / `<head>` with `BaseHead` / `<body>` with `Header` + `main` + `Footer`). Accepts `title`, `description`, `locale`, optional `image`, optional `entry` (for the language-switch translation lookup), optional `bodyClass`, and a `head` named slot for per-page `<link>`/`<meta>` extras. All page templates compose via this layout — don't re-assemble head/header/footer by hand. [Post.astro](src/layouts/Post.astro) wraps `BaseLayout` to add hero image + title block + category line for single posts. `Header`, `BaseHead`, and `FormattedDate` also accept `locale` directly and fall back to `getLocaleFromUrl(Astro.url)`.
|
||||
- Separate RSS feeds per locale: `/rss.xml` (de) and `/en/rss.xml`. The sitemap integration is configured with `i18n: { defaultLocale: 'de', locales: { de: 'de-DE', en: 'en-US' } }`.
|
||||
|
||||
### Routing and data flow
|
||||
|
||||
- [src/pages/[...slug].astro](src/pages/[...slug].astro) and [src/pages/en/[...slug].astro](src/pages/en/[...slug].astro) generate post pages via `getStaticPaths` + `getPostsByLocale(locale)`, rendering through [BlogPost.astro](src/layouts/BlogPost.astro).
|
||||
- `@astrojs/sitemap` generates the sitemap index; `<link rel="alternate" hreflang="…">` tags are emitted in `BaseHead.astro` for both locales plus `x-default`.
|
||||
- **Fonts**: Atkinson is loaded as a local font via Astro's `fonts` API in `astro.config.mjs`, exposed as CSS variable `--font-atkinson`. Files in `src/assets/fonts/`.
|
||||
- **MDX** is enabled via `@astrojs/mdx`; posts can mix Markdown and components.
|
||||
|
||||
## Hero image workflow
|
||||
|
||||
Per user convention: hero image templates live under `src/assets/heros/*`. Workflow uses Maple Mono font, headless Brave to render, then `sharp` to export JPG at 1020×510.
|
||||
35
Containerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
FROM node:22-bookworm-slim AS build
|
||||
|
||||
ARG WEBMENTION_TOKEN=""
|
||||
ENV WEBMENTION_TOKEN=$WEBMENTION_TOKEN
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||||
COPY --from=build --chown=node:node /app/package-lock.json ./package-lock.json
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["node", "dist/server/entry.mjs"]
|
||||
63
README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Astro Starter Kit: Blog
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template blog
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
Features:
|
||||
|
||||
- ✅ Minimal styling (make it your own!)
|
||||
- ✅ 100/100 Lighthouse performance
|
||||
- ✅ SEO-friendly with canonical URLs and Open Graph data
|
||||
- ✅ Sitemap support
|
||||
- ✅ RSS Feed support
|
||||
- ✅ Markdown & MDX support
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── components/
|
||||
│ ├── content/
|
||||
│ ├── layouts/
|
||||
│ └── pages/
|
||||
├── astro.config.mjs
|
||||
├── README.md
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
The `src/content/` directory contains "collections" of related Markdown and MDX documents. Use `getCollection()` to retrieve posts from `src/content/blog/`, and type-check your frontmatter using an optional schema. See [Astro's Content Collections docs](https://docs.astro.build/en/guides/content-collections/) to learn more.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
|
||||
## Credit
|
||||
|
||||
This theme is based off of the lovely [Bear Blog](https://github.com/HermanMartinus/bearblog/).
|
||||
75
astro.config.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// @ts-check
|
||||
|
||||
import mdx from '@astrojs/mdx';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { defineConfig, fontProviders } from 'astro/config';
|
||||
import { loadEnv } from 'vite';
|
||||
|
||||
import node from '@astrojs/node';
|
||||
|
||||
const envMode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||
const envVars = loadEnv(envMode, process.cwd(), '');
|
||||
const WEBMENTION_TOKEN = envVars.WEBMENTION_TOKEN || process.env.WEBMENTION_TOKEN || '';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://adrian-altner.de',
|
||||
|
||||
vite: {
|
||||
define: {
|
||||
'globalThis.__WEBMENTION_TOKEN__': JSON.stringify(WEBMENTION_TOKEN),
|
||||
},
|
||||
},
|
||||
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'de',
|
||||
locales: ['de', 'en'],
|
||||
routing: {
|
||||
prefixDefaultLocale: false,
|
||||
redirectToDefaultLocale: false,
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
mdx(),
|
||||
sitemap({
|
||||
i18n: {
|
||||
defaultLocale: 'de',
|
||||
locales: { de: 'de-DE', en: 'en-US' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
fonts: [
|
||||
{
|
||||
provider: fontProviders.local(),
|
||||
name: 'Atkinson',
|
||||
cssVariable: '--font-atkinson',
|
||||
fallbacks: ['sans-serif'],
|
||||
options: {
|
||||
variants: [
|
||||
{
|
||||
src: ['./src/assets/fonts/atkinson-regular.woff'],
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
},
|
||||
{
|
||||
src: ['./src/assets/fonts/atkinson-bold.woff'],
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
});
|
||||
13
compose.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: adrian-altner-de
|
||||
|
||||
services:
|
||||
website:
|
||||
image: localhost/adrian-altner.de:latest
|
||||
container_name: adrian-altner.de
|
||||
ports:
|
||||
- "4321:4321"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 4321
|
||||
restart: unless-stopped
|
||||
5928
package-lock.json
generated
Normal file
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "adrian-altner.de",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
"@astrojs/node": "^10.0.5",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"astro": "^6.1.8",
|
||||
"sharp": "^0.34.3"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 655 B |
9
public/favicon.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
76
scripts/VISION.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Vision Script
|
||||
|
||||
Generiert Metadaten-Sidecars (JSON) fuer Foto-Kollektionen mithilfe von EXIF-Daten und einer Vision-AI (Anthropic oder OpenAI).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- `exiftool` installiert (`brew install exiftool`)
|
||||
- `ANTHROPIC_API_KEY` oder `OPENAI_API_KEY` in `.env.local` gesetzt (je nach Provider)
|
||||
|
||||
## Aufruf
|
||||
|
||||
```bash
|
||||
pnpm run vision [optionen] [verzeichnis]
|
||||
```
|
||||
|
||||
Ohne Verzeichnis wird der Standard `content/fotos` verwendet.
|
||||
|
||||
## Optionen
|
||||
|
||||
| Option | Beschreibung |
|
||||
|---|---|
|
||||
| `--provider=anthropic\|openai` | Vision-API Provider (Standard: `anthropic`). Anthropic nutzt `claude-opus-4-6`, OpenAI nutzt `gpt-4o-mini`. |
|
||||
| `--refresh` | Alle Sidecars neu generieren (EXIF + AI). Ueberschreibt vorhandene Dateien. |
|
||||
| `--exif-only` | Nur EXIF-Daten in bestehenden Sidecars aktualisieren. AI-Felder (Titel, Alt, Tags) bleiben erhalten. |
|
||||
| `--concurrency=N` | Anzahl paralleler Vision-API-Anfragen (Standard: 2) |
|
||||
| `--retries=N` | Maximale Wiederholungsversuche bei Rate-Limits (Standard: 8) |
|
||||
| `--backoff-ms=N` | Basis-Wartezeit in ms fuer exponentielles Backoff (Standard: 1500) |
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Alternativ zu den CLI-Optionen koennen diese Werte auch per Umgebungsvariable gesetzt werden:
|
||||
|
||||
| Variable | Entspricht |
|
||||
|---|---|
|
||||
| `VISION_PROVIDER` | `--provider` |
|
||||
| `VISION_CONCURRENCY` | `--concurrency` |
|
||||
| `VISION_MAX_RETRIES` | `--retries` |
|
||||
| `VISION_BASE_BACKOFF_MS` | `--backoff-ms` |
|
||||
|
||||
CLI-Optionen haben Vorrang vor Umgebungsvariablen.
|
||||
|
||||
## Beispiele
|
||||
|
||||
```bash
|
||||
# Neue Bilder ohne Sidecar verarbeiten (Anthropic)
|
||||
pnpm run vision
|
||||
|
||||
# Mit OpenAI statt Anthropic
|
||||
pnpm run vision --provider=openai
|
||||
|
||||
# Alle Sidecars in einem bestimmten Ordner neu generieren
|
||||
pnpm run vision --refresh --provider=openai content/fotos/kollektionen/reisen/asien/thailand
|
||||
|
||||
# Nur EXIF-Daten aktualisieren (z.B. nach erneutem Lightroom-Export)
|
||||
pnpm run vision --exif-only
|
||||
|
||||
# Mit hoeherer Parallelitaet
|
||||
pnpm run vision --refresh --concurrency=4
|
||||
```
|
||||
|
||||
## Ausgabe
|
||||
|
||||
Pro Bild wird eine JSON-Sidecar-Datei mit folgendem Inhalt erstellt:
|
||||
|
||||
- `title` - 5 Titelvorschlaege (deutsch, via AI)
|
||||
- `alt` - Bildbeschreibung / Alt-Text (deutsch, via AI)
|
||||
- `tags` - 5 thematische Tags (deutsch, via AI)
|
||||
- `date` - Aufnahmedatum (aus EXIF)
|
||||
- `location` - GPS-Koordinaten (aus EXIF, falls vorhanden)
|
||||
- `locationName` - Aufgeloester Ortsname via Nominatim (falls GPS vorhanden)
|
||||
- `exif` - Kamera, Objektiv, Blende, ISO, Brennweite, Belichtungszeit
|
||||
|
||||
|
||||
# Fix
|
||||
bei OPENAI rate-limit mit einem gerigem Tier-Level
|
||||
pnpm run vision --refresh --provider=openai --concurrency=1 content/fotos/kollektionen/reisen/asien/malaysia
|
||||
17
scripts/copy-sw.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Copies sw.js + workbox-*.js from dist/server/ to dist/client/ after build.
|
||||
// @astrojs/node standalone only serves static files from dist/client/, but
|
||||
// @vite-pwa/astro generates the service worker into dist/server/ during the
|
||||
// SSR Vite build pass.
|
||||
import { copyFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const serverDir = "dist/server";
|
||||
const clientDir = "dist/client";
|
||||
|
||||
const files = await readdir(serverDir).catch(() => []);
|
||||
for (const file of files) {
|
||||
if (file === "sw.js" || file.startsWith("workbox-")) {
|
||||
await copyFile(join(serverDir, file), join(clientDir, file));
|
||||
console.log(`[copy-sw] ${file} → dist/client/`);
|
||||
}
|
||||
}
|
||||
49
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
REMOTE_BASE='/opt/websites/adrian-altner.de'
|
||||
REMOTE_URL='ssh://git@git.altner.cloud:2222/adrian/adrian-altner.de.git'
|
||||
GIT_HOST='git.altner.cloud'
|
||||
GIT_PORT='2222'
|
||||
|
||||
# --- 1. Pull latest from repo ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
mkdir -p ~/.ssh && touch ~/.ssh/known_hosts && chmod 600 ~/.ssh/known_hosts
|
||||
ssh-keygen -F '[$GIT_HOST]:$GIT_PORT' >/dev/null || ssh-keyscan -p '$GIT_PORT' '$GIT_HOST' >> ~/.ssh/known_hosts
|
||||
git remote set-url origin '$REMOTE_URL'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
"
|
||||
|
||||
# --- 2. Build + deploy ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
sudo podman build -t localhost/adrian-altner.de:latest .
|
||||
sudo systemctl restart podman-compose@adrian-altner.de.service
|
||||
sudo podman container prune -f 2>/dev/null || true
|
||||
sudo podman image prune --external -f 2>/dev/null || true
|
||||
sudo podman image prune -f 2>/dev/null || true
|
||||
sudo podman builder prune -af 2>/dev/null || true
|
||||
"
|
||||
|
||||
echo "Deploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
|
||||
# --- 3. Webmentions ---
|
||||
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
|
||||
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
|
||||
echo "Sending webmentions via webmention.app..."
|
||||
for feed in rss.xml en/rss.xml; do
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/${feed}&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
done
|
||||
echo "Webmentions triggered."
|
||||
else
|
||||
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
|
||||
fi
|
||||
74
scripts/fetch-favicons.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Fetches favicons for all linked domains and saves them locally to public/favicons/.
|
||||
* Run before astro build so favicons are served statically instead of via Google S2.
|
||||
* Idempotent: skips domains that already have a cached favicon.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
const linksPath = join(root, "src/content/links/links.json");
|
||||
const outDir = join(root, "public/favicons");
|
||||
|
||||
function getDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function faviconPath(domain) {
|
||||
return join(outDir, `${domain}.png`);
|
||||
}
|
||||
|
||||
async function fetchFavicon(domain) {
|
||||
const url = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const links = JSON.parse(readFileSync(linksPath, "utf-8"));
|
||||
|
||||
const domains = [
|
||||
...new Set(links.map((l) => getDomain(l.url)).filter(Boolean)),
|
||||
];
|
||||
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
||||
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
await Promise.all(
|
||||
domains.map(async (domain) => {
|
||||
const dest = faviconPath(domain);
|
||||
if (existsSync(dest)) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await fetchFavicon(domain);
|
||||
writeFileSync(dest, data);
|
||||
fetched++;
|
||||
console.log(` ✓ ${domain}`);
|
||||
} catch (err) {
|
||||
console.warn(` ✗ ${domain}: ${err.message}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Favicons: ${fetched} fetched, ${skipped} cached, ${domains.length} total`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
424
scripts/metadata.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { basename, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { consola } from "consola";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
getImagesMissingMetadata,
|
||||
getMetadataPathForImage,
|
||||
getPhotoAbsolutePath,
|
||||
getPhotoDirectories,
|
||||
PHOTOS_DIRECTORY,
|
||||
} from "../src/lib/photo-albums.ts";
|
||||
|
||||
const PHOTOS_DIR = PHOTOS_DIRECTORY;
|
||||
|
||||
// ─── IPTC parser ────────────────────────────────────────────────────────────
|
||||
|
||||
interface IptcFields {
|
||||
title?: string;
|
||||
caption?: string;
|
||||
keywords?: string[];
|
||||
dateCreated?: string;
|
||||
timeCreated?: string;
|
||||
}
|
||||
|
||||
function parseIptc(buf: Buffer): IptcFields {
|
||||
const fields: IptcFields = {};
|
||||
let i = 0;
|
||||
|
||||
while (i < buf.length - 4) {
|
||||
if (buf[i] !== 0x1c) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = buf[i + 1];
|
||||
const dataset = buf[i + 2];
|
||||
const len = buf.readUInt16BE(i + 3);
|
||||
const value = buf.subarray(i + 5, i + 5 + len).toString("utf8");
|
||||
i += 5 + len;
|
||||
|
||||
if (record !== 2) continue;
|
||||
|
||||
switch (dataset) {
|
||||
case 5:
|
||||
fields.title = value;
|
||||
break;
|
||||
case 25:
|
||||
fields.keywords ??= [];
|
||||
fields.keywords.push(value);
|
||||
break;
|
||||
case 55:
|
||||
fields.dateCreated = value;
|
||||
break;
|
||||
case 60:
|
||||
fields.timeCreated = value;
|
||||
break;
|
||||
case 120:
|
||||
fields.caption = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ─── XMP parser ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface XmpFields {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
lens?: string | undefined;
|
||||
createDate?: string | undefined;
|
||||
}
|
||||
|
||||
function extractRdfLiValues(xml: string, tagName: string): string[] {
|
||||
const re = new RegExp(`<${tagName}[^>]*>[\\s\\S]*?<\\/${tagName}>`, "i");
|
||||
const match = xml.match(re);
|
||||
if (!match) return [];
|
||||
|
||||
const liRe = /<rdf:li[^>]*>([^<]*)<\/rdf:li>/gi;
|
||||
const values: string[] = [];
|
||||
for (let m = liRe.exec(match[0]); m !== null; m = liRe.exec(match[0])) {
|
||||
if (m[1]?.trim()) values.push(m[1].trim());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function extractXmpAttr(xml: string, attr: string): string | undefined {
|
||||
const re = new RegExp(`${attr}="([^"]*)"`, "i");
|
||||
return xml.match(re)?.[1] ?? undefined;
|
||||
}
|
||||
|
||||
function parseXmp(buf: Buffer): XmpFields {
|
||||
const xml = buf.toString("utf8");
|
||||
const fields: XmpFields = {};
|
||||
|
||||
const titles = extractRdfLiValues(xml, "dc:title");
|
||||
if (titles[0]) fields.title = titles[0];
|
||||
|
||||
const descriptions = extractRdfLiValues(xml, "dc:description");
|
||||
if (descriptions[0]) fields.description = descriptions[0];
|
||||
|
||||
const subjects = extractRdfLiValues(xml, "dc:subject");
|
||||
if (subjects.length > 0) fields.keywords = subjects;
|
||||
|
||||
fields.lens = extractXmpAttr(xml, "aux:Lens");
|
||||
fields.createDate = extractXmpAttr(xml, "xmp:CreateDate");
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ─── EXIF parser (minimal TIFF IFD0 + SubIFD) ──────────────────────────────
|
||||
|
||||
interface ExifFields {
|
||||
model?: string;
|
||||
lensModel?: string;
|
||||
fNumber?: number;
|
||||
focalLength?: string;
|
||||
exposureTime?: string;
|
||||
iso?: number;
|
||||
dateTimeOriginal?: string;
|
||||
gpsLatitude?: string;
|
||||
gpsLongitude?: string;
|
||||
gpsLatitudeRef?: string;
|
||||
gpsLongitudeRef?: string;
|
||||
}
|
||||
|
||||
function parseExifBuffer(buf: Buffer): ExifFields {
|
||||
// Skip "Exif\0\0" header if present
|
||||
let offset = 0;
|
||||
if (
|
||||
buf[0] === 0x45 &&
|
||||
buf[1] === 0x78 &&
|
||||
buf[2] === 0x69 &&
|
||||
buf[3] === 0x66
|
||||
) {
|
||||
offset = 6;
|
||||
}
|
||||
|
||||
const isLE = buf[offset] === 0x49; // "II" = little endian
|
||||
const read16 = isLE
|
||||
? (o: number) => buf.readUInt16LE(offset + o)
|
||||
: (o: number) => buf.readUInt16BE(offset + o);
|
||||
const read32 = isLE
|
||||
? (o: number) => buf.readUInt32LE(offset + o)
|
||||
: (o: number) => buf.readUInt32BE(offset + o);
|
||||
|
||||
const readRational = (o: number): number => {
|
||||
const num = read32(o);
|
||||
const den = read32(o + 4);
|
||||
return den === 0 ? 0 : num / den;
|
||||
};
|
||||
|
||||
const readString = (o: number, len: number): string => {
|
||||
return buf
|
||||
.subarray(offset + o, offset + o + len)
|
||||
.toString("ascii")
|
||||
.replace(/\0+$/, "");
|
||||
};
|
||||
|
||||
const fields: ExifFields = {};
|
||||
|
||||
const parseIfd = (ifdOffset: number, parseGps = false) => {
|
||||
if (ifdOffset + 2 > buf.length - offset) return;
|
||||
const count = read16(ifdOffset);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entryOffset = ifdOffset + 2 + i * 12;
|
||||
if (entryOffset + 12 > buf.length - offset) break;
|
||||
|
||||
const tag = read16(entryOffset);
|
||||
const type = read16(entryOffset + 2);
|
||||
const numValues = read32(entryOffset + 4);
|
||||
const valueOffset = read32(entryOffset + 8);
|
||||
|
||||
// For values that fit in 4 bytes, data is inline at entryOffset+8
|
||||
const dataOffset =
|
||||
type === 2 && numValues <= 4
|
||||
? entryOffset + 8
|
||||
: type === 5 || numValues > 4
|
||||
? valueOffset
|
||||
: entryOffset + 8;
|
||||
|
||||
if (parseGps) {
|
||||
switch (tag) {
|
||||
case 1: // GPSLatitudeRef
|
||||
fields.gpsLatitudeRef = readString(entryOffset + 8, 2);
|
||||
break;
|
||||
case 2: // GPSLatitude
|
||||
if (dataOffset + 24 <= buf.length - offset) {
|
||||
const d = readRational(dataOffset);
|
||||
const m = readRational(dataOffset + 8);
|
||||
const s = readRational(dataOffset + 16);
|
||||
fields.gpsLatitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`;
|
||||
}
|
||||
break;
|
||||
case 3: // GPSLongitudeRef
|
||||
fields.gpsLongitudeRef = readString(entryOffset + 8, 2);
|
||||
break;
|
||||
case 4: // GPSLongitude
|
||||
if (dataOffset + 24 <= buf.length - offset) {
|
||||
const d = readRational(dataOffset);
|
||||
const m = readRational(dataOffset + 8);
|
||||
const s = readRational(dataOffset + 16);
|
||||
fields.gpsLongitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case 0x0110: // Model
|
||||
if (dataOffset + numValues <= buf.length - offset) {
|
||||
fields.model = readString(dataOffset, numValues);
|
||||
}
|
||||
break;
|
||||
case 0x8769: // ExifIFD pointer
|
||||
parseIfd(valueOffset);
|
||||
break;
|
||||
case 0x8825: // GPS IFD pointer
|
||||
parseIfd(valueOffset, true);
|
||||
break;
|
||||
case 0x829a: // ExposureTime
|
||||
if (dataOffset + 8 <= buf.length - offset) {
|
||||
const num = read32(dataOffset);
|
||||
const den = read32(dataOffset + 4);
|
||||
fields.exposureTime =
|
||||
den > num ? `1/${Math.round(den / num)}` : `${num / den}`;
|
||||
}
|
||||
break;
|
||||
case 0x829d: // FNumber
|
||||
if (dataOffset + 8 <= buf.length - offset) {
|
||||
fields.fNumber = readRational(dataOffset);
|
||||
}
|
||||
break;
|
||||
case 0x8827: // ISO
|
||||
fields.iso = type === 3 ? read16(entryOffset + 8) : valueOffset;
|
||||
break;
|
||||
case 0x9003: // DateTimeOriginal
|
||||
if (dataOffset + numValues <= buf.length - offset) {
|
||||
fields.dateTimeOriginal = readString(dataOffset, numValues);
|
||||
}
|
||||
break;
|
||||
case 0x920a: // FocalLength
|
||||
if (dataOffset + 8 <= buf.length - offset) {
|
||||
const fl = readRational(dataOffset);
|
||||
fields.focalLength = fl.toFixed(1).replace(/\.0$/, "");
|
||||
}
|
||||
break;
|
||||
case 0xa434: // LensModel
|
||||
if (dataOffset + numValues <= buf.length - offset) {
|
||||
fields.lensModel = readString(dataOffset, numValues);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ifdOffset = read32(4);
|
||||
parseIfd(ifdOffset);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ─── Merged metadata ────────────────────────────────────────────────────────
|
||||
|
||||
interface ImageMetadata {
|
||||
id: string;
|
||||
title: string[];
|
||||
image: string;
|
||||
alt: string;
|
||||
location: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
exif: {
|
||||
camera: string;
|
||||
lens: string;
|
||||
aperture: string;
|
||||
iso: string;
|
||||
focal_length: string;
|
||||
shutter_speed: string;
|
||||
};
|
||||
}
|
||||
|
||||
function formatGpsLocation(exif: ExifFields): string {
|
||||
if (!exif.gpsLatitude || !exif.gpsLongitude) return "";
|
||||
|
||||
const latRef = exif.gpsLatitudeRef ?? "N";
|
||||
const lonRef = exif.gpsLongitudeRef ?? "E";
|
||||
return `${exif.gpsLatitude} ${latRef}, ${exif.gpsLongitude} ${lonRef}`;
|
||||
}
|
||||
|
||||
function formatDate(raw: string | undefined): string {
|
||||
if (!raw) return "";
|
||||
|
||||
// Handle "YYYY:MM:DD HH:MM:SS" or "YYYYMMDD" or "YYYY-MM-DDTHH:MM:SS"
|
||||
if (/^\d{8}$/.test(raw)) {
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
const [datePart] = raw.split(/[T ]/);
|
||||
if (!datePart) return "";
|
||||
return datePart.replaceAll(":", "-");
|
||||
}
|
||||
|
||||
async function extractMetadata(imagePath: string): Promise<ImageMetadata> {
|
||||
const meta = await sharp(imagePath).metadata();
|
||||
const fileName = basename(imagePath);
|
||||
|
||||
const iptc = meta.iptc ? parseIptc(meta.iptc) : ({} as IptcFields);
|
||||
const xmp = meta.xmp ? parseXmp(meta.xmp) : ({} as XmpFields);
|
||||
const exif = meta.exif ? parseExifBuffer(meta.exif) : ({} as ExifFields);
|
||||
|
||||
const title = iptc.title || xmp.title || "";
|
||||
const caption = iptc.caption || xmp.description || "";
|
||||
const keywords = iptc.keywords ?? xmp.keywords ?? [];
|
||||
const date = formatDate(
|
||||
exif.dateTimeOriginal ?? xmp.createDate ?? iptc.dateCreated,
|
||||
);
|
||||
|
||||
if (!title && !caption) {
|
||||
consola.warn(`No title or caption found in ${fileName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: fileName.replace(/\.jpg$/i, ""),
|
||||
title: title ? [title] : [],
|
||||
image: `./${fileName}`,
|
||||
alt: caption,
|
||||
location: formatGpsLocation(exif),
|
||||
date,
|
||||
tags: keywords,
|
||||
exif: {
|
||||
camera: exif.model ?? "",
|
||||
lens: exif.lensModel || xmp.lens || "",
|
||||
aperture: exif.fNumber?.toString() ?? "",
|
||||
iso: exif.iso?.toString() ?? "",
|
||||
focal_length: exif.focalLength?.replace(/ mm$/, "") ?? "",
|
||||
shutter_speed: exif.exposureTime ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliOptions {
|
||||
refresh: boolean;
|
||||
photosDirectory: string;
|
||||
}
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--"));
|
||||
return {
|
||||
refresh: argv.includes("--refresh"),
|
||||
photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR),
|
||||
};
|
||||
}
|
||||
|
||||
async function getImagesToProcess(
|
||||
photosDirectory: string,
|
||||
options: Pick<CliOptions, "refresh">,
|
||||
): Promise<string[]> {
|
||||
const relativeImagePaths = options.refresh
|
||||
? (await getPhotoDirectories(photosDirectory)).flatMap((d) => d.imagePaths)
|
||||
: await getImagesMissingMetadata(photosDirectory);
|
||||
|
||||
consola.info(
|
||||
options.refresh
|
||||
? `Refreshing ${relativeImagePaths.length} image(s)`
|
||||
: `Found ${relativeImagePaths.length} image(s) without metadata`,
|
||||
);
|
||||
|
||||
return relativeImagePaths.map((p) =>
|
||||
getPhotoAbsolutePath(p, photosDirectory),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
consola.start("Checking for images to process...");
|
||||
const opts = parseCliOptions(process.argv.slice(2));
|
||||
|
||||
const images = await getImagesToProcess(opts.photosDirectory, opts);
|
||||
|
||||
if (images.length === 0) {
|
||||
consola.success(
|
||||
opts.refresh
|
||||
? "No images found to refresh."
|
||||
: "No images require metadata.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const imagePath = images[i] as string;
|
||||
const rel = relative(process.cwd(), imagePath);
|
||||
consola.info(`Processing ${i + 1}/${images.length}: ${rel}`);
|
||||
|
||||
const metadata = await extractMetadata(imagePath);
|
||||
const relativeImagePath = relative(opts.photosDirectory, imagePath);
|
||||
const jsonPath = getMetadataPathForImage(
|
||||
relativeImagePath,
|
||||
opts.photosDirectory,
|
||||
);
|
||||
|
||||
await writeFile(jsonPath, JSON.stringify(metadata, null, 2));
|
||||
consola.info(`Wrote ${relative(process.cwd(), jsonPath)}`);
|
||||
}
|
||||
|
||||
consola.success(`Processed ${images.length} image(s).`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
consola.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
40
scripts/new-note-mdx-prompt.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# Standalone wrapper für Obsidian Script Runner — neue Note mit Cover-Bild (MDX)
|
||||
set -euo pipefail
|
||||
|
||||
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
|
||||
|
||||
TITLE=$(osascript \
|
||||
-e 'Tell application "System Events" to display dialog "Note title (with cover):" default answer ""' \
|
||||
-e 'text returned of result' 2>/dev/null) || exit 0
|
||||
|
||||
if [[ -z "$TITLE" ]]; then exit 0; fi
|
||||
|
||||
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
|
||||
DATE_FOLDER=$(date +%Y/%m/%d)
|
||||
PUBLISH_DATE=$(date +%Y-%m-%d)
|
||||
DIR="$VAULT/content/notes/$DATE_FOLDER"
|
||||
FILE="$DIR/$SLUG.mdx"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
|
||||
if [[ -f "$FILE" ]]; then
|
||||
osascript -e "display notification \"File already exists: $SLUG.mdx\" with title \"New Note\"" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$FILE" << EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
publishDate: $PUBLISH_DATE
|
||||
description: ""
|
||||
cover: "./$SLUG.jpg"
|
||||
coverAlt: ""
|
||||
tags:
|
||||
-
|
||||
draft: false
|
||||
syndication:
|
||||
---
|
||||
EOF
|
||||
|
||||
echo "Created: $FILE"
|
||||
38
scripts/new-note-prompt.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Standalone wrapper für Obsidian Script Runner — neue Note anlegen
|
||||
set -euo pipefail
|
||||
|
||||
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
|
||||
|
||||
TITLE=$(osascript \
|
||||
-e 'Tell application "System Events" to display dialog "Note title:" default answer ""' \
|
||||
-e 'text returned of result' 2>/dev/null) || exit 0
|
||||
|
||||
if [[ -z "$TITLE" ]]; then exit 0; fi
|
||||
|
||||
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
|
||||
DATE_FOLDER=$(date +%Y/%m/%d)
|
||||
PUBLISH_DATE=$(date +%Y-%m-%d)
|
||||
DIR="$VAULT/content/notes/$DATE_FOLDER"
|
||||
FILE="$DIR/$SLUG.md"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
|
||||
if [[ -f "$FILE" ]]; then
|
||||
osascript -e "display notification \"File already exists: $SLUG.md\" with title \"New Note\"" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$FILE" << EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
publishDate: $PUBLISH_DATE
|
||||
description: ""
|
||||
tags:
|
||||
-
|
||||
draft: false
|
||||
syndication:
|
||||
---
|
||||
EOF
|
||||
|
||||
echo "Created: $FILE"
|
||||
68
scripts/new-note.sh
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: new-note.sh "Note Title" [--mdx]
|
||||
# Creates a new note in the Obsidian vault with correct frontmatter and folder structure.
|
||||
# Use --mdx for notes that need a cover image or custom components (creates .mdx file).
|
||||
set -euo pipefail
|
||||
|
||||
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
|
||||
|
||||
if [[ -z "${1:-}" ]]; then
|
||||
echo "Usage: new-note.sh \"Note Title\" [--mdx]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TITLE="$1"
|
||||
MDX=false
|
||||
if [[ "${2:-}" == "--mdx" ]]; then
|
||||
MDX=true
|
||||
fi
|
||||
|
||||
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
|
||||
DATE_FOLDER=$(date +%Y/%m/%d)
|
||||
PUBLISH_DATE=$(date +%Y-%m-%d)
|
||||
DIR="$VAULT/content/notes/$DATE_FOLDER"
|
||||
|
||||
if $MDX; then
|
||||
EXT="mdx"
|
||||
else
|
||||
EXT="md"
|
||||
fi
|
||||
|
||||
FILE="$DIR/$SLUG.$EXT"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
|
||||
if [[ -f "$FILE" ]]; then
|
||||
echo "File already exists: $FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $MDX; then
|
||||
cat > "$FILE" << EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
publishDate: $PUBLISH_DATE
|
||||
description: ""
|
||||
cover: "./$SLUG.jpg"
|
||||
coverAlt: ""
|
||||
tags:
|
||||
-
|
||||
draft: false
|
||||
syndication:
|
||||
---
|
||||
EOF
|
||||
else
|
||||
cat > "$FILE" << EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
publishDate: $PUBLISH_DATE
|
||||
description: ""
|
||||
tags:
|
||||
-
|
||||
draft: false
|
||||
syndication:
|
||||
---
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Created: $FILE"
|
||||
38
scripts/new-post-prompt.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Standalone wrapper für Obsidian Script Runner — neuen Blog-Post anlegen
|
||||
set -euo pipefail
|
||||
|
||||
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
|
||||
|
||||
TITLE=$(osascript \
|
||||
-e 'Tell application "System Events" to display dialog "Post title:" default answer ""' \
|
||||
-e 'text returned of result' 2>/dev/null) || exit 0
|
||||
|
||||
if [[ -z "$TITLE" ]]; then exit 0; fi
|
||||
|
||||
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
|
||||
DATE_FOLDER=$(date +%Y/%m/%d)
|
||||
PUBLISH_DATE=$(date +%Y-%m-%dT%H:%M:%S%z)
|
||||
DIR="$VAULT/content/blog/posts/$DATE_FOLDER"
|
||||
FILE="$DIR/$SLUG.md"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
|
||||
if [[ -f "$FILE" ]]; then
|
||||
osascript -e "display notification \"File already exists: $SLUG.md\" with title \"New Post\"" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$FILE" << EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
description: ""
|
||||
publishDate: $PUBLISH_DATE
|
||||
tags:
|
||||
-
|
||||
draft: true
|
||||
syndication:
|
||||
---
|
||||
EOF
|
||||
|
||||
echo "Created: $FILE"
|
||||
39
scripts/new-post.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: new-post.sh "Post Title"
|
||||
# Creates a new blog post in the Obsidian vault with correct frontmatter and folder structure.
|
||||
set -euo pipefail
|
||||
|
||||
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
|
||||
|
||||
if [[ -z "${1:-}" ]]; then
|
||||
echo "Usage: new-post.sh \"Post Title\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TITLE="$1"
|
||||
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
|
||||
DATE_FOLDER=$(date +%Y/%m/%d)
|
||||
PUBLISH_DATE=$(date +%Y-%m-%dT%H:%M:%S%z)
|
||||
DIR="$VAULT/content/blog/posts/$DATE_FOLDER"
|
||||
FILE="$DIR/$SLUG.md"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
|
||||
if [[ -f "$FILE" ]]; then
|
||||
echo "File already exists: $FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$FILE" << EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
description: ""
|
||||
publishDate: $PUBLISH_DATE
|
||||
tags:
|
||||
-
|
||||
draft: true
|
||||
syndication:
|
||||
---
|
||||
EOF
|
||||
|
||||
echo "Created: $FILE"
|
||||
62
scripts/publish-all.sh
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_CONTENT='/Users/adrian/Library/Mobile Documents/iCloud~md~obsidian/Documents/03 Bereiche/Webseite/adrian-altner-de/content'
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
|
||||
REMOTE_BASE='/opt/websites/adrian-altner.de'
|
||||
REMOTE_CONTENT="${REMOTE_BASE}/src/content"
|
||||
|
||||
# --- 1. Sync vault to VPS ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
mkdir -p '$REMOTE_CONTENT'
|
||||
"
|
||||
|
||||
rsync -az --delete \
|
||||
--include='*/' \
|
||||
--include='*.md' \
|
||||
--include='*.mdx' \
|
||||
--include='*.jpg' \
|
||||
--include='*.jpeg' \
|
||||
--include='*.png' \
|
||||
--include='*.PNG' \
|
||||
--include='*.JPG' \
|
||||
--include='*.JPEG' \
|
||||
--include='*.json' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='*' \
|
||||
"$VAULT_CONTENT/" "$VPS:$REMOTE_CONTENT/"
|
||||
|
||||
# --- 2. Build + cleanup ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
sudo podman build -t localhost/adrian-altner.de:latest .
|
||||
sudo systemctl restart podman-compose@adrian-altner.de.service
|
||||
sudo podman container prune -f 2>/dev/null || true
|
||||
sudo podman image prune --external -f 2>/dev/null || true
|
||||
sudo podman image prune -f 2>/dev/null || true
|
||||
sudo podman builder prune -af 2>/dev/null || true
|
||||
"
|
||||
|
||||
echo "Redeploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
|
||||
# --- 3. Webmentions ---
|
||||
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
|
||||
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
|
||||
echo "Sending webmentions via webmention.app..."
|
||||
for feed in rss/blog.xml rss/fotos.xml; do
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/${feed}&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
done
|
||||
echo "Webmentions triggered."
|
||||
else
|
||||
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
|
||||
fi
|
||||
62
scripts/publish-blog.sh
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: publish-blog.sh [vps-host] [branch]
|
||||
# Can be called from any directory — no dependency on the repo being the working dir.
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_BLOG='/Users/adrian/Obsidian/Web/adrian-altner-com/content/blog'
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
|
||||
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
|
||||
REMOTE_BLOG="${REMOTE_BASE}/src/content/blog"
|
||||
|
||||
# --- 1. Sync vault to VPS ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
mkdir -p '$REMOTE_BLOG'
|
||||
"
|
||||
|
||||
rsync -az --delete \
|
||||
--include='*/' \
|
||||
--include='*.md' \
|
||||
--include='*.mdx' \
|
||||
--include='*.jpg' \
|
||||
--include='*.jpeg' \
|
||||
--include='*.JPG' \
|
||||
--include='*.JPEG' \
|
||||
--include='*.png' \
|
||||
--include='*.PNG' \
|
||||
--include='*.webp' \
|
||||
--include='*.gif' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='*' \
|
||||
"$VAULT_BLOG/" "$VPS:$REMOTE_BLOG/"
|
||||
|
||||
# --- 2. Build + cleanup ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
|
||||
podman-compose -f compose.yml up --build -d --force-recreate
|
||||
|
||||
podman image prune -af
|
||||
podman builder prune -af || true
|
||||
"
|
||||
|
||||
echo "Blog deploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
|
||||
# --- 3. Webmentions ---
|
||||
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
|
||||
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
|
||||
echo "Sending webmentions..."
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/rss/blog.xml&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
echo "Webmentions triggered."
|
||||
else
|
||||
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
|
||||
fi
|
||||
52
scripts/publish-links.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: publish-links.sh [vps-host] [branch]
|
||||
# Can be called from any directory — no dependency on the repo being the working dir.
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_LINKS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/links'
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
|
||||
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
|
||||
REMOTE_LINKS="${REMOTE_BASE}/src/content/links"
|
||||
|
||||
# --- 1. Sync vault to VPS ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
mkdir -p '$REMOTE_LINKS'
|
||||
"
|
||||
|
||||
rsync -az --delete \
|
||||
--include='*/' \
|
||||
--include='*.md' \
|
||||
--include='*.mdx' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='*' \
|
||||
"$VAULT_LINKS/" "$VPS:$REMOTE_LINKS/"
|
||||
|
||||
# --- 2. Build + cleanup ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
podman-compose -f compose.yml up --build -d --force-recreate
|
||||
podman image prune -af
|
||||
podman builder prune -af
|
||||
"
|
||||
|
||||
echo "Links deploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
|
||||
# --- 3. Webmentions ---
|
||||
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
|
||||
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
|
||||
echo "Sending webmentions..."
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/rss/links.xml&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
echo "Webmentions triggered."
|
||||
else
|
||||
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
|
||||
fi
|
||||
58
scripts/publish-notes.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: publish-notes.sh [vps-host] [branch]
|
||||
# Can be called from any directory — no dependency on the repo being the working dir.
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_NOTES='/Users/adrian/Obsidian/Web/adrian-altner-com/content/notes'
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
|
||||
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
|
||||
REMOTE_NOTES="${REMOTE_BASE}/src/content/notes"
|
||||
|
||||
# --- 1. Sync vault to VPS ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
mkdir -p '$REMOTE_NOTES'
|
||||
"
|
||||
|
||||
rsync -az --delete \
|
||||
--include='*/' \
|
||||
--include='*.md' \
|
||||
--include='*.mdx' \
|
||||
--include='*.jpg' \
|
||||
--include='*.jpeg' \
|
||||
--include='*.JPG' \
|
||||
--include='*.JPEG' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='*' \
|
||||
"$VAULT_NOTES/" "$VPS:$REMOTE_NOTES/"
|
||||
|
||||
# --- 2. Build + cleanup ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
|
||||
podman-compose -f compose.yml up --build -d --force-recreate
|
||||
|
||||
podman image prune -af
|
||||
podman builder prune -af
|
||||
"
|
||||
|
||||
echo "Notes deploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
|
||||
# --- 3. Webmentions ---
|
||||
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
|
||||
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
|
||||
echo "Sending webmentions..."
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/rss/notes.xml&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
echo "Webmentions triggered."
|
||||
else
|
||||
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
|
||||
fi
|
||||
57
scripts/publish-photos.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: publish-photos.sh [vps-host] [branch]
|
||||
# Can be called from any directory — no dependency on the repo being the working dir.
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_PHOTOS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/photos'
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
|
||||
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
|
||||
REMOTE_PHOTOS="${REMOTE_BASE}/src/content/photos"
|
||||
|
||||
# --- 1. Sync vault to VPS ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
mkdir -p '$REMOTE_PHOTOS'
|
||||
"
|
||||
|
||||
rsync -az --delete \
|
||||
--include='*/' \
|
||||
--include='*.md' \
|
||||
--include='*.mdx' \
|
||||
--include='*.jpg' \
|
||||
--include='*.jpeg' \
|
||||
--include='*.JPG' \
|
||||
--include='*.JPEG' \
|
||||
--include='*.json' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='*' \
|
||||
"$VAULT_PHOTOS/" "$VPS:$REMOTE_PHOTOS/"
|
||||
|
||||
# --- 2. Build + cleanup ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
podman-compose -f compose.yml up --build -d --force-recreate
|
||||
podman image prune -af
|
||||
podman builder prune -af
|
||||
"
|
||||
|
||||
echo "Photos deploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
|
||||
# --- 3. Webmentions ---
|
||||
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
|
||||
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
|
||||
echo "Sending webmentions..."
|
||||
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.de/rss/photos.xml&token=${WEBMENTION_APP_TOKEN}" \
|
||||
| grep -o '"status":"[^"]*"' || true
|
||||
echo "Webmentions triggered."
|
||||
else
|
||||
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
|
||||
fi
|
||||
49
scripts/publish-projects.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
# Usage: publish-projects.sh [vps-host] [branch]
|
||||
# Can be called from any directory — no dependency on the repo being the working dir.
|
||||
set -euo pipefail
|
||||
|
||||
VAULT_PROJECTS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/projects'
|
||||
VPS="${1:-hetzner}"
|
||||
REMOTE_BRANCH="${2:-main}"
|
||||
|
||||
REMOTE_BASE='/opt/websites/www.adrian-altner.de'
|
||||
REMOTE_PROJECTS="${REMOTE_BASE}/src/content/projects"
|
||||
|
||||
# --- 1. Sync vault to VPS ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
git fetch --prune origin '$REMOTE_BRANCH'
|
||||
git checkout '$REMOTE_BRANCH'
|
||||
git reset --hard 'origin/$REMOTE_BRANCH'
|
||||
git clean -fd -e .env -e .env.production
|
||||
mkdir -p '$REMOTE_PROJECTS'
|
||||
"
|
||||
|
||||
rsync -az --delete \
|
||||
--include='*/' \
|
||||
--include='*.md' \
|
||||
--include='*.mdx' \
|
||||
--include='*.jpg' \
|
||||
--include='*.jpeg' \
|
||||
--include='*.JPG' \
|
||||
--include='*.JPEG' \
|
||||
--include='*.png' \
|
||||
--include='*.PNG' \
|
||||
--include='*.webp' \
|
||||
--include='*.gif' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='*' \
|
||||
"$VAULT_PROJECTS/" "$VPS:$REMOTE_PROJECTS/"
|
||||
|
||||
# --- 2. Build + cleanup ---
|
||||
ssh "$VPS" "
|
||||
set -euo pipefail
|
||||
cd '$REMOTE_BASE'
|
||||
podman-compose -f compose.yml up --build -d --force-recreate
|
||||
podman image prune -af
|
||||
podman builder prune -af
|
||||
"
|
||||
|
||||
echo "Projects deploy done via $VPS (branch: $REMOTE_BRANCH)."
|
||||
23
scripts/squash-history.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# squash-history.sh — Replaces entire git history with a single "init" commit.
|
||||
# WARNING: Destructive and irreversible. Force-pushes to remote.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMMIT_MSG="${1:-init}"
|
||||
REMOTE="${2:-origin}"
|
||||
BRANCH="main"
|
||||
TEMP="temp-squash-$$"
|
||||
|
||||
echo "⚠️ This will destroy all git history and force-push to $REMOTE/$BRANCH."
|
||||
read -r -p "Continue? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
|
||||
git checkout --orphan "$TEMP"
|
||||
git add -A
|
||||
git commit -m "$COMMIT_MSG"
|
||||
git branch -D "$BRANCH"
|
||||
git branch -m "$TEMP" "$BRANCH"
|
||||
git push --force "$REMOTE" "$BRANCH"
|
||||
|
||||
echo "Done. $(git log --oneline)"
|
||||
91
scripts/vision.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExifMetadata,
|
||||
ImageMetadataSuggestion,
|
||||
VisionAIResult,
|
||||
} from "./vision.ts";
|
||||
import { getImagesToProcess, mergeMetaAndVisionData } from "./vision.ts";
|
||||
|
||||
const FINAL: ImageMetadataSuggestion = {
|
||||
id: "2R9A2805",
|
||||
title: [
|
||||
"Blossom and Buzz",
|
||||
"Spring's Gentle Awakening",
|
||||
"Cherry Blossom Haven",
|
||||
"Nature's Delicate Balance",
|
||||
"A Bee's Spring Feast",
|
||||
],
|
||||
image: "./2R9A2805.jpg",
|
||||
alt: "Close-up of vibrant pink cherry blossoms on a branch with a honeybee collecting nectar. The bee's wings are slightly blurred, capturing its motion as it works. The background is a soft, dreamy pink hue, complementing the sharp details of the blossoms and the bee.",
|
||||
location: "48 deg 8' 37.56\" N, 11 deg 34' 13.32\" E",
|
||||
date: "2024-03-17",
|
||||
tags: ["nature", "cherryblossom", "bee", "spring", "floral"],
|
||||
exif: {
|
||||
camera: "Canon EOS R6m2",
|
||||
lens: "RF70-200mm F2.8 L IS USM",
|
||||
aperture: "2.8",
|
||||
iso: "125",
|
||||
focal_length: "200.0",
|
||||
shutter_speed: "1/1000",
|
||||
},
|
||||
};
|
||||
|
||||
const VISION_DATA: VisionAIResult = {
|
||||
title_ideas: [
|
||||
"Blossom and Buzz",
|
||||
"Spring's Gentle Awakening",
|
||||
"Cherry Blossom Haven",
|
||||
"Nature's Delicate Balance",
|
||||
"A Bee's Spring Feast",
|
||||
],
|
||||
description:
|
||||
"Close-up of vibrant pink cherry blossoms on a branch with a honeybee collecting nectar. The bee's wings are slightly blurred, capturing its motion as it works. The background is a soft, dreamy pink hue, complementing the sharp details of the blossoms and the bee.",
|
||||
tags: ["nature", "cherryblossom", "bee", "spring", "floral"],
|
||||
};
|
||||
|
||||
const EXIF_DATA: ExifMetadata = {
|
||||
SourceFile: "/Users/flori/Sites/flori-dev/src/content/grid/2R9A2805.jpg",
|
||||
FileName: "2R9A2805.jpg",
|
||||
Model: "Canon EOS R6m2",
|
||||
ExposureTime: "1/1000",
|
||||
FNumber: 2.8,
|
||||
ISO: 125,
|
||||
DateTimeOriginal: "2024:03:17 15:06:16",
|
||||
FocalLength: "200.0 mm",
|
||||
LensModel: "RF70-200mm F2.8 L IS USM",
|
||||
GPSPosition: "48 deg 8' 37.56\" N, 11 deg 34' 13.32\" E",
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "vision-photos-"));
|
||||
|
||||
try {
|
||||
assert.deepEqual(mergeMetaAndVisionData(EXIF_DATA, VISION_DATA), FINAL);
|
||||
|
||||
const albumDirectory = join(tempRoot, "chiang-mai");
|
||||
const missingImage = join(albumDirectory, "2025-10-06-121017.jpg");
|
||||
const completeImage = join(albumDirectory, "2025-10-06-121212.jpg");
|
||||
|
||||
await mkdir(albumDirectory, { recursive: true });
|
||||
await writeFile(missingImage, "");
|
||||
await writeFile(completeImage, "");
|
||||
await writeFile(join(albumDirectory, "2025-10-06-121212.json"), "{}");
|
||||
|
||||
assert.deepEqual(await getImagesToProcess(tempRoot), [missingImage]);
|
||||
assert.deepEqual(
|
||||
await getImagesToProcess(tempRoot, { refresh: true, exifOnly: false }),
|
||||
[missingImage, completeImage],
|
||||
);
|
||||
assert.deepEqual(
|
||||
await getImagesToProcess(tempRoot, { refresh: false, exifOnly: true }),
|
||||
[completeImage],
|
||||
);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
820
scripts/vision.ts
Normal file
|
|
@ -0,0 +1,820 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { consola } from "consola";
|
||||
import OpenAI from "openai";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
getImagesMissingMetadata,
|
||||
getImagesWithExistingMetadata,
|
||||
getMetadataPathForImage,
|
||||
getPhotoAbsolutePath,
|
||||
getPhotoDirectories,
|
||||
PHOTOS_DIRECTORY,
|
||||
} from "../src/lib/photo-albums.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Define the directory where the images are located.
|
||||
*/
|
||||
const PHOTOS_DIR = PHOTOS_DIRECTORY;
|
||||
|
||||
/**
|
||||
* Instantiate the Anthropic client.
|
||||
*/
|
||||
type VisionProvider = "anthropic" | "openai";
|
||||
|
||||
let anthropic: Anthropic | undefined;
|
||||
let openai: OpenAI | undefined;
|
||||
|
||||
function getAnthropicClient(): Anthropic {
|
||||
anthropic ??= new Anthropic({ maxRetries: 0 });
|
||||
return anthropic;
|
||||
}
|
||||
|
||||
function getOpenAIClient(): OpenAI {
|
||||
openai ??= new OpenAI({ maxRetries: 0 });
|
||||
return openai;
|
||||
}
|
||||
|
||||
function assertRequiredEnvironment(provider: VisionProvider): void {
|
||||
if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
|
||||
throw new Error(
|
||||
"Missing ANTHROPIC_API_KEY. `pnpm run vision` loads `.env.local` automatically. If you run the script directly, use `node --env-file=.env.local --experimental-strip-types scripts/vision.ts`.",
|
||||
);
|
||||
}
|
||||
if (provider === "openai" && !process.env.OPENAI_API_KEY) {
|
||||
throw new Error("Missing OPENAI_API_KEY. Set it in `.env.local`.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the metadata of an image in the Exif format.
|
||||
*/
|
||||
export interface ExifMetadata {
|
||||
SourceFile: string;
|
||||
FileName: string;
|
||||
Model: string;
|
||||
FNumber: number;
|
||||
FocalLength: string;
|
||||
ExposureTime: string;
|
||||
ISO: number;
|
||||
DateTimeOriginal: string;
|
||||
LensModel: string;
|
||||
GPSPosition?: string;
|
||||
GPSLatitude?: string;
|
||||
GPSLongitude?: string;
|
||||
Keywords?: string | string[];
|
||||
Subject?: string | string[];
|
||||
Title?: string;
|
||||
"Caption-Abstract"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the result of the AI analysis.
|
||||
*/
|
||||
export interface VisionAIResult {
|
||||
title_ideas: string[];
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the final metadata suggestion for an image.
|
||||
*/
|
||||
export interface ImageMetadataSuggestion {
|
||||
id: string;
|
||||
title: string[];
|
||||
image: string;
|
||||
alt: string;
|
||||
location: string;
|
||||
locationName?: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
exif: {
|
||||
camera: string;
|
||||
lens: string;
|
||||
aperture: string;
|
||||
iso: string;
|
||||
focal_length: string;
|
||||
shutter_speed: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface VisionCliOptions {
|
||||
refresh: boolean;
|
||||
exifOnly: boolean;
|
||||
photosDirectory?: string;
|
||||
visionConcurrency: number;
|
||||
visionMaxRetries: number;
|
||||
visionBaseBackoffMs: number;
|
||||
provider: VisionProvider;
|
||||
}
|
||||
|
||||
function parseCliOptions(argv: string[]): VisionCliOptions {
|
||||
const getNumericOption = (name: string, fallback: number): number => {
|
||||
const prefix = `--${name}=`;
|
||||
const rawValue = argv
|
||||
.find((arg) => arg.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
const parsed = Number.parseInt(rawValue ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const envConcurrency = Number.parseInt(
|
||||
process.env.VISION_CONCURRENCY ?? "",
|
||||
10,
|
||||
);
|
||||
const envMaxRetries = Number.parseInt(
|
||||
process.env.VISION_MAX_RETRIES ?? "",
|
||||
10,
|
||||
);
|
||||
const envBaseBackoffMs = Number.parseInt(
|
||||
process.env.VISION_BASE_BACKOFF_MS ?? "",
|
||||
10,
|
||||
);
|
||||
const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
const providerArg = argv
|
||||
.find((arg) => arg.startsWith("--provider="))
|
||||
?.slice("--provider=".length);
|
||||
const envProvider = process.env.VISION_PROVIDER;
|
||||
const rawProvider = providerArg ?? envProvider ?? "anthropic";
|
||||
const provider: VisionProvider =
|
||||
rawProvider === "openai" ? "openai" : "anthropic";
|
||||
|
||||
return {
|
||||
refresh: argv.includes("--refresh"),
|
||||
exifOnly: argv.includes("--exif-only"),
|
||||
provider,
|
||||
photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR),
|
||||
visionConcurrency: getNumericOption(
|
||||
"concurrency",
|
||||
Number.isFinite(envConcurrency) && envConcurrency > 0
|
||||
? envConcurrency
|
||||
: 2,
|
||||
),
|
||||
visionMaxRetries: getNumericOption(
|
||||
"retries",
|
||||
Number.isFinite(envMaxRetries) && envMaxRetries > 0 ? envMaxRetries : 8,
|
||||
),
|
||||
visionBaseBackoffMs: getNumericOption(
|
||||
"backoff-ms",
|
||||
Number.isFinite(envBaseBackoffMs) && envBaseBackoffMs > 0
|
||||
? envBaseBackoffMs
|
||||
: 1500,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRateLimitError(error: unknown): boolean {
|
||||
return error instanceof Anthropic.RateLimitError;
|
||||
}
|
||||
|
||||
function extractRetryAfterMs(error: unknown): number | null {
|
||||
if (!(error instanceof Anthropic.RateLimitError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryAfter = error.headers?.get("retry-after");
|
||||
if (retryAfter) {
|
||||
const seconds = Number.parseFloat(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
values: T[],
|
||||
concurrency: number,
|
||||
mapper: (value: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: R[] = new Array(values.length);
|
||||
const workerCount = Math.max(1, Math.min(concurrency, values.length));
|
||||
let cursor = 0;
|
||||
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const currentIndex = cursor;
|
||||
cursor += 1;
|
||||
|
||||
if (currentIndex >= values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = values[currentIndex];
|
||||
if (typeof value === "undefined") {
|
||||
continue;
|
||||
}
|
||||
|
||||
results[currentIndex] = await mapper(value, currentIndex);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all images that don't have a JSON file and therefore need to be processed.
|
||||
*/
|
||||
export async function getImagesToProcess(
|
||||
photosDirectory = PHOTOS_DIR,
|
||||
options: Pick<VisionCliOptions, "refresh" | "exifOnly"> = {
|
||||
refresh: false,
|
||||
exifOnly: false,
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let relativeImagePaths: string[];
|
||||
let label: string;
|
||||
|
||||
if (options.exifOnly) {
|
||||
relativeImagePaths = await getImagesWithExistingMetadata(photosDirectory);
|
||||
label = `Found ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} with existing metadata (EXIF-only update)`;
|
||||
} else if (options.refresh) {
|
||||
relativeImagePaths = (await getPhotoDirectories(photosDirectory)).flatMap(
|
||||
(directory) => directory.imagePaths,
|
||||
);
|
||||
label = `Refreshing ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} with metadata sidecars`;
|
||||
} else {
|
||||
relativeImagePaths = await getImagesMissingMetadata(photosDirectory);
|
||||
label = `Found ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} without metadata`;
|
||||
}
|
||||
|
||||
consola.info(label);
|
||||
|
||||
return relativeImagePaths.map((imagePath) =>
|
||||
getPhotoAbsolutePath(imagePath, photosDirectory),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an existing JSON sidecar for an image, preserving all fields.
|
||||
*/
|
||||
async function readExistingJsonSidecar(
|
||||
imagePath: string,
|
||||
photosDirectory: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const relativeImagePath = relative(photosDirectory, imagePath);
|
||||
const jsonPath = getMetadataPathForImage(relativeImagePath, photosDirectory);
|
||||
const content = await readFile(jsonPath, "utf-8");
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates only the EXIF-derived fields in an existing metadata object,
|
||||
* preserving all other fields (title, alt, tags, flickrId, etc.).
|
||||
*/
|
||||
export function mergeExifIntoExisting(
|
||||
exifData: ExifMetadata,
|
||||
existing: Record<string, unknown>,
|
||||
locationName?: string | null,
|
||||
): Record<string, unknown> {
|
||||
const [date] = exifData.DateTimeOriginal.split(" ");
|
||||
|
||||
if (!date) {
|
||||
throw new Error(`Missing original date for ${exifData.SourceFile}.`);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
...existing,
|
||||
location: getLocationFromExif(exifData),
|
||||
date: date.replaceAll(":", "-"),
|
||||
exif: {
|
||||
camera: exifData.Model,
|
||||
lens: exifData.LensModel,
|
||||
aperture: exifData.FNumber.toString(),
|
||||
iso: exifData.ISO.toString(),
|
||||
focal_length: exifData.FocalLength.replace(" mm", ""),
|
||||
shutter_speed: exifData.ExposureTime,
|
||||
},
|
||||
};
|
||||
|
||||
if (locationName) {
|
||||
result.locationName = locationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the EXIF metadata from an image file.
|
||||
* @param imagePath - The path to the image file.
|
||||
*
|
||||
* @returns A promise that resolves to the extracted EXIF metadata.
|
||||
*/
|
||||
export async function extractExifMetadata(
|
||||
imagePath: string,
|
||||
): Promise<ExifMetadata> {
|
||||
/// Check if `exiftool` is installed.
|
||||
try {
|
||||
await execFileAsync("exiftool", ["--version"]);
|
||||
} catch (_error) {
|
||||
consola.error(
|
||||
"exiftool is not installed. Please run `brew install exiftool`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/// Extract the metadata
|
||||
const { stdout } = await execFileAsync("exiftool", ["-j", imagePath]);
|
||||
const output = JSON.parse(stdout) as ExifMetadata[];
|
||||
|
||||
if (!output[0]) {
|
||||
throw new Error(`No EXIF metadata found for ${imagePath}.`);
|
||||
}
|
||||
|
||||
return output[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an image file to base64.
|
||||
* @param imagePath - The path to the image file.
|
||||
* @returns A Promise that resolves to the base64 encoded image.
|
||||
*/
|
||||
/**
|
||||
* The Vision API internally downscales to max 1568px on the longest side.
|
||||
* Anything larger wastes tokens without improving results.
|
||||
*/
|
||||
const VISION_MAX_DIMENSION = 1568;
|
||||
|
||||
async function base64EncodeImage(imagePath: string): Promise<string> {
|
||||
const resized = await sharp(imagePath)
|
||||
.resize({
|
||||
width: VISION_MAX_DIMENSION,
|
||||
height: VISION_MAX_DIMENSION,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
return resized.toString("base64");
|
||||
}
|
||||
|
||||
const VISION_TOOL = {
|
||||
name: "vision_response",
|
||||
description: "Return the vision analysis of the image.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title_ideas: { type: "array", items: { type: "string" } },
|
||||
description: { type: "string" },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["title_ideas", "description", "tags"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates image description, title suggestions and tags using AI.
|
||||
*
|
||||
* @param metadata - The metadata of the image.
|
||||
* @returns A Promise that resolves to a VisionAIResult object containing the generated image description, title suggestions, and tags.
|
||||
*/
|
||||
function buildVisionPrompt(
|
||||
metadata: ExifMetadata,
|
||||
locationName: string | null,
|
||||
): string {
|
||||
const locationContext = locationName
|
||||
? ` Das Foto wurde aufgenommen in: ${locationName}. Verwende diesen Ort konkret in der Beschreibung.`
|
||||
: "";
|
||||
|
||||
const rawKeywords = metadata.Keywords ?? metadata.Subject ?? [];
|
||||
const keywords = Array.isArray(rawKeywords) ? rawKeywords : [rawKeywords];
|
||||
const keywordContext =
|
||||
keywords.length > 0
|
||||
? ` Folgende Tags sind vom Fotografen vergeben worden: ${keywords.join(", ")}. Beruecksichtige diese Informationen in der Beschreibung.`
|
||||
: "";
|
||||
|
||||
return `Erstelle eine präzise und detaillierte Beschreibung dieses Bildes, die auch als Alt-Text funktioniert. Der Alt-Text soll keine Wörter wie Bild, Foto, Fotografie, Illustration oder Ähnliches enthalten. Beschreibe die Szene so, wie sie ist.${locationContext}${keywordContext} Erstelle außerdem 5 Titelvorschläge für dieses Bild. Schlage zuletzt 5 Tags vor, die zur Bildbeschreibung passen. Diese Tags sollen einzelne Wörter sein. Identifiziere das Hauptmotiv oder Thema und stelle den entsprechenden Tag an die erste Stelle. Gib die Beschreibung, die Titelvorschläge und die Tags zurück.`;
|
||||
}
|
||||
|
||||
async function callAnthropicVision(
|
||||
encodedImage: string,
|
||||
prompt: string,
|
||||
sourceFile: string,
|
||||
): Promise<VisionAIResult> {
|
||||
const response = await getAnthropicClient().messages.create({
|
||||
model: "claude-opus-4-6",
|
||||
max_tokens: 2048,
|
||||
tools: [VISION_TOOL],
|
||||
tool_choice: { type: "tool", name: "vision_response" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/jpeg",
|
||||
data: encodedImage,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toolUseBlock = response.content.find((b) => b.type === "tool_use");
|
||||
if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
|
||||
throw new Error(`No tool use response from AI for ${sourceFile}.`);
|
||||
}
|
||||
|
||||
return toolUseBlock.input as VisionAIResult;
|
||||
}
|
||||
|
||||
async function callOpenAIVision(
|
||||
encodedImage: string,
|
||||
prompt: string,
|
||||
sourceFile: string,
|
||||
): Promise<VisionAIResult> {
|
||||
const jsonPrompt = `${prompt}\n\nWICHTIG: Antworte komplett auf Deutsch. Alle Titel, Beschreibungen und Tags muessen auf Deutsch sein.\n\nAntworte ausschliesslich mit einem JSON-Objekt im folgenden Format:\n{"title_ideas": ["...", "...", "...", "...", "..."], "description": "...", "tags": ["...", "...", "...", "...", "..."]}`;
|
||||
|
||||
const response = await getOpenAIClient().chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 2048,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/jpeg;base64,${encodedImage}` },
|
||||
},
|
||||
{ type: "text", text: jsonPrompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`No response from OpenAI for ${sourceFile}.`);
|
||||
}
|
||||
|
||||
return JSON.parse(content) as VisionAIResult;
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown, provider: VisionProvider): boolean {
|
||||
if (provider === "anthropic") {
|
||||
return isRateLimitError(error);
|
||||
}
|
||||
if (error instanceof OpenAI.RateLimitError) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function generateImageDescriptionTitleSuggestionsAndTags(
|
||||
metadata: ExifMetadata,
|
||||
locationName: string | null,
|
||||
options: Pick<
|
||||
VisionCliOptions,
|
||||
"visionMaxRetries" | "visionBaseBackoffMs" | "provider"
|
||||
>,
|
||||
): Promise<VisionAIResult> {
|
||||
const encodedImage = await base64EncodeImage(metadata.SourceFile);
|
||||
const prompt = buildVisionPrompt(metadata, locationName);
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= options.visionMaxRetries; attempt += 1) {
|
||||
try {
|
||||
const result =
|
||||
options.provider === "openai"
|
||||
? await callOpenAIVision(encodedImage, prompt, metadata.SourceFile)
|
||||
: await callAnthropicVision(
|
||||
encodedImage,
|
||||
prompt,
|
||||
metadata.SourceFile,
|
||||
);
|
||||
|
||||
if (
|
||||
result.title_ideas.length === 0 ||
|
||||
result.description.length === 0 ||
|
||||
result.tags.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Incomplete vision response for ${metadata.SourceFile}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (
|
||||
!isRetryableError(error, options.provider) ||
|
||||
attempt >= options.visionMaxRetries
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
const retryAfterMs = extractRetryAfterMs(error);
|
||||
const exponentialBackoffMs = options.visionBaseBackoffMs * 2 ** attempt;
|
||||
const jitterMs = Math.floor(Math.random() * 350);
|
||||
const waitMs =
|
||||
Math.max(retryAfterMs ?? 0, exponentialBackoffMs) + jitterMs;
|
||||
const relativeSourcePath = relative(process.cwd(), metadata.SourceFile);
|
||||
const nextAttempt = attempt + 1;
|
||||
consola.warn(
|
||||
`Rate limit for ${relativeSourcePath}. Retry ${nextAttempt}/${options.visionMaxRetries} in ${Math.ceil(waitMs / 1000)}s...`,
|
||||
);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function ensureVisionCanRun(
|
||||
imagesToProcess: string[],
|
||||
provider: VisionProvider,
|
||||
): void {
|
||||
if (imagesToProcess.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertRequiredEnvironment(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an EXIF DMS string like `7 deg 49' 12.00" N` into a decimal number.
|
||||
*/
|
||||
function parseDms(dms: string): number {
|
||||
const match = dms.match(/(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([NSEW])/i);
|
||||
if (!match) {
|
||||
return Number.NaN;
|
||||
}
|
||||
const [, deg, min, sec, dir] = match;
|
||||
let decimal = Number(deg) + Number(min) / 60 + Number(sec) / 3600;
|
||||
if (dir === "S" || dir === "W") {
|
||||
decimal *= -1;
|
||||
}
|
||||
return decimal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves GPS coordinates to a human-readable location via Nominatim.
|
||||
*/
|
||||
async function reverseGeocode(
|
||||
lat: number,
|
||||
lon: number,
|
||||
): Promise<string | null> {
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&accept-language=de&zoom=14`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": "adrian-altner.de/vision-script" },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { display_name?: string };
|
||||
return data.display_name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves EXIF GPS data to a readable location name. Returns null if no GPS data.
|
||||
*/
|
||||
async function resolveLocationName(
|
||||
exifData: ExifMetadata,
|
||||
): Promise<string | null> {
|
||||
const latStr = exifData.GPSLatitude;
|
||||
const lonStr = exifData.GPSLongitude;
|
||||
if (!latStr || !lonStr) return null;
|
||||
|
||||
const lat = parseDms(latStr);
|
||||
const lon = parseDms(lonStr);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lon)) return null;
|
||||
|
||||
return await reverseGeocode(lat, lon);
|
||||
}
|
||||
|
||||
function getLocationFromExif(exifData: ExifMetadata): string {
|
||||
if (exifData.GPSPosition) {
|
||||
return exifData.GPSPosition;
|
||||
}
|
||||
|
||||
if (exifData.GPSLatitude && exifData.GPSLongitude) {
|
||||
return `${exifData.GPSLatitude}, ${exifData.GPSLongitude}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the metadata from EXIF data and vision data to create an ImageMetadataSuggestion object.
|
||||
* @param exifData - The EXIF metadata of the image.
|
||||
* @param visionData - The vision AI result data of the image.
|
||||
* @returns The merged ImageMetadataSuggestion object.
|
||||
*/
|
||||
export function mergeMetaAndVisionData(
|
||||
exifData: ExifMetadata,
|
||||
visionData: VisionAIResult,
|
||||
locationName?: string | null,
|
||||
): ImageMetadataSuggestion {
|
||||
const [date] = exifData.DateTimeOriginal.split(" ");
|
||||
|
||||
if (!date) {
|
||||
throw new Error(`Missing original date for ${exifData.SourceFile}.`);
|
||||
}
|
||||
|
||||
const result: ImageMetadataSuggestion = {
|
||||
id: exifData.FileName.replace(".jpg", ""),
|
||||
title: visionData.title_ideas,
|
||||
image: `./${exifData.FileName}`,
|
||||
alt: visionData.description,
|
||||
location: getLocationFromExif(exifData),
|
||||
date: date.replaceAll(":", "-"),
|
||||
tags: visionData.tags,
|
||||
exif: {
|
||||
camera: exifData.Model,
|
||||
lens: exifData.LensModel,
|
||||
aperture: exifData.FNumber.toString(),
|
||||
iso: exifData.ISO.toString(),
|
||||
focal_length: exifData.FocalLength.replace(" mm", ""),
|
||||
shutter_speed: exifData.ExposureTime,
|
||||
},
|
||||
};
|
||||
|
||||
if (locationName) {
|
||||
result.locationName = locationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given image metadata to a JSON file.
|
||||
* @param imageMetadata - The image metadata to be written.
|
||||
* @returns A Promise that resolves when the JSON file is written successfully.
|
||||
*/
|
||||
async function writeToJsonFile(
|
||||
imageMetadata: ImageMetadataSuggestion,
|
||||
imagePath: string,
|
||||
photosDirectory: string,
|
||||
): Promise<void> {
|
||||
const relativeImagePath = relative(photosDirectory, imagePath);
|
||||
const jsonPath = getMetadataPathForImage(relativeImagePath, photosDirectory);
|
||||
const json = JSON.stringify(imageMetadata, null, 2);
|
||||
await writeFile(jsonPath, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main.
|
||||
*/
|
||||
async function main() {
|
||||
consola.start("Checking for images to process...");
|
||||
const cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
const photosDirectory = cliOptions.photosDirectory ?? PHOTOS_DIR;
|
||||
|
||||
/// Load all images that don't have a JSON file.
|
||||
const images = await getImagesToProcess(photosDirectory, cliOptions);
|
||||
|
||||
if (images.length === 0) {
|
||||
consola.success(
|
||||
cliOptions.exifOnly
|
||||
? "No images with existing metadata found."
|
||||
: cliOptions.refresh
|
||||
? "No images found to refresh."
|
||||
: "No images require metadata.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Extract EXIF metadata from these images.
|
||||
const exifData = await mapWithConcurrency(
|
||||
images,
|
||||
8,
|
||||
async (imagePath, index) => {
|
||||
consola.info(`Extracting EXIF ${index + 1}/${images.length}...`);
|
||||
return await extractExifMetadata(imagePath);
|
||||
},
|
||||
);
|
||||
|
||||
/// Resolve location names via Nominatim (sequential, 1 req/s limit).
|
||||
consola.info("Resolving location names via Nominatim...");
|
||||
const locationNames: (string | null)[] = [];
|
||||
for (const exifEntry of exifData) {
|
||||
const name = await resolveLocationName(exifEntry);
|
||||
locationNames.push(name);
|
||||
if (name) {
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
const resolvedCount = locationNames.filter(Boolean).length;
|
||||
consola.info(`Resolved ${resolvedCount}/${exifData.length} location names.`);
|
||||
|
||||
if (cliOptions.exifOnly) {
|
||||
/// EXIF-only mode: read existing JSON, merge EXIF fields, write back.
|
||||
const existingData = await mapWithConcurrency(
|
||||
images,
|
||||
8,
|
||||
async (imagePath) => readExistingJsonSidecar(imagePath, photosDirectory),
|
||||
);
|
||||
|
||||
await mapWithConcurrency(exifData, 8, async (exifEntry, index) => {
|
||||
const existing = existingData[index];
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`Missing existing metadata for ${exifEntry.SourceFile}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = mergeExifIntoExisting(
|
||||
exifEntry,
|
||||
existing,
|
||||
locationNames[index],
|
||||
);
|
||||
const relativeImagePath = relative(photosDirectory, exifEntry.SourceFile);
|
||||
const jsonPath = getMetadataPathForImage(
|
||||
relativeImagePath,
|
||||
photosDirectory,
|
||||
);
|
||||
await writeFile(jsonPath, JSON.stringify(updated, null, 2));
|
||||
consola.info(
|
||||
`Updated EXIF ${index + 1}/${exifData.length}: ${relativeImagePath}`,
|
||||
);
|
||||
});
|
||||
|
||||
consola.success("All EXIF data updated successfully.");
|
||||
return;
|
||||
}
|
||||
|
||||
consola.info(
|
||||
`Vision settings: provider=${cliOptions.provider}, concurrency=${cliOptions.visionConcurrency}, retries=${cliOptions.visionMaxRetries}, backoff=${cliOptions.visionBaseBackoffMs}ms`,
|
||||
);
|
||||
|
||||
ensureVisionCanRun(images, cliOptions.provider);
|
||||
|
||||
/// Determine the image description, title suggestions and tags for each image with AI.
|
||||
const visionData = await mapWithConcurrency(
|
||||
exifData,
|
||||
cliOptions.visionConcurrency,
|
||||
async (exifEntry, index) => {
|
||||
consola.info(`Generating AI metadata ${index + 1}/${exifData.length}...`);
|
||||
return await generateImageDescriptionTitleSuggestionsAndTags(
|
||||
exifEntry,
|
||||
locationNames[index] ?? null,
|
||||
cliOptions,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Merge the EXIF and Vision data to create the final metadata suggestion.
|
||||
const imageData = exifData.map((e, i) => {
|
||||
const currentVisionData = visionData[i];
|
||||
|
||||
if (!currentVisionData) {
|
||||
throw new Error(`Missing vision data for ${e.SourceFile}.`);
|
||||
}
|
||||
|
||||
return mergeMetaAndVisionData(e, currentVisionData, locationNames[i]);
|
||||
});
|
||||
|
||||
/// Write the metadata to JSON files.
|
||||
await mapWithConcurrency(imageData, 8, async (imageMetadata, index) => {
|
||||
const sourceFile = exifData[index]?.SourceFile;
|
||||
|
||||
if (!sourceFile) {
|
||||
throw new Error(`Missing source file for ${imageMetadata.id}.`);
|
||||
}
|
||||
|
||||
await writeToJsonFile(imageMetadata, sourceFile, photosDirectory);
|
||||
consola.info(`Wrote metadata ${index + 1}/${imageData.length}.`);
|
||||
});
|
||||
|
||||
consola.success("All images processed successfully.");
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
consola.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
BIN
src/assets/blog-placeholder-1.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/assets/blog-placeholder-2.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/blog-placeholder-3.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/blog-placeholder-4.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/blog-placeholder-5.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/blog-placeholder-about.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/fonts/atkinson-bold.woff
Normal file
BIN
src/assets/fonts/atkinson-regular.woff
Normal file
70
src/components/BaseHead.astro
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of the <BaseHead /> component.
|
||||
import '~/styles/global.css';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import FallbackImage from '~/assets/blog-placeholder-1.jpg';
|
||||
import { DEFAULT_LOCALE, type Locale, SITE } from '~/consts';
|
||||
import { getLocaleFromUrl, switchLocalePath } from '~/i18n/ui';
|
||||
import { Font } from 'astro:assets';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: ImageMetadata;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image = FallbackImage,
|
||||
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
|
||||
} = Astro.props;
|
||||
|
||||
const otherLocale: Locale = locale === 'de' ? 'en' : 'de';
|
||||
const alternateHref = new URL(switchLocalePath(Astro.url.pathname, otherLocale), Astro.site);
|
||||
const rssHref = new URL(locale === 'de' ? 'rss.xml' : 'en/rss.xml', Astro.site);
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="alternate" type="application/rss+xml" title={SITE[locale].title} href={rssHref} />
|
||||
<link rel="alternate" hreflang={locale} href={canonicalURL} />
|
||||
<link rel="alternate" hreflang={otherLocale} href={alternateHref} />
|
||||
<link rel="alternate" hreflang="x-default" href={new URL('/', Astro.site)} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<Font cssVariable="--font-atkinson" preload />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Webmention endpoints (webmention.io) -->
|
||||
<link rel="webmention" href="https://webmention.io/adrian-altner.de/webmention" />
|
||||
<link rel="pingback" href="https://webmention.io/adrian-altner.de/xmlrpc" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content={locale === 'de' ? 'de_DE' : 'en_US'} />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image.src, Astro.url)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image" content={new URL(image.src, Astro.url)} />
|
||||
62
src/components/CategoriesPage.astro
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { type Locale, SITE } from '~/consts';
|
||||
import { categoryHref, getCategoriesByLocale, getPostsByCategory } from '~/i18n/posts';
|
||||
import { t } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const categories = await getCategoriesByLocale(locale);
|
||||
const withCounts = await Promise.all(
|
||||
categories.map(async (c) => ({ category: c, count: (await getPostsByCategory(c)).length })),
|
||||
);
|
||||
|
||||
const pageTitle = `${t(locale, 'categories.title')} — ${SITE[locale].title}`;
|
||||
const pageDescription = t(locale, 'categories.description');
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
locale={locale}
|
||||
bodyClass="category-index"
|
||||
>
|
||||
<article class="prose">
|
||||
<h1>{t(locale, 'categories.title')}</h1>
|
||||
<p>{pageDescription}</p>
|
||||
<ul>
|
||||
{
|
||||
withCounts.map(({ category, count }) => (
|
||||
<li>
|
||||
<a href={categoryHref(category)}>
|
||||
<strong>{category.data.name}</strong>
|
||||
</a>
|
||||
<span class="count"> ({count})</span>
|
||||
{category.data.description && <p>{category.data.description}</p>}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
body.category-index main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.category-index ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
body.category-index li {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
body.category-index .count {
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
</style>
|
||||
88
src/components/CategoryDetailPage.astro
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import FormattedDate from '~/components/FormattedDate.astro';
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { type Locale, SITE } from '~/consts';
|
||||
import { getPostsByCategory, postSlug } from '~/i18n/posts';
|
||||
import { localizePath, t } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
category: CollectionEntry<'categories'>;
|
||||
}
|
||||
|
||||
const { locale, category } = Astro.props;
|
||||
const posts = await getPostsByCategory(category);
|
||||
const pageTitle = `${category.data.name} — ${SITE[locale].title}`;
|
||||
const pageDescription =
|
||||
category.data.description ?? `${t(locale, 'category.postsIn')} ${category.data.name}`;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
locale={locale}
|
||||
entry={category}
|
||||
bodyClass="category-detail"
|
||||
>
|
||||
<article class="prose">
|
||||
<h1>{category.data.name}</h1>
|
||||
{category.data.description && <p class="lead">{category.data.description}</p>}
|
||||
<h2>{t(locale, 'category.postsIn')} {category.data.name}</h2>
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<p>{t(locale, 'category.noPosts')}</p>
|
||||
) : (
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={localizePath(`/${postSlug(post)}/`, locale)}>
|
||||
{post.data.heroImage && (
|
||||
<Image
|
||||
width={320}
|
||||
height={160}
|
||||
src={post.data.heroImage}
|
||||
alt=""
|
||||
transition:name={`hero-${post.id}`}
|
||||
/>
|
||||
)}
|
||||
<h3>{post.data.title}</h3>
|
||||
<p class="date">
|
||||
<FormattedDate date={post.data.pubDate} locale={locale} />
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
body.category-detail main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.category-detail ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
body.category-detail li {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
body.category-detail li a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
body.category-detail li img {
|
||||
border-radius: 8px;
|
||||
}
|
||||
body.category-detail .date {
|
||||
color: rgb(var(--gray));
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
91
src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||
import { getLocaleFromUrl, localizePath, t } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const locale: Locale = Astro.props.locale ?? getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE;
|
||||
const contactSegment = locale === 'de' ? 'kontakt' : 'contact';
|
||||
const imprintSegment = locale === 'de' ? 'impressum' : 'imprint';
|
||||
const privacySegment = locale === 'de' ? 'datenschutz' : 'privacy-policy';
|
||||
|
||||
const today = new Date();
|
||||
---
|
||||
|
||||
<footer>
|
||||
<div class="footer__inner">
|
||||
<span>© {today.getFullYear()} Adrian Altner</span>
|
||||
<nav class="footer__social" aria-label="Social">
|
||||
<a
|
||||
href="https://mastodon.social/@altner"
|
||||
rel="me noopener"
|
||||
target="_blank"
|
||||
aria-label="Mastodon"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor">
|
||||
<path
|
||||
d="M21.58 13.91c-.29 1.48-2.58 3.1-5.21 3.41-1.37.17-2.72.32-4.16.25-2.35-.11-4.2-.56-4.2-.56 0 .23.01.45.04.65.3 2.28 2.26 2.42 4.11 2.48 1.87.07 3.54-.46 3.54-.46l.08 1.69s-1.31.7-3.64.83c-1.28.07-2.88-.03-4.74-.52-4.04-1.07-4.73-5.38-4.84-9.74-.03-1.3-.01-2.52-.01-3.54 0-4.46 2.92-5.77 2.92-5.77C6.95.89 9.48.72 12.11.7h.06c2.63.02 5.17.19 6.64.87 0 0 2.92 1.31 2.92 5.77 0 0 .04 3.29-.41 5.58"
|
||||
/>
|
||||
<path
|
||||
d="M18.66 7.63v5.45h-2.16V7.79c0-1.09-.46-1.64-1.38-1.64-1.01 0-1.52.65-1.52 1.95v2.82h-2.14V8.1c0-1.3-.51-1.95-1.52-1.95-.92 0-1.38.55-1.38 1.64v5.29H6.4V7.63c0-1.09.28-1.95.83-2.59.57-.64 1.32-.97 2.25-.97 1.08 0 1.9.41 2.43 1.24L12 6.08l.54-.77c.53-.83 1.35-1.24 2.43-1.24.93 0 1.68.33 2.25.97.55.64.83 1.5.83 2.59"
|
||||
fill="var(--bg, #fff)"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://github.com/altner" rel="me noopener" target="_blank" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor">
|
||||
<path
|
||||
d="M12 .5a12 12 0 0 0-3.79 23.4c.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.21.08 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.67-.31-5.47-1.34-5.47-5.95 0-1.31.47-2.39 1.24-3.23-.12-.31-.54-1.53.12-3.19 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.19.77.84 1.24 1.92 1.24 3.23 0 4.62-2.81 5.63-5.49 5.94.43.37.82 1.1.82 2.22v3.29c0 .32.22.7.83.58A12 12 0 0 0 12 .5Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
<nav class="footer__links" aria-label="Legal">
|
||||
<a href={localizePath(`/${contactSegment}`, locale)}>{t(locale, 'footer.contact')}</a>
|
||||
<a href={localizePath(`/${imprintSegment}`, locale)}>{t(locale, 'footer.imprint')}</a>
|
||||
<a href={localizePath(`/${privacySegment}`, locale)}>{t(locale, 'footer.privacy')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
<style>
|
||||
footer {
|
||||
padding: 2em 20px;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
}
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5em 1.25em;
|
||||
}
|
||||
.footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
}
|
||||
.footer__links a {
|
||||
color: rgb(var(--gray));
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer__links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.footer__social {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
line-height: 1;
|
||||
}
|
||||
.footer__social a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.footer__social a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
27
src/components/FormattedDate.astro
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||
import { getLocaleFromUrl } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
date: Date;
|
||||
locale?: Locale;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
date,
|
||||
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
const tag = locale === 'de' ? 'de-DE' : 'en-US';
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()} class={className}>
|
||||
{
|
||||
date.toLocaleDateString(tag, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</time>
|
||||
433
src/components/Header.astro
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { type Locale, SITE } from '~/consts';
|
||||
import {
|
||||
aboutSegment,
|
||||
categoryIndexSegment,
|
||||
entryHref,
|
||||
findTagBySlug,
|
||||
findTranslation,
|
||||
tagHref,
|
||||
tagIndexSegment,
|
||||
tagSegment,
|
||||
} from '~/i18n/posts';
|
||||
import { getLocaleFromUrl, localizePath, switchLocalePath, t } from '~/i18n/ui';
|
||||
import HeaderLink from '~/components/HeaderLink.astro';
|
||||
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
/** The current page's content entry, if any — used to resolve a translated language-switch link. */
|
||||
entry?: CollectionEntry<'posts' | 'categories'>;
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const locale: Locale = Astro.props.locale ?? getLocaleFromUrl(Astro.url);
|
||||
const otherLocale: Locale = locale === 'de' ? 'en' : 'de';
|
||||
const homeHref = localizePath('/', locale);
|
||||
const aboutHref = localizePath(`/${aboutSegment(locale)}`, locale);
|
||||
const categoriesHref = localizePath(`/${categoryIndexSegment(locale)}`, locale);
|
||||
const tagsHref = localizePath(`/${tagIndexSegment(locale)}`, locale);
|
||||
|
||||
const translated = entry ? await findTranslation(entry, otherLocale) : undefined;
|
||||
|
||||
// If we're on a tag detail page, verify the tag exists in the target locale —
|
||||
// otherwise the switched URL would 404. Fall back to the target tags index.
|
||||
async function resolveSwitchHref(): Promise<string> {
|
||||
if (translated) return entryHref(translated);
|
||||
if (entry) return localizePath('/', otherLocale);
|
||||
const parts = Astro.url.pathname.split('/').filter(Boolean);
|
||||
const localePrefix = parts[0] === locale ? 1 : 0;
|
||||
const first = parts[localePrefix];
|
||||
const slug = parts[localePrefix + 1];
|
||||
if (first === tagSegment(locale) && slug) {
|
||||
const target = await findTagBySlug(otherLocale, slug);
|
||||
return target
|
||||
? tagHref(otherLocale, target)
|
||||
: localizePath(`/${tagIndexSegment(otherLocale)}`, otherLocale);
|
||||
}
|
||||
return switchLocalePath(Astro.url.pathname, otherLocale);
|
||||
}
|
||||
const switchHref = await resolveSwitchHref();
|
||||
---
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<h2><a href={homeHref}>{SITE[locale].title}</a></h2>
|
||||
<div class="internal-links" id="mobile-menu">
|
||||
<HeaderLink href={homeHref}>{t(locale, 'nav.home')}</HeaderLink>
|
||||
<HeaderLink href={aboutHref}>{t(locale, 'nav.about')}</HeaderLink>
|
||||
<HeaderLink href={categoriesHref}>{t(locale, 'nav.categories')}</HeaderLink>
|
||||
<HeaderLink href={tagsHref}>{t(locale, 'nav.tags')}</HeaderLink>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="theme-toggle" type="button" aria-label="Toggle theme">
|
||||
<span class="theme-toggle__thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle__icon theme-toggle__icon--sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle__icon theme-toggle__icon--moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<a
|
||||
class={`lang-toggle lang-toggle--${locale}`}
|
||||
href={switchHref}
|
||||
hreflang={otherLocale}
|
||||
lang={otherLocale}
|
||||
aria-label={t(locale, `lang.${otherLocale}`)}
|
||||
>
|
||||
<span class="lang-toggle__thumb" aria-hidden="true"></span>
|
||||
<span class={`lang-toggle__label${locale === 'de' ? ' is-active' : ''}`}>DE</span>
|
||||
<span class={`lang-toggle__label${locale === 'en' ? ' is-active' : ''}`}>EN</span>
|
||||
</a>
|
||||
<button class="menu-toggle" type="button" aria-label="Menu" aria-controls="mobile-menu" aria-expanded="false">
|
||||
<span class="menu-toggle__bar"></span>
|
||||
<span class="menu-toggle__bar"></span>
|
||||
<span class="menu-toggle__bar"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
import { navigate } from 'astro:transitions/client';
|
||||
function wireLangToggle() {
|
||||
const toggle = document.querySelector<HTMLAnchorElement>('.lang-toggle');
|
||||
if (!toggle || toggle.dataset.wired) return;
|
||||
toggle.dataset.wired = '1';
|
||||
toggle.addEventListener('click', (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || (e as MouseEvent).button === 1) return;
|
||||
const href = toggle.getAttribute('href');
|
||||
if (!href) return;
|
||||
e.preventDefault();
|
||||
toggle.classList.add('is-switching');
|
||||
window.setTimeout(() => navigate(href), 280);
|
||||
});
|
||||
}
|
||||
function wireThemeToggle() {
|
||||
const btn = document.querySelector<HTMLButtonElement>('.theme-toggle');
|
||||
if (!btn || btn.dataset.wired) return;
|
||||
btn.dataset.wired = '1';
|
||||
btn.addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
}
|
||||
function wireMenuToggle() {
|
||||
const btn = document.querySelector<HTMLButtonElement>('.menu-toggle');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
const header = document.querySelector<HTMLElement>('header');
|
||||
if (!btn || !menu || !header || btn.dataset.wired) return;
|
||||
btn.dataset.wired = '1';
|
||||
const nav = header.querySelector('nav');
|
||||
const mq = window.matchMedia('(max-width: 960px)');
|
||||
const syncLocation = () => {
|
||||
if (mq.matches) {
|
||||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||||
} else {
|
||||
if (menu.parentElement !== nav) nav!.insertBefore(menu, nav!.children[1] ?? null);
|
||||
}
|
||||
};
|
||||
syncLocation();
|
||||
mq.addEventListener('change', syncLocation);
|
||||
const setScrollbarCompensation = (on: boolean) => {
|
||||
if (on) {
|
||||
const sbw = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.documentElement.style.setProperty('--scrollbar-width', `${sbw}px`);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--scrollbar-width');
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
header.classList.remove('is-menu-open');
|
||||
menu.classList.remove('is-open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
setScrollbarCompensation(false);
|
||||
};
|
||||
btn.addEventListener('click', () => {
|
||||
const willOpen = !header.classList.contains('is-menu-open');
|
||||
if (willOpen) setScrollbarCompensation(true);
|
||||
const open = header.classList.toggle('is-menu-open');
|
||||
menu.classList.toggle('is-open', open);
|
||||
btn.setAttribute('aria-expanded', String(open));
|
||||
if (!open) setScrollbarCompensation(false);
|
||||
});
|
||||
menu.querySelectorAll('a').forEach((a) => a.addEventListener('click', close));
|
||||
document.addEventListener('astro:after-swap', close);
|
||||
}
|
||||
wireLangToggle();
|
||||
wireThemeToggle();
|
||||
wireMenuToggle();
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
wireLangToggle();
|
||||
wireThemeToggle();
|
||||
wireMenuToggle();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
header {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
background: rgb(var(--surface));
|
||||
box-shadow: 0 2px 8px rgba(var(--black), 5%);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
text-align: left;
|
||||
}
|
||||
nav > h2 a {
|
||||
display: block;
|
||||
padding: 1em 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 a,
|
||||
h2 a.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
nav {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
nav > h2 {
|
||||
justify-self: start;
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
nav > .internal-links {
|
||||
justify-self: center;
|
||||
}
|
||||
nav > .toolbar {
|
||||
justify-self: end;
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
nav a {
|
||||
padding: 1em 0.5em;
|
||||
color: var(--black);
|
||||
border-bottom: 4px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a.active {
|
||||
text-decoration: none;
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
nav a.lang-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
padding: 3px;
|
||||
margin: 0;
|
||||
background: rgba(var(--gray-light), 0.7);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum';
|
||||
user-select: none;
|
||||
}
|
||||
nav a.lang-toggle:hover {
|
||||
background: rgba(var(--gray-light), 1);
|
||||
}
|
||||
.lang-toggle__thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
width: calc(50% - 3px);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 1px 3px rgba(var(--black), 0.18);
|
||||
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.lang-toggle--en .lang-toggle__thumb {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.lang-toggle--de.is-switching .lang-toggle__thumb {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.lang-toggle--en.is-switching .lang-toggle__thumb {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.lang-toggle__label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
flex: 1;
|
||||
width: 2.2em;
|
||||
padding: 0.6em 0 0.5em;
|
||||
text-align: center;
|
||||
color: rgb(var(--gray));
|
||||
transition: color 280ms ease;
|
||||
}
|
||||
.lang-toggle__label.is-active {
|
||||
color: white;
|
||||
}
|
||||
.lang-toggle.is-switching .lang-toggle__label.is-active {
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.lang-toggle.is-switching .lang-toggle__label:not(.is-active) {
|
||||
color: white;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lang-toggle__thumb,
|
||||
.lang-toggle__label {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
}
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
padding: 3px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: rgba(var(--gray-light), 0.7);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
font: inherit;
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: rgba(var(--gray-light), 1);
|
||||
}
|
||||
.theme-toggle__thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
width: calc(50% - 3px);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 1px 3px rgba(var(--black), 0.18);
|
||||
}
|
||||
:global(:root[data-theme-ready]) .theme-toggle__thumb {
|
||||
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
:global(:root[data-theme='dark']) .theme-toggle__thumb {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.theme-toggle__icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
font-size: 0.9em;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
:global(:root[data-theme-ready]) .theme-toggle__icon {
|
||||
transition: color 280ms ease;
|
||||
}
|
||||
:global(:root:not([data-theme='dark'])) .theme-toggle__icon--sun,
|
||||
:global(:root[data-theme='dark']) .theme-toggle__icon--moon {
|
||||
color: white;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-toggle__thumb,
|
||||
.theme-toggle__icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
margin-left: 0.8em;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 2.1em;
|
||||
height: 2.1em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgba(var(--gray-light), 0.7);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-toggle:hover {
|
||||
background: rgba(var(--gray-light), 1);
|
||||
}
|
||||
.menu-toggle__bar {
|
||||
display: block;
|
||||
width: 1em;
|
||||
height: 2px;
|
||||
background: rgb(var(--gray-dark));
|
||||
border-radius: 1px;
|
||||
transition:
|
||||
transform 260ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 180ms ease;
|
||||
}
|
||||
header.is-menu-open .menu-toggle__bar:nth-child(1) {
|
||||
transform: translateY(6px) rotate(45deg);
|
||||
}
|
||||
header.is-menu-open .menu-toggle__bar:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
header.is-menu-open .menu-toggle__bar:nth-child(3) {
|
||||
transform: translateY(-6px) rotate(-45deg);
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
nav {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
.internal-links {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: var(--scrollbar-width, 0px);
|
||||
bottom: 0;
|
||||
box-sizing: border-box;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
background: rgb(var(--surface));
|
||||
padding: 2em;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
.internal-links a {
|
||||
padding: 0.5em 0;
|
||||
border-bottom: none;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
color: var(--black);
|
||||
text-decoration: none;
|
||||
}
|
||||
.internal-links.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
body:has(header.is-menu-open) {
|
||||
overflow: hidden;
|
||||
padding-right: var(--scrollbar-width, 0px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
src/components/HeaderLink.astro
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { LOCALES } from '~/consts';
|
||||
|
||||
type Props = HTMLAttributes<'a'>;
|
||||
|
||||
const { href, class: className, ...props } = Astro.props;
|
||||
|
||||
function stripTrailing(p: string) {
|
||||
return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p;
|
||||
}
|
||||
|
||||
function stripBase(p: string) {
|
||||
const base = import.meta.env.BASE_URL;
|
||||
if (base && base !== '/' && p.startsWith(base)) return p.slice(base.length - 1) || '/';
|
||||
return p;
|
||||
}
|
||||
|
||||
const pathname = stripTrailing(stripBase(Astro.url.pathname));
|
||||
const target = stripTrailing(String(href ?? ''));
|
||||
|
||||
// Locale home URLs (`/`, `/en`) should only activate on exact match; deeper
|
||||
// routes activate when the pathname equals or is nested under the href.
|
||||
const localeHomes = new Set(['/', ...LOCALES.map((l) => `/${l}`)]);
|
||||
const isActive = localeHomes.has(target)
|
||||
? pathname === target
|
||||
: pathname === target || pathname.startsWith(target + '/');
|
||||
---
|
||||
|
||||
<a href={href} class:list={[className, { active: isActive }]} {...props}>
|
||||
<slot />
|
||||
</a>
|
||||
<style>
|
||||
a {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.active {
|
||||
font-weight: bolder;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
118
src/components/HomePage.astro
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import FormattedDate from '~/components/FormattedDate.astro';
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { type Locale, SITE } from '~/consts';
|
||||
import { getPostsByLocale, postSlug } from '~/i18n/posts';
|
||||
import { localizePath } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const posts = await getPostsByLocale(locale);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={SITE[locale].title}
|
||||
description={SITE[locale].description}
|
||||
locale={locale}
|
||||
bodyClass="home"
|
||||
>
|
||||
<section>
|
||||
<ul>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<a href={localizePath(`/${postSlug(post)}/`, locale)}>
|
||||
{post.data.heroImage && (
|
||||
<Image
|
||||
width={720}
|
||||
height={360}
|
||||
src={post.data.heroImage}
|
||||
alt=""
|
||||
transition:name={`hero-${post.id}`}
|
||||
/>
|
||||
)}
|
||||
<h4 class="title">{post.data.title}</h4>
|
||||
<p class="date">
|
||||
<FormattedDate date={post.data.pubDate} locale={locale} />
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
body.home main {
|
||||
width: 960px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul li {
|
||||
width: calc(50% - 1rem);
|
||||
}
|
||||
ul li * {
|
||||
text-decoration: none;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
ul li:first-child {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
ul li:first-child img {
|
||||
width: 100%;
|
||||
}
|
||||
ul li:first-child .title {
|
||||
font-size: 2.369rem;
|
||||
}
|
||||
ul li img {
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
ul li a {
|
||||
display: block;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
color: rgb(var(--black));
|
||||
line-height: 1;
|
||||
}
|
||||
.date {
|
||||
margin: 0;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
ul li a:hover h4,
|
||||
ul li a:hover .date {
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
ul a:hover img {
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
ul {
|
||||
gap: 0.5em;
|
||||
}
|
||||
ul li {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
ul li:first-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ul li:first-child .title {
|
||||
font-size: 1.563em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
src/components/TagDetailPage.astro
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import FormattedDate from '~/components/FormattedDate.astro';
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { type Locale, SITE } from '~/consts';
|
||||
import { type TagEntry, getPostsByTag, postSlug } from '~/i18n/posts';
|
||||
import { localizePath, t } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
tag: TagEntry;
|
||||
}
|
||||
|
||||
const { locale, tag } = Astro.props;
|
||||
const posts = await getPostsByTag(locale, tag.slug);
|
||||
const pageTitle = `${tag.name} — ${SITE[locale].title}`;
|
||||
const pageDescription = `${t(locale, 'tag.postsTagged')} ${tag.name}`;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
locale={locale}
|
||||
bodyClass="tag-detail"
|
||||
>
|
||||
<article class="prose">
|
||||
<h1>{tag.name}</h1>
|
||||
<h2>{t(locale, 'tag.postsTagged')} {tag.name}</h2>
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<p>{t(locale, 'tag.noPosts')}</p>
|
||||
) : (
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={localizePath(`/${postSlug(post)}/`, locale)}>
|
||||
{post.data.heroImage && (
|
||||
<Image
|
||||
width={320}
|
||||
height={160}
|
||||
src={post.data.heroImage}
|
||||
alt=""
|
||||
transition:name={`hero-${post.id}`}
|
||||
/>
|
||||
)}
|
||||
<h3>{post.data.title}</h3>
|
||||
<p class="date">
|
||||
<FormattedDate date={post.data.pubDate} locale={locale} />
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
body.tag-detail main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.tag-detail ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
body.tag-detail li {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
body.tag-detail li a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
body.tag-detail li img {
|
||||
border-radius: 8px;
|
||||
}
|
||||
body.tag-detail .date {
|
||||
color: rgb(var(--gray));
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
57
src/components/TagsPage.astro
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { type Locale, SITE } from '~/consts';
|
||||
import { getTagsByLocale, tagHref } from '~/i18n/posts';
|
||||
import { t } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const tags = await getTagsByLocale(locale);
|
||||
const pageTitle = `${t(locale, 'tags.title')} — ${SITE[locale].title}`;
|
||||
const pageDescription = t(locale, 'tags.description');
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
locale={locale}
|
||||
bodyClass="tag-index"
|
||||
>
|
||||
<article class="prose">
|
||||
<h1>{t(locale, 'tags.title')}</h1>
|
||||
<p>{pageDescription}</p>
|
||||
<ul class="tag-cloud">
|
||||
{
|
||||
tags.map((tag) => (
|
||||
<li>
|
||||
<a href={tagHref(locale, tag)}>
|
||||
<strong>{tag.name}</strong>
|
||||
</a>
|
||||
<span class="count"> ({tag.count})</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
body.tag-index main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.tag-index .tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em 1em;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
body.tag-index .count {
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
</style>
|
||||
300
src/components/Webmentions.astro
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
---
|
||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||
import { getLocaleFromUrl, t } from '~/i18n/ui';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __WEBMENTION_TOKEN__: string;
|
||||
}
|
||||
const tokenRaw = (globalThis as unknown as { __WEBMENTION_TOKEN__?: string }).__WEBMENTION_TOKEN__;
|
||||
const WEBMENTION_TOKEN = typeof tokenRaw === 'string' ? tokenRaw : '';
|
||||
|
||||
interface WMAuthor {
|
||||
name?: string;
|
||||
url?: string;
|
||||
photo?: string;
|
||||
}
|
||||
interface WMEntry {
|
||||
author?: WMAuthor;
|
||||
url: string;
|
||||
published?: string;
|
||||
'wm-received'?: string;
|
||||
'wm-id'?: number;
|
||||
'wm-property'?: string;
|
||||
content?: { text?: string };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
target: string | URL;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const { target, locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE } = Astro.props;
|
||||
|
||||
async function fetchMentions(target: string): Promise<WMEntry[]> {
|
||||
const token = WEBMENTION_TOKEN;
|
||||
if (!token) return [];
|
||||
const withSlash = target.endsWith('/') ? target : `${target}/`;
|
||||
const withoutSlash = target.replace(/\/+$/, '');
|
||||
const fetchOne = async (t: string) => {
|
||||
const url = new URL('https://webmention.io/api/mentions.jf2');
|
||||
url.searchParams.set('target', t);
|
||||
url.searchParams.set('token', token);
|
||||
url.searchParams.set('per-page', '100');
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [] as WMEntry[];
|
||||
const json = (await res.json()) as { children?: WMEntry[] };
|
||||
return json.children ?? [];
|
||||
};
|
||||
const [a, b] = await Promise.all([fetchOne(withSlash), fetchOne(withoutSlash)]);
|
||||
const seen = new Set<number>();
|
||||
const merged: WMEntry[] = [];
|
||||
for (const m of [...a, ...b]) {
|
||||
const id = m['wm-id'];
|
||||
if (id == null || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push(m);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const targetStr = target.toString();
|
||||
const all = await fetchMentions(targetStr);
|
||||
|
||||
const likes = all.filter((m) => m['wm-property'] === 'like-of');
|
||||
const reposts = all.filter((m) => m['wm-property'] === 'repost-of');
|
||||
const replies = all.filter((m) => m['wm-property'] === 'in-reply-to');
|
||||
const mentions = all.filter(
|
||||
(m) => !['like-of', 'repost-of', 'in-reply-to', 'bookmark-of'].includes(m['wm-property'] ?? ''),
|
||||
);
|
||||
const facepile = [...likes, ...reposts];
|
||||
|
||||
function authorInitial(m: WMEntry) {
|
||||
return m.author?.name?.trim()?.[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
const hasAny = facepile.length > 0 || replies.length > 0 || mentions.length > 0;
|
||||
---
|
||||
|
||||
{
|
||||
hasAny && (
|
||||
<section class="webmentions" aria-labelledby="webmentions-heading">
|
||||
<h2 id="webmentions-heading">{t(locale, 'webmentions.title')}</h2>
|
||||
|
||||
{facepile.length > 0 && (
|
||||
<div class="facepile-group">
|
||||
{likes.length > 0 && (
|
||||
<div class="facepile">
|
||||
<h3>
|
||||
{`${likes.length} ${t(locale, likes.length === 1 ? 'webmentions.like' : 'webmentions.likes')}`}
|
||||
</h3>
|
||||
<ul>
|
||||
{likes.map((m) => (
|
||||
<li>
|
||||
<a
|
||||
href={m.url}
|
||||
title={m.author?.name ?? m.url}
|
||||
rel="noopener nofollow external"
|
||||
>
|
||||
{m.author?.photo ? (
|
||||
<img src={m.author.photo} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span class="avatar-fallback">{authorInitial(m)}</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reposts.length > 0 && (
|
||||
<div class="facepile">
|
||||
<h3>
|
||||
{`${reposts.length} ${t(locale, reposts.length === 1 ? 'webmentions.repost' : 'webmentions.reposts')}`}
|
||||
</h3>
|
||||
<ul>
|
||||
{reposts.map((m) => (
|
||||
<li>
|
||||
<a
|
||||
href={m.url}
|
||||
title={m.author?.name ?? m.url}
|
||||
rel="noopener nofollow external"
|
||||
>
|
||||
{m.author?.photo ? (
|
||||
<img src={m.author.photo} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span class="avatar-fallback">{authorInitial(m)}</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replies.length > 0 && (
|
||||
<div class="replies">
|
||||
<h3>{t(locale, 'webmentions.replies')}</h3>
|
||||
<ol>
|
||||
{replies.map((m) => (
|
||||
<li>
|
||||
<div class="meta">
|
||||
{m.author?.photo && (
|
||||
<img src={m.author.photo} alt="" class="avatar" loading="lazy" />
|
||||
)}
|
||||
<a
|
||||
href={m.author?.url ?? m.url}
|
||||
rel="noopener nofollow external"
|
||||
class="author"
|
||||
>
|
||||
{m.author?.name ?? m.url}
|
||||
</a>
|
||||
<a href={m.url} rel="noopener nofollow external" class="permalink">
|
||||
<time datetime={m['wm-received'] ?? m.published}>
|
||||
{formatDate(m['wm-received'] ?? m.published)}
|
||||
</time>
|
||||
</a>
|
||||
</div>
|
||||
{m.content?.text && <p>{m.content.text}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mentions.length > 0 && (
|
||||
<div class="mentions">
|
||||
<h3>{t(locale, 'webmentions.mentions')}</h3>
|
||||
<ul>
|
||||
{mentions.map((m) => (
|
||||
<li>
|
||||
<a href={m.url} rel="noopener nofollow external">
|
||||
{m.author?.name ?? m.url}
|
||||
</a>
|
||||
{m['wm-received'] && (
|
||||
<>
|
||||
{' · '}
|
||||
<time datetime={m['wm-received']}>{formatDate(m['wm-received'])}</time>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.webmentions {
|
||||
margin-top: 3em;
|
||||
padding-top: 2em;
|
||||
border-top: 1px solid rgba(var(--gray-light), 1);
|
||||
}
|
||||
.webmentions h2 {
|
||||
margin: 0 0 1em;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
.webmentions h3 {
|
||||
margin: 0 0 0.5em;
|
||||
font-size: 1em;
|
||||
color: rgb(var(--gray));
|
||||
font-weight: 600;
|
||||
}
|
||||
.facepile-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.facepile ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.facepile a {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(var(--gray-light), 1);
|
||||
}
|
||||
.facepile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.avatar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--gray-dark));
|
||||
}
|
||||
.replies ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25em;
|
||||
}
|
||||
.replies li {
|
||||
padding: 0.75em 1em;
|
||||
background: rgba(var(--gray-light), 0.4);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.replies .meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
.replies .avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.replies .author {
|
||||
font-weight: 600;
|
||||
}
|
||||
.replies .permalink {
|
||||
margin-left: auto;
|
||||
color: rgb(var(--gray));
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.replies p {
|
||||
margin: 0;
|
||||
}
|
||||
.mentions ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
14
src/consts.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export const LOCALES = ['de', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'de';
|
||||
|
||||
export const SITE: Record<Locale, { title: string; description: string }> = {
|
||||
de: {
|
||||
title: 'Adrian Altner',
|
||||
description: 'Willkommen auf meiner Website!',
|
||||
},
|
||||
en: {
|
||||
title: 'Adrian Altner',
|
||||
description: 'Welcome to my website!',
|
||||
},
|
||||
};
|
||||
42
src/content.config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { defineCollection, reference } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
|
||||
// Shared per-locale layout:
|
||||
// src/content/posts/<locale>/… — posts
|
||||
// src/content/categories/<locale>/… — categories
|
||||
// Entry `id` is always `<locale>/<slug>`. A blog post's `category` references a
|
||||
// category by that id (e.g. "de/technik" or "en/tech").
|
||||
|
||||
// Optional `translationKey`: entries in different locales that represent the
|
||||
// same logical piece of content share one key. Used to wire up the language
|
||||
// switcher so it points at the translated URL instead of 404-ing.
|
||||
|
||||
const posts = defineCollection({
|
||||
loader: glob({ base: './src/content/posts', pattern: '{de,en}/**/*.{md,mdx}' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.optional(image()),
|
||||
category: z.optional(reference('categories')),
|
||||
// Free-form tags (aka Stichwörter). Plain strings kept inline on each
|
||||
// post; no separate collection. The tag listing pages aggregate them
|
||||
// across posts per locale.
|
||||
tags: z.array(z.string()).optional(),
|
||||
translationKey: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const categories = defineCollection({
|
||||
loader: glob({ base: './src/content/categories', pattern: '{de,en}/**/*.md' }),
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
translationKey: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { posts, categories };
|
||||
5
src/content/categories/de/allgemein.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
name: Allgemein
|
||||
description: Sammelkategorie für alles andere.
|
||||
translationKey: general
|
||||
---
|
||||
5
src/content/categories/de/technik.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
name: Technik
|
||||
description: Beiträge rund um Technik, Entwicklung und Tools.
|
||||
translationKey: tech
|
||||
---
|
||||
5
src/content/categories/en/general.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
name: General
|
||||
description: Catch-all category.
|
||||
translationKey: general
|
||||
---
|
||||
5
src/content/categories/en/tech.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
name: Tech
|
||||
description: Posts about technology, development and tools.
|
||||
translationKey: tech
|
||||
---
|
||||
18
src/content/posts/de/first-post.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
title: 'First post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 08 2022'
|
||||
heroImage: '../../../assets/blog-placeholder-3.jpg'
|
||||
category: de/allgemein
|
||||
translationKey: hello-world
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
218
src/content/posts/de/markdown-style-guide.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
title: 'Markdown Style Guide'
|
||||
description: 'Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.'
|
||||
pubDate: 'Jun 19 2024'
|
||||
heroImage: '../../../assets/blog-placeholder-1.jpg'
|
||||
category: de/technik
|
||||
tags:
|
||||
- markdown
|
||||
- astro
|
||||
---
|
||||
|
||||
Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.
|
||||
|
||||
## Headings
|
||||
|
||||
The following HTML `<h1>`—`<h6>` elements represent six levels of section headings. `<h1>` is the highest section level while `<h6>` is the lowest.
|
||||
|
||||
# H1
|
||||
|
||||
## H2
|
||||
|
||||
### H3
|
||||
|
||||
#### H4
|
||||
|
||||
##### H5
|
||||
|
||||
###### H6
|
||||
|
||||
## Paragraph
|
||||
|
||||
Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.
|
||||
|
||||
Itatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.
|
||||
|
||||
## Images
|
||||
|
||||
### Syntax
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||

|
||||
|
||||
## Blockquotes
|
||||
|
||||
The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations.
|
||||
|
||||
### Blockquote without attribution
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
|
||||
> **Note** that you can use _Markdown syntax_ within a blockquote.
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
|
||||
> **Note** that you can use _Markdown syntax_ within a blockquote.
|
||||
|
||||
### Blockquote with attribution
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
> Don't communicate by sharing memory, share memory by communicating.<br>
|
||||
> — <cite>Rob Pike[^1]</cite>
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
> Don't communicate by sharing memory, share memory by communicating.<br>
|
||||
> — <cite>Rob Pike[^1]</cite>
|
||||
|
||||
[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.
|
||||
|
||||
## Tables
|
||||
|
||||
### Syntax
|
||||
|
||||
```markdown
|
||||
| Italics | Bold | Code |
|
||||
| --------- | -------- | ------ |
|
||||
| _italics_ | **bold** | `code` |
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
| Italics | Bold | Code |
|
||||
| --------- | -------- | ------ |
|
||||
| _italics_ | **bold** | `code` |
|
||||
|
||||
## Code Blocks
|
||||
|
||||
### Syntax
|
||||
|
||||
we can use 3 backticks ``` in new line and write snippet and close with 3 backticks on new line and to highlight language specific syntax, write one word of language name after first 3 backticks, for eg. html, javascript, css, markdown, typescript, txt, bash
|
||||
|
||||
````markdown
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Example HTML5 Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Test</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
````
|
||||
|
||||
### Output
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Example HTML5 Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Test</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## List Types
|
||||
|
||||
### Ordered List
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
### Unordered List
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
- List item
|
||||
- Another item
|
||||
- And another item
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
- List item
|
||||
- Another item
|
||||
- And another item
|
||||
|
||||
### Nested list
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
- Fruit
|
||||
- Apple
|
||||
- Orange
|
||||
- Banana
|
||||
- Dairy
|
||||
- Milk
|
||||
- Cheese
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
- Fruit
|
||||
- Apple
|
||||
- Orange
|
||||
- Banana
|
||||
- Dairy
|
||||
- Milk
|
||||
- Cheese
|
||||
|
||||
## Other Elements — abbr, sub, sup, kbd, mark
|
||||
|
||||
### Syntax
|
||||
|
||||
```markdown
|
||||
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
|
||||
|
||||
H<sub>2</sub>O
|
||||
|
||||
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
|
||||
|
||||
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
|
||||
|
||||
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
|
||||
|
||||
H<sub>2</sub>O
|
||||
|
||||
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
|
||||
|
||||
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
|
||||
|
||||
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
|
||||
17
src/content/posts/de/second-post.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
title: 'Second post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 15 2022'
|
||||
heroImage: '../../../assets/blog-placeholder-4.jpg'
|
||||
category: de/allgemein
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
237
src/content/posts/de/setting-up-forgejo-actions-runner.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
---
|
||||
title: 'Forgejo Actions Runner für self-hosted CI/CD einrichten'
|
||||
description: 'Wie ich manuelle SSH-Deploys durch eine Push-to-Deploy-Pipeline mit einem self-hosted Forgejo Actions Runner auf demselben VPS ersetzt habe.'
|
||||
pubDate: 'Apr 22 2026'
|
||||
heroImage: '../../../assets/blog-placeholder-2.jpg'
|
||||
category: de/technik
|
||||
tags:
|
||||
- forgejo
|
||||
- ci
|
||||
- self-hosted
|
||||
- devops
|
||||
- podman
|
||||
translationKey: forgejo-actions-runner
|
||||
---
|
||||
|
||||
Nachdem ich meine Git-Repos von GitHub auf eine self-hosted Forgejo-Instanz umgezogen hatte, war der nächste logische Schritt, das Deployment von meinem Laptop wegzubekommen. Statt lokal `./scripts/deploy.sh` auszuführen und zu hoffen, dass nichts uncommittet ist, sollte `git push` den Build anstoßen und den Container automatisch ausrollen.
|
||||
|
||||
Dieser Beitrag dokumentiert das komplette Setup: Forgejo Actions Runner auf demselben VPS installieren, an einen Workflow koppeln und Secrets sauber aus dem Repo halten.
|
||||
|
||||
## Das Setup
|
||||
|
||||
- **VPS**: eine Debian-Maschine, die sowohl Forgejo (rootless Podman-Container) als auch die Astro-Website (`/opt/websites/adrian-altner.de`, verwaltet über einen `podman-compose@` systemd-Service) hostet.
|
||||
- **Forgejo**: v11 LTS, rootless, läuft unter einem eigenen `git` System-User.
|
||||
- **Ziel**: bei jedem Push auf `main` das Production-Image neu bauen und den Service neu starten — alles auf derselben Maschine.
|
||||
|
||||
## Warum ein eigener Runner-User
|
||||
|
||||
Der Runner führt beliebigen Code aus Workflow-Dateien aus. Ihn als `git`-User laufen zu lassen (der Zugriff auf Forgejos Datenbank und jedes Repo hat) wäre keine gute Idee. Ich habe einen separaten System-User mit abgeschottetem Home-Verzeichnis angelegt:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --create-home \
|
||||
--home-dir /var/lib/forgejo-runner \
|
||||
--shell /bin/bash forgejo-runner
|
||||
```
|
||||
|
||||
Dieser User bekommt standardmäßig kein sudo — wir erteilen gezielt nur die Rechte, die der Deploy tatsächlich braucht.
|
||||
|
||||
## Runner-Binary installieren
|
||||
|
||||
Der Runner wird als einzelnes statisches Binary aus Forgejos eigener Registry verteilt. Ich hole mir das neueste Release programmatisch:
|
||||
|
||||
```bash
|
||||
LATEST=$(curl -s https://code.forgejo.org/api/v1/repos/forgejo/runner/releases \
|
||||
| grep -oE '"tag_name":"[^"]+"' | head -1 | cut -d'"' -f4)
|
||||
VER="${LATEST#v}"
|
||||
|
||||
cd /tmp
|
||||
curl -L -o forgejo-runner \
|
||||
"https://code.forgejo.org/forgejo/runner/releases/download/${LATEST}/forgejo-runner-${VER}-linux-amd64"
|
||||
chmod +x forgejo-runner
|
||||
sudo mv forgejo-runner /usr/local/bin/
|
||||
```
|
||||
|
||||
Ein kurzes `forgejo-runner --version` bestätigt v12.9.0 — die aktuelle Major-Version, kompatibel mit Forgejo v10, v11 und allem darüber.
|
||||
|
||||
## Actions in Forgejo aktivieren
|
||||
|
||||
Actions sind bei einer Forgejo-Instanz standardmäßig aus. Die minimale Konfiguration kommt in die `app.ini` (bei mir im rootless-Container-Volume unter `/home/git/forgejo-data/custom/conf/app.ini`):
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
DEFAULT_ACTIONS_URL = https://code.forgejo.org
|
||||
```
|
||||
|
||||
`DEFAULT_ACTIONS_URL` ist wichtig, weil der GitHub Actions Marketplace nicht direkt erreichbar ist — Forgejo pflegt eigene Mirrors der gängigen Actions wie `actions/checkout` unter `code.forgejo.org/actions/*`. Nach einem Container-Restart taucht das Verzeichnis `actions_artifacts` in den Logs auf.
|
||||
|
||||
## Runner registrieren
|
||||
|
||||
Runner können auf ein einzelnes Repo, einen User-Account oder die gesamte Instanz registriert werden. Ich habe mit einer Repo-Registrierung für meine Website angefangen und dann auf User-Scope umgestellt, damit derselbe Runner alle meine Repos bedienen kann, ohne sich neu registrieren zu müssen.
|
||||
|
||||
Der Registrierungstoken kommt aus `Benutzer-Einstellungen → Actions → Runner → Neuen Runner erstellen`:
|
||||
|
||||
```bash
|
||||
sudo -iu forgejo-runner /usr/local/bin/forgejo-runner register \
|
||||
--no-interactive \
|
||||
--instance https://git.altner.cloud \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name arcturus-runner \
|
||||
--labels "self-hosted:host"
|
||||
```
|
||||
|
||||
Das Label `self-hosted:host` bedeutet: "Jobs mit Label `self-hosted` laufen direkt auf dem Host". Kein Container-Runtime für den Runner selbst nötig — Podman haben wir ja schon für die Anwendung.
|
||||
|
||||
Umstellung eines bestehenden Runners von Repo- auf User-Scope: Service stoppen, alten Runner-Eintrag in der Forgejo-UI löschen, `/var/lib/forgejo-runner/.runner` lokal entfernen, neuen User-Level-Token holen, neu registrieren, Service starten. Gleiches Binary, anderer Scope.
|
||||
|
||||
## Docker-Abhängigkeit abschalten
|
||||
|
||||
Beim ersten Start hat sich der Runner geweigert zu laufen:
|
||||
|
||||
```
|
||||
Error: daemon Docker Engine socket not found and docker_host config was invalid
|
||||
```
|
||||
|
||||
Auch wenn man nur das Host-Label nutzt, prüft der Runner beim Start auf einen Docker-Socket. Da der Server nur rootless Podman hat, habe ich eine Config-Datei erzeugt und den Docker-Check explizit deaktiviert:
|
||||
|
||||
```bash
|
||||
sudo -iu forgejo-runner /usr/local/bin/forgejo-runner generate-config \
|
||||
> /tmp/runner-config.yaml
|
||||
sudo mv /tmp/runner-config.yaml /var/lib/forgejo-runner/config.yaml
|
||||
sudo chown forgejo-runner:forgejo-runner /var/lib/forgejo-runner/config.yaml
|
||||
|
||||
sudo -iu forgejo-runner sed -i \
|
||||
-e 's|docker_host: .*|docker_host: "-"|' \
|
||||
-e 's| labels: \[\]| labels: ["self-hosted:host"]|' \
|
||||
/var/lib/forgejo-runner/config.yaml
|
||||
```
|
||||
|
||||
## Systemd-Service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Forgejo Actions Runner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=forgejo-runner
|
||||
Group=forgejo-runner
|
||||
WorkingDirectory=/var/lib/forgejo-runner
|
||||
ExecStart=/usr/local/bin/forgejo-runner --config /var/lib/forgejo-runner/config.yaml daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
NoNewPrivileges=false
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/lib/forgejo-runner
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now forgejo-runner
|
||||
```
|
||||
|
||||
## Nur die nötigen sudo-Rechte
|
||||
|
||||
Der Deploy-Step muss ein Podman-Image bauen und den systemd-Service neu starten, der es ausführt. Beides braucht Root. Statt dem Runner-User breites sudo zu geben, habe ich eine eng gefasste Allowlist unter `/etc/sudoers.d/forgejo-runner-deploy` angelegt:
|
||||
|
||||
```
|
||||
forgejo-runner ALL=(root) NOPASSWD: /usr/bin/podman build *, \
|
||||
/usr/bin/podman container prune *, \
|
||||
/usr/bin/podman image prune *, \
|
||||
/usr/bin/podman builder prune *, \
|
||||
/usr/bin/systemctl restart podman-compose@adrian-altner.de.service, \
|
||||
/usr/bin/rsync *
|
||||
```
|
||||
|
||||
`visudo -cf` prüft die Syntax, bevor man sich versehentlich komplett aus sudo aussperrt.
|
||||
|
||||
## Der Workflow
|
||||
|
||||
Workflows liegen unter `.forgejo/workflows/*.yml`. Der Deploy-Flow macht dasselbe wie mein altes Shell-Skript, nur ohne SSH:
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
DEPLOY_DIR: /opt/websites/adrian-altner.de
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync to deploy directory
|
||||
run: |
|
||||
sudo rsync -a --delete \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.production' \
|
||||
--exclude='.git/' \
|
||||
--exclude='node_modules/' \
|
||||
./ "${DEPLOY_DIR}/"
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd "${DEPLOY_DIR}"
|
||||
sudo podman build \
|
||||
--build-arg WEBMENTION_TOKEN="${{ secrets.WEBMENTION_TOKEN }}" \
|
||||
-t localhost/adrian-altner.de:latest .
|
||||
|
||||
- name: Restart service
|
||||
run: sudo systemctl restart podman-compose@adrian-altner.de.service
|
||||
|
||||
- name: Prune
|
||||
run: |
|
||||
sudo podman container prune -f 2>/dev/null || true
|
||||
sudo podman image prune --external -f 2>/dev/null || true
|
||||
sudo podman image prune -f 2>/dev/null || true
|
||||
sudo podman builder prune -af 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Secrets bleiben in Forgejo
|
||||
|
||||
Alles Sensible — in meinem Fall API-Tokens für webmention.io und webmention.app — liegt in `Settings → Actions → Secrets` und wird als `${{ secrets.NAME }}` in den Job injiziert. Forgejo speichert sie verschlüsselt, und Workflow-Logs maskieren die Werte automatisch. Die Tokens werden an genau zwei Stellen referenziert: in der CI-Workflow-Datei (committet) und im verschlüsselten Forgejo-Store (nie im Repo).
|
||||
|
||||
Der Build-Time-Token wird als `ARG` in den Container gereicht, nur während des Build-Stages benutzt und ist im finalen Runtime-Image nicht enthalten — ein schnelles `podman run --rm <image> env | grep -i webmention` bestätigt das.
|
||||
|
||||
## Der eine Stolperstein: Node auf dem Host
|
||||
|
||||
Der erste echte Workflow-Lauf ist sofort gestorben mit:
|
||||
|
||||
```
|
||||
Cannot find: node in PATH
|
||||
```
|
||||
|
||||
`actions/checkout@v4` ist eine JavaScript-basierte Action. Bei einem Runner mit Host-Label läuft sie direkt auf dem VPS und braucht einen Node-Interpreter im `PATH`. Ein `apt install` später war der Runner zufrieden:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo systemctl restart forgejo-runner
|
||||
```
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Von einem kalten `git push origin main` bis zur komplett durchgelaufenen Pipeline — Checkout, rsync, Podman-Build, systemd-Restart, Prune, Webmention-Pings — vergehen etwa 1 Minute 15 Sekunden. Keine SSH-Keys zu rotieren, kein Laptop involviert, kein Mysterium über den Stand der Live-Version.
|
||||
|
||||
Der Runner selbst belegt im Idle rund 5 MB RAM und pollt Forgejo alle zwei Sekunden auf neue Jobs. Der Ressourcen-Overhead ist vernachlässigbar verglichen mit dem Komfort von Push-to-Deploy auf Infrastruktur, die mir komplett gehört.
|
||||
|
||||
## Runner für neue Projekte wiederverwenden
|
||||
|
||||
Weil der Runner auf User-Scope registriert ist, reduziert sich das Anhängen von CI an ein neues Repo auf drei Schritte:
|
||||
|
||||
1. Eine `.forgejo/workflows/deploy.yml` mit `runs-on: self-hosted` ins Repo packen.
|
||||
2. Projekt-spezifische Secrets unter den Actions-Settings des Repos anlegen.
|
||||
3. Falls das Projekt einen eigenen systemd-Service hat, `/etc/sudoers.d/forgejo-runner-deploy` um eine Zeile `systemctl restart <neuer-service>` erweitern. Sonst muss auf dem Server nichts geändert werden.
|
||||
|
||||
Die einmaligen Infrastrukturkosten — User-Account, Binary, Config, systemd-Unit, Node-Runtime, sudoers — amortisieren sich über jedes weitere Projekt.
|
||||
17
src/content/posts/de/third-post.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
title: 'Third post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 22 2022'
|
||||
heroImage: '../../../assets/blog-placeholder-2.jpg'
|
||||
category: de/allgemein
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
35
src/content/posts/de/using-mdx.mdx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
title: 'Using MDX'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jun 01 2024'
|
||||
heroImage: '../../../assets/blog-placeholder-5.jpg'
|
||||
category: de/technik
|
||||
tags:
|
||||
- markdown
|
||||
- astro
|
||||
---
|
||||
|
||||
This theme comes with the [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/) integration installed and configured in your `astro.config.mjs` config file. If you prefer not to use MDX, you can disable support by removing the integration from your config file.
|
||||
|
||||
## Why MDX?
|
||||
|
||||
MDX is a special flavor of Markdown that supports embedded JavaScript & JSX syntax. This unlocks the ability to [mix JavaScript and UI Components into your Markdown content](https://docs.astro.build/en/guides/integrations-guide/mdx/#mdx-in-astro) for things like interactive charts or alerts.
|
||||
|
||||
If you have existing content authored in MDX, this integration will hopefully make migrating to Astro a breeze.
|
||||
|
||||
## Example
|
||||
|
||||
Here is how you import and use a UI component inside of MDX.
|
||||
When you open this page in the browser, you should see the clickable button below.
|
||||
|
||||
import HeaderLink from '~/components/HeaderLink.astro';
|
||||
|
||||
<HeaderLink href="#" onclick="alert('clicked!')">
|
||||
Embedded component in MDX
|
||||
</HeaderLink>
|
||||
|
||||
## More Links
|
||||
|
||||
- [MDX Syntax Documentation](https://mdxjs.com/docs/what-is-mdx)
|
||||
- [Astro Usage Documentation](https://docs.astro.build/en/basics/astro-pages/#markdownmdx-pages)
|
||||
- **Note:** [Client Directives](https://docs.astro.build/en/reference/directives-reference/#client-directives) are still required to create interactive components. Otherwise, all components in your MDX will render as static HTML (no JavaScript) by default.
|
||||
12
src/content/posts/en/hello-world.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: 'Hello World'
|
||||
description: 'First English post.'
|
||||
pubDate: 'Apr 20 2026'
|
||||
heroImage: '../../../assets/blog-placeholder-1.jpg'
|
||||
category: en/general
|
||||
tags:
|
||||
- markdown
|
||||
translationKey: hello-world
|
||||
---
|
||||
|
||||
This is the first English post.
|
||||
237
src/content/posts/en/setting-up-forgejo-actions-runner.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
---
|
||||
title: 'Setting up a Forgejo Actions runner for self-hosted CI/CD'
|
||||
description: 'How I replaced manual SSH deploys with a push-to-deploy pipeline using a self-hosted Forgejo Actions runner on the same VPS.'
|
||||
pubDate: 'Apr 22 2026'
|
||||
heroImage: '../../../assets/blog-placeholder-2.jpg'
|
||||
category: en/tech
|
||||
tags:
|
||||
- forgejo
|
||||
- ci
|
||||
- self-hosted
|
||||
- devops
|
||||
- podman
|
||||
translationKey: forgejo-actions-runner
|
||||
---
|
||||
|
||||
After moving my Git repositories from GitHub to a self-hosted Forgejo instance, the next logical step was to move deployment off my laptop. Instead of running `./scripts/deploy.sh` locally and hoping nothing was uncommitted, I wanted `git push` to trigger the build and roll out the container automatically.
|
||||
|
||||
This post documents the full setup: installing a Forgejo Actions runner on the same VPS that runs Forgejo, wiring it to a workflow, and keeping secrets out of the repo.
|
||||
|
||||
## The setup
|
||||
|
||||
- **VPS**: single Debian machine hosting both Forgejo (rootless Podman container) and the Astro website (`/opt/websites/adrian-altner.de`, managed by a `podman-compose@` systemd service).
|
||||
- **Forgejo**: v11 LTS, rootless, running under a dedicated `git` system user.
|
||||
- **Goal**: on every push to `main`, rebuild the production image and restart the service — all on the same box.
|
||||
|
||||
## Why a dedicated runner user
|
||||
|
||||
The runner executes arbitrary code defined in workflow files. Running it as the `git` user (which has access to Forgejo's database and every repo) would be a bad idea. I created a separate system user with a locked-down home directory:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --create-home \
|
||||
--home-dir /var/lib/forgejo-runner \
|
||||
--shell /bin/bash forgejo-runner
|
||||
```
|
||||
|
||||
That user gets no sudo by default — we'll grant targeted privileges later only for the specific commands the deploy needs.
|
||||
|
||||
## Installing the runner binary
|
||||
|
||||
The runner is distributed as a single static binary from Forgejo's own registry. I grabbed the latest release programmatically:
|
||||
|
||||
```bash
|
||||
LATEST=$(curl -s https://code.forgejo.org/api/v1/repos/forgejo/runner/releases \
|
||||
| grep -oE '"tag_name":"[^"]+"' | head -1 | cut -d'"' -f4)
|
||||
VER="${LATEST#v}"
|
||||
|
||||
cd /tmp
|
||||
curl -L -o forgejo-runner \
|
||||
"https://code.forgejo.org/forgejo/runner/releases/download/${LATEST}/forgejo-runner-${VER}-linux-amd64"
|
||||
chmod +x forgejo-runner
|
||||
sudo mv forgejo-runner /usr/local/bin/
|
||||
```
|
||||
|
||||
A quick `forgejo-runner --version` confirmed v12.9.0 was in place — which is the current major, compatible with Forgejo v10, v11, and beyond.
|
||||
|
||||
## Enabling Actions in Forgejo
|
||||
|
||||
Actions are off by default on Forgejo instances. I added the minimal configuration to `app.ini` (found inside the rootless container's volume at `/home/git/forgejo-data/custom/conf/app.ini`):
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
DEFAULT_ACTIONS_URL = https://code.forgejo.org
|
||||
```
|
||||
|
||||
`DEFAULT_ACTIONS_URL` matters because GitHub's Actions marketplace isn't reachable as-is — Forgejo maintains its own mirrors of common actions like `actions/checkout` at `code.forgejo.org/actions/*`. A container restart and the `actions_artifacts` storage directory appeared in the logs.
|
||||
|
||||
## Registering the runner
|
||||
|
||||
Runners can be scoped to a single repo, to a user account, or to the whole instance. I started with a repo-scoped registration for my website, then moved it to user-scope so the same runner can serve every repo I own without re-registration.
|
||||
|
||||
The registration token came from `User Settings → Actions → Runners → Create new Runner`:
|
||||
|
||||
```bash
|
||||
sudo -iu forgejo-runner /usr/local/bin/forgejo-runner register \
|
||||
--no-interactive \
|
||||
--instance https://git.altner.cloud \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name arcturus-runner \
|
||||
--labels "self-hosted:host"
|
||||
```
|
||||
|
||||
The label `self-hosted:host` means "jobs labelled `self-hosted` run directly on the host". No container runtime required for the runner itself — we already have Podman for the application.
|
||||
|
||||
To switch an existing runner from repo to user scope: stop the service, delete the old runner entry in the Forgejo UI, remove `/var/lib/forgejo-runner/.runner` locally, grab a new user-level token, re-register, start the service. Same binary, different scope.
|
||||
|
||||
## Making it not-need-Docker
|
||||
|
||||
On first boot, the runner refused to start with:
|
||||
|
||||
```
|
||||
Error: daemon Docker Engine socket not found and docker_host config was invalid
|
||||
```
|
||||
|
||||
Even when using only the host label, the runner checks for a Docker socket on startup. Since the server only has rootless Podman, I generated a config file and explicitly disabled the Docker check:
|
||||
|
||||
```bash
|
||||
sudo -iu forgejo-runner /usr/local/bin/forgejo-runner generate-config \
|
||||
> /tmp/runner-config.yaml
|
||||
sudo mv /tmp/runner-config.yaml /var/lib/forgejo-runner/config.yaml
|
||||
sudo chown forgejo-runner:forgejo-runner /var/lib/forgejo-runner/config.yaml
|
||||
|
||||
sudo -iu forgejo-runner sed -i \
|
||||
-e 's|docker_host: .*|docker_host: "-"|' \
|
||||
-e 's| labels: \[\]| labels: ["self-hosted:host"]|' \
|
||||
/var/lib/forgejo-runner/config.yaml
|
||||
```
|
||||
|
||||
## Systemd service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Forgejo Actions Runner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=forgejo-runner
|
||||
Group=forgejo-runner
|
||||
WorkingDirectory=/var/lib/forgejo-runner
|
||||
ExecStart=/usr/local/bin/forgejo-runner --config /var/lib/forgejo-runner/config.yaml daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
NoNewPrivileges=false
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/lib/forgejo-runner
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now forgejo-runner
|
||||
```
|
||||
|
||||
## Granting just enough sudo
|
||||
|
||||
The deploy step needs to build a Podman image and restart the systemd service that runs it. Both require root. Instead of giving the runner user broad sudo, I created a narrow allowlist in `/etc/sudoers.d/forgejo-runner-deploy`:
|
||||
|
||||
```
|
||||
forgejo-runner ALL=(root) NOPASSWD: /usr/bin/podman build *, \
|
||||
/usr/bin/podman container prune *, \
|
||||
/usr/bin/podman image prune *, \
|
||||
/usr/bin/podman builder prune *, \
|
||||
/usr/bin/systemctl restart podman-compose@adrian-altner.de.service, \
|
||||
/usr/bin/rsync *
|
||||
```
|
||||
|
||||
`visudo -cf` parses it to catch syntax errors before you accidentally lock yourself out of sudo entirely.
|
||||
|
||||
## The workflow
|
||||
|
||||
Workflows live under `.forgejo/workflows/*.yml`. The deploy flow mirrors what my old shell script did, minus the SSH:
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
DEPLOY_DIR: /opt/websites/adrian-altner.de
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync to deploy directory
|
||||
run: |
|
||||
sudo rsync -a --delete \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.production' \
|
||||
--exclude='.git/' \
|
||||
--exclude='node_modules/' \
|
||||
./ "${DEPLOY_DIR}/"
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd "${DEPLOY_DIR}"
|
||||
sudo podman build \
|
||||
--build-arg WEBMENTION_TOKEN="${{ secrets.WEBMENTION_TOKEN }}" \
|
||||
-t localhost/adrian-altner.de:latest .
|
||||
|
||||
- name: Restart service
|
||||
run: sudo systemctl restart podman-compose@adrian-altner.de.service
|
||||
|
||||
- name: Prune
|
||||
run: |
|
||||
sudo podman container prune -f 2>/dev/null || true
|
||||
sudo podman image prune --external -f 2>/dev/null || true
|
||||
sudo podman image prune -f 2>/dev/null || true
|
||||
sudo podman builder prune -af 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Secrets stay in Forgejo
|
||||
|
||||
Anything sensitive — API tokens for webmention.io and webmention.app in my case — lives in `Settings → Actions → Secrets` and is injected into the job as `${{ secrets.NAME }}`. Forgejo stores them encrypted, and the workflow logs automatically mask the values. The tokens are referenced from exactly two places: the CI workflow file (committed) and Forgejo's encrypted store (never in the repo).
|
||||
|
||||
The build-time token is passed into the container as an `ARG`, used only during the build stage, and not present in the final runtime image — a quick `podman run --rm <image> env | grep -i webmention` confirms it's gone.
|
||||
|
||||
## The one gotcha: Node on the host
|
||||
|
||||
The first real workflow run failed immediately with:
|
||||
|
||||
```
|
||||
Cannot find: node in PATH
|
||||
```
|
||||
|
||||
`actions/checkout@v4` is a JavaScript-based action. On a runner using the host label, it runs directly on the VPS and needs a Node interpreter available in `PATH`. One apt install later and the runner was happy:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo systemctl restart forgejo-runner
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
From a cold `git push origin main`, the whole pipeline — checkout, rsync, Podman build, systemd restart, prune, Webmention pings — completes in about 1 minute 15 seconds. No SSH keys to rotate, no laptop involved, no mystery about which version of the code is live.
|
||||
|
||||
The runner itself uses about 5 MB of RAM while idle, polling Forgejo every two seconds for new jobs. Resource overhead is negligible compared to the convenience of push-to-deploy on infrastructure I fully control.
|
||||
|
||||
## Reusing the runner for new projects
|
||||
|
||||
Because the runner is registered at user scope, adding CI to a new repository boils down to three steps:
|
||||
|
||||
1. Drop a `.forgejo/workflows/deploy.yml` into the repo with `runs-on: self-hosted`.
|
||||
2. Add any project-specific secrets under the repo's Actions settings.
|
||||
3. If the project has its own systemd service, extend `/etc/sudoers.d/forgejo-runner-deploy` with a line allowing `systemctl restart <new-service>`. Nothing else on the server needs to change.
|
||||
|
||||
The one-time infrastructure cost — user account, binary, config, systemd unit, Node runtime, sudoers — gets amortised across every project from here on.
|
||||
152
src/i18n/posts.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
|
||||
import { type Locale } from '~/consts';
|
||||
import { isLocale, localizePath } from '~/i18n/ui';
|
||||
|
||||
export function entryLocale(entry: { id: string }): Locale {
|
||||
const first = entry.id.split('/')[0];
|
||||
if (!isLocale(first)) {
|
||||
throw new Error(`Content entry "${entry.id}" is not under a locale folder (de/ or en/).`);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
export function entrySlug(entry: { id: string }): string {
|
||||
return entry.id.split('/').slice(1).join('/');
|
||||
}
|
||||
|
||||
// Back-compat aliases used across the codebase.
|
||||
export const postLocale = entryLocale;
|
||||
export const postSlug = entrySlug;
|
||||
|
||||
export async function getPostsByLocale(locale: Locale) {
|
||||
const posts = await getCollection('posts', (p) => entryLocale(p) === locale);
|
||||
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
}
|
||||
|
||||
export async function getCategoriesByLocale(locale: Locale) {
|
||||
const categories = await getCollection('categories', (c) => entryLocale(c) === locale);
|
||||
return categories.sort((a, b) => a.data.name.localeCompare(b.data.name));
|
||||
}
|
||||
|
||||
export async function getPostsByCategory(category: CollectionEntry<'categories'>) {
|
||||
const locale = entryLocale(category);
|
||||
const posts = await getCollection(
|
||||
'posts',
|
||||
(p) => entryLocale(p) === locale && p.data.category?.id === category.id,
|
||||
);
|
||||
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
}
|
||||
|
||||
/** Convert a tag name into a URL-safe slug. */
|
||||
export function tagSlug(tag: string): string {
|
||||
return tag
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/** Aggregated tag info across posts in one locale. */
|
||||
export interface TagEntry {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function getTagsByLocale(locale: Locale): Promise<TagEntry[]> {
|
||||
const posts = await getPostsByLocale(locale);
|
||||
const byName = new Map<string, TagEntry>();
|
||||
for (const post of posts) {
|
||||
for (const raw of post.data.tags ?? []) {
|
||||
const name = raw.trim();
|
||||
if (!name) continue;
|
||||
const existing = byName.get(name);
|
||||
if (existing) existing.count++;
|
||||
else byName.set(name, { name, slug: tagSlug(name), count: 1 });
|
||||
}
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Resolve a tag slug for a locale back to its canonical TagEntry. */
|
||||
export async function findTagBySlug(locale: Locale, slug: string): Promise<TagEntry | undefined> {
|
||||
const tags = await getTagsByLocale(locale);
|
||||
return tags.find((t) => t.slug === slug);
|
||||
}
|
||||
|
||||
export async function getPostsByTag(locale: Locale, slug: string) {
|
||||
const posts = await getPostsByLocale(locale);
|
||||
return posts.filter((p) => (p.data.tags ?? []).some((name) => tagSlug(name) === slug));
|
||||
}
|
||||
|
||||
export async function resolveCategory(post: CollectionEntry<'posts'>) {
|
||||
if (!post.data.category) return undefined;
|
||||
return await getEntry(post.data.category);
|
||||
}
|
||||
|
||||
/** URL segment used for category detail pages per locale. */
|
||||
export function categorySegment(locale: Locale): string {
|
||||
return locale === 'de' ? 'kategorie' : 'category';
|
||||
}
|
||||
|
||||
/** URL segment used for the category listing page per locale. */
|
||||
export function categoryIndexSegment(locale: Locale): string {
|
||||
return locale === 'de' ? 'kategorien' : 'categories';
|
||||
}
|
||||
|
||||
/** URL segment used for the about page per locale. */
|
||||
export function aboutSegment(locale: Locale): string {
|
||||
return locale === 'de' ? 'ueber-mich' : 'about';
|
||||
}
|
||||
|
||||
/** URL segment used for tag detail pages per locale. */
|
||||
export function tagSegment(locale: Locale): string {
|
||||
return locale === 'de' ? 'schlagwort' : 'tag';
|
||||
}
|
||||
|
||||
/** URL segment used for the tag listing page per locale. */
|
||||
export function tagIndexSegment(locale: Locale): string {
|
||||
return locale === 'de' ? 'schlagwoerter' : 'tags';
|
||||
}
|
||||
|
||||
export function categoryHref(category: CollectionEntry<'categories'>): string {
|
||||
const locale = entryLocale(category);
|
||||
return localizePath(`/${categorySegment(locale)}/${entrySlug(category)}/`, locale);
|
||||
}
|
||||
|
||||
export function postHref(post: CollectionEntry<'posts'>): string {
|
||||
const locale = entryLocale(post);
|
||||
return localizePath(`/${entrySlug(post)}/`, locale);
|
||||
}
|
||||
|
||||
export function tagHref(locale: Locale, tag: string | TagEntry): string {
|
||||
const slug = typeof tag === 'string' ? tagSlug(tag) : tag.slug;
|
||||
return localizePath(`/${tagSegment(locale)}/${slug}/`, locale);
|
||||
}
|
||||
|
||||
/** Canonical URL for any translatable entry. */
|
||||
export function entryHref(entry: CollectionEntry<'posts' | 'categories'>): string {
|
||||
return entry.collection === 'categories' ? categoryHref(entry) : postHref(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the translation of an entry in the target locale, matched via the
|
||||
* shared `translationKey` frontmatter field. Returns `undefined` when no
|
||||
* matching translation exists.
|
||||
*/
|
||||
export async function findTranslation(
|
||||
entry: CollectionEntry<'posts' | 'categories'>,
|
||||
target: Locale,
|
||||
): Promise<CollectionEntry<'posts' | 'categories'> | undefined> {
|
||||
const key = entry.data.translationKey;
|
||||
if (!key) return undefined;
|
||||
const collection = entry.collection;
|
||||
const all = await getCollection(collection, (e) => entryLocale(e) === target);
|
||||
return all.find((e) => e.data.translationKey === key);
|
||||
}
|
||||
134
src/i18n/ui.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { DEFAULT_LOCALE, type Locale, LOCALES } from '~/consts';
|
||||
|
||||
export const ui = {
|
||||
de: {
|
||||
'nav.home': 'Start',
|
||||
'nav.about': 'Über mich',
|
||||
'nav.categories': 'Kategorien',
|
||||
'nav.tags': 'Schlagwörter',
|
||||
'post.lastUpdated': 'Zuletzt aktualisiert am',
|
||||
'post.category': 'Kategorie',
|
||||
'post.tags': 'Schlagwörter',
|
||||
'post.translationAvailable': 'Dieser Beitrag ist auch auf Englisch verfügbar:',
|
||||
'post.translationLink': 'Englische Version lesen',
|
||||
'categories.title': 'Kategorien',
|
||||
'categories.description': 'Alle Kategorien im Überblick.',
|
||||
'category.postsIn': 'Beiträge in',
|
||||
'category.noPosts': 'Noch keine Beiträge in dieser Kategorie.',
|
||||
'tags.title': 'Schlagwörter',
|
||||
'tags.description': 'Alle Schlagwörter im Überblick.',
|
||||
'tag.postsTagged': 'Beiträge mit',
|
||||
'tag.noPosts': 'Noch keine Beiträge mit diesem Stichwort.',
|
||||
'footer.contact': 'Kontakt',
|
||||
'footer.imprint': 'Impressum',
|
||||
'footer.privacy': 'Datenschutz',
|
||||
'webmentions.title': 'Reaktionen',
|
||||
'webmentions.like': 'Like',
|
||||
'webmentions.likes': 'Likes',
|
||||
'webmentions.repost': 'Repost',
|
||||
'webmentions.reposts': 'Reposts',
|
||||
'webmentions.replies': 'Antworten',
|
||||
'webmentions.mentions': 'Erwähnungen',
|
||||
'lang.de': 'Deutsch',
|
||||
'lang.en': 'English',
|
||||
},
|
||||
en: {
|
||||
'nav.home': 'Home',
|
||||
'nav.about': 'About',
|
||||
'nav.categories': 'Categories',
|
||||
'nav.tags': 'Tags',
|
||||
'post.lastUpdated': 'Last updated on',
|
||||
'post.category': 'Category',
|
||||
'post.tags': 'Tags',
|
||||
'post.translationAvailable': 'This post is also available in German:',
|
||||
'post.translationLink': 'Read the German version',
|
||||
'categories.title': 'Categories',
|
||||
'categories.description': 'All categories at a glance.',
|
||||
'category.postsIn': 'Posts in',
|
||||
'category.noPosts': 'No posts in this category yet.',
|
||||
'tags.title': 'Tags',
|
||||
'tags.description': 'All tags at a glance.',
|
||||
'tag.postsTagged': 'Posts tagged',
|
||||
'tag.noPosts': 'No posts with this tag yet.',
|
||||
'footer.contact': 'Contact',
|
||||
'footer.imprint': 'Imprint',
|
||||
'footer.privacy': 'Privacy',
|
||||
'webmentions.title': 'Reactions',
|
||||
'webmentions.like': 'Like',
|
||||
'webmentions.likes': 'Likes',
|
||||
'webmentions.repost': 'Repost',
|
||||
'webmentions.reposts': 'Reposts',
|
||||
'webmentions.replies': 'Replies',
|
||||
'webmentions.mentions': 'Mentions',
|
||||
'lang.de': 'Deutsch',
|
||||
'lang.en': 'English',
|
||||
},
|
||||
} as const satisfies Record<Locale, Record<string, string>>;
|
||||
|
||||
export type UIKey = keyof (typeof ui)['de'];
|
||||
|
||||
export function t(locale: Locale, key: UIKey): string {
|
||||
return ui[locale][key];
|
||||
}
|
||||
|
||||
export function isLocale(value: string | undefined): value is Locale {
|
||||
return !!value && (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getLocaleFromUrl(url: URL): Locale {
|
||||
const seg = url.pathname.split('/').filter(Boolean)[0];
|
||||
return isLocale(seg) ? seg : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL for a route within a given locale. `path` is the route without
|
||||
* any language prefix, e.g. "/" or "/about".
|
||||
*/
|
||||
export function localizePath(path: string, locale: Locale): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
if (locale === DEFAULT_LOCALE) return normalized;
|
||||
if (normalized === '/') return `/${locale}/`;
|
||||
return `/${locale}${normalized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments whose URL slug differs per locale. The first segment of any
|
||||
* non-prefixed pathname is translated through this map when switching.
|
||||
*/
|
||||
const LOCALIZED_SEGMENTS: Record<Locale, Record<string, string>> = {
|
||||
de: {
|
||||
category: 'kategorie',
|
||||
categories: 'kategorien',
|
||||
about: 'ueber-mich',
|
||||
tag: 'schlagwort',
|
||||
tags: 'schlagwoerter',
|
||||
contact: 'kontakt',
|
||||
imprint: 'impressum',
|
||||
'privacy-policy': 'datenschutz',
|
||||
},
|
||||
en: {
|
||||
kategorie: 'category',
|
||||
kategorien: 'categories',
|
||||
'ueber-mich': 'about',
|
||||
schlagwort: 'tag',
|
||||
schlagwoerter: 'tags',
|
||||
kontakt: 'contact',
|
||||
impressum: 'imprint',
|
||||
datenschutz: 'privacy-policy',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Swap the locale of the current pathname, preserving the rest of the route
|
||||
* and translating known per-locale URL segments (e.g. `kategorie` ↔ `category`).
|
||||
*/
|
||||
export function switchLocalePath(pathname: string, target: Locale): string {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
if (parts.length > 0 && isLocale(parts[0])) parts.shift();
|
||||
if (parts.length > 0) {
|
||||
const translated = LOCALIZED_SEGMENTS[target][parts[0]];
|
||||
if (translated) parts[0] = translated;
|
||||
}
|
||||
const rest = parts.length ? `/${parts.join('/')}` : '/';
|
||||
return localizePath(rest === '/' ? '/' : rest, target);
|
||||
}
|
||||
57
src/layouts/BaseLayout.astro
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import BaseHead from '~/components/BaseHead.astro';
|
||||
import Footer from '~/components/Footer.astro';
|
||||
import Header from '~/components/Header.astro';
|
||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||
import { getLocaleFromUrl } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
locale?: Locale;
|
||||
image?: ImageMetadata;
|
||||
/** Current content entry, used for the language switcher's translation lookup. */
|
||||
entry?: CollectionEntry<'posts' | 'categories'>;
|
||||
/** Optional extra class on `<body>` for per-page styling hooks. */
|
||||
bodyClass?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
entry,
|
||||
bodyClass,
|
||||
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<BaseHead title={title} description={description} image={image} locale={locale} />
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const stored = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = stored === 'dark' || stored === 'light' ? stored : prefersDark ? 'dark' : 'light';
|
||||
root.dataset.theme = theme;
|
||||
// Enable theme transitions after initial render
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => root.setAttribute('data-theme-ready', '')),
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class={bodyClass}>
|
||||
<Header locale={locale} entry={entry} />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<Footer locale={locale} />
|
||||
</body>
|
||||
</html>
|
||||
163
src/layouts/Post.astro
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import { getEntry } from 'astro:content';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import FormattedDate from '~/components/FormattedDate.astro';
|
||||
import Webmentions from '~/components/Webmentions.astro';
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||
import { categoryHref, entryHref, findTranslation, tagHref } from '~/i18n/posts';
|
||||
import { getLocaleFromUrl, t } from '~/i18n/ui';
|
||||
|
||||
type Props = CollectionEntry<'posts'>['data'] & {
|
||||
locale?: Locale;
|
||||
entry?: CollectionEntry<'posts'>;
|
||||
};
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
pubDate,
|
||||
updatedDate,
|
||||
heroImage,
|
||||
category,
|
||||
tags,
|
||||
entry,
|
||||
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
|
||||
} = Astro.props;
|
||||
|
||||
const categoryEntry = category ? await getEntry(category) : undefined;
|
||||
const otherLocale: Locale = locale === 'de' ? 'en' : 'de';
|
||||
const translation = entry ? await findTranslation(entry, otherLocale) : undefined;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} image={heroImage} locale={locale} entry={entry}>
|
||||
<article class="h-entry">
|
||||
<a href={Astro.url.pathname} class="u-url" hidden></a>
|
||||
<div class="hero-image">
|
||||
{
|
||||
heroImage && (
|
||||
<Image
|
||||
class="u-photo"
|
||||
width={1020}
|
||||
height={510}
|
||||
src={heroImage}
|
||||
alt=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="prose">
|
||||
<div class="title">
|
||||
<div class="date">
|
||||
<FormattedDate date={pubDate} locale={locale} class="dt-published" />
|
||||
{
|
||||
updatedDate && (
|
||||
<div class="last-updated-on">
|
||||
{t(locale, 'post.lastUpdated')}{' '}
|
||||
<FormattedDate date={updatedDate} locale={locale} class="dt-updated" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<h1 class="p-name">{title}</h1>
|
||||
<p class="p-summary" hidden>{description}</p>
|
||||
<p class="p-author h-card" hidden>
|
||||
<a class="u-url p-name" href={new URL('/', Astro.site)}>Adrian Altner</a>
|
||||
</p>
|
||||
{
|
||||
categoryEntry && (
|
||||
<p class="category">
|
||||
{t(locale, 'post.category')}:{' '}
|
||||
<a href={categoryHref(categoryEntry)} class="p-category">
|
||||
{categoryEntry.data.name}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
{
|
||||
tags && tags.length > 0 && (
|
||||
<p class="tags">
|
||||
{t(locale, 'post.tags')}:{' '}
|
||||
{tags.map((name, i) => (
|
||||
<>
|
||||
{i > 0 && ', '}
|
||||
<a href={tagHref(locale, name)} class="p-category">
|
||||
{name}
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<hr />
|
||||
</div>
|
||||
{
|
||||
translation && (
|
||||
<aside class="translation-notice" lang={otherLocale}>
|
||||
<span>{t(locale, 'post.translationAvailable')}</span>{' '}
|
||||
<a href={entryHref(translation)} hreflang={otherLocale}>
|
||||
{t(locale, 'post.translationLink')}
|
||||
</a>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
<div class="e-content">
|
||||
<slot />
|
||||
</div>
|
||||
<Webmentions target={new URL(Astro.url.pathname, Astro.site)} locale={locale} />
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: calc(100% - 2em);
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
}
|
||||
.hero-image img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
.prose {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
padding: 1em;
|
||||
color: rgb(var(--gray-dark));
|
||||
}
|
||||
.title {
|
||||
margin-bottom: 1em;
|
||||
padding: 1em 0;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.title h1 {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
.date {
|
||||
margin-bottom: 0.5em;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.last-updated-on {
|
||||
font-style: italic;
|
||||
}
|
||||
.translation-notice {
|
||||
margin: 0 0 2em 0;
|
||||
padding: 0.75em 1em;
|
||||
background: rgba(var(--gray-light), 0.6);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.translation-notice a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
50
src/layouts/Prose.astro
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
||||
import { getLocaleFromUrl } from '~/i18n/ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} locale={locale} bodyClass="prose-page">
|
||||
<article class="prose">
|
||||
<slot />
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
body.prose-page main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.prose-page .prose :global(.r) {
|
||||
direction: rtl;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function decode() {
|
||||
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
|
||||
el.textContent = atob(el.dataset.obf!);
|
||||
el.removeAttribute('data-obf');
|
||||
});
|
||||
document.querySelectorAll<HTMLAnchorElement>('[data-obf-href]').forEach((el) => {
|
||||
el.href = atob(el.dataset.obfHref!);
|
||||
el.removeAttribute('data-obf-href');
|
||||
});
|
||||
}
|
||||
decode();
|
||||
document.addEventListener('astro:page-load', decode);
|
||||
</script>
|
||||
21
src/pages/[...slug].astro
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
import { type CollectionEntry, render } from 'astro:content';
|
||||
import Post from '~/layouts/Post.astro';
|
||||
import { getPostsByLocale, postSlug } from '~/i18n/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPostsByLocale('de');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: postSlug(post) },
|
||||
props: post,
|
||||
}));
|
||||
}
|
||||
type Props = CollectionEntry<'posts'>;
|
||||
|
||||
const post = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
---
|
||||
|
||||
<Post {...post.data} locale="de" entry={post}>
|
||||
<Content />
|
||||
</Post>
|
||||
119
src/pages/datenschutz.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
layout: ~/layouts/Prose.astro
|
||||
title: Datenschutzerklärung
|
||||
description: Datenschutzerklärung und Informationen zum Datenschutz für adrian-altner.de.
|
||||
---
|
||||
|
||||
# Datenschutzerklärung
|
||||
|
||||
*Zuletzt aktualisiert: März 2026 (ergänzt: Hosting-Anbieter, interaktive Karte)*
|
||||
|
||||
---
|
||||
|
||||
## 1. Verantwortlicher
|
||||
|
||||
Verantwortlicher für die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO) ist:
|
||||
|
||||
<strong><span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span></strong><br>
|
||||
**E-Mail:** <span class="r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA=="></span>
|
||||
|
||||
---
|
||||
|
||||
## 2. Allgemeine Hinweise zur Datenverarbeitung
|
||||
|
||||
Der Schutz deiner personenbezogenen Daten ist mir sehr wichtig. Ich behandle deine personenbezogenen Daten vertraulich und entsprechend den gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||
|
||||
Die Nutzung dieser Website ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf dieser Website personenbezogene Daten (z.B. Name oder E-Mail-Adresse) erhoben werden, erfolgt dies, soweit möglich, auf freiwilliger Basis. Diese Daten werden ohne deine ausdrückliche Zustimmung nicht an Dritte weitergegeben.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenerfassung beim Besuch dieser Website
|
||||
|
||||
**Server-Logfiles**
|
||||
|
||||
Beim Besuch dieser Website erhebt und speichert der Hosting-Anbieter automatisch Informationen in sogenannten Server-Logfiles, die dein Browser automatisch übermittelt. Dies sind:
|
||||
|
||||
- Deine IP-Adresse
|
||||
- Datum und Uhrzeit der Anfrage
|
||||
- Inhalt der Anforderung (konkrete Seite)
|
||||
- Zugriffsstatus / HTTP-Statuscode
|
||||
- Übertragene Datenmenge
|
||||
- Website, von der die Anforderung kommt (Referrer)
|
||||
- Browsertyp und Version
|
||||
- Betriebssystem
|
||||
|
||||
Diese Daten werden nicht mit anderen Datenquellen zusammengeführt. Rechtsgrundlage der Verarbeitung ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Das berechtigte Interesse liegt in der technisch fehlerfreien Darstellung und Optimierung dieser Website.
|
||||
|
||||
Die Server-Logfiles werden maximal 7 Tage gespeichert und anschließend gelöscht, sofern keine weitere Aufbewahrung aus Sicherheitsgründen erforderlich ist.
|
||||
|
||||
---
|
||||
|
||||
## 4. Hosting
|
||||
|
||||
Diese Website wird gehostet bei der **Hetzner Online GmbH**, Industriestr. 25, 91710 Gunzenhausen, Deutschland (<https://www.hetzner.com>). Die Datenverarbeitung erfolgt ausschließlich auf Servern innerhalb der Europäischen Union und gewährleistet damit ein hohes Datenschutzniveau im Sinne der DSGVO.
|
||||
|
||||
Beim Besuch dieser Website kann Hetzner technische Verbindungsdaten (z.B. IP-Adresse) im Rahmen des Serverbetriebs verarbeiten. Mit Hetzner wurde ein Auftragsverarbeitungsvertrag (AVV) nach Art. 28 DSGVO abgeschlossen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Interaktive Karte (Stadia Maps)
|
||||
|
||||
Die Foto-Karten-Seite (`/photos/map`) verwendet Kartenkacheln von **Stadia Maps** (Stadia Maps, LLC, <https://stadiamaps.com>). Beim Besuch dieser Seite lädt dein Browser Kartenkacheln direkt von Servern von Stadia Maps innerhalb der Europäischen Union (`tiles-eu.stadiamaps.com`). Dabei werden deine IP-Adresse sowie Browser-Informationen an Stadia Maps übertragen.
|
||||
|
||||
Stadia Maps verfolgt keine Nutzer websiteübergreifend und verarbeitet Daten DSGVO-konform. Details siehe Datenschutzerklärung: <https://stadiamaps.com/privacy-policy/>. Ein Auftragsverarbeitungsvertrag (AVV) ist verfügbar unter <https://stadiamaps.com/legal/data-processing-addendum/>.
|
||||
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Bereitstellen einer interaktiven Karte der Foto-Standorte). Kartenkachel-Anfragen erfolgen nur bei aktivem Aufruf der Kartenseite.
|
||||
|
||||
Die Kartendaten stammen von den **OpenStreetMap**-Mitwirkenden (<https://www.openstreetmap.org/copyright>, ODbL-Lizenz) und werden von **OpenMapTiles** (<https://openmaptiles.org>) zu Vektorkacheln verarbeitet.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cookies
|
||||
|
||||
Diese Website verwendet keine Cookies, Tracking-Technologien oder Analyse-Tools. Beim Besuch werden keine Informationen auf deinem Gerät gespeichert.
|
||||
|
||||
---
|
||||
|
||||
## 7. Kontaktaufnahme
|
||||
|
||||
Wenn du mich per E-Mail kontaktierst, werden die von dir mitgeteilten Daten (deine E-Mail-Adresse und der Inhalt deiner Nachricht) zum Zweck der Bearbeitung deiner Anfrage und für den Fall von Anschlussfragen gespeichert. Diese Daten werden ohne deine Einwilligung nicht an Dritte weitergegeben.
|
||||
|
||||
Rechtsgrundlage für die Verarbeitung ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Beantwortung deiner Anfrage) bzw. Art. 6 Abs. 1 lit. b DSGVO, sofern deine Anfrage auf den Abschluss eines Vertrages abzielt.
|
||||
|
||||
Du kannst der Speicherung deiner Daten jederzeit widersprechen. In diesem Fall kann die Konversation nicht fortgeführt werden. Alle im Kontext gespeicherten personenbezogenen Daten werden gelöscht.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deine Rechte
|
||||
|
||||
Dir stehen folgende Rechte bezüglich deiner personenbezogenen Daten zu:
|
||||
|
||||
- **Auskunftsrecht** (Art. 15 DSGVO) – du kannst Auskunft über die zu dir verarbeiteten personenbezogenen Daten verlangen.
|
||||
- **Recht auf Berichtigung** (Art. 16 DSGVO) – du kannst die Berichtigung unrichtiger oder unvollständiger Daten verlangen.
|
||||
- **Recht auf Löschung** (Art. 17 DSGVO) – du kannst unter bestimmten Voraussetzungen die Löschung deiner Daten verlangen.
|
||||
- **Recht auf Einschränkung der Verarbeitung** (Art. 18 DSGVO) – du kannst verlangen, dass die Verarbeitung deiner Daten eingeschränkt wird.
|
||||
- **Recht auf Datenübertragbarkeit** (Art. 20 DSGVO) – du kannst verlangen, deine Daten in einem strukturierten, maschinenlesbaren Format zu erhalten.
|
||||
- **Widerspruchsrecht** (Art. 21 DSGVO) – du kannst einer auf berechtigten Interessen beruhenden Verarbeitung jederzeit widersprechen.
|
||||
|
||||
Zur Ausübung dieser Rechte wende dich bitte an die oben angegebene Adresse.
|
||||
|
||||
---
|
||||
|
||||
## 9. Beschwerderecht
|
||||
|
||||
Du hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Die zuständige Aufsichtsbehörde richtet sich nach deinem Wohnort bzw. dem Ort des mutmaßlichen Verstoßes.
|
||||
|
||||
In Deutschland ist in der Regel die Datenschutzbehörde des Bundeslands zuständig, in dem du wohnst. Eine Liste aller deutschen Aufsichtsbehörden findest du unter:
|
||||
<https://www.bfdi.bund.de/DE/Service/Anschriften/Laender/Laender-node.html>
|
||||
|
||||
---
|
||||
|
||||
## 10. Datensicherheit
|
||||
|
||||
Diese Website nutzt aus Sicherheitsgründen und zum Schutz der Übertragung vertraulicher Inhalte eine SSL/TLS-Verschlüsselung. Eine verschlüsselte Verbindung erkennst du am Schloss-Symbol in der Adressleiste deines Browsers sowie am Präfix `https://`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Änderungen dieser Datenschutzerklärung
|
||||
|
||||
Ich behalte mir vor, diese Datenschutzerklärung anzupassen, damit sie stets den aktuellen rechtlichen Anforderungen entspricht oder um Änderungen meiner Leistungen in der Datenschutzerklärung umzusetzen. Die aktuelle Version der Datenschutzerklärung ist immer auf dieser Seite verfügbar. Bitte prüfe diese Seite regelmäßig.
|
||||
21
src/pages/en/[...slug].astro
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
import { type CollectionEntry, render } from 'astro:content';
|
||||
import Post from '~/layouts/Post.astro';
|
||||
import { getPostsByLocale, postSlug } from '~/i18n/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPostsByLocale('en');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: postSlug(post) },
|
||||
props: post,
|
||||
}));
|
||||
}
|
||||
type Props = CollectionEntry<'posts'>;
|
||||
|
||||
const post = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
---
|
||||
|
||||
<Post {...post.data} locale="en" entry={post}>
|
||||
<Content />
|
||||
</Post>
|
||||
14
src/pages/en/about.astro
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
import AboutHeroImage from '~/assets/blog-placeholder-about.jpg';
|
||||
import Layout from '~/layouts/Post.astro';
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="About Me"
|
||||
description="Short introduction."
|
||||
pubDate={new Date('August 08 2021')}
|
||||
heroImage={AboutHeroImage}
|
||||
locale="en"
|
||||
>
|
||||
<p>This is the English version of the about page.</p>
|
||||
</Layout>
|
||||
5
src/pages/en/categories.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import CategoriesPage from '~/components/CategoriesPage.astro';
|
||||
---
|
||||
|
||||
<CategoriesPage locale="en" />
|
||||
18
src/pages/en/category/[slug].astro
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import CategoryDetailPage from '~/components/CategoryDetailPage.astro';
|
||||
import { entrySlug, getCategoriesByLocale } from '~/i18n/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const categories = await getCategoriesByLocale('en');
|
||||
return categories.map((category) => ({
|
||||
params: { slug: entrySlug(category) },
|
||||
props: { category },
|
||||
}));
|
||||
}
|
||||
|
||||
type Props = { category: CollectionEntry<'categories'> };
|
||||
const { category } = Astro.props;
|
||||
---
|
||||
|
||||
<CategoryDetailPage locale="en" category={category} />
|
||||
138
src/pages/en/contact.astro
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Contact" description="Get in touch with Adrian Altner." locale="en" bodyClass="contact-page">
|
||||
<article class="prose">
|
||||
<h1>Contact</h1>
|
||||
|
||||
<h3>Notes & Feedback</h3>
|
||||
<p>Feedback and corrections are always welcome. I read everything, but rarely reply — please don't be discouraged if you don't hear back.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Please note:</strong> Unsolicited press releases, advertising, and SEO pitches will be deleted without a response.
|
||||
</div>
|
||||
|
||||
<h2>How to reach me</h2>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card__icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p class="card__label">Email</p>
|
||||
<span class="card__value r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA=="></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card--placeholder">
|
||||
<div class="card__icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.15 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.05 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 17z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p class="card__label">Signal</p>
|
||||
<p class="card__value card__value--muted">Coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
function decode() {
|
||||
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
|
||||
el.textContent = atob(el.dataset.obf!);
|
||||
el.removeAttribute('data-obf');
|
||||
});
|
||||
}
|
||||
decode();
|
||||
document.addEventListener('astro:page-load', decode);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body.contact-page main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.contact-page h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--gray));
|
||||
margin: 2rem 0 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 2px solid rgb(var(--black));
|
||||
}
|
||||
body.contact-page h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
body.contact-page .note {
|
||||
font-size: 0.9rem;
|
||||
color: rgb(var(--gray-dark));
|
||||
background: rgb(var(--gray-light));
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
body.contact-page .cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
body.contact-page .card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgb(var(--surface));
|
||||
border: 1px solid rgb(var(--gray-light));
|
||||
border-radius: 8px;
|
||||
}
|
||||
body.contact-page .card--placeholder {
|
||||
opacity: 0.55;
|
||||
}
|
||||
body.contact-page .card__icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.2rem;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
body.contact-page .card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
body.contact-page .card__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--gray));
|
||||
margin: 0;
|
||||
}
|
||||
body.contact-page .card__value {
|
||||
font-size: clamp(0.8rem, 3.5vw, 1rem);
|
||||
color: rgb(var(--gray-dark));
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body.contact-page .card__value--muted {
|
||||
color: rgb(var(--gray));
|
||||
font-style: italic;
|
||||
}
|
||||
body.contact-page .r {
|
||||
direction: rtl;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
</style>
|
||||
38
src/pages/en/imprint.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
layout: ~/layouts/Prose.astro
|
||||
title: Imprint
|
||||
description: Legal notice for adrian-altner.de.
|
||||
---
|
||||
|
||||
# Imprint
|
||||
|
||||
## Information pursuant to Section 5 DDG
|
||||
|
||||
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span><br>
|
||||
<span class="r" data-obf="MTMgLnJ0Uy1kcmFobm9lTC1mbG9kdVI="></span><br>
|
||||
<span class="r" data-obf="bmVkc2VyRCA3OTAxMA=="></span><br>
|
||||
<span class="r" data-obf="eW5hbXJlRw=="></span>
|
||||
|
||||
**Phone:** <span class="r" data-obf="MDI0MDM1ODcgNjUxIDk0Kw=="></span><br>
|
||||
**Email:** <span class="r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA=="></span>
|
||||
|
||||
---
|
||||
|
||||
## Responsible for Editorial Content pursuant to Section 18 para. 2 MStV
|
||||
|
||||
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span><br>
|
||||
<span class="r" data-obf="MTMgLnJ0Uy1kcmFobm9lTC1mbG9kdVI="></span><br>
|
||||
<span class="r" data-obf="bmVkc2VyRCA3OTAxMA=="></span><br>
|
||||
<span class="r" data-obf="eW5hbXJlRw=="></span>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Unless otherwise noted, content published on this website is licensed under the [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) license. The source code is licensed under the [MIT License](https://mit-license.org/).
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
Information on the processing of personal data can be found in the [Privacy Policy](/privacy-policy).
|
||||
5
src/pages/en/index.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import HomePage from '~/components/HomePage.astro';
|
||||
---
|
||||
|
||||
<HomePage locale="en" />
|
||||
119
src/pages/en/privacy-policy.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
layout: ~/layouts/Prose.astro
|
||||
title: Privacy Policy
|
||||
description: Privacy policy and data protection information for adrian-altner.de.
|
||||
---
|
||||
|
||||
# Privacy Policy
|
||||
|
||||
*Last updated: March 2026 (added: hosting provider, interactive map)*
|
||||
|
||||
---
|
||||
|
||||
## 1. Controller
|
||||
|
||||
The controller responsible for data processing on this website within the meaning of the General Data Protection Regulation (GDPR) is:
|
||||
|
||||
<strong><span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span></strong><br>
|
||||
**Email:** <span class="r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA=="></span>
|
||||
|
||||
---
|
||||
|
||||
## 2. General Information on Data Processing
|
||||
|
||||
We take the protection of your personal data very seriously. We treat your personal data confidentially and in accordance with the statutory data protection regulations and this privacy policy.
|
||||
|
||||
As a rule, it is possible to use this website without providing personal data. If personal data (e.g. name or email address) is collected on this website, this is done on a voluntary basis where possible. This data will not be passed on to third parties without your explicit consent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Collection When Visiting This Website
|
||||
|
||||
**Server Log Files**
|
||||
|
||||
When you visit this website, our hosting provider automatically collects and stores information in server log files that your browser transmits to us. This includes:
|
||||
|
||||
- Your IP address
|
||||
- Date and time of the request
|
||||
- Content of the request (specific page)
|
||||
- Access status / HTTP status code
|
||||
- Amount of data transferred
|
||||
- Website from which the request originated (referrer)
|
||||
- Browser type and version
|
||||
- Operating system
|
||||
|
||||
This data is not merged with other data sources. The legal basis for this processing is Art. 6(1)(f) GDPR (legitimate interest). Our legitimate interest lies in the technically error-free presentation and optimisation of this website.
|
||||
|
||||
The server log files are stored for a maximum of 7 days and then deleted, unless further retention is required for security purposes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Hosting
|
||||
|
||||
This website is hosted by **Hetzner Online GmbH**, Industriestr. 25, 91710 Gunzenhausen, Germany (<https://www.hetzner.com>). All data processing takes place on servers within the European Union, ensuring a high level of data protection in accordance with GDPR.
|
||||
|
||||
When you visit this website, Hetzner may process technical connection data (e.g. IP address) as part of server operations. We have concluded a data processing agreement (DPA) with Hetzner in accordance with Art. 28 GDPR.
|
||||
|
||||
---
|
||||
|
||||
## 5. Interactive Map (Stadia Maps)
|
||||
|
||||
The photo map page (`/photos/map`) uses map tiles provided by **Stadia Maps** (Stadia Maps, LLC, <https://stadiamaps.com>). When you visit this page, your browser loads map tiles directly from Stadia Maps servers located in the European Union (`tiles-eu.stadiamaps.com`). This causes your IP address and browser information to be transmitted to Stadia Maps.
|
||||
|
||||
Stadia Maps does not track users across websites and processes data in accordance with GDPR. For details, see their privacy policy: <https://stadiamaps.com/privacy-policy/>. A Data Processing Addendum (DPA) is available at <https://stadiamaps.com/legal/data-processing-addendum/>.
|
||||
|
||||
The legal basis for this processing is Art. 6(1)(f) GDPR (legitimate interest in providing an interactive map of photo locations). Map tile requests are only made when you actively visit the map page.
|
||||
|
||||
The map data is provided by **OpenStreetMap** contributors (<https://www.openstreetmap.org/copyright>, ODbL license) and processed into vector tiles by **OpenMapTiles** (<https://openmaptiles.org>).
|
||||
|
||||
---
|
||||
|
||||
## 6. Cookies
|
||||
|
||||
This website does not use cookies, tracking technologies, or any analytics tools. No information is stored on your device when you visit this website.
|
||||
|
||||
---
|
||||
|
||||
## 7. Contact
|
||||
|
||||
If you contact us by email, the data you provide (your email address and the content of your message) will be stored for the purpose of processing your inquiry and in case of follow-up questions. This data will not be passed on to third parties without your consent.
|
||||
|
||||
The legal basis for processing this data is Art. 6(1)(f) GDPR (legitimate interest in responding to your inquiry) or, where your inquiry aims at concluding a contract, Art. 6(1)(b) GDPR.
|
||||
|
||||
You may object to the storage of your data at any time. In this case, the conversation cannot be continued. All personal data stored in the course of the contact will be deleted.
|
||||
|
||||
---
|
||||
|
||||
## 8. Your Rights
|
||||
|
||||
You have the following rights regarding your personal data:
|
||||
|
||||
- **Right of access** (Art. 15 GDPR) – you may request information about the personal data we process about you.
|
||||
- **Right to rectification** (Art. 16 GDPR) – you may request correction of inaccurate or incomplete data.
|
||||
- **Right to erasure** (Art. 17 GDPR) – you may request deletion of your data under certain conditions.
|
||||
- **Right to restriction of processing** (Art. 18 GDPR) – you may request that processing of your data be restricted.
|
||||
- **Right to data portability** (Art. 20 GDPR) – you may request to receive your data in a structured, machine-readable format.
|
||||
- **Right to object** (Art. 21 GDPR) – you may object to processing based on legitimate interests at any time.
|
||||
|
||||
To exercise any of these rights, please contact us at the address provided above.
|
||||
|
||||
---
|
||||
|
||||
## 9. Right to Lodge a Complaint
|
||||
|
||||
You have the right to lodge a complaint with a data protection supervisory authority. The supervisory authority competent for you depends on your place of residence or the location of the alleged infringement.
|
||||
|
||||
In Germany, the competent authority is typically the data protection authority of the federal state in which you reside. A list of all German supervisory authorities is available at:
|
||||
<https://www.bfdi.bund.de/DE/Service/Anschriften/Laender/Laender-node.html>
|
||||
|
||||
---
|
||||
|
||||
## 10. Data Security
|
||||
|
||||
This website uses SSL/TLS encryption for security reasons and to protect the transmission of confidential content. You can recognise an encrypted connection by the padlock icon in your browser address bar and by the `https://` prefix.
|
||||
|
||||
---
|
||||
|
||||
## 11. Changes to This Privacy Policy
|
||||
|
||||
We reserve the right to update this privacy policy to reflect changes in legal requirements or changes to our services. The current version of the privacy policy is always available on this page. Please check this page regularly.
|
||||
16
src/pages/en/rss.xml.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { SITE } from '~/consts';
|
||||
import { getPostsByLocale, postSlug } from '~/i18n/posts';
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = await getPostsByLocale('en');
|
||||
return rss({
|
||||
title: SITE.en.title,
|
||||
description: SITE.en.description,
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
...post.data,
|
||||
link: `/en/${postSlug(post)}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
13
src/pages/en/tag/[slug].astro
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
import TagDetailPage from '~/components/TagDetailPage.astro';
|
||||
import { getTagsByLocale } from '~/i18n/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getTagsByLocale('en');
|
||||
return tags.map((tag) => ({ params: { slug: tag.slug }, props: { tag } }));
|
||||
}
|
||||
|
||||
const { tag } = Astro.props;
|
||||
---
|
||||
|
||||
<TagDetailPage locale="en" tag={tag} />
|
||||
5
src/pages/en/tags.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import TagsPage from '~/components/TagsPage.astro';
|
||||
---
|
||||
|
||||
<TagsPage locale="en" />
|
||||
38
src/pages/impressum.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
layout: ~/layouts/Prose.astro
|
||||
title: Impressum
|
||||
description: Rechtliche Hinweise für adrian-altner.de.
|
||||
---
|
||||
|
||||
# Impressum
|
||||
|
||||
## Angaben gemäß § 5 DDG
|
||||
|
||||
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span><br>
|
||||
<span class="r" data-obf="MTMgLnJ0Uy1kcmFobm9lTC1mbG9kdVI="></span><br>
|
||||
<span class="r" data-obf="bmVkc2VyRCA3OTAxMA=="></span><br>
|
||||
<span class="r" data-obf="eW5hbXJlRw=="></span>
|
||||
|
||||
**Telefon:** <span class="r" data-obf="MDI0MDM1ODcgNjUxIDk0Kw=="></span><br>
|
||||
**E-Mail:** <span class="r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA=="></span>
|
||||
|
||||
---
|
||||
|
||||
## Redaktionell verantwortlich gemäß § 18 Abs. 2 MStV
|
||||
|
||||
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span><br>
|
||||
<span class="r" data-obf="MTMgLnJ0Uy1kcmFobm9lTC1mbG9kdVI="></span><br>
|
||||
<span class="r" data-obf="bmVkc2VyRCA3OTAxMA=="></span><br>
|
||||
<span class="r" data-obf="eW5hbXJlRw=="></span>
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
Soweit nicht anders angegeben, stehen die auf dieser Website veröffentlichten Inhalte unter der Lizenz [Creative Commons Namensnennung – Nicht kommerziell – Weitergabe unter gleichen Bedingungen 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.de). Der Quellcode steht unter der [MIT-Lizenz](https://mit-license.org/).
|
||||
|
||||
---
|
||||
|
||||
## Datenschutz
|
||||
|
||||
Informationen zur Verarbeitung personenbezogener Daten findest du in der [Datenschutzerklärung](/datenschutz).
|
||||
5
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import HomePage from '~/components/HomePage.astro';
|
||||
---
|
||||
|
||||
<HomePage locale="de" />
|
||||
18
src/pages/kategorie/[slug].astro
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import CategoryDetailPage from '~/components/CategoryDetailPage.astro';
|
||||
import { entrySlug, getCategoriesByLocale } from '~/i18n/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const categories = await getCategoriesByLocale('de');
|
||||
return categories.map((category) => ({
|
||||
params: { slug: entrySlug(category) },
|
||||
props: { category },
|
||||
}));
|
||||
}
|
||||
|
||||
type Props = { category: CollectionEntry<'categories'> };
|
||||
const { category } = Astro.props;
|
||||
---
|
||||
|
||||
<CategoryDetailPage locale="de" category={category} />
|
||||
5
src/pages/kategorien.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import CategoriesPage from '~/components/CategoriesPage.astro';
|
||||
---
|
||||
|
||||
<CategoriesPage locale="de" />
|
||||
138
src/pages/kontakt.astro
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
import BaseLayout from '~/layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Kontakt" description="Kontakt zu Adrian Altner." locale="de" bodyClass="contact-page">
|
||||
<article class="prose">
|
||||
<h1>Kontakt</h1>
|
||||
|
||||
<h3>Hinweise & Feedback</h3>
|
||||
<p>Anmerkungen und Korrekturen sind jederzeit willkommen. Ich lese alles, antworte aber nur selten — lass dich davon bitte nicht abschrecken.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Bitte beachten:</strong> Unaufgefordert zugesandte Pressemitteilungen, Werbung und SEO-Angebote werden ohne Antwort gelöscht.
|
||||
</div>
|
||||
|
||||
<h2>So erreichst du mich</h2>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card__icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p class="card__label">E-Mail</p>
|
||||
<span class="card__value r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA=="></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card--placeholder">
|
||||
<div class="card__icon" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.15 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.05 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 17z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p class="card__label">Signal</p>
|
||||
<p class="card__value card__value--muted">Folgt demnächst.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
function decode() {
|
||||
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
|
||||
el.textContent = atob(el.dataset.obf!);
|
||||
el.removeAttribute('data-obf');
|
||||
});
|
||||
}
|
||||
decode();
|
||||
document.addEventListener('astro:page-load', decode);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body.contact-page main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: 2em auto;
|
||||
}
|
||||
body.contact-page h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--gray));
|
||||
margin: 2rem 0 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 2px solid rgb(var(--black));
|
||||
}
|
||||
body.contact-page h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
body.contact-page .note {
|
||||
font-size: 0.9rem;
|
||||
color: rgb(var(--gray-dark));
|
||||
background: rgb(var(--gray-light));
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
body.contact-page .cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
body.contact-page .card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgb(var(--surface));
|
||||
border: 1px solid rgb(var(--gray-light));
|
||||
border-radius: 8px;
|
||||
}
|
||||
body.contact-page .card--placeholder {
|
||||
opacity: 0.55;
|
||||
}
|
||||
body.contact-page .card__icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.2rem;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
body.contact-page .card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
body.contact-page .card__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(var(--gray));
|
||||
margin: 0;
|
||||
}
|
||||
body.contact-page .card__value {
|
||||
font-size: clamp(0.8rem, 3.5vw, 1rem);
|
||||
color: rgb(var(--gray-dark));
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body.contact-page .card__value--muted {
|
||||
color: rgb(var(--gray));
|
||||
font-style: italic;
|
||||
}
|
||||
body.contact-page .r {
|
||||
direction: rtl;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
</style>
|
||||
16
src/pages/rss.xml.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { SITE } from '~/consts';
|
||||
import { getPostsByLocale, postSlug } from '~/i18n/posts';
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = await getPostsByLocale('de');
|
||||
return rss({
|
||||
title: SITE.de.title,
|
||||
description: SITE.de.description,
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
...post.data,
|
||||
link: `/${postSlug(post)}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
5
src/pages/schlagwoerter.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import TagsPage from '~/components/TagsPage.astro';
|
||||
---
|
||||
|
||||
<TagsPage locale="de" />
|
||||
13
src/pages/schlagwort/[slug].astro
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
import TagDetailPage from '~/components/TagDetailPage.astro';
|
||||
import { getTagsByLocale } from '~/i18n/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getTagsByLocale('de');
|
||||
return tags.map((tag) => ({ params: { slug: tag.slug }, props: { tag } }));
|
||||
}
|
||||
|
||||
const { tag } = Astro.props;
|
||||
---
|
||||
|
||||
<TagDetailPage locale="de" tag={tag} />
|
||||
14
src/pages/ueber-mich.astro
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
import AboutHeroImage from '~/assets/blog-placeholder-about.jpg';
|
||||
import Layout from '~/layouts/Post.astro';
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Über mich"
|
||||
description="Kurze Vorstellung."
|
||||
pubDate={new Date('August 08 2021')}
|
||||
heroImage={AboutHeroImage}
|
||||
locale="de"
|
||||
>
|
||||
<p>Hier steht die deutsche Version der Über-mich-Seite.</p>
|
||||
</Layout>
|
||||
167
src/styles/global.css
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
The CSS in this style tag is based off of Bear Blog's default CSS.
|
||||
https://github.com/HermanMartinus/bearblog/blob/297026a877bc2ab2b3bdfbd6b9f7961c350917dd/templates/styles/blog/default.css
|
||||
License MIT: https://github.com/HermanMartinus/bearblog/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:root {
|
||||
--accent: #2337ff;
|
||||
--accent-dark: #000d8a;
|
||||
--black: 15, 18, 25;
|
||||
--gray: 96, 115, 159;
|
||||
--gray-light: 229, 233, 240;
|
||||
--gray-dark: 34, 41, 57;
|
||||
--surface: 255, 255, 255;
|
||||
--gray-gradient: rgba(var(--gray-light), 50%), rgb(var(--surface));
|
||||
--box-shadow:
|
||||
0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%),
|
||||
0 16px 32px rgba(var(--gray), 33%);
|
||||
color-scheme: light;
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
--accent: #8c96ff;
|
||||
--accent-dark: #c2c8ff;
|
||||
--black: 230, 233, 240;
|
||||
--gray: 150, 160, 190;
|
||||
--gray-light: 38, 42, 56;
|
||||
--gray-dark: 220, 225, 235;
|
||||
--surface: 17, 20, 28;
|
||||
--gray-gradient: rgba(var(--gray-light), 50%), rgb(var(--surface));
|
||||
color-scheme: dark;
|
||||
}
|
||||
html {
|
||||
background: rgb(var(--surface));
|
||||
transition:
|
||||
background-color 300ms ease,
|
||||
color 300ms ease;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-atkinson);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
background: linear-gradient(var(--gray-gradient)) no-repeat;
|
||||
background-size: 100% 600px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: rgb(var(--gray-dark));
|
||||
font-size: 20px;
|
||||
line-height: 1.7;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
}
|
||||
body > main {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
padding: 3em 1em;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: rgb(var(--black));
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3.052em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 2.441em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.953em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.563em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
strong,
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose p {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
code {
|
||||
padding: 2px 5px;
|
||||
background-color: rgb(var(--gray-light));
|
||||
border-radius: 2px;
|
||||
}
|
||||
pre {
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
pre > code {
|
||||
all: unset;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 0 0 0 20px;
|
||||
margin: 0;
|
||||
font-size: 1.333em;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--gray-light));
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
}
|
||||
main {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
/* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
|
||||
clip: rect(1px 1px 1px 1px);
|
||||
/* maybe deprecated but we need to support legacy browsers */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
/* modern browsers, clip-path works inwards from each corner */
|
||||
clip-path: inset(50%);
|
||||
/* added line to stop words getting smushed together (as they go onto separate lines and some screen readers do not understand line feeds as a space */
|
||||
white-space: nowrap;
|
||||
}
|
||||
11
tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||