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:
commit
7ab2320be6
35 changed files with 2726 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
.playwright-mcp/
|
||||
*.png
|
||||
16
Caddyfile.example
Normal file
16
Caddyfile.example
Normal 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
35
Containerfile
Normal 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
81
README.md
Normal 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 ``. 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
19
compose.yaml
Normal 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
30
pyproject.toml
Normal 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
1
soapbox/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Soapbox – en liten statisk blogg med mobilvennlig editor og innhold i git."""
|
||||
393
soapbox/admin.py
Normal file
393
soapbox/admin.py
Normal 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"",
|
||||
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 (a–z, 0–9, bindestrek, 2–31 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
122
soapbox/app.py
Normal 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 på
|
||||
``/`` 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
134
soapbox/build.py
Normal 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, så ``rel("theme/style.css")``
|
||||
blir ``theme/style.css`` på 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
78
soapbox/cli.py
Normal 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
96
soapbox/config.py
Normal 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
173
soapbox/content.py
Normal 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
|
||||
(````). 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
143
soapbox/db.py
Normal 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 på 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`` må 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
45
soapbox/gitstore.py
Normal 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,
|
||||
så 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
61
soapbox/images.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Behandling av opplastede bilder.
|
||||
|
||||
Mobiltelefoner leverer gjerne 12 MP-bilder på 5 MB med GPS-koordinater i
|
||||
EXIF. Vi skalerer ned til ``max_px`` på 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
73
soapbox/render.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Markdown → HTML, med omskriving av interne lenker til relative stier.
|
||||
|
||||
Sikkerhet: rå 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 på ``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
31
soapbox/slug.py
Normal 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}"
|
||||
71
soapbox/static/admin/admin.css
Normal file
71
soapbox/static/admin/admin.css
Normal 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; }
|
||||
124
soapbox/static/admin/editor.js
Normal file
124
soapbox/static/admin/editor.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* Markdown-editor: toolbar, live forhåndsvisning og bildeopplasting.
|
||||
* Ingen avhengigheter. Forhåndsvisningen rendres på serveren (samme kode som
|
||||
* publisert side) med ~400 ms debounce, så 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 = ""; } });
|
||||
})();
|
||||
36
soapbox/templates/admin/base.html
Normal file
36
soapbox/templates/admin/base.html
Normal 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>
|
||||
77
soapbox/templates/admin/edit.html
Normal file
77
soapbox/templates/admin/edit.html
Normal 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></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 %}
|
||||
24
soapbox/templates/admin/index.html
Normal file
24
soapbox/templates/admin/index.html
Normal 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 %}
|
||||
12
soapbox/templates/admin/login.html
Normal file
12
soapbox/templates/admin/login.html
Normal 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 %}
|
||||
11
soapbox/templates/admin/password.html
Normal file
11
soapbox/templates/admin/password.html
Normal 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 %}
|
||||
28
soapbox/templates/admin/settings.html
Normal file
28
soapbox/templates/admin/settings.html
Normal 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 %}
|
||||
41
soapbox/templates/admin/users.html
Normal file
41
soapbox/templates/admin/users.html
Normal 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 %}
|
||||
74
soapbox/themes/green/static/style.css
Normal file
74
soapbox/themes/green/static/style.css
Normal 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 på 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; }
|
||||
28
soapbox/themes/green/templates/base.html
Normal file
28
soapbox/themes/green/templates/base.html
Normal 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>
|
||||
15
soapbox/themes/green/templates/index.html
Normal file
15
soapbox/themes/green/templates/index.html
Normal 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 %}
|
||||
15
soapbox/themes/green/templates/post.html
Normal file
15
soapbox/themes/green/templates/post.html
Normal 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
46
tests/conftest.py
Normal 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
136
tests/test_app.py
Normal 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": " [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\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
56
tests/test_core.py
Normal 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) ", "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
392
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue