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:
parent
259465342b
commit
f0fcb875c5
11 changed files with 109 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
11
soapbox/themes/green/templates/tags.html
Normal file
11
soapbox/themes/green/templates/tags.html
Normal 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 %}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue