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 +4 -1
- package/dist/analytics.d.ts +11 -3
- package/dist/analytics.js +37 -13
- package/dist/block-views.js +8 -6
- package/dist/blocks.js +9 -9
- package/dist/chart.js +7 -4
- package/dist/editor.d.ts +20 -4
- package/dist/editor.js +24 -1
- package/dist/react-server.d.ts +2 -0
- package/dist/react-server.js +1 -1
- package/dist/sanitize.d.ts +14 -2
- package/dist/sanitize.js +43 -9
- package/dist/stats-figures.js +4 -3
- package/dist/stats.d.ts +4 -3
- package/dist/stats.js +6 -4
- package/dist/text.js +2 -2
- package/package.json +2 -1
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
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
|
82
|
-
* and starts at `counted_from`,
|
|
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
|
-
|
|
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
|
|
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',
|
|
261
|
-
'entry_pages', ${list(merged('page', 'entryPages',
|
|
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',
|
|
265
|
-
'devices', ${list(merged('device', 'devices',
|
|
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
|
package/dist/block-views.js
CHANGED
|
@@ -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",
|
|
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", {
|
|
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", {
|
|
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", {
|
|
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, {
|
|
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().
|
|
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 && ``,
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
*
|
|
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, ' ') },
|
package/dist/react-server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/react-server.js
CHANGED
|
@@ -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.
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
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()
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
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
|
|
161
|
-
*
|
|
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)
|
|
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
|
}
|
package/dist/stats-figures.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
const
|
|
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(
|
|
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" | "
|
|
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
|
|
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
|
|
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.
|
|
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",
|