Files
havelseiten/generator/havelseiten.py
2026-05-16 14:16:54 +02:00

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()