wiki-formant 0.27.1 → 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 +49 -13
- package/bin/analytics-digest.mjs +57 -0
- package/dist/analytics.d.ts +116 -26
- package/dist/analytics.js +224 -48
- package/dist/dom.d.ts +15 -0
- package/dist/dom.js +57 -0
- package/dist/react.d.ts +7 -0
- package/dist/react.js +9 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -551,27 +551,55 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
|
|
|
551
551
|
|
|
552
552
|
## Analytics
|
|
553
553
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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:
|
|
560
580
|
|
|
561
581
|
```ts
|
|
562
582
|
const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
|
|
563
583
|
// null for an empty field, so a blank search cannot fire an event
|
|
564
|
-
if (props) after(() =>
|
|
565
|
-
// a person triggered this, so it joins their session rather than landing
|
|
566
|
-
// as the fixed bot-tracker pseudo-visitor
|
|
567
|
-
userAgent: headers.get('user-agent') ?? undefined,
|
|
568
|
-
}));
|
|
584
|
+
if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
|
|
569
585
|
```
|
|
570
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
|
+
|
|
571
599
|
Instrumenting the agent lane and not the human one is the easy mistake: it
|
|
572
600
|
leaves a wiki able to say what every crawler asked for and nothing about what
|
|
573
|
-
its readers asked for. The queries that return zero rows are the valuable ones
|
|
574
|
-
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.
|
|
575
603
|
|
|
576
604
|
## Base stylesheet
|
|
577
605
|
|
|
@@ -605,6 +633,14 @@ npx check-classes --warn # report and exit 0
|
|
|
605
633
|
npx check-classes --compositions # also fail on the inline compositions
|
|
606
634
|
```
|
|
607
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
|
+
|
|
608
644
|
## Licence
|
|
609
645
|
|
|
610
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
|
+
}
|
package/dist/analytics.d.ts
CHANGED
|
@@ -1,36 +1,127 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1
|
+
// analytics.ts – page views and events, counted in the site's own Postgres.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
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
|
-
*
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/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.
|
|
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",
|
|
@@ -239,7 +240,8 @@
|
|
|
239
240
|
"fast-diff": ">=1.3",
|
|
240
241
|
"jose": ">=5",
|
|
241
242
|
"react": ">=18",
|
|
242
|
-
"sanitize-html": ">=2"
|
|
243
|
+
"sanitize-html": ">=2",
|
|
244
|
+
"pg": ">=8"
|
|
243
245
|
},
|
|
244
246
|
"peerDependenciesMeta": {
|
|
245
247
|
"react": {
|
|
@@ -295,6 +297,9 @@
|
|
|
295
297
|
},
|
|
296
298
|
"fast-diff": {
|
|
297
299
|
"optional": true
|
|
300
|
+
},
|
|
301
|
+
"pg": {
|
|
302
|
+
"optional": true
|
|
298
303
|
}
|
|
299
304
|
}
|
|
300
305
|
}
|