The cube view-transition block moves out of pagerite.css into transition
designs (pagerite/themes/{name}/transition.css), handled like banner
designs: served from disk at /_themes/..., injected by the backend as
#pagerite-transition after the banner sheet, and picked site-wide in the
site settings (Data.transition, default cube; GET/PUT /_api/settings
carry transition + the designs found on disk). The site editor swaps the
sheet in place on change; the dev-mode order fixup knows the new id.
Designs: cube (unchanged), crossfade (all navigations), slide (sideways,
both pages moving together) and reveal (clip-path wipe over the
stationary old page) — the latter two mirrored on history-back and
crossfading within a section like cube.
Also compress the site settings form into a two-column grid: site name +
favicon on one row, theme + transition on the next.
Anchored h1/h2s carry data-line (markdown source line, top-level
headings only) and h2s get dimmed section pens that open the page
editor at that section. Editor scroll sync is now piecewise-linear
keyed on those anchors: the page follows the cursor (fractional,
wrap-aware, fixed window anchor; editor scroll no longer drives it),
the editor follows page scroll with a progress-based viewport anchor,
so document ends line up exactly in both directions. The editor always
follows the URL — fetch-navigation retargets it — with unsaved text
stashed per path for the session and restored on return. Banner pen
removed (the tab stays in the shell); top-right order is now
analytics, site settings, login/logout.
Long-enough articles (3+ in-body h1/h2) get slug ids (python-slugify,
mirroring the editor's slugify.js) and the heading text becomes a
self-link (a.anchor) for clean link copying; {#id} always wins,
duplicates get -2/-3 suffixes, h3+ never. The implicit page title is
now injected as '# {title}' into the markdown (render(title=...)), so
it takes the same first-h1 path as an explicit one: no id, href=""
self-link scrolling to top, not counted toward the threshold. The
.body wrapper is gone — segments are direct article children — and the
editor preview swaps the whole article in one go. pagerite.js tracks
the reading position in the location hash (last tagged heading above
the viewport middle; cleared at the top, never on unscrollable pages)
and scrolls to anchors after fetch-navigation.
The first positional CLI argument (default localhost) names the site's
public hostname and its data directory under the cwd, replacing the
CWD-relative pagerite.* files. The public origin (https://<hostname>)
is now authoritative configuration instead of a value learned from
admin browsers: POST /_api/site-url, Data.site_url and the pagerite.js
reporter are removed, and page rendering, sitemap, robots.txt and
analytics own_origin all use SITE_URL consistently (localhost falls
back to the request's base URL).
render() now segments the body into .colseg wrappers and flags .multicol
itself, replacing the fragile colseg injection in pagerite.js. Columns
are capped at two; shrink-wrapped figures left-align inside columns.
New {.margin} breakout (and ::: aside) drops blocks into the left gutter
on wide viewports, falling back to in-column floats.
Two analytics corrections verified against the production capture:
- pagerite.js suppressed pings with fr == '/_a', but fetch-navigation
away from the analytics page had already GET-ed the target without the
preload header; the orphaned pending hit then flushed to the crawler
list, classifying a real user as a crawler. Navigations away from /_a
now ping normally (the server rejects /_a as a target regardless, and
admin noise is already handled by hide=1).
- _remove_visit only reversed the visit's creation counts, leaving
views/transitions from later pings behind as orphans on the graph with
no matching row in the visitor table. An in-memory per-visit count log
now tracks every count event, so an admin hide=1 scrub reverses the
visit completely.
Document GETs now stash their status (200/404) in a pending table,
consumed by the matching ping: visits gain a per-path statuses map and
crawler hits a status field. Trail links with a 404 status render in
red with the status code in the tooltip, alongside the read time.
- Cull connections whose thin middle would render below ~0.8px
(MIN_WMID); drop external source/exit nodes whose connectors are
all culled, while site page nodes always stay
- Bead simulation persists across data reloads: emitters keyed per edge
direction, beads tracked by progress, so unrelated count changes no
longer reshuffle bead positions
- Bead speed relative to span length: constant 1.5s traversal per edge
- Top lane labeled with a house icon; all lane labels left-aligned just
past the source pill (half height on near-vertical branch lanes), with
guides running to the lane end so long slugs are never truncated
- weeklySeries shifts overlaid weeks onto the current week's time axis so
they overlay inside the plot instead of overflowing left; oldest weeks
paint first, current week on top
- Legend moved inside the visits chart's top right: current ISO week in
accent, past weeks as a single muted "Week M" / "Week M–N" specimen
- Past week curves use the muted color instead of faded accent
- Chart height reduced ~30% (180 -> 126)
- Charts render as single SVGs with axis labels inside the viewBox,
replacing the stretched plot + HTML overlay labels
- Rolling ranges end at now, t0 aligned to UTC day; bucket size follows
the window (6h up to 31 days) so "all" at its 30-day minimum renders
identically to "month"
- X labels always centered on their true position; no edge-align shifting
- rangeWindow simplified to rolling spans ending at now
- TransitionGraph "all" visual scale floored at the 30-day plot minimum
JS-running crawlers (Googlebot, GoogleOther, Applebot) execute pagerite.js
and send navigation pings, registering as visitors. Pings whose User-Agent
matches _is_bot_ua (any "bot" token plus listed exceptions) are now
ignored, so their document GETs flush to the crawler list as intended. No
source verification: a spoofed bot UA merely lands in the crawler stats,
and path-based abuse classification catches scanners regardless.
Idle-time link preloads from pagerite.js were queued as pending crawler
hits and flushed to the crawler list whenever the user navigated more than
10s later, so real visitors' subpage loads showed up as crawler hits.
Preload fetches now carry an x-pagerite-preload header and the document
GET handler skips tracking for them; the ping sent on actual navigation
does the counting.
Downloads the latest dbip-city-lite-YYYY-MM.mmdb.gz before starting the
server, skipping when the local database is current, falling back to the
previous month on 404, and removing older databases after an update.
Promotes httpx to a runtime dependency.
- keep visitor charts y-axis minimum range at 10
- keep 'all' chart x-axis minimum span at 30 days
- group crawler hits by (ip, ua) and list top pages visited, show crawler page load counts as N× prefix
- store and display geoip city, keep geoip country overwrite
- stream live updates over WebSocket /_api/ws/analytics
- include family ring arcs in transition map crop bounds
- remove top UA summary, limit crawlers to 10 and visits to 20
- human-readable relative timestamps with UTC tooltip
- Edge widths grow logarithmically with the connection count (~1 px at
a single count, uncapped); connections below 1% of total traffic are
pruned, bounding the graph to ~100 edges.
- Beads: per-direction flows emitted at a rate linear in the count,
each bead simulated independently in JS (no in-flight limit), offset
onto right-hand lanes so opposing flows don't collide, running under
the node circles with a glow.
- External links: referer origins as a node row above the map, exit
origins fanned outwards from their source page.
- Transitions are now stored per 5-minute bucket (sparse
from -> to -> bucket -> count) so the graph filters by time range
like the other series; legacy analytics files are discarded.
Add server-side visit analytics collection, a public-page ping endpoint,
and a full-screen AnalyticsView for admins.
Backend:
- Add pagerite/analytics.py: Analytics/Visit model, Store, and persistence
- Wire /_a ping endpoint and GET /_api/analytics into pagerite/app.py
Frontend:
- Add full-screen AnalyticsView with visitor charts and transition map
- Add VisitorCharts and TransitionGraph subcomponents
- Add analytics JS helpers in frontend/src/analytics/
- Send navigation pings from frontend/src/pagerite.js
- Mount AnalyticsView from frontend/src/main.js
- Document the feature in docs/analytics.md and update AGENTS.md
Standalone markdown images (captioned or not) now become block <figure>
elements; only inline-with-text images and raw author <img> HTML stay
plain. This collapses the img/figure selector duplication in pagerite.css
into a default + override structure: figure fills the column, floats go
30% (1em text gap), an explicit width attribute shrink-wraps the figure
(images with width are left untouched by CSS so the attribute hint
survives), and .wide re-anchors are grouped with the main rule.
Banner designs can now ship arbitrary markup as banner.html (canvas +
style + script), taking precedence over banner.svg; the artwork is
inlined in a div[data-design] wrapper either way, which the editor's
live preview preserves (and never re-runs its scripts).
The bundled 'eyes' design (themes/eyes/banner.html + banner.css sizing
the stage) replaces the eyes canvas script that was embedded in the
notes-on-urls page's own banner — the page now just picks the design.
Seeds set banners only on select sub pages (the-long-read gradient,
canvas-nights stars): the front page no longer gets a banner image and
shows the theme's default design. Seed entries carry a banner_design
field.
Themes move from Vite-built frontend assets to pagerite/themes/{name}/
folders holding theme.css and/or banner.css (+ banner.svg), served by the
backend at /_themes/{name}/... and re-read from disk per request (etag by
mtime), so on-disk edits show on the next page load even in prod and new
themes need no build or config. The theme and banner-design selectors
enumerate these folders via GET /_api/settings.
Banner designs: Node.banner_design picks a design per page (None inherits
from ancestors, then the front page, then the active theme's own design;
"" = none). The design's banner.css is linked in <head> (id
pagerite-banner, between theme and custom CSS) and its banner.svg inlined
into #page-banner first (marked svg[data-design]); the page's own
Node.banner HTML renders after it, so author code always wins. #page-banner
is now a stacking grid so artwork and author code overlay.
Dev/prod hot loading unified: the backend renders the theme/design links
in both modes; in dev pagerite.js only re-appends them (and the custom
CSS) after the Vite-injected base styles. Theme switches just swap the
link href. The pagerite:theme meta and Vite theme build entries are gone.
Picker font-variable regexes only allowed [a-z-], so --font-exo2 was
never parsed or stripped: picking another font left the old row behind
and the selection showed wrong. Now digits are allowed, stripping is
declaration-level (handles inline rows) and removes all font rows
before inserting, and the picker re-parses on every CSS edit so manual
changes update the selection live.
- Two new themes (corporate light/dark, nitro racing) and a purple
retune; theme banner SVGs moved to pagerite/themes/, inlined by the
backend, recolored per scheme via CSS classes, with scroll parallax.
- pygments.css maps tokens onto --code-* variables; light and dark
palette sets in the base stylesheet resolve via light-dark() from the
theme's color-scheme; themes just pick a set or retint --code-bg.
- Sidebar omitted unless the section has at least two published items;
fetch-navigation and the site editor handle it appearing/disappearing.
- Floated figures keep captions below at 30% of the text column; h1/h2
clear floats so images never overflow into the next section.
- Links use --link (color-mix of text and accent), weight 500, no
underlines; hover goes full accent.
- New-page flow: slug placeholder shows the slug derived live from the
title (placeholders keep browser-default styling; slug values no longer
muted). Committing PUTs empty markdown, which now creates an empty page
rendering with its title instead of 404, then hands over to the page
editor (CodeMirror focuses on mount)
- Saving never deletes: REST PUT and WS save store empty content as an
empty page; deletion is the page editor's explicit choice — saving
trimmed-empty text issues a REST DELETE
- Fonts: add Cormorant (variable 300-700, normal+italic) to the serif
picks, replacing single-weight Gloock; Playfair Display files kept on
disk but not offered
- Base stylesheet: per-family font variables (--font-source-sans etc.)
with functional slots referencing them; --font-brand defaults to
var(--font-heading). Restore Fraunces/Literata faces dropped in the
font-set swap
- Site editor: stylized A button (always Fira Code) opens a font panel
with body/heading/brand tabs; options render their own name in the
candidate font at the target's size/weight; clicking the current pick
clears it. Picks are stored as plain :root rows in the custom CSS,
parsed and rewritten on change
- Base banner: drop black text-shadow on light background; legibility
via a bottom scrim fading artwork into --bg. Purple keeps its own nav
shadow
- Headings no longer accent-colored in base (moved to purple theme)
- Keep #pagerite-user custom CSS last in <head> at every mutation point
so its :root rules win equal specificity (dev injects base/theme
styles after the server-rendered tag)