Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2ae104d7 | ||
|
|
b7fc543a83 | ||
|
|
b868033ddc | ||
|
|
8aad64cced | ||
|
|
319163ee7e | ||
|
|
87b16b7144 | ||
|
|
20ae6501f2 | ||
|
|
a16fe88114 | ||
|
|
c77598adc7 |
+18
-4
@@ -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
|
- **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
|
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
|
visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a
|
||||||
refresh neither counts a second view nor logs a self-transition. The GET
|
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
|
handler stashes a cross-origin https `Referer` (origin part only) and any
|
||||||
@@ -74,8 +81,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
|
removing older versions after an update; without the flag only an existing
|
||||||
file is used.
|
file is used.
|
||||||
- **Crawler hits**: every document GET is queued in RAM as a pending crawler
|
- **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
|
hit — except idle-time link preloads from pagerite.js, which carry an
|
||||||
discarded; otherwise it is written to `crawlers`. Crawlers do not count as
|
`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
|
visits or views. The `Accept-Language` header is stored on the shared
|
||||||
`Client` immediately; reverse-DNS host names and DB-IP geoip
|
`Client` immediately; reverse-DNS host names and DB-IP geoip
|
||||||
country/city are filled in asynchronously, just like for real visits. In
|
country/city are filled in asynchronously, just like for real visits. In
|
||||||
@@ -197,7 +209,9 @@ 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
|
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
|
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 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`
|
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
|
||||||
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
||||||
|
|||||||
+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.
|
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`
|
## `data.py`
|
||||||
|
|
||||||
msgspec Structs for the kanta database. See `docs/content-model.md` for the full data model.
|
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.
|
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).
|
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` 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
|
## 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`.
|
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.
|
`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`
|
## `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/`
|
## `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.
|
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
|
## Database file
|
||||||
|
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ The banner artwork has scroll parallax: pagerite.js sets the `--pry` scroll para
|
|||||||
|
|
||||||
## Stylesheet order
|
## 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)`.
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ import VisitorCell from './VisitorCell.vue'
|
|||||||
import TransitionGraph from './TransitionGraph.vue'
|
import TransitionGraph from './TransitionGraph.vue'
|
||||||
import VisitorCharts from './VisitorCharts.vue'
|
import VisitorCharts from './VisitorCharts.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
initialRange: { type: String, default: 'week' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const ABUSE_MAX_LINES = 5
|
const ABUSE_MAX_LINES = 5
|
||||||
|
|
||||||
const data = ref(null)
|
const data = ref(null)
|
||||||
@@ -35,6 +31,13 @@ let ws = null
|
|||||||
let reconnectTimeout = null
|
let reconnectTimeout = null
|
||||||
let timeInterval = 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() {
|
function connectAnalytics() {
|
||||||
if (ws) return
|
if (ws) return
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
@@ -43,6 +46,15 @@ function connectAnalytics() {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
data.value = JSON.parse(event.data)
|
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 {
|
} catch {
|
||||||
error.value = 'analytics data could not be loaded'
|
error.value = 'analytics data could not be loaded'
|
||||||
}
|
}
|
||||||
@@ -82,8 +94,6 @@ const visits = computed(() => data.value?.visits || [])
|
|||||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||||
const readStats = computed(() => calcReadStats(visits.value))
|
const readStats = computed(() => calcReadStats(visits.value))
|
||||||
|
|
||||||
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
|
||||||
|
|
||||||
// Keep the URL shareable when the range changes.
|
// Keep the URL shareable when the range changes.
|
||||||
watch(range, (r) => {
|
watch(range, (r) => {
|
||||||
const url = new URL(location.href)
|
const url = new URL(location.href)
|
||||||
@@ -251,9 +261,10 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
}
|
}
|
||||||
|
|
||||||
.analytics-panel {
|
.analytics-panel {
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
width: min(60rem, 96vw);
|
width: 100%;
|
||||||
padding: 1.5rem 2rem 4rem;
|
/* Same 1.25rem side spacing as main's article padding. */
|
||||||
|
padding: 1.5rem 1.25rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-panel header {
|
.analytics-panel header {
|
||||||
@@ -276,7 +287,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
.ranges button {
|
.ranges button {
|
||||||
padding: 0.2rem 0.7rem;
|
padding: 0.2rem 0.7rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -334,7 +345,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
.visit-table {
|
.visit-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.82rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +367,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
|||||||
}
|
}
|
||||||
|
|
||||||
.visit-table .last-seen {
|
.visit-table .last-seen {
|
||||||
width: 5rem;
|
width: 6rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
+29
-16
@@ -242,30 +242,43 @@ async function saveSettings(opts = {}) {
|
|||||||
async function onThemeChange() {
|
async function onThemeChange() {
|
||||||
await saveSettings()
|
await saveSettings()
|
||||||
// Theme CSS is backend-served at /_themes/{theme}/theme.css in both dev
|
// 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
|
// and prod, but rendered differently: a <link> in dev, an inline <style>
|
||||||
// banner design and the page's stylesheet links may change with it).
|
// in prod. Swap it in place, then re-render (the theme's default banner
|
||||||
let link = document.getElementById('pagerite-theme')
|
// 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) {
|
if (theme.value) {
|
||||||
const href = `/_themes/${theme.value}/theme.css`
|
if (el?.tagName === 'STYLE') {
|
||||||
if (link) {
|
el.textContent = await (await fetch(url)).text()
|
||||||
link.href = href
|
} else if (el) {
|
||||||
} else {
|
el.href = url
|
||||||
|
} else if (import.meta.env.DEV) {
|
||||||
// Re-create after "none": keep base < theme < design < custom CSS.
|
// 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
|
// Vite-injected <style>), so anchor to the next sheet instead of
|
||||||
// prepending before the base styles.
|
// prepending before the base styles.
|
||||||
link = document.createElement('link')
|
el = document.createElement('link')
|
||||||
link.rel = 'stylesheet'
|
el.rel = 'stylesheet'
|
||||||
link.id = 'pagerite-theme'
|
el.id = 'pagerite-theme'
|
||||||
link.href = href
|
el.href = url
|
||||||
const before = document.getElementById('pagerite-base')?.nextSibling
|
const before = document.getElementById('pagerite-base')?.nextSibling
|
||||||
?? document.getElementById('pagerite-banner')
|
?? document.getElementById('pagerite-banner')
|
||||||
?? document.getElementById('pagerite-user')
|
?? document.getElementById('pagerite-user')
|
||||||
if (before) before.before(link)
|
if (before) before.before(el)
|
||||||
else document.head.append(link)
|
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) {
|
} else if (el) {
|
||||||
link.remove()
|
el.remove()
|
||||||
}
|
}
|
||||||
loadPlain(path.value)
|
loadPlain(path.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
* count), so the graph sums the buckets falling inside the selected
|
* count), so the graph sums the buckets falling inside the selected
|
||||||
* range, exactly like the charts and per-page views do.
|
* range, exactly like the charts and per-page views do.
|
||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||||
import { rangeWindow, WEEK } from './analytics/time.js'
|
import { rangeWindow, WEEK } from './analytics/time.js'
|
||||||
import { formatCount } from './analytics/format.js'
|
import { formatCount } from './analytics/format.js'
|
||||||
import {
|
import {
|
||||||
TNODE_R,
|
TNODE_W,
|
||||||
|
TNODE_H,
|
||||||
BEAD_R,
|
BEAD_R,
|
||||||
BEAD_SPEED,
|
BEAD_SPEED,
|
||||||
buildTransitionGraph,
|
buildTransitionGraph,
|
||||||
@@ -121,16 +122,42 @@ const startBeads = (flows) => {
|
|||||||
|
|
||||||
watch(() => graph.value?.flows, startBeads, { immediate: true })
|
watch(() => graph.value?.flows, startBeads, { immediate: true })
|
||||||
onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||||
|
|
||||||
|
// Text in the graph must render at a constant screen size regardless of
|
||||||
|
// how far the enlarged graph's viewBox is scaled down to fit the panel:
|
||||||
|
// measure the unit→pixel ratio and expose it as --u on the svg, which the
|
||||||
|
// font-size rules divide by. Falls back to 1 (raw units) until measured.
|
||||||
|
const svgEl = ref(null)
|
||||||
|
const pxPerUnit = ref(1)
|
||||||
|
let resizeObs = null
|
||||||
|
|
||||||
|
function updateScale() {
|
||||||
|
const el = svgEl.value
|
||||||
|
if (el && el.viewBox.baseVal.width) {
|
||||||
|
pxPerUnit.value = el.clientWidth / el.viewBox.baseVal.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resizeObs = new ResizeObserver(updateScale)
|
||||||
|
})
|
||||||
|
watch(svgEl, (el) => {
|
||||||
|
resizeObs?.disconnect()
|
||||||
|
if (el) resizeObs?.observe(el)
|
||||||
|
})
|
||||||
|
watch(() => graph.value?.bounds, updateScale)
|
||||||
|
onBeforeUnmount(() => resizeObs?.disconnect())
|
||||||
|
|
||||||
|
// Font size (px) that fits a label inside the pill width at the current
|
||||||
|
// zoom: ~0.52 em average glyph width, 12 px padding per side, capped.
|
||||||
|
const fitPx = (label) =>
|
||||||
|
Math.min(15, (TNODE_W * pxPerUnit.value - 24) / (0.52 * Math.max(label.length, 1)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="graph">
|
<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 ref="svgEl" class="tmap" :style="{ '--u': pxPerUnit }" :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">
|
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"
|
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||||
:d="a.d" class="tarc" />
|
:d="a.d" class="tarc" />
|
||||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||||
@@ -140,43 +167,35 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
<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">
|
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener">
|
||||||
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
<title>{{ x.path }}</title>
|
||||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||||
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle"
|
||||||
</text>
|
:style="{ '--slug-px': `${fitPx(x.label)}px` }">{{ x.label }}</text>
|
||||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
|
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||||
</a>
|
</a>
|
||||||
<g v-else :title="x.path">
|
<g v-else>
|
||||||
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
<title>{{ x.path }}</title>
|
||||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||||
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle"
|
||||||
</text>
|
:style="{ '--slug-px': `${fitPx(x.label)}px` }">{{ x.label }}</text>
|
||||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</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>
|
||||||
<g v-for="n in graph.nodes" :key="n.path">
|
<g v-for="n in graph.nodes" :key="n.path">
|
||||||
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
<a :href="n.path">
|
||||||
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
<title>{{ n.title }}</title>
|
||||||
<text :transform="`translate(${n.x}, ${n.y}) scale(${TNODE_R - 4})`" class="tnodeslug" :style="{ '--node-r': TNODE_R - 4 }">
|
<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" />
|
||||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath>
|
<text :x="n.x" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle"
|
||||||
</text>
|
:style="{ '--slug-px': `${fitPx(n.label)}px` }">{{ n.label }}</text>
|
||||||
<text :x="n.x" :y="n.y + 4" class="tnodecount">
|
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
||||||
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
|
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
|
||||||
</text>
|
</text>
|
||||||
</a>
|
</a>
|
||||||
<template v-else>
|
<text v-if="n.crumb" :x="n.x" :y="n.y - TNODE_H/2 - 10"
|
||||||
<text :x="n.x" :y="n.y"
|
:class="['tnodepath', n.path === '/' && 'tnodepath-home']">{{ n.crumb }}</text>
|
||||||
: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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</section>
|
</section>
|
||||||
@@ -187,8 +206,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
.tmap {
|
.tmap {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 36rem;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
.tmap .tconn {
|
.tmap .tconn {
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
@@ -203,37 +221,41 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
filter: drop-shadow(0 0 2.5px var(--accent));
|
filter: drop-shadow(0 0 2.5px var(--accent));
|
||||||
}
|
}
|
||||||
.tmap .txnode {
|
.tmap .txnode {
|
||||||
fill: var(--bg, Canvas);
|
fill: var(--text);
|
||||||
stroke-width: 1.5;
|
stroke: none;
|
||||||
}
|
}
|
||||||
.tmap .txnode-source { stroke: var(--text); }
|
.tmap .txnode-source { fill: var(--text); }
|
||||||
.tmap .txnode-exit { stroke: var(--text); }
|
.tmap .txnode-exit { fill: var(--text); }
|
||||||
.tmap .tarc {
|
.tmap .tarc {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--line);
|
stroke: var(--line);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
}
|
}
|
||||||
.tmap .tnode {
|
.tmap .tnode {
|
||||||
fill: var(--bg, Canvas);
|
fill: var(--accent);
|
||||||
stroke: var(--accent);
|
stroke: none;
|
||||||
stroke-width: 1.5;
|
|
||||||
}
|
}
|
||||||
|
/* Text renders at a constant screen size: --u (set from JS) is the
|
||||||
|
viewBox-unit → pixel ratio of the rendered svg, so dividing by it makes
|
||||||
|
the sizes independent of how far the graph is scaled down. */
|
||||||
.tmap .tnodeslug {
|
.tmap .tnodeslug {
|
||||||
fill: var(--text);
|
fill: var(--bg, Canvas);
|
||||||
font-size: calc(11px / var(--node-r, 34));
|
font-size: calc(var(--slug-px, 15px) / var(--u, 1));
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
.tmap a { cursor: pointer; }
|
.tmap a { cursor: pointer; }
|
||||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
|
||||||
.tmap .tnodecount {
|
.tmap .tnodecount {
|
||||||
fill: var(--muted);
|
fill: var(--bg, Canvas);
|
||||||
font-size: 10px;
|
opacity: 0.75;
|
||||||
|
font-size: calc(13px / var(--u, 1));
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
.tmap .tnodehidden {
|
.tmap .tnodepath {
|
||||||
fill: var(--text);
|
fill: var(--muted);
|
||||||
font-size: 9px;
|
font-size: calc(11px / var(--u, 1));
|
||||||
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
|
.tmap .tnodepath-home { font-size: calc(17px / var(--u, 1)); }
|
||||||
|
|
||||||
section { margin-top: 1.8rem; }
|
section { margin-top: 1.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
width: 2.6rem;
|
width: 2.6rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
transform: translateY(50%);
|
transform: translateY(50%);
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.25rem;
|
top: 0.25rem;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: -2.8rem;
|
left: -2.8rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
writing-mode: vertical-rl;
|
writing-mode: vertical-rl;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// Analytics page entry: mounts AnalyticsView inside the normal page layout.
|
// Analytics page entry: mounts AnalyticsView inside the normal page layout.
|
||||||
// The backend renders #analytics-app inside #main and links this module for
|
// In production the backend inlines this module into the /_a page (and
|
||||||
// the initial load; pagerite.js also imports it on fetch-navigation to /_a.
|
// 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 { createApp } from 'vue'
|
||||||
import AnalyticsView from './AnalyticsView.vue'
|
import AnalyticsView from './AnalyticsView.vue'
|
||||||
|
|
||||||
@@ -8,9 +11,7 @@ let app = null
|
|||||||
|
|
||||||
export function mount(container) {
|
export function mount(container) {
|
||||||
if (app) return
|
if (app) return
|
||||||
app = createApp(AnalyticsView, {
|
app = createApp(AnalyticsView)
|
||||||
initialRange: location.hash.slice(1) || 'week',
|
|
||||||
})
|
|
||||||
app.mount(container)
|
app.mount(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,11 @@ export function unmount() {
|
|||||||
app = null
|
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')
|
const container = document.getElementById('analytics-app')
|
||||||
if (container) mount(container)
|
if (container) mount(container)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function calcReadStats(visits) {
|
|||||||
totalVisitSeconds += secs.reduce((a, b) => a + b, 0)
|
totalVisitSeconds += secs.reduce((a, b) => a + b, 0)
|
||||||
for (const [path, s] of Object.entries(v.read || {})) {
|
for (const [path, s] of Object.entries(v.read || {})) {
|
||||||
if (s >= MIN_READ_SECONDS) {
|
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) {
|
if (adiff <= 86400000) {
|
||||||
return formatter
|
return formatter
|
||||||
.format(Math.round(diff / 3600000), 'hour')
|
.format(Math.round(diff / 3600000), 'hour')
|
||||||
|
.replace('hours', 'h')
|
||||||
|
.replace('hour', 'h')
|
||||||
.replaceAll(' ', '\u202F')
|
.replaceAll(' ', '\u202F')
|
||||||
}
|
}
|
||||||
if (adiff <= 604800000) {
|
if (adiff <= 604800000) {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Radial transition map and helpers.
|
* Radial transition map and helpers.
|
||||||
*
|
*
|
||||||
* Radial site map: the front page at the center, each slug level on its own
|
* Site map following the menu structure: top-level items in a row at the
|
||||||
* ring. All pages of the site are shown (from /_api/pages), plus any extra
|
* top (below the external source row), each item's subtree fanning out
|
||||||
* paths seen in transitions (deleted pages); siblings run clockwise in
|
* below it in menu order along a slightly circular downward arc. Index
|
||||||
* navigation order, starting at the top. Internal path -> path transitions
|
* pages with no views are omitted, their children moving up in their
|
||||||
* join opposite directions into straight connections (middle width = total
|
* place. All pages of the site are shown (from /_api/pages), plus any
|
||||||
* count, wrapping the node circles at both ends). Connection width grows
|
* extra paths seen in transitions (deleted pages); these form their own
|
||||||
|
* top-level groups. Internal path -> path transitions join opposite
|
||||||
|
* directions into straight connections (middle width = total
|
||||||
|
* count; connectors flare into the node pills at both ends and wrap
|
||||||
|
* around their backs, surrounding them; the pills are drawn on top). Connection width grows
|
||||||
* logarithmically with the count (a single count renders as a ~1 px
|
* logarithmically with the count (a single count renders as a ~1 px
|
||||||
* line, uncapped growth); connections carrying less than 1% of the total
|
* line, uncapped growth); connections carrying less than 1% of the total
|
||||||
* traffic are pruned, which naturally keeps the graph under ~100
|
* traffic are pruned, which naturally keeps the graph under ~100
|
||||||
@@ -25,8 +29,97 @@
|
|||||||
|
|
||||||
import { MIN_READ_SECONDS } from './format.js'
|
import { MIN_READ_SECONDS } from './format.js'
|
||||||
|
|
||||||
export const TNODE_R = 34 // node circles hold the slug and the view count
|
// Nodes are constant-size pills (stadium rects) holding the slug and the
|
||||||
export const EXT_R = 34 // external referer/exit nodes use the same full size
|
// view count on two centered lines. TNODE_BOUND is the pill's bounding
|
||||||
|
// radius, used for layout clearance and placement; connectors and flows
|
||||||
|
// use the exact outline geometry instead (pillContact below).
|
||||||
|
export const TNODE_W = 160
|
||||||
|
export const TNODE_H = 54
|
||||||
|
const TNODE_BOUND = Math.hypot(TNODE_W, TNODE_H) / 2
|
||||||
|
|
||||||
|
const PILL_R = TNODE_H / 2 // cap radius and straight-section half-height
|
||||||
|
const PILL_OFF = TNODE_W / 2 - PILL_R // x offset of the cap centers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the ray from a node center along (ux, uy) exits the pill outline
|
||||||
|
* (a capsule: straight top/bottom plus semicircular caps), enlarged by
|
||||||
|
* `margin`. Returns the distance `t` to the contact point and the outline
|
||||||
|
* arc position `s` of that point (see pillPointAt).
|
||||||
|
*/
|
||||||
|
const pillContact = (ux, uy, margin = 0) => {
|
||||||
|
const r = PILL_R + margin
|
||||||
|
const off = PILL_OFF + margin
|
||||||
|
const q = (Math.PI / 2) * r
|
||||||
|
// Straight top/bottom: valid when the crossing lands on the flat section.
|
||||||
|
let tf = Infinity
|
||||||
|
if (Math.abs(uy) > 1e-9) {
|
||||||
|
const t = r / Math.abs(uy)
|
||||||
|
if (Math.abs(t * ux) <= off + 1e-9) tf = t
|
||||||
|
}
|
||||||
|
// Rounded cap on the side the ray points to.
|
||||||
|
const cx = off * (ux >= 0 ? 1 : -1)
|
||||||
|
const disc = r * r - (cx * uy) ** 2
|
||||||
|
const tc = disc >= 0 ? cx * ux + Math.sqrt(disc) : Infinity
|
||||||
|
if (tf <= tc) {
|
||||||
|
const x = tf * ux
|
||||||
|
return { t: tf, s: uy > 0 ? q + off - x : q + 2 * off + Math.PI * r + x + off }
|
||||||
|
}
|
||||||
|
if (tc < Infinity) {
|
||||||
|
let th = Math.atan2(tc * uy, tc * ux - cx)
|
||||||
|
if (th < 0) th += 2 * Math.PI
|
||||||
|
const s = cx > 0
|
||||||
|
? th <= Math.PI / 2
|
||||||
|
? th * r
|
||||||
|
: q + 4 * off + Math.PI * r + (th - (3 * Math.PI) / 2) * r
|
||||||
|
: q + 2 * off + (th - Math.PI / 2) * r
|
||||||
|
return { t: tc, s }
|
||||||
|
}
|
||||||
|
return { t: TNODE_BOUND + margin, s: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total perimeter of the (margined) pill outline. */
|
||||||
|
const pillPerimeter = (margin = 0) =>
|
||||||
|
4 * (PILL_OFF + margin) + 2 * Math.PI * (PILL_R + margin)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point on the pill outline at arc position `s`, counterclockwise from the
|
||||||
|
* right cap tip: right cap up, top flat right-to-left, left cap down,
|
||||||
|
* bottom flat left-to-right, right cap up to the tip. Pills are never
|
||||||
|
* rotated, so the returned offset from the node center is in absolute
|
||||||
|
* coordinates.
|
||||||
|
*/
|
||||||
|
const pillPointAt = (s, margin = 0) => {
|
||||||
|
const r = PILL_R + margin
|
||||||
|
const off = PILL_OFF + margin
|
||||||
|
const P = pillPerimeter(margin)
|
||||||
|
const q = (Math.PI / 2) * r
|
||||||
|
s = ((s % P) + P) % P
|
||||||
|
if (s < q) {
|
||||||
|
const th = s / r
|
||||||
|
return [off + r * Math.cos(th), r * Math.sin(th)]
|
||||||
|
}
|
||||||
|
s -= q
|
||||||
|
if (s < 2 * off) return [off - s, r]
|
||||||
|
s -= 2 * off
|
||||||
|
if (s < Math.PI * r) {
|
||||||
|
const th = Math.PI / 2 + s / r
|
||||||
|
return [-off + r * Math.cos(th), r * Math.sin(th)]
|
||||||
|
}
|
||||||
|
s -= Math.PI * r
|
||||||
|
if (s < 2 * off) return [-off + s, -r]
|
||||||
|
s -= 2 * off
|
||||||
|
const th = (3 * Math.PI) / 2 + s / r
|
||||||
|
return [off + r * Math.cos(th), r * Math.sin(th)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unit tangent to the pill outline at arc position `s`, in the direction
|
||||||
|
* of increasing `s` (numeric; exact on both flats and caps). */
|
||||||
|
const pillTangent = (s, margin = 0) => {
|
||||||
|
const [x1, y1] = pillPointAt(s - 0.5, margin)
|
||||||
|
const [x2, y2] = pillPointAt(s + 0.5, margin)
|
||||||
|
const m = Math.hypot(x2 - x1, y2 - y1) || 1
|
||||||
|
return [(x2 - x1) / m, (y2 - y1) / m]
|
||||||
|
}
|
||||||
|
|
||||||
// Edge width (half-width of the thin middle) grows logarithmically with
|
// Edge width (half-width of the thin middle) grows logarithmically with
|
||||||
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
||||||
@@ -44,7 +137,7 @@ const PRUNE_FRACTION = 0.01
|
|||||||
// bead independently in JS at BEAD_SPEED along the edge, with no limit on
|
// bead independently in JS at BEAD_SPEED along the edge, with no limit on
|
||||||
// beads in flight.
|
// beads in flight.
|
||||||
export const BEAD_SPEED = 180 // svg units per second
|
export const BEAD_SPEED = 180 // svg units per second
|
||||||
export const BEAD_R = 2.2
|
export const BEAD_R = 3.2
|
||||||
const BEAD_RATE = 0.012 // beads per second per recorded transition
|
const BEAD_RATE = 0.012 // beads per second per recorded transition
|
||||||
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
|
||||||
|
|
||||||
@@ -149,38 +242,16 @@ function buildNodeTree(internal, navOrder) {
|
|||||||
return { nodes, byPath, root: byPath.get('/') }
|
return { nodes, byPath, root: byPath.get('/') }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sort children by navigation order and compute each subtree's angular weight. */
|
/** Sort each node's children by navigation order, recursively. */
|
||||||
function prepareWeights(root, navOrder) {
|
function sortByNav(root, navOrder) {
|
||||||
const byNav = (a, b) =>
|
const byNav = (a, b) =>
|
||||||
(navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
|
(navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
|
||||||
|| a.path.localeCompare(b.path)
|
|| a.path.localeCompare(b.path)
|
||||||
const weight = (n) =>
|
const walk = (n) => {
|
||||||
n.children.length ? n.children.reduce((s, k) => s + weight(k), 0) : 1 / n.depth
|
|
||||||
|
|
||||||
const walkSort = (n) => {
|
|
||||||
n.children.sort(byNav)
|
n.children.sort(byNav)
|
||||||
n.children.forEach(walkSort)
|
n.children.forEach(walk)
|
||||||
}
|
|
||||||
walkSort(root)
|
|
||||||
|
|
||||||
return weight
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assign angles clockwise starting from the top (-PI/2). */
|
|
||||||
function layoutAngles(root, unit, weight) {
|
|
||||||
const lay = (n, a0) => {
|
|
||||||
n.angle = a0
|
|
||||||
let a = a0
|
|
||||||
for (const k of n.children) {
|
|
||||||
lay(k, a)
|
|
||||||
a += weight(k) * unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let a = -Math.PI / 2
|
|
||||||
for (const k of root.children) {
|
|
||||||
lay(k, a)
|
|
||||||
a += weight(k) * unit
|
|
||||||
}
|
}
|
||||||
|
walk(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute median reading time per article in whole minutes. */
|
/** Compute median reading time per article in whole minutes. */
|
||||||
@@ -204,16 +275,8 @@ function buildReadMinutes(visits) {
|
|||||||
return minutes
|
return minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute radial positions, view counts and labels for each node. */
|
/** Compute view counts, labels and hidden flags for each node. */
|
||||||
function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
|
function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
||||||
// Constant radial gap between rings, equal to the arc spacing of nodes
|
|
||||||
// along a ring: leaf arc = unit * GAP, so GAP scales up with `unit` on
|
|
||||||
// sparse trees (where closing the circle forces wider arcs) and with
|
|
||||||
// 1/unit on dense ones (keeping arcs at the node clearance).
|
|
||||||
const CLEAR = 2 * TNODE_R + 12
|
|
||||||
const GAP = CLEAR * Math.max(unit, 1 / unit)
|
|
||||||
const radius = (d) => d * GAP
|
|
||||||
|
|
||||||
const viewCount = (p) => {
|
const viewCount = (p) => {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
||||||
@@ -221,75 +284,110 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const r = radius(n.depth)
|
|
||||||
n.x = Math.cos(n.angle) * r
|
|
||||||
n.y = Math.sin(n.angle) * r
|
|
||||||
n.views = viewCount(n.path)
|
n.views = viewCount(n.path)
|
||||||
n.readMin = readMinutes[n.path] || 0
|
n.readMin = readMinutes[n.path] || 0
|
||||||
// Slug inside the circle; full title goes on the link title attribute.
|
// Article title inside the pill (shortened with ellipsis as needed),
|
||||||
const slug = n.path === '/' ? '🏠︎' : n.path.split('/').pop()
|
// slug as fallback for pages missing from the site tree. The short
|
||||||
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
|
// path (last two segments, no leading /) renders above the pill; the
|
||||||
|
// front page shows a home symbol there instead (larger).
|
||||||
|
const slug = n.path.split('/').pop()
|
||||||
|
const label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : slug)
|
||||||
|
n.label = label.length > 24 ? `${label.slice(0, 23)}…` : label
|
||||||
|
const segs = n.path.split('/').filter(Boolean)
|
||||||
|
n.crumb = n.path === '/'
|
||||||
|
? '🏠︎'
|
||||||
|
: segs.length > 2 ? `…/${segs.slice(-2).join('/')}` : segs.join('/')
|
||||||
n.title = titles.get(n.path) || ''
|
n.title = titles.get(n.path) || ''
|
||||||
// Category (non-leaf) pages with no views in this window are left
|
// Category (non-leaf) pages with no views in this window are omitted:
|
||||||
// blank to keep the layout, but their circle/label is not drawn.
|
// their children move up in their place (see layoutGroups).
|
||||||
n.hidden = n.children.length > 0 && n.views === 0
|
n.hidden = n.children.length > 0 && n.views === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return { radius, GAP }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Family structure at a glance: a radial spoke from each parent to its
|
* Top-down layout following the menu structure: top-level items in an
|
||||||
* first child, and a ring arc across each sibling group from first to last
|
* equally spaced row at the top (right below the external source row),
|
||||||
* child in navigation (clockwise) order.
|
* the row following a shallow circular sag (center lowest) so connections
|
||||||
|
* between neighbors do not overlap the pills in between. Each top item's
|
||||||
|
* whole subtree fans out from it in menu (DFS preorder) order along a
|
||||||
|
* parabola that leaves the parent heading straight down and gradually
|
||||||
|
* bends to the right — no horizontal space is reserved for fans, they
|
||||||
|
* extend under the slots to their right. Hidden index pages are omitted
|
||||||
|
* from the fan; when the top item itself is hidden, the fan shifts one
|
||||||
|
* slot up, the first visible child taking the top position. The short
|
||||||
|
* path shown above each pill (last two segments) keeps the omitted menu
|
||||||
|
* level visible.
|
||||||
|
* Also returns curved spoke paths tracing each fan: top slot to first
|
||||||
|
* member, then member to member in menu order, each bowed to the right.
|
||||||
*/
|
*/
|
||||||
function buildFamilyArcs(nodes, radius) {
|
function layoutGroups(root) {
|
||||||
const arcs = []
|
// Top slots are spaced well over one pill width apart regardless of
|
||||||
for (const n of nodes) {
|
// fan sizes.
|
||||||
if (!n.children.length) continue
|
const SLOT = TNODE_W + 100
|
||||||
// The spoke aims along the FIRST CHILD's angle (the node's own angle
|
const CLEAR = TNODE_W * 0.8 // fan spacing per member along the curve
|
||||||
// coincides with it, except for the center page which has none).
|
|
||||||
const first = n.children[0]
|
|
||||||
const r1 = radius(n.depth) + TNODE_R
|
|
||||||
const r2 = radius(first.depth) - TNODE_R
|
|
||||||
arcs.push({
|
|
||||||
d: `M ${Math.cos(first.angle) * r1} ${Math.sin(first.angle) * r1} `
|
|
||||||
+ `L ${Math.cos(first.angle) * r2} ${Math.sin(first.angle) * r2}`,
|
|
||||||
})
|
|
||||||
if (n.children.length < 2) continue
|
|
||||||
const r = radius(n.children[0].depth)
|
|
||||||
const a0 = n.children[0].angle
|
|
||||||
const a1 = n.children[n.children.length - 1].angle
|
|
||||||
if (a1 - a0 >= 2 * Math.PI - 1e-6) continue // full circle: degenerate arc
|
|
||||||
const large = a1 - a0 > Math.PI ? 1 : 0
|
|
||||||
arcs.push({
|
|
||||||
d: `M ${Math.cos(a0) * r} ${Math.sin(a0) * r} `
|
|
||||||
+ `A ${r} ${r} 0 ${large} 1 ${Math.cos(a1) * r} ${Math.sin(a1) * r}`,
|
|
||||||
r,
|
|
||||||
a0,
|
|
||||||
a1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return arcs
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bounding box of a circular arc centred at the origin, sampled. */
|
// First pass: visible members per group, in menu order. Hidden index
|
||||||
function arcBounds(r, a0, a1) {
|
// pages are skipped, but their children still appear. The front page
|
||||||
let x0 = Infinity
|
// forms its own group.
|
||||||
let y0 = Infinity
|
const groups = []
|
||||||
let x1 = -Infinity
|
for (const g of [root, ...root.children]) {
|
||||||
let y1 = -Infinity
|
const members = []
|
||||||
const steps = 36
|
if (g === root) {
|
||||||
for (let i = 0; i <= steps; i++) {
|
if (!g.hidden) members.push(g)
|
||||||
const t = a0 + (a1 - a0) * (i / steps)
|
} else {
|
||||||
const x = Math.cos(t) * r
|
const walk = (n) => {
|
||||||
const y = Math.sin(t) * r
|
if (!n.hidden) members.push(n)
|
||||||
if (x < x0) x0 = x
|
n.children.forEach(walk)
|
||||||
if (y < y0) y0 = y
|
}
|
||||||
if (x > x1) x1 = x
|
walk(g)
|
||||||
if (y > y1) y1 = y
|
}
|
||||||
|
if (members.length) groups.push(members)
|
||||||
}
|
}
|
||||||
return { x0, y0, x1, y1 }
|
|
||||||
|
// Top row on a true circular sag: center lowest, edges raised by SAG.
|
||||||
|
const half = ((groups.length - 1) * SLOT) / 2 || 1
|
||||||
|
const SAG = TNODE_H * 0.6
|
||||||
|
const Rc = (half * half + SAG * SAG) / (2 * SAG)
|
||||||
|
const topY = (x) => SAG - Rc + Math.sqrt(Rc * Rc - x * x)
|
||||||
|
|
||||||
|
// Second pass: place groups. Fan members follow a right-opening cubic
|
||||||
|
// p(t) = (gx + B t³, y0 + t): the tangent stays vertical near the
|
||||||
|
// parent (leaving almost straight down) and bends right gently,
|
||||||
|
// reaching ~50° from vertical at the last member. Member spacing along
|
||||||
|
// the curve is the pill clearance (dt integrated against curve speed).
|
||||||
|
const spokePairs = [] // [from node, to node] — paths emitted below
|
||||||
|
groups.forEach((members, gi) => {
|
||||||
|
const gx = gi * SLOT - half
|
||||||
|
const y0 = topY(gx)
|
||||||
|
members[0].x = gx
|
||||||
|
members[0].y = y0
|
||||||
|
const m = members.length - 1
|
||||||
|
if (!m) return
|
||||||
|
const tMax = m * CLEAR * 0.9
|
||||||
|
const B = 0.4 / (tMax * tMax)
|
||||||
|
let t = 0
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
const bend = 3 * B * t * t
|
||||||
|
t += CLEAR / Math.hypot(bend, 1)
|
||||||
|
const n = members[i]
|
||||||
|
n.x = gx + B * t * t * t
|
||||||
|
n.y = y0 + t
|
||||||
|
spokePairs.push([members[i - 1], n])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fan spokes bow to the right via a quadratic control point pushed
|
||||||
|
// rightward from the segment midpoint.
|
||||||
|
const spokes = spokePairs.map(([p, n]) => {
|
||||||
|
const mx = (p.x + n.x) / 2
|
||||||
|
const my = (p.y + n.y) / 2
|
||||||
|
const bow = Math.hypot(n.x - p.x, n.y - p.y) * 0.18
|
||||||
|
return {
|
||||||
|
d: `M ${p.x.toFixed(2)} ${p.y.toFixed(2)} `
|
||||||
|
+ `Q ${(mx + bow).toFixed(2)} ${my.toFixed(2)} ${n.x.toFixed(2)} ${n.y.toFixed(2)}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { GAP: TNODE_H * 2.6, spokes }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
||||||
@@ -310,9 +408,13 @@ const fmtPt = (p) => `${p[0].toFixed(2)} ${p[1].toFixed(2)}`
|
|||||||
/**
|
/**
|
||||||
* Build one ribbon edge between two nodes with counts ab and ba.
|
* Build one ribbon edge between two nodes with counts ab and ba.
|
||||||
* `wMid` is the half-width of the thin middle (already strength-scaled by
|
* `wMid` is the half-width of the thin middle (already strength-scaled by
|
||||||
* the caller); `ra`/`rb` are the radii of the node circles each end wraps.
|
* the caller). Each end flares into the node's pill surround (the outline
|
||||||
|
* enlarged by margin S): the flare contact points follow the pill outline
|
||||||
|
* a constant arc distance to each side of the direct contact point, and
|
||||||
|
* the back of the ribbon wraps all the way around the pill between them,
|
||||||
|
* surrounding the node. The pills themselves are drawn on top.
|
||||||
*/
|
*/
|
||||||
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external = false) {
|
function buildRibbon(a, b, ab, ba, wMid, external = false) {
|
||||||
const count = ab + ba
|
const count = ab + ba
|
||||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||||
const ux = (b.x - a.x) / len
|
const ux = (b.x - a.x) / len
|
||||||
@@ -320,21 +422,40 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
|||||||
const nx = -uy
|
const nx = -uy
|
||||||
const ny = ux
|
const ny = ux
|
||||||
|
|
||||||
// Radius of each node surround and the attachment geometry on it.
|
// Direct contact: where the centerline exits each pill's surround.
|
||||||
const R2A = ra + 3
|
const S = 4
|
||||||
const R2B = rb + 3
|
const cA = pillContact(ux, uy, S)
|
||||||
|
const cB = pillContact(-ux, -uy, S)
|
||||||
// Attachment points sit somewhat forward from the side of the node,
|
|
||||||
// leaving enough room for the surround to flow naturally into the flare.
|
|
||||||
const BETA = (65 * Math.PI) / 180
|
|
||||||
const ENDA = R2A * Math.cos(BETA)
|
|
||||||
const wEndA = R2A * Math.sin(BETA)
|
|
||||||
const ENDB = R2B * Math.cos(BETA)
|
|
||||||
const wEndB = R2B * Math.sin(BETA)
|
|
||||||
|
|
||||||
// Flares take a fair share of the free span while leaving the
|
// Flares take a fair share of the free span while leaving the
|
||||||
// count-scaled thin middle a visible share of the connection length.
|
// count-scaled thin middle a visible share of the connection length.
|
||||||
const FLARE = Math.min(36, Math.max(0, (len - ENDA - ENDB) * 0.4))
|
// The maximum flare length scales with the contact distance so wide
|
||||||
|
// approach angles still show a wide connector end.
|
||||||
|
const free = Math.max(0, len - cA.t - cB.t)
|
||||||
|
const FLARE = Math.min(Math.max(cA.t, cB.t) * 1.2, free * 0.4)
|
||||||
|
|
||||||
|
// Flare endpoints: walk the outline a constant arc distance to each
|
||||||
|
// side of the direct contact point (spanning flats and caps alike).
|
||||||
|
const D = (Math.PI / 4) * (PILL_R + S)
|
||||||
|
// Per node: endpoints for the +n (left) and -n (right) flare sides,
|
||||||
|
// each with its arc position, absolute point, and an outline tangent
|
||||||
|
// oriented back toward the direct contact point.
|
||||||
|
const ends = (cx, cy, contact) => {
|
||||||
|
const pick = (s) => {
|
||||||
|
const [px, py] = pillPointAt(s, S)
|
||||||
|
// Outline tangent oriented back toward the direct contact point
|
||||||
|
// (the flare side sweeps from the contact point around to its
|
||||||
|
// endpoint and into the connection), so it can never fork outward.
|
||||||
|
const tan = pillTangent(s, S)
|
||||||
|
if (s > contact.s) { tan[0] = -tan[0]; tan[1] = -tan[1] }
|
||||||
|
return { s, p: [cx + px, cy + py], tan, side: px * nx + py * ny }
|
||||||
|
}
|
||||||
|
const plus = pick(contact.s + D)
|
||||||
|
const minus = pick(contact.s - D)
|
||||||
|
return plus.side >= 0 ? [plus, minus] : [minus, plus]
|
||||||
|
}
|
||||||
|
const [aLeftEnd, aRightEnd] = ends(a.x, a.y, cA)
|
||||||
|
const [bLeftEnd, bRightEnd] = ends(b.x, b.y, cB)
|
||||||
|
|
||||||
// Point on the connection centerline at distance t from A, offset s
|
// Point on the connection centerline at distance t from A, offset s
|
||||||
// perpendicular to it.
|
// perpendicular to it.
|
||||||
@@ -343,48 +464,24 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
|||||||
a.y + t * uy + s * ny,
|
a.y + t * uy + s * ny,
|
||||||
]
|
]
|
||||||
|
|
||||||
// Arc around a node from p to q the long way, passing its back side.
|
// One side of a flare: from the outline endpoint, leaving tangent to
|
||||||
const wrap = (p, q, node, back, R2) => {
|
// the pill outline, to the connection middle arriving parallel with
|
||||||
const ang = (pt2) =>
|
// the centerline. The tangent pull is clamped so the control point
|
||||||
Math.atan2(pt2[1] - node[1], pt2[0] - node[0])
|
// stays well on its own side of the centerline — otherwise a long
|
||||||
|
// flare on a rounded cap crosses the opposite side.
|
||||||
const TAU = 2 * Math.PI
|
const flarePoints = (end, midT, s, dir) => {
|
||||||
const da = ((ang(back) - ang(p)) % TAU + TAU) % TAU
|
let hEnd = FLARE * 0.65
|
||||||
const db = ((ang(q) - ang(p)) % TAU + TAU) % TAU
|
const hMid = FLARE * 0.4
|
||||||
|
const tanS = end.tan[0] * nx + end.tan[1] * ny // inward rate
|
||||||
return `A ${R2} ${R2} 0 1 ${da < db ? 1 : 0} ${fmtPt(q)} `
|
if (tanS * end.side < 0) {
|
||||||
}
|
hEnd = Math.min(hEnd, (Math.abs(end.side) * 0.6) / Math.abs(tanS))
|
||||||
|
}
|
||||||
// Build one side of a flare in node -> middle order.
|
return {
|
||||||
const flarePoints = (endT, midT, s, dir, R2, END, wEnd) => {
|
pEnd: end.p,
|
||||||
const span = Math.abs(midT - endT)
|
cEnd: [end.p[0] + end.tan[0] * hEnd, end.p[1] + end.tan[1] * hEnd],
|
||||||
const pEnd = P(endT, s * wEnd)
|
cMid: P(midT - dir * hMid, s * wMid),
|
||||||
const pMid = P(midT, s * wMid)
|
pMid: P(midT, s * wMid),
|
||||||
|
}
|
||||||
// At the node, leave tangent to the circular surround.
|
|
||||||
// The circle radius at the attachment is locally:
|
|
||||||
// A: (+END, ±wEnd)
|
|
||||||
// B: (-END, ±wEnd)
|
|
||||||
// A perpendicular tangent pointing into the connection therefore has
|
|
||||||
// these centerline/normal components.
|
|
||||||
const tangentT = dir * wEnd / R2
|
|
||||||
const tangentS = -s * END / R2
|
|
||||||
|
|
||||||
const hEnd = span * 0.65
|
|
||||||
const hMid = span * 0.4
|
|
||||||
|
|
||||||
const cEnd = P(
|
|
||||||
endT + tangentT * hEnd,
|
|
||||||
s * wEnd + tangentS * hEnd,
|
|
||||||
)
|
|
||||||
|
|
||||||
// At the thin end, arrive parallel with the centerline.
|
|
||||||
const cMid = P(
|
|
||||||
midT - dir * hMid,
|
|
||||||
s * wMid,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { pEnd, cEnd, cMid, pMid }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit a cubic in either traversal direction. Reversing a cubic requires
|
// Emit a cubic in either traversal direction. Reversing a cubic requires
|
||||||
@@ -396,25 +493,39 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
|||||||
return `C ${fmtPt(f.cMid)} ${fmtPt(f.cEnd)} ${fmtPt(f.pEnd)} `
|
return `C ${fmtPt(f.cMid)} ${fmtPt(f.cEnd)} ${fmtPt(f.pEnd)} `
|
||||||
}
|
}
|
||||||
|
|
||||||
const LA = P(ENDA, wEndA)
|
// Trace the surround outline the long way around (behind the node) from
|
||||||
const RA = P(ENDA, -wEndA)
|
// arc s1 to arc s2. Sampled as a polyline: the visible result is a thin
|
||||||
const LB = P(len - ENDB, wEndB)
|
// halo hugging the pill, so exact arc segments are unnecessary.
|
||||||
const RB = P(len - ENDB, -wEndB)
|
const outlineWrap = (cx, cy, s1, s2) => {
|
||||||
|
const per = pillPerimeter(S)
|
||||||
|
const dPlus = ((s2 - s1) % per + per) % per
|
||||||
|
const total = dPlus > per / 2 ? dPlus : per - dPlus
|
||||||
|
const dir = dPlus > per / 2 ? 1 : -1
|
||||||
|
const n = Math.max(4, Math.ceil(total / 6))
|
||||||
|
let out = ''
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const [x, y] = pillPointAt(s1 + (dir * total * i) / n, S)
|
||||||
|
out += `L ${(cx + x).toFixed(2)} ${(cy + y).toFixed(2)} `
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
const aLeft = flarePoints(ENDA, ENDA + FLARE, 1, 1, R2A, ENDA, wEndA)
|
const aLeft = flarePoints(aLeftEnd, cA.t + FLARE, 1, 1)
|
||||||
const bLeft = flarePoints(len - ENDB, len - ENDB - FLARE, 1, -1, R2B, ENDB, wEndB)
|
const bLeft = flarePoints(bLeftEnd, len - cB.t - FLARE, 1, -1)
|
||||||
const bRight = flarePoints(len - ENDB, len - ENDB - FLARE, -1, -1, R2B, ENDB, wEndB)
|
const bRight = flarePoints(bRightEnd, len - cB.t - FLARE, -1, -1)
|
||||||
const aRight = flarePoints(ENDA, ENDA + FLARE, -1, 1, R2A, ENDA, wEndA)
|
const aRight = flarePoints(aRightEnd, cA.t + FLARE, -1, 1)
|
||||||
|
|
||||||
const d = `M ${fmtPt(LA)} `
|
// Each end wraps the full back of the node pill between its two flare
|
||||||
|
// contact points (bLeft -> bRight around B, aRight -> aLeft around A).
|
||||||
|
const d = `M ${fmtPt(aLeft.pEnd)} `
|
||||||
+ curve(aLeft)
|
+ curve(aLeft)
|
||||||
+ `L ${fmtPt(bLeft.pMid)} `
|
+ `L ${fmtPt(bLeft.pMid)} `
|
||||||
+ curve(bLeft, true)
|
+ curve(bLeft, true)
|
||||||
+ wrap(LB, RB, [b.x, b.y], P(len + R2B, 0), R2B)
|
+ outlineWrap(b.x, b.y, bLeftEnd.s, bRightEnd.s)
|
||||||
+ curve(bRight)
|
+ curve(bRight)
|
||||||
+ `L ${fmtPt(aRight.pMid)} `
|
+ `L ${fmtPt(aRight.pMid)} `
|
||||||
+ curve(aRight, true)
|
+ curve(aRight, true)
|
||||||
+ wrap(RA, LA, [a.x, a.y], P(-R2A, 0), R2A)
|
+ outlineWrap(a.x, a.y, aRightEnd.s, aLeftEnd.s)
|
||||||
+ 'Z'
|
+ 'Z'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -427,7 +538,7 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
|||||||
/**
|
/**
|
||||||
* Flow descriptors for the bead animation, one per edge direction with a
|
* Flow descriptors for the bead animation, one per edge direction with a
|
||||||
* nonzero count: a straight segment running from inside the source node
|
* nonzero count: a straight segment running from inside the source node
|
||||||
* to inside the target node (beads render under the node circles, so
|
* to inside the target node (beads render under the node pills, so
|
||||||
* they emerge from and vanish beneath the nodes rather than popping in
|
* they emerge from and vanish beneath the nodes rather than popping in
|
||||||
* at the surround), plus the emission interval (seconds between beads,
|
* at the surround), plus the emission interval (seconds between beads,
|
||||||
* inverse of count * BEAD_RATE). Each segment is offset to the
|
* inverse of count * BEAD_RATE). Each segment is offset to the
|
||||||
@@ -435,12 +546,14 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
|||||||
* edge run on parallel lanes instead of colliding. The component turns
|
* edge run on parallel lanes instead of colliding. The component turns
|
||||||
* these into independently simulated beads.
|
* these into independently simulated beads.
|
||||||
*/
|
*/
|
||||||
function buildFlows(a, b, ra, rb, ab, ba, visualScale = 1) {
|
function buildFlows(a, b, ab, ba, visualScale = 1) {
|
||||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||||
const ux = (b.x - a.x) / len
|
const ux = (b.x - a.x) / len
|
||||||
const uy = (b.y - a.y) / len
|
const uy = (b.y - a.y) / len
|
||||||
const t0 = ra / 3
|
const rA = pillContact(ux, uy).t
|
||||||
const t1 = len - rb / 3
|
const rB = pillContact(-ux, -uy).t
|
||||||
|
const t0 = rA / 3
|
||||||
|
const t1 = len - rB / 3
|
||||||
if (t1 - t0 < 12) return []
|
if (t1 - t0 < 12) return []
|
||||||
|
|
||||||
// Unit normal pointing to the visual right of the A -> B direction.
|
// Unit normal pointing to the visual right of the A -> B direction.
|
||||||
@@ -493,10 +606,11 @@ function buildInternalEdges(pairs, byPath, visualScale = 1) {
|
|||||||
const [pf, pt] = k.split(' ')
|
const [pf, pt] = k.split(' ')
|
||||||
const a = byPath.get(pf)
|
const a = byPath.get(pf)
|
||||||
const b = byPath.get(pt)
|
const b = byPath.get(pt)
|
||||||
|
if (a.hidden || b.hidden) continue // unplaced index pages are omitted
|
||||||
const wMid = scaledWidth((ab + ba) * visualScale)
|
const wMid = scaledWidth((ab + ba) * visualScale)
|
||||||
if (wMid <= 0) continue
|
if (wMid <= 0) continue
|
||||||
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
||||||
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba, visualScale))
|
flows.push(...buildFlows(a, b, ab, ba, visualScale))
|
||||||
}
|
}
|
||||||
return { edges, flows }
|
return { edges, flows }
|
||||||
}
|
}
|
||||||
@@ -616,7 +730,7 @@ function collectSourcePairs(visits) {
|
|||||||
* Widths and pruning use the same log scale and traffic-share rule as
|
* Widths and pruning use the same log scale and traffic-share rule as
|
||||||
* internal connections.
|
* internal connections.
|
||||||
*/
|
*/
|
||||||
function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualScale = 1) {
|
function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale = 1) {
|
||||||
const extNodes = []
|
const extNodes = []
|
||||||
const edges = []
|
const edges = []
|
||||||
const flows = []
|
const flows = []
|
||||||
@@ -630,9 +744,13 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
|||||||
|
|
||||||
const width = (count) => scaledWidth(count * visualScale)
|
const width = (count) => scaledWidth(count * visualScale)
|
||||||
|
|
||||||
const overlaps = (x, y, r) =>
|
// Pill-shape overlap test (axis-aligned pills): much tighter than the
|
||||||
|
// bounding-circle test, so diagonal placements can sit close.
|
||||||
|
const overlaps = (x, y) =>
|
||||||
[...byPath.values(), ...extNodes].some(
|
[...byPath.values(), ...extNodes].some(
|
||||||
(n) => Math.hypot(n.x - x, n.y - y) < (n.r ?? TNODE_R) + r + 10,
|
(n) => !n.hidden
|
||||||
|
&& Math.abs(n.x - x) < TNODE_W + 12
|
||||||
|
&& Math.abs(n.y - y) < TNODE_H + 12,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Incoming: one source node per identified source, in a row centered
|
// Incoming: one source node per identified source, in a row centered
|
||||||
@@ -655,8 +773,8 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
|||||||
.slice(0, MAX_EXT_IN)
|
.slice(0, MAX_EXT_IN)
|
||||||
if (origins.length) {
|
if (origins.length) {
|
||||||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||||||
const y = innerBounds.y0 - TNODE_R - 64
|
const y = innerBounds.y0 - TNODE_BOUND - 64
|
||||||
const spacing = 2 * EXT_R + 44
|
const spacing = TNODE_W + 44
|
||||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
||||||
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
||||||
const label = isUtm ? source : extLabel(source)
|
const label = isUtm ? source : extLabel(source)
|
||||||
@@ -666,26 +784,37 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
|||||||
label: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
label: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
||||||
x: x0 + i * spacing,
|
x: x0 + i * spacing,
|
||||||
y,
|
y,
|
||||||
r: EXT_R,
|
|
||||||
count: total,
|
count: total,
|
||||||
kind: 'source',
|
kind: 'source',
|
||||||
}
|
}
|
||||||
extNodes.push(xn)
|
extNodes.push(xn)
|
||||||
for (const p of ps) {
|
for (const p of ps) {
|
||||||
const page = byPath.get(p.page)
|
const page = byPath.get(p.page)
|
||||||
|
if (page.hidden) continue
|
||||||
const wMid = width(p.in)
|
const wMid = width(p.in)
|
||||||
if (wMid <= 0) continue
|
if (wMid <= 0) continue
|
||||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, EXT_R, TNODE_R, true))
|
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
||||||
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0, visualScale))
|
flows.push(...buildFlows(xn, page, p.in, 0, visualScale))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing: group by full URL so several links to the same domain stay
|
// Outgoing: group by full URL so several links to the same domain stay
|
||||||
// distinct. Each exit node is placed one ring-gap outside its source page
|
// distinct; each shows the total count across all pages linking to it.
|
||||||
// (same radial spacing internal rings use), fanned around the source angle,
|
// Placement looks for empty space around the source page, always
|
||||||
// and shows the total count across all pages that link to that URL.
|
// leftward: diagonal down-left first (often right beside the source,
|
||||||
const GAP = radius(1) - radius(0)
|
// no need to drop below the fans), then left, up-left, and steeper
|
||||||
|
// fallbacks; the distance grows until a spot is free. Several exits of
|
||||||
|
// one page start at different directions.
|
||||||
|
const GAP = gap
|
||||||
|
const DIRS = [
|
||||||
|
(3 * Math.PI) / 4, // diagonal down-left
|
||||||
|
Math.PI, // left
|
||||||
|
(5 * Math.PI) / 4, // diagonal up-left
|
||||||
|
Math.PI / 2 + 0.35, // steep down-left
|
||||||
|
Math.PI - 0.35, // shallow up-left
|
||||||
|
(3 * Math.PI) / 4 + 0.5, // far down-left
|
||||||
|
]
|
||||||
const outgoing = liveExits.filter((p) => p.out >= minCount)
|
const outgoing = liveExits.filter((p) => p.out >= minCount)
|
||||||
.sort((a, b) => b.out - a.out)
|
.sort((a, b) => b.out - a.out)
|
||||||
const perPage = new Map()
|
const perPage = new Map()
|
||||||
@@ -699,30 +828,32 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exitNodes = new Map() // full URL -> node
|
const exitNodes = new Map() // full URL -> node
|
||||||
const placedPerPage = new Map() // for angle fanning of the placement anchor
|
const placedPerPage = new Map() // for the placement direction offset
|
||||||
for (const p of selected) {
|
for (const p of selected) {
|
||||||
const page = byPath.get(p.page)
|
const page = byPath.get(p.page)
|
||||||
|
if (page.hidden) continue
|
||||||
let xn = exitNodes.get(p.ext)
|
let xn = exitNodes.get(p.ext)
|
||||||
if (!xn) {
|
if (!xn) {
|
||||||
const used = placedPerPage.get(p.page) || 0
|
const used = placedPerPage.get(p.page) || 0
|
||||||
placedPerPage.set(p.page, used + 1)
|
placedPerPage.set(p.page, used + 1)
|
||||||
const base = page.depth ? page.angle : Math.PI / 2
|
let x = 0
|
||||||
const ang = base + [0, 0.4, -0.4][used]
|
let y = 0
|
||||||
let dist = GAP
|
let found = false
|
||||||
let x = page.x + Math.cos(ang) * dist
|
for (let di = 0; di < DIRS.length && !found; di++) {
|
||||||
let y = page.y + Math.sin(ang) * dist
|
const ang = DIRS[(used + di) % DIRS.length]
|
||||||
for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
|
for (let dist = GAP; dist <= GAP * 3.5; dist += GAP * 0.4) {
|
||||||
dist += GAP * 0.3
|
x = page.x + Math.cos(ang) * dist
|
||||||
x = page.x + Math.cos(ang) * dist
|
y = page.y + Math.sin(ang) * dist
|
||||||
y = page.y + Math.sin(ang) * dist
|
if (!overlaps(x, y)) { found = true; break }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!found) continue // no empty space near the page: leave it out
|
||||||
xn = {
|
xn = {
|
||||||
path: p.ext,
|
path: p.ext,
|
||||||
href: p.ext,
|
href: p.ext,
|
||||||
label: extLabel(p.ext),
|
label: extLabel(p.ext),
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
r: EXT_R,
|
|
||||||
count: 0,
|
count: 0,
|
||||||
kind: 'exit',
|
kind: 'exit',
|
||||||
}
|
}
|
||||||
@@ -732,17 +863,18 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
|||||||
xn.count += p.out
|
xn.count += p.out
|
||||||
const wMid = width(p.out)
|
const wMid = width(p.out)
|
||||||
if (wMid <= 0) continue
|
if (wMid <= 0) continue
|
||||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, TNODE_R, EXT_R, true))
|
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
||||||
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, p.out, 0, visualScale))
|
flows.push(...buildFlows(page, xn, p.out, 0, visualScale))
|
||||||
}
|
}
|
||||||
|
|
||||||
return { extNodes, edges, flows }
|
return { extNodes, edges, flows }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the radial transition map model.
|
* Build the transition map model.
|
||||||
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
||||||
* there is nothing to show.
|
* there is nothing to show. `arcs` holds the family spokes; `nodes` only
|
||||||
|
* contains placed (visible) nodes.
|
||||||
*/
|
*/
|
||||||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||||||
const internal = collectInternalTransitions(data?.transitions)
|
const internal = collectInternalTransitions(data?.transitions)
|
||||||
@@ -755,52 +887,38 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
|||||||
if (!internal.length && !navOrder.size) return null
|
if (!internal.length && !navOrder.size) return null
|
||||||
|
|
||||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
||||||
const weightFn = prepareWeights(root, navOrder)
|
sortByNav(root, navOrder)
|
||||||
const unit = (2 * Math.PI) / weightFn(root)
|
annotateNodes(nodes, data?.views, titles, readMinutes)
|
||||||
layoutAngles(root, unit, weightFn)
|
const { GAP, spokes } = layoutGroups(root)
|
||||||
|
const placed = nodes.filter((n) => !n.hidden)
|
||||||
const maxDepth = Math.max(1, ...nodes.map((n) => n.depth))
|
|
||||||
const { radius } = positionNodes(nodes, maxDepth, unit, data?.views, titles, readMinutes)
|
|
||||||
const arcs = buildFamilyArcs(nodes, radius)
|
|
||||||
const pairs = aggregatePairs(internal)
|
const pairs = aggregatePairs(internal)
|
||||||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||||||
|
|
||||||
// Tight bounding box of the actual page nodes; family ring arcs can sweep
|
// Tight bounding box of the placed page nodes; external nodes extend it.
|
||||||
// outside the node circle (e.g. a large arc between two siblings on the
|
|
||||||
// left side reaching around the right), so their geometry is included too.
|
|
||||||
// External nodes extend the box below.
|
|
||||||
const pad = 16
|
const pad = 16
|
||||||
const xs = nodes.map((n) => n.x)
|
const xs = placed.map((n) => n.x)
|
||||||
const ys = nodes.map((n) => n.y)
|
const ys = placed.map((n) => n.y)
|
||||||
const bounds = {
|
const bounds = {
|
||||||
x0: Math.min(...xs) - TNODE_R - pad,
|
x0: Math.min(...xs) - TNODE_BOUND - pad,
|
||||||
y0: Math.min(...ys) - TNODE_R - pad,
|
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
||||||
x1: Math.max(...xs) + TNODE_R + pad,
|
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
||||||
y1: Math.max(...ys) + TNODE_R + pad,
|
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
||||||
}
|
|
||||||
for (const arc of arcs) {
|
|
||||||
if (arc.a0 == null) continue
|
|
||||||
const b = arcBounds(arc.r, arc.a0, arc.a1)
|
|
||||||
bounds.x0 = Math.min(bounds.x0, b.x0)
|
|
||||||
bounds.y0 = Math.min(bounds.y0, b.y0)
|
|
||||||
bounds.x1 = Math.max(bounds.x1, b.x1)
|
|
||||||
bounds.y1 = Math.max(bounds.y1, b.y1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = buildExternal({ sources, exits }, byPath, radius, bounds, visualScale)
|
const ext = buildExternal({ sources, exits }, byPath, GAP, bounds, visualScale)
|
||||||
for (const xn of ext.extNodes) {
|
for (const xn of ext.extNodes) {
|
||||||
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
|
bounds.x0 = Math.min(bounds.x0, xn.x - TNODE_BOUND - pad)
|
||||||
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
|
bounds.y0 = Math.min(bounds.y0, xn.y - TNODE_BOUND - pad)
|
||||||
bounds.x1 = Math.max(bounds.x1, xn.x + xn.r + pad)
|
bounds.x1 = Math.max(bounds.x1, xn.x + TNODE_BOUND + pad)
|
||||||
bounds.y1 = Math.max(bounds.y1, xn.y + xn.r + pad)
|
bounds.y1 = Math.max(bounds.y1, xn.y + TNODE_BOUND + pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes,
|
nodes: placed,
|
||||||
edges: [...edges, ...ext.edges],
|
edges: [...edges, ...ext.edges],
|
||||||
flows: [...flows, ...ext.flows],
|
flows: [...flows, ...ext.flows],
|
||||||
extNodes: ext.extNodes,
|
extNodes: ext.extNodes,
|
||||||
arcs,
|
arcs: spokes,
|
||||||
bounds,
|
bounds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,12 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Links never underline — including SVG link text, which the UA stylesheet
|
||||||
|
underlines by default. */
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
/* Native-scrollbar fallback styling (JS off or before pagerite.js runs):
|
/* Native-scrollbar fallback styling (JS off or before pagerite.js runs):
|
||||||
@@ -791,8 +797,11 @@ figure:has(img[width]) {
|
|||||||
|
|
||||||
The rules below re-anchor the bleed for the layouts where the article
|
The rules below re-anchor the bleed for the layouts where the article
|
||||||
is not viewport-centered; each just overrides width/margin-inline, and
|
is not viewport-centered; each just overrides width/margin-inline, and
|
||||||
later rules win at equal specificity. */
|
later rules win at equal specificity. The analytics dashboard uses the
|
||||||
figure:has(.wide) {
|
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;
|
width: 100vw;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-inline: calc(50% - 50vw);
|
margin-inline: calc(50% - 50vw);
|
||||||
@@ -912,7 +921,9 @@ article h2 {
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#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-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem 1.2rem;
|
gap: 0.5rem 1.2rem;
|
||||||
|
|||||||
+145
-37
@@ -55,6 +55,19 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
let editorMeta = null;
|
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) {
|
function makePen(mode) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
@@ -124,11 +137,11 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupAuth() {
|
async function setupAuth() {
|
||||||
const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content;
|
const src = assets["pagerite:editor-src"];
|
||||||
if (!src) { pingEntryOnce(); return; }
|
if (!src) { pingEntryOnce(); return; }
|
||||||
editorMeta = {
|
editorMeta = {
|
||||||
src,
|
src,
|
||||||
css: document.querySelector('meta[name="pagerite:editor-css"]')?.content,
|
css: assets["pagerite:editor-css"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Detect whether Paskia SSO is available on this site.
|
// Detect whether Paskia SSO is available on this site.
|
||||||
@@ -147,6 +160,26 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// No auth proxy / dev.
|
// 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();
|
renderAuthUi();
|
||||||
pingEntryOnce();
|
pingEntryOnce();
|
||||||
}
|
}
|
||||||
@@ -230,6 +263,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// buttons; re-add whichever auth UI is appropriate for this session.
|
// buttons; re-add whichever auth UI is appropriate for this session.
|
||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
placeEditPen();
|
placeEditPen();
|
||||||
|
fitNav();
|
||||||
// Multi-column layout only when there is enough text to justify it.
|
// Multi-column layout only when there is enough text to justify it.
|
||||||
// Split the body into columned segments: h1s, h2s and wide figures are
|
// Split the body into columned segments: h1s, h2s and wide figures are
|
||||||
// full-width separators and never go inside columns.
|
// 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
|
// internal link is fetched exactly once, and navigation is served from
|
||||||
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
|
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
|
||||||
// announce their fresh copies via pagerite:page-fetched, keeping the
|
// 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
|
const pageCache = new Map(); // pathname -> HTML text
|
||||||
addEventListener("pagerite:page-fetched", (ev) => {
|
addEventListener("pagerite:page-fetched", (ev) => {
|
||||||
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
|
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
|
||||||
});
|
});
|
||||||
|
|
||||||
function preload() {
|
function preload() {
|
||||||
const urls = new Set([location.pathname]);
|
const urls = new Set();
|
||||||
for (const a of document.querySelectorAll(
|
for (const a of document.querySelectorAll(
|
||||||
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
|
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
|
||||||
)) {
|
)) {
|
||||||
@@ -300,7 +337,10 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
}
|
}
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
if (pageCache.has(url)) continue;
|
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")
|
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
|
||||||
? r.text() : ""))
|
? r.text() : ""))
|
||||||
.then((html) => { if (html) pageCache.set(url, html); })
|
.then((html) => { if (html) pageCache.set(url, html); })
|
||||||
@@ -455,29 +495,39 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
|
|
||||||
// --- Analytics page mount/unmount --------------------------------------
|
// --- Analytics page mount/unmount --------------------------------------
|
||||||
// The analytics page is a normal page whose body is rendered by the server
|
// 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
|
// but whose content is a Vue app. In dev the entry module is imported from
|
||||||
// analytics bundle is only fetched when visiting /_a, and unmount the app
|
// the Vite dev server on demand; in production it is inlined into the /_a
|
||||||
// before swapping away so Vue teardown runs cleanly.
|
// page as script#pagerite-js-analytics, which a fetch-navigation swap does
|
||||||
let analyticsUnmount = null;
|
// 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() {
|
function teardownAnalytics() {
|
||||||
analyticsUnmount?.();
|
// Remove even the server-rendered script element so a later return to
|
||||||
analyticsUnmount = null;
|
// /_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) {
|
async function mountAnalytics(doc) {
|
||||||
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content;
|
if (!doc.getElementById("analytics-app")) return;
|
||||||
if (!src) {
|
// Already mounted: on a full /_a load the inline script has run.
|
||||||
teardownAnalytics();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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");
|
const container = document.getElementById("analytics-app");
|
||||||
if (container) {
|
if (container) mod.mount(container);
|
||||||
mod.mount(container);
|
|
||||||
analyticsUnmount = mod.unmount;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("analytics mount failed:", e);
|
console.error("analytics mount failed:", e);
|
||||||
}
|
}
|
||||||
@@ -504,8 +554,8 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// Reflect any redirect the server issued.
|
// Reflect any redirect the server issued.
|
||||||
if (res.redirected) finalUrl = res.url;
|
if (res.redirected) finalUrl = res.url;
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
// Populate the cache too, or the post-swap preload (which includes
|
// Populate the cache too, so returning here (back/forward, or a
|
||||||
// location.pathname) would fetch the very page we just loaded again.
|
// self-link in the nav) is served from memory.
|
||||||
pageCache.set(new URL(finalUrl, location.href).pathname, html);
|
pageCache.set(new URL(finalUrl, location.href).pathname, html);
|
||||||
doc = new DOMParser().parseFromString(html, "text/html");
|
doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -534,27 +584,47 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
} else if (oldSidebar) {
|
} else if (oldSidebar) {
|
||||||
oldSidebar.remove();
|
oldSidebar.remove();
|
||||||
}
|
}
|
||||||
// Site-wide custom CSS lives in <head id="pagerite-user"> and must be
|
// Stylesheets live in <head> with stable ids — links in dev, inline
|
||||||
// kept in sync across fetch-navigations. It is kept last in <head>:
|
// <style> elements in production — and must follow the swap: the
|
||||||
// in dev Vite injects the base stylesheet after the server-rendered
|
// analytics sheet exists on /_a only, and theme/banner/custom CSS
|
||||||
// tag, and equal-specificity :root rules are decided by order.
|
// may have changed since this page was loaded. Diff by id, keeping
|
||||||
const oldUserStyle = document.getElementById("pagerite-user");
|
// the fresh document's order; unchanged sheets keep their elements
|
||||||
const newUserStyle = doc.getElementById("pagerite-user");
|
// so their @keyframes are never torn down. Editor-injected sheets
|
||||||
if (oldUserStyle && newUserStyle) {
|
// (data-pagerite, no id) and Vite's dev styles (no id) are left
|
||||||
oldUserStyle.textContent = newUserStyle.textContent;
|
// alone. Mirrors the head sync in swapdoc.js.
|
||||||
document.head.appendChild(oldUserStyle);
|
const sel = 'link[rel="stylesheet"][id], style[id]';
|
||||||
} else if (newUserStyle) {
|
const fresh = [...doc.head.querySelectorAll(sel)];
|
||||||
document.head.appendChild(document.importNode(newUserStyle, true));
|
const freshIds = new Set(fresh.map((el) => el.id));
|
||||||
} else if (oldUserStyle) {
|
for (const el of [...document.head.querySelectorAll(sel)]) {
|
||||||
oldUserStyle.remove();
|
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;
|
document.title = doc.title;
|
||||||
// Banners may contain scripts (canvas etc.), content pages may too.
|
// Banners may contain scripts (canvas etc.), content pages may too.
|
||||||
runScripts(document.getElementById("page-banner"));
|
runScripts(document.getElementById("page-banner"));
|
||||||
runScripts(document.getElementById("main"));
|
runScripts(document.getElementById("main"));
|
||||||
applyEffects();
|
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);
|
mountAnalytics(doc);
|
||||||
};
|
};
|
||||||
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
||||||
@@ -707,6 +777,44 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
fit();
|
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();
|
setupAuth();
|
||||||
applyEffects();
|
applyEffects();
|
||||||
mountAnalytics(document);
|
mountAnalytics(document);
|
||||||
|
|||||||
+26
-19
@@ -59,32 +59,39 @@ function swapRegions(doc) {
|
|||||||
curUserStyle.remove()
|
curUserStyle.remove()
|
||||||
}
|
}
|
||||||
// Theme and other public stylesheets live in <head>, rendered with stable
|
// Theme and other public stylesheets live in <head>, rendered with stable
|
||||||
// ids by the backend; sync them positionally so the custom CSS (rendered
|
// ids by the backend (links in dev, inline <style> elements in prod);
|
||||||
// last) always keeps winning by order. Diff-based: unchanged sheets keep
|
// sync them positionally so the custom CSS (rendered last) always keeps
|
||||||
// their elements, so their @keyframes are never torn down (re-creating
|
// winning by order. Diff-based: unchanged sheets keep their elements, so
|
||||||
// keyframes would replay the editor's slide-in animation).
|
// their @keyframes are never torn down (re-creating keyframes would
|
||||||
const freshLinks = [...doc.head.querySelectorAll('link[rel="stylesheet"]')]
|
// replay the editor's slide-in animation).
|
||||||
const freshIds = new Set(freshLinks.map((l) => l.id))
|
const sel = 'link[rel="stylesheet"][id], style[id]'
|
||||||
for (const link of [...document.head.querySelectorAll('link[rel="stylesheet"]')]) {
|
const freshEls = [...doc.head.querySelectorAll(sel)]
|
||||||
if (!link.dataset.pagerite && !freshIds.has(link.id)) link.remove()
|
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
|
// Insert missing sheets in the fresh document's order, each right after
|
||||||
// its predecessor's element. The first sheet rendered is always the base
|
// 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
|
// CSS, so its element doubles as the fallback anchor when nothing matched
|
||||||
// (e.g. no theme was selected before and the position is otherwise lost).
|
// yet (e.g. no theme was selected before and the position is otherwise
|
||||||
|
// lost).
|
||||||
let anchor = null
|
let anchor = null
|
||||||
for (const link of freshLinks) {
|
for (const el of freshEls) {
|
||||||
const cur = link.id && document.getElementById(link.id)
|
const cur = el.id && document.getElementById(el.id)
|
||||||
if (cur && cur.href === link.href) {
|
if (cur && cur.outerHTML === el.outerHTML) {
|
||||||
anchor = cur
|
anchor = cur
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const el = document.importNode(link, true)
|
const imported = document.importNode(el, true)
|
||||||
// Same id, new URL (theme switch): replace in place, keeping position.
|
// Same id, new content (theme switch): replace in place, keeping position.
|
||||||
if (cur) cur.replaceWith(el)
|
if (cur) cur.replaceWith(imported)
|
||||||
else if (anchor) anchor.after(el)
|
else if (anchor) anchor.after(imported)
|
||||||
else document.getElementById('pagerite-base')?.after(el) ?? document.head.append(el)
|
else {
|
||||||
anchor = el
|
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
|
// The editor keeps its own title while open; only inherit the server title
|
||||||
// when navigating outside the editor (e.g. fetch-navigation swaps).
|
// 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'
|
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
||||||
|
|
||||||
// Proxy content pages (/slug, /path/to/slug) to the FastAPI backend in dev.
|
// Proxy everything except Vite's own dev-time paths and the backend machinery
|
||||||
// Excludes Vite internals (/@..., /src, /node_modules, /__...) and the
|
// to the FastAPI backend in dev. /_api, /_f, /_themes and /_a are handled by
|
||||||
// backend's /_ prefix. /_api, /_f, /_themes and the /_a analytics ping are
|
// the fastapi-vue plugin, and /@..., /src, /node_modules, /__... stay with Vite.
|
||||||
// handled by the fastapi-vue plugin.
|
const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$'
|
||||||
const CONTENT_PROXY = '^\\/(?!_|@|src|node_modules|__)(?:[^./?]+(?:\\/[^./?]+)*)?(?:\\?.*)?$'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
+28
-2
@@ -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
|
known session start a fresh one (missing data, not dropped). The document
|
||||||
GET handler stashes the entry referer (external https origin) and any
|
GET handler stashes the entry referer (external https origin) and any
|
||||||
utm_* query parameters in in-memory IP tables, consumed when the ping
|
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
|
Admin clients ping with ``hide=1``, which records nothing and removes any
|
||||||
visit the session accumulated before logging in. Scanner telltale 404s
|
visit the session accumulated before logging in. Scanner telltale 404s
|
||||||
(dotpaths, *.php) classify the source IP as abuse; its hits — including
|
(dotpaths, *.php) classify the source IP as abuse; its hits — including
|
||||||
@@ -239,6 +245,18 @@ def _utm_tags(query: str) -> dict[str, str]:
|
|||||||
|
|
||||||
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
_CRAWLER_TIMEOUT = timedelta(seconds=10)
|
||||||
|
|
||||||
|
#: UAs of JS-running crawlers, which would register as visitors on their
|
||||||
|
#: ping. Anything calling itself a "bot" matches; known crawlers without
|
||||||
|
#: that token (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|googleother", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bot_ua(ua: str) -> bool:
|
||||||
|
"""True when the UA claims a crawler identity (Googlebot, Applebot, ...)."""
|
||||||
|
return bool(_BOT_UA.search(ua))
|
||||||
|
|
||||||
#: Plain-404 count per IP that classifies it as abuse even without a
|
#: Plain-404 count per IP that classifies it as abuse even without a
|
||||||
#: telltale path hit.
|
#: telltale path hit.
|
||||||
_ABUSE_404_THRESHOLD = 10
|
_ABUSE_404_THRESHOLD = 10
|
||||||
@@ -664,7 +682,10 @@ class Store:
|
|||||||
removed from the stats (the admin browsed anonymously before logging
|
removed from the stats (the admin browsed anonymously before logging
|
||||||
in). Nothing new is recorded.
|
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
|
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
|
the client hashes of any crawler hits flushed by this call, so callers
|
||||||
@@ -685,6 +706,11 @@ class Store:
|
|||||||
return None, flushed
|
return None, flushed
|
||||||
if ip in self.data.abuse_ips:
|
if ip in self.data.abuse_ips:
|
||||||
return None, flushed
|
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.
|
# A real visitor ping cancels any pending crawler hits from this client.
|
||||||
self.pending_crawlers = [
|
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
|
||||||
|
|||||||
+203
-14
@@ -27,14 +27,16 @@ from email.utils import format_datetime
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
import blake3
|
import blake3
|
||||||
import msgspec
|
import msgspec
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
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 fastapi_vue import Frontend
|
||||||
from kanta import Kanta
|
from kanta import Kanta
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from pagerite import analytics, seed, views
|
from pagerite import analytics, seed, views
|
||||||
from pagerite.__main__ import DEVMODE
|
from pagerite.__main__ import DEVMODE
|
||||||
@@ -281,6 +283,79 @@ async def _headers(request: Request, call_next) -> Response:
|
|||||||
return 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):
|
class PageIn(BaseModel):
|
||||||
"""Payload for creating or replacing a page."""
|
"""Payload for creating or replacing a page."""
|
||||||
|
|
||||||
@@ -434,6 +509,33 @@ async def put_settings(settings: SettingsIn) -> None:
|
|||||||
data.version += 1
|
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")
|
@app.put("/_api/settings/favicon")
|
||||||
async def put_favicon(request: Request) -> dict[str, str]:
|
async def put_favicon(request: Request) -> dict[str, str]:
|
||||||
"""Upload a favicon into the content-addressed store and activate it.
|
"""Upload a favicon into the content-addressed store and activate it.
|
||||||
@@ -712,18 +814,19 @@ class AnalyticsPing(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/_a", response_model=None)
|
@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.
|
"""Render the analytics viewer as a normal site page at /_a.
|
||||||
|
|
||||||
The page itself is public, but the data stream (/_api/ws/analytics) stays
|
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
|
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.
|
statistics; others get the viewer with a "could not be loaded" message.
|
||||||
"""
|
"""
|
||||||
return HTMLResponse(
|
return _html_response(
|
||||||
views.render_analytics(
|
request,
|
||||||
data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html
|
"analytics",
|
||||||
),
|
"",
|
||||||
headers={"cache-control": "no-cache"},
|
headers={"cache-control": "no-cache"},
|
||||||
|
etag=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -754,8 +857,9 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
|
|||||||
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
"""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
|
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
|
visit, so bots never register as visits (JS-running crawlers ping too,
|
||||||
with hide=1, which scrubs their session instead of recording it.)
|
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
|
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
|
``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
|
Returns the client hashes of any pending crawler hits flushed to persistent
|
||||||
storage, so callers can schedule async geoip and reverse-DNS enrichment.
|
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 (
|
if (
|
||||||
path == ""
|
path == ""
|
||||||
and str(request.url.query) == "from=devserver.py"
|
and str(request.url.query) == "from=devserver.py"
|
||||||
@@ -986,13 +1096,88 @@ async def front_page(request: Request) -> Response:
|
|||||||
return await show_page(request, "")
|
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
|
# Vue build asset routes are inserted at this position during load(): the
|
||||||
# build mirrors the URL space (/_assets/*, /favicon.ico at the root).
|
# build mirrors the URL space (/_assets/*, /favicon.ico at the root).
|
||||||
frontend.route(app, "/")
|
frontend.route(app, "/")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{path:path}", response_model=None)
|
@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.
|
"""Render the content page at a slug path, or 404.
|
||||||
|
|
||||||
A node without content is a category label: its URL renders a
|
A node without content is a category label: its URL renders a
|
||||||
@@ -1029,8 +1214,10 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
return HTMLResponse(
|
return _html_response(
|
||||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, str(request.base_url).rstrip("/")),
|
request,
|
||||||
|
"page",
|
||||||
|
path,
|
||||||
headers={
|
headers={
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": _http_date(node.modified),
|
"last-modified": _http_date(node.modified),
|
||||||
@@ -1043,8 +1230,10 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
return HTMLResponse(
|
return _html_response(
|
||||||
views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
|
request,
|
||||||
|
"category",
|
||||||
|
path,
|
||||||
404,
|
404,
|
||||||
headers={
|
headers={
|
||||||
"last-modified": _http_date(node.modified),
|
"last-modified": _http_date(node.modified),
|
||||||
@@ -1067,4 +1256,4 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
asyncio.create_task(_enrich_client(client_hash))
|
asyncio.create_task(_enrich_client(client_hash))
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request)
|
||||||
_schedule_client_enrichment(flushed)
|
_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
|
#: Favicon: name of a file in `files` (content-addressed), linked as
|
||||||
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
|
#: <link rel="icon"> on every page. Empty = the build's /favicon.ico.
|
||||||
favicon: str = ""
|
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`
|
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
||||||
#: on startup, then cleared. Never written otherwise.
|
#: on startup, then cleared. Never written otherwise.
|
||||||
pages: dict[str, Page] = {}
|
pages: dict[str, Page] = {}
|
||||||
|
|||||||
+114
-30
@@ -110,6 +110,35 @@ def _editor_css_url(vite_url: str | None) -> str | None:
|
|||||||
return 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(
|
def _layout(
|
||||||
modules: list[str] = (),
|
modules: list[str] = (),
|
||||||
stylesheets: list[str] = (),
|
stylesheets: list[str] = (),
|
||||||
@@ -118,16 +147,21 @@ def _layout(
|
|||||||
banner_design: str = "",
|
banner_design: str = "",
|
||||||
favicon: str = "",
|
favicon: str = "",
|
||||||
social: dict[str, str] | None = None,
|
social: dict[str, str] | None = None,
|
||||||
extra_meta: dict[str, str] | None = None,
|
|
||||||
) -> Template:
|
) -> 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
|
In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev
|
||||||
showing the page, avoiding a flash of unstyled content. Order matters and
|
server and stylesheets use ``blocking="render"`` so the browser waits
|
||||||
is fixed: base (Vite build, absent in dev where Vite injects it from JS),
|
for them before showing the page, avoiding a flash of unstyled content.
|
||||||
theme and banner design (backend-served from pagerite/themes/), entry-
|
In production all page assets are inlined into the document: stylesheets
|
||||||
specific stylesheets (e.g. overlayscrollbars.css), then the user's custom
|
become ``<style>`` elements and module scripts inline ``<script>``s, so
|
||||||
CSS last so it always wins.
|
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
|
In dev, pagerite.js re-appends the backend-rendered theme/design links
|
||||||
(and the custom CSS) after the Vite-injected base styles, keeping this
|
(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
|
``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as
|
||||||
property attributes, everything else (description, twitter:*) as name.
|
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")
|
doc = Document(E.Title, lang="en")
|
||||||
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
|
# 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.
|
# one, browsers fall back to the build's /favicon.ico by convention.
|
||||||
if favicon:
|
if favicon:
|
||||||
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
||||||
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
|
# Asset URLs for the on-demand bundles (editor, analytics) for
|
||||||
# itself once it has validated the session (pages render identically
|
# pagerite.js, which injects the 🖊️ edit pens itself once it has
|
||||||
# for everyone; editing is gated by the auth proxy in front of /_api).
|
# validated the session (pages render identically for everyone; editing
|
||||||
script, editor_css = _editor_assets()
|
# is gated by the auth proxy in front of /_api). Dev passes the Vite
|
||||||
doc.meta(name="pagerite:editor-src", content=script[-1])
|
# dev-server URLs as meta tags (Vite serves the modules and injects
|
||||||
if editor_css:
|
# their CSS for hot reloads); production inlines all page assets and
|
||||||
doc.meta(name="pagerite:editor-css", content=editor_css)
|
# carries the on-demand URLs in one JSON script instead.
|
||||||
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).
|
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
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 = [
|
sheets = [
|
||||||
("pagerite-base", _base_css_url(vite_url)),
|
("pagerite-base", _base_css_url(vite_url)),
|
||||||
("pagerite-theme", _theme_css_url(theme)),
|
("pagerite-theme", _theme_css_url(theme)),
|
||||||
("pagerite-banner", _banner_css_url(banner_design)),
|
("pagerite-banner", _banner_css_url(banner_design)),
|
||||||
]
|
]
|
||||||
for id_, url in sheets:
|
for id_, url in sheets:
|
||||||
if url:
|
if not url:
|
||||||
|
continue
|
||||||
|
if vite_url:
|
||||||
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
|
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
|
||||||
|
else:
|
||||||
|
doc.style(HTML(_inline_asset(url)), id=id_)
|
||||||
for url in stylesheets:
|
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:
|
for src in modules:
|
||||||
doc.script(src=src, type="module")
|
if vite_url:
|
||||||
|
doc.script(src=src, type="module")
|
||||||
if custom_css.strip():
|
if custom_css.strip():
|
||||||
doc.style(custom_css, id="pagerite-user")
|
doc.style(custom_css, id="pagerite-user")
|
||||||
return Template(
|
body = (
|
||||||
doc
|
doc
|
||||||
.header(
|
.header(
|
||||||
E.div(E.Banner, id="page-banner"),
|
E.div(E.Banner, id="page-banner"),
|
||||||
@@ -194,8 +255,23 @@ def _layout(
|
|||||||
E.main(E.Main, id="main"),
|
E.main(E.Main, id="main"),
|
||||||
id="content",
|
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:
|
def _brand_link(brand: str, brand_html: str = "") -> HTML:
|
||||||
@@ -682,14 +758,23 @@ def render_analytics(
|
|||||||
favicon: str = "",
|
favicon: str = "",
|
||||||
brand_html: str = "",
|
brand_html: str = "",
|
||||||
) -> 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()
|
page_scripts, page_stylesheets = _page_assets()
|
||||||
analytics_scripts, analytics_stylesheets = _analytics_assets()
|
analytics_scripts, analytics_stylesheets = _analytics_assets()
|
||||||
scripts = page_scripts + analytics_scripts
|
scripts = page_scripts + analytics_scripts
|
||||||
stylesheets = page_stylesheets + analytics_stylesheets
|
stylesheets = page_stylesheets + analytics_stylesheets
|
||||||
doc = E.article
|
doc = E.article
|
||||||
with doc:
|
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(
|
return str(
|
||||||
_layout(
|
_layout(
|
||||||
scripts,
|
scripts,
|
||||||
@@ -698,7 +783,6 @@ def render_analytics(
|
|||||||
theme,
|
theme,
|
||||||
banner_design(menu, "_a", theme),
|
banner_design(menu, "_a", theme),
|
||||||
favicon,
|
favicon,
|
||||||
extra_meta={"pagerite:analytics-src": analytics_scripts[0]},
|
|
||||||
)(
|
)(
|
||||||
Title=f"Analytics – {brand}" if brand else "Analytics",
|
Title=f"Analytics – {brand}" if brand else "Analytics",
|
||||||
Brand=_brand_link(brand, brand_html),
|
Brand=_brand_link(brand, brand_html),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ dependencies = [
|
|||||||
"mdit-py-plugins>=0.6.1",
|
"mdit-py-plugins>=0.6.1",
|
||||||
"pygments>=2.20.0",
|
"pygments>=2.20.0",
|
||||||
"ua-parser>=1.0.2",
|
"ua-parser>=1.0.2",
|
||||||
|
"zstandard>=0.25.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
Reference in New Issue
Block a user