diff --git a/README.md b/README.md index 705142a..d89822b 100644 --- a/README.md +++ b/README.md @@ -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//` 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 diff --git a/soapbox/admin.py b/soapbox/admin.py index 760cf67..41464e6 100644 --- a/soapbox/admin.py +++ b/soapbox/admin.py @@ -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 diff --git a/soapbox/build.py b/soapbox/build.py index be1d4cc..b27bb20 100644 --- a/soapbox/build.py +++ b/soapbox/build.py @@ -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// 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 ( '\n' '' @@ -171,7 +193,8 @@ def _feed(cfg: Config, site: dict, posts: list[Post]) -> str: f"{format_datetime(p.date)}" + (f"{escape(p.author_name)}" if p.author_name else "") + f"{escape(excerpt(p.body))}" - "" + + "".join(f"{escape(t)}" for t in p.tags) + + "" ) return ( '\n' diff --git a/soapbox/content.py b/soapbox/content.py index c50742e..a2b5947 100644 --- a/soapbox/content.py +++ b/soapbox/content.py @@ -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). diff --git a/soapbox/templates/admin/edit.html b/soapbox/templates/admin/edit.html index 7cf2879..53c21c5 100644 --- a/soapbox/templates/admin/edit.html +++ b/soapbox/templates/admin/edit.html @@ -6,6 +6,8 @@
+ {% if post.draft %}utkast Publiseres på {{ cfg.site_url }}/{{ post.url_path }} (følger tittelen) {% else %}Publisert på {{ cfg.site_url }}/{{ post.url_path }} diff --git a/soapbox/themes/green/static/style.css b/soapbox/themes/green/static/style.css index 02d5a99..607318b 100644 --- a/soapbox/themes/green/static/style.css +++ b/soapbox/themes/green/static/style.css @@ -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; } diff --git a/soapbox/themes/green/templates/base.html b/soapbox/themes/green/templates/base.html index 1b4c25a..605fc04 100644 --- a/soapbox/themes/green/templates/base.html +++ b/soapbox/themes/green/templates/base.html @@ -28,7 +28,7 @@