wiki-formant 0.27.0 → 0.28.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
@@ -541,6 +541,8 @@ The ones that take config take it because that is exactly where the two copies d
541
541
 
542
542
  `TOOLBAR_ACTIONS` are the formatting commands with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Pick yours in order with `toolbarActions([...])`, render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
543
543
 
544
+ A link is stored as its `href` and the `link` class, nothing else (`wikiLink`). Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
545
+
544
546
  The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
545
547
 
546
548
  ```ts
@@ -549,27 +551,55 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
549
551
 
550
552
  ## Analytics
551
553
 
552
- Two lanes, one events helper. `mcpCallProps` reads a tool name out of a JSON-RPC
553
- envelope that is untrusted and may be a batch; `searchQueryProps` normalises a
554
- search box's free text so the same question aggregates as one row. Sending stays
555
- with the caller — `plausibleEvent` builds the request, and the framework's own
556
- deferral (`after()` in a route or server action, `event.waitUntil` in a proxy)
557
- decides when it goes, so this package keeps its zero-dependency guarantee.
554
+ Page views and events, counted in the site's own Postgres instead of a hosted
555
+ service. No cookie is set and no address is kept: a visitor is a hash of the
556
+ address and browser under a salt that is replaced every UTC day, and the old
557
+ salt is deleted, so a visitor is told apart within a day and never followed
558
+ into the next. Rows are kept 400 days. The package owns the SQL and the app
559
+ owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
560
+ placeholders, and the three tables it needs are stated as Prisma models at the
561
+ top of `src/analytics.ts`.
562
+
563
+ ```ts
564
+ // src/lib/track.ts
565
+ export const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
566
+
567
+ // src/app/api/view/route.ts – the whole beacon route
568
+ export const POST = (request: Request) => collect(request, { sql, defer: after });
569
+
570
+ // the root layout, once
571
+ <Beacon />
572
+ ```
573
+
574
+ `<Beacon>` (`wiki-formant/react`) sends a view each time the path changes,
575
+ client-side moves included, and a click on a link to another host as an
576
+ "Outbound Link: Click" event; `track(name, props)` (`wiki-formant/dom`) sends
577
+ any other browser event. Server code calls `recordEvent` and leaves the
578
+ deferral to the framework, so this package keeps its zero-dependency
579
+ guarantee:
558
580
 
559
581
  ```ts
560
582
  const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
561
583
  // null for an empty field, so a blank search cannot fire an event
562
- if (props) after(() => plausibleEvent({ domain }, 'Search Query', url, props, headers, {
563
- // a person triggered this, so it joins their session rather than landing
564
- // as the fixed bot-tracker pseudo-visitor
565
- userAgent: headers.get('user-agent') ?? undefined,
566
- }));
584
+ if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
567
585
  ```
568
586
 
587
+ `mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
588
+ and may be a batch; `searchQueryProps` normalises a search box's free text so
589
+ the same question aggregates as one row. Pass the request's headers and the
590
+ event joins whoever sent it, person or agent.
591
+
592
+ `digest(sql, { days })` reads a site back as one JSON object: visitors, page
593
+ views, bounce rate, visit length, sources (a link's `utm_source`, else the
594
+ referring host), top and entry pages, countries, devices, visitors by day, MCP
595
+ tool calls, and the searches that found nothing. A visit is a run of one
596
+ visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
597
+ key to every call and a `site` resolver to `collect`.
598
+
569
599
  Instrumenting the agent lane and not the human one is the easy mistake: it
570
600
  leaves a wiki able to say what every crawler asked for and nothing about what
571
- its readers asked for. The queries that return zero rows are the valuable ones —
572
- they name a gap in the corpus in the reader's own words.
601
+ its readers asked for. The queries that return zero rows are the valuable ones
602
+ – they name a gap in the corpus in the reader's own words.
573
603
 
574
604
  ## Base stylesheet
575
605
 
@@ -603,6 +633,14 @@ npx check-classes --warn # report and exit 0
603
633
  npx check-classes --compositions # also fail on the inline compositions
604
634
  ```
605
635
 
636
+ `analytics-digest` prints a site's `digest` as JSON for a script outside the app. Run it from the app's root, where `pg` is installed and `.env` holds `DATABASE_URL`:
637
+
638
+ ```sh
639
+ npx analytics-digest --days 7
640
+ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
641
+ npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
642
+ ```
643
+
606
644
  ## Licence
607
645
 
608
646
  MIT.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // bin/analytics-digest.mjs – print one site's `digest` (`wiki-formant/analytics`)
3
+ // as JSON, for a script that reads traffic from outside the app.
4
+ //
5
+ // Run from the app's root, where `pg` is installed and `.env` holds
6
+ // DATABASE_URL (read from the environment first). The SQL is the package's, so
7
+ // a report reads the figures the app's own pages read.
8
+ //
9
+ // npx analytics-digest --days 7
10
+ // npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
11
+ // npx analytics-digest --days 7 --site <id> # a multi-tenant app's site
12
+ // npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds to the search gaps
13
+ import pg from 'pg';
14
+ import { digest, SEARCH_GAPS } from '../dist/analytics.js';
15
+
16
+ const args = process.argv.slice(2);
17
+ const arg = name => {
18
+ const i = args.indexOf(name);
19
+ return i >= 0 ? args[i + 1] : undefined;
20
+ };
21
+ const all = name => args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1]] : []));
22
+
23
+ if (!process.env.DATABASE_URL) {
24
+ try {
25
+ process.loadEnvFile('.env');
26
+ } catch {
27
+ // reported below
28
+ }
29
+ }
30
+ if (!process.env.DATABASE_URL) {
31
+ console.log(JSON.stringify({ error: 'no DATABASE_URL in the environment or ./.env' }));
32
+ process.exit(0);
33
+ }
34
+
35
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
36
+ try {
37
+ await client.connect();
38
+ const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
39
+ const result = await digest(sql, {
40
+ site: arg('--site') ?? '',
41
+ days: Number(arg('--days') ?? 7),
42
+ handle: arg('--handle'),
43
+ siblings: arg('--siblings')?.split(',').filter(Boolean) ?? [],
44
+ gaps: [
45
+ SEARCH_GAPS,
46
+ ...all('--gap').map(g => {
47
+ const [event, prop] = g.split(':');
48
+ return { event, prop };
49
+ }),
50
+ ],
51
+ });
52
+ console.log(JSON.stringify(result));
53
+ } catch (e) {
54
+ console.log(JSON.stringify({ error: e.message }));
55
+ } finally {
56
+ await client.end().catch(() => {});
57
+ }
@@ -1,36 +1,127 @@
1
- import type { HeaderReader } from './rate-limit.js';
2
- export interface PlausibleConfig {
3
- /** The hostname the Plausible property is registered under. */
4
- domain: string;
5
- /** Override for self-hosted Plausible. */
6
- endpoint?: string;
1
+ import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
2
+ /**
3
+ * Runs one statement with `$1, $2…` placeholders and resolves to its rows.
4
+ * Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
5
+ */
6
+ export type Sql = (query: string, ...values: unknown[]) => Promise<unknown>;
7
+ /** Long enough to compare a month with the same month a year before. */
8
+ export declare const KEEP_DAYS = 400;
9
+ /** iPadOS reports itself as a Mac, so its views count as a computer's. */
10
+ export declare const deviceOf: (ua: string) => "phone" | "tablet" | "computer";
11
+ /**
12
+ * Where a visit came from: its `utm_source` when the link carried one, else the
13
+ * referring host, and null for a referrer that is this site or none.
14
+ */
15
+ export declare function sourceOf(referrer: unknown, utm: unknown, host: string): string | null;
16
+ /** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
17
+ export declare function normalisePath(path: string): string | null;
18
+ export interface ViewInput {
19
+ /** The beacon request's headers: the address, browser, host and country come from them. */
20
+ headers: HeaderReader;
21
+ path: string;
22
+ referrer?: unknown;
23
+ /** The page URL's `utm_source`. */
24
+ utm?: unknown;
25
+ site?: string;
26
+ }
27
+ /** Count one page view, unless a bot sent it. Never rejects. */
28
+ export declare function recordView(sql: Sql, { headers, path, referrer, utm, site }: ViewInput): Promise<void>;
29
+ export interface EventInput {
30
+ name: string;
31
+ props?: Record<string, string | number>;
32
+ /** The page it happened on, as a URL or a path. */
33
+ url?: string;
34
+ /** The request of whoever caused it, so the event joins their visitor. Omit for an event no visitor caused. */
35
+ headers?: HeaderReader;
36
+ site?: string;
37
+ }
38
+ /** Record one named event. Never rejects. */
39
+ export declare function recordEvent(sql: Sql, { name, props, url, headers, site }: EventInput): Promise<void>;
40
+ /** What a page sends: a view, or with `name` an event. */
41
+ export interface Beacon {
42
+ path: string;
43
+ referrer?: string;
44
+ utm?: string;
45
+ name?: string;
46
+ props?: Record<string, string | number>;
7
47
  }
8
- export interface PlausibleExtra {
9
- /** Revenue goal payload; accepted and ignored on plans without revenue goals. */
10
- revenue?: {
11
- currency: string;
12
- amount: number;
13
- };
48
+ export interface CollectOptions {
49
+ sql: Sql;
50
+ /** `after` in a Next route: the write runs once the 204 has gone. */
51
+ defer: (task: () => Promise<unknown>) => void;
52
+ /** The site a beacon counts toward, or null to drop it. A single-site app omits it. */
53
+ site?: (request: Request, path: string) => string | null | Promise<string | null>;
54
+ rateLimit?: RateLimitOptions;
55
+ }
56
+ /**
57
+ * The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
58
+ * Answers 204 whatever happens, before anything is written, so a reader never waits on it.
59
+ */
60
+ export declare function collect(request: Request, { sql, defer, site, rateLimit: limit }: CollectOptions): Promise<Response>;
61
+ export interface Gap {
62
+ event: string;
63
+ /** The prop holding the reader's words. */
64
+ prop: string;
65
+ /** Props the event must carry, e.g. `{ results: '0' }`. */
66
+ where?: Record<string, string>;
67
+ }
68
+ /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
69
+ export declare const SEARCH_GAPS: Gap;
70
+ export interface DigestOptions {
71
+ site?: string;
72
+ /** How far back, in whole days from now. */
73
+ days: number;
74
+ /** The site's own X handle: `follow_clicks` counts clicks on links to it. */
75
+ handle?: string;
76
+ /** Hosts of sibling sites: `sibling_referrals` counts the visits they sent. */
77
+ siblings?: string[];
78
+ gaps?: Gap[];
14
79
  /**
15
- * Send the visitor's own user agent so the event joins their session rather
16
- * than landing as the fixed bot-tracker pseudo-visitor. Right for an event
17
- * a person triggered; wrong for one a crawler did.
80
+ * MCP callers left out of `agent_tool_calls`, matched inside the user agent.
81
+ * The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
18
82
  */
19
- userAgent?: string;
83
+ ownAgents?: string[];
84
+ }
85
+ type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
86
+ export interface Digest {
87
+ visitors: number;
88
+ pageviews: number;
89
+ /** Percent of visits that saw one page; null with no visits. */
90
+ bounce_rate: number | null;
91
+ /** Mean seconds from a visit's first view to its last; null with no visits. */
92
+ visit_duration: number | null;
93
+ /** Visitors plus every agent that sent an event, such as an MCP call. */
94
+ visitors_incl_agents: number;
95
+ /** Visitors in the same number of days before. */
96
+ previous_visitors: number;
97
+ top_sources: Ranked<'source', 'visitors'>;
98
+ top_pages: Ranked<'page', 'visitors'>;
99
+ entry_pages: Ranked<'page', 'visitors'>;
100
+ /** Entry pages of visits from links tagged `utm_source=x`. */
101
+ from_posts: Ranked<'page', 'visitors'>;
102
+ sibling_referrals: Ranked<'source', 'visitors'>;
103
+ countries: Ranked<'country', 'visitors'>;
104
+ devices: Ranked<'device', 'visitors'>;
105
+ by_day: Ranked<'date', 'visitors'>;
106
+ follow_clicks: {
107
+ events: number;
108
+ visitors: number;
109
+ } | null;
110
+ agent_tool_calls: Ranked<'tool', 'events'>;
111
+ gaps: Ranked<'query', 'events'>;
20
112
  }
21
113
  /**
22
- * The exact request shape Plausible's events API expects.
23
- *
24
- * Never rejects: analytics must not be able to fail a response the reader is
25
- * waiting on.
114
+ * Everything a report needs about one site's last `days` days, in one query.
115
+ * Visitor figures count distinct visitors, so a person who comes back on
116
+ * another day counts once for each day.
26
117
  */
27
- export declare function plausibleEvent(config: PlausibleConfig, name: string, url: string, props: Record<string, string>, headers: HeaderReader, extra?: PlausibleExtra): Promise<unknown>;
118
+ export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
28
119
  /**
29
120
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
30
121
  *
31
122
  * The body is untrusted and may be a batch, a notification, or malformed
32
123
  * entirely, so every extraction is defensive and the result is always a
33
- * complete props object. A batch is attributed to its first member — the
124
+ * complete props object. A batch is attributed to its first member – the
34
125
  * alternative is one event per member, which would make a fan-out look like
35
126
  * traffic it is not.
36
127
  */
@@ -47,7 +138,7 @@ export interface SearchQueryInput {
47
138
  surface?: string;
48
139
  }
49
140
  /**
50
- * Props for a "Search Query" event — the human-side twin of `mcpCallProps`.
141
+ * Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
51
142
  *
52
143
  * A wiki instruments its agent surface and then counts every question an agent
53
144
  * asks while recording none of the questions a person asks. The queries that
@@ -56,9 +147,8 @@ export interface SearchQueryInput {
56
147
  *
57
148
  * Returns `null` for a query with no content, so an empty field cannot fire an
58
149
  * event. The text is untrusted, so it is collapsed, lowercased for aggregation
59
- * and truncated — the same 64-character bound search itself applies, which
150
+ * and truncated – the same 64-character bound search itself applies, which
60
151
  * keeps the prop and the query that produced it the same string.
61
152
  */
62
153
  export declare function searchQueryProps({ query, results, surface, }: SearchQueryInput): Record<string, string> | null;
63
- /** The hostname of a site URL, for the `domain` a Plausible property is filed under. */
64
- export declare function plausibleDomain(siteUrl: string | undefined, fallback: string): string;
154
+ export {};
package/dist/analytics.js CHANGED
@@ -1,49 +1,235 @@
1
- // analytics.ts — server-side Plausible events for a wiki's agent and human lanes.
1
+ // analytics.ts – page views and events, counted in the site's own Postgres.
2
2
  //
3
- // The proxy counts an "AI Bot Visit" by matching user agents, which cannot see
4
- // inside a JSON-RPC envelope. So an MCP call is only countable by tool name at
5
- // the route, and that extraction — from a body that is untrusted and may be a
6
- // batch — is the part worth sharing. Every wiki in the workspace had its own
7
- // copy of it, differing only in the default hostname.
3
+ // It replaces a hosted analytics service. A page reports its own view to the
4
+ // site's own address (`startBeacon` in `dom.ts`, `<Beacon>` in `react.tsx`), a
5
+ // route hands the beacon to `collect`, and server code records events with
6
+ // `recordEvent`. `digest` reads it all back as one JSON object, and the
7
+ // `analytics-digest` bin prints that object for scripts outside the app.
8
8
  //
9
- // `searchQueryProps` is the human-side twin: the same shape problem, from the
10
- // other direction. A search box is untrusted free text on every wiki, and the
11
- // normalisation that makes it aggregatable — and keeps an empty field from
12
- // firing an event — is the part worth sharing rather than re-deriving.
9
+ // No cookie is set and no address is kept. A visitor is a hash of the address
10
+ // and the browser under a salt that changes every UTC day, and the old salt is
11
+ // deleted when the new one is made, so a visitor can be told apart within a day
12
+ // and never followed into the next. A visit is a run of one visitor's views
13
+ // with no gap over 30 minutes.
13
14
  //
14
- // Deferral stays with the caller: `after()` in a route handler,
15
- // `event.waitUntil` in a proxy. Both are framework calls, and importing one
16
- // here would cost this package its zero-dependency guarantee.
17
- const DEFAULT_ENDPOINT = 'https://plausible.io/api/event';
15
+ // The package owns the SQL; the app owns the connection. `Sql` is one function
16
+ // that runs a parameterised statement, which Prisma, `pg` and postgres.js can
17
+ // all supply in a line. The app declares the three tables in its own schema,
18
+ // so `prisma db push` keeps them:
19
+ //
20
+ // model View {
21
+ // id BigInt @id @default(autoincrement())
22
+ // siteId String @default("")
23
+ // path String
24
+ // source String?
25
+ // country String?
26
+ // device String
27
+ // visitor String
28
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
29
+ // @@index([siteId, createdAt])
30
+ // }
31
+ //
32
+ // model Event {
33
+ // id BigInt @id @default(autoincrement())
34
+ // siteId String @default("")
35
+ // name String
36
+ // path String?
37
+ // props Json @default("{}")
38
+ // visitor String?
39
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
40
+ // @@index([siteId, name, createdAt])
41
+ // }
42
+ //
43
+ // model Salt {
44
+ // day String @id
45
+ // value String
46
+ // }
47
+ //
48
+ // `siteId` is "" in an app that serves one site. A multi-tenant app passes its
49
+ // own site key to every call.
50
+ //
51
+ // Deferral stays with the caller: `after()` in a route, `event.waitUntil` in a
52
+ // proxy. Both are framework calls, and importing one here would cost this
53
+ // package its zero-dependency guarantee. Hashing is Web Crypto, so the module
54
+ // runs anywhere Node 20, a browser or an edge runtime does.
55
+ import { clientIp, clientKey, rateLimit } from './rate-limit.js';
56
+ /** Long enough to compare a month with the same month a year before. */
57
+ export const KEEP_DAYS = 400;
58
+ const DAY_MS = 86_400_000;
59
+ /** A script that runs a page's JavaScript and says what it is. */
60
+ const BOT = /bot|crawl|spider|slurp|headless|lighthouse|prerender|preview/i;
61
+ /** iPadOS reports itself as a Mac, so its views count as a computer's. */
62
+ export const deviceOf = (ua) => /iPad|Tablet|Android(?!.*Mobile)/i.test(ua) ? 'tablet' : /Mobi|iPhone|Android/i.test(ua) ? 'phone' : 'computer';
63
+ const bareHost = (host) => host.toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
18
64
  /**
19
- * The exact request shape Plausible's events API expects.
20
- *
21
- * Never rejects: analytics must not be able to fail a response the reader is
22
- * waiting on.
65
+ * Where a visit came from: its `utm_source` when the link carried one, else the
66
+ * referring host, and null for a referrer that is this site or none.
23
67
  */
24
- export function plausibleEvent(config, name, url, props, headers, extra = {}) {
25
- return fetch(config.endpoint ?? DEFAULT_ENDPOINT, {
26
- method: 'POST',
27
- headers: {
28
- 'Content-Type': 'application/json',
29
- 'User-Agent': extra.userAgent || 'Mozilla/5.0 (compatible; BotTracker/1.0)',
30
- 'X-Forwarded-For': headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1',
31
- },
32
- body: JSON.stringify({
33
- name,
34
- url,
35
- domain: config.domain,
36
- props: JSON.stringify(props),
37
- ...(extra.revenue ? { revenue: extra.revenue } : {}),
38
- }),
39
- }).catch(() => undefined);
68
+ export function sourceOf(referrer, utm, host) {
69
+ if (typeof utm === 'string' && utm.trim())
70
+ return utm.trim().toLowerCase().slice(0, 100);
71
+ if (typeof referrer !== 'string' || !referrer)
72
+ return null;
73
+ try {
74
+ const from = bareHost(new URL(referrer).hostname);
75
+ return from && from !== bareHost(host) ? from.slice(0, 200) : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ /** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
82
+ export function normalisePath(path) {
83
+ try {
84
+ const p = decodeURIComponent(path.split(/[?#]/)[0].slice(0, 500));
85
+ return `/${p.replace(/^\/+|\/+$/g, '')}`;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ const hex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
92
+ const salts = new WeakMap();
93
+ /** Today's salt. Whoever makes it also deletes yesterday's, and rows past keeping. */
94
+ async function todaysSalt(sql) {
95
+ const day = new Date().toISOString().slice(0, 10);
96
+ const cached = salts.get(sql);
97
+ if (cached?.day === day)
98
+ return cached.value;
99
+ const made = (await sql('INSERT INTO "Salt" (day, value) VALUES ($1, $2) ON CONFLICT (day) DO NOTHING RETURNING day', day, hex(crypto.getRandomValues(new Uint8Array(32)))));
100
+ if (made.length) {
101
+ const cutoff = new Date(Date.now() - KEEP_DAYS * DAY_MS);
102
+ await sql('DELETE FROM "Salt" WHERE day < $1', day);
103
+ await sql('DELETE FROM "View" WHERE "createdAt" < $1', cutoff);
104
+ await sql('DELETE FROM "Event" WHERE "createdAt" < $1', cutoff);
105
+ }
106
+ const [row] = (await sql('SELECT value FROM "Salt" WHERE day = $1', day));
107
+ salts.set(sql, { day, value: row.value });
108
+ return row.value;
109
+ }
110
+ async function visitorOf(sql, site, headers) {
111
+ const text = [await todaysSalt(sql), site, clientIp(headers), headers.get('user-agent') ?? ''].join('\n');
112
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
113
+ return hex(new Uint8Array(digest)).slice(0, 32);
114
+ }
115
+ /** Analytics must not be able to fail a response, so a failed write is logged and dropped. */
116
+ const quietly = (task) => task().then(() => undefined, (e) => console.error(`analytics: ${e.message}`));
117
+ /** Count one page view, unless a bot sent it. Never rejects. */
118
+ export function recordView(sql, { headers, path, referrer, utm, site = '' }) {
119
+ const ua = headers.get('user-agent') ?? '';
120
+ const page = normalisePath(path);
121
+ if (!ua || BOT.test(ua) || page === null)
122
+ return Promise.resolve();
123
+ 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)));
124
+ }
125
+ /** Record one named event. Never rejects. */
126
+ export function recordEvent(sql, { name, props = {}, url, headers, site = '' }) {
127
+ return quietly(async () => {
128
+ let path = null;
129
+ if (url)
130
+ path = normalisePath(new URL(url, 'http://x').pathname);
131
+ return sql('INSERT INTO "Event" ("siteId", name, path, props, visitor) VALUES ($1, $2, $3, $4::jsonb, $5)', site, name.slice(0, 64), path, JSON.stringify(props), headers?.get('user-agent') ? await visitorOf(sql, site, headers) : null);
132
+ });
133
+ }
134
+ /** A browser-sent event's props: eight at most, each value a short string or a number. */
135
+ function beaconProps(props) {
136
+ if (!props || typeof props !== 'object' || Array.isArray(props))
137
+ return {};
138
+ return Object.fromEntries(Object.entries(props)
139
+ .filter(([, v]) => typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v)))
140
+ .slice(0, 8)
141
+ .map(([k, v]) => [k.slice(0, 40), typeof v === 'string' ? v.slice(0, 300) : v]));
142
+ }
143
+ // A person reads a few pages a minute. This leaves room for a fast reader and
144
+ // stops one address filling a site's figures.
145
+ const BEACONS = { capacity: 30, refillPerSec: 0.5 };
146
+ /**
147
+ * The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
148
+ * Answers 204 whatever happens, before anything is written, so a reader never waits on it.
149
+ */
150
+ export async function collect(request, { sql, defer, site, rateLimit: limit = BEACONS }) {
151
+ const done = new Response(null, { status: 204 });
152
+ const { headers } = request;
153
+ if (BOT.test(headers.get('user-agent') ?? '') || !rateLimit(clientKey('beacon', headers), limit).ok)
154
+ return done;
155
+ const body = (await request.json().catch(() => null));
156
+ const path = typeof body?.path === 'string' ? normalisePath(body.path) : null;
157
+ if (!body || !path)
158
+ return done;
159
+ const key = site ? await site(request, path) : '';
160
+ if (key === null)
161
+ return done;
162
+ defer(() => typeof body.name === 'string' && body.name
163
+ ? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: path, headers, site: key })
164
+ : recordView(sql, { headers, path, referrer: body.referrer, utm: body.utm, site: key }));
165
+ return done;
166
+ }
167
+ /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
168
+ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
169
+ /**
170
+ * Everything a report needs about one site's last `days` days, in one query.
171
+ * Visitor figures count distinct visitors, so a person who comes back on
172
+ * another day counts once for each day.
173
+ */
174
+ export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
175
+ // The window is the database's clock, which stamped the rows; the app's can run behind it.
176
+ const values = [site, Math.max(1, Math.round(days))];
177
+ const $ = (v) => `$${values.push(v)}`;
178
+ const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
179
+ const gapSelects = gaps.map(g => {
180
+ const prop = $(g.prop);
181
+ return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
182
+ WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
183
+ GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
184
+ });
185
+ const [row] = (await sql(`WITH v AS (
186
+ SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
187
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
188
+ ), marked AS (
189
+ 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
190
+ ), numbered AS (
191
+ SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
192
+ ), visits AS (
193
+ SELECT visitor, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
194
+ (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
195
+ FROM numbered GROUP BY visitor, visit
196
+ ), e AS (
197
+ SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
198
+ )
199
+ SELECT json_build_object(
200
+ 'visitors', (SELECT count(DISTINCT visitor) FROM v),
201
+ 'pageviews', (SELECT count(*) FROM v),
202
+ 'bounce_rate', (SELECT round(100.0 * count(*) FILTER (WHERE views = 1) / nullif(count(*), 0)) FROM visits),
203
+ 'visit_duration', (SELECT round(avg(seconds)) FROM visits),
204
+ 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) a),
205
+ 'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
206
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int)),
207
+ 'top_sources', ${list(`SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1`, 6)},
208
+ 'top_pages', ${list('SELECT path AS page, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1', 6)},
209
+ 'entry_pages', ${list('SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1', 6)},
210
+ 'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
211
+ 'sibling_referrals', ${list(`SELECT source, count(DISTINCT visitor) AS visitors FROM visits WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
212
+ 'countries', ${list('SELECT country, count(DISTINCT visitor) AS visitors FROM v WHERE country IS NOT NULL GROUP BY 1', 10)},
213
+ 'devices', ${list('SELECT device, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1')},
214
+ 'by_day', ${list(`SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1`, undefined, '1')},
215
+ 'follow_clicks', ${handle
216
+ ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
217
+ WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
218
+ : 'NULL'},
219
+ 'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
220
+ WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
221
+ AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
222
+ GROUP BY 1`, 10)},
223
+ 'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
224
+ ) AS digest`, ...values));
225
+ return row.digest;
40
226
  }
41
227
  /**
42
228
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
43
229
  *
44
230
  * The body is untrusted and may be a batch, a notification, or malformed
45
231
  * entirely, so every extraction is defensive and the result is always a
46
- * complete props object. A batch is attributed to its first member — the
232
+ * complete props object. A batch is attributed to its first member – the
47
233
  * alternative is one event per member, which would make a fan-out look like
48
234
  * traffic it is not.
49
235
  */
@@ -55,7 +241,7 @@ export function mcpCallProps(request, body, server) {
55
241
  return { ...(server ? { server } : {}), method, ...(tool ? { tool } : {}), ua };
56
242
  }
57
243
  /**
58
- * Props for a "Search Query" event — the human-side twin of `mcpCallProps`.
244
+ * Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
59
245
  *
60
246
  * A wiki instruments its agent surface and then counts every question an agent
61
247
  * asks while recording none of the questions a person asks. The queries that
@@ -64,7 +250,7 @@ export function mcpCallProps(request, body, server) {
64
250
  *
65
251
  * Returns `null` for a query with no content, so an empty field cannot fire an
66
252
  * event. The text is untrusted, so it is collapsed, lowercased for aggregation
67
- * and truncated — the same 64-character bound search itself applies, which
253
+ * and truncated – the same 64-character bound search itself applies, which
68
254
  * keeps the prop and the query that produced it the same string.
69
255
  */
70
256
  export function searchQueryProps({ query, results, surface, }) {
@@ -73,18 +259,8 @@ export function searchQueryProps({ query, results, surface, }) {
73
259
  return null;
74
260
  return {
75
261
  q,
76
- // A string because Plausible props are strings; `results == "0"` is the
77
- // filter that yields the gap list.
262
+ // A string, so `{ results: '0' }` is the filter that yields the gap list.
78
263
  results: String(Math.max(0, Math.trunc(results) || 0)),
79
264
  ...(surface ? { surface } : {}),
80
265
  };
81
266
  }
82
- /** The hostname of a site URL, for the `domain` a Plausible property is filed under. */
83
- export function plausibleDomain(siteUrl, fallback) {
84
- try {
85
- return new URL(siteUrl || fallback).hostname;
86
- }
87
- catch {
88
- return fallback.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
89
- }
90
- }
package/dist/dom.d.ts CHANGED
@@ -103,3 +103,18 @@ export declare function sortTables(root: ParentNode, options?: SortTablesOptions
103
103
  * A table already in a box keeps it, so a second pass changes nothing.
104
104
  */
105
105
  export declare function scrollTables(html: string): string;
106
+ /**
107
+ * Report each page view to the site's own `endpoint`, whose route hands it to
108
+ * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
109
+ * runs and no blocklist names the request.
110
+ *
111
+ * A client-side router moves between pages with `history.pushState`, which no
112
+ * load event marks, so both history writes are wrapped and a view is sent
113
+ * whenever the path changes; a change to the query or hash alone is not a page.
114
+ * A click on a link to another host is sent as an "Outbound Link: Click" event
115
+ * carrying its `url`. Starts once per page load however often it is called, so
116
+ * React's doubled effects in development send one view, not two.
117
+ */
118
+ export declare function startBeacon(endpoint?: string): void;
119
+ /** Record a named event from the browser, on the endpoint `startBeacon` was given. */
120
+ export declare function track(name: string, props?: Record<string, string | number>): void;
package/dist/dom.js CHANGED
@@ -322,3 +322,60 @@ export function scrollTables(html) {
322
322
  return hadBox ? tag : SCROLL_BOX + tag;
323
323
  });
324
324
  }
325
+ // ---- analytics beacon -------------------------------------------------------
326
+ let beaconEndpoint = '/api/view';
327
+ let beaconStarted = false;
328
+ const sendBeacon = (body) => navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
329
+ /**
330
+ * Report each page view to the site's own `endpoint`, whose route hands it to
331
+ * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
332
+ * runs and no blocklist names the request.
333
+ *
334
+ * A client-side router moves between pages with `history.pushState`, which no
335
+ * load event marks, so both history writes are wrapped and a view is sent
336
+ * whenever the path changes; a change to the query or hash alone is not a page.
337
+ * A click on a link to another host is sent as an "Outbound Link: Click" event
338
+ * carrying its `url`. Starts once per page load however often it is called, so
339
+ * React's doubled effects in development send one view, not two.
340
+ */
341
+ export function startBeacon(endpoint = '/api/view') {
342
+ if (beaconStarted || typeof window === 'undefined')
343
+ return;
344
+ beaconStarted = true;
345
+ beaconEndpoint = endpoint;
346
+ let last = '';
347
+ let first = true;
348
+ const view = () => {
349
+ if (location.pathname === last)
350
+ return;
351
+ last = location.pathname;
352
+ // document.referrer belongs to the page load, so only its first view carries it.
353
+ sendBeacon({
354
+ path: last,
355
+ referrer: first ? document.referrer : undefined,
356
+ utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
357
+ });
358
+ first = false;
359
+ };
360
+ for (const method of ['pushState', 'replaceState']) {
361
+ const write = history[method];
362
+ history[method] = function (...args) {
363
+ write.apply(this, args);
364
+ view();
365
+ };
366
+ }
367
+ addEventListener('popstate', view);
368
+ document.addEventListener('click', e => {
369
+ const a = e.target?.closest?.('a[href]');
370
+ if (a instanceof HTMLAnchorElement && /^https?:$/.test(a.protocol) && a.host !== location.host) {
371
+ track('Outbound Link: Click', { url: a.href });
372
+ }
373
+ }, { capture: true });
374
+ view();
375
+ }
376
+ /** Record a named event from the browser, on the endpoint `startBeacon` was given. */
377
+ export function track(name, props) {
378
+ if (typeof window === 'undefined')
379
+ return;
380
+ sendBeacon({ name, path: location.pathname, props });
381
+ }
package/dist/editor.d.ts CHANGED
@@ -15,6 +15,19 @@ export type { StarterKitOptions } from '@tiptap/starter-kit';
15
15
  export type { LinkOptions } from '@tiptap/extension-link';
16
16
  export type { ImageOptions } from '@tiptap/extension-image';
17
17
  export type { TableOptions } from '@tiptap/extension-table';
18
+ /**
19
+ * Tiptap's Link, storing an href and the `link` class and nothing else.
20
+ *
21
+ * Stock Link writes `target="_blank" rel="noopener noreferrer nofollow"` onto
22
+ * every link, internal ones included, and the schema then reads both back from
23
+ * stored HTML, so a page edited once kept them for good. Nofollow on a site's
24
+ * own links throws away its link equity, and a new tab for in-site navigation
25
+ * is a bug. Whether a link is external depends on the host it is served from,
26
+ * which the editor cannot know, so the render path decides: `normaliseLinks`
27
+ * from `wiki-formant/links`. Dropping both attributes from the mark also cleans
28
+ * a legacy link the next time its page is saved.
29
+ */
30
+ export declare const wikiLink: import("@tiptap/core").Mark<import("@tiptap/extension-link").LinkOptions, any>;
18
31
  export interface WikiEditorExtensionOptions {
19
32
  /** Empty-document prompt. */
20
33
  placeholder?: string;
package/dist/editor.js CHANGED
@@ -56,6 +56,24 @@ function cleanPastedHtml(html) {
56
56
  });
57
57
  return doc.body.innerHTML;
58
58
  }
59
+ // ---- extensions -------------------------------------------------------------
60
+ /**
61
+ * Tiptap's Link, storing an href and the `link` class and nothing else.
62
+ *
63
+ * Stock Link writes `target="_blank" rel="noopener noreferrer nofollow"` onto
64
+ * every link, internal ones included, and the schema then reads both back from
65
+ * stored HTML, so a page edited once kept them for good. Nofollow on a site's
66
+ * own links throws away its link equity, and a new tab for in-site navigation
67
+ * is a bug. Whether a link is external depends on the host it is served from,
68
+ * which the editor cannot know, so the render path decides: `normaliseLinks`
69
+ * from `wiki-formant/links`. Dropping both attributes from the mark also cleans
70
+ * a legacy link the next time its page is saved.
71
+ */
72
+ export const wikiLink = TiptapLink.extend({
73
+ addAttributes() {
74
+ return Object.fromEntries(Object.entries(this.parent?.() ?? {}).filter(([name]) => name !== 'target' && name !== 'rel'));
75
+ },
76
+ }).configure({ openOnClick: false, HTMLAttributes: { class: 'link', target: null, rel: null } });
59
77
  /**
60
78
  * The editor's extension set.
61
79
  *
@@ -67,7 +85,7 @@ function cleanPastedHtml(html) {
67
85
  function wikiEditorExtensions({ placeholder = '', nodes = [], } = {}) {
68
86
  return [
69
87
  StarterKit.configure({ heading: { levels: [2, 3, 4] }, codeBlock: false }),
70
- TiptapLink.configure({ openOnClick: false, HTMLAttributes: { class: 'link' } }),
88
+ wikiLink,
71
89
  TiptapImage.configure({
72
90
  inline: false,
73
91
  allowBase64: true,
package/dist/react.d.ts CHANGED
@@ -444,3 +444,10 @@ export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
444
444
  export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
445
445
  /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
446
446
  export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
447
+ /**
448
+ * Counts the page views of the app it is mounted in: place it once, in the root
449
+ * layout. `endpoint` is the route that hands the beacon to `collect`.
450
+ */
451
+ export declare function Beacon({ endpoint }: {
452
+ endpoint?: string;
453
+ }): null;
package/dist/react.js CHANGED
@@ -27,7 +27,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
27
27
  // state on <html> before first paint. The hook keeps that attribute in
28
28
  // sync afterwards, so CSS has one source of truth either side of hydration.
29
29
  import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
30
- import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables } from './dom.js';
30
+ import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables, startBeacon } from './dom.js';
31
31
  import { comboboxAria } from './combobox.js';
32
32
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
33
33
  const readStored = (key) => {
@@ -820,3 +820,11 @@ export function useTweetEmbeds(ref, deps) {
820
820
  // eslint-disable-next-line react-hooks/exhaustive-deps
821
821
  }, deps);
822
822
  }
823
+ /**
824
+ * Counts the page views of the app it is mounted in: place it once, in the root
825
+ * layout. `endpoint` is the route that hands the beacon to `collect`.
826
+ */
827
+ export function Beacon({ endpoint = '/api/view' }) {
828
+ useEffect(() => startBeacon(endpoint), [endpoint]);
829
+ return null;
830
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.27.0",
3
+ "version": "0.28.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, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,7 +182,8 @@
182
182
  },
183
183
  "bin": {
184
184
  "check-classes": "bin/check-classes.mjs",
185
- "check-pages": "bin/check-pages.mjs"
185
+ "check-pages": "bin/check-pages.mjs",
186
+ "analytics-digest": "bin/analytics-digest.mjs"
186
187
  },
187
188
  "files": [
188
189
  "dist",
@@ -193,7 +194,8 @@
193
194
  },
194
195
  "scripts": {
195
196
  "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/base.css dist/base.css",
196
- "test": "npm run build && node --test 'test/*.test.js'"
197
+ "test": "npm run build && node --test 'test/*.test.js'",
198
+ "prepare": "git config core.hooksPath .githooks || true"
197
199
  },
198
200
  "devDependencies": {
199
201
  "@radixdlt/rola": "^2.1.0",
@@ -238,7 +240,8 @@
238
240
  "fast-diff": ">=1.3",
239
241
  "jose": ">=5",
240
242
  "react": ">=18",
241
- "sanitize-html": ">=2"
243
+ "sanitize-html": ">=2",
244
+ "pg": ">=8"
242
245
  },
243
246
  "peerDependenciesMeta": {
244
247
  "react": {
@@ -294,6 +297,9 @@
294
297
  },
295
298
  "fast-diff": {
296
299
  "optional": true
300
+ },
301
+ "pg": {
302
+ "optional": true
297
303
  }
298
304
  }
299
305
  }