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
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""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"
|
||
)
|