wiki-formant 0.48.0 → 0.50.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.
Files changed (79) hide show
  1. package/README.md +85 -27
  2. package/bin/maintenance-log.mjs +198 -0
  3. package/bin/mark-verified.mjs +75 -0
  4. package/bin/prisma-models.mjs +53 -0
  5. package/dist/analytics.d.ts +0 -25
  6. package/dist/analytics.js +10 -49
  7. package/dist/base.css +23 -2
  8. package/dist/blocks.d.ts +7 -6
  9. package/dist/blocks.js +11 -3
  10. package/dist/chart.d.ts +0 -13
  11. package/dist/chart.js +4 -0
  12. package/dist/combobox.d.ts +0 -4
  13. package/dist/combobox.js +2 -0
  14. package/dist/crawlers.d.ts +0 -24
  15. package/dist/crawlers.js +9 -3
  16. package/dist/dom.d.ts +0 -5
  17. package/dist/dom.js +2 -0
  18. package/dist/editor.d.ts +0 -43
  19. package/dist/editor.js +7 -1
  20. package/dist/feed.d.ts +0 -14
  21. package/dist/feed.js +4 -0
  22. package/dist/freshness.d.ts +0 -7
  23. package/dist/freshness.js +3 -0
  24. package/dist/headers.d.ts +0 -8
  25. package/dist/headers.js +3 -1
  26. package/dist/headings.d.ts +0 -8
  27. package/dist/headings.js +2 -0
  28. package/dist/history.d.ts +0 -6
  29. package/dist/history.js +2 -0
  30. package/dist/http.d.ts +0 -11
  31. package/dist/http.js +5 -0
  32. package/dist/license.d.ts +0 -8
  33. package/dist/license.js +2 -0
  34. package/dist/link-check.d.ts +0 -21
  35. package/dist/link-check.js +6 -2
  36. package/dist/links.d.ts +0 -5
  37. package/dist/links.js +2 -0
  38. package/dist/maintenance.d.ts +61 -0
  39. package/dist/maintenance.js +137 -0
  40. package/dist/maps.d.ts +0 -6
  41. package/dist/maps.js +2 -0
  42. package/dist/markdown.d.ts +0 -2
  43. package/dist/markdown.js +1 -1
  44. package/dist/mcp.d.ts +0 -14
  45. package/dist/mcp.js +1 -1
  46. package/dist/metadata.d.ts +14 -11
  47. package/dist/metadata.js +33 -0
  48. package/dist/pagination.d.ts +0 -5
  49. package/dist/pagination.js +0 -4
  50. package/dist/passkey.js +2 -15
  51. package/dist/rate-limit.d.ts +5 -16
  52. package/dist/rate-limit.js +10 -1
  53. package/dist/react-server.d.ts +0 -12
  54. package/dist/react-server.js +2 -0
  55. package/dist/revisions.d.ts +0 -7
  56. package/dist/revisions.js +2 -0
  57. package/dist/rola.d.ts +74 -0
  58. package/dist/rola.js +97 -0
  59. package/dist/search.d.ts +0 -12
  60. package/dist/search.js +4 -0
  61. package/dist/sidebar.d.ts +0 -16
  62. package/dist/sidebar.js +2 -0
  63. package/dist/stats.d.ts +0 -4
  64. package/dist/stats.js +2 -2
  65. package/dist/taxonomy.d.ts +0 -4
  66. package/dist/taxonomy.js +2 -2
  67. package/dist/text.d.ts +16 -22
  68. package/dist/text.js +39 -5
  69. package/dist/tiptap.d.ts +5 -4
  70. package/dist/tiptap.js +2 -1
  71. package/dist/upload.d.ts +45 -0
  72. package/dist/upload.js +106 -0
  73. package/dist/validation.d.ts +0 -6
  74. package/dist/validation.js +3 -3
  75. package/dist/well-known.d.ts +0 -20
  76. package/dist/well-known.js +3 -0
  77. package/package.json +37 -8
  78. package/prisma/analytics.prisma +57 -0
  79. package/prisma/passkey.prisma +21 -0
package/dist/headers.js CHANGED
@@ -24,12 +24,14 @@ export const DEFAULT_IFRAME_HOSTS = [
24
24
  'www.google.com', 'maps.google.com',
25
25
  'embed.apple.com', 'maps.apple.com',
26
26
  ];
27
- /** `frame-src` sources for a host list — the one a sanitiser was given. */
27
+ /** `frame-src` sources for a host list — the one a sanitiser was given. @internal */
28
28
  export const frameSources = (hosts = DEFAULT_IFRAME_HOSTS) => hosts.length ? hosts.map(h => `https://${h}`) : ["'none'"];
29
29
  /**
30
30
  * The policy all four origins shared line for line. Everything a site adds is
31
31
  * a source list it hands `contentSecurityPolicy`, which replaces the default
32
32
  * for that directive rather than appending, so a reader sees the whole list.
33
+ *
34
+ * @internal
33
35
  */
34
36
  export const BASE_CSP = {
35
37
  'default-src': ["'self'"],
@@ -53,14 +53,6 @@ export declare function injectHeadingIds(html: string, options?: HeadingIdOption
53
53
  * consumers use only the injector above.
54
54
  */
55
55
  export declare function headingsFrom(html: string): Heading[];
56
- /**
57
- * Every heading in `html`, in document order, whether or not it carries an id —
58
- * the outline a crawler reads. `headingsFrom` above answers a different
59
- * question: which headings a rail can link to, which is why it drops the ones
60
- * with no id. An audit must not, because a chrome heading with no id still
61
- * takes a level in the outline, and the levels are the thing under test.
62
- */
63
- export declare function headingOutline(html: string): Heading[];
64
56
  /**
65
57
  * What is wrong with a page's heading outline.
66
58
  *
package/dist/headings.js CHANGED
@@ -92,6 +92,8 @@ export function headingsFrom(html) {
92
92
  * question: which headings a rail can link to, which is why it drops the ones
93
93
  * with no id. An audit must not, because a chrome heading with no id still
94
94
  * takes a level in the outline, and the levels are the thing under test.
95
+ *
96
+ * @internal
95
97
  */
96
98
  export function headingOutline(html) {
97
99
  const out = [];
package/dist/history.d.ts CHANGED
@@ -3,12 +3,6 @@ import { type BlockChange, type BlockGroup, type DiffBlock } from './revisions.j
3
3
  export type DiffPart = [-1 | 0 | 1, string];
4
4
  /** A change as a history view lists it: the diff it draws, not the two bodies behind it. */
5
5
  export type HistoryChange = BlockChange<DiffPart[]>;
6
- /**
7
- * Two HTML bodies as the words struck and added between them, or `undefined`
8
- * when their text is the same. Semantic cleanup is on: without it a rewritten
9
- * sentence comes out as fragments of the letters the two versions share.
10
- */
11
- export declare function textDiff(from: string, to: string): DiffPart[] | undefined;
12
6
  export interface HistoryOptions<B> {
13
7
  /** Defaults to `coreBlockGroups`, as in `diffBlocks`. */
14
8
  containers?: (block: B) => BlockGroup<B>[] | null;
package/dist/history.js CHANGED
@@ -20,6 +20,8 @@ const clip = (t) => (t.length > KEPT_MAX ? `${t.slice(0, 30)}…${t.slice(-30)}`
20
20
  * Two HTML bodies as the words struck and added between them, or `undefined`
21
21
  * when their text is the same. Semantic cleanup is on: without it a rewritten
22
22
  * sentence comes out as fragments of the letters the two versions share.
23
+ *
24
+ * @internal
23
25
  */
24
26
  export function textDiff(from, to) {
25
27
  const a = stripHtml(from);
package/dist/http.d.ts CHANGED
@@ -76,17 +76,6 @@ export declare function wantsMarkdown(request: {
76
76
  export declare const VARY_ACCEPT: {
77
77
  readonly Vary: "Accept";
78
78
  };
79
- /**
80
- * Headers for a JSON descriptor — an agent card, an OpenAPI document, a
81
- * registry manifest. These are the documents a client refetches most and the
82
- * ones that had no validator at all: served as `Cache-Control: public` with no
83
- * `max-age`, a caller falls back to heuristic freshness and can never
84
- * revalidate, so a corrected card takes an unbounded time to reach anyone.
85
- */
86
- export declare function descriptorHeaders(etag: string, opts?: {
87
- maxAge?: number;
88
- extra?: Record<string, string>;
89
- }): Record<string, string>;
90
79
  /**
91
80
  * A JSON descriptor served with a validator, answering a conditional GET.
92
81
  *
package/dist/http.js CHANGED
@@ -141,6 +141,10 @@ export const VARY_ACCEPT = { Vary: 'Accept' };
141
141
  * ones that had no validator at all: served as `Cache-Control: public` with no
142
142
  * `max-age`, a caller falls back to heuristic freshness and can never
143
143
  * revalidate, so a corrected card takes an unbounded time to reach anyone.
144
+ * Every one is public and read by browser agents too, so each allows any
145
+ * origin; six of the nine routes passed that by hand and three did not.
146
+ *
147
+ * @internal
144
148
  */
145
149
  export function descriptorHeaders(etag, opts = {}) {
146
150
  const maxAge = opts.maxAge ?? 86400;
@@ -148,6 +152,7 @@ export function descriptorHeaders(etag, opts = {}) {
148
152
  'Content-Type': 'application/json; charset=utf-8',
149
153
  'Cache-Control': `public, max-age=300, s-maxage=${maxAge}, stale-while-revalidate=${maxAge * 7}`,
150
154
  ETag: etag,
155
+ 'Access-Control-Allow-Origin': '*',
151
156
  ...opts.extra,
152
157
  };
153
158
  }
package/dist/license.d.ts CHANGED
@@ -32,14 +32,6 @@ export interface LicenseBlockOptions {
32
32
  /** `Licence` where the site's prose says so. Defaults to `License`. */
33
33
  heading?: string;
34
34
  }
35
- /**
36
- * The block, as lines, ready to join into any text export.
37
- *
38
- * Returned as an array rather than a string because every caller splices it
39
- * into a document it is already building line by line, and a caller that wants
40
- * the string can join it.
41
- */
42
- export declare function licenseLines({ license, scope, scopeVerb, excludes, heading, }: LicenseBlockOptions): string[];
43
35
  export declare function licenseBlock(opts: LicenseBlockOptions): string;
44
36
  /** The one-line form, for a frontmatter field or a feed's `<copyright>`. */
45
37
  export declare function licenseNote(license: License): string;
package/dist/license.js CHANGED
@@ -24,6 +24,8 @@ export function ccBy40(opts) {
24
24
  * Returned as an array rather than a string because every caller splices it
25
25
  * into a document it is already building line by line, and a caller that wants
26
26
  * the string can join it.
27
+ *
28
+ * @internal
27
29
  */
28
30
  export function licenseLines({ license, scope = 'This content', scopeVerb = 'is', excludes = [], heading = 'License & Attribution', }) {
29
31
  return [
@@ -15,22 +15,6 @@ export interface ProbeOptions {
15
15
  /** Second-attempt budget for a host that is merely slow. */
16
16
  slowTimeoutMs?: number;
17
17
  }
18
- /**
19
- * Codes that flap. A sweep found 16 status-0 rows and 15 were one host, every
20
- * one answering 200 when probed alone. Carry the code through so a caller can
21
- * treat these as retryable rather than banking a death.
22
- */
23
- export declare const TRANSIENT_CODES: Set<string>;
24
- /** A failure as a report row: the message, the code, and the TLS caveat. */
25
- export declare function describeFailure(err: unknown): Pick<Probe, 'error' | 'code' | 'tls' | 'note'>;
26
- /**
27
- * The URL to probe INSTEAD, where the public page lies about its own health.
28
- *
29
- * npmjs.com serves 403 to scripted requests whether or not the package exists,
30
- * which made every package citation a permanent false positive. The registry
31
- * answers honestly.
32
- */
33
- export declare function probeUrlFor(url: string): string;
34
18
  /**
35
19
  * Run `fn` with at most one request in flight against this hostname.
36
20
  *
@@ -59,11 +43,6 @@ export declare function extractLinks(html: string): Array<{
59
43
  href: string;
60
44
  text: string;
61
45
  }>;
62
- /** Every `<iframe>` and `<img>` source in a fragment. */
63
- export declare function extractEmbeds(html: string): Array<{
64
- kind: string;
65
- url: string;
66
- }>;
67
46
  /** What a page links to, split the way a checker probes it. */
68
47
  export interface BlockLinks {
69
48
  /** Absolute http(s) targets. */
@@ -36,6 +36,8 @@ const TLS_CODES = new Set([
36
36
  * Codes that flap. A sweep found 16 status-0 rows and 15 were one host, every
37
37
  * one answering 200 when probed alone. Carry the code through so a caller can
38
38
  * treat these as retryable rather than banking a death.
39
+ *
40
+ * @internal
39
41
  */
40
42
  export const TRANSIENT_CODES = new Set([
41
43
  'UND_ERR_CONNECT_TIMEOUT',
@@ -48,7 +50,7 @@ export const TRANSIENT_CODES = new Set([
48
50
  'EAI_AGAIN',
49
51
  'EPIPE',
50
52
  ]);
51
- /** A failure as a report row: the message, the code, and the TLS caveat. */
53
+ /** A failure as a report row: the message, the code, and the TLS caveat. @internal */
52
54
  export function describeFailure(err) {
53
55
  const e = err;
54
56
  if (e?.name === 'AbortError')
@@ -71,6 +73,8 @@ export function describeFailure(err) {
71
73
  * npmjs.com serves 403 to scripted requests whether or not the package exists,
72
74
  * which made every package citation a permanent false positive. The registry
73
75
  * answers honestly.
76
+ *
77
+ * @internal
74
78
  */
75
79
  export function probeUrlFor(url) {
76
80
  const npm = url.match(/^https:\/\/(?:www\.)?npmjs\.com\/package\/(.+?)\/?$/);
@@ -223,7 +227,7 @@ export function extractLinks(html) {
223
227
  }
224
228
  return out;
225
229
  }
226
- /** Every `<iframe>` and `<img>` source in a fragment. */
230
+ /** Every `<iframe>` and `<img>` source in a fragment. @internal */
227
231
  export function extractEmbeds(html) {
228
232
  const out = [];
229
233
  for (const m of html.matchAll(EMBED_RE)) {
package/dist/links.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * A readable label for an anchor whose text is empty, derived from its href.
3
- * Without one, crawlers and screen readers meet a link with nothing in it.
4
- */
5
- export declare function fallbackAnchorText(href: string): string;
6
1
  export interface NormaliseLinkOptions {
7
2
  /**
8
3
  * This wiki's own origin. An absolute link back to it is really internal, so
package/dist/links.js CHANGED
@@ -18,6 +18,8 @@ import { getAttr, removeAttr, stripTags } from './html.js';
18
18
  /**
19
19
  * A readable label for an anchor whose text is empty, derived from its href.
20
20
  * Without one, crawlers and screen readers meet a link with nothing in it.
21
+ *
22
+ * @internal
21
23
  */
22
24
  export function fallbackAnchorText(href) {
23
25
  if (href.startsWith('#'))
@@ -0,0 +1,61 @@
1
+ export type SourceListItem = string | {
2
+ handle?: string;
3
+ name?: string;
4
+ url?: string;
5
+ };
6
+ export interface SourceEntry {
7
+ lastChecked?: string | null;
8
+ note?: string;
9
+ list?: SourceListItem[];
10
+ }
11
+ export type BacklogEntry = string | {
12
+ date?: string;
13
+ item: string;
14
+ status?: string;
15
+ };
16
+ export type FeedbackEntry = string | {
17
+ date?: string;
18
+ note: string;
19
+ };
20
+ export interface RunEntry {
21
+ date?: string;
22
+ category?: string;
23
+ summary?: string;
24
+ tweet?: string;
25
+ }
26
+ export interface LogState {
27
+ mission?: string;
28
+ positioning?: string;
29
+ totalRuns?: number;
30
+ lastRun?: string | null;
31
+ rotation?: {
32
+ order?: string[];
33
+ next?: string;
34
+ };
35
+ sources?: Record<string, SourceEntry>;
36
+ backlog?: BacklogEntry[];
37
+ feedback?: FeedbackEntry[];
38
+ runHistory?: RunEntry[];
39
+ [key: string]: unknown;
40
+ }
41
+ /** Drops what is closed or a diary entry, and caps what is append-only. Newest first. */
42
+ export declare function compact(state: LogState): LogState;
43
+ /** New backlog items, deduplicated against what is there, dated today unless they carry a date. */
44
+ export declare function addBacklog(state: LogState, items: readonly BacklogEntry[], today: string): BacklogEntry[];
45
+ /** The page's content blocks, re-rendered from state on every write. `routine` names who keeps it. */
46
+ export declare function render(state: LogState, routine: string, newId?: () => string): {
47
+ id: string;
48
+ type: "content";
49
+ text: string;
50
+ }[];
51
+ /** A table or column name from the command line, quoted, or a thrown usage error. */
52
+ export declare function ident(name: string | undefined, what: string): string;
53
+ /**
54
+ * `--scope column` limits a bin to rows whose column is NULL, `--scope
55
+ * column=value` to rows where it equals the value: caper's platform pages
56
+ * have no caper, and its company pages share their tag paths.
57
+ */
58
+ export declare function scopeSql(scope: string | undefined, param: number): {
59
+ sql: string;
60
+ values: unknown[];
61
+ };
@@ -0,0 +1,137 @@
1
+ // maintenance.ts — what the `maintenance-log` and `mark-verified` bins share,
2
+ // kept pure so it is tested without a database.
3
+ //
4
+ // A wiki-sweep routine keeps its memory between runs (the rotation cursor,
5
+ // source watermarks, backlog, feedback and run history) in one wiki page's
6
+ // `metadata.state`, and that page shows a capped rendering of it. radix-wiki
7
+ // and caper each had the script, with the same caps and rules and a different
8
+ // table. The rules are here; the table is the bin's arguments.
9
+ //
10
+ // Every write compacts first. `backlog` holds only open work items, since a
11
+ // run diary entry belongs in `runHistory`, which it duplicates, and the
12
+ // append-only lists are capped. Uncapped, radix-wiki's log page reached 384 KB
13
+ // of content and 710 KB of state, and a revision per run made it over half of
14
+ // its revisions table. Two kinds of entry are pinned against the caps because
15
+ // a run cannot recreate them: a backlog item flagged for a human, and a
16
+ // watermark that banks a verdict on a whole class of site (a bot wall, a 403),
17
+ // which is what lets an audit spend no probes on its flag list.
18
+ //
19
+ // No subpath exports this: the bins and the tests import it from dist.
20
+ /** Held in state. */
21
+ const MAX = { backlog: 60, runs: 30, feedback: 40, sources: 150 };
22
+ /** Shown on the page. What a reader pays is words, and the entries run long. */
23
+ const SHOWN = { backlog: 15, runs: 5, feedback: 8, sources: 15 };
24
+ /** "run 214 (ecosystem): link audit CLEAN…" is a diary entry, not a work item. */
25
+ const JOURNAL = /^run\s+\d+\b/i;
26
+ const PINNED = /FLAG FOR A HUMAN/i;
27
+ const BANKED = /\bbanked\b|bot[- ]?(wall|block)|\b(403|429|999)\b|cloudflare/i;
28
+ const itemOf = (b) => (typeof b === 'string' ? b : b.item);
29
+ const recency = (a, b) => String(b[1]?.lastChecked ?? '').localeCompare(String(a[1]?.lastChecked ?? ''));
30
+ /** Drops what is closed or a diary entry, and caps what is append-only. Newest first. */
31
+ export function compact(state) {
32
+ // Undated items fall back to append order: a later index is newer.
33
+ const open = (state.backlog ?? [])
34
+ .map((b, i) => ({ status: 'open', ...(typeof b === 'string' ? { date: '', item: b } : b), i }))
35
+ .filter(b => b.status === 'open' && b.item && (PINNED.test(b.item) || !JOURNAL.test(b.item)))
36
+ .sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')) || b.i - a.i);
37
+ const backlog = [...open.filter(b => PINNED.test(b.item)), ...open.filter(b => !PINNED.test(b.item))]
38
+ .slice(0, MAX.backlog)
39
+ .map(({ i: _i, ...b }) => b);
40
+ // Most source keys are one-off verdicts on a single site. Sorting by
41
+ // lastChecked lets the cap age out only probes no run has touched since.
42
+ const entries = Object.entries(state.sources ?? {});
43
+ const banked = ([, v]) => BANKED.test(String(v?.note ?? ''));
44
+ const sources = Object.fromEntries([...entries.filter(banked), ...entries.filter(e => !banked(e)).sort(recency).slice(0, MAX.sources)].sort(recency));
45
+ return {
46
+ ...state,
47
+ backlog,
48
+ sources,
49
+ feedback: (state.feedback ?? []).slice(-MAX.feedback),
50
+ runHistory: (state.runHistory ?? []).slice(-MAX.runs),
51
+ };
52
+ }
53
+ /** New backlog items, deduplicated against what is there, dated today unless they carry a date. */
54
+ export function addBacklog(state, items, today) {
55
+ const seen = new Set((state.backlog ?? []).map(itemOf));
56
+ const added = [];
57
+ for (const raw of items) {
58
+ const item = itemOf(raw);
59
+ if (!item || seen.has(item))
60
+ continue;
61
+ seen.add(item);
62
+ added.push({ date: (typeof raw === 'object' && raw.date) || today, item, status: 'open' });
63
+ }
64
+ return added;
65
+ }
66
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
67
+ const more = (shown, total, what) => total > shown ? `<p><em>${total - shown} older ${what} held in state.</em></p>` : '';
68
+ const table = (head, rows) => `<table><tbody><tr>${head.map(h => `<td><strong>${h}</strong></td>`).join('')}</tr>` +
69
+ (rows.join('') || `<tr>${head.map(() => '<td>–</td>').join('')}</tr>`) +
70
+ '</tbody></table>';
71
+ /** The page's content blocks, re-rendered from state on every write. `routine` names who keeps it. */
72
+ export function render(state, routine, newId = () => crypto.randomUUID()) {
73
+ const rot = state.rotation ?? {};
74
+ const backlog = state.backlog ?? [];
75
+ const feedback = state.feedback ?? [];
76
+ const sources = Object.entries(state.sources ?? {}).sort(recency);
77
+ const block = (text) => ({ id: newId(), type: 'content', text });
78
+ const label = (x) => (typeof x === 'string' ? x : (x.handle ?? x.name ?? x.url ?? ''));
79
+ const sourceRows = sources.slice(0, SHOWN.sources).map(([key, v]) => {
80
+ const watch = (v.list ?? []).map(label).filter(Boolean);
81
+ const watching = watch.length ? ` — watching: ${esc(watch.slice(0, 8).join(', '))}${watch.length > 8 ? '…' : ''}` : '';
82
+ return `<tr><td>${esc(key)}</td><td>${esc(v.lastChecked || '–')}</td><td>${esc(v.note)}${watching}</td></tr>`;
83
+ });
84
+ const runRows = (state.runHistory ?? []).slice(-SHOWN.runs).reverse().map(r => {
85
+ const tweet = r.tweet && /^https?:\/\//.test(r.tweet) ? `<a href="${esc(r.tweet)}">link</a>` : esc(r.tweet);
86
+ return `<tr><td>${esc(r.date)}</td><td>${esc(r.category)}</td><td>${esc(r.summary)}</td><td>${tweet}</td></tr>`;
87
+ });
88
+ return [
89
+ block(`<p><em>Auto-maintained by the <code>${esc(routine)}</code> routine. Each run records what was swept, ` +
90
+ 'advances the category rotation, logs ecosystem signals, and tracks community reception. ' +
91
+ 'Do not hand-edit the state block – the routine overwrites it.</em></p>' +
92
+ (state.mission ? `<p><strong>Mission:</strong> ${esc(state.mission)}</p>` : '') +
93
+ (state.positioning ? `<p><strong>Positioning:</strong> ${esc(state.positioning)}</p>` : '')),
94
+ block('<h2>Current status</h2><table><tbody>' +
95
+ `<tr><td><strong>Next category in rotation</strong></td><td>${esc(rot.next || '–')}</td></tr>` +
96
+ `<tr><td><strong>Rotation order</strong></td><td>${esc((rot.order ?? []).join(' → '))}</td></tr>` +
97
+ `<tr><td><strong>Last run</strong></td><td>${esc(state.lastRun || '–')}</td></tr>` +
98
+ `<tr><td><strong>Total runs</strong></td><td>${esc(state.totalRuns ?? 0)}</td></tr>` +
99
+ '</tbody></table>'),
100
+ block(`<h2>Signal watermarks</h2>${table(['Source', 'Last checked', 'Note'], sourceRows)}` +
101
+ more(SHOWN.sources, sources.length, 'watermarks')),
102
+ block('<h2>Open backlog</h2>' +
103
+ (backlog.length
104
+ ? `<ul>${backlog.slice(0, SHOWN.backlog).map(b => `<li>${esc(itemOf(b))}</li>`).join('')}</ul>` +
105
+ more(SHOWN.backlog, backlog.length, 'open items')
106
+ : '<p><em>Empty.</em></p>')),
107
+ block('<h2>Community feedback &amp; reception</h2>' +
108
+ (feedback.length
109
+ ? `<ul>${feedback
110
+ .slice(-SHOWN.feedback)
111
+ .reverse()
112
+ .map(f => `<li>${esc(typeof f === 'string' ? f : `${f.date ?? ''} – ${f.note}`)}</li>`)
113
+ .join('')}</ul>` + more(SHOWN.feedback, feedback.length, 'entries')
114
+ : '<p><em>Nothing logged yet.</em></p>')),
115
+ block(`<h2>Recent runs</h2>${table(['Date', 'Category', 'Summary', 'Tweet'], runRows)}`),
116
+ ];
117
+ }
118
+ const IDENT = /^[a-z_][a-z0-9_]*$/;
119
+ /** A table or column name from the command line, quoted, or a thrown usage error. */
120
+ export function ident(name, what) {
121
+ if (!name || !IDENT.test(name))
122
+ throw new Error(`${what} must be a lower-case SQL identifier, got ${JSON.stringify(name)}`);
123
+ return `"${name}"`;
124
+ }
125
+ /**
126
+ * `--scope column` limits a bin to rows whose column is NULL, `--scope
127
+ * column=value` to rows where it equals the value: caper's platform pages
128
+ * have no caper, and its company pages share their tag paths.
129
+ */
130
+ export function scopeSql(scope, param) {
131
+ if (!scope)
132
+ return { sql: 'TRUE', values: [] };
133
+ const [column, value] = scope.split('=');
134
+ return value === undefined
135
+ ? { sql: `${ident(column, '--scope')} IS NULL`, values: [] }
136
+ : { sql: `${ident(column, '--scope')} = $${param}`, values: [value] };
137
+ }
package/dist/maps.d.ts CHANGED
@@ -3,12 +3,6 @@ export interface MapCoords {
3
3
  lon: number;
4
4
  zoom?: number | undefined;
5
5
  }
6
- /**
7
- * Dig a coordinate pair out of a maps URL. Four shapes in descending
8
- * specificity: the `@lat,lon,zoom` path segment, a `/search/lat,lon`, the
9
- * `!3d…!4d…` data blob, and finally an `ll`/`sll` query parameter.
10
- */
11
- export declare function extractCoordsFromUrl(url: string): MapCoords | null;
12
6
  /**
13
7
  * An embeddable URL for `url`, or null when it is not a map link this
14
8
  * understands. Already-embeddable URLs pass through untouched.
package/dist/maps.js CHANGED
@@ -12,6 +12,8 @@ function mapsEmbedUrl(lat, lon, zoom = 15) {
12
12
  * Dig a coordinate pair out of a maps URL. Four shapes in descending
13
13
  * specificity: the `@lat,lon,zoom` path segment, a `/search/lat,lon`, the
14
14
  * `!3d…!4d…` data blob, and finally an `ll`/`sll` query parameter.
15
+ *
16
+ * @internal
15
17
  */
16
18
  export function extractCoordsFromUrl(url) {
17
19
  const coords = url.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*),?(\d+\.?\d*)?z?/);
@@ -1,8 +1,6 @@
1
1
  export declare function decodeEntities(s: string): string;
2
2
  /** Inline-level HTML → markdown. Applied inside cells, list items, headings. */
3
3
  export declare function inlineToMarkdown(html: string): string;
4
- /** One `<table>` → a GFM table. Falls back to nothing when there are no rows. */
5
- export declare function tableToMarkdown(html: string): string;
6
4
  /** Block-level HTML → markdown. */
7
5
  export declare function htmlToMarkdown(html: string): string;
8
6
  export interface FrontmatterFields {
package/dist/markdown.js CHANGED
@@ -62,7 +62,7 @@ export function inlineToMarkdown(html) {
62
62
  .replace(/\s+/g, ' ')
63
63
  .trim();
64
64
  }
65
- /** One `<table>` → a GFM table. Falls back to nothing when there are no rows. */
65
+ /** One `<table>` → a GFM table. Falls back to nothing when there are no rows. @internal */
66
66
  export function tableToMarkdown(html) {
67
67
  const rows = [...html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(m => [...m[1].matchAll(/<(t[hd])\b[^>]*>([\s\S]*?)<\/\1>/gi)].map(c => inlineToMarkdown(c[2]).replace(/\|/g, '\\|')));
68
68
  if (!rows.length)
package/dist/mcp.d.ts CHANGED
@@ -265,20 +265,6 @@ export declare const toolText: (id: RpcRequest["id"], text: string, isError?: bo
265
265
  }[];
266
266
  };
267
267
  };
268
- /** The methods one era has and the other does not. Everything else is shared. */
269
- declare const ERA_METHODS: {
270
- readonly legacy: readonly ["initialize", "ping"];
271
- readonly modern: readonly ["server/discover"];
272
- };
273
- type Era = keyof typeof ERA_METHODS;
274
- /** Everything the dispatcher needs that is not in the JSON-RPC entry itself. */
275
- interface Dispatch {
276
- protocolVersion: McpProtocolVersion;
277
- era: Era;
278
- request?: Request;
279
- }
280
- /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. */
281
- export declare function handleMcp(body: RpcRequest | RpcRequest[], config: McpServerConfig, dispatch?: Dispatch): Promise<object | object[] | null>;
282
268
  export declare const MCP_CORS: Record<string, string>;
283
269
  export declare function withMcpCors<T extends Response>(res: T): T;
284
270
  export declare function mcpOptions(): Response;
package/dist/mcp.js CHANGED
@@ -433,7 +433,7 @@ async function handleRpc(req, config, dispatch) {
433
433
  return rpcError(id, -32603, 'Internal error');
434
434
  }
435
435
  }
436
- /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. */
436
+ /** Dispatch a parsed body. `null` means notification-only — answer 202, not 200. @internal */
437
437
  export async function handleMcp(body, config, dispatch = { protocolVersion: ASSUMED_VERSION, era: 'legacy' }) {
438
438
  const maxBatch = config.maxBatch ?? DEFAULT_MAX_BATCH;
439
439
  const isBatch = Array.isArray(body);
@@ -152,17 +152,6 @@ export declare function faqPageLd(pairs: readonly QaPair[], url: string): LdNode
152
152
  * which is the number that matters to whoever writes the title.
153
153
  */
154
154
  export declare const TITLE_BUDGET = 60;
155
- /**
156
- * Past this, a title is not merely trimmed but cut into.
157
- *
158
- * Between the budget and this limit a title loses its tail, which is often the
159
- * site name and no loss at all. Past it, the words carrying what the page is
160
- * about are going too: the radix.wiki ideas board spent its first sixteen
161
- * characters on a working group and reached 93, and the symptom pages on
162
- * acuiq.com reached 88 before naming the symptom. That is the line worth
163
- * failing a build over; the band below it is worth reporting and no more.
164
- */
165
- export declare const TITLE_LIMIT = 70;
166
155
  /**
167
156
  * The document `<title>` for a page: its short form where the page carries one,
168
157
  * else its own title.
@@ -196,6 +185,20 @@ export declare function fittingTitle(variants: readonly string[], opts?: {
196
185
  suffix?: string;
197
186
  limit?: number;
198
187
  }): string;
188
+ /** Characters a search result shows of a meta description before it cuts. */
189
+ export declare const DESCRIPTION_LIMIT = 160;
190
+ /**
191
+ * A meta description from any prose: HTML, markdown or plain. Tags, entities,
192
+ * link targets and bare URLs go; what is left is fitted to `limit` as whole
193
+ * sentences where they fill at least half of it, else cut at a word and marked
194
+ * with an ellipsis. Never longer than `limit`.
195
+ *
196
+ * Each of the four sites had its own: one clamped at 150 mid-word, one sliced
197
+ * at 160 with no mark, one fitted sentences, and one kept whatever sentences
198
+ * fitted however few. Pass a page's own excerpt where it has one and its
199
+ * opening (`openingText`) where it does not: `describe(excerpt || openingText(blocks))`.
200
+ */
201
+ export declare function describe(text: string | null | undefined, limit?: number): string;
199
202
  /**
200
203
  * `pageMetadata` bound to a site's name, locale and handle, the three fields
201
204
  * Next drops from every page that sets its own card. Two wikis wrapped it to
package/dist/metadata.js CHANGED
@@ -19,6 +19,7 @@
19
19
  // with no image while their og:image named a card, one listed citations, one
20
20
  // collection page had no ItemList at all.
21
21
  import { decodeEntities } from './markdown.js';
22
+ import { plainText } from './text.js';
22
23
  export function pageMetadata(o) {
23
24
  const { title, description, url, type = 'website', image, imageSize = { width: 1200, height: 630 }, siteName, locale, handle, markdownTwin, article } = o;
24
25
  const images = image ? [{ url: image, ...imageSize, alt: o.imageAlt ?? title }] : undefined;
@@ -175,6 +176,8 @@ export const TITLE_BUDGET = 60;
175
176
  * characters on a working group and reached 93, and the symptom pages on
176
177
  * acuiq.com reached 88 before naming the symptom. That is the line worth
177
178
  * failing a build over; the band below it is worth reporting and no more.
179
+ *
180
+ * @internal
178
181
  */
179
182
  export const TITLE_LIMIT = 70;
180
183
  /**
@@ -216,6 +219,36 @@ export function fittingTitle(variants, opts = {}) {
216
219
  return '';
217
220
  return usable.find(v => v.length + suffix.length <= limit) ?? usable[usable.length - 1];
218
221
  }
222
+ /** Characters a search result shows of a meta description before it cuts. */
223
+ export const DESCRIPTION_LIMIT = 160;
224
+ /**
225
+ * A meta description from any prose: HTML, markdown or plain. Tags, entities,
226
+ * link targets and bare URLs go; what is left is fitted to `limit` as whole
227
+ * sentences where they fill at least half of it, else cut at a word and marked
228
+ * with an ellipsis. Never longer than `limit`.
229
+ *
230
+ * Each of the four sites had its own: one clamped at 150 mid-word, one sliced
231
+ * at 160 with no mark, one fitted sentences, and one kept whatever sentences
232
+ * fitted however few. Pass a page's own excerpt where it has one and its
233
+ * opening (`openingText`) where it does not: `describe(excerpt || openingText(blocks))`.
234
+ */
235
+ export function describe(text, limit = DESCRIPTION_LIMIT) {
236
+ const t = plainText(text ?? '')
237
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
238
+ .replace(/\((?:https?:\/\/|mailto:|\/|#)[^)\s]*\)/g, '')
239
+ .replace(/https?:\/\/\S+/g, '')
240
+ .replace(/\(\s*\)/g, '')
241
+ .replace(/\s+([,.;:!?])/g, '$1')
242
+ .replace(/\s{2,}/g, ' ')
243
+ .trim();
244
+ if (t.length <= limit)
245
+ return t;
246
+ const sentences = t.slice(0, limit + 1).match(/^.*[.!?](?=\s)/)?.[0];
247
+ if (sentences && sentences.length >= limit / 2)
248
+ return sentences;
249
+ const cut = t.lastIndexOf(' ', limit - 1);
250
+ return `${t.slice(0, cut > 0 ? cut : limit - 1).replace(/[\s,;:–—-]+$/, '')}…`;
251
+ }
219
252
  // ---- the site's own identity ----------------------------------------------------
220
253
  /**
221
254
  * `pageMetadata` bound to a site's name, locale and handle, the three fields
@@ -42,11 +42,6 @@ export declare function listEnvelope<T>(pages: T[], total: number, page: number,
42
42
  note?: string;
43
43
  pages: T[];
44
44
  };
45
- /** The `skip`/`take` an ORM wants, from the same clamped pair. */
46
- export declare function toOffset({ page, pageSize }: Pagination): {
47
- skip: number;
48
- take: number;
49
- };
50
45
  /**
51
46
  * The entries either side of the current one in an already-ordered list.
52
47
  *
@@ -52,10 +52,6 @@ export function listEnvelope(pages, total, page, pageSize, emptyNote) {
52
52
  pages,
53
53
  };
54
54
  }
55
- /** The `skip`/`take` an ORM wants, from the same clamped pair. */
56
- export function toOffset({ page, pageSize }) {
57
- return { skip: (page - 1) * pageSize, take: pageSize };
58
- }
59
55
  /**
60
56
  * The entries either side of the current one in an already-ordered list.
61
57
  *
package/dist/passkey.js CHANGED
@@ -10,21 +10,8 @@
10
10
  // not open the deployed site, and one saved on the site does not open localhost.
11
11
  //
12
12
  // Like analytics, the package owns the SQL and the app owns the connection. The
13
- // app declares the two tables in its own schema:
14
- //
15
- // model Passkey {
16
- // id String @id // the credential id, base64url
17
- // publicKey String // base64url
18
- // counter Int
19
- // transports String[]
20
- // createdAt DateTime @default(now()) @db.Timestamptz(3)
21
- // }
22
- //
23
- // model PasskeyToken {
24
- // hash String @id // sha-256 of the token, base64url
25
- // kind String // session | invite | challenge
26
- // expiresAt DateTime @db.Timestamptz(3)
27
- // }
13
+ // two tables are Prisma models shipped in `prisma/passkey.prisma`, which the
14
+ // `prisma-models` bin copies into the app's schema folder.
28
15
  //
29
16
  // Every token is stored as its hash, so a read of the table opens nothing. A
30
17
  // challenge is spent the moment a response quotes it, and an invite the moment