Egne temaer i innholdsrepoet, temavalg i innstillinger

Temaer slås opp i content/themes/<navn>/ 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
Ole-Morten Duesund 2026-08-26 13:45:04 +02:00
commit 9eb375e339
7 changed files with 112 additions and 15 deletions

View file

@ -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/<navn>/ (følger med i git og backup).
SOAPBOX_THEME=green
# Lengste side på opplastede bilder (px). EXIF fjernes alltid.

View file

@ -93,9 +93,21 @@ verdiene og start. Siden bygges på nytt ved oppstart.
## Tema
Et tema er en mappe under `soapbox/themes/<navn>/` 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

View file

@ -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"])

View file

@ -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")

View file

@ -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/<navn>``
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")),
)

View file

@ -7,6 +7,12 @@
<label>Bloggens tittel <input name="title" value="{{ site.title }}" required></label>
<label>Beskrivelse <input name="description" value="{{ site.description }}"></label>
<label>Språk (lang-attributt) <input name="language" value="{{ site.language }}" size="5"></label>
<label>Tema
<select name="theme">
{% for t in themes %}<option value="{{ t }}"{% if t == cfg.theme %} selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
</label>
<p class="meta">Egne temaer legges i <code>{{ cfg.user_themes_dir }}/&lt;navn&gt;/</code> og følger med i git.</p>
<button type="submit" class="primary">Lagre</button>
</form>
@ -14,7 +20,7 @@
<dl class="meta">
<dt>Base-path</dt><dd><code>{{ cfg.base_path or '/' }}</code></dd>
<dt>Site-URL (for RSS)</dt><dd><code>{{ cfg.site_url }}</code></dd>
<dt>Tema</dt><dd><code>{{ cfg.theme }}</code></dd>
<dt>Tema</dt><dd><code>{{ cfg.theme }}</code> (standard fra miljø: <code>{{ cfg.default_theme }}</code>)</dd>
<dt>Innhold</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd>
</dl>
<form method="post" action="{{ url_for('admin.rebuild') }}">

View file

@ -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 '<article class="post">' in client.get("/p/").text