Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d595f8c15 | ||
|
|
b1fc8e24d7 | ||
|
|
c5cf799f68 | ||
|
|
a5edc7b3b6 | ||
|
|
09ebc63690 | ||
|
|
2868843028 | ||
|
|
6fcebaea3f | ||
|
|
4a48d08a19 | ||
|
|
e3e29251ca | ||
|
|
8affc41289 | ||
|
|
2de4717230 | ||
|
|
563e8fcaf2 | ||
|
|
921a5484a2 | ||
|
|
0e2e52fa45 | ||
|
|
075848f782 | ||
|
|
7b8899af92 | ||
|
|
6d2ae104d7 | ||
|
|
b7fc543a83 | ||
|
|
b868033ddc | ||
|
|
8aad64cced | ||
|
|
319163ee7e | ||
|
|
87b16b7144 | ||
|
|
20ae6501f2 | ||
|
|
a16fe88114 | ||
|
|
c77598adc7 |
+84
-33
@@ -24,7 +24,14 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
|
||||
|
||||
- **Initial page load**: `to` is the loaded path. This ping is what starts
|
||||
the visit and counts the entry page view — the document GET alone records
|
||||
nothing, so bots and admin browsing never register. Reloads are not
|
||||
nothing, so bots and admin browsing never register. JS-running crawlers
|
||||
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent
|
||||
gives them away: pings 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
|
||||
refresh neither counts a second view nor logs a self-transition. The GET
|
||||
handler stashes a cross-origin https `Referer` (origin part only) and any
|
||||
@@ -38,16 +45,19 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
|
||||
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, navigation involving
|
||||
the analytics page itself (`/_a`), and everything while the user has the
|
||||
editor open (`body.editing`). Admin noise, not visits.
|
||||
- **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
|
||||
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
|
||||
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 server then records
|
||||
nothing — and if the same client session already had a visit from before
|
||||
logging in, that visit is removed from the JSON along with the counts
|
||||
recorded when it was created (site visit, entry view, entry transition).
|
||||
Views/transitions logged by later pings inside such a visit lack
|
||||
per-event timestamps and are left as-is. With no auth proxy (dev/test)
|
||||
logging in, that visit is removed from the JSON along with every count
|
||||
it recorded — an in-memory per-visit log of count events makes full
|
||||
reversal possible. With no auth proxy (dev/test)
|
||||
"admin" is everyone's state, so `hide` stays 0 and everything is recorded.
|
||||
- The server validates `to`: internal paths must be valid slug paths
|
||||
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
||||
@@ -74,8 +84,13 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
|
||||
removing older versions after an update; without the flag only an existing
|
||||
file is used.
|
||||
- **Crawler hits**: every document GET is queued in RAM as a pending crawler
|
||||
hit. If a ping from the same client arrives within 10 seconds the hit is
|
||||
discarded; otherwise it is written to `crawlers`. Crawlers do not count as
|
||||
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
|
||||
the header only hides a GET from the crawler stats, the path-based abuse
|
||||
classification is unaffected). If a ping
|
||||
from the same client arrives within 10 seconds the hit is discarded;
|
||||
otherwise it is written to `crawlers`. Crawlers do not count as
|
||||
visits or views. The `Accept-Language` header is stored on the shared
|
||||
`Client` immediately; reverse-DNS host names and DB-IP geoip
|
||||
country/city are filled in asynchronously, just like for real visits. In
|
||||
@@ -135,6 +150,8 @@ Each `Visit` record:
|
||||
does not append.
|
||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||
- `read` — active reading time per path (seconds), keyed by path.
|
||||
- `statuses` — HTTP status of the response when each path was first seen
|
||||
(200 or 404), keyed by path.
|
||||
|
||||
Each `CrawlerHit` record:
|
||||
|
||||
@@ -142,7 +159,9 @@ Each `CrawlerHit` record:
|
||||
- `entry` — page path requested,
|
||||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||||
- `referer` — external https origin of the request, `""` for direct/none,
|
||||
- `query` — raw query string of the request.
|
||||
- `query` — raw query string of the request,
|
||||
- `status` — HTTP status of the served response (200 for a real page, 404
|
||||
for a category placeholder or missing page).
|
||||
|
||||
Each `AbuseHit` record:
|
||||
|
||||
@@ -161,6 +180,10 @@ that triggered classification are lifted to the top, followed by other 404s
|
||||
and then document GETs from the abuser. Within each category paths are
|
||||
sorted by count descending, then by their earliest hit.
|
||||
|
||||
In the visitor and crawler tables, internal paths that returned a 404 status
|
||||
are shown in red and the link title includes the status code, so it is easy
|
||||
to tell misses from real pages at a glance.
|
||||
|
||||
## Aggregates
|
||||
|
||||
- `transitions`: time series of page transitions, sparse nested dict
|
||||
@@ -197,17 +220,18 @@ Because it is a real page, fetch-navigation handles it like any other internal
|
||||
link: clicking the 📊 pen (or any link to `/_a`) fetches the server-rendered
|
||||
HTML, swaps the dynamic regions and mounts the Vue analytics app in place. The
|
||||
range selector updates the URL hash (`#week` etc.) so links to a specific
|
||||
range can be shared.
|
||||
range can be shared. When the URL has no hash, the client derives the
|
||||
default from the first analytics snapshot: `day` if the recorded history
|
||||
spans less than 24 hours, otherwise `week`.
|
||||
|
||||
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
|
||||
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
||||
|
||||
Charts are SVG curves (Catmull-Rom over an edge-aware adaptive Gaussian —
|
||||
a change-point detector splits the series at traffic-level shifts, then
|
||||
each segment is smoothed with a bandwidth that ramps with a broad pilot
|
||||
estimate of the local rate: isolated events stay narrow (~0.4-unit sigma,
|
||||
peaking at ~1 event/unit), busy traffic widens to a 1-unit sigma. The raw
|
||||
series is drawn faint underneath). Values are
|
||||
Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian — a
|
||||
change-point detector splits the series at traffic-level shifts, then each
|
||||
segment is smoothed independently with a fixed sigma chosen so N events in
|
||||
a single bucket peak at N events per unit. The raw series is drawn faint
|
||||
underneath). Values are
|
||||
**per-unit rates** — per hour on the week view (5-minute bucket counts × 12,
|
||||
plotted at native 5-minute resolution), per day on the month+ ranges — and
|
||||
the smoothing time scale follows the unit: the month+ sigmas are 24× the
|
||||
@@ -218,31 +242,58 @@ labeled intervals, minor lines at fifths when integral; the minimum y-axis
|
||||
range is 10 so tiny values such as a single visit are not stretched to a
|
||||
fractional scale).
|
||||
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
|
||||
weeks in the same accent color at decreasing opacity (the current week is
|
||||
weeks in the muted color at decreasing opacity (the current week keeps the
|
||||
accent color and is
|
||||
truncated at the current bucket, never drawing fake zeroes for the future);
|
||||
its x labels are weekday names centered at midday UTC, without vertical grid
|
||||
a compact legend inside the top right of the visits chart marks the current
|
||||
ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
|
||||
a muted specimen. Its x labels are weekday names centered at midday UTC, without
|
||||
vertical grid
|
||||
lines (day boundaries would be misleading in the viewer's timezone). The
|
||||
month view labels days the same lineless way — day numbers at noon UTC,
|
||||
with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points,
|
||||
with boundary lines at months/years. All uses the full data reach, but keeps
|
||||
at least the past 30 days so the chart never collapses to a tiny sliver when
|
||||
the site is young. Below the charts: a radial **transition map** (all pages from
|
||||
`/_api/pages` — front page at the center, each slug level on its own ring,
|
||||
siblings clockwise in navigation order from the top, radial gap equal to
|
||||
the arc spacing — opposite transition directions joined into organic
|
||||
with the month name substituted for the 1st. Month, year and all are
|
||||
rolling windows ending at now, aligned to UTC day boundaries at the start
|
||||
so the labels span the whole range; the bucket size follows the window —
|
||||
6 hours up to 31 days, daily beyond — with boundary lines at months/years
|
||||
on the longer ranges. All uses the full data reach, but keeps
|
||||
at least the past 30 days (identical to the month view when the site is
|
||||
younger than that, bucket size included) so the chart never collapses to a
|
||||
tiny sliver when the site is young. Below the charts: a **transition map** (all pages from
|
||||
`/_api/pages` — top-level menu items on a large-radius circular arc whose
|
||||
bottom point is the last item (each earlier item a bit higher), connected
|
||||
by a top lane labeled 🏠︎ beside the home pill (50% thicker than
|
||||
the branch lanes, its label font and guide offset scaled along), each item's
|
||||
subtree fanning out below it in menu order along a large-radius circular
|
||||
arc that leaves heading
|
||||
straight down and gradually bends right, index pages without views omitted
|
||||
and their children promoted in their place. The submenu structure is drawn
|
||||
as wide branch lanes: one per path prefix with at least two visible
|
||||
nodes, running behind the branch's node pills as circle arcs concentric
|
||||
with the fan (parent levels one radius step outward, so all lanes of a
|
||||
group share exactly one form), each labeled with its branch slug
|
||||
left-aligned just past the first pill and allowed to run along the lane to
|
||||
its end, disappearing under later pills when long — so the lanes reflect
|
||||
the path
|
||||
structure even where index pages are omitted — opposite transition
|
||||
directions joined into organic
|
||||
tapered connections whose middle width grows logarithmically with the
|
||||
count (a single count renders as a ~1 px line, uncapped), connections
|
||||
count (uncapped), connections
|
||||
carrying less than 1% of the total traffic
|
||||
pruned; beads are simulated one by one in JS (requestAnimationFrame) and
|
||||
flow along each edge, emitted at a rate linearly proportional
|
||||
pruned, as are those whose thin middle would render below ~0.8 px —
|
||||
fainter strands are invisible and only their wide end flares would show; beads are simulated one by one in JS (requestAnimationFrame) and
|
||||
flow along each edge, persisting across data reloads (emitters are keyed
|
||||
per edge direction and beads tracked by progress, so an unrelated count
|
||||
change never reshuffles them), emitted at a rate linearly proportional
|
||||
to the directional count with no in-flight limit, opposing directions
|
||||
offset onto parallel lanes. External sources show as a node row above the
|
||||
offset onto parallel lanes. External sources and exits whose connectors are
|
||||
all culled by the width threshold are dropped from their rows themselves
|
||||
(the site's own page nodes always stay, connected or not). External sources show as a node row above the
|
||||
map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
||||
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
||||
under their campaign/source value rather than the referer domain. A UTM
|
||||
source node only links to its referer when every visit carrying that tag
|
||||
came from the same origin. External exits are small nodes fanned outwards
|
||||
from their source page), per-page view
|
||||
came from the same origin. External exits are full-size nodes in a matching
|
||||
row centered below the map, so the site itself stays in the middle), per-page view
|
||||
counts, the top transitions and the 50 most recent visit trails. Data is
|
||||
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
||||
JSON snapshot on connect and again whenever the analytics file is updated
|
||||
|
||||
+3
-1
@@ -8,6 +8,8 @@ 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.
|
||||
|
||||
## `data.py`
|
||||
|
||||
msgspec Structs for the kanta database. See `docs/content-model.md` for the full data model.
|
||||
@@ -20,7 +22,7 @@ markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists,
|
||||
|
||||
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 request base URL; `article:published/modified_time` come from `Node.created`/`modified`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still 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 (`Data.site_url` — learned from admin browsers reporting their `location.origin` via `POST /_api/site-url`, correct even behind reverse proxies; until learned, the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. If the markdown contains its own h1, the page title is NOT rendered as an additional h1 (it still 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), which is rendered 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, 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).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Siblings order by the fractional `Node.order` key: a moved item gets a fresh key
|
||||
|
||||
`Node.banner` is a raw trusted HTML snippet for the header banner (img, styled div, canvas+script...); empty inherits from the node's ancestors (front page last). It is rendered AFTER the banner design's artwork, so author code (e.g. a `<style>` override) always wins over the design's own styles.
|
||||
|
||||
`Node.banner_design` picks a banner design: a theme folder name whose `banner.css` styles it and whose `banner.html` (arbitrary markup: canvas + style + script) or `banner.svg` supplies the inline artwork (wrapped in `div[data-design]`); "" = explicitly no design, None = inherit (nearest ancestor, front page last, then the active theme's own design if it ships banner.css/banner.svg/banner.html). The design's banner.css is linked in `<head>` (id `pagerite-banner`) between the theme and the custom CSS.
|
||||
`Node.banner_design` picks a banner design: a theme folder name whose `banner.css` styles it and whose `banner.html` (arbitrary markup: canvas + style + script) or `banner.svg` supplies the inline artwork (wrapped in `div[data-design]`); "" = explicitly no design, None = inherit (nearest ancestor, front page last, then the active theme's own design if it ships banner.css/banner.svg/banner.html). The design's banner.css lives in `<head>` (id `pagerite-banner`) between the theme and the custom CSS — a `<link>` in dev, an inline `<style>` in production.
|
||||
|
||||
## Site settings
|
||||
|
||||
|
||||
+1
-1
@@ -25,6 +25,6 @@ Dropping ON the lower part of a row moves the page under that row (the child lis
|
||||
|
||||
The shell is dynamic-imported onto the content page by pagerite.js when an edit pen is clicked (the pens are injected by pagerite.js after the session validates; they carry `data-editor-src`/`data-editor-css`/`data-editor-mode`). In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), in prod from the hashed build assets resolved via `frontend-build/.vite/manifest.json`.
|
||||
|
||||
`vite.config.js` sets `appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL space; `frontend/public/favicon.ico` lands at the build root and is served at `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus `src/assets/pagerite.css` as a separate stylesheet entry; theme and banner-design CSS are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. There is no `index.html` source (it would shadow `/` and turn missing dev paths into an empty Vue shell). All outputs are ES modules. The build sets `preserveEntrySignatures: 'exports-only'` because main.js is consumed via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite app builds otherwise strip unused entry exports, leaving dead edit pens. In dev the backend links theme/banner-design stylesheets like in prod (`/_themes/...`); only the base CSS is Vite-injected from JS, and pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/`#pagerite-user` elements to restore the canonical order (base < theme < design < custom CSS). Theme switches in the site editor simply swap the `#pagerite-theme` link href, identically in dev and prod.
|
||||
`vite.config.js` sets `appType: 'mpa'` (no SPA fallback) and builds with `manifest: true`, `assetsDir: '_/assets'` (so the build mirrors the URL space; `frontend/public/favicon.ico` lands at the build root and is served at `/favicon.ico`). JS inputs are `src/main.js` and `src/pagerite.js`, plus `src/assets/pagerite.css` as a separate stylesheet entry; theme and banner-design CSS are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. There is no `index.html` source (it would shadow `/` and turn missing dev paths into an empty Vue shell). All outputs are ES modules. The build sets `preserveEntrySignatures: 'exports-only'` because main.js is consumed via dynamic `import()` for its `openEditor`/`closeEditor` exports — Vite app builds otherwise strip unused entry exports, leaving dead edit pens. In dev the backend links theme/banner-design stylesheets like in prod (`/_themes/...`); only the base CSS is Vite-injected from JS, and pagerite.js then re-appends the `#pagerite-theme`/`#pagerite-banner`/`#pagerite-user` elements to restore the canonical order (base < theme < design < custom CSS). In production all page assets are inlined instead (styles as `<style id="pagerite-…">` in `<head>`, scripts at the end of the body). Theme switches in the site editor swap the `#pagerite-theme` element in place — the link href in dev, the inline style's text (fetched from `/_themes/...`) in prod.
|
||||
|
||||
`vite-plugin-fastapi.js` has an auto-upgrade marker — edit `vite.config.js`, not the plugin.
|
||||
|
||||
@@ -8,9 +8,11 @@ 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 — and the current page — is fetched once at load, clicks are then served from JS with no fetch, and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire), scroll-reveal, 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), 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), scroll-reveal, 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.
|
||||
|
||||
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 (asset URLs from the `pagerite:editor-src`/`-css` meta tags). 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`. The backend links the stylesheets in a fixed order — base (Vite build), theme, banner design, custom CSS last — each with a stable id so the site editor can swap them in place.
|
||||
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`.
|
||||
|
||||
Asset wiring differs by mode. In dev the backend links the Vite dev-server URLs (`pagerite:editor-src`/`-css`/`pagerite:analytics-src` meta tags, `<link>` stylesheets) and Vite injects the entry CSS from JS for hot reloads. In production there are no pagerite meta tags: all page assets are inlined into the document — stylesheets as `<style>` elements in `<head>` (fixed order: base, theme, banner design, entry sheets, custom CSS last), module scripts as inline `<script>`s at the end of the body (relative chunk imports are rewritten to absolute `/_assets/` paths) — and the on-demand bundles' URLs ride in a `<script type="application/json" id="pagerite-assets">` config. The editor bundle always stays external, imported on demand when a pen is opened. Every stylesheet element carries a stable id so fetch-navigation and the site editor can sync `<head>` positionally across swaps (the analytics sheet exists on `/_a` only and is added/removed as you navigate). The analytics entry is inlined into the `/_a` page itself; pagerite.js re-creates that script element after fetch-navigating there (inline scripts don't execute on a DOM swap) and calls the module's exposed unmount before swapping away.
|
||||
|
||||
## `assets/`
|
||||
|
||||
@@ -18,7 +20,7 @@ Shared styles and data files built by Vite and served hashed under `/_assets/`:
|
||||
|
||||
The `::view-transition*` block at the end of `pagerite.css` (from termotohtori.fi) is fragile — do not tweak. Themes and banner designs are NOT built — they live in `pagerite/themes/{name}/` and are served by the backend. See `docs/themes-and-assets.md` for details.
|
||||
|
||||
Vite builds ES-module `.js` outputs; the backend renders `<script type="module">` for them (module scripts defer by default).
|
||||
Vite builds ES-module `.js` outputs; in dev the backend links them as `<script type="module">` (module scripts defer by default), in production it inlines them at the end of the body.
|
||||
|
||||
## Database file
|
||||
|
||||
|
||||
@@ -34,4 +34,4 @@ The banner artwork has scroll parallax: pagerite.js sets the `--pry` scroll para
|
||||
|
||||
## Stylesheet order
|
||||
|
||||
The backend links the stylesheets in a fixed order — base (Vite build), theme, banner design, custom CSS last — each with a stable id so the site editor can swap them in place. The base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
|
||||
The backend emits the stylesheets in a fixed order — base (Vite build), theme, banner design, entry sheets, custom CSS last — each with a stable id so fetch-navigation and the site editor can sync them in place. In dev they are `<link>`s (the base is Vite-injected from JS instead); in production they are inlined as `<style>` elements. The base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// visit/crawler tables. Read-only.
|
||||
// See docs/analytics.md for the data format.
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RANGES } from './analytics/time.js'
|
||||
import {
|
||||
RANGES,
|
||||
rangeWindow,
|
||||
filterRecordsByRange,
|
||||
filterTransitionsByRange,
|
||||
filterViewsByRange,
|
||||
} from './analytics/time.js'
|
||||
import {
|
||||
calcReadStats,
|
||||
calcTotalViews,
|
||||
@@ -20,10 +26,11 @@ import TrailLink from './TrailLink.vue'
|
||||
import VisitorCell from './VisitorCell.vue'
|
||||
import TransitionGraph from './TransitionGraph.vue'
|
||||
import VisitorCharts from './VisitorCharts.vue'
|
||||
import { VIEW_W } from './analytics/chart.js'
|
||||
|
||||
const props = defineProps({
|
||||
initialRange: { type: String, default: 'week' },
|
||||
})
|
||||
// Same centering margin as the charts, so the totals row's left edge
|
||||
// aligns with the chart svg above the natural width.
|
||||
const CHART_MARGIN = `max(0px, calc(50% - ${VIEW_W / 2}px))`
|
||||
|
||||
const ABUSE_MAX_LINES = 5
|
||||
|
||||
@@ -35,6 +42,13 @@ let ws = null
|
||||
let reconnectTimeout = null
|
||||
let timeInterval = null
|
||||
|
||||
// The initial range comes from the URL hash (shareable links); without one,
|
||||
// it is derived from the first analytics snapshot: day when the recorded
|
||||
// history is shorter than 24 h, week otherwise.
|
||||
const hashRange = location.hash.slice(1)
|
||||
const range = ref(RANGES[hashRange] ? hashRange : 'week')
|
||||
let rangePinned = Boolean(RANGES[hashRange])
|
||||
|
||||
function connectAnalytics() {
|
||||
if (ws) return
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
@@ -43,6 +57,15 @@ function connectAnalytics() {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
data.value = JSON.parse(event.data)
|
||||
if (!rangePinned) {
|
||||
rangePinned = true
|
||||
const starts = (data.value?.visits || [])
|
||||
.map((v) => Date.parse(v.start))
|
||||
.filter((t) => !Number.isNaN(t))
|
||||
if (starts.length && Date.now() - Math.min(...starts) < 24 * 3600 * 1000) {
|
||||
range.value = 'day'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error.value = 'analytics data could not be loaded'
|
||||
}
|
||||
@@ -78,11 +101,26 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const visits = computed(() => data.value?.visits || [])
|
||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||
const readStats = computed(() => calcReadStats(visits.value))
|
||||
const window = computed(() => rangeWindow(range.value))
|
||||
|
||||
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
||||
// All non-chart stats follow the selected range; the charts keep their own
|
||||
// range-specific x windows (week overlays previous weeks aligned to Monday).
|
||||
const rangeData = computed(() => {
|
||||
if (!data.value) return null
|
||||
const { t0, t1 } = window.value
|
||||
return {
|
||||
...data.value,
|
||||
transitions: filterTransitionsByRange(data.value.transitions, t0, t1),
|
||||
views: filterViewsByRange(data.value.views, t0, t1),
|
||||
visits: filterRecordsByRange(data.value.visits, t0, t1),
|
||||
crawlers: filterRecordsByRange(data.value.crawlers, t0, t1),
|
||||
abuse: filterRecordsByRange(data.value.abuse, t0, t1),
|
||||
}
|
||||
})
|
||||
|
||||
const visits = computed(() => rangeData.value?.visits || [])
|
||||
const totalViews = computed(() => calcTotalViews(rangeData.value?.views))
|
||||
const readStats = computed(() => calcReadStats(visits.value))
|
||||
|
||||
// Keep the URL shareable when the range changes.
|
||||
watch(range, (r) => {
|
||||
@@ -93,9 +131,9 @@ watch(range, (r) => {
|
||||
|
||||
const clients = computed(() => data.value?.clients || {})
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
|
||||
const crawlers = computed(() => data.value?.crawlers || [])
|
||||
const crawlers = computed(() => rangeData.value?.crawlers || [])
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
|
||||
const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], clients.value, now.value))
|
||||
const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, now.value))
|
||||
|
||||
</script>
|
||||
|
||||
@@ -115,15 +153,15 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||
<p v-else-if="!data" class="loading">loading…</p>
|
||||
<template v-else>
|
||||
<section class="totals">
|
||||
<section class="totals" :style="{ marginLeft: CHART_MARGIN }">
|
||||
<div><strong :title="String(visits.length)">{{ formatCount(visits.length) }}</strong> visits</div>
|
||||
<div><strong :title="String(totalViews)">{{ formatCount(totalViews) }}</strong> page views</div>
|
||||
<div><strong>{{ readStats.avgMinPerVisit }}</strong> min/visit</div>
|
||||
<div><strong>{{ readStats.avgArticleMedianMin }}</strong> min article read</div>
|
||||
<div><strong>{{ readStats.avgArticleMedianMin }}</strong> min/read</div>
|
||||
</section>
|
||||
|
||||
<VisitorCharts :data="data" :range="range" />
|
||||
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" />
|
||||
<TransitionGraph :data="rangeData" :window="window" :page-tree="pageTree" />
|
||||
|
||||
<section>
|
||||
<h2>Recent visits</h2>
|
||||
@@ -251,9 +289,12 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
}
|
||||
|
||||
.analytics-panel {
|
||||
margin: 0 auto;
|
||||
width: min(60rem, 96vw);
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
/* Same 1.25rem side spacing as main's article padding. */
|
||||
padding: 1.5rem 1.25rem 4rem;
|
||||
/* Container for cqw-based shrink-to-fit (see .totals). */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.analytics-panel header {
|
||||
@@ -276,7 +317,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
.ranges button {
|
||||
padding: 0.2rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
@@ -320,12 +361,15 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
.analytics-view :deep(.muted) { color: var(--muted); }
|
||||
.analytics-view :deep(.small) { font-size: 0.75em; }
|
||||
|
||||
/* One line at any width: the gap shrinks first, then the font (the number
|
||||
scales along in em), both following the panel's container width. */
|
||||
.totals {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 1.1rem;
|
||||
gap: clamp(0.5rem, 3cqw, 2rem);
|
||||
font-size: clamp(0.6rem, 2.2cqw, 1.1rem);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.totals strong { font-size: 1.5rem; }
|
||||
.totals strong { font-size: 1.36em; }
|
||||
|
||||
.visit-table-wrap {
|
||||
overflow-x: auto;
|
||||
@@ -334,7 +378,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
.visit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -356,7 +400,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
}
|
||||
|
||||
.visit-table .last-seen {
|
||||
width: 5rem;
|
||||
width: 6rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
@@ -381,6 +425,11 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.analytics-view :deep(.trail-link.error),
|
||||
.analytics-view :deep(.trail-link.error:hover) {
|
||||
color: var(--error, #c00);
|
||||
}
|
||||
|
||||
.visit-table .utm-tag {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
|
||||
+29
-16
@@ -242,30 +242,43 @@ async function saveSettings(opts = {}) {
|
||||
async function onThemeChange() {
|
||||
await saveSettings()
|
||||
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
|
||||
// and prod: swap the link in place, then re-render (the theme's default
|
||||
// banner design and the page's stylesheet links may change with it).
|
||||
let link = document.getElementById('pagerite-theme')
|
||||
// and prod, but rendered differently: a <link> in dev, an inline <style>
|
||||
// in prod. Swap it in place, then re-render (the theme's default banner
|
||||
// design and the page's stylesheets may change with it).
|
||||
let el = document.getElementById('pagerite-theme')
|
||||
const url = `/_themes/${theme.value}/theme.css`
|
||||
if (theme.value) {
|
||||
const href = `/_themes/${theme.value}/theme.css`
|
||||
if (link) {
|
||||
link.href = href
|
||||
} else {
|
||||
if (el?.tagName === 'STYLE') {
|
||||
el.textContent = await (await fetch(url)).text()
|
||||
} else if (el) {
|
||||
el.href = url
|
||||
} else if (import.meta.env.DEV) {
|
||||
// Re-create after "none": keep base < theme < design < custom CSS.
|
||||
// In dev there is no #pagerite-base link (the base is a
|
||||
// In dev there is no #pagerite-base element (the base is a
|
||||
// Vite-injected <style>), so anchor to the next sheet instead of
|
||||
// prepending before the base styles.
|
||||
link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.id = 'pagerite-theme'
|
||||
link.href = href
|
||||
el = document.createElement('link')
|
||||
el.rel = 'stylesheet'
|
||||
el.id = 'pagerite-theme'
|
||||
el.href = url
|
||||
const before = document.getElementById('pagerite-base')?.nextSibling
|
||||
?? document.getElementById('pagerite-banner')
|
||||
?? document.getElementById('pagerite-user')
|
||||
if (before) before.before(link)
|
||||
else document.head.append(link)
|
||||
if (before) before.before(el)
|
||||
else document.head.append(el)
|
||||
} else {
|
||||
// Prod: inline <style>, fetched from the backend-served URL.
|
||||
el = document.createElement('style')
|
||||
el.id = 'pagerite-theme'
|
||||
el.textContent = await (await fetch(url)).text()
|
||||
const before = document.getElementById('pagerite-base')?.nextSibling
|
||||
?? document.getElementById('pagerite-banner')
|
||||
?? document.getElementById('pagerite-user')
|
||||
if (before) before.before(el)
|
||||
else document.head.append(el)
|
||||
}
|
||||
} else if (link) {
|
||||
link.remove()
|
||||
} else if (el) {
|
||||
el.remove()
|
||||
}
|
||||
loadPlain(path.value)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
<script setup>
|
||||
import { formatCount } from './analytics/format.js'
|
||||
import { computed } from 'vue'
|
||||
import { formatCount, formatReadTime } from './analytics/format.js'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
step: { type: Object, required: true },
|
||||
count: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const hasError = computed(() => props.step.status >= 400)
|
||||
|
||||
const title = computed(() => {
|
||||
const parts = [props.step.title]
|
||||
if (props.step.readSeconds > 0) {
|
||||
parts.push(formatReadTime(props.step.readSeconds))
|
||||
}
|
||||
if (hasError.value) {
|
||||
parts.push(`${props.step.status}`)
|
||||
}
|
||||
return parts.filter(Boolean).join(' — ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="trail-link"
|
||||
:class="{ error: hasError }"
|
||||
:href="step.path"
|
||||
:title="count > 1 ? `${step.title} (${count} hits)` : step.title"
|
||||
:title="title"
|
||||
:target="step.external ? '_blank' : undefined"
|
||||
:rel="step.external ? 'noopener' : undefined"
|
||||
@click="(e) => { if (!step.external) $emit('close') }">
|
||||
|
||||
+176
-130
@@ -1,138 +1,168 @@
|
||||
<script setup>
|
||||
/**
|
||||
* Radial transition map filtered to the selected time range.
|
||||
* Radial transition map for a pre-filtered time range.
|
||||
*
|
||||
* Transitions are stored per 5-minute bucket (from -> to -> bucket ->
|
||||
* count), so the graph sums the buckets falling inside the selected
|
||||
* range, exactly like the charts and per-page views do.
|
||||
* The parent filters transitions, views and visits to the selected range
|
||||
* before passing them in; `window` carries the absolute [t0, t1) window
|
||||
* so the visual scale can normalize against a one-week reference.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
|
||||
import { rangeWindow, WEEK } from './analytics/time.js'
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||
import { DAY, WEEK } from './analytics/time.js'
|
||||
import { formatCount } from './analytics/format.js'
|
||||
import {
|
||||
TNODE_R,
|
||||
TNODE_W,
|
||||
TNODE_H,
|
||||
BEAD_R,
|
||||
BEAD_SPEED,
|
||||
buildTransitionGraph,
|
||||
filterTransitionsByRange,
|
||||
filterViewsByRange,
|
||||
filterVisitsByRange,
|
||||
} from './analytics/transitions.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: null },
|
||||
range: { type: String, required: true },
|
||||
window: { type: Object, required: true },
|
||||
pageTree: { type: Array, default: null },
|
||||
})
|
||||
|
||||
const window = computed(() => rangeWindow(props.range))
|
||||
|
||||
const visualScale = computed(() => {
|
||||
const { t0, t1 } = window.value
|
||||
const { t0, t1 } = props.window
|
||||
if (t0 != null && t1 != null) return WEEK / (t1 - t0)
|
||||
// 'all': scale by the actual data span.
|
||||
// 'all': scale by the actual data span, but never less than the 30-day
|
||||
// minimum the plot enforces, so sparse young data is not over-amplified.
|
||||
const times = new Set()
|
||||
for (const buckets of Object.values(props.data?.views || {})) {
|
||||
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
|
||||
}
|
||||
const arr = [...times]
|
||||
if (arr.length < 2) return 1
|
||||
return WEEK / (Math.max(...arr) - Math.min(...arr))
|
||||
const span = Math.max(...arr) - Math.min(...arr)
|
||||
return WEEK / Math.max(span, 30 * DAY)
|
||||
})
|
||||
|
||||
const filteredData = computed(() => {
|
||||
if (!props.data) return null
|
||||
const { t0, t1 } = window.value
|
||||
return {
|
||||
transitions: filterTransitionsByRange(props.data.transitions, t0, t1),
|
||||
views: filterViewsByRange(props.data.views, t0, t1),
|
||||
}
|
||||
})
|
||||
|
||||
const filteredVisits = computed(() =>
|
||||
filterVisitsByRange(props.data?.visits, window.value.t0, window.value.t1),
|
||||
)
|
||||
|
||||
const graph = computed(() =>
|
||||
filteredData.value
|
||||
? buildTransitionGraph(filteredData.value, props.pageTree, filteredVisits.value, visualScale.value)
|
||||
props.data
|
||||
? buildTransitionGraph(props.data, props.pageTree, props.data.visits || [], visualScale.value)
|
||||
: null,
|
||||
)
|
||||
|
||||
// Bead animation: every bead is simulated independently in JS. Each flow
|
||||
// (one per edge direction) emits a bead every `interval` seconds; beads
|
||||
// travel at BEAD_SPEED along the segment and are dropped at the end.
|
||||
// cross their segment in a constant TRAVERSAL_S seconds (speed relative
|
||||
// to span length) and are dropped at the end.
|
||||
// There is deliberately no cap on beads in flight.
|
||||
// Emitters persist across data reloads, keyed by flow.key: an unchanged
|
||||
// link keeps its emission phase and in-flight beads (tracked by progress,
|
||||
// not absolute time), so a count change elsewhere never reshuffles them.
|
||||
const beads = shallowRef([])
|
||||
let rafId = 0
|
||||
const emitters = new Map() // flow.key -> { flow, interval, next, alive }
|
||||
const live = [] // { e, p } — beads in flight, p = progress 0..1
|
||||
let lastTick = 0
|
||||
|
||||
const MAX_BEAD_RATE = 120 // upper bound on total beads per second
|
||||
const TRAVERSAL_S = 1.5 // seconds to cross any segment, end to end
|
||||
|
||||
const startBeads = (flows) => {
|
||||
cancelAnimationFrame(rafId)
|
||||
beads.value = []
|
||||
if (!flows?.length) return
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
const syncBeads = (flows) => {
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (!flows?.length || reduced) {
|
||||
emitters.clear()
|
||||
live.length = 0
|
||||
beads.value = []
|
||||
return
|
||||
}
|
||||
// Cap the total bead emission rate so a busy range cannot spawn enough
|
||||
// beads to kill the page. Existing per-range time scaling is preserved;
|
||||
// this is only a proportional emergency throttle when the limit is hit.
|
||||
const totalRate = flows.reduce((s, f) => s + 1 / f.interval, 0)
|
||||
const scale = totalRate > MAX_BEAD_RATE ? MAX_BEAD_RATE / totalRate : 1
|
||||
|
||||
const live = [] // { flow, t0 } — one entry per bead in flight
|
||||
const now = performance.now()
|
||||
const emitters = flows.map((flow) => {
|
||||
const seen = new Set()
|
||||
for (const flow of flows) {
|
||||
seen.add(flow.key)
|
||||
const interval = (flow.interval / scale) * 1000
|
||||
// Pre-fill the traversal with evenly spaced beads (random phase), so
|
||||
// the flow appears already running instead of starting empty.
|
||||
const e = emitters.get(flow.key)
|
||||
if (e) {
|
||||
e.flow = flow // pick up new geometry/rate, keep the phase
|
||||
e.interval = interval
|
||||
continue
|
||||
}
|
||||
// New emitter: pre-fill the traversal with evenly spaced beads (random
|
||||
// phase), so the flow appears already running instead of empty.
|
||||
const phase = Math.random() * interval
|
||||
for (let t = now - (flow.len / BEAD_SPEED) * 1000 + phase; t <= now; t += interval) {
|
||||
live.push({ flow, t0: t })
|
||||
const dp = interval / 1000 / TRAVERSAL_S
|
||||
const ne = { flow, interval, next: now + phase, alive: true }
|
||||
for (let p = 1 - phase / 1000 / TRAVERSAL_S; p > 0; p -= dp) {
|
||||
live.push({ e: ne, p })
|
||||
}
|
||||
return { flow, interval, next: now + phase }
|
||||
})
|
||||
|
||||
const tick = (t) => {
|
||||
for (const e of emitters) {
|
||||
while (e.next <= t) {
|
||||
live.push({ flow: e.flow, t0: e.next })
|
||||
e.next += e.interval
|
||||
}
|
||||
}
|
||||
const out = []
|
||||
for (let i = live.length - 1; i >= 0; i--) {
|
||||
const b = live[i]
|
||||
const p = ((t - b.t0) / 1000) * BEAD_SPEED / b.flow.len
|
||||
if (p >= 1) {
|
||||
live.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
out.push({
|
||||
x: b.flow.x1 + (b.flow.x2 - b.flow.x1) * p,
|
||||
y: b.flow.y1 + (b.flow.y2 - b.flow.y1) * p,
|
||||
})
|
||||
}
|
||||
beads.value = out
|
||||
rafId = requestAnimationFrame(tick)
|
||||
emitters.set(flow.key, ne)
|
||||
}
|
||||
for (const [key, e] of emitters) {
|
||||
if (!seen.has(key)) {
|
||||
e.alive = false
|
||||
emitters.delete(key)
|
||||
}
|
||||
}
|
||||
for (let i = live.length - 1; i >= 0; i--) {
|
||||
if (!live[i].e.alive) live.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const tick = (t) => {
|
||||
const dt = lastTick ? (t - lastTick) / 1000 : 0
|
||||
lastTick = t
|
||||
for (const e of emitters.values()) {
|
||||
while (e.next <= t) {
|
||||
live.push({ e, p: 0 })
|
||||
e.next += e.interval
|
||||
}
|
||||
}
|
||||
const out = []
|
||||
for (let i = live.length - 1; i >= 0; i--) {
|
||||
const b = live[i]
|
||||
b.p += dt / TRAVERSAL_S
|
||||
if (b.p >= 1) {
|
||||
live.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const f = b.e.flow
|
||||
out.push({ x: f.x1 + (f.x2 - f.x1) * b.p, y: f.y1 + (f.y2 - f.y1) * b.p })
|
||||
}
|
||||
beads.value = out
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
watch(() => graph.value?.flows, startBeads, { immediate: true })
|
||||
watch(() => graph.value?.flows, syncBeads, { immediate: true })
|
||||
onMounted(() => {
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
|
||||
// The svg never renders larger than its natural size (1 viewBox unit = 1
|
||||
// px, max-width below): the layout geometry is designed in pixel-like
|
||||
// units, and upscaling would blow up the pills around their text. Narrow
|
||||
// panels scale the graph down to fit (width: 100%), text along with it.
|
||||
|
||||
// Pill text is not truncated: text is clipped at the pill's rounded border
|
||||
// (clipPath per node, inset a few units for padding). Captions center when
|
||||
// they fit; overlong ones anchor left so their beginning (not their
|
||||
// middle) survives the clip. Width estimate: ~0.52 em per glyph.
|
||||
const fitsPill = (label, fontPx = 19) => label.length * 0.52 * fontPx <= TNODE_W - 16
|
||||
|
||||
const countLabel = (n) =>
|
||||
n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="graph">
|
||||
<svg class="tmap" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
||||
<svg class="tmap" :style="{ maxWidth: `${graph.bounds.x1 - graph.bounds.x0}px` }" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
||||
role="img" aria-label="map of transitions between pages">
|
||||
<defs>
|
||||
<!-- Unit-radius circle; only the portion near the bottom is used. -->
|
||||
<path id="tnode-label-arc" d="M 0,-1 A 1,1 0 1,0 0,1 A 1,1 0 1,0 -0.001,-1" />
|
||||
</defs>
|
||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||
:d="a.d" class="tarc" />
|
||||
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
|
||||
<template v-for="(a, i) in graph.arcs" :key="'t' + i">
|
||||
<path v-if="a.ld" :id="`tarcl${i}`" :d="a.ld" fill="none" stroke="none" />
|
||||
<text v-if="a.ld" class="tarclabel" :class="{ 'tarclabel-top': a.top }"><textPath :href="`#tarcl${i}`" startOffset="0">{{ a.label }}</textPath></text>
|
||||
</template>
|
||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
||||
<title>{{ e.title }}</title>
|
||||
@@ -140,43 +170,44 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener" :title="x.path">
|
||||
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
||||
</text>
|
||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
|
||||
<clipPath :id="`xclip${i}`">
|
||||
<rect :x="x.x - TNODE_W/2 + 6" :y="x.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||
:height="TNODE_H" :rx="TNODE_H/2 - 4" />
|
||||
</clipPath>
|
||||
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener">
|
||||
<title>{{ x.path }}</title>
|
||||
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<g :clip-path="`url(#xclip${i})`">
|
||||
<text :x="fitsPill(x.label) ? x.x : x.x - TNODE_W/2 + 8" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(x.label) ? 'middle' : 'start' }">{{ x.label }}</text>
|
||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||
</g>
|
||||
</a>
|
||||
<g v-else :title="x.path">
|
||||
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
||||
</text>
|
||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
|
||||
<g v-else>
|
||||
<title>{{ x.path }}</title>
|
||||
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<g :clip-path="`url(#xclip${i})`">
|
||||
<text :x="fitsPill(x.label) ? x.x : x.x - TNODE_W/2 + 8" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(x.label) ? 'middle' : 'start' }">{{ x.label }}</text>
|
||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g v-for="n in graph.nodes" :key="n.path">
|
||||
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
||||
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
||||
<text :transform="`translate(${n.x}, ${n.y}) scale(${TNODE_R - 4})`" class="tnodeslug" :style="{ '--node-r': TNODE_R - 4 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath>
|
||||
</text>
|
||||
<text :x="n.x" :y="n.y + 4" class="tnodecount">
|
||||
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
|
||||
</text>
|
||||
<g v-for="(n, i) in graph.nodes" :key="n.path">
|
||||
<clipPath :id="`nclip${i}`">
|
||||
<rect :x="n.x - TNODE_W/2 + 6" :y="n.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||
:height="TNODE_H" :rx="TNODE_H/2 - 4" />
|
||||
</clipPath>
|
||||
<a :href="n.path">
|
||||
<title>{{ n.title }}</title>
|
||||
<rect :x="n.x - TNODE_W/2" :y="n.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2" class="tnode" />
|
||||
<g :clip-path="`url(#nclip${i})`">
|
||||
<text :x="fitsPill(n.label) ? n.x : n.x - TNODE_W/2 + 8" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(n.label) ? 'middle' : 'start' }">{{ n.label }}</text>
|
||||
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
||||
{{ countLabel(n) }}
|
||||
</text>
|
||||
</g>
|
||||
</a>
|
||||
<template v-else>
|
||||
<text :x="n.x" :y="n.y"
|
||||
:transform="`rotate(${n.angle * 180 / Math.PI}, ${n.x}, ${n.y})`"
|
||||
class="tnodehidden" text-anchor="start" dominant-baseline="middle">➤</text>
|
||||
<text :x="n.x + Math.cos(n.angle) * 10"
|
||||
:y="n.y + Math.sin(n.angle) * 10"
|
||||
:transform="`rotate(${(n.angle + (Math.cos(n.angle) < 0 ? Math.PI : 0)) * 180 / Math.PI}, ${n.x + Math.cos(n.angle) * 10}, ${n.y + Math.sin(n.angle) * 10})`"
|
||||
:text-anchor="Math.cos(n.angle) < 0 ? 'end' : 'start'"
|
||||
class="tnodehidden" dominant-baseline="middle">{{ n.label }}</text>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
@@ -187,7 +218,8 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
.tmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
/* max-width is set inline to the natural content width (px = viewBox
|
||||
units), so wide panels never upscale the graph beyond 1:1. */
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tmap .tconn {
|
||||
@@ -203,37 +235,51 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
filter: drop-shadow(0 0 2.5px var(--accent));
|
||||
}
|
||||
.tmap .txnode {
|
||||
fill: var(--bg, Canvas);
|
||||
stroke-width: 1.5;
|
||||
fill: var(--text);
|
||||
stroke: none;
|
||||
}
|
||||
.tmap .txnode-source { stroke: var(--text); }
|
||||
.tmap .txnode-exit { stroke: var(--text); }
|
||||
.tmap .txnode-source { fill: var(--text); }
|
||||
.tmap .txnode-exit { fill: var(--text); }
|
||||
/* Branch lanes: one wide concentric arc per path prefix, running behind
|
||||
the node pills around the fan's circle center; parent levels sit one
|
||||
indent (radius step) outward. Each lane's label follows a short guide
|
||||
arc across the first inter-node gap (the part pills never cover). */
|
||||
.tmap .tarc {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
stroke: var(--muted);
|
||||
stroke-width: 16;
|
||||
opacity: 0.25;
|
||||
}
|
||||
.tmap .tarc-top { stroke-width: 24; }
|
||||
/* Lane labels are left-aligned: each guide arc starts just past the source
|
||||
pill's edge, the earliest point where the text is visible. */
|
||||
.tmap .tarclabel {
|
||||
fill: var(--muted);
|
||||
font-size: 13px;
|
||||
text-anchor: start;
|
||||
}
|
||||
/* The top lane is 50% thicker; its 🏠︎ label scales along. */
|
||||
.tmap .tarclabel-top {
|
||||
font-size: 19.5px;
|
||||
}
|
||||
.tmap .tnode {
|
||||
fill: var(--bg, Canvas);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
fill: var(--accent);
|
||||
stroke: none;
|
||||
}
|
||||
/* Text sizes are viewBox units: they shrink along with the graph on
|
||||
narrow panels. Overlong labels are clipped at the pill border. */
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--text);
|
||||
font-size: calc(11px / var(--node-r, 34));
|
||||
text-anchor: middle;
|
||||
fill: var(--bg, Canvas);
|
||||
font-size: 19px;
|
||||
text-anchor: start;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--muted);
|
||||
font-size: 10px;
|
||||
fill: var(--bg, Canvas);
|
||||
opacity: 0.75;
|
||||
font-size: 15px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.tmap .tnodehidden {
|
||||
fill: var(--text);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
section { margin-top: 1.8rem; }
|
||||
</style>
|
||||
|
||||
+93
-107
@@ -4,10 +4,23 @@
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { makeSeries } from './analytics/time.js'
|
||||
import { CHART_H, CHART_W, buildChart, fmtY } from './analytics/chart.js'
|
||||
import {
|
||||
CHART_H,
|
||||
CHART_W,
|
||||
MARGIN_B,
|
||||
MARGIN_L,
|
||||
VIEW_H,
|
||||
VIEW_W,
|
||||
buildChart,
|
||||
} from './analytics/chart.js'
|
||||
|
||||
const DAY_REFRESH_MS = 15000
|
||||
|
||||
// Keep the whole svg within page bounds: full width below the natural
|
||||
// size, centered with equal side margins above it (max() clamps the
|
||||
// centering margin to 0 at the breakpoint, so the rule is continuous).
|
||||
const CHART_MARGIN = `max(0px, calc(50% - ${VIEW_W / 2}px))`
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: null },
|
||||
range: { type: String, required: true },
|
||||
@@ -29,6 +42,17 @@ function freqLabel(unit) {
|
||||
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
|
||||
}
|
||||
|
||||
/** Vertical axis caption: "visits / 5 min" on the day view, else "hourly visits" style. */
|
||||
function axisLabel(unit, ylabel) {
|
||||
return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}`
|
||||
}
|
||||
|
||||
/** Legend label for the overlaid past weeks: "Week M" or "Week M–N". */
|
||||
function pastLabel(series) {
|
||||
const oldest = series.at(-1).label.slice(5) // strip "Week "
|
||||
return series.length > 2 ? `Week ${oldest}–${series[1].label.slice(5)}` : `Week ${oldest}`
|
||||
}
|
||||
|
||||
const now = ref(Date.now())
|
||||
let refreshInterval = null
|
||||
onMounted(() => {
|
||||
@@ -44,107 +68,87 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
|
||||
<template>
|
||||
<section v-for="c in [
|
||||
{ ylabel: 'visits', chart: visitChart, empty: 'no visits recorded yet' },
|
||||
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
|
||||
{ ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' },
|
||||
{ ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' },
|
||||
]" :key="c.ylabel">
|
||||
<template v-if="c.chart">
|
||||
<div class="chartwrap">
|
||||
<div class="plot">
|
||||
<div class="plotarea">
|
||||
<span class="yaxis-label">{{ freqLabel(c.chart.unit) }} {{ c.ylabel }}</span>
|
||||
<svg class="chart" :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
|
||||
preserveAspectRatio="none" role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
|
||||
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
||||
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
|
||||
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
||||
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
|
||||
class="minor vertical" />
|
||||
</template>
|
||||
<template v-if="c.chart.bars">
|
||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
||||
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||
<path :d="c.chart.skyline" class="line" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
||||
</template>
|
||||
</template>
|
||||
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
||||
class="axis" />
|
||||
</svg>
|
||||
<span v-for="g in c.chart.majors" :key="g.value" class="ylab"
|
||||
:style="{ bottom: g.bottom + '%' }">{{ fmtY(g.value) }}</span>
|
||||
</div>
|
||||
<div class="xlabels">
|
||||
<span v-for="t in c.chart.xticks" :key="t.x" class="xlab"
|
||||
:style="{ left: t.left + '%' }">{{ t.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="c.chart.series && c.chart.series.length > 1" class="legend">
|
||||
<span v-for="(s, i) in c.chart.series" :key="i" :style="{ opacity: s.opacity }">
|
||||
● {{ s.label }}
|
||||
</span>
|
||||
</div>
|
||||
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
|
||||
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
|
||||
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
||||
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
|
||||
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
||||
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
|
||||
class="minor vertical" />
|
||||
</template>
|
||||
<template v-if="c.chart.bars">
|
||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
||||
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||
<path :d="c.chart.skyline" class="line" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Oldest overlay weeks first so the current week paints on top. -->
|
||||
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :class="{ past: s.past }"
|
||||
:style="{ opacity: s.opacity }" />
|
||||
</template>
|
||||
</template>
|
||||
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
||||
class="axis" />
|
||||
<text v-for="g in c.chart.majors" :key="'y' + g.value" x="-5" :y="g.y"
|
||||
text-anchor="end" dominant-baseline="middle" class="ylab">{{ g.label }}</text>
|
||||
<text :x="-(MARGIN_L - 10)" :y="CHART_H / 2" text-anchor="middle"
|
||||
:transform="`rotate(-90 ${-(MARGIN_L - 10)} ${CHART_H / 2})`"
|
||||
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}</text>
|
||||
<text v-for="t in c.chart.xticks" :key="'x' + t.x" :x="t.x" :y="CHART_H + MARGIN_B - 8"
|
||||
text-anchor="middle" class="xlab">{{ t.label }}</text>
|
||||
<!-- Week overlay legend, top right inside the plot: current week in
|
||||
accent, one muted specimen for the whole past range. -->
|
||||
<g v-if="c.legend && c.chart.series.length > 1">
|
||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="10" y2="10" class="line" />
|
||||
<text :x="CHART_W - 72" y="10" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.series[0].label }}</text>
|
||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="25" y2="25"
|
||||
class="line past" style="opacity: 0.6" />
|
||||
<text :x="CHART_W - 72" y="25" dominant-baseline="middle"
|
||||
class="leglab">{{ pastLabel(c.chart.series) }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
<p v-else class="empty">{{ c.empty }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The svg is stretched (preserveAspectRatio none), so all text lives in
|
||||
HTML overlays positioned by the same fractions the geometry uses. */
|
||||
.chartwrap {
|
||||
padding-left: 2.8rem; /* y labels */
|
||||
}
|
||||
|
||||
.plot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plotarea {
|
||||
position: relative;
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.xlabels {
|
||||
position: relative;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
/* Each chart is a self-contained SVG: the viewBox includes the axis label
|
||||
margins, so nothing is positioned with HTML overlays. Never upscale past
|
||||
the natural size (1 viewBox unit = 1 px, max-width set inline) — that
|
||||
would blow up the constant-size text; smaller panels still scale the
|
||||
chart down to fit. The margin-left (set inline) centers the chart above
|
||||
its natural width; the svg always stays within page bounds.
|
||||
overflow: visible lets wider fonts extend past the viewBox instead of
|
||||
clipping. */
|
||||
.chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ylab {
|
||||
position: absolute;
|
||||
left: -2.8rem;
|
||||
width: 2.6rem;
|
||||
text-align: right;
|
||||
transform: translateY(50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
.chart .ylab,
|
||||
.chart .xlab,
|
||||
.chart .yaxis-label,
|
||||
.chart .leglab {
|
||||
font-family: system-ui, sans-serif; /* theme fonts can be overly styled */
|
||||
font-size: 11px;
|
||||
fill: var(--muted);
|
||||
}
|
||||
|
||||
.chart .ylab {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.xlab {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xlabels .xlab:first-child { transform: none; }
|
||||
.xlabels .xlab:last-child { transform: translateX(-100%); }
|
||||
|
||||
.chart .minor {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
@@ -189,28 +193,10 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
/* Past overlay weeks contrast with the current week's accent color. */
|
||||
.chart .line.past {
|
||||
stroke: var(--muted);
|
||||
}
|
||||
|
||||
.legend span { color: var(--accent); }
|
||||
|
||||
.yaxis-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -2.8rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
section { margin-top: 1.8rem; }
|
||||
|
||||
.empty { color: var(--muted); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Analytics page entry: mounts AnalyticsView inside the normal page layout.
|
||||
// The backend renders #analytics-app inside #main and links this module for
|
||||
// the initial load; pagerite.js also imports it on fetch-navigation to /_a.
|
||||
// In production the backend inlines this module into the /_a page (and
|
||||
// pagerite.js re-creates the script element after fetch-navigations there);
|
||||
// in dev pagerite.js imports it from the Vite dev server on demand. Either
|
||||
// way it auto-mounts on #analytics-app when it evaluates, and unmounts when
|
||||
// pagerite.js announces a swap away from /_a.
|
||||
import { createApp } from 'vue'
|
||||
import AnalyticsView from './AnalyticsView.vue'
|
||||
|
||||
@@ -8,9 +11,7 @@ let app = null
|
||||
|
||||
export function mount(container) {
|
||||
if (app) return
|
||||
app = createApp(AnalyticsView, {
|
||||
initialRange: location.hash.slice(1) || 'week',
|
||||
})
|
||||
app = createApp(AnalyticsView)
|
||||
app.mount(container)
|
||||
}
|
||||
|
||||
@@ -19,6 +20,11 @@ export function unmount() {
|
||||
app = null
|
||||
}
|
||||
|
||||
// Auto-mount on a normal (non-fetch) page load.
|
||||
// pagerite.js calls this before swapping away from /_a; each evaluation
|
||||
// (the inlined production module evaluates fresh on every visit) replaces
|
||||
// the handle.
|
||||
window.__pageriteAnalyticsUnmount = unmount
|
||||
|
||||
// Auto-mount when the page holding #analytics-app is present.
|
||||
const container = document.getElementById('analytics-app')
|
||||
if (container) mount(container)
|
||||
|
||||
+63
-110
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
||||
*
|
||||
* Fixed 720x180 viewBox, stretched to the panel width; values are per-unit
|
||||
* rates (hour on the week view, day on month+).
|
||||
* Fixed 720x180 plot area inside a larger viewBox that also holds the axis
|
||||
* labels, so each chart SVG is self-contained; values are per-unit rates
|
||||
* (hour on the week view, day on month+).
|
||||
*/
|
||||
|
||||
import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
|
||||
import { formatCount } from './format.js'
|
||||
|
||||
export const CHART_W = 720
|
||||
export const CHART_H = 180
|
||||
export const CHART_W = 1000
|
||||
export const CHART_H = 150
|
||||
export const PAD_TOP = 14 // room above the highest point
|
||||
export const MARGIN_L = 40 // y tick labels + vertical axis label
|
||||
export const MARGIN_B = 24 // x tick labels
|
||||
export const VIEW_W = MARGIN_L + CHART_W + 8
|
||||
export const VIEW_H = CHART_H + MARGIN_B
|
||||
|
||||
/**
|
||||
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
|
||||
@@ -37,24 +42,19 @@ export function yScale(maxValue) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge-aware adaptive Gaussian smoothing. A change-point detector first
|
||||
* finds traffic-level shifts (two-unit totals compared on both sides of
|
||||
* each bucket; strong ratio + significance marks a candidate, and each run
|
||||
* of candidates keeps only its best-scoring bucket as an edge). Each
|
||||
* edge-delimited segment is then smoothed independently: a broad two-unit
|
||||
* pilot estimates the local traffic rate, which ramps the Gaussian sigma
|
||||
* from ~0.4 units (isolated events stay narrow, peaking at ~1 event/unit)
|
||||
* up to 1 unit (busy traffic gets full smoothing), and every bucket spreads
|
||||
* its count with its local sigma, clipped to the segment and renormalized
|
||||
* so total visitor count is preserved exactly. The unit is one hour on the
|
||||
* week view and one day on the month+ views, so the smoothing time scale
|
||||
* follows the range (month+ sigmas are 24x the hourly ones). The raw series
|
||||
* is drawn faintly behind the curve for reference. Operates on raw counts.
|
||||
* Edge-aware Gaussian smoothing with a fixed bandwidth. A change-point
|
||||
* detector first finds traffic-level shifts (two-unit totals compared on
|
||||
* both sides of each bucket; strong ratio + significance marks a candidate,
|
||||
* and each run of candidates keeps only its best-scoring bucket as an
|
||||
* edge). Each edge-delimited segment is then smoothed independently: every
|
||||
* bucket spreads its count with a fixed Gaussian sigma chosen so N events
|
||||
* in a single bucket peak at N events per unit, clipped to the segment and
|
||||
* renormalized so total visitor count is preserved exactly. The unit is
|
||||
* one hour on the week view and one day on the month+ views, so the
|
||||
* smoothing time scale follows the range. The raw series is drawn faintly
|
||||
* behind the curve for reference. Operates on raw counts.
|
||||
*/
|
||||
export function smooth(counts, binMinutes, unitMinutes, {
|
||||
minSigmaMinutes = unitMinutes / Math.sqrt(2 * Math.PI),
|
||||
maxSigmaMinutes = unitMinutes,
|
||||
pilotSigmaMinutes = 2 * unitMinutes,
|
||||
detectorWindowMinutes = 2 * unitMinutes,
|
||||
// Count thresholds are defined per hour and scale with the unit, so
|
||||
// "low traffic" means the same thing on hourly and daily views
|
||||
@@ -62,8 +62,6 @@ export function smooth(counts, binMinutes, unitMinutes, {
|
||||
highTrafficEvents = 10 * unitMinutes / 60,
|
||||
minRatio = 2.5,
|
||||
minSignificance = 4,
|
||||
sigmaRampStart = 5 * unitMinutes / 60,
|
||||
sigmaRampEnd = 20 * unitMinutes / 60,
|
||||
} = {}) {
|
||||
const n = counts.length
|
||||
if (!n) return counts
|
||||
@@ -105,68 +103,25 @@ export function smooth(counts, binMinutes, unitMinutes, {
|
||||
i = j
|
||||
}
|
||||
|
||||
const reflectIndex = (i, length) => {
|
||||
while (i < 0 || i >= length) {
|
||||
i = i < 0 ? -i - 1 : 2 * length - i - 1
|
||||
}
|
||||
return i
|
||||
}
|
||||
// Fixed sigma: N events in one bucket peak at N events per unit.
|
||||
// sigma_bins * sqrt(2*pi) = rate = unitMinutes / binMinutes.
|
||||
const sigmaBins = unitMinutes / (binMinutes * Math.sqrt(2 * Math.PI))
|
||||
const radius = Math.ceil(4 * sigmaBins)
|
||||
|
||||
const gaussianFilterReflect = (values, sigmaBins) => {
|
||||
const length = values.length
|
||||
const radius = Math.ceil(4 * sigmaBins)
|
||||
const kernel = new Float64Array(radius * 2 + 1)
|
||||
let sum = 0
|
||||
for (let k = -radius; k <= radius; k++) {
|
||||
const w = Math.exp(-0.5 * (k / sigmaBins) ** 2)
|
||||
kernel[k + radius] = w
|
||||
sum += w
|
||||
}
|
||||
for (let i = 0; i < kernel.length; i++) kernel[i] /= sum
|
||||
const out = new Float64Array(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
let value = 0
|
||||
for (let k = -radius; k <= radius; k++) {
|
||||
value += values[reflectIndex(i + k, length)] * kernel[k + radius]
|
||||
}
|
||||
out[i] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Process each discontinuity-delimited regime independently so neither
|
||||
// the pilot nor the final Gaussian can see through a detected boundary.
|
||||
// Process each discontinuity-delimited regime independently so the
|
||||
// Gaussian cannot see through a detected boundary. Each input bin spreads
|
||||
// its count with the fixed sigma; the kernel is renormalized after
|
||||
// clipping to the segment, preserving total visitor count apart from
|
||||
// floating-point error.
|
||||
const bounds = [0, ...edges, n]
|
||||
const smoothed = new Float64Array(n)
|
||||
for (let b = 0; b < bounds.length - 1; b++) {
|
||||
const lo = bounds[b]
|
||||
const length = bounds[b + 1] - lo
|
||||
const segment = counts.slice(lo, lo + length)
|
||||
|
||||
// Broad pilot estimates only the generic local traffic level used for
|
||||
// choosing sigma; it is not the final displayed curve.
|
||||
const pilot = gaussianFilterReflect(segment, pilotSigmaMinutes / binMinutes)
|
||||
|
||||
// Keep isolated/sparse traffic at the minimum bandwidth through
|
||||
// sigmaRampStart events, then ramp toward maxSigmaMinutes (thresholds
|
||||
// are per-hour rates scaled to the unit: low traffic is low traffic
|
||||
// on every range).
|
||||
const sigmaMinutes = new Float64Array(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const ratePerUnit = pilot[i] * unitMinutes / binMinutes
|
||||
let mix = (ratePerUnit - sigmaRampStart) / (sigmaRampEnd - sigmaRampStart)
|
||||
mix = Math.sqrt(Math.max(0, Math.min(1, mix)))
|
||||
sigmaMinutes[i] = minSigmaMinutes + mix * (maxSigmaMinutes - minSigmaMinutes)
|
||||
}
|
||||
|
||||
// Each input bin spreads its own count using its local sigma. The
|
||||
// per-bin kernel is renormalized after clipping to the segment,
|
||||
// preserving total visitor count apart from floating-point error.
|
||||
for (let j = 0; j < length; j++) {
|
||||
const count = segment[j]
|
||||
if (!count) continue
|
||||
const sigmaBins = sigmaMinutes[j] / binMinutes
|
||||
const radius = Math.ceil(4 * sigmaBins)
|
||||
const start = Math.max(0, j - radius)
|
||||
const end = Math.min(length, j + radius + 1)
|
||||
let weightSum = 0
|
||||
@@ -201,7 +156,7 @@ export function spline(pts) {
|
||||
const c1y = clampY(p1.y + (p2.y - p0.y) / 6)
|
||||
const c2y = clampY(p2.y - (p3.y - p1.y) / 6)
|
||||
d += `C${p1.x + (p2.x - p0.x) / 6},${c1y} `
|
||||
+ `${p2.x - (p3.x - p1.x) / 6},${c2y} ${p2.x},${p2.y}`
|
||||
+ `${p2.x - (p3.x - p1.x) / 6},${c2y} ${p2.x},${p2.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -240,7 +195,7 @@ export function buildChart(input, now = Date.now()) {
|
||||
const nMajor = Math.round(max / step)
|
||||
for (let k = 0; k <= nMajor; k++) {
|
||||
const v = k * step
|
||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
||||
majors.push({ value: v, y: y(v), label: fmtY(v) })
|
||||
}
|
||||
if (minor) {
|
||||
for (let v = minor; v < max; v += minor) {
|
||||
@@ -254,37 +209,37 @@ export function buildChart(input, now = Date.now()) {
|
||||
// ranges: boundary lines at Mondays / months / years.
|
||||
const isWeek = t1 - t0 === WEEK
|
||||
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
||||
const xticks = isWeek
|
||||
? Array.from({ length: 7 }, (_, d) => {
|
||||
const t = t0 + d * DAY + 12 * HOUR
|
||||
return {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: new Date(t).toLocaleDateString(undefined, {
|
||||
weekday: 'short', timeZone: 'UTC',
|
||||
}),
|
||||
line: false,
|
||||
}
|
||||
let xticks
|
||||
if (isWeek) {
|
||||
xticks = Array.from({ length: 7 }, (_, d) => {
|
||||
const t = t0 + d * DAY + 12 * HOUR
|
||||
return {
|
||||
x: x(t),
|
||||
label: new Date(t).toLocaleDateString(undefined, {
|
||||
weekday: 'short', timeZone: 'UTC',
|
||||
}),
|
||||
line: false,
|
||||
}
|
||||
})
|
||||
} else if (isMonth) {
|
||||
// t0 is day-aligned; label every day whose noon falls inside the range.
|
||||
xticks = []
|
||||
for (let day = t0; day + 12 * HOUR < t1; day += DAY) {
|
||||
const date = new Date(day)
|
||||
const t = day + 12 * HOUR
|
||||
xticks.push({
|
||||
x: x(t),
|
||||
label: date.getUTCDate() === 1
|
||||
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||
: String(date.getUTCDate()),
|
||||
line: false,
|
||||
})
|
||||
: isMonth
|
||||
? Array.from(
|
||||
{ length: Math.floor((t1 - Math.ceil(t0 / DAY) * DAY) / DAY) },
|
||||
(_, d) => {
|
||||
const day = Math.ceil(t0 / DAY) * DAY + d * DAY
|
||||
const date = new Date(day)
|
||||
const t = day + 12 * HOUR
|
||||
return {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: date.getUTCDate() === 1
|
||||
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||
: String(date.getUTCDate()),
|
||||
line: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
: xticksFor(t0, t1).map((t) => ({
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
xticks = xticksFor(t0, t1).map((t) => ({
|
||||
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
}
|
||||
return { max, majors, minors, series: drawn, xticks, unit }
|
||||
}
|
||||
|
||||
@@ -347,7 +302,7 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
const nMajor = Math.round(max / step)
|
||||
for (let k = 0; k <= nMajor; k++) {
|
||||
const v = k * step
|
||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
||||
majors.push({ value: v, y: y(v), label: fmtY(v) })
|
||||
}
|
||||
if (minor) {
|
||||
for (let v = minor; v < max; v += minor) {
|
||||
@@ -363,12 +318,10 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
const d = new Date(t)
|
||||
xticks.push({
|
||||
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
||||
left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
||||
line: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
}
|
||||
|
||||
@@ -386,7 +339,7 @@ export function xticksFor(t0, t1) {
|
||||
if (span <= 4 * 365 * DAY) {
|
||||
const d = new Date(t0)
|
||||
let t = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)
|
||||
for (; t <= t1; ) {
|
||||
for (; t <= t1;) {
|
||||
ticks.push(t)
|
||||
const m = new Date(t)
|
||||
t = Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1)
|
||||
|
||||
@@ -92,7 +92,7 @@ export function calcReadStats(visits) {
|
||||
totalVisitSeconds += secs.reduce((a, b) => a + b, 0)
|
||||
for (const [path, s] of Object.entries(v.read || {})) {
|
||||
if (s >= MIN_READ_SECONDS) {
|
||||
;(perArticle[path] || (perArticle[path] = [])).push(s)
|
||||
; (perArticle[path] || (perArticle[path] = [])).push(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,8 @@ export function formatWhen(ts, now = Date.now()) {
|
||||
if (adiff <= 86400000) {
|
||||
return formatter
|
||||
.format(Math.round(diff / 3600000), 'hour')
|
||||
.replace('hours', 'h')
|
||||
.replace('hour', 'h')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 604800000) {
|
||||
@@ -238,6 +240,14 @@ export function formatWhenIso(ts) {
|
||||
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact read time for tooltips: "50s" under a minute, "1m23s" otherwise.
|
||||
*/
|
||||
export function formatReadTime(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
return `${Math.floor(seconds / 60)}m${seconds % 60}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact visitor counts: plain below 1k, then 1.2k / 10k / 1.2M.
|
||||
* Truncated, not rounded.
|
||||
@@ -357,13 +367,16 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
||||
const start = new Date(c.start).getTime()
|
||||
if (start > g.lastStart) g.lastStart = start
|
||||
if (c.entry?.startsWith('/')) {
|
||||
g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1)
|
||||
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
|
||||
existing.count += 1
|
||||
if (c.status != null) existing.status = c.status
|
||||
g.pages.set(c.entry, existing)
|
||||
}
|
||||
groups.set(c.client, g)
|
||||
}
|
||||
const totalHits = (g) => {
|
||||
let n = 0
|
||||
for (const c of g.pages.values()) n += c
|
||||
for (const p of g.pages.values()) n += p.count
|
||||
return n
|
||||
}
|
||||
return [...groups.values()]
|
||||
@@ -378,8 +391,8 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([path, count]) => ({ ...stepOf(path, titles), count })),
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
|
||||
ip: client.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||
isHost,
|
||||
@@ -497,8 +510,17 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||
const client = (clients || {})[v.client] || {}
|
||||
const read = v.read || {}
|
||||
const statuses = v.statuses || {}
|
||||
const trail = [v.entry, ...(v.trail || [])]
|
||||
.map((p) => stepOf(p, titles))
|
||||
.map((p) => {
|
||||
const step = stepOf(p, titles)
|
||||
if (step) {
|
||||
if (read[p]) step.readSeconds = read[p]
|
||||
if (statuses[p]) step.status = statuses[p]
|
||||
}
|
||||
return step
|
||||
})
|
||||
.filter(Boolean)
|
||||
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
||||
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
||||
|
||||
@@ -26,6 +26,15 @@ export function mondayUTC(t) {
|
||||
return (d - ((d + 3) % 7)) * DAY
|
||||
}
|
||||
|
||||
/** ISO 8601 week number of the week containing t (via its Thursday). */
|
||||
export function isoWeek(t) {
|
||||
const d = new Date(t)
|
||||
d.setUTCHours(0, 0, 0, 0)
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7))
|
||||
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
|
||||
return Math.ceil(((d - yearStart) / DAY + 1) / 7)
|
||||
}
|
||||
|
||||
/** Parse sparse timestamp buckets into a { epochMs: count } map. */
|
||||
export function rawTimes(buckets) {
|
||||
const raw = {}
|
||||
@@ -44,7 +53,9 @@ export function sumRange(raw, t0, t1) {
|
||||
/**
|
||||
* One series per overlaid week: [this week, 1 week ago, ...], at native
|
||||
* 5-minute resolution, up to 8 weeks back (and only weeks that overlap the
|
||||
* recorded data at all). The current week is truncated at the current bucket
|
||||
* recorded data at all). Each older week's timestamps are shifted forward
|
||||
* onto the current week's axis so all curves overlay inside the plot.
|
||||
* The current week is truncated at the current bucket
|
||||
* — no fake zeroes drawn for the future. Counts are rates per hour
|
||||
* (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h".
|
||||
* The coarser ranges use per-day rates instead (unitMinutes = 24*60).
|
||||
@@ -67,12 +78,13 @@ export function weeklySeries(buckets) {
|
||||
: start + WEEK
|
||||
const points = []
|
||||
for (let t = start; t < end; t += MIN5) {
|
||||
points.push({ t, count: raw[t] || 0 })
|
||||
points.push({ t: t + back * WEEK, count: raw[t] || 0 })
|
||||
}
|
||||
out.push({
|
||||
points,
|
||||
label: back === 0 ? 'this week' : `${back}w ago`,
|
||||
label: `Week ${isoWeek(start)}`,
|
||||
opacity: Math.max(0.15, 1 - back * 0.25),
|
||||
past: back > 0,
|
||||
area: back === 0,
|
||||
})
|
||||
}
|
||||
@@ -92,27 +104,38 @@ export function weeklySeries(buckets) {
|
||||
* to per-day rates (the unit the month+ charts are read in).
|
||||
* Ranges without a fixed span use the full data reach, but never less than
|
||||
* their configured minSpan so the chart keeps a readable minimum x scale.
|
||||
* t0 is aligned to the UTC day so the x labels cover the whole range;
|
||||
* t1 is now, so the scale never extends into the future. The bucket size
|
||||
* follows the resulting window (6h up to 31 days, daily beyond), so ranges
|
||||
* covering the same window — "all" at its 30-day minimum vs "month" —
|
||||
* render the identical curve.
|
||||
*/
|
||||
export function rollingSeries(buckets, rangeKey) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
|
||||
const earliest = Math.floor(Math.min(...times) / bucket) * bucket
|
||||
const t0 = span != null
|
||||
const t1 = Date.now()
|
||||
const earliest = Math.min(...times)
|
||||
const t0 = Math.floor((span != null
|
||||
? t1 - span
|
||||
: Math.min(earliest, t1 - minSpan)
|
||||
: Math.min(earliest, t1 - minSpan)) / DAY) * DAY
|
||||
// The bucket follows the actual window length, not the range key: when
|
||||
// "all" is capped to its 30-day minimum it covers the very window "month"
|
||||
// does, and daily bins would draw a different curve over the same data
|
||||
// (coarser edge detection, points a day apart plotted at bin starts, the
|
||||
// last point stuck at today's midnight instead of reaching now).
|
||||
const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket
|
||||
const points = []
|
||||
for (let t = t0; t < t1; t += bucket) {
|
||||
points.push({ t, count: sumRange(raw, t, t + bucket) })
|
||||
for (let t = t0; t < t1; t += bucketMs) {
|
||||
points.push({ t, count: sumRange(raw, t, t + bucketMs) })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: '', opacity: 1, area: true }],
|
||||
t0,
|
||||
t1,
|
||||
rate: DAY / bucket,
|
||||
binMinutes: bucket / 60e3,
|
||||
rate: DAY / bucketMs,
|
||||
binMinutes: bucketMs / 60e3,
|
||||
unitMinutes: 24 * 60,
|
||||
unit: 'day',
|
||||
}
|
||||
@@ -152,19 +175,64 @@ export function makeSeries(buckets, rangeKey) {
|
||||
|
||||
/**
|
||||
* Absolute UTC time window for a given range key. Used to filter visits,
|
||||
* transitions and views to the same period the charts are showing.
|
||||
* transitions and views for the non-chart stats on the analytics page.
|
||||
* Every bounded range is a rolling span ending at now; the charts instead
|
||||
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
|
||||
* to UTC day boundaries, so their x windows differ from the stats range
|
||||
* on purpose.
|
||||
* Returns { t0, t1 } where null means unbounded.
|
||||
*/
|
||||
export function rangeWindow(rangeKey) {
|
||||
const now = Date.now()
|
||||
if (rangeKey === 'week') {
|
||||
const start = mondayUTC(now)
|
||||
return { t0: start, t1: start + WEEK }
|
||||
}
|
||||
if (rangeKey === 'all') {
|
||||
return { t0: null, t1: null }
|
||||
}
|
||||
const { span, bucket } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
||||
return { t0: t1 - span, t1 }
|
||||
const span = rangeKey === 'week' ? WEEK : RANGES[rangeKey].span
|
||||
return { t0: now - span, t1: now }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the bucketed transition matrix (from -> to -> bucket ISO -> count)
|
||||
* into a plain from -> to -> count matrix for the window [t0, t1).
|
||||
*/
|
||||
export function filterTransitionsByRange(transitions, t0, t1) {
|
||||
const out = {}
|
||||
for (const [fr, tos] of Object.entries(transitions || {})) {
|
||||
for (const [to, buckets] of Object.entries(tos)) {
|
||||
let n = 0
|
||||
for (const [k, c] of Object.entries(buckets)) {
|
||||
const t = Date.parse(k)
|
||||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) n += c
|
||||
}
|
||||
if (n) {
|
||||
out[fr] = out[fr] || {}
|
||||
out[fr][to] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Keep only the 5-minute view buckets that fall inside [t0, t1). */
|
||||
export function filterViewsByRange(views, t0, t1) {
|
||||
const filtered = {}
|
||||
for (const [path, buckets] of Object.entries(views || {})) {
|
||||
const out = {}
|
||||
for (const [k, c] of Object.entries(buckets)) {
|
||||
const t = Date.parse(k)
|
||||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out[k] = c
|
||||
}
|
||||
if (Object.keys(out).length) filtered[path] = out
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
/** Keep only records whose start time falls inside [t0, t1). */
|
||||
export function filterRecordsByRange(records, t0, t1) {
|
||||
const out = []
|
||||
for (const r of records || []) {
|
||||
const t = Date.parse(r.start)
|
||||
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out.push(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Links never underline — including SVG link text, which the UA stylesheet
|
||||
underlines by default. */
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Native-scrollbar fallback styling (JS off or before pagerite.js runs):
|
||||
@@ -186,6 +192,10 @@ body {
|
||||
/* One line always: pagerite.js shrinks the font size to fit instead of
|
||||
wrapping (the themed size is the maximum). */
|
||||
white-space: nowrap;
|
||||
/* Shrink-wrap to the text: as a flex child of the column-direction
|
||||
#banner it would otherwise stretch full-width, making the empty banner
|
||||
area beside the text a link to the front page. */
|
||||
align-self: flex-start;
|
||||
margin: auto 1.25rem 0;
|
||||
padding-top: 1.5rem;
|
||||
color: var(--text);
|
||||
@@ -791,8 +801,11 @@ figure:has(img[width]) {
|
||||
|
||||
The rules below re-anchor the bleed for the layouts where the article
|
||||
is not viewport-centered; each just overrides width/margin-inline, and
|
||||
later rules win at equal specificity. */
|
||||
figure:has(.wide) {
|
||||
later rules win at equal specificity. The analytics dashboard uses the
|
||||
same breakout directly on its container (div.wide — it is the page's
|
||||
whole content, not a figure). */
|
||||
figure:has(.wide),
|
||||
div.wide {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin-inline: calc(50% - 50vw);
|
||||
@@ -898,6 +911,30 @@ article h2 {
|
||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
||||
viewport bleed. */
|
||||
@media (max-width: 48rem) {
|
||||
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
||||
em-based both in base and in every theme override, so scaling the
|
||||
banner's font-size (nothing else in the banner is em-sized — brand and
|
||||
gaps use rem) reaches the nav through all themes with a single rule.
|
||||
2.6vw crosses 1rem at ≈38.5rem, so only genuinely narrow viewports
|
||||
shrink. */
|
||||
#banner {
|
||||
font-size: clamp(0.65rem, 2.6vw, 1rem);
|
||||
}
|
||||
|
||||
/* Tighter margins/padding/gaps: the 1.25rem side gutter is wasted space
|
||||
on a phone. */
|
||||
#brand {
|
||||
margin-inline: 0.6rem;
|
||||
}
|
||||
|
||||
#nav {
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
|
||||
#nav ul {
|
||||
gap: 0.15rem 0.9rem;
|
||||
}
|
||||
|
||||
#content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -909,13 +946,19 @@ article h2 {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
/* Smaller type: the horizontal link strip fits roughly a third more
|
||||
items per line. The nested-list gaps below are em-based and shrink
|
||||
along. */
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#sidebar ul {
|
||||
/* Only the main level becomes a horizontal wrapping strip; submenus stay
|
||||
vertical blocks attached under their parent item. */
|
||||
#sidebar > ul {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.2rem;
|
||||
gap: 0.3rem 0.75rem;
|
||||
}
|
||||
|
||||
figure:has(.right),
|
||||
|
||||
+151
-41
@@ -55,6 +55,19 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
let isAdmin = false;
|
||||
let editorMeta = null;
|
||||
|
||||
// Asset URLs for the on-demand bundles. Dev renders them as
|
||||
// pagerite:* meta tags (Vite dev-server URLs); production inlines all
|
||||
// page assets and carries the on-demand URLs in a JSON script instead.
|
||||
const assets = (() => {
|
||||
const el = document.getElementById("pagerite-assets");
|
||||
if (el) return JSON.parse(el.textContent);
|
||||
const map = {};
|
||||
for (const m of document.querySelectorAll('meta[name^="pagerite:"]')) {
|
||||
map[m.name] = m.content;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
function makePen(mode) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
@@ -124,11 +137,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}
|
||||
|
||||
async function setupAuth() {
|
||||
const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content;
|
||||
const src = assets["pagerite:editor-src"];
|
||||
if (!src) { pingEntryOnce(); return; }
|
||||
editorMeta = {
|
||||
src,
|
||||
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
|
||||
css: assets["pagerite:editor-css"],
|
||||
};
|
||||
|
||||
// Detect whether Paskia SSO is available on this site.
|
||||
@@ -147,6 +160,26 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// No auth proxy / dev.
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
// Teach the backend the site's public origin (used for absolute
|
||||
// social/canonical URLs): unlike request headers, location.origin
|
||||
// reflects the real scheme and host even behind reverse proxies.
|
||||
fetch("/_api/site-url", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: location.origin }),
|
||||
}).catch(() => {});
|
||||
// Warm the cache with the editor bundle: the hashed asset is
|
||||
// immutable, so preloading costs nothing and the pens then open
|
||||
// instantly. The analytics page has no editor.
|
||||
if (currentPath !== "/_a" && !import.meta.env.DEV) {
|
||||
const preload = document.createElement("link");
|
||||
preload.rel = "modulepreload";
|
||||
preload.href = src;
|
||||
document.head.append(preload);
|
||||
}
|
||||
}
|
||||
|
||||
renderAuthUi();
|
||||
pingEntryOnce();
|
||||
}
|
||||
@@ -230,6 +263,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// buttons; re-add whichever auth UI is appropriate for this session.
|
||||
renderAuthUi();
|
||||
placeEditPen();
|
||||
fitNav();
|
||||
// Multi-column layout only when there is enough text to justify it.
|
||||
// Split the body into columned segments: h1s, h2s and wide figures are
|
||||
// full-width separators and never go inside columns.
|
||||
@@ -285,14 +319,17 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// internal link is fetched exactly once, and navigation is served from
|
||||
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
|
||||
// announce their fresh copies via pagerite:page-fetched, keeping the
|
||||
// cache in sync after edits.
|
||||
// cache in sync after edits. The current page is NOT preloaded: we just
|
||||
// received it as the document (re-fetching would be redundant, and
|
||||
// browser heuristics may send it without if-none-match, defeating the
|
||||
// conditional request); it enters the cache when navigated to.
|
||||
const pageCache = new Map(); // pathname -> HTML text
|
||||
addEventListener("pagerite:page-fetched", (ev) => {
|
||||
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
|
||||
});
|
||||
|
||||
function preload() {
|
||||
const urls = new Set([location.pathname]);
|
||||
const urls = new Set();
|
||||
for (const a of document.querySelectorAll(
|
||||
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
|
||||
)) {
|
||||
@@ -300,7 +337,10 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
}
|
||||
for (const url of urls) {
|
||||
if (pageCache.has(url)) continue;
|
||||
fetch(url)
|
||||
// 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).
|
||||
fetch(url, { headers: { "x-pagerite-preload": "1" } })
|
||||
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
|
||||
? r.text() : ""))
|
||||
.then((html) => { if (html) pageCache.set(url, html); })
|
||||
@@ -345,9 +385,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// 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
|
||||
// editor is open (body.editing — admin noise, not visits), and the
|
||||
// analytics page itself (/_a), even though fetch-navigation treats it
|
||||
// like a normal article.
|
||||
// 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.
|
||||
// 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
|
||||
@@ -355,7 +397,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// See docs/analytics.md.
|
||||
function ping(to, fr = currentPath, read = 0) {
|
||||
if (document.body.classList.contains("editing")) return;
|
||||
if ((to && to === "/_a") || fr === "/_a") return;
|
||||
if (to && to === "/_a") return;
|
||||
const hide = ssoAvailable && isAdmin ? 1 : 0;
|
||||
const body = JSON.stringify({
|
||||
fr, to, hide,
|
||||
@@ -455,29 +497,39 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
// --- Analytics page mount/unmount --------------------------------------
|
||||
// The analytics page is a normal page whose body is rendered by the server
|
||||
// but whose content is a Vue app. We load the entry module on demand so the
|
||||
// analytics bundle is only fetched when visiting /_a, and unmount the app
|
||||
// before swapping away so Vue teardown runs cleanly.
|
||||
let analyticsUnmount = null;
|
||||
|
||||
// but whose content is a Vue app. In dev the entry module is imported from
|
||||
// the Vite dev server on demand; in production it is inlined into the /_a
|
||||
// page as script#pagerite-js-analytics, which a fetch-navigation swap does
|
||||
// not execute — re-create the element so the fresh module auto-mounts on
|
||||
// #analytics-app (see analytics-main.js). The module exposes its unmount
|
||||
// as window.__pageriteAnalyticsUnmount.
|
||||
function teardownAnalytics() {
|
||||
analyticsUnmount?.();
|
||||
analyticsUnmount = null;
|
||||
// Remove even the server-rendered script element so a later return to
|
||||
// /_a re-mounts from a fresh copy (the module has torn itself down).
|
||||
document.getElementById("pagerite-js-analytics")?.remove();
|
||||
window.__pageriteAnalyticsUnmount?.();
|
||||
window.__pageriteAnalyticsUnmount = null;
|
||||
}
|
||||
|
||||
async function mountAnalytics(doc) {
|
||||
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content;
|
||||
if (!src) {
|
||||
teardownAnalytics();
|
||||
if (!doc.getElementById("analytics-app")) return;
|
||||
// Already mounted: on a full /_a load the inline script has run.
|
||||
if (document.getElementById("pagerite-js-analytics")) return;
|
||||
const inline = doc.getElementById("pagerite-js-analytics");
|
||||
if (inline) {
|
||||
const s = document.createElement("script");
|
||||
for (const a of inline.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = inline.textContent;
|
||||
document.body.append(s);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ src);
|
||||
// Dev: the cached module auto-mounts only on its first evaluation,
|
||||
// so call mount() explicitly for repeat visits (it no-ops when the
|
||||
// app is already up).
|
||||
const mod = await import(/* @vite-ignore */ assets["pagerite:analytics-src"]);
|
||||
const container = document.getElementById("analytics-app");
|
||||
if (container) {
|
||||
mod.mount(container);
|
||||
analyticsUnmount = mod.unmount;
|
||||
}
|
||||
if (container) mod.mount(container);
|
||||
} catch (e) {
|
||||
console.error("analytics mount failed:", e);
|
||||
}
|
||||
@@ -504,8 +556,8 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
// Reflect any redirect the server issued.
|
||||
if (res.redirected) finalUrl = res.url;
|
||||
const html = await res.text();
|
||||
// Populate the cache too, or the post-swap preload (which includes
|
||||
// location.pathname) would fetch the very page we just loaded again.
|
||||
// Populate the cache too, so returning here (back/forward, or a
|
||||
// self-link in the nav) is served from memory.
|
||||
pageCache.set(new URL(finalUrl, location.href).pathname, html);
|
||||
doc = new DOMParser().parseFromString(html, "text/html");
|
||||
} catch {
|
||||
@@ -534,27 +586,47 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
} else if (oldSidebar) {
|
||||
oldSidebar.remove();
|
||||
}
|
||||
// Site-wide custom CSS lives in <head id="pagerite-user"> and must be
|
||||
// kept in sync across fetch-navigations. It is kept last in <head>:
|
||||
// in dev Vite injects the base stylesheet after the server-rendered
|
||||
// tag, and equal-specificity :root rules are decided by order.
|
||||
const oldUserStyle = document.getElementById("pagerite-user");
|
||||
const newUserStyle = doc.getElementById("pagerite-user");
|
||||
if (oldUserStyle && newUserStyle) {
|
||||
oldUserStyle.textContent = newUserStyle.textContent;
|
||||
document.head.appendChild(oldUserStyle);
|
||||
} else if (newUserStyle) {
|
||||
document.head.appendChild(document.importNode(newUserStyle, true));
|
||||
} else if (oldUserStyle) {
|
||||
oldUserStyle.remove();
|
||||
// Stylesheets live in <head> with stable ids — links in dev, inline
|
||||
// <style> elements in production — and must follow the swap: the
|
||||
// analytics sheet exists on /_a only, and theme/banner/custom CSS
|
||||
// may have changed since this page was loaded. Diff by id, keeping
|
||||
// the fresh document's order; unchanged sheets keep their elements
|
||||
// so their @keyframes are never torn down. Editor-injected sheets
|
||||
// (data-pagerite, no id) and Vite's dev styles (no id) are left
|
||||
// alone. Mirrors the head sync in swapdoc.js.
|
||||
const sel = 'link[rel="stylesheet"][id], style[id]';
|
||||
const fresh = [...doc.head.querySelectorAll(sel)];
|
||||
const freshIds = new Set(fresh.map((el) => el.id));
|
||||
for (const el of [...document.head.querySelectorAll(sel)]) {
|
||||
if (!freshIds.has(el.id)) el.remove();
|
||||
}
|
||||
let anchor = null;
|
||||
for (const el of fresh) {
|
||||
const cur = document.getElementById(el.id);
|
||||
if (cur && cur.outerHTML === el.outerHTML) {
|
||||
anchor = cur;
|
||||
continue;
|
||||
}
|
||||
const imported = document.importNode(el, true);
|
||||
if (cur) cur.replaceWith(imported);
|
||||
else if (anchor) anchor.after(imported);
|
||||
else {
|
||||
const base = document.getElementById("pagerite-base");
|
||||
if (base) base.after(imported);
|
||||
else document.head.append(imported);
|
||||
}
|
||||
anchor = imported;
|
||||
}
|
||||
// Custom CSS must stay last: equal-specificity :root rules (font
|
||||
// variables) are decided by order, and in dev Vite injects the base
|
||||
// stylesheet after the server-rendered tag.
|
||||
const userStyle = document.getElementById("pagerite-user");
|
||||
if (userStyle) document.head.appendChild(userStyle);
|
||||
document.title = doc.title;
|
||||
// Banners may contain scripts (canvas etc.), content pages may too.
|
||||
runScripts(document.getElementById("page-banner"));
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
// The fetched doc carries the analytics meta; the live document's
|
||||
// <head> is never swapped, so querying it would never find the entry.
|
||||
mountAnalytics(doc);
|
||||
};
|
||||
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
||||
@@ -707,6 +779,44 @@ import "overlayscrollbars/overlayscrollbars.css";
|
||||
fit();
|
||||
}
|
||||
|
||||
// --- Nav condense-to-fit -------------------------------------------------
|
||||
// The top nav stays on one row even on too-narrow screens: first the link
|
||||
// gaps shrink, then the nav's side padding, and only in extreme cases the
|
||||
// font size. #nav is replaced on fetch-navigation swaps, so this re-runs
|
||||
// from applyEffects (fresh elements each time); CSS keeps flex-wrap: wrap
|
||||
// as the no-JS fallback.
|
||||
function fitNav() {
|
||||
const nav = document.getElementById("nav");
|
||||
const ul = nav?.querySelector("ul");
|
||||
if (!ul) return;
|
||||
// Restore the themed defaults before measuring.
|
||||
nav.style.fontSize = "";
|
||||
nav.style.paddingInline = "";
|
||||
ul.style.columnGap = "";
|
||||
ul.style.flexWrap = "nowrap";
|
||||
const overflow = () => ul.scrollWidth - ul.clientWidth;
|
||||
if (overflow() <= 0) return;
|
||||
// 1) shrink the gaps between items (down to a fifth of the themed gap)
|
||||
const gap = parseFloat(getComputedStyle(ul).columnGap) || 0;
|
||||
const joints = Math.max(ul.children.length - 1, 1);
|
||||
if (gap > 0) {
|
||||
ul.style.columnGap = `${Math.max(0.2 * gap, gap - overflow() / joints)}px`;
|
||||
}
|
||||
// 2) shrink the nav's side padding (down to 0.4x)
|
||||
if (overflow() > 0) {
|
||||
const pad = parseFloat(getComputedStyle(nav).paddingInlineStart) || 0;
|
||||
nav.style.paddingInline = `${Math.max(0.4 * pad, pad - overflow() / 2)}px`;
|
||||
}
|
||||
// 3) shrink the font to fit what remains
|
||||
if (overflow() > 0) {
|
||||
const fs = parseFloat(getComputedStyle(nav).fontSize);
|
||||
nav.style.fontSize = `${fs * ul.clientWidth / ul.scrollWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("resize", fitNav);
|
||||
document.fonts?.ready.then(fitNav);
|
||||
|
||||
setupAuth();
|
||||
applyEffects();
|
||||
mountAnalytics(document);
|
||||
|
||||
+26
-19
@@ -59,32 +59,39 @@ function swapRegions(doc) {
|
||||
curUserStyle.remove()
|
||||
}
|
||||
// Theme and other public stylesheets live in <head>, rendered with stable
|
||||
// ids by the backend; sync them positionally so the custom CSS (rendered
|
||||
// last) always keeps winning by order. Diff-based: unchanged sheets keep
|
||||
// their elements, so their @keyframes are never torn down (re-creating
|
||||
// keyframes would replay the editor's slide-in animation).
|
||||
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
|
||||
const freshIds = new Set(freshLinks.map((l) => l.id))
|
||||
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
|
||||
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
|
||||
// ids by the backend (links in dev, inline <style> elements in prod);
|
||||
// sync them positionally so the custom CSS (rendered last) always keeps
|
||||
// winning by order. Diff-based: unchanged sheets keep their elements, so
|
||||
// their @keyframes are never torn down (re-creating keyframes would
|
||||
// replay the editor's slide-in animation).
|
||||
const sel = 'link[rel="stylesheet"][id], style[id]'
|
||||
const freshEls = [...doc.head.querySelectorAll(sel)]
|
||||
const freshIds = new Set(freshEls.map((el) => el.id))
|
||||
for (const el of [...document.head.querySelectorAll(sel)]) {
|
||||
if (!freshIds.has(el.id)) el.remove()
|
||||
}
|
||||
// Insert missing sheets in the fresh document's order, each right after
|
||||
// its predecessor's element. The first sheet rendered is always the base
|
||||
// CSS, so its link doubles as the fallback anchor when nothing matched yet
|
||||
// (e.g. no theme was selected before and the position is otherwise lost).
|
||||
// CSS, so its element doubles as the fallback anchor when nothing matched
|
||||
// yet (e.g. no theme was selected before and the position is otherwise
|
||||
// lost).
|
||||
let anchor = null
|
||||
for (const link of freshLinks) {
|
||||
const cur = link.id && document.getElementById(link.id)
|
||||
if (cur && cur.href === link.href) {
|
||||
for (const el of freshEls) {
|
||||
const cur = el.id && document.getElementById(el.id)
|
||||
if (cur && cur.outerHTML === el.outerHTML) {
|
||||
anchor = cur
|
||||
continue
|
||||
}
|
||||
const el = document.importNode(link, true)
|
||||
// Same id, new URL (theme switch): replace in place, keeping position.
|
||||
if (cur) cur.replaceWith(el)
|
||||
else if (anchor) anchor.after(el)
|
||||
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
|
||||
anchor = el
|
||||
const imported = document.importNode(el, true)
|
||||
// Same id, new content (theme switch): replace in place, keeping position.
|
||||
if (cur) cur.replaceWith(imported)
|
||||
else if (anchor) anchor.after(imported)
|
||||
else {
|
||||
const base = document.getElementById('pagerite-base')
|
||||
if (base) base.after(imported)
|
||||
else document.head.append(imported)
|
||||
}
|
||||
anchor = imported
|
||||
}
|
||||
// The editor keeps its own title while open; only inherit the server title
|
||||
// when navigating outside the editor (e.g. fetch-navigation swaps).
|
||||
|
||||
@@ -7,11 +7,10 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
||||
|
||||
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
|
||||
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
|
||||
// backend's /_ prefix. /_api, /_f, /_themes and the /_a analytics ping are
|
||||
// handled by the fastapi-vue plugin.
|
||||
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
|
||||
// Proxy everything except Vite's own dev-time paths and the backend machinery
|
||||
// to the FastAPI backend in dev. /_api, /_f, /_themes and /_a are handled by
|
||||
// the fastapi-vue plugin, and /@..., /src, /node_modules, /__... stay with Vite.
|
||||
const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
|
||||
+101
-30
@@ -5,7 +5,13 @@ 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
|
||||
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 (bots stay invisible).
|
||||
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
|
||||
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``, which records nothing and removes any
|
||||
visit the session accumulated before logging in. Scanner telltale 404s
|
||||
(dotpaths, *.php) classify the source IP as abuse; its hits — including
|
||||
@@ -103,6 +109,8 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
||||
utm: dict[str, str] = {}
|
||||
#: Active reading time per path (seconds), keyed by path.
|
||||
read: dict[str, int] = {}
|
||||
#: HTTP status of the response when the path was first seen (200 or 404).
|
||||
statuses: dict[str, int] = {}
|
||||
|
||||
|
||||
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||
@@ -119,6 +127,8 @@ class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||
referer: str = ""
|
||||
#: Raw query string of the landing URL (UTM tags can be parsed from it).
|
||||
query: str = ""
|
||||
#: HTTP status of the served response (200 or 404 for content pages).
|
||||
status: int = 200
|
||||
|
||||
|
||||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||||
@@ -239,6 +249,18 @@ def _utm_tags(query: str) -> dict[str, str]:
|
||||
|
||||
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
||||
|
||||
#: UAs of JS-running crawlers, which would register as visitors on their
|
||||
#: ping. Anything calling itself a "bot" or "spider" matches; known crawlers
|
||||
#: without those tokens (GoogleOther) are listed as extra alternates. No
|
||||
#: source verification: a spoofed bot UA just lands in the crawler list, and
|
||||
#: scanners that probe telltale paths are caught by the abuse rules anyway.
|
||||
_BOT_UA = re.compile(r"bot|spider|googleother", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_bot_ua(ua: str) -> bool:
|
||||
"""True when the UA claims a crawler identity (bot or spider)."""
|
||||
return bool(_BOT_UA.search(ua))
|
||||
|
||||
#: Plain-404 count per IP that classifies it as abuse even without a
|
||||
#: telltale path hit.
|
||||
_ABUSE_404_THRESHOLD = 10
|
||||
@@ -294,6 +316,10 @@ class Store:
|
||||
pass # legacy schema / corrupt or unreadable file: start fresh
|
||||
#: client hash -> index of the current visit in data.visits
|
||||
self.sessions: dict[bytes, int] = {}
|
||||
#: visit index -> count events recorded for that visit, so
|
||||
#: ``_remove_visit`` can reverse all of them — not just the ones
|
||||
#: from the visit's creation. In-memory only, like ``sessions``.
|
||||
self._count_log: dict[int, list[tuple]] = {}
|
||||
#: ip -> external https origin of the latest document GET carrying
|
||||
#: one, stashed for the visit the client's initial ping starts.
|
||||
#: Internal or absent referers never touch the table.
|
||||
@@ -306,6 +332,9 @@ class Store:
|
||||
#: Document GETs that have not yet been matched by a ping. 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.
|
||||
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.
|
||||
self.not_found_counts: dict[str, int] = {}
|
||||
@@ -378,35 +407,44 @@ class Store:
|
||||
del table[key]
|
||||
|
||||
def _remove_visit(self, index: int) -> None:
|
||||
"""Delete a visit and reverse the counts its creation recorded.
|
||||
"""Delete a visit and reverse every count it recorded.
|
||||
|
||||
Used when a known visitor turns out to be an admin (hide=1 ping):
|
||||
the session is scrubbed from the stats. Views/transitions logged
|
||||
by later pings inside the visit lack per-event timestamps and are
|
||||
left as-is.
|
||||
the session is scrubbed from the stats. The in-memory
|
||||
``_count_log`` tracks each site-visit/view/transition count the
|
||||
visit produced, so the scrub reverses all of them — including the
|
||||
ones logged by later pings inside the visit.
|
||||
"""
|
||||
visit = self.data.visits[index]
|
||||
bucket = _bucket(visit.start)
|
||||
self._uncount(self.data.site_visits, bucket)
|
||||
views = self.data.views.get(visit.entry)
|
||||
if views is not None:
|
||||
self._uncount(views, bucket)
|
||||
if not views:
|
||||
del self.data.views[visit.entry]
|
||||
fr_map = self.data.transitions.get(visit.referer or "(direct)")
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(visit.entry)
|
||||
if buckets is not None:
|
||||
self._uncount(buckets, bucket)
|
||||
if not buckets:
|
||||
del fr_map[visit.entry]
|
||||
if not fr_map:
|
||||
del self.data.transitions[visit.referer or "(direct)"]
|
||||
for event in self._count_log.pop(index, ()):
|
||||
kind = event[0]
|
||||
if kind == "site":
|
||||
self._uncount(self.data.site_visits, event[1])
|
||||
elif kind == "view":
|
||||
views = self.data.views.get(event[1])
|
||||
if views is not None:
|
||||
self._uncount(views, event[2])
|
||||
if not views:
|
||||
del self.data.views[event[1]]
|
||||
else: # transition
|
||||
_, fr, to, bucket = event
|
||||
fr_map = self.data.transitions.get(fr)
|
||||
if fr_map is not None:
|
||||
buckets = fr_map.get(to)
|
||||
if buckets is not None:
|
||||
self._uncount(buckets, bucket)
|
||||
if not buckets:
|
||||
del fr_map[to]
|
||||
if not fr_map:
|
||||
del self.data.transitions[fr]
|
||||
del self.data.visits[index]
|
||||
# Sessions store list indices; shift the ones past the removed visit.
|
||||
# Sessions and count logs store list indices; shift the ones past
|
||||
# the removed visit.
|
||||
for key, i in list(self.sessions.items()):
|
||||
if i > index:
|
||||
self.sessions[key] = i - 1
|
||||
self._count_log = {
|
||||
i - 1 if i > index else i: log for i, log in self._count_log.items()
|
||||
}
|
||||
|
||||
def _client_ip(self, client_hash: bytes) -> str:
|
||||
"""Return the IP stored for ``client_hash``, or "" if missing."""
|
||||
@@ -553,6 +591,7 @@ class Store:
|
||||
referer: str,
|
||||
client_hash: bytes,
|
||||
utm: dict[str, str] | None = None,
|
||||
status: int = 200,
|
||||
) -> Visit:
|
||||
now = datetime.now(UTC)
|
||||
visit = Visit(
|
||||
@@ -562,11 +601,20 @@ class Store:
|
||||
client=client_hash,
|
||||
utm=utm or {},
|
||||
)
|
||||
visit.statuses[entry] = status
|
||||
self.data.visits.append(visit)
|
||||
self.sessions[client_hash] = len(self.data.visits) - 1
|
||||
self._count(self.data.site_visits, _bucket(now))
|
||||
self._count(self.data.views.setdefault(entry, {}), _bucket(now))
|
||||
self._count_transition(referer or "(direct)", entry, now)
|
||||
index = len(self.data.visits) - 1
|
||||
self.sessions[client_hash] = index
|
||||
bucket = _bucket(now)
|
||||
fr = referer or "(direct)"
|
||||
self._count(self.data.site_visits, bucket)
|
||||
self._count(self.data.views.setdefault(entry, {}), bucket)
|
||||
self._count_transition(fr, entry, now)
|
||||
self._count_log[index] = [
|
||||
("site", bucket),
|
||||
("view", entry, bucket),
|
||||
("transition", fr, entry, bucket),
|
||||
]
|
||||
return visit
|
||||
|
||||
def track_entry(
|
||||
@@ -577,6 +625,8 @@ class Store:
|
||||
ua: str,
|
||||
full_path: str,
|
||||
accept_language: str = "",
|
||||
*,
|
||||
status: int = 200,
|
||||
) -> list[bytes]:
|
||||
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
||||
|
||||
@@ -624,8 +674,10 @@ class Store:
|
||||
client=client_hash,
|
||||
referer=self.pending_referers.get(ip, ""),
|
||||
query=query,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
self.pending_statuses.setdefault(client_hash, {})[entry] = status
|
||||
return flushed
|
||||
|
||||
def _add_read(self, client_hash: bytes, path: str, seconds: int) -> None:
|
||||
@@ -664,7 +716,10 @@ class Store:
|
||||
removed from the stats (the admin browsed anonymously before logging
|
||||
in). Nothing new is recorded.
|
||||
|
||||
Pings from IPs classified as abuse are ignored entirely.
|
||||
Pings from IPs classified as abuse, and pings 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.
|
||||
|
||||
Returns the index of the new visit when one is created (or None) and
|
||||
the client hashes of any crawler hits flushed by this call, so callers
|
||||
@@ -676,8 +731,9 @@ class Store:
|
||||
if hide:
|
||||
# Admin ping: cancel pending crawler hits and scrub the session.
|
||||
self.pending_crawlers = [
|
||||
hit for hit in self.pending_crawlers if hit.client == client_hash
|
||||
hit for hit in self.pending_crawlers if hit.client != client_hash
|
||||
]
|
||||
self.pending_statuses.pop(client_hash, None)
|
||||
index = self.sessions.pop(client_hash, None)
|
||||
if index is not None and index < len(self.data.visits):
|
||||
self._remove_visit(index)
|
||||
@@ -685,6 +741,11 @@ class Store:
|
||||
return None, flushed
|
||||
if ip in self.data.abuse_ips:
|
||||
return None, flushed
|
||||
if _is_bot_ua(ua):
|
||||
# A JS-running crawler (Googlebot, GoogleOther, Applebot execute
|
||||
# JS and ping): never a visit. Its pending crawler hits are
|
||||
# kept and flush to ``data.crawlers`` normally.
|
||||
return None, flushed
|
||||
# A real visitor ping cancels any pending crawler hits from this client.
|
||||
self.pending_crawlers = [
|
||||
hit for hit in self.pending_crawlers if hit.client != client_hash
|
||||
@@ -704,6 +765,10 @@ class Store:
|
||||
return None, flushed
|
||||
index = self.sessions.get(client_hash)
|
||||
fr = fr_path or "(direct)"
|
||||
statuses = self.pending_statuses.setdefault(client_hash, {})
|
||||
target_status = statuses.pop(target, None) or 200
|
||||
if not statuses:
|
||||
self.pending_statuses.pop(client_hash, None)
|
||||
if index is None or index >= len(self.data.visits):
|
||||
# No known session: the initial ping of a fresh page load (or
|
||||
# missing data after a server restart) — start a visit.
|
||||
@@ -714,16 +779,22 @@ class Store:
|
||||
self.pending_referers.pop(ip, ""),
|
||||
client_hash,
|
||||
utm=self.pending_utms.pop(ip, {}),
|
||||
status=target_status,
|
||||
)
|
||||
else:
|
||||
visit = self.data.visits[index]
|
||||
now = datetime.now(UTC)
|
||||
bucket = _bucket(now)
|
||||
log = self._count_log.setdefault(index, [])
|
||||
if target.startswith("/"):
|
||||
self._count(self.data.views.setdefault(target, {}), _bucket(now))
|
||||
self._count(self.data.views.setdefault(target, {}), bucket)
|
||||
log.append(("view", target, bucket))
|
||||
self._count_transition(fr, target, now)
|
||||
log.append(("transition", fr, target, bucket))
|
||||
# First-seen only: repeat pages and repeated exits don't append.
|
||||
if visit.entry != target and target not in visit.trail:
|
||||
visit.trail.append(target)
|
||||
visit.statuses[target] = target_status
|
||||
self._save()
|
||||
visit_index = index if index is not None and index < len(self.data.visits) else None
|
||||
return visit_index, flushed
|
||||
|
||||
+207
-17
@@ -27,14 +27,16 @@ from email.utils import format_datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
import blake3
|
||||
import msgspec
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from fastapi_vue import Frontend
|
||||
from kanta import Kanta
|
||||
from pydantic import BaseModel
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from pagerite import analytics, seed, views
|
||||
from pagerite.__main__ import DEVMODE
|
||||
@@ -281,6 +283,79 @@ async def _headers(request: Request, call_next) -> Response:
|
||||
return response
|
||||
|
||||
|
||||
# Dynamic HTML is compressed per request at level 9 (static assets are
|
||||
# already pre-compressed by fastapi-vue's Frontend).
|
||||
_zstd = ZstdCompressor(9)
|
||||
|
||||
|
||||
def _render_html(kind: str, path: str, base_url: str) -> str:
|
||||
"""Render one of the generated pages (see _html_response)."""
|
||||
if kind == "page":
|
||||
return views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, base_url)
|
||||
if kind == "category":
|
||||
return views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html)
|
||||
if kind == "not-found":
|
||||
return views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html)
|
||||
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html)
|
||||
|
||||
|
||||
@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).
|
||||
"""
|
||||
body = _render_html(kind, path, base_url).encode()
|
||||
return _zstd.compress(body) if zstd else body
|
||||
|
||||
|
||||
def _html_response(
|
||||
request: Request,
|
||||
kind: str,
|
||||
path: str,
|
||||
status_code: int = 200,
|
||||
headers: dict | None = None,
|
||||
etag: bool = False,
|
||||
) -> Response:
|
||||
"""Response for a generated page, zstd-compressed when the client
|
||||
accepts it (no gzip fallback).
|
||||
|
||||
Done per handler rather than in middleware so that Frontend's
|
||||
already-compressed asset responses are never touched. The ETag stays
|
||||
identical across encodings (revalidation compares it before
|
||||
compression); ``vary: accept-encoding`` keeps caches from mixing the
|
||||
representations. In dev the cache is bypassed so theme/design edits on
|
||||
disk apply immediately.
|
||||
|
||||
``etag=True`` derives the validator from a blake3 hash of the
|
||||
(uncompressed) body — for pages like /_a that have no Node whose
|
||||
modified timestamp could serve as one — and answers matching
|
||||
if-none-match revalidations with a 304.
|
||||
"""
|
||||
zstd = "zstd" in request.headers.get("accept-encoding", "")
|
||||
# Absolute social/canonical URLs use the learned public origin; until
|
||||
# an admin visit teaches it, fall back to the request's own base URL.
|
||||
base_url = data.site_url or str(request.base_url).rstrip("/")
|
||||
if DEVMODE:
|
||||
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
|
||||
h = dict(headers or {})
|
||||
if zstd:
|
||||
h["vary"] = "accept-encoding"
|
||||
if etag:
|
||||
tag = f'"{blake3.blake3(identity).hexdigest()[:32]}"'
|
||||
h["etag"] = tag
|
||||
if request.headers.get("if-none-match") == tag:
|
||||
return Response(status_code=304, headers=h)
|
||||
if zstd:
|
||||
h["content-encoding"] = "zstd"
|
||||
return Response(body, status_code, h, media_type="text/html")
|
||||
|
||||
|
||||
class PageIn(BaseModel):
|
||||
"""Payload for creating or replacing a page."""
|
||||
|
||||
@@ -434,6 +509,33 @@ async def put_settings(settings: SettingsIn) -> None:
|
||||
data.version += 1
|
||||
|
||||
|
||||
class SiteUrlIn(BaseModel):
|
||||
"""Payload for learning the site's public origin."""
|
||||
|
||||
url: str
|
||||
|
||||
|
||||
@app.post("/_api/site-url", status_code=204)
|
||||
async def learn_site_url(payload: SiteUrlIn) -> None:
|
||||
"""Learn the site's public origin (scheme + host) from an admin browser.
|
||||
|
||||
pagerite.js reports location.origin once an admin session is detected:
|
||||
unlike request Host headers it reflects the real public scheme and host
|
||||
even behind reverse proxies, with zero manual configuration. Stored in
|
||||
the database with a version bump so cached pages re-render with correct
|
||||
absolute social/canonical URLs.
|
||||
"""
|
||||
url = payload.url.rstrip("/")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc or parsed.path:
|
||||
raise HTTPException(400, "not an origin")
|
||||
if url == data.site_url:
|
||||
return
|
||||
with kanta.transaction("learn site url"):
|
||||
data.site_url = url
|
||||
data.version += 1
|
||||
|
||||
|
||||
@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.
|
||||
@@ -712,18 +814,19 @@ class AnalyticsPing(BaseModel):
|
||||
|
||||
|
||||
@app.get("/_a", response_model=None)
|
||||
async def analytics_page(request: Request) -> HTMLResponse:
|
||||
async def analytics_page(request: Request) -> Response:
|
||||
"""Render the analytics viewer as a normal site page at /_a.
|
||||
|
||||
The page itself is public, but the data stream (/_api/ws/analytics) stays
|
||||
admin-gated like the rest of /_api, so only authorized users see the
|
||||
statistics; others get the viewer with a "could not be loaded" message.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
views.render_analytics(
|
||||
data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html
|
||||
),
|
||||
return _html_response(
|
||||
request,
|
||||
"analytics",
|
||||
"",
|
||||
headers={"cache-control": "no-cache"},
|
||||
etag=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -750,12 +853,13 @@ async def analytics_ping(ping: AnalyticsPing, request: Request) -> None:
|
||||
_schedule_client_enrichment(flushed_clients)
|
||||
|
||||
|
||||
def _track_entry(path: str, request: Request) -> list[bytes]:
|
||||
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. (Admin clients ping too, but
|
||||
with hide=1, which scrubs their session instead of recording it.)
|
||||
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 scrubs their session instead of recording it.)
|
||||
|
||||
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
|
||||
@@ -765,6 +869,12 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
|
||||
Returns the client hashes of any pending crawler hits flushed to persistent
|
||||
storage, so callers can schedule async geoip and reverse-DNS enrichment.
|
||||
"""
|
||||
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.
|
||||
# (Forging the header only hides a GET from the crawler stats; the
|
||||
# path-based abuse classification is unaffected.)
|
||||
return []
|
||||
if (
|
||||
path == ""
|
||||
and str(request.url.query) == "from=devserver.py"
|
||||
@@ -780,6 +890,7 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
|
||||
request.headers.get("user-agent", ""),
|
||||
full_path,
|
||||
request.headers.get("accept-language", ""),
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@@ -986,13 +1097,88 @@ async def front_page(request: Request) -> Response:
|
||||
return await show_page(request, "")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap(request: Request) -> Response:
|
||||
"""Dynamically generate a sitemap of all published article pages."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
entries: list[tuple[str, datetime, int]] = []
|
||||
|
||||
def walk(
|
||||
nodes: dict[str, Node], prefix: str, parent_has_content: bool = True
|
||||
) -> None:
|
||||
first_content_slug = next(
|
||||
(
|
||||
slug
|
||||
for slug, node in sorted_nodes(nodes)
|
||||
if node.published and node.content is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
depth = path.count("/") if path else 0
|
||||
if (
|
||||
not parent_has_content
|
||||
and slug == first_content_slug
|
||||
and node.published
|
||||
and node.content is not None
|
||||
and depth > 0
|
||||
):
|
||||
depth -= 1
|
||||
if node.published and node.content is not None:
|
||||
entries.append((path, node.modified, depth))
|
||||
if node.children:
|
||||
walk(node.children, path, node.content is not None)
|
||||
|
||||
walk(data.menu, "")
|
||||
|
||||
def priority(depth: int) -> float:
|
||||
return max(0.1, 1.0 - depth * 0.2)
|
||||
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
]
|
||||
for path, modified, depth in entries:
|
||||
loc = xml_escape(f"{base}/{path}" if path else base)
|
||||
lastmod = (
|
||||
modified.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
lines.append(
|
||||
f" <url>"
|
||||
f"<loc>{loc}</loc>"
|
||||
f"<lastmod>{lastmod}</lastmod>"
|
||||
f"<priority>{priority(depth):.1f}</priority>"
|
||||
f"</url>"
|
||||
)
|
||||
lines.append("</urlset>")
|
||||
|
||||
return Response(
|
||||
"\n".join(lines),
|
||||
media_type="application/xml",
|
||||
headers={"cache-control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/robots.txt")
|
||||
async def robots_txt(request: Request) -> Response:
|
||||
"""Allow all crawling and point crawlers at the sitemap."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
body = f"User-agent: *\nAllow: /\nSitemap: {base}/sitemap.xml\n"
|
||||
return Response(
|
||||
body,
|
||||
media_type="text/plain",
|
||||
headers={"cache-control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
# Vue build asset routes are inserted at this position during load(): the
|
||||
# build mirrors the URL space (/_assets/*, /favicon.ico at the root).
|
||||
frontend.route(app, "/")
|
||||
|
||||
|
||||
@app.get("/{path:path}", response_model=None)
|
||||
async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
async def show_page(request: Request, path: str) -> Response:
|
||||
"""Render the content page at a slug path, or 404.
|
||||
|
||||
A node without content is a category label: its URL renders a
|
||||
@@ -1029,8 +1215,10 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
if _is_trackable_path(path):
|
||||
flushed = _track_entry(path, request)
|
||||
_schedule_client_enrichment(flushed)
|
||||
return HTMLResponse(
|
||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, str(request.base_url).rstrip("/")),
|
||||
return _html_response(
|
||||
request,
|
||||
"page",
|
||||
path,
|
||||
headers={
|
||||
"etag": etag,
|
||||
"last-modified": _http_date(node.modified),
|
||||
@@ -1041,10 +1229,12 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
# Category label without a landing page: placeholder with the pen
|
||||
# to create it (404 — no page here, but the node is real).
|
||||
if _is_trackable_path(path):
|
||||
flushed = _track_entry(path, request)
|
||||
flushed = _track_entry(path, request, status=404)
|
||||
_schedule_client_enrichment(flushed)
|
||||
return HTMLResponse(
|
||||
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
|
||||
return _html_response(
|
||||
request,
|
||||
"category",
|
||||
path,
|
||||
404,
|
||||
headers={
|
||||
"last-modified": _http_date(node.modified),
|
||||
@@ -1065,6 +1255,6 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||
accept_language,
|
||||
)
|
||||
asyncio.create_task(_enrich_client(client_hash))
|
||||
flushed = _track_entry(path, request)
|
||||
flushed = _track_entry(path, request, status=404)
|
||||
_schedule_client_enrichment(flushed)
|
||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html), 404)
|
||||
return _html_response(request, "not-found", path, 404)
|
||||
|
||||
@@ -100,6 +100,12 @@ class Data(msgspec.Struct):
|
||||
#: Favicon: name of a file in `files` (content-addressed), linked as
|
||||
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
|
||||
favicon: str = ""
|
||||
#: Public origin (scheme + host) of the site, learned from admin
|
||||
#: browsers (POST /_api/site-url — location.origin is correct even
|
||||
#: behind reverse proxies, unlike request Host headers). Used for
|
||||
#: absolute social/canonical URLs; empty = fall back to the request's
|
||||
#: own base URL.
|
||||
site_url: str = ""
|
||||
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
||||
#: on startup, then cleared. Never written otherwise.
|
||||
pages: dict[str, Page] = {}
|
||||
|
||||
@@ -27,15 +27,30 @@
|
||||
let my = 0
|
||||
let lastMove = 0
|
||||
|
||||
addEventListener('mousemove', e => {
|
||||
// Mouse and touch tracked with separate listeners (pointer events arrive
|
||||
// too late on some mobile browsers). Passive listeners: a drag on the
|
||||
// banner still scrolls the page — on browsers that stop delivering
|
||||
// touchmove once scrolling takes over, the gaze just follows until then.
|
||||
const track = (x, y) => {
|
||||
const r = c.getBoundingClientRect()
|
||||
|
||||
// Convert viewport coordinates into the canvas' CSS-pixel coordinate
|
||||
// system. This remains correct with browser zoom, CSS transforms, etc.
|
||||
mx = (e.clientX - r.left) * c.clientWidth / r.width
|
||||
my = (e.clientY - r.top) * c.clientHeight / r.height
|
||||
mx = (x - r.left) * c.clientWidth / r.width
|
||||
my = (y - r.top) * c.clientHeight / r.height
|
||||
lastMove = performance.now()
|
||||
})
|
||||
}
|
||||
|
||||
addEventListener('mousemove', e => track(e.clientX, e.clientY))
|
||||
|
||||
const trackTouch = e => {
|
||||
const t = e.touches[0]
|
||||
|
||||
if (t) track(t.clientX, t.clientY)
|
||||
}
|
||||
|
||||
addEventListener('touchstart', trackTouch, { passive: true })
|
||||
addEventListener('touchmove', trackTouch, { passive: true })
|
||||
|
||||
let gx = 0.5
|
||||
let gy = 0.5
|
||||
|
||||
+114
-30
@@ -110,6 +110,35 @@ def _editor_css_url(vite_url: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _inline_asset(url: str) -> str:
|
||||
"""Read a served asset's content for inlining into the page (prod only).
|
||||
|
||||
Handles build assets (``/_assets/...`` from the Vite build) and theme
|
||||
files (``/_themes/{name}/...`` from pagerite/themes/).
|
||||
"""
|
||||
if url.startswith("/_themes/"):
|
||||
name, _, file = url.removeprefix("/_themes/").partition("/")
|
||||
if _valid_name(name) and _valid_name(file):
|
||||
return (THEMES / name / file).read_text()
|
||||
raise ValueError(f"not a theme asset: {url}")
|
||||
return (BUILD / url.lstrip("/")).read_text()
|
||||
|
||||
|
||||
def _inline_script(url: str) -> str:
|
||||
"""Read a built JS bundle for inlining (prod only).
|
||||
|
||||
Inline modules resolve relative imports against the document URL, not
|
||||
the bundle's directory, so rewrite the build's relative chunk
|
||||
specifiers ("./chunk.js") to absolute /_assets/ paths.
|
||||
"""
|
||||
js = _inline_asset(url)
|
||||
for chunk in _manifest().values():
|
||||
file = chunk.get("file", "")
|
||||
if file.endswith(".js"):
|
||||
js = js.replace(f'"./{file.rsplit("/", 1)[-1]}"', f'"/{file}"')
|
||||
return js
|
||||
|
||||
|
||||
def _layout(
|
||||
modules: list[str] = (),
|
||||
stylesheets: list[str] = (),
|
||||
@@ -118,16 +147,21 @@ def _layout(
|
||||
banner_design: str = "",
|
||||
favicon: str = "",
|
||||
social: dict[str, str] | None = None,
|
||||
extra_meta: dict[str, str] | None = None,
|
||||
) -> Template:
|
||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||
"""Page layout template with standard assets and ES-module scripts.
|
||||
|
||||
Stylesheets use ``blocking="render"`` so the browser waits for them before
|
||||
showing the page, avoiding a flash of unstyled content. Order matters and
|
||||
is fixed: base (Vite build, absent in dev where Vite injects it from JS),
|
||||
theme and banner design (backend-served from pagerite/themes/), entry-
|
||||
specific stylesheets (e.g. overlayscrollbars.css), then the user's custom
|
||||
CSS last so it always wins.
|
||||
In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev
|
||||
server and stylesheets use ``blocking="render"`` so the browser waits
|
||||
for them before showing the page, avoiding a flash of unstyled content.
|
||||
In production all page assets are inlined into the document: stylesheets
|
||||
become ``<style>`` elements and module scripts inline ``<script>``s, so
|
||||
a page loads with no asset round trips. The on-demand bundles (editor,
|
||||
analytics) stay external in both modes.
|
||||
|
||||
Order matters and is fixed: base (Vite build, absent in dev where Vite
|
||||
injects it from JS), theme and banner design (from pagerite/themes/),
|
||||
entry-specific stylesheets (e.g. overlayscrollbars.css), then the user's
|
||||
custom CSS last so it always wins.
|
||||
|
||||
In dev, pagerite.js re-appends the backend-rendered theme/design links
|
||||
(and the custom CSS) after the Vite-injected base styles, keeping this
|
||||
@@ -135,9 +169,6 @@ def _layout(
|
||||
|
||||
``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as
|
||||
property attributes, everything else (description, twitter:*) as name.
|
||||
|
||||
``extra_meta`` is emitted as plain ``<meta name="..." content="...">``
|
||||
tags after the editor meta tags; used for page-specific import hints.
|
||||
"""
|
||||
doc = Document(E.Title, lang="en")
|
||||
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
|
||||
@@ -155,33 +186,63 @@ def _layout(
|
||||
# one, browsers fall back to the build's /favicon.ico by convention.
|
||||
if favicon:
|
||||
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
||||
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
|
||||
# itself once it has validated the session (pages render identically
|
||||
# for everyone; editing is gated by the auth proxy in front of /_api).
|
||||
script, editor_css = _editor_assets()
|
||||
doc.meta(name="pagerite:editor-src", content=script[-1])
|
||||
if editor_css:
|
||||
doc.meta(name="pagerite:editor-css", content=editor_css)
|
||||
for key, value in (extra_meta or {}).items():
|
||||
doc.meta(name=key, content=value)
|
||||
# Stylesheet links carry stable ids so the site editor's hot swap can
|
||||
# keep each sheet at its rendered position (see swapRegions).
|
||||
# Asset URLs for the on-demand bundles (editor, analytics) for
|
||||
# pagerite.js, which injects the 🖊️ edit pens itself once it has
|
||||
# validated the session (pages render identically for everyone; editing
|
||||
# is gated by the auth proxy in front of /_api). Dev passes the Vite
|
||||
# dev-server URLs as meta tags (Vite serves the modules and injects
|
||||
# their CSS for hot reloads); production inlines all page assets and
|
||||
# carries the on-demand URLs in one JSON script instead.
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
editor_scripts, editor_css = _editor_assets()
|
||||
config = {
|
||||
"pagerite:editor-src": editor_scripts[-1],
|
||||
"pagerite:analytics-src": _analytics_assets()[0][0],
|
||||
}
|
||||
if editor_css:
|
||||
config["pagerite:editor-css"] = editor_css
|
||||
if vite_url:
|
||||
for key, value in config.items():
|
||||
doc.meta(name=key, content=value)
|
||||
else:
|
||||
# Inert JSON script; URLs never contain "</", but stay safe.
|
||||
doc.script(
|
||||
HTML(json.dumps(config).replace("</", "<\\/")),
|
||||
type="application/json",
|
||||
id="pagerite-assets",
|
||||
)
|
||||
# Stylesheets carry stable ids so the fetch-navigation and the site
|
||||
# editor's hot swap can sync <head> positionally (see swapdoc.js).
|
||||
# Production inlines the CSS as <style> elements: one less round trip
|
||||
# per sheet, and fetch-navigation can carry them across swaps whole.
|
||||
sheets = [
|
||||
("pagerite-base", _base_css_url(vite_url)),
|
||||
("pagerite-theme", _theme_css_url(theme)),
|
||||
("pagerite-banner", _banner_css_url(banner_design)),
|
||||
]
|
||||
for id_, url in sheets:
|
||||
if url:
|
||||
if not url:
|
||||
continue
|
||||
if vite_url:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
|
||||
else:
|
||||
doc.style(HTML(_inline_asset(url)), id=id_)
|
||||
for url in stylesheets:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render")
|
||||
if vite_url:
|
||||
doc.link(rel="stylesheet", href=url, blocking="render")
|
||||
else:
|
||||
# Id from the file stem minus the content hash, so the head
|
||||
# sync can match sheets across pages (e.g. the analytics sheet
|
||||
# exists on /_a only and is added/removed on swaps).
|
||||
stem = url.rsplit("/", 1)[-1].removesuffix(".css")
|
||||
name = re.sub(r"-[A-Za-z0-9_-]{8}$", "", stem)
|
||||
doc.style(HTML(_inline_asset(url)), id=f"pagerite-css-{name}")
|
||||
for src in modules:
|
||||
doc.script(src=src, type="module")
|
||||
if vite_url:
|
||||
doc.script(src=src, type="module")
|
||||
if custom_css.strip():
|
||||
doc.style(custom_css, id="pagerite-user")
|
||||
return Template(
|
||||
body = (
|
||||
doc
|
||||
.header(
|
||||
E.div(E.Banner, id="page-banner"),
|
||||
@@ -194,8 +255,23 @@ def _layout(
|
||||
E.main(E.Main, id="main"),
|
||||
id="content",
|
||||
)
|
||||
.footer(None), # kept empty for now; zero-height (see pagerite.css)
|
||||
.footer(None) # kept empty for now; zero-height (see pagerite.css)
|
||||
)
|
||||
if not vite_url:
|
||||
# Inline the bundles at the end of the body: module scripts are
|
||||
# deferred anyway, and the page can render before they execute.
|
||||
# Escape "</script" so it cannot terminate the element early (only
|
||||
# ever occurs inside string literals, where the backslash escape is
|
||||
# a no-op).
|
||||
for src in modules:
|
||||
js = re.sub(r"</script", r"<\\/script", _inline_script(src), flags=re.I)
|
||||
# Stable id from the file stem minus the content hash; the
|
||||
# analytics page's script (pagerite-js-analytics) is found and
|
||||
# re-created by pagerite.js on fetch-navigations to /_a.
|
||||
stem = src.rsplit("/", 1)[-1].removesuffix(".js")
|
||||
name = re.sub(r"-[A-Za-z0-9_-]{8}$", "", stem)
|
||||
body.script(HTML(js), type="module", id=f"pagerite-js-{name}")
|
||||
return Template(body)
|
||||
|
||||
|
||||
def _brand_link(brand: str, brand_html: str = "") -> HTML:
|
||||
@@ -682,14 +758,23 @@ def render_analytics(
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
) -> str:
|
||||
"""Render the analytics viewer as a normal page at /_a."""
|
||||
"""Render the analytics viewer as a normal page at /_a.
|
||||
|
||||
The analytics entry is inlined into this page only (prod) or loaded
|
||||
from the Vite dev server (dev); its stylesheet rides along in <head>
|
||||
so fetch-navigations can sync it into the live document. The initial
|
||||
range is not rendered in: the client takes it from the URL hash or
|
||||
derives it from the analytics data itself.
|
||||
"""
|
||||
page_scripts, page_stylesheets = _page_assets()
|
||||
analytics_scripts, analytics_stylesheets = _analytics_assets()
|
||||
scripts = page_scripts + analytics_scripts
|
||||
stylesheets = page_stylesheets + analytics_stylesheets
|
||||
doc = E.article
|
||||
with doc:
|
||||
doc.div(id="analytics-app")
|
||||
# .wide: the dashboard breaks out of the article column to the full
|
||||
# viewport width, like wide figures (see the .wide rules).
|
||||
doc.div(id="analytics-app", class_="wide")
|
||||
return str(
|
||||
_layout(
|
||||
scripts,
|
||||
@@ -698,7 +783,6 @@ def render_analytics(
|
||||
theme,
|
||||
banner_design(menu, "_a", theme),
|
||||
favicon,
|
||||
extra_meta={"pagerite:analytics-src": analytics_scripts[0]},
|
||||
)(
|
||||
Title=f"Analytics – {brand}" if brand else "Analytics",
|
||||
Brand=_brand_link(brand, brand_html),
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies = [
|
||||
"mdit-py-plugins>=0.6.1",
|
||||
"pygments>=2.20.0",
|
||||
"ua-parser>=1.0.2",
|
||||
"zstandard>=0.25.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
Reference in New Issue
Block a user