#!/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 hashlib
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
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)
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",
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}")
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, _factory, _extra, 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.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"]:
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.startswith("/gtin/") and (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 / 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.
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())