privatecoffee-website/main.py
Kumi 5a0aed1730
chore: Removes markdown exclusion from asset copying
Simplifies asset copying logic to include all file types.
Replaces "Recur" with "Recurse" in comments for clarity.
2025-02-02 14:12:00 +01:00

482 lines
16 KiB
Python

import json
import pathlib
import datetime
import shutil
import math
import os
import logging
from http.server import SimpleHTTPRequestHandler
from socketserver import TCPServer
from threading import Thread
from argparse import ArgumentParser
import yaml
import markdown2
from PIL import Image
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from helpers.finances import (
generate_transparency_table,
get_transparency_data,
get_latest_month,
)
class StaticPageHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory="build", **kwargs)
# Configure logging
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
)
# Configure Jinja2 environment
env = Environment(loader=FileSystemLoader("templates"))
# Set up the output directory for static files
output_dir = pathlib.Path("build")
output_dir.mkdir(exist_ok=True, parents=True)
# Define the icon filter
def icon(icon_name):
icon_path = pathlib.Path("assets") / f"dist/icons/{icon_name}.svg"
try:
with open(icon_path, "r", encoding="utf-8") as file:
file_content = file.read()
except FileNotFoundError:
file_content = ""
return file_content
env.filters["icon"] = icon
# Filter for rendering a month name from a number
def month_name(month_number):
return datetime.date(1900, int(month_number), 1).strftime("%B")
env.filters["month_name"] = month_name
def render_template_to_file(template_name, output_name, **kwargs):
try:
template = env.get_template(template_name)
output_path = output_dir / output_name
kwargs.setdefault("theme", "plain")
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(template.render(**kwargs))
except TemplateNotFound:
logging.error(f"Template {template_name} not found.")
def create_thumbnail(input_image_path, output_image_path, size=(150, 150)):
with Image.open(input_image_path) as img:
img.thumbnail(size)
img.save(output_image_path)
def calculate_relative_path(depth):
return "../" * depth
def copy_assets(src_dir, dest_dir):
for item in src_dir.iterdir():
if item.is_dir():
# Recurse for subdirectories
item_dest_dir = dest_dir / item.name
item_dest_dir.mkdir(parents=True, exist_ok=True)
copy_assets(item, item_dest_dir)
elif item.is_file():
shutil.copy(item, dest_dir)
def parse_markdown_file(filepath):
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# Split the front matter and markdown content
parts = content.split("---", 2)
if len(parts) == 3:
_, fm_text, md_content = parts
front_matter = yaml.safe_load(fm_text)
else:
front_matter, md_content = {}, content
return front_matter, md_content
def generate_blog_html(template_kwargs={}, posts_per_page=5):
blog_dir = pathlib.Path("blog")
blog_posts = []
blog_tags = {}
for post_dir in blog_dir.iterdir():
if post_dir.is_dir():
md_path = post_dir / "index.md"
if md_path.exists():
front_matter, md_content = parse_markdown_file(md_path)
html_content = markdown2.markdown(md_content)
# Only process future posts in development mode
if front_matter.get("date"):
if isinstance(front_matter["date"], str):
post_date = datetime.datetime.strptime(
front_matter["date"], "%Y-%m-%d %H:%M:%S"
)
else:
post_date = front_matter["date"]
front_matter["date"] = post_date.strftime("%Y-%m-%d %H:%M:%S")
if post_date > datetime.datetime.now():
if not args.dev:
logging.info(f"Skipping future post: {post_dir.name}")
continue
front_matter["date"] = front_matter["date"] + " (future)"
front_matter["content"] = html_content
front_matter["slug"] = post_dir.name
# Add post to relevant tag lists
if "tags" in front_matter:
for tag in front_matter["tags"].split(","):
tag = tag.strip()
if tag not in blog_tags:
blog_tags[tag] = []
blog_tags[tag].append(front_matter)
# Create excerpt if not present
if "excerpt" not in front_matter:
excerpt = html_content.split("</p>")[0]
front_matter["excerpt"] = excerpt
blog_posts.append(front_matter)
# Ensure the build directory structure exists
output_post_dir = output_dir / "blog" / post_dir.name
output_post_dir.mkdir(parents=True, exist_ok=True)
# Generate thumbnail if image is present
if "image" in front_matter:
original_image = post_dir / front_matter["image"]
thumbnail_image_name = f"thumb_{original_image.name}"
thumbnail_image = (
output_dir / "blog" / post_dir.name / thumbnail_image_name
)
create_thumbnail(original_image, thumbnail_image)
front_matter["thumbnail"] = thumbnail_image_name
# Copy non-markdown assets
copy_assets(post_dir, output_post_dir)
# Sort posts by date, descending
blog_posts.sort(key=lambda x: x.get("date", ""), reverse=True)
# Render each individual post
for post in blog_posts:
post.setdefault("license", "CC BY-SA 4.0")
post.setdefault(
"license-url", "https://creativecommons.org/licenses/by-sa/4.0/"
)
post.setdefault("author", "Private.coffee Team")
post.setdefault("author-url", "https://private.coffee")
post["tags"] = [tag.strip() for tag in post.get("tags", "").split(",") if tag]
post_slug = post["slug"]
render_template_to_file(
"blog/post.html",
f"blog/{post_slug}/index.html",
**{**post, "relative_path": calculate_relative_path(2)},
**template_kwargs,
)
# Add tags to template kwargs
template_kwargs["tags"] = blog_tags.keys()
# Generate each index page
total_posts = len(blog_posts)
total_pages = math.ceil(total_posts / posts_per_page)
for page in range(total_pages):
start = page * posts_per_page
end = start + posts_per_page
paginated_posts = blog_posts[start:end]
context = {
"posts": paginated_posts,
"current_page": page + 1,
"total_pages": total_pages,
"relative_path": calculate_relative_path(1 if page == 0 else 3),
**template_kwargs,
}
output_path = (
"blog/index.html" if page == 0 else f"blog/page/{page + 1}/index.html"
)
render_template_to_file("blog/index.html", output_path, **context)
if page == 0:
pathlib.Path("build/blog/page/1").mkdir(parents=True, exist_ok=True)
context["relative_path"] = calculate_relative_path(3)
render_template_to_file(
"blog/index.html", "blog/page/1/index.html", **context
)
# Generate tag pages
for tag, posts in blog_tags.items():
tag_posts = sorted(posts, key=lambda x: x.get("date", ""), reverse=True)
total_tag_posts = len(tag_posts)
total_tag_pages = math.ceil(total_tag_posts / posts_per_page)
for page in range(total_tag_pages):
start = page * posts_per_page
end = start + posts_per_page
paginated_posts = tag_posts[start:end]
context = {
"posts": paginated_posts,
"current_page": page + 1,
"total_pages": total_tag_pages,
"tag": tag,
"relative_path": calculate_relative_path(3),
**template_kwargs,
}
output_path = (
f"blog/tag/{tag}/index.html"
if page == 0
else f"blog/tag/{tag}/page/{page + 1}/index.html"
)
render_template_to_file("blog/index.html", output_path, **context)
if page == 0:
pathlib.Path(f"build/blog/tag/{tag}/page/1").mkdir(
parents=True, exist_ok=True
)
context["relative_path"] = calculate_relative_path(5)
render_template_to_file(
"blog/index.html", f"blog/tag/{tag}/page/1/index.html", **context
)
logging.info("Blog section generated successfully.")
def generate_blog_rss(development_mode=False):
blog_dir = pathlib.Path("blog")
blog_posts = []
for post_dir in blog_dir.iterdir():
if post_dir.is_dir():
md_path = post_dir / "index.md"
if md_path.exists():
front_matter, _ = parse_markdown_file(md_path)
# Ensure date is RFC 822 compliant
if "date" in front_matter:
if isinstance(front_matter["date"], str):
post_date = datetime.datetime.strptime(
front_matter["date"], "%Y-%m-%d %H:%M:%S"
)
else:
post_date = front_matter["date"]
if post_date.tzinfo is None:
post_date = post_date.astimezone()
front_matter["date"] = post_date.strftime(
"%a, %d %b %Y %H:%M:%S %z"
)
front_matter["link"] = (
f"https://{"dev." if development_mode else ""}private.coffee/blog/{post_dir.name}/"
)
blog_posts.append(front_matter)
blog_posts.sort(key=lambda x: x.get("date", ""), reverse=True)
context = {
"development_mode": development_mode,
"posts": blog_posts,
"current_time": datetime.datetime.now()
.astimezone()
.strftime("%a, %d %b %Y %H:%M:%S %z"),
}
render_template_to_file("blog/rss.xml", "blog/rss.xml", **context)
logging.info("RSS feed generated successfully.")
def generate_static_pages(development_mode=False, data={}, template_kwargs={}):
# Iterate over all templates in the templates directory
templates_path = pathlib.Path("templates")
for template_file in templates_path.glob("*.html"):
template_name = template_file.stem
context = template_kwargs.copy()
context["path"] = f"{template_name}.html" if template_name != "index" else ""
if template_name in ["index", "simple"]:
context.update({"services": data["services"]})
if template_name == "bridges":
context.update({"bridges": data["bridges"]})
if template_name.startswith("membership"):
allow_current = development_mode
finances_month, finances_year = get_latest_month(
data["finances"], allow_current
)
finances_period = datetime.date(finances_year, finances_month, 1)
finances_period_str = finances_period.strftime("%B %Y")
finances_table = generate_transparency_table(
get_transparency_data(
data["finances"], finances_year, finances_month, allow_current
)
)
context.update(
{
"finances": finances_table,
"finances_period": finances_period_str,
}
)
if template_name == "transparency":
finance_data = {}
for year in sorted(data["finances"].keys(), reverse=True):
for month in sorted(data["finances"][year].keys(), reverse=True):
if year not in finance_data:
finance_data[year] = {}
finance_data[year][month] = generate_transparency_table(
get_transparency_data(data["finances"], year, month, True)
)
context.update({"finances": finance_data})
render_template_to_file(
f"{template_name}.html", f"{template_name}.html", **context
)
logging.info("Static pages generated successfully.")
def generate_metrics(data):
balances = get_transparency_data(data["finances"], allow_current=True)[
"end_balance"
]
response = (
"# HELP privatecoffee_balance The balance of the private.coffee account\n"
)
response += "# TYPE privatecoffee_balance gauge\n"
for currency, balance in balances.items():
response += f'privatecoffee_balance{{currency="{currency}"}} {balance}\n'
metrics_path = output_dir / "metrics.txt"
with open(metrics_path, "w", encoding="utf-8") as f:
f.write(response)
logging.info("Metrics generated successfully.")
def generate_static_site(development_mode=False, theme="plain"):
# Common context
template_kwargs = {
"timestamp": int(datetime.datetime.now().timestamp()),
"theme": theme,
}
if development_mode:
template_kwargs.update(
{
"warning": env.get_template("prod-warning.html").render(),
}
)
data = {}
# Load services data
data["services"] = json.loads(
(pathlib.Path(__file__).parent / "data" / "services.json").read_text()
)
# Load finances data
data["finances"] = json.loads(
(pathlib.Path(__file__).parent / "data" / "finances.json").read_text()
)
# Load bridges data
data["bridges"] = json.loads(
(pathlib.Path(__file__).parent / "data" / "bridges.json").read_text()
)
# Generate static pages
generate_static_pages(development_mode, data, template_kwargs)
# Generate blog section
generate_blog_html(template_kwargs)
generate_blog_rss(development_mode)
# Generate metrics
generate_metrics(data)
# Copy static assets
for folder in ["assets", "data"]:
src = pathlib.Path(folder)
dst = output_dir / folder
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
logging.info("Static site generated successfully.")
if __name__ == "__main__":
parser = ArgumentParser(description="Generate the private.coffee static site.")
parser.add_argument("--dev", action="store_true", help="Enable development mode")
parser.add_argument(
"--serve", action="store_true", help="Serve the site after building"
)
parser.add_argument(
"--port", type=int, default=8000, help="Port to serve the site on"
)
parser.add_argument(
"--theme", type=str, default="plain", help="Theme to use for the site"
)
parser.add_argument("--debug", action="store_true", help="Enable debug output")
args = parser.parse_args()
if os.environ.get("PRIVATECOFFEE_DEV"):
args.dev = True
if os.environ.get("PRIVATECOFFEE_THEME"):
args.theme = os.environ["PRIVATECOFFEE_THEME"]
if os.environ.get("PRIVATECOFFEE_PORT"):
args.serve = True
args.port = int(os.environ["PRIVATECOFFEE_PORT"])
if os.environ.get("PRIVATECOFFEE_DEBUG"):
logging.getLogger().setLevel(logging.DEBUG)
generate_static_site(development_mode=args.dev, theme=args.theme)
if args.serve:
server = TCPServer(("", args.port), StaticPageHandler)
logging.info(f"Serving on http://localhost:{args.port}")
thread = Thread(target=server.serve_forever)
thread.start()
thread.join()