From 775dc3f65a8016c786da41459df321cfb2db305f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 29 Aug 2026 02:41:29 +0000 Subject: [PATCH] Look up themes and theme per-file overrides in system, user and site directories. --- AGENTS.md | 5 +- docs/themes-and-assets.md | 21 ++++ frontend/src/SiteEditor.vue | 18 +++- frontend/vite.config.js | 7 +- pagerite/app.py | 53 +++++----- pagerite/views.py | 186 ++++++++++++++++++++++++++++-------- pyproject.toml | 1 + 7 files changed, 221 insertions(+), 70 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9eefaa5..82fd6e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `data.py` — msgspec Structs for the kanta database. - `migrations.py` — kanta schema migrations (`migrate_vN`), e.g. v1 moves legacy in-db file blobs to the on-disk store. - `markdown.py` — markdown-it-py renderer. - - `views.py` — shared page layout and rendering. + - `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`). - `seed.py` — demo content, written only on first database creation. - `analytics.py` — visit analytics collection (see `docs/analytics.md`). - `frontend/src/` — Vue editor and public-page JS entries. @@ -28,7 +28,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed). ## Toolchain -- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). +- Python >= 3.14, managed with **uv**. Dependencies: `fastapi[standard]`, `fastapi-vue`, `html5tagger`, `kanta`, `markdown-it-py`, `mdit-py-plugins`, `platformdirs`, `pygments`, `tracerite`; dev group has `httpx`. Run anything via `uv run ...` (the venv is `.venv`). - Key libraries: - **html5tagger** — all HTML generation (`E`, `Document`, `Template`, `HTML` for trusted/raw HTML). - To create stand alone pages, begin with `doc = Document(...)` that gives a HTML5 page header @@ -46,6 +46,7 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed). - Maintaining and owning the app's own `Data` object is preferable; Kanta never copies this, only edits in place - Note: besides opening it every access is immediate direct variable access: no `await`, no locks, no delays - **fastapi-vue** — template glue for serving/building the Vue frontend; keep its integration points (`Frontend`, build hook) intact. + - **platformdirs** — platform user/system data dirs for the theme and font search roots (`views.THEME_DIRS` / `views.FONT_DIRS`; use `site_data_dir(..., multipath=True)`, not `site_data_path`, which collapses multipath). - **markdown-it-py** — Markdown rendering with `html=True` raw passthrough; mdit-py-plugins for footnote/deflist/tasklists/attrs; in-body h1/h2 headings get auto slug ids + self-links when the body has 3+ of them (`python-slugify`, mirroring `slugify.js`); **Pygments** for server-side code highlighting (`nowrap` spans, styled by `frontend/src/assets/pygments.css` which maps token classes 1:1 onto the `--code-*` variables; light/dark palette sets live in `pagerite.css` and resolve via `light-dark()` from the theme's `color-scheme` — themes pick a set, not individual colors). ## Conventions diff --git a/docs/themes-and-assets.md b/docs/themes-and-assets.md index 81bb704..9128c97 100644 --- a/docs/themes-and-assets.md +++ b/docs/themes-and-assets.md @@ -14,6 +14,18 @@ The `::view-transition*` rules are not in the base stylesheet: they live in the Themes are folders in `pagerite/themes/{name}/` containing `theme.css` and/or `banner.css` (+ `banner.svg` artwork and any extra assets the CSS references, like summer's `grass.svg`). They are served by the backend at `/_themes/{name}/...` — read from disk per request (etag by mtime), never built, so on-disk edits show on the next page load even in prod. +Themes are searched across several roots, most specific first (see `views.THEME_DIRS`): + +1. `themes/` under the current working directory +2. `/themes/` (the site's own folder, e.g. `localhost/themes/`) +3. The platform user data dir's `pagerite/themes/` (Linux: `~/.local/share/pagerite/themes/`, Windows: `%LOCALAPPDATA%\pagerite\themes\`) +4. The platform system data dirs' `pagerite/themes/` (Linux: `/usr/local/share/pagerite/themes/`, `/usr/share/pagerite/themes/`, ...; Windows: `%PROGRAMDATA%\pagerite\themes\`) — several combine +5. `pagerite/themes/` (built-in package dir, fallback) + +The data dirs come from `platformdirs` (`views._data_roots`). + +All roots combine: listings are the union of folder names, and each file resolves from the first root that has it. So users can add completely new themes in any root, shadow a built-in file with their own (`/themes/corporate/theme.css`), or extend a built-in theme with extra files (any files the user folder doesn't provide still come from the built-in). Since everything is read per request, new or changed folders take effect without a server restart. + `Data.theme` selects the active theme (empty = none/base only) and the site editor can switch it, choosing from the theme folders found on disk. Vue may add per-component styles on top where needed. The site editor shows a light/dark-mode indicator in front of each theme name, read from the theme's `color-scheme` declaration in `theme.css`: ☀️ for light-only, 🌙 for dark-only, and 🌓 for themes that support both. The base theme (`none`) is light-only. @@ -25,6 +37,15 @@ Current themes: - `nitro` — racing/HUD style following `prefers-color-scheme` (warm light-grey page, deep violet in dark), Montserrat/Literata, black as an accent only, a straight orange blade under the banner, and an orange racing-tab nav clipped with a bezier `shape()`. - `summer` — light playful meadow, one palette sampled from its illustrated `banner.svg` (sky/grass/sun/flower pink), Fraunces/Literata, a tilted gradient brand, flower bullets, and a layered-parallax banner (sun rises, clouds drift, nearer hills move less) with idle animations (swaying flowers, floating clouds, breathing sun glow) wrapped in `prefers-reduced-motion: no-preference`. +## User fonts + +Fonts are shared across themes, so user fonts live in `fonts/` folders next to the theme roots (same list minus the built-in fallback: `views.FONT_DIRS`, e.g. `localhost/fonts/` for the site; built-in fonts ship with the Vite build). A font is a folder `fonts/{name}/` with: + +- `font.css` — `@font-face` rules with URLs relative to the folder (files served at `/_fonts/{name}/...`, per request like themes), plus a `:root { --font-{name}: "Family Name", serif; }` stack variable so themes and custom CSS reference it like the built-in `--font-*` variables. +- the font files the CSS references (e.g. `{name}.woff2`). + +Every font.css is linked on all pages (after the base stylesheet, before the theme). The site editor's font picker lists user fonts too, with label and serif/sans grouping parsed from the `--font-{name}` stack. Everything is read per request, so new fonts appear without a restart. + ## Banner designs A theme folder may also ship a banner design (`banner.css` + `banner.html` arbitrary markup or `banner.svg`), selectable per page independently of the active theme. Standalone banner designs (no theme.css) ship as: diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 6e40d74..f858886 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -85,6 +85,14 @@ async function loadSettings() { ] transition.value = s.transition || 'cube' transitionOptions.value = s.transitions || [] + fontOptions.value = [ + ...BASE_FONT_OPTIONS, + ...(s.fonts || []).map((f) => ({ + value: `var(--font-${f.name})`, + label: f.label, + serif: f.serif, + })), + ] } catch { /* keep default */ } } @@ -374,7 +382,7 @@ function setCssDocument(text) { // inline with other content, and those must be stripped/parsed too or // re-picking a font would insert a duplicate row. const FONT_DECL = /--font-(?:body|heading|brand)\s*:\s*var\(--font-[a-z0-9-]+\)\s*;/g -const FONT_OPTIONS = [ +const BASE_FONT_OPTIONS = [ { value: 'var(--font-source-serif)', label: 'Source Serif 4', serif: true }, { value: 'var(--font-fraunces)', label: 'Fraunces', serif: true }, { value: 'var(--font-literata)', label: 'Literata', serif: true }, @@ -388,6 +396,10 @@ const FONT_OPTIONS = [ { value: 'var(--font-exo2)', label: 'Exo 2', serif: false }, { value: 'var(--font-fira-code)', label: 'Fira Code', serif: false }, ] +// Built-in options plus user fonts reported by the backend (fonts/ +// folders on disk, see GET /_api/settings), so added fonts need no +// frontend changes. +const fontOptions = ref(BASE_FONT_OPTIONS) const fontHeading = ref('') const fontBody = ref('') const fontBrand = ref('') @@ -397,8 +409,8 @@ const fontBrand = ref('') // the candidate font at the size and weight of the element being styled. const fontPicker = ref(null) // open tab: 'heading' | 'body' | 'brand' | null let fontTabLast = 'body' -const serifFonts = computed(() => FONT_OPTIONS.filter((o) => o.serif)) -const sansFonts = computed(() => FONT_OPTIONS.filter((o) => !o.serif)) +const serifFonts = computed(() => fontOptions.value.filter((o) => o.serif)) +const sansFonts = computed(() => fontOptions.value.filter((o) => !o.serif)) function toggleFontPanel() { if (fontPicker.value) { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index da9bd24..37c17dd 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -8,14 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools' const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200' // Proxy everything except Vite's own dev-time paths and the backend machinery -// to the FastAPI backend in dev. /_api, /_f, /_themes and /_a are handled by -// the fastapi-vue plugin, and /@..., /src, /node_modules, /__... stay with Vite. +// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are +// handled by the fastapi-vue plugin, and /@..., /src, /node_modules, /__... +// stay with Vite. const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$' // https://vite.dev/config/ export default defineConfig({ plugins: [ - fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_a"] }), + fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a"] }), vue(), vueDevTools(), ], diff --git a/pagerite/app.py b/pagerite/app.py index 598c6bf..054f70e 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -550,7 +550,8 @@ async def update_structure(op: StructureOp) -> None: @app.get("/_api/settings") async def get_settings() -> dict: """Site-wide settings (brand, theme, custom CSS and favicon URL), plus - the themes and banner designs available on disk for the selectors.""" + the themes, banner designs and user fonts available on disk for the + selectors.""" return { "brand": data.brand, "brand_html": data.brand_html, @@ -559,6 +560,7 @@ async def get_settings() -> dict: "favicon": f"/_f/{data.favicon}" if data.favicon else "", "themes": views._theme_info(), "banner_designs": views._banner_design_names(), + "fonts": views._user_fonts(), "transition": data.transition, "transitions": views._transition_names(), } @@ -678,33 +680,20 @@ async def delete_file(name: str) -> None: file_store.delete(name) -@app.get("/_themes/{name}/{filename}") -async def theme_file(name: str, filename: str, request: Request) -> Response: - """Serve a theme/banner-design file from pagerite/themes/{name}/. +async def _serve_user_file(path: Path | None, request: Request) -> Response: + """Serve a user-asset file resolved on disk, with mtime etag. - Stylesheets plus any extra assets the CSS references (like summer's - grass.svg). Read from disk on every request (etag by mtime+size): - theme files are never built or content-hashed, so edits on disk show - on the next page load, in prod as well as dev. + Read from disk on every request (etag by mtime+size): user assets are + never built or content-hashed, so edits on disk show on the next page + load, in prod as well as dev. """ - if ( - "/" in filename - or filename.startswith(".") - or "/" in name - or name.startswith(".") - ): - raise HTTPException(404) - path = views.THEMES / name / filename - try: - stat = path.stat() - except FileNotFoundError: - raise HTTPException(404) from None - if not path.is_file(): + if path is None: raise HTTPException(404) + stat = path.stat() etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"' if request.headers.get("if-none-match") == etag: return Response(status_code=304) - mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" + mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" return Response( path.read_bytes(), media_type=mime, @@ -712,6 +701,26 @@ async def theme_file(name: str, filename: str, request: Request) -> Response: ) +@app.get("/_themes/{name}/{filename}") +async def theme_file(name: str, filename: str, request: Request) -> Response: + """Serve a theme/banner-design file, resolved across views.THEME_DIRS. + + Stylesheets plus any extra assets the CSS references (like summer's + grass.svg). + """ + return await _serve_user_file(views.theme_file(name, filename), request) + + +@app.get("/_fonts/{name}/{filename}") +async def user_font_file(name: str, filename: str, request: Request) -> Response: + """Serve a user font file, resolved across views.FONT_DIRS. + + The folder's font.css (@font-face rules + --font-{name} stack variable) + is linked on every page; the woff2 files it references come from here. + """ + return await _serve_user_file(views.font_file(name, filename), request) + + @app.get("/_f/{name}") async def stored_file(name: str, request: Request) -> Response: """Serve a file from the content-addressed store (immutable: the name diff --git a/pagerite/views.py b/pagerite/views.py index c462e4d..556d1c2 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -22,18 +22,62 @@ import os import re from html5tagger import HTML, Document, E, Template +from platformdirs import site_data_dir, user_data_path from pagerite.data import Node, prettify, resolve, sorted_nodes from pagerite.markdown import render SITE_NAME = "Pagerite" BUILD = Path(__file__).with_name("frontend-build") -THEMES = Path(__file__).parent / "themes" + + +def _data_roots() -> list[Path]: + """Platform data dirs for user-provided assets (platformdirs), most + specific first: user data dir, then the system-wide data dirs.""" + roots = [user_data_path("pagerite", appauthor=False)] + # site_data_dir keeps the multipath (site_data_path collapses it to the + # first entry, since a Path cannot hold several). + roots += site_data_dir("pagerite", appauthor=False, multipath=True).split(os.pathsep) + return [Path(r) for r in roots] + + +def _theme_dirs() -> list[Path]: + """Theme search roots, most specific first; first match wins per file. + + Users can add new themes or override/extend built-in ones file by file + by placing folders in any of these roots (all combine into one listing): + cwd, the site folder, and the platform user/system data dirs + (e.g. ~/.local/share/pagerite, /usr/share/pagerite on Linux; + %LOCALAPPDATA%\\pagerite, %PROGRAMDATA%\\pagerite on Windows). + The built-in package themes are the final fallback. + """ + return [ + Path("themes"), + Path(os.getenv("PAGERITE_HOSTNAME", "localhost")) / "themes", + *(root / "themes" for root in _data_roots()), + Path(__file__).parent / "themes", + ] + + +THEME_DIRS = _theme_dirs() + +# User fonts are shared across themes, so they live in fonts/ folders next +# to the themes/ roots (no built-in fallback: built-in fonts ship with the +# Vite build). A font folder {name}/ carries font.css with @font-face rules +# (relative URLs resolve under /_fonts/{name}/) and a :root --font-{name} +# stack variable, so themes and custom CSS can reference it like the +# built-in --font-* variables. +FONT_DIRS = [ + Path("fonts"), + Path(os.getenv("PAGERITE_HOSTNAME", "localhost")) / "fonts", + *(root / "fonts" for root in _data_roots()), +] # The base CSS is built by Vite as a separate entry so the backend can link # it independently of the theme. Themes and banner designs are plain .css -# files in THEMES/{name}/, served by the backend at /_themes/{name}/... and -# re-read from disk on every request (see app.py), so they are never built. +# files in {THEME_DIRS}/{name}/, served by the backend at /_themes/{name}/... +# and re-read from disk on every request (see app.py), so they are never +# built and edits/new folders show without a restart. _BASE_CSS_KEY = "src/assets/pagerite.css" _manifest_cache: dict | None = None @@ -53,7 +97,9 @@ def _theme_color_schemes(theme: str) -> set[str]: Reads the first ``color-scheme:`` declaration in the file. An empty set means the theme did not declare one. """ - path = THEMES / theme / "theme.css" + path = theme_file(theme, "theme.css") + if path is None: + return set() try: css = path.read_text() except (OSError, ValueError): @@ -82,26 +128,68 @@ def _theme_mode(theme: str) -> str: def _theme_info() -> list[dict[str, str]]: """Theme folders on disk, each with its name and supported color mode.""" return [ - {"name": d.name, "mode": _theme_mode(d.name)} - for d in sorted(THEMES.iterdir(), key=lambda d: d.name) - if d.is_dir() and (d / "theme.css").exists() + {"name": name, "mode": _theme_mode(name)} + for name in _theme_folder_names(("theme.css",)) ] def _banner_design_names() -> list[str]: """Available banner designs: theme folders with artwork and/or styles.""" - return sorted( - d.name - for d in THEMES.iterdir() - if d.is_dir() - and any((d / f).exists() for f in ("banner.css", "banner.svg", "banner.html")) - ) + return _theme_folder_names(("banner.css", "banner.svg", "banner.html")) def _transition_names() -> list[str]: """Available page-transition designs: theme folders with transition.css.""" + return _theme_folder_names(("transition.css",)) + + +def _theme_folder_names(required: tuple[str, ...]) -> list[str]: + """Sorted union of theme folder names across THEME_DIRS containing any + of the required files.""" return sorted( - d.name for d in THEMES.iterdir() if d.is_dir() and (d / "transition.css").exists() + name + for root in THEME_DIRS + if root.is_dir() + for name in {d.name: d for d in root.iterdir() if d.is_dir()} + if any(theme_file(name, f) for f in required) + ) + + +def _user_fonts() -> list[dict]: + """User font folders on disk: FONT_DIRS/{name}/ with a font.css. + + The label and serif flag are read from the font.css ``--font-{name}`` + stack variable; the stylesheet itself is linked on every page (see + _layout) so the variable and @font-face rules just exist. + """ + fonts = [] + for name in _font_folder_names(): + path = font_file(name, "font.css") + css = path.read_text(errors="replace") if path else "" + m = re.search( + rf"--font-{re.escape(name)}\s*:\s*([^;]+);", + re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL), + ) + stack = m.group(1) if m else "" + family = re.search(r'"([^"]+)"|\'([^\']+)\'', stack) + fonts.append( + { + "name": name, + "label": (family.group(1) or family.group(2)) if family else name, + "serif": bool(re.search(r"(^|,\s*)serif\s*$", stack)), + } + ) + return fonts + + +def _font_folder_names() -> list[str]: + """Sorted union of font folder names across FONT_DIRS with a font.css.""" + return sorted( + d.name + for root in FONT_DIRS + if root.is_dir() + for d in root.iterdir() + if d.is_dir() and (d / "font.css").is_file() ) @@ -110,6 +198,27 @@ def _valid_name(name: str) -> bool: return bool(name) and "/" not in name and not name.startswith(".") +def theme_file(name: str, filename: str) -> Path | None: + """Resolve a theme file across THEME_DIRS; the first root with the file + wins, so users can override or extend built-in themes file by file.""" + return _user_file(THEME_DIRS, name, filename) + + +def font_file(name: str, filename: str) -> Path | None: + """Resolve a user font file across FONT_DIRS (first match wins).""" + return _user_file(FONT_DIRS, name, filename) + + +def _user_file(dirs: list[Path], name: str, filename: str) -> Path | None: + if not _valid_name(name) or not _valid_name(filename): + return None + for root in dirs: + path = root / name / filename + if path.is_file(): + return path + return None + + def _base_css_url(vite_url: str | None) -> str | None: """URL for the base stylesheet (None in dev: Vite injects it from JS, avoiding the HMR-wrapped module output).""" @@ -123,25 +232,21 @@ def _base_css_url(vite_url: str | None) -> str | None: def _theme_css_url(theme: str) -> str | None: """URL for the theme stylesheet, served by the backend (dev and prod).""" - if theme and _valid_name(theme) and (THEMES / theme / "theme.css").exists(): + if theme and theme_file(theme, "theme.css"): return f"/_themes/{theme}/theme.css" return None def _banner_css_url(design: str) -> str | None: """URL for a banner design's stylesheet, served by the backend.""" - if design and _valid_name(design) and (THEMES / design / "banner.css").exists(): + if design and theme_file(design, "banner.css"): return f"/_themes/{design}/banner.css" return None def _transition_css_url(transition: str) -> str | None: """URL for a page-transition stylesheet, served by the backend.""" - if ( - transition - and _valid_name(transition) - and (THEMES / transition / "transition.css").exists() - ): + if transition and theme_file(transition, "transition.css"): return f"/_themes/{transition}/transition.css" return None @@ -167,13 +272,16 @@ def _inline_asset(url: str) -> str: """Read a served asset's content for inlining into the page (prod only). Handles build assets (``/_assets/...`` from the Vite build) and theme - files (``/_themes/{name}/...`` from pagerite/themes/). + or user-font files (``/_themes/{name}/...``, ``/_fonts/{name}/...``, + resolved across THEME_DIRS / FONT_DIRS). """ - if url.startswith("/_themes/"): - name, _, file = url.removeprefix("/_themes/").partition("/") - if _valid_name(name) and _valid_name(file): - return (THEMES / name / file).read_text() - raise ValueError(f"not a theme asset: {url}") + for prefix, resolver in (("/_themes/", theme_file), ("/_fonts/", font_file)): + if url.startswith(prefix): + name, _, file = url.removeprefix(prefix).partition("/") + path = resolver(name, file) + if path is None: + raise ValueError(f"not a theme/font asset: {url}") + return path.read_text() return (BUILD / url.lstrip("/")).read_text() @@ -213,8 +321,9 @@ def _layout( analytics) stay external in both modes. Order matters and is fixed: base (Vite build, absent in dev where Vite - injects it from JS), theme, banner design and page transition (from - pagerite/themes/), + injects it from JS), user fonts (from FONT_DIRS, so themes and custom + CSS can reference their --font-* variables), theme, banner design and + page transition (from THEME_DIRS), entry-specific stylesheets (e.g. overlayscrollbars.css), then the user's custom CSS last so it always wins. @@ -272,6 +381,10 @@ def _layout( # per sheet, and fetch-navigation can carry them across swaps whole. sheets = [ ("pagerite-base", _base_css_url(vite_url)), + *[ + (f"pagerite-font-{name}", f"/_fonts/{name}/font.css") + for name in _font_folder_names() + ], ("pagerite-theme", _theme_css_url(theme)), ("pagerite-banner", _banner_css_url(banner_design)), ("pagerite-transition", _transition_css_url(transition)), @@ -489,14 +602,10 @@ def _design_banner(design: str) -> HTML: """ if not _valid_name(design): return HTML("") - html = THEMES / design / "banner.html" - svg = THEMES / design / "banner.svg" - if html.exists(): - body = html.read_text() - elif svg.exists(): - body = svg.read_text() - else: + path = theme_file(design, "banner.html") or theme_file(design, "banner.svg") + if path is None: return HTML("") + body = path.read_text() return HTML(f'
{body}
') @@ -504,10 +613,7 @@ def theme_banner_design(theme: str) -> str: """The theme's own banner design (a theme folder doubles as a banner design when it ships banner.css, banner.svg or banner.html), "" if it has none.""" - if _valid_name(theme) and any( - (THEMES / theme / f).exists() - for f in ("banner.css", "banner.svg", "banner.html") - ): + if any(theme_file(theme, f) for f in ("banner.css", "banner.svg", "banner.html")): return theme return "" diff --git a/pyproject.toml b/pyproject.toml index f8d8886..44f26cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "markdown-it-py>=4.2.0", "maxminddb>=3.1.1", "mdit-py-plugins>=0.6.1", + "platformdirs>=4.11.5", "pygments>=2.20.0", "python-slugify>=8.0.4", "ua-parser>=1.0.2",