Sitemap, Open Graph-metadata og --follow-historikk

- sitemap.xml med forside, innlegg (lastmod) og gjesteoversikter.
- Open Graph/Twitter-kort på alle sider; innlegg får article-type,
  utdrag som beskrivelse, første bilde som og:image og canonical.
  Absolutte URL-er via SOAPBOX_SITE_URL.
- Historikk per innlegg kombinerer `git log --follow` på index.md med
  loggen for mappen, så utkast-lagringer fra før slug-omdøpingen vises.
  Diffen begrenses ikke lenger til nåværende sti av samme grunn.

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 14:39:29 +02:00
commit 2f74de4a32
6 changed files with 106 additions and 18 deletions

View file

@ -339,7 +339,7 @@ def history(post_id: str):
sha = request.args.get("sha")
diff = None
if sha:
diff = gitstore.path_diff(cfg.content_dir, sha, rel)
diff = gitstore.path_diff(cfg.content_dir, sha)
if diff is None:
abort(404)
return render_template("admin/history.html", post=post, entries=entries, sha=sha, diff=diff)

View file

@ -63,6 +63,7 @@ def build_site(cfg: Config) -> int:
return env.get_template(template).render(
site=site,
source_url=SOURCE_URL,
site_url=cfg.site_url, # absolutt; kun for Open Graph/canonical
page_path=page_path,
rel=lambda target: relative_to(page_path, target),
now=datetime.now(UTC),
@ -93,6 +94,7 @@ def build_site(cfg: Config) -> int:
)
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
_write(tmp / "sitemap.xml", _sitemap(cfg, posts, sorted(guests)))
# Statiske filer lagvis: innebygd først, eget tema oppå (overskriver).
for d in reversed(cfg.theme_dirs):
@ -111,6 +113,27 @@ def build_site(cfg: Config) -> int:
return len(posts)
def _sitemap(cfg: Config, posts: list[Post], guests: list[str]) -> str:
"""sitemap.xml med forsiden, alle innlegg og gjesteoversiktene."""
base = cfg.site_url + "/"
def url(path: str, lastmod: datetime | None = None) -> str:
loc = f"<loc>{escape(base + quote(path))}</loc>"
mod = f"<lastmod>{lastmod.date().isoformat()}</lastmod>" if lastmod else ""
return f"<url>{loc}{mod}</url>"
newest = posts[0].updated or posts[0].date if posts else None
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]
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
+ "".join(entries)
+ "</urlset>\n"
)
def _feed(cfg: Config, site: dict, posts: list[Post]) -> str:
base = cfg.site_url + "/"
items = []

View file

@ -45,21 +45,11 @@ def log(repo: Path, limit: int = 20) -> list[str]:
return out.stdout.splitlines() if out.returncode == 0 else []
def path_log(repo: Path, path: str, limit: int = 50) -> list[dict]:
"""Commits som har rørt ``path`` (relativ til repo), nyeste først.
_LOG_FORMAT = "--format=%H%x1f%aI%x1f%an%x1f%s"
Merk: en katalog som ble omdøpt (slug endret mens innlegget var utkast)
mister historikken fra før omdøpingen git følger bare filer, ikke mapper.
"""
out = _git(
repo,
"log",
f"-{limit}",
"--format=%H%x1f%aI%x1f%an%x1f%s",
"--",
path,
check=False,
)
def _log(repo: Path, *args: str) -> list[dict]:
out = _git(repo, "log", _LOG_FORMAT, *args, check=False)
entries = []
for line in out.stdout.splitlines():
sha, date, author, subject = line.split("\x1f", 3)
@ -67,9 +57,29 @@ def path_log(repo: Path, path: str, limit: int = 50) -> list[dict]:
return entries
def path_diff(repo: Path, sha: str, path: str) -> str | None:
"""Diff for ``path`` i én commit, eller None hvis commit-en ikke finnes."""
def path_log(repo: Path, path: str, limit: int = 50) -> list[dict]:
"""Commits som har rørt innleggsmappen ``path`` (relativ til repo), nyeste først.
git følger filer, ikke mapper, en katalog som ble omdøpt (slug endret
mens innlegget var utkast) ville mistet historikken fra før omdøpingen.
Derfor kombineres to logger: ``--follow`` ``index.md`` (tar med tiden
før omdøpingen) og en vanlig logg mappen (tar med bildeopplastinger).
"""
seen: dict[str, dict] = {}
for e in _log(repo, f"-{limit}", "--follow", "--", f"{path}/index.md") + _log(
repo, f"-{limit}", "--", path
):
seen.setdefault(e["sha"], e)
return sorted(seen.values(), key=lambda e: e["date"], reverse=True)[:limit]
def path_diff(repo: Path, sha: str) -> str | None:
"""Diff for én commit, eller None hvis commit-en ikke finnes.
Diffen begrenses ikke til innleggets nåværende sti: før en omdøping
filene et annet sted, og hver commit er uansett én handling ett innlegg.
"""
if not all(c in "0123456789abcdef" for c in sha) or len(sha) < 7:
return None
out = _git(repo, "show", "--format=", "--stat", "--patch", sha, "--", path, check=False)
out = _git(repo, "show", "--format=", "--stat", "--patch", sha, check=False)
return out.stdout if out.returncode == 0 else None

View file

@ -8,6 +8,14 @@
<link rel="stylesheet" href="{{ rel('theme/style.css') }}">
<link rel="icon" type="image/svg+xml" href="{{ rel('theme/favicon.svg') }}">
<link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="{{ rel('feed.xml') }}">
{# Open Graph krever absolutte URL-er; site_url er det eneste stedet det brukes utenom feed/sitemap. #}
{% block og %}
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{ site.title }}">
<meta property="og:title" content="{{ site.title }}">
<meta property="og:url" content="{{ site_url }}/{{ page_path }}">
{% if site.description %}<meta property="og:description" content="{{ site.description }}">{% endif %}
{% endblock %}
</head>
<body>
<a class="skip" href="#main">Hopp til innhold</a>

View file

@ -1,5 +1,20 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} {{ site.title }}{% endblock %}
{% block og %}
{% set summary = post.body|excerpt(200) %}
<meta property="og:type" content="article">
<meta property="og:site_name" content="{{ site.title }}">
<meta property="og:title" content="{{ post.title }}">
<meta property="og:url" content="{{ site_url }}/{{ post.url_path }}">
<link rel="canonical" href="{{ site_url }}/{{ post.url_path }}">
{% if summary %}<meta property="og:description" content="{{ summary }}">
<meta name="description" content="{{ summary }}">{% endif %}
{% if post.images %}<meta property="og:image" content="{{ site_url }}/{{ post.url_path }}{{ post.images[0] }}">{% endif %}
<meta property="article:published_time" content="{{ post.date.isoformat() }}">
{% if post.updated %}<meta property="article:modified_time" content="{{ post.updated.isoformat() }}">{% endif %}
{% if post.author %}<meta property="article:author" content="{{ post.author }}">{% endif %}
<meta name="twitter:card" content="{{ 'summary_large_image' if post.images else 'summary' }}">
{% endblock %}
{% block content %}
<article class="post">
<h1>{{ post.title }}</h1>

View file

@ -180,6 +180,7 @@ def test_post_history(client, cfg):
html = client.get(f"/admin/history/{pid}").text
assert "Publiserte: Hist" in html and "Lagret: Hist" in html
assert "Opprettet utkast" in html # fra før slug-omdøpingen (--follow)
sha = re.search(r"sha=([0-9a-f]{40})", html).group(1)
diff = client.get(f"/admin/history/{pid}?sha={sha}").text
assert '<span class="add">+andre</span>' in diff and '<span class="del">-første</span>' in diff
@ -264,3 +265,34 @@ def test_logo_links_to_blog_root(client):
assert '<a class="brand" href="/"' in client.get("/admin/").text
# offentlig tema: relativt til roten fra hvilken som helst dybde
assert 'class="site-title" href="./"' in client.get("/").text
def test_sitemap_and_open_graph(client, cfg):
post_id = _create_post(client)
token = csrf(client)
client.post(
f"/admin/upload/{post_id}",
data={"file": (__import__("io").BytesIO(png_bytes((10, 10))), "b.png")},
headers={"X-CSRF": token},
content_type="multipart/form-data",
)
client.post(
f"/admin/edit/{post_id}",
data={
"csrf": token,
"title": "OG-test",
"body": "Første *avsnitt* her.\n\n![x](b.png)",
"action": "publish",
},
)
html = client.get("/og-test/").text
assert '<meta property="og:type" content="article">' in html
assert '<meta property="og:title" content="OG-test">' in html
assert '<meta property="og:url" content="https://blog.example.no/og-test/">' in html
assert '<meta property="og:image" content="https://blog.example.no/og-test/b.png">' in html
assert '<meta property="og:description" content="Første avsnitt her.">' in html
assert '<meta property="og:type" content="website">' in client.get("/").text
sm = client.get("/sitemap.xml")
assert sm.status_code == 200
assert "<loc>https://blog.example.no/</loc>" in sm.text
assert "<loc>https://blog.example.no/og-test/</loc>" in sm.text