wiki-formant 0.100.0 → 0.105.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 +4 -4
- package/dist/analytics.d.ts +28 -21
- package/dist/analytics.js +94 -56
- package/dist/dom.d.ts +5 -0
- package/dist/dom.js +39 -0
- package/dist/search.d.ts +19 -0
- package/dist/search.js +59 -0
- package/dist/stats-figures.d.ts +1 -2
- package/dist/stats-figures.js +27 -30
- package/dist/stats.d.ts +4 -7
- package/dist/stats.js +13 -17
- package/dist/store.d.ts +10 -6
- package/dist/store.js +23 -3
- package/dist/wiki-tools.d.ts +1 -0
- package/dist/wiki-tools.js +5 -4
- package/package.json +1 -1
- package/prisma/analytics.prisma +2 -0
package/README.md
CHANGED
|
@@ -565,7 +565,7 @@ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
|
|
|
565
565
|
|
|
566
566
|
## Search
|
|
567
567
|
|
|
568
|
-
`wiki-formant/search`: what a literal tier and a full-text tier must agree on: `proseSql`, `HEADLINE_OPTIONS`, `FTS_RANK_NORMALIZATION`, and `searchTsvDdl(table)`, the statements that drop and re-add the generated `search_tsv` column and its index in one transaction.
|
|
568
|
+
`wiki-formant/search`: what a literal tier and a full-text tier must agree on: `proseSql`, `HEADLINE_OPTIONS`, `FTS_RANK_NORMALIZATION`, and `searchTsvDdl(table)`, the statements that drop and re-add the generated `search_tsv` column and its index in one transaction. `fullTextQuery` is a query as the full-text tier reads it, a hyphen or underscore inside a word a space; `correctQuery(query, vocabulary)` corrects a query's misspelt words against a site's own, which the store's `search` retries with when its first page is empty, returning the query that found the rows as `corrected`.
|
|
569
569
|
|
|
570
570
|
```ts
|
|
571
571
|
const { column, index } = searchTsvDdl('"WikiPage"');
|
|
@@ -702,7 +702,7 @@ const SCHEMA: BlockSchema<Block> = {
|
|
|
702
702
|
|
|
703
703
|
## Analytics
|
|
704
704
|
|
|
705
|
-
`wiki-formant/analytics`: page views and events in the site's own Postgres. No cookie and no stored address: a visitor is a hash of address and browser under a salt replaced every UTC day. Rows are kept 400 days. The tables ship in `prisma/analytics.prisma`; `ViewDay` holds days a site counted elsewhere first.
|
|
705
|
+
`wiki-formant/analytics`: page views and events in the site's own Postgres. No cookie and no stored address: a visitor is a hash of address and browser under a salt replaced every UTC day. Rows are kept 400 days. A view is marked engaged once its reader has had the page visible fifteen seconds or given it input, so `engaged` counts people where `agents` counts crawlers. The tables ship in `prisma/analytics.prisma`; `ViewDay` holds days a site counted elsewhere first.
|
|
706
706
|
|
|
707
707
|
```ts
|
|
708
708
|
// src/lib/track.ts
|
|
@@ -715,7 +715,7 @@ export const POST = viewRoute();
|
|
|
715
715
|
|
|
716
716
|
- `createTracker` records only on a Vercel production deployment unless `enabled` says otherwise. `trackMcpCall` fits `McpServerConfig.onCall`; `trackSearch` normalises a query and drops an empty one; `recordEvent` is the undeferred write.
|
|
717
717
|
- A proxy reaches the tracker through a dynamic `import()` inside `event.waitUntil`, since it imports the database client.
|
|
718
|
-
- `digest(sql, { days })` is the site as one JSON object:
|
|
718
|
+
- `digest(sql, { days })` is the site as one JSON object: people, engaged people, agents, views, visits, bounce rate, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing, less a search its visitor went on typing within five minutes and any of one character. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit its reader never engaged with. Agents are counted beside people, never among them: the ranked lists are people's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
|
|
719
719
|
- A browser under automation (`navigator.webdriver`) sends no beacon.
|
|
720
720
|
|
|
721
721
|
## Passkeys and the stats page
|
|
@@ -741,7 +741,7 @@ return (
|
|
|
741
741
|
|
|
742
742
|
- There is no sign-up: every passkey is saved from a single-use link `npx passkey-invite` prints. A passkey is bound to its host. Deleting a `Passkey` row signs out every session it opened. Tokens are kept only as hashes (`prisma/passkey.prisma`).
|
|
743
743
|
- `gate.signedIn(cookie)` is a page's check and `gate.allows(request)` a write route's.
|
|
744
|
-
- `statsQuery` reads `?days=` (1, 7, 30, 90, 365 or all)
|
|
744
|
+
- `statsQuery` reads `?days=` (1, 7, 30, 90, 365 or all) and the narrowings `?page=`, `?entry=`, `?source=`, `?country=`, `?device=`, which stack and recount every figure from the visits that pass.
|
|
745
745
|
- Each headline figure shows its change from the window before (`.stats-delta[data-better]`) and charts on press. A narrowed page over `ViewDay` totals starts at `counted_from` and says so.
|
|
746
746
|
|
|
747
747
|
## ROLA sign-in
|
package/dist/analytics.d.ts
CHANGED
|
@@ -25,11 +25,12 @@ export interface EventInput {
|
|
|
25
25
|
}
|
|
26
26
|
/** Record one named event. Never rejects. */
|
|
27
27
|
export declare function recordEvent(sql: Sql, { name, props, url, headers, site }: EventInput): Promise<void>;
|
|
28
|
-
/** What a page sends: a view, or with `name` an event. */
|
|
28
|
+
/** What a page sends: a view, with `engaged` its reader's engagement, or with `name` an event. */
|
|
29
29
|
export interface Beacon {
|
|
30
30
|
path: string;
|
|
31
31
|
referrer?: string;
|
|
32
32
|
utm?: string;
|
|
33
|
+
engaged?: boolean;
|
|
33
34
|
name?: string;
|
|
34
35
|
props?: Record<string, string | number>;
|
|
35
36
|
}
|
|
@@ -83,55 +84,61 @@ export interface DigestOptions {
|
|
|
83
84
|
* and has no `previous_visitors`.
|
|
84
85
|
*/
|
|
85
86
|
filter?: StatsFilter;
|
|
86
|
-
/**
|
|
87
|
-
* People and agents counted apart, as they are by default: the ranked lists
|
|
88
|
-
* count people. False counts agents in the pages, entry pages, sources,
|
|
89
|
-
* countries and devices too, as `visitors_incl_agents` counts them, so a
|
|
90
|
-
* stats page's lists add up to the visitors its chart draws.
|
|
91
|
-
*/
|
|
92
|
-
apart?: boolean;
|
|
93
87
|
}
|
|
94
88
|
type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
|
|
95
89
|
/** One UTC day of the figures a digest totals; a visit falls on the day it began. */
|
|
96
90
|
export interface DigestDay {
|
|
97
91
|
date: string;
|
|
98
92
|
visitors: number;
|
|
93
|
+
/** Visitors who engaged with a page they viewed (`startBeacon`). */
|
|
94
|
+
engaged: number;
|
|
99
95
|
pageviews: number;
|
|
100
96
|
visits: number;
|
|
101
|
-
/** Visits that
|
|
97
|
+
/** Visits that bounced (`Digest.bounce_rate`); an archived day has none. */
|
|
102
98
|
bounces: number;
|
|
103
|
-
/** Seconds from each visit's first view to its last, summed. */
|
|
104
|
-
seconds: number;
|
|
105
99
|
agents: number;
|
|
106
|
-
visitors_incl_agents: number;
|
|
107
100
|
/** Clicks on links to the site's X handle; zero without a handle. */
|
|
108
101
|
follow_clicks: number;
|
|
109
102
|
}
|
|
110
103
|
export interface Digest {
|
|
111
104
|
visitors: number;
|
|
105
|
+
/**
|
|
106
|
+
* Visitors who engaged with a page they viewed: had it in a visible tab for
|
|
107
|
+
* fifteen seconds, or gave it input (`startBeacon`). A crawler that runs
|
|
108
|
+
* scripts renders a page and goes, so these are people. `ViewDay` keeps no
|
|
109
|
+
* engagement, so an archived day adds nothing, and the window before is
|
|
110
|
+
* compared only where engagement was counted from its first day.
|
|
111
|
+
*/
|
|
112
|
+
engaged: number;
|
|
112
113
|
pageviews: number;
|
|
113
|
-
/**
|
|
114
|
+
/** Runs of one visitor's views with no gap over 30 minutes. */
|
|
115
|
+
visits: number;
|
|
116
|
+
/**
|
|
117
|
+
* Percent of visits that bounced: saw one page and never engaged with it,
|
|
118
|
+
* the complement of `engaged`. Over the site's own visits, since `ViewDay`
|
|
119
|
+
* keeps no engagement; null with none. Compared with the window before as
|
|
120
|
+
* `engaged` is.
|
|
121
|
+
*/
|
|
114
122
|
bounce_rate: number | null;
|
|
115
|
-
/** Mean seconds from a visit's first view to its last; null with no visits. */
|
|
116
|
-
visit_duration: number | null;
|
|
117
123
|
/**
|
|
118
124
|
* Agents, counted as visitors are: an AI crawler, an MCP client, or anything
|
|
119
125
|
* else reading the machine surface (`trackAiBot`), one per address and user
|
|
120
126
|
* agent a day, the site's own left out. They run no page script, so they are
|
|
121
|
-
* never among `visitors
|
|
127
|
+
* never among `visitors`, and the ranked lists count people alone.
|
|
122
128
|
*/
|
|
123
129
|
agents: number;
|
|
124
|
-
/** Visitors and `agents` as one count, which a stats page shows unless asked to keep them apart. */
|
|
125
|
-
visitors_incl_agents: number;
|
|
126
130
|
/**
|
|
127
131
|
* The headline figures over the same number of days before, narrowed alike.
|
|
128
132
|
* A figure is null where that window reaches back past `counted_from` and
|
|
129
133
|
* the archive cannot stand in for it: event figures always, all of them
|
|
130
134
|
* when filtered.
|
|
131
135
|
*/
|
|
132
|
-
previous: Record<'visitors' | '
|
|
136
|
+
previous: Record<'visitors' | 'engaged' | 'pageviews' | 'visits' | 'bounce_rate' | 'agents' | 'follow_clicks', number | null>;
|
|
133
137
|
/** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
|
|
134
138
|
counted_from: string | null;
|
|
139
|
+
/** The first UTC day, in this window or the one before, with a view marked engaged, before which `engaged` and `bounce_rate` mean nothing; null with none. */
|
|
140
|
+
engaged_from: string | null;
|
|
141
|
+
/** `(bot)` stands for each referrer `BOT_VISITORS` takes for a crawler's. */
|
|
135
142
|
top_sources: Ranked<'source', 'visitors'>;
|
|
136
143
|
top_pages: Ranked<'page', 'visitors'>;
|
|
137
144
|
entry_pages: Ranked<'page', 'visitors'>;
|
|
@@ -164,11 +171,11 @@ export interface Digest {
|
|
|
164
171
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
165
172
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
166
173
|
* page, visit, source, country, device and per-day figures; events have no
|
|
167
|
-
* history
|
|
174
|
+
* history.
|
|
168
175
|
* The window before is the same chain of views, visits and events a second
|
|
169
176
|
* time, `_p` on every name, so its figures are the headline's to the letter.
|
|
170
177
|
*/
|
|
171
|
-
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter
|
|
178
|
+
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
|
|
172
179
|
/**
|
|
173
180
|
* Props for an "MCP Call" event, read out of the JSON-RPC envelope.
|
|
174
181
|
*
|
package/dist/analytics.js
CHANGED
|
@@ -103,6 +103,19 @@ export function recordView(sql, { headers, path, referrer, utm, site = '' }) {
|
|
|
103
103
|
return Promise.resolve();
|
|
104
104
|
return quietly(async () => sql('INSERT INTO "View" ("siteId", path, source, country, device, visitor) VALUES ($1, $2, $3, $4, $5, $6)', site, page, sourceOf(referrer, utm, headers.get('host') ?? ''), headers.get('x-vercel-ip-country')?.slice(0, 2) || null, deviceOf(ua), await visitorOf(sql, site, headers)));
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Mark a visitor's latest unengaged view of `path` in the last half hour as
|
|
108
|
+
* engaged: the beacon's second word on a view, sent once its reader engaged
|
|
109
|
+
* (`startBeacon`). Never rejects. @internal
|
|
110
|
+
*/
|
|
111
|
+
export function recordEngaged(sql, { headers, path, site = '' }) {
|
|
112
|
+
const page = normalisePath(path);
|
|
113
|
+
if (page === null)
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
return quietly(async () => sql(`UPDATE "View" SET engaged = true WHERE id = (
|
|
116
|
+
SELECT id FROM "View" WHERE "siteId" = $1 AND visitor = $2 AND path = $3 AND NOT engaged AND "createdAt" > now() - interval '30 minutes'
|
|
117
|
+
ORDER BY id DESC LIMIT 1)`, site, await visitorOf(sql, site, headers), page));
|
|
118
|
+
}
|
|
106
119
|
/** Record one named event. Never rejects. */
|
|
107
120
|
export function recordEvent(sql, { name, props = {}, url, headers, site = '' }) {
|
|
108
121
|
return quietly(async () => {
|
|
@@ -147,7 +160,9 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
|
|
|
147
160
|
// decoded twice, `/100%25-off` was dropped and `/what%3Fnow` cut to `/what`.
|
|
148
161
|
defer(() => typeof body.name === 'string' && body.name
|
|
149
162
|
? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: raw, headers, site: key })
|
|
150
|
-
:
|
|
163
|
+
: body.engaged === true
|
|
164
|
+
? recordEngaged(sql, { headers, path: raw, site: key })
|
|
165
|
+
: recordView(sql, { headers, path: raw, referrer: body.referrer, utm: body.utm, site: key }));
|
|
151
166
|
return done;
|
|
152
167
|
}
|
|
153
168
|
/** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
|
|
@@ -158,30 +173,40 @@ export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
|
|
|
158
173
|
* `Stats` calls it on the server, where a client module's export is only a reference. */
|
|
159
174
|
export const count = (n) => n.toLocaleString('en');
|
|
160
175
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
176
|
+
* A referrer that sent this many visitors in the window, not one of whom read a
|
|
177
|
+
* second page, is a crawler naming a site it never came from: aocr.org sent
|
|
178
|
+
* radix.wiki 319 one-page visitors in a month while Google's bounced four in five.
|
|
179
|
+
*/
|
|
180
|
+
const BOT_VISITORS = 20;
|
|
181
|
+
const BOT_SOURCE = '(bot)';
|
|
182
|
+
/** A visit's source: its first view's, or `(bot)` where that source is among `bots`. */
|
|
183
|
+
const visitSource = (s) => {
|
|
184
|
+
const first = '(array_agg(source ORDER BY at, id))[1]';
|
|
185
|
+
return `CASE WHEN ${first} IN (SELECT source FROM bots${s}) THEN '${BOT_SOURCE}' ELSE ${first} END`;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Each filter as a test on one visit in the window suffixed `s`. A page,
|
|
189
|
+
* country or device is any of the visit's views; an entry page or a source is
|
|
190
|
+
* its first. `(none)` is no source.
|
|
163
191
|
*/
|
|
164
192
|
const VISIT_TEST = {
|
|
165
193
|
page: v => `bool_or(path = ${v})`,
|
|
166
194
|
entry: v => `(array_agg(path ORDER BY at, id))[1] = ${v}`,
|
|
167
|
-
source: v => `coalesce((
|
|
195
|
+
source: (v, s) => `coalesce(${visitSource(s)}, '(none)') = ${v}`,
|
|
168
196
|
country: v => `bool_or(country = ${v})`,
|
|
169
197
|
device: v => `bool_or(device = ${v})`,
|
|
170
198
|
};
|
|
171
|
-
/** An agent's source and device in the lists, which no request of its carries. */
|
|
172
|
-
const AGENT_SOURCE = '(agent)';
|
|
173
|
-
const AGENT_DEVICE = 'agent';
|
|
174
199
|
/**
|
|
175
200
|
* Each filter as a test on one agent: its reads in the window are its visit,
|
|
176
|
-
* the first its entry page
|
|
177
|
-
*
|
|
201
|
+
* the first its entry page, and its country the one its requests came from.
|
|
202
|
+
* No request of an agent's carries a source or a device, so neither passes one.
|
|
178
203
|
*/
|
|
179
204
|
const AGENT_TEST = {
|
|
180
205
|
page: v => `bool_or(path = ${v})`,
|
|
181
206
|
entry: v => `(array_agg(path ORDER BY at))[1] = ${v}`,
|
|
182
|
-
source:
|
|
207
|
+
source: () => 'false',
|
|
183
208
|
country: v => `bool_or(country = ${v})`,
|
|
184
|
-
device:
|
|
209
|
+
device: () => 'false',
|
|
185
210
|
};
|
|
186
211
|
/**
|
|
187
212
|
* An agent's name from its event: the roster's label, else what its user agent
|
|
@@ -193,11 +218,11 @@ const AGENT_NAME = `coalesce(props->>'bot', substring(props->>'ua' from 'compati
|
|
|
193
218
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
194
219
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
195
220
|
* page, visit, source, country, device and per-day figures; events have no
|
|
196
|
-
* history
|
|
221
|
+
* history.
|
|
197
222
|
* The window before is the same chain of views, visits and events a second
|
|
198
223
|
* time, `_p` on every name, so its figures are the headline's to the letter.
|
|
199
224
|
*/
|
|
200
|
-
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {}
|
|
225
|
+
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
|
|
201
226
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
202
227
|
// All time is a century: further back than any row, and still an int to Postgres.
|
|
203
228
|
const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
|
|
@@ -207,17 +232,29 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
207
232
|
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`;
|
|
208
233
|
/** Clicks on links to the site's own X account, among `e`. */
|
|
209
234
|
const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
const
|
|
235
|
+
/** Each filter given, its value a parameter that the test on visits and the one on agents share. */
|
|
236
|
+
const given = STATS_FILTERS.flatMap(k => (filter[k] ? [[k, $(filter[k])]] : []));
|
|
237
|
+
const tests = given.map(([k, v]) => (s) => VISIT_TEST[k](v, s));
|
|
238
|
+
const agentTests = given.map(([k, v]) => AGENT_TEST[k](v));
|
|
214
239
|
const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
|
|
215
240
|
/** The site's own agents, as a test on an event's user agent. */
|
|
216
241
|
const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
|
|
242
|
+
/**
|
|
243
|
+
* A typeahead logs each settled keystroke, so one reader typing a question
|
|
244
|
+
* filled acuiq's list with thirteen of its prefixes. A query is left out when
|
|
245
|
+
* the same visitor's next one, inside five minutes, extends it: only where
|
|
246
|
+
* the reader stopped is a gap. So is a single character.
|
|
247
|
+
*/
|
|
217
248
|
const gapSelects = gaps.map(g => {
|
|
218
249
|
const prop = $(g.prop);
|
|
219
|
-
return `(SELECT
|
|
220
|
-
|
|
250
|
+
return `(SELECT query, count(*) AS events FROM (
|
|
251
|
+
SELECT props, visitor, props->>${prop} AS query,
|
|
252
|
+
lead(props->>${prop}) OVER typed AS next, lead(at) OVER typed - at AS after
|
|
253
|
+
FROM e WHERE name = ${$(g.event)} AND props->>${prop} IS NOT NULL
|
|
254
|
+
WINDOW typed AS (PARTITION BY visitor ORDER BY at)
|
|
255
|
+
) s
|
|
256
|
+
WHERE props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND char_length(query) > 1
|
|
257
|
+
AND NOT coalesce(visitor IS NOT NULL AND after <= interval '5 minutes' AND char_length(next) > char_length(query) AND starts_with(next, query), false)
|
|
221
258
|
GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
|
|
222
259
|
});
|
|
223
260
|
/**
|
|
@@ -229,17 +266,20 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
229
266
|
const dayAgo = (k) => `(now() AT TIME ZONE 'UTC' - make_interval(days => ${k} * $2::int))::date`;
|
|
230
267
|
const at = `"createdAt" >= ${ago(back + 1)}${back ? ` AND "createdAt" < ${ago(back)}` : ''}`;
|
|
231
268
|
return `viewed${s} AS (
|
|
232
|
-
SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View" WHERE "siteId" = $1 AND ${at}
|
|
269
|
+
SELECT id, path, source, country, device, visitor, engaged, "createdAt" AS at FROM "View" WHERE "siteId" = $1 AND ${at}
|
|
233
270
|
), marked${s} AS (
|
|
234
271
|
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${s}
|
|
235
272
|
), numbered${s} AS (
|
|
236
273
|
SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked${s}
|
|
274
|
+
), bots${s} AS (
|
|
275
|
+
SELECT source FROM (SELECT visitor, (array_agg(source ORDER BY at, id))[1] AS source, count(*) AS views FROM numbered${s} GROUP BY visitor, visit) f
|
|
276
|
+
WHERE source IS NOT NULL GROUP BY 1 HAVING count(DISTINCT visitor) >= ${BOT_VISITORS} AND max(views) = 1
|
|
237
277
|
), visits${s} AS (
|
|
238
|
-
SELECT visitor, visit, min(at) AS at, count(*)
|
|
239
|
-
(array_agg(path ORDER BY at, id))[1] AS entry, (
|
|
240
|
-
FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
|
|
278
|
+
SELECT visitor, visit, min(at) AS at, count(*) = 1 AND NOT bool_or(engaged) AS bounced,
|
|
279
|
+
(array_agg(path ORDER BY at, id))[1] AS entry, ${visitSource(s)} AS source
|
|
280
|
+
FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.map(t => t(s)).join(' AND ')}` : ''}
|
|
241
281
|
), v${s} AS (
|
|
242
|
-
${tests.length ? `SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered${s} n JOIN visits${s} USING (visitor, visit)` : `SELECT * FROM viewed${s}`}
|
|
282
|
+
${tests.length ? `SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.engaged, n.at FROM numbered${s} n JOIN visits${s} USING (visitor, visit)` : `SELECT * FROM viewed${s}`}
|
|
243
283
|
), read${s} AS (
|
|
244
284
|
SELECT visitor, "createdAt" AS at, path, props->>'country' AS country, ${AGENT_NAME} AS agent FROM "Event"
|
|
245
285
|
WHERE "siteId" = $1 AND ${at} AND name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
|
|
@@ -248,55 +288,53 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
248
288
|
), e${s} AS (
|
|
249
289
|
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
|
|
250
290
|
${tests.length ? `AND (visitor IN (SELECT visitor FROM v${s}) OR visitor IN (SELECT visitor FROM ag${s}))` : ''}
|
|
251
|
-
), acted${s} AS MATERIALIZED (
|
|
252
|
-
SELECT visitor, at FROM e${s} WHERE name NOT IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL
|
|
253
|
-
), bounced${s} AS (
|
|
254
|
-
-- Against the few reader events alone: probing every agent event once per visit took seconds.
|
|
255
|
-
SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
|
|
256
|
-
SELECT 1 FROM acted${s} x WHERE x.visitor = b.visitor AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
|
|
257
291
|
), a${s} AS (
|
|
258
292
|
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
|
|
259
|
-
), sessions${s} AS (
|
|
260
|
-
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
261
|
-
SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
|
|
262
|
-
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
|
|
263
|
-
) t(bounces, visits, seconds)
|
|
264
293
|
), archived${s} AS (
|
|
265
|
-
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a${s}
|
|
294
|
+
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews, coalesce(sum(visits), 0) AS visits FROM a${s}
|
|
266
295
|
)`;
|
|
267
296
|
};
|
|
297
|
+
/**
|
|
298
|
+
* A figure engagement decides. The window before has it only where
|
|
299
|
+
* engagement was counted from its first day: its first engaged view is on the day of its first view.
|
|
300
|
+
*/
|
|
301
|
+
const engagement = (s, figure) => {
|
|
302
|
+
if (!s)
|
|
303
|
+
return figure;
|
|
304
|
+
const first = (where) => `(SELECT ${day('min(at)')} FROM viewed${s}${where})`;
|
|
305
|
+
return `CASE WHEN ${first(' WHERE engaged')} = ${first('')} THEN ${figure} END`;
|
|
306
|
+
};
|
|
268
307
|
/** One window's headline figures, as `json_build_object` arguments. */
|
|
269
308
|
const headline = (s) => `'visitors', (SELECT count(DISTINCT visitor) FROM v${s}) + (SELECT visitors FROM archived${s}),
|
|
309
|
+
'engaged', ${engagement(s, `(SELECT count(DISTINCT visitor) FROM v${s} WHERE engaged)`)},
|
|
270
310
|
'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
|
|
271
|
-
'
|
|
272
|
-
'
|
|
273
|
-
'agents', (SELECT count(DISTINCT visitor) FROM ag${s})
|
|
274
|
-
'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})`;
|
|
311
|
+
'visits', (SELECT count(*) FROM visits${s}) + (SELECT visits FROM archived${s}),
|
|
312
|
+
'bounce_rate', ${engagement(s, `(SELECT round(100.0 * count(*) FILTER (WHERE bounced) / nullif(count(*), 0)) FROM visits${s})`)},
|
|
313
|
+
'agents', (SELECT count(DISTINCT visitor) FROM ag${s})`;
|
|
275
314
|
const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
|
|
276
315
|
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
277
|
-
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
316
|
+
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
278
317
|
)
|
|
279
318
|
SELECT json_build_object(
|
|
280
319
|
${headline('')},
|
|
281
320
|
'previous', json_build_object(${headline('_p')}, 'follow_clicks', ${follows ? `(SELECT count(*) FROM e_p WHERE ${follows})` : 'NULL'}),
|
|
282
321
|
'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
|
|
322
|
+
'engaged_from', (SELECT ${day('min(at)')} FROM (SELECT at FROM viewed_p WHERE engaged UNION ALL SELECT at FROM viewed WHERE engaged) t),
|
|
283
323
|
'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
|
|
284
|
-
'top_pages', ${list(merged('page', 'pages',
|
|
285
|
-
'entry_pages', ${list(merged('page', 'entryPages',
|
|
324
|
+
'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
|
|
325
|
+
'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
|
|
286
326
|
'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
|
|
287
327
|
'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
|
|
288
|
-
'countries', ${list(merged('country', 'countries',
|
|
289
|
-
'devices', ${list(merged('device', 'devices',
|
|
290
|
-
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
291
|
-
sum(
|
|
292
|
-
SELECT ${day('at')}, count(DISTINCT visitor), count(
|
|
293
|
-
UNION ALL SELECT ${day('at')}, 0, 0, count(*),
|
|
294
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0,
|
|
295
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(
|
|
296
|
-
UNION ALL SELECT
|
|
297
|
-
|
|
298
|
-
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
|
|
299
|
-
) t(date, visitors, pageviews, visits, bounces, seconds, agents, everyone, follows) GROUP BY 1`, undefined, '1')},
|
|
328
|
+
'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
|
|
329
|
+
'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
|
|
330
|
+
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(engaged) AS engaged, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
331
|
+
sum(agents) AS agents, sum(follows) AS follow_clicks FROM (
|
|
332
|
+
SELECT ${day('at')}, count(DISTINCT visitor), count(DISTINCT visitor) FILTER (WHERE engaged), count(*), 0, 0, 0, 0 FROM v GROUP BY 1
|
|
333
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), count(*) FILTER (WHERE bounced), 0, 0 FROM visits GROUP BY 1
|
|
334
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM ag GROUP BY 1
|
|
335
|
+
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
336
|
+
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, 0, pageviews, visits, 0, 0, 0 FROM a
|
|
337
|
+
) t(date, visitors, engaged, pageviews, visits, bounces, agents, follows) GROUP BY 1`, undefined, '1')},
|
|
300
338
|
'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
|
|
301
339
|
'top_agents', ${list('SELECT agent, count(DISTINCT visitor) AS visitors FROM ag GROUP BY 1', 10)},
|
|
302
340
|
'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
|
|
@@ -316,7 +354,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
316
354
|
for (const k of Object.keys(p))
|
|
317
355
|
p[k] = null;
|
|
318
356
|
else
|
|
319
|
-
p.agents = p.
|
|
357
|
+
p.agents = p.follow_clicks = p.engaged = p.bounce_rate = null;
|
|
320
358
|
}
|
|
321
359
|
return d;
|
|
322
360
|
}
|
package/dist/dom.d.ts
CHANGED
|
@@ -111,6 +111,11 @@ export declare function scrollTables(html: string): string;
|
|
|
111
111
|
* A click on a link to another host is sent as an "Outbound Link: Click" event
|
|
112
112
|
* carrying its `url`. Starts once per page load however often it is called, so
|
|
113
113
|
* React's doubled effects in development send one view, not two.
|
|
114
|
+
*
|
|
115
|
+
* Each view is then marked engaged, `{ path, engaged: true }`, once its reader
|
|
116
|
+
* has had the page in a visible tab for `ENGAGED_MS`, or touches, clicks, types
|
|
117
|
+
* or wheels on it first. A crawler that runs scripts renders a page and leaves
|
|
118
|
+
* within seconds and gives it no input, so engaged views are the people's.
|
|
114
119
|
*/
|
|
115
120
|
export declare function startBeacon(endpoint?: string): void;
|
|
116
121
|
/** Record a named event from the browser, on the endpoint `startBeacon` was given. */
|
package/dist/dom.js
CHANGED
|
@@ -330,6 +330,8 @@ export function scrollTables(html) {
|
|
|
330
330
|
// ---- analytics beacon -------------------------------------------------------
|
|
331
331
|
let beaconEndpoint = '/api/view';
|
|
332
332
|
let beaconStarted = false;
|
|
333
|
+
/** How long a view's page must be visible, in all, before its reader counts as engaged. */
|
|
334
|
+
const ENGAGED_MS = 15_000;
|
|
333
335
|
// A browser under automation (Puppeteer, Playwright, Selenium) sets
|
|
334
336
|
// `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
|
|
335
337
|
// pattern cannot tell from a reader's, so it sends nothing at all.
|
|
@@ -345,6 +347,11 @@ const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaco
|
|
|
345
347
|
* A click on a link to another host is sent as an "Outbound Link: Click" event
|
|
346
348
|
* carrying its `url`. Starts once per page load however often it is called, so
|
|
347
349
|
* React's doubled effects in development send one view, not two.
|
|
350
|
+
*
|
|
351
|
+
* Each view is then marked engaged, `{ path, engaged: true }`, once its reader
|
|
352
|
+
* has had the page in a visible tab for `ENGAGED_MS`, or touches, clicks, types
|
|
353
|
+
* or wheels on it first. A crawler that runs scripts renders a page and leaves
|
|
354
|
+
* within seconds and gives it no input, so engaged views are the people's.
|
|
348
355
|
*/
|
|
349
356
|
export function startBeacon(endpoint = '/api/view') {
|
|
350
357
|
if (beaconStarted || typeof window === 'undefined')
|
|
@@ -353,6 +360,30 @@ export function startBeacon(endpoint = '/api/view') {
|
|
|
353
360
|
beaconEndpoint = endpoint;
|
|
354
361
|
let last = '';
|
|
355
362
|
let first = true;
|
|
363
|
+
/** The path whose view is not yet engaged, the visible time it has had, and since when it has been visible. */
|
|
364
|
+
let pending = null;
|
|
365
|
+
let seen = 0;
|
|
366
|
+
let since = 0;
|
|
367
|
+
let timer;
|
|
368
|
+
const engage = () => {
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
if (pending !== null)
|
|
371
|
+
sendBeacon({ path: pending, engaged: true });
|
|
372
|
+
pending = null;
|
|
373
|
+
};
|
|
374
|
+
const watch = () => {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
if (pending === null)
|
|
377
|
+
return;
|
|
378
|
+
if (document.visibilityState === 'visible') {
|
|
379
|
+
since = Date.now();
|
|
380
|
+
timer = setTimeout(engage, ENGAGED_MS - seen);
|
|
381
|
+
}
|
|
382
|
+
else if (since) {
|
|
383
|
+
seen += Date.now() - since;
|
|
384
|
+
since = 0;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
356
387
|
const view = () => {
|
|
357
388
|
if (location.pathname === last)
|
|
358
389
|
return;
|
|
@@ -364,7 +395,15 @@ export function startBeacon(endpoint = '/api/view') {
|
|
|
364
395
|
utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
|
|
365
396
|
});
|
|
366
397
|
first = false;
|
|
398
|
+
pending = last;
|
|
399
|
+
seen = since = 0;
|
|
400
|
+
watch();
|
|
367
401
|
};
|
|
402
|
+
document.addEventListener('visibilitychange', watch);
|
|
403
|
+
// Not `scroll`, which a script fires as readily as a reader. Capture, so a tap on a link engages the page it leaves.
|
|
404
|
+
for (const type of ['pointerdown', 'keydown', 'wheel', 'touchstart']) {
|
|
405
|
+
addEventListener(type, e => e.isTrusted && engage(), { capture: true, passive: true });
|
|
406
|
+
}
|
|
368
407
|
for (const method of ['pushState', 'replaceState']) {
|
|
369
408
|
const write = history[method];
|
|
370
409
|
history[method] = function (...args) {
|
package/dist/search.d.ts
CHANGED
|
@@ -72,3 +72,22 @@ export declare const FTS_RANK_NORMALIZATION = 32;
|
|
|
72
72
|
* rather than as a pattern matching everything.
|
|
73
73
|
*/
|
|
74
74
|
export declare function escapeLikeTerm(query: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* A query as the full-text tier reads it: a hyphen or underscore inside a word
|
|
77
|
+
* is a space. `websearch_to_tsquery` keeps a hyphenated word as a phrase that
|
|
78
|
+
* needs the compound itself, so the slug `radix-pipeline` found nothing where
|
|
79
|
+
* `radix pipeline` found sixteen pages. A leading `-` is websearch's NOT, and
|
|
80
|
+
* stays.
|
|
81
|
+
*/
|
|
82
|
+
export declare function fullTextQuery(query: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* A query with each misspelled word replaced by the nearest word in `vocabulary`
|
|
85
|
+
* (word → how often it occurs), or `null` when no word changes. Called only for
|
|
86
|
+
* a query that found nothing, so a word the wiki already uses is never touched.
|
|
87
|
+
*
|
|
88
|
+
* Tight on purpose: a word of four to seven letters may be one edit out, a
|
|
89
|
+
* longer one two, and a shorter one none. Looser, a name the wiki lacks turns
|
|
90
|
+
* into a word it has – `bondix` into `bonds` – and the reader gets an answer to
|
|
91
|
+
* a question they did not ask. Ties go to the commoner word.
|
|
92
|
+
*/
|
|
93
|
+
export declare function correctQuery(query: string, vocabulary: ReadonlyMap<string, number>): string | null;
|
package/dist/search.js
CHANGED
|
@@ -120,3 +120,62 @@ export const FTS_RANK_NORMALIZATION = 32;
|
|
|
120
120
|
export function escapeLikeTerm(query) {
|
|
121
121
|
return (query ?? '').trim().replace(/[\\%_]/g, char => `\\${char}`);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* A query as the full-text tier reads it: a hyphen or underscore inside a word
|
|
125
|
+
* is a space. `websearch_to_tsquery` keeps a hyphenated word as a phrase that
|
|
126
|
+
* needs the compound itself, so the slug `radix-pipeline` found nothing where
|
|
127
|
+
* `radix pipeline` found sixteen pages. A leading `-` is websearch's NOT, and
|
|
128
|
+
* stays.
|
|
129
|
+
*/
|
|
130
|
+
export function fullTextQuery(query) {
|
|
131
|
+
return (query ?? '').trim().replace(/([\p{L}\p{N}])[-_]+(?=[\p{L}\p{N}])/gu, '$1 ');
|
|
132
|
+
}
|
|
133
|
+
/** Edits between two words, a swapped adjacent pair counting as one. */
|
|
134
|
+
function editDistance(a, b) {
|
|
135
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
136
|
+
for (let j = 1; j <= b.length; j++)
|
|
137
|
+
d[0][j] = j;
|
|
138
|
+
for (let i = 1; i <= a.length; i++)
|
|
139
|
+
for (let j = 1; j <= b.length; j++) {
|
|
140
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
141
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
142
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
143
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
144
|
+
}
|
|
145
|
+
return d[a.length][b.length];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A query with each misspelled word replaced by the nearest word in `vocabulary`
|
|
149
|
+
* (word → how often it occurs), or `null` when no word changes. Called only for
|
|
150
|
+
* a query that found nothing, so a word the wiki already uses is never touched.
|
|
151
|
+
*
|
|
152
|
+
* Tight on purpose: a word of four to seven letters may be one edit out, a
|
|
153
|
+
* longer one two, and a shorter one none. Looser, a name the wiki lacks turns
|
|
154
|
+
* into a word it has – `bondix` into `bonds` – and the reader gets an answer to
|
|
155
|
+
* a question they did not ask. Ties go to the commoner word.
|
|
156
|
+
*/
|
|
157
|
+
export function correctQuery(query, vocabulary) {
|
|
158
|
+
let changed = false;
|
|
159
|
+
const fixed = query.trim().toLowerCase().split(/\s+/).map(word => {
|
|
160
|
+
const budget = word.length >= 8 ? 2 : word.length >= 4 ? 1 : 0;
|
|
161
|
+
if (!budget || vocabulary.has(word) || !/^[\p{L}\p{N}]+$/u.test(word))
|
|
162
|
+
return word;
|
|
163
|
+
let best = null;
|
|
164
|
+
let bestDistance = Infinity;
|
|
165
|
+
let bestCount = 0;
|
|
166
|
+
for (const [candidate, count] of vocabulary) {
|
|
167
|
+
if (Math.abs(candidate.length - word.length) > budget)
|
|
168
|
+
continue;
|
|
169
|
+
const distance = editDistance(word, candidate);
|
|
170
|
+
if (distance > budget)
|
|
171
|
+
continue;
|
|
172
|
+
if (distance < bestDistance || (distance === bestDistance && count > bestCount))
|
|
173
|
+
[best, bestDistance, bestCount] = [candidate, distance, count];
|
|
174
|
+
}
|
|
175
|
+
if (!best)
|
|
176
|
+
return word;
|
|
177
|
+
changed = true;
|
|
178
|
+
return best;
|
|
179
|
+
});
|
|
180
|
+
return changed ? fixed.join(' ') : null;
|
|
181
|
+
}
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -6,10 +6,9 @@ import { type Digest } from './analytics.js';
|
|
|
6
6
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
7
7
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
8
8
|
*/
|
|
9
|
-
export declare function StatsFigures({ digest, first,
|
|
9
|
+
export declare function StatsFigures({ digest, first, controls }: {
|
|
10
10
|
digest: Digest;
|
|
11
11
|
first: number;
|
|
12
|
-
apart: boolean;
|
|
13
12
|
controls: ReactNode;
|
|
14
13
|
}): import("react").JSX.Element;
|
|
15
14
|
export interface PasskeyButtonProps {
|
package/dist/stats-figures.js
CHANGED
|
@@ -9,10 +9,11 @@ import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
|
|
|
9
9
|
import { useMemo, useState } from 'react';
|
|
10
10
|
import { count } from './analytics.js';
|
|
11
11
|
import { TimeChart, stepDecimals } from './chart.js';
|
|
12
|
-
// Whole
|
|
12
|
+
// Whole 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
|
-
const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(Math.round(s) / 60)}m ${Math.round(s) % 60}s`);
|
|
15
14
|
const percent = (n, step) => `${n.toFixed(places(step))}%`;
|
|
15
|
+
// Views per visit sit between one and a few, so a tenth tells them apart.
|
|
16
|
+
const perVisit = (n, step) => n.toFixed(step ? stepDecimals(step) : 1);
|
|
16
17
|
/**
|
|
17
18
|
* The change from the window before: a share of the figure before, or for a
|
|
18
19
|
* rate its difference in points, since a bounce rate up 10% of itself reads
|
|
@@ -26,22 +27,24 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
|
|
|
26
27
|
const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
|
|
27
28
|
return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
|
|
28
29
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
]),
|
|
30
|
+
/**
|
|
31
|
+
* People, the ones among them who engaged, and agents, never one count of both.
|
|
32
|
+
* Engaged and bounce rate are drawn from the day engagement was first counted:
|
|
33
|
+
* before it is nothing, not days of no one engaging.
|
|
34
|
+
*/
|
|
35
|
+
const figuresOf = ({ previous: p, ...d }) => [
|
|
36
|
+
{ label: 'People', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
|
|
37
|
+
{ label: 'Engaged', value: count(d.engaged), delta: change(d.engaged, p.engaged), day: r => r.engaged, from: d.engaged_from ?? '9999' },
|
|
38
|
+
{ label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: r => r.agents },
|
|
44
39
|
{ label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
|
|
40
|
+
{
|
|
41
|
+
label: 'Views per visit',
|
|
42
|
+
value: d.visits ? perVisit(d.pageviews / d.visits) : '–',
|
|
43
|
+
delta: change(d.visits ? d.pageviews / d.visits : null, p.pageviews != null && p.visits ? p.pageviews / p.visits : null),
|
|
44
|
+
day: r => (r.visits ? r.pageviews / r.visits : null),
|
|
45
|
+
format: perVisit,
|
|
46
|
+
aggregate: 'mean',
|
|
47
|
+
},
|
|
45
48
|
{
|
|
46
49
|
label: 'Bounce rate',
|
|
47
50
|
value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
|
|
@@ -49,14 +52,7 @@ const figuresOf = ({ previous: p, ...d }, apart) => [
|
|
|
49
52
|
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
50
53
|
format: percent,
|
|
51
54
|
aggregate: 'mean',
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
label: 'Visit length',
|
|
55
|
-
value: d.visit_duration == null ? '–' : duration(d.visit_duration),
|
|
56
|
-
delta: change(d.visit_duration, p.visit_duration),
|
|
57
|
-
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
58
|
-
format: duration,
|
|
59
|
-
aggregate: 'mean',
|
|
55
|
+
from: d.engaged_from ?? '9999',
|
|
60
56
|
},
|
|
61
57
|
...(d.follow_clicks
|
|
62
58
|
? [
|
|
@@ -70,16 +66,16 @@ const figuresOf = ({ previous: p, ...d }, apart) => [
|
|
|
70
66
|
: []),
|
|
71
67
|
];
|
|
72
68
|
const DAY = 86_400;
|
|
73
|
-
const EMPTY = { date: '', visitors: 0,
|
|
69
|
+
const EMPTY = { date: '', visitors: 0, engaged: 0, pageviews: 0, visits: 0, bounces: 0, agents: 0, follow_clicks: 0 };
|
|
74
70
|
/**
|
|
75
71
|
* The figures as `.stats-figures button[aria-pressed]`, each with its change
|
|
76
72
|
* as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
|
|
77
73
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
78
74
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
79
75
|
*/
|
|
80
|
-
export function StatsFigures({ digest, first,
|
|
81
|
-
const figures = useMemo(() => figuresOf(digest
|
|
82
|
-
// By name, since a window
|
|
76
|
+
export function StatsFigures({ digest, first, controls }) {
|
|
77
|
+
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
78
|
+
// By name, since a window can arrive without a reload and the figures shift (Clicks to X comes and goes).
|
|
83
79
|
const [chosen, choose] = useState(figures[0].label);
|
|
84
80
|
const figure = figures.find(f => f.label === chosen) ?? figures[0];
|
|
85
81
|
const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
|
|
@@ -87,7 +83,8 @@ export function StatsFigures({ digest, first, apart, controls }) {
|
|
|
87
83
|
const today = Math.floor(Date.now() / 1000 / DAY);
|
|
88
84
|
return Array.from({ length: today - first + 1 }, (_, i) => {
|
|
89
85
|
const time = (first + i) * DAY;
|
|
90
|
-
const
|
|
86
|
+
const date = new Date(time * 1000).toISOString().slice(0, 10);
|
|
87
|
+
const value = figure.from && date < figure.from ? null : figure.day(byDay.get(date) ?? EMPTY);
|
|
91
88
|
return value == null ? null : { time, value };
|
|
92
89
|
}).filter(p => p !== null);
|
|
93
90
|
}, [byDay, first, figure]);
|
package/dist/stats.d.ts
CHANGED
|
@@ -9,8 +9,6 @@ export interface StatsProps {
|
|
|
9
9
|
days: number;
|
|
10
10
|
/** What the digest was narrowed to, as `digest` was given it. */
|
|
11
11
|
filter?: StatsFilter;
|
|
12
|
-
/** Visitors as people and agents, two figures, rather than one that counts both. */
|
|
13
|
-
apart?: boolean;
|
|
14
12
|
/** The windows offered as `?days=` links on the same page. */
|
|
15
13
|
ranges?: readonly number[];
|
|
16
14
|
/** Every link on the page: `next/link`, say, so a window or a filter does not reload it. Each is given `prefetch={false}`. */
|
|
@@ -24,17 +22,16 @@ export interface StatsProps {
|
|
|
24
22
|
export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
|
|
25
23
|
days: number;
|
|
26
24
|
filter: Partial<Record<"page" | "country" | "entry" | "source" | "device", string>>;
|
|
27
|
-
apart: boolean;
|
|
28
25
|
};
|
|
29
26
|
/**
|
|
30
27
|
* A site's own analytics on one page: the headline figures against the window
|
|
31
|
-
* before,
|
|
28
|
+
* before, people and agents apart, whichever of them is chosen per day, and every ranked list the
|
|
32
29
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
33
|
-
* nothing in them are left out
|
|
34
|
-
*
|
|
30
|
+
* nothing in them are left out, but for the searches that found nothing,
|
|
31
|
+
* which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
|
|
35
32
|
* until the importing stylesheet gives them a colour.
|
|
36
33
|
*/
|
|
37
|
-
export declare function Stats({ digest: d, days, filter,
|
|
34
|
+
export declare function Stats({ digest: d, days, filter, ranges, link: Link }: StatsProps): import("react").JSX.Element;
|
|
38
35
|
export interface StatsGateProps {
|
|
39
36
|
gate: PasskeyGate;
|
|
40
37
|
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
package/dist/stats.js
CHANGED
|
@@ -22,14 +22,11 @@ export function statsQuery(params, ranges = STATS_RANGES) {
|
|
|
22
22
|
if (typeof v === 'string' && v)
|
|
23
23
|
filter[k] = v.slice(0, 500);
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
return { days: statsDays(params.days, ranges), filter, apart: params.agents === 'apart' };
|
|
25
|
+
return { days: statsDays(params.days, ranges), filter };
|
|
27
26
|
}
|
|
28
|
-
/** The page at a window
|
|
29
|
-
const href = (days, filter
|
|
27
|
+
/** The page at a window and a filter, as a query string. */
|
|
28
|
+
const href = (days, filter) => {
|
|
30
29
|
const q = new URLSearchParams({ days: daysParam(days) });
|
|
31
|
-
if (apart)
|
|
32
|
-
q.set('agents', 'apart');
|
|
33
30
|
for (const k of STATS_FILTERS)
|
|
34
31
|
if (filter[k])
|
|
35
32
|
q.set(k, filter[k]);
|
|
@@ -46,21 +43,21 @@ const country = (code) => {
|
|
|
46
43
|
};
|
|
47
44
|
const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
|
|
48
45
|
const DAY_MS = 86_400_000;
|
|
49
|
-
function StatsList({ title, rows, label = v => v, to, link: Link, }) {
|
|
46
|
+
function StatsList({ title, rows, label = v => v, to, empty, link: Link, }) {
|
|
50
47
|
if (!rows.length)
|
|
51
|
-
return null;
|
|
48
|
+
return empty ? (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("p", { children: empty })] })) : null;
|
|
52
49
|
const max = Math.max(...rows.map(([, n]) => n));
|
|
53
50
|
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(Link, { href: to(v), prefetch: false, children: label(v) })) : (label(v)) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
|
|
54
51
|
}
|
|
55
52
|
/**
|
|
56
53
|
* A site's own analytics on one page: the headline figures against the window
|
|
57
|
-
* before,
|
|
54
|
+
* before, people and agents apart, whichever of them is chosen per day, and every ranked list the
|
|
58
55
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
59
|
-
* nothing in them are left out
|
|
60
|
-
*
|
|
56
|
+
* nothing in them are left out, but for the searches that found nothing,
|
|
57
|
+
* which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
|
|
61
58
|
* until the importing stylesheet gives them a colour.
|
|
62
59
|
*/
|
|
63
|
-
export function Stats({ digest: d, days, filter = {},
|
|
60
|
+
export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES, link: Link = Anchor }) {
|
|
64
61
|
const today = Math.floor(Date.now() / DAY_MS);
|
|
65
62
|
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
66
63
|
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
@@ -72,12 +69,11 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
|
|
|
72
69
|
? Math.max(start, counted)
|
|
73
70
|
: Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
|
|
74
71
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
75
|
-
const at = (f, n = days
|
|
72
|
+
const at = (f, n = days) => href(n, f);
|
|
76
73
|
const to = (k) => (v) => at({ ...filter, [k]: v });
|
|
77
|
-
// Each window is another digest, so a link rather than a button, pinned
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
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(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", prefetch: false, 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, { link: Link, title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { link: Link, title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { link: Link, title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { link: Link, title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { link: Link, title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { link: Link, title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { link: Link, title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
74
|
+
// Each window is another digest, so a link rather than a button, pinned with the chart's own controls.
|
|
75
|
+
const controls = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(Link, { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, prefetch: false, children: windowLabel(n) }, n))) }));
|
|
76
|
+
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(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", prefetch: false, 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, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { link: Link, title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { link: Link, title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { link: Link, title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { link: Link, title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { link: Link, title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { link: Link, title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { link: Link, title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events'), empty: "None in this window." })] })] }));
|
|
81
77
|
}
|
|
82
78
|
/**
|
|
83
79
|
* A stats page's door: `children` and a sign-out button for a signed-in
|
package/dist/store.d.ts
CHANGED
|
@@ -47,6 +47,13 @@ export type WikiRevisionEntry = Omit<WikiRevisionRow, 'content'> & {
|
|
|
47
47
|
changes: HistoryChange[];
|
|
48
48
|
};
|
|
49
49
|
export type ListSort = 'title' | 'recent' | 'date';
|
|
50
|
+
/** A search's scope and page. `content` adds each row's blocks. */
|
|
51
|
+
export interface SearchOptions {
|
|
52
|
+
tagPath?: string | null;
|
|
53
|
+
skip?: number;
|
|
54
|
+
take?: number;
|
|
55
|
+
content?: boolean;
|
|
56
|
+
}
|
|
50
57
|
export interface WikiStoreOptions<B extends DiffBlock> {
|
|
51
58
|
sql: Sql;
|
|
52
59
|
/** The site every read and write is scoped to. "" in an app that serves one wiki. */
|
|
@@ -169,17 +176,14 @@ export declare function wikiStore<B extends DiffBlock = DiffBlock>(o: WikiStoreO
|
|
|
169
176
|
pages: number;
|
|
170
177
|
updated: Date | null;
|
|
171
178
|
}>;
|
|
172
|
-
search: (query: string, { tagPath, skip, take, content: withContent }?: {
|
|
173
|
-
tagPath?: string | null;
|
|
174
|
-
skip?: number;
|
|
175
|
-
take?: number;
|
|
176
|
-
content?: boolean;
|
|
177
|
-
}) => Promise<{
|
|
179
|
+
search: (query: string, options?: SearchOptions) => Promise<Awaited<ReturnType<(query: string, { tagPath, skip, take, content: withContent }?: SearchOptions) => Promise<{
|
|
178
180
|
items: (WikiPageListing & {
|
|
179
181
|
score: number;
|
|
180
182
|
content?: B[];
|
|
181
183
|
})[];
|
|
182
184
|
total: number;
|
|
185
|
+
}>>> & {
|
|
186
|
+
corrected?: string;
|
|
183
187
|
}>;
|
|
184
188
|
history: (pageId: string, { limit, offset }?: {
|
|
185
189
|
limit?: number;
|
package/dist/store.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { computeRevisionDiff } from './revisions.js';
|
|
19
19
|
import { bump } from './versioning.js';
|
|
20
20
|
import { withChanges } from './history.js';
|
|
21
|
-
import { proseSql, escapeLikeTerm, HEADLINE_OPTIONS, FTS_RANK_NORMALIZATION } from './search.js';
|
|
21
|
+
import { proseSql, escapeLikeTerm, fullTextQuery, correctQuery, HEADLINE_OPTIONS, FTS_RANK_NORMALIZATION } from './search.js';
|
|
22
22
|
import { openingText, matchSnippet } from './text.js';
|
|
23
23
|
import { describe } from './metadata.js';
|
|
24
24
|
import { decodeEntities } from './markdown.js';
|
|
@@ -132,7 +132,7 @@ export function wikiStore(o) {
|
|
|
132
132
|
* page is never a result. A tier-3 row's snippet is Postgres' own headline,
|
|
133
133
|
* because a stem match has no literal substring to window on.
|
|
134
134
|
*/
|
|
135
|
-
const
|
|
135
|
+
const ranked = async (query, { tagPath = null, skip = 0, take = 25, content: withContent = false } = {}) => {
|
|
136
136
|
const term = escapeLikeTerm(query);
|
|
137
137
|
if (!term)
|
|
138
138
|
return { items: [], total: 0 };
|
|
@@ -163,7 +163,7 @@ export function wikiStore(o) {
|
|
|
163
163
|
SELECT ${COLUMNS}, tier, total,
|
|
164
164
|
CASE WHEN tier = 3 THEN ts_headline('english', ${proseSql()}, tsq, '${HEADLINE_OPTIONS}') END AS headline
|
|
165
165
|
FROM (SELECT count(*)::int AS total FROM matched) t LEFT JOIN paged ON true
|
|
166
|
-
ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, query
|
|
166
|
+
ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, fullTextQuery(query), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
|
|
167
167
|
const items = found.filter(hit => hit.id !== null).map(({ tier, total: _, headline, content, ...row }) => ({
|
|
168
168
|
...row,
|
|
169
169
|
...(withContent ? { content: content } : {}),
|
|
@@ -173,6 +173,26 @@ export function wikiStore(o) {
|
|
|
173
173
|
}));
|
|
174
174
|
return { items, total: found[0]?.total ?? 0 };
|
|
175
175
|
};
|
|
176
|
+
/** Every word of the searchable titles, with how many titles use it: what a misspelling is corrected against. */
|
|
177
|
+
const titleWords = async (tagPath) => new Map((await rows(`SELECT word, count(*)::int AS n FROM ${PAGE} p, regexp_split_to_table(lower(p.title), '[^[:alnum:]]+') word
|
|
178
|
+
WHERE p."siteId" = $1 AND word <> '' AND ($2::text IS NULL OR p."tagPath" = $2) AND ($2::text IS NOT NULL OR NOT ${hiddenSql('$3')})
|
|
179
|
+
GROUP BY 1`, site, tagPath, hidden)).map(r => [r.word, r.n]));
|
|
180
|
+
/**
|
|
181
|
+
* {@link ranked}, and when its first page is empty, the same search with each
|
|
182
|
+
* misspelled word corrected against the titles' own words (`correctQuery`).
|
|
183
|
+
* A result found that way carries `corrected`, the query that found it, so a
|
|
184
|
+
* page can say what it searched for instead.
|
|
185
|
+
*/
|
|
186
|
+
const search = async (query, options = {}) => {
|
|
187
|
+
const found = await ranked(query, options);
|
|
188
|
+
if (found.total || options.skip)
|
|
189
|
+
return found;
|
|
190
|
+
const corrected = correctQuery(query, await titleWords(options.tagPath ?? null));
|
|
191
|
+
if (!corrected)
|
|
192
|
+
return found;
|
|
193
|
+
const again = await ranked(corrected, options);
|
|
194
|
+
return again.total ? { ...again, corrected } : found;
|
|
195
|
+
};
|
|
176
196
|
/** A revision query's guard: `$1`, its page, is this site's. Another site's page id finds nothing. */
|
|
177
197
|
const OWN_PAGE = `"pageId" IN (SELECT id FROM ${PAGE} WHERE id = $1 AND "siteId" = $2)`;
|
|
178
198
|
/**
|
package/dist/wiki-tools.d.ts
CHANGED
|
@@ -75,5 +75,6 @@ export declare function searchWiki<B extends DiffBlock>(o: Pick<WikiToolsOptions
|
|
|
75
75
|
}): Promise<{
|
|
76
76
|
items: WikiRow[];
|
|
77
77
|
total: number;
|
|
78
|
+
corrected?: string;
|
|
78
79
|
}>;
|
|
79
80
|
export declare function wikiReadTools<B extends DiffBlock>(o: WikiToolsOptions<B>): WikiTool[];
|
package/dist/wiki-tools.js
CHANGED
|
@@ -54,8 +54,8 @@ const rowFor = (o) => (p) => {
|
|
|
54
54
|
export async function searchWiki(o, query, { tagPath, page = 1, size = 20 } = {}) {
|
|
55
55
|
if (offFor(o)(tagPath))
|
|
56
56
|
return { items: [], total: 0 };
|
|
57
|
-
const { items, total } = await o.store.search(query, { tagPath, skip: (page - 1) * size, take: size, content: true });
|
|
58
|
-
return { items: items.map(rowFor(o)), total };
|
|
57
|
+
const { items, total, corrected } = await o.store.search(query, { tagPath, skip: (page - 1) * size, take: size, content: true });
|
|
58
|
+
return { items: items.map(rowFor(o)), total, ...(corrected ? { corrected } : {}) };
|
|
59
59
|
}
|
|
60
60
|
export function wikiReadTools(o) {
|
|
61
61
|
const { store, tree, noun } = o;
|
|
@@ -116,7 +116,7 @@ export function wikiReadTools(o) {
|
|
|
116
116
|
skill: { id: 'search', tags: ['search', 'wiki'] },
|
|
117
117
|
description: `Keyword search across ${noun}: the first call when you know what you are looking for but not where it lives. ` +
|
|
118
118
|
'Matches titles and body prose, ranked in four tiers: titles starting with the term, titles containing it, literal body hits, then English full-text hits by relevance, which stem words and drop stopwords. ' +
|
|
119
|
-
'Not semantic: one distinctive word beats a sentence. ' +
|
|
119
|
+
'Not semantic: one distinctive word beats a sentence. A query that finds nothing is retried with its misspelled words corrected, and `corrected` then names what was searched. ' +
|
|
120
120
|
'Each row carries a `path`, its size in `chars` and a snippet windowed on the match. Feed the paths to get_pages in one call rather than looping get_page. ' +
|
|
121
121
|
'Narrow with tagPath when a term is common. Defaults: page 1, pageSize 20 (max 50); keep paging while `hasMore` is true.',
|
|
122
122
|
inputSchema: {
|
|
@@ -137,9 +137,10 @@ export function wikiReadTools(o) {
|
|
|
137
137
|
const size = r.num('pageSize', 20, 1, 50);
|
|
138
138
|
if (!query)
|
|
139
139
|
throw new McpToolError('`query` is empty. Pass a keyword to search for, or call list_pages to enumerate a branch instead.');
|
|
140
|
-
const { items, total } = await searchWiki(o, query, { tagPath, page, size });
|
|
140
|
+
const { items, total, corrected } = await searchWiki(o, query, { tagPath, page, size });
|
|
141
141
|
return withAdjustments(r, {
|
|
142
142
|
query,
|
|
143
|
+
...(corrected ? { corrected } : {}),
|
|
143
144
|
...listEnvelope(items, total, page, size, o.noMatch?.(query, tagPath)
|
|
144
145
|
?? `Nothing matches "${query}"${tagPath ? ` under "${tagPath}"` : ''}. Search is keyword, not semantic: try one distinctive word${tagPath ? ', or drop the tagPath filter' : ''}, or call get_categories to see what ${noun} covers.`),
|
|
145
146
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.105.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",
|
package/prisma/analytics.prisma
CHANGED
|
@@ -14,6 +14,8 @@ model View {
|
|
|
14
14
|
country String?
|
|
15
15
|
device String
|
|
16
16
|
visitor String
|
|
17
|
+
// Set once its reader has had the page visible fifteen seconds or given it input.
|
|
18
|
+
engaged Boolean @default(false)
|
|
17
19
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
|
18
20
|
|
|
19
21
|
@@index([siteId, createdAt])
|