Compare commits

..
8 Commits
Author SHA1 Message Date
LeoVasanko 7e8e0a2ea7 Click-to-enlarge lightbox for article figures
Clicking a figure image opens a full-viewport overlay: the image as
large as fits (100vw, flex-shrunk to leave exactly the caption's
height) with the caption below. Click or any key closes it; wheel and
touch scrolling stop at the overlay (overscroll-behavior: contain).

Styling lives in the base theme: blurred dark backdrop (theme-tunable
via --lightbox-bg / --lightbox-text), soft shadow, reduced-motion-aware
open animation.
2026-08-30 02:08:06 +00:00
LeoVasanko 8fc5dd7b9b Keep margin boxes in the column flow, position them out of flow
::: aside / .margin blocks no longer split the column segments: they
stay inside the .colseg at their anchor point, so the surrounding text
is measured and laid out as a single columned layout (split-off sides
previously lost .cols when too short on their own).

The zone placements (multicol side zone, sidebar track, wide gutter)
now position the boxes absolutely off the article's left border —
unaffected by any column layout inside — with the vertical spot coming
from the unset top (where the box occurs in the text). Narrow widths
keep the in-column float fallback. Trade-off: out-of-flow boxes no
longer stack via clear, so boxes anchored close together may overlap.
2026-08-30 01:41:47 +00:00
LeoVasanko fbebddeaa9 Style inline code; drop explicit colorspace from color-mix
Inline code gets padding-inline, keep-all word-break, and a color nudged
30% toward --muted from the inherited color, so code inside
accent-colored text keeps its hue.

All color-mix calls now rely on the default interpolation space (oklab)
instead of declaring srgb/oklab explicitly.
2026-08-30 01:22:22 +00:00
LeoVasanko 31895065f8 Collect analytics over a /_ws WebSocket instead of POST /_a pings 2026-08-29 21:20:04 +00:00
LeoVasanko 447a565b05 Disallow /auth/ and /_api in robots.txt 2026-08-29 20:45:39 +00:00
LeoVasanko 399aa95d44 Encapsulate all migrations in kanta migrate_vN, drop persisted Data.version
migrate_v1 now also rebuilds the legacy flat pages store as the menu
tree (moved from the app lifespan, raw-dict level); migrate_v2 now also
backfills missing AVIF/WebP/JPEG derivatives on disk (moved from the
lifespan) and drops the obsolete version field. The version render
counter was cache-invalidation state, not database state: replaced by an
in-memory render generation that clears the page-body LRU and feeds page
ETags. The legacy Page struct and Data.pages/version fields are removed;
old databases lose the stale keys on re-serialization.
2026-08-29 20:16:13 +00:00
LeoVasanko f793d21c5e Serve images extension-less at /_f/{hash} with Accept-negotiated AVIF/WebP/JPEG
Uploaded images (SVGs rasterized, GIFs excepted) are stored as the
original (hash.orig.ext, internal only, never served) plus AVIF primary
and WebP/JPEG fallback derivatives re-encoded from it. Pages link the
bare hash; the server serves a format only when Accept lists it
explicitly (image/avif -> AVIF, image/webp -> WebP, else JPEG) with
vary: accept, while an explicit extension pins the format. Favicons go
through the same pipeline at 192px. migrate_v2 rewrites old
/_f/{hash}.avif article links, a startup backfill creates missing
derivatives, and twitter:image pins the .webp variant for X's scraper.
2026-08-29 19:46:47 +00:00
LeoVasanko fb3e6d1a04 Use runtime-only Vue build, raise chunk warning to 1.2 MB 2026-08-29 06:27:16 +00:00
20 changed files with 798 additions and 347 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `pagerite/` — Python backend package (hatchling build target).
- `app.py` — FastAPI app and route registration.
- `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.
- `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field.
- `markdown.py` — markdown-it-py renderer.
- `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.
+46 -29
View File
@@ -9,9 +9,9 @@ directory, e.g. `localhost/analytics.json`).
`CrawlerHit`, `AbuseHit`, `Favicon`) and the `Store` (in-memory data + session map,
atomic JSON persistence).
- `pagerite/app.py` — entry-referer stashing in `show_page` (`_track_entry`),
the `POST /_a` ping endpoint, and `WebSocket /_api/ws/analytics`
the `/_ws` activity WebSocket, and `WebSocket /_api/ws/analytics`
(admin-gated like every `/_api` endpoint).
- `frontend/src/pagerite.js` — client navigation pings and the 📊 pen.
- `frontend/src/pagerite.js` the client activity channel and the 📊 pen.
- `frontend/src/AnalyticsView.vue` — viewer component rendered inside the
normal site layout on the `/_a` analytics page.
- `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView`
@@ -19,46 +19,61 @@ directory, e.g. `localhost/analytics.json`).
## What is collected
The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
`fr`, `to`, `hide` and `read` as query parameters (`fr` = source path;
falsy values are omitted):
The client (`pagerite.js`) keeps a WebSocket connection to `/_ws` for the
whole browsing session and sends activity messages over it — JSON text
frames matching the server's `Ping` msgspec struct with the fields `fr`
(source path), `to` (navigation target), `read` (active seconds on `fr`
since the last report) and `hide`; falsy fields are omitted. One channel
follows the session, so the activity of a visit stays tied together, and
while the user is active the accumulated reading time is flushed every few
seconds: the trail times are cumulative, so a disconnection simply leaves
the last reported time in place (no close beacon). After 5 minutes without
any activity the client closes the socket itself — a sleeping browser tab
would lose it anyway — and the next activity reconnects as a fresh session;
reconnects are attempted only on user activity, with an exponential backoff
between attempts so a failing endpoint is never hammered. Idle-time link preloads
stay plain `fetch()` calls so the browser may cache the responses; the
WebSocket reports actual navigations and active time spent on a page.
- **Initial page load**: only `to` — the loaded path — is sent, never `fr`
(an `fr` equal to `to` would log a bogus self-transition when a session
already exists, e.g. a second tab). This ping is what starts
already exists, e.g. a second tab). This message is what starts
the visit and counts the entry page view — the document GET alone records
nothing, so bots never register (admin browsing does register, but
flagged `hide`; see **Admins** below). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent
gives them away: pings whose UA matches `_is_bot_ua` (anything calling
(Googlebot, GoogleOther, Applebot, ...) do connect and report, but their
User-Agent gives them away: messages whose UA matches `_is_bot_ua`
(anything calling
itself a "bot", plus known exceptions such as GoogleOther) are ignored
server-side, and their document GETs land in the crawler list instead.
No source-IP verification is done: a spoofed bot UA merely lands in the
crawler stats, and scanners that probe telltale paths are caught by the
abuse rules regardless. Reloads are not
visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a
visits: the message is skipped (PerformanceNavigationTiming `reload`), so a
refresh neither counts a second view nor logs a self-transition. The GET
handler stashes a cross-origin https `Referer` (origin part only —
unavailable to JS once the page has loaded) and any
`utm_*` query parameters in in-memory IP tables, consumed by the ping that
`utm_*` query parameters in in-memory IP tables, consumed by the first
message that
starts the visit; internal or absent referers never touch the referer table.
- **Internal fetch-navigations**: `to` is the target path, sent only after
the swap actually happened (a failed swap falls back to a full load,
whose initial ping counts the view instead — no gap, no double count).
whose initial message counts the view instead — no gap, no double count).
- **External links** (`https` only): `to` is the link's full URL. This is the
exit-link record; the user may continue navigating afterwards (new tab,
back), so the exit URL is not necessarily the last trail entry. Outbound
links are stored by full URL so several links to the same domain remain
distinct.
- **Excluded**: back/forward (popstate) navigations, navigating *to* the
analytics page (`/_a` — its GET is untracked, and the server rejects it
as a ping target anyway), and everything while the user has the editor
analytics page (`/_a` — its GET is untracked, and the server cannot
record it as a navigation target anyway), and everything while the user has
the editor
open (`body.editing`). Admin noise, not visits. Navigating *away* from
`/_a` does ping: the fetch-navigation already GET-ed the target page
without the preload header, and without the ping that GET would flush to
`/_a` does report: the fetch-navigation already GET-ed the target page
without the preload header, and without the message that GET would flush to
the crawler list.
- **Admins**: when SSO is in use and the session is known to be an admin,
the client still pings but adds `hide=1`. The activity is recorded as
the client still reports but adds `hide`. The activity is recorded as
usual (navigations and all), but the `hide` flag is set on the **client
record** — so it covers everything that client ever did: visits and
crawler hits from before the login included. Hidden clients never appear
@@ -81,7 +96,7 @@ falsy values are omitted):
extension matching the actual MIME). The origin → file name mapping is
recorded in `Analytics.favicons` (`Favicon.file`/`fetched`); misses are
recorded too and retried only after 7 days. Fetches are scheduled after
each ping and once at startup, which backfills icons for already-recorded
each activity message and once at startup, which backfills icons for already-recorded
data. The viewer payload carries `favicons` (origin → `/_f/...` path),
and the viewer shows the icon wherever an external site is mentioned:
referer/exit trail links in the visit table and the source/exit pills of
@@ -100,8 +115,8 @@ falsy values are omitted):
`host`; local/reserved/multicast addresses are skipped. If a DB-IP MMDB
file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present in the repository
root, it is loaded at startup and used to look up `country`/`city`. These
lookups run in background tasks after the event is stored, so the `/_a`
response is never delayed. The decompressed `dbip-*.mmdb` file is kept in
lookups run in background tasks after the event is stored, so WebSocket
message handling is never delayed. The decompressed `dbip-*.mmdb` file is kept in
the repository root and ignored by git. The CLI flag `--dbip`
(`uv run pagerite --dbip`) downloads the latest
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP before the server starts,
@@ -110,10 +125,11 @@ falsy values are omitted):
file is used.
- **Crawler hits**: every document GET is queued in RAM as a pending crawler
hit — except idle-time link preloads from pagerite.js, which carry an
`x-pagerite-preload` header and are not tracked at all (the ping sent when
the user actually navigates to a preloaded page does the counting; forging
`x-pagerite-preload` header and are not tracked at all (the navigation
message sent when the user actually navigates to a preloaded page does
the counting; forging
the header only hides a GET from the crawler stats, the path-based abuse
classification is unaffected). If a ping
classification is unaffected). If a message
from the same client arrives within 10 seconds the hit is discarded;
otherwise it is written to `crawlers` — unless the client is hidden
(admin), in which case the hit is discarded on expiry too. Crawlers do not count as
@@ -131,7 +147,7 @@ falsy values are omitted):
random-UA scanner no longer pollutes the crawler stats of the legitimate
bot it impersonates. Once classified, every document GET and 404 from the
IP is recorded as an abuse hit with the full request path (query string
included), and its pings are ignored. The classified IP set (`abuse_ips`)
included), and its activity messages are ignored. The classified IP set (`abuse_ips`)
is persisted in the JSON file; the plain-404 counters are RAM-only. In the
viewer, abuse hits are grouped by IP (never by client/UA — scanners
randomize theirs) in a separate "Abuse" table. Identical paths are
@@ -145,9 +161,9 @@ falsy values are omitted):
There are no cookies. A visit is tied together by a client hash — the first
6 bytes of a blake3 digest over the prettified IP (IPv4 unchanged, IPv6
/64 network), the raw `User-Agent` string and the extracted
`Accept-Language` tag. The first ping from a client hash starts a new
visit; subsequent pings extend it. Pings arriving with no known session
(server restart) start a fresh visit from the first ping — treated as
`Accept-Language` tag. The first message from a client hash starts a new
visit; subsequent messages extend it. Messages arriving with no known session
(server restart) start a fresh visit from the first message — treated as
missing data rather than dropped. The client-hash → visit map and the IP →
entry-referer/UTM tables are in-memory only; client metadata is stored in
`Analytics.clients` keyed by the client hash.
@@ -164,7 +180,7 @@ Each `Client` record:
- `ua` — raw `User-Agent` string,
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
parsable, otherwise the raw string,
- `hide` — true for admin clients (`hide=1` ping): all their visits,
- `hide` — true for admin clients (`hide` message field): all their visits,
crawler hits and abuse hits are recorded but excluded from every
statistic and from the viewer payload.
@@ -180,7 +196,7 @@ Each `Visit` record:
reading time in seconds (`read`) and the most recent HTTP status seen
for the target (`status`). Re-visiting an already seen target updates
its item instead of appending.
- `navs` — every navigation ping (`fr`, `to`), keyed by its timestamp,
- `navs` — every navigation message (`fr`, `to`), keyed by its timestamp,
repeats included. The aggregates are computed from this log at display
time.
- `utm``utm_*` query parameters from the landing URL, as a dict.
@@ -227,7 +243,8 @@ shapes, part of the WebSocket payload (`Display` struct alongside `visits`,
- `transitions`: time series of page transitions, sparse nested dict
`from -> to -> bucket -> count` with 5-minute bucketing. `from` is the
referer origin or `"(direct)"` for initial loads, a page path for pings.
referer origin or `"(direct)"` for initial loads, a page path for
navigations.
- `views`: time series of page loads, `path -> bucket -> count`, sparse: only
non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp).
Every load counts, including repeats within a visit; external exit origins
+4 -4
View File
@@ -8,9 +8,9 @@ The FastAPI app. FastAPI's built-in API docs are disabled (`docs_url`/`redoc_url
The build mirrors the URL space — hashed 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.
Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding, and `data.version`, which bumps on every content/settings change and so transparently invalidates the whole cache. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and `data.version`; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304.
Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding and cleared wholesale by `_invalidate_pages()` on every content/settings change, which also bumps the in-memory render generation. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and the render generation; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304.
Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `<hostname>/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Legacy databases that still carry blobs in a `files` kanta field are migrated to disk by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which pops the field from the raw state before struct decoding.
Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `<hostname>/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Uploaded raster images (and rasterized SVGs) are stored as `<hash>.orig<ext>` (internal only, never served) plus AVIF, WebP and JPEG derivatives, and pages link the extension-less `/_f/{hash}`: the server serves a format only when the Accept header lists it explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise — including `*/*` — JPEG), with `vary: accept`; an explicit extension pins the format. `migrate_v2` rewrites old `/_f/{hash}.avif` article links to the bare form, backfills missing derivatives on disk, and drops the obsolete `version` field. Legacy databases that still carry blobs in a `files` kanta field or a flat `pages` store are migrated by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which rewrites the raw state before struct decoding — all schema/storage upgrades live in that module, none in the app lifespan.
## `data.py`
@@ -20,13 +20,13 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class.
`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `<div class="colseg">` (margin-breakout boxes — `.margin`, `::: aside` stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
## `views.py`
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL``https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels).
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL``https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. Additionally `twitter:image` pins extension-less `/_f/{hash}` share images to the `.webp` variant — X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels).
The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, main-level pages, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap).
+3 -3
View File
@@ -8,13 +8,13 @@ The site structure is stored in the kanta database managed by `pagerite/data.py`
`Node.content` is the Markdown page, or None for a pure category label whose URL renders a 404 listing its children as cards (while nav links to it point at its first child); every label's title and slug are editable. A page with published children — a category page — lists them as cards after its markdown content; the sidebar sub-navigation renders only from the second level down, never on main-level pages.
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `Data.pages` (pre-tree databases) migrates into `menu` on startup. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `pages` (pre-tree databases) migrates into `menu` via `migrate_v1`. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
`Data.version` is bumped on every write and embedded in page ETags so nav-affecting changes invalidate caches.
Every content/settings write calls `_invalidate_pages()` in app.py, which clears the rendered-body LRU and bumps an in-memory render generation embedded in page ETags, so nav-affecting changes invalidate caches. (This used to be a persisted `Data.version` counter — cache invalidation is not database state, so the field was dropped; old databases lose the key on re-serialization.)
## Files
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except SVG/GIF) are stored as a pair: the untouched original under `<hash>.orig<ext>` and a mediapreview-recompressed AVIF derivative (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`) which is the externally linked file; deleting either name removes the pair. the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except GIF) and SVGs (rasterized) get a set of derivatives: the untouched original under `<hash>.orig<ext>` (internal only — it may carry EXIF data and is never served; SVG originals stay servable as `<hash>.svg`), a mediapreview-recompressed AVIF (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`), and WebP/JPEG fallbacks re-encoded from the AVIF at lower quality (`IMAGE_WEBP_QUALITY`/`IMAGE_JPG_QUALITY`, chosen for similar-or-smaller file size). Pages link the bare `/_f/<hash>` and the server negotiates by Accept header: a format is served only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, anything else including `image/*` and `*/*` → JPEG); an explicit extension in the URL pins the format. Responses carry `vary: accept`. Favicons uploaded in settings go through the same pipeline at `FAVICON_MAXSIZE` (192px). Existing databases are updated by `migrate_v2` (link rewrite plus on-disk derivative backfill). Deleting any name of a hash removes the whole group. The `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
## Banners
+4 -4
View File
@@ -19,8 +19,8 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Raster images (not SVG/GIF) are recompressed via mediapreview: the original is kept as `/_f/{hash}.orig{ext}` while pages link the thumbnailed AVIF derivative `/_f/{hash}.avif`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `![alt](/_f/….avif "Caption"){.right}``{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they are taken out of flow into the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/`. Raster images (not GIF) and SVGs (rasterized) are recompressed via mediapreview: the original is kept as `{hash}.orig{ext}` (internal only, never served — it may carry EXIF data; SVG originals stay servable as `{hash}.svg`) while pages link the extension-less `/_f/{hash}` and the server picks from the derivatives (`{hash}.avif` / `{hash}.webp` / `{hash}.jpg`) by Accept header — a format only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise JPEG), with `vary: accept`; an explicit extension in the URL pins the format. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `![alt](/_f/… "Caption"){.right}``{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, placed in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
## Page structure and navigation
@@ -34,7 +34,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
## Reading experience
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem.
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings and `.wide` elements (full-width separators, never inside columns); margin boxes stay inside the segment at their anchor point and the CSS takes them out of flow — absolutely positioned off the article's left border into the zone, the columns flowing through unaffected — tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem.
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
## Styling
@@ -48,7 +48,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap).
- **Site mode** — the ⚙️ at the top right (after the 📊 analytics link, before login) opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), a **page transition** selector (`cube`/`crossfade`, swapping `#pagerite-transition` in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor.
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash)` at the cursor.
- The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page.
- Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a stateless JSON protocol (`open`/`render`/`save`; on save all fields are optional and absent ones keep their old values, `move_from` renames), avoiding REST polling and races. Rendering always stays server-side.
- A REST API also exists for scripting, all under `/_api/`: `GET pages` (the full tree), `PUT/DELETE pages/{path}`, `GET/PUT settings` (site brand, theme and custom CSS), `POST structure` (reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`.
+1 -1
View File
@@ -8,7 +8,7 @@ Vue editor app entry, mounts the tabbed `EditorShell`. See `docs/editing.md` for
## `pagerite.js`
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, and the auth check.
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, click-to-enlarge on article figure images (a full-viewport lightbox with the caption, closed by click or Esc), and the auth check.
It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`.
+140 -52
View File
@@ -17,7 +17,7 @@
--line: #0000001a;
/* Links stay quiet — mostly text color with a hint of the accent —
and light up to the full accent on hover. */
--link: color-mix(in oklab, var(--text) 50%, var(--accent));
--link: color-mix(var(--text) 50%, var(--accent));
/* Selection fill for page text and the CodeMirror editors; themes
override when the accent tint clashes with accent-colored text. */
--selection-bg: color-mix(var(--accent) 30%, transparent);
@@ -111,13 +111,13 @@ html {
auto-hidden scrollbars styled by the --os-* variables below, so they
never reserve layout space or shift the page when appearing. */
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
scrollbar-color: color-mix(var(--muted) 45%, transparent) transparent;
}
.os-scrollbar {
--os-size: 0.5rem;
--os-thumb-bg: color-mix(in srgb, var(--muted) 45%, transparent);
--os-thumb-hover-bg: color-mix(in srgb, var(--muted) 65%, transparent);
--os-thumb-bg: color-mix(var(--muted) 45%, transparent);
--os-thumb-hover-bg: color-mix(var(--muted) 65%, transparent);
--os-thumb-active-bg: var(--muted);
--os-track-bg: transparent;
--os-thumb-border-radius: 0.25rem;
@@ -406,7 +406,7 @@ body.editing #sidebar {
overflow-y: auto;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 0 0.5rem 0;
background: color-mix(in srgb, var(--bg) 75%, transparent);
background: color-mix(var(--bg) 75%, transparent);
backdrop-filter: blur(0.5rem);
}
@@ -818,7 +818,7 @@ article dd {
two fluid lanes (36rem minimum, never more than two) once they fit
beside the zone, capped at 102rem total. The zone — the region the nav
sidebar overlays — is a margin indent on the lane content; margin
boxes float into it, and the text never moves. */
boxes are placed into it out of flow, and the text never moves. */
article.multicol {
margin-inline: auto;
max-width: 58rem; /* 42rem lane + 16rem zone */
@@ -827,32 +827,27 @@ article.multicol {
@container (min-width: 45rem) {
/* The side zone (not on phones): lane content indents 16rem; margin
boxes ({.margin} / ::: margin blocks, ::: aside, {.margin} figures)
float at the article's left edge — the same region the nav sidebar
overlays. Scoped to direct article children (the backend render keeps
margin blocks out of the column segments); nested ones keep the
in-column float fallback. */
are taken out of flow and placed against the article's left edge —
the same region the nav sidebar overlays. The boxes stay in the
column segment at their anchor point (the backend render no longer
splits segments around them); absolute positioning off the article —
always position: relative — pins the horizontal side to the zone
regardless of any column layout inside, while the unset top keeps
the box at the vertical position where it occurs in the text. */
article.multicol>.colseg,
article.multicol>h1,
article.multicol>h2 {
margin-left: 16rem;
}
article.multicol>.margin,
article.multicol>.aside,
article.multicol>figure:has(.margin) {
float: left;
clear: left;
article.multicol .margin,
article.multicol .aside,
article.multicol figure:has(.margin) {
position: absolute;
left: 0;
width: 14rem;
max-width: none;
margin: 0.3rem 2rem 1rem 0;
}
/* Wide separators start below any margin box — their bleed must not
wrap around it. */
article.multicol>figure:has(.wide),
article.multicol>div.wide,
article.multicol>pre.wide {
clear: left;
margin: 0.3rem 0 0;
}
}
@@ -896,11 +891,10 @@ article.multicol {
margin-left: 0;
}
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.margin,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.aside,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>figure:has(.margin) {
float: left;
clear: left;
body:has(#sidebar):has(.multicol):not(.editing) article.multicol .margin,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol .aside,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol figure:has(.margin) {
position: absolute;
/* Attached to the article's left border (1.25rem gap), hanging into
the left lane and growing leftward with it: 12rem when the lane is
tight, up to 150% (18rem) when the track or the surplus has room
@@ -909,7 +903,8 @@ article.multicol {
--box-w: min(18rem, var(--lane) + 100cqw - 100% - 1.25rem);
width: var(--box-w);
max-width: none;
margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w));
left: calc(-1.25rem - var(--box-w));
margin: 0.3rem 0 0;
}
}
@@ -1020,7 +1015,7 @@ blockquote p + p {
padding: 0.4rem 0.9rem;
border-left: 0.25rem solid var(--admonition-color, var(--accent));
border-radius: 0 0.3rem 0.3rem 0;
background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent);
background: color-mix(var(--admonition-color, var(--accent)) 7%, transparent);
}
.admonition> :last-child,
@@ -1093,14 +1088,17 @@ blockquote p + p {
--admonition-color: var(--accent3);
}
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
via clear: left); {.margin} / ::: margin is a plainer margin note, and
figures take {.margin} like {.left}. On multicol pages they float in
the composition's left side zone — or in the sidebar's track when the
layout reserves one (see the article section); on wide single-column
pages they lean into the left gutter (with the figure rules below);
otherwise they stay in-column left floats. Headings already clear
floats, so boxes never bleed into the next section. */
/* Side boxes: ::: aside is a muted floated box; {.margin} / ::: margin
is a plainer margin note, and figures take {.margin} like {.left}.
Where the layout has room for a side zone — multicol pages, the
sidebar's track, the wide single-column gutter (see the article
section and the figure rules below) — the boxes are taken out of flow
and absolutely positioned into it, off the article's left border, each
at the vertical spot where it occurs in the text (boxes occurring
closer together than their heights may overlap — keep them apart);
otherwise they stay in-column left floats (consecutive floats stack
via clear: left). Headings already clear floats, so in-column boxes
never bleed into the next section. */
.aside {
float: left;
clear: left;
@@ -1110,7 +1108,7 @@ blockquote p + p {
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
color: var(--muted);
background: color-mix(in srgb, var(--accent) 6%, transparent);
background: color-mix(var(--accent) 6%, transparent);
border-radius: 0.3rem;
}
@@ -1169,6 +1167,21 @@ code {
font-size-adjust: ex-height var(--code-x-height);
}
/* Inline code: a slight nudge toward the muted tone rather than a fixed
color, so code inside accent-colored headings keeps the heading's hue
and the distinction stays subtle everywhere. color-mix resolves against
the inherited color (currentColor in a color declaration refers to the
inherited value), shifting it 30% toward --muted. */
code:not(pre code) {
padding-inline: 0.2em;
word-break: keep-all;
color: color-mix(currentColor 70%, var(--muted));
}
code:not(pre code):first-child {
padding-left: 0;
}
/* Click-to-copy button (added by pagerite.js) */
.copy {
position: absolute;
@@ -1213,18 +1226,18 @@ td {
th {
text-align: left;
background: linear-gradient(180deg,
var(--table-head-a, color-mix(in srgb, var(--accent) 10%, var(--surface))),
var(--table-head-b, color-mix(in srgb, var(--accent) 18%, var(--surface))));
var(--table-head-a, color-mix(var(--accent) 10%, var(--surface))),
var(--table-head-b, color-mix(var(--accent) 18%, var(--surface))));
}
td {
background: linear-gradient(160deg,
color-mix(in srgb, var(--table-tint, var(--accent)) 5%, transparent),
color-mix(var(--table-tint, var(--accent)) 5%, transparent),
transparent 75%);
}
tbody tr+tr td {
border-top: 1px solid color-mix(in srgb, var(--table-tint, var(--accent)) 12%, transparent);
border-top: 1px solid color-mix(var(--table-tint, var(--accent)) 12%, transparent);
}
/* Definition lists are laid out as a lightweight two-column grid — terms
@@ -1333,21 +1346,96 @@ figure:has(.margin) {
margin: 0.3rem 1em 1rem 0;
}
/* Click-to-enlarge (pagerite.js): article figure images open in a
full-viewport lightbox — the image as large as fits with the caption
below; click or Esc closes. The backdrop keeps a hint of the page
behind it (blur + dark tint) rather than going solid black; themes
retune it via --lightbox-bg / --lightbox-text. */
article figure img {
cursor: zoom-in;
}
#lightbox {
--lightbox-bg: rgb(0 0 0 / 0.68);
--lightbox-text: #e8e8e8;
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--lightbox-bg);
backdrop-filter: blur(1rem) saturate(0.85);
/* Wheel/touch scrolling stops at the overlay instead of chaining to
the page behind it. overscroll-behavior needs a scroll container,
hence overflow: auto — the content is clamped to fit, so it never
actually scrolls. */
overflow: auto;
overscroll-behavior: contain;
cursor: zoom-out;
}
#lightbox img {
max-width: 100vw;
/* Flex-shrink does the vertical fit: the image yields exactly the
space the caption needs, no fixed reservation. */
max-height: 100%;
flex: 0 1 auto;
min-height: 0;
object-fit: contain;
border-radius: 3px;
box-shadow: 0 1rem 4rem rgb(0 0 0 / 0.55);
}
#lightbox .caption {
max-width: 65ch;
padding: 0.8rem 1rem;
color: var(--lightbox-text);
font-size: 0.95rem;
text-align: center;
opacity: 0.85;
}
@media (prefers-reduced-motion: no-preference) {
#lightbox {
animation: lightbox-in 0.18s ease-out;
}
#lightbox img {
animation: lightbox-img 0.22s ease-out;
}
@keyframes lightbox-in {
from {
opacity: 0;
}
}
@keyframes lightbox-img {
from {
opacity: 0;
transform: scale(0.96);
}
}
}
/* Wide single-column pages: margin boxes lean into the vacant left
gutter instead (below 104rem the gutter cannot hold the box, and while
editing the docked panel reshapes the gutters — in both they stay
plain floats). The box grows with the gutter up to 150% (18rem), its
right side 1.25rem off the article's left border. */
plain floats). Out of flow like on multicol pages: the box hangs off
the article's left border, growing with the gutter up to 150% (18rem),
its right side 1.25rem off the border. */
@media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) article>.margin,
body:not(.editing):not(:has(.multicol)) article>.aside,
body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) {
float: left;
clear: left;
body:not(.editing):not(:has(.multicol)) article .margin,
body:not(.editing):not(:has(.multicol)) article .aside,
body:not(.editing):not(:has(.multicol)) article figure:has(.margin) {
position: absolute;
--box-w: min(18rem, (100vw - 78rem) / 2 - 1.25rem);
width: var(--box-w);
max-width: none;
margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w));
left: calc(-1.25rem - var(--box-w));
margin: 0.3rem 0 0;
}
}
+2 -2
View File
@@ -25,12 +25,12 @@ pre code .cp { color: var(--code-comment); font-weight: bold; font-style: italic
pre code .cpf { color: var(--code-comment); font-style: italic } /* Comment.PreprocFile */
pre code .c1 { color: var(--code-comment); font-style: italic } /* Comment.Single */
pre code .cs { color: var(--code-comment); font-weight: bold; font-style: italic } /* Comment.Special */
pre code .gd { color: var(--code-error); background-color: color-mix(in oklab, var(--code-error) 25%, var(--code-bg)) } /* Generic.Deleted */
pre code .gd { color: var(--code-error); background-color: color-mix(var(--code-error) 25%, var(--code-bg)) } /* Generic.Deleted */
pre code .ge { color: var(--code-text); font-style: italic } /* Generic.Emph */
pre code .ges { color: var(--code-text); font-weight: bold; font-style: italic } /* Generic.EmphStrong */
pre code .gr { color: var(--code-error) } /* Generic.Error */
pre code .gh { color: var(--code-builtin); font-weight: bold } /* Generic.Heading */
pre code .gi { color: var(--code-added); background-color: color-mix(in oklab, var(--code-added) 25%, var(--code-bg)) } /* Generic.Inserted */
pre code .gi { color: var(--code-added); background-color: color-mix(var(--code-added) 25%, var(--code-bg)) } /* Generic.Inserted */
pre code .go { color: var(--code-muted) } /* Generic.Output */
pre code .gp { color: var(--code-muted) } /* Generic.Prompt */
pre code .gs { color: var(--code-text); font-weight: bold } /* Generic.Strong */
+153 -54
View File
@@ -268,6 +268,41 @@ import "overlayscrollbars/overlayscrollbars.css";
}
}
// --- Figure lightbox (click to enlarge) --------------------------------
// Clicking an article figure's image opens it in a full-viewport box:
// the image as large as fits with its caption below; click or Esc
// closes. The zoomed <img> reuses the source URL — /_f/ is immutable
// and content-negotiated, so the large view comes from the browser
// cache at no extra cost.
let lightbox = null;
function closeLightbox() {
lightbox?.remove();
lightbox = null;
}
function openLightbox(figure) {
const img = figure.querySelector("img");
if (!img) return;
closeLightbox();
lightbox = document.createElement("div");
lightbox.id = "lightbox";
const big = document.createElement("img");
big.src = img.currentSrc || img.src;
big.alt = img.alt;
lightbox.append(big);
const cap = figure.querySelector("figcaption");
if (cap) {
const c = document.createElement("div");
c.className = "caption";
c.textContent = cap.textContent;
lightbox.append(c);
}
lightbox.addEventListener("click", closeLightbox);
document.body.append(lightbox);
}
// Any key dismisses the lightbox — Esc included, and the rest would
// only scroll the page behind it anyway.
addEventListener("keydown", () => closeLightbox());
// Tuck the article edit pen at the end of the first h1 (which may come
// from the markdown itself). Re-runs when the editor replaces the
// previewed article, since that wipes elements inside it.
@@ -347,8 +382,8 @@ import "overlayscrollbars/overlayscrollbars.css";
for (const url of urls) {
if (pageCache.has(url)) continue;
// x-pagerite-preload: idle cache warm-up, not a page view — the
// server excludes these GETs from analytics (the ping sent on actual
// navigation does the counting).
// server excludes these GETs from analytics (the navigation message
// sent on actual navigation does the counting).
fetch(url, { headers: { "x-pagerite-preload": "1" } })
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : ""))
@@ -432,61 +467,111 @@ import "overlayscrollbars/overlayscrollbars.css";
});
}, { passive: true });
// --- Analytics pings ---------------------------------------------------
// Fire-and-forget POSTs to /_a with the fields as query parameters (a
// beacon can carry no body, and query args show in server logs next to
// the document GET they refer to): on the initial page load (starts the
// visit — the server counts nothing from the document GET alone), for
// internal fetch-navigations, for external https exits, and on window
// close. ``read`` is the active time (ms) spent on ``fr``.
// Reading time pauses after 1 minute of inactivity and resumes on the
// next mouse/touch/scroll/keyboard event.
// Excluded: back/forward (popstate never pings), everything while the
// --- Analytics over WebSocket ------------------------------------------
// One /_ws connection follows the whole browsing session: the initial page
// load (starts the visit — the server counts nothing from the document GET
// alone), internal fetch-navigations, external https exits, and frequent
// active reading-time updates. Messages are JSON text frames matching the
// server's msgspec Ping struct: {fr?, to?, read?, hide?} — falsy fields
// are omitted. ``read`` is the active time (ms) accumulated on ``fr`` since
// the last report; reading time pauses after 1 minute of inactivity and
// resumes on the next mouse/touch/scroll/keyboard event. While the user is
// active, accumulated reading time is flushed every few seconds, so a
// disconnect simply leaves the last reported time on the server — no close
// beacon is needed. After 5 minutes without any activity the client closes
// the channel itself (a sleeping tab would lose it anyway); the next
// activity reconnects and the server sees a new session.
// Excluded: back/forward (popstate never reports), everything while the
// editor is open (body.editing — admin noise, not visits), and
// navigations TO the analytics page (/_a — admin machinery, and the
// server rejects it as a ping target anyway). Navigations AWAY from /_a
// must ping: load() already fetched the target page without the preload
// header, and without the ping that GET would flush to the crawler list.
// navigations TO the analytics page (/_a — admin machinery). Navigations
// AWAY from /_a must report: load() already fetched the target page
// without the preload header, and without the message that GET would flush
// to the crawler list.
// Admins (when SSO is actually in use — with no auth proxy "admin" is
// everyone's state) ping normally but with hide=1: the server then
// records nothing and scrubs any session the same browser accumulated
// before logging in, so admins never show up as visits or crawlers.
// everyone's state) report normally but with hide: the server then flags
// the client record, scrubbing everything it ever did from the statistics,
// so admins never show up as visits or crawlers.
// See docs/analytics.md.
// fetch wrapper: every key of ``params`` becomes a query arg on /_a
// (falsy values are omitted). Admins get hide=1. ``beacon`` uses
// sendBeacon when available, for unload-time pings.
function pingFetch(params, { beacon = false } = {}) {
const query = new URLSearchParams();
if (ssoAvailable && isAdmin) params = { ...params, hide: 1 };
for (const [key, value] of Object.entries(params)) {
if (value) query.set(key, value);
}
const url = `/_a?${query}`;
// The activity WebSocket. Messages sent before the connection opens are
// queued (the queue keeps the interim activity). Reconnects are driven by
// user activity only — never by timers while the page sits idle — with an
// exponential falloff between attempts so a failing endpoint cannot make
// us hammer the server (or trip its security limits). After a longer
// stretch without any activity we close the socket proactively: the user
// has moved on and left the tab open (a sleeping browser tab would lose
// the connection anyway), so the next activity reconnects and registers
// as a fresh session. Analytics must never break navigation: every send
// is wrapped, and a server without the endpoint just leaves the socket
// failing in the background.
let ws = null;
const wsQueue = [];
let wsReconnectMs = 1000;
let wsNotBefore = 0;
function activityWs() {
if (ws || Date.now() < wsNotBefore) return;
const url = new URL("/_ws", location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
try {
if (beacon && navigator.sendBeacon) {
navigator.sendBeacon(url);
} else {
fetch(url, { method: "POST", keepalive: true });
}
} catch { /* analytics must never break navigation */ }
ws = new WebSocket(url);
} catch {
return;
}
ws.onopen = () => {
wsReconnectMs = 1000;
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
};
ws.onclose = () => {
ws = null;
// No timer here: the next user activity retries, after the backoff.
wsNotBefore = Date.now() + wsReconnectMs;
wsReconnectMs = Math.min(wsReconnectMs * 2, 30_000);
};
ws.onerror = () => ws.close();
}
function ping({ to, fr = currentPath, read = 0, beacon = false } = {}) {
function report(msg) {
if (document.body.classList.contains("editing")) return;
if (to === "/_a") return;
pingFetch({ fr, to, read: Math.round(read / 1000) }, { beacon });
if (msg.to === "/_a") return;
if (ssoAvailable && isAdmin) msg.hide = true;
activityWs();
if (ws?.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(msg));
return;
} catch { /* fall through to queueing */ }
}
wsQueue.push(msg);
}
function ping({ to, fr = currentPath, read = 0 } = {}) {
// Reading-time updates from the analytics page itself are not tracked
// (/_a is admin machinery; the server would reject the path anyway).
if (!to && currentPath === "/_a") return;
const msg = {};
if (fr) msg.fr = fr;
if (to) msg.to = to;
const secs = Math.round(read / 1000);
if (secs > 0) msg.read = secs;
if (!msg.to && !msg.read) return;
report(msg);
}
// Active reading time for the current page. The clock stops after 1 minute
// without activity and restarts on the next mouse/touch/scroll/keyboard
// event.
// event. Every READ_FLUSH_MS of accumulated activity is reported. After
// IDLE_MS with no activity at all, the remaining read time is flushed and
// the WebSocket is closed: the user has moved on, and the next activity
// reconnects as a new session.
const INACTIVE_MS = 60_000;
const READ_FLUSH_MS = 5_000;
const IDLE_MS = 5 * 60_000;
let readStart = performance.now();
let readElapsed = 0;
let reading = true;
let readInactivityTimer = null;
let closePingedFor = null;
let idleTimer = null;
function markReadActivity() {
if (!reading) {
@@ -500,6 +585,24 @@ import "overlayscrollbars/overlayscrollbars.css";
reading = false;
}
}, INACTIVE_MS);
// Any activity is a sign of life: (re)connect the channel if it was
// dropped or idle-closed (not while editing — admin noise), and push
// the idle disconnect forward.
if (!document.body.classList.contains("editing")) activityWs();
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (ws) {
// Flush what is left unsent, then hang up. report() would try to
// reconnect a dead socket, which is exactly what we avoid here.
const left = takeReadTime();
if (Math.round(left / 1000) > 0) ping({ read: left });
ws.close();
}
}, IDLE_MS);
// Frequently update the article read time on the server.
if (readElapsed + performance.now() - readStart >= READ_FLUSH_MS) {
ping({ read: takeReadTime() });
}
}
function takeReadTime() {
@@ -519,28 +622,19 @@ import "overlayscrollbars/overlayscrollbars.css";
clearTimeout(readInactivityTimer);
}
function sendClosePing() {
if (closePingedFor === currentPath) return;
closePingedFor = currentPath;
const read = takeReadTime();
if (Math.round(read / 1000) <= 0) return;
ping({ read, beacon: true });
}
for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) {
addEventListener(ev, markReadActivity, { passive: true });
}
addEventListener("pagehide", sendClosePing);
// The initial page load pings too — it is what starts the visit and
// The initial page load reports too — it is what starts the visit and
// counts the entry page view (the document GET alone records nothing).
// It carries only ``to``: the server attributes the entry to the referer
// it saw on the document GET (unavailable to JS once loaded), and an
// ``fr`` equal to ``to`` would log a bogus self-transition when a
// session already exists (e.g. a second tab).
// Sent once per load, after the auth probes so the admin gate applies;
// the pageshow re-probe must not ping again. Reloads are not visits:
// pinging them would double-count the view and log a self-transition.
// the pageshow re-probe must not report again. Reloads are not visits:
// reporting them would double-count the view and log a self-transition.
let entryPinged = false;
function pingEntryOnce() {
if (entryPinged) return;
@@ -763,6 +857,13 @@ import "overlayscrollbars/overlayscrollbars.css";
.catch((e) => console.error("editor load failed:", e));
return;
}
// Article figure images enlarge into the lightbox.
const fig = ev.target.closest("#main article figure");
if (fig && ev.target.closest("img")) {
ev.preventDefault();
openLightbox(fig);
return;
}
const a = ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href);
@@ -770,7 +871,6 @@ import "overlayscrollbars/overlayscrollbars.css";
// External link: the browser navigates; record the full https URL so
// different links to the same domain stay distinct in analytics.
if (url.protocol === "https:") {
closePingedFor = currentPath;
ping({ to: url.href, read: takeReadTime() });
}
return;
@@ -794,7 +894,6 @@ import "overlayscrollbars/overlayscrollbars.css";
const from = currentPath;
load(url).then((ok) => {
if (!ok) return;
closePingedFor = null;
ping({ to: url.pathname, fr: from, read: takeReadTime() });
resetReadTime();
});
+9
View File
@@ -26,7 +26,16 @@ export default defineConfig({
},
},
appType: 'mpa', // no SPA fallback; every HTML page is served by FastAPI
resolve: {
alias: {
// All components are precompiled SFCs — drop the runtime template
// compiler (~60 kB min) from the bundle.
vue: 'vue/dist/vue.runtime.esm-bundler.js',
},
},
build: {
// The main editor bundle (CodeMirror + Vue) is intentionally one chunk.
chunkSizeWarningLimit: 1200,
// Mirror the URL space in the build output: hashed files land under
// frontend-build/_assets/ and the Frontend serves the build directory
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
+53 -26
View File
@@ -1,18 +1,23 @@
"""Server-side visit analytics (collection only; see docs/analytics.md).
Events come from navigation pings POSTed to /_a by pagerite.js: the first
ping on page load starts a visit, later pings extend it, and pings with no
known session start a fresh one (missing data, not dropped). The document
Events come from pagerite.js over the /_ws WebSocket (``Ping`` messages as
JSON text frames): the first navigation message on page load starts a
visit, later messages extend it, and messages with no known session start a
fresh one (missing data, not dropped). Active reading time is reported as
frequent ``read`` updates while the user is active; the times are
cumulative per trail item, so a disconnect simply leaves the last logged
time in place. The document
GET handler stashes the entry referer (external https origin) and any
utm_* query parameters in in-memory IP tables, consumed when the ping
starts the visit; nothing is counted without a ping (plain bots that only
fetch documents end up in the crawler list). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their UA gives them
away (``_is_bot_ua``) and their pings are ignored, so they land in the
utm_* query parameters in in-memory IP tables, consumed when the first
message starts the visit; nothing is counted without a message (plain bots
that only fetch documents end up in the crawler list). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do connect and send messages, but
their UA gives them
away (``_is_bot_ua``) and their messages are ignored, so they land in the
crawler list too. Idle-time link preloads from pagerite.js carry an
``x-pagerite-preload`` header and are not tracked at all — the ping sent
when the user actually navigates does the counting.
Admin clients ping with ``hide=1``: the client record is flagged ``hide``,
``x-pagerite-preload`` header and are not tracked at all — the navigation
message sent when the user actually navigates does the counting.
Admin clients send ``hide``: the client record is flagged ``hide``,
which covers everything that client ever did — visits and crawler hits
from before the login included. Aggregates (site visits, page views,
transitions) are not stored; they are computed at display time from the
@@ -70,6 +75,27 @@ def _compact_user_agent(ua: str) -> str:
return " ".join(p for p in parts if p).strip()
class Ping(msgspec.Struct, omit_defaults=True):
"""One client message on the /_ws activity WebSocket.
Sent as a JSON text frame (msgspec-encoded, decoded to str for the
wire). ``to`` set: a navigation — internal page path or external https
exit URL. ``read`` alone (with ``fr``): an active reading-time update
for the page ``fr``; these arrive frequently while the user is active
and accumulate on the trail item. ``hide`` flags the client as an
admin: everything it ever did is excluded from the statistics.
"""
#: Path of the page the activity happened on ("" for the initial load).
fr: str = ""
#: Navigation target: internal path or external https exit URL.
to: str = ""
#: Active reading time (seconds) spent on ``fr`` since the last report.
read: int = 0
#: Admin client: record but hide everything from the statistics.
hide: bool = False
class Client(msgspec.Struct, omit_defaults=True):
"""Client metadata shared by visits, crawler hits and abuse hits.
@@ -151,7 +177,7 @@ class Visit(msgspec.Struct, omit_defaults=True):
class CrawlerHit(msgspec.Struct, omit_defaults=True):
"""A document GET that was never followed by an analytics ping.
"""A document GET that was never followed by an activity message.
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
"""
@@ -392,19 +418,19 @@ class Store:
#: client hash -> index of the current visit in data.visits
self.sessions: dict[bytes, int] = {}
#: ip -> external https origin of the latest document GET carrying
#: one, stashed for the visit the client's initial ping starts.
#: one, stashed for the visit the client's initial message starts.
#: Internal or absent referers never touch the table.
self.pending_referers: dict[str, str] = {}
#: ip -> utm_* query parameters from the latest document GET that
#: carried any, stashed for the visit the client's initial ping starts.
#: carried any, stashed for the visit the client's initial message starts.
#: Only non-empty sets are stored, so a later parameter-less page
#: does not overwrite an earlier tagged landing URL.
self.pending_utms: dict[str, dict[str, str]] = {}
#: Document GETs that have not yet been matched by a ping. Kept
#: Document GETs that have not yet been matched by a message. Kept
#: in RAM only; expired entries are written to ``data.crawlers``.
self.pending_crawlers: list[CrawlerHit] = []
#: client hash -> {path: status} for recent document GETs, consumed
#: by the matching ping to record the status of each visited path.
#: by the matching message to record the status of each visited path.
self.pending_statuses: dict[bytes, dict[str, int]] = {}
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
@@ -721,16 +747,16 @@ class Store:
) -> list[bytes]:
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
Nothing is counted here — the client's initial /_a ping starts the
visit (only non-admin clients ping). Only a cross-origin https
Nothing is counted here — the client's first /_ws message starts the
visit (only non-admin clients report). Only a cross-origin https
referer updates the table; an internal or absent referer leaves any
stashed origin untouched. UTM parameters are kept only when the
landing URL actually carries them, so a subsequent parameter-less page
does not erase an earlier tagged landing.
Every document GET is also queued as a pending crawler hit. If a ping
from the same client arrives within ``_CRAWLER_TIMEOUT``, the hit is
discarded; otherwise it is flushed to ``data.crawlers``. The
Every document GET is also queued as a pending crawler hit. If a
message from the same client arrives within ``_CRAWLER_TIMEOUT``, the
hit is discarded; otherwise it is flushed to ``data.crawlers``. The
Accept-Language header is stored on the client record immediately;
host/geoip are filled in later by async enrichment.
@@ -794,15 +820,16 @@ class Store:
hide: bool = False,
read: int = 0,
) -> tuple[int | None, list[bytes]]:
"""Record a client navigation ping ({from, to, read} from pagerite.js).
"""Record a client activity message (``Ping`` from pagerite.js over /_ws).
``to`` is an internal path ("/...") or an https URL for exit links; a
missing/empty ``to`` means the page is being closed and only the
missing/empty ``to`` means a pure reading-time update and only the
``read`` time should be recorded. The transition is always counted when
``to`` is present; the trail only grows on first sight of a page within
the visit. ``read`` is the active time (seconds) spent on ``from_``.
the visit. ``read`` is the active time (seconds) spent on ``from_``
since the previous report.
A ping with no known session starts a fresh visit, consuming the
A message with no known session starts a fresh visit, consuming the
referer and UTM tags stashed by the document GET if there are any.
``hide`` is set by admin clients: the client record is flagged
@@ -811,7 +838,7 @@ class Store:
normally. Hidden clients are excluded from every statistic and list
at display time, and their pending crawler hits are discarded.
Pings from IPs classified as abuse, and pings whose User-Agent
Messages from IPs classified as abuse, and messages whose User-Agent
claims a JS-running crawler identity (``_is_bot_ua``), are ignored
entirely — the crawler's pending hits stay queued and flush to
``data.crawlers`` normally.
+226 -109
View File
@@ -32,10 +32,10 @@ from xml.sax.saxutils import escape as xml_escape
import blake3
import httpx
import msgspec
from fastapi import (
FastAPI,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
@@ -80,10 +80,18 @@ analytics_store = analytics.Store(ANALYTICS_PATH)
# files on disk under hash-prefixed names, cached in RAM, served at /_f/.
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
# Uploaded raster images are thumbnailed to this size and recompressed to
# AVIF; the untouched original is kept alongside as ``<hash>.orig<ext>``.
# Uploaded images are thumbnailed to this size and recompressed to AVIF
# (primary), with WebP and JPEG fallbacks re-encoded from the AVIF at
# somewhat lower quality (similar or smaller file size); the untouched
# original is kept alongside as ``<hash>.orig<ext>`` (never served).
IMAGE_MAXSIZE = 1920
IMAGE_QUALITY = 60
IMAGE_WEBP_QUALITY = 50
IMAGE_JPG_QUALITY = 55
# Favicons get the same derivatives but thumbnailed much smaller — 192px
# is plenty (browsers scale down for the 16x16 tab icon themselves).
FAVICON_MAXSIZE = 192
# Live WebSocket clients for the analytics stream.
_analytics_ws_clients: set[WebSocket] = set()
@@ -199,9 +207,21 @@ def _hash_name(body: bytes, orig: str) -> str:
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
"""Store a seed file content-addressed and point references at /_f/."""
name = _hash_name(body, orig)
file_store.put(name, body)
"""Store a seed file content-addressed and point references at /_f/.
Images get the same AVIF/WebP/JPEG derivatives as uploads and are
linked extension-less; other content is stored as-is with its
extension."""
digest = blake3.blake3(body).hexdigest()[:12]
derivatives = None if _ext(orig) == ".gif" else _image_derivatives(body, _ext(orig))
if derivatives is None:
file_store.put(digest + _ext(orig), body)
name = digest + _ext(orig)
else:
file_store.put(f"{digest}.svg" if _ext(orig) == ".svg" else f"{digest}.orig{_ext(orig)}", body)
for fmt, variant in derivatives.items():
file_store.put(f"{digest}.{fmt}", variant)
name = digest
markdown = markdown.replace(f"]({orig}", f"](/_f/{name}")
banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"')
banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"')
@@ -241,24 +261,6 @@ def _remove_page_content(menu: dict[str, Node], path: str) -> None:
del slot[0][slot[1]]
def _migrate_legacy() -> None:
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
if not data.pages:
return
with kanta.transaction("migrate pages to tree"):
for path, page in data.pages.items():
node = _ensure(data.menu, path)
node.title = page.title
node.content = page.markdown
node.banner = page.banner
node.published = page.published
node.order = page.order
node.created = page.created
node.modified = page.modified
data.pages.clear()
data.version += 1
@kanta.bootstrap
def _seed(data: Data) -> None:
"""Write the demo pages on database creation (never on existing dbs)."""
@@ -280,10 +282,9 @@ def _seed(data: Data) -> None:
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Open the database, migrate legacy content, load assets, load GeoIP."""
"""Open the database (migrations run inside kanta.open), load assets, load GeoIP."""
await kanta.open()
await asyncio.to_thread(file_store.load)
_migrate_legacy()
await frontend.load()
# Decompress/open the DB-IP MMDB once at startup. Lookups are then
# read-only and safe to run in background ``to_thread`` workers.
@@ -361,11 +362,11 @@ class FileStore:
self._cache[name] = self._entry(body)
def delete(self, name: str) -> None:
"""Delete a file plus its derivative/original counterpart, if any.
"""Delete a file plus its derivatives/original counterparts, if any.
An image upload is stored as a pair sharing the hash prefix
(``<hash>.orig.<ext>`` + ``<hash>.avif``); deleting either removes
both.
An image upload is stored as a group sharing the hash prefix
(``<hash>.orig.<ext>`` + ``<hash>.avif/.webp/.jpg``); deleting any
of the names removes them all.
"""
stem = name.partition(".")[0]
for key in [k for k in self._cache if k.partition(".")[0] == stem]:
@@ -391,12 +392,25 @@ def _render_html(kind: str, path: str, base_url: str) -> str:
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
# Render generation: bumped (and the body cache cleared) by every
# content/settings write, so page ETags and cached copies invalidate when
# navigation-affecting changes happen. In-memory only — not database state.
_render_gen = 0
def _invalidate_pages() -> None:
"""Drop cached page bodies and bump the render generation (ETags)."""
global _render_gen
_render_gen += 1
_cached_body.cache_clear()
@lru_cache(maxsize=128)
def _cached_body(kind: str, path: str, base_url: str, version: int, zstd: bool) -> bytes:
"""Rendered page body. Every input the output depends on is in the key:
data.version bumps on any content/settings change, base_url feeds the
social meta URLs, and zstd selects the stored encoding (both variants
are cached rather than re-compressed).
def _cached_body(kind: str, path: str, base_url: str, zstd: bool) -> bytes:
"""Rendered page body; cleared by _invalidate_pages on any
content/settings change. base_url feeds the social meta URLs and zstd
selects the stored encoding (both variants are cached rather than
re-compressed).
"""
body = _render_html(kind, path, base_url).encode()
return _zstd.compress(body) if zstd else body
@@ -433,8 +447,8 @@ def _html_response(
identity = _render_html(kind, path, base_url).encode()
body = _zstd.compress(identity) if zstd else identity
else:
identity = _cached_body(kind, path, base_url, data.version, False)
body = _cached_body(kind, path, base_url, data.version, True) if zstd else identity
identity = _cached_body(kind, path, base_url, False)
body = _cached_body(kind, path, base_url, True) if zstd else identity
h = dict(headers or {})
if zstd:
h["vary"] = "accept-encoding"
@@ -502,7 +516,7 @@ async def save_page(path: str, page: PageIn) -> None:
if page.banner is not None:
node.banner = page.banner
node.modified = datetime.now(UTC)
data.version += 1
_invalidate_pages()
class StructureOp(BaseModel):
@@ -563,7 +577,7 @@ async def update_structure(op: StructureOp) -> None:
elif op.order is not None:
node.order = op.order
node.modified = datetime.now(UTC)
data.version += 1
_invalidate_pages()
@app.get("/_api/settings")
@@ -597,32 +611,44 @@ class SettingsIn(BaseModel):
@app.put("/_api/settings", status_code=204)
async def put_settings(settings: SettingsIn) -> None:
"""Update site-wide settings; bumps the version so ETags invalidate."""
"""Update site-wide settings; invalidates cached pages and ETags."""
with kanta.transaction("update settings"):
data.brand = settings.brand
data.brand_html = settings.brand_html
data.theme = settings.theme
data.custom_css = settings.custom_css
data.transition = settings.transition
data.version += 1
_invalidate_pages()
@app.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it.
Raw image body (ico/png/svg...); the stored name is a blake3 hash
prefix + extension, and pages link it as <link rel="icon">. Returns
Raw image body (ico/png/svg...). Decodable images are thumbnailed to
FAVICON_MAXSIZE (192px — browsers scale down from there themselves)
and stored as AVIF/WebP/JPEG derivatives linked extension-less; SVG
originals also stay servable under their ``.svg`` name. Undecodable
bodies are stored as-is. Pages link it as <link rel="icon">. Returns
{"path": "/_f/..."}.
"""
body = await request.body()
if not body:
raise HTTPException(400, "empty file")
stored = _hash_name(body, request.headers.get("x-filename", "favicon.ico"))
file_store.put(stored, body)
ext = _ext(request.headers.get("x-filename", "favicon.ico"))
digest = blake3.blake3(body).hexdigest()[:12]
derivatives = await asyncio.to_thread(_image_derivatives, body, ext, FAVICON_MAXSIZE)
if derivatives is None: # undecodable (e.g. some .ico): store as-is
stored = digest + ext
file_store.put(stored, body)
else:
stored = digest
file_store.put(f"{digest}.svg" if ext == ".svg" else f"{digest}.orig{ext}", body)
for fmt, variant in derivatives.items():
file_store.put(f"{digest}.{fmt}", variant)
with kanta.transaction("upload favicon"):
data.favicon = stored
data.version += 1
_invalidate_pages()
return {"path": f"/_f/{stored}"}
@@ -634,7 +660,7 @@ async def delete_favicon() -> None:
"""
with kanta.transaction("clear favicon"):
data.favicon = ""
data.version += 1
_invalidate_pages()
class ToggleTaskIn(BaseModel):
@@ -671,11 +697,11 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
with kanta.transaction("toggle task", extra=path):
node.content = new_markdown
node.modified = datetime.now(UTC)
data.version += 1
_invalidate_pages()
return {"markdown": new_markdown}
def _to_avif(body: bytes, ext: str) -> bytes | None:
def _to_avif(body: bytes, ext: str, maxsize: int = IMAGE_MAXSIZE) -> bytes | None:
"""Recompress an image body to a thumbnailed AVIF via mediapreview's
dispatch (pyvips for common formats, ffmpeg for HEIC/HEIF/AVIF), or
None if the body is not a decodable image (stored as-is by the caller).
@@ -689,7 +715,7 @@ def _to_avif(body: bytes, ext: str) -> bytes | None:
avif, _resp = dispatch(
Path(tmp.name),
quality=IMAGE_QUALITY,
maxsize=IMAGE_MAXSIZE,
maxsize=maxsize,
maxzoom=1,
)
except Exception:
@@ -697,6 +723,56 @@ def _to_avif(body: bytes, ext: str) -> bytes | None:
return avif
def _svg_to_png(body: bytes, maxsize: int) -> bytes | None:
"""Rasterize an SVG to PNG via pyvips, scaled so the long side is
``maxsize`` — SVGs often carry no meaningful intrinsic resolution, so
we rasterize at full image size rather than the tiny nominal one."""
import pyvips
try:
img = pyvips.Image.new_from_buffer(body, "")
scale = maxsize / max(img.width, img.height) if img.width and img.height else maxsize
if scale != 1:
img = pyvips.Image.new_from_buffer(body, "", scale=scale)
return img.write_to_buffer(".png")
except pyvips.Error:
return None
def _avif_to_format(avif: bytes, suffix: str, quality: int) -> bytes:
"""Re-encode the AVIF derivative into a fallback format (WebP/JPEG)
via pyvips. JPEG has no alpha, so it is flattened onto white;
``strip`` keeps metadata (EXIF) out of the fallbacks."""
import pyvips
img = pyvips.Image.new_from_buffer(avif, "")
if suffix == ".jpg" and img.hasalpha():
img = img.flatten(background=[255, 255, 255])
return img.write_to_buffer(suffix, Q=quality, strip=True)
def _image_derivatives(body: bytes, ext: str, maxsize: int = IMAGE_MAXSIZE) -> dict[str, bytes] | None:
"""The served variants of an uploaded image: ``avif`` (primary,
thumbnailed to ``maxsize``) plus ``webp`` and ``jpg`` fallbacks
re-encoded from it. SVGs are rasterized first (they are vector, so
the raster replaces nothing — the .svg itself stays servable).
Returns None for non-decodable content (stored as-is by the caller).
"""
if ext == ".svg":
png = _svg_to_png(body, maxsize)
if png is None:
return None
body, ext = png, ".png"
avif = _to_avif(body, ext, maxsize)
if avif is None:
return None
return {
"avif": avif,
"webp": _avif_to_format(avif, ".webp", IMAGE_WEBP_QUALITY),
"jpg": _avif_to_format(avif, ".jpg", IMAGE_JPG_QUALITY),
}
@app.put("/_api/files/{name}")
async def upload_file(name: str, request: Request) -> dict[str, str]:
"""Store an upload (image, video...) in the content-addressed store.
@@ -704,11 +780,14 @@ async def upload_file(name: str, request: Request) -> dict[str, str]:
The stored name is a blake3 hash prefix + the original extension,
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
Raster images are additionally recompressed with mediapreview: the
original goes to ``<hash>.orig<ext>`` (kept for reprocessing) while
pages link the thumbnailed AVIF derivative ``<hash>.avif``. SVGs and
GIFs are stored as-is (vector/animation would be lost). Other content
and failed conversions fall back to plain storage.
Raster images and SVGs are recompressed (SVGs rasterized) into AVIF
(primary) plus WebP and JPEG fallbacks: the original goes to
``<hash>.orig<ext>`` (kept for reprocessing, never served — it may
carry EXIF data; SVG originals stay servable as ``<hash>.svg`` since
vector carries no EXIF) and pages link the bare ``/_f/<hash>``, the
server picking the format from the request's Accept header. GIFs are
stored as-is (animation would be lost), as is other non-decodable
content.
"""
if "/" in name or name in {".", ".."}:
raise HTTPException(400, "bad file name")
@@ -717,18 +796,19 @@ async def upload_file(name: str, request: Request) -> dict[str, str]:
raise HTTPException(400, "empty file")
ext = _ext(name)
digest = blake3.blake3(body).hexdigest()[:12]
avif = (
derivatives = (
None
if ext in {".svg", ".gif"}
else await asyncio.to_thread(_to_avif, body, ext)
if ext == ".gif"
else await asyncio.to_thread(_image_derivatives, body, ext)
)
if avif is None: # not a decodable image: store the body as-is
if derivatives is None: # not a decodable image: store the body as-is
stored = digest + ext
file_store.put(stored, body)
return {"path": f"/_f/{stored}"}
file_store.put(f"{digest}.orig{ext}", body)
file_store.put(f"{digest}.avif", avif)
return {"path": f"/_f/{digest}.avif"}
file_store.put(f"{digest}.svg" if ext == ".svg" else f"{digest}.orig{ext}", body)
for fmt, variant in derivatives.items():
file_store.put(f"{digest}.{fmt}", variant)
return {"path": f"/_f/{digest}"}
@app.delete("/_api/files/{name}", status_code=204)
@@ -786,19 +866,47 @@ async def stored_file(name: str, request: Request) -> Response:
"""Serve a file from the content-addressed store (immutable: the name
is its own hash, so cache forever). Bodies are served from the RAM
cache, zstd-compressed when the client accepts it and compression
actually shrank the file."""
actually shrank the file.
A bare ``/_f/{hash}`` (no extension, how pages link uploaded images)
content-negotiates between the stored derivatives: a format is served
only when the Accept header lists it explicitly — ``image/avif`` →
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
originals are internal (they may carry EXIF data) and never served."""
if ".orig." in name:
raise HTTPException(404)
etag = name
vary = ""
entry = file_store.get(name)
if entry is None and "." not in name:
# Extension-less image link: negotiate avif/webp/jpg by Accept.
vary = "accept"
accept = request.headers.get("accept", "")
if "image/avif" in accept:
order = ("avif", "webp", "jpg")
elif "image/webp" in accept:
order = ("webp", "jpg", "avif")
else:
order = ("jpg", "webp", "avif")
for ext in order:
etag = f"{name}.{ext}"
entry = file_store.get(etag)
if entry is not None:
break
if entry is None:
raise HTTPException(404)
if request.headers.get("if-none-match") == name:
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
body, compressed = entry
headers = {"etag": name, "cache-control": "public, max-age=31536000, immutable"}
headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
headers["content-encoding"] = "zstd"
headers["vary"] = "accept-encoding"
vary = f"{vary}, accept-encoding".lstrip(", ")
body = compressed
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
if vary:
headers["vary"] = vary
mime = mimetypes.guess_type(etag)[0] or "application/octet-stream"
return Response(body, media_type=mime, headers=headers)
@@ -821,13 +929,13 @@ async def delete_page(path: str) -> None:
node.modified = datetime.now(UTC)
else:
del slot[0][slot[1]]
data.version += 1
_invalidate_pages()
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
def _client_ip(request: Request) -> str:
def _client_ip(request: Request | WebSocket) -> str:
"""Client IP: first X-Forwarded-For hop (we sit behind a proxy), else
the direct peer."""
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
@@ -1000,48 +1108,54 @@ async def analytics_page(request: Request) -> Response:
)
@app.post("/_a", status_code=204)
async def analytics_ping(
request: Request,
fr: str = Query(""),
to: str | None = Query(None),
hide: int = Query(0),
read: int = Query(0),
) -> None:
"""Record a navigation ping (?fr=&to=&hide=&read=); fire-and-forget.
@app.websocket("/_ws")
async def activity_ws(ws: WebSocket) -> None:
"""Collect visitor activity: navigations and reading-time updates.
The initial page-load ping carries only ``to``: the entry is attributed
to the referer/UTM tags stashed by the document GET (see _track_entry),
which JS cannot see once the page has loaded.
The reverse-DNS and DB-IP geoip lookups happen in a background task so
the response is never delayed by slow DNS or the first MMDB decompress.
Public, like the pages themselves (only /_api is gated); one connection
follows a browsing session. Messages are ``analytics.Ping`` structs as
JSON text frames; ``to`` set is a navigation, ``read`` alone a
reading-time update. The reverse-DNS and DB-IP geoip lookups happen in
background tasks so message handling is never delayed by slow DNS or
the first MMDB decompress.
"""
ip = _client_ip(request)
visit_index, flushed_clients = analytics_store.ping(
fr,
to,
ip,
request.headers.get("user-agent", ""),
request.headers.get("accept-language", ""),
hide=bool(hide),
read=read,
)
if visit_index is not None:
visit = analytics_store.data.visits[visit_index]
asyncio.create_task(_enrich_client(visit.client))
_schedule_client_enrichment(flushed_clients)
_schedule_favicon_fetch()
await ws.accept()
ip = _client_ip(ws)
ua = ws.headers.get("user-agent", "")
accept_language = ws.headers.get("accept-language", "")
try:
while True:
text = await ws.receive_text()
try:
msg = msgspec.json.decode(text.encode(), type=analytics.Ping)
except msgspec.DecodeError:
continue
visit_index, flushed_clients = analytics_store.ping(
msg.fr,
msg.to or None,
ip,
ua,
accept_language,
hide=msg.hide,
read=msg.read,
)
if visit_index is not None:
visit = analytics_store.data.visits[visit_index]
asyncio.create_task(_enrich_client(visit.client))
_schedule_client_enrichment(flushed_clients)
_schedule_favicon_fetch()
except WebSocketDisconnect:
pass
def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]:
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
Nothing is counted on the GET itself — the client's /_a ping starts the
visit, so bots never register as visits (JS-running crawlers ping too,
but the ping handler ignores known bot UAs). (Admin clients ping too,
but with hide=1, which flags their visit hidden: it is recorded but
excluded from all statistics and from the crawler list.)
Nothing is counted on the GET itself — the client's first /_ws message
starts the visit, so bots never register as visits (JS-running crawlers
connect too, but the WebSocket handler ignores known bot UAs). (Admin
clients report too, but with hide, which flags their visit hidden: it is
recorded but excluded from all statistics and from the crawler list.)
The devserver's health probe (``GET /?from=devserver.py`` from
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
@@ -1053,7 +1167,8 @@ def _track_entry(path: str, request: Request, *, status: int = 200) -> list[byte
"""
if request.headers.get("x-pagerite-preload"):
# Idle-time page-cache warm-up by pagerite.js, not a page view: the
# ping sent when the user actually navigates does the counting.
# activity message sent when the user actually navigates does the
# counting.
# (Forging the header only hides a GET from the crawler stats; the
# path-based abuse classification is unaffected.)
return []
@@ -1274,7 +1389,7 @@ async def editor_ws(ws: WebSocket) -> None:
if "banner_design" in msg:
node.banner_design = msg["banner_design"]
node.modified = datetime.now(UTC)
data.version += 1
_invalidate_pages()
await ws.send_json({"type": "saved", "path": path})
except WebSocketDisconnect:
pass
@@ -1351,9 +1466,11 @@ async def sitemap(request: Request) -> Response:
@app.get("/robots.txt")
async def robots_txt(request: Request) -> Response:
"""Allow all crawling and point crawlers at the sitemap."""
"""Allow content crawling, keep the SSO login (/auth/) and the
admin-gated API (/_api) out of search results, and point crawlers at
the sitemap."""
base = SITE_URL or str(request.base_url).rstrip("/")
body = f"User-agent: *\nAllow: /\nSitemap: {base}/sitemap.xml\n"
body = f"User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /_api\nSitemap: {base}/sitemap.xml\n"
return Response(
body,
media_type="text/plain",
@@ -1398,7 +1515,7 @@ async def show_page(request: Request, path: str) -> Response:
# from pagerite.js's in-memory page cache (preload everything, never
# fetch on navigation); the ETag just makes those one-time preload
# fetches and any revalidation cheap.
etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304)
if _is_trackable_path(path):
-26
View File
@@ -50,34 +50,11 @@ class Node(msgspec.Struct, omit_defaults=True):
)
class Page(msgspec.Struct, omit_defaults=True):
"""Legacy flat page record, from before the tree model.
Kept only so old databases still decode; app.py migrates any entries
into ``Data.menu`` on startup and clears this.
"""
title: str
markdown: str
published: bool = True
order: float = 0
banner: str = ""
created: datetime = msgspec.field(
default_factory=lambda: datetime.now(UTC),
)
modified: datetime = msgspec.field(
default_factory=lambda: datetime.now(UTC),
)
class Data(msgspec.Struct):
"""Root object of the kanta database. Owned and edited in place by us."""
#: Top-level menu items by slug; "" is the front page.
menu: dict[str, Node] = {}
#: Bumped on every structure/content write, so page ETags (which embed
#: it) invalidate cached copies when navigation-affecting changes happen.
version: int = 0
#: Site name shown in the header and <title> suffix; editable in the
#: site editor. Empty = no brand link in the header, no title suffix.
brand: str = "Pagerite"
@@ -101,9 +78,6 @@ class Data(msgspec.Struct):
#: linked as <link rel="icon"> on every page. Empty = the build's
#: /favicon.ico.
favicon: str = ""
#: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {}
def prettify(slug: str) -> str:
+19 -20
View File
@@ -25,14 +25,18 @@ schemes stay visible; manually labelled links are untouched), and
``H~2~O`` / ``x^2^`` give sub/superscripts.
render() also builds the layout structure: the top-level blocks are
segmented for the column layout — h1/h2 headings, ``.wide`` blocks and
margin-breakout blocks (``.margin``, ``::: aside``) stand on their own,
the runs between them are wrapped in ``<div class="colseg">`` (tagged
segmented for the column layout — h1/h2 headings and ``.wide`` blocks
stand on their own, the runs between them are wrapped in
``<div class="colseg">`` (tagged
``.cols`` when the segment holds enough text — COLS_TEXT — in at least
COLS_PARAS paragraphs or one paragraph long enough to turn .breakable,
unless a ``::: nocols`` container opts it out;
in column segments, paragraphs past BREAKABLE_TEXT are marked
``.breakable`` so they may split across columns). The result carries
``.breakable`` so they may split across columns). Margin-breakout boxes
(``.margin``, ``::: aside``) stay inside the segment at their anchor
point; pagerite.css takes them out of flow (absolute, off the article's
left border, into the side zone), so the columns flow through as if the
box wasn't there. The result carries
``multicol`` when the whole body justifies columns (views.py puts the
class on the article); how many columns (never more than two), whether
the margin breakout applies and every other viewport adaptation is then
@@ -268,8 +272,8 @@ def _container_attrs(state) -> None:
The container plugin's default render is a plain renderToken, so the
name and brace attributes must live on the token itself — and being a
core rule (rather than a render rule) lets the segmentation in
render() see the classes (::: aside's margin breakout, the ::: nocols
opt-out, {.wide} containers).
render() see the classes (the ::: nocols opt-out, {.wide}
containers).
"""
for token in state.tokens:
if token.type != "container_block_open":
@@ -493,11 +497,12 @@ _PARA_OPEN_RE = re.compile(r"<p[\s>]")
_PARA_RE = re.compile(r"<p((?:\s[^>]*)?)>(.*?)</p>", re.S)
# Classes that take their block out of the column flow: .wide is a
# full-width separator, .margin/.aside float in the side zone at the
# article's left (they must be direct article children for that — the zone
# rules key off it — never inside a column).
# full-width separator that splits the column segments. Margin-breakout
# boxes (.margin/.aside) are NOT boundaries: they stay inside the segment
# at their anchor point, and CSS positions them absolutely out of the
# article's left border (the zone rules anchor off the article), so the
# column flow is unaffected.
_WIDE = "wide"
_BREAKOUT = ("margin", "aside")
class Rendered(NamedTuple):
@@ -557,14 +562,10 @@ def _top_level_blocks(tokens: list) -> list[list]:
def _is_boundary(block: list) -> bool:
"""True for blocks that never go inside a column segment (see the
_WIDE/_BREAKOUT comment above): h1/h2 headings, anything carrying
.wide, and blocks whose own element carries .margin/.aside — for a
lone-image paragraph (which renders as a <figure>) the image's classes
count as the block's own."""
_WIDE comment above): h1/h2 headings and anything carrying .wide."""
first = block[0]
if first.type == "heading_open" and first.tag in ("h1", "h2"):
return True
own = _classes(first)
for token in block:
if _WIDE in _classes(token):
return True
@@ -572,9 +573,7 @@ def _is_boundary(block: list) -> bool:
children = token.children or []
if any(_WIDE in _classes(c) for c in children):
return True
if len(children) == 1 and children[0].type == "image":
own |= _classes(children[0])
return bool(own & set(_BREAKOUT))
return False
def render(
@@ -591,8 +590,8 @@ def render(
same pipeline as an explicit one (first-h1 anchor treatment included).
The top-level blocks are grouped into column segments: boundary blocks
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are
rendered bare, the runs between them wrapped in <div class="colseg">.
(h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs
between them wrapped in <div class="colseg">.
A segment is tagged .cols when it holds enough text (COLS_TEXT) in at
least two paragraphs (COLS_PARAS) or one breakable-length paragraph,
and no ::: nocols container; its long paragraphs are marked .breakable;
+121 -11
View File
@@ -1,23 +1,133 @@
"""Kanta schema migrations, discovered by name (``migrate_vN``).
Each function receives the raw state dict (JSON-level: bytes are base64
strings) before it is decoded into ``Data`` structs, and runs exactly once
strings, datetimes RFC 3339 strings, struct fields with default values
omitted) before it is decoded into ``Data`` structs, and runs exactly once
per database based on its recorded version.
All storage/schema upgrades live here — including on-disk file work, which
runs through app.py's file store (imported lazily: app.py owns the store
and passes this module to Kanta; at migration time, during lifespan
``kanta.open()``, the app module is fully loaded).
"""
import base64
import re
from pathlib import Path
from pagerite.data import prettify
def _append_order(nodes: dict) -> float:
"""Raw-dict equivalent of data.append_order (order keys may be absent)."""
return max((n.get("order", 0) for n in nodes.values()), default=0) + 1
def _ensure(menu: dict, path: str) -> dict:
"""Raw-dict equivalent of app._ensure: the node dict at ``path``,
creating it and any missing ancestors (content-less category labels)
appended at the end of their level."""
nodes = menu
node = None
for seg in path.split("/"):
node = nodes.get(seg)
if node is None:
node = {"title": prettify(seg), "order": _append_order(nodes)}
nodes[seg] = node
nodes = node.setdefault("children", {})
return node
def migrate_v1(d: dict) -> None:
"""Move in-database file blobs to the on-disk content-addressed store."""
"""Move in-database file blobs to the on-disk content-addressed store,
and rebuild the legacy flat page store (``pages``) as the menu tree."""
files = d.pop("files", None)
if not files:
return
# Deferred import: app.py owns the file store and passes this module to
# Kanta; at migration time (lifespan open) the module is fully loaded.
from pagerite.app import file_store
if files:
from pagerite.app import file_store
for name, body in files.items():
if isinstance(body, str): # JSON-level bytes are base64 strings
body = base64.b64decode(body)
file_store.put(name, body)
for name, body in files.items():
if isinstance(body, str): # JSON-level bytes are base64 strings
body = base64.b64decode(body)
file_store.put(name, body)
pages = d.pop("pages", None)
if not pages:
return
menu = d.setdefault("menu", {})
for path, page in pages.items():
node = _ensure(menu, path)
node["title"] = page["title"]
node["content"] = page["markdown"]
for key in ("banner", "published", "order", "created", "modified"):
if key in page:
node[key] = page[key]
#: Extension-less file links: uploaded images are linked as /_f/<hash>
#: and the server negotiates avif/webp/jpg from the Accept header.
_DERIVATIVE_LINK = re.compile(r"(/_f/[0-9a-f]{12})\.(?:avif|webp)\b")
def _backfill_derivatives() -> None:
"""Create missing AVIF/WebP/JPEG derivatives for files stored before
they were introduced (older uploads may have only the original plus
AVIF, and SVGs no raster variants at all). WebP/JPEG are re-encoded
from an existing AVIF when available, everything else from the
original (SVGs rasterized first)."""
from pagerite import app
file_store = app.file_store
try:
paths = [f for f in file_store.path.iterdir() if f.is_file()]
except FileNotFoundError:
return
groups: dict[str, list[Path]] = {}
for p in paths:
groups.setdefault(p.name.partition(".")[0], []).append(p)
for digest, files in groups.items():
names = {p.name for p in files}
source = next(
(p for p in files if ".orig." in p.name or p.suffix == ".svg"), None
)
if source is None:
continue # plain as-is file, no derivatives to make
avif = file_store.get(f"{digest}.avif")
if avif is None:
ext = source.suffix
body = source.read_bytes()
if ext == ".svg":
png = app._svg_to_png(body, app.IMAGE_MAXSIZE)
if png is None:
continue
body, ext = png, ".png"
converted = app._to_avif(body, ext)
if converted is None:
continue
file_store.put(f"{digest}.avif", converted)
avif = file_store.get(f"{digest}.avif")
for fmt, quality in (
("webp", app.IMAGE_WEBP_QUALITY),
("jpg", app.IMAGE_JPG_QUALITY),
):
if f"{digest}.{fmt}" not in names:
file_store.put(
f"{digest}.{fmt}", app._avif_to_format(avif[0], f".{fmt}", quality)
)
def migrate_v2(d: dict) -> None:
"""Extension-less image links: strip .avif/.webp extensions from /_f/
links in page content and banners (the server now negotiates the format
by Accept header), backfill missing AVIF/WebP/JPEG derivatives on disk,
and drop the obsolete render-counter field ``version`` (invalidation is
an in-memory concern now, not database state)."""
def walk(nodes: dict) -> None:
for node in nodes.values():
for field in ("content", "banner"):
if isinstance(node.get(field), str):
node[field] = _DERIVATIVE_LINK.sub(r"\1", node[field])
walk(node.get("children") or {})
walk(d.get("menu") or {})
d.pop("version", None)
_backfill_derivatives()
+1 -1
View File
@@ -51,7 +51,7 @@ The 🖊️ pens open a tabbed editor over the page you are viewing:
The URL is the structure: a page at `docs/markdown` lives under `docs`, and the menus are derived from that. Slugs are lowercase ASCII (`a-z 0-9 - _`). A node without content is a category label — it renders a placeholder and its menu link points at its first child page. This site's own `docs` label demonstrates that, and the sidebar on this page shows the two submenu levels below it.
Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}.ext`, so links survive page moves. The article editor's format bar and copy-paste both upload images for you.
Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}`, so links survive page moves. The server picks AVIF, WebP or JPEG from your browser's Accept header. The article editor's format bar and copy-paste both upload images for you.
{dates}
"""
+1 -1
View File
@@ -112,7 +112,7 @@ article h3 {
blockquote {
border-left-color: var(--accent);
background: color-mix(in oklab, var(--accent) 6%, transparent);
background: color-mix(var(--accent) 6%, transparent);
padding: 0.4rem 0.9rem;
/* Keep the quoted text on the paragraph edge: the tinted box extends
past it by its own border/padding, like code blocks. */
+1 -1
View File
@@ -187,7 +187,7 @@ article ul ul ul li::before {
blockquote {
border-left-color: var(--accent2);
background: color-mix(in oklab, var(--accent2) 6%, transparent);
background: color-mix(var(--accent2) 6%, transparent);
padding: 0.25rem 0.75rem;
/* Keep the quoted text on the paragraph edge: the tinted box extends
past it by its own border/padding, like code blocks. */
+1 -1
View File
@@ -24,7 +24,7 @@
--sun: #ffe071;
--line: #4f913b29;
--link: color-mix(in oklab, var(--text) 38%, var(--accent));
--link: color-mix(var(--text) 38%, var(--accent));
--font-body: var(--font-cause);
--font-heading: var(--font-new-rocker);
+12 -1
View File
@@ -815,7 +815,10 @@ def _share_media(html: str, base_url: str) -> tuple[str, str]:
"""(image, video) share URLs from the rendered article.
The _media picks as absolute URLs built from the request base —
social scrapers cannot use relative ones.
social scrapers cannot use relative ones. Extension-less store links
(``/_f/<hash>``) are used as-is: the server negotiates the format
from the scraper's Accept header (no explicit image/avif|webp → JPEG,
which every scraper supports).
"""
if not base_url:
return "", ""
@@ -838,10 +841,17 @@ def _social_meta(
share image the article's first <img> — authors lead with their most
representative figure. Absolute URLs are built from the request's base
(social scrapers cannot use relative ones).
``twitter:image`` pins extension-less store links to the ``.webp``
variant: X only honors WebP via twitter:image (not og:image) and its
scraper cannot be trusted to negotiate via Accept.
"""
url = f"{base_url}/{path}" if base_url else ""
text = _description(html)
image, video = _share_media(html, base_url)
twitter_image = (
re.sub(r"(/_f/[0-9a-f]{12})$", r"\1.webp", image) if image else ""
)
return {
"description": text,
"canonical": url,
@@ -855,6 +865,7 @@ def _social_meta(
"article:published_time": node.created.isoformat(),
"article:modified_time": node.modified.isoformat(),
"twitter:card": "summary_large_image" if image else "summary",
"twitter:image": twitter_image,
}