Refactor analytics to /_a instead of under article pages.
This commit is contained in:
@@ -17,7 +17,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
|||||||
- `seed.py` — demo content, written only on first database creation.
|
- `seed.py` — demo content, written only on first database creation.
|
||||||
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
|
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
|
||||||
- `frontend/src/` — Vue editor and public-page JS entries.
|
- `frontend/src/` — Vue editor and public-page JS entries.
|
||||||
- `main.js` — Vue editor app entry (also mounts the full-screen AnalyticsView).
|
- `main.js` — Vue editor app entry.
|
||||||
|
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
||||||
- `pagerite.js` — public page entry.
|
- `pagerite.js` — public page entry.
|
||||||
- `assets/` — base CSS, Pygments styles, fonts.
|
- `assets/` — base CSS, Pygments styles, fonts.
|
||||||
- `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test).
|
- `scripts/devserver.py` — dev server with auto reload (the user mostly uses this; avoid running the server yourself, ask the user to test).
|
||||||
|
|||||||
+24
-13
@@ -11,8 +11,10 @@ Struct dumped to disk — separate from the kanta content database, path from
|
|||||||
the `POST /_a` ping endpoint, and `GET /_api/analytics` (admin-gated like
|
the `POST /_a` ping endpoint, and `GET /_api/analytics` (admin-gated like
|
||||||
every `/_api` endpoint).
|
every `/_api` endpoint).
|
||||||
- `frontend/src/pagerite.js` — client navigation pings and the 📊 pen.
|
- `frontend/src/pagerite.js` — client navigation pings and the 📊 pen.
|
||||||
- `frontend/src/AnalyticsView.vue` — full-screen viewer (its own Vue app via
|
- `frontend/src/AnalyticsView.vue` — viewer component rendered inside the
|
||||||
`openAnalytics()`/`closeAnalytics()` in `main.js`, not a docked-panel tab).
|
normal site layout on the `/_a` analytics page.
|
||||||
|
- `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView`
|
||||||
|
into `#analytics-app` inside `#main`.
|
||||||
|
|
||||||
## What is collected
|
## What is collected
|
||||||
|
|
||||||
@@ -33,11 +35,11 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
|
|||||||
- **External links** (`https` only): `to` is the link's origin. This is the
|
- **External links** (`https` only): `to` is the link's origin. This is the
|
||||||
exit-link record; the user may continue navigating afterwards (new tab,
|
exit-link record; the user may continue navigating afterwards (new tab,
|
||||||
back), so the exit origin is not necessarily the last trail entry.
|
back), so the exit origin is not necessarily the last trail entry.
|
||||||
- **Excluded**: back/forward (popstate) navigations, and everything while
|
- **Excluded**: back/forward (popstate) navigations, navigation involving
|
||||||
the user is known to be an admin *and SSO is actually in use* — with no
|
the analytics page itself (`/_a`), and everything while the user is known to
|
||||||
auth proxy (dev/test) "admin" is everyone's state, so the gate is off and
|
be an admin *and SSO is actually in use* — with no auth proxy (dev/test)
|
||||||
everything is recorded — or has the editor open (`body.editing`) or the
|
"admin" is everyone's state, so the gate is off and everything is recorded —
|
||||||
analytics view open (`body.analytics-open`) — admin noise, not visits.
|
or has the editor open (`body.editing`). Admin noise, not visits.
|
||||||
- The server validates `to`: internal paths must be valid slug paths
|
- The server validates `to`: internal paths must be valid slug paths
|
||||||
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
||||||
https origin and accepted only when the client sent exactly that.
|
https origin and accepted only when the client sent exactly that.
|
||||||
@@ -126,12 +128,21 @@ this cheap enough; batching can be added later without changing the format.
|
|||||||
## Viewing
|
## Viewing
|
||||||
|
|
||||||
The 📊 pen in the banner corner (admins only, injected by pagerite.js next to
|
The 📊 pen in the banner corner (admins only, injected by pagerite.js next to
|
||||||
the edit pens) opens `AnalyticsView.vue` — a true full-screen app, not an
|
the edit pens) links to `/_a`, the analytics page. It is a normal site page:
|
||||||
overlay: `body.analytics-open` hides the page chrome and the document itself
|
the standard banner, navigation and footer stay in place, and the analytics
|
||||||
scrolls the view, styled by the active theme's variables. It is addressable
|
content is rendered inside `#main`. The page itself is public, but the data
|
||||||
by URL: `#/analytics/<range>` (`week` default; opening via the pen pushes a
|
still comes from `GET /_api/analytics`, which remains admin-gated like the
|
||||||
history entry so the back button exits, and pagerite.js auto-opens it on
|
rest of the management API; visitors without access see the viewer with a
|
||||||
load for editors when the hash is present, so refresh and link sharing work).
|
"could not be loaded" message.
|
||||||
|
|
||||||
|
Because it is a real page, fetch-navigation handles it like any other internal
|
||||||
|
link: clicking the 📊 pen (or any link to `/_a`) fetches the server-rendered
|
||||||
|
HTML, swaps the dynamic regions and mounts the Vue analytics app in place. The
|
||||||
|
range selector updates the URL query string (`?range=week` etc.) so links to
|
||||||
|
a specific range can be shared.
|
||||||
|
|
||||||
|
`AnalyticsView.vue` is no longer a full-screen overlay; the `body.analytics-open`
|
||||||
|
page-chrome hiding and `#/analytics/<range>` hash routing have been removed.
|
||||||
|
|
||||||
Charts are SVG curves (Catmull-Rom over an edge-aware adaptive Gaussian —
|
Charts are SVG curves (Catmull-Rom over an edge-aware adaptive Gaussian —
|
||||||
a change-point detector splits the series at traffic-level shifts, then
|
a change-point detector splits the series at traffic-level shifts, then
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Full-screen analytics app (replaces the page chrome while open; opened via
|
// Analytics viewer rendered as a normal page inside #main. Fetches the raw
|
||||||
// the 📊 pen or directly by URL hash #/analytics/<range>, so refresh and link
|
// collected data from /_api/analytics (admin-gated by the auth proxy) and
|
||||||
// sharing work). Fetches the raw collected data from /_api/analytics
|
// renders totals, smoothed visit/views curves, a transition map, and recent
|
||||||
// (admin-gated by the auth proxy) and renders it: totals, smoothed
|
// visit/crawler tables. Read-only.
|
||||||
// visit/views curves over a selectable range, a transition map, and the
|
|
||||||
// recent visit trails. Read-only.
|
|
||||||
// See docs/analytics.md for the data format.
|
// See docs/analytics.md for the data format.
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { RANGES } from './analytics/time.js'
|
import { RANGES } from './analytics/time.js'
|
||||||
import {
|
import {
|
||||||
calcTotalViews,
|
calcTotalViews,
|
||||||
@@ -23,7 +21,6 @@ import VisitorCharts from './VisitorCharts.vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialRange: { type: String, default: 'week' },
|
initialRange: { type: String, default: 'week' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['close'])
|
|
||||||
|
|
||||||
const data = ref(null)
|
const data = ref(null)
|
||||||
const pageTree = ref(null)
|
const pageTree = ref(null)
|
||||||
@@ -45,22 +42,16 @@ onMounted(async () => {
|
|||||||
} catch { /* map just narrows to pages seen in transitions */ }
|
} catch { /* map just narrows to pages seen in transitions */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
function onKeydown(ev) {
|
|
||||||
if (ev.key === 'Escape') emit('close')
|
|
||||||
}
|
|
||||||
onMounted(() => addEventListener('keydown', onKeydown))
|
|
||||||
onUnmounted(() => removeEventListener('keydown', onKeydown))
|
|
||||||
|
|
||||||
const visits = computed(() => data.value?.visits || [])
|
const visits = computed(() => data.value?.visits || [])
|
||||||
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
const totalViews = computed(() => calcTotalViews(data.value?.views))
|
||||||
|
|
||||||
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
const range = ref(RANGES[props.initialRange] ? props.initialRange : 'week')
|
||||||
|
|
||||||
// Keep the URL shareable: the hash names the open view and its range.
|
// Keep the URL shareable when the range changes.
|
||||||
watch(range, (r) => {
|
watch(range, (r) => {
|
||||||
if (location.hash.startsWith('#/analytics')) {
|
const url = new URL(location.href)
|
||||||
history.replaceState(null, '', `#/analytics/${r}`)
|
url.searchParams.set('range', r)
|
||||||
}
|
history.replaceState(null, '', url)
|
||||||
})
|
})
|
||||||
|
|
||||||
const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value))
|
const visitRows = computed(() => formatVisitRows(visits.value, pageTree.value))
|
||||||
@@ -93,7 +84,7 @@ function countryName(code) {
|
|||||||
{{ r.label }}
|
{{ r.label }}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
|
<a href="/" class="close" title="home">✕</a>
|
||||||
</header>
|
</header>
|
||||||
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
<p v-if="error" class="error">⚠️ {{ error }}</p>
|
||||||
<p v-else-if="!data" class="loading">loading…</p>
|
<p v-else-if="!data" class="loading">loading…</p>
|
||||||
@@ -104,7 +95,7 @@ function countryName(code) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<VisitorCharts :data="data" :range="range" />
|
<VisitorCharts :data="data" :range="range" />
|
||||||
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" @close="emit('close')" />
|
<TransitionGraph :data="data" :range="range" :page-tree="pageTree" />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Recent visits</h2>
|
<h2>Recent visits</h2>
|
||||||
@@ -361,13 +352,3 @@ function countryName(code) {
|
|||||||
.empty, .loading, .error { color: var(--muted); }
|
.empty, .loading, .error { color: var(--muted); }
|
||||||
.error { color: var(--error, #c00); }
|
.error { color: var(--error, #c00); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* True full screen: while the analytics app is open the page chrome is
|
|
||||||
hidden, so the document itself (not an overlay) scrolls the view. */
|
|
||||||
body.analytics-open #banner,
|
|
||||||
body.analytics-open #content,
|
|
||||||
body.analytics-open > footer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const props = defineProps({
|
|||||||
range: { type: String, required: true },
|
range: { type: String, required: true },
|
||||||
pageTree: { type: Array, default: null },
|
pageTree: { type: Array, default: null },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['close'])
|
|
||||||
|
|
||||||
const window = computed(() => rangeWindow(props.range))
|
const window = computed(() => rangeWindow(props.range))
|
||||||
|
|
||||||
@@ -116,7 +115,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
<text :x="x.x" :y="x.y + x.r + 11" class="txlabel">{{ x.label }}</text>
|
<text :x="x.x" :y="x.y + x.r + 11" class="txlabel">{{ x.label }}</text>
|
||||||
</g>
|
</g>
|
||||||
<g v-for="n in graph.nodes" :key="n.path">
|
<g v-for="n in graph.nodes" :key="n.path">
|
||||||
<a :href="n.path" :title="n.title" @click="emit('close')">
|
<a :href="n.path" :title="n.title">
|
||||||
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
||||||
<text :x="n.x" :y="n.y - 2" class="tnodeslug">{{ n.label }}</text>
|
<text :x="n.x" :y="n.y - 2" class="tnodeslug">{{ n.label }}</text>
|
||||||
<text :x="n.x" :y="n.y + 12" class="tnodecount">{{ n.views }}</text>
|
<text :x="n.x" :y="n.y + 12" class="tnodecount">{{ n.views }}</text>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Analytics page entry: mounts AnalyticsView inside the normal page layout.
|
||||||
|
// The backend renders #analytics-app inside #main and links this module for
|
||||||
|
// the initial load; pagerite.js also imports it on fetch-navigation to /_a.
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import AnalyticsView from './AnalyticsView.vue'
|
||||||
|
|
||||||
|
let app = null
|
||||||
|
|
||||||
|
export function mount(container) {
|
||||||
|
if (app) return
|
||||||
|
app = createApp(AnalyticsView, {
|
||||||
|
initialRange: new URLSearchParams(location.search).get('range') || 'week',
|
||||||
|
})
|
||||||
|
app.mount(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmount() {
|
||||||
|
app?.unmount()
|
||||||
|
app = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-mount on a normal (non-fetch) page load.
|
||||||
|
const container = document.getElementById('analytics-app')
|
||||||
|
if (container) mount(container)
|
||||||
@@ -17,7 +17,6 @@ if (import.meta.env.DEV) {
|
|||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import EditorShell from './EditorShell.vue'
|
import EditorShell from './EditorShell.vue'
|
||||||
import AnalyticsView from './AnalyticsView.vue'
|
|
||||||
|
|
||||||
let host = null
|
let host = null
|
||||||
let app = null
|
let app = null
|
||||||
@@ -98,51 +97,4 @@ export function closeEditor() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Full-screen analytics app ---------------------------------------------
|
|
||||||
// Replaces the page chrome while open (body.analytics-open hides it, see
|
|
||||||
// AnalyticsView.vue); opened from the 📊 pen or directly via the URL hash
|
|
||||||
// #/analytics/<range> so refresh and link sharing stay in analytics.
|
|
||||||
let analyticsHost = null
|
|
||||||
let analyticsApp = null
|
|
||||||
|
|
||||||
function analyticsHashRange() {
|
|
||||||
const m = location.hash.match(/^#\/analytics(?:\/(\w+))?/)
|
|
||||||
return m ? m[1] || 'week' : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHashChange() {
|
|
||||||
if (analyticsHashRange() === null) closeAnalytics()
|
|
||||||
else openAnalytics()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openAnalytics() {
|
|
||||||
if (analyticsHost) return
|
|
||||||
let r = analyticsHashRange()
|
|
||||||
if (r === null) {
|
|
||||||
r = 'week'
|
|
||||||
// Pushed (not replaced) so the back button exits the app via hashchange.
|
|
||||||
history.pushState(null, '', `#/analytics/${r}`)
|
|
||||||
}
|
|
||||||
analyticsHost = document.createElement('div')
|
|
||||||
document.body.append(analyticsHost)
|
|
||||||
document.body.classList.add('analytics-open')
|
|
||||||
analyticsApp = createApp(AnalyticsView, {
|
|
||||||
initialRange: r,
|
|
||||||
onClose: closeAnalytics,
|
|
||||||
})
|
|
||||||
analyticsApp.mount(analyticsHost)
|
|
||||||
addEventListener('hashchange', onHashChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeAnalytics() {
|
|
||||||
if (!analyticsHost) return
|
|
||||||
removeEventListener('hashchange', onHashChange)
|
|
||||||
analyticsApp?.unmount()
|
|
||||||
analyticsApp = null
|
|
||||||
analyticsHost?.remove()
|
|
||||||
analyticsHost = null
|
|
||||||
document.body.classList.remove('analytics-open')
|
|
||||||
if (analyticsHashRange() !== null) {
|
|
||||||
history.replaceState(null, '', location.pathname + location.search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+54
-39
@@ -97,30 +97,30 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||||
// Paskia SSO is detected at all.
|
// Paskia SSO is detected at all.
|
||||||
const canEdit = isAdmin || !ssoAvailable;
|
const canEdit = isAdmin || !ssoAvailable;
|
||||||
|
// The analytics page is a read-only dashboard: editing pens and the side
|
||||||
|
// panel do not apply there. Login/logout links are still useful.
|
||||||
|
const onAnalytics = currentPath === "/_a";
|
||||||
const banner = document.getElementById("page-banner");
|
const banner = document.getElementById("page-banner");
|
||||||
if (banner) {
|
if (banner) {
|
||||||
const old = banner.parentElement.querySelector(".editor-pens");
|
const old = banner.parentElement.querySelector(".editor-pens");
|
||||||
if (old) old.remove();
|
if (old) old.remove();
|
||||||
const pens = document.createElement("div");
|
const pens = document.createElement("div");
|
||||||
pens.className = "editor-pens";
|
pens.className = "editor-pens";
|
||||||
if (canEdit) {
|
if (canEdit && !onAnalytics) {
|
||||||
pens.append(makePen("banner"));
|
pens.append(makePen("banner"));
|
||||||
pens.append(makePen("site"));
|
pens.append(makePen("site"));
|
||||||
if (editorMeta) {
|
// Analytics viewer is now a normal page at /_a.
|
||||||
// Full-screen analytics view (separate from the docked panel).
|
const a = document.createElement("a");
|
||||||
const btn = document.createElement("button");
|
a.className = "edit-link analytics-link";
|
||||||
btn.type = "button";
|
a.href = "/_a";
|
||||||
btn.className = "edit-link analytics-link";
|
a.title = "analytics";
|
||||||
btn.title = "analytics";
|
a.textContent = "📊";
|
||||||
btn.textContent = "📊";
|
pens.append(a);
|
||||||
btn.dataset.editorSrc = editorMeta.src;
|
|
||||||
pens.append(btn);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||||
banner.after(pens);
|
banner.after(pens);
|
||||||
}
|
}
|
||||||
if (canEdit) injectPagePen();
|
if (canEdit && !onAnalytics) injectPagePen();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupAuth() {
|
async function setupAuth() {
|
||||||
@@ -149,18 +149,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
|
|
||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
pingEntryOnce();
|
pingEntryOnce();
|
||||||
|
|
||||||
// The analytics app is addressable by URL (#/analytics/<range>), so a
|
|
||||||
// refresh or a shared link lands back in it. Only for editors.
|
|
||||||
const openAnalyticsFromHash = () => {
|
|
||||||
if (!location.hash.startsWith("#/analytics")) return;
|
|
||||||
if (!(isAdmin || !ssoAvailable) || !editorMeta) return;
|
|
||||||
import(/* @vite-ignore */ editorMeta.src)
|
|
||||||
.then((m) => m.openAnalytics())
|
|
||||||
.catch((e) => console.error("analytics view load failed:", e));
|
|
||||||
};
|
|
||||||
openAnalyticsFromHash();
|
|
||||||
addEventListener("hashchange", openAnalyticsFromHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returning to the page via history back/forward may restore a cached
|
// Returning to the page via history back/forward may restore a cached
|
||||||
@@ -356,11 +344,13 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// back/forward (popstate never pings) and everything while we know the
|
// back/forward (popstate never pings) and everything while we know the
|
||||||
// user is an admin — but only when SSO is actually in use; with no auth
|
// user is an admin — but only when SSO is actually in use; with no auth
|
||||||
// (dev/test) "admin" is everyone's state and nothing would be recorded —
|
// (dev/test) "admin" is everyone's state and nothing would be recorded —
|
||||||
// or has the editor/analytics view open (admin noise, not visits).
|
// or has the editor open (admin noise, not visits). The analytics page
|
||||||
|
// itself (/_a) is also excluded even though fetch-navigation treats it like
|
||||||
|
// a normal article.
|
||||||
// See docs/analytics.md.
|
// See docs/analytics.md.
|
||||||
function ping(to, fr = currentPath) {
|
function ping(to, fr = currentPath) {
|
||||||
if ((ssoAvailable && isAdmin) || document.body.classList.contains("editing")
|
if ((ssoAvailable && isAdmin) || document.body.classList.contains("editing")
|
||||||
|| document.body.classList.contains("analytics-open")) return;
|
|| to === "/_a" || fr === "/_a") return;
|
||||||
try {
|
try {
|
||||||
fetch("/_a", {
|
fetch("/_a", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -385,6 +375,36 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
ping(currentPath);
|
ping(currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Analytics page mount/unmount --------------------------------------
|
||||||
|
// The analytics page is a normal page whose body is rendered by the server
|
||||||
|
// but whose content is a Vue app. We load the entry module on demand so the
|
||||||
|
// analytics bundle is only fetched when visiting /_a, and unmount the app
|
||||||
|
// before swapping away so Vue teardown runs cleanly.
|
||||||
|
let analyticsUnmount = null;
|
||||||
|
|
||||||
|
function teardownAnalytics() {
|
||||||
|
analyticsUnmount?.();
|
||||||
|
analyticsUnmount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountAnalytics(doc) {
|
||||||
|
const src = doc.querySelector('meta[name="pagerite:analytics-src"]')?.content;
|
||||||
|
if (!src) {
|
||||||
|
teardownAnalytics();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mod = await import(/* @vite-ignore */ src);
|
||||||
|
const container = document.getElementById("analytics-app");
|
||||||
|
if (container) {
|
||||||
|
mod.mount(container);
|
||||||
|
analyticsUnmount = mod.unmount;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("analytics mount failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Fetch navigation ------------------------------------------------
|
// --- Fetch navigation ------------------------------------------------
|
||||||
async function load(url, push = true, back = false) {
|
async function load(url, push = true, back = false) {
|
||||||
// Navigating with the editor open closes it; unsaved edits are lost
|
// Navigating with the editor open closes it; unsaved edits are lost
|
||||||
@@ -392,6 +412,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
if (document.body.classList.contains("editing")) {
|
if (document.body.classList.contains("editing")) {
|
||||||
editorModule?.then((m) => m.closeEditor());
|
editorModule?.then((m) => m.closeEditor());
|
||||||
}
|
}
|
||||||
|
teardownAnalytics();
|
||||||
let doc;
|
let doc;
|
||||||
let finalUrl = url;
|
let finalUrl = url;
|
||||||
const cached = pageCache.get(new URL(url, location.href).pathname);
|
const cached = pageCache.get(new URL(url, location.href).pathname);
|
||||||
@@ -450,6 +471,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
runScripts(document.getElementById("page-banner"));
|
runScripts(document.getElementById("page-banner"));
|
||||||
runScripts(document.getElementById("main"));
|
runScripts(document.getElementById("main"));
|
||||||
applyEffects();
|
applyEffects();
|
||||||
|
mountAnalytics(document);
|
||||||
};
|
};
|
||||||
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
// Rotating cube page transition (see the FRAGILE block in pagerite.css);
|
||||||
// mirrored when navigating back through history. Navigation within the
|
// mirrored when navigating back through history. Navigation within the
|
||||||
@@ -479,16 +501,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// the Vue app on demand (with any extra styles) and mount it in place.
|
// the Vue app on demand (with any extra styles) and mount it in place.
|
||||||
// Clicking the pen of the already-open tab closes the shell; clicking
|
// Clicking the pen of the already-open tab closes the shell; clicking
|
||||||
// another pen switches the shell to that tab.
|
// another pen switches the shell to that tab.
|
||||||
// The 📊 pen opens the full-screen analytics view (its own Vue app,
|
|
||||||
// not a tab of the docked editor shell).
|
|
||||||
const analyticsBtn = ev.target.closest("button.analytics-link");
|
|
||||||
if (analyticsBtn && analyticsBtn.dataset.editorSrc) {
|
|
||||||
ev.preventDefault();
|
|
||||||
import(/* @vite-ignore */ analyticsBtn.dataset.editorSrc)
|
|
||||||
.then((m) => m.openAnalytics())
|
|
||||||
.catch((e) => console.error("analytics view load failed:", e));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const editBtn = ev.target.closest("button.edit-link");
|
const editBtn = ev.target.closest("button.edit-link");
|
||||||
if (editBtn && editBtn.dataset.editorSrc) {
|
if (editBtn && editBtn.dataset.editorSrc) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -525,8 +537,10 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
}
|
}
|
||||||
// Same-page anchor links (footnotes etc.): let the browser handle them
|
// Same-page anchor links (footnotes etc.): let the browser handle them
|
||||||
if (url.pathname === location.pathname && url.hash) return;
|
if (url.pathname === location.pathname && url.hash) return;
|
||||||
// Machinery and auth endpoints are never fetch-navigated.
|
// Machinery and auth endpoints are never fetch-navigated, except the
|
||||||
if (url.pathname.startsWith("/_") || url.pathname.startsWith("/auth")) return;
|
// public analytics viewer page at /_a.
|
||||||
|
if ((url.pathname.startsWith("/_") && url.pathname !== "/_a")
|
||||||
|
|| url.pathname.startsWith("/auth")) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
// Capture the source now: load() updates currentPath before pinging.
|
// Capture the source now: load() updates currentPath before pinging.
|
||||||
const from = currentPath;
|
const from = currentPath;
|
||||||
@@ -534,7 +548,7 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
});
|
});
|
||||||
|
|
||||||
addEventListener("popstate", () => {
|
addEventListener("popstate", () => {
|
||||||
// Hash-only history entries (the analytics app) are not navigations.
|
// Hash-only history entries are not navigations.
|
||||||
if (location.pathname === currentPath) return;
|
if (location.pathname === currentPath) return;
|
||||||
load(location.href, false, true);
|
load(location.href, false, true);
|
||||||
});
|
});
|
||||||
@@ -603,4 +617,5 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
|
|
||||||
setupAuth();
|
setupAuth();
|
||||||
applyEffects();
|
applyEffects();
|
||||||
|
mountAnalytics(document);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default defineConfig({
|
|||||||
input: {
|
input: {
|
||||||
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
||||||
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
||||||
|
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
|
||||||
// Only the base CSS is built; theme/banner-design stylesheets live
|
// Only the base CSS is built; theme/banner-design stylesheets live
|
||||||
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
||||||
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
||||||
|
|||||||
+17
-1
@@ -631,6 +631,22 @@ class AnalyticsPing(BaseModel):
|
|||||||
to: str
|
to: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/_a", response_model=None)
|
||||||
|
async def analytics_page(request: Request) -> HTMLResponse:
|
||||||
|
"""Render the analytics viewer as a normal site page at /_a.
|
||||||
|
|
||||||
|
The page itself is public, but the data endpoint (/_api/analytics) stays
|
||||||
|
admin-gated like the rest of /_api, so only authorized users see the
|
||||||
|
statistics; others get the viewer with a "could not be loaded" message.
|
||||||
|
"""
|
||||||
|
return HTMLResponse(
|
||||||
|
views.render_analytics(
|
||||||
|
data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html
|
||||||
|
),
|
||||||
|
headers={"cache-control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/_a", status_code=204)
|
@app.post("/_a", status_code=204)
|
||||||
async def analytics_ping(ping: AnalyticsPing, request: Request) -> None:
|
async def analytics_ping(ping: AnalyticsPing, request: Request) -> None:
|
||||||
"""Record a navigation ping ({fr, to}); fire-and-forget, never fails.
|
"""Record a navigation ping ({fr, to}); fire-and-forget, never fails.
|
||||||
@@ -716,7 +732,7 @@ async def get_analytics() -> Response:
|
|||||||
"""The collected visit analytics as JSON (see docs/analytics.md).
|
"""The collected visit analytics as JSON (see docs/analytics.md).
|
||||||
|
|
||||||
Admin-only via the /_api forward-auth gate, like every management
|
Admin-only via the /_api forward-auth gate, like every management
|
||||||
endpoint. Powers the full-screen analytics viewer in the frontend.
|
endpoint. Powers the analytics viewer rendered at /_a.
|
||||||
"""
|
"""
|
||||||
return Response(
|
return Response(
|
||||||
msgspec.json.encode(analytics_store.data), media_type="application/json"
|
msgspec.json.encode(analytics_store.data), media_type="application/json"
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ 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 asset URLs and ES-module scripts.
|
||||||
|
|
||||||
@@ -134,6 +135,9 @@ 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
|
||||||
@@ -158,6 +162,8 @@ def _layout(
|
|||||||
doc.meta(name="pagerite:editor-src", content=script[-1])
|
doc.meta(name="pagerite:editor-src", content=script[-1])
|
||||||
if editor_css:
|
if editor_css:
|
||||||
doc.meta(name="pagerite:editor-css", content=editor_css)
|
doc.meta(name="pagerite:editor-css", content=editor_css)
|
||||||
|
for key, value in (extra_meta or {}).items():
|
||||||
|
doc.meta(name=key, content=value)
|
||||||
# Stylesheet links carry stable ids so the site editor's hot swap can
|
# Stylesheet links carry stable ids so the site editor's hot swap can
|
||||||
# keep each sheet at its rendered position (see swapRegions).
|
# 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")
|
||||||
@@ -652,3 +658,53 @@ def _editor_assets() -> tuple[list[str], str | None]:
|
|||||||
entry = manifest["src/main.js"]
|
entry = manifest["src/main.js"]
|
||||||
_asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
|
_asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
|
||||||
return _asset_cache["editor"]
|
return _asset_cache["editor"]
|
||||||
|
|
||||||
|
|
||||||
|
def _analytics_assets() -> tuple[list[str], list[str]]:
|
||||||
|
"""Script and stylesheet URLs for the analytics page entry."""
|
||||||
|
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||||
|
if vite_url:
|
||||||
|
return [f"{vite_url}/src/analytics-main.js"], []
|
||||||
|
if "analytics" not in _asset_cache:
|
||||||
|
manifest = _manifest()
|
||||||
|
entry = manifest["src/analytics-main.js"]
|
||||||
|
scripts = [f"/{entry['file']}"]
|
||||||
|
stylesheets = [f"/{css}" for css in entry.get("css", [])]
|
||||||
|
_asset_cache["analytics"] = scripts, stylesheets
|
||||||
|
return _asset_cache["analytics"]
|
||||||
|
|
||||||
|
|
||||||
|
def render_analytics(
|
||||||
|
menu: dict[str, Node],
|
||||||
|
brand: str = SITE_NAME,
|
||||||
|
custom_css: str = "",
|
||||||
|
theme: str = "",
|
||||||
|
favicon: str = "",
|
||||||
|
brand_html: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Render the analytics viewer as a normal page at /_a."""
|
||||||
|
page_scripts, page_stylesheets = _page_assets()
|
||||||
|
analytics_scripts, analytics_stylesheets = _analytics_assets()
|
||||||
|
scripts = page_scripts + analytics_scripts
|
||||||
|
stylesheets = page_stylesheets + analytics_stylesheets
|
||||||
|
doc = E.article
|
||||||
|
with doc:
|
||||||
|
doc.div(id="analytics-app")
|
||||||
|
return str(
|
||||||
|
_layout(
|
||||||
|
scripts,
|
||||||
|
stylesheets,
|
||||||
|
custom_css,
|
||||||
|
theme,
|
||||||
|
banner_design(menu, "_a", theme),
|
||||||
|
favicon,
|
||||||
|
extra_meta={"pagerite:analytics-src": analytics_scripts[0]},
|
||||||
|
)(
|
||||||
|
Title=f"Analytics – {brand}" if brand else "Analytics",
|
||||||
|
Brand=_brand_link(brand, brand_html),
|
||||||
|
Nav=nav_html(menu, "_a"),
|
||||||
|
Sidebar=sidebar_html(menu, "_a"),
|
||||||
|
Banner=banner_html(menu, "_a", theme),
|
||||||
|
Main=HTML(str(doc)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user