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.
|
# Sub-path bloggen ligger under bak Caddy, f.eks. /blog. Tom = rot.
|
||||||
SOAPBOX_BASE_PATH=
|
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
|
SOAPBOX_THEME=green
|
||||||
|
|
||||||
# Lengste side på opplastede bilder (px). EXIF fjernes alltid.
|
# 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
|
## Tema
|
||||||
|
|
||||||
Et tema er en mappe under `soapbox/themes/<navn>/` med `templates/` (`base.html`, `index.html`,
|
Aktivt tema velges under Innstillinger i admin og lagres i `site.toml` (følger med i git).
|
||||||
`post.html`) og `static/` (kopieres til `/theme/`). Enkleste tilpasning: kopier `green`, endre
|
`SOAPBOX_THEME` er bare standardverdien før noe er valgt.
|
||||||
CSS-variablene øverst i `style.css`. Malene får `site`, `posts`/`post`, `html` og `rel(sti)`.
|
|
||||||
|
**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
|
## Lisens
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -388,10 +388,14 @@ def password():
|
||||||
def settings():
|
def settings():
|
||||||
cfg = _cfg()
|
cfg = _cfg()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
theme = request.form.get("theme", "")
|
||||||
|
if theme not in cfg.available_themes():
|
||||||
|
abort(400)
|
||||||
site = {
|
site = {
|
||||||
"title": request.form.get("title", "").strip() or "Soapbox",
|
"title": request.form.get("title", "").strip() or "Soapbox",
|
||||||
"description": request.form.get("description", "").strip(),
|
"description": request.form.get("description", "").strip(),
|
||||||
"language": request.form.get("language", "nb").strip() or "nb",
|
"language": request.form.get("language", "nb").strip() or "nb",
|
||||||
|
"theme": theme,
|
||||||
}
|
}
|
||||||
# JSON-strenger er gyldige TOML basic strings for alt vi skriver her.
|
# 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())
|
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)
|
build_site(cfg)
|
||||||
flash("Innstillingene er lagret.", "ok")
|
flash("Innstillingene er lagret.", "ok")
|
||||||
return redirect(url_for("admin.settings"))
|
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"])
|
@bp.route("/rebuild", methods=["POST"])
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from email.utils import format_datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from xml.sax.saxutils import escape
|
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 .config import Config, load_site_settings
|
||||||
from .content import IMAGE_EXT, Post, list_posts
|
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:
|
def _env(cfg: Config) -> Environment:
|
||||||
env = Environment(
|
env = Environment(
|
||||||
loader=FileSystemLoader(cfg.theme_dir / "templates"),
|
loader=ChoiceLoader([FileSystemLoader(d / "templates") for d in cfg.theme_dirs]),
|
||||||
autoescape=select_autoescape(["html"]),
|
autoescape=select_autoescape(["html"]),
|
||||||
)
|
)
|
||||||
env.filters["date"] = lambda d, fmt="%d.%m.%Y": d.strftime(fmt)
|
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]))
|
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
|
||||||
|
|
||||||
theme_static = cfg.theme_dir / "static"
|
# Statiske filer lagvis: innebygd først, eget tema oppå (overskriver).
|
||||||
if theme_static.exists():
|
for d in reversed(cfg.theme_dirs):
|
||||||
shutil.copytree(theme_static, tmp / "theme")
|
if (d / "static").is_dir():
|
||||||
|
shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True)
|
||||||
|
|
||||||
if out.exists():
|
if out.exists():
|
||||||
old = out.with_name(out.name + ".old")
|
old = out.with_name(out.name + ".old")
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class Config:
|
||||||
data_dir: Path
|
data_dir: Path
|
||||||
base_path: str # "" eller "/blog" – aldri med avsluttende skråstrek
|
base_path: str # "" eller "/blog" – aldri med avsluttende skråstrek
|
||||||
site_url: str # absolutt URL til bloggens rot, brukes kun i feed.xml
|
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
|
secret_key: str
|
||||||
max_image_px: int = 2000
|
max_image_px: int = 2000
|
||||||
site: dict = field(default_factory=dict)
|
site: dict = field(default_factory=dict)
|
||||||
|
|
@ -38,8 +38,42 @@ class Config:
|
||||||
return self.data_dir / "soapbox.sqlite"
|
return self.data_dir / "soapbox.sqlite"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def theme_dir(self) -> Path:
|
def theme(self) -> str:
|
||||||
return Path(__file__).parent / "themes" / self.theme
|
"""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:
|
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,
|
data_dir=data_dir,
|
||||||
base_path=base_path,
|
base_path=base_path,
|
||||||
site_url=site_url,
|
site_url=site_url,
|
||||||
theme=env.get("SOAPBOX_THEME", "green"),
|
default_theme=env.get("SOAPBOX_THEME", "green"),
|
||||||
secret_key=secret,
|
secret_key=secret,
|
||||||
max_image_px=int(env.get("SOAPBOX_MAX_IMAGE_PX", "2000")),
|
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>Bloggens tittel <input name="title" value="{{ site.title }}" required></label>
|
||||||
<label>Beskrivelse <input name="description" value="{{ site.description }}"></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>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>
|
<button type="submit" class="primary">Lagre</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -14,7 +20,7 @@
|
||||||
<dl class="meta">
|
<dl class="meta">
|
||||||
<dt>Base-path</dt><dd><code>{{ cfg.base_path or '/' }}</code></dd>
|
<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>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>
|
<dt>Innhold</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form method="post" action="{{ url_for('admin.rebuild') }}">
|
<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})
|
client.post("/admin/logout", data={"csrf": token})
|
||||||
login(client, "gjest1")
|
login(client, "gjest1")
|
||||||
assert client.get(f"/admin/history/{pid}").status_code == 403
|
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