generate-menu.py
tools/generate-menu.py
#!/usr/bin/env python3
"""
Statisches Menü-HTML generieren für SSI-Include.
Quelle: git ls-files, ohne Entwickler-Material (siehe EXCLUDE_*).
"""
import json
import os
import re
import subprocess
from html import escape
from html.parser import HTMLParser
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUTPUT_FILE = os.path.join(ROOT_DIR, 'includes', 'something-in-the-body.html')
INDEX_FILE = os.path.join(ROOT_DIR, 'includes', 'search-index.json')
# Volltext-Index: sichtbarer Text der Seiten im Menue, geladen von meta.js beim
# ersten Tastendruck in der Suche. Je Datei auf INDEX_CAP Zeichen begrenzt.
INDEX_CAP = 20000
NON_TEXT_EXTS = {
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.mp3', '.mp4', '.webm',
'.wav', '.ogg', '.pdf', '.zip', '.woff', '.ttf', '.otf', '.php',
}
# Das Menue listet nur, was git verfolgt (git ls-files) und ein Besucher
# aufrufen kann. Entwickler-Material bleibt draussen.
EXCLUDE_DIRS = {
'00_Archiv', '__pycache__', 'includes',
'fonts', 'node_modules', 'test-results', 'profilbilder',
'tests', # Testsuiten und Mutationsskripte
'docs', # interne Specs, Plaene, Reviews
'tools', # Skripte, Vendor-Bibliotheken, Worker
'api', # Endpunkte, keine Seiten
'errorpages', # Fehlerseiten-Vorlagen
}
EXCLUDE_FILES = {'meta.json', 'robots.txt', 'AGENTS.md', 'CLAUDE.md'}
EXCLUDE_EXTS = {
'.js', '.css', '.py', '.sh', '.json', '.xml', '.sql', '.pdn', '.ico',
'.hmtl', '.idea', '.before-gitnexus', '.woff2', '.map', '.config',
}
# Gleiche Priority-Items wie in meta.js
PRIORITY_ITEMS = [
{'type': 'heading', 'text': 'legal stuff first:'},
{'type': 'file', 'path': 'impressum-und-datenschutz.html', 'name': 'impressum-und-datenschutz'},
{'type': 'heading', 'text': 'now the party:'},
{'type': 'file', 'path': 'README.html', 'name': 'README'},
]
EXCLUDE_FROM_LIST = {'impressum-und-datenschutz.html', 'README.html'}
def should_include(name):
if name.startswith('.'):
return False
if name.startswith('_'):
return False
if name in EXCLUDE_FILES:
return False
return True
def is_page(rel_path):
"""True, wenn der getrackte Pfad ins Menue gehoert."""
parts = rel_path.split('/')
if any(not should_include(p) for p in parts):
return False
if any(d in EXCLUDE_DIRS for d in parts[:-1]):
return False
return os.path.splitext(parts[-1])[1].lower() not in EXCLUDE_EXTS
def tracked_files():
"""Alle von git verfolgten Pfade (bricht laut ab, wenn git fehlt)."""
out = subprocess.run(
['git', 'ls-files', '-z'], cwd=ROOT_DIR, check=True,
stdout=subprocess.PIPE).stdout.decode('utf-8')
return [p for p in out.split('\0') if p]
def collect_structure():
"""Sammelt Dateien/Ordner in hierarchische Struktur."""
structure = {'folders': {}, 'files': []}
for rel_path in sorted(tracked_files(), key=lambda x: x.lower()):
if not is_page(rel_path):
continue
if not os.path.isfile(os.path.join(ROOT_DIR, rel_path)):
continue # getrackt, aber lokal geloescht
parts = rel_path.split('/')
filename = parts[-1]
current = structure
for folder in parts[:-1]:
current = current['folders'].setdefault(folder, {'folders': {}, 'files': []})
if len(parts) == 1 and filename in EXCLUDE_FROM_LIST:
continue
dot = filename.rfind('.')
display = filename[:dot] if dot > 0 else filename
current['files'].append({'name': filename, 'path': rel_path, 'display': display})
return structure
class _TextExtractor(HTMLParser):
SKIP = {'script', 'style', 'template', 'svg', 'noscript', 'canvas'}
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
self.skip = 0
def handle_starttag(self, tag, attrs):
if tag in self.SKIP:
self.skip += 1
self.parts.append(' ')
def handle_endtag(self, tag):
if tag in self.SKIP and self.skip:
self.skip -= 1
self.parts.append(' ')
def handle_data(self, data):
if not self.skip:
self.parts.append(data)
def visible_text(rel_path):
"""Sichtbarer Text einer Seite (None: keine Textdatei)."""
ext = os.path.splitext(rel_path)[1].lower()
if ext in NON_TEXT_EXTS:
return None
with open(os.path.join(ROOT_DIR, rel_path), 'rb') as fh:
raw = fh.read()
if b'\0' in raw[:4096]:
return None
text = raw.decode('utf-8', errors='replace')
if ext in ('.html', '.htm') or (not ext and text.lstrip()[:1] == '<'):
parser = _TextExtractor()
parser.feed(text)
parser.close()
text = ''.join(parser.parts)
return re.sub(r'\s+', ' ', text).strip()[:INDEX_CAP]
def build_search_index(structure):
"""{ '/pfad': text } fuer genau die Dateien, die im Menue stehen."""
index = {}
def walk(node):
for f in node['files']:
text = visible_text(f['path'])
if text is not None:
index['/' + f['path']] = text
for sub in node['folders'].values():
walk(sub)
walk(structure)
for item in PRIORITY_ITEMS:
if item['type'] == 'file':
text = visible_text(item['path'])
if text is not None:
index['/' + item['path']] = text
return index
def render_file(f):
href = '/' + f['path']
return f'<li><a href="{escape(href)}">{escape(f["name"])}</a></li>'
def render_folder(name, data):
parts = [f'<li><details><summary>{escape(name)}</summary><ul class="folder-contents">']
for subfolder_name in sorted(data['folders'].keys(), key=lambda x: x.lower()):
parts.append(render_folder(subfolder_name, data['folders'][subfolder_name]))
for f in sorted(data['files'], key=lambda x: x['name'].lower()):
parts.append(render_file(f))
parts.append('</ul></details></li>')
return ''.join(parts)
def generate():
structure = collect_structure()
lines = []
lines.append('<nav id="meta-nav" aria-label="Hauptnavigation">')
lines.append('<a href="/index.html" class="back-button" aria-label="Startseite">\u22C5</a>')
lines.append('<div class="menu">')
lines.append('<input type="checkbox" id="menu-toggle" class="menu-toggle-input">')
lines.append('<label for="menu-toggle" class="menu-backdrop" aria-hidden="true"></label>')
lines.append('<label for="menu-toggle" class="menu-button" aria-label="Men\u00fc"><span class="menu-closed-text">\u2261</span><span class="menu-open-text">0 \u2261 1 \u2261 \u221E</span></label>')
lines.append('<div class="menu-content-wrapper">')
lines.append('<div class="menu-search"><svg class="meta-loupe" viewBox="-79 -498 946 946" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false"><path class="meta-loupe-lens" d="M261 5Q198 5 148.5 -23.0Q99 -51 70.5 -99.0Q42 -147 42 -209Q42 -270 70.5 -317.5Q99 -365 148.0 -392.0Q197 -419 260 -419Q322 -419 371.5 -391.5Q421 -364 449.5 -316.5Q478 -269 478 -206Q478 -146 449.5 -97.5Q421 -49 372.5 -22.0Q324 5 261 5ZM261 -50Q304 -50 338.0 -70.0Q372 -90 391.0 -125.5Q410 -161 410 -206Q410 -251 391.0 -287.0Q372 -323 338.0 -344.0Q304 -365 260 -365Q217 -365 182.5 -345.0Q148 -325 128.0 -290.0Q108 -255 108 -209Q108 -164 128.0 -127.5Q148 -91 183.0 -70.5Q218 -50 261 -50Z"/><g class="meta-loupe-handle"><path d="M196 -414H243V0H175V-394L201 -360L59 -278L34 -322Z"/></g></svg><input type="search" placeholder="activate js or use browser search" disabled aria-label="Suche ben\u00f6tigt JavaScript"><span class="menu-search-stop" aria-hidden="true"></span></div>')
lines.append('<ul id="file-list">')
# Priority items
for item in PRIORITY_ITEMS:
if item['type'] == 'heading':
lines.append(f'<li><span class="menu-heading">{escape(item["text"])}</span></li>')
elif item['type'] == 'file':
href = '/' + item['path']
lines.append(f'<li><a href="{escape(href)}">{escape(item["name"])}</a></li>')
# Ordner
for folder_name in sorted(structure['folders'].keys(), key=lambda x: x.lower()):
lines.append(render_folder(folder_name, structure['folders'][folder_name]))
# Root-Dateien
for f in sorted(structure['files'], key=lambda x: x['name'].lower()):
lines.append(render_file(f))
lines.append('</ul>')
lines.append('</div>')
lines.append('</div>')
lines.append('</nav>')
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
with open(OUTPUT_FILE, 'w', encoding='utf-8') as out:
out.write(''.join(lines) + '\n')
index = build_search_index(structure)
with open(INDEX_FILE, 'w', encoding='utf-8') as out:
json.dump(index, out, ensure_ascii=False, separators=(',', ':'), sort_keys=True)
out.write('\n')
print(f'search-index.json — {len(index)} Dateien, {os.path.getsize(INDEX_FILE) // 1024} KB')
total = sum(1 for line in lines if '<li><a ' in line)
print(f'menu.html generiert — {total} Einträge')
if __name__ == '__main__':
generate()