Refactor analytics to /_a instead of under article pages.

This commit is contained in:
2026-08-20 23:16:51 +00:00
parent 7f4bc8efa4
commit f78229bd30
10 changed files with 190 additions and 134 deletions
+2 -1
View File
@@ -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
View File
@@ -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
+11 -30
View File
@@ -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>
+1 -2
View File
@@ -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>
+24
View File
@@ -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)
-48
View File
@@ -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
View File
@@ -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);
})(); })();
+1
View File
@@ -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
View File
@@ -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"
+56
View File
@@ -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)),
),
)