Tagger på innlegg med tagg-sider

Kommaseparert tagg-felt i editoren, lagret som liste i frontmatter
(dedup case-ufølsomt). /tag/<slug>/ lister alle innlegg med taggen,
også gjesters; /tag/ lister alle tagger med antall. Tagger vises som
piller på innlegg og i lister, som <category> i feeden og i sitemap.
«tag» er reservert som slug/brukernavn.

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 15:42:36 +02:00
commit f0fcb875c5
11 changed files with 109 additions and 6 deletions

View file

@ -27,6 +27,8 @@ data/
«Historikk»-seksjon i editoren med diff per endring; hele loggen ligger under Innstillinger
(eller `git -C data/content log`).
* **Gjester** ser og redigerer bare egne innlegg. Hovedbrukeren ser alt og administrerer gjester.
* **Tagger** settes kommaseparert i editoren. `/tag/<tagg>/` lister alle innlegg med taggen (også
gjesters), `/tag/` alle tagger. Tagger får slug på samme måte som titler.
* **Signatur**: innlegg avsluttes med «– Visningsnavn», evt. med e-post som mailto-lenke. Begge
settes under Profil i admin og slås opp ved bygging, så en endring gjelder alle innlegg.
@ -115,7 +117,7 @@ cp soapbox/themes/green/static/style.css data/content/themes/skog/static/ # re
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), samt filtrene `date` og `excerpt(n)`. `static/` havner under `/theme/`.
gjeldende side), samt filtrene `date`, `excerpt(n)` og `tagslug`. `static/` havner under `/theme/`.
## Lisens

View file

@ -44,6 +44,7 @@ from .content import (
existing_slugs,
get_post,
list_posts,
parse_tags,
post_dir,
save_post,
split_id,
@ -63,7 +64,7 @@ bp = Blueprint(
USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# Reserverte navn som ville kollidert med URL-strukturen
RESERVED = {"admin", "theme", "feed.xml", "posts", "guests", "index.html"}
RESERVED = {"admin", "theme", "tag", "feed.xml", "sitemap.xml", "posts", "guests", "index.html"}
def _cfg():
@ -261,6 +262,7 @@ def edit(post_id: str):
if request.method == "POST":
title = request.form.get("title", "").strip() or "Uten tittel"
body = request.form.get("body", "").replace("\r\n", "\n")
post.tags = parse_tags(request.form.get("tags", ""))
action = request.form.get("action", "save")
was_draft = post.draft

View file

@ -27,6 +27,7 @@ from . import db
from .config import Config, load_site_settings
from .content import IMAGE_EXT, Post, list_posts
from .render import excerpt, relative_to, render_markdown
from .slug import slugify
SOURCE_URL = "https://kode.naiv.no/olemd/soapbox"
@ -38,6 +39,7 @@ def _env(cfg: Config) -> Environment:
)
env.filters["date"] = lambda d, fmt="%d.%m.%Y": d.strftime(fmt)
env.filters["excerpt"] = excerpt
env.filters["tagslug"] = slugify
return env
@ -95,8 +97,27 @@ def build_site(cfg: Config) -> int:
render("index.html", f"{guest}/", posts=gposts, heading=gposts[0].author_name or guest),
)
# Tagger: /tag/<slug>/ per tag (alle brukeres innlegg), /tag/ som oversikt.
# Slug-en er nøkkel så «Skog» og «skog» havner på samme side.
tags: dict[str, dict] = {}
for p in posts:
for t in p.tags:
entry = tags.setdefault(slugify(t), {"name": t, "posts": []})
entry["posts"].append(p)
for tslug, entry in tags.items():
_write(
tmp / "tag" / tslug / "index.html",
render(
"index.html",
f"tag/{tslug}/",
posts=entry["posts"],
heading=f"Tagg: {entry['name']}",
),
)
_write(tmp / "tag" / "index.html", render("tags.html", "tag/", tags=tags))
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
_write(tmp / "sitemap.xml", _sitemap(cfg, posts, sorted(guests)))
_write(tmp / "sitemap.xml", _sitemap(cfg, posts, sorted(guests), sorted(tags)))
# Statiske filer lagvis: innebygd først, eget tema oppå (overskriver).
for d in reversed(cfg.theme_dirs):
@ -137,7 +158,7 @@ def _resolve_authors(cfg: Config, posts: list[Post]) -> None:
p.author_email = user.email if user else ""
def _sitemap(cfg: Config, posts: list[Post], guests: list[str]) -> str:
def _sitemap(cfg: Config, posts: list[Post], guests: list[str], tags: list[str]) -> str:
"""sitemap.xml med forsiden, alle innlegg og gjesteoversiktene."""
base = cfg.site_url + "/"
@ -150,6 +171,7 @@ def _sitemap(cfg: Config, posts: list[Post], guests: list[str]) -> str:
entries = [url("", newest)]
entries += [url(p.url_path, p.updated or p.date) for p in posts]
entries += [url(f"{g}/") for g in guests]
entries += [url(f"tag/{t}/") for t in tags]
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
@ -171,7 +193,8 @@ def _feed(cfg: Config, site: dict, posts: list[Post]) -> str:
f"<pubDate>{format_datetime(p.date)}</pubDate>"
+ (f"<dc:creator>{escape(p.author_name)}</dc:creator>" if p.author_name else "")
+ f"<description>{escape(excerpt(p.body))}</description>"
"</item>"
+ "".join(f"<category>{escape(t)}</category>" for t in p.tags)
+ "</item>"
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'

View file

@ -40,6 +40,7 @@ class Post:
author: str = "" # brukernavn (eldre innlegg kan ha visningsnavn her)
author_name: str = "" # visningsnavn, fylles ut ved bygging fra brukertabellen
author_email: str = ""
tags: list[str] = field(default_factory=list) # visningsform, f.eks. "Friluftsliv"
guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn
path: Path | None = None
images: list[str] = field(default_factory=list)
@ -107,6 +108,9 @@ def load_post(path: Path, guest: str | None) -> Post:
updated=_parse_dt(meta.get("updated")),
draft=bool(meta.get("draft", False)),
author=meta.get("author", ""),
tags=[t for t in meta.get("tags", []) if isinstance(t, str) and t.strip()]
if isinstance(meta.get("tags"), list)
else [],
guest=guest,
path=path,
images=images,
@ -153,6 +157,7 @@ def save_post(cfg: Config, post: Post) -> Path:
"date": post.date.isoformat(timespec="seconds"),
"updated": post.updated.isoformat(timespec="seconds") if post.updated else None,
"author": post.author,
"tags": post.tags or None,
"draft": True if post.draft else None,
}
path = d / "index.md"
@ -173,6 +178,16 @@ SLUG_RE = re.compile(r"^[\w-]+$") # samme alfabet som slugify() produserer
GUEST_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
def parse_tags(raw: str) -> list[str]:
"""Kommaseparert input → unike tagger, første forekomst vinner (case-ufølsomt)."""
seen: dict[str, str] = {}
for part in raw.split(","):
tag = " ".join(part.split())
if tag and tag.casefold() not in seen:
seen[tag.casefold()] = tag
return list(seen.values())
def split_id(post_id: str) -> tuple[str | None, str]:
"""``"_owner/slug"`` → (None, slug); ``"gjest/slug"`` → ("gjest", slug).

View file

@ -6,6 +6,8 @@
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row">
<label class="grow">Tittel <input name="title" value="{{ post.title }}" required autofocus></label>
<label class="grow">Tagger <span class="hint" id="tags-hint">Kommaseparert, f.eks. «friluftsliv, skog»</span>
<input name="tags" value="{{ post.tags|join(', ') }}" aria-describedby="tags-hint" autocapitalize="none"></label>
<span class="meta">
{% if post.draft %}<span class="badge">utkast</span> Publiseres på <code>{{ cfg.site_url }}/{{ post.url_path }}</code> (følger tittelen)
{% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">{{ cfg.site_url }}/{{ post.url_path }}</a>

View file

@ -82,6 +82,13 @@ h2 { font-size: 1.4rem; line-height: 1.25; margin: 2rem 0 .5rem; }
.summary .meta { margin: 0; }
.summary .excerpt { margin: .5rem 0 0; color: var(--fg); }
.body p, .body ul, .body ol { margin: 0 0 1.1rem; }
.tags { list-style: none; padding: 0; margin: .75rem 0 0; display: flex; flex-wrap: wrap; gap: .4rem; }
.tags a {
display: inline-block; padding: .2rem .6rem; border: 1px solid var(--border); border-radius: 999px;
background: var(--surface); text-decoration: none; font-size: .85rem; min-height: 32px; line-height: 1.6;
}
.tags a:hover, .tags a:focus-visible { border-color: var(--accent); }
.tag-cloud { margin-top: 1rem; } .tag-cloud a { font-size: 1rem; }
.signature { margin: 1.5rem 0 0; color: var(--muted); font-style: italic; }
.body img { max-width: 100%; height: auto; display: block; margin: 1.5rem 0; border-radius: 4px; }
.body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }

View file

@ -28,7 +28,7 @@
</main>
<footer class="site-footer">
<nav aria-label="Bunnmeny">
<a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
<a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('tag/') }}">Tagger</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
</nav>
{# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #}
<p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p>

View file

@ -11,6 +11,11 @@
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author_name or post.guest }}</a>{% endif %}
</p>
{% set text = post.body|excerpt(220) %}{% if text %}<p class="excerpt">{{ text }}</p>{% endif %}
{% if post.tags %}
<ul class="tags" aria-label="Tagger">
{% for t in post.tags %}<li><a href="{{ rel('tag/' ~ (t|tagslug) ~ '/') }}">{{ t }}</a></li>{% endfor %}
</ul>
{% endif %}
</article>
{% endfor %}
{% endblock %}

View file

@ -26,6 +26,11 @@
<div class="body">
{{ html|safe }}
</div>
{% if post.tags %}
<ul class="tags" aria-label="Tagger">
{% for t in post.tags %}<li><a href="{{ rel('tag/' ~ (t|tagslug) ~ '/') }}">{{ t }}</a></li>{% endfor %}
</ul>
{% endif %}
{% if post.author_name %}
<p class="signature"> {{ post.author_name }}{% if post.author_email %}
· <a href="mailto:{{ post.author_email }}">{{ post.author_email }}</a>{% endif %}</p>

View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Tagger {{ site.title }}{% endblock %}
{% block content %}
<h1>Tagger</h1>
{% if not tags %}<p>Ingen tagger ennå.</p>{% endif %}
<ul class="tags tag-cloud">
{% for tslug, t in tags|dictsort(by='key') %}
<li><a href="{{ rel('tag/' ~ tslug ~ '/') }}">{{ t.name }} <span class="meta">({{ t.posts|length }})</span></a></li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -339,3 +339,34 @@ def test_signature_from_profile(client, cfg):
).status_code
== 200
)
def test_tags(client, cfg):
login(client)
token = csrf(client)
def publish(title, tags):
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": title, "body": "x", "tags": tags, "action": "publish"},
)
publish("En", "Friluftsliv, skog ,skog, Skog")
publish("To", "friluftsliv")
publish("Tre", "")
md = (cfg.content_dir / "posts/en/index.md").read_text()
assert 'tags: ["Friluftsliv", "skog"]' in md # dedup, case-ufølsomt, trimmet
assert "tags:" not in (cfg.content_dir / "posts/tre/index.md").read_text()
assert 'href="../tag/friluftsliv/"' in client.get("/en/").text
page = client.get("/tag/friluftsliv/").text
assert "tagg: friluftsliv" in page.lower() and ">En<" in page and ">To<" in page and ">Tre<" not in page
assert client.get("/tag/skog/").status_code == 200
assert client.get("/tag/").status_code == 200 and "(2)" in client.get("/tag/").text
assert "<category>skog</category>" in client.get("/feed.xml").text
assert "https://blog.example.no/tag/friluftsliv/" in client.get("/sitemap.xml").text
# "tag" er reservert som slug
publish("tag", "")
assert not (cfg.content_dir / "posts/tag").exists()