wiki-formant 0.107.0 → 0.112.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
@@ -94,7 +94,7 @@ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size
94
94
  - `get_page` with `blocks: true` returns the stored blocks, `id`, metadata and `version`: what an edit starts from. `forEdit` adds the app's own edit fields; `authors` names revision authors.
95
95
  - `text` should be the twin's serializer and `resolve` the page's live resolver, so a page reads the same over MCP as at its twin.
96
96
  - `get_full_corpus` is a `sizeOnly` preflight, then page-aligned slices under `maxChars` (`CORPUS_BUDGET`) with `truncated`, `nextSkip` and `clippedPage`. `corpus` returns the app's own `CorpusSection`s.
97
- - `privateHidden` closes the back office to every tool. `metadata`, `label`, `noMatch` and `skills` are the app's.
97
+ - `privateHidden` closes the back office to every tool. `metadata`, `label`, `noMatch` and `skills` are the app's. `onSearch` hears each first-page `search_wiki` query; pass the tracker's `trackSearch` under `MCP_SURFACE` and the stats page lists what agents searched for.
98
98
  - Writes stay with each app.
99
99
 
100
100
  ## Taxonomy
@@ -196,7 +196,7 @@ const { first } = await wikiReadChecks(t, { query: 'radix', address: r => ({ tag
196
196
  process.exit(t.summary());
197
197
  ```
198
198
 
199
- `standardSuite(t, cfg)` runs the suites every surface runs, in order: `transportChecks` (CORS preflight, `GET`→405, 202 for a notification, `-32700`, the batch cap, honest capabilities, version negotiation), `versionCoherence` over `standardVersionSources`, `agentCardParity`, `annotationChecks`, `manifestParity`, `payloadBudget`, `conditionalGetChecks`, `descriptorChecks` (default `S10_DESCRIPTORS`) and `robotsChecks`. `budget`, `conditionalGet` and `robots` run only when given, for a surface whose fixtures exist only later in its run. It returns the `initialize` response. Each suite is exported for a surface that needs it alone.
199
+ `standardSuite(t, cfg)` runs the suites every surface runs, in order: `transportChecks` (CORS preflight, `GET`→405, 202 for a notification, `-32700`, the batch cap, honest capabilities, version negotiation), `versionCoherence` over `standardVersionSources`, `agentCardChecks`, `annotationChecks`, `manifestParity`, `payloadBudget`, `conditionalGetChecks`, `descriptorChecks` (default `S10_DESCRIPTORS`) and `robotsChecks`. `budget`, `conditionalGet` and `robots` run only when given, for a surface whose fixtures exist only later in its run. It returns the `initialize` response. Each suite is exported for a surface that needs it alone.
200
200
 
201
201
  `wikiReadChecks(t, opts)` checks the `wiki-tools` read tools: categories, a search and the page it finds (by `address`, default `{ path }`), its twin (given `twin`), a listing, recent changes, a corpus preflight and a slice that is cut and resumes when the corpus exceeds `corpusChars`, and the teaching answers to no hits, a missing argument, an unknown tool and a missing page. `endpoint` and `label` run it against a second server. It returns `{ rows, first, page }`.
202
202
 
@@ -260,7 +260,7 @@ export const POST = (req: Request) => handleRoute(async () => {
260
260
  - `corpusEtag`, `notModified` and `textHeaders` are the parts; `corpusRoute` skips the build on a 304.
261
261
  - A URL answering JSON or markdown by `Accept` asks `wantsMarkdown(request)` and sends `VARY_ACCEPT` on both branches.
262
262
  - `descriptorResponse` serves a JSON descriptor with its own ETag and `Access-Control-Allow-Origin: *` (`descriptorHandler` in `wiki-formant/well-known` is the route).
263
- - Every error body is `{ error, ...extra }`. A thrown `HttpError` answers with its status; anything else is logged and answered with a 500 carrying only the handler's message (`'Search failed'`), so no exception text reaches a caller. `errors.tooManyRequests(sec)` states `Retry-After`.
263
+ - Every error body is `{ error, ...extra }`. A thrown `HttpError` answers with its status, a thrown `Response` (`errors.tooManyRequests(sec)`, say) is the answer as built; anything else is logged and answered with a 500 carrying only the handler's message (`'Search failed'`), so no exception text reaches a caller. `errors.tooManyRequests(sec)` states `Retry-After`.
264
264
 
265
265
  ## Response headers and the Next config
266
266
 
@@ -511,7 +511,7 @@ const block = licenseBlock({ license, scope: 'The protocol compilation and prose
511
511
  export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a named trial's prescription with its citation" });
512
512
  ```
513
513
 
514
- - One `License` goes to `licenseBlock`, `agentCard({ license, licenseScope })` and `openApiLicense` for `info.license`.
514
+ - One `License` goes to `licenseBlock`, `agentCard({ license, licenseScope })` and `openApiDocument`, which sets `info.license` from it.
515
515
  - `X.url` is the `sameAs`, `X.handle` the card's, `followLines(X)` goes into llms.txt and AGENTS.md, `followNote(X)` into the MCP instructions, and `<a {...followLinkProps(X)}>` is the footer link with `rel="me"`.
516
516
 
517
517
  ## Agent descriptors
@@ -526,7 +526,23 @@ const AGENT_CARD = agentCard({ name, description, url: `${SITE_URL}/api/mcp`, ve
526
526
  export const GET = wellKnownHandler({ agentCard: AGENT_CARD, mcpManifest: mcpManifest({ … }), openapi: SPEC });
527
527
  ```
528
528
 
529
- The card is served identically at `agent-card.json` and `agent.json`, and its `url` is the MCP endpoint, which answers A2A. `registryAuthHandler` serves `MCP_REGISTRY_PUBLIC_KEY` at `/.well-known/mcp-registry-auth`, 404 when unset.
529
+ The card is served at `agent-card.json`, and its `url` is the MCP endpoint, which answers A2A. `registryAuthHandler` serves `MCP_REGISTRY_PUBLIC_KEY` at `/.well-known/mcp-registry-auth`, 404 when unset.
530
+
531
+ ## OpenAPI
532
+
533
+ `wiki-formant/openapi`: the frame of the spec `wellKnownHandler` serves, and the paths every site shares. A spec writes only its own paths.
534
+
535
+ ```ts
536
+ import { openApiDocument, jsonBody, jsonResponse, toolBody, llmsTxtPath, mcpPath } from 'wiki-formant/openapi';
537
+
538
+ export const SPEC = openApiDocument({ title, version, description, license, siteUrl: SITE_URL }, {
539
+ '/api/wiki/{path}': { put: { requestBody: toolBody(toolInput('edit_page'), { inPath: ['tagPath', 'slug'], refs: { Block: BLOCK_SCHEMA } }), … } },
540
+ '/llms.txt': llmsTxtPath('Index of every page (plain text)'),
541
+ '/api/mcp': mcpPath(TOOLS.map(t => t.name)),
542
+ }, { schemas: { Block: BLOCK_SCHEMA } });
543
+ ```
544
+
545
+ `toolBody` is a write tool's input schema as the REST body, so `openapiWriteParity` holds by construction. `mcpPath` states the protocol versions, the batch cap and `MCP_RATE_LIMIT_TEXT`; `llmsTxtPath` the `If-None-Match` and its 304.
530
546
 
531
547
  ## AI crawlers
532
548
 
@@ -716,7 +732,7 @@ export const POST = viewRoute();
716
732
 
717
733
  - `createTracker` records only on a Vercel production deployment unless `enabled` says otherwise. `trackMcpCall` fits `McpServerConfig.onCall`; `trackSearch` normalises a query and drops an empty one; `recordEvent` is the undeferred write.
718
734
  - A proxy reaches the tracker through a dynamic `import()` inside `event.waitUntil`, since it imports the database client.
719
- - `digest(sql, { days })` is the site as one JSON object: people, engaged people, agents, views, visits, bounce rate, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing, less a search its visitor went on typing within five minutes and any of one character. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit its reader never engaged with. Agents are counted beside people, never among them: the ranked lists are people's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
735
+ - `digest(sql, { days })` is the site as one JSON object: people, engaged people, agents, views, visits, bounce rate, sources, pages, countries, devices, by day, agents by name, MCP calls, every search people made, every `search_wiki` query agents made, the searches of either that found nothing, and agents' `send_feedback` reports. A search its visitor went on typing within five minutes is left out, as is any of one character. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit its reader never engaged with. Agents are counted beside people, never among them: the ranked lists are people's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
720
736
  - A browser under automation (`navigator.webdriver`) sends no beacon.
721
737
 
722
738
  ## Passkeys and the stats page
@@ -58,12 +58,12 @@ const get = async url => {
58
58
 
59
59
  // The sitemap is the URL list.
60
60
  async function sitemapUrls(origin) {
61
- const seen = (await readSitemap(`${origin}/sitemap.xml`, url => fetch(url, { headers: { 'user-agent': UA } }))).map(e => e.url);
61
+ const { entries, unread } = await readSitemap(`${origin}/sitemap.xml`, url => fetch(url, { headers: { 'user-agent': UA } }));
62
62
  // A sitemap states absolute production URLs by definition, so pointing
63
63
  // --site at a dev server would otherwise crawl production and report the
64
64
  // build you were trying to replace. The path is what the sitemap contributes;
65
65
  // the origin is what you asked for.
66
- return seen.map(u => {
66
+ const urls = entries.map(({ url: u }) => {
67
67
  try {
68
68
  const { pathname, search } = new URL(u);
69
69
  return `${origin}${pathname}${search}`;
@@ -71,6 +71,7 @@ async function sitemapUrls(origin) {
71
71
  return u;
72
72
  }
73
73
  });
74
+ return { urls, unread };
74
75
  }
75
76
 
76
77
  const titleOf = html => {
@@ -84,17 +85,23 @@ const descriptionOf = html => {
84
85
 
85
86
  // A sitemap that does not answer is a fault, not an empty site: read as no
86
87
  // URLs, a down sitemap passed the gate it exists to hold.
87
- const urls = await sitemapUrls(site).catch(e => {
88
+ const { urls, unread } = await sitemapUrls(site).catch(e => {
88
89
  console.error(`check-pages: ${site}/sitemap.xml could not be read: ${e.message}`);
89
90
  process.exit(WARN_ONLY ? 0 : 1);
90
91
  });
92
+ // A child sitemap that does not answer hides its pages from every check below,
93
+ // so it is a fault of its own (`sitemapUnread`), never a site with fewer pages.
94
+ if (!urls.length && unread.length) {
95
+ for (const { url, error } of unread) console.error(`check-pages: ${url} could not be read: ${error}`);
96
+ process.exit(WARN_ONLY ? 0 : 1);
97
+ }
91
98
  if (!urls.length) {
92
99
  console.log(`check-pages: no URLs in ${site}/sitemap.xml — nothing to check. Skipping.`);
93
100
  process.exit(0);
94
101
  }
95
102
 
96
103
  const checked = urls.slice(0, limit);
97
- const faults = [];
104
+ const faults = unread.map(({ url, error }) => ({ url, fault: 'sitemapUnread', detail: error }));
98
105
  const titles = new Map();
99
106
 
100
107
  for (const url of checked) {
package/bin/indexnow.mjs CHANGED
@@ -40,11 +40,12 @@ if (live.trim() !== key) {
40
40
  process.exit(1);
41
41
  }
42
42
 
43
- const entries = await readSitemap(`${origin}/sitemap.xml`).catch(e => {
43
+ const { entries, unread } = await readSitemap(`${origin}/sitemap.xml`).catch(e => {
44
44
  console.error(e.message);
45
45
  process.exit(1);
46
46
  });
47
47
  const urls = args.includes('--all') ? entries.map(e => e.url) : changedUrls(entries, Date.now() - hours * 3_600_000);
48
48
  const statuses = await submitIndexNow(key, urls);
49
- console.log(JSON.stringify({ origin, submitted: urls.length, statuses }));
50
- if (!statuses.every(s => s === 200 || s === 202)) process.exit(1);
49
+ // What was read is submitted; a child sitemap that did not answer fails the run, since its pages were not.
50
+ console.log(JSON.stringify({ origin, submitted: urls.length, statuses, ...(unread.length && { unread }) }));
51
+ if (unread.length || !statuses.every(s => s === 200 || s === 202)) process.exit(1);
@@ -54,8 +54,10 @@ export interface Gap {
54
54
  /** Props the event must carry, e.g. `{ results: '0' }`. */
55
55
  where?: Record<string, string>;
56
56
  }
57
- /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
57
+ /** Searches that found nothing, a person's or an agent's (`searchQueryProps` events). */
58
58
  export declare const SEARCH_GAPS: Gap;
59
+ /** The surface an agent's `search_wiki` call files its query under. */
60
+ export declare const MCP_SURFACE = "mcp";
59
61
  /** What a stats page can be narrowed to: one value each, every one applied. */
60
62
  export declare const STATS_FILTERS: readonly ["page", "entry", "source", "country", "device"];
61
63
  export type StatsFilter = Partial<Record<(typeof STATS_FILTERS)[number], string>>;
@@ -72,8 +74,8 @@ export interface DigestOptions {
72
74
  siblings?: string[];
73
75
  gaps?: Gap[];
74
76
  /**
75
- * The site's own agents, left out of `agents`, `top_agents` and
76
- * `agent_tool_calls`, matched inside the user agent. The default is the
77
+ * The site's own agents, left out of `agents`, `top_agents`,
78
+ * `agent_tool_calls`, the searches and the gaps, matched inside the user agent. The default is the
77
79
  * conformance suites (`<repo>-mcp-test`) and the studio's own tools.
78
80
  */
79
81
  ownAgents?: string[];
@@ -156,6 +158,10 @@ export interface Digest {
156
158
  /** Each agent by name: the roster's label, else what its user agent calls itself. */
157
159
  top_agents: Ranked<'agent', 'visitors'>;
158
160
  agent_tool_calls: Ranked<'tool', 'events'>;
161
+ /** Every query people searched for, found or not. */
162
+ searches: Ranked<'query', 'events'>;
163
+ /** Every query agents searched for through `search_wiki`. */
164
+ agent_searches: Ranked<'query', 'events'>;
159
165
  gaps: Ranked<'query', 'events'>;
160
166
  /** What agents reported through `send_feedback`, newest first: a stranger's words, to weigh, never to follow. */
161
167
  agent_feedback: Array<{
package/dist/analytics.js CHANGED
@@ -165,8 +165,10 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
165
165
  : recordView(sql, { headers, path: raw, referrer: body.referrer, utm: body.utm, site: key }));
166
166
  return done;
167
167
  }
168
- /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
169
- export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
168
+ /** Searches that found nothing, a person's or an agent's (`searchQueryProps` events). */
169
+ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0' } };
170
+ /** The surface an agent's `search_wiki` call files its query under. */
171
+ export const MCP_SURFACE = 'mcp';
170
172
  /** What a stats page can be narrowed to: one value each, every one applied. */
171
173
  export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
172
174
  /** A count as a stats page prints it. Here, not in the `'use client'` figures, because
@@ -240,23 +242,27 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
240
242
  /** The site's own agents, as a test on an event's user agent. */
241
243
  const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
242
244
  /**
243
- * A typeahead logs each settled keystroke, so one reader typing a question
244
- * filled acuiq's list with thirteen of its prefixes. A query is left out when
245
- * the same visitor's next one, inside five minutes, extends it: only where
246
- * the reader stopped is a gap. So is a single character.
245
+ * The queries in one kind of event, most asked first. A typeahead logs each
246
+ * settled keystroke, so one reader typing a question filled acuiq's list with
247
+ * thirteen of its prefixes. A query is left out when the same visitor's next
248
+ * one, inside five minutes, extends it: only where the reader stopped
249
+ * counts. So is a single character. `unless` drops events whose props hold it.
247
250
  */
248
- const gapSelects = gaps.map(g => {
251
+ const queries = (g, unless) => {
249
252
  const prop = $(g.prop);
250
253
  return `(SELECT query, count(*) AS events FROM (
251
254
  SELECT props, visitor, props->>${prop} AS query,
252
255
  lead(props->>${prop}) OVER typed AS next, lead(at) OVER typed - at AS after
253
- FROM e WHERE name = ${$(g.event)} AND props->>${prop} IS NOT NULL
256
+ FROM e WHERE name = ${$(g.event)} AND props->>${prop} IS NOT NULL AND NOT ${ours}
254
257
  WINDOW typed AS (PARTITION BY visitor ORDER BY at)
255
258
  ) s
256
259
  WHERE props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND char_length(query) > 1
260
+ ${unless ? `AND NOT props @> ${$(JSON.stringify(unless))}::jsonb` : ''}
257
261
  AND NOT coalesce(visitor IS NOT NULL AND after <= interval '5 minutes' AND char_length(next) > char_length(query) AND starts_with(next, query), false)
258
262
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
259
- });
263
+ };
264
+ const searches = { event: SEARCH_GAPS.event, prop: SEARCH_GAPS.prop };
265
+ const json = (select) => `(SELECT coalesce(json_agg(t), '[]') FROM ${select} t)`;
260
266
  /**
261
267
  * One window's views, visits, events and archived days as CTEs whose names
262
268
  * end in `s`: this window with `back` 0, the one before it with 1.
@@ -342,7 +348,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
342
348
  GROUP BY 1`, 10)},
343
349
  'agent_feedback', ${list(`SELECT ${day('at')} AS date, props->>'kind' AS kind, props->>'message' AS message, props->>'page' AS page, ${AGENT_NAME} AS agent
344
350
  FROM e WHERE name = 'Agent Feedback' AND NOT ${ours}`, 20, 'at DESC')},
345
- 'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
351
+ 'searches', ${json(queries(searches, { surface: MCP_SURFACE }))},
352
+ 'agent_searches', ${json(queries({ ...searches, where: { surface: MCP_SURFACE } }))},
353
+ 'gaps', ${gaps.length ? json(`(${gaps.map(g => queries(g)).join(' UNION ALL ')})`) : `'[]'::json`}
346
354
  ) AS digest`, ...values));
347
355
  const d = row.digest;
348
356
  // Before the site counted itself only ViewDay speaks, and it holds no
@@ -432,7 +440,8 @@ export function createTracker({ sql, defer, enabled = typeof process !== 'undefi
432
440
  defer(async () => {
433
441
  try {
434
442
  const headers = typeof source === 'function' ? await source() : source;
435
- await trackEvent('Search Query', headers.get('referer') || siteUrl, props, headers);
443
+ const ua = (headers.get('user-agent') || 'unknown').slice(0, 80);
444
+ await trackEvent('Search Query', headers.get('referer') || siteUrl, { ...props, ua }, headers);
436
445
  }
437
446
  catch {
438
447
  // A context with no readable headers loses the event, never the response.
package/dist/base.css CHANGED
@@ -263,6 +263,10 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
263
263
  .infobox-series { align-items: center; gap: 0.125rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--ui-border); text-align: center; font-size: var(--ui-small); color: var(--ui-muted); }
264
264
  .page-with-infobox { gap: 1.5rem; }
265
265
 
266
+ /* `Cite` in a meta line: the summary reads as a link, the citation as a line of its own. */
267
+ .article-cite summary { cursor: pointer; color: var(--ui-accent); }
268
+ .article-cite code { display: block; margin-top: 0.25rem; white-space: normal; }
269
+
266
270
  .section-card { display: flex; flex-direction: column; gap: 0.5rem; padding: var(--ui-pad); transition: border-color 150ms; }
267
271
  .section-card:hover { border-color: var(--ui-accent); }
268
272
 
@@ -698,7 +702,7 @@ pre:hover > .code-copy-btn,
698
702
  position: absolute;
699
703
  inset: 0 auto 0 0;
700
704
  width: calc(var(--share) * 100%);
701
- background: currentColor;
705
+ background: var(--ui-accent);
702
706
  opacity: 0.08;
703
707
  pointer-events: none;
704
708
  }
package/dist/chart.js CHANGED
@@ -167,12 +167,13 @@ const MARGIN_TOP = 0.15;
167
167
  */
168
168
  export function TimeChart({ series, label, ranges = typeof series === 'function' ? CHART_RANGES : DAILY_RANGES, range: initial = ranges.includes('30d') ? '30d' : ranges[0], aggregate = 'last', fromZero = false, format = formatChartValue, candles: initialCandles = false, height, controls, markers, step: initialStep, onView, className, }) {
169
169
  const box = useRef(null);
170
- const chart = useRef(null);
171
170
  const formatRef = useRef(format);
172
171
  formatRef.current = format;
173
172
  const onViewRef = useRef(onView);
174
173
  onViewRef.current = onView;
175
- const [ready, setReady] = useState(false);
174
+ // The drawn chart once the library has loaded, else null: the handle is the
175
+ // readiness, so no flag can say ready over a chart that is gone.
176
+ const [chart, setChart] = useState(null);
176
177
  const [range, setRange] = useState(initial);
177
178
  const [step, setStep] = useState(initialStep ?? null);
178
179
  const [candles, setCandles] = useState(initialCandles);
@@ -186,6 +187,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
186
187
  if (!el)
187
188
  return;
188
189
  let disposed = false;
190
+ let built = null;
189
191
  import('lightweight-charts').then(({ createChart, createSeriesMarkers, AreaSeries, CandlestickSeries, HistogramSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
190
192
  if (disposed)
191
193
  return;
@@ -258,29 +260,28 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
258
260
  api.panes()[1]?.setStretchFactor(1);
259
261
  return volume;
260
262
  };
261
- chart.current = {
263
+ built = {
262
264
  api, area, candle, volume: null, addVolume, up, down, ink,
263
265
  curved: LineType.Curved, straight: LineType.Simple,
264
266
  areaMarkers: createSeriesMarkers(area, []),
265
267
  candleMarkers: createSeriesMarkers(candle, []),
266
268
  };
267
- setReady(true);
269
+ setChart(built);
268
270
  });
269
271
  return () => {
270
272
  disposed = true;
271
- chart.current?.api.remove();
272
- chart.current = null;
273
- setReady(false);
273
+ built?.api.remove();
274
+ setChart(null);
274
275
  };
275
276
  }, []);
276
277
  // Applied to the chart in place: rebuilding it would leave a new chart with nothing drawn on it.
277
278
  useEffect(() => {
278
- const c = chart.current;
279
- if (!ready || !c)
279
+ const c = chart;
280
+ if (!c)
280
281
  return;
281
282
  c.api.priceScale('right').applyOptions({ scaleMargins: { top: MARGIN_TOP, bottom: fromZero ? 0 : 0.1 } });
282
283
  c.area.applyOptions({ lineType: aggregate === 'last' ? c.straight : c.curved, autoscaleInfoProvider: fromZero ? fromZeroScale : ownScale });
283
- }, [ready, fromZero, aggregate]);
284
+ }, [chart, fromZero, aggregate]);
284
285
  // One request per resolution for the life of the loader: every range from 30D up reads the same days.
285
286
  const cache = useRef(new Map());
286
287
  useEffect(() => {
@@ -330,8 +331,8 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
330
331
  }, [points, range, ranges.length, resolution, step, aggregate]);
331
332
  // While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
332
333
  useEffect(() => {
333
- const c = chart.current;
334
- if (!ready || !c || !source)
334
+ const c = chart;
335
+ if (!c || !source)
335
336
  return;
336
337
  const shown = view?.shown ?? [];
337
338
  const asCandles = candles && !!view?.ohlc;
@@ -364,7 +365,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
364
365
  c.areaMarkers.setMarkers(asCandles ? [] : drawn);
365
366
  c.candleMarkers.setMarkers(asCandles ? drawn : []);
366
367
  c.api.timeScale().fitContent();
367
- }, [ready, source, view, candles, markers]);
368
+ }, [chart, source, view, candles, markers]);
368
369
  // Reported once there are points to step, so a step read from a URL is not cleared while they load.
369
370
  const shown = !!view;
370
371
  const picked = step && step === view?.step ? step : null;
@@ -382,5 +383,5 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
382
383
  top: box.current.offsetTop + tip.y,
383
384
  ...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
384
385
  translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
385
- }, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => setRange(r), children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
386
+ }, children: tip.label })), (!chart || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => setRange(r), children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
386
387
  }
@@ -123,13 +123,8 @@ openapi?: readonly string[]): Record<string, {
123
123
  url: string;
124
124
  at: (json: Record<string, unknown>) => unknown;
125
125
  }>;
126
- /**
127
- * Both A2A well-known paths must serve the identical card. v0.3 renamed
128
- * agent.json to agent-card.json and defined no fallback either way, so an origin
129
- * that lets them drift answers two different questions to two halves of its
130
- * callers.
131
- */
132
- export declare function agentCardParity(t: Tester): Promise<void>;
126
+ /** The A2A card at `agent-card.json`: complete, and its `url` an endpoint that answers. */
127
+ export declare function agentCardChecks(t: Tester): Promise<void>;
133
128
  /**
134
129
  * Any surface an agent recrawls should answer a conditional GET with a 304.
135
130
  *
@@ -140,7 +135,7 @@ export declare function agentCardParity(t: Tester): Promise<void>;
140
135
  * will pass is a check that measures nothing.
141
136
  */
142
137
  export declare function conditionalGetChecks(t: Tester, paths: readonly string[]): Promise<void>;
143
- /** The JSON descriptors every S10 origin serves: the two agent-card paths, the OpenAPI pair, the MCP manifest and the server card. */
138
+ /** The JSON descriptors every S10 origin serves: the agent card, the OpenAPI pair, the MCP manifest and the server card. */
144
139
  export declare const S10_DESCRIPTORS: readonly string[];
145
140
  /**
146
141
  * A JSON descriptor is revalidatable — an agent card, an OpenAPI document, a
@@ -252,7 +252,6 @@ openapi = ['/openapi.json', '/.well-known/openapi.json']) {
252
252
  const version = (j) => j.version;
253
253
  return {
254
254
  card: { url: `${t.base}/.well-known/agent-card.json`, at: version },
255
- legacyCard: { url: `${t.base}/.well-known/agent.json`, at: version },
256
255
  mcpManifest: { url: `${t.base}/.well-known/mcp.json`, at: version },
257
256
  serverCard: { url: `${t.endpoint}/server-card`, at: version },
258
257
  ...Object.fromEntries(openapi.map(path => [
@@ -261,20 +260,10 @@ openapi = ['/openapi.json', '/.well-known/openapi.json']) {
261
260
  ])),
262
261
  };
263
262
  }
264
- /**
265
- * Both A2A well-known paths must serve the identical card. v0.3 renamed
266
- * agent.json to agent-card.json and defined no fallback either way, so an origin
267
- * that lets them drift answers two different questions to two halves of its
268
- * callers.
269
- */
270
- export async function agentCardParity(t) {
271
- const [a, b] = await Promise.all([
272
- fetch(`${t.base}/.well-known/agent-card.json`).then(r => r.json()),
273
- fetch(`${t.base}/.well-known/agent.json`).then(r => r.json()),
274
- ]);
275
- t.check('agent card parity', JSON.stringify(a) === JSON.stringify(b), 'agent.json === agent-card.json');
276
- // A card missing a required field is a card a spec-current client rejects, and
277
- // parity alone happily reports two identical unusable ones.
263
+ /** The A2A card at `agent-card.json`: complete, and its `url` an endpoint that answers. */
264
+ export async function agentCardChecks(t) {
265
+ const a = await fetch(`${t.base}/.well-known/agent-card.json`).then(r => r.json());
266
+ // A card missing a required field is a card a spec-current client rejects.
278
267
  const required = [
279
268
  'protocolVersion',
280
269
  'name',
@@ -335,10 +324,9 @@ export async function conditionalGetChecks(t, paths) {
335
324
  }
336
325
  }
337
326
  }
338
- /** The JSON descriptors every S10 origin serves: the two agent-card paths, the OpenAPI pair, the MCP manifest and the server card. */
327
+ /** The JSON descriptors every S10 origin serves: the agent card, the OpenAPI pair, the MCP manifest and the server card. */
339
328
  export const S10_DESCRIPTORS = [
340
329
  '.well-known/agent-card.json',
341
- '.well-known/agent.json',
342
330
  '.well-known/openapi.json',
343
331
  'openapi.json',
344
332
  '.well-known/mcp.json',
@@ -635,7 +623,7 @@ export async function rolaOriginCheck(t, opts = {}) {
635
623
  export async function standardSuite(t, cfg) {
636
624
  const init = await transportChecks(t, t.clientName, cfg.capabilities);
637
625
  await versionCoherence(t, cfg.version, standardVersionSources(t, cfg.openapi), init.result?.serverInfo?.version);
638
- await agentCardParity(t);
626
+ await agentCardChecks(t);
639
627
  await annotationChecks(t, { writes: cfg.writes });
640
628
  await manifestParity(t, cfg.manifest);
641
629
  if (cfg.budget)
package/dist/editor.js CHANGED
@@ -421,10 +421,10 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
421
421
  const uploadAttempt = useRef(0);
422
422
  const initialValueRef = useRef(value);
423
423
  const onChangeRef = useRef(onChange);
424
- const debounceRef = useRef(undefined);
425
- // An edit made and not yet reported. `onUpdate` fires for edits only, never
426
- // for the `setContent` that loads the stored value.
427
- const pendingRef = useRef(false);
424
+ // The timer of an edit made and not yet reported, or null when every edit
425
+ // has been. `onUpdate` fires for edits only, never for the `setContent` that
426
+ // loads the stored value.
427
+ const pendingRef = useRef(null);
428
428
  // Written AFTER render, not during: a ref mutated mid-render can tear under
429
429
  // concurrent rendering. Only read from events and timeouts, which run later.
430
430
  useEffect(() => {
@@ -441,10 +441,10 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
441
441
  transformPastedHTML: cleanPastedHtml,
442
442
  },
443
443
  onUpdate: ({ editor }) => {
444
- clearTimeout(debounceRef.current);
445
- pendingRef.current = true;
446
- debounceRef.current = setTimeout(() => {
447
- pendingRef.current = false;
444
+ if (pendingRef.current)
445
+ clearTimeout(pendingRef.current);
446
+ pendingRef.current = setTimeout(() => {
447
+ pendingRef.current = null;
448
448
  onChangeRef.current(editor.getHTML());
449
449
  }, debounceMs);
450
450
  },
@@ -452,10 +452,10 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
452
452
  // before the click fires — never drops the final keystroke. A blur with no
453
453
  // edit reports nothing.
454
454
  onBlur: ({ editor }) => {
455
- clearTimeout(debounceRef.current);
456
455
  if (!pendingRef.current)
457
456
  return;
458
- pendingRef.current = false;
457
+ clearTimeout(pendingRef.current);
458
+ pendingRef.current = null;
459
459
  onChangeRef.current(editor.getHTML());
460
460
  },
461
461
  immediatelyRender: false,
@@ -465,7 +465,10 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
465
465
  }
466
466
  },
467
467
  });
468
- useEffect(() => () => clearTimeout(debounceRef.current), []);
468
+ useEffect(() => () => {
469
+ if (pendingRef.current)
470
+ clearTimeout(pendingRef.current);
471
+ }, []);
469
472
  // Accept an outside change, but never while the author is typing into it.
470
473
  useEffect(() => {
471
474
  if (!editor || editor.isFocused)
package/dist/http.d.ts CHANGED
@@ -188,8 +188,9 @@ export declare class HttpError extends Error {
188
188
  constructor(status: number, message: string, extra?: Record<string, unknown> | undefined);
189
189
  }
190
190
  /**
191
- * Run a handler; a thrown `HttpError` becomes its own answer, anything else a
192
- * logged 500 that names nothing internal. One handler here echoed every
191
+ * Run a handler; a thrown `HttpError` becomes its own answer, a thrown
192
+ * `Response` is the answer as built (a 429 keeps its `Retry-After`), anything
193
+ * else a logged 500 that names nothing internal. One handler here echoed every
193
194
  * exception's message to the caller, which is how a database error's text
194
195
  * becomes public.
195
196
  */
package/dist/http.js CHANGED
@@ -300,8 +300,9 @@ export class HttpError extends Error {
300
300
  }
301
301
  }
302
302
  /**
303
- * Run a handler; a thrown `HttpError` becomes its own answer, anything else a
304
- * logged 500 that names nothing internal. One handler here echoed every
303
+ * Run a handler; a thrown `HttpError` becomes its own answer, a thrown
304
+ * `Response` is the answer as built (a 429 keeps its `Retry-After`), anything
305
+ * else a logged 500 that names nothing internal. One handler here echoed every
305
306
  * exception's message to the caller, which is how a database error's text
306
307
  * becomes public.
307
308
  */
@@ -310,6 +311,8 @@ export async function handleRoute(fn, errorMsg = 'Internal server error') {
310
311
  return await fn();
311
312
  }
312
313
  catch (error) {
314
+ if (error instanceof Response)
315
+ return error;
313
316
  if (error instanceof HttpError)
314
317
  return json({ error: error.message, ...error.extra }, error.status);
315
318
  console.error(errorMsg, error);
@@ -7,13 +7,23 @@ export interface IndexNowEntry {
7
7
  }
8
8
  /** The `<loc>` and `<lastmod>` of every `<url>` in a sitemap. */
9
9
  export declare function sitemapEntries(xml: string): IndexNowEntry[];
10
+ /** A sitemap read: its entries, and each child sitemap that did not answer and why. */
11
+ export interface SitemapRead {
12
+ entries: IndexNowEntry[];
13
+ unread: {
14
+ url: string;
15
+ error: string;
16
+ }[];
17
+ }
10
18
  /**
11
19
  * Every entry of the sitemap at `url`, following a sitemap index one level down
12
20
  * to at most 20 children. The `indexnow` and `check-pages` bins each parsed
13
21
  * sitemaps, and only one of them read an index. Throws when `url` itself does
14
- * not answer 200; a child that fails is skipped.
22
+ * not answer 200. A child that fails is listed in `unread`, never read as a
23
+ * sitemap of no pages: skipped silently, a dead child passed `check-pages` with
24
+ * its pages unchecked.
15
25
  */
16
- export declare function readSitemap(url: string, fetchImpl?: typeof fetch): Promise<IndexNowEntry[]>;
26
+ export declare function readSitemap(url: string, fetchImpl?: typeof fetch): Promise<SitemapRead>;
17
27
  /**
18
28
  * The URLs modified at or after `since`. An entry with no `lastModified` is
19
29
  * left out: nothing says it changed, and the protocol asks for changed pages
package/dist/indexnow.js CHANGED
@@ -35,7 +35,9 @@ export function sitemapEntries(xml) {
35
35
  * Every entry of the sitemap at `url`, following a sitemap index one level down
36
36
  * to at most 20 children. The `indexnow` and `check-pages` bins each parsed
37
37
  * sitemaps, and only one of them read an index. Throws when `url` itself does
38
- * not answer 200; a child that fails is skipped.
38
+ * not answer 200. A child that fails is listed in `unread`, never read as a
39
+ * sitemap of no pages: skipped silently, a dead child passed `check-pages` with
40
+ * its pages unchecked.
39
41
  */
40
42
  export async function readSitemap(url, fetchImpl = fetch) {
41
43
  const res = await fetchImpl(url);
@@ -43,12 +45,20 @@ export async function readSitemap(url, fetchImpl = fetch) {
43
45
  throw new Error(`${url} answered ${res.status}`);
44
46
  const xml = await res.text();
45
47
  if (!/<sitemapindex/i.test(xml))
46
- return sitemapEntries(xml);
48
+ return { entries: sitemapEntries(xml), unread: [] };
47
49
  const children = [...xml.matchAll(/<loc>([\s\S]*?)<\/loc>/g)].slice(0, 20).map(m => decodeEntities(m[1].trim()));
48
- const entries = [];
49
- for (const child of children)
50
- entries.push(...(await readSitemap(child, fetchImpl).catch(() => [])));
51
- return entries;
50
+ const read = { entries: [], unread: [] };
51
+ for (const child of children) {
52
+ try {
53
+ const sub = await readSitemap(child, fetchImpl);
54
+ read.entries.push(...sub.entries);
55
+ read.unread.push(...sub.unread);
56
+ }
57
+ catch (e) {
58
+ read.unread.push({ url: child, error: e instanceof Error ? e.message : String(e) });
59
+ }
60
+ }
61
+ return read;
52
62
  }
53
63
  /**
54
64
  * The URLs modified at or after `since`. An entry with no `lastModified` is
package/dist/knip.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // knip.ts — the knip configuration every app here shares: what the App
2
2
  // Router loads by convention and nothing imports, the scripts the routines
3
- // run from the command line, and the two dependencies no TS import names.
3
+ // run from the command line, and the dependency no TS import names.
4
4
  // An app's `knip.ts` spreads it and adds what is its own:
5
5
  //
6
6
  // import base from 'wiki-formant/knip';
@@ -23,8 +23,6 @@ const config = {
23
23
  ignoreDependencies: [
24
24
  // Reached through `@tailwindcss/postcss` and the `@import` in globals.css.
25
25
  'tailwindcss',
26
- // Imported by the generated Prisma client.
27
- '@prisma/client',
28
26
  ],
29
27
  // The Vercel CLI the scripts shell out to for a cache tag; a global, never a dependency.
30
28
  ignoreBinaries: ['vercel'],