1693 lines
36 KiB
Python
1693 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import shutil
|
|
import os
|
|
import json
|
|
import re
|
|
import markdown
|
|
import struct
|
|
from urllib.parse import parse_qs, quote_plus, urlparse
|
|
|
|
import settings_config
|
|
import validate
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
CONTENT_DIR = settings_config.active_content_dir()
|
|
OUTPUT_DIR = ROOT_DIR / "ausgabe"
|
|
|
|
GENERATOR_DIR = ROOT_DIR / "generator"
|
|
|
|
TEMPLATE_DIR = GENERATOR_DIR / "templates"
|
|
STATIC_DIR = GENERATOR_DIR / "static"
|
|
COMPONENTS_FILE = GENERATOR_DIR / "components.json"
|
|
SETTINGS_FILE = GENERATOR_DIR / "settings.json"
|
|
SETTINGS_MARKDOWN_FILE = settings_config.active_settings_file() or settings_config.SETTINGS_MARKDOWN_FILE
|
|
|
|
MEDIA_DIR_NAME = "medien"
|
|
LEGACY_MEDIA_DIR_NAME = "assets"
|
|
GALLERY_DIR_NAME = "galerie"
|
|
LEGACY_GALLERY_DIR_NAME = "gallery"
|
|
FOOTER_DIR_NAME = "fusszeile"
|
|
LEGACY_FOOTER_DIR_NAME = "footer"
|
|
FOOTER_FILE_NAME = "fusszeile.md"
|
|
LEGACY_FOOTER_FILE_NAME = "footer.md"
|
|
|
|
IMAGE_EXTENSIONS = {
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".webp",
|
|
".svg"
|
|
}
|
|
|
|
VIDEO_EXTENSIONS = {
|
|
".mp4",
|
|
".webm",
|
|
".ogg",
|
|
".mov"
|
|
}
|
|
|
|
|
|
env = Environment(
|
|
loader=FileSystemLoader(TEMPLATE_DIR)
|
|
)
|
|
|
|
|
|
with open(COMPONENTS_FILE, encoding="utf-8") as f:
|
|
COMPONENTS = json.load(f)
|
|
|
|
|
|
DEFAULT_SETTINGS = settings_config.DEFAULT_SETTINGS
|
|
|
|
|
|
def merge_settings(
|
|
defaults: dict,
|
|
custom: dict
|
|
) -> dict:
|
|
merged = defaults.copy()
|
|
|
|
for key, value in custom.items():
|
|
if (
|
|
isinstance(value, dict)
|
|
and isinstance(merged.get(key), dict)
|
|
):
|
|
merged[key] = merge_settings(
|
|
merged[key],
|
|
value
|
|
)
|
|
else:
|
|
merged[key] = value
|
|
|
|
return merged
|
|
|
|
|
|
def load_settings() -> dict:
|
|
return settings_config.load_settings()
|
|
|
|
|
|
SETTINGS = DEFAULT_SETTINGS
|
|
|
|
|
|
def run_validation():
|
|
settings_config.format_settings_file()
|
|
messages, language = validate.collect_messages()
|
|
|
|
if not messages:
|
|
print(validate.tr(language, "ok"))
|
|
return
|
|
|
|
print(validate.tr(language, "found_header"))
|
|
|
|
for message in messages:
|
|
print(f"- {message}")
|
|
|
|
print(validate.tr(language, "found_footer"))
|
|
|
|
if validate.has_errors(messages):
|
|
raise SystemExit(1)
|
|
|
|
|
|
def normalize_component(name: str) -> str:
|
|
return COMPONENTS.get(name, name)
|
|
|
|
|
|
def parse_annotation(line: str) -> tuple[str, str | None]:
|
|
annotation = line.strip()[1:]
|
|
|
|
if ":" in annotation:
|
|
component, parameter = annotation.split(":", 1)
|
|
|
|
return (
|
|
normalize_component(component.strip()),
|
|
parameter.strip()
|
|
)
|
|
|
|
return (
|
|
normalize_component(annotation.strip()),
|
|
None
|
|
)
|
|
|
|
|
|
def parse_blocks(md_text: str) -> list[dict]:
|
|
blocks = []
|
|
|
|
current_type = "text"
|
|
current_parameter = None
|
|
current_lines = []
|
|
|
|
for line in md_text.splitlines():
|
|
|
|
if line.strip().startswith("@"):
|
|
|
|
if current_lines:
|
|
blocks.append({
|
|
"type": current_type,
|
|
"parameter": current_parameter,
|
|
"content": "\n".join(current_lines).strip()
|
|
})
|
|
|
|
current_type, current_parameter = parse_annotation(line)
|
|
|
|
current_lines = []
|
|
|
|
else:
|
|
current_lines.append(line)
|
|
|
|
if current_lines:
|
|
blocks.append({
|
|
"type": current_type,
|
|
"parameter": current_parameter,
|
|
"content": "\n".join(current_lines).strip()
|
|
})
|
|
|
|
return [
|
|
block for block in blocks
|
|
if block["content"]
|
|
]
|
|
|
|
|
|
def render_markdown(md_text: str) -> str:
|
|
return markdown.markdown(
|
|
md_text,
|
|
extensions=["extra"]
|
|
)
|
|
|
|
|
|
def render_inline_markdown(md_text: str) -> str:
|
|
html = render_markdown(md_text.strip())
|
|
match = re.fullmatch(r"<p>(.*)</p>", html, re.S)
|
|
|
|
return match.group(1) if match else html
|
|
|
|
|
|
def split_h2_blocks(md_text: str):
|
|
lines = md_text.splitlines()
|
|
|
|
title_lines = []
|
|
blocks = []
|
|
current = []
|
|
|
|
for line in lines:
|
|
|
|
if line.startswith("## "):
|
|
|
|
if current:
|
|
blocks.append(
|
|
"\n".join(current).strip()
|
|
)
|
|
|
|
current = [line]
|
|
|
|
else:
|
|
|
|
if current:
|
|
current.append(line)
|
|
|
|
else:
|
|
title_lines.append(line)
|
|
|
|
if current:
|
|
blocks.append(
|
|
"\n".join(current).strip()
|
|
)
|
|
|
|
return (
|
|
"\n".join(title_lines).strip(),
|
|
blocks
|
|
)
|
|
|
|
|
|
def extract_images(md_text: str) -> list[dict]:
|
|
pattern = r"!\[(.*?)\]\((.*?)\)"
|
|
|
|
images = []
|
|
|
|
for alt, src in re.findall(pattern, md_text):
|
|
|
|
images.append({
|
|
"alt": alt.strip(),
|
|
"src": src.strip()
|
|
})
|
|
|
|
return images
|
|
|
|
|
|
def remove_markdown_images(md_text: str) -> str:
|
|
return re.sub(
|
|
r"!\[(.*?)\]\((.*?)\)",
|
|
"",
|
|
md_text
|
|
).strip()
|
|
|
|
|
|
def remove_first_image(md_text: str):
|
|
|
|
pattern = r"!\[(.*?)\]\((.*?)\)"
|
|
|
|
match = re.search(pattern, md_text)
|
|
|
|
if not match:
|
|
return None, md_text
|
|
|
|
image = {
|
|
"alt": match.group(1).strip(),
|
|
"src": match.group(2).strip()
|
|
}
|
|
|
|
remaining = (
|
|
md_text[:match.start()]
|
|
+ md_text[match.end():]
|
|
)
|
|
|
|
return image, remaining.strip()
|
|
|
|
|
|
def extract_links(md_text: str) -> list[dict]:
|
|
pattern = r"\[(.*?)\]\((.*?)\)"
|
|
|
|
links = []
|
|
|
|
for label, href in re.findall(pattern, md_text):
|
|
links.append({
|
|
"label": label.strip(),
|
|
"href": href.strip()
|
|
})
|
|
|
|
return links
|
|
|
|
|
|
def key_value_lines(md_text: str) -> tuple[dict, str]:
|
|
values = {}
|
|
remaining_lines = []
|
|
|
|
for line in md_text.splitlines():
|
|
match = re.match(r"^([A-Za-zÄÖÜäöüß _-]+):\s*(.+)$", line)
|
|
|
|
if match:
|
|
key = slugify_title(match.group(1)).replace("-", "_")
|
|
values[key] = match.group(2).strip()
|
|
else:
|
|
remaining_lines.append(line)
|
|
|
|
return values, "\n".join(remaining_lines).strip()
|
|
|
|
|
|
def href_from_output(
|
|
current_output_file: Path,
|
|
target_output_file: Path
|
|
) -> str:
|
|
|
|
current_dir = current_output_file.parent
|
|
|
|
relative_href = os.path.relpath(
|
|
target_output_file,
|
|
start=current_dir
|
|
)
|
|
|
|
return relative_href.replace("\\", "/")
|
|
|
|
|
|
def content_path_from_src(src: str) -> Path:
|
|
return CONTENT_DIR / src
|
|
|
|
|
|
def output_path_from_src(src: str) -> Path:
|
|
return OUTPUT_DIR / src
|
|
|
|
|
|
def jpeg_dimensions(file_path: Path) -> tuple[int, int] | None:
|
|
with file_path.open("rb") as f:
|
|
if f.read(2) != b"\xff\xd8":
|
|
return None
|
|
|
|
while True:
|
|
marker_start = f.read(1)
|
|
|
|
if not marker_start:
|
|
return None
|
|
|
|
if marker_start != b"\xff":
|
|
continue
|
|
|
|
marker = f.read(1)
|
|
|
|
while marker == b"\xff":
|
|
marker = f.read(1)
|
|
|
|
if marker in {
|
|
b"\xc0",
|
|
b"\xc1",
|
|
b"\xc2",
|
|
b"\xc3",
|
|
b"\xc5",
|
|
b"\xc6",
|
|
b"\xc7",
|
|
b"\xc9",
|
|
b"\xca",
|
|
b"\xcb",
|
|
b"\xcd",
|
|
b"\xce",
|
|
b"\xcf"
|
|
}:
|
|
f.read(3)
|
|
height, width = struct.unpack(">HH", f.read(4))
|
|
return width, height
|
|
|
|
segment_length_data = f.read(2)
|
|
|
|
if len(segment_length_data) != 2:
|
|
return None
|
|
|
|
segment_length = struct.unpack(
|
|
">H",
|
|
segment_length_data
|
|
)[0]
|
|
|
|
f.seek(segment_length - 2, os.SEEK_CUR)
|
|
|
|
|
|
def image_dimensions(file_path: Path) -> tuple[int, int] | None:
|
|
try:
|
|
with file_path.open("rb") as f:
|
|
header = f.read(24)
|
|
|
|
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
width, height = struct.unpack(">II", header[16:24])
|
|
return width, height
|
|
|
|
if header[:6] in {b"GIF87a", b"GIF89a"}:
|
|
width, height = struct.unpack("<HH", header[6:10])
|
|
return width, height
|
|
|
|
if header.startswith(b"\xff\xd8"):
|
|
return jpeg_dimensions(file_path)
|
|
|
|
except OSError:
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def fallback_image_size() -> dict:
|
|
return {
|
|
"width": 1600,
|
|
"height": 1000
|
|
}
|
|
|
|
|
|
def image_size_for_content_relative(src: str) -> dict:
|
|
src = src.split("?", 1)[0].split("#", 1)[0]
|
|
|
|
if is_external_url(src):
|
|
return fallback_image_size()
|
|
|
|
file_path = CONTENT_DIR / src
|
|
|
|
if not file_path.exists():
|
|
return fallback_image_size()
|
|
|
|
dimensions = image_dimensions(file_path)
|
|
|
|
if not dimensions:
|
|
return fallback_image_size()
|
|
|
|
width, height = dimensions
|
|
|
|
return {
|
|
"width": width,
|
|
"height": height
|
|
}
|
|
|
|
|
|
def is_external_url(src: str) -> bool:
|
|
return (
|
|
src.startswith("http://")
|
|
or src.startswith("https://")
|
|
)
|
|
|
|
|
|
def media_src_variants(src: str) -> list[str]:
|
|
variants = [src]
|
|
|
|
if src.startswith(f"{LEGACY_MEDIA_DIR_NAME}/"):
|
|
variants.append(
|
|
f"{MEDIA_DIR_NAME}/{src.removeprefix(f'{LEGACY_MEDIA_DIR_NAME}/')}"
|
|
)
|
|
|
|
variants = [
|
|
variant.replace(
|
|
f"{MEDIA_DIR_NAME}/{LEGACY_GALLERY_DIR_NAME}/",
|
|
f"{MEDIA_DIR_NAME}/{GALLERY_DIR_NAME}/"
|
|
)
|
|
for variant in variants
|
|
]
|
|
|
|
return list(dict.fromkeys(variants))
|
|
|
|
|
|
def find_asset_by_name(file_name: str) -> Path | None:
|
|
media_dirs = [
|
|
CONTENT_DIR / MEDIA_DIR_NAME,
|
|
CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
|
|
]
|
|
|
|
existing_dirs = [
|
|
media_dir for media_dir in media_dirs
|
|
if media_dir.exists()
|
|
]
|
|
|
|
if not existing_dirs:
|
|
return None
|
|
|
|
matches = []
|
|
|
|
for media_dir in existing_dirs:
|
|
matches.extend([
|
|
path for path in media_dir.rglob(file_name)
|
|
if path.is_file()
|
|
])
|
|
|
|
matches = sorted(matches)
|
|
|
|
if not matches:
|
|
return None
|
|
|
|
return matches[0]
|
|
|
|
|
|
def content_relative_from_src(
|
|
src: str,
|
|
source_file: Path | None = None
|
|
) -> str:
|
|
src = src.strip().strip("/")
|
|
|
|
candidates = []
|
|
|
|
for variant in media_src_variants(src):
|
|
|
|
if source_file:
|
|
candidates.append(source_file.parent / variant)
|
|
|
|
candidates.append(CONTENT_DIR / variant)
|
|
|
|
for candidate in candidates:
|
|
candidate = candidate.resolve()
|
|
|
|
if not candidate.exists():
|
|
continue
|
|
|
|
try:
|
|
return candidate.relative_to(CONTENT_DIR).as_posix()
|
|
except ValueError:
|
|
pass
|
|
|
|
fallback = find_asset_by_name(Path(src).name)
|
|
|
|
if fallback:
|
|
return fallback.relative_to(CONTENT_DIR).as_posix()
|
|
|
|
return src
|
|
|
|
|
|
def image_src_for_page(
|
|
src: str,
|
|
current_output_file: Path,
|
|
source_file: Path | None = None
|
|
) -> str:
|
|
|
|
content_relative = content_relative_from_src(
|
|
src,
|
|
source_file
|
|
)
|
|
|
|
target_output_file = output_path_from_src(
|
|
content_relative
|
|
)
|
|
|
|
return href_from_output(
|
|
current_output_file,
|
|
target_output_file
|
|
)
|
|
|
|
|
|
def collect_gallery_images(
|
|
folder_name: str,
|
|
current_output_file: Path
|
|
) -> list[dict]:
|
|
|
|
source_folder = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME / folder_name
|
|
|
|
if not source_folder.exists():
|
|
source_folder = (
|
|
CONTENT_DIR
|
|
/ LEGACY_MEDIA_DIR_NAME
|
|
/ LEGACY_GALLERY_DIR_NAME
|
|
/ folder_name
|
|
)
|
|
|
|
if not source_folder.exists():
|
|
return []
|
|
|
|
images = []
|
|
|
|
for file in sorted(source_folder.iterdir()):
|
|
|
|
if (
|
|
file.is_file()
|
|
and file.suffix.lower() in IMAGE_EXTENSIONS
|
|
):
|
|
|
|
content_relative = file.relative_to(
|
|
CONTENT_DIR
|
|
).as_posix()
|
|
|
|
image_src = image_src_for_page(
|
|
content_relative,
|
|
current_output_file
|
|
)
|
|
|
|
images.append({
|
|
"alt": "",
|
|
"src": image_src,
|
|
**image_size_for_content_relative(
|
|
content_relative
|
|
)
|
|
})
|
|
|
|
return images
|
|
|
|
|
|
def resolve_manual_image_paths(
|
|
images: list[dict],
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> list[dict]:
|
|
|
|
resolved_images = []
|
|
|
|
for image in images:
|
|
|
|
src = image["src"]
|
|
|
|
if is_external_url(src):
|
|
|
|
resolved_src = src
|
|
size = fallback_image_size()
|
|
|
|
else:
|
|
content_relative = content_relative_from_src(
|
|
src,
|
|
source_file
|
|
)
|
|
|
|
resolved_src = image_src_for_page(
|
|
content_relative,
|
|
current_output_file,
|
|
None
|
|
)
|
|
|
|
size = image_size_for_content_relative(
|
|
content_relative
|
|
)
|
|
|
|
resolved_images.append({
|
|
"alt": image["alt"],
|
|
"src": resolved_src,
|
|
**size
|
|
})
|
|
|
|
return resolved_images
|
|
|
|
|
|
def card_layout(parameter: str) -> str:
|
|
normalized = slugify_title(parameter or "")
|
|
|
|
return {
|
|
"einspaltig": "one",
|
|
"eine_spalte": "one",
|
|
"zweispaltig": "two",
|
|
"zwei_spalten": "two",
|
|
"dreispaltig": "three",
|
|
"drei_spalten": "three"
|
|
}.get(normalized, "one")
|
|
|
|
|
|
def render_cards(md_text: str, parameter: str = "") -> str:
|
|
|
|
title, cards = split_h2_blocks(md_text)
|
|
|
|
template = env.get_template("cards.html")
|
|
|
|
return template.render(
|
|
layout=card_layout(parameter),
|
|
title=render_markdown(title),
|
|
cards=[
|
|
render_markdown(card)
|
|
for card in cards
|
|
]
|
|
)
|
|
|
|
|
|
def render_two_columns(md_text: str) -> str:
|
|
|
|
title, columns = split_h2_blocks(md_text)
|
|
|
|
left = columns[0] if len(columns) > 0 else ""
|
|
right = columns[1] if len(columns) > 1 else ""
|
|
|
|
template = env.get_template("two-columns.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title),
|
|
left=render_markdown(left),
|
|
right=render_markdown(right)
|
|
)
|
|
|
|
|
|
def render_timeline(md_text: str) -> str:
|
|
|
|
title, items = split_h2_blocks(md_text)
|
|
|
|
template = env.get_template("timeline.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title),
|
|
items=[
|
|
render_markdown(item)
|
|
for item in items
|
|
]
|
|
)
|
|
|
|
|
|
def render_faq(md_text: str) -> str:
|
|
|
|
title, items = split_h2_blocks(md_text)
|
|
|
|
template = env.get_template("faq.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title),
|
|
items=[
|
|
render_markdown(item)
|
|
for item in items
|
|
]
|
|
)
|
|
|
|
|
|
def render_gallery(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path,
|
|
parameter: str | None
|
|
) -> str:
|
|
|
|
if parameter:
|
|
|
|
images = collect_gallery_images(
|
|
parameter,
|
|
current_output_file
|
|
)
|
|
|
|
title_text = md_text.strip()
|
|
|
|
else:
|
|
|
|
manual_images = extract_images(md_text)
|
|
|
|
images = resolve_manual_image_paths(
|
|
manual_images,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
title_text = remove_markdown_images(md_text)
|
|
|
|
template = env.get_template("gallery.html")
|
|
external_content_enabled = SETTINGS.get(
|
|
"privacy",
|
|
{}
|
|
).get("external_content", False)
|
|
has_external_images = any(
|
|
is_external_url(image["src"])
|
|
for image in images
|
|
)
|
|
|
|
return template.render(
|
|
title=render_markdown(title_text),
|
|
images=images,
|
|
gallery=SETTINGS.get("gallery", {}),
|
|
needs_external_consent=has_external_images and not external_content_enabled
|
|
)
|
|
|
|
|
|
def render_image_text(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> str:
|
|
|
|
image, remaining_text = remove_first_image(md_text)
|
|
|
|
if image:
|
|
|
|
image["src"] = image_src_for_page(
|
|
image["src"],
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
template = env.get_template("image-text.html")
|
|
|
|
return template.render(
|
|
image=image,
|
|
content=render_markdown(remaining_text)
|
|
)
|
|
|
|
|
|
def event_parts(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> dict:
|
|
values, remaining_text = key_value_lines(md_text)
|
|
image, remaining_text = remove_first_image(remaining_text)
|
|
|
|
if image:
|
|
image["src"] = image_src_for_page(
|
|
image["src"],
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
return {
|
|
"facts": {
|
|
key: render_inline_markdown(value)
|
|
for key, value in values.items()
|
|
},
|
|
"content": render_markdown(remaining_text),
|
|
"image": image
|
|
}
|
|
|
|
|
|
def render_event(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> str:
|
|
event = event_parts(md_text, current_output_file, source_file)
|
|
template = env.get_template("event.html")
|
|
|
|
return template.render(
|
|
values=event["facts"],
|
|
content=event["content"],
|
|
image=event["image"]
|
|
)
|
|
|
|
|
|
def render_events(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> str:
|
|
title, event_blocks = split_h2_blocks(md_text)
|
|
events = []
|
|
|
|
for event_block in event_blocks:
|
|
events.append(event_parts(
|
|
event_block,
|
|
current_output_file,
|
|
source_file
|
|
))
|
|
|
|
template = env.get_template("events.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title),
|
|
events=events
|
|
)
|
|
|
|
|
|
def render_downloads(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> str:
|
|
title_text = remove_markdown_images(md_text)
|
|
links = []
|
|
|
|
for link in extract_links(md_text):
|
|
href = link["href"]
|
|
|
|
if not is_external_url(href):
|
|
href = image_src_for_page(
|
|
href,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
links.append({
|
|
"label": link["label"],
|
|
"href": href
|
|
})
|
|
|
|
template = env.get_template("downloads.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(re.sub(
|
|
r"\[(.*?)\]\((.*?)\)",
|
|
"",
|
|
title_text
|
|
).strip()),
|
|
links=links
|
|
)
|
|
|
|
|
|
def render_people(md_text: str) -> str:
|
|
title, people = split_h2_blocks(md_text)
|
|
template = env.get_template("people.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title),
|
|
people=[
|
|
render_markdown(person)
|
|
for person in people
|
|
]
|
|
)
|
|
|
|
|
|
def render_person(md_text: str) -> str:
|
|
template = env.get_template("person.html")
|
|
|
|
return template.render(
|
|
content=render_markdown(md_text)
|
|
)
|
|
|
|
|
|
def render_sponsors(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> str:
|
|
images = resolve_manual_image_paths(
|
|
extract_images(md_text),
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
title_text = remove_markdown_images(md_text)
|
|
template = env.get_template("sponsors.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title_text),
|
|
sponsors=images
|
|
)
|
|
|
|
|
|
def youtube_video_id(url: str) -> str | None:
|
|
parsed = urlparse(url)
|
|
host = parsed.netloc.lower().replace("www.", "")
|
|
path_parts = [
|
|
part for part in parsed.path.split("/")
|
|
if part
|
|
]
|
|
|
|
if host == "youtu.be" and path_parts:
|
|
return path_parts[0]
|
|
|
|
if host.endswith("youtube.com"):
|
|
if parsed.path == "/watch":
|
|
return parse_qs(parsed.query).get("v", [None])[0]
|
|
|
|
if len(path_parts) >= 2 and path_parts[0] in {
|
|
"embed",
|
|
"shorts"
|
|
}:
|
|
return path_parts[1]
|
|
|
|
return None
|
|
|
|
|
|
def youtube_embed_url(url: str) -> str | None:
|
|
video_id = youtube_video_id(url)
|
|
|
|
if not video_id:
|
|
return None
|
|
|
|
return (
|
|
"https://www.youtube.com/embed/"
|
|
f"{video_id}"
|
|
"?autoplay=1"
|
|
"&mute=1"
|
|
"&loop=1"
|
|
f"&playlist={video_id}"
|
|
"&controls=0"
|
|
"&modestbranding=1"
|
|
"&playsinline=1"
|
|
)
|
|
|
|
|
|
def hero_media_from_parameter(
|
|
parameter: str | None,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> dict | None:
|
|
if not parameter:
|
|
return None
|
|
|
|
parameter = parameter.strip()
|
|
suffix = Path(urlparse(parameter).path).suffix.lower()
|
|
|
|
if is_external_url(parameter):
|
|
embed_url = youtube_embed_url(parameter)
|
|
|
|
if embed_url:
|
|
return {
|
|
"type": "youtube",
|
|
"src": embed_url,
|
|
"external_url": parameter,
|
|
"style": ""
|
|
}
|
|
|
|
if suffix in IMAGE_EXTENSIONS:
|
|
return {
|
|
"type": "image",
|
|
"src": parameter,
|
|
"style": ""
|
|
}
|
|
|
|
if suffix in VIDEO_EXTENSIONS:
|
|
return {
|
|
"type": "video",
|
|
"src": parameter,
|
|
"style": ""
|
|
}
|
|
|
|
if suffix in IMAGE_EXTENSIONS:
|
|
return {
|
|
"type": "image",
|
|
"src": image_src_for_page(
|
|
parameter,
|
|
current_output_file,
|
|
source_file
|
|
),
|
|
"style": ""
|
|
}
|
|
|
|
if suffix in VIDEO_EXTENSIONS:
|
|
return {
|
|
"type": "video",
|
|
"src": image_src_for_page(
|
|
parameter,
|
|
current_output_file,
|
|
source_file
|
|
),
|
|
"style": ""
|
|
}
|
|
|
|
return {
|
|
"type": "color",
|
|
"src": "",
|
|
"style": f"background: {parameter};"
|
|
}
|
|
|
|
|
|
def render_hero(
|
|
md_text: str,
|
|
current_output_file: Path,
|
|
source_file: Path,
|
|
parameter: str | None
|
|
) -> str:
|
|
template = env.get_template("hero.html")
|
|
|
|
return template.render(
|
|
content=render_markdown(md_text),
|
|
external_content_enabled=SETTINGS.get(
|
|
"privacy",
|
|
{}
|
|
).get("external_content", False),
|
|
hero=hero_media_from_parameter(
|
|
parameter,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
)
|
|
|
|
|
|
def osm_search_url(address: str) -> str:
|
|
return (
|
|
"https://www.openstreetmap.org/search?query="
|
|
f"{quote_plus(address)}"
|
|
)
|
|
|
|
|
|
def render_location(
|
|
md_text: str,
|
|
parameter: str | None
|
|
) -> str:
|
|
address = (parameter or "").strip()
|
|
content = md_text.strip()
|
|
|
|
if not address:
|
|
values, remaining_text = key_value_lines(md_text)
|
|
address = values.get("Adresse", values.get("adresse", "")).strip()
|
|
content = remaining_text
|
|
|
|
map_url = osm_search_url(address) if address else ""
|
|
template = env.get_template("location.html")
|
|
|
|
return template.render(
|
|
address=address,
|
|
content=render_markdown(content),
|
|
map_url=map_url,
|
|
external_content_enabled=SETTINGS.get(
|
|
"privacy",
|
|
{}
|
|
).get("external_content", False)
|
|
)
|
|
|
|
|
|
def render_footer(
|
|
md_text: str,
|
|
logo: dict | None = None,
|
|
social_links: list[dict] | None = None
|
|
) -> str:
|
|
|
|
title, columns = split_h2_blocks(md_text)
|
|
columns = [
|
|
column for column in columns
|
|
if not re.match(
|
|
r"(?im)^##\s*(social|soziales|social media)\s*$",
|
|
column.strip()
|
|
)
|
|
]
|
|
|
|
template = env.get_template("footer.html")
|
|
|
|
return template.render(
|
|
title=render_markdown(title),
|
|
logo=logo,
|
|
social_links=social_links or [],
|
|
columns=[
|
|
render_markdown(column)
|
|
for column in columns
|
|
]
|
|
)
|
|
|
|
|
|
def render_block(
|
|
block: dict,
|
|
current_output_file: Path,
|
|
source_file: Path
|
|
) -> str:
|
|
|
|
block_type = block["type"]
|
|
content = block["content"]
|
|
parameter = block["parameter"]
|
|
|
|
if block_type == "cards":
|
|
return render_cards(content, parameter)
|
|
|
|
if block_type == "two-columns":
|
|
return render_two_columns(content)
|
|
|
|
if block_type == "timeline":
|
|
return render_timeline(content)
|
|
|
|
if block_type == "faq":
|
|
return render_faq(content)
|
|
|
|
if block_type == "gallery":
|
|
return render_gallery(
|
|
content,
|
|
current_output_file,
|
|
source_file,
|
|
parameter
|
|
)
|
|
|
|
if block_type == "image-text":
|
|
return render_image_text(
|
|
content,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
if block_type == "event":
|
|
return render_event(
|
|
content,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
if block_type == "events":
|
|
return render_events(
|
|
content,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
if block_type == "downloads":
|
|
return render_downloads(
|
|
content,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
if block_type == "person":
|
|
return render_person(content)
|
|
|
|
if block_type == "people":
|
|
return render_people(content)
|
|
|
|
if block_type == "sponsors":
|
|
return render_sponsors(
|
|
content,
|
|
current_output_file,
|
|
source_file
|
|
)
|
|
|
|
if block_type == "location":
|
|
return render_location(
|
|
content,
|
|
parameter
|
|
)
|
|
|
|
if block_type == "hero":
|
|
return render_hero(
|
|
content,
|
|
current_output_file,
|
|
source_file,
|
|
parameter
|
|
)
|
|
|
|
template_name = f"{block_type}.html"
|
|
|
|
if not (TEMPLATE_DIR / template_name).exists():
|
|
template_name = "text.html"
|
|
|
|
template = env.get_template(template_name)
|
|
|
|
return template.render(
|
|
content=render_markdown(content)
|
|
)
|
|
|
|
|
|
def title_from_file(md_file: Path) -> str:
|
|
|
|
stem = display_name(md_file.stem)
|
|
|
|
if stem == "index":
|
|
|
|
if md_file.parent == CONTENT_DIR:
|
|
return "Start"
|
|
|
|
return display_name(md_file.parent.name).replace(
|
|
"-",
|
|
" "
|
|
).title()
|
|
|
|
return stem.replace(
|
|
"-",
|
|
" "
|
|
).title()
|
|
|
|
|
|
def display_name(name: str) -> str:
|
|
return re.sub(r"^\d+[_-]+", "", name)
|
|
|
|
|
|
def output_path_from_file(md_file: Path) -> Path:
|
|
|
|
relative = md_file.relative_to(CONTENT_DIR)
|
|
relative_parts = [
|
|
display_name(part)
|
|
for part in relative.parts
|
|
]
|
|
relative = Path(*relative_parts)
|
|
|
|
if display_name(md_file.stem) == "index":
|
|
return OUTPUT_DIR / relative.parent / "index.html"
|
|
|
|
return OUTPUT_DIR / relative.with_suffix(".html")
|
|
|
|
|
|
def is_footer_file(md_file: Path) -> bool:
|
|
|
|
relative = md_file.relative_to(CONTENT_DIR)
|
|
|
|
return (
|
|
len(relative.parts) >= 2
|
|
and relative.parts[0] in {FOOTER_DIR_NAME, LEGACY_FOOTER_DIR_NAME}
|
|
and md_file.name in {FOOTER_FILE_NAME, LEGACY_FOOTER_FILE_NAME}
|
|
)
|
|
|
|
|
|
def is_settings_file(md_file: Path) -> bool:
|
|
return md_file == SETTINGS_MARKDOWN_FILE
|
|
|
|
|
|
def static_path_from_output(output_file: Path) -> str:
|
|
|
|
return href_from_output(
|
|
output_file,
|
|
OUTPUT_DIR / "static" / "style.css"
|
|
)
|
|
|
|
|
|
def logo_for_page(
|
|
settings: dict,
|
|
current_output_file: Path
|
|
) -> dict:
|
|
logo = settings.get("logo", {}).copy()
|
|
|
|
logo_src = logo.get("src", "").strip().strip("/")
|
|
logo_folder = logo.get("folder", "logo").strip().strip("/") or "logo"
|
|
|
|
if not logo_src:
|
|
logo_dir = CONTENT_DIR / MEDIA_DIR_NAME / logo_folder
|
|
|
|
if not logo_dir.exists():
|
|
logo_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / logo_folder
|
|
|
|
logo_files = sorted([
|
|
file.name for file in logo_dir.iterdir()
|
|
if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS
|
|
]) if logo_dir.exists() else []
|
|
|
|
if logo_files:
|
|
logo_src = f"{MEDIA_DIR_NAME}/{logo_folder}/{logo_files[0]}"
|
|
|
|
if logo_src:
|
|
|
|
if "/" not in logo_src:
|
|
logo_src = f"{MEDIA_DIR_NAME}/{logo_folder}/{logo_src}"
|
|
|
|
logo["url"] = image_src_for_page(
|
|
logo_src,
|
|
current_output_file
|
|
)
|
|
else:
|
|
logo["url"] = ""
|
|
|
|
logo["home_url"] = href_from_output(
|
|
current_output_file,
|
|
OUTPUT_DIR / "index.html"
|
|
)
|
|
|
|
return logo
|
|
|
|
|
|
def social_for_page(settings: dict) -> list[dict]:
|
|
social = settings.get("social", {})
|
|
icon_style = settings.get("icons", {}).get("style", "round")
|
|
labels = {
|
|
"instagram": "Instagram",
|
|
"youtube": "YouTube",
|
|
"facebook": "Facebook",
|
|
"x": "X",
|
|
"twitter": "Twitter",
|
|
"linkedin": "LinkedIn"
|
|
}
|
|
icons = {
|
|
"instagram": "IG",
|
|
"youtube": "YT",
|
|
"facebook": "FB",
|
|
"x": "X",
|
|
"twitter": "TW",
|
|
"linkedin": "IN"
|
|
}
|
|
|
|
return [
|
|
{
|
|
"key": key,
|
|
"label": labels.get(key, key.title()),
|
|
"icon": (
|
|
labels.get(key, key.title())
|
|
if icon_style == "text"
|
|
else icons.get(key, key[:2].upper())
|
|
),
|
|
"url": url
|
|
}
|
|
for key, url in social.items()
|
|
if url
|
|
]
|
|
|
|
|
|
def slugify_title(title: str) -> str:
|
|
|
|
slug = title.strip().lower()
|
|
|
|
slug = slug.replace("ä", "ae")
|
|
slug = slug.replace("ö", "oe")
|
|
slug = slug.replace("ü", "ue")
|
|
slug = slug.replace("ß", "ss")
|
|
|
|
slug = re.sub(
|
|
r"[^a-z0-9]+",
|
|
"-",
|
|
slug
|
|
)
|
|
|
|
slug = slug.strip("-")
|
|
|
|
return slug
|
|
|
|
|
|
def build_page_index() -> dict:
|
|
|
|
page_index = {}
|
|
|
|
for md_file in CONTENT_DIR.rglob("*.md"):
|
|
|
|
if is_footer_file(md_file):
|
|
continue
|
|
|
|
if is_settings_file(md_file):
|
|
continue
|
|
|
|
key_from_filename = md_file.stem.lower()
|
|
|
|
key_from_title = slugify_title(
|
|
title_from_file(md_file)
|
|
)
|
|
|
|
target = output_path_from_file(md_file)
|
|
|
|
page_index[key_from_filename] = target
|
|
page_index[key_from_title] = target
|
|
|
|
return page_index
|
|
|
|
|
|
def resolve_wiki_links(
|
|
text: str,
|
|
current_output_file: Path,
|
|
page_index: dict
|
|
) -> str:
|
|
|
|
pattern = r"\[\[(.*?)\]\]"
|
|
|
|
def replacer(match):
|
|
|
|
label = match.group(1).strip()
|
|
|
|
key = slugify_title(label)
|
|
|
|
if key not in page_index:
|
|
return label
|
|
|
|
target_output = page_index[key]
|
|
|
|
href = href_from_output(
|
|
current_output_file,
|
|
target_output
|
|
)
|
|
|
|
return f"[{label}]({href})"
|
|
|
|
return re.sub(pattern, replacer, text)
|
|
|
|
|
|
def build_navigation_raw() -> list[dict]:
|
|
|
|
navigation = []
|
|
|
|
for md_file in sorted(CONTENT_DIR.glob("*.md")):
|
|
|
|
if md_file.name == FOOTER_FILE_NAME:
|
|
continue
|
|
|
|
if is_settings_file(md_file):
|
|
continue
|
|
|
|
navigation.append({
|
|
"type": "link",
|
|
"title": title_from_file(md_file),
|
|
"target": output_path_from_file(md_file)
|
|
})
|
|
|
|
for folder in sorted([
|
|
p for p in CONTENT_DIR.iterdir()
|
|
if p.is_dir()
|
|
]):
|
|
|
|
if folder.name == FOOTER_DIR_NAME:
|
|
continue
|
|
|
|
if folder.name in {MEDIA_DIR_NAME, LEGACY_MEDIA_DIR_NAME}:
|
|
continue
|
|
|
|
children = []
|
|
|
|
for md_file in sorted(folder.glob("*.md")):
|
|
|
|
children.append({
|
|
"title": title_from_file(md_file),
|
|
"target": output_path_from_file(md_file)
|
|
})
|
|
|
|
if children:
|
|
|
|
navigation.append({
|
|
"type": "dropdown",
|
|
"title": display_name(folder.name).replace(
|
|
"-",
|
|
" "
|
|
).title(),
|
|
"children": children
|
|
})
|
|
|
|
return navigation
|
|
|
|
|
|
def navigation_for_page(
|
|
navigation_raw: list[dict],
|
|
current_output_file: Path
|
|
) -> list[dict]:
|
|
|
|
navigation = []
|
|
|
|
for item in navigation_raw:
|
|
|
|
if item["type"] == "link":
|
|
|
|
navigation.append({
|
|
"type": "link",
|
|
"title": item["title"],
|
|
"url": href_from_output(
|
|
current_output_file,
|
|
item["target"]
|
|
)
|
|
})
|
|
|
|
elif item["type"] == "dropdown":
|
|
|
|
children = []
|
|
|
|
for child in item["children"]:
|
|
|
|
children.append({
|
|
"title": child["title"],
|
|
"url": href_from_output(
|
|
current_output_file,
|
|
child["target"]
|
|
)
|
|
})
|
|
|
|
navigation.append({
|
|
"type": "dropdown",
|
|
"title": item["title"],
|
|
"children": children
|
|
})
|
|
|
|
return navigation
|
|
|
|
|
|
def render_footer_for_page(
|
|
current_output_file: Path,
|
|
page_index: dict
|
|
) -> str:
|
|
|
|
footer_file = CONTENT_DIR / FOOTER_DIR_NAME / FOOTER_FILE_NAME
|
|
|
|
if not footer_file.exists():
|
|
footer_file = CONTENT_DIR / FOOTER_DIR_NAME / LEGACY_FOOTER_FILE_NAME
|
|
|
|
if not footer_file.exists():
|
|
footer_file = CONTENT_DIR / LEGACY_FOOTER_DIR_NAME / LEGACY_FOOTER_FILE_NAME
|
|
|
|
if not footer_file.exists():
|
|
return ""
|
|
|
|
footer_md = footer_file.read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
footer_md = resolve_wiki_links(
|
|
footer_md,
|
|
current_output_file,
|
|
page_index
|
|
)
|
|
|
|
return render_footer(
|
|
footer_md,
|
|
logo_for_page(
|
|
SETTINGS,
|
|
current_output_file
|
|
),
|
|
social_for_page(SETTINGS)
|
|
)
|
|
|
|
|
|
def build_page(
|
|
input_file: Path,
|
|
output_file: Path,
|
|
navigation_raw: list[dict],
|
|
page_index: dict
|
|
):
|
|
|
|
md_text = input_file.read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
md_text = resolve_wiki_links(
|
|
md_text,
|
|
output_file,
|
|
page_index
|
|
)
|
|
|
|
blocks = parse_blocks(md_text)
|
|
|
|
has_gallery = any(
|
|
block["type"] == "gallery"
|
|
for block in blocks
|
|
)
|
|
|
|
body = "\n".join([
|
|
render_block(
|
|
block,
|
|
output_file,
|
|
input_file
|
|
)
|
|
for block in blocks
|
|
])
|
|
|
|
footer = render_footer_for_page(
|
|
output_file,
|
|
page_index
|
|
)
|
|
|
|
html = env.get_template(
|
|
"base.html"
|
|
).render(
|
|
body=body,
|
|
footer=footer,
|
|
navigation=navigation_for_page(
|
|
navigation_raw,
|
|
output_file
|
|
),
|
|
static_path=static_path_from_output(output_file),
|
|
has_gallery=has_gallery,
|
|
settings=SETTINGS,
|
|
logo=logo_for_page(
|
|
SETTINGS,
|
|
output_file
|
|
)
|
|
)
|
|
|
|
output_file.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True
|
|
)
|
|
|
|
output_file.write_text(
|
|
html,
|
|
encoding="utf-8"
|
|
)
|
|
|
|
|
|
def copy_static():
|
|
|
|
target = OUTPUT_DIR / "static"
|
|
|
|
if target.exists():
|
|
shutil.rmtree(target)
|
|
|
|
shutil.copytree(
|
|
STATIC_DIR,
|
|
target
|
|
)
|
|
|
|
|
|
def copy_content_media():
|
|
|
|
source = CONTENT_DIR / MEDIA_DIR_NAME
|
|
target = OUTPUT_DIR / MEDIA_DIR_NAME
|
|
|
|
if not source.exists():
|
|
source = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
|
|
|
|
if not source.exists():
|
|
return
|
|
|
|
if target.exists():
|
|
shutil.rmtree(target)
|
|
|
|
shutil.copytree(
|
|
source,
|
|
target
|
|
)
|
|
|
|
|
|
def build_site():
|
|
global SETTINGS
|
|
|
|
run_validation()
|
|
SETTINGS = load_settings()
|
|
|
|
if OUTPUT_DIR.exists():
|
|
shutil.rmtree(OUTPUT_DIR)
|
|
|
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
|
|
|
copy_static()
|
|
copy_content_media()
|
|
|
|
navigation_raw = build_navigation_raw()
|
|
|
|
page_index = build_page_index()
|
|
|
|
for md_file in CONTENT_DIR.rglob("*.md"):
|
|
|
|
if is_footer_file(md_file):
|
|
continue
|
|
|
|
if is_settings_file(md_file):
|
|
continue
|
|
|
|
output_file = output_path_from_file(
|
|
md_file
|
|
)
|
|
|
|
build_page(
|
|
md_file,
|
|
output_file,
|
|
navigation_raw,
|
|
page_index
|
|
)
|
|
|
|
print(
|
|
f"gebaut: "
|
|
f"{md_file.relative_to(ROOT_DIR)} "
|
|
f"→ "
|
|
f"{output_file.relative_to(ROOT_DIR)}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
build_site()
|