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 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. `ownAgents` leaves the site's own out. A visit is a run of one
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, agents, page views, bounce rate,
939
- visit length and clicks to X. `digest`'s `by_day` carries every
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
@@ -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 plus every agent that sent an event, such as an MCP call. */
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 e${s}) u) + (SELECT visitors FROM archived${s})`;
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 e) u GROUP BY 1
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
+ }
@@ -1,16 +1,16 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { Digest } from './analytics.js';
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, windows }: {
9
+ export declare function StatsFigures({ digest, first, apart, controls }: {
11
10
  digest: Digest;
12
11
  first: number;
13
- windows: ReactNode;
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`. */
@@ -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
- const figuresOf = ({ previous: p, ...d }) => [
30
- { label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
31
- { label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: r => r.agents },
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, windows }) {
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: windows, className: "stats-chart" })] }));
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, count } from './stats-figures.js';
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 filter, as a query string. */
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 to = (k) => (v) => href(days, { ...filter, [k]: v });
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 windows = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }));
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: href(days, { ...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, windows: windows }), _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 => href(days, { ...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
+ // 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.65.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",