From 9eb375e339b40b817284b06a071feac49c301edc Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 26 Aug 2026 13:45:04 +0200 Subject: [PATCH] Egne temaer i innholdsrepoet, temavalg i innstillinger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temaer slås opp i content/themes// før de innebygde, slik at et eget tema følger med i git, backup og ved flytting – uten ny container. Et tema kan være delvis: manglende maler/filer arves fra green (Jinja ChoiceLoader, static kopieres lagvis). Valget lagres i site.toml; SOAPBOX_THEME er bare standardverdi. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR --- .env.example | 3 +- README.md | 18 ++++++++++-- soapbox/admin.py | 11 ++++++- soapbox/build.py | 11 +++---- soapbox/config.py | 42 ++++++++++++++++++++++++--- soapbox/templates/admin/settings.html | 8 ++++- tests/test_app.py | 34 ++++++++++++++++++++++ 7 files changed, 112 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index c75afd2..a700a8a 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,8 @@ SOAPBOX_SITE_URL=https://blog.domene.no # Sub-path bloggen ligger under bak Caddy, f.eks. /blog. Tom = rot. SOAPBOX_BASE_PATH= -# Tema: mappe under soapbox/themes/ +# Standardtema før noe er valgt i admin. Valget lagres i content/site.toml. +# Egne temaer: /data/content/themes// (følger med i git og backup). SOAPBOX_THEME=green # Lengste side på opplastede bilder (px). EXIF fjernes alltid. diff --git a/README.md b/README.md index 319634e..940d054 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,21 @@ verdiene og start. Siden bygges på nytt ved oppstart. ## Tema -Et tema er en mappe under `soapbox/themes//` med `templates/` (`base.html`, `index.html`, -`post.html`) og `static/` (kopieres til `/theme/`). Enkleste tilpasning: kopier `green`, endre -CSS-variablene øverst i `style.css`. Malene får `site`, `posts`/`post`, `html` og `rel(sti)`. +Aktivt tema velges under Innstillinger i admin og lagres i `site.toml` (følger med i git). +`SOAPBOX_THEME` er bare standardverdien før noe er valgt. + +**Lag et eget tema uten å bygge containeren på nytt:** legg det i innholdsrepoet, så følger det +med i git, backup og ved flytting. + +```sh +mkdir -p data/content/themes/skog/static +cp soapbox/themes/green/static/style.css data/content/themes/skog/static/ # rediger fargene +``` + +Et tema kan være **delvis**: bare filene som finnes overstyrer det innebygde `green`-temaet. +Vil du endre malene, legg `templates/base.html`, `index.html` eller `post.html` i temamappen. +Malene får `site`, `posts`/`post`, `html`, `source_url` og `rel(sti)` (relativ lenke fra +gjeldende side). `static/` havner under `/theme/`. ## Lisens diff --git a/soapbox/admin.py b/soapbox/admin.py index 40dd48a..9edb6f9 100644 --- a/soapbox/admin.py +++ b/soapbox/admin.py @@ -388,10 +388,14 @@ def password(): def settings(): cfg = _cfg() if request.method == "POST": + theme = request.form.get("theme", "") + if theme not in cfg.available_themes(): + abort(400) site = { "title": request.form.get("title", "").strip() or "Soapbox", "description": request.form.get("description", "").strip(), "language": request.form.get("language", "nb").strip() or "nb", + "theme": theme, } # JSON-strenger er gyldige TOML basic strings for alt vi skriver her. toml = "".join(f"{k} = {json.dumps(v, ensure_ascii=False)}\n" for k, v in site.items()) @@ -401,7 +405,12 @@ def settings(): build_site(cfg) flash("Innstillingene er lagret.", "ok") return redirect(url_for("admin.settings")) - return render_template("admin/settings.html", log=gitstore.log(cfg.content_dir), cfg=cfg) + return render_template( + "admin/settings.html", + log=gitstore.log(cfg.content_dir), + cfg=cfg, + themes=cfg.available_themes(), + ) @bp.route("/rebuild", methods=["POST"]) diff --git a/soapbox/build.py b/soapbox/build.py index 905a89d..de957e7 100644 --- a/soapbox/build.py +++ b/soapbox/build.py @@ -20,7 +20,7 @@ from email.utils import format_datetime from pathlib import Path from xml.sax.saxutils import escape -from jinja2 import Environment, FileSystemLoader, select_autoescape +from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape from .config import Config, load_site_settings from .content import IMAGE_EXT, Post, list_posts @@ -31,7 +31,7 @@ SOURCE_URL = "https://kode.naiv.no/olemd/soapbox" def _env(cfg: Config) -> Environment: env = Environment( - loader=FileSystemLoader(cfg.theme_dir / "templates"), + loader=ChoiceLoader([FileSystemLoader(d / "templates") for d in cfg.theme_dirs]), autoescape=select_autoescape(["html"]), ) env.filters["date"] = lambda d, fmt="%d.%m.%Y": d.strftime(fmt) @@ -92,9 +92,10 @@ def build_site(cfg: Config) -> int: _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") + # Statiske filer lagvis: innebygd først, eget tema oppå (overskriver). + for d in reversed(cfg.theme_dirs): + if (d / "static").is_dir(): + shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True) if out.exists(): old = out.with_name(out.name + ".old") diff --git a/soapbox/config.py b/soapbox/config.py index d4528b1..449c992 100644 --- a/soapbox/config.py +++ b/soapbox/config.py @@ -20,7 +20,7 @@ class Config: data_dir: Path base_path: str # "" eller "/blog" – aldri med avsluttende skråstrek site_url: str # absolutt URL til bloggens rot, brukes kun i feed.xml - theme: str + default_theme: str # fra SOAPBOX_THEME; site.toml kan overstyre secret_key: str max_image_px: int = 2000 site: dict = field(default_factory=dict) @@ -38,8 +38,42 @@ class Config: return self.data_dir / "soapbox.sqlite" @property - def theme_dir(self) -> Path: - return Path(__file__).parent / "themes" / self.theme + def theme(self) -> str: + """Aktivt tema: ``site.toml`` vinner (følger med i git), ellers miljøvariabelen.""" + name = self.site.get("theme") or self.default_theme + return name if name in self.available_themes() else "green" + + @property + def user_themes_dir(self) -> Path: + """Egne temaer ligger i innholdsrepoet, så de følger med i git og backup.""" + return self.content_dir / "themes" + + @property + def theme_dirs(self) -> list[Path]: + """Kataloger for aktivt tema, mest spesifikke først. + + Et eget tema kan være delvis: filer som finnes i ``content/themes/`` + overstyrer, alt annet hentes fra det innebygde ``green``-temaet. + """ + dirs = [ + self.user_themes_dir / self.theme, + BUILTIN_THEMES / self.theme, + BUILTIN_THEMES / "green", + ] + seen: list[Path] = [] + for d in dirs: + if d.is_dir() and d not in seen: + seen.append(d) + return seen + + def available_themes(self) -> list[str]: + names = {p.name for p in BUILTIN_THEMES.iterdir() if p.is_dir()} + if self.user_themes_dir.is_dir(): + names |= {p.name for p in self.user_themes_dir.iterdir() if p.is_dir()} + return sorted(names) + + +BUILTIN_THEMES = Path(__file__).parent / "themes" def _normalize_base_path(raw: str) -> str: @@ -73,7 +107,7 @@ def load_config(env: dict[str, str] | None = None) -> Config: data_dir=data_dir, base_path=base_path, site_url=site_url, - theme=env.get("SOAPBOX_THEME", "green"), + default_theme=env.get("SOAPBOX_THEME", "green"), secret_key=secret, max_image_px=int(env.get("SOAPBOX_MAX_IMAGE_PX", "2000")), ) diff --git a/soapbox/templates/admin/settings.html b/soapbox/templates/admin/settings.html index 1730d25..18b415c 100644 --- a/soapbox/templates/admin/settings.html +++ b/soapbox/templates/admin/settings.html @@ -7,6 +7,12 @@ + +

Egne temaer legges i {{ cfg.user_themes_dir }}/<navn>/ og følger med i git.

@@ -14,7 +20,7 @@
Base-path
{{ cfg.base_path or '/' }}
Site-URL (for RSS)
{{ cfg.site_url }}
-
Tema
{{ cfg.theme }}
+
Tema
{{ cfg.theme }} (standard fra miljø: {{ cfg.default_theme }})
Innhold
{{ cfg.content_dir }} (git-repo)
diff --git a/tests/test_app.py b/tests/test_app.py index d6e3e65..a6997c0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -190,3 +190,37 @@ def test_post_history(client, cfg): client.post("/admin/logout", data={"csrf": token}) login(client, "gjest1") assert client.get(f"/admin/history/{pid}").status_code == 403 + + +def test_user_theme_in_content_repo(client, cfg): + """Eget tema i content/themes/ overstyrer delvis og velges via innstillinger.""" + theme = cfg.content_dir / "themes" / "skog" + (theme / "static").mkdir(parents=True) + (theme / "static" / "style.css").write_text(":root{--bg:#000}") + (theme / "templates").mkdir() + (theme / "templates" / "index.html").write_text("SKOG {{ rel('theme/style.css') }}") + assert cfg.available_themes() == ["green", "skog"] + + login(client) + token = csrf(client) + assert ( + client.post( + "/admin/settings", data={"csrf": token, "title": "T", "theme": "../etc"} + ).status_code + == 400 + ) + r = client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "skog"}) + assert r.status_code == 302 + assert 'theme = "skog"' in (cfg.content_dir / "site.toml").read_text() + assert cfg.theme == "skog" + + assert client.get("/").text.startswith("SKOG theme/style.css") + assert client.get("/theme/style.css").text == ":root{--bg:#000}" + assert client.get("/theme/favicon.svg").status_code == 200 # arvet fra green + # post.html finnes ikke i skog → arves fra green + r = client.post("/admin/new", data={"csrf": token}) + pid = r.headers["Location"].split("/admin/edit/")[1] + client.post( + f"/admin/edit/{pid}", data={"csrf": token, "title": "P", "body": "x", "action": "publish"} + ) + assert '
' in client.get("/p/").text