Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e7fa9be05 | ||
|
|
de6de3c6c4 | ||
|
|
8be63309d3 | ||
|
|
89b8ca2283 | ||
|
|
160d72f144 | ||
|
|
68613788b8 | ||
|
|
96247da074 | ||
|
|
8ffeea6731 | ||
|
|
447b909d05 |
@@ -39,7 +39,10 @@ if [ "$release_sha" = "rollback" ]; then
|
||||
sudo cp '$target/nginx.conf' '$live_dir/nginx.conf'
|
||||
sudo cp '$target/compose.yml' '$live_dir/compose.yml'
|
||||
sudo cp '$target/compose.vps.yml' '$live_dir/compose.vps.yml'
|
||||
sudo -n -u denny bash -c \"cd '$live_dir' && docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans\"
|
||||
# --force-recreate for the same reason as in the forward path: the site
|
||||
# tree inode changes, so a merely running container would keep the deleted
|
||||
# directory mounted and serve an empty root.
|
||||
sudo -n -u denny bash -c \"cd '$live_dir' && docker compose -f compose.yml -f compose.vps.yml up -d --force-recreate --remove-orphans\"
|
||||
else
|
||||
echo '[deploy] initial-state snapshot: removing the desfoto stack again'
|
||||
if sudo test -d '$live_dir'; then
|
||||
@@ -98,12 +101,19 @@ sudo chown -R denny:denny "$LIVE_DIR"
|
||||
sudo rm -f "$archive"
|
||||
printf '%s\n' "$REMOTE_SHA" | sudo tee "$LIVE_DIR/RELEASE" >/dev/null
|
||||
|
||||
# 3. Start or update the stack. The live directory belongs to denny and the SSH
|
||||
# login cannot traverse /home/denny, so the directory change and the compose
|
||||
# command run together in one shell under the owning identity.
|
||||
sudo -n -u denny bash -c "cd '$LIVE_DIR' && docker compose -f compose.yml -f compose.vps.yml up -d --remove-orphans"
|
||||
# 3. Start or update the stack. --force-recreate is deliberate: step 2 replaces
|
||||
# the site directory (new inode) and rewrites nginx.conf, and a container that
|
||||
# is merely "running" keeps the bind mount to the deleted directory and then
|
||||
# serves an empty document root (every request 404) while Compose reports no
|
||||
# change. Recreating costs a moment of 502s behind Traefik and guarantees that
|
||||
# a fresh container binds the new tree and reads the new config. The live
|
||||
# directory belongs to denny and the SSH login cannot traverse /home/denny, so
|
||||
# the directory change and the compose command run together in one shell under
|
||||
# the owning identity.
|
||||
sudo -n -u denny bash -c "cd '$LIVE_DIR' && docker compose -f compose.yml -f compose.vps.yml up -d --force-recreate --remove-orphans"
|
||||
|
||||
# 4. Wait for the container health check.
|
||||
# 4. Wait for the container health check. The healthcheck requests "/" from
|
||||
# inside the container, so an empty document root cannot report healthy.
|
||||
for _ in $(seq 1 30); do
|
||||
status="$(sudo docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' desfoto-web-1 2>/dev/null || echo missing)"
|
||||
[ "$status" = "healthy" ] && break
|
||||
@@ -117,6 +127,15 @@ if [ "$status" != "healthy" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4b. Content check, not process check: a stale bind mount served 404 for every
|
||||
# path while Compose still reported the container healthy. Ask the container
|
||||
# itself for the homepage and fail the deploy if it is not served.
|
||||
if ! sudo docker exec desfoto-web-1 wget -q --spider http://127.0.0.1/; then
|
||||
echo "[deploy] ERROR: desfoto-web-1 does not serve the release at / (empty mount?)" >&2
|
||||
sudo docker logs --tail 40 desfoto-web-1 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Remove the obsolete desfoto.de redirect from the shared stack project.
|
||||
# The new stack already owns desfoto.de through a higher Traefik priority,
|
||||
# this only deletes the now-dead labels so Traefik no longer advertises them.
|
||||
|
||||
133
.ocauto/verify
@@ -12,7 +12,13 @@ cd "$(dirname "$0")/.."
|
||||
root="$(pwd)"
|
||||
release_sha="${1:-unknown}"
|
||||
remote="${DESFOTO_REMOTE:-prod-main}"
|
||||
base="https://desfoto.de"
|
||||
# DESFOTO_BASE lets the hook be dry-run against a local container
|
||||
# (DESFOTO_BASE=http://127.0.0.1:18430 .ocauto/verify unknown); the
|
||||
# domain-specific sections (deployed tree, canonical host, neighbours, TLS)
|
||||
# only run against the real production origin.
|
||||
base="${DESFOTO_BASE:-https://desfoto.de}"
|
||||
live=0
|
||||
if [ "$base" = "https://desfoto.de" ]; then live=1; fi
|
||||
|
||||
failures=0
|
||||
pass() { printf ' ok %s\n' "$1"; }
|
||||
@@ -44,8 +50,29 @@ check_header() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_redirect() {
|
||||
local url="$1" want="$2" got target
|
||||
got="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000)"
|
||||
target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 "$url" || true)"
|
||||
if [ "$got" = "301" ] && [ "$target" = "$want" ]; then
|
||||
pass "$url -> 301 $want"
|
||||
else
|
||||
fail "$url -> $got ${target:-<no Location>} (want 301 $want)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_not_contains() {
|
||||
local url="$1" needle="$2" body
|
||||
body="$(curl -sS --max-time 30 "$url" || true)"
|
||||
if printf '%s' "$body" | grep -qF -- "$needle"; then
|
||||
fail "$url still contains '$needle'"
|
||||
else
|
||||
pass "$url no longer contains '$needle'"
|
||||
fi
|
||||
}
|
||||
|
||||
printf '\n== release identity\n'
|
||||
if [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet "$release_sha" >/dev/null; then
|
||||
if [ "$live" = 1 ] && [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet "$release_sha" >/dev/null; then
|
||||
local_hash="$(cd "$root/site" && find . -type f ! -name sitemap.xml ! -path './.well-known/*' -print0 |
|
||||
sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1)"
|
||||
remote_hash="$(ssh "$remote" "sudo sh -c 'cd /home/denny/stacks/desfoto/site && find . -type f ! -name sitemap.xml ! -path ./.well-known/\* -print0 | sort -z | xargs -0 sha256sum | sha256sum'" | cut -d' ' -f1)"
|
||||
@@ -63,12 +90,29 @@ if [ "$release_sha" != "unknown" ] && git -C "$root" rev-parse --verify --quiet
|
||||
fi
|
||||
|
||||
printf '\n== routes\n'
|
||||
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familien-und-paare/ \
|
||||
/minishootings/ /video/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/; do
|
||||
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
|
||||
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/ \
|
||||
/gtin/ /gtin/impressum/ /gtin/datenschutz/ /gtin/aktualisieren/; do
|
||||
check_status "$base$route" 200
|
||||
done
|
||||
check_redirect "$base/gtin" "$base/gtin/"
|
||||
check_redirect "$base/gtin/impressum" "$base/gtin/impressum/"
|
||||
check_redirect "$base/gtin/datenschutz" "$base/gtin/datenschutz/"
|
||||
check_redirect "$base/gtin/aktualisieren" "$base/gtin/aktualisieren/"
|
||||
|
||||
# The retired photo URL must keep working: it is the umbrella page now.
|
||||
check_redirect "$base/shootings" "$base/fotografie/"
|
||||
check_redirect "$base/shootings/" "$base/fotografie/"
|
||||
check_redirect "$base/shootings.html" "$base/fotografie/"
|
||||
check_redirect "$base/fotografie.html" "$base/fotografie/"
|
||||
|
||||
# Familien & Paare was renamed: the remaining family work stays with desfoto.
|
||||
check_redirect "$base/familien-und-paare" "$base/familie/"
|
||||
check_redirect "$base/familien-und-paare/" "$base/familie/"
|
||||
check_redirect "$base/familien-und-paare.html" "$base/familie/"
|
||||
|
||||
printf '\n== canonical host and redirects\n'
|
||||
if [ "$live" = 1 ]; then
|
||||
www_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 https://www.desfoto.de/ || echo 000)"
|
||||
www_target="$(curl -sS -o /dev/null -w '%{redirect_url}' --max-time 30 https://www.desfoto.de/ || true)"
|
||||
if [ "$www_status" = "301" ] && [ "$www_target" = "https://desfoto.de/" ]; then
|
||||
@@ -81,15 +125,51 @@ case "$http_target" in
|
||||
https://desfoto.de/*) pass "http://desfoto.de/ -> $http_target" ;;
|
||||
*) fail "http://desfoto.de/ redirects to '$http_target'" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
printf '\n== content\n'
|
||||
check_contains "$base/" "Bilder und Filme, die nicht beliebig aussehen"
|
||||
check_contains "$base/" 'href="/fotografie/"'
|
||||
check_contains "$base/" 'href="/musik-und-buehne/"'
|
||||
check_contains "$base/" 'href="/video/"'
|
||||
check_contains "$base/" "Mobiles Studio"
|
||||
check_contains "$base/" "Studio Neumünster"
|
||||
check_contains "$base/" "desfoto ist ein Angebot von Denny Schulz, Neumünster."
|
||||
check_not_contains "$base/" 'href="/shootings'
|
||||
check_not_contains "$base/" "Paarshooting"
|
||||
check_contains "$base/fotografie/" "Autarke Blitzanlage"
|
||||
check_contains "$base/fotografie/" "Minishootings"
|
||||
check_contains "$base/familie/" "Familienshooting"
|
||||
check_contains "$base/musik-und-buehne/" "Thjódrörir"
|
||||
check_contains "$base/musik-und-buehne/" "Sagenbringer"
|
||||
check_contains "$base/impressum/" "§ 5 DDG"
|
||||
check_contains "$base/impressum/" "DE462149560"
|
||||
check_contains "$base/datenschutz/" "keine Zugriffsprotokolle"
|
||||
check_not_contains "$base/datenschutz/" "GTIN"
|
||||
check_not_contains "$base/impressum/" "GTIN"
|
||||
check_contains "$base/gtin/" 'name="robots" content="noindex, nofollow"'
|
||||
check_not_contains "$base/" 'href="/gtin'
|
||||
check_not_contains "$base/sitemap.xml" "/gtin/"
|
||||
check_contains "$base/gtin/" 'href="/gtin/impressum/"'
|
||||
check_contains "$base/gtin/" 'href="/gtin/datenschutz/"'
|
||||
check_contains "$base/gtin/impressum/" "§ 5 DDG"
|
||||
check_contains "$base/gtin/impressum/" "DE462149560"
|
||||
check_contains "$base/gtin/datenschutz/" "Cache API"
|
||||
check_contains "$base/gtin/datenschutz/" "nicht an einen Server übertragen"
|
||||
check_contains "$base/gtin/datenschutz/" 'name="robots" content="noindex, nofollow"'
|
||||
check_contains "$base/gtin/impressum/" 'name="robots" content="noindex, nofollow"'
|
||||
check_contains "$base/gtin/aktualisieren/" 'name="robots" content="noindex, nofollow"'
|
||||
check_status "$base/gtin/js/aktualisieren.js" 200
|
||||
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
|
||||
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ \
|
||||
/kontakt/ /impressum/ /datenschutz/; do
|
||||
check_not_contains "$base$route" 'href="/gtin'
|
||||
done
|
||||
check_contains "$base/datenschutz/" "youtube-nocookie.com"
|
||||
check_contains "$base/video/" "NWWFTf7l8g0"
|
||||
check_contains "$base/sitemap.xml" "<loc>https://desfoto.de/video/</loc>"
|
||||
check_contains "$base/" "https://www.dennyschulz.de/"
|
||||
check_contains "$base/" "https://dennyapp.de/"
|
||||
check_contains "$base/sitemap.xml" "<loc>https://desfoto.de/fotografie/</loc>"
|
||||
check_not_contains "$base/sitemap.xml" "<loc>https://desfoto.de/shootings/</loc>"
|
||||
|
||||
printf '\n== error handling and metadata\n'
|
||||
check_status "$base/diese-seite-gibt-es-nicht/" 404
|
||||
@@ -98,11 +178,44 @@ check_status "$base/.well-known/security.txt" 200
|
||||
check_status "$base/robots.txt" 200
|
||||
|
||||
printf '\n== assets\n'
|
||||
check_status "$base/assets/img/hero-studio-1200.webp" 200
|
||||
check_status "$base/assets/img/logo-160.png" 200
|
||||
check_status "$base/assets/img/favicon-32.png" 200
|
||||
check_status "$base/assets/img/og-fotografie.jpg" 200
|
||||
check_status "$base/assets/site.css" 200
|
||||
check_status "$base/gtin/css/app.css" 200
|
||||
check_status "$base/gtin/js/app.js" 200
|
||||
check_status "$base/gtin/sw.js" 200
|
||||
check_status "$base/gtin/manifest.webmanifest" 200
|
||||
check_status "$base/gtin/health" 404
|
||||
manifest_type="$(curl -sSI --max-time 30 "$base/gtin/manifest.webmanifest" | tr -d '\r' | grep -i '^content-type:' || true)"
|
||||
if printf '%s' "$manifest_type" | grep -qi 'application/manifest+json'; then
|
||||
pass "$base/gtin/manifest.webmanifest uses the Web App Manifest MIME type"
|
||||
else
|
||||
fail "$base/gtin/manifest.webmanifest has wrong Content-Type: ${manifest_type:-<missing>}"
|
||||
fi
|
||||
check_status "$base/assets/fonts/fraunces-latin.woff2" 200
|
||||
check_status "$base/assets/img/og-desfoto.jpg" 200
|
||||
# The portfolio expansion ships the operator's own public work...
|
||||
check_status "$base/assets/img/music-dreadlocks-1200.webp" 200
|
||||
check_status "$base/assets/img/business-hotel-window-1200.webp" 200
|
||||
check_status "$base/assets/img/free-portrait-light-1200.webp" 200
|
||||
check_status "$base/assets/img/editorial-leap-1200.webp" 200
|
||||
# ...and the wedding pictures showing the guests stay removed.
|
||||
check_status "$base/assets/img/group-feier-1200.webp" 404
|
||||
check_status "$base/assets/img/group-outdoor-1200.webp" 404
|
||||
check_status "$base/assets/img/group-posiert-1200.webp" 404
|
||||
# Couple imagery and the studio shot with the old business card ship nowhere.
|
||||
check_status "$base/assets/img/couple-park-1200.webp" 404
|
||||
check_status "$base/assets/img/portrait-couple-1200.webp" 404
|
||||
check_status "$base/assets/img/studio-setup-1200.webp" 404
|
||||
# Brautpaar-Bilder sind komplett aus dem Bestand: Fotografie- und Eventbilder
|
||||
# mit Paaren gehören zu dennyschulz.de, nicht zu desfoto.
|
||||
check_status "$base/assets/img/portrait-natural-1200.webp" 404
|
||||
check_status "$base/assets/img/event-motion-1200.webp" 404
|
||||
check_status "$base/assets/img/event-dance-1200.webp" 404
|
||||
check_contains "$base/minishootings/" "free-modern-portrait-1200.webp"
|
||||
check_contains "$base/social-media/" "event-speaker-1200.webp"
|
||||
check_contains "$base/projekte/" "music-dreadlocks-1200.webp"
|
||||
check_contains "$base/familie/" "free-portrait-light-1200.webp"
|
||||
|
||||
printf '\n== privacy promises and headers\n'
|
||||
check_header "$base/" "Strict-Transport-Security"
|
||||
@@ -116,12 +229,14 @@ else
|
||||
fi
|
||||
|
||||
printf '\n== neighbouring sites untouched\n'
|
||||
if [ "$live" = 1 ]; then
|
||||
check_status "https://www.dennyschulz.de/" 200
|
||||
check_contains "https://www.dennyschulz.de/impressum" "Denny Schulz"
|
||||
check_status "https://dennyapp.de/" 200
|
||||
fi
|
||||
|
||||
printf '\n== TLS\n'
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
if [ "$live" = 1 ] && command -v openssl >/dev/null 2>&1; then
|
||||
if echo | openssl s_client -servername desfoto.de -connect desfoto.de:443 2>/dev/null |
|
||||
openssl x509 -noout -checkend 604800 -subject 2>/dev/null; then
|
||||
pass "certificate for desfoto.de is valid for at least 7 more days"
|
||||
|
||||
72
AGENTS.md
@@ -7,7 +7,23 @@ no runtime dependencies. `src/` holds Python builders and the editorial content,
|
||||
`scripts/build-site.py` renders everything into the committed `site/` directory,
|
||||
and nginx serves `site/` from a container behind the shared VPS Traefik instance.
|
||||
|
||||
`dennyschulz.de` is a **separate** site and must never be modified or linked from here.
|
||||
`dennyschulz.de` is a **separate** site: nothing here may modify it, proxy it or
|
||||
hotlink its assets. Linking out to it from the footer is an explicit operator
|
||||
request and is the only permitted reference (see "Editing rules").
|
||||
|
||||
## Structure
|
||||
- `/` — hero, the four **Arbeitsbereiche** (Fotografie, Video, Musik & Bühne, Projekte),
|
||||
the bookable services ("Was du bei mir buchen kannst"), the portfolio gallery
|
||||
("Was ich außerdem mache"), music video, mobiles Studio, process, facts.
|
||||
- `/fotografie/` — the umbrella for all photographic work: Businessfotografie,
|
||||
Portrait & Model, Familie, Minishootings, plus the mobiles Studio.
|
||||
`/shootings`, `/shootings/` and `/shootings.html` are 301-redirected here by
|
||||
`nginx.conf`, and the old `/fotografie.html` is canonicalised to `/fotografie/`.
|
||||
- `/familie/` — family photography (no couples: Paare and Engagement stay on
|
||||
`dennyschulz.de`). `/familien-und-paare`, `/familien-und-paare/` and
|
||||
`/familien-und-paare.html` are 301-redirected here.
|
||||
- `/musik-und-buehne/` — concerts, band portraits and the Thjódrörir music video.
|
||||
- `/video/`, `/social-media/`, `/projekte/` (music + free work + pro bono), `/ueber/`.
|
||||
|
||||
## Commands
|
||||
- `python3 scripts/fetch-assets.py` — download pool images, Google Fonts subsets and
|
||||
@@ -27,20 +43,65 @@ and nginx serves `site/` from a container behind the shared VPS Traefik instance
|
||||
- Never invent facts. Prices, testimonials, client names, awards and biography dates
|
||||
are intentionally absent; do not add them without verified information. Legal data
|
||||
in `src/content.py:LEGAL` mirrors the operator's own Impressum (`§ 5 DDG`).
|
||||
The portfolio must reflect the operator's real work (musicians, live shows, free
|
||||
and artistic series, business shoots) — never invent trips, locations or jobs
|
||||
(an earlier build captioned an image as a "Japanreise"; that was invented and must
|
||||
not return).
|
||||
- Keep the design language: paper/ink/rust/teal tokens from `src/theme.py`, rounded
|
||||
cards, hairline dividers, Fraunces for display and Manrope for text.
|
||||
- Images come from the operator's own pool through `src/images.json`. Only add an
|
||||
entry if the operator holds the rights; always give a meaningful German `alt`.
|
||||
`"source": "legacy"` marks images fetched from the operator's own site
|
||||
(`dennyschulz.de/legacy/<file>`) — they are written into `assets-src/images/` and
|
||||
never hotlinked at runtime. `alt` texts must come from the operator's own captions.
|
||||
- The operator's original logo lives in `assets-src/brand/logo.png`; the build derives
|
||||
`logo-160.png`, the favicons, the apple-touch icon and `favicon.svg` from it. Never
|
||||
redraw or guess a replacement mark.
|
||||
- The footer's "Netzwerk" column links to `https://www.dennyschulz.de/` and
|
||||
`https://dennyapp.de/` because the operator asked for it; these are the only
|
||||
external links allowed and they must keep `target="_blank" rel="noopener"`.
|
||||
Nothing else may reference `dennyschulz.de` (no assets, no fetches, no rewriting).
|
||||
- Animations are progressive enhancement: they live in CSS plus the guarded
|
||||
`site.js` block, every page carries the `<noscript>` reveal fallback, and the
|
||||
`prefers-reduced-motion` block must stay the **last** rule in `src/theme.py` so it
|
||||
wins over the motion definitions.
|
||||
- Never hide the element you observe: Chromium folds a target's own `clip-path` into
|
||||
the `IntersectionObserver` geometry, so a closed mask never reports as visible and
|
||||
the content stays hidden forever. `.reveal--mask` therefore clips its children.
|
||||
- Never nest `<a>` inside `<a>` (or any other invalid nesting). The parser closes the
|
||||
outer anchor and the surrounding grid falls apart — that is what happened to the
|
||||
two homepage pillars the redesign replaced. Cards with their own links use the
|
||||
stretched-link pattern (`.pillar__go::after`, `.link-arrow`) or keep the link in
|
||||
the card foot; `tests/test_site.py` guards this for every page.
|
||||
- **Brand separation:** `desfoto` is the brand, `Denny Schulz Fotografie` is only the
|
||||
legal entity. The legal name may appear in the Impressum, the Datenschutz page and
|
||||
the JSON-LD `legalName` — nowhere else. The footer carries exactly one ownership
|
||||
sentence (`SITE["ownership"]`). Couples, engagements and weddings are advertised on
|
||||
`dennyschulz.de` only; desfoto shows single-person portraits, family and business
|
||||
work, and no couple imagery may be added back to `src/images.json`.
|
||||
- `/fotografie/` is the umbrella term for the photographic work ("Shooting" is not a
|
||||
section label any more). Renaming a route means updating `pages.ROUTES`,
|
||||
`nginx.conf` (301 from the old URL) and `scripts/build-site.py:ROUTE_OG`;
|
||||
the build prunes the retired page from `site/` automatically.
|
||||
- Two-space indentation, `from __future__ import annotations`, type hints on public
|
||||
functions, no new third-party Python packages (Pillow is the only dependency).
|
||||
|
||||
## Privacy contract
|
||||
The `/datenschutz/` page makes concrete promises. Any change that would break one of
|
||||
them is a defect, not a style question:
|
||||
- no cookies, no `localStorage`, no analytics, no tracking;
|
||||
- no cookies, analytics or tracking. The standalone `/gtin/` tool is the only
|
||||
`localStorage`/Cache API exception: it keeps its last 20 generated entries in
|
||||
the browser and caches only its own offline app shell; it sends no tool data
|
||||
to a server. Its `/gtin/impressum/` and `/gtin/datenschutz/` pages are
|
||||
standalone, unlisted legal pages. The tool stays unlisted: no main-site
|
||||
navigation or page links, no sitemap entry, and `noindex,nofollow`; only the
|
||||
GTIN area links to its own legal pages, which never link back to the main site.
|
||||
The unlisted `/gtin/aktualisieren/` page repairs old redirected app caches;
|
||||
- `access_log off` in `nginx.conf` — never enable request logging;
|
||||
- `error_log /dev/null crit;` in `nginx.conf` — never write an error-log file either;
|
||||
- fonts, scripts, styles and images are served from this origin only;
|
||||
- the GTIN service worker is scoped to `/gtin/` and may not intercept other site
|
||||
routes or delete caches owned by another application;
|
||||
- YouTube is embedded exclusively via the click-to-load facade
|
||||
(`youtube-nocookie.com`) — no iframe before the click, no preconnect;
|
||||
- the contact form never posts anywhere; it composes a local `mailto:` draft
|
||||
@@ -55,3 +116,10 @@ and removes the obsolete `desfoto.de → dennyschulz.de` labels from `/srv/stack
|
||||
`.ocauto/verify <sha>` proves the live site matches the release. Details, including
|
||||
rollback, are in `docs/deployment.md`. Traefik is shared infrastructure: route through
|
||||
labels, never restart or recreate the proxy.
|
||||
|
||||
The deploy replaces `site/` (new inode) and rewrites `nginx.conf`, so it runs
|
||||
`docker compose up -d --force-recreate`: a container that is merely "running" keeps
|
||||
the deleted directory bind-mounted and then serves an empty document root — every
|
||||
path 404, while Compose reports no change and the stale health status still says
|
||||
`healthy`. The recreate is followed by an explicit in-container request for `/`, and
|
||||
`tests/test_site.py` guards the flag. Never remove either check.
|
||||
|
||||
20
README.md
@@ -41,9 +41,14 @@ open http://127.0.0.1:18430/
|
||||
|
||||
## Seiten
|
||||
|
||||
Startseite · Fotografie · Businessfotografie · Portrait & Model · Familien & Paare ·
|
||||
Minishootings · Video · Social Media · Projekte · Über · Kontakt · Impressum ·
|
||||
Datenschutz — plus eine eigene 404-Seite.
|
||||
Startseite · Fotografie · Businessfotografie · Portrait & Model · Familie · Minishootings ·
|
||||
Video · Social Media · Projekte · Über · Kontakt · Impressum · Datenschutz — plus eine eigene
|
||||
404-Seite.
|
||||
|
||||
Der GTIN-Generator unter `/gtin/` ist ein separates, öffentlich direkt aufrufbares Werkzeug.
|
||||
Er bleibt absichtlich unverlinkt, fehlt in der Sitemap und trägt `noindex,nofollow`; nur das
|
||||
Werkzeug selbst verlinkt auf seine eigenständigen Rechtstexte unter `/gtin/impressum/` und
|
||||
`/gtin/datenschutz/`. Diese Seiten verwenden keine Navigation oder Rechtstexte des Hauptauftritts.
|
||||
|
||||
## Technische Entscheidungen
|
||||
|
||||
@@ -59,6 +64,15 @@ Datenschutz — plus eine eigene 404-Seite.
|
||||
Es gibt keinen API-Endpunkt und keine Datenübertragung an den Server.
|
||||
- **Keine Zugriffsprotokolle.** `nginx.conf` schaltet das Access-Log ab; es gibt
|
||||
keine Cookies, keine Analyse und keine externen Ressourcen.
|
||||
- **GTIN-Generator.** Der Generator unter `/gtin/` rechnet vollständig im Browser,
|
||||
ohne Konto, externe API oder Serverübertragung. Er speichert bis zu 20 zuletzt
|
||||
erzeugte Einträge lokal im Browser; der Offline-Appcache enthält nur die
|
||||
Anwendungsdateien. Der Service Worker speichert die kanonische Startadresse
|
||||
`/gtin/` ohne Weiterleitung; jede Änderung am Werkzeug erhält eine neue
|
||||
Cache-Version. Die eigenständige Datenschutzerklärung liegt unter
|
||||
`/gtin/datenschutz/`; die Rechtstexte sind nicht mit denen des Hauptauftritts verknüpft.
|
||||
Der unverlinkte Pfad `/gtin/aktualisieren/` entfernt eine fehlerhafte
|
||||
Startantwort aus älteren Browser-Caches und öffnet das Werkzeug danach erneut.
|
||||
|
||||
## Herkunft der Inhalte
|
||||
|
||||
|
||||
24
nginx.conf
@@ -64,6 +64,30 @@ server {
|
||||
location = /impressum.html { return 301 /impressum/; }
|
||||
location = /datenschutz.html { return 301 /datenschutz/; }
|
||||
location = /kontakt.html { return 301 /kontakt/; }
|
||||
location = /gtin { return 301 /gtin/; }
|
||||
location = /gtin/impressum { return 301 /gtin/impressum/; }
|
||||
location = /gtin/datenschutz { return 301 /gtin/datenschutz/; }
|
||||
location = /gtin/aktualisieren { return 301 /gtin/aktualisieren/; }
|
||||
location = /gtin/manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
# /fotografie.html used to redirect to /shootings/; it resolves to its own
|
||||
# canonical page now instead of turning into a 404.
|
||||
location = /fotografie.html { return 301 /fotografie/; }
|
||||
|
||||
# Fotografie is the umbrella term for the photographic work; the old
|
||||
# /shootings/ URL stays reachable and points to the new page.
|
||||
# (/fotografie/index.html is already canonicalised to /fotografie/ above.)
|
||||
location = /shootings { return 301 /fotografie/; }
|
||||
location = /shootings/ { return 301 /fotografie/; }
|
||||
location = /shootings.html { return 301 /fotografie/; }
|
||||
|
||||
# Familie keeps the photographic range that stays with desfoto; the old
|
||||
# combined Familien-und-Paare URL points here.
|
||||
location = /familien-und-paare { return 301 /familie/; }
|
||||
location = /familien-und-paare/ { return 301 /familie/; }
|
||||
location = /familien-und-paare.html { return 301 /familie/; }
|
||||
|
||||
location ^~ /.well-known/ {
|
||||
allow all;
|
||||
|
||||
@@ -12,7 +12,10 @@ Steps
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
@@ -27,6 +30,7 @@ SITE = ROOT / "site"
|
||||
IMG_OUT = SITE / "assets" / "img"
|
||||
FONT_OUT = SITE / "assets" / "fonts"
|
||||
TTF = ASSETS / "fonts-ttf"
|
||||
LOGO_SRC = ASSETS / "brand" / "logo.png"
|
||||
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
@@ -35,8 +39,14 @@ import pages # noqa: E402
|
||||
from content import LEGAL, SITE as SITE_META, VIDEO # noqa: E402
|
||||
from theme import CSS, TOKENS # noqa: E402
|
||||
|
||||
WIDTHS = (480, 768, 1200, 1600, 2000)
|
||||
WIDTHS = (480, 768, 1200, 1600)
|
||||
WEBP_QUALITY = 84
|
||||
UNLISTED_HTML = [
|
||||
"gtin/index.html",
|
||||
"gtin/impressum/index.html",
|
||||
"gtin/datenschutz/index.html",
|
||||
"gtin/aktualisieren/index.html",
|
||||
]
|
||||
|
||||
INK = (23, 20, 15)
|
||||
PAPER = (246, 242, 234)
|
||||
@@ -44,16 +54,17 @@ ACCENT = (192, 67, 28)
|
||||
|
||||
# OpenGraph image per route, built from the page's own key visual.
|
||||
ROUTE_OG = {
|
||||
"/": ("hero-studio", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
|
||||
"/fotografie/": ("editorial-earring", "og-fotografie.jpg", "Fotografie aus Neumünster"),
|
||||
"/": ("music-vocal-blue", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
|
||||
"/fotografie/": ("business-portrait-studio", "og-fotografie.jpg", "Fotografie aus Neumünster"),
|
||||
"/businessfotografie/": ("business-portrait-studio", "og-businessfotografie.jpg", "Businessfotografie"),
|
||||
"/portrait-und-model/": ("editorial-colour", "og-portrait.jpg", "Portrait & Model"),
|
||||
"/familien-und-paare/": ("people-joy", "og-familien.jpg", "Familien & Paare"),
|
||||
"/minishootings/": ("portrait-natural", "og-minishootings.jpg", "Minishootings"),
|
||||
"/video/": ("studio-lens", "og-video.jpg", "Videoprojekte"),
|
||||
"/social-media/": ("event-motion", "og-social-media.jpg", "Social-Media-Inhalte"),
|
||||
"/projekte/": ("travel-norway-pano", "og-projekte.jpg", "Eigene Projekte"),
|
||||
"/ueber/": ("business-portrait-dark", "og-ueber.jpg", "Über desfoto"),
|
||||
"/familie/": ("free-portrait-light", "og-familie.jpg", "Familienfotografie"),
|
||||
"/minishootings/": ("free-modern-portrait", "og-minishootings.jpg", "Minishootings"),
|
||||
"/video/": ("music-guitar-silhouette", "og-video.jpg", "Videoprojekte"),
|
||||
"/musik-und-buehne/": ("music-vocal-blue", "og-musik-und-buehne.jpg", "Musik & Bühne"),
|
||||
"/social-media/": ("event-speaker", "og-social-media.jpg", "Social-Media-Inhalte"),
|
||||
"/projekte/": ("free-mountain-view", "og-projekte.jpg", "Eigene Projekte"),
|
||||
"/ueber/": ("gear-cases", "og-ueber.jpg", "Über desfoto"),
|
||||
"/kontakt/": ("event-venue", "og-kontakt.jpg", "Kontakt"),
|
||||
"/impressum/": ("gear-cases", "og-impressum.jpg", "Impressum"),
|
||||
"/datenschutz/": ("event-venue", "og-datenschutz.jpg", "Datenschutz"),
|
||||
@@ -159,59 +170,38 @@ def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
|
||||
return ImageFont.truetype(str(TTF / name), size)
|
||||
|
||||
|
||||
def draw_mark(size: int, background: bool = True) -> Image.Image:
|
||||
"""The desfoto aperture mark, drawn deterministically with PIL."""
|
||||
scale = 8
|
||||
s = size * scale
|
||||
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
if background:
|
||||
d.rounded_rectangle((0, 0, s - 1, s - 1), radius=int(s * 0.22), fill=INK + (255,))
|
||||
cx = cy = s / 2
|
||||
ring_w = max(2, int(s * 0.055))
|
||||
d.ellipse(
|
||||
(cx - s * 0.27, cy - s * 0.27, cx + s * 0.27, cy + s * 0.27),
|
||||
outline=PAPER + (255,),
|
||||
width=ring_w,
|
||||
)
|
||||
d.ellipse(
|
||||
(cx - s * 0.105, cy - s * 0.105, cx + s * 0.105, cy + s * 0.105),
|
||||
outline=PAPER + (255,),
|
||||
width=ring_w,
|
||||
)
|
||||
blade_w = max(2, int(s * 0.058))
|
||||
import math
|
||||
|
||||
for angle in (270, 30, 150):
|
||||
rad = math.radians(angle)
|
||||
x1 = cx + math.cos(rad) * s * 0.27
|
||||
y1 = cy + math.sin(rad) * s * 0.27
|
||||
x2 = cx + math.cos(rad) * s * 0.45
|
||||
y2 = cy + math.sin(rad) * s * 0.45
|
||||
d.line((x1, y1, x2, y2), fill=ACCENT + (255,), width=blade_w)
|
||||
for (px, py) in ((x1, y1), (x2, y2)):
|
||||
r = blade_w / 2
|
||||
d.ellipse((px - r, py - r, px + r, py + r), fill=ACCENT + (255,))
|
||||
return img.resize((size, size), Image.Resampling.LANCZOS)
|
||||
def load_logo() -> Image.Image:
|
||||
"""The operator's own logo (as published on dennyschulz.de)."""
|
||||
if not LOGO_SRC.exists():
|
||||
raise SystemExit(f"missing brand asset {LOGO_SRC} - run scripts/fetch-assets.py")
|
||||
return Image.open(LOGO_SRC).convert("RGBA")
|
||||
|
||||
|
||||
def build_brand_assets(table: dict[str, dict]) -> None:
|
||||
logo = load_logo()
|
||||
# 1:1 copy of the operator's original logo for the header/footer mark.
|
||||
logo.save(IMG_OUT / "logo-160.png", "PNG", optimize=True)
|
||||
logo.resize((180, 180), Image.Resampling.LANCZOS).save(
|
||||
IMG_OUT / "apple-touch-icon.png", "PNG", optimize=True
|
||||
)
|
||||
logo.resize((32, 32), Image.Resampling.LANCZOS).save(
|
||||
IMG_OUT / "favicon-32.png", "PNG", optimize=True
|
||||
)
|
||||
logo.resize((16, 16), Image.Resampling.LANCZOS).save(
|
||||
IMG_OUT / "favicon-16.png", "PNG", optimize=True
|
||||
)
|
||||
|
||||
# Self-contained SVG favicon; the operator's own site uses the same trick.
|
||||
buffer = io.BytesIO()
|
||||
logo.save(buffer, "PNG", optimize=True)
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
(SITE / "favicon.svg").write_text(
|
||||
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="desfoto">
|
||||
<rect width="64" height="64" rx="14" fill="#17140F"/>
|
||||
<circle cx="32" cy="32" r="17" fill="none" stroke="#F6F2EA" stroke-width="3.4"/>
|
||||
<circle cx="32" cy="32" r="6.6" fill="none" stroke="#F6F2EA" stroke-width="2.6"/>
|
||||
<g stroke="#C0431C" stroke-width="3.6" stroke-linecap="round">
|
||||
<path d="M32 15V6"/><path d="M46.7 40.5 54.4 45"/><path d="M17.3 40.5 9.6 45"/>
|
||||
</g>
|
||||
</svg>
|
||||
""",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-label="desfoto">\n'
|
||||
f' <image width="160" height="160" href="data:image/png;base64,{encoded}"/>\n'
|
||||
"</svg>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
draw_mark(180).save(IMG_OUT / "apple-touch-icon.png")
|
||||
draw_mark(64).save(IMG_OUT / "favicon-32.png")
|
||||
draw_mark(64).resize((16, 16), Image.Resampling.LANCZOS).save(IMG_OUT / "favicon-16.png")
|
||||
print(" [brand] favicon.svg, apple-touch-icon.png, favicon-32.png")
|
||||
print(" [brand] logo-160.png, favicon.svg, apple-touch-icon.png, favicon-32.png")
|
||||
|
||||
og_done = set()
|
||||
for slug, filename, headline in ROUTE_OG.values():
|
||||
@@ -219,6 +209,11 @@ def build_brand_assets(table: dict[str, dict]) -> None:
|
||||
continue
|
||||
og_done.add(filename)
|
||||
build_og(table, slug, filename, headline)
|
||||
# A renamed route would otherwise leave its old card behind forever.
|
||||
for stale in sorted(IMG_OUT.glob("og-*.jpg")):
|
||||
if stale.name not in og_done:
|
||||
stale.unlink()
|
||||
print(f" [brand] removed stale {stale.name}")
|
||||
|
||||
|
||||
def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) -> None:
|
||||
@@ -249,8 +244,10 @@ def build_og(table: dict[str, dict], slug: str, filename: str, headline: str) ->
|
||||
brand_font = _font("fraunces-600.ttf", 92)
|
||||
head_font = _font("fraunces-400.ttf", 54)
|
||||
small_font = _font("manrope-600.ttf", 26)
|
||||
d.text((72, 74), "desfoto", font=brand_font, fill=PAPER)
|
||||
d.text((76, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132))
|
||||
mark = load_logo().resize((102, 102), Image.Resampling.LANCZOS)
|
||||
source.paste(mark, (76, 62), mark)
|
||||
d.text((198, 72), "desfoto", font=brand_font, fill=PAPER)
|
||||
d.text((202, 186), "FOTOGRAFIE & VIDEO · NEUMÜNSTER", font=small_font, fill=(230, 161, 132))
|
||||
lines = wrap_text(headline, head_font, target_w - 150)
|
||||
y = target_h - 90 - len(lines) * 64
|
||||
for line in lines:
|
||||
@@ -288,16 +285,40 @@ def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
|
||||
target.write_text(html, encoding="utf-8")
|
||||
written.append((path, rel))
|
||||
print(f" [html] {rel}")
|
||||
shutil.rmtree(SITE / "gtin", ignore_errors=True)
|
||||
shutil.copytree(SRC / "gtin", SITE / "gtin")
|
||||
version_gtin_service_worker()
|
||||
print(" [html] gtin/ (unlisted browser tool and standalone legal pages)")
|
||||
not_found = pages.not_found().replace("og-desfoto.jpg", "og-desfoto.jpg")
|
||||
(SITE / "404.html").write_text(not_found, encoding="utf-8")
|
||||
print(" [html] 404.html")
|
||||
return written
|
||||
|
||||
|
||||
def version_gtin_service_worker() -> None:
|
||||
"""Give each GTIN source release its own offline cache."""
|
||||
digest = hashlib.sha256()
|
||||
source_root = SRC / "gtin"
|
||||
for path in sorted(source_root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
digest.update(path.relative_to(source_root).as_posix().encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(path.read_bytes())
|
||||
digest.update(b"\0")
|
||||
|
||||
worker = SITE / "gtin" / "sw.js"
|
||||
script = worker.read_text(encoding="utf-8")
|
||||
marker = "__BUILD_VERSION__"
|
||||
if script.count(marker) != 1:
|
||||
raise RuntimeError("GTIN service worker cache version marker is missing or duplicated")
|
||||
worker.write_text(script.replace(marker, digest.hexdigest()[:12]), encoding="utf-8")
|
||||
|
||||
|
||||
def build_meta_files(routes: list[tuple[str, str]]) -> None:
|
||||
today = dt.date.today().isoformat()
|
||||
urls = []
|
||||
for path, _rel, _f, _x, priority in pages.ROUTES:
|
||||
for path, _rel, _factory, _extra, priority in pages.ROUTES:
|
||||
urls.append(
|
||||
f" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
|
||||
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
|
||||
@@ -421,22 +442,108 @@ def build_script() -> None:
|
||||
});
|
||||
}
|
||||
|
||||
/* Soft reveal on scroll, disabled for reduced motion */
|
||||
/* Motion layer: reveals, scroll progress, parallax and pointer spotlights.
|
||||
Everything degrades to a static page and is disabled for reduced motion. */
|
||||
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* Anything inside [data-stagger] reveals one item after another. */
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-stagger]'), function (group) {
|
||||
var step = parseFloat(group.getAttribute('data-stagger')) || 0.08;
|
||||
Array.prototype.forEach.call(group.children, function (child, index) {
|
||||
if (!child.classList.contains('reveal')) {
|
||||
child.classList.add('reveal');
|
||||
}
|
||||
child.style.setProperty('--d', (index * step).toFixed(2) + 's');
|
||||
});
|
||||
});
|
||||
|
||||
var items = document.querySelectorAll('.reveal');
|
||||
var revealAll = function () {
|
||||
Array.prototype.forEach.call(items, function (item) { item.classList.add('is-in'); });
|
||||
};
|
||||
if (reduce || !('IntersectionObserver' in window)) {
|
||||
items.forEach(function (item) { item.classList.add('is-in'); });
|
||||
revealAll();
|
||||
} else {
|
||||
try {
|
||||
var fired = 0;
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
fired = 1;
|
||||
entry.target.classList.add('is-in');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });
|
||||
items.forEach(function (item) { observer.observe(item); });
|
||||
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.06 });
|
||||
Array.prototype.forEach.call(items, function (item) { observer.observe(item); });
|
||||
/* Safety net: if the browser never reports a box that is on screen, drop the
|
||||
effect entirely instead of leaving content hidden. */
|
||||
window.setTimeout(function () {
|
||||
if (fired) { return; }
|
||||
var onScreen = false;
|
||||
Array.prototype.forEach.call(items, function (item) {
|
||||
var box = item.getBoundingClientRect();
|
||||
if (box.top < window.innerHeight && box.bottom > 0) { onScreen = true; }
|
||||
});
|
||||
if (onScreen) {
|
||||
observer.disconnect();
|
||||
revealAll();
|
||||
}
|
||||
}, 1600);
|
||||
} catch (error) {
|
||||
revealAll();
|
||||
}
|
||||
}
|
||||
|
||||
if (reduce) {
|
||||
return;
|
||||
}
|
||||
|
||||
var progress = document.querySelector('[data-progress]');
|
||||
var parallax = Array.prototype.slice.call(document.querySelectorAll('[data-parallax]'));
|
||||
var ticking = false;
|
||||
|
||||
function onScroll() {
|
||||
var doc = document.documentElement;
|
||||
var max = doc.scrollHeight - window.innerHeight;
|
||||
var ratio = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
|
||||
if (progress) {
|
||||
progress.style.setProperty('--scroll', ratio.toFixed(4));
|
||||
}
|
||||
if (parallax.length) {
|
||||
var view = window.innerHeight;
|
||||
parallax.forEach(function (element) {
|
||||
var rect = element.getBoundingClientRect();
|
||||
if (rect.bottom < -240 || rect.top > view + 240) {
|
||||
return;
|
||||
}
|
||||
var speed = parseFloat(element.getAttribute('data-parallax')) || 0.12;
|
||||
var offset = (rect.top + rect.height / 2 - view / 2) * speed;
|
||||
element.style.setProperty('--py', offset.toFixed(1) + 'px');
|
||||
});
|
||||
}
|
||||
ticking = false;
|
||||
}
|
||||
|
||||
function requestScroll() {
|
||||
if (!ticking) {
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', requestScroll, { passive: true });
|
||||
window.addEventListener('resize', requestScroll);
|
||||
onScroll();
|
||||
|
||||
/* Pointer-following highlight on the media cards. */
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.spot'), function (card) {
|
||||
card.addEventListener('pointermove', function (event) {
|
||||
var rect = card.getBoundingClientRect();
|
||||
card.style.setProperty('--mx', ((event.clientX - rect.left) / rect.width * 100).toFixed(1) + '%');
|
||||
card.style.setProperty('--my', ((event.clientY - rect.top) / rect.height * 100).toFixed(1) + '%');
|
||||
});
|
||||
});
|
||||
}());
|
||||
""",
|
||||
encoding="utf-8",
|
||||
@@ -451,6 +558,7 @@ def link_check(routes: list[tuple[str, str]]) -> list[str]:
|
||||
|
||||
problems: list[str] = []
|
||||
built = {f"/{rel}".replace("/index.html", "/") for _p, rel in routes}
|
||||
built.update(f"/{rel}".replace("/index.html", "/") for rel in UNLISTED_HTML)
|
||||
built.add("/404.html")
|
||||
for rel in ["sitemap.xml", "robots.txt", "site.webmanifest", "favicon.svg", ".well-known/security.txt",
|
||||
"assets/site.css", "assets/site.js", "assets/fonts.css"]:
|
||||
@@ -467,6 +575,8 @@ def link_check(routes: list[tuple[str, str]]) -> list[str]:
|
||||
if target.startswith("/assets/img/") or target.startswith("/assets/fonts/"):
|
||||
if (SITE / target.lstrip("/")).exists():
|
||||
continue
|
||||
if target.startswith("/gtin/") and (SITE / target.lstrip("/")).exists():
|
||||
continue
|
||||
if target == "/assets/fonts/fonts.css":
|
||||
continue
|
||||
problems.append(f"{file.relative_to(SITE)} -> {target}")
|
||||
@@ -488,6 +598,28 @@ def link_check(routes: list[tuple[str, str]]) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def prune_stale_pages(routes: list[tuple]) -> None:
|
||||
"""Remove built pages that no longer belong to the route table.
|
||||
|
||||
The build writes into the existing tree, so a renamed route would otherwise
|
||||
leave its old HTML (and its retired image srcsets) behind and the link check
|
||||
would keep failing on files nobody serves any more.
|
||||
"""
|
||||
expected = (
|
||||
{SITE / route[1] for route in routes}
|
||||
| {SITE / rel for rel in UNLISTED_HTML}
|
||||
| {SITE / "404.html"}
|
||||
)
|
||||
for html in sorted(SITE.rglob("*.html")):
|
||||
if html in expected:
|
||||
continue
|
||||
print(f" [pages] removed stale {html.relative_to(SITE)}")
|
||||
html.unlink()
|
||||
parent = html.parent
|
||||
if parent != SITE and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
||||
|
||||
def normalize_permissions() -> None:
|
||||
"""Make the build output readable for the serving nginx worker.
|
||||
|
||||
@@ -512,6 +644,7 @@ def main() -> int:
|
||||
build_brand_assets(table)
|
||||
print("== pages ==")
|
||||
routes = build_html(table)
|
||||
prune_stale_pages(routes)
|
||||
build_meta_files(routes)
|
||||
build_script()
|
||||
print("== checks ==")
|
||||
|
||||
@@ -7,6 +7,10 @@ Sources
|
||||
We pull the pre-derived `web` variant (long edge 2560 px) instead of the
|
||||
20-60 MB camera originals. Only assets we are allowed to publish as the
|
||||
rights holder are listed in src/images.json.
|
||||
* Own portfolio files of the existing site (www.dennyschulz.de/legacy/<file>).
|
||||
These carry the concert, band and free-work photographs that are missing from
|
||||
the pool; they are fetched as-is and downscaled by the build. Also used for
|
||||
the operator's original logo (www.dennyschulz.de/logo.png).
|
||||
* Google Fonts (open licensed, OFL) - downloaded once, then self-hosted.
|
||||
Licences are written to docs/.
|
||||
* YouTube poster frame for the own music video "Thjodroerir - Skogamor".
|
||||
@@ -26,10 +30,13 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "assets-src"
|
||||
IMG_OUT = SRC / "images"
|
||||
BRAND_OUT = SRC / "brand"
|
||||
FONT_OUT = SRC / "fonts"
|
||||
TTF_OUT = SRC / "fonts-ttf"
|
||||
DOCS = ROOT / "docs"
|
||||
|
||||
LOGO_URL = "https://www.dennyschulz.de/logo.png"
|
||||
|
||||
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
# Old Safari advertises truetype-only support, so Google serves real .ttf
|
||||
# files (the MSIE 6 UA returns EOT, which PIL/FreeType cannot read).
|
||||
@@ -81,22 +88,26 @@ def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
|
||||
def fetch_images() -> None:
|
||||
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
|
||||
base = manifest["pool_base"].rstrip("/")
|
||||
legacy_base = manifest["legacy_base"].rstrip("/")
|
||||
suffixes = (".webp", ".src", ".jpg", ".jpeg", ".png")
|
||||
ok = skipped = 0
|
||||
for entry in manifest["images"]:
|
||||
slug, raw = entry["slug"], entry["raw"]
|
||||
rel = raw.replace("\\", "/")
|
||||
target = IMG_OUT / f"{slug}.webp"
|
||||
source = IMG_OUT / f"{slug}.src"
|
||||
if target.exists() or source.exists():
|
||||
if any((IMG_OUT / f"{slug}{suffix}").exists() for suffix in suffixes):
|
||||
skipped += 1
|
||||
continue
|
||||
if entry.get("source") == "legacy":
|
||||
data = fetch(f"{legacy_base}/{rel}")
|
||||
(IMG_OUT / f"{slug}.jpg").write_bytes(data)
|
||||
else:
|
||||
data = try_fetch(f"{base}/_derived/web/{rel}.webp")
|
||||
if data is None:
|
||||
print(f" [!] web variant missing, falling back to original: {rel}")
|
||||
data = fetch(f"{base}/{rel}")
|
||||
source.write_bytes(data)
|
||||
(IMG_OUT / f"{slug}.src").write_bytes(data)
|
||||
else:
|
||||
target.write_bytes(data)
|
||||
(IMG_OUT / f"{slug}.webp").write_bytes(data)
|
||||
ok += 1
|
||||
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
|
||||
print(f"images: {ok} fetched, {skipped} already present")
|
||||
@@ -110,6 +121,18 @@ def fetch_images() -> None:
|
||||
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
|
||||
|
||||
|
||||
def fetch_brand() -> None:
|
||||
"""The operator's original logo (used for the brand mark and all icons)."""
|
||||
BRAND_OUT.mkdir(parents=True, exist_ok=True)
|
||||
target = BRAND_OUT / "logo.png"
|
||||
if target.exists():
|
||||
print(f"brand: logo already present ({target.stat().st_size / 1024:.0f} KiB)")
|
||||
return
|
||||
data = fetch(LOGO_URL)
|
||||
target.write_bytes(data)
|
||||
print(f" [ok] logo ({len(data) / 1024:.0f} KiB)")
|
||||
|
||||
|
||||
def fetch_fonts() -> None:
|
||||
FONT_OUT.mkdir(parents=True, exist_ok=True)
|
||||
for family, spec in FAMILIES.items():
|
||||
@@ -160,8 +183,10 @@ def fetch_fonts() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for path in (IMG_OUT, FONT_OUT, DOCS):
|
||||
for path in (IMG_OUT, BRAND_OUT, FONT_OUT, DOCS):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
print("== brand ==")
|
||||
fetch_brand()
|
||||
print("== images ==")
|
||||
fetch_images()
|
||||
print("== fonts ==")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Contact: mailto:info@dennyschulz.de
|
||||
Expires: 2027-09-19T00:00:00.000Z
|
||||
Expires: 2027-09-25T00:00:00.000Z
|
||||
Preferred-Languages: de, en
|
||||
Canonical: https://desfoto.de/.well-known/security.txt
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="stylesheet" href="/assets/fonts.css">
|
||||
<link rel="stylesheet" href="/assets/site.css">
|
||||
<noscript><style>.reveal,.reveal--mask{opacity:1;transform:none;clip-path:none}.reveal--mask>*{clip-path:none}.hero__title .w>span,.hero__sub,.hero__actions,.hero__facts{opacity:1;transform:none}</style></noscript>
|
||||
|
||||
|
||||
</head>
|
||||
@@ -35,20 +36,15 @@
|
||||
<header class="masthead">
|
||||
<div class="shell masthead__inner">
|
||||
<a class="brand" href="/" aria-label="desfoto Startseite">
|
||||
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke">
|
||||
<circle class="ring" cx="20" cy="20" r="13"/>
|
||||
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
|
||||
<path class="blade" d="M20 7 20 1.6"/>
|
||||
<path class="blade" d="M31.3 26.5 36 29.2"/>
|
||||
<path class="blade" d="M8.7 26.5 4 29.2"/>
|
||||
</svg>
|
||||
<span class="brand__mark"><img src="/assets/img/logo-160.png" width="160" height="160" alt="" decoding="async"></span>
|
||||
<span class="brand__text"><span>desfoto</span><small>Fotografie & Video</small></span>
|
||||
</a>
|
||||
<button class="nav__toggle" type="button" aria-expanded="false" aria-controls="site-nav" data-nav-toggle>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> Menü
|
||||
</button>
|
||||
<nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/fotografie/">Fotografie<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait & Model</a><a href="/familien-und-paare/">Familien & Paare</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/social-media/">Social Media</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav>
|
||||
<nav class="nav" id="site-nav" aria-label="Hauptnavigation"><div class="nav__item"><a class="nav__link" href="/fotografie/">Fotografie<svg class="nav__caret" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg></a><div class="nav__sub"><a href="/businessfotografie/">Businessfotografie</a><a href="/portrait-und-model/">Portrait & Model</a><a href="/familie/">Familie</a><a href="/minishootings/">Minishootings</a></div></div><a class="nav__link" href="/video/">Video</a><a class="nav__link" href="/musik-und-buehne/">Musik & Bühne</a><a class="nav__link" href="/projekte/">Projekte</a><a class="nav__link" href="/ueber/">Über</a><a class="btn btn--solid" href="/kontakt/">Projekt anfragen</a></nav>
|
||||
</div>
|
||||
<span class="progress" aria-hidden="true"><span class="progress__bar" data-progress></span></span>
|
||||
</header>
|
||||
<main id="main"><section class="section">
|
||||
<div class="shell center-page">
|
||||
@@ -70,31 +66,32 @@
|
||||
<div class="footer__grid">
|
||||
<div class="footer__brand">
|
||||
<span class="brand">
|
||||
<svg class="brand__mark" viewBox="0 0 40 40" role="img" aria-label="desfoto Bildmarke">
|
||||
<circle class="ring" cx="20" cy="20" r="13"/>
|
||||
<circle class="ring" cx="20" cy="20" r="5.2" stroke-width="2"/>
|
||||
<path class="blade" d="M20 7 20 1.6"/>
|
||||
<path class="blade" d="M31.3 26.5 36 29.2"/>
|
||||
<path class="blade" d="M8.7 26.5 4 29.2"/>
|
||||
</svg>
|
||||
<span class="brand__mark"><img src="/assets/img/logo-160.png" width="160" height="160" alt="" decoding="async"></span>
|
||||
<span class="brand__text"><span>desfoto</span><small>Fotografie & Video</small></span>
|
||||
</span>
|
||||
<p class="footer__claim">Fotografie und Video aus Neumünster. Ein Angebot von Denny Schulz Fotografie.</p>
|
||||
<p class="footer__claim">desfoto ist ein Angebot von Denny Schulz, Neumünster.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Fotografie</h2>
|
||||
<div class="footer__links">
|
||||
<a href="/fotografie/">Überblick Fotografie</a>
|
||||
<a href="/businessfotografie/">Businessfotografie</a>
|
||||
<a href="/portrait-und-model/">Portrait & Model</a>
|
||||
<a href="/familien-und-paare/">Familien & Paare</a>
|
||||
<a href="/familie/">Familie</a>
|
||||
<a href="/minishootings/">Minishootings</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Weitere Leistungen</h2>
|
||||
<h2>Video & Bühne</h2>
|
||||
<div class="footer__links">
|
||||
<a href="/video/">Videoprojekte</a>
|
||||
<a href="/musik-und-buehne/">Musik & Bühne</a>
|
||||
<a href="/social-media/">Social Media</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Mehr</h2>
|
||||
<div class="footer__links">
|
||||
<a href="/projekte/">Projekte</a>
|
||||
<a href="/ueber/">Über desfoto</a>
|
||||
</div>
|
||||
@@ -108,6 +105,10 @@
|
||||
<span class="muted">Neumünster · Schleswig-Holstein</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Netzwerk</h2>
|
||||
<div class="footer__links"><a class="footer__ext" href="https://www.dennyschulz.de/" target="_blank" rel="noopener">dennyschulz.de <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-7 7M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/></svg></a><small>Hochzeiten und Paare — die Arbeit unter eigenem Namen, mit eigenem Schwerpunkt.</small><a class="footer__ext" href="https://dennyapp.de/" target="_blank" rel="noopener">dennyapp.de <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-7 7M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/></svg></a><small>Websites und Web-Apps aus Neumünster — Design, Entwicklung und eigene Produkte.</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__bottom">
|
||||
<nav aria-label="Rechtliches">
|
||||
@@ -115,7 +116,7 @@
|
||||
<a href="/datenschutz/">Datenschutz</a>
|
||||
<a href="/kontakt/">Kontakt</a>
|
||||
</nav>
|
||||
<span>© Denny Schulz · Denny Schulz Fotografie · Alle Rechte an den gezeigten Bildern und Filmen vorbehalten.</span>
|
||||
<span>© Denny Schulz · Alle Rechte an den gezeigten Bildern und Filmen vorbehalten.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 54 KiB |
BIN
site/assets/img/band-extinct-1200.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
site/assets/img/band-extinct-1600.webp
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
site/assets/img/band-extinct-480.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
site/assets/img/band-extinct-768.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
site/assets/img/band-extinct-group-1200.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
site/assets/img/band-extinct-group-1600.webp
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
site/assets/img/band-extinct-group-480.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
site/assets/img/band-extinct-group-768.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
site/assets/img/band-extinct-guitar-1200.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
site/assets/img/band-extinct-guitar-1600.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
site/assets/img/band-extinct-guitar-480.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
site/assets/img/band-extinct-guitar-768.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
site/assets/img/band-sagenbringer-1200.webp
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
site/assets/img/band-sagenbringer-1600.webp
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
site/assets/img/band-sagenbringer-480.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
site/assets/img/band-sagenbringer-768.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
site/assets/img/business-hotel-laugh-1200.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
site/assets/img/business-hotel-laugh-1600.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
site/assets/img/business-hotel-laugh-480.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
site/assets/img/business-hotel-laugh-768.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
site/assets/img/business-hotel-orange-1200.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
site/assets/img/business-hotel-orange-1600.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
site/assets/img/business-hotel-orange-480.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
site/assets/img/business-hotel-orange-768.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
site/assets/img/business-hotel-tea-1200.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
site/assets/img/business-hotel-tea-1600.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
site/assets/img/business-hotel-tea-480.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
site/assets/img/business-hotel-tea-768.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
site/assets/img/business-hotel-window-1200.webp
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
site/assets/img/business-hotel-window-1600.webp
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
site/assets/img/business-hotel-window-480.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
site/assets/img/business-hotel-window-768.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 339 KiB |
BIN
site/assets/img/editorial-balance-1200.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
site/assets/img/editorial-balance-1600.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
site/assets/img/editorial-balance-480.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
site/assets/img/editorial-balance-768.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
site/assets/img/editorial-leap-1200.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
site/assets/img/editorial-leap-1600.webp
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
site/assets/img/editorial-leap-480.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
site/assets/img/editorial-leap-768.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 165 KiB |
BIN
site/assets/img/editorial-studio-side-1200.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
site/assets/img/editorial-studio-side-1600.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
site/assets/img/editorial-studio-side-480.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
site/assets/img/editorial-studio-side-768.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 763 B After Width: | Height: | Size: 927 B |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.8 KiB |
BIN
site/assets/img/free-arch-garden-1200.webp
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
site/assets/img/free-arch-garden-1600.webp
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
site/assets/img/free-arch-garden-480.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
site/assets/img/free-arch-garden-768.webp
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
site/assets/img/free-castle-lake-1200.webp
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
site/assets/img/free-castle-lake-1600.webp
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
site/assets/img/free-castle-lake-480.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
site/assets/img/free-castle-lake-768.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
site/assets/img/free-cat-1200.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
site/assets/img/free-cat-1600.webp
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
site/assets/img/free-cat-480.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
site/assets/img/free-cat-768.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
site/assets/img/free-cat-light-1200.webp
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
site/assets/img/free-cat-light-1600.webp
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
site/assets/img/free-cat-light-480.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
site/assets/img/free-cat-light-768.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
site/assets/img/free-cat-white-1200.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
site/assets/img/free-cat-white-1600.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
site/assets/img/free-cat-white-480.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/assets/img/free-cat-white-768.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
site/assets/img/free-dog-husky-1200.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
site/assets/img/free-dog-husky-1600.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
site/assets/img/free-dog-husky-480.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
site/assets/img/free-dog-husky-768.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
site/assets/img/free-dogs-forest-1200.webp
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
site/assets/img/free-dogs-forest-1600.webp
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
site/assets/img/free-dogs-forest-480.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
site/assets/img/free-dogs-forest-768.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
site/assets/img/free-fjord-1200.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
site/assets/img/free-fjord-1600.webp
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
site/assets/img/free-fjord-480.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |