wiki-formant 0.92.0 → 0.97.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 +41 -12
- package/dist/analytics.d.ts +16 -2
- package/dist/analytics.js +15 -2
- package/dist/base.css +14 -0
- package/dist/chart.d.ts +9 -5
- package/dist/chart.js +44 -20
- package/dist/dom.js +4 -1
- package/dist/mcp.d.ts +33 -0
- package/dist/mcp.js +73 -4
- package/dist/og.d.ts +17 -0
- package/dist/og.js +38 -0
- package/dist/react-server.d.ts +7 -2
- package/dist/stats.d.ts +4 -1
- package/dist/stats.js +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -231,6 +231,14 @@ handler: async (args, ctx) => {
|
|
|
231
231
|
|
|
232
232
|
`config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
|
|
233
233
|
|
|
234
|
+
### Agent feedback
|
|
235
|
+
|
|
236
|
+
`feedbackTool(record, noun)` is `send_feedback`: an agent says what it needed and did not find, what is wrong or what broke (`kind` is `missing`, `wrong`, `broken` or `suggestion`), in `FEEDBACK_MAX` characters. It asks for something to act on rather than for a rating, which an agent answers with praise. List it among a server's tools and the transport does the inviting: `initialize` appends `FEEDBACK_INVITE` to `instructions`, and an empty `listEnvelope` page or an `McpToolError` carries a one-line `feedback` pointer, because a miss is when an agent knows what it was after. A tool whose miss has another shape quotes `FEEDBACK_NUDGE` in its own note. A server without the tool mentions it nowhere. Pass `tracker.trackFeedback` as `record` and each report is an "Agent Feedback" event, listed newest first in the digest's `agent_feedback`. Nothing is published: a report is a stranger's text, read as data.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
tools: [...TOOLS, feedbackTool(tracker.trackFeedback, 'the Radix Wiki')]
|
|
240
|
+
```
|
|
241
|
+
|
|
234
242
|
### Tool arguments and the corpus tool
|
|
235
243
|
|
|
236
244
|
`readArgs` reads a tool's raw arguments as typed, clamped values and records every value it had to override, so a result can say `adjustments: [{ param: 'limit', requested: -4, used: 1, … }]` instead of silently answering a different question. Defaults belong in the tool description; the result echoes only overrides.
|
|
@@ -574,6 +582,16 @@ return new ImageResponse(<SocialCard {...{ title, eyebrow, description, backgrou
|
|
|
574
582
|
|
|
575
583
|
Fonts stay with the site: Next traces a font file into the function only from a literal path in the app's own code, and satori reads TTF or OTF, never the woff2 `next/font` serves. The frame never sets a style key to `undefined`, which satori throws on rather than skipping; `scrim` darkens the foot for art that is a photograph.
|
|
576
584
|
|
|
585
|
+
**A page's banner is its card.** The generated field exists so a page with no banner still unfurls with something; it never stands in for a banner the page has. Every route picks the same way, and every page's metadata passes its banner to the card URL, the homepage and category hubs included:
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
const field = (await bannerField(searchParams.get('banner'), /\.public\.blob\.vercel-storage\.com$/))
|
|
589
|
+
?? bubbles(cardRandom(title), { field, colors });
|
|
590
|
+
return new ImageResponse(<SocialCard {...{ title, description }} {...field} colors={colors} />, { ...OG_SIZE, headers: OG_HEADERS });
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
`bannerField` fetches the banner only from the hosts you name, so `/og` is no proxy to any URL a caller writes, crops it to cover the card, inlines it as PNG (satori decodes no WebP or AVIF) and turns the scrim on. A missing, foreign or undecodable banner returns null and the generated field draws instead. It loads `sharp`, an optional peer, so the route runs on Node.
|
|
594
|
+
|
|
577
595
|
## Revisions
|
|
578
596
|
|
|
579
597
|
What changed between two versions of a page, and therefore which semver bump to
|
|
@@ -957,7 +975,10 @@ An agent is counted as a visitor is, one address and user agent a day, from its
|
|
|
957
975
|
`AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
|
|
958
976
|
among the visitors, and `visitors_incl_agents` counts the two at once.
|
|
959
977
|
`ownAgents` leaves the site's own out of both. A visit is a run of one
|
|
960
|
-
visitor's views with no gap over 30 minutes
|
|
978
|
+
visitor's views with no gap over 30 minutes, and a bounce is a visit of one view
|
|
979
|
+
with no event of the reader's inside it: a search that keeps the path, a click
|
|
980
|
+
out or a share makes the visit count. A browser under automation
|
|
981
|
+
(`navigator.webdriver`) sends no beacon at all. A multi-tenant app passes a `site`
|
|
961
982
|
key to every call and a `site` resolver to `collect`.
|
|
962
983
|
|
|
963
984
|
Instrumenting the agent lane and not the human one is the easy mistake: it
|
|
@@ -995,7 +1016,7 @@ export const DELETE = gate.signOut;
|
|
|
995
1016
|
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30), ?agents=apart and the filters
|
|
996
1017
|
return (
|
|
997
1018
|
<StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
|
|
998
|
-
{async () => <Stats digest={await digest(sql, q)} {...q} />}
|
|
1019
|
+
{async () => <Stats digest={await digest(sql, q)} {...q} link={Link} />}
|
|
999
1020
|
</StatsGate>
|
|
1000
1021
|
);
|
|
1001
1022
|
```
|
|
@@ -1011,12 +1032,15 @@ ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
|
1011
1032
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
1012
1033
|
figure is a button that charts it: visitors, page views, bounce rate,
|
|
1013
1034
|
visit length and clicks to X. Visitors count agents; `?agents=apart`, a switch
|
|
1014
|
-
beside the windows, splits them into people and agents, and every
|
|
1035
|
+
beside the windows drawn as a robot's head, splits them into people and agents, and every
|
|
1015
1036
|
other link on the page keeps the choice. `digest`'s `by_day` carries every
|
|
1016
1037
|
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
1017
1038
|
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
1018
1039
|
of those charts rather than drawn as zero. Everything but the figures and the
|
|
1019
|
-
chart is server-rendered.
|
|
1040
|
+
chart is server-rendered. Every link on the page is `link`, a bare `<a>` unless
|
|
1041
|
+
the site passes its router's: with `next/link` a window, the switch or a
|
|
1042
|
+
narrowing re-renders the page in place rather than reloading it, and the chart
|
|
1043
|
+
keeps its figure and step.
|
|
1020
1044
|
|
|
1021
1045
|
Every row in a list is a link that narrows the page to it, and the narrowings
|
|
1022
1046
|
stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
|
|
@@ -1052,9 +1076,11 @@ token prices, ledger activity, a caper's price, visitors per day. One area
|
|
|
1052
1076
|
series in the box's own `color`, its axis in `--chart-axis` if set, with range
|
|
1053
1077
|
buttons — 24H, 7D, 30D, 90D, 1Y, All — then, after a rule, the day, week and
|
|
1054
1078
|
month steps. The steps show wherever a range is drawn by day, so the buttons
|
|
1055
|
-
never shift; one is enabled
|
|
1056
|
-
|
|
1057
|
-
|
|
1079
|
+
never shift; every one is enabled, however few points it makes, so 90D draws
|
|
1080
|
+
monthly as readily as daily (none under 24H and 7D, which are drawn by the hour
|
|
1081
|
+
and the four hours, and none finer than the points: weekly readings have no
|
|
1082
|
+
day). The default is the finest that makes 200 or fewer, so a year opens weekly
|
|
1083
|
+
and five years monthly, and a step picked holds across the ranges.
|
|
1058
1084
|
|
|
1059
1085
|
```tsx
|
|
1060
1086
|
// Daily points: the chart slices and steps them itself; 30D up.
|
|
@@ -1081,7 +1107,8 @@ which no total makes sense of. A stepped range starts on a whole week or month,
|
|
|
1081
1107
|
so its first point is not a few days of one; the week in progress is its total
|
|
1082
1108
|
so far. `fromZero` starts a count's axis at zero and stops it there, with
|
|
1083
1109
|
no negative ticks under it. A single range draws no range buttons and shows every point.
|
|
1084
|
-
|
|
1110
|
+
No step is finer than the points' own spacing, so weekly readings offer W and M
|
|
1111
|
+
and draw on the days they were taken. `chartSteps` is exported for a chart drawn elsewhere.
|
|
1085
1112
|
|
|
1086
1113
|
`format` writes the axis and the crosshair label. An axis tick also gets the
|
|
1087
1114
|
spacing between ticks as a second argument, so a formatter can write every tick
|
|
@@ -1090,13 +1117,15 @@ decimal places that takes, and `formatChartValue`, the default, uses it.
|
|
|
1090
1117
|
|
|
1091
1118
|
`markers` are events, `{ time, label }`, each drawn as an arrow on the last
|
|
1092
1119
|
point at or before it, so one mid-week lands on its week and two on one point
|
|
1093
|
-
share it.
|
|
1094
|
-
|
|
1095
|
-
|
|
1120
|
+
share it. No words go on the plot: the label is a tooltip (`.chart-tip`) while
|
|
1121
|
+
the crosshair is on the point, one line an event, so it can be the event's full
|
|
1122
|
+
name. `step` opens on a step where the range can make it, and `onView` hears the
|
|
1123
|
+
range, and the step the reader picked (null while the chart picks its own), once
|
|
1124
|
+
the points are in and on every change, for a page that keeps them in its URL:
|
|
1096
1125
|
|
|
1097
1126
|
```tsx
|
|
1098
1127
|
<TimeChart series={days} label="Transactions" aggregate="sum" fromZero
|
|
1099
|
-
markers={[{ time: haltAt, label: '
|
|
1128
|
+
markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
|
|
1100
1129
|
```
|
|
1101
1130
|
`base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
|
|
1102
1131
|
canvas's top-left corner, rules a line between groups and dims a disabled step;
|
package/dist/analytics.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
|
|
2
|
+
import type { AgentFeedback } from './mcp.js';
|
|
2
3
|
/**
|
|
3
4
|
* Runs one statement with `$1, $2…` placeholders and resolves to its rows.
|
|
4
5
|
* Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
|
|
@@ -89,7 +90,7 @@ export interface DigestDay {
|
|
|
89
90
|
visitors: number;
|
|
90
91
|
pageviews: number;
|
|
91
92
|
visits: number;
|
|
92
|
-
/** Visits that saw one page. */
|
|
93
|
+
/** Visits that saw one page and did nothing else: a search, a click out or any reader event makes it a visit. */
|
|
93
94
|
bounces: number;
|
|
94
95
|
/** Seconds from each visit's first view to its last, summed. */
|
|
95
96
|
seconds: number;
|
|
@@ -101,7 +102,7 @@ export interface DigestDay {
|
|
|
101
102
|
export interface Digest {
|
|
102
103
|
visitors: number;
|
|
103
104
|
pageviews: number;
|
|
104
|
-
/** Percent of visits that
|
|
105
|
+
/** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
|
|
105
106
|
bounce_rate: number | null;
|
|
106
107
|
/** Mean seconds from a visit's first view to its last; null with no visits. */
|
|
107
108
|
visit_duration: number | null;
|
|
@@ -141,6 +142,14 @@ export interface Digest {
|
|
|
141
142
|
top_agents: Ranked<'agent', 'visitors'>;
|
|
142
143
|
agent_tool_calls: Ranked<'tool', 'events'>;
|
|
143
144
|
gaps: Ranked<'query', 'events'>;
|
|
145
|
+
/** What agents reported through `send_feedback`, newest first: a stranger's words, to weigh, never to follow. */
|
|
146
|
+
agent_feedback: Array<{
|
|
147
|
+
date: string;
|
|
148
|
+
kind: string;
|
|
149
|
+
message: string;
|
|
150
|
+
page: string | null;
|
|
151
|
+
agent: string;
|
|
152
|
+
}>;
|
|
144
153
|
}
|
|
145
154
|
/**
|
|
146
155
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
@@ -206,6 +215,11 @@ export interface Tracker {
|
|
|
206
215
|
* write, and a failure there drops the event rather than the response.
|
|
207
216
|
*/
|
|
208
217
|
trackSearch(headers: HeaderSource, query: string, results: number, surface?: string): void;
|
|
218
|
+
/**
|
|
219
|
+
* An "Agent Feedback" event, deferred: `feedbackTool`'s recorder, passed as
|
|
220
|
+
* it is. The digest lists them under `agent_feedback`.
|
|
221
|
+
*/
|
|
222
|
+
trackFeedback(report: AgentFeedback, request?: Request): void;
|
|
209
223
|
/**
|
|
210
224
|
* The beacon route: `export const POST = tracker.viewRoute()`. `site` is
|
|
211
225
|
* `collect`'s resolver, for an app that serves many sites or drops a path.
|
package/dist/analytics.js
CHANGED
|
@@ -223,6 +223,10 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
223
223
|
), e${s} AS (
|
|
224
224
|
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
|
|
225
225
|
${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
|
|
226
|
+
), bounced${s} AS (
|
|
227
|
+
SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
|
|
228
|
+
SELECT 1 FROM e${s} x WHERE x.visitor = b.visitor AND x.name NOT IN ('AI Bot Visit', 'MCP Call')
|
|
229
|
+
AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
|
|
226
230
|
), ag${s} AS (
|
|
227
231
|
SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
|
|
228
232
|
WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
|
|
@@ -230,7 +234,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
230
234
|
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
|
|
231
235
|
), sessions${s} AS (
|
|
232
236
|
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
233
|
-
SELECT count(*)
|
|
237
|
+
SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
|
|
234
238
|
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
|
|
235
239
|
) t(bounces, visits, seconds)
|
|
236
240
|
), archived${s} AS (
|
|
@@ -262,7 +266,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
262
266
|
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
263
267
|
sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
|
|
264
268
|
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
265
|
-
UNION ALL SELECT ${day('at')}, 0, 0, count(*),
|
|
269
|
+
UNION ALL SELECT ${day('at')}, 0, 0, count(*), 0, coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
|
|
270
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), 0, 0, 0, 0 FROM bounced GROUP BY 1
|
|
266
271
|
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
|
|
267
272
|
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM ag) u GROUP BY 1
|
|
268
273
|
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
@@ -273,6 +278,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
273
278
|
'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
|
|
274
279
|
WHERE name = 'MCP Call' AND props->>'method' = 'tools/call' AND NOT ${ours}
|
|
275
280
|
GROUP BY 1`, 10)},
|
|
281
|
+
'agent_feedback', ${list(`SELECT ${day('at')} AS date, props->>'kind' AS kind, props->>'message' AS message, props->>'page' AS page, ${AGENT_NAME} AS agent
|
|
282
|
+
FROM e WHERE name = 'Agent Feedback' AND NOT ${ours}`, 20, 'at DESC')},
|
|
276
283
|
'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
|
|
277
284
|
) AS digest`, ...values));
|
|
278
285
|
const d = row.digest;
|
|
@@ -370,6 +377,12 @@ export function createTracker({ sql, defer, enabled = typeof process !== 'undefi
|
|
|
370
377
|
}
|
|
371
378
|
});
|
|
372
379
|
},
|
|
380
|
+
trackFeedback(report, request) {
|
|
381
|
+
if (!enabled)
|
|
382
|
+
return;
|
|
383
|
+
const ua = (request?.headers.get('user-agent') || 'unknown').slice(0, 80);
|
|
384
|
+
defer(() => trackEvent('Agent Feedback', request?.url ?? siteUrl, { ...report, ua }, request?.headers));
|
|
385
|
+
},
|
|
373
386
|
viewRoute(opts = {}) {
|
|
374
387
|
return async (request) => enabled ? collect(request, { sql, defer, ...opts }) : new Response(null, { status: 204 });
|
|
375
388
|
},
|
package/dist/base.css
CHANGED
|
@@ -614,6 +614,20 @@ pre:hover > .code-copy-btn,
|
|
|
614
614
|
cut the axis to half its labels. */
|
|
615
615
|
.chart-canvas table { table-layout: auto; }
|
|
616
616
|
.chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
|
|
617
|
+
/* An event's name, over the mark the crosshair is on, in the colours of the axis labels. */
|
|
618
|
+
.chart-tip {
|
|
619
|
+
position: absolute;
|
|
620
|
+
z-index: 2;
|
|
621
|
+
max-width: 50%;
|
|
622
|
+
padding: 0.25rem 0.5rem;
|
|
623
|
+
font-size: 0.6875rem;
|
|
624
|
+
line-height: 1.4;
|
|
625
|
+
white-space: pre-line;
|
|
626
|
+
color: var(--ui-on-accent);
|
|
627
|
+
background: var(--ui-accent);
|
|
628
|
+
border-radius: calc(var(--ui-radius) / 2);
|
|
629
|
+
pointer-events: none;
|
|
630
|
+
}
|
|
617
631
|
.chart-controls {
|
|
618
632
|
position: absolute;
|
|
619
633
|
top: 0.5rem;
|
package/dist/chart.d.ts
CHANGED
|
@@ -18,12 +18,12 @@ export type ChartResolution = 'hour' | '4h' | 'day';
|
|
|
18
18
|
export type ChartStep = 'day' | 'week' | 'month';
|
|
19
19
|
/** Points by resolution, from `from` (Unix seconds, 0 for all time), oldest first. */
|
|
20
20
|
export type ChartLoader = (resolution: ChartResolution, from: number) => Promise<readonly ChartPoint[]>;
|
|
21
|
-
/** Something that happened at a time, in Unix seconds,
|
|
21
|
+
/** Something that happened at a time, in Unix seconds, marked on the point that holds it; `label` is its tooltip. */
|
|
22
22
|
export type ChartMarker = {
|
|
23
23
|
time: number;
|
|
24
24
|
label: string;
|
|
25
25
|
};
|
|
26
|
-
/** The range and step a reader has picked, for a page that keeps them in its URL. */
|
|
26
|
+
/** The range and step a reader has picked, for a page that keeps them in its URL; `step` is null while the chart picks its own. */
|
|
27
27
|
export type ChartView = {
|
|
28
28
|
range: ChartRange;
|
|
29
29
|
step: ChartStep | null;
|
|
@@ -32,8 +32,12 @@ export type ChartView = {
|
|
|
32
32
|
export type ChartAggregate = 'last' | 'sum' | 'mean';
|
|
33
33
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
34
34
|
export declare const CHART_RANGES: readonly ChartRange[];
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Every step is offered, however few points it makes, and the default is the finest
|
|
37
|
+
* that makes 200 or fewer. None is finer than the points' own `spacing`: weekly
|
|
38
|
+
* readings have no day.
|
|
39
|
+
*/
|
|
40
|
+
export declare function chartSteps(span: number, spacing?: number): {
|
|
37
41
|
steps: ChartStep[];
|
|
38
42
|
fallback: ChartStep;
|
|
39
43
|
};
|
|
@@ -71,7 +75,7 @@ export interface TimeChartProps {
|
|
|
71
75
|
* window links, which load another digest rather than slice this one.
|
|
72
76
|
*/
|
|
73
77
|
controls?: ReactNode;
|
|
74
|
-
/** Events
|
|
78
|
+
/** Events marked on the points that hold them, each named in a tooltip. */
|
|
75
79
|
markers?: readonly ChartMarker[];
|
|
76
80
|
/** The step shown first, where the range can make it. */
|
|
77
81
|
step?: ChartStep;
|
package/dist/chart.js
CHANGED
|
@@ -8,8 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// loaded on mount, so the page paints without it. Every colour is the canvas
|
|
9
9
|
// box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
|
|
10
10
|
// `--chart-down`, each if set. A count's line is curved; a price's is straight,
|
|
11
|
-
// since a curve between two closes draws prices nobody paid. Events are
|
|
12
|
-
// on the point they fall in, so a spike carries the
|
|
11
|
+
// since a curve between two closes draws prices nobody paid. Events are marks
|
|
12
|
+
// on the point they fall in, named in a tooltip over it, so a spike carries the
|
|
13
|
+
// reason for it without words across the plot.
|
|
13
14
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
14
15
|
const DAY = 86_400;
|
|
15
16
|
const RANGE = {
|
|
@@ -29,11 +30,21 @@ const STEPS = ['day', 'week', 'month'];
|
|
|
29
30
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
30
31
|
export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
|
|
31
32
|
const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Every step is offered, however few points it makes, and the default is the finest
|
|
35
|
+
* that makes 200 or fewer. None is finer than the points' own `spacing`: weekly
|
|
36
|
+
* readings have no day.
|
|
37
|
+
*/
|
|
38
|
+
export function chartSteps(span, spacing = DAY) {
|
|
39
|
+
const fine = STEPS.filter(s => STEP[s].span >= spacing * 0.9);
|
|
40
|
+
const steps = fine.length ? fine : ['month'];
|
|
35
41
|
return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
|
|
36
42
|
}
|
|
43
|
+
/** The points' own spacing: the median gap, so a missing day or a skipped week does not change it. */
|
|
44
|
+
function spacing(points) {
|
|
45
|
+
const gaps = points.slice(1).map((p, i) => p.time - points[i].time).sort((a, b) => a - b);
|
|
46
|
+
return gaps[Math.floor(gaps.length / 2)] ?? DAY;
|
|
47
|
+
}
|
|
37
48
|
/** The UTC day, Monday or first of the month a time falls in. */
|
|
38
49
|
function bucketStart(time, step) {
|
|
39
50
|
const day = Math.floor(time / DAY);
|
|
@@ -81,7 +92,7 @@ export function bucketChart(points, step, aggregate) {
|
|
|
81
92
|
}
|
|
82
93
|
/**
|
|
83
94
|
* Markers on the points drawn: each on the last point at or before it, so one
|
|
84
|
-
* mid-week sits on its week. Markers on one point share it,
|
|
95
|
+
* mid-week sits on its week. Markers on one point share it, a label a line; one
|
|
85
96
|
* before the first point or after the last point's step is left off.
|
|
86
97
|
*
|
|
87
98
|
* @internal
|
|
@@ -102,7 +113,7 @@ export function placeMarkers(markers, shown, step) {
|
|
|
102
113
|
const time = shown[i].time;
|
|
103
114
|
placed.set(time, [...(placed.get(time) ?? []), m.label]);
|
|
104
115
|
}
|
|
105
|
-
return [...placed].map(([time, labels]) => ({ time, label: labels.join('
|
|
116
|
+
return [...placed].map(([time, labels]) => ({ time, label: labels.join('\n') }));
|
|
106
117
|
}
|
|
107
118
|
const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
|
|
108
119
|
/** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
|
|
@@ -163,6 +174,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
163
174
|
const [step, setStep] = useState(initialStep ?? null);
|
|
164
175
|
const [candles, setCandles] = useState(initialCandles);
|
|
165
176
|
const [loaded, setLoaded] = useState(null);
|
|
177
|
+
// The marker under the crosshair, at the cursor, in the box's pixels.
|
|
178
|
+
const [tip, setTip] = useState(null);
|
|
179
|
+
const placed = useRef(new Map());
|
|
166
180
|
const resolution = RANGE[range].resolution;
|
|
167
181
|
useEffect(() => {
|
|
168
182
|
const el = box.current;
|
|
@@ -227,6 +241,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
227
241
|
wickDownColor: down,
|
|
228
242
|
priceFormat,
|
|
229
243
|
});
|
|
244
|
+
api.subscribeCrosshairMove(({ time, point }) => {
|
|
245
|
+
const label = time === undefined || !point ? undefined : placed.current.get(time);
|
|
246
|
+
setTip(label ? { x: point.x, y: point.y, label } : null);
|
|
247
|
+
});
|
|
230
248
|
// The chart cannot be scrolled or zoomed, so every size it takes shows every point,
|
|
231
249
|
// the first included: data drawn before autoSize has measured the box fits nothing.
|
|
232
250
|
api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
|
|
@@ -298,9 +316,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
298
316
|
});
|
|
299
317
|
if (resolution !== 'day' || !windowed.length)
|
|
300
318
|
return drawn(windowed, [], null);
|
|
301
|
-
const { steps, fallback } = chartSteps(end - windowed[0].time);
|
|
319
|
+
const { steps, fallback } = chartSteps(end - windowed[0].time, spacing(points));
|
|
302
320
|
const chosen = step && steps.includes(step) ? step : fallback;
|
|
303
|
-
|
|
321
|
+
// The points' own step draws them as they are: a Sunday's reading stays on its Sunday.
|
|
322
|
+
if (chosen === steps[0])
|
|
304
323
|
return drawn(windowed, steps, chosen);
|
|
305
324
|
// A stepped range starts on a whole week or month, so its first total is not a few days of one.
|
|
306
325
|
const from = bucketStart(windowed[0].time, chosen);
|
|
@@ -336,24 +355,29 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
336
355
|
const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
|
|
337
356
|
return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
|
|
338
357
|
}));
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
shape: 'arrowDown',
|
|
343
|
-
color: c.ink,
|
|
344
|
-
text: m.label,
|
|
345
|
-
}));
|
|
358
|
+
const marks = placeMarkers(markers ?? [], shown, view?.step ?? null);
|
|
359
|
+
placed.current = new Map(marks.map(m => [m.time, m.label]));
|
|
360
|
+
const drawn = marks.map(m => ({ time: time(m), position: 'aboveBar', shape: 'arrowDown', color: c.ink }));
|
|
346
361
|
c.areaMarkers.setMarkers(asCandles ? [] : drawn);
|
|
347
362
|
c.candleMarkers.setMarkers(asCandles ? drawn : []);
|
|
348
363
|
c.api.timeScale().fitContent();
|
|
349
364
|
}, [ready, source, view, candles, markers]);
|
|
350
|
-
|
|
365
|
+
// Reported once there are points to step, so a step read from a URL is not cleared while they load.
|
|
366
|
+
const shown = !!view;
|
|
367
|
+
const picked = step && step === view?.step ? step : null;
|
|
351
368
|
useEffect(() => {
|
|
352
|
-
|
|
353
|
-
|
|
369
|
+
if (shown)
|
|
370
|
+
onViewRef.current?.({ range, step: picked });
|
|
371
|
+
}, [shown, range, picked]);
|
|
354
372
|
// The steps always show where a range is drawn by day, so the buttons do not jump about; one
|
|
355
373
|
// the range cannot make (any, under 24H and 7D) is disabled.
|
|
356
374
|
const daily = ranges.some(r => RANGE[r].resolution === 'day');
|
|
357
375
|
const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
|
|
358
|
-
return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }),
|
|
376
|
+
return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), tip && box.current && (
|
|
377
|
+
// Over the cursor, or under it near the top; growing away from the nearer side.
|
|
378
|
+
_jsx("span", { className: "chart-tip", role: "tooltip", style: {
|
|
379
|
+
top: box.current.offsetTop + tip.y,
|
|
380
|
+
...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
|
|
381
|
+
translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
|
|
382
|
+
}, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => setRange(r), children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
|
|
359
383
|
}
|
package/dist/dom.js
CHANGED
|
@@ -330,7 +330,10 @@ export function scrollTables(html) {
|
|
|
330
330
|
// ---- analytics beacon -------------------------------------------------------
|
|
331
331
|
let beaconEndpoint = '/api/view';
|
|
332
332
|
let beaconStarted = false;
|
|
333
|
-
|
|
333
|
+
// A browser under automation (Puppeteer, Playwright, Selenium) sets
|
|
334
|
+
// `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
|
|
335
|
+
// pattern cannot tell from a reader's, so it sends nothing at all.
|
|
336
|
+
const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
|
|
334
337
|
/**
|
|
335
338
|
* Report each page view to the site's own `endpoint`, whose route hands it to
|
|
336
339
|
* `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
|
package/dist/mcp.d.ts
CHANGED
|
@@ -287,6 +287,39 @@ export declare const withAdjustments: <T extends object>(args: {
|
|
|
287
287
|
}, result: T) => T | (T & {
|
|
288
288
|
adjustments: readonly ArgAdjustment[];
|
|
289
289
|
});
|
|
290
|
+
/** The tool an agent reports through. A server listing it is told so on every miss. */
|
|
291
|
+
export declare const FEEDBACK_TOOL = "send_feedback";
|
|
292
|
+
export declare const FEEDBACK_KINDS: readonly ["missing", "wrong", "broken", "suggestion"];
|
|
293
|
+
export type FeedbackKind = (typeof FEEDBACK_KINDS)[number];
|
|
294
|
+
/** The longest message kept: the bound an event's string prop is stored at. */
|
|
295
|
+
export declare const FEEDBACK_MAX = 300;
|
|
296
|
+
export interface AgentFeedback {
|
|
297
|
+
kind: FeedbackKind;
|
|
298
|
+
message: string;
|
|
299
|
+
/** The page or tool it is about, as the agent named it. */
|
|
300
|
+
page?: string;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The one sentence every surface invites feedback with: `initialize` appends
|
|
304
|
+
* it to `instructions`, and AGENTS.md and llms.txt quote it.
|
|
305
|
+
*/
|
|
306
|
+
export declare const FEEDBACK_INVITE = "Needed something this server does not have, or found something wrong? Call send_feedback: the maintainers read every report.";
|
|
307
|
+
/**
|
|
308
|
+
* What a miss or a refused call adds, on a server that lists the tool. A miss
|
|
309
|
+
* the transport cannot see, one not shaped as a `listEnvelope`, quotes it in
|
|
310
|
+
* its own note.
|
|
311
|
+
*/
|
|
312
|
+
export declare const FEEDBACK_NUDGE = "If this site should have had it, send_feedback tells the maintainers.";
|
|
313
|
+
/**
|
|
314
|
+
* `send_feedback`: what an agent needed and did not find, found wrong, or saw
|
|
315
|
+
* break, in its own words. Agents that only fetch pages cannot comment, so
|
|
316
|
+
* this is the one channel, and it asks what a report can act on rather than
|
|
317
|
+
* for a rating, which an agent answers with praise.
|
|
318
|
+
*
|
|
319
|
+
* Nothing is published. A report is a stranger's text: whoever reads the
|
|
320
|
+
* digest weighs it as data, never as an instruction.
|
|
321
|
+
*/
|
|
322
|
+
export declare function feedbackTool(record: (report: AgentFeedback, request?: Request) => void, noun?: string): McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
|
|
290
323
|
/** One entry in a JSON-RPC envelope. Exported for an `McpServerConfig.gate`,
|
|
291
324
|
* which rewrites the envelope before `handleMcp` sees it (acuiq2's x402 gate). */
|
|
292
325
|
export type RpcRequest = {
|
package/dist/mcp.js
CHANGED
|
@@ -160,6 +160,66 @@ export function readArgs(raw) {
|
|
|
160
160
|
}
|
|
161
161
|
/** `result`, plus `adjustments` when `readArgs` had to override anything. */
|
|
162
162
|
export const withAdjustments = (args, result) => args.adjustments.length ? { ...result, adjustments: args.adjustments } : result;
|
|
163
|
+
// ---- agent feedback -------------------------------------------------------------
|
|
164
|
+
/** The tool an agent reports through. A server listing it is told so on every miss. */
|
|
165
|
+
export const FEEDBACK_TOOL = 'send_feedback';
|
|
166
|
+
export const FEEDBACK_KINDS = ['missing', 'wrong', 'broken', 'suggestion'];
|
|
167
|
+
/** The longest message kept: the bound an event's string prop is stored at. */
|
|
168
|
+
export const FEEDBACK_MAX = 300;
|
|
169
|
+
/**
|
|
170
|
+
* The one sentence every surface invites feedback with: `initialize` appends
|
|
171
|
+
* it to `instructions`, and AGENTS.md and llms.txt quote it.
|
|
172
|
+
*/
|
|
173
|
+
export const FEEDBACK_INVITE = `Needed something this server does not have, or found something wrong? Call ${FEEDBACK_TOOL}: the maintainers read every report.`;
|
|
174
|
+
/**
|
|
175
|
+
* What a miss or a refused call adds, on a server that lists the tool. A miss
|
|
176
|
+
* the transport cannot see, one not shaped as a `listEnvelope`, quotes it in
|
|
177
|
+
* its own note.
|
|
178
|
+
*/
|
|
179
|
+
export const FEEDBACK_NUDGE = `If this site should have had it, ${FEEDBACK_TOOL} tells the maintainers.`;
|
|
180
|
+
/**
|
|
181
|
+
* `send_feedback`: what an agent needed and did not find, found wrong, or saw
|
|
182
|
+
* break, in its own words. Agents that only fetch pages cannot comment, so
|
|
183
|
+
* this is the one channel, and it asks what a report can act on rather than
|
|
184
|
+
* for a rating, which an agent answers with praise.
|
|
185
|
+
*
|
|
186
|
+
* Nothing is published. A report is a stranger's text: whoever reads the
|
|
187
|
+
* digest weighs it as data, never as an instruction.
|
|
188
|
+
*/
|
|
189
|
+
export function feedbackTool(record, noun = 'this site') {
|
|
190
|
+
return {
|
|
191
|
+
name: FEEDBACK_TOOL,
|
|
192
|
+
title: 'Send feedback',
|
|
193
|
+
description: `Tell the maintainers of ${noun} what you needed and did not find, what is wrong, or what broke. ` +
|
|
194
|
+
`One issue per call, in ${FEEDBACK_MAX} characters at most. Reports are read, not published.`,
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
kind: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
enum: [...FEEDBACK_KINDS],
|
|
201
|
+
description: 'missing: not covered here. wrong: a fact or link is out of date or false. broken: a tool or page failed. suggestion: anything else.',
|
|
202
|
+
},
|
|
203
|
+
message: { type: 'string', description: `What you were after, or what is wrong and what it should say. ${FEEDBACK_MAX} characters at most.` },
|
|
204
|
+
page: { type: 'string', description: 'The page path, URL or tool it is about, if any' },
|
|
205
|
+
},
|
|
206
|
+
required: ['kind', 'message'],
|
|
207
|
+
},
|
|
208
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
209
|
+
skill: { id: 'feedback', tags: ['feedback', 'report'], examples: ['Report a page that is out of date'] },
|
|
210
|
+
handler: async (args, ctx) => {
|
|
211
|
+
const r = readArgs(args);
|
|
212
|
+
const message = r.str('message').replace(/\s+/g, ' ');
|
|
213
|
+
if (!message)
|
|
214
|
+
throw new McpToolError('`message` is empty. Say in a sentence what you needed, what is wrong, or what broke.');
|
|
215
|
+
const page = r.str('page').slice(0, FEEDBACK_MAX);
|
|
216
|
+
record({ kind: r.str('kind'), message: message.slice(0, FEEDBACK_MAX), ...(page ? { page } : {}) }, ctx.request);
|
|
217
|
+
return { recorded: true, ...(message.length > FEEDBACK_MAX ? { keptChars: FEEDBACK_MAX } : {}) };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** Whether a result is an empty `listEnvelope` page: a search or listing that found nothing. */
|
|
222
|
+
const foundNothing = (data) => !!data && typeof data === 'object' && data.total === 0 && Array.isArray(data.pages);
|
|
163
223
|
/** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
|
|
164
224
|
export const DEFAULT_MAX_BATCH = 20;
|
|
165
225
|
const quote = (list) => list.map(s => `"${s}"`).join(', ');
|
|
@@ -291,6 +351,11 @@ const methodNotFound = (id, method, config, era) => {
|
|
|
291
351
|
* `resources` whose list comes back empty reads as a bug to a client, not as
|
|
292
352
|
* honesty. One function, so `initialize` and `server/discover` cannot disagree.
|
|
293
353
|
*/
|
|
354
|
+
const listsFeedback = (config) => config.tools.some(t => t.name === FEEDBACK_TOOL);
|
|
355
|
+
/** `instructions`, inviting feedback when the server takes it, so the invitation cannot be the line an app forgets. */
|
|
356
|
+
const instructionsOf = (config) => listsFeedback(config) && !config.instructions.includes(FEEDBACK_TOOL)
|
|
357
|
+
? `${config.instructions.trimEnd()}\n\n${FEEDBACK_INVITE}`
|
|
358
|
+
: config.instructions;
|
|
294
359
|
const capabilitiesOf = (config) => ({
|
|
295
360
|
tools: {},
|
|
296
361
|
...(config.resources?.length ? { resources: {} } : {}),
|
|
@@ -324,7 +389,7 @@ async function handleRpc(req, config, dispatch) {
|
|
|
324
389
|
protocolVersion: negotiateProtocol(p.protocolVersion),
|
|
325
390
|
capabilities: capabilitiesOf(config),
|
|
326
391
|
serverInfo: config.serverInfo,
|
|
327
|
-
instructions: config
|
|
392
|
+
instructions: instructionsOf(config),
|
|
328
393
|
});
|
|
329
394
|
// What `initialize` tells a legacy client, asked the modern way. The
|
|
330
395
|
// transport adds `resultType`, the cache hints and `_meta` serverInfo.
|
|
@@ -332,7 +397,7 @@ async function handleRpc(req, config, dispatch) {
|
|
|
332
397
|
return ok(id, {
|
|
333
398
|
supportedVersions: [...MCP_PROTOCOL_VERSIONS],
|
|
334
399
|
capabilities: capabilitiesOf(config),
|
|
335
|
-
instructions: config
|
|
400
|
+
instructions: instructionsOf(config),
|
|
336
401
|
});
|
|
337
402
|
case 'ping':
|
|
338
403
|
return ok(id, {});
|
|
@@ -399,8 +464,11 @@ async function handleRpc(req, config, dispatch) {
|
|
|
399
464
|
protocolVersion: dispatch.protocolVersion,
|
|
400
465
|
request: dispatch.request,
|
|
401
466
|
};
|
|
467
|
+
// A miss is when an agent knows what it was after, so that is where it hears it can say so.
|
|
468
|
+
const nudge = tool.name !== FEEDBACK_TOOL && listsFeedback(config);
|
|
402
469
|
try {
|
|
403
|
-
const
|
|
470
|
+
const raw = await tool.handler(args, ctx);
|
|
471
|
+
const data = nudge && foundNothing(raw) ? { ...raw, feedback: FEEDBACK_NUDGE } : raw;
|
|
404
472
|
// `?? null`: a handler that returns nothing still owes a string, and
|
|
405
473
|
// JSON.stringify(undefined) is undefined.
|
|
406
474
|
const text = typeof data === 'string' ? data : JSON.stringify(data ?? null, null, 2);
|
|
@@ -423,7 +491,8 @@ async function handleRpc(req, config, dispatch) {
|
|
|
423
491
|
catch (err) {
|
|
424
492
|
if (!(err instanceof McpToolError))
|
|
425
493
|
throw err;
|
|
426
|
-
|
|
494
|
+
const feedback = nudge ? { feedback: FEEDBACK_NUDGE } : {};
|
|
495
|
+
return toolText(id, JSON.stringify({ error: err.message, ...err.details, ...feedback }, null, 2), true);
|
|
427
496
|
}
|
|
428
497
|
}
|
|
429
498
|
default:
|
package/dist/og.d.ts
CHANGED
|
@@ -25,6 +25,23 @@ export declare function bubbles(rand: () => number, o: BubbleOptions): {
|
|
|
25
25
|
background: string;
|
|
26
26
|
art: ReactNode;
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* A page's banner as the card's field: fetched, cropped to cover 1200×630 and
|
|
30
|
+
* inlined, under the scrim a photograph needs. Null when there is no banner,
|
|
31
|
+
* it is not an https URL on `hosts`, or it cannot be fetched and decoded, and
|
|
32
|
+
* the route draws its generated field instead.
|
|
33
|
+
*
|
|
34
|
+
* `hosts` is where the site stores banners. Fetching whatever `?banner=` named
|
|
35
|
+
* would make `/og` an anonymous proxy to any URL the server reaches, decoded by
|
|
36
|
+
* sharp on the way through. The bytes are inlined as PNG because satori decodes
|
|
37
|
+
* PNG and JPEG, not the WebP or AVIF an upload is stored as. `sharp` is an
|
|
38
|
+
* optional peer, loaded only here; run the route on Node.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bannerField(banner: string | null | undefined, hosts: RegExp): Promise<{
|
|
41
|
+
background: string;
|
|
42
|
+
art: ReactNode;
|
|
43
|
+
scrim: true;
|
|
44
|
+
} | null>;
|
|
28
45
|
export interface SocialCardProps {
|
|
29
46
|
title: string;
|
|
30
47
|
/** The line above the title: a section, a code, a handle. */
|
package/dist/og.js
CHANGED
|
@@ -22,6 +22,44 @@ export function bubbles(rand, o) {
|
|
|
22
22
|
});
|
|
23
23
|
return { background, art };
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A page's banner as the card's field: fetched, cropped to cover 1200×630 and
|
|
27
|
+
* inlined, under the scrim a photograph needs. Null when there is no banner,
|
|
28
|
+
* it is not an https URL on `hosts`, or it cannot be fetched and decoded, and
|
|
29
|
+
* the route draws its generated field instead.
|
|
30
|
+
*
|
|
31
|
+
* `hosts` is where the site stores banners. Fetching whatever `?banner=` named
|
|
32
|
+
* would make `/og` an anonymous proxy to any URL the server reaches, decoded by
|
|
33
|
+
* sharp on the way through. The bytes are inlined as PNG because satori decodes
|
|
34
|
+
* PNG and JPEG, not the WebP or AVIF an upload is stored as. `sharp` is an
|
|
35
|
+
* optional peer, loaded only here; run the route on Node.
|
|
36
|
+
*/
|
|
37
|
+
export async function bannerField(banner, hosts) {
|
|
38
|
+
if (!banner)
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(banner);
|
|
42
|
+
if (url.protocol !== 'https:' || !hosts.test(url.hostname))
|
|
43
|
+
return null;
|
|
44
|
+
const res = await fetch(url, { redirect: 'error' });
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
return null;
|
|
47
|
+
const { default: sharp } = await import('sharp');
|
|
48
|
+
const png = await sharp(Buffer.from(await res.arrayBuffer()))
|
|
49
|
+
.resize(OG_SIZE.width, OG_SIZE.height, { fit: 'cover' })
|
|
50
|
+
.png()
|
|
51
|
+
.toBuffer();
|
|
52
|
+
const src = `data:image/png;base64,${png.toString('base64')}`;
|
|
53
|
+
return {
|
|
54
|
+
background: '#111',
|
|
55
|
+
art: _jsx("img", { src: src, alt: "", width: OG_SIZE.width, height: OG_SIZE.height, style: { position: 'absolute', width: '100%', height: '100%', objectFit: 'cover' } }),
|
|
56
|
+
scrim: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
25
63
|
const SCRIM = 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.3) 50%, transparent 100%)';
|
|
26
64
|
/** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
|
|
27
65
|
export function SocialCard(p) {
|
package/dist/react-server.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { MaintenanceQueue } from './queues.js';
|
|
|
5
5
|
/**
|
|
6
6
|
* The shape a router's link component has to satisfy here.
|
|
7
7
|
*
|
|
8
|
-
* Deliberately the intersection the three rails
|
|
9
|
-
* use, not a re-declaration of `AnchorHTMLAttributes`: a wider surface would
|
|
8
|
+
* Deliberately the intersection the three rails, three facet bars and the stats
|
|
9
|
+
* page actually use, not a re-declaration of `AnchorHTMLAttributes`: a wider surface would
|
|
10
10
|
* let a consumer pass something `next/link` accepts and a bare `<a>` silently
|
|
11
11
|
* drops. `next/link` satisfies this as it stands.
|
|
12
12
|
*/
|
|
@@ -17,6 +17,11 @@ export interface WikiLinkProps {
|
|
|
17
17
|
'aria-current'?: 'true' | 'page' | undefined;
|
|
18
18
|
/** `prev`/`next` on the foot nav — the half that makes the pair machine-readable. */
|
|
19
19
|
rel?: string;
|
|
20
|
+
/** `switch`, with `aria-checked`, on the stats page's toggle between two views. */
|
|
21
|
+
role?: string;
|
|
22
|
+
'aria-checked'?: boolean;
|
|
23
|
+
/** The name of a link drawn as an icon. */
|
|
24
|
+
'aria-label'?: string;
|
|
20
25
|
children?: ReactNode;
|
|
21
26
|
}
|
|
22
27
|
export type WikiLinkComponent = ComponentType<WikiLinkProps>;
|
package/dist/stats.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import { type Digest, type StatsFilter } from './analytics.js';
|
|
3
3
|
import type { PasskeyGate } from './passkey.js';
|
|
4
|
+
import { type WikiLinkComponent } from './react-server.js';
|
|
4
5
|
export interface StatsProps {
|
|
5
6
|
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
6
7
|
digest: Digest;
|
|
@@ -12,6 +13,8 @@ export interface StatsProps {
|
|
|
12
13
|
apart?: boolean;
|
|
13
14
|
/** The windows offered as `?days=` links on the same page. */
|
|
14
15
|
ranges?: readonly number[];
|
|
16
|
+
/** Every link on the page: `next/link`, say, so a window or a filter does not reload it. */
|
|
17
|
+
link?: WikiLinkComponent;
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
17
20
|
* A stats page's query as the window and the filter, both ready for `digest`
|
|
@@ -30,7 +33,7 @@ export declare function statsQuery(params: Record<string, string | string[] | un
|
|
|
30
33
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
31
34
|
* until the importing stylesheet gives them a colour.
|
|
32
35
|
*/
|
|
33
|
-
export declare function Stats({ digest: d, days, filter, apart, ranges }: StatsProps): import("react").JSX.Element;
|
|
36
|
+
export declare function Stats({ digest: d, days, filter, apart, ranges, link: Link }: StatsProps): import("react").JSX.Element;
|
|
34
37
|
export interface StatsGateProps {
|
|
35
38
|
gate: PasskeyGate;
|
|
36
39
|
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
package/dist/stats.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { STATS_FILTERS, count } from './analytics.js';
|
|
3
|
+
import { Anchor } from './react-server.js';
|
|
3
4
|
import { PasskeyButton, PasskeySignOut, StatsFigures } from './stats-figures.js';
|
|
4
5
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
5
6
|
const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
@@ -44,11 +45,11 @@ const country = (code) => {
|
|
|
44
45
|
};
|
|
45
46
|
const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
|
|
46
47
|
const DAY_MS = 86_400_000;
|
|
47
|
-
function StatsList({ title, rows, label = v => v, to, }) {
|
|
48
|
+
function StatsList({ title, rows, label = v => v, to, link: Link, }) {
|
|
48
49
|
if (!rows.length)
|
|
49
50
|
return null;
|
|
50
51
|
const max = Math.max(...rows.map(([, n]) => n));
|
|
51
|
-
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(
|
|
52
|
+
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), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
55
|
* A site's own analytics on one page: the headline figures against the window
|
|
@@ -57,7 +58,7 @@ function StatsList({ title, rows, label = v => v, to, }) {
|
|
|
57
58
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
58
59
|
* until the importing stylesheet gives them a colour.
|
|
59
60
|
*/
|
|
60
|
-
export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES }) {
|
|
61
|
+
export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES, link: Link = Anchor }) {
|
|
61
62
|
const today = Math.floor(Date.now() / DAY_MS);
|
|
62
63
|
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
63
64
|
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
@@ -73,8 +74,8 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
|
|
|
73
74
|
const to = (k) => (v) => at({ ...filter, [k]: v });
|
|
74
75
|
// Each window is another digest, so a link rather than a button, pinned
|
|
75
76
|
// with the chart's own controls, and so is the switch that counts agents apart.
|
|
76
|
-
const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(
|
|
77
|
-
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs(
|
|
77
|
+
const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(Link, { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }), _jsx("div", { children: _jsx(Link, { href: at(filter, days, !apart), role: "switch", "aria-checked": apart, "aria-label": "People and agents counted apart", title: "People and agents counted apart", children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M6 1.5v2M1 6.5v2M11 6.5v2", stroke: "currentColor" }), _jsx("rect", { x: "2.5", y: "3.5", width: "7", height: "7", rx: "1.5", fill: "none", stroke: "currentColor" }), _jsx("circle", { cx: "4.75", cy: "7", r: "0.9", fill: "currentColor" }), _jsx("circle", { cx: "7.25", cy: "7", r: "0.9", fill: "currentColor" })] }) }) })] }));
|
|
78
|
+
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", 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') })] })] }));
|
|
78
79
|
}
|
|
79
80
|
/**
|
|
80
81
|
* A stats page's door: `children` and a sign-out button for a signed-in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.97.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",
|