wiki-formant 0.39.0 → 0.40.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
@@ -724,8 +724,8 @@ export const POST = gate.route;
724
724
  // src/app/stats/page.tsx
725
725
  if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
726
726
  return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
727
- const days = statsDays((await searchParams).days); // 1, 7, 30, 90, 365 or Infinity (?days=all), else 30
728
- return <Stats digest={await digest(sql, { days })} days={days} />;
727
+ const q = statsQuery(await searchParams); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
728
+ return <Stats digest={await digest(sql, q)} {...q} />;
729
729
  ```
730
730
 
731
731
  `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
@@ -738,7 +738,17 @@ length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
738
738
  one of those per UTC day, a visit on the day it began, so the days add up to the
739
739
  totals. A day with no visits has no bounce rate or visit length and is left out
740
740
  of those charts rather than drawn as zero. Everything but the figures and the
741
- chart is server-rendered. The chart is `TimeChart` with a count
741
+ chart is server-rendered.
742
+
743
+ Every row in a list is a link that narrows the page to it, and the narrowings
744
+ stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
745
+ a chip that drops it. A narrowing keeps the visits that pass – that saw the
746
+ page, began on it, came from the source, or were made from the country or
747
+ device – and recounts every figure, list and day from those visits alone, with
748
+ events only from their visitors. Agent tool calls and missed searches are
749
+ events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
750
+ not visits, so a narrowed page starts at `counted_from`, the first day the site
751
+ counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
742
752
  axis from zero, so `lightweight-charts` is an optional peer that a stats page
743
753
  needs installed.
744
754
 
@@ -65,6 +65,9 @@ export interface Gap {
65
65
  }
66
66
  /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
67
67
  export declare const SEARCH_GAPS: Gap;
68
+ /** What a stats page can be narrowed to: one value each, every one applied. */
69
+ export declare const STATS_FILTERS: readonly ["page", "entry", "source", "country", "device"];
70
+ export type StatsFilter = Partial<Record<(typeof STATS_FILTERS)[number], string>>;
68
71
  export interface DigestOptions {
69
72
  site?: string;
70
73
  /** How far back, in whole days from now; `Infinity` for all time. */
@@ -79,6 +82,12 @@ export interface DigestOptions {
79
82
  * The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
80
83
  */
81
84
  ownAgents?: string[];
85
+ /**
86
+ * Narrows every figure to the visits that pass, and the events to their
87
+ * visitors. `ViewDay` keeps no visits, so a narrowed digest leaves it out
88
+ * and starts at `counted_from`, and has no `previous_visitors`.
89
+ */
90
+ filter?: StatsFilter;
82
91
  }
83
92
  type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
84
93
  /** One UTC day of the figures a digest totals; a visit falls on the day it began. */
@@ -104,8 +113,10 @@ export interface Digest {
104
113
  visit_duration: number | null;
105
114
  /** Visitors plus every agent that sent an event, such as an MCP call. */
106
115
  visitors_incl_agents: number;
107
- /** Visitors in the same number of days before. */
108
- previous_visitors: number;
116
+ /** Visitors in the same number of days before; null when filtered. */
117
+ previous_visitors: number | null;
118
+ /** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
119
+ counted_from: string | null;
109
120
  top_sources: Ranked<'source', 'visitors'>;
110
121
  top_pages: Ranked<'page', 'visitors'>;
111
122
  entry_pages: Ranked<'page', 'visitors'>;
@@ -130,7 +141,7 @@ export interface Digest {
130
141
  * page, visit, source, country, device and per-day figures; events have no
131
142
  * history, so an archived day's visitors are its visitors with agents too.
132
143
  */
133
- export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
144
+ export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
134
145
  /**
135
146
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
136
147
  *
package/dist/analytics.js CHANGED
@@ -188,6 +188,19 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
188
188
  }
189
189
  /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
190
190
  export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
191
+ /** What a stats page can be narrowed to: one value each, every one applied. */
192
+ export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
193
+ /**
194
+ * Each filter as a test on one visit. A page, country or device is any of the
195
+ * visit's views; an entry page or a source is its first. `(none)` is no source.
196
+ */
197
+ const VISIT_TEST = {
198
+ page: v => `bool_or(path = ${v})`,
199
+ entry: v => `(array_agg(path ORDER BY at, id))[1] = ${v}`,
200
+ source: v => `coalesce((array_agg(source ORDER BY at, id))[1], '(none)') = ${v}`,
201
+ country: v => `bool_or(country = ${v})`,
202
+ device: v => `bool_or(device = ${v})`,
203
+ };
191
204
  /**
192
205
  * Everything a report needs about one site's last `days` days, in one query.
193
206
  * Visitor figures count distinct visitors, so a person who comes back on
@@ -195,7 +208,7 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
195
208
  * page, visit, source, country, device and per-day figures; events have no
196
209
  * history, so an archived day's visitors are its visitors with agents too.
197
210
  */
198
- export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
211
+ export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
199
212
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
200
213
  // All time is a century: further back than any row, and still an int to Postgres.
201
214
  const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
@@ -205,6 +218,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
205
218
  const merged = (label, column, live) => `SELECT k AS ${label}, sum(n) AS visitors FROM (${live} UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a."${column}")) t(k, n) GROUP BY 1`;
206
219
  /** Clicks on links to the site's own X account, among `e`. */
207
220
  const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
221
+ const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
208
222
  const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
209
223
  const gapSelects = gaps.map(g => {
210
224
  const prop = $(g.prop);
@@ -212,21 +226,24 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
212
226
  WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
213
227
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
214
228
  });
215
- const [row] = (await sql(`WITH v AS (
229
+ const [row] = (await sql(`WITH viewed AS (
216
230
  SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
217
231
  WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
218
232
  ), marked AS (
219
- SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM v
233
+ SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM viewed
220
234
  ), numbered AS (
221
235
  SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
222
236
  ), visits AS (
223
- SELECT visitor, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
237
+ SELECT visitor, visit, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
224
238
  (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
225
- FROM numbered GROUP BY visitor, visit
239
+ FROM numbered GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
240
+ ), v AS (
241
+ ${tests.length ? 'SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered n JOIN visits USING (visitor, visit)' : 'SELECT * FROM viewed'}
226
242
  ), e AS (
227
243
  SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
244
+ ${tests.length ? 'AND visitor IN (SELECT visitor FROM v)' : ''}
228
245
  ), a AS (
229
- SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
246
+ SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date${tests.length ? ' AND false' : ''}
230
247
  ), sessions AS (
231
248
  SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
232
249
  SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
@@ -244,11 +261,14 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
244
261
  'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
245
262
  'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
246
263
  'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
247
- 'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
264
+ 'previous_visitors', ${tests.length
265
+ ? 'NULL'
266
+ : `(SELECT count(DISTINCT visitor) FROM "View"
248
267
  WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
249
268
  + (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
250
269
  AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
251
- AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date),
270
+ AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date)`},
271
+ 'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
252
272
  'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
253
273
  'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
254
274
  'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
package/dist/base.css CHANGED
@@ -242,4 +242,12 @@ pre:hover > .code-copy-btn,
242
242
  pointer-events: none;
243
243
  }
244
244
  .stats-list li > span:first-child { min-width: 0; overflow-wrap: anywhere; }
245
+ /* A row narrows the page to itself; the whole row is the link. */
246
+ .stats-list a { color: inherit; text-decoration: none; }
247
+ .stats-list a::after { content: ''; position: absolute; inset: 0; }
248
+ .stats-list a:is(:hover, :focus-visible) { text-decoration: underline; text-underline-offset: 0.2em; }
249
+ .stats-filters { display: grid; gap: 0.5rem; }
250
+ .stats-filters ul { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
251
+ .stats-filters a { display: inline-block; padding: 0.25em 0.75em; border: 1px solid; border-radius: 9999px; text-decoration: none; }
252
+ .stats-filters p { margin: 0; opacity: 0.7; }
245
253
  .stats-count { font-variant-numeric: tabular-nums; }
package/dist/stats.d.ts CHANGED
@@ -1,20 +1,32 @@
1
- import type { Digest } from './analytics.js';
1
+ import { type Digest, type StatsFilter } from './analytics.js';
2
2
  export interface StatsProps {
3
3
  /** One window's figures, from `digest` in `wiki-formant/analytics`. */
4
4
  digest: Digest;
5
5
  /** The window the digest covers, in days; `Infinity` for all time. */
6
6
  days: number;
7
+ /** What the digest was narrowed to, as `digest` was given it. */
8
+ filter?: StatsFilter;
7
9
  /** The windows offered as `?days=` links on the same page. */
8
10
  ranges?: readonly number[];
9
11
  }
10
12
  /** The windows the stats page offers, in days; `Infinity` is all time. */
11
13
  export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
12
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
14
+ /** A `?days=` value as one of `ranges`, else `fallback`. */
13
15
  export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
16
+ /**
17
+ * A stats page's query as the window and the filter, both ready for `digest`
18
+ * and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
19
+ * parameter is ignored.
20
+ */
21
+ export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
22
+ days: number;
23
+ filter: Partial<Record<"page" | "entry" | "source" | "country" | "device", string>>;
24
+ };
14
25
  /**
15
26
  * A site's own analytics on one page: the headline figures against the window
16
27
  * before, whichever of them is chosen per day, and every ranked list the
17
- * digest carries. Lists with nothing in them are left out. The chart and the
18
- * bars are `currentColor` until the importing stylesheet gives them a colour.
28
+ * digest carries, each row a link that narrows the page to it. Lists with
29
+ * nothing in them are left out. The chart and the bars are `currentColor`
30
+ * until the importing stylesheet gives them a colour.
19
31
  */
20
- export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
32
+ export declare function Stats({ digest: d, days, filter, ranges }: StatsProps): import("react").JSX.Element;
package/dist/stats.js CHANGED
@@ -1,11 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { STATS_FILTERS } from './analytics.js';
2
3
  import { StatsFigures } from './stats-figures.js';
3
4
  /** The windows the stats page offers, in days; `Infinity` is all time. */
4
5
  export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
5
6
  /** A window as its `?days=` value. */
6
7
  const daysParam = (n) => (Number.isFinite(n) ? String(n) : 'all');
7
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
8
+ /** A `?days=` value as one of `ranges`, else `fallback`. */
8
9
  export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
10
+ /**
11
+ * A stats page's query as the window and the filter, both ready for `digest`
12
+ * and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
13
+ * parameter is ignored.
14
+ */
15
+ export function statsQuery(params, ranges = STATS_RANGES) {
16
+ const filter = {};
17
+ for (const k of STATS_FILTERS) {
18
+ const v = params[k];
19
+ if (typeof v === 'string' && v)
20
+ filter[k] = v.slice(0, 500);
21
+ }
22
+ return { days: statsDays(params.days, ranges), filter };
23
+ }
24
+ /** The page at a window and a filter, as a query string. */
25
+ const href = (days, filter) => {
26
+ const q = new URLSearchParams({ days: daysParam(days) });
27
+ for (const k of STATS_FILTERS)
28
+ if (filter[k])
29
+ q.set(k, filter[k]);
30
+ return `?${q}`;
31
+ };
9
32
  const count = (n) => n.toLocaleString('en');
10
33
  const regions = new Intl.DisplayNames(['en'], { type: 'region' });
11
34
  const country = (code) => {
@@ -16,23 +39,33 @@ const country = (code) => {
16
39
  return code;
17
40
  }
18
41
  };
19
- function StatsList({ title, rows, label = v => v }) {
42
+ const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
43
+ const DAY_MS = 86_400_000;
44
+ function StatsList({ title, rows, label = v => v, to, }) {
20
45
  if (!rows.length)
21
46
  return null;
22
47
  const max = Math.max(...rows.map(([, n]) => n));
23
- return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
48
+ return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? _jsx("a", { href: to(v), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
24
49
  }
25
50
  /**
26
51
  * A site's own analytics on one page: the headline figures against the window
27
52
  * before, whichever of them is chosen per day, and every ranked list the
28
- * digest carries. Lists with nothing in them are left out. The chart and the
29
- * bars are `currentColor` until the importing stylesheet gives them a colour.
53
+ * digest carries, each row a link that narrows the page to it. Lists with
54
+ * nothing in them are left out. The chart and the bars are `currentColor`
55
+ * until the importing stylesheet gives them a colour.
30
56
  */
31
- export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
57
+ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
58
+ const today = Math.floor(Date.now() / DAY_MS);
59
+ const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
60
+ const narrowed = STATS_FILTERS.filter(k => filter[k]);
32
61
  // A rolling window of n days touches n + 1 UTC dates, the first of them in
33
- // part. All time starts at the first day anything was counted.
34
- const today = Math.floor(Date.now() / 86_400_000);
35
- const first = Number.isFinite(days) ? today - days : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / 86_400_000));
62
+ // part. All time starts at the first day anything was counted, and a
63
+ // narrowed window at the first day the site counted itself.
64
+ const start = Number.isFinite(days) ? today - days : -Infinity;
65
+ const first = narrowed.length
66
+ ? Math.max(start, counted)
67
+ : Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
36
68
  const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
37
- return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${daysParam(n)}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : Number.isFinite(n) ? `${n} days` : 'All time' }, n))) }), _jsx(StatsFigures, { digest: d, first: first }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', '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') })] })] }));
69
+ const to = (k) => (v) => href(days, { ...filter, [k]: v });
70
+ return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : Number.isFinite(n) ? `${n} days` : 'All time' }, n))) }), 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 }), _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: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
38
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.39.0",
3
+ "version": "0.40.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",