QUELLCODE / PY

build_site.py

project-site/build_site.py

1#!/usr/bin/env python32# OpenAss: nur privat und experimentell. Bau, Download und Nutzung auf eigene Verantwortung.3# Niemals Unternehmensrechner, Produktivsysteme, Industrieanlagen oder sicherheitskritische Systeme anschließen oder bedienen.4# Keine freiwillige Funktions-/Sicherheitsgarantie; zwingende gesetzliche Ansprüche bleiben unberührt. Siehe DISCLAIMER.txt.5"""Build the self-contained public project site. Run build_downloads.py first."""6from __future__ import annotations78import html9import json10import re11import shutil12from pathlib import Path13from urllib.parse import quote, urlsplit1415import markdown16from PIL import Image, ImageDraw, ImageFont17from privacy_check import check_tree1819SITE = Path(__file__).resolve().parent20PROJECT = SITE.parent21OUTPUT = SITE / 'public'22DOMAIN = 'https://openass.wolperdinger.at'23DISCLAIMER = PROJECT / 'DISCLAIMER.txt'24USE_SUMMARY = 'Nur für privaten, experimentellen Gebrauch. Bau, Download und Nutzung auf eigene Verantwortung.'25USE_RESTRICTION = 'Niemals an Unternehmensrechner, Produktivsysteme, Industrieanlagen oder sicherheitskritische Systeme anschließen oder diese bedienen.'26DOCS = [27    ('overview', 'Projektübersicht', 'README.md', 'Architektur, Funktionen und der vollständige Referenzumfang.'),28    ('disclaimer', 'Haftungshinweise & Verwendungszweck', 'docs/DISCLAIMER.md', 'Nur privat und experimentell: eigene Verantwortung, ausgeschlossene Einsatzbereiche und gesetzliche Grenzen.'),29    ('hardware', 'Hardware & Verkabelung', 'docs/HARDWARE.md', 'Konkrete Bauteile, Versorgung und die beiden Signalwege.'),30    ('installation', 'Installation & Inbetriebnahme', 'docs/INSTALLATION.md', 'Pi, Firmware, Capture und lokaler Leitstand Schritt für Schritt.'),31    ('software', 'OpenAI & Agentenloop', 'docs/OPENAI.md', 'Bildbasierte API-Integration mit kontrollierter Rückkopplung.'),32    ('protocol', 'HID & HB1-Protokoll', 'docs/PROTOCOL.md', 'UART-Frames, USB-Reports und Koordinatenübertragung.'),33    ('safety', 'Freigabe & Sicherheitsmodell', 'docs/SAFETY.md', 'Kontrolle, Stoppebenen und technische Grenzen.'),34    ('testplan', 'Testplan & praktische Abnahme', 'docs/TESTPLAN.md', 'Vom Simulationstest bis zum geprüften Hardwareaufbau.'),35    ('verification', 'Dokumentierter Prüfnachweis', 'docs/VERIFICATION.md', 'Was getestet ist und welche Prüfungen noch offen sind.'),36    ('firmware-build', 'Pico-Firmware: Buildnachweis', 'pico/BUILD_VALIDATION.md', 'SDK, Toolchain, Crossbuild und Firmware-Hash.'),37    ('printing', 'Druck- & Montagehinweise', 'design/v2/DRUCKHINWEISE.txt', 'Print Edition mit Mini-Pilz: Maße, Ausrichtung und Passung.'),38    ('design-archive', 'Ursprüngliches Designkonzept', 'design/DESIGN_NOTES.md', 'V1-Designarchiv; das aktuelle Druckgehäuse liegt in Print Edition V2.'),39]40DOC_MAP = {str((PROJECT / path).resolve()): name for name, _, path, _ in DOCS}414243def escape(value: object) -> str:44    return html.escape(str(value), quote=True)454647def size(value: int) -> str:48    return f'{value / 1_000_000:.1f} MB'.replace('.', ',') if value >= 1_000_000 else f'{value / 1000:.0f} KB'495051def header(prefix: str = '../') -> str:52    return f'''<a class="skip-link" href="#main">Zum Inhalt</a><header class="site-header"><a class="brand" href="{prefix}">openass<span>.</span><small>HARDWARE LAB</small></a><button class="menu-toggle" aria-label="Navigation öffnen" aria-controls="main-nav" aria-expanded="false"><span></span><span></span></button><nav id="main-nav" aria-label="Hauptnavigation"><a href="{prefix}#idee">Die Idee</a><a href="{prefix}#hardware">Die Box</a><a href="{prefix}docs/">Dokumentation</a><a href="{prefix}source/">Quellcode</a><a class="nav-cta" href="{prefix}#downloads">Downloads ↗</a></nav></header>'''535455def use_notice(prefix: str = '../', *, compact: bool = False) -> str:56    """Keep the experimental-use notice visible on every public HTML page."""57    variant = ' use-notice-compact' if compact else ''58    return f'''<aside class="use-notice{variant}" aria-label="Experimenteller Gebrauch und Haftung"><span class="use-notice-label">EXPERIMENT / AUF EIGENE VERANTWORTUNG</span><div><strong>{escape(USE_SUMMARY)}</strong><p>{escape(USE_RESTRICTION)} <a href="{prefix}disclaimer.html">Haftungshinweise lesen ↗</a></p></div></aside>'''596061def footer(prefix: str = '../') -> str:62    return f'''<footer class="site-footer wrap"><div><a class="brand" href="{prefix}">openass<span>.</span></a><p>LINK · Eine Idee zum Anfassen.</p></div><div><a href="{prefix}docs/">Dokumentation</a><a href="{prefix}source/">Quellcode</a><a href="{prefix}#downloads">Downloads</a><a href="{prefix}privacy.html">Datenschutz</a><a href="{prefix}disclaimer.html">Haftungshinweise</a></div><p class="footer-meta">Referenzstand v0.1 · 9. Oktober 2026</p><p class="footer-responsibility">{escape(USE_SUMMARY)} Bau, Downloads und Nutzung erfolgen auf eigene Verantwortung. Keine Zusicherung von Funktion, Sicherheit oder Eignung; Gewährleistung und Haftung nur im gesetzlich zulässigen Umfang eingeschränkt. <a href="{prefix}disclaimer.html">Vollständige Haftungshinweise ↗</a></p></footer><div class="toast" id="toast" role="status" aria-live="polite"></div>'''636465def page(title: str, description: str, content: str, route: str, prefix: str = '../') -> str:66    return f'''<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f5f4ef"><title>{escape(title)} · OpenAss LINK</title><meta name="description" content="{escape(description)}"><link rel="canonical" href="{DOMAIN}/{route}"><link rel="icon" href="{prefix}assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="{prefix}styles.css"><script src="{prefix}app.js" defer></script></head><body>{header(prefix)}<div class="wrap">{use_notice(prefix, compact=True)}</div><main id="main">{content}</main>{footer(prefix)}</body></html>'''676869def doc_sidebar(current: str) -> str:70    links = ''71    for name, title, _, _ in DOCS:72        active = ' class="active" aria-current="page"' if name == current else ''73        links += f'<a href="{name}.html"{active}>{escape(title)}</a>'74    return f'<aside class="document-sidebar"><h2>BAUUNTERLAGEN</h2><nav aria-label="Dokumentation">{links}</nav></aside>'757677def source_paths() -> list[Path]:78    paths: set[Path] = set()79    for folder, suffixes in [('src', {'.py', '.js', '.css', '.html'}), ('pico', {'.c', '.h', '.sh', '.md', '.txt'}), ('tests', {'.py'}), ('config', {'.toml'}), ('scripts', {'.py'}), ('deploy', {'.service'})]:80        for path in (PROJECT / folder).rglob('*'):81            relative = path.relative_to(PROJECT)82            if any(part in {'build', 'build-native', '__pycache__'} for part in relative.parts):83                continue84            if path.is_file() and not path.is_symlink() and path.suffix in suffixes and path.name != 'local.toml':85                paths.add(path)86    for relative in ['README.md', 'DISCLAIMER.txt', 'pyproject.toml', 'requirements-tested.txt', '.env.example', '.gitignore', 'design/build_model.py', 'design/public_export.py', 'design/model_components.py', 'design/v2/build_print_model.py']:87        path = PROJECT / relative88        if path.is_file():89            paths.add(path)90    for name in ['index.html', 'styles.css', 'app.js', 'build_site.py', 'build_downloads.py', 'privacy_check.py', 'test_privacy_check.py', 'requirements-build.txt', 'README.txt', 'THIRD_PARTY_NOTICES.txt']:91        path = SITE / name92        if path.is_file():93            paths.add(path)94    return sorted(paths)959697def raw_url(path: Path, prefix: str = '../') -> str:98    return prefix + 'raw/' + quote(path.relative_to(PROJECT).as_posix(), safe='/') + '.txt'99100101def rewrite_doc_links(content: str, source: Path) -> str:102    # Local tutorial addresses are examples, not links to a visitor's own operator.103    content = re.sub(r'<a href="https?://(?:127\.0\.0\.1|localhost)(?::\d+)?[^"]*">(.*?)</a>', r'<code>\1</code>', content, flags=re.S)104    def replace(match: re.Match[str]) -> str:105        attribute, url = match.group(1), html.unescape(match.group(2))106        parsed = urlsplit(url)107        if parsed.scheme or parsed.netloc or url.startswith('#'):108            return match.group(0)109        path = (source.parent / parsed.path).resolve()110        if str(path) in DOC_MAP:111            destination = DOC_MAP[str(path)] + '.html'112        elif path.name == 'hardware_box_pico.uf2':113            destination = '../downloads/hardware_box_pico.uf2'114        elif path.name == 'SHA256SUMS':115            destination = '../downloads/SHA256SUMS.txt'116        elif path == (PROJECT / 'docs/web-demo.jpg').resolve():117            destination = '../assets/operator.webp'118        elif path.is_relative_to(PROJECT.resolve()) and path.is_file():119            destination = raw_url(path)120        else:121            return match.group(0)122        if parsed.fragment:123            destination += '#' + parsed.fragment124        return f'{attribute}="{escape(destination)}"'125    content = re.sub(r'(href|src)="([^"]+)"', replace, content)126    return re.sub(r'(<table\b.*?</table>)', r'<div class="table-scroll">\1</div>', content, flags=re.S)127128129def build_docs() -> None:130    (OUTPUT / 'docs').mkdir(parents=True, exist_ok=True)131    cards = []132    for index, (name, title, relative, description) in enumerate(DOCS, 1):133        source = PROJECT / relative134        text = source.read_text()135        if source.suffix == '.txt':136            body = '<pre style="white-space:pre-wrap">' + escape(text) + '</pre>'137        else:138            # The source files are authored project documentation; only their own HTML is rendered.139            body = markdown.markdown(text, extensions=['tables', 'fenced_code', 'toc', 'sane_lists'])140            body = rewrite_doc_links(body, source)141            body = re.sub(r'<h1\b[^>]*>.*?</h1>', '', body, count=1, flags=re.S)142        (OUTPUT / 'raw' / relative).parent.mkdir(parents=True, exist_ok=True)143        (OUTPUT / 'raw' / (relative + '.txt')).write_text(text)144        document = f'''<div class="wrap"><header class="document-header"><p class="breadcrumb"><a href="../">OpenAss</a> / <a href="./">Dokumentation</a> / {escape(title)}</p><p class="eyebrow">LINK / BAUUNTERLAGEN</p><h1>{escape(title)}</h1><p class="intro">{escape(description)}</p><div class="doc-actions"><a href="{raw_url(source)}" download>Originaldatei herunterladen ↓</a><a href="../downloads/openass-documentation.zip" download>Alle Bauunterlagen als ZIP ↓</a></div></header><div class="document-layout">{doc_sidebar(name)}<article class="document-body">{body}</article></div></div>'''145        (OUTPUT / 'docs' / f'{name}.html').write_text(page(title, description, document, 'docs/' + name + '.html'))146        cards.append(f'<a class="doc-card" href="{name}.html"><span>{index:02} / BAUUNTERLAGEN</span><h3>{escape(title)}</h3><p>{escape(description)}</p><i aria-hidden="true">↗</i></a>')147    overview = f'''<div class="wrap"><header class="document-header"><p class="breadcrumb"><a href="../">OpenAss</a> / Dokumentation</p><p class="eyebrow">WISSEN & AUFBAU</p><h1>Das Projekt verstehen.<br>Den Aufbau planen.</h1><p class="intro">Alle Bauunterlagen direkt im Browser. Die Prüfberichte unterscheiden den getesteten Softwarestand von der noch offenen physischen Abnahme.</p><div class="doc-actions"><a href="../downloads/openass-documentation.zip" download>Vollständige Dokumentation herunterladen ↓</a><a href="../source/">Quellcode entdecken ↗</a></div></header><div class="docs-overview"><div class="doc-grid">{''.join(cards)}</div></div></div>'''148    (OUTPUT / 'docs/index.html').write_text(page('Dokumentation', 'Vollständige Bauunterlagen für die LINK Hardware Box.', overview, 'docs/'))149150151def build_sources() -> int:152    (OUTPUT / 'source/files').mkdir(parents=True, exist_ok=True)153    items = []154    for path in source_paths():155        relative = path.relative_to(PROJECT).as_posix()156        code = path.read_text()157        raw = OUTPUT / 'raw' / (relative + '.txt')158        raw.parent.mkdir(parents=True, exist_ok=True)159        raw.write_text(code)160        filename = relative.replace('/', '--') + '.html'161        if filename.startswith('.'):162            # Public HTML names must pass the server's hidden-file guard.163            (OUTPUT / 'source/files' / filename).unlink(missing_ok=True)164            filename = 'file-' + filename165        lines = ''.join(f'<span class="code-line" id="L{number}"><a href="#L{number}" class="line-no" aria-label="Zeile {number}">{number}</a>{escape(line)}</span>' for number, line in enumerate(code.splitlines(), 1))166        body = f'''<div class="wrap"><header class="document-header"><p class="breadcrumb"><a href="../../">OpenAss</a> / <a href="../">Quellcode</a></p><p class="eyebrow">QUELLCODE / {escape(path.suffix.upper().lstrip('.') or 'TEXT')}</p><h1>{escape(path.name)}</h1><p class="intro">{escape(relative)}</p><div class="doc-actions"><a href="{raw_url(path, '../../')}" download>Quelldatei herunterladen ↓</a><a href="../../downloads/openass-all-source.zip" download>Gesamten Quellcode herunterladen ↓</a></div></header><div class="source-content"><pre class="source-code"><code>{lines}</code></pre><p class="source-footer">Projektquellen des Referenzstands. Konfiguration und Gerätezugriff erfolgen lokal auf dem Raspberry Pi.</p></div></div>'''167        (OUTPUT / 'source/files' / filename).write_text(page(relative, f'Quellcode: {relative}', body, 'source/files/' + filename, '../../'))168        items.append(f'<a class="source-item" data-path="{escape(relative)}" href="files/{quote(filename)}"><span>{escape(relative)}</span><span class="source-size">{len(code.splitlines())} Zeilen ↗</span></a>')169    body = f'''<div class="wrap"><header class="document-header"><p class="breadcrumb"><a href="../">OpenAss</a> / Quellcode</p><p class="eyebrow">ALLE PROJEKTQUELLEN</p><h1>Ein Blick in die Box.<br>Bis zur letzten Zeile.</h1><p class="intro">Python-Host, lokaler Webleitstand, Pico-C-Firmware, Tests und Blender-Aufbauskripte. Die Projektseite selbst ist ebenfalls im Quellcodepaket enthalten.</p><div class="doc-actions"><a href="../downloads/openass-all-source.zip" download>Gesamten Quellcode herunterladen ↓</a><a href="../docs/">Dokumentation öffnen ↗</a></div></header><div class="source-toolbar"><span id="source-count" class="eyebrow">{len(items)} Quelldateien</span><label class="download-search"><input id="source-search" type="search" aria-label="Quelldateien durchsuchen" placeholder="Modul oder Datei finden"></label></div><div class="source-list">{''.join(items)}</div></div>'''170    (OUTPUT / 'source/index.html').write_text(page('Quellcode', 'Alle Quellen der Hardware Box direkt im Browser lesen.', body, 'source/'))171    return len(items)172173174def build_images() -> None:175    mappings = {'ivory': '01-ivory-hero.png', 'anthracite': '05-anthracite-hero.png', 'rear': '02-rear.png', 'open': '03-open.png', 'internals': '04-internals.png'}176    for name, original in mappings.items():177        with Image.open(PROJECT / 'design/v2/renders' / original) as image:178            image = image.convert('RGB')179            image.thumbnail((1600, 1600), Image.Resampling.LANCZOS)180            image.save(OUTPUT / 'assets' / f'{name}.webp', quality=86, method=6)181    with Image.open(PROJECT / 'docs/web-demo.jpg') as image:182        image.convert('RGB').save(OUTPUT / 'assets/operator.webp', quality=85)183    # A dedicated social card uses the actual product render and site branding.184    social = Image.new('RGB', (1200, 630), '#f5f4ef')185    with Image.open(PROJECT / 'design/v2/renders/01-ivory-hero.png') as image:186        image = image.convert('RGB').resize((810, 597), Image.Resampling.LANCZOS)187        social.paste(image, (470, 25))188    draw = ImageDraw.Draw(social)189    font_path = Path('/System/Library/Fonts/Helvetica.ttc')190    try:191        large = ImageFont.truetype(str(font_path), 58)192        medium = ImageFont.truetype(str(font_path), 26)193        small = ImageFont.truetype(str(font_path), 18)194    except OSError:195        large = ImageFont.load_default(size=55)196        medium = ImageFont.load_default(size=26)197        small = ImageFont.load_default(size=18)198    draw.text((48, 46), 'openass.', font=medium, fill='#23251f')199    draw.text((48, 166), 'Computer Use.', font=large, fill='#23251f')200    draw.text((48, 241), 'Zum Anstecken.', font=large, fill='#808574')201    draw.text((50, 365), 'LINK / HARDWARE BOX', font=small, fill='#73766b')202    draw.text((50, 425), 'Quellcode · Firmware · 3D-Druck', font=small, fill='#73766b')203    draw.text((50, 524), 'Privat & experimentell', font=small, fill='#7c5b35')204    draw.text((50, 553), 'Alles auf eigene Verantwortung.', font=small, fill='#7c5b35')205    social.save(OUTPUT / 'assets/social.jpg', quality=90)206207208def download_card(item: dict) -> str:209    format_name = Path(item['href']).suffix.upper().lstrip('.') or 'TXT'210    return f'''<article class="download-card" data-group="{escape(item['group'])}" data-filename="{escape(item['href'])}"><div class="download-card-top"><span class="file-format">{escape(format_name)}</span><span class="file-size">{size(item['bytes'])}</span></div><h3>{escape(item['title'])}</h3><p>{escape(item['description'])}</p><div class="download-card-bottom"><a class="download-link" href="{escape(item['href'])}" download aria-label="{escape(item['title'])} herunterladen">Herunterladen <span aria-hidden="true">↓</span></a><button class="checksum-button" data-sha256="{escape(item['sha256'])}" aria-label="SHA-256 für {escape(item['title'])} kopieren" title="SHA-256-Prüfsumme kopieren">SHA-256</button></div></article>'''211212213def build() -> None:214    # Missing notices must never silently produce a public release without them.215    disclaimer_text = DISCLAIMER.read_text(encoding='utf-8')216    if not disclaimer_text.strip():217        raise ValueError('The canonical project disclaimer is empty.')218    if OUTPUT.is_symlink():219        raise ValueError('Refusing to replace a symlinked public output.')220    if OUTPUT.exists():221        shutil.rmtree(OUTPUT)222    OUTPUT.mkdir(exist_ok=True)223    assets = OUTPUT / 'assets'224    assets.mkdir(exist_ok=True)225    shutil.copytree(SITE / 'assets/vendor', assets / 'vendor', dirs_exist_ok=True)226    for name in ['styles.css', 'app.js']:227        shutil.copy2(SITE / name, OUTPUT / name)228    shutil.copytree(SITE / 'downloads', OUTPUT / 'downloads', dirs_exist_ok=True)229    (OUTPUT / 'data').mkdir(exist_ok=True)230    shutil.copy2(SITE / 'data/downloads.json', OUTPUT / 'data/downloads.json')231    (assets / 'favicon.svg').write_text('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="15" fill="#242921"/><circle cx="28" cy="32" r="14" fill="none" stroke="#e9eddf" stroke-width="8"/><circle cx="49" cy="45" r="5" fill="#c9e5a9"/></svg>')232    build_images()233    downloads = json.loads((SITE / 'data/downloads.json').read_text())['downloads']234    featured = next(item for item in downloads if item['id'] == 'complete')235    feature_html = f'''<article class="featured-download"><div><span class="featured-label">ALLES IN EINEM PAKET</span><h3>OpenAss / LINK — das Gesamtprojekt.</h3><p>Software, Firmware, Bauunterlagen, aktuelle Print Edition und Designarchiv.</p><div class="featured-meta"><span>ZIP · {size(featured['bytes'])}</span><span>{featured['count']} Dateien</span><span>Stand 09.10.2026</span></div></div><a class="button" href="{featured['href']}" download>Projekt herunterladen <span aria-hidden="true">↓</span></a></article>'''236    source = (SITE / 'index.html').read_text()237    source = source.replace('<!-- FEATURED_DOWNLOAD -->', feature_html).replace('<!-- DOWNLOAD_CARDS -->', ''.join(download_card(item) for item in downloads if item['id'] != 'complete'))238    source = source.replace('<!-- USE_NOTICE -->', use_notice('./')).replace('<!-- DOWNLOAD_NOTICE -->', use_notice('./', compact=True)).replace('<!-- SITE_FOOTER -->', footer('./'))239    (OUTPUT / 'index.html').write_text(source)240    build_docs()241    source_count = build_sources()242    privacy = '''<div class="wrap"><header class="document-header"><p class="breadcrumb"><a href="./">OpenAss</a> / Datenschutz</p><h1>Datenschutz auf dieser Projektseite.</h1><p class="intro">Diese Seite stellt Bauunterlagen und Dateien zum Download bereit.</p></header><article class="document-body privacy-content"><h2>Statische Inhalte</h2><p>Die Projektseite setzt keine Analyse-Cookies und verwendet keine Besucheranalyse. Bilder, Schriften der Systemoberfläche und der 3D-Viewer werden lokal beziehungsweise vom eigenen Server geladen. Der 3D-Viewer lädt das Modell erst, wenn du „3D ansehen“ auswählst.</p><h2>Technische Auslieferung</h2><p>Bei einem Seitenaufruf verarbeitet der Server die für die Auslieferung notwendigen Verbindungsdaten. Technische Zugriffsprotokolle können unter anderem IP-Adresse, Zeitpunkt, angefragten Pfad und Browserkennung enthalten.</p><h2>Projektsoftware</h2><p>Diese öffentliche Website stellt keine Verbindung zu einer Hardware-Box her und nimmt keine Steueraufträge entgegen. Die herunterladbare Hostsoftware läuft separat auf deinem Raspberry Pi. Ihre Screenshot-Übertragung an OpenAI, lokale Freigabe und Protokollierung sind in den Bauunterlagen beschrieben.</p><h2>Externe Links</h2><p>Links zu Hersteller- und API-Unterlagen führen zu deren eigenen Websites. Dort gelten die jeweiligen Informationen zur Datenverarbeitung.</p><h2>Bibliothek des 3D-Viewers</h2><p>Model Viewer 4.3.1 von Google und Mitwirkenden, Apache License 2.0. <a href="assets/vendor/model-viewer.LICENSE">Lizenztext</a>. Eine Projektlizenz für die Hardware-Box wurde bisher nicht festgelegt.</p></article></div>'''243    (OUTPUT / 'privacy.html').write_text(page('Datenschutz', 'Datenverarbeitung der statischen OpenAss-Projektseite.', privacy, 'privacy.html', './'))244    disclaimer_body = f'''<div class="wrap"><header class="document-header"><p class="breadcrumb"><a href="./">OpenAss</a> / Haftungshinweise</p><p class="eyebrow">NUR PRIVAT & EXPERIMENTELL</p><h1>Auf eigene Verantwortung.</h1><p class="intro">Diese Hinweise gelten für das gesamte OpenAss-Projekt: Inhalte, Bauunterlagen, Quellcode, Firmware, 3D-Modelle, Downloads und Demonstrationen.</p><div class="doc-actions"><a href="downloads/DISCLAIMER.txt" download>Haftungshinweise als Textdatei ↓</a><a href="docs/safety.html">Technische Grenzen und Stoppebenen ↗</a></div></header><article class="document-body privacy-content"><div class="legal-text">{escape(disclaimer_text)}</div></article></div>'''245    (OUTPUT / 'disclaimer.html').write_text(page('Haftungshinweise', USE_SUMMARY, disclaimer_body, 'disclaimer.html', './'))246    not_found = '''<div class="wrap"><div class="privacy-content"><p class="eyebrow">404 / NICHT GEFUNDEN</p><h2>Hier liegt gerade<br>keine Projektdatei.</h2><p>Zur Projektübersicht oder den vollständigen Downloads zurückkehren.</p><div class="hero-actions"><a class="button button-dark" href="/">Zur Projektseite ↗</a><a class="text-link" href="/#downloads">Downloads ↓</a></div></div></div>'''247    (OUTPUT / '404.html').write_text(page('Seite nicht gefunden', 'Diese Datei wurde nicht gefunden.', not_found, '404.html', '/'))248    routes = ['', 'docs/', 'source/', 'privacy.html', 'disclaimer.html'] + ['docs/' + name + '.html' for name, *_ in DOCS]249    (OUTPUT / 'sitemap.xml').write_text('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + ''.join(f'<url><loc>{DOMAIN}/{route}</loc><lastmod>2026-10-09</lastmod></url>' for route in routes) + '</urlset>')250    (OUTPUT / 'robots.txt').write_text(f'User-agent: *\nAllow: /\nDisallow: /raw/\nSitemap: {DOMAIN}/sitemap.xml\n')251    (OUTPUT / 'release.json').write_text(json.dumps({'release': '2026-10-09', 'project': 'OpenAss / LINK', 'downloads': len(downloads), 'sourceFiles': source_count, 'documentationPages': len(DOCS)}, ensure_ascii=False, indent=2))252    print(f'Privacy guard passed: {check_tree(OUTPUT)} public files, including ZIP payloads.')253    print(f'Static site built: {len(downloads)} downloads, {source_count} source files, {len(DOCS)} documentation pages.')254255256if __name__ == '__main__':257    build()