wiki-formant 0.56.0 → 0.57.1

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
@@ -916,9 +916,15 @@ straight between closes and a count's is curved.
916
916
 
917
917
  `aggregate` is how a week or month is made from its days: `last` for a price,
918
918
  `mean` for a count, so the week in progress keeps its level instead of reading
919
- as a collapse. A single range draws no range buttons and shows every point.
919
+ as a collapse. `fromZero` starts a count's axis at zero and stops it there, with
920
+ no negative ticks under it. A single range draws no range buttons and shows every point.
920
921
  `chartSteps` is exported for a chart drawn elsewhere.
921
922
 
923
+ `format` writes the axis and the crosshair label. An axis tick also gets the
924
+ spacing between ticks as a second argument, so a formatter can write every tick
925
+ to the same precision and two never read alike: `stepDecimals(step)` is the
926
+ decimal places that takes, and `formatChartValue`, the default, uses it.
927
+
922
928
  `markers` are events, `{ time, label }`, each drawn as an arrow on the last
923
929
  point at or before it, so one mid-week lands on its week and two on one point
924
930
  share it. `step` opens on a step where the
@@ -1021,10 +1027,11 @@ An app that keeps one schema file and writes the models into it by hand, because
1021
1027
  npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
1022
1028
  ```
1023
1029
 
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.
1030
+ `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
1031
 
1026
1032
  ```sh
1027
1033
  npx fts-ddl pages
1034
+ npx fts-ddl '"WikiPage"'
1028
1035
  ```
1029
1036
 
1030
1037
  `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_]*$/.test(table)) {
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='${table}' and column_name='search_tsv'`));
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('${table}_search_tsv_idx')) index_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
@@ -35,8 +35,13 @@ export declare function chartSteps(span: number): {
35
35
  steps: ChartStep[];
36
36
  fallback: ChartStep;
37
37
  };
38
- /** Compact from a thousand up, four significant figures below. */
39
- export declare const formatChartValue: (n: number) => string;
38
+ /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
39
+ export declare function stepDecimals(step: number): number;
40
+ /**
41
+ * Compact from a thousand up, four significant figures below. An axis tick is
42
+ * written to its step in the unit it reads in, so 4.845B is not a second 4.85B.
43
+ */
44
+ export declare function formatChartValue(n: number, step?: number): string;
40
45
  export interface TimeChartProps {
41
46
  /** Daily points, oldest first, or a loader for every resolution the ranges need. */
42
47
  series: readonly ChartPoint[] | ChartLoader;
@@ -50,8 +55,11 @@ export interface TimeChartProps {
50
55
  aggregate?: 'last' | 'mean';
51
56
  /** The value axis starts at zero, so a quiet week does not read as a collapse. */
52
57
  fromZero?: boolean;
53
- /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
54
- format?: (n: number) => string;
58
+ /**
59
+ * The value axis and crosshair label. Read on every draw, so it need not be stable.
60
+ * An axis tick passes the spacing between ticks, so neighbours never read alike.
61
+ */
62
+ format?: (n: number, step?: number) => string;
55
63
  /** Open on candles, where the points carry them. A candles toggle shows either way. */
56
64
  candles?: boolean;
57
65
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
package/dist/chart.js CHANGED
@@ -103,10 +103,25 @@ export function placeMarkers(markers, shown, step) {
103
103
  return [...placed].map(([time, labels]) => ({ time, label: labels.join(' · ') }));
104
104
  }
105
105
  const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
106
+ /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
107
+ export function stepDecimals(step) {
108
+ const exponent = Math.floor(Math.log10(step));
109
+ // The axis steps by 1, 2, 2.5 or 5 times a power of ten; rounding drops float noise.
110
+ const mantissa = String(Math.round((step / 10 ** exponent) * 1e6) / 1e6);
111
+ return Math.min(20, Math.max(0, (mantissa.split('.')[1]?.length ?? 0) - exponent));
112
+ }
106
113
  const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
107
114
  const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
108
- /** Compact from a thousand up, four significant figures below. */
109
- export const formatChartValue = (n) => (Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n));
115
+ /**
116
+ * Compact from a thousand up, four significant figures below. An axis tick is
117
+ * written to its step in the unit it reads in, so 4.845B is not a second 4.85B.
118
+ */
119
+ export function formatChartValue(n, step) {
120
+ if (step === undefined)
121
+ return Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n);
122
+ const unit = Math.abs(n) >= 1e3 ? 1e3 ** Math.min(4, Math.floor(Math.log10(Math.abs(n)) / 3)) : 1;
123
+ return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: stepDecimals(step / unit) }).format(n);
124
+ }
110
125
  /**
111
126
  * A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
112
127
  * a pixel and read back, because a site's colour may be oklch() or a named one.
@@ -119,12 +134,16 @@ function rgba(colour, alpha = 1) {
119
134
  ctx.fillRect(0, 0, 1, 1);
120
135
  return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
121
136
  }
137
+ // A count's axis starts at zero and stops there: the scale's bottom margin is off and
138
+ // a few pixels below zero hold its label, where a margin would draw negative ticks.
122
139
  const fromZeroScale = (base) => {
123
140
  const info = base();
124
141
  if (info?.priceRange)
125
142
  info.priceRange.minValue = 0;
126
- return info;
143
+ return info && { ...info, margins: { above: info.margins?.above ?? 0, below: 8 } };
127
144
  };
145
+ const ownScale = (base) => base();
146
+ const MARGIN_TOP = 0.15;
128
147
  /**
129
148
  * A time series with its range, step and style buttons. The buttons are
130
149
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
@@ -156,7 +175,17 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
156
175
  const ink = rgba(style.color);
157
176
  const up = rgba(style.getPropertyValue('--chart-up').trim() || style.color);
158
177
  const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
159
- const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
178
+ const priceFormat = {
179
+ type: 'custom',
180
+ formatter: (n) => formatRef.current(n),
181
+ // Ticks are evenly spaced, so the gap between any two is the step. Each is snapped to
182
+ // it, since the library's zero can arrive as -1e-18.
183
+ tickmarksFormatter: (ticks) => {
184
+ const step = ticks.length > 1 ? Math.abs(ticks[0] - ticks[1]) : 0;
185
+ return ticks.map(n => (step ? formatRef.current(Math.round(n / step) * step, step) : formatRef.current(n)));
186
+ },
187
+ minMove: 1e-12,
188
+ };
160
189
  const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
161
190
  const grid = rgba(axis || style.color, 0.1);
162
191
  const api = createChart(el, {
@@ -171,8 +200,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
171
200
  },
172
201
  grid: { vertLines: { color: grid }, horzLines: { color: grid } },
173
202
  crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
174
- rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
175
- timeScale: { borderVisible: false, secondsVisible: false },
203
+ rightPriceScale: { borderVisible: false },
204
+ // The value axis settles its width after the points are fitted; the range holds through it.
205
+ timeScale: { borderVisible: false, secondsVisible: false, lockVisibleTimeRangeOnResize: true },
176
206
  handleScroll: false,
177
207
  handleScale: false,
178
208
  });
@@ -181,11 +211,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
181
211
  topColor: rgba(style.color, 0.4),
182
212
  bottomColor: rgba(style.color, 0.02),
183
213
  lineWidth: 2,
184
- lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
185
214
  crosshairMarkerBackgroundColor: ink,
186
215
  crosshairMarkerBorderColor: ink,
187
216
  priceFormat,
188
- ...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
189
217
  });
190
218
  const candle = api.addSeries(CandlestickSeries, {
191
219
  visible: false,
@@ -209,6 +237,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
209
237
  };
210
238
  chart.current = {
211
239
  api, area, candle, volume: null, addVolume, up, down, ink,
240
+ curved: LineType.Curved, straight: LineType.Simple,
212
241
  areaMarkers: createSeriesMarkers(area, []),
213
242
  candleMarkers: createSeriesMarkers(candle, []),
214
243
  };
@@ -220,7 +249,15 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
220
249
  chart.current = null;
221
250
  setReady(false);
222
251
  };
223
- }, [fromZero, aggregate]);
252
+ }, []);
253
+ // Applied to the chart in place: rebuilding it would leave a new chart with nothing drawn on it.
254
+ useEffect(() => {
255
+ const c = chart.current;
256
+ if (!ready || !c)
257
+ return;
258
+ c.api.priceScale('right').applyOptions({ scaleMargins: { top: MARGIN_TOP, bottom: fromZero ? 0 : 0.1 } });
259
+ c.area.applyOptions({ lineType: aggregate === 'mean' ? c.curved : c.straight, autoscaleInfoProvider: fromZero ? fromZeroScale : ownScale });
260
+ }, [ready, fromZero, aggregate]);
224
261
  // One request per resolution for the life of the loader: every range from 30D up reads the same days.
225
262
  const cache = useRef(new Map());
226
263
  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. Then three classes of non-prose are collapsed to
6
- * spaces:
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 `&nbsp;` 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. Then three classes of non-prose are collapsed to
29
- * spaces:
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 `&nbsp;` 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
  /**
@@ -5,10 +5,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
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
- const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
11
- const percent = (n) => `${Math.round(n)}%`;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
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",