Soapbox: statisk blogg med mobilvennlig markdown-editor

Flask-app som genererer statiske sider til public/ og serverer dem
sammen med et admin-grensesnitt (/admin). Innhold ligger som markdown
og bilder i et git-repo (data/content), brukere i sqlite.

- Editor med toolbar, live server-side forhåndsvisning, bildeopplasting
  via knapp, dra-og-slipp og lim inn. Bilder nedskaleres og EXIF fjernes.
- Hovedbruker publiserer under /<slug>/, gjester under /<bruker>/<slug>/.
  Slug lages fra tittelen og fryses ved publisering.
- Alle interne lenker er relative, så siden kan flyttes mellom domener og
  sub-paths (SOAPBOX_BASE_PATH) uten rebuild.
- Tema 'green' med CSS-variabler, mørk modus og WCAG-kontrast.
- Containerfile (python:alpine + git), compose.yaml og Caddyfile-eksempel.

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 12:12:33 +02:00
commit 7ab2320be6
35 changed files with 2726 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
data/
.pytest_cache/
.ruff_cache/
dist/
.playwright-mcp/
*.png

16
Caddyfile.example Normal file
View file

@ -0,0 +1,16 @@
# Eksempel 1: bloggen på et eget domene (SOAPBOX_BASE_PATH tom)
blog.domene.no {
encode zstd gzip
reverse_proxy localhost:8080
}
# Eksempel 2: bloggen under en sub-path (SOAPBOX_BASE_PATH=/blog).
# Appen forventer å få hele stien inkl. /blog, så ingen strip_prefix her.
annet-domene.com {
encode zstd gzip
redir /blog /blog/ permanent
handle /blog/* {
reverse_proxy localhost:8080
}
# … resten av nettstedet
}

35
Containerfile Normal file
View file

@ -0,0 +1,35 @@
# Soapbox lettest mulig image: python:alpine + git + appen.
# Bygg (fra repo-rot):
# BUILDAH_FORMAT=docker podman build \
# --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
# --build-arg GIT_REVISION="$(git describe --always --dirty)" \
# -t soapbox .
FROM python:3.13-alpine AS build
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY soapbox ./soapbox
RUN uv sync --frozen --no-dev
FROM python:3.13-alpine
ARG BUILD_DATE
ARG GIT_REVISION
LABEL org.opencontainers.image.title="soapbox" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${GIT_REVISION}"
RUN apk add --no-cache git curl \
&& printf 'build_date=%s\ngit_revision=%s\n' "${BUILD_DATE}" "${GIT_REVISION}" > /etc/build-info \
&& adduser -D -u 1000 soapbox \
&& mkdir -p /data && chown soapbox:soapbox /data
WORKDIR /app
COPY --from=build --chown=soapbox:soapbox /app /app
USER soapbox
ENV PATH="/app/.venv/bin:$PATH" \
SOAPBOX_DATA_DIR=/data \
PYTHONUNBUFFERED=1
VOLUME /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:8080/ >/dev/null || exit 1
CMD ["soapbox", "serve"]

81
README.md Normal file
View file

@ -0,0 +1,81 @@
# Soapbox
En liten blogg: statiske sider, mobilvennlig Markdown-editor med bildeopplasting og live
forhåndsvisning, innhold versjonert i git. Én hovedbruker («soapbox») pluss valgfrie gjester.
## Hvordan det henger sammen
```
data/
├── content/ git-repo alt innhold, kan klones/flyttes fritt
│ ├── site.toml tittel, beskrivelse, språk
│ ├── posts/<slug>/index.md + bilder → /<slug>/
│ └── guests/<bruker>/<slug>/index.md → /<bruker>/<slug>/
├── public/ generert statisk side (bygges automatisk ved lagring)
├── soapbox.sqlite brukere og passord-hasher det eneste som ikke ligger i git
└── secret_key cookie-signering (genereres automatisk)
```
* **Slug** lages av tittelen (`Grønt er skjønt``groent-er-skjoent`). Den følger tittelen så
lenge innlegget er utkast, og fryses ved publisering så lenker aldri brekker.
* **Alle interne lenker er relative.** Malene regner ut `../` ut fra sidens dybde, og absolutte
lenker du skriver i Markdown (`/annet-innlegg/`) skrives om. Derfor fungerer samme `public/`
både på `blog.domene.no/` og `annet-domene.com/blog/` uten rebuild.
* **Bilder** ligger i samme mappe som innlegget og refereres som `![](bilde.jpg)`. Ved opplasting
skaleres de ned (maks 2000 px) og EXIF fjernes (ingen GPS-posisjon publiseres).
* **Hver lagring er en git-commit** med brukeren som forfatter. Se historikken under
Innstillinger, eller `git -C data/content log`.
* **Gjester** ser og redigerer bare egne innlegg. Hovedbrukeren ser alt og administrerer gjester.
## Kjøre lokalt
```sh
uv sync
SOAPBOX_DATA_DIR=./data uv run soapbox create-user olemd --role owner
SOAPBOX_DATA_DIR=./data uv run soapbox serve --debug
# → http://localhost:8080/admin/
```
Tester: `uv run pytest`. Lint: `uv run ruff check . && uv run ruff format .`
## Konfigurasjon (miljøvariabler)
| Variabel | Standard | Beskrivelse |
|---|---|---|
| `SOAPBOX_DATA_DIR` | `./data` (`/data` i container) | Alt persistent |
| `SOAPBOX_BASE_PATH` | `` | `/blog` hvis bloggen ligger under en sub-path |
| `SOAPBOX_SITE_URL` | `http://localhost:8080` | Absolutt URL til rot, brukes **bare** i `feed.xml` |
| `SOAPBOX_THEME` | `green` | Mappe under `soapbox/themes/` |
| `SOAPBOX_MAX_IMAGE_PX` | `2000` | Lengste side på opplastede bilder |
| `SOAPBOX_SECRET_KEY` | autogenerert | Overstyr hvis du vil ha nøkkelen utenfor volumet |
| `SOAPBOX_OWNER_USER` / `_PASSWORD` | | Oppretter hovedbruker ved første start |
## Container og Caddy
```sh
BUILDAH_FORMAT=docker podman build \
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--build-arg GIT_REVISION="$(git describe --always --dirty)" \
-t soapbox .
podman compose up -d # se compose.yaml
```
Caddy er ren reverse proxy, se `Caddyfile.example`. Ved sub-path skal Caddy **ikke** strippe
prefikset appen monteres selv under `SOAPBOX_BASE_PATH`.
## Flytte bloggen (nytt domene og/eller sub-path)
1. Kopier `data/`-volumet (eller bare `git clone` `data/content` og opprett brukerne på nytt).
2. Sett `SOAPBOX_SITE_URL` og eventuelt `SOAPBOX_BASE_PATH` til de nye verdiene.
3. Start siden bygges på nytt. Ingen innhold trenger endring.
## Tema
Et tema er en mappe under `soapbox/themes/<navn>/` med `templates/` (`base.html`, `index.html`,
`post.html`) og `static/` (kopieres til `/theme/`). Enkleste tilpasning: kopier `green`, endre
CSS-variablene øverst i `style.css`. Malene får `site`, `posts`/`post`, `html` og `rel(sti)`.
## Veien videre (ikke i denne omgangen)
* Poste lenker til fediverset/Bluesky og vise svar som kommentarer.
* Push av innholdsrepoet til en ekstern git-remote.

19
compose.yaml Normal file
View file

@ -0,0 +1,19 @@
# podman-compose up -d (eller: podman compose up -d)
services:
soapbox:
image: localhost/soapbox:latest
container_name: soapbox
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
volumes:
- soapbox-data:/data
environment:
SOAPBOX_SITE_URL: https://blog.domene.no
SOAPBOX_BASE_PATH: ""
SOAPBOX_THEME: green
# Settes kun første gang; ignoreres når en hovedbruker finnes.
SOAPBOX_OWNER_USER: olemd
SOAPBOX_OWNER_PASSWORD: bytt-meg-nå
volumes:
soapbox-data:

30
pyproject.toml Normal file
View file

@ -0,0 +1,30 @@
[project]
name = "soapbox"
version = "0.1.0"
description = "Liten statisk blogg med mobilvennlig editor, innhold i git"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"flask>=3.1",
"linkify-it-py>=2.1.1",
"markdown-it-py>=3.0",
"mdit-py-plugins>=0.4",
"pillow>=11.0",
"waitress>=3.0",
]
[project.scripts]
soapbox = "soapbox.cli:main"
[dependency-groups]
dev = ["pytest>=8", "ruff>=0.8"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["soapbox"]
[tool.ruff]
line-length = 100

1
soapbox/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Soapbox en liten statisk blogg med mobilvennlig editor og innhold i git."""

393
soapbox/admin.py Normal file
View file

@ -0,0 +1,393 @@
"""Admin-blueprint: innlogging, editor, bilder, brukere og innstillinger.
Tilgangsregler:
* Alle innloggede kan opprette/redigere/slette *egne* innlegg.
* Hovedbruker (owner) kan i tillegg redigere alle innlegg, administrere
gjestebrukere og endre side-innstillinger.
CSRF: hvert skjema og hvert fetch-kall sender et token som ligger i
sesjonen. Enkelt, men tilstrekkelig siden cookien er SameSite=Lax.
"""
from __future__ import annotations
import json
import re
import secrets
import sqlite3
from datetime import UTC, datetime
from functools import wraps
from flask import (
Blueprint,
abort,
current_app,
flash,
g,
jsonify,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from . import db, gitstore
from .build import build_site
from .content import (
IMAGE_EXT,
Post,
delete_post,
existing_slugs,
get_post,
list_posts,
post_dir,
save_post,
split_id,
)
from .images import ImageError, process_image, safe_filename
from .render import render_markdown
from .slug import slugify, unique_slug
bp = Blueprint(
"admin",
__name__,
template_folder="templates",
static_folder="static/admin",
static_url_path="/static",
)
USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
# Reserverte navn som ville kollidert med URL-strukturen
RESERVED = {"admin", "theme", "feed.xml", "posts", "guests", "index.html"}
def _cfg():
return current_app.soapbox # type: ignore[attr-defined]
def _now() -> datetime:
return datetime.now(UTC).replace(microsecond=0)
# ---------- auth ----------
def current_user() -> db.User | None:
uid = session.get("uid")
return db.get_user(g.db, uid) if uid else None
def csrf_token() -> str:
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
def login_required(view):
@wraps(view)
def wrapped(*a, **kw):
user = current_user()
if user is None:
return redirect(url_for("admin.login", next=request.path))
g.user = user
if request.method == "POST":
token = request.form.get("csrf") or request.headers.get("X-CSRF")
if not token or token != session.get("csrf"):
abort(403)
return view(*a, **kw)
return wrapped
def owner_required(view):
@wraps(view)
@login_required
def wrapped(*a, **kw):
if not g.user.is_owner:
abort(403)
return view(*a, **kw)
return wrapped
@bp.context_processor
def _inject():
return {"csrf": csrf_token(), "user": getattr(g, "user", None), "site": _cfg().site}
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = db.authenticate(
g.db, request.form.get("username", ""), request.form.get("password", "")
)
if user:
session.clear()
session.permanent = True
session["uid"] = user.id
csrf_token()
nxt = request.form.get("next") or url_for("admin.index")
if not nxt.startswith("/"):
nxt = url_for("admin.index")
return redirect(nxt)
flash("Feil brukernavn eller passord.", "error")
return render_template("admin/login.html", next=request.args.get("next", ""))
@bp.route("/logout", methods=["POST"])
@login_required
def logout():
session.clear()
return redirect(url_for("admin.login"))
# ---------- innlegg ----------
def _load_or_404(post_id: str) -> Post:
guest, slug = split_id(post_id)
post = get_post(_cfg(), guest, slug)
if post is None:
abort(404)
if not g.user.is_owner and guest != g.user.username:
abort(403)
return post
def _commit(message: str) -> None:
gitstore.commit_all(_cfg().content_dir, message, g.user.username)
@bp.route("/")
@login_required
def index():
posts = list_posts(_cfg(), include_drafts=True)
if not g.user.is_owner:
posts = [p for p in posts if p.guest == g.user.username]
return render_template("admin/index.html", posts=posts)
@bp.route("/new", methods=["POST"])
@login_required
def new():
"""Oppretter et tomt utkast med en gang, slik at bildeopplasting har en
mappe å lande i før første lagring."""
cfg = _cfg()
guest = g.user.guest_prefix
slug = unique_slug("nytt-innlegg", existing_slugs(cfg, guest) | RESERVED)
post = Post(
slug=slug,
title="Nytt innlegg",
body="",
date=_now(),
draft=True,
author=g.user.display_name,
guest=guest,
)
save_post(cfg, post)
_commit(f"Opprettet utkast: {post.title}")
return redirect(url_for("admin.edit", post_id=post.id))
@bp.route("/edit/<path:post_id>", methods=["GET", "POST"])
@login_required
def edit(post_id: str):
cfg = _cfg()
post = _load_or_404(post_id)
if request.method == "POST":
title = request.form.get("title", "").strip() or "Uten tittel"
body = request.form.get("body", "").replace("\r\n", "\n")
action = request.form.get("action", "save")
was_draft = post.draft
post.title = title
post.body = body
if action == "publish":
post.draft = False
elif action == "unpublish":
post.draft = True
# Slug følger tittelen så lenge innlegget er utkast. Etter publisering
# fryses den, så eksterne lenker aldri brekker.
if was_draft:
wanted = slugify(title)
if wanted != post.slug:
taken = (existing_slugs(cfg, post.guest) - {post.slug}) | RESERVED
new_slug = unique_slug(wanted, taken)
post_dir(cfg, post.guest, post.slug).rename(post_dir(cfg, post.guest, new_slug))
post.slug = new_slug
if was_draft and not post.draft:
post.date = _now() # publiseringstidspunkt, ikke utkastets
elif not post.draft:
post.updated = _now()
save_post(cfg, post)
_commit(f"{'Publiserte' if action == 'publish' else 'Lagret'}: {post.title}")
build_site(cfg)
flash("Publisert." if action == "publish" else "Lagret.", "ok")
return redirect(url_for("admin.edit", post_id=post.id))
return render_template("admin/edit.html", post=post)
@bp.route("/delete/<path:post_id>", methods=["POST"])
@login_required
def delete(post_id: str):
post = _load_or_404(post_id)
delete_post(_cfg(), post)
_commit(f"Slettet: {post.title}")
build_site(_cfg())
flash("Innlegget er slettet.", "ok")
return redirect(url_for("admin.index"))
@bp.route("/preview/<path:post_id>", methods=["POST"])
@login_required
def preview(post_id: str):
"""Server-side forhåndsvisning identisk med publisert resultat.
Relative bildereferanser pekes om til admin-ruten som serverer bilder rett
fra innholdskatalogen, siden utkast ikke finnes i ``public/``.
"""
post = _load_or_404(post_id)
text = request.get_json(silent=True, force=True) or {}
html = render_markdown(text.get("body", ""), post.url_path)
media = url_for("admin.media", post_id=post.id, filename="X").rsplit("X", 1)[0]
def fix(m: re.Match) -> str:
src = m.group(1)
if "://" in src or src.startswith(("/", "../", "data:")):
return m.group(0)
return f'src="{media}{src}"'
return re.sub(r'src="([^"]+)"', fix, html)
@bp.route("/media/<path:post_id>/<filename>")
@login_required
def media(post_id: str, filename: str):
post = _load_or_404(post_id)
return send_from_directory(post.path.parent, filename)
@bp.route("/upload/<path:post_id>", methods=["POST"])
@login_required
def upload(post_id: str):
post = _load_or_404(post_id)
f = request.files.get("file")
if f is None:
return jsonify(error="Ingen fil"), 400
try:
data, ext = process_image(f.read(), _cfg().max_image_px)
except ImageError as e:
return jsonify(error=str(e)), 400
taken = {p.name for p in post.path.parent.iterdir()}
name = safe_filename(f.filename or "bilde", ext, taken)
(post.path.parent / name).write_bytes(data)
_commit(f"Lastet opp {name} til {post.title}")
return jsonify(
filename=name,
markdown=f"![]({name})",
url=url_for("admin.media", post_id=post.id, filename=name),
)
@bp.route("/image/delete/<path:post_id>", methods=["POST"])
@login_required
def delete_image(post_id: str):
post = _load_or_404(post_id)
name = request.form.get("filename", "")
target = post.path.parent / name
if "/" in name or target.suffix.lower() not in IMAGE_EXT or not target.exists():
abort(404)
target.unlink()
_commit(f"Slettet {name} fra {post.title}")
flash(f"Slettet {name}.", "ok")
return redirect(url_for("admin.edit", post_id=post.id))
# ---------- brukere og innstillinger ----------
@bp.route("/users", methods=["GET", "POST"])
@owner_required
def users():
if request.method == "POST":
action = request.form.get("action")
if action == "create":
username = request.form.get("username", "").strip().lower()
password = request.form.get("password", "")
if not USERNAME_RE.match(username) or username in RESERVED:
flash("Ugyldig brukernavn (az, 09, bindestrek, 231 tegn).", "error")
elif len(password) < 8:
flash("Passordet må være minst 8 tegn.", "error")
else:
try:
db.create_user(
g.db,
username,
password,
db.ROLE_GUEST,
request.form.get("display_name", ""),
)
flash(f"Gjestebruker {username} opprettet.", "ok")
except sqlite3.IntegrityError:
flash("Brukernavnet er allerede i bruk.", "error")
elif action == "password":
uid = int(request.form.get("user_id", 0))
password = request.form.get("password", "")
if len(password) < 8:
flash("Passordet må være minst 8 tegn.", "error")
else:
db.set_password(g.db, uid, password)
flash("Passordet er endret.", "ok")
elif action == "delete":
db.delete_user(g.db, int(request.form.get("user_id", 0)))
flash("Brukeren er slettet. Innleggene ligger fortsatt i git.", "ok")
return redirect(url_for("admin.users"))
return render_template("admin/users.html", users=db.list_users(g.db))
@bp.route("/password", methods=["GET", "POST"])
@login_required
def password():
if request.method == "POST":
if not db.authenticate(g.db, g.user.username, request.form.get("current", "")):
flash("Nåværende passord er feil.", "error")
elif len(request.form.get("password", "")) < 8:
flash("Passordet må være minst 8 tegn.", "error")
else:
db.set_password(g.db, g.user.id, request.form["password"])
flash("Passordet er endret.", "ok")
return redirect(url_for("admin.index"))
return render_template("admin/password.html")
@bp.route("/settings", methods=["GET", "POST"])
@owner_required
def settings():
cfg = _cfg()
if request.method == "POST":
site = {
"title": request.form.get("title", "").strip() or "Soapbox",
"description": request.form.get("description", "").strip(),
"language": request.form.get("language", "nb").strip() or "nb",
}
# JSON-strenger er gyldige TOML basic strings for alt vi skriver her.
toml = "".join(f"{k} = {json.dumps(v, ensure_ascii=False)}\n" for k, v in site.items())
(cfg.content_dir / "site.toml").write_text(toml, encoding="utf-8")
cfg.site = site
_commit("Endret innstillinger")
build_site(cfg)
flash("Innstillingene er lagret.", "ok")
return redirect(url_for("admin.settings"))
return render_template("admin/settings.html", log=gitstore.log(cfg.content_dir), cfg=cfg)
@bp.route("/rebuild", methods=["POST"])
@owner_required
def rebuild():
n = build_site(_cfg())
flash(f"Bygde siden på nytt ({n} innlegg).", "ok")
return redirect(url_for("admin.settings"))

122
soapbox/app.py Normal file
View file

@ -0,0 +1,122 @@
"""Flask-appen: serverer den statiske bloggen og admin-grensesnittet.
Ruter (alle under ``SOAPBOX_BASE_PATH``):
* ``/admin/`` innlogging, editor, brukere, innstillinger
* alt annet statiske filer fra ``public/`` (generert av :mod:`build`)
Appen monteres under base-path via werkzeugs ``DispatcherMiddleware`` slik
at Flask selv ser ``SCRIPT_NAME`` og ``url_for`` gir riktige stier både
``/`` og ``/blog``.
"""
from __future__ import annotations
import os
import sqlite3
from flask import Flask, abort, g, redirect, request, send_from_directory
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from . import db, gitstore
from .admin import bp as admin_bp
from .build import build_site
from .config import Config, load_config
def create_app(cfg: Config | None = None) -> Flask:
cfg = cfg or load_config()
app = Flask(__name__, static_folder=None)
app.config.update(
SECRET_KEY=cfg.secret_key,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=cfg.site_url.startswith("https://"),
SESSION_COOKIE_PATH=cfg.base_path or "/",
SESSION_COOKIE_NAME="soapbox_session",
MAX_CONTENT_LENGTH=32 * 1024 * 1024,
PERMANENT_SESSION_LIFETIME=60 * 60 * 24 * 30,
)
app.soapbox = cfg # type: ignore[attr-defined]
gitstore.ensure_repo(cfg.content_dir)
_bootstrap_owner(cfg)
if not cfg.public_dir.exists():
build_site(cfg)
@app.before_request
def _open_db():
g.db = db.connect(cfg.db_path)
@app.teardown_request
def _close_db(_exc):
conn = g.pop("db", None)
if conn is not None:
conn.close()
@app.after_request
def _headers(resp):
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
if request.path.startswith("/admin"):
resp.headers["Cache-Control"] = "no-store"
resp.headers["X-Frame-Options"] = "DENY"
return resp
app.register_blueprint(admin_bp, url_prefix="/admin")
@app.route("/")
@app.route("/<path:path>")
def public(path: str = ""):
return _serve_public(cfg, path)
if cfg.base_path:
# Alt utenfor base-path svarer 404; Caddy ruter uansett bare base-path hit.
app.wsgi_app = DispatcherMiddleware(_not_found, {cfg.base_path: app.wsgi_app}) # type: ignore[assignment]
return app
def _not_found(environ, start_response):
start_response("404 NOT FOUND", [("Content-Type", "text/plain; charset=utf-8")])
return [b"Not found"]
def _serve_public(cfg: Config, path: str):
root = cfg.public_dir
if not root.exists():
abort(404)
target = (root / path).resolve() if path else root
if not str(target).startswith(str(root.resolve())):
abort(404)
if target.is_dir():
# Katalog-URL-er må ha avsluttende skråstrek, ellers brekker relative
# bildelenker (``bilde.jpg`` relativt til ``/post1`` er ``/bilde.jpg``).
if path and not request.path.endswith("/"):
return redirect(request.path + "/", code=301)
path = f"{path}index.html" if path else "index.html"
resp = send_from_directory(root, path)
if path.startswith("theme/") or "." in os.path.basename(path) and not path.endswith(".html"):
resp.headers["Cache-Control"] = "public, max-age=3600"
else:
resp.headers["Cache-Control"] = "public, max-age=60"
return resp
def _bootstrap_owner(cfg: Config) -> None:
"""Oppretter hovedbruker fra miljøvariabler ved første oppstart.
Praktisk for containere: ``SOAPBOX_OWNER_USER`` / ``SOAPBOX_OWNER_PASSWORD``
settes én gang; når en eier finnes, ignoreres variablene.
"""
user = os.environ.get("SOAPBOX_OWNER_USER")
password = os.environ.get("SOAPBOX_OWNER_PASSWORD")
if not (user and password):
return
conn = db.connect(cfg.db_path)
try:
if not db.has_owner(conn):
db.create_user(conn, user, password, db.ROLE_OWNER)
except sqlite3.IntegrityError:
pass
finally:
conn.close()

134
soapbox/build.py Normal file
View file

@ -0,0 +1,134 @@
"""Statisk generering av bloggen til ``public/``.
Kjøres ved hver lagring i editoren (tar millisekunder for en vanlig blogg).
Utdata er rene filer som kan serveres av hva som helst appen selv, Caddy
eller en CDN.
Alle lenker i genererte sider er relative. Malene får en ``rel()``-funksjon
som regner ut stien fra *denne* siden til et mål, ``rel("theme/style.css")``
blir ``theme/style.css`` forsiden og ``../../theme/style.css`` i et
gjesteinnlegg. Dermed kan hele ``public/`` flyttes til hvilken som helst
sub-path uten rebuild. Unntaket er ``feed.xml``, der RSS krever absolutte
URL-er; den bruker ``SOAPBOX_SITE_URL``.
"""
from __future__ import annotations
import shutil
from datetime import UTC, datetime
from email.utils import format_datetime
from pathlib import Path
from xml.sax.saxutils import escape
from jinja2 import Environment, FileSystemLoader, select_autoescape
from .config import Config, load_site_settings
from .content import IMAGE_EXT, Post, list_posts
from .render import excerpt, relative_to, render_markdown
def _env(cfg: Config) -> Environment:
env = Environment(
loader=FileSystemLoader(cfg.theme_dir / "templates"),
autoescape=select_autoescape(["html"]),
)
env.filters["date"] = lambda d, fmt="%d.%m.%Y": d.strftime(fmt)
return env
def _write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def build_site(cfg: Config) -> int:
"""Genererer hele siden. Returnerer antall publiserte innlegg."""
site = load_site_settings(cfg)
env = _env(cfg)
posts = list_posts(cfg)
out = cfg.public_dir
# Bygg i en midlertidig katalog og bytt atomisk, så besøkende aldri ser
# en halvferdig side.
tmp = out.with_name(out.name + ".tmp")
if tmp.exists():
shutil.rmtree(tmp)
tmp.mkdir(parents=True)
def render(template: str, page_path: str, **ctx) -> str:
return env.get_template(template).render(
site=site,
page_path=page_path,
rel=lambda target: relative_to(page_path, target),
now=datetime.now(UTC),
**ctx,
)
guests: dict[str, list[Post]] = {}
for p in posts:
if p.guest:
guests.setdefault(p.guest, []).append(p)
# Forside: alle innlegg, også gjestenes, nyeste først
_write(tmp / "index.html", render("index.html", "", posts=posts, heading=None))
# Innlegg + bilder
for p in posts:
html = render_markdown(p.body, p.url_path)
_write(tmp / p.url_path / "index.html", render("post.html", p.url_path, post=p, html=html))
for f in p.path.parent.iterdir():
if f.suffix.lower() in IMAGE_EXT:
shutil.copy2(f, tmp / p.url_path / f.name)
# Én oversiktsside per gjest
for guest, gposts in guests.items():
_write(
tmp / guest / "index.html",
render("index.html", f"{guest}/", posts=gposts, heading=gposts[0].author or guest),
)
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
theme_static = cfg.theme_dir / "static"
if theme_static.exists():
shutil.copytree(theme_static, tmp / "theme")
if out.exists():
old = out.with_name(out.name + ".old")
if old.exists():
shutil.rmtree(old)
out.rename(old)
tmp.rename(out)
shutil.rmtree(old)
else:
tmp.rename(out)
return len(posts)
def _feed(cfg: Config, site: dict, posts: list[Post]) -> str:
base = cfg.site_url + "/"
items = []
for p in posts:
link = base + p.url_path
items.append(
"<item>"
f"<title>{escape(p.title)}</title>"
f"<link>{escape(link)}</link>"
f"<guid>{escape(link)}</guid>"
f"<pubDate>{format_datetime(p.date)}</pubDate>"
+ (f"<dc:creator>{escape(p.author)}</dc:creator>" if p.author else "")
+ f"<description>{escape(excerpt(p.body))}</description>"
"</item>"
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:atom="http://www.w3.org/2005/Atom"><channel>'
f"<title>{escape(site['title'])}</title>"
f"<link>{escape(base)}</link>"
f'<atom:link href="{escape(base)}feed.xml" rel="self" type="application/rss+xml"/>'
f"<description>{escape(site.get('description', ''))}</description>"
f"<language>{escape(site.get('language', 'nb'))}</language>"
+ "".join(items)
+ "</channel></rss>\n"
)

78
soapbox/cli.py Normal file
View file

@ -0,0 +1,78 @@
"""Kommandolinje: ``soapbox serve|build|create-user|set-password``."""
from __future__ import annotations
import argparse
import getpass
import sys
from . import db
from .build import build_site
from .config import load_config
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="soapbox", description="Liten statisk blogg")
sub = parser.add_subparsers(dest="cmd", required=True)
serve = sub.add_parser("serve", help="Start webserveren")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8080)
serve.add_argument("--debug", action="store_true", help="Flask dev-server med reload")
sub.add_parser("build", help="Generer statiske sider på nytt")
cu = sub.add_parser("create-user", help="Opprett bruker")
cu.add_argument("username")
cu.add_argument("--role", choices=[db.ROLE_OWNER, db.ROLE_GUEST], default=db.ROLE_GUEST)
cu.add_argument("--display-name", default="")
cu.add_argument("--password", help="Leses interaktivt hvis utelatt")
sp = sub.add_parser("set-password", help="Sett nytt passord")
sp.add_argument("username")
sp.add_argument("--password")
args = parser.parse_args(argv)
cfg = load_config()
if args.cmd == "serve":
from .app import create_app
app = create_app(cfg)
if args.debug:
app.run(host=args.host, port=args.port, debug=True)
else:
from waitress import serve as waitress_serve
print(f"Soapbox lytter på http://{args.host}:{args.port}{cfg.base_path}/admin/")
waitress_serve(app, host=args.host, port=args.port, threads=4)
return 0
if args.cmd == "build":
from . import gitstore
gitstore.ensure_repo(cfg.content_dir)
n = build_site(cfg)
print(f"Bygde {n} innlegg til {cfg.public_dir}")
return 0
conn = db.connect(cfg.db_path)
password = args.password or getpass.getpass("Passord: ")
if len(password) < 8:
print("Passordet må være minst 8 tegn", file=sys.stderr)
return 1
if args.cmd == "create-user":
db.create_user(conn, args.username, password, args.role, args.display_name)
print(f"Opprettet {args.role} '{args.username}'")
elif args.cmd == "set-password":
user = db.get_user_by_name(conn, args.username)
if not user:
print("Fant ikke brukeren", file=sys.stderr)
return 1
db.set_password(conn, user.id, password)
print("Passord oppdatert")
return 0
if __name__ == "__main__":
sys.exit(main())

96
soapbox/config.py Normal file
View file

@ -0,0 +1,96 @@
"""Konfigurasjon lest fra miljøvariabler.
Alt som varierer mellom installasjoner (katalog, base-path, tema) styres
via ``SOAPBOX_*``-variabler slik at samme container-image kan brukes overalt.
Innholdet i seg selv (tittel, forfatter) ligger i ``content/site.toml`` i
git-repoet, slik at det følger med ved migrering.
"""
from __future__ import annotations
import os
import secrets
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Config:
data_dir: Path
base_path: str # "" eller "/blog" aldri med avsluttende skråstrek
site_url: str # absolutt URL til bloggens rot, brukes kun i feed.xml
theme: str
secret_key: str
max_image_px: int = 2000
site: dict = field(default_factory=dict)
@property
def content_dir(self) -> Path:
return self.data_dir / "content"
@property
def public_dir(self) -> Path:
return self.data_dir / "public"
@property
def db_path(self) -> Path:
return self.data_dir / "soapbox.sqlite"
@property
def theme_dir(self) -> Path:
return Path(__file__).parent / "themes" / self.theme
def _normalize_base_path(raw: str) -> str:
raw = raw.strip()
if raw in ("", "/"):
return ""
return "/" + raw.strip("/")
def load_config(env: dict[str, str] | None = None) -> Config:
env = env if env is not None else os.environ
data_dir = Path(env.get("SOAPBOX_DATA_DIR", "./data")).resolve()
data_dir.mkdir(parents=True, exist_ok=True)
# Hemmelig nøkkel for cookie-signering. Genereres én gang og lagres på disk
# så sesjoner overlever restart uten at brukeren må konfigurere noe.
secret = env.get("SOAPBOX_SECRET_KEY")
if not secret:
key_file = data_dir / "secret_key"
if key_file.exists():
secret = key_file.read_text().strip()
else:
secret = secrets.token_hex(32)
key_file.write_text(secret)
key_file.chmod(0o600)
base_path = _normalize_base_path(env.get("SOAPBOX_BASE_PATH", ""))
site_url = env.get("SOAPBOX_SITE_URL", "http://localhost:8080" + base_path).rstrip("/")
cfg = Config(
data_dir=data_dir,
base_path=base_path,
site_url=site_url,
theme=env.get("SOAPBOX_THEME", "green"),
secret_key=secret,
max_image_px=int(env.get("SOAPBOX_MAX_IMAGE_PX", "2000")),
)
cfg.site = load_site_settings(cfg)
return cfg
DEFAULT_SITE = {
"title": "Soapbox",
"description": "",
"language": "nb",
}
def load_site_settings(cfg: Config) -> dict:
path = cfg.content_dir / "site.toml"
site = dict(DEFAULT_SITE)
if path.exists():
site.update(tomllib.loads(path.read_text(encoding="utf-8")))
return site

173
soapbox/content.py Normal file
View file

@ -0,0 +1,173 @@
"""Lesing og skriving av innlegg som markdown-filer med frontmatter.
Katalogstruktur i ``content/``::
site.toml
posts/<slug>/index.md # hovedbrukerens innlegg → /<slug>/
posts/<slug>/bilde.jpg
guests/<bruker>/<slug>/index.md # gjesteinnlegg → /<bruker>/<slug>/
Bilder ligger i samme mappe som innlegget og refereres relativt
(``![](bilde.jpg)``). Dermed er URL-strukturen identisk med mappestrukturen,
og alt kan flyttes mellom domener og sub-paths uten omskriving.
Frontmatter er et lite ``key: value``-format der verdier skrives som JSON-
strenger. Det gjør parsing triviell uten YAML-avhengighet, og tåler kolon og
anførselstegn i titler.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from .config import Config
OWNER = "_owner" # sentinel: hovedbrukerens innlegg har ingen gjest-prefiks
@dataclass
class Post:
slug: str
title: str
body: str
date: datetime
updated: datetime | None = None
draft: bool = False
author: str = "" # brukernavn; tomt for hovedbruker
guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn
path: Path | None = None
images: list[str] = field(default_factory=list)
@property
def url_path(self) -> str:
"""Sti relativt til bloggens rot, med avsluttende skråstrek."""
return f"{self.guest}/{self.slug}/" if self.guest else f"{self.slug}/"
@property
def id(self) -> str:
"""Stabil identifikator brukt i admin-URL-er."""
return f"{self.guest or OWNER}/{self.slug}"
_FM_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.DOTALL)
def parse_frontmatter(text: str) -> tuple[dict, str]:
m = _FM_RE.match(text)
if not m:
return {}, text
meta = {}
for line in m.group(1).splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
value = value.strip()
try:
meta[key.strip()] = json.loads(value)
except json.JSONDecodeError:
meta[key.strip()] = value
return meta, m.group(2)
def dump_frontmatter(meta: dict, body: str) -> str:
lines = ["---"]
for key, value in meta.items():
if value is None or value == "":
continue
lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}")
lines.append("---")
return "\n".join(lines) + "\n\n" + body.strip() + "\n"
def _parse_dt(value) -> datetime | None:
if not value:
return None
dt = datetime.fromisoformat(value)
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg"}
def load_post(path: Path, guest: str | None) -> Post:
meta, body = parse_frontmatter(path.read_text(encoding="utf-8"))
slug = path.parent.name
images = sorted(p.name for p in path.parent.iterdir() if p.suffix.lower() in IMAGE_EXT)
return Post(
slug=slug,
title=meta.get("title") or slug,
body=body,
date=_parse_dt(meta.get("date")) or datetime.now(UTC),
updated=_parse_dt(meta.get("updated")),
draft=bool(meta.get("draft", False)),
author=meta.get("author", ""),
guest=guest,
path=path,
images=images,
)
def post_dir(cfg: Config, guest: str | None, slug: str) -> Path:
if guest:
return cfg.content_dir / "guests" / guest / slug
return cfg.content_dir / "posts" / slug
def list_posts(cfg: Config, include_drafts: bool = False) -> list[Post]:
posts: list[Post] = []
owner_dir = cfg.content_dir / "posts"
if owner_dir.exists():
for md in owner_dir.glob("*/index.md"):
posts.append(load_post(md, None))
guests_dir = cfg.content_dir / "guests"
if guests_dir.exists():
for md in guests_dir.glob("*/*/index.md"):
posts.append(load_post(md, md.parent.parent.name))
if not include_drafts:
posts = [p for p in posts if not p.draft]
posts.sort(key=lambda p: p.date, reverse=True)
return posts
def get_post(cfg: Config, guest: str | None, slug: str) -> Post | None:
md = post_dir(cfg, guest, slug) / "index.md"
return load_post(md, guest) if md.exists() else None
def existing_slugs(cfg: Config, guest: str | None) -> set[str]:
base = post_dir(cfg, guest, "").parent
return {p.name for p in base.iterdir() if p.is_dir()} if base.exists() else set()
def save_post(cfg: Config, post: Post) -> Path:
d = post_dir(cfg, post.guest, post.slug)
d.mkdir(parents=True, exist_ok=True)
meta = {
"title": post.title,
"date": post.date.isoformat(timespec="seconds"),
"updated": post.updated.isoformat(timespec="seconds") if post.updated else None,
"author": post.author,
"draft": True if post.draft else None,
}
path = d / "index.md"
path.write_text(dump_frontmatter(meta, post.body), encoding="utf-8")
post.path = path
return path
def delete_post(cfg: Config, post: Post) -> None:
import shutil
d = post_dir(cfg, post.guest, post.slug)
if d.exists():
shutil.rmtree(d)
def split_id(post_id: str) -> tuple[str | None, str]:
"""``"_owner/slug"`` → (None, slug); ``"gjest/slug"`` → ("gjest", slug)."""
guest, _, slug = post_id.partition("/")
return (None if guest == OWNER else guest), slug

143
soapbox/db.py Normal file
View file

@ -0,0 +1,143 @@
"""Brukere i sqlite.
Kun brukere og passord-hasher lever i databasen alt innhold ligger i git.
Det gjør migrering enkel: klon innholdsrepoet og opprett brukerne nytt.
Passord hashes med ``hashlib.scrypt`` fra standardbiblioteket for å slippe
en ekstra avhengighet. Parametrene (n=2**15, r=8, p=1) tilsvarer OWASP-
anbefalingene. ``maxmem`` settes eksplisitt fordi OpenSSL ellers avviser
kall som trenger mer enn 32 MB, som er akkurat det disse parametrene bruker.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
ROLE_OWNER = "owner"
ROLE_GUEST = "guest"
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'guest')),
created_at TEXT NOT NULL
);
"""
@dataclass
class User:
id: int
username: str
display_name: str
role: str
@property
def is_owner(self) -> bool:
return self.role == ROLE_OWNER
@property
def guest_prefix(self) -> str | None:
"""Gjester publiserer under /<brukernavn>/, hovedbruker under /."""
return None if self.is_owner else self.username
def connect(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
return conn
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=2**15, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024
)
return f"scrypt${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, salt_hex, digest_hex = stored.split("$")
except ValueError:
return False
digest = hashlib.scrypt(
password.encode(),
salt=bytes.fromhex(salt_hex),
n=2**15,
r=8,
p=1,
dklen=32,
maxmem=64 * 1024 * 1024,
)
return hmac.compare_digest(digest.hex(), digest_hex)
def _row_to_user(row: sqlite3.Row | None) -> User | None:
if row is None:
return None
return User(row["id"], row["username"], row["display_name"], row["role"])
def create_user(
conn: sqlite3.Connection, username: str, password: str, role: str, display_name: str = ""
) -> User:
conn.execute(
"INSERT INTO users (username, display_name, password_hash, role, created_at) VALUES (?,?,?,?,?)",
(
username,
display_name or username,
hash_password(password),
role,
datetime.now(UTC).isoformat(timespec="seconds"),
),
)
conn.commit()
return get_user_by_name(conn, username)
def get_user(conn: sqlite3.Connection, user_id: int) -> User | None:
return _row_to_user(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone())
def get_user_by_name(conn: sqlite3.Connection, username: str) -> User | None:
return _row_to_user(
conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
)
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> User | None:
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if row and verify_password(password, row["password_hash"]):
return _row_to_user(row)
return None
def set_password(conn: sqlite3.Connection, user_id: int, password: str) -> None:
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), user_id))
conn.commit()
def list_users(conn: sqlite3.Connection) -> list[User]:
rows = conn.execute("SELECT * FROM users ORDER BY role, username").fetchall()
return [_row_to_user(r) for r in rows]
def delete_user(conn: sqlite3.Connection, user_id: int) -> None:
conn.execute("DELETE FROM users WHERE id=? AND role='guest'", (user_id,))
conn.commit()
def has_owner(conn: sqlite3.Connection) -> bool:
return conn.execute("SELECT 1 FROM users WHERE role='owner' LIMIT 1").fetchone() is not None

45
soapbox/gitstore.py Normal file
View file

@ -0,0 +1,45 @@
"""Versjonering av innholdskatalogen med git.
Vi kaller git-binæren direkte i stedet for å dra inn pygit2/dulwich: git
finnes uansett i containeren, og et lite subprocess-lag er lettere å forstå
og feilsøke. Hver lagring i editoren blir én commit med brukeren som forfatter,
historikken i git er en fullverdig revisjonslogg for bloggen.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(repo), *args],
check=check,
capture_output=True,
text=True,
)
def ensure_repo(repo: Path) -> None:
repo.mkdir(parents=True, exist_ok=True)
if not (repo / ".git").exists():
_git(repo, "init", "-q", "-b", "main")
# Uten identitet nekter git å committe. Vi setter en lokal fallback;
# hver commit overstyrer uansett forfatter med den innloggede brukeren.
_git(repo, "config", "user.name", "soapbox")
_git(repo, "config", "user.email", "soapbox@localhost")
def commit_all(repo: Path, message: str, author: str) -> bool:
"""Legger til alle endringer og committer. Returnerer False hvis ingenting endret."""
_git(repo, "add", "-A")
if _git(repo, "diff", "--cached", "--quiet", check=False).returncode == 0:
return False
_git(repo, "commit", "-q", "-m", message, f"--author={author} <{author}@soapbox>")
return True
def log(repo: Path, limit: int = 20) -> list[str]:
out = _git(repo, "log", f"-{limit}", "--format=%h %ad %an: %s", "--date=short", check=False)
return out.stdout.splitlines() if out.returncode == 0 else []

61
soapbox/images.py Normal file
View file

@ -0,0 +1,61 @@
"""Behandling av opplastede bilder.
Mobiltelefoner leverer gjerne 12 MP-bilder 5 MB med GPS-koordinater i
EXIF. Vi skalerer ned til ``max_px`` lengste side, roterer i henhold til
EXIF-orientering og lagrer *uten* metadata både for filstørrelse og for
personvern (posisjonen der bildet ble tatt skal ikke publiseres).
"""
from __future__ import annotations
import io
import re
from PIL import Image, ImageOps, UnidentifiedImageError
from .slug import slugify
ALLOWED = {"JPEG": ".jpg", "PNG": ".png", "GIF": ".gif", "WEBP": ".webp"}
class ImageError(ValueError):
pass
def safe_filename(name: str, ext: str, taken: set[str]) -> str:
stem = slugify(re.sub(r"\.[^.]+$", "", name)) or "bilde"
candidate = stem + ext
n = 2
while candidate in taken:
candidate = f"{stem}-{n}{ext}"
n += 1
return candidate
def process_image(data: bytes, max_px: int) -> tuple[bytes, str]:
"""Returnerer (bytes, filendelse) for et nedskalert bilde uten EXIF."""
try:
img = Image.open(io.BytesIO(data))
img.load()
except (UnidentifiedImageError, OSError) as e:
raise ImageError("Filen er ikke et gyldig bilde") from e
fmt = img.format
if fmt not in ALLOWED:
raise ImageError(f"Bildeformatet {fmt} støttes ikke (bruk JPEG, PNG, GIF eller WebP)")
if fmt == "GIF":
# Animerte GIF-er beholdes urørt; nedskalering ville drept animasjonen.
return data, ALLOWED[fmt]
img = ImageOps.exif_transpose(img) # roter etter EXIF før vi kaster EXIF
img.thumbnail((max_px, max_px)) # bevarer aspekt, forstørrer aldri
out = io.BytesIO()
if fmt == "JPEG":
img = img.convert("RGB")
img.save(out, "JPEG", quality=85, optimize=True, progressive=True)
elif fmt == "PNG":
img.save(out, "PNG", optimize=True)
else:
img.save(out, "WEBP", quality=85)
return out.getvalue(), ALLOWED[fmt]

73
soapbox/render.py Normal file
View file

@ -0,0 +1,73 @@
"""Markdown → HTML, med omskriving av interne lenker til relative stier.
Sikkerhet: HTML i markdown er slått av (``html=False``) fordi gjestebrukere
skriver innhold. Alt som ikke er markdown blir escapet.
Relative lenker: hvert innlegg vet sin egen dybde i URL-treet. Absolutte
interne lenker som forfatteren skriver (``/annet-innlegg/``) skrives om til
``../annet-innlegg/`` slik at siden fungerer både ``domene.no/`` og
``domene.no/blog/`` uten rebuild.
"""
from __future__ import annotations
import posixpath
from urllib.parse import urlsplit
from markdown_it import MarkdownIt
def make_markdown() -> MarkdownIt:
md = MarkdownIt("commonmark", {"html": False, "linkify": True, "typographer": False})
md.enable(["table", "strikethrough", "linkify"])
return md
_md = make_markdown()
def relative_to(page_path: str, target: str) -> str:
"""Relativ sti fra siden ``page_path`` (f.eks. ``"gjest/post/"``) til ``target``.
Begge er stier relativt til bloggens rot. Rot-siden har ``page_path == ""``.
Mapper (som ender med ``/``) beholder avsluttende skråstrek.
"""
page_dir = posixpath.dirname(page_path.rstrip("/") + "/x") if page_path else ""
# posixpath.relpath trenger absolutte-aktige stier for å regne riktig
rel = posixpath.relpath("/" + target.lstrip("/"), "/" + page_dir)
if target.endswith("/") or target in ("", "/"):
rel = rel + "/" if rel != "." else "./"
return rel
def rewrite_internal_links(html: str, page_path: str) -> str:
"""Skriver om href/src som starter med ``/`` (men ikke ``//``) til relative."""
import re
def repl(m: re.Match) -> str:
attr, quote, url = m.group(1), m.group(2), m.group(3)
parts = urlsplit(url)
if parts.scheme or parts.netloc or not url.startswith("/") or url.startswith("//"):
return m.group(0)
new = relative_to(page_path, parts.path)
if parts.query:
new += "?" + parts.query
if parts.fragment:
new += "#" + parts.fragment
return f"{attr}={quote}{new}{quote}"
return re.sub(r'\b(href|src)=(["\'])([^"\']*)\2', repl, html)
def render_markdown(text: str, page_path: str = "") -> str:
return rewrite_internal_links(_md.render(text), page_path)
def excerpt(text: str, limit: int = 300) -> str:
"""Første avsnitt som ren tekst, til innholdslister og feed."""
for para in text.split("\n\n"):
para = para.strip()
if para and not para.startswith(("#", "!", "```", "|")):
plain = " ".join(para.split())
return plain if len(plain) <= limit else plain[:limit].rsplit(" ", 1)[0] + ""
return ""

31
soapbox/slug.py Normal file
View file

@ -0,0 +1,31 @@
"""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").
"""
from __future__ import annotations
import re
import unicodedata
_NORDIC = str.maketrans(
{"æ": "ae", "ø": "oe", "å": "aa", "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}
)
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("-")
return text or "post"
def unique_slug(base: str, taken: set[str]) -> str:
"""Returnerer ``base`` eller ``base-2``, ``base-3`` … til den er ledig."""
if base not in taken:
return base
n = 2
while f"{base}-{n}" in taken:
n += 1
return f"{base}-{n}"

View file

@ -0,0 +1,71 @@
/* Admin-grensesnitt. Mobil først; editor og forhåndsvisning side om side fra 900px. */
:root {
--bg: #f4f8f3; --fg: #1c2a1e; --muted: #4d6350; --accent: #1f6b3a; --accent-strong: #154d29;
--surface: #fff; --border: #c9dccb; --code-bg: #e6efe6; --danger: #a12626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101a12; --fg: #e4ede4; --muted: #a7bda9; --accent: #7fd39a; --accent-strong: #a9e6bb;
--surface: #182619; --border: #2f4532; --code-bg: #1e2f20; --danger: #ff8a8a;
}
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; }
.skip { position: absolute; left: -999px; } .skip:focus { left: 0; background: var(--accent); color: #fff; padding: .5rem; z-index: 10; }
a { color: var(--accent); }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.topbar { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; padding: .6rem 1rem; border-bottom: 3px solid var(--accent); }
.topbar nav { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: center; }
.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; }
main { max-width: 70rem; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.5rem; margin: .5rem 0; }
.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; }
.grow { flex: 1 1 18rem; }
.stack { display: flex; flex-direction: column; gap: .75rem; }
.narrow { max-width: 28rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-weight: 600; }
input, textarea, button { font: inherit; }
input[type=text], input:not([type]), input[type=password], textarea {
padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%;
}
button { padding: .55rem .9rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--accent-strong); cursor: pointer; min-height: 44px; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.link { border: none; background: none; padding: .25rem; min-height: auto; text-decoration: underline; color: var(--accent); }
button.danger { color: var(--danger); }
.inline { display: inline; }
.meta { color: var(--muted); font-size: .9rem; }
.badge { background: var(--accent); color: #fff; padding: .05rem .45rem; border-radius: 999px; font-size: .8rem; }
.flash { padding: .6rem .9rem; border-radius: 4px; border-left: 4px solid var(--accent); background: var(--surface); }
.flash.error { border-color: var(--danger); }
.posts, .users { list-style: none; padding: 0; }
.posts li, .users li { padding: .6rem 0; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; }
/* Editor */
.tabs { display: flex; gap: .25rem; margin-top: 1rem; }
.tabs button { border-radius: 4px 4px 0 0; border-bottom: none; }
.tabs button[aria-selected=true] { background: var(--accent); color: #fff; }
.panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; }
.toolbar { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; }
.toolbar button { min-height: 40px; padding: .3rem .6rem; }
textarea#body { min-height: 50vh; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; }
textarea#body.dragover { outline: 3px dashed var(--accent); }
.preview { min-height: 50vh; padding: 0 .5rem; overflow-wrap: anywhere; }
.preview img { max-width: 100%; height: auto; }
.preview pre, .preview code { background: var(--code-bg); }
.preview pre { padding: 1rem; overflow-x: auto; }
.preview table { border-collapse: collapse; } .preview td, .preview th { border: 1px solid var(--border); padding: .3rem .5rem; }
.preview blockquote { border-left: 4px solid var(--accent); margin: 1rem 0; padding: .25rem 1rem; color: var(--muted); }
.actions { margin-top: 1rem; justify-content: flex-start; }
@media (min-width: 900px) {
.tabs { display: none; }
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.panes section { border-radius: 4px; }
.panes section[hidden] { display: block; }
}
.images ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 1rem; }
.images li { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; }
.images img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
.danger-zone { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1rem; }
pre.log { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; }
dl.meta dt { font-weight: 600; margin-top: .5rem; } dl.meta dd { margin: 0; }

View file

@ -0,0 +1,124 @@
/* Markdown-editor: toolbar, live forhåndsvisning og bildeopplasting.
* Ingen avhengigheter. Forhåndsvisningen rendres serveren (samme kode som
* publisert side) med ~400 ms debounce, det som vises er det som publiseres. */
(function () {
"use strict";
const form = document.getElementById("editor");
const ta = document.getElementById("body");
const preview = document.getElementById("preview");
const status = document.getElementById("upload-status");
const csrf = form.dataset.csrf;
let dirty = false;
// ---- tabs (kun synlige på små skjermer) ----
const tabs = form.querySelectorAll('[role="tab"]');
tabs.forEach((tab) =>
tab.addEventListener("click", () => {
tabs.forEach((t) => {
const on = t === tab;
t.setAttribute("aria-selected", on);
document.getElementById(t.getAttribute("aria-controls")).hidden = !on;
});
if (tab.id === "tab-preview") renderPreview();
})
);
// ---- forhåndsvisning ----
let timer = null;
let lastBody = null;
async function renderPreview() {
if (ta.value === lastBody) return;
lastBody = ta.value;
try {
const res = await fetch(form.dataset.previewUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF": csrf },
body: JSON.stringify({ body: ta.value }),
});
preview.innerHTML = res.ok ? await res.text() : "<p>Kunne ikke lage forhåndsvisning.</p>";
} catch (e) {
preview.innerHTML = "<p>Kunne ikke lage forhåndsvisning (frakoblet?).</p>";
}
}
ta.addEventListener("input", () => {
dirty = true;
clearTimeout(timer);
timer = setTimeout(renderPreview, 400);
});
renderPreview();
// ---- tekstinnsetting ----
function insert(before, after, placeholder) {
const s = ta.selectionStart, e = ta.selectionEnd;
const sel = ta.value.slice(s, e) || placeholder || "";
ta.setRangeText(before + sel + after, s, e, "select");
ta.setSelectionRange(s + before.length, s + before.length + sel.length);
ta.focus();
ta.dispatchEvent(new Event("input"));
}
function prefixLines(prefix) {
const s = ta.selectionStart, e = ta.selectionEnd;
const start = ta.value.lastIndexOf("\n", s - 1) + 1;
const block = ta.value.slice(start, e);
const out = block.split("\n").map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : prefix + l)).join("\n");
ta.setRangeText(out, start, e, "end");
ta.focus();
ta.dispatchEvent(new Event("input"));
}
form.querySelectorAll(".toolbar button").forEach((b) => {
b.addEventListener("click", () => {
if (b.dataset.wrap !== undefined) insert(b.dataset.wrap, b.dataset.wrap, "tekst");
else if (b.dataset.prefix !== undefined) prefixLines(b.dataset.prefix);
else if (b.dataset.link !== undefined) {
const url = prompt("Lenkeadresse (bruk /slug/ for interne lenker):", "https://");
if (url) insert("[", "](" + url + ")", "lenketekst");
}
});
});
ta.addEventListener("keydown", (ev) => {
if (!(ev.ctrlKey || ev.metaKey)) return;
if (ev.key === "b") { ev.preventDefault(); insert("**", "**", "tekst"); }
if (ev.key === "i") { ev.preventDefault(); insert("_", "_", "tekst"); }
});
// ---- bildeopplasting: knapp, dra-og-slipp og lim inn ----
const fileInput = document.getElementById("file-input");
document.getElementById("btn-image").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => { uploadAll(fileInput.files); fileInput.value = ""; });
ta.addEventListener("dragover", (ev) => { ev.preventDefault(); ta.classList.add("dragover"); });
ta.addEventListener("dragleave", () => ta.classList.remove("dragover"));
ta.addEventListener("drop", (ev) => {
ev.preventDefault(); ta.classList.remove("dragover");
uploadAll(ev.dataTransfer.files);
});
ta.addEventListener("paste", (ev) => {
const files = [...(ev.clipboardData?.items || [])].filter((i) => i.kind === "file").map((i) => i.getAsFile());
if (files.length) { ev.preventDefault(); uploadAll(files); }
});
async function uploadAll(files) {
for (const f of files) await upload(f);
}
async function upload(file) {
if (!file.type.startsWith("image/")) { status.textContent = "Bare bilder kan lastes opp."; return; }
status.textContent = "Laster opp " + file.name + " …";
const fd = new FormData();
fd.append("file", file, file.name);
try {
const res = await fetch(form.dataset.uploadUrl, { method: "POST", headers: { "X-CSRF": csrf }, body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
// Bilder skal stå på egen linje for å bli egne avsnitt i Markdown.
const before = ta.value.slice(0, ta.selectionStart);
const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
insert(lead + data.markdown + "\n", "", "");
status.textContent = "Lastet opp " + data.filename + ". Husk å lagre.";
} catch (e) {
status.textContent = "Opplasting feilet: " + e.message;
}
}
// ---- ikke mist tekst ved uhell ----
form.addEventListener("submit", () => { dirty = false; });
window.addEventListener("beforeunload", (ev) => { if (dirty) { ev.preventDefault(); ev.returnValue = ""; } });
})();

View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="nb">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin{% endblock %} {{ site.title }}</title>
<link rel="stylesheet" href="{{ url_for('admin.static', filename='admin.css') }}">
</head>
<body>
<a class="skip" href="#main">Hopp til innhold</a>
<header class="topbar">
<a class="brand" href="{{ url_for('admin.index') }}">{{ site.title }}</a>
{% if user %}
<nav aria-label="Admin-meny">
<a href="{{ url_for('admin.index') }}">Innlegg</a>
{% if user.is_owner %}
<a href="{{ url_for('admin.users') }}">Brukere</a>
<a href="{{ url_for('admin.settings') }}">Innstillinger</a>
{% endif %}
<a href="{{ url_for('admin.password') }}">Passord</a>
<a href="{{ url_for('public') }}">Se bloggen</a>
<form method="post" action="{{ url_for('admin.logout') }}" class="inline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit" class="link">Logg ut ({{ user.username }})</button>
</form>
</nav>
{% endif %}
</header>
<main id="main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}<p class="flash {{ cat }}" role="status">{{ msg }}</p>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,77 @@
{% extends "admin/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<form method="post" id="editor" class="editor" data-preview-url="{{ url_for('admin.preview', post_id=post.id) }}"
data-upload-url="{{ url_for('admin.upload', post_id=post.id) }}" data-csrf="{{ csrf }}">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row">
<label class="grow">Tittel <input name="title" value="{{ post.title }}" required></label>
<span class="meta">
{% if post.draft %}<span class="badge">utkast</span> URL: /{{ post.url_path }} (følger tittelen)
{% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">/{{ post.url_path }}</a>{% endif %}
</span>
</div>
<div class="tabs" role="tablist" aria-label="Visning">
<button type="button" role="tab" aria-selected="true" aria-controls="pane-write" id="tab-write">Skriv</button>
<button type="button" role="tab" aria-selected="false" aria-controls="pane-preview" id="tab-preview">Forhåndsvisning</button>
</div>
<div class="panes">
<section id="pane-write" role="tabpanel" aria-labelledby="tab-write">
<div class="toolbar" role="toolbar" aria-label="Formatering">
<button type="button" data-wrap="**" title="Fet (Ctrl+B)"><b>B</b></button>
<button type="button" data-wrap="_" title="Kursiv (Ctrl+I)"><i>I</i></button>
<button type="button" data-prefix="## " title="Overskrift">H2</button>
<button type="button" data-prefix="- " title="Liste">• liste</button>
<button type="button" data-prefix="> " title="Sitat">“ sitat</button>
<button type="button" data-link title="Lenke">🔗</button>
<button type="button" id="btn-image" title="Last opp bilde">📷 Bilde</button>
<input type="file" id="file-input" accept="image/*" multiple hidden>
</div>
<textarea name="body" id="body" rows="18" spellcheck="true"
placeholder="Skriv i Markdown. Dra inn eller lim inn bilder for å laste dem opp.">{{ post.body }}</textarea>
<p class="meta" id="upload-status" role="status" aria-live="polite"></p>
</section>
<section id="pane-preview" role="tabpanel" aria-labelledby="tab-preview" hidden>
<div class="preview body" id="preview" aria-live="polite"><p class="meta">Forhåndsvisningen kommer her.</p></div>
</section>
</div>
<div class="row actions">
<button type="submit" name="action" value="save" class="primary">Lagre</button>
{% if post.draft %}
<button type="submit" name="action" value="publish">Publiser</button>
{% else %}
<button type="submit" name="action" value="unpublish">Avpubliser</button>
{% endif %}
</div>
</form>
{% if post.images %}
<details class="images">
<summary>Bilder i dette innlegget ({{ post.images|length }})</summary>
<ul>
{% for name in post.images %}
<li>
<img src="{{ url_for('admin.media', post_id=post.id, filename=name) }}" alt="" loading="lazy">
<code>![]({{ name }})</code>
<form method="post" action="{{ url_for('admin.delete_image', post_id=post.id) }}" class="inline"
onsubmit="return confirm('Slette {{ name }}?')">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="filename" value="{{ name }}">
<button type="submit" class="link danger">Slett</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<form method="post" action="{{ url_for('admin.delete', post_id=post.id) }}" class="danger-zone"
onsubmit="return confirm('Slette hele innlegget «{{ post.title }}»?')">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit" class="link danger">Slett innlegget</button>
</form>
<script src="{{ url_for('admin.static', filename='editor.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "admin/base.html" %}
{% block title %}Innlegg{% endblock %}
{% block content %}
<div class="row">
<h1>Innlegg</h1>
<form method="post" action="{{ url_for('admin.new') }}">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit" class="primary">+ Nytt innlegg</button>
</form>
</div>
{% if not posts %}<p>Ingen innlegg ennå. Trykk «Nytt innlegg» for å komme i gang.</p>{% endif %}
<ul class="posts">
{% for p in posts %}
<li>
<a href="{{ url_for('admin.edit', post_id=p.id) }}">{{ p.title }}</a>
<span class="meta">
{% if p.draft %}<span class="badge">utkast</span>{% endif %}
{{ p.date.strftime('%d.%m.%Y') }}
{% if p.guest %}· {{ p.author or p.guest }} (/{{ p.guest }}/){% endif %}
</span>
</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "admin/base.html" %}
{% block title %}Logg inn{% endblock %}
{% block content %}
<h1>Logg inn</h1>
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="next" value="{{ next }}">
<label>Brukernavn <input name="username" autocomplete="username" autocapitalize="none" required autofocus></label>
<label>Passord <input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit" class="primary">Logg inn</button>
</form>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "admin/base.html" %}
{% block title %}Bytt passord{% endblock %}
{% block content %}
<h1>Bytt passord</h1>
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label>Nåværende passord <input type="password" name="current" required autocomplete="current-password"></label>
<label>Nytt passord <input type="password" name="password" minlength="8" required autocomplete="new-password"></label>
<button type="submit" class="primary">Bytt passord</button>
</form>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "admin/base.html" %}
{% block title %}Innstillinger{% endblock %}
{% block content %}
<h1>Innstillinger</h1>
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label>Bloggens tittel <input name="title" value="{{ site.title }}" required></label>
<label>Beskrivelse <input name="description" value="{{ site.description }}"></label>
<label>Språk (lang-attributt) <input name="language" value="{{ site.language }}" size="5"></label>
<button type="submit" class="primary">Lagre</button>
</form>
<h2>Drift</h2>
<dl class="meta">
<dt>Base-path</dt><dd><code>{{ cfg.base_path or '/' }}</code></dd>
<dt>Site-URL (for RSS)</dt><dd><code>{{ cfg.site_url }}</code></dd>
<dt>Tema</dt><dd><code>{{ cfg.theme }}</code></dd>
<dt>Innhold</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd>
</dl>
<form method="post" action="{{ url_for('admin.rebuild') }}">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">Bygg siden på nytt</button>
</form>
<h2>Siste endringer i git</h2>
<pre class="log">{% for line in log %}{{ line }}
{% endfor %}</pre>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "admin/base.html" %}
{% block title %}Brukere{% endblock %}
{% block content %}
<h1>Brukere</h1>
<ul class="users">
{% for u in users %}
<li>
<strong>{{ u.display_name }}</strong> <code>{{ u.username }}</code>
{% if u.is_owner %}<span class="badge">hovedbruker</span>{% else %}<span class="meta">/{{ u.username }}/</span>{% endif %}
{% if not u.is_owner %}
<details>
<summary>Endre</summary>
<form method="post" class="inline stack">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="action" value="password">
<input type="hidden" name="user_id" value="{{ u.id }}">
<label>Nytt passord <input type="password" name="password" minlength="8" required autocomplete="new-password"></label>
<button type="submit">Sett passord</button>
</form>
<form method="post" class="inline" onsubmit="return confirm('Slette brukeren {{ u.username }}? Innleggene beholdes i git.')">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button type="submit" class="link danger">Slett bruker</button>
</form>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
<h2>Ny gjestebruker</h2>
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="action" value="create">
<label>Brukernavn (blir URL: /brukernavn/) <input name="username" pattern="[a-z0-9][a-z0-9-]{1,30}" required autocapitalize="none"></label>
<label>Visningsnavn <input name="display_name"></label>
<label>Passord <input type="password" name="password" minlength="8" required autocomplete="new-password"></label>
<button type="submit" class="primary">Opprett</button>
</form>
{% endblock %}

View file

@ -0,0 +1,74 @@
/* Tema: "grønt er skjønt".
Alle farger er CSS-variabler; et nytt tema kan nøye seg med å overstyre :root.
Kontrast: tekst/bakgrunn 7:1 (AAA), lenker 4.5:1 begge skjemaer. */
:root {
--bg: #f4f8f3;
--fg: #1c2a1e;
--muted: #4d6350;
--accent: #1f6b3a;
--accent-strong: #154d29;
--surface: #ffffff;
--border: #c9dccb;
--code-bg: #e6efe6;
--max-width: 42rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101a12;
--fg: #e4ede4;
--muted: #a7bda9;
--accent: #7fd39a;
--accent-strong: #a9e6bb;
--surface: #182619;
--border: #2f4532;
--code-bg: #1e2f20;
}
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
* { box-sizing: border-box; }
html { font-size: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
}
.skip {
position: absolute; left: -999px; top: 0;
background: var(--accent); color: #fff; padding: .5rem 1rem;
}
.skip:focus { left: 0; z-index: 10; }
.site-header, main, .site-footer {
max-width: var(--max-width);
margin: 0 auto;
padding: 1rem;
}
.site-header { border-bottom: 3px solid var(--accent); padding-top: 1.5rem; }
.site-title {
font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--accent-strong);
}
.site-description { margin: .25rem 0 0; color: var(--muted); }
a { color: var(--accent); }
a:hover { color: var(--accent-strong); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid var(--accent); outline-offset: 2px;
}
h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .25rem; }
h2 { font-size: 1.35rem; margin: 1.25rem 0 .25rem; }
.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1rem; }
.summary { padding: .5rem 0; border-bottom: 1px solid var(--border); }
.summary h2 a { text-decoration: none; }
.summary h2 a:hover { text-decoration: underline; }
.body img { max-width: 100%; height: auto; border-radius: 4px; }
.body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }
.body code { background: var(--code-bg); padding: .1em .3em; border-radius: 3px; font-size: .95em; }
.body pre code { background: none; padding: 0; }
.body blockquote {
margin: 1rem 0; padding: .25rem 1rem; border-left: 4px solid var(--accent); color: var(--muted);
}
.body table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
.body th, .body td { border: 1px solid var(--border); padding: .4rem .6rem; text-align: left; }
.site-footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .9rem; margin-top: 2rem; }

View file

@ -0,0 +1,28 @@
<!doctype html>
<html lang="{{ site.language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site.title }}{% endblock %}</title>
{% if site.description %}<meta name="description" content="{{ site.description }}">{% endif %}
<link rel="stylesheet" href="{{ rel('theme/style.css') }}">
<link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="{{ rel('feed.xml') }}">
</head>
<body>
<a class="skip" href="#main">Hopp til innhold</a>
<header class="site-header">
<a class="site-title" href="{{ rel('') }}">{{ site.title }}</a>
{% if site.description %}<p class="site-description">{{ site.description }}</p>{% endif %}
</header>
<main id="main">
{% block content %}{% endblock %}
</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>
</nav>
</footer>
</body>
</html>

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{% if heading %}{{ heading }} {% endif %}{{ site.title }}{% endblock %}
{% block content %}
{% if heading %}<h1>{{ heading }}</h1>{% endif %}
{% if not posts %}<p>Ingen innlegg ennå.</p>{% endif %}
{% for post in posts %}
<article class="summary">
<h2><a href="{{ rel(post.url_path) }}">{{ post.title }}</a></h2>
<p class="meta">
<time datetime="{{ post.date.isoformat() }}">{{ post.date|date }}</time>
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author or post.guest }}</a>{% endif %}
</p>
</article>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} {{ site.title }}{% endblock %}
{% block content %}
<article class="post">
<h1>{{ post.title }}</h1>
<p class="meta">
<time datetime="{{ post.date.isoformat() }}">{{ post.date|date }}</time>
{% if post.updated %} · oppdatert <time datetime="{{ post.updated.isoformat() }}">{{ post.updated|date }}</time>{% endif %}
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author or post.guest }}</a>{% endif %}
</p>
<div class="body">
{{ html|safe }}
</div>
</article>
{% endblock %}

46
tests/conftest.py Normal file
View file

@ -0,0 +1,46 @@
import io
import pytest
from PIL import Image
from soapbox import db
from soapbox.app import create_app
from soapbox.config import load_config
@pytest.fixture
def cfg(tmp_path):
return load_config(
{"SOAPBOX_DATA_DIR": str(tmp_path / "data"), "SOAPBOX_SITE_URL": "https://blog.example.no"}
)
@pytest.fixture
def app(cfg):
app = create_app(cfg)
app.config["TESTING"] = True
conn = db.connect(cfg.db_path)
db.create_user(conn, "eier", "hemmelig123", db.ROLE_OWNER, "Ole")
db.create_user(conn, "gjest1", "hemmelig123", db.ROLE_GUEST, "Gjest En")
conn.close()
return app
@pytest.fixture
def client(app):
return app.test_client()
def login(client, username="eier", password="hemmelig123", prefix=""):
return client.post(f"{prefix}/admin/login", data={"username": username, "password": password})
def csrf(client, prefix=""):
with client.session_transaction(path=f"{prefix}/admin/") as s:
return s["csrf"]
def png_bytes(size=(3000, 1500)):
buf = io.BytesIO()
Image.new("RGB", size, "green").save(buf, "PNG")
return buf.getvalue()

136
tests/test_app.py Normal file
View file

@ -0,0 +1,136 @@
import re
from soapbox.app import create_app
from soapbox.config import load_config
from tests.conftest import csrf, login, png_bytes
def _create_post(client, prefix=""):
login(client, prefix=prefix)
r = client.post(f"{prefix}/admin/new", data={"csrf": csrf(client)})
assert r.status_code == 302
post_id = r.headers["Location"].split("/admin/edit/")[1]
return post_id
def test_login_required(client):
assert client.get("/admin/").status_code == 302
assert client.get("/admin/login").status_code == 200
assert client.get("/admin/static/admin.css").content_type.startswith("text/css")
assert client.get("/admin/static/editor.js").status_code == 200
def test_full_flow_owner(client, cfg):
post_id = _create_post(client)
assert post_id == "_owner/nytt-innlegg"
token = csrf(client)
# Last opp et bilde
r = client.post(
f"/admin/upload/{post_id}",
data={"file": (__import__("io").BytesIO(png_bytes()), "Mitt Bilde.PNG")},
headers={"X-CSRF": token},
content_type="multipart/form-data",
)
assert r.status_code == 200, r.data
assert r.json["filename"] == "mitt-bilde.png"
# Forhåndsvisning peker bildet til admin-media
r = client.post(
f"/admin/preview/{post_id}",
json={"body": "![](mitt-bilde.png) [x](/annet/)"},
headers={"X-CSRF": token},
)
assert f'src="/admin/media/{post_id}/mitt-bilde.png"' in r.text
assert 'href="../annet/"' in r.text
# Publiser: slug følger tittelen
body = "Hei **verden**\n\n![](mitt-bilde.png)\n\n[hjem](/)"
r = client.post(
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()
# 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
assert "<strong>verden</strong>" in html
assert 'href="../theme/style.css"' in html
assert 'href="../"' in html # intern lenke omskrevet til relativ
assert client.get("/groent-er-skjoent/mitt-bilde.png").status_code == 200
assert "Grønt er skjønt" in client.get("/").text
feed = client.get("/feed.xml").text
assert "https://blog.example.no/groent-er-skjoent/" in feed
# Etter publisering fryses slug selv om tittelen endres
r = client.post(
"/admin/edit/_owner/groent-er-skjoent",
data={"csrf": token, "title": "Ny tittel", "body": body, "action": "save"},
)
assert r.headers["Location"].endswith("/admin/edit/_owner/groent-er-skjoent")
# Git-historikk
from soapbox.gitstore import log
lines = log(cfg.content_dir)
assert any("Publiserte: Grønt er skjønt" in line for line in lines)
assert all("eier:" in line for line in lines)
def test_guest_flow_and_isolation(client, cfg):
post_id = _create_post(client) # eierens utkast
client.post("/admin/logout", data={"csrf": csrf(client)})
login(client, "gjest1")
token = csrf(client)
# Gjest kan ikke røre eierens innlegg
assert client.get(f"/admin/edit/{post_id}").status_code == 403
assert client.get("/admin/users").status_code == 403
r = client.post("/admin/new", data={"csrf": token})
gid = r.headers["Location"].split("/admin/edit/")[1]
assert gid == "gjest1/nytt-innlegg"
client.post(
f"/admin/edit/{gid}",
data={"csrf": token, "title": "Post 1", "body": "hei", "action": "publish"},
)
assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists()
html = client.get("/gjest1/post-1/").text
assert 'href="../../theme/style.css"' in html
assert "Gjest En" in html
assert "Post 1" in client.get("/gjest1/").text
assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden
# eierens utkast er ikke publisert
assert client.get("/nytt-innlegg/").status_code == 404
def test_csrf_enforced(client):
login(client)
assert client.post("/admin/new", data={}).status_code == 403
def test_subpath_mount(tmp_path):
cfg = load_config({"SOAPBOX_DATA_DIR": str(tmp_path / "d"), "SOAPBOX_BASE_PATH": "/blog/"})
assert cfg.base_path == "/blog"
app = create_app(cfg)
app.config["TESTING"] = True
from soapbox import db
conn = db.connect(cfg.db_path)
db.create_user(conn, "eier", "hemmelig123", db.ROLE_OWNER)
conn.close()
client = app.test_client()
assert client.get("/admin/login").status_code == 404
assert client.get("/blog/admin/login").status_code == 200
login(client, prefix="/blog")
r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")})
assert r.headers["Location"].startswith("/blog/admin/edit/")
html = client.get("/blog/").text
assert 'href="theme/style.css"' in html
assert re.search(r'href="admin/"', html)
assert client.get("/blog/theme/style.css").status_code == 200

56
tests/test_core.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import UTC, datetime
from soapbox import db
from soapbox.content import dump_frontmatter, parse_frontmatter
from soapbox.images import process_image
from soapbox.render import relative_to, render_markdown
from soapbox.slug import slugify, unique_slug
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(" ") == "post"
assert unique_slug("a", {"a", "a-2"}) == "a-3"
def test_frontmatter_roundtrip():
meta = {"title": 'Tittel: med "anførsel"', "date": "2026-01-01T00:00:00+00:00", "draft": True}
text = dump_frontmatter(meta, "# Hei\n\nbrødtekst")
parsed, body = parse_frontmatter(text)
assert parsed == meta
assert body.strip() == "# Hei\n\nbrødtekst"
def test_relative_paths():
assert relative_to("", "post1/") == "post1/"
assert relative_to("post1/", "") == "../"
assert relative_to("gjest/post1/", "post2/") == "../../post2/"
assert relative_to("gjest/post1/", "theme/style.css") == "../../theme/style.css"
def test_markdown_safe_and_relative():
html = render_markdown("<script>x</script>\n\n[a](/post2/#x) ![](b.jpg)", "g/p/")
assert "<script>" not in html
assert 'href="../../post2/#x"' in html
assert 'src="b.jpg"' in html
assert "//example.com" in render_markdown("[x](//example.com)")
def test_image_downscale():
data, ext = process_image(png_bytes((3000, 1500)), 2000)
import io
from PIL import Image
img = Image.open(io.BytesIO(data))
assert ext == ".png" and img.size == (2000, 1000)
def test_password_hashing(tmp_path):
conn = db.connect(tmp_path / "x.sqlite")
db.create_user(conn, "a", "passord123", db.ROLE_OWNER)
assert db.authenticate(conn, "a", "passord123")
assert db.authenticate(conn, "a", "feil") is None
assert db.has_owner(conn)
assert datetime.now(UTC) # sanity: UTC alias tilgjengelig

392
uv.lock generated Normal file
View file

@ -0,0 +1,392 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/3e/79f35b8c31a1881893b7e62be80b2573f06e38db47c33065749293ee1b97/linkify_it_py-2.1.1.tar.gz", hash = "sha256:a78f40fee177eb912e9d2375074108378523c38d3fde5d3ee804f465b6cfbfee", size = 30889, upload-time = "2026-08-24T17:16:57.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/3d/e34b19cd144071c583317268c4feb2c59c03ac57eef69753410c7abb11c0/linkify_it_py-2.1.1-py3-none-any.whl", hash = "sha256:8539a6b470efce90ba9b69e39b848e5b15b7ad89f7f98ca17d3532c243f987dc", size = 20532, upload-time = "2026-08-24T17:16:55.965Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "ruff"
version = "0.16.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
{ url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
{ url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
{ url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
{ url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
{ url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
{ url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
{ url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
{ url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
{ url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
{ url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
{ url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
]
[[package]]
name = "soapbox"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "flask" },
{ name = "linkify-it-py" },
{ name = "markdown-it-py" },
{ name = "mdit-py-plugins" },
{ name = "pillow" },
{ name = "waitress" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.1" },
{ name = "linkify-it-py", specifier = ">=2.1.1" },
{ name = "markdown-it-py", specifier = ">=3.0" },
{ name = "mdit-py-plugins", specifier = ">=0.4" },
{ name = "pillow", specifier = ">=11.0" },
{ name = "waitress", specifier = ">=3.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "waitress"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]