wiki-formant 0.98.1 → 0.100.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
@@ -8,6 +8,8 @@ The editor, history, sanitiser, chart and passkey modules bring their libraries
8
8
  npm install wiki-formant
9
9
  ```
10
10
 
11
+ An agent can start a wiki for you, hosted on miow.me or in your own app, with the skill in `skills/wiki-formant`: `npx skills add tutmoses/wiki-formant`.
12
+
11
13
  What stays the app's: its design system, its block types beyond the core, its connection, cache and auth. Everything else here is shared.
12
14
 
13
15
  ## API
@@ -601,6 +603,7 @@ const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
601
603
  - The default list is what `wiki-formant/tiptap`'s nodes store plus a presentational SVG subset. Extend `tags`, `attributes`, `classes` and `schemesByTag` from your stored HTML, never from memory.
602
604
  - A YouTube iframe comes out as the `YouTube` node's markup, whoever wrote it, and `base.css` draws it as a 16:9 player.
603
605
  - Classes pass by name only, since a site's own utilities can draw over the chrome.
606
+ - `sanitizeLine(html, { links })` cleans what a `WikiLine` stores: its marks and `wikiTextStyle`'s spans. `sanitizeCoreLeaf` runs it over the core leaves' one-line fields (a figure, a quote and who said it, the events' and places' headings, a place's name), which `block-views` draws as HTML.
604
607
 
605
608
  ## Image uploads
606
609
 
@@ -665,7 +668,7 @@ const url = useUrlPrompt(editor, { resolveMapUrl });
665
668
 
666
669
  - `WikiToolbar` shows each command the editor's extensions support (`supports`) and nothing else, in fixed groups, with the table controls while the cursor is in a table.
667
670
  - `useUrlPrompt` is the link and embed field: it opens on the current link, unlinks on an emptied one, and closes when its editor changes.
668
- - `WikiLine` is a one-line field with its own `marks`, undo and `onFocus`, so one toolbar can drive the field last touched.
671
+ - `WikiLine` is a one-line field with its own `marks`, undo and `onFocus`, so one toolbar can drive the field last touched. Its `style` mark is `wikiTextStyle`: a font, colour and size on a run of words, which an article editor takes through `nodes` and an app's own controls set with `setTextStyle`.
669
672
  - A link is stored as its `href` and the `link` class only; whether it is external is decided at render by `articleHtml`'s `links` pass. `uploadImageTo('/api/upload')` is the `uploadImage` an editor passes.
670
673
 
671
674
  ## Block editor
@@ -78,10 +78,18 @@ export interface DigestOptions {
78
78
  ownAgents?: string[];
79
79
  /**
80
80
  * Narrows every figure to the visits that pass, and the events to their
81
- * visitors. `ViewDay` keeps no visits, so a narrowed digest leaves it out
82
- * and starts at `counted_from`, and has no `previous_visitors`.
81
+ * visitors; an agent passes as `AGENT_TEST` has it. `ViewDay` keeps no
82
+ * visits, so a narrowed digest leaves it out and starts at `counted_from`,
83
+ * and has no `previous_visitors`.
83
84
  */
84
85
  filter?: StatsFilter;
86
+ /**
87
+ * People and agents counted apart, as they are by default: the ranked lists
88
+ * count people. False counts agents in the pages, entry pages, sources,
89
+ * countries and devices too, as `visitors_incl_agents` counts them, so a
90
+ * stats page's lists add up to the visitors its chart draws.
91
+ */
92
+ apart?: boolean;
85
93
  }
86
94
  type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
87
95
  /** One UTC day of the figures a digest totals; a visit falls on the day it began. */
@@ -160,7 +168,7 @@ export interface Digest {
160
168
  * The window before is the same chain of views, visits and events a second
161
169
  * time, `_p` on every name, so its figures are the headline's to the letter.
162
170
  */
163
- export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
171
+ export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter, apart }: DigestOptions): Promise<Digest>;
164
172
  /**
165
173
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
166
174
  *
package/dist/analytics.js CHANGED
@@ -109,7 +109,9 @@ export function recordEvent(sql, { name, props = {}, url, headers, site = '' })
109
109
  let path = null;
110
110
  if (url)
111
111
  path = normalisePath(new URL(url, 'http://x').pathname);
112
- return sql('INSERT INTO "Event" ("siteId", name, path, props, visitor) VALUES ($1, $2, $3, $4::jsonb, $5)', site, name.slice(0, 64), path, JSON.stringify(props), headers?.get('user-agent') ? await visitorOf(sql, site, headers) : null);
112
+ // An agent's country is where its requests came from, so the stats page's countries can count it.
113
+ const country = headers?.get('x-vercel-ip-country')?.slice(0, 2);
114
+ return sql('INSERT INTO "Event" ("siteId", name, path, props, visitor) VALUES ($1, $2, $3, $4::jsonb, $5)', site, name.slice(0, 64), path, JSON.stringify(country ? { ...props, country } : props), headers?.get('user-agent') ? await visitorOf(sql, site, headers) : null);
113
115
  });
114
116
  }
115
117
  /** A browser-sent event's props: eight at most, each value a short string or a number. */
@@ -166,6 +168,21 @@ const VISIT_TEST = {
166
168
  country: v => `bool_or(country = ${v})`,
167
169
  device: v => `bool_or(device = ${v})`,
168
170
  };
171
+ /** An agent's source and device in the lists, which no request of its carries. */
172
+ const AGENT_SOURCE = '(agent)';
173
+ const AGENT_DEVICE = 'agent';
174
+ /**
175
+ * Each filter as a test on one agent: its reads in the window are its visit,
176
+ * the first its entry page. Its source and device are the two above, and its
177
+ * country the one its requests came from, where they were counted with one.
178
+ */
179
+ const AGENT_TEST = {
180
+ page: v => `bool_or(path = ${v})`,
181
+ entry: v => `(array_agg(path ORDER BY at))[1] = ${v}`,
182
+ source: v => `${v} = '${AGENT_SOURCE}'`,
183
+ country: v => `bool_or(country = ${v})`,
184
+ device: v => `${v} = '${AGENT_DEVICE}'`,
185
+ };
169
186
  /**
170
187
  * An agent's name from its event: the roster's label, else what its user agent
171
188
  * calls itself, `compatible; X` or the first product token.
@@ -180,7 +197,7 @@ const AGENT_NAME = `coalesce(props->>'bot', substring(props->>'ua' from 'compati
180
197
  * The window before is the same chain of views, visits and events a second
181
198
  * time, `_p` on every name, so its figures are the headline's to the letter.
182
199
  */
183
- export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
200
+ export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {}, apart = true }) {
184
201
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
185
202
  // All time is a century: further back than any row, and still an int to Postgres.
186
203
  const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
@@ -191,6 +208,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
191
208
  /** Clicks on links to the site's own X account, among `e`. */
192
209
  const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
193
210
  const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
211
+ const agentTests = STATS_FILTERS.flatMap(k => (filter[k] ? [AGENT_TEST[k]($(filter[k]))] : []));
212
+ /** Agents among the rows of a ranked list, unless they are counted apart. */
213
+ const agentsToo = (select) => (apart ? '' : ` UNION ALL ${select}`);
194
214
  const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
195
215
  /** The site's own agents, as a test on an event's user agent. */
196
216
  const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
@@ -220,16 +240,20 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
220
240
  FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
221
241
  ), v${s} AS (
222
242
  ${tests.length ? `SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered${s} n JOIN visits${s} USING (visitor, visit)` : `SELECT * FROM viewed${s}`}
243
+ ), read${s} AS (
244
+ SELECT visitor, "createdAt" AS at, path, props->>'country' AS country, ${AGENT_NAME} AS agent FROM "Event"
245
+ WHERE "siteId" = $1 AND ${at} AND name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
246
+ ), ag${s} AS (
247
+ SELECT * FROM read${s}${agentTests.length ? ` WHERE visitor IN (SELECT visitor FROM read${s} GROUP BY 1 HAVING ${agentTests.join(' AND ')})` : ''}
223
248
  ), e${s} AS (
224
249
  SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
225
- ${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
250
+ ${tests.length ? `AND (visitor IN (SELECT visitor FROM v${s}) OR visitor IN (SELECT visitor FROM ag${s}))` : ''}
251
+ ), acted${s} AS MATERIALIZED (
252
+ SELECT visitor, at FROM e${s} WHERE name NOT IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL
226
253
  ), bounced${s} AS (
254
+ -- Against the few reader events alone: probing every agent event once per visit took seconds.
227
255
  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')
230
- ), ag${s} AS (
231
- SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
232
- WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
256
+ SELECT 1 FROM acted${s} x WHERE x.visitor = b.visitor AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
233
257
  ), a${s} AS (
234
258
  SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
235
259
  ), sessions${s} AS (
@@ -250,19 +274,19 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
250
274
  'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM ag${s}) u) + (SELECT visitors FROM archived${s})`;
251
275
  const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
252
276
  SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
253
- UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
277
+ UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)${agentsToo(`SELECT '${AGENT_SOURCE}', count(DISTINCT visitor) FROM ag HAVING count(*) > 0`)}
254
278
  )
255
279
  SELECT json_build_object(
256
280
  ${headline('')},
257
281
  'previous', json_build_object(${headline('_p')}, 'follow_clicks', ${follows ? `(SELECT count(*) FROM e_p WHERE ${follows})` : 'NULL'}),
258
282
  'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
259
283
  'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
260
- 'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
261
- 'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
284
+ 'top_pages', ${list(merged('page', 'pages', `SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1${agentsToo('SELECT path, count(DISTINCT visitor) FROM ag WHERE path IS NOT NULL GROUP BY 1')}`), 6)},
285
+ 'entry_pages', ${list(merged('page', 'entryPages', `SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1${agentsToo('SELECT entry, count(*) FROM (SELECT (array_agg(path ORDER BY at))[1] AS entry FROM ag GROUP BY visitor) f WHERE entry IS NOT NULL GROUP BY 1')}`), 6)},
262
286
  'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
263
287
  'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
264
- 'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
265
- 'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
288
+ 'countries', ${list(merged('country', 'countries', `SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1${agentsToo('SELECT country, count(DISTINCT visitor) FROM ag WHERE country IS NOT NULL GROUP BY 1')}`), 10)},
289
+ 'devices', ${list(merged('device', 'devices', `SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1${agentsToo(`SELECT '${AGENT_DEVICE}', count(DISTINCT visitor) FROM ag HAVING count(*) > 0`)}`))},
266
290
  'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
267
291
  sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
268
292
  SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
@@ -23,7 +23,9 @@ import { clock, DEFAULT_ZONE, hoursRows, mapEmbed, mapHref, openState } from './
23
23
  import { Anchor } from './react-server.js';
24
24
  import { safeLinkHref } from './validation.js';
25
25
  import { cx } from './html.js';
26
- import { BANNER_LABELS, BANNER_MESSAGES, bannerVariant } from './text.js';
26
+ import { BANNER_LABELS, BANNER_MESSAGES, bannerVariant, plainText } from './text.js';
27
+ /** A one-line field as the HTML it holds, cleaned by `sanitizeCoreLeaf` (`sanitizeLine`) on its way here. */
28
+ const line = (html) => ({ dangerouslySetInnerHTML: { __html: html } });
27
29
  /**
28
30
  * Tabbed code samples. Every tab's body stays mounted and the inactive ones are
29
31
  * hidden rather than unmounted: remounting a highlighted panel would re-run
@@ -115,14 +117,14 @@ export function StatsView({ items, columns = 4, }) {
115
117
  const rows = items ?? [];
116
118
  if (!rows.length)
117
119
  return null;
118
- return (_jsx("div", { className: cx('stat-grid', `stat-grid-${columns}`), children: rows.map((item, i) => (_jsxs("div", { className: "stat-card", children: [_jsxs("span", { className: "stat-value", children: [item.value, item.suffix && _jsx("span", { className: "stat-suffix", children: item.suffix })] }), _jsx("span", { className: "stat-label", children: item.label })] }, item.id ?? i))) }));
120
+ return (_jsx("div", { className: cx('stat-grid', `stat-grid-${columns}`), children: rows.map((item, i) => (_jsxs("div", { className: "stat-card", children: [_jsxs("span", { className: "stat-value", children: [_jsx("span", { ...line(String(item.value)) }), item.suffix && _jsx("span", { className: "stat-suffix", children: item.suffix })] }), _jsx("span", { className: "stat-label", ...line(item.label) })] }, item.id ?? i))) }));
119
121
  }
120
122
  /** Someone's words over who said them, with their photo when there is one. */
121
123
  export function TestimonialView({ quote, author, role, avatarUrl, photo }) {
122
124
  if (!quote.trim())
123
125
  return null;
124
126
  const src = avatarUrl && safeLinkHref(avatarUrl);
125
- return (_jsxs("figure", { className: "testimonial", children: [_jsx("blockquote", { children: quote }), _jsxs("figcaption", { children: [src && _jsx("img", { src: src, ...photo, alt: "", loading: "lazy" }), _jsxs("span", { children: [_jsx("strong", { children: author }), role && _jsx("small", { children: role })] })] })] }));
127
+ return (_jsxs("figure", { className: "testimonial", children: [_jsx("blockquote", { ...line(quote) }), _jsxs("figcaption", { children: [src && _jsx("img", { src: src, ...photo, alt: "", loading: "lazy" }), _jsxs("span", { children: [_jsx("strong", { ...line(author) }), role && _jsx("small", { ...line(role) })] })] })] }));
126
128
  }
127
129
  // ---- events -----------------------------------------------------------------
128
130
  const still = () => () => { };
@@ -140,7 +142,7 @@ export function EventsView({ items, upcoming, past, day }) {
140
142
  return (_jsx("section", { className: "events", children: [
141
143
  [upcoming || 'Upcoming', split.upcoming],
142
144
  [past || 'Past', split.past],
143
- ].map(([heading, rows], ended) => rows.length ? (_jsxs(Fragment, { children: [_jsx("h2", { children: heading }), _jsx("ol", { className: "event-list", children: rows.map((e, i) => (_jsxs("li", { children: [_jsx("time", { dateTime: e.date, children: eventWhen(e) }), _jsxs("span", { children: [_jsx("span", { dangerouslySetInnerHTML: { __html: e.text } }), !ended && eventBegun(e, now) && _jsx("small", { className: "event-now", children: "Now on" })] })] }, e.id ?? i))) })] }, ended)) : null) }));
145
+ ].map(([heading, rows], ended) => rows.length ? (_jsxs(Fragment, { children: [_jsx("h2", { ...line(heading) }), _jsx("ol", { className: "event-list", children: rows.map((e, i) => (_jsxs("li", { children: [_jsx("time", { dateTime: e.date, children: eventWhen(e) }), _jsxs("span", { children: [_jsx("span", { dangerouslySetInnerHTML: { __html: e.text } }), !ended && eventBegun(e, now) && _jsx("small", { className: "event-now", children: "Now on" })] })] }, e.id ?? i))) })] }, ended)) : null) }));
144
146
  }
145
147
  // ---- locations --------------------------------------------------------------
146
148
  const everyMinute = (tick) => {
@@ -169,10 +171,10 @@ export function PlaceHours({ hours, timeZone = DEFAULT_ZONE }) {
169
171
  */
170
172
  export function LocationsView({ heading, map, items }) {
171
173
  const Name = heading ? 'h3' : 'h2';
172
- return (_jsxs("section", { className: "locations", children: [heading && _jsx("h2", { children: heading }), _jsx("ul", { className: "places", children: items.map((p, i) => {
174
+ return (_jsxs("section", { className: "locations", children: [heading && _jsx("h2", { ...line(heading) }), _jsx("ul", { className: "places", children: items.map((p, i) => {
173
175
  const link = safeLinkHref(mapHref(p));
174
176
  const image = p.image && safeLinkHref(p.image);
175
- return (_jsxs("li", { className: "place", children: [image && _jsx("img", { src: image, ...p.photo, alt: "", className: "place-picture", loading: "lazy" }), p.name && _jsx(Name, { children: p.name }), p.address && _jsx("address", { children: link ? _jsx("a", { href: link, children: p.address.trim() }) : p.address.trim() }), !!p.hours?.length && _jsx(PlaceHours, { hours: p.hours, timeZone: p.timeZone }), p.note && _jsx("p", { dangerouslySetInnerHTML: { __html: p.note } }), (p.phone || p.email) && (_jsxs("p", { className: "place-contact", children: [p.phone && _jsx("a", { href: `tel:${p.phone.replace(/[^\d+]/g, '')}`, children: p.phone }), p.email && _jsx("a", { href: `mailto:${p.email}`, children: p.email })] })), map && p.address && (_jsx("iframe", { className: "place-map", src: mapEmbed({ address: p.address, map: p.map }), title: `Map of ${p.name || p.address.split('\n')[0]}`, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade" }))] }, p.id ?? i));
177
+ return (_jsxs("li", { className: "place", children: [image && _jsx("img", { src: image, ...p.photo, alt: "", className: "place-picture", loading: "lazy" }), p.name && _jsx(Name, { ...line(p.name) }), p.address && _jsx("address", { children: link ? _jsx("a", { href: link, children: p.address.trim() }) : p.address.trim() }), !!p.hours?.length && _jsx(PlaceHours, { hours: p.hours, timeZone: p.timeZone }), p.note && _jsx("p", { dangerouslySetInnerHTML: { __html: p.note } }), (p.phone || p.email) && (_jsxs("p", { className: "place-contact", children: [p.phone && _jsx("a", { href: `tel:${p.phone.replace(/[^\d+]/g, '')}`, children: p.phone }), p.email && _jsx("a", { href: `mailto:${p.email}`, children: p.email })] })), map && p.address && (_jsx("iframe", { className: "place-map", src: mapEmbed({ address: p.address, map: p.map }), title: `Map of ${(p.name && plainText(p.name)) || p.address.split('\n')[0]}`, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade" }))] }, p.id ?? i));
176
178
  }) })] }));
177
179
  }
178
180
  // ---- banner -----------------------------------------------------------------
package/dist/blocks.js CHANGED
@@ -13,7 +13,7 @@
13
13
  import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
14
14
  import { relativeTime } from './freshness.js';
15
15
  import { hoursRows, mapHref } from './hours.js';
16
- import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, referencesToText, statsToText, stripHtml, testimonialToText, } from './text.js';
16
+ import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, plainText, referencesToText, statsToText, stripHtml, testimonialToText, } from './text.js';
17
17
  // ---- the shapes the standard block types carry ------------------------------
18
18
  /**
19
19
  * The languages a code block or code tab offers, and the one a new one opens
@@ -187,11 +187,11 @@ export function referencesToMarkdown(items, title = 'References') {
187
187
  export function statsToMarkdown(items) {
188
188
  if (!items.length)
189
189
  return '';
190
- return items.map(s => `- **${s.value}${s.suffix ?? ''}** — ${s.label}`).join('\n');
190
+ return items.map(s => `- **${plainText(String(s.value))}${s.suffix ?? ''}** — ${inlineToMarkdown(s.label)}`).join('\n');
191
191
  }
192
192
  /** A quotation over its attribution. @internal */
193
193
  export function testimonialToMarkdown(quote, author, role) {
194
- return `> ${quote.trim().replace(/\n+/g, '\n> ')}\n>\n> — ${[author, role].filter(Boolean).join(', ')}`;
194
+ return `> ${quote.trim().split(/\n+/).map(inlineToMarkdown).join('\n> ')}\n>\n> — ${[author, role].filter(Boolean).map(w => inlineToMarkdown(w)).join(', ')}`;
195
195
  }
196
196
  /** Grouped links under their headings, with an optional lead paragraph. @internal */
197
197
  export function linkGridToMarkdown(groups, intro) {
@@ -258,13 +258,13 @@ function eventSections({ upcoming, past, items, day }) {
258
258
  /** Events under their headings, split by the day the twin is read, as the page is. */
259
259
  export function eventsToMarkdown(block) {
260
260
  return eventSections(block)
261
- .map(s => `## ${s.heading}\n\n${s.items.map(e => `- ${eventWhen(e)}: ${inlineToMarkdown(e.text)}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
261
+ .map(s => `## ${inlineToMarkdown(s.heading)}\n\n${s.items.map(e => `- ${eventWhen(e)}: ${inlineToMarkdown(e.text)}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
262
262
  .join('\n\n');
263
263
  }
264
264
  /** Events as prose, `12 Nov 2026: Group show, London` a line, under their headings. */
265
265
  export function eventsToText(block) {
266
266
  return eventSections(block)
267
- .map(s => `${s.heading}\n${s.items.map(e => `${eventWhen(e)}: ${stripHtml(e.text).replace(/\s+/g, ' ')}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
267
+ .map(s => `${plainText(s.heading)}\n${s.items.map(e => `${eventWhen(e)}: ${stripHtml(e.text).replace(/\s+/g, ' ')}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
268
268
  .join('\n\n');
269
269
  }
270
270
  const tel = (phone) => `tel:${phone.replace(/[^\d+]/g, '')}`;
@@ -276,7 +276,7 @@ export function locationsToMarkdown({ heading, items }) {
276
276
  const map = mapHref(p);
277
277
  const address = addressLine(p.address);
278
278
  return [
279
- p.name && `${level} ${p.name}`,
279
+ p.name && `${level} ${inlineToMarkdown(p.name)}`,
280
280
  p.image && `![](${p.image})`,
281
281
  address && (map ? `[${address}](${map})` : address),
282
282
  p.hours?.length && hoursRows(p.hours).map(r => `- ${r.label}: ${r.times}`).join('\n'),
@@ -286,12 +286,12 @@ export function locationsToMarkdown({ heading, items }) {
286
286
  .filter(Boolean)
287
287
  .join('\n\n');
288
288
  };
289
- return [heading && `## ${heading}`, ...items.map(place)].filter(Boolean).join('\n\n');
289
+ return [heading && `## ${inlineToMarkdown(heading)}`, ...items.map(place)].filter(Boolean).join('\n\n');
290
290
  }
291
291
  /** Each place as prose: its name, address, hours a row to a line, note, phone and email. */
292
292
  export function locationsToText({ heading, items }) {
293
293
  const place = (p) => [
294
- p.name,
294
+ p.name && plainText(p.name),
295
295
  addressLine(p.address),
296
296
  ...(p.hours?.length ? hoursRows(p.hours).map(r => `${r.label} ${r.times}`) : []),
297
297
  p.note && stripHtml(p.note).replace(/\s+/g, ' '),
@@ -300,7 +300,7 @@ export function locationsToText({ heading, items }) {
300
300
  ]
301
301
  .filter(Boolean)
302
302
  .join('\n');
303
- return [heading, ...items.map(place)].filter(Boolean).join('\n\n');
303
+ return [heading && plainText(heading), ...items.map(place)].filter(Boolean).join('\n\n');
304
304
  }
305
305
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
306
306
  export function linkList(items) {
package/dist/chart.js CHANGED
@@ -60,21 +60,24 @@ function bucketStart(time, step) {
60
60
  * Points, oldest first, one per step. A price keeps the last value in each
61
61
  * step, as a candle does its close: the first open, the highest high, the
62
62
  * lowest low. A count is the total of its days; a rate, their mean. Volume is
63
- * summed.
63
+ * summed. The first step sits on its first point when the points start inside
64
+ * it, so the axis never reaches back past the days that were counted.
64
65
  *
65
66
  * @internal
66
67
  */
67
68
  export function bucketChart(points, step, aggregate) {
68
69
  const out = [];
69
70
  let n = 0;
71
+ let bucket;
70
72
  for (const p of points) {
71
73
  const start = bucketStart(p.time, step);
72
- const last = out.at(-1);
73
- if (last?.time !== start) {
74
- out.push({ ...p, time: start });
74
+ if (bucket !== start) {
75
+ out.push({ ...p, time: out.length ? start : p.time });
76
+ bucket = start;
75
77
  n = 1;
76
78
  continue;
77
79
  }
80
+ const last = out.at(-1);
78
81
  if (aggregate === 'last')
79
82
  last.value = p.value;
80
83
  else if (aggregate === 'sum')
package/dist/editor.d.ts CHANGED
@@ -5,19 +5,35 @@ import { type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject }
5
5
  *
6
6
  * Each of these packages ships a `declare module '@tiptap/core'` block that adds
7
7
  * its commands to `ChainedCommands` — `toggleBold`, `insertTable`, `setLink`,
8
- * `setImage`. Because this module owns the extensions, it has to carry
8
+ * `setImage`, `removeEmptyTextStyle`. Because this module owns the extensions, it has to carry
9
9
  * the augmentation across the package boundary, and a re-exported type is what
10
10
  * makes the emitted `.d.ts` load the module that declares it. Without these
11
- * four lines every `editor.chain().focus().toggleBold()` in every consumer
11
+ * lines every `editor.chain().focus().toggleBold()` in every consumer
12
12
  * stops type-checking.
13
13
  */
14
14
  export type { StarterKitOptions } from '@tiptap/starter-kit';
15
15
  export type { LinkOptions } from '@tiptap/extension-link';
16
16
  export type { ImageOptions } from '@tiptap/extension-image';
17
17
  export type { TableOptions } from '@tiptap/extension-table';
18
+ export type { TextStyleOptions } from '@tiptap/extension-text-style';
18
19
  /** The view and the editor's type, so a consumer imports nothing from `@tiptap/*` itself. */
19
20
  export { EditorContent } from '@tiptap/react';
20
21
  export type { Editor } from '@tiptap/core';
22
+ /** What `wikiTextStyle` may set on a run of words, each one property of its span's `style`. */
23
+ export interface TextStyleAttrs {
24
+ fontFamily?: string | null;
25
+ color?: string | null;
26
+ fontSize?: string | null;
27
+ }
28
+ /**
29
+ * Tiptap's TextStyle, carrying a font, a colour and a size: a run of words
30
+ * styled apart from the block around it, the same in a line as in an article.
31
+ * Each is read back from stored HTML, so a span the sanitiser kept is edited
32
+ * as it was written. Set with `setTextStyle`, which drops an empty span.
33
+ */
34
+ export declare const wikiTextStyle: import("@tiptap/core").Mark<import("@tiptap/extension-text-style").TextStyleOptions, any>;
35
+ /** Style the selected words, or with null take a property off them; a span left with none goes. */
36
+ export declare const setTextStyle: (editor: Editor, attrs: TextStyleAttrs) => boolean;
21
37
  export interface WikiEditorExtensionOptions {
22
38
  /** Empty-document prompt. */
23
39
  placeholder?: string;
@@ -169,8 +185,8 @@ export interface WikiEditorState {
169
185
  * the buttons its extensions allow; the icons and classes are the app's.
170
186
  */
171
187
  export declare function useWikiEditor({ value, onChange, placeholder, nodes, proseClass, uploadImage, debounceMs, }: WikiEditorOptions): WikiEditorState;
172
- /** What a one-line field may keep. With none, its value is plain text. */
173
- export type LineMark = 'bold' | 'italic' | 'link';
188
+ /** What a one-line field may keep: an article's inline marks, and `wikiTextStyle`'s font, colour and size. With none, its value is plain text. */
189
+ export type LineMark = 'bold' | 'italic' | 'code' | 'link' | 'style';
174
190
  export interface WikiLineProps {
175
191
  as: 'h1' | 'h2' | 'h3' | 'p' | 'span' | 'div' | 'small' | 'figcaption';
176
192
  value: string;
package/dist/editor.js CHANGED
@@ -23,6 +23,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
23
23
  // the taxonomy or the MCP transport installs none of them.
24
24
  import StarterKit from '@tiptap/starter-kit';
25
25
  import TiptapLink from '@tiptap/extension-link';
26
+ import TextStyle from '@tiptap/extension-text-style';
26
27
  import TiptapImage from '@tiptap/extension-image';
27
28
  import TiptapTable from '@tiptap/extension-table';
28
29
  import TiptapTableRow from '@tiptap/extension-table-row';
@@ -83,6 +84,27 @@ export const wikiLink = TiptapLink.extend({
83
84
  return Object.fromEntries(Object.entries(this.parent?.() ?? {}).filter(([name]) => name !== 'target' && name !== 'rel'));
84
85
  },
85
86
  }).configure({ openOnClick: false, HTMLAttributes: { class: 'link', target: null, rel: null } });
87
+ const STYLE_CSS = { fontFamily: 'font-family', color: 'color', fontSize: 'font-size' };
88
+ /**
89
+ * Tiptap's TextStyle, carrying a font, a colour and a size: a run of words
90
+ * styled apart from the block around it, the same in a line as in an article.
91
+ * Each is read back from stored HTML, so a span the sanitiser kept is edited
92
+ * as it was written. Set with `setTextStyle`, which drops an empty span.
93
+ */
94
+ export const wikiTextStyle = TextStyle.extend({
95
+ addAttributes() {
96
+ return Object.fromEntries(Object.entries(STYLE_CSS).map(([name, css]) => [
97
+ name,
98
+ {
99
+ default: null,
100
+ parseHTML: (el) => el.style.getPropertyValue(css) || null,
101
+ renderHTML: (attrs) => (attrs[name] ? { style: `${css}: ${attrs[name]}` } : {}),
102
+ },
103
+ ]));
104
+ },
105
+ });
106
+ /** Style the selected words, or with null take a property off them; a span left with none goes. */
107
+ export const setTextStyle = (editor, attrs) => editor.chain().focus().setMark('textStyle', attrs).removeEmptyTextStyle().run();
86
108
  /**
87
109
  * The editor's extension set.
88
110
  *
@@ -514,7 +536,6 @@ export function WikiLine({ as: Tag, value, onChange, placeholder, marks = [], cl
514
536
  orderedList: false,
515
537
  listItem: false,
516
538
  codeBlock: false,
517
- code: false,
518
539
  horizontalRule: false,
519
540
  hardBreak: false,
520
541
  strike: false,
@@ -522,8 +543,10 @@ export function WikiLine({ as: Tag, value, onChange, placeholder, marks = [], cl
522
543
  gapcursor: false,
523
544
  ...(has('bold') ? {} : { bold: false }),
524
545
  ...(has('italic') ? {} : { italic: false }),
546
+ ...(has('code') ? {} : { code: false }),
525
547
  }),
526
548
  ...(has('link') ? [wikiLink] : []),
549
+ ...(has('style') ? [wikiTextStyle] : []),
527
550
  ],
528
551
  content: content(value),
529
552
  editorProps: { transformPastedHTML: pastedLine, transformPastedText: t => t.replace(/\s+/g, ' ') },
@@ -22,6 +22,8 @@ export interface WikiLinkProps {
22
22
  'aria-checked'?: boolean;
23
23
  /** The name of a link drawn as an icon. */
24
24
  'aria-label'?: string;
25
+ /** False where following the link costs the server a query, as a stats page's links do; a plain anchor ignores it. */
26
+ prefetch?: boolean;
25
27
  children?: ReactNode;
26
28
  }
27
29
  export type WikiLinkComponent = ComponentType<WikiLinkProps>;
@@ -29,7 +29,7 @@ import { formatDay } from './freshness.js';
29
29
  * Exported because `block-views` renders links too and had declared the same
30
30
  * three lines; a default that exists twice is a default that can diverge.
31
31
  */
32
- export const Anchor = (props) => _jsx("a", { ...props });
32
+ export const Anchor = ({ prefetch: _, ...props }) => _jsx("a", { ...props });
33
33
  const pagesPhrase = (n) => `${n} page${n === 1 ? '' : 's'}`;
34
34
  /**
35
35
  * The section's second axis, as pressable chips.
@@ -20,10 +20,22 @@ export interface HtmlSanitizerOptions {
20
20
  * got here, and a list written from memory erases content on the first render.
21
21
  */
22
22
  export declare function createHtmlSanitizer(options?: HtmlSanitizerOptions): (html: string) => string;
23
+ /**
24
+ * A one-line field's HTML, cleaned: bold, italic, code, a link and a styled
25
+ * span, and nothing else, so a heading or a caption drawn from it as HTML holds
26
+ * no block, picture or script. `links: false` drops a link's tag and keeps its
27
+ * words, for a field drawn inside a link already, a button's label.
28
+ */
29
+ export declare function sanitizeLine(html: string, { links }?: {
30
+ links?: boolean;
31
+ }): string;
23
32
  /**
24
33
  * The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
25
- * descriptions, `references` and `events` item text, and a place's note. Every other field those blocks
26
- * carry renders as a React text node and is escaped already.
34
+ * descriptions, `references` and `events` item text, and a place's note, by
35
+ * `clean`; and their one-line fields, a figure's value and label, a quote and
36
+ * who said it, the events' headings and a place's heading and name, by
37
+ * `sanitizeLine`. Every other field those blocks carry renders as a React text
38
+ * node and is escaped already.
27
39
  *
28
40
  * Returns any other block untouched, so it drops straight into a
29
41
  * `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
package/dist/sanitize.js CHANGED
@@ -43,13 +43,14 @@ const STYLE_PROPS = [
43
43
  'border', 'border-top', 'border-bottom', 'border-left', 'border-right',
44
44
  'border-radius', 'border-color', 'border-width', 'border-style',
45
45
  'background', 'background-color', 'color', 'opacity',
46
- 'font-size', 'font-weight', 'font-style', 'line-height',
46
+ 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
47
47
  'text-align', 'vertical-align', 'display', 'overflow',
48
48
  ];
49
- // Ordinary CSS tokens plus rgb()/rgba(). Excluding quotes, backslashes, angle
50
- // brackets and any other `(` keeps `url(...)` and `expression(...)` out of
51
- // every property without a per-property regex.
52
- const SAFE_CSS_VALUE = /^(?:[a-z0-9#%.,\-+/ ]|rgba?\([\d\s,.%]+\))*$/i;
49
+ // Ordinary CSS tokens plus rgb()/rgba(), and var() naming a custom property, so
50
+ // a run of words can take the site's own colour or web font. Excluding quotes,
51
+ // backslashes, angle brackets and any other `(` keeps `url(...)` and
52
+ // `expression(...)` out of every property without a per-property regex.
53
+ const SAFE_CSS_VALUE = /^(?:[a-z0-9#%.,\-+/ ]|rgba?\([\d\s,.%]+\)|var\(--[a-z0-9-]+\))*$/i;
53
54
  const PROSE_TAGS = [
54
55
  'p', 'br', 'hr', 'div', 'span', 'blockquote', 'pre', 'code',
55
56
  'strong', 'em', 'b', 'i', 's', 'u', 'sub', 'sup', 'mark', 'small',
@@ -145,6 +146,29 @@ export function createHtmlSanitizer(options = {}) {
145
146
  };
146
147
  return (html) => (!html || !html.includes('<') ? html : asYouTubeNodes(sanitizeHtml(html, config)));
147
148
  }
149
+ // What a `WikiLine` keeps: its marks, the link's class, and `wikiTextStyle`'s
150
+ // three properties, each held to the same safe value as an article's.
151
+ const LINE_TAGS = ['strong', 'em', 'b', 'i', 'code', 'span', 'a'];
152
+ const LINE_STYLES = Object.fromEntries(['font-family', 'color', 'font-size'].map(p => [p, [SAFE_CSS_VALUE]]));
153
+ /**
154
+ * A one-line field's HTML, cleaned: bold, italic, code, a link and a styled
155
+ * span, and nothing else, so a heading or a caption drawn from it as HTML holds
156
+ * no block, picture or script. `links: false` drops a link's tag and keeps its
157
+ * words, for a field drawn inside a link already, a button's label.
158
+ */
159
+ export function sanitizeLine(html, { links = true } = {}) {
160
+ if (!html || !html.includes('<'))
161
+ return html;
162
+ return sanitizeHtml(html, {
163
+ allowedTags: links ? LINE_TAGS : LINE_TAGS.filter(t => t !== 'a'),
164
+ allowedAttributes: { a: ['href'], span: ['style'] },
165
+ allowedClasses: { a: ['link'] },
166
+ allowedStyles: { span: LINE_STYLES },
167
+ allowedSchemesByTag: { a: ['http', 'https', 'mailto', 'tel'] },
168
+ allowProtocolRelative: false,
169
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript'],
170
+ });
171
+ }
148
172
  // A YouTube player the editor did not write: an agent's, an import's, a pasted
149
173
  // embed code. Run over the sanitiser's own output, whose markup is regular.
150
174
  const BARE_YOUTUBE = /(<div data-youtube-video>)?(<iframe\b[^>]*\bsrc="https:\/\/www\.youtube(?:-nocookie)?\.com\/embed\/[^"]*"[^>]*><\/iframe>)/g;
@@ -155,10 +179,15 @@ const BARE_YOUTUBE = /(<div data-youtube-video>)?(<iframe\b[^>]*\bsrc="https:\/\
155
179
  const asYouTubeNodes = (html) => html.includes('youtube') ? html.replace(BARE_YOUTUBE, (all, wrapped, frame) => (wrapped ? all : `<div data-youtube-video>${frame}</div>`)) : html;
156
180
  const each = (v, map) => Array.isArray(v) ? v.map(item => (item && typeof item === 'object' ? map(item) : item)) : v;
157
181
  const str = (v) => typeof v === 'string';
182
+ /** The named one-line fields of `o` that hold words, each through `sanitizeLine`. */
183
+ const lines = (o, ...keys) => Object.fromEntries(keys.filter(k => str(o[k])).map(k => [k, sanitizeLine(o[k])]));
158
184
  /**
159
185
  * The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
160
- * descriptions, `references` and `events` item text, and a place's note. Every other field those blocks
161
- * carry renders as a React text node and is escaped already.
186
+ * descriptions, `references` and `events` item text, and a place's note, by
187
+ * `clean`; and their one-line fields, a figure's value and label, a quote and
188
+ * who said it, the events' headings and a place's heading and name, by
189
+ * `sanitizeLine`. Every other field those blocks carry renders as a React text
190
+ * node and is escaped already.
162
191
  *
163
192
  * Returns any other block untouched, so it drops straight into a
164
193
  * `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
@@ -177,10 +206,15 @@ export function sanitizeCoreLeaf(block, clean) {
177
206
  groups: each(b.groups, g => (str(g.description) ? { ...g, description: clean(g.description) } : g)),
178
207
  };
179
208
  case 'references':
180
- case 'events':
181
209
  return { ...b, items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
210
+ case 'events':
211
+ return { ...b, ...lines(b, 'upcoming', 'past'), items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
182
212
  case 'locations':
183
- return { ...b, items: each(b.items, p => (str(p.note) ? { ...p, note: clean(p.note) } : p)) };
213
+ return { ...b, ...lines(b, 'heading'), items: each(b.items, p => ({ ...p, ...lines(p, 'name'), ...(str(p.note) && { note: clean(p.note) }) })) };
214
+ case 'stats':
215
+ return { ...b, items: each(b.items, s => ({ ...s, ...lines(s, 'value', 'label') })) };
216
+ case 'testimonial':
217
+ return { ...b, ...lines(b, 'quote', 'author', 'role') };
184
218
  default:
185
219
  return block;
186
220
  }
@@ -79,8 +79,9 @@ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seco
79
79
  */
80
80
  export function StatsFigures({ digest, first, apart, controls }) {
81
81
  const figures = useMemo(() => figuresOf(digest, apart), [digest, apart]);
82
- const [chosen, choose] = useState(0);
83
- const figure = figures[chosen] ?? figures[0];
82
+ // By name, since a window or the agents switch can arrive without a reload and the figures shift.
83
+ const [chosen, choose] = useState(figures[0].label);
84
+ const figure = figures.find(f => f.label === chosen) ?? figures[0];
84
85
  const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
85
86
  const series = useMemo(() => {
86
87
  const today = Math.floor(Date.now() / 1000 / DAY);
@@ -90,7 +91,7 @@ export function StatsFigures({ digest, first, apart, controls }) {
90
91
  return value == null ? null : { time, value };
91
92
  }).filter(p => p !== null);
92
93
  }, [byDay, first, figure]);
93
- 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: controls, className: "stats-chart" })] }));
94
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map(f => (_jsxs("button", { type: "button", "aria-pressed": f === figure, onClick: () => choose(f.label), 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: controls, className: "stats-chart" })] }));
94
95
  }
95
96
  async function post(endpoint, body) {
96
97
  const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
package/dist/stats.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface StatsProps {
13
13
  apart?: boolean;
14
14
  /** The windows offered as `?days=` links on the same page. */
15
15
  ranges?: readonly number[];
16
- /** Every link on the page: `next/link`, say, so a window or a filter does not reload it. */
16
+ /** Every link on the page: `next/link`, say, so a window or a filter does not reload it. Each is given `prefetch={false}`. */
17
17
  link?: WikiLinkComponent;
18
18
  }
19
19
  /**
@@ -23,14 +23,15 @@ export interface StatsProps {
23
23
  */
24
24
  export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
25
25
  days: number;
26
- filter: Partial<Record<"page" | "entry" | "source" | "country" | "device", string>>;
26
+ filter: Partial<Record<"page" | "country" | "entry" | "source" | "device", string>>;
27
27
  apart: boolean;
28
28
  };
29
29
  /**
30
30
  * A site's own analytics on one page: the headline figures against the window
31
31
  * before, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
32
32
  * digest carries, each row a link that narrows the page to it. Lists with
33
- * nothing in them are left out. The chart and the bars are `currentColor`
33
+ * nothing in them are left out. The digest is made with the same `apart`, so
34
+ * the lists count the visitors the chart draws. The chart and the bars are `currentColor`
34
35
  * until the importing stylesheet gives them a colour.
35
36
  */
36
37
  export declare function Stats({ digest: d, days, filter, apart, ranges, link: Link }: StatsProps): import("react").JSX.Element;
package/dist/stats.js CHANGED
@@ -22,6 +22,7 @@ export function statsQuery(params, ranges = STATS_RANGES) {
22
22
  if (typeof v === 'string' && v)
23
23
  filter[k] = v.slice(0, 500);
24
24
  }
25
+ // `apart` is `digest`'s option too, so its lists count the visitors the chart draws.
25
26
  return { days: statsDays(params.days, ranges), filter, apart: params.agents === 'apart' };
26
27
  }
27
28
  /** The page at a window, a filter and a count of agents, as a query string. */
@@ -49,13 +50,14 @@ function StatsList({ title, rows, label = v => v, to, link: Link, }) {
49
50
  if (!rows.length)
50
51
  return null;
51
52
  const max = Math.max(...rows.map(([, n]) => n));
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))) })] }));
53
+ 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), prefetch: false, children: label(v) })) : (label(v)) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
53
54
  }
54
55
  /**
55
56
  * A site's own analytics on one page: the headline figures against the window
56
57
  * before, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
57
58
  * digest carries, each row a link that narrows the page to it. Lists with
58
- * nothing in them are left out. The chart and the bars are `currentColor`
59
+ * nothing in them are left out. The digest is made with the same `apart`, so
60
+ * the lists count the visitors the chart draws. The chart and the bars are `currentColor`
59
61
  * until the importing stylesheet gives them a colour.
60
62
  */
61
63
  export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES, link: Link = Anchor }) {
@@ -74,8 +76,8 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
74
76
  const to = (k) => (v) => at({ ...filter, [k]: v });
75
77
  // Each window is another digest, so a link rather than a button, pinned
76
78
  // with the chart's own controls, and so is the switch that counts agents apart.
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": "Agents counted among visitors", title: "Agents counted among visitors", 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') })] })] }));
79
+ 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, prefetch: false, children: windowLabel(n) }, n))) }), _jsx("div", { children: _jsx(Link, { href: at(filter, days, !apart), role: "switch", "aria-checked": !apart, "aria-label": "Agents counted among visitors", title: "Agents counted among visitors", prefetch: false, 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" })] }) }) })] }));
80
+ 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", prefetch: false, 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') })] })] }));
79
81
  }
80
82
  /**
81
83
  * A stats page's door: `children` and a sign-out button for a signed-in
package/dist/text.js CHANGED
@@ -109,11 +109,11 @@ export function codeTabsToText(tabs) {
109
109
  }
110
110
  /** `“Their words” — Name, role`. @internal */
111
111
  export function testimonialToText(quote, author, role) {
112
- return `“${quote.trim()}” — ${[author, role].filter(Boolean).join(', ')}`;
112
+ return `“${stripHtml(quote).trim()}” — ${[author, role].filter(Boolean).map(w => plainText(w)).join(', ')}`;
113
113
  }
114
114
  /** Metric cards, one per line: `99% Uptime`. @internal */
115
115
  export function statsToText(items) {
116
- return items.map(s => `${s.value}${s.suffix ?? ''} ${s.label}`).join('\n');
116
+ return items.map(s => `${plainText(String(s.value))}${s.suffix ?? ''} ${plainText(s.label)}`).join('\n');
117
117
  }
118
118
  /**
119
119
  * Each group's heading over its links, hrefs in parentheses the way
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.98.1",
3
+ "version": "0.100.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",
@@ -327,6 +327,7 @@
327
327
  "@tiptap/extension-table-cell": "^2.27.3",
328
328
  "@tiptap/extension-table-header": "^2.27.3",
329
329
  "@tiptap/extension-table-row": "^2.27.3",
330
+ "@tiptap/extension-text-style": "^2.27.3",
330
331
  "@tiptap/extension-youtube": "^2.27.3",
331
332
  "@tiptap/pm": "^2.27.3",
332
333
  "@tiptap/react": "^2.27.3",