soapbox/soapbox/build.py
Ole-Morten Duesund 7ab2320be6 Soapbox: statisk blogg med mobilvennlig markdown-editor
Flask-app som genererer statiske sider til public/ og serverer dem
sammen med et admin-grensesnitt (/admin). Innhold ligger som markdown
og bilder i et git-repo (data/content), brukere i sqlite.

- Editor med toolbar, live server-side forhåndsvisning, bildeopplasting
  via knapp, dra-og-slipp og lim inn. Bilder nedskaleres og EXIF fjernes.
- Hovedbruker publiserer under /<slug>/, gjester under /<bruker>/<slug>/.
  Slug lages fra tittelen og fryses ved publisering.
- Alle interne lenker er relative, så siden kan flyttes mellom domener og
  sub-paths (SOAPBOX_BASE_PATH) uten rebuild.
- Tema 'green' med CSS-variabler, mørk modus og WCAG-kontrast.
- Containerfile (python:alpine + git), compose.yaml og Caddyfile-eksempel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
2026-08-26 12:12:33 +02:00

134 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Statisk generering av bloggen til ``public/``.
Kjøres ved hver lagring i editoren (tar millisekunder for en vanlig blogg).
Utdata er rene filer som kan serveres av hva som helst appen selv, Caddy
eller en CDN.
Alle lenker i genererte sider er relative. Malene får en ``rel()``-funksjon
som regner ut stien fra *denne* siden til et mål, så ``rel("theme/style.css")``
blir ``theme/style.css`` på forsiden og ``../../theme/style.css`` i et
gjesteinnlegg. Dermed kan hele ``public/`` flyttes til hvilken som helst
sub-path uten rebuild. Unntaket er ``feed.xml``, der RSS krever absolutte
URL-er; den bruker ``SOAPBOX_SITE_URL``.
"""
from __future__ import annotations
import shutil
from datetime import UTC, datetime
from email.utils import format_datetime
from pathlib import Path
from xml.sax.saxutils import escape
from jinja2 import Environment, FileSystemLoader, select_autoescape
from .config import Config, load_site_settings
from .content import IMAGE_EXT, Post, list_posts
from .render import excerpt, relative_to, render_markdown
def _env(cfg: Config) -> Environment:
env = Environment(
loader=FileSystemLoader(cfg.theme_dir / "templates"),
autoescape=select_autoescape(["html"]),
)
env.filters["date"] = lambda d, fmt="%d.%m.%Y": d.strftime(fmt)
return env
def _write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def build_site(cfg: Config) -> int:
"""Genererer hele siden. Returnerer antall publiserte innlegg."""
site = load_site_settings(cfg)
env = _env(cfg)
posts = list_posts(cfg)
out = cfg.public_dir
# Bygg i en midlertidig katalog og bytt atomisk, så besøkende aldri ser
# en halvferdig side.
tmp = out.with_name(out.name + ".tmp")
if tmp.exists():
shutil.rmtree(tmp)
tmp.mkdir(parents=True)
def render(template: str, page_path: str, **ctx) -> str:
return env.get_template(template).render(
site=site,
page_path=page_path,
rel=lambda target: relative_to(page_path, target),
now=datetime.now(UTC),
**ctx,
)
guests: dict[str, list[Post]] = {}
for p in posts:
if p.guest:
guests.setdefault(p.guest, []).append(p)
# Forside: alle innlegg, også gjestenes, nyeste først
_write(tmp / "index.html", render("index.html", "", posts=posts, heading=None))
# Innlegg + bilder
for p in posts:
html = render_markdown(p.body, p.url_path)
_write(tmp / p.url_path / "index.html", render("post.html", p.url_path, post=p, html=html))
for f in p.path.parent.iterdir():
if f.suffix.lower() in IMAGE_EXT:
shutil.copy2(f, tmp / p.url_path / f.name)
# Én oversiktsside per gjest
for guest, gposts in guests.items():
_write(
tmp / guest / "index.html",
render("index.html", f"{guest}/", posts=gposts, heading=gposts[0].author or guest),
)
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
theme_static = cfg.theme_dir / "static"
if theme_static.exists():
shutil.copytree(theme_static, tmp / "theme")
if out.exists():
old = out.with_name(out.name + ".old")
if old.exists():
shutil.rmtree(old)
out.rename(old)
tmp.rename(out)
shutil.rmtree(old)
else:
tmp.rename(out)
return len(posts)
def _feed(cfg: Config, site: dict, posts: list[Post]) -> str:
base = cfg.site_url + "/"
items = []
for p in posts:
link = base + p.url_path
items.append(
"<item>"
f"<title>{escape(p.title)}</title>"
f"<link>{escape(link)}</link>"
f"<guid>{escape(link)}</guid>"
f"<pubDate>{format_datetime(p.date)}</pubDate>"
+ (f"<dc:creator>{escape(p.author)}</dc:creator>" if p.author else "")
+ f"<description>{escape(excerpt(p.body))}</description>"
"</item>"
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:atom="http://www.w3.org/2005/Atom"><channel>'
f"<title>{escape(site['title'])}</title>"
f"<link>{escape(base)}</link>"
f'<atom:link href="{escape(base)}feed.xml" rel="self" type="application/rss+xml"/>'
f"<description>{escape(site.get('description', ''))}</description>"
f"<language>{escape(site.get('language', 'nb'))}</language>"
+ "".join(items)
+ "</channel></rss>\n"
)