wiki-formant 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -730,13 +730,16 @@ return <Stats digest={await digest(sql, { days })} days={days} />;
730
730
 
731
731
  `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
732
732
  own subpath, because it imports the optional peer `@simplewebauthn/browser`.
733
- `Stats` (`wiki-formant/react-server`) renders a `digest` as one page: the
733
+ `Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
734
734
  headline figures against the window before, visitors per day, every non-empty
735
- ranked list, and `?days=` links between windows. It ships no JavaScript.
735
+ ranked list, and `?days=` links between windows. Everything but the daily chart
736
+ is server-rendered. The chart is a `lightweight-charts` area series with a date
737
+ axis and a count axis from zero, drawn in the box's own `color`, so
738
+ `lightweight-charts` is an optional peer that a stats page needs installed.
736
739
 
737
740
  ## Base stylesheet
738
741
 
739
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
742
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid, bars and chart height. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
740
743
 
741
744
  ```css
742
745
  @import "wiki-formant/base.css" layer(components);
package/dist/base.css CHANGED
@@ -183,23 +183,9 @@ pre:hover > .code-copy-btn,
183
183
  margin: 0;
184
184
  }
185
185
  .stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
186
- .stats-chart {
187
- display: flex;
188
- align-items: flex-end;
189
- /* A year's bars on a phone would be all gap at 2px each. */
190
- gap: min(2px, 100% / var(--bars, 1) / 4);
191
- height: 8rem;
192
- margin: 0;
193
- padding: 0;
194
- list-style: none;
195
- }
196
- .stats-chart li {
197
- flex: 1;
198
- height: calc(var(--share) * 100%);
199
- min-height: 1px;
200
- background: currentColor;
201
- opacity: 0.4;
202
- }
186
+ .stats-chart { height: 15rem; }
187
+ /* The chart lays itself out in a <table>; a site's table styles are not for it. */
188
+ .stats-chart :is(table, tr, td) { background: none; border: 0; border-radius: 0; }
203
189
  .stats-lists {
204
190
  display: grid;
205
191
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
@@ -1,7 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
2
  import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
3
3
  import type { HistoryChange } from './history.js';
4
- import type { Digest } from './analytics.js';
5
4
  /**
6
5
  * The shape a router's link component has to satisfy here.
7
6
  *
@@ -268,22 +267,3 @@ export interface RevisionChangesProps {
268
267
  * are the importing stylesheet's.
269
268
  */
270
269
  export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
271
- export interface StatsProps {
272
- /** One window's figures, from `digest` in `wiki-formant/analytics`. */
273
- digest: Digest;
274
- /** The window the digest covers, in days. */
275
- days: number;
276
- /** The windows offered as `?days=` links on the same page. */
277
- ranges?: readonly number[];
278
- }
279
- /** The windows the stats page offers, in days. */
280
- export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365];
281
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
282
- export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
283
- /**
284
- * A site's own analytics on one page: the headline figures against the window
285
- * before, visitors per day, and every ranked list the digest carries. Lists
286
- * with nothing in them are left out. Bars are `currentColor` until the
287
- * importing stylesheet gives them a colour.
288
- */
289
- export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
@@ -225,58 +225,3 @@ export function RevisionChanges({ changes, label = type => type }) {
225
225
  return null;
226
226
  return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
227
227
  }
228
- /** The windows the stats page offers, in days. */
229
- export const STATS_RANGES = [1, 7, 30, 90, 365];
230
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
231
- export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
232
- const count = (n) => n.toLocaleString('en');
233
- const regions = new Intl.DisplayNames(['en'], { type: 'region' });
234
- const country = (code) => {
235
- try {
236
- return regions.of(code) ?? code;
237
- }
238
- catch {
239
- return code;
240
- }
241
- };
242
- const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
243
- /** Unsigned at zero, so no change never reads as growth. */
244
- const change = (now, before) => {
245
- if (!before)
246
- return null;
247
- const pct = Math.round((100 * (now - before)) / before);
248
- return pct > 0 ? `+${pct}%` : `${pct}%`;
249
- };
250
- function StatsList({ title, rows, label = v => v }) {
251
- if (!rows.length)
252
- return null;
253
- const max = Math.max(...rows.map(([, n]) => n));
254
- return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
255
- }
256
- /**
257
- * A site's own analytics on one page: the headline figures against the window
258
- * before, visitors per day, and every ranked list the digest carries. Lists
259
- * with nothing in them are left out. Bars are `currentColor` until the
260
- * importing stylesheet gives them a colour.
261
- */
262
- export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
263
- const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
264
- // A rolling window of n days touches n + 1 UTC dates, the first of them in part.
265
- const now = Date.now();
266
- const series = Array.from({ length: days + 1 }, (_, i) => {
267
- const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
268
- return [date, byDay.get(date) ?? 0];
269
- });
270
- const peak = Math.max(1, ...series.map(([, n]) => n));
271
- const figures = [
272
- ['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
273
- ['Page views', count(d.pageviews), null],
274
- ['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
275
- ['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
276
- ['With agents', count(d.visitors_incl_agents), null],
277
- ];
278
- if (d.follow_clicks)
279
- figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
280
- const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
281
- return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx("ol", { className: "stats-chart", "aria-label": "Visitors per day", style: { '--bars': series.length }, children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
282
- }
@@ -0,0 +1,4 @@
1
+ /** One `[YYYY-MM-DD, visitors]` per UTC day, oldest first. */
2
+ export declare function StatsChart({ series }: {
3
+ series: readonly (readonly [string, number])[];
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // stats-chart.tsx — visitors per day for `Stats`, with a date axis and a count
4
+ // axis. `lightweight-charts` is loaded on mount, so the rest of the page paints
5
+ // without it. Every colour is the box's own `color`, like the bars beside it.
6
+ import { useEffect, useRef } from 'react';
7
+ /**
8
+ * A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
9
+ * a pixel and read back, because a site's colour may be oklch() or a named one.
10
+ */
11
+ function rgba(colour, alpha = 1) {
12
+ const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
13
+ if (!ctx)
14
+ return colour;
15
+ ctx.fillStyle = colour;
16
+ ctx.fillRect(0, 0, 1, 1);
17
+ return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
18
+ }
19
+ /** Counts start at zero, so a quiet week does not read as a collapse. */
20
+ const fromZero = (base) => {
21
+ const info = base();
22
+ if (info?.priceRange)
23
+ info.priceRange.minValue = 0;
24
+ return info;
25
+ };
26
+ /** One `[YYYY-MM-DD, visitors]` per UTC day, oldest first. */
27
+ export function StatsChart({ series }) {
28
+ const ref = useRef(null);
29
+ useEffect(() => {
30
+ const box = ref.current;
31
+ if (!box)
32
+ return;
33
+ let chart;
34
+ let disposed = false;
35
+ import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle }) => {
36
+ if (disposed)
37
+ return;
38
+ const { color, fontFamily } = getComputedStyle(box);
39
+ const ink = rgba(color);
40
+ const guide = { color: rgba(color, 0.4), width: 1, style: LineStyle.Dotted };
41
+ chart = createChart(box, {
42
+ autoSize: true,
43
+ layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: rgba(color, 0.7), fontFamily },
44
+ grid: { vertLines: { visible: false }, horzLines: { color: rgba(color, 0.1) } },
45
+ crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
46
+ rightPriceScale: { borderVisible: false },
47
+ timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
48
+ handleScroll: false,
49
+ handleScale: false,
50
+ });
51
+ chart
52
+ .addSeries(AreaSeries, {
53
+ lineColor: ink,
54
+ topColor: rgba(color, 0.4),
55
+ bottomColor: rgba(color, 0.02),
56
+ lineWidth: 2,
57
+ crosshairMarkerBackgroundColor: ink,
58
+ priceLineVisible: false,
59
+ priceFormat: { type: 'price', precision: 0, minMove: 1 },
60
+ autoscaleInfoProvider: fromZero,
61
+ })
62
+ .setData(series.map(([time, value]) => ({ time, value })));
63
+ chart.timeScale().fitContent();
64
+ });
65
+ return () => {
66
+ disposed = true;
67
+ chart?.remove();
68
+ };
69
+ }, [series]);
70
+ return _jsx("div", { ref: ref, className: "stats-chart", role: "img", "aria-label": "Visitors per day" });
71
+ }
@@ -0,0 +1,20 @@
1
+ import type { Digest } from './analytics.js';
2
+ export interface StatsProps {
3
+ /** One window's figures, from `digest` in `wiki-formant/analytics`. */
4
+ digest: Digest;
5
+ /** The window the digest covers, in days. */
6
+ days: number;
7
+ /** The windows offered as `?days=` links on the same page. */
8
+ ranges?: readonly number[];
9
+ }
10
+ /** The windows the stats page offers, in days. */
11
+ export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365];
12
+ /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
13
+ export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
14
+ /**
15
+ * A site's own analytics on one page: the headline figures against the window
16
+ * before, visitors per day, and every ranked list the digest carries. Lists
17
+ * with nothing in them are left out. The chart and the bars are
18
+ * `currentColor` until the importing stylesheet gives them a colour.
19
+ */
20
+ export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
package/dist/stats.js ADDED
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { StatsChart } from './stats-chart.js';
3
+ /** The windows the stats page offers, in days. */
4
+ export const STATS_RANGES = [1, 7, 30, 90, 365];
5
+ /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
6
+ export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
7
+ const count = (n) => n.toLocaleString('en');
8
+ const regions = new Intl.DisplayNames(['en'], { type: 'region' });
9
+ const country = (code) => {
10
+ try {
11
+ return regions.of(code) ?? code;
12
+ }
13
+ catch {
14
+ return code;
15
+ }
16
+ };
17
+ const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
18
+ /** Unsigned at zero, so no change never reads as growth. */
19
+ const change = (now, before) => {
20
+ if (!before)
21
+ return null;
22
+ const pct = Math.round((100 * (now - before)) / before);
23
+ return pct > 0 ? `+${pct}%` : `${pct}%`;
24
+ };
25
+ function StatsList({ title, rows, label = v => v }) {
26
+ if (!rows.length)
27
+ return null;
28
+ const max = Math.max(...rows.map(([, n]) => n));
29
+ return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
30
+ }
31
+ /**
32
+ * A site's own analytics on one page: the headline figures against the window
33
+ * before, visitors per day, and every ranked list the digest carries. Lists
34
+ * with nothing in them are left out. The chart and the bars are
35
+ * `currentColor` until the importing stylesheet gives them a colour.
36
+ */
37
+ export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
38
+ const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
39
+ // A rolling window of n days touches n + 1 UTC dates, the first of them in part.
40
+ const now = Date.now();
41
+ const series = Array.from({ length: days + 1 }, (_, i) => {
42
+ const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
43
+ return [date, byDay.get(date) ?? 0];
44
+ });
45
+ const figures = [
46
+ ['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
47
+ ['Page views', count(d.pageviews), null],
48
+ ['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
49
+ ['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
50
+ ['With agents', count(d.visitors_incl_agents), null],
51
+ ];
52
+ if (d.follow_clicks)
53
+ figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
54
+ const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
55
+ return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx(StatsChart, { series: series }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.34.0",
3
+ "version": "0.35.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",
@@ -172,6 +172,11 @@
172
172
  "import": "./dist/react-server.js",
173
173
  "default": "./dist/react-server.js"
174
174
  },
175
+ "./stats": {
176
+ "types": "./dist/stats.d.ts",
177
+ "import": "./dist/stats.js",
178
+ "default": "./dist/stats.js"
179
+ },
175
180
  "./dom": {
176
181
  "types": "./dist/dom.d.ts",
177
182
  "import": "./dist/dom.js",
@@ -266,11 +271,14 @@
266
271
  "@types/sanitize-html": "^2.16.1",
267
272
  "fast-diff": "^1.3.0",
268
273
  "jsdom": "^30.1.0",
274
+ "lightweight-charts": "^5.2.1",
269
275
  "react": "^19.2.4",
270
276
  "sanitize-html": "^2.17.7",
271
277
  "typescript": "^5.9.0"
272
278
  },
273
279
  "peerDependencies": {
280
+ "@simplewebauthn/browser": ">=13",
281
+ "@simplewebauthn/server": ">=13",
274
282
  "@tiptap/core": ">=2.10",
275
283
  "@tiptap/extension-code-block": ">=2.10",
276
284
  "@tiptap/extension-image": ">=2.10",
@@ -288,8 +296,7 @@
288
296
  "pg": ">=8",
289
297
  "react": ">=18",
290
298
  "sanitize-html": ">=2",
291
- "@simplewebauthn/server": ">=13",
292
- "@simplewebauthn/browser": ">=13"
299
+ "lightweight-charts": ">=5"
293
300
  },
294
301
  "peerDependenciesMeta": {
295
302
  "react": {
@@ -348,6 +355,9 @@
348
355
  },
349
356
  "@simplewebauthn/browser": {
350
357
  "optional": true
358
+ },
359
+ "lightweight-charts": {
360
+ "optional": true
351
361
  }
352
362
  }
353
363
  }