feat: launch desfoto.de as a standalone photography site and retire the old redirect
This commit is contained in:
547
scripts/build-site.py
Executable file
547
scripts/build-site.py
Executable file
@@ -0,0 +1,547 @@
|
||||
#!/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 datetime as dt
|
||||
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"
|
||||
|
||||
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, 2000)
|
||||
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 = {
|
||||
"/": ("hero-studio", "og-desfoto.jpg", "Bilder und Filme, die nicht beliebig aussehen."),
|
||||
"/fotografie/": ("editorial-earring", "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"),
|
||||
"/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 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 build_brand_assets(table: dict[str, dict]) -> None:
|
||||
(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>
|
||||
""",
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
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))
|
||||
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" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
|
||||
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
|
||||
f"<priority>{priority}</priority></url>"
|
||||
)
|
||||
(SITE / "sitemap.xml").write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(urls)
|
||||
+ "\n</urlset>\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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Soft reveal on scroll, disabled for reduced motion */
|
||||
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var items = document.querySelectorAll('.reveal');
|
||||
if (reduce || !('IntersectionObserver' in window)) {
|
||||
items.forEach(function (item) { item.classList.add('is-in'); });
|
||||
} else {
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
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); });
|
||||
}
|
||||
}());
|
||||
""",
|
||||
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'<loc>([^<]+)</loc>', (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 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)
|
||||
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"<img\b[^>]*>", 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())
|
||||
174
scripts/fetch-assets.py
Executable file
174
scripts/fetch-assets.py
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch real source assets for desfoto.de.
|
||||
|
||||
Sources
|
||||
-------
|
||||
* Own image pool (Orgatool volume, served through the photographer's own API).
|
||||
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.
|
||||
* 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".
|
||||
|
||||
Everything lands in assets-src/ which is *not* shipped. scripts/build-site.py
|
||||
turns assets-src/ into the committed site/ output.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "assets-src"
|
||||
IMG_OUT = SRC / "images"
|
||||
FONT_OUT = SRC / "fonts"
|
||||
TTF_OUT = SRC / "fonts-ttf"
|
||||
DOCS = ROOT / "docs"
|
||||
|
||||
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).
|
||||
UA_TTF = (
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_8; en-US) "
|
||||
"AppleWebKit/533.18.1 (KHTML, like Gecko) Version/5.0.2 Safari/533.18.1"
|
||||
)
|
||||
|
||||
# family -> (google css2 query, wanted unicode-range comments)
|
||||
FAMILIES = {
|
||||
"fraunces": {
|
||||
"query": "family=Fraunces:opsz,wght@9..144,300..700",
|
||||
"keep": ("latin", "latin-ext"),
|
||||
"license": "https://raw.githubusercontent.com/google/fonts/main/ofl/fraunces/OFL.txt",
|
||||
},
|
||||
"manrope": {
|
||||
"query": "family=Manrope:wght@200..800",
|
||||
"keep": ("latin", "latin-ext"),
|
||||
"license": "https://raw.githubusercontent.com/google/fonts/main/ofl/manrope/OFL.txt",
|
||||
},
|
||||
}
|
||||
|
||||
TTF_QUERIES = (
|
||||
"family=Fraunces:opsz,wght@9..144,400",
|
||||
"family=Fraunces:opsz,wght@9..144,600",
|
||||
"family=Manrope:wght@400",
|
||||
"family=Manrope:wght@600",
|
||||
)
|
||||
VIDEO_ID = "NWWFTf7l8g0"
|
||||
|
||||
|
||||
def fetch(url: str, *, ua: str = UA, timeout: int = 120) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "*/*"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
def try_fetch(url: str, *, ua: str = UA) -> bytes | None:
|
||||
try:
|
||||
return fetch(url, ua=ua)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (403, 404, 410):
|
||||
return None
|
||||
raise
|
||||
except urllib.error.URLError:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_images() -> None:
|
||||
manifest = json.loads((ROOT / "src" / "images.json").read_text(encoding="utf-8"))
|
||||
base = manifest["pool_base"].rstrip("/")
|
||||
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():
|
||||
skipped += 1
|
||||
continue
|
||||
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)
|
||||
else:
|
||||
target.write_bytes(data)
|
||||
ok += 1
|
||||
print(f" [ok] {slug} ({len(data) / 1024:.0f} KiB)")
|
||||
print(f"images: {ok} fetched, {skipped} already present")
|
||||
|
||||
poster = IMG_OUT / "video-skogamor-poster.jpg"
|
||||
if not poster.exists():
|
||||
data = try_fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/maxresdefault.jpg")
|
||||
if data is None:
|
||||
data = fetch(f"https://i.ytimg.com/vi/{VIDEO_ID}/hqdefault.jpg")
|
||||
poster.write_bytes(data)
|
||||
print(f" [ok] video poster ({len(data) / 1024:.0f} KiB)")
|
||||
|
||||
|
||||
def fetch_fonts() -> None:
|
||||
FONT_OUT.mkdir(parents=True, exist_ok=True)
|
||||
for family, spec in FAMILIES.items():
|
||||
css = fetch(f"https://fonts.googleapis.com/css2?{spec['query']}&display=swap").decode()
|
||||
blocks = re.findall(
|
||||
r"/\* ([a-z0-9-]+) \*/\s*@font-face \{(.*?)\}", css, flags=re.S
|
||||
)
|
||||
index = []
|
||||
for subset, body in blocks:
|
||||
if subset not in spec["keep"]:
|
||||
continue
|
||||
url = re.search(r"url\((https://[^)]+\.woff2)\)", body)
|
||||
rng = re.search(r"unicode-range:\s*([^;]+);", body)
|
||||
if not url:
|
||||
continue
|
||||
name = f"{family}-{subset}.woff2"
|
||||
target = FONT_OUT / name
|
||||
if not target.exists():
|
||||
target.write_bytes(fetch(url.group(1)))
|
||||
index.append({"file": name, "range": rng.group(1).strip() if rng else ""})
|
||||
print(f" [ok] font {name} ({target.stat().st_size / 1024:.0f} KiB)")
|
||||
(SRC / f"font-index-{family}.json").write_text(
|
||||
json.dumps(index, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
license_target = DOCS / f"font-{family}-OFL.txt"
|
||||
if not license_target.exists():
|
||||
license_target.write_bytes(fetch(spec["license"]))
|
||||
print(f" [ok] licence {license_target.name}")
|
||||
|
||||
# Build-time only TTFs (used to render the OG image and brand PNGs).
|
||||
TTF_OUT.mkdir(parents=True, exist_ok=True)
|
||||
for query in TTF_QUERIES:
|
||||
css = fetch(f"https://fonts.googleapis.com/css2?{query}", ua=UA_TTF).decode()
|
||||
blocks = re.findall(r"@font-face \{(.*?)\}", css, flags=re.S)
|
||||
for block in blocks:
|
||||
url_match = re.search(r"url\((https://[^)]+)\)", block)
|
||||
if not url_match:
|
||||
continue
|
||||
family = (re.search(r"font-family: '([^']+)'", block) or [None, "font"])[1]
|
||||
weight = (re.search(r"font-weight: (\d+)", block) or [None, "400"])[1]
|
||||
name = f"{family.lower()}-{weight}.ttf"
|
||||
target = TTF_OUT / name
|
||||
if target.exists():
|
||||
continue
|
||||
target.write_bytes(fetch(url_match.group(1), ua=UA_TTF))
|
||||
print(f" [ok] build font {name} ({target.stat().st_size / 1024:.0f} KiB)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for path in (IMG_OUT, FONT_OUT, DOCS):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
print("== images ==")
|
||||
fetch_images()
|
||||
print("== fonts ==")
|
||||
fetch_fonts()
|
||||
print("done")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user