Mirror the URL space in the frontend build layout

Build with assetsDir: '_/assets' so hashed assets land under
frontend-build/_/assets/ and favicon.ico (from frontend/public) at the
build root, then serve the whole build directory at the site root again
(frontend.route(app, "/"), cached="/_/assets/"). The explicit
favicon route is dropped — the Frontend serves it as an ordinary
unhashed (no-cache) file. Manifest paths now carry the _/assets/
prefix, so views.py only prepends a slash.
This commit is contained in:
2026-08-16 16:22:39 +00:00
parent d5e40fba12
commit 508795437f
5 changed files with 30 additions and 30 deletions
+8 -4
View File
@@ -21,15 +21,17 @@ not for the public pages. See `docs/design-principles.md` for the design.
- `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled - `app.py` — the FastAPI app. FastAPI's built-in API docs are disabled
(`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to (`docs_url`/`redoc_url`/`openapi_url=None`) because `/docs` belongs to
our content. Our own routes (content pages, `/_/api/...`, `/_/f/...`, our content. Our own routes (content pages, `/_/api/...`, `/_/f/...`,
`/_/assets/...`, `/_/admin`) are registered BEFORE `frontend.route(app, "/_/assets")` is `/_/admin`) are registered BEFORE `frontend.route(app, "/")` is
called: fastapi-vue inserts its file routes at the position where called: fastapi-vue inserts its file routes at the position where
`route()` was called (during `load()` in the lifespan), so anything `route()` was called (during `load()` in the lifespan), so anything
defined earlier wins. The one exception is the content catch-all defined earlier wins. The one exception is the content catch-all
`/{path:path}`, registered AFTER `frontend.route()` so that built `/{path:path}`, registered AFTER `frontend.route()` so that built
frontend assets still take priority over content slugs. The `Frontend` frontend assets still take priority over content slugs. The `Frontend`
is constructed with `spa=False` explicitly: it only serves the built is constructed with `spa=False` explicitly: it only serves the built
asset files under `/_/assets/` without a catch-all; an `index.html` in the build files without a catch-all. The build mirrors the URL space — hashed
would become a `/_/assets/` route, so leave it out of the build to keep `/` ours. immutable assets under `/_/assets/`, `favicon.ico` at the site root —
and an `index.html` in the build would become a `/` route, so leave it
out of the build to keep `/` ours.
- `data.py` — msgspec Structs for the kanta database. The site structure - `data.py` — msgspec Structs for the kanta database. The site structure
is a tree: `Data.menu` maps top-level slugs to `Node`s, each with is a tree: `Data.menu` maps top-level slugs to `Node`s, each with
`children` keyed by slug — the URL path is the slug chain. The front `children` keyed by slug — the URL path is the slug chain. The front
@@ -108,7 +110,9 @@ not for the public pages. See `docs/design-principles.md` for the design.
In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`),
in prod from the hashed build assets resolved via in prod from the hashed build assets resolved via
`frontend-build/.vite/manifest.json`. `vite.config.js` builds with `frontend-build/.vite/manifest.json`. `vite.config.js` builds with
`manifest: true`, `assetsDir: ''` and JS inputs (`src/main.js` and `manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL
space; `frontend/public/favicon.ico` lands at the build root and is
served at `/favicon.ico`) and JS inputs (`src/main.js` and
`src/pagerite.js`) so no `index.html` ends up in the build (it would shadow `src/pagerite.js`) so no `index.html` ends up in the build (it would shadow
`/`). All outputs are ES modules. vite-plugin-fastapi.js has an `/`). All outputs are ES modules. vite-plugin-fastapi.js has an
auto-upgrade marker — edit `vite.config.js`, not the plugin. auto-upgrade marker — edit `vite.config.js`, not the plugin.
+4 -2
View File
@@ -30,7 +30,8 @@ evolves.
(`/docs/design-principles`-style). The URL space is the author's, so (`/docs/design-principles`-style). The URL space is the author's, so
reserved prefixes must be kept few and deliberate: everything internal reserved prefixes must be kept few and deliberate: everything internal
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
assets at `/_/assets/`, and the admin shell at `/_/admin`). assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
other reserved root path is `/favicon.ico`, served from the build.
- **Single user, trusted author.** No auth concerns in the core design. - **Single user, trusted author.** No auth concerns in the core design.
Everything published is public; only editing tools will later sit behind Everything published is public; only editing tools will later sit behind
access control (external SSO when that time comes). The author is trusted access control (external SSO when that time comes). The author is trusted
@@ -75,7 +76,8 @@ evolves.
**per-page configurable**: `Node.banner` holds an arbitrary trusted HTML **per-page configurable**: `Node.banner` holds an arbitrary trusted HTML
snippet (an image, a styled div, canvas + script — anything), resolved by snippet (an image, a styled div, canvas + script — anything), resolved by
walking up the node's ancestors to the front page; when nothing in the walking up the node's ancestors to the front page; when nothing in the
chain sets one, the default `/_/assets/banner-*.svg` artwork shows. chain sets one, the default `banner.svg` artwork (inlined into the
stylesheet by the build) shows.
- **Fetch-navigation.** Links are plain `<a href>`; a small script - **Fetch-navigation.** Links are plain `<a href>`; a small script
(`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the
page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions
+4 -3
View File
@@ -27,10 +27,11 @@ export default defineConfig({
}, },
}, },
build: { build: {
// Emit hashed assets at the root of frontend-build so the backend can // Mirror the URL space in the build output: hashed files land under
// serve them under /_/assets/{file} without a nested /assets directory. // frontend-build/_/assets/ and the Frontend serves the build directory
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
manifest: true, manifest: true,
assetsDir: '', assetsDir: '_/assets',
rollupOptions: { rollupOptions: {
input: { input: {
main: fileURLToPath(new URL('./src/main.js', import.meta.url)), main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
+9 -17
View File
@@ -1,7 +1,7 @@
"""FastAPI application: server-rendered content pages plus Vue assets. """FastAPI application: server-rendered content pages plus Vue assets.
Route ordering matters: our routes are defined before Route ordering matters: our routes are defined before
``frontend.route(app, "/_/assets")`` is called, so they take priority over ``frontend.route(app, "/")`` is called, so they take priority over
the asset routes that fastapi-vue inserts at that position during ``load()``. the asset routes that fastapi-vue inserts at that position during ``load()``.
The content catch-all (``/{path:path}``) is defined last, so built The content catch-all (``/{path:path}``) is defined last, so built
frontend assets still win over content slugs; anything unmatched falls frontend assets still win over content slugs; anything unmatched falls
@@ -21,7 +21,7 @@ from pathlib import Path
import blake3 import blake3
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi_vue import Frontend from fastapi_vue import Frontend
from kanta import Kanta from kanta import Kanta
from pydantic import BaseModel from pydantic import BaseModel
@@ -45,11 +45,11 @@ DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta")
data = Data() data = Data()
kanta = Kanta(DB_PATH, data) kanta = Kanta(DB_PATH, data)
# Vue build assets served under /_/assets/, no SPA catch-all (assets only). # Vue build served at the site root, no SPA catch-all (assets only). The
# With assetsDir: '', all files are emitted at the build root, so cached="/" # build mirrors the URL space: hashed, immutable files live under
# marks every built file immutable. # /_/assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
BUILD_DIR = Path(__file__).with_name("frontend-build") BUILD_DIR = Path(__file__).with_name("frontend-build")
frontend = Frontend(BUILD_DIR, spa=False, cached="/") frontend = Frontend(BUILD_DIR, spa=False, cached="/_/assets/")
def _hash_name(body: bytes, orig: str) -> str: def _hash_name(body: bytes, orig: str) -> str:
@@ -475,23 +475,15 @@ async def admin() -> HTMLResponse:
return HTMLResponse(views.render_editor()) return HTMLResponse(views.render_editor())
@app.get("/favicon.ico")
async def favicon() -> FileResponse:
"""Serve the favicon copied from frontend/public by the Vite build."""
file = BUILD_DIR / "favicon.ico"
if not file.is_file():
raise HTTPException(404)
return FileResponse(file)
@app.get("/") @app.get("/")
async def front_page(request: Request) -> Response: async def front_page(request: Request) -> Response:
"""Render the front page (slug path "").""" """Render the front page (slug path "")."""
return await show_page(request, "") return await show_page(request, "")
# Vue build asset routes are inserted at this position during load(). # Vue build asset routes are inserted at this position during load(): the
frontend.route(app, "/_/assets") # build mirrors the URL space (/_/assets/*, /favicon.ico at the root).
frontend.route(app, "/")
@app.get("/{path:path}", response_model=None) @app.get("/{path:path}", response_model=None)
+5 -4
View File
@@ -243,8 +243,9 @@ def _page_assets() -> tuple[list[str], list[str]]:
) )
manifest = json.loads((BUILD / ".vite/manifest.json").read_text()) manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
entry = manifest["src/pagerite.js"] entry = manifest["src/pagerite.js"]
styles = [f"/_/assets/{css}" for css in entry.get("css", [])] # Manifest paths already carry the _/assets/ prefix (assetsDir).
return [f"/_/assets/{entry['file']}"], styles styles = [f"/{css}" for css in entry.get("css", [])]
return [f"/{entry['file']}"], styles
def _editor_assets() -> tuple[list[str], list[str]]: def _editor_assets() -> tuple[list[str], list[str]]:
@@ -260,8 +261,8 @@ def _editor_assets() -> tuple[list[str], list[str]]:
) )
manifest = json.loads((BUILD / ".vite/manifest.json").read_text()) manifest = json.loads((BUILD / ".vite/manifest.json").read_text())
entry = manifest["src/main.js"] entry = manifest["src/main.js"]
styles = [f"/_/assets/{css}" for css in entry.get("css", [])] styles = [f"/{css}" for css in entry.get("css", [])]
return [f"/_/assets/{entry['file']}"], styles return [f"/{entry['file']}"], styles
def render_editor() -> str: def render_editor() -> str: