wiki-formant 0.65.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/dist/analytics.d.ts +4 -1
- package/dist/analytics.js +5 -2
- package/dist/og.d.ts +54 -0
- package/dist/og.js +36 -0
- package/dist/stats-figures.d.ts +4 -4
- package/dist/stats-figures.js +19 -7
- package/dist/stats.d.ts +5 -2
- package/dist/stats.js +14 -11
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -542,6 +542,18 @@ export const SITE = siteRefs(SITE_URL);
|
|
|
542
542
|
<JsonLd data={siteGraphLd({ name, url: SITE_URL, logo, sameAs, searchUrl, api })} />
|
|
543
543
|
```
|
|
544
544
|
|
|
545
|
+
## Social cards
|
|
546
|
+
|
|
547
|
+
`wiki-formant/og` is the card at `/og`, as the element `next/og`'s `ImageResponse` takes. `SocialCard` is the frame: an eyebrow, a title fitted with `describe`, anything under it, then the description and the site's mark along the foot, bottom-aligned on a 1200×630 field. The field and the art are arguments, because they were all that differed: two wikis had each written the same seeded gradient with translucent circles, which is `bubbles(rand, { field, colors })`, and a third draws its own art and passes it as `art`. `cardRandom(...text)` seeds either from the page, so one page always draws the same card. `OG_SIZE` and `OG_HEADERS` (a day at the CDN, a week stale) go on the response.
|
|
548
|
+
|
|
549
|
+
```tsx
|
|
550
|
+
const rand = cardRandom(title, eyebrow);
|
|
551
|
+
const { background, art } = bubbles(rand, { field: [BG, NAVY], colors: [GREEN, ORANGE] });
|
|
552
|
+
return new ImageResponse(<SocialCard {...{ title, eyebrow, description, background, art }} mark="example.org" colors={{ text, muted, accent }} fontFamily="Inter" />, { ...OG_SIZE, headers: OG_HEADERS, fonts: await fonts });
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Fonts stay with the site: Next traces a font file into the function only from a literal path in the app's own code, and satori reads TTF or OTF, never the woff2 `next/font` serves. The frame never sets a style key to `undefined`, which satori throws on rather than skipping; `scrim` darkens the foot for art that is a photograph.
|
|
556
|
+
|
|
545
557
|
## Revisions
|
|
546
558
|
|
|
547
559
|
What changed between two versions of a page, and therefore which semver bump to
|
|
@@ -884,7 +896,8 @@ referring host), top and entry pages, countries, devices, visitors and agents by
|
|
|
884
896
|
day, the agents by name, MCP tool calls, and the searches that found nothing.
|
|
885
897
|
An agent is counted as a visitor is, one address and user agent a day, from its
|
|
886
898
|
`AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
|
|
887
|
-
among the visitors
|
|
899
|
+
among the visitors, and `visitors_incl_agents` counts the two at once.
|
|
900
|
+
`ownAgents` leaves the site's own out of both. A visit is a run of one
|
|
888
901
|
visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
|
|
889
902
|
key to every call and a `site` resolver to `collect`.
|
|
890
903
|
|
|
@@ -918,7 +931,7 @@ export const POST = gate.route;
|
|
|
918
931
|
export const DELETE = gate.signOut;
|
|
919
932
|
|
|
920
933
|
// src/app/stats/page.tsx
|
|
921
|
-
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
|
|
934
|
+
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30), ?agents=apart and the filters
|
|
922
935
|
return (
|
|
923
936
|
<StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
|
|
924
937
|
{async () => <Stats digest={await digest(sql, q)} {...q} />}
|
|
@@ -935,8 +948,10 @@ site's stylesheet to colour), one of them by day, week or month (a count's
|
|
|
935
948
|
total, a rate's mean), every non-empty
|
|
936
949
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
937
950
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
938
|
-
figure is a button that charts it: visitors,
|
|
939
|
-
visit length and clicks to X.
|
|
951
|
+
figure is a button that charts it: visitors, page views, bounce rate,
|
|
952
|
+
visit length and clicks to X. Visitors count agents; `?agents=apart`, a pair
|
|
953
|
+
of links beside the windows, splits them into people and agents, and every
|
|
954
|
+
other link on the page keeps the choice. `digest`'s `by_day` carries every
|
|
940
955
|
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
941
956
|
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
942
957
|
of those charts rather than drawn as zero. Everything but the figures and the
|
package/dist/analytics.d.ts
CHANGED
|
@@ -57,6 +57,9 @@ export declare const SEARCH_GAPS: Gap;
|
|
|
57
57
|
/** What a stats page can be narrowed to: one value each, every one applied. */
|
|
58
58
|
export declare const STATS_FILTERS: readonly ["page", "entry", "source", "country", "device"];
|
|
59
59
|
export type StatsFilter = Partial<Record<(typeof STATS_FILTERS)[number], string>>;
|
|
60
|
+
/** A count as a stats page prints it. Here, not in the `'use client'` figures, because
|
|
61
|
+
* `Stats` calls it on the server, where a client module's export is only a reference. */
|
|
62
|
+
export declare const count: (n: number) => string;
|
|
60
63
|
export interface DigestOptions {
|
|
61
64
|
site?: string;
|
|
62
65
|
/** How far back, in whole days from now; `Infinity` for all time. */
|
|
@@ -109,7 +112,7 @@ export interface Digest {
|
|
|
109
112
|
* never among `visitors`.
|
|
110
113
|
*/
|
|
111
114
|
agents: number;
|
|
112
|
-
/** Visitors
|
|
115
|
+
/** Visitors and `agents` as one count, which a stats page shows unless asked to keep them apart. */
|
|
113
116
|
visitors_incl_agents: number;
|
|
114
117
|
/**
|
|
115
118
|
* The headline figures over the same number of days before, narrowed alike.
|
package/dist/analytics.js
CHANGED
|
@@ -149,6 +149,9 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
|
|
|
149
149
|
export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
|
|
150
150
|
/** What a stats page can be narrowed to: one value each, every one applied. */
|
|
151
151
|
export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
|
|
152
|
+
/** A count as a stats page prints it. Here, not in the `'use client'` figures, because
|
|
153
|
+
* `Stats` calls it on the server, where a client module's export is only a reference. */
|
|
154
|
+
export const count = (n) => n.toLocaleString('en');
|
|
152
155
|
/**
|
|
153
156
|
* Each filter as a test on one visit. A page, country or device is any of the
|
|
154
157
|
* visit's views; an entry page or a source is its first. `(none)` is no source.
|
|
@@ -237,7 +240,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
237
240
|
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
|
|
238
241
|
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
|
|
239
242
|
'agents', (SELECT count(DISTINCT visitor) FROM ag${s}),
|
|
240
|
-
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM
|
|
243
|
+
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM ag${s}) u) + (SELECT visitors FROM archived${s})`;
|
|
241
244
|
const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
|
|
242
245
|
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
243
246
|
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
@@ -258,7 +261,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
258
261
|
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
259
262
|
UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
|
|
260
263
|
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
|
|
261
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM
|
|
264
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM ag) u GROUP BY 1
|
|
262
265
|
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
263
266
|
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
|
|
264
267
|
) t(date, visitors, pageviews, visits, bounces, seconds, agents, everyone, follows) GROUP BY 1`, undefined, '1')},
|
package/dist/og.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
export declare const OG_SIZE: {
|
|
3
|
+
readonly width: 1200;
|
|
4
|
+
readonly height: 630;
|
|
5
|
+
};
|
|
6
|
+
/** A day at the CDN, a week stale: a card changes when its page's title does, and crawlers cache it for longer anyway. */
|
|
7
|
+
export declare const OG_HEADERS: {
|
|
8
|
+
readonly 'Cache-Control': "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800";
|
|
9
|
+
};
|
|
10
|
+
/** The card's generator, seeded from its text, so one page always draws the same card. */
|
|
11
|
+
export declare function cardRandom(...parts: string[]): () => number;
|
|
12
|
+
export interface BubbleOptions {
|
|
13
|
+
/** The two ends of the field's gradient; its angle is seeded. */
|
|
14
|
+
field: readonly [string, string];
|
|
15
|
+
/** Circle colours, picked at random per circle. */
|
|
16
|
+
colors: readonly string[];
|
|
17
|
+
count?: number;
|
|
18
|
+
/** Smallest radius and the spread above it, in px. */
|
|
19
|
+
radius?: readonly [number, number];
|
|
20
|
+
/** Lowest opacity and the spread above it. */
|
|
21
|
+
opacity?: readonly [number, number];
|
|
22
|
+
}
|
|
23
|
+
/** The seeded gradient and translucent circles: a field and its art, for `SocialCard`. */
|
|
24
|
+
export declare function bubbles(rand: () => number, o: BubbleOptions): {
|
|
25
|
+
background: string;
|
|
26
|
+
art: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
export interface SocialCardProps {
|
|
29
|
+
title: string;
|
|
30
|
+
/** The line above the title: a section, a code, a handle. */
|
|
31
|
+
eyebrow?: string;
|
|
32
|
+
/** The line along the foot. */
|
|
33
|
+
description?: string;
|
|
34
|
+
/** The site's name, at the foot's right. */
|
|
35
|
+
mark?: string;
|
|
36
|
+
colors: {
|
|
37
|
+
text: string;
|
|
38
|
+
/** The description. */
|
|
39
|
+
muted: string;
|
|
40
|
+
/** The eyebrow and the mark. */
|
|
41
|
+
accent: string;
|
|
42
|
+
};
|
|
43
|
+
/** The field: any CSS background satori draws, a gradient from `bubbles` or a flat colour. */
|
|
44
|
+
background: string;
|
|
45
|
+
/** Absolutely positioned layers between the field and the text. */
|
|
46
|
+
art?: ReactNode;
|
|
47
|
+
/** A dark gradient up from the foot, for art that is a photograph. */
|
|
48
|
+
scrim?: boolean;
|
|
49
|
+
fontFamily?: string;
|
|
50
|
+
/** Anything that belongs under the title: chips, a figure. */
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
}
|
|
53
|
+
/** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
|
|
54
|
+
export declare function SocialCard(p: SocialCardProps): ReactElement;
|
package/dist/og.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { describe } from './metadata.js';
|
|
3
|
+
import { hashStr, seededRandom } from './seeded.js';
|
|
4
|
+
export const OG_SIZE = { width: 1200, height: 630 };
|
|
5
|
+
/** A day at the CDN, a week stale: a card changes when its page's title does, and crawlers cache it for longer anyway. */
|
|
6
|
+
export const OG_HEADERS = { 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800' };
|
|
7
|
+
/** The card's generator, seeded from its text, so one page always draws the same card. */
|
|
8
|
+
export function cardRandom(...parts) {
|
|
9
|
+
return seededRandom(hashStr(parts.join('')));
|
|
10
|
+
}
|
|
11
|
+
/** The seeded gradient and translucent circles: a field and its art, for `SocialCard`. */
|
|
12
|
+
export function bubbles(rand, o) {
|
|
13
|
+
const [r0, rSpan] = o.radius ?? [60, 180];
|
|
14
|
+
const [a0, aSpan] = o.opacity ?? [0.12, 0.2];
|
|
15
|
+
const background = `linear-gradient(${Math.floor(rand() * 360)}deg, ${o.field[0]}, ${o.field[1]})`;
|
|
16
|
+
const art = Array.from({ length: o.count ?? 7 }, (_, i) => {
|
|
17
|
+
const cx = rand() * OG_SIZE.width;
|
|
18
|
+
const cy = rand() * OG_SIZE.height;
|
|
19
|
+
const r = r0 + rand() * rSpan;
|
|
20
|
+
const color = o.colors[Math.floor(rand() * o.colors.length)] ?? o.field[1];
|
|
21
|
+
return (_jsx("div", { style: { position: 'absolute', left: cx - r, top: cy - r, width: r * 2, height: r * 2, borderRadius: '50%', background: color, opacity: a0 + rand() * aSpan } }, i));
|
|
22
|
+
});
|
|
23
|
+
return { background, art };
|
|
24
|
+
}
|
|
25
|
+
const SCRIM = 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.3) 50%, transparent 100%)';
|
|
26
|
+
/** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
|
|
27
|
+
export function SocialCard(p) {
|
|
28
|
+
const title = describe(p.title, 90);
|
|
29
|
+
// Three lines at most at either size; the step is where 68px would wrap a fourth.
|
|
30
|
+
const titleSize = title.length > 48 ? 56 : 72;
|
|
31
|
+
return (_jsxs("div", { style: { display: 'flex', width: '100%', height: '100%', position: 'relative', background: p.background, ...(p.fontFamily ? { fontFamily: p.fontFamily } : {}) }, children: [p.art, _jsxs("div", { style: {
|
|
32
|
+
display: 'flex', flexDirection: 'column', justifyContent: 'flex-end',
|
|
33
|
+
width: '100%', height: '100%', padding: '56px 64px', position: 'relative',
|
|
34
|
+
...(p.scrim ? { background: SCRIM } : {}),
|
|
35
|
+
}, children: [p.eyebrow ? _jsx("div", { style: { display: 'flex', fontSize: 30, fontWeight: 700, color: p.colors.accent, marginBottom: 14 }, children: describe(p.eyebrow, 48) }) : null, _jsx("div", { style: { display: 'flex', fontSize: titleSize, fontWeight: 700, color: p.colors.text, lineHeight: 1.08, letterSpacing: '-0.02em' }, children: title }), p.children ? _jsx("div", { style: { display: 'flex', marginTop: 28 }, children: p.children }) : null, p.description || p.mark ? (_jsxs("div", { style: { display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 40, marginTop: 28 }, children: [_jsx("div", { style: { display: 'flex', flex: 1, fontSize: 26, lineHeight: 1.35, color: p.colors.muted }, children: p.description ? describe(p.description, 110) : '' }), p.mark ? _jsx("div", { style: { display: 'flex', fontSize: 28, fontWeight: 700, color: p.colors.accent }, children: p.mark }) : null] })) : null] })] }));
|
|
36
|
+
}
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type
|
|
3
|
-
export declare const count: (n: number) => string;
|
|
2
|
+
import { type Digest } from './analytics.js';
|
|
4
3
|
/**
|
|
5
4
|
* The figures as `.stats-figures button[aria-pressed]`, each with its change
|
|
6
5
|
* as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
|
|
7
6
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
8
7
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
9
8
|
*/
|
|
10
|
-
export declare function StatsFigures({ digest, first,
|
|
9
|
+
export declare function StatsFigures({ digest, first, apart, controls }: {
|
|
11
10
|
digest: Digest;
|
|
12
11
|
first: number;
|
|
13
|
-
|
|
12
|
+
apart: boolean;
|
|
13
|
+
controls: ReactNode;
|
|
14
14
|
}): import("react").JSX.Element;
|
|
15
15
|
export interface PasskeyButtonProps {
|
|
16
16
|
/** The route that serves `gate.route`. */
|
package/dist/stats-figures.js
CHANGED
|
@@ -7,8 +7,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
// component and hands it the digest, which is plain JSON.
|
|
8
8
|
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
|
9
9
|
import { useMemo, useState } from 'react';
|
|
10
|
+
import { count } from './analytics.js';
|
|
10
11
|
import { TimeChart, stepDecimals } from './chart.js';
|
|
11
|
-
export const count = (n) => n.toLocaleString('en');
|
|
12
12
|
// Whole seconds and percentages, unless an axis tick's step needs more to tell it from the next.
|
|
13
13
|
const places = (step) => (step ? stepDecimals(step) : 0);
|
|
14
14
|
const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
|
|
@@ -26,9 +26,21 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
|
|
|
26
26
|
const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
|
|
27
27
|
return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/** Visitors are one figure counting agents too, or `apart` two: people, and agents. */
|
|
30
|
+
const figuresOf = ({ previous: p, ...d }, apart) => [
|
|
31
|
+
...(apart
|
|
32
|
+
? [
|
|
33
|
+
{ label: 'People', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: (r) => r.visitors },
|
|
34
|
+
{ label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: (r) => r.agents },
|
|
35
|
+
]
|
|
36
|
+
: [
|
|
37
|
+
{
|
|
38
|
+
label: 'Visitors',
|
|
39
|
+
value: count(d.visitors_incl_agents),
|
|
40
|
+
delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
|
|
41
|
+
day: (r) => r.visitors_incl_agents,
|
|
42
|
+
},
|
|
43
|
+
]),
|
|
32
44
|
{ label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
|
|
33
45
|
{
|
|
34
46
|
label: 'Bounce rate',
|
|
@@ -65,8 +77,8 @@ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seco
|
|
|
65
77
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
66
78
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
67
79
|
*/
|
|
68
|
-
export function StatsFigures({ digest, first,
|
|
69
|
-
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
80
|
+
export function StatsFigures({ digest, first, apart, controls }) {
|
|
81
|
+
const figures = useMemo(() => figuresOf(digest, apart), [digest, apart]);
|
|
70
82
|
const [chosen, choose] = useState(0);
|
|
71
83
|
const figure = figures[chosen] ?? figures[0];
|
|
72
84
|
const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
|
|
@@ -78,7 +90,7 @@ export function StatsFigures({ digest, first, windows }) {
|
|
|
78
90
|
return value == null ? null : { time, value };
|
|
79
91
|
}).filter(p => p !== null);
|
|
80
92
|
}, [byDay, first, figure]);
|
|
81
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls:
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls: controls, className: "stats-chart" })] }));
|
|
82
94
|
}
|
|
83
95
|
async function post(endpoint, body) {
|
|
84
96
|
const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
package/dist/stats.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface StatsProps {
|
|
|
8
8
|
days: number;
|
|
9
9
|
/** What the digest was narrowed to, as `digest` was given it. */
|
|
10
10
|
filter?: StatsFilter;
|
|
11
|
+
/** Visitors as people and agents, two figures, rather than one that counts both. */
|
|
12
|
+
apart?: boolean;
|
|
11
13
|
/** The windows offered as `?days=` links on the same page. */
|
|
12
14
|
ranges?: readonly number[];
|
|
13
15
|
}
|
|
@@ -19,15 +21,16 @@ export interface StatsProps {
|
|
|
19
21
|
export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
|
|
20
22
|
days: number;
|
|
21
23
|
filter: Partial<Record<"page" | "entry" | "source" | "country" | "device", string>>;
|
|
24
|
+
apart: boolean;
|
|
22
25
|
};
|
|
23
26
|
/**
|
|
24
27
|
* A site's own analytics on one page: the headline figures against the window
|
|
25
|
-
* before, whichever of them is chosen per day, and every ranked list the
|
|
28
|
+
* before, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
|
|
26
29
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
27
30
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
28
31
|
* until the importing stylesheet gives them a colour.
|
|
29
32
|
*/
|
|
30
|
-
export declare function Stats({ digest: d, days, filter, ranges }: StatsProps): import("react").JSX.Element;
|
|
33
|
+
export declare function Stats({ digest: d, days, filter, apart, ranges }: StatsProps): import("react").JSX.Element;
|
|
31
34
|
export interface StatsGateProps {
|
|
32
35
|
gate: PasskeyGate;
|
|
33
36
|
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
package/dist/stats.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { STATS_FILTERS } from './analytics.js';
|
|
3
|
-
import { PasskeyButton, PasskeySignOut, StatsFigures
|
|
2
|
+
import { STATS_FILTERS, count } from './analytics.js';
|
|
3
|
+
import { PasskeyButton, PasskeySignOut, StatsFigures } from './stats-figures.js';
|
|
4
4
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
5
5
|
const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
6
6
|
/** A window as its `?days=` value. */
|
|
@@ -21,11 +21,13 @@ export function statsQuery(params, ranges = STATS_RANGES) {
|
|
|
21
21
|
if (typeof v === 'string' && v)
|
|
22
22
|
filter[k] = v.slice(0, 500);
|
|
23
23
|
}
|
|
24
|
-
return { days: statsDays(params.days, ranges), filter };
|
|
24
|
+
return { days: statsDays(params.days, ranges), filter, apart: params.agents === 'apart' };
|
|
25
25
|
}
|
|
26
|
-
/** The page at a window and a
|
|
27
|
-
const href = (days, filter) => {
|
|
26
|
+
/** The page at a window, a filter and a count of agents, as a query string. */
|
|
27
|
+
const href = (days, filter, apart) => {
|
|
28
28
|
const q = new URLSearchParams({ days: daysParam(days) });
|
|
29
|
+
if (apart)
|
|
30
|
+
q.set('agents', 'apart');
|
|
29
31
|
for (const k of STATS_FILTERS)
|
|
30
32
|
if (filter[k])
|
|
31
33
|
q.set(k, filter[k]);
|
|
@@ -50,12 +52,12 @@ function StatsList({ title, rows, label = v => v, to, }) {
|
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
54
|
* A site's own analytics on one page: the headline figures against the window
|
|
53
|
-
* before, whichever of them is chosen per day, and every ranked list the
|
|
55
|
+
* before, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
|
|
54
56
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
55
57
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
56
58
|
* until the importing stylesheet gives them a colour.
|
|
57
59
|
*/
|
|
58
|
-
export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
60
|
+
export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES }) {
|
|
59
61
|
const today = Math.floor(Date.now() / DAY_MS);
|
|
60
62
|
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
61
63
|
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
@@ -67,11 +69,12 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
|
67
69
|
? Math.max(start, counted)
|
|
68
70
|
: Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
|
|
69
71
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
70
|
-
const
|
|
72
|
+
const at = (f, n = days, a = apart) => href(n, f, a);
|
|
73
|
+
const to = (k) => (v) => at({ ...filter, [k]: v });
|
|
71
74
|
// Each window is another digest, so a link rather than a button, pinned
|
|
72
|
-
// with the chart's own controls.
|
|
73
|
-
const
|
|
74
|
-
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href:
|
|
75
|
+
// with the chart's own controls, and so is whether agents count apart.
|
|
76
|
+
const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }), _jsxs("div", { role: "group", "aria-label": "Agents", children: [_jsx("a", { href: at(filter, days, false), "aria-current": apart ? undefined : 'page', title: "Visitors count agents", children: "With agents" }), _jsx("a", { href: at(filter, days, true), "aria-current": apart ? 'page' : undefined, title: "People and agents counted apart", children: "Apart" })] })] }));
|
|
77
|
+
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: at({ ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, apart: apart, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
77
80
|
* A stats page's door: `children` and a sign-out button for a signed-in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -243,6 +243,11 @@
|
|
|
243
243
|
"import": "./dist/seeded.js",
|
|
244
244
|
"default": "./dist/seeded.js"
|
|
245
245
|
},
|
|
246
|
+
"./og": {
|
|
247
|
+
"types": "./dist/og.d.ts",
|
|
248
|
+
"import": "./dist/og.js",
|
|
249
|
+
"default": "./dist/og.js"
|
|
250
|
+
},
|
|
246
251
|
"./sanitize": {
|
|
247
252
|
"types": "./dist/sanitize.d.ts",
|
|
248
253
|
"import": "./dist/sanitize.js",
|