feat: convert Flask app to static site generator

Refactored the Flask application to generate a static site instead of running as a dynamic web app. Added a CI workflow to build and deploy the static site on pushes to the 'static' branch.

- Replaced Flask route handling with a function that generates HTML files using Jinja2 templates.
- Modified the argparse logic to trigger the static site generation.
- Updated the .gitignore file to exclude the build directory.
- Created a Forgejo Actions workflow to automate the build and deploy process.

This change improves performance and reduces server overhead by serving pre-rendered static content.
This commit is contained in:
Kumi 2024-07-01 09:56:04 +02:00
parent f9fa18a8c1
commit 61de9ec81b
Signed by: kumi
GPG key ID: ECBCC9082395383F
3 changed files with 139 additions and 109 deletions

View file

@ -0,0 +1,50 @@
name: Build and Deploy Static Site
on:
push:
branches:
- static
jobs:
build:
runs-on: node:20-bookworm
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies
run: |
apt update
apt install -y python3 python3-pip
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Generate static site
run: python main.py
- name: Deploy to pages branch
run: |
# Configure Git
git config --global user.name "Forgejo"
git config --global user.email "noreply@private.coffee"
# Move generated static site files to a temporary location
mv build ../static_site_temp
# Create a new orphan branch named 'pages'
git checkout --orphan pages
# Remove all files from the working directory
git rm -rf .
# Move the static site files back to the working directory
mv ../static_site_temp/* ./
mv ../static_site_temp/.* ./ 2>/dev/null || true
# Add and commit the static site files
git add .
git commit -m "Deploy static site"
# Force push to the 'pages' branch
git push origin pages --force

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
venv/ venv/
*.pyc *.pyc
__pycache__/ __pycache__/
build/

195
main.py
View file

@ -1,10 +1,9 @@
from flask import Flask, render_template, send_from_directory from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from jinja2 import TemplateNotFound
import json import json
import pathlib import pathlib
import os import os
import datetime import datetime
import shutil
from argparse import ArgumentParser from argparse import ArgumentParser
@ -14,99 +13,90 @@ from helpers.finances import (
get_latest_month, get_latest_month,
) )
app = Flask(__name__) # 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)
@app.route("/assets/<path:path>") # Define the icon filter
def send_assets(path): def icon(icon_name):
return send_from_directory("assets", path) icon_path = pathlib.Path('assets') / f"dist/icons/{icon_name}.svg"
@app.route("/", defaults={"path": "index"})
@app.route("/<path:path>.html")
def catch_all(path):
try: try:
kwargs = {} with open(icon_path, 'r', encoding='utf-8') as file:
file_content = file.read()
except FileNotFoundError:
file_content = ''
return file_content
if app.development_mode: env.filters['icon'] = icon
kwargs.update(
{
"warning": render_template("prod-warning.html"),
}
)
if path in (
"index",
"simple",
):
services = json.loads(
(pathlib.Path(__file__).parent / "data" / "services.json").read_text()
)
kwargs.update(
{
"services": services,
}
)
if path == "membership":
finances = json.loads(
(pathlib.Path(__file__).parent / "data" / "finances.json").read_text()
)
allow_current = app.development_mode
finances_month, finances_year = get_latest_month(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(
finances, finances_year, finances_month, allow_current
)
)
kwargs.update(
{
"finances": finances_table,
"finances_period": finances_period_str,
}
)
if path == "transparency":
finances = json.loads(
(pathlib.Path(__file__).parent / "data" / "finances.json").read_text()
)
finance_data = {}
for year in sorted(finances.keys(), reverse=True):
for month in sorted(finances[year].keys(), reverse=True):
if year not in finance_data:
finance_data[year] = {}
print(get_transparency_data(finances, year, month))
finance_data[year][month] = generate_transparency_table(
get_transparency_data(finances, year, month)
)
kwargs.update(
{
"finances": finance_data,
}
)
return render_template(f"{path}.html", **kwargs)
def render_template_to_file(template_name, output_name, **kwargs):
try:
template = env.get_template(template_name)
output_path = output_dir / output_name
with open(output_path, 'w', encoding='utf-8') as f:
f.write(template.render(**kwargs))
except TemplateNotFound: except TemplateNotFound:
return "404 Not Found", 404 print(f"Template {template_name} not found.")
def generate_static_site(development_mode=False):
# Common context
kwargs = {}
if development_mode:
kwargs.update(
{
"warning": env.get_template("prod-warning.html").render(),
}
)
@app.route("/metrics/") # Load services data
def metrics(): services = json.loads(
(pathlib.Path(__file__).parent / "data" / "services.json").read_text()
)
# Load finances data
finances = json.loads( finances = json.loads(
(pathlib.Path(__file__).parent / "data" / "finances.json").read_text() (pathlib.Path(__file__).parent / "data" / "finances.json").read_text()
) )
# 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 = kwargs.copy()
if template_name in ["index", "simple"]:
context.update({"services": services})
if template_name == "membership":
allow_current = development_mode
finances_month, finances_year = get_latest_month(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(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(finances.keys(), reverse=True):
for month in sorted(finances[year].keys(), reverse=True):
if year not in finance_data:
finance_data[year] = {}
finance_data[year][month] = generate_transparency_table(
get_transparency_data(finances, year, month)
)
context.update({"finances": finance_data})
render_template_to_file(f"{template_name}.html", f"{template_name}.html", **context)
# Generate metrics
balances = get_transparency_data(finances, allow_current=True)["end_balance"] balances = get_transparency_data(finances, allow_current=True)["end_balance"]
response = ( response = (
@ -117,33 +107,22 @@ def metrics():
for currency, balance in balances.items(): for currency, balance in balances.items():
response += f'privatecoffee_balance{{currency="{currency}"}} {balance}\n' response += f'privatecoffee_balance{{currency="{currency}"}} {balance}\n'
return response metrics_path = output_dir / "metrics.txt"
with open(metrics_path, 'w', encoding='utf-8') as f:
f.write(response)
# Copy static assets
assets_src = pathlib.Path('assets')
assets_dst = output_dir / 'assets'
if assets_dst.exists():
shutil.rmtree(assets_dst)
shutil.copytree(assets_src, assets_dst)
app.development_mode = False print("Static site generated successfully.")
if os.environ.get("PRIVATECOFFEE_DEV"):
app.development_mode = True
def icon(icon_name):
file = send_from_directory("assets", f"dist/icons/{icon_name}.svg")
try:
file_content = file.response.file.read().decode("utf-8")
except AttributeError:
file_content = file.response.read().decode("utf-8")
return file_content
app.add_template_filter(icon)
if __name__ == "__main__": if __name__ == "__main__":
parser = ArgumentParser(description="Run the private.coffee web server.") parser = ArgumentParser(description="Generate the private.coffee static site.")
parser.add_argument("--port", type=int, default=9810) parser.add_argument("--dev", action="store_true", help="Enable development mode")
parser.add_argument("--debug", action="store_true")
parser.add_argument("--dev", action="store_true")
args = parser.parse_args() args = parser.parse_args()
app.development_mode = args.dev or app.development_mode generate_static_site(development_mode=args.dev)
app.run(port=args.port, debug=args.debug)