Unicode i slugs: «Grønt er skjønt» → grønt-er-skjønt

Bokstaver og tall i alle skriftsystemer beholdes, alt annet blir
bindestrek. NFC-normalisering gir samme slug uansett om ø/å kommer
prekomponert eller som base + kombinerende tegn (macOS). URL-er i
feed.xml percent-encodes siden RSS-lesere krever ASCII.

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:02:07 +02:00
commit 1218c5d650
4 changed files with 26 additions and 20 deletions

View file

@ -18,6 +18,7 @@ import shutil
from datetime import UTC, datetime
from email.utils import format_datetime
from pathlib import Path
from urllib.parse import quote
from xml.sax.saxutils import escape
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
@ -113,7 +114,7 @@ def _feed(cfg: Config, site: dict, posts: list[Post]) -> str:
base = cfg.site_url + "/"
items = []
for p in posts:
link = base + p.url_path
link = base + quote(p.url_path) # RSS-lesere krever ASCII-URL-er
items.append(
"<item>"
f"<title>{escape(p.title)}</title>"

View file

@ -1,7 +1,10 @@
"""Lager URL-vennlige slugs fra titler.
Norske tegn translittereres eksplisitt (æae, øoe, åaa) fordi generisk
unicode-normalisering ville mistet dem helt ("blåbær" "blbr").
Unicode-bokstaver beholdes ("Grønt er skjønt" "grønt-er-skjønt"); moderne
nettlesere og servere håndterer UTF-8 i stier, og det gir penere og mer
lesbare adresser enn translitterering. Teksten NFC-normaliseres slik at
f.eks. «ø» skrevet som base + kombinerende tegn (vanlig fra macOS) gir samme
slug som det prekomponerte tegnet.
"""
from __future__ import annotations
@ -9,15 +12,13 @@ from __future__ import annotations
import re
import unicodedata
_NORDIC = str.maketrans(
{"æ": "ae", "ø": "oe", "å": "aa", "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}
)
# Alt som ikke er bokstav eller tall (i alle skriftsystemer) blir skilletegn.
_SEP = re.compile(r"[^\w]+|_+", re.UNICODE)
def slugify(text: str) -> str:
text = text.lower().translate(_NORDIC)
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
text = unicodedata.normalize("NFC", text).casefold()
text = _SEP.sub("-", text).strip("-")
return text or "post"

View file

@ -1,4 +1,5 @@
import re
from urllib.parse import unquote
from soapbox.app import create_app
from soapbox.config import load_config
@ -50,31 +51,31 @@ def test_full_flow_owner(client, cfg):
f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Grønt er skjønt", "body": body, "action": "publish"},
)
assert r.headers["Location"].endswith("/admin/edit/_owner/groent-er-skjoent")
assert (cfg.content_dir / "posts/groent-er-skjoent/index.md").exists()
assert (cfg.content_dir / "posts/groent-er-skjoent/mitt-bilde.png").exists()
assert unquote(r.headers["Location"]).endswith("/admin/edit/_owner/grønt-er-skjønt")
assert (cfg.content_dir / "posts/grønt-er-skjønt/index.md").exists()
assert (cfg.content_dir / "posts/grønt-er-skjønt/mitt-bilde.png").exists()
# Statisk output
r = client.get("/groent-er-skjoent")
assert r.status_code == 301 and r.headers["Location"].endswith("/groent-er-skjoent/")
html = client.get("/groent-er-skjoent/").text
r = client.get("/grønt-er-skjønt")
assert r.status_code == 301 and unquote(r.headers["Location"]).endswith("/grønt-er-skjønt/")
html = client.get("/grønt-er-skjønt/").text
assert "<strong>verden</strong>" in html
assert 'href="../theme/style.css"' in html
assert 'href="../theme/favicon.svg"' in html
assert 'href="../"' in html # intern lenke omskrevet til relativ
assert client.get("/groent-er-skjoent/mitt-bilde.png").status_code == 200
assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200
front = client.get("/").text
assert "Grønt er skjønt" in front
assert 'href="https://kode.naiv.no/olemd/soapbox"' in front # AGPL: lenke til kilden
feed = client.get("/feed.xml").text
assert "https://blog.example.no/groent-er-skjoent/" in feed
assert "https://blog.example.no/gr%C3%B8nt-er-skj%C3%B8nt/" in feed
# Etter publisering fryses slug selv om tittelen endres
r = client.post(
"/admin/edit/_owner/groent-er-skjoent",
"/admin/edit/_owner/grønt-er-skjønt",
data={"csrf": token, "title": "Ny tittel", "body": body, "action": "save"},
)
assert r.headers["Location"].endswith("/admin/edit/_owner/groent-er-skjoent")
assert unquote(r.headers["Location"]).endswith("/admin/edit/_owner/grønt-er-skjønt")
# Git-historikk
from soapbox.gitstore import log

View file

@ -9,7 +9,10 @@ from tests.conftest import png_bytes
def test_slugify_norwegian():
assert slugify("Grønt er skjønt: blåbær & øl!") == "groent-er-skjoent-blaabaer-oel"
assert slugify("Grønt er skjønt: blåbær & øl!") == "grønt-er-skjønt-blåbær-øl"
assert slugify("Hello, World_2!") == "hello-world-2"
assert slugify("Blåbær") == slugify("Bla\u030abær") # NFD → NFC
assert slugify("日本語 タイトル") == "日本語-タイトル"
assert slugify(" ") == "post"
assert unique_slug("a", {"a", "a-2"}) == "a-3"