wiki-formant 0.56.0 → 0.58.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 +16 -6
- package/bin/fts-ddl.mjs +5 -3
- package/dist/chart.d.ts +16 -6
- package/dist/chart.js +55 -12
- package/dist/search.d.ts +8 -2
- package/dist/search.js +10 -4
- package/dist/stats-figures.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -847,7 +847,8 @@ own subpath, because it imports `@simplewebauthn/browser`.
|
|
|
847
847
|
`Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
|
|
848
848
|
headline figures, each with its change from the window before
|
|
849
849
|
(`.stats-delta[data-better]`, a share or for the bounce rate points, for the
|
|
850
|
-
site's stylesheet to colour), one of them
|
|
850
|
+
site's stylesheet to colour), one of them by day, week or month (a count's
|
|
851
|
+
total, a rate's mean), every non-empty
|
|
851
852
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
852
853
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
853
854
|
figure is a button that charts it: visitors, agents, page views, bounce rate,
|
|
@@ -897,7 +898,7 @@ that makes 200 or fewer, so a year opens weekly and five years monthly.
|
|
|
897
898
|
|
|
898
899
|
```tsx
|
|
899
900
|
// Daily points: the chart slices and steps them itself; 30D up.
|
|
900
|
-
<TimeChart series={days} label="Transactions" aggregate="
|
|
901
|
+
<TimeChart series={days} label="Transactions" aggregate="sum" fromZero />
|
|
901
902
|
|
|
902
903
|
// A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
|
|
903
904
|
// range), once each.
|
|
@@ -915,10 +916,18 @@ the slot empty, except the last, which is the price now. A price's line is
|
|
|
915
916
|
straight between closes and a count's is curved.
|
|
916
917
|
|
|
917
918
|
`aggregate` is how a week or month is made from its days: `last` for a price,
|
|
918
|
-
`
|
|
919
|
-
|
|
919
|
+
`sum` for a count, so a week reads as the week's total, and `mean` for a rate,
|
|
920
|
+
which no total makes sense of. A stepped range starts on a whole week or month,
|
|
921
|
+
so its first point is not a few days of one; the week in progress is its total
|
|
922
|
+
so far. `fromZero` starts a count's axis at zero and stops it there, with
|
|
923
|
+
no negative ticks under it. A single range draws no range buttons and shows every point.
|
|
920
924
|
`chartSteps` is exported for a chart drawn elsewhere.
|
|
921
925
|
|
|
926
|
+
`format` writes the axis and the crosshair label. An axis tick also gets the
|
|
927
|
+
spacing between ticks as a second argument, so a formatter can write every tick
|
|
928
|
+
to the same precision and two never read alike: `stepDecimals(step)` is the
|
|
929
|
+
decimal places that takes, and `formatChartValue`, the default, uses it.
|
|
930
|
+
|
|
922
931
|
`markers` are events, `{ time, label }`, each drawn as an arrow on the last
|
|
923
932
|
point at or before it, so one mid-week lands on its week and two on one point
|
|
924
933
|
share it. `step` opens on a step where the
|
|
@@ -926,7 +935,7 @@ range can make it, and `onView` hears the range and step on mount and on every
|
|
|
926
935
|
change, for a page that keeps them in its URL:
|
|
927
936
|
|
|
928
937
|
```tsx
|
|
929
|
-
<TimeChart series={days} label="Transactions" aggregate="
|
|
938
|
+
<TimeChart series={days} label="Transactions" aggregate="sum" fromZero
|
|
930
939
|
markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
|
|
931
940
|
```
|
|
932
941
|
`base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
|
|
@@ -1021,10 +1030,11 @@ An app that keeps one schema file and writes the models into it by hand, because
|
|
|
1021
1030
|
npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
|
|
1022
1031
|
```
|
|
1023
1032
|
|
|
1024
|
-
`fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why.
|
|
1033
|
+
`fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why. A PascalCase Prisma table goes in quoted, as SQL names it.
|
|
1025
1034
|
|
|
1026
1035
|
```sh
|
|
1027
1036
|
npx fts-ddl pages
|
|
1037
|
+
npx fts-ddl '"WikiPage"'
|
|
1028
1038
|
```
|
|
1029
1039
|
|
|
1030
1040
|
`mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
|
package/bin/fts-ddl.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// npx fts-ddl pages
|
|
10
10
|
// npx fts-ddl wiki_pages
|
|
11
|
+
// npx fts-ddl '"WikiPage"'
|
|
11
12
|
//
|
|
12
13
|
// The column is rebuilt every run rather than skipped when it exists: a skip
|
|
13
14
|
// is blind to a changed EXPRESSION, and Postgres normalises the stored one, so
|
|
@@ -32,7 +33,7 @@ import { searchTsvDdl } from '../dist/search.js';
|
|
|
32
33
|
import { connect } from './_cli.mjs';
|
|
33
34
|
|
|
34
35
|
const table = process.argv[2];
|
|
35
|
-
if (!table || !/^[a-z_][a-z0-9_]
|
|
36
|
+
if (!table || !/^([a-z_][a-z0-9_]*|"[A-Za-z_][A-Za-z0-9_]*")$/.test(table)) {
|
|
36
37
|
console.error('usage: npx fts-ddl <table>');
|
|
37
38
|
process.exit(1);
|
|
38
39
|
}
|
|
@@ -43,6 +44,7 @@ const client = await connect({ direct: true, ssl: { rejectUnauthorized: false },
|
|
|
43
44
|
process.exit(1);
|
|
44
45
|
});
|
|
45
46
|
const q = s => client.query(s).then(r => r.rows);
|
|
47
|
+
const bare = table.replaceAll('"', '');
|
|
46
48
|
try {
|
|
47
49
|
const ddl = searchTsvDdl(table);
|
|
48
50
|
await q('BEGIN');
|
|
@@ -52,9 +54,9 @@ try {
|
|
|
52
54
|
await q(ddl.index);
|
|
53
55
|
console.log('GIN index present');
|
|
54
56
|
console.table(await q(`select column_name, data_type, is_generated from information_schema.columns
|
|
55
|
-
where table_name='${
|
|
57
|
+
where table_name='${bare}' and column_name='search_tsv'`));
|
|
56
58
|
console.table(await q(`select count(*)::int rows, count(*) filter (where search_tsv is null)::int null_tsv from ${table}`));
|
|
57
|
-
console.table(await q(`select pg_size_pretty(pg_relation_size('${
|
|
59
|
+
console.table(await q(`select pg_size_pretty(pg_relation_size('"${bare}_search_tsv_idx"')) index_size`));
|
|
58
60
|
} catch (e) {
|
|
59
61
|
await q('ROLLBACK').catch(() => {});
|
|
60
62
|
console.error(e.message);
|
package/dist/chart.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export type ChartView = {
|
|
|
28
28
|
range: ChartRange;
|
|
29
29
|
step: ChartStep | null;
|
|
30
30
|
};
|
|
31
|
+
/** How a week or month is made from its days: a price's last, a count's total, a rate's mean. */
|
|
32
|
+
export type ChartAggregate = 'last' | 'sum' | 'mean';
|
|
31
33
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
32
34
|
export declare const CHART_RANGES: readonly ChartRange[];
|
|
33
35
|
/** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
|
|
@@ -35,8 +37,13 @@ export declare function chartSteps(span: number): {
|
|
|
35
37
|
steps: ChartStep[];
|
|
36
38
|
fallback: ChartStep;
|
|
37
39
|
};
|
|
38
|
-
/**
|
|
39
|
-
export declare
|
|
40
|
+
/** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
|
|
41
|
+
export declare function stepDecimals(step: number): number;
|
|
42
|
+
/**
|
|
43
|
+
* Compact from a thousand up, four significant figures below. An axis tick is
|
|
44
|
+
* written to its step in the unit it reads in, so 4.845B is not a second 4.85B.
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatChartValue(n: number, step?: number): string;
|
|
40
47
|
export interface TimeChartProps {
|
|
41
48
|
/** Daily points, oldest first, or a loader for every resolution the ranges need. */
|
|
42
49
|
series: readonly ChartPoint[] | ChartLoader;
|
|
@@ -46,12 +53,15 @@ export interface TimeChartProps {
|
|
|
46
53
|
ranges?: readonly ChartRange[];
|
|
47
54
|
/** The range shown first. */
|
|
48
55
|
range?: ChartRange;
|
|
49
|
-
/** A week or month of prices keeps its last; of counts, the
|
|
50
|
-
aggregate?:
|
|
56
|
+
/** A week or month of prices keeps its last; of counts, the total of its days; of rates, their mean. */
|
|
57
|
+
aggregate?: ChartAggregate;
|
|
51
58
|
/** The value axis starts at zero, so a quiet week does not read as a collapse. */
|
|
52
59
|
fromZero?: boolean;
|
|
53
|
-
/**
|
|
54
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The value axis and crosshair label. Read on every draw, so it need not be stable.
|
|
62
|
+
* An axis tick passes the spacing between ticks, so neighbours never read alike.
|
|
63
|
+
*/
|
|
64
|
+
format?: (n: number, step?: number) => string;
|
|
55
65
|
/** Open on candles, where the points carry them. A candles toggle shows either way. */
|
|
56
66
|
candles?: boolean;
|
|
57
67
|
/** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
|
package/dist/chart.js
CHANGED
|
@@ -48,8 +48,8 @@ function bucketStart(time, step) {
|
|
|
48
48
|
/**
|
|
49
49
|
* Points, oldest first, one per step. A price keeps the last value in each
|
|
50
50
|
* step, as a candle does its close: the first open, the highest high, the
|
|
51
|
-
* lowest low. A count
|
|
52
|
-
*
|
|
51
|
+
* lowest low. A count is the total of its days; a rate, their mean. Volume is
|
|
52
|
+
* summed.
|
|
53
53
|
*
|
|
54
54
|
* @internal
|
|
55
55
|
*/
|
|
@@ -66,6 +66,8 @@ export function bucketChart(points, step, aggregate) {
|
|
|
66
66
|
}
|
|
67
67
|
if (aggregate === 'last')
|
|
68
68
|
last.value = p.value;
|
|
69
|
+
else if (aggregate === 'sum')
|
|
70
|
+
last.value += p.value;
|
|
69
71
|
else
|
|
70
72
|
last.value += (p.value - last.value) / ++n;
|
|
71
73
|
if (last.high !== undefined && p.high !== undefined)
|
|
@@ -103,10 +105,25 @@ export function placeMarkers(markers, shown, step) {
|
|
|
103
105
|
return [...placed].map(([time, labels]) => ({ time, label: labels.join(' · ') }));
|
|
104
106
|
}
|
|
105
107
|
const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
|
|
108
|
+
/** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
|
|
109
|
+
export function stepDecimals(step) {
|
|
110
|
+
const exponent = Math.floor(Math.log10(step));
|
|
111
|
+
// The axis steps by 1, 2, 2.5 or 5 times a power of ten; rounding drops float noise.
|
|
112
|
+
const mantissa = String(Math.round((step / 10 ** exponent) * 1e6) / 1e6);
|
|
113
|
+
return Math.min(20, Math.max(0, (mantissa.split('.')[1]?.length ?? 0) - exponent));
|
|
114
|
+
}
|
|
106
115
|
const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
|
|
107
116
|
const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
|
|
108
|
-
/**
|
|
109
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Compact from a thousand up, four significant figures below. An axis tick is
|
|
119
|
+
* written to its step in the unit it reads in, so 4.845B is not a second 4.85B.
|
|
120
|
+
*/
|
|
121
|
+
export function formatChartValue(n, step) {
|
|
122
|
+
if (step === undefined)
|
|
123
|
+
return Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n);
|
|
124
|
+
const unit = Math.abs(n) >= 1e3 ? 1e3 ** Math.min(4, Math.floor(Math.log10(Math.abs(n)) / 3)) : 1;
|
|
125
|
+
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: stepDecimals(step / unit) }).format(n);
|
|
126
|
+
}
|
|
110
127
|
/**
|
|
111
128
|
* A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
|
|
112
129
|
* a pixel and read back, because a site's colour may be oklch() or a named one.
|
|
@@ -119,12 +136,16 @@ function rgba(colour, alpha = 1) {
|
|
|
119
136
|
ctx.fillRect(0, 0, 1, 1);
|
|
120
137
|
return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
|
|
121
138
|
}
|
|
139
|
+
// A count's axis starts at zero and stops there: the scale's bottom margin is off and
|
|
140
|
+
// a few pixels below zero hold its label, where a margin would draw negative ticks.
|
|
122
141
|
const fromZeroScale = (base) => {
|
|
123
142
|
const info = base();
|
|
124
143
|
if (info?.priceRange)
|
|
125
144
|
info.priceRange.minValue = 0;
|
|
126
|
-
return info;
|
|
145
|
+
return info && { ...info, margins: { above: info.margins?.above ?? 0, below: 8 } };
|
|
127
146
|
};
|
|
147
|
+
const ownScale = (base) => base();
|
|
148
|
+
const MARGIN_TOP = 0.15;
|
|
128
149
|
/**
|
|
129
150
|
* A time series with its range, step and style buttons. The buttons are
|
|
130
151
|
* `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
|
|
@@ -156,7 +177,17 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
156
177
|
const ink = rgba(style.color);
|
|
157
178
|
const up = rgba(style.getPropertyValue('--chart-up').trim() || style.color);
|
|
158
179
|
const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
|
|
159
|
-
const priceFormat = {
|
|
180
|
+
const priceFormat = {
|
|
181
|
+
type: 'custom',
|
|
182
|
+
formatter: (n) => formatRef.current(n),
|
|
183
|
+
// Ticks are evenly spaced, so the gap between any two is the step. Each is snapped to
|
|
184
|
+
// it, since the library's zero can arrive as -1e-18.
|
|
185
|
+
tickmarksFormatter: (ticks) => {
|
|
186
|
+
const step = ticks.length > 1 ? Math.abs(ticks[0] - ticks[1]) : 0;
|
|
187
|
+
return ticks.map(n => (step ? formatRef.current(Math.round(n / step) * step, step) : formatRef.current(n)));
|
|
188
|
+
},
|
|
189
|
+
minMove: 1e-12,
|
|
190
|
+
};
|
|
160
191
|
const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
|
|
161
192
|
const grid = rgba(axis || style.color, 0.1);
|
|
162
193
|
const api = createChart(el, {
|
|
@@ -171,8 +202,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
171
202
|
},
|
|
172
203
|
grid: { vertLines: { color: grid }, horzLines: { color: grid } },
|
|
173
204
|
crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
|
|
174
|
-
rightPriceScale: { borderVisible: false
|
|
175
|
-
|
|
205
|
+
rightPriceScale: { borderVisible: false },
|
|
206
|
+
// The value axis settles its width after the points are fitted; the range holds through it.
|
|
207
|
+
timeScale: { borderVisible: false, secondsVisible: false, lockVisibleTimeRangeOnResize: true },
|
|
176
208
|
handleScroll: false,
|
|
177
209
|
handleScale: false,
|
|
178
210
|
});
|
|
@@ -181,11 +213,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
181
213
|
topColor: rgba(style.color, 0.4),
|
|
182
214
|
bottomColor: rgba(style.color, 0.02),
|
|
183
215
|
lineWidth: 2,
|
|
184
|
-
lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
|
|
185
216
|
crosshairMarkerBackgroundColor: ink,
|
|
186
217
|
crosshairMarkerBorderColor: ink,
|
|
187
218
|
priceFormat,
|
|
188
|
-
...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
|
|
189
219
|
});
|
|
190
220
|
const candle = api.addSeries(CandlestickSeries, {
|
|
191
221
|
visible: false,
|
|
@@ -209,6 +239,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
209
239
|
};
|
|
210
240
|
chart.current = {
|
|
211
241
|
api, area, candle, volume: null, addVolume, up, down, ink,
|
|
242
|
+
curved: LineType.Curved, straight: LineType.Simple,
|
|
212
243
|
areaMarkers: createSeriesMarkers(area, []),
|
|
213
244
|
candleMarkers: createSeriesMarkers(candle, []),
|
|
214
245
|
};
|
|
@@ -220,7 +251,15 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
220
251
|
chart.current = null;
|
|
221
252
|
setReady(false);
|
|
222
253
|
};
|
|
223
|
-
}, [
|
|
254
|
+
}, []);
|
|
255
|
+
// Applied to the chart in place: rebuilding it would leave a new chart with nothing drawn on it.
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const c = chart.current;
|
|
258
|
+
if (!ready || !c)
|
|
259
|
+
return;
|
|
260
|
+
c.api.priceScale('right').applyOptions({ scaleMargins: { top: MARGIN_TOP, bottom: fromZero ? 0 : 0.1 } });
|
|
261
|
+
c.area.applyOptions({ lineType: aggregate === 'last' ? c.straight : c.curved, autoscaleInfoProvider: fromZero ? fromZeroScale : ownScale });
|
|
262
|
+
}, [ready, fromZero, aggregate]);
|
|
224
263
|
// One request per resolution for the life of the loader: every range from 30D up reads the same days.
|
|
225
264
|
const cache = useRef(new Map());
|
|
226
265
|
useEffect(() => {
|
|
@@ -261,7 +300,11 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
261
300
|
return drawn(windowed, [], null);
|
|
262
301
|
const { steps, fallback } = chartSteps(end - windowed[0].time);
|
|
263
302
|
const chosen = step && steps.includes(step) ? step : fallback;
|
|
264
|
-
|
|
303
|
+
if (chosen === 'day')
|
|
304
|
+
return drawn(windowed, steps, chosen);
|
|
305
|
+
// A stepped range starts on a whole week or month, so its first total is not a few days of one.
|
|
306
|
+
const from = bucketStart(windowed[0].time, chosen);
|
|
307
|
+
return drawn(bucketChart(points.filter(p => p.time >= from), chosen, aggregate), steps, chosen);
|
|
265
308
|
}, [points, range, ranges.length, resolution, step, aggregate]);
|
|
266
309
|
// While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
|
|
267
310
|
useEffect(() => {
|
package/dist/search.d.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Every `text` value at any block depth, as prose.
|
|
3
3
|
*
|
|
4
4
|
* `$.**.text` rather than the raw JSON, so block ids and `type` discriminators
|
|
5
|
-
* cannot score as prose.
|
|
6
|
-
*
|
|
5
|
+
* cannot score as prose. In strict mode: lax, Postgres' default, unwraps each
|
|
6
|
+
* array `.**` meets and visits its elements twice, so every value came out
|
|
7
|
+
* twice — measured on all 624 miow pages in October 2026, doubling snippets and
|
|
8
|
+
* every lexeme in `search_tsv`. `silent` keeps strict from raising on items
|
|
9
|
+
* with no `text`. Then three classes of non-prose are collapsed to spaces:
|
|
7
10
|
*
|
|
8
11
|
* - markup (`<[^>]*>`) and the ` ` entity
|
|
9
12
|
* - the JSON array literal's own syntax — `", "` between adjacent values,
|
|
@@ -32,6 +35,9 @@ export declare function proseSql(content?: string): string;
|
|
|
32
35
|
* rebuilds. The column is derived from `content`, so dropping it loses nothing.
|
|
33
36
|
* Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
|
|
34
37
|
* a generated column it does not know about as drift and drops it.
|
|
38
|
+
*
|
|
39
|
+
* @param table the table as SQL names it: `pages`, or `"WikiPage"` for a
|
|
40
|
+
* PascalCase Prisma model, whose index is `"WikiPage_search_tsv_idx"`.
|
|
35
41
|
*/
|
|
36
42
|
export declare function searchTsvDdl(table: string, { content, title }?: {
|
|
37
43
|
content?: string;
|
package/dist/search.js
CHANGED
|
@@ -25,8 +25,11 @@
|
|
|
25
25
|
* Every `text` value at any block depth, as prose.
|
|
26
26
|
*
|
|
27
27
|
* `$.**.text` rather than the raw JSON, so block ids and `type` discriminators
|
|
28
|
-
* cannot score as prose.
|
|
29
|
-
*
|
|
28
|
+
* cannot score as prose. In strict mode: lax, Postgres' default, unwraps each
|
|
29
|
+
* array `.**` meets and visits its elements twice, so every value came out
|
|
30
|
+
* twice — measured on all 624 miow pages in October 2026, doubling snippets and
|
|
31
|
+
* every lexeme in `search_tsv`. `silent` keeps strict from raising on items
|
|
32
|
+
* with no `text`. Then three classes of non-prose are collapsed to spaces:
|
|
30
33
|
*
|
|
31
34
|
* - markup (`<[^>]*>`) and the ` ` entity
|
|
32
35
|
* - the JSON array literal's own syntax — `", "` between adjacent values,
|
|
@@ -43,7 +46,7 @@
|
|
|
43
46
|
* when the query aliases its table.
|
|
44
47
|
*/
|
|
45
48
|
export function proseSql(content = 'content') {
|
|
46
|
-
return `regexp_replace(translate(jsonb_path_query_array(${content},'$.**.text')::text, chr(160),' '),'${PROSE_PATTERN}',' ','g')`;
|
|
49
|
+
return `regexp_replace(translate(jsonb_path_query_array(${content},'strict $.**.text','{}',true)::text, chr(160),' '),'${PROSE_PATTERN}',' ','g')`;
|
|
47
50
|
}
|
|
48
51
|
/**
|
|
49
52
|
* The `regexp_replace` pattern inside {@link proseSql}, as it must appear in
|
|
@@ -76,6 +79,9 @@ export function searchTsvSql(content = 'content', title = 'title') {
|
|
|
76
79
|
* rebuilds. The column is derived from `content`, so dropping it loses nothing.
|
|
77
80
|
* Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
|
|
78
81
|
* a generated column it does not know about as drift and drops it.
|
|
82
|
+
*
|
|
83
|
+
* @param table the table as SQL names it: `pages`, or `"WikiPage"` for a
|
|
84
|
+
* PascalCase Prisma model, whose index is `"WikiPage_search_tsv_idx"`.
|
|
79
85
|
*/
|
|
80
86
|
export function searchTsvDdl(table, { content = 'content', title = 'title' } = {}) {
|
|
81
87
|
return {
|
|
@@ -83,7 +89,7 @@ export function searchTsvDdl(table, { content = 'content', title = 'title' } = {
|
|
|
83
89
|
`ALTER TABLE ${table} DROP COLUMN IF EXISTS search_tsv`,
|
|
84
90
|
`ALTER TABLE ${table} ADD COLUMN search_tsv tsvector GENERATED ALWAYS AS (${searchTsvSql(content, title)}) STORED`,
|
|
85
91
|
],
|
|
86
|
-
index: `CREATE INDEX IF NOT EXISTS ${table}_search_tsv_idx ON ${table} USING GIN (search_tsv)`,
|
|
92
|
+
index: `CREATE INDEX IF NOT EXISTS "${table.replaceAll('"', '')}_search_tsv_idx" ON ${table} USING GIN (search_tsv)`,
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
/**
|
package/dist/stats-figures.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// stats-figures.tsx — the stats page's headline figures and the chart under
|
|
4
|
-
// them. Each figure is a button that charts that figure
|
|
4
|
+
// them. Each figure is a button that charts that figure by day, week or month, so the chart
|
|
5
5
|
// is not only visitors. Its own module because it holds state; `Stats` stays a
|
|
6
6
|
// server component and hands it the digest, which is plain JSON.
|
|
7
7
|
import { useMemo, useState } from 'react';
|
|
8
|
-
import { TimeChart } from './chart.js';
|
|
8
|
+
import { TimeChart, stepDecimals } from './chart.js';
|
|
9
9
|
const count = (n) => n.toLocaleString('en');
|
|
10
|
-
|
|
11
|
-
const
|
|
10
|
+
// Whole seconds and percentages, unless an axis tick's step needs more to tell it from the next.
|
|
11
|
+
const places = (step) => (step ? stepDecimals(step) : 0);
|
|
12
|
+
const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
|
|
13
|
+
const percent = (n, step) => `${n.toFixed(places(step))}%`;
|
|
12
14
|
/**
|
|
13
15
|
* The change from the window before: a share of the figure before, or for a
|
|
14
16
|
* rate its difference in points, since a bounce rate up 10% of itself reads
|
|
@@ -32,6 +34,7 @@ const figuresOf = ({ previous: p, ...d }) => [
|
|
|
32
34
|
delta: change(d.bounce_rate, p.bounce_rate, { points: true, lowerIsBetter: true }),
|
|
33
35
|
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
34
36
|
format: percent,
|
|
37
|
+
aggregate: 'mean',
|
|
35
38
|
},
|
|
36
39
|
{
|
|
37
40
|
label: 'Visit length',
|
|
@@ -39,6 +42,7 @@ const figuresOf = ({ previous: p, ...d }) => [
|
|
|
39
42
|
delta: change(d.visit_duration, p.visit_duration),
|
|
40
43
|
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
41
44
|
format: duration,
|
|
45
|
+
aggregate: 'mean',
|
|
42
46
|
},
|
|
43
47
|
...(d.follow_clicks
|
|
44
48
|
? [
|
|
@@ -72,5 +76,5 @@ export function StatsFigures({ digest, first, windows }) {
|
|
|
72
76
|
return value == null ? null : { time, value };
|
|
73
77
|
}).filter(p => p !== null);
|
|
74
78
|
}, [byDay, first, figure]);
|
|
75
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label:
|
|
79
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
|
|
76
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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",
|