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:
parent
8d40a39187
commit
9eb375e339
7 changed files with 112 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }}/<navn>/</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') }}">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue