wiki-formant 0.47.1 → 0.49.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 +95 -26
  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 +17 -8
  11. package/dist/chart.js +53 -6
  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 +31 -0
  72. package/dist/upload.js +91 -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/editor.js CHANGED
@@ -71,6 +71,8 @@ function cleanPastedHtml(html) {
71
71
  * which the editor cannot know, so the render path decides: `normaliseLinks`
72
72
  * from `wiki-formant/links`. Dropping both attributes from the mark also cleans
73
73
  * a legacy link the next time its page is saved.
74
+ *
75
+ * @internal
74
76
  */
75
77
  export const wikiLink = TiptapLink.extend({
76
78
  addAttributes() {
@@ -117,7 +119,7 @@ const SHORT_MAP = /maps\.app\.goo\.gl|goo\.gl\/maps/;
117
119
  * swap re-finds the node by its `url` attribute rather than caching a position,
118
120
  * because every keystroke between the paste and the resolve moves it.
119
121
  */
120
- export function insertEmbed(editor, url, { resolveMapUrl }) {
122
+ function insertEmbed(editor, url, { resolveMapUrl }) {
121
123
  const yt = url.match(YOUTUBE);
122
124
  if (yt) {
123
125
  editor.chain().focus().setYoutubeVideo({ src: url }).run();
@@ -168,6 +170,8 @@ export function runTableAction(editor, command) {
168
170
  * two titled their buttons with the internal key — a screen reader said
169
171
  * "codeBlock", and inline code and code block shared one icon and one name.
170
172
  * Upload and embed stay with the caller: they are the parts that differ.
173
+ *
174
+ * @internal
171
175
  */
172
176
  export const TOOLBAR_ACTIONS = [
173
177
  { key: 'bold', label: 'Bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
@@ -220,6 +224,8 @@ export function ToolbarButton({ label, pressed, onPress, className, children, })
220
224
  * the link the cursor is in, across the whole link rather than the collapsed
221
225
  * selection, and an emptied address takes it off while keeping the text. An
222
226
  * embed with no address does nothing.
227
+ *
228
+ * @internal
223
229
  */
224
230
  export function applyUrlPrompt(editor, kind, url, { resolveMapUrl }) {
225
231
  const href = url.trim();
package/dist/feed.d.ts CHANGED
@@ -1,10 +1,4 @@
1
1
  export declare const escXml: (s: string) => string;
2
- /**
3
- * `content:encoded` carries raw HTML, so it travels inside CDATA. A literal
4
- * `]]>` in the body would close the section early and corrupt every item after
5
- * it — not just the one that contained it.
6
- */
7
- export declare const cdata: (html: string) => string;
8
2
  /**
9
3
  * Aggregators hard-truncate a description around 150 characters. Trimming on a
10
4
  * word boundary first means the cut lands between words rather than inside one.
@@ -45,14 +39,6 @@ export interface FeedChannel {
45
39
  */
46
40
  lastBuild?: Date | null;
47
41
  }
48
- /**
49
- * `lastBuildDate` comes from the newest item, not from the clock.
50
- *
51
- * A feed whose build date moves on every request tells every poller it changed
52
- * when it did not, which is exactly the recrawl the conditional-GET helpers
53
- * exist to prevent. An empty feed has no build date rather than a fictional one.
54
- */
55
- export declare function renderFeed(channel: FeedChannel, items: readonly FeedItem[]): string;
56
42
  /**
57
43
  * The feed as a response, with the validators a poller revalidates against.
58
44
  *
package/dist/feed.js CHANGED
@@ -28,6 +28,8 @@ export const escXml = (s) => s.replace(/[&<>"']/g, c => XML_ESCAPES[c] ?? c);
28
28
  * `content:encoded` carries raw HTML, so it travels inside CDATA. A literal
29
29
  * `]]>` in the body would close the section early and corrupt every item after
30
30
  * it — not just the one that contained it.
31
+ *
32
+ * @internal
31
33
  */
32
34
  export const cdata = (html) => `<![CDATA[${html.replace(/]]>/g, ']]&gt;')}]]>`;
33
35
  /**
@@ -69,6 +71,8 @@ export function renderItem(item) {
69
71
  * A feed whose build date moves on every request tells every poller it changed
70
72
  * when it did not, which is exactly the recrawl the conditional-GET helpers
71
73
  * exist to prevent. An empty feed has no build date rather than a fictional one.
74
+ *
75
+ * @internal
72
76
  */
73
77
  export function renderFeed(channel, items) {
74
78
  const newest = lastBuild(channel, items);
@@ -4,7 +4,6 @@ export interface FreshnessInput {
4
4
  lastVerifiedAt?: Date | string | null;
5
5
  updatedAt?: Date | string | null;
6
6
  }
7
- export declare const DEFAULT_MAX_AGE_DAYS = 180;
8
7
  /** Whole days between `date` and `now`, or null when there is no usable date. */
9
8
  export declare function daysSince(date: Date | string | null | undefined, now: number): number | null;
10
9
  /**
@@ -13,12 +12,6 @@ export declare function daysSince(date: Date | string | null | undefined, now: n
13
12
  * edit is a weaker signal than a verification, but it is a signal.
14
13
  */
15
14
  export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?: number): boolean;
16
- /**
17
- * The sentence the notice carries. Both wikis shipped this string identically,
18
- * down to the ISO date and the closing request, so it lives here rather than
19
- * being re-typed either side of the extraction.
20
- */
21
- export declare function freshnessNotice(page: FreshnessInput): string;
22
15
  /**
23
16
  * A synthetic `outdated` banner block for a stale page, or null when it is
24
17
  * fresh — the shape every wiki's `banner` type already stores, so it drops
package/dist/freshness.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // disagree with the one the server just sent.
11
11
  import { isoDate } from './html.js';
12
12
  export { isoDate };
13
+ /** @internal */
13
14
  export const DEFAULT_MAX_AGE_DAYS = 180;
14
15
  const DAY_MS = 86_400_000;
15
16
  /** Whole days between `date` and `now`, or null when there is no usable date. */
@@ -34,6 +35,8 @@ export function isStale(page, now, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
34
35
  * The sentence the notice carries. Both wikis shipped this string identically,
35
36
  * down to the ISO date and the closing request, so it lives here rather than
36
37
  * being re-typed either side of the extraction.
38
+ *
39
+ * @internal
37
40
  */
38
41
  export function freshnessNotice(page) {
39
42
  const when = page.lastVerifiedAt
package/dist/headers.d.ts CHANGED
@@ -9,16 +9,8 @@ export interface HeaderEntry {
9
9
  * embed that clears one clears the other.
10
10
  */
11
11
  export declare const DEFAULT_IFRAME_HOSTS: readonly string[];
12
- /** `frame-src` sources for a host list — the one a sanitiser was given. */
13
- export declare const frameSources: (hosts?: readonly string[]) => string[];
14
12
  /** CSP directive names this builder writes, in the order it writes them. */
15
13
  export type CspDirective = 'default-src' | 'base-uri' | 'object-src' | 'frame-ancestors' | 'form-action' | 'script-src' | 'style-src' | 'font-src' | 'img-src' | 'media-src' | 'connect-src' | 'frame-src' | 'worker-src' | 'manifest-src';
16
- /**
17
- * The policy all four origins shared line for line. Everything a site adds is
18
- * a source list it hands `contentSecurityPolicy`, which replaces the default
19
- * for that directive rather than appending, so a reader sees the whole list.
20
- */
21
- export declare const BASE_CSP: Readonly<Partial<Record<CspDirective, readonly string[]>>>;
22
14
  /**
23
15
  * The policy string: `BASE_CSP` with each directive in `directives` replacing
24
16
  * its default. An empty list drops the directive.
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);