diff --git a/AGENTS.md b/AGENTS.md
index 96469bf..2674238 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -64,6 +64,6 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
## Conventions
- Keep dependencies minimal; add via `uv add` and mention it.
-- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
+- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` (backend redirect to the configured site icon). Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
diff --git a/pagerite/app.py b/pagerite/app.py
index 65c9d4b..8ca2379 100644
--- a/pagerite/app.py
+++ b/pagerite/app.py
@@ -48,7 +48,7 @@ logger = logging.getLogger(__name__)
# Vue build served at the site root, no SPA catch-all (assets only). The
# build mirrors the URL space: hashed, immutable files live under
-# /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
+# /_assets/ (assetsDir: '_/assets').
frontend = Frontend(
Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/"
)
@@ -124,7 +124,7 @@ app.include_router(tracking.router)
app.include_router(files.router)
# Vue build asset routes are inserted at this position during load(): the
-# build mirrors the URL space (/_assets/*, /favicon.ico at the root).
+# build mirrors the URL space (/_assets/*).
frontend.route(app, "/")
# The content catch-all goes last: built assets win over content slugs,
diff --git a/pagerite/data.py b/pagerite/data.py
index ea3e987..aebfe1b 100644
--- a/pagerite/data.py
+++ b/pagerite/data.py
@@ -98,8 +98,8 @@ class Data(msgspec.Struct):
#: Trusted author content; not sanitized.
custom_css: str = ""
#: Favicon: content-addressed file name (served at "/_f/{name}"),
- #: linked as on every page. Empty = the build's
- #: /favicon.ico.
+ #: linked as on every page; /favicon.ico redirects
+ #: to it. Empty = no icon (and /favicon.ico 404s).
favicon: str = ""
#: API keys gating the translator service WebSocket (/_translate/{key};
#: the external forward-auth does not cover that route): key -> display
diff --git a/pagerite/files.py b/pagerite/files.py
index 1b2e657..73f3c65 100644
--- a/pagerite/files.py
+++ b/pagerite/files.py
@@ -6,7 +6,8 @@ when compression shrinks the body), served immutable at ``/_f/``. Raster
images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
(``store_image`` and helpers); the untouched original is kept alongside as
``.orig`` (never served). Routes: upload/delete under
-``/_api/files``, the favicon settings endpoints, the ``/_f/`` server with
+``/_api/files``, the favicon settings endpoints, the /favicon.ico
+redirect to the configured icon, the ``/_f/`` server with
Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``).
"""
@@ -19,7 +20,7 @@ from pathlib import Path
import blake3
from fastapi import APIRouter, HTTPException, Request
-from fastapi.responses import Response
+from fastapi.responses import RedirectResponse, Response
from mediapreview import dispatch
from pagerite import views
@@ -252,6 +253,20 @@ async def delete_file(name: str) -> None:
file_store.delete(name)
+@router.get("/favicon.ico", include_in_schema=False)
+async def favicon_ico() -> Response:
+ """The conventional /favicon.ico: redirect to the configured site icon.
+
+ Browsers request this path on their own (tabs, bookmarks, feeds and
+ other non-HTML contexts) regardless of the pages
+ carry. Redirect to the icon's store URL, which negotiates the format
+ and caches immutably; 404 when no custom icon is configured.
+ """
+ if not data.favicon:
+ raise HTTPException(404)
+ return RedirectResponse(f"/_f/{data.favicon}")
+
+
@router.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it.
@@ -276,7 +291,8 @@ async def put_favicon(request: Request) -> dict[str, str]:
@router.delete("/_api/settings/favicon", status_code=204)
async def delete_favicon(request: Request) -> None:
- """Clear the custom favicon (back to the build's /favicon.ico).
+ """Clear the custom favicon (/favicon.ico goes back to 404, pages drop
+ the ).
The blob stays in the content-addressed store; only the reference goes.
"""
diff --git a/pagerite/views.py b/pagerite/views.py
index 52b44a3..cc62cba 100644
--- a/pagerite/views.py
+++ b/pagerite/views.py
@@ -372,8 +372,8 @@ def _layout(
doc.meta(property=key, content=value)
else:
doc.meta(name=key, content=value)
- # A custom favicon (from the site editor) is linked explicitly; without
- # one, browsers fall back to the build's /favicon.ico by convention.
+ # A custom favicon (from the site editor) is linked explicitly;
+ # /favicon.ico redirects to the same store file for non-HTML contexts.
if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Asset URLs for the on-demand bundles (editor, analytics) for