#!/usr/bin/env python3 """Build the committed static site in site/ from src/ and assets-src/. Steps ----- 1. Responsive WebP derivatives for every pool image (no upscaling). 2. Self-hosted font CSS from the downloaded Google Fonts subsets. 3. Brand assets: favicon, touch icon, per-page OpenGraph images. 4. HTML for every route, robots.txt, sitemap.xml, webmanifest, security.txt. 5. A build report with size totals and an internal-link check. """ from __future__ import annotations import base64 import datetime as dt import io import json import shutil import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" ASSETS = ROOT / "assets-src" 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)) import layout # noqa: E402 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) WEBP_QUALITY = 84 INK = (23, 20, 15) PAPER = (246, 242, 234) ACCENT = (192, 67, 28) # OpenGraph image per route, built from the page's own key visual. ROUTE_OG = { "/": ("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"), "/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"), } def load_manifest() -> list[dict]: data = json.loads((SRC / "images.json").read_text(encoding="utf-8")) entries = list(data["images"]) entries.append( { "slug": VIDEO["poster"].replace(".jpg", ""), "raw": VIDEO["poster"], "alt": f"Vorschaubild zum Musikvideo {VIDEO['title']}", "focus": "50% 50%", "local": True, } ) return entries def source_path(entry: dict) -> Path: webp = ASSETS / "images" / f"{entry['slug']}.webp" if webp.exists(): return webp for suffix in (".src", ".jpg", ".jpeg", ".png"): candidate = ASSETS / "images" / f"{entry['slug']}{suffix}" if candidate.exists(): return candidate raise SystemExit(f"missing source asset for {entry['slug']}") def build_images(entries: list[dict]) -> dict[str, dict]: if IMG_OUT.exists(): for stale in IMG_OUT.glob("*.webp"): stale.unlink() IMG_OUT.mkdir(parents=True, exist_ok=True) table: dict[str, dict] = {} total = 0 for entry in entries: src = Image.open(source_path(entry)).convert("RGB") src_w, src_h = src.size widths = sorted({width for width in WIDTHS if width <= src_w} or {min(src_w, WIDTHS[0])}) if src_w < widths[-1]: widths.append(src_w) widths = sorted(set(widths)) for width in widths: height = round(src_h * width / src_w) out = IMG_OUT / f"{entry['slug']}-{width}.webp" resized = src if width >= src_w else src.resize((width, height), Image.Resampling.LANCZOS) resized.save(out, "WebP", quality=WEBP_QUALITY, method=6) total += out.stat().st_size table[entry["slug"]] = { "base": f"/assets/img/{entry['slug']}", "alt": entry["alt"], "focus": entry.get("focus", "50% 50%"), "widths": widths, "width": widths[-1], "height": round(src_h * widths[-1] / src_w), } print(f" [img] {entry['slug']:<26} {src_w}x{src_h} -> {len(widths)} widths") print(f" image payload: {total / 1024 / 1024:.1f} MiB across {len(table)} images") return table def build_fonts() -> None: FONT_OUT.mkdir(parents=True, exist_ok=True) for existing in FONT_OUT.glob("*.woff2"): existing.unlink() blocks = [] for family in ("fraunces", "manrope"): index = json.loads((ASSETS / f"font-index-{family}.json").read_text(encoding="utf-8")) for item in index: shutil.copyfile(ASSETS / "fonts" / item["file"], FONT_OUT / item["file"]) for item in index: weight = "300 700" if family == "fraunces" else "200 800" blocks.append( "@font-face{" f"font-family:'{family.capitalize()}';font-style:normal;font-weight:{weight};" "font-display:swap;" f"src:url('/assets/fonts/{item['file']}') format('woff2');" f"unicode-range:{item['range']};" "}" ) print(f" [font] {family}: {len(index)} subsets") header = ( "/* Self-hosted open licensed fonts (SIL Open Font License 1.1).\n" " Fraunces + Manrope, sourced from Google Fonts. Licences: docs/font-*-OFL.txt */\n" ) (SITE / "assets" / "fonts.css").write_text(header + "\n".join(blocks) + "\n", encoding="utf-8") def build_stylesheet() -> None: (SITE / "assets" / "site.css").write_text( f"/* desfoto.de - generated by scripts/build-site.py */\n:root{{{TOKENS}}}{CSS}", encoding="utf-8", ) print(" [css] site.css written") def _font(name: str, size: int) -> ImageFont.FreeTypeFont: return ImageFont.truetype(str(TTF / name), size) 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( '\n' f' \n' "\n", encoding="utf-8", ) 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(): if filename in og_done: 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: meta = table[slug] source = Image.open(IMG_OUT / f"{slug}-{meta['widths'][-1]}.webp").convert("RGB") target_w, target_h = 1200, 630 ratio = target_w / target_h src_ratio = source.width / source.height if src_ratio > ratio: new_w = int(source.height * ratio) left = (source.width - new_w) // 2 source = source.crop((left, 0, left + new_w, source.height)) else: new_h = int(source.width / ratio) top = int((source.height - new_h) * 0.35) source = source.crop((0, top, source.width, top + new_h)) source = source.resize((target_w, target_h), Image.Resampling.LANCZOS) scrim = Image.new("L", (target_w, target_h), 0) sd = ImageDraw.Draw(scrim) for y in range(target_h): alpha = int(40 + 175 * (y / target_h) ** 1.35) sd.line((0, y, target_w, y), fill=alpha) source = Image.composite(Image.new("RGB", (target_w, target_h), (12, 11, 9)), source, scrim) d = ImageDraw.Draw(source) d.rectangle((0, 0, target_w, 6), fill=ACCENT) brand_font = _font("fraunces-600.ttf", 92) head_font = _font("fraunces-400.ttf", 54) small_font = _font("manrope-600.ttf", 26) 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: d.text((76, y), line, font=head_font, fill=PAPER) y += 64 source.save(IMG_OUT / filename, "JPEG", quality=88, optimize=True, progressive=True) def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> list[str]: words = text.split() lines: list[str] = [] current = "" for word in words: candidate = f"{current} {word}".strip() if font.getlength(candidate) <= max_width: current = candidate else: if current: lines.append(current) current = word if current: lines.append(current) return lines[:3] def build_html(table: dict[str, dict]) -> list[tuple[str, str]]: layout.IMAGE_TABLE = table written: list[tuple[str, str]] = [] for path, rel, factory, _x, _p in pages.ROUTES: html = factory() if path in ROUTE_OG: html = html.replace("og-desfoto.jpg", ROUTE_OG[path][1]) target = SITE / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_text(html, encoding="utf-8") written.append((path, rel)) print(f" [html] {rel}") 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 build_meta_files(routes: list[tuple[str, str]]) -> None: today = dt.date.today().isoformat() urls = [] for path, _rel, _f, _x, priority in pages.ROUTES: urls.append( f" {SITE_META['url']}{path}{today}" f"{'weekly' if priority == '1.0' else 'monthly'}" f"{priority}" ) (SITE / "sitemap.xml").write_text( '\n' '\n' + "\n".join(urls) + "\n\n", encoding="utf-8", ) (SITE / "robots.txt").write_text( "User-agent: *\nAllow: /\n\n" f"Sitemap: {SITE_META['url']}/sitemap.xml\n", encoding="utf-8", ) (SITE / "site.webmanifest").write_text( json.dumps( { "name": "desfoto", "short_name": "desfoto", "description": SITE_META["default_description"], "start_url": "/", "display": "standalone", "background_color": "#F6F2EA", "theme_color": SITE_META["theme_color"], "icons": [ {"src": "/assets/img/favicon-32.png", "sizes": "32x32", "type": "image/png"}, {"src": "/assets/img/apple-touch-icon.png", "sizes": "180x180", "type": "image/png"}, ], }, ensure_ascii=False, indent=2, ) + "\n", encoding="utf-8", ) wellknown = SITE / ".well-known" wellknown.mkdir(parents=True, exist_ok=True) (wellknown / "security.txt").write_text( "Contact: mailto:info@dennyschulz.de\n" f"Expires: {(dt.date.today() + dt.timedelta(days=365)).isoformat()}T00:00:00.000Z\n" "Preferred-Languages: de, en\n" f"Canonical: {SITE_META['url']}/.well-known/security.txt\n", encoding="utf-8", ) print(" [meta] sitemap.xml, robots.txt, site.webmanifest, .well-known/security.txt") def build_script() -> None: (SITE / "assets" / "site.js").write_text( """/* desfoto.de - progressive enhancement only. */ (function () { 'use strict'; /* Mobile navigation */ var toggle = document.querySelector('[data-nav-toggle]'); var nav = document.getElementById('site-nav'); if (toggle && nav) { toggle.addEventListener('click', function () { var open = nav.classList.toggle('is-open'); toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); }); nav.addEventListener('click', function (event) { if (event.target.closest('a') && window.innerWidth < 832) { nav.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); } }); } /* Click-to-load YouTube (youtube-nocookie) */ document.querySelectorAll('[data-video-id]').forEach(function (facade) { facade.addEventListener('click', function () { var id = facade.getAttribute('data-video-id'); var iframe = document.createElement('iframe'); iframe.setAttribute('src', 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(id) + '?autoplay=1&rel=0&modestbranding=1&playsinline=1'); iframe.setAttribute('title', facade.getAttribute('aria-label') || 'Video'); iframe.setAttribute('loading', 'eager'); iframe.setAttribute('allow', 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture'); iframe.setAttribute('allowfullscreen', ''); iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); var shell = facade.parentElement; shell.replaceChild(iframe, facade); iframe.focus(); }); }); /* Contact form -> local mail draft, nothing is transmitted by this page */ var form = document.querySelector('[data-contact-form]'); if (form) { var status = form.querySelector('[data-contact-status]'); form.addEventListener('submit', function (event) { event.preventDefault(); var data = new FormData(form); var name = (data.get('name') || '').toString().trim(); var email = (data.get('email') || '').toString().trim(); var topic = (data.get('topic') || '').toString().trim(); var date = (data.get('date') || '').toString().trim(); var message = (data.get('message') || '').toString().trim(); if (!name || !email || !message) { if (status) { status.hidden = false; status.textContent = 'Bitte Name, E-Mail und Nachricht ausfüllen.'; status.style.color = '#95330F'; } return; } var subject = 'Anfrage über desfoto.de: ' + topic; var body = 'Name: ' + name + '\\nE-Mail: ' + email + '\\nBereich: ' + topic + (date ? '\\nWunschtermin: ' + date : '') + '\\n\\n' + message + '\\n'; window.location.href = 'mailto:info@dennyschulz.de?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body); if (status) { status.hidden = false; status.textContent = 'Dein Mailprogramm sollte sich mit dem fertigen Entwurf öffnen.'; status.style.color = ''; } }); } /* 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)) { 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 -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", ) print(" [js] site.js written") def link_check(routes: list[tuple[str, str]]) -> list[str]: """Verify that every internal href/src written into the build resolves.""" import html as html_mod import re problems: list[str] = [] built = {f"/{rel}".replace("/index.html", "/") for _p, rel in routes} 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"]: built.add(f"/{rel}") for file in SITE.rglob("*"): if file.is_dir() or file.suffix not in (".html", ".xml", ".json", ".txt", ".webmanifest", ".css", ".js"): continue text = file.read_text(encoding="utf-8", errors="replace") if file.suffix in (".html", ".css"): for match in re.finditer(r'(?:href|src)="(/[^"#?]*)"', text): target = html_mod.unescape(match.group(1)) if target in built: continue if target.startswith("/assets/img/") or target.startswith("/assets/fonts/"): if (SITE / target.lstrip("/")).exists(): continue if target == "/assets/fonts/fonts.css": continue problems.append(f"{file.relative_to(SITE)} -> {target}") if file.suffix == ".html": for match in re.finditer(r'srcset="([^"]+)"', text): for candidate in re.findall(r'(/assets/img/[^\s,]+)', match.group(1)): if not (SITE / candidate.lstrip("/")).exists(): problems.append(f"{file.relative_to(SITE)} -> srcset {candidate}") for match in re.finditer(r'url\((/(?:assets/fonts/)[^)]+)\)', text): if not (SITE / match.group(1).lstrip("/")).exists(): problems.append(f"{file.relative_to(SITE)} -> font {match.group(1)}") for match in re.finditer(r'([^<]+)', (SITE / "sitemap.xml").read_text(encoding="utf-8")): path = match.group(1).replace(SITE_META["url"], "") if path not in built: problems.append(f"sitemap.xml -> {path}") for match in re.finditer(r"url\('(/assets/fonts/[^']+)'\)", (SITE / "assets" / "fonts.css").read_text(encoding="utf-8")): if not (SITE / match.group(1).lstrip("/")).exists(): problems.append(f"fonts.css -> missing {match.group(1)}") 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 / "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. The image and font files are written with the ambient umask, so a build run with a restrictive umask would leave 0600 files that an unprivileged nginx worker cannot read (HTTP 403). The build therefore fixes the modes itself: directories 0755, files 0644. """ for path in sorted(SITE.rglob("*"), key=lambda p: len(p.parts), reverse=True): path.chmod(0o755 if path.is_dir() else 0o644) SITE.chmod(0o755) def main() -> int: print("== images ==") entries = load_manifest() table = build_images(entries) print("== fonts ==") build_fonts() build_stylesheet() print("== brand ==") build_brand_assets(table) print("== pages ==") routes = build_html(table) prune_stale_pages(routes) build_meta_files(routes) build_script() print("== checks ==") problems = link_check(routes) html_files = sorted(SITE.rglob("*.html")) broken_alt = [] import re for file in html_files: text = file.read_text(encoding="utf-8") for tag in re.finditer(r"]*>", text): if 'alt="' not in tag.group(0): broken_alt.append(f"{file.relative_to(SITE)}: img without alt") if problems: print(" internal link problems:") for problem in problems: print(" -", problem) if broken_alt: print(" images without alt:") for problem in broken_alt: print(" -", problem) total = sum(f.stat().st_size for f in SITE.rglob("*") if f.is_file()) normalize_permissions() print(f" pages: {len(html_files)} | site size: {total / 1024 / 1024:.1f} MiB") print(f" link problems: {len(problems)} | alt problems: {len(broken_alt)}") if problems or broken_alt: return 1 print("build ok") return 0 if __name__ == "__main__": raise SystemExit(main())