Add analytics tracking to API endpoints for improved event monitoring. Updated GET handlers across various routes to include tracking of successful and failed requests, enhancing data collection for package usage insights.

This commit is contained in:
Luke Hagar
2025-08-29 15:46:54 -05:00
parent 8d6c5c67a2
commit 128b923760
12 changed files with 240 additions and 23 deletions

56
src/lib/analytics.ts Normal file
View File

@@ -0,0 +1,56 @@
import { PLAUSIBLE_DOMAIN as PUBLIC_DOMAIN, PLAUSIBLE_EVENT_ENDPOINT } from '$lib/plausible-config.js';
type TrackProps = Record<string, string | number | boolean | null | undefined>;
function getEnv(name: string, fallback: string = ''): string {
const v = process.env[name];
return typeof v === 'string' && v.trim() !== '' ? v : fallback;
}
function buildEventUrl(pathname: string): string {
const siteBase = 'https://pypistats.dev';
return `${siteBase}${pathname}`;
}
export function trackApiEvent(
name: string,
pathname: string,
props: TrackProps = {},
requestHeaders?: Headers
): void {
const endpoint = PLAUSIBLE_EVENT_ENDPOINT;
const domain = PUBLIC_DOMAIN;
if (!domain) return;
const url = endpoint;
const body = {
name,
url: buildEventUrl(pathname),
domain,
props
} as any;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
const apiKey = getEnv('PLAUSIBLE_API_KEY');
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
const ua = requestHeaders?.get('user-agent');
if (ua) headers['User-Agent'] = ua;
const ip =
requestHeaders?.get('x-forwarded-for') ||
requestHeaders?.get('x-real-ip') ||
'';
if (ip) headers['X-Forwarded-For'] = ip;
// Fire-and-forget; never throw or await
void fetch(url, { method: 'POST', headers, body: JSON.stringify(body) })
.then(() => {})
.catch(() => {});
}

View File

@@ -0,0 +1,15 @@
// Hardcoded Plausible configuration for public usage
// Self-hosted Plausible server base
export const PLAUSIBLE_BASE = 'https://events.plygrnd.org';
// Standard event endpoint derived from the unified base
export const PLAUSIBLE_EVENT_ENDPOINT = `${PLAUSIBLE_BASE}/api/event`;
// Public domain/site ID for this app
export const PLAUSIBLE_DOMAIN = 'pypistats.dev';
// Dev capture toggle (public) — default false; override via Vite define if needed
export const PLAUSIBLE_CAPTURE_LOCALHOST = false;

View File

@@ -3,13 +3,14 @@ import { dev } from '$app/environment';
import { CacheManager } from '$lib/redis.js'; import { CacheManager } from '$lib/redis.js';
import { getOverallDownloads, getPythonMajorDownloads, getPythonMinorDownloads, getSystemDownloads, getInstallerDownloads, getVersionDownloads } from '$lib/api.js'; import { getOverallDownloads, getPythonMajorDownloads, getPythonMinorDownloads, getSystemDownloads, getInstallerDownloads, getVersionDownloads } from '$lib/api.js';
import { ChartJSNodeCanvas } from 'chartjs-node-canvas'; import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
import { trackApiEvent } from '$lib/analytics.js';
const cache = new CacheManager(); const cache = new CacheManager();
const width = 1200; const width = 1200;
const height = 600; const height = 600;
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const type = params.type || 'overall'; const type = params.type || 'overall';
const chartType = (url.searchParams.get('chart') || 'line').toLowerCase(); // 'line' | 'bar' const chartType = (url.searchParams.get('chart') || 'line').toLowerCase(); // 'line' | 'bar'
@@ -102,6 +103,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
labels, labels,
datasets datasets
}; };
trackApiEvent('api_chart', `/api/packages/${encodeURIComponent(packageName)}/chart/${type}`, {
package: packageName,
type,
chart: chartType,
format: 'json',
ok: true
}, request.headers);
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' } }); return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' } });
} }
@@ -140,9 +148,22 @@ export const GET: RequestHandler = async ({ params, url }) => {
await cache.set(cacheKey, image.toString('base64'), 3600); await cache.set(cacheKey, image.toString('base64'), 3600);
} }
trackApiEvent('api_chart', `/api/packages/${encodeURIComponent(packageName)}/chart/${type}`, {
package: packageName,
type,
chart: chartType,
format: 'png',
ok: true
}, request.headers);
return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } }); return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } });
} catch (error) { } catch (error) {
console.error('Error rendering chart:', error); console.error('Error rendering chart:', error);
trackApiEvent('api_chart', `/api/packages/${encodeURIComponent(packageName)}/chart/${type}`, {
package: packageName,
type,
chart: chartType,
ok: false
}, request.headers);
return new Response('Internal server error', { status: 500 }); return new Response('Internal server error', { status: 500 });
} }
}; };

View File

@@ -2,8 +2,9 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma.js'; import { prisma } from '$lib/prisma.js';
import { DataProcessor } from '$lib/data-processor.js'; import { DataProcessor } from '$lib/data-processor.js';
import { trackApiEvent } from '$lib/analytics.js';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') { if (!packageName || packageName === '__all__') {
return json({ error: 'Invalid package name' }, { status: 400 }); return json({ error: 'Invalid package name' }, { status: 400 });
@@ -23,9 +24,17 @@ export const GET: RequestHandler = async ({ params }) => {
type: 'installer_downloads', type: 'installer_downloads',
data: rows.map(r => ({ date: r.date, category: r.category, downloads: r.downloads })) data: rows.map(r => ({ date: r.date, category: r.category, downloads: r.downloads }))
}; };
trackApiEvent('api_installer', `/api/packages/${encodeURIComponent(packageName)}/installer`, {
package: packageName,
ok: true
}, request.headers);
return json(response); return json(response);
} catch (error) { } catch (error) {
console.error('Error fetching installer downloads:', error); console.error('Error fetching installer downloads:', error);
trackApiEvent('api_installer', `/api/packages/${encodeURIComponent(packageName)}/installer`, {
package: packageName,
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getOverallDownloads } from '$lib/api.js'; import { getOverallDownloads } from '$lib/api.js';
import { trackApiEvent } from '$lib/analytics.js';
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const mirrors = url.searchParams.get('mirrors'); const mirrors = url.searchParams.get('mirrors');
@@ -27,9 +28,19 @@ export const GET: RequestHandler = async ({ params, url }) => {
})) }))
}; };
trackApiEvent('api_overall', `/api/packages/${encodeURIComponent(packageName)}/overall`, {
package: packageName,
mirrors: String(mirrors ?? ''),
ok: true
}, request.headers);
return json(response); return json(response);
} catch (error) { } catch (error) {
console.error('Error fetching overall downloads:', error); console.error('Error fetching overall downloads:', error);
trackApiEvent('api_overall', `/api/packages/${encodeURIComponent(packageName)}/overall`, {
package: packageName,
mirrors: String(mirrors ?? ''),
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getPythonMajorDownloads } from '$lib/api.js'; import { getPythonMajorDownloads } from '$lib/api.js';
import { trackApiEvent } from '$lib/analytics.js';
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const version = url.searchParams.get('version'); const version = url.searchParams.get('version');
@@ -26,10 +27,19 @@ export const GET: RequestHandler = async ({ params, url }) => {
downloads: r.downloads downloads: r.downloads
})) }))
}; };
trackApiEvent('api_python_major', `/api/packages/${encodeURIComponent(packageName)}/python_major`, {
package: packageName,
version: String(version ?? ''),
ok: true
}, request.headers);
return json(response); return json(response);
} catch (error) { } catch (error) {
console.error('Error fetching Python major downloads:', error); console.error('Error fetching Python major downloads:', error);
trackApiEvent('api_python_major', `/api/packages/${encodeURIComponent(packageName)}/python_major`, {
package: packageName,
version: String(version ?? ''),
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getPythonMinorDownloads } from '$lib/api.js'; import { getPythonMinorDownloads } from '$lib/api.js';
import { trackApiEvent } from '$lib/analytics.js';
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const version = url.searchParams.get('version'); const version = url.searchParams.get('version');
@@ -26,10 +27,19 @@ export const GET: RequestHandler = async ({ params, url }) => {
downloads: r.downloads downloads: r.downloads
})) }))
}; };
trackApiEvent('api_python_minor', `/api/packages/${encodeURIComponent(packageName)}/python_minor`, {
package: packageName,
version: String(version ?? ''),
ok: true
}, request.headers);
return json(response); return json(response);
} catch (error) { } catch (error) {
console.error('Error fetching Python minor downloads:', error); console.error('Error fetching Python minor downloads:', error);
trackApiEvent('api_python_minor', `/api/packages/${encodeURIComponent(packageName)}/python_minor`, {
package: packageName,
version: String(version ?? ''),
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -4,6 +4,7 @@ import { getRecentDownloads } from '$lib/api.js';
import { RECENT_CATEGORIES } from '$lib/database.js'; import { RECENT_CATEGORIES } from '$lib/database.js';
import { RateLimiter } from '$lib/redis.js'; import { RateLimiter } from '$lib/redis.js';
import { DataProcessor } from '$lib/data-processor.js'; import { DataProcessor } from '$lib/data-processor.js';
import { trackApiEvent } from '$lib/analytics.js';
const rateLimiter = new RateLimiter(); const rateLimiter = new RateLimiter();
@@ -62,9 +63,19 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
'X-RateLimit-Reset': (Math.floor(Date.now() / 1000) + 3600).toString() 'X-RateLimit-Reset': (Math.floor(Date.now() / 1000) + 3600).toString()
}; };
trackApiEvent('api_recent', `/api/packages/${encodeURIComponent(packageName)}/recent`, {
package: packageName,
period: String(category ?? ''),
ok: true
}, request.headers);
return json(response, { headers }); return json(response, { headers });
} catch (error) { } catch (error) {
console.error('Error fetching recent downloads:', error); console.error('Error fetching recent downloads:', error);
trackApiEvent('api_recent', `/api/packages/${encodeURIComponent(packageName)}/recent`, {
package: packageName,
period: String(category ?? ''),
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -2,8 +2,9 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma.js'; import { prisma } from '$lib/prisma.js';
import { DataProcessor } from '$lib/data-processor.js'; import { DataProcessor } from '$lib/data-processor.js';
import { trackApiEvent } from '$lib/analytics.js';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
if (!packageName || packageName === '__all__') { if (!packageName || packageName === '__all__') {
return json({ error: 'Invalid package name' }, { status: 400 }); return json({ error: 'Invalid package name' }, { status: 400 });
@@ -41,6 +42,10 @@ export const GET: RequestHandler = async ({ params }) => {
const pythonMajorTotals = Object.fromEntries(pyMajorAll.map(r => [r.category, Number(r._sum.downloads || 0)])); const pythonMajorTotals = Object.fromEntries(pyMajorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
const pythonMinorTotals = Object.fromEntries(pyMinorAll.map(r => [r.category, Number(r._sum.downloads || 0)])); const pythonMinorTotals = Object.fromEntries(pyMinorAll.map(r => [r.category, Number(r._sum.downloads || 0)]));
trackApiEvent('api_summary', `/api/packages/${encodeURIComponent(packageName)}/summary`, {
package: packageName,
ok: true
}, request.headers);
return json({ return json({
package: packageName, package: packageName,
type: 'summary', type: 'summary',
@@ -53,6 +58,10 @@ export const GET: RequestHandler = async ({ params }) => {
}); });
} catch (error) { } catch (error) {
console.error('Error building package summary:', error); console.error('Error building package summary:', error);
trackApiEvent('api_summary', `/api/packages/${encodeURIComponent(packageName)}/summary`, {
package: packageName,
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getSystemDownloads } from '$lib/api.js'; import { getSystemDownloads } from '$lib/api.js';
import { trackApiEvent } from '$lib/analytics.js';
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || '';
const os = url.searchParams.get('os'); const os = url.searchParams.get('os');
@@ -26,10 +27,19 @@ export const GET: RequestHandler = async ({ params, url }) => {
downloads: r.downloads downloads: r.downloads
})) }))
}; };
trackApiEvent('api_system', `/api/packages/${encodeURIComponent(packageName)}/system`, {
package: packageName,
os: String(os ?? ''),
ok: true
}, request.headers);
return json(response); return json(response);
} catch (error) { } catch (error) {
console.error('Error fetching system downloads:', error); console.error('Error fetching system downloads:', error);
trackApiEvent('api_system', `/api/packages/${encodeURIComponent(packageName)}/system`, {
package: packageName,
os: String(os ?? ''),
ok: false
}, request.headers);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });
} }
}; };

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { afterNavigate } from '$app/navigation';
import { PLAUSIBLE_DOMAIN, PLAUSIBLE_EVENT_ENDPOINT, PLAUSIBLE_CAPTURE_LOCALHOST } from '$lib/plausible-config.js';
import LoadingSpinner from '$lib/components/LoadingSpinner.svelte'; import LoadingSpinner from '$lib/components/LoadingSpinner.svelte';
import { MetaTags } from 'svelte-meta-tags'; import { MetaTags } from 'svelte-meta-tags';
const { data }: { data: PageData } = $props(); const { data }: { data: PageData } = $props();
@@ -12,6 +14,37 @@
let charts: any[] = []; let charts: any[] = [];
let installerCanvas: HTMLCanvasElement | null = $state(null); let installerCanvas: HTMLCanvasElement | null = $state(null);
// Plausible: track package views
let lastTrackedPackage: string = '';
let plausibleInitDone: boolean = false;
let plausibleTrack: ((name: string, options?: any) => void) | null = null;
async function ensurePlausible() {
if (plausibleInitDone && plausibleTrack) return;
if (typeof window === 'undefined') return;
const mod = await import('@plausible-analytics/tracker');
const domain = PLAUSIBLE_DOMAIN || window.location.hostname;
const endpoint = PLAUSIBLE_EVENT_ENDPOINT || undefined;
mod.init({
domain,
endpoint,
captureOnLocalhost: PLAUSIBLE_CAPTURE_LOCALHOST,
logging: false,
bindToWindow: false
});
plausibleTrack = mod.track;
plausibleInitDone = true;
}
async function trackPackageView(pkg: string) {
if (!pkg || lastTrackedPackage === pkg) return;
if (typeof window === 'undefined') return;
await ensurePlausible();
if (!plausibleTrack) return;
plausibleTrack('package_view', { props: { package: pkg } });
lastTrackedPackage = pkg;
}
const numberFormatter = new Intl.NumberFormat(undefined); const numberFormatter = new Intl.NumberFormat(undefined);
const compactFormatter = new Intl.NumberFormat(undefined, { const compactFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact', notation: 'compact',
@@ -138,6 +171,9 @@
} }
onMount(() => { onMount(() => {
// Track initial view
if (data?.packageName) trackPackageView(data.packageName);
loadAndRenderChart(overallCanvas, 'overall'); loadAndRenderChart(overallCanvas, 'overall');
requestAnimationFrame(() => { requestAnimationFrame(() => {
loadAndRenderChart(pyMajorCanvas, 'python_major'); loadAndRenderChart(pyMajorCanvas, 'python_major');
@@ -147,6 +183,19 @@
}); });
}); });
afterNavigate((nav) => {
let url: URL;
const maybe = (nav as any);
if (maybe && maybe.to && maybe.to.url instanceof URL) {
url = maybe.to.url as URL;
} else {
url = new URL(window.location.href);
}
const match = url.pathname.match(/^\/packages\/([^\/]+)/);
const pkg = match?.[1] ? decodeURIComponent(match[1]) : '';
if (pkg) void trackPackageView(pkg);
});
onDestroy(() => { onDestroy(() => {
for (const c of charts) { for (const c of charts) {
try { try {
@@ -241,14 +290,14 @@
href={meta.pypiUrl} href={meta.pypiUrl}
rel="noopener" rel="noopener"
target="_blank">View on PyPI</a target="_blank">View on PyPI</a
> >
{#if meta.homePage} {#if meta.homePage}
<a <a
class="inline-flex items-center rounded-full border border-gray-700 bg-gray-900 px-2.5 py-1 text-gray-300 hover:bg-gray-800" class="inline-flex items-center rounded-full border border-gray-700 bg-gray-900 px-2.5 py-1 text-gray-300 hover:bg-gray-800"
href={meta.homePage} href={meta.homePage}
rel="noopener" rel="noopener"
target="_blank">Homepage</a target="_blank">Homepage</a
> >
{/if} {/if}
{#if meta.projectUrls} {#if meta.projectUrls}
{#each Object.entries(meta.projectUrls).filter(([label, url]) => !['homepage'].includes(label.toLowerCase())) as [label, url]} {#each Object.entries(meta.projectUrls).filter(([label, url]) => !['homepage'].includes(label.toLowerCase())) as [label, url]}
@@ -258,7 +307,7 @@
href={url} href={url}
rel="noopener" rel="noopener"
target="_blank">{label}</a target="_blank">{label}</a
> >
{/if} {/if}
{/each} {/each}
{/if} {/if}
@@ -284,11 +333,11 @@
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>Period</th >Period</th
> >
<th <th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th >Downloads</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-800 bg-gray-950"> <tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -322,11 +371,11 @@
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>Category</th >Category</th
> >
<th <th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th >Downloads</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-800 bg-gray-950"> <tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -362,11 +411,11 @@
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>System</th >System</th
> >
<th <th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th >Downloads</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-800 bg-gray-950"> <tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -402,11 +451,11 @@
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>Version</th >Version</th
> >
<th <th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase" class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th >Downloads</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-800 bg-gray-950"> <tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -424,7 +473,7 @@
</td> </td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-100" <td class="px-6 py-3 text-right text-sm font-semibold text-gray-100"
>{formatNumber(row.downloads)}</td >{formatNumber(row.downloads)}</td
> >
</tr> </tr>
{/each} {/each}
{:catch _} {:catch _}
@@ -545,7 +594,7 @@
href={endpointUrl(ep.path)} href={endpointUrl(ep.path)}
rel="noopener" rel="noopener"
target="_blank">{endpointUrl(ep.path)}</a target="_blank">{endpointUrl(ep.path)}</a
> >
</div> </div>
<button <button
class="ml-3 shrink-0 rounded-md border border-blue-700 bg-blue-800 px-2 py-1 text-xs text-white hover:bg-blue-700" class="ml-3 shrink-0 rounded-md border border-blue-700 bg-blue-800 px-2 py-1 text-xs text-white hover:bg-blue-700"

6
src/types/plausible-tracker.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '@plausible-analytics/tracker' {
export function init(options: any): void;
export const track: (name: string, options?: any) => void;
}