wiki-formant 0.13.0 → 0.16.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 (137) hide show
  1. package/README.md +12 -25
  2. package/bin/check-classes.mjs +2 -3
  3. package/dist/analytics.d.ts +0 -1
  4. package/dist/analytics.js +0 -1
  5. package/dist/block-views.d.ts +97 -0
  6. package/dist/block-views.js +119 -0
  7. package/dist/blocks.d.ts +0 -1
  8. package/dist/blocks.js +1 -2
  9. package/dist/combobox.d.ts +0 -1
  10. package/dist/combobox.js +0 -1
  11. package/dist/conformance.d.ts +0 -3
  12. package/dist/conformance.js +40 -38
  13. package/dist/crawlers.d.ts +0 -1
  14. package/dist/crawlers.js +0 -1
  15. package/dist/dom.d.ts +0 -1
  16. package/dist/dom.js +0 -1
  17. package/dist/editor.d.ts +101 -0
  18. package/dist/editor.js +218 -0
  19. package/dist/feed.d.ts +0 -1
  20. package/dist/feed.js +0 -1
  21. package/dist/freshness.d.ts +0 -1
  22. package/dist/freshness.js +2 -2
  23. package/dist/headings.d.ts +0 -1
  24. package/dist/headings.js +1 -3
  25. package/dist/html.d.ts +16 -0
  26. package/dist/html.js +23 -0
  27. package/dist/http.d.ts +20 -1
  28. package/dist/http.js +23 -4
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.js +2 -2
  31. package/dist/license.d.ts +0 -1
  32. package/dist/license.js +0 -1
  33. package/dist/link-check.d.ts +92 -0
  34. package/dist/link-check.js +249 -0
  35. package/dist/links.d.ts +0 -1
  36. package/dist/links.js +1 -6
  37. package/dist/maps.d.ts +0 -1
  38. package/dist/maps.js +2 -2
  39. package/dist/markdown.d.ts +1 -1
  40. package/dist/markdown.js +34 -3
  41. package/dist/mcp.d.ts +17 -7
  42. package/dist/mcp.js +8 -8
  43. package/dist/pagination.d.ts +19 -1
  44. package/dist/pagination.js +21 -1
  45. package/dist/rate-limit.d.ts +18 -1
  46. package/dist/rate-limit.js +22 -1
  47. package/dist/react-server.d.ts +91 -9
  48. package/dist/react-server.js +68 -16
  49. package/dist/react.d.ts +37 -48
  50. package/dist/react.js +44 -23
  51. package/dist/revisions.d.ts +0 -1
  52. package/dist/revisions.js +0 -1
  53. package/dist/rola.d.ts +0 -1
  54. package/dist/rola.js +0 -1
  55. package/dist/seeded.d.ts +11 -0
  56. package/dist/seeded.js +32 -0
  57. package/dist/sidebar.d.ts +0 -1
  58. package/dist/sidebar.js +0 -1
  59. package/dist/taxonomy.d.ts +0 -1
  60. package/dist/taxonomy.js +0 -1
  61. package/dist/text.d.ts +14 -1
  62. package/dist/text.js +15 -2
  63. package/dist/tiptap.d.ts +1 -1
  64. package/dist/tiptap.js +1 -2
  65. package/dist/validation.d.ts +2 -3
  66. package/dist/validation.js +4 -5
  67. package/dist/versioning.d.ts +0 -1
  68. package/dist/versioning.js +0 -1
  69. package/dist/well-known.d.ts +19 -2
  70. package/dist/well-known.js +28 -2
  71. package/dist/x402.d.ts +2 -11
  72. package/dist/x402.js +4 -16
  73. package/package.json +52 -27
  74. package/dist/analytics.d.ts.map +0 -1
  75. package/dist/analytics.js.map +0 -1
  76. package/dist/blocks.d.ts.map +0 -1
  77. package/dist/blocks.js.map +0 -1
  78. package/dist/combobox.d.ts.map +0 -1
  79. package/dist/combobox.js.map +0 -1
  80. package/dist/conformance.d.ts.map +0 -1
  81. package/dist/conformance.js.map +0 -1
  82. package/dist/crawlers.d.ts.map +0 -1
  83. package/dist/crawlers.js.map +0 -1
  84. package/dist/dom.d.ts.map +0 -1
  85. package/dist/dom.js.map +0 -1
  86. package/dist/entities.d.ts +0 -3
  87. package/dist/entities.d.ts.map +0 -1
  88. package/dist/entities.js +0 -30
  89. package/dist/entities.js.map +0 -1
  90. package/dist/feed.d.ts.map +0 -1
  91. package/dist/feed.js.map +0 -1
  92. package/dist/freshness.d.ts.map +0 -1
  93. package/dist/freshness.js.map +0 -1
  94. package/dist/headings.d.ts.map +0 -1
  95. package/dist/headings.js.map +0 -1
  96. package/dist/http.d.ts.map +0 -1
  97. package/dist/http.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/license.d.ts.map +0 -1
  101. package/dist/license.js.map +0 -1
  102. package/dist/links.d.ts.map +0 -1
  103. package/dist/links.js.map +0 -1
  104. package/dist/maps.d.ts.map +0 -1
  105. package/dist/maps.js.map +0 -1
  106. package/dist/markdown.d.ts.map +0 -1
  107. package/dist/markdown.js.map +0 -1
  108. package/dist/mcp.d.ts.map +0 -1
  109. package/dist/mcp.js.map +0 -1
  110. package/dist/pagination.d.ts.map +0 -1
  111. package/dist/pagination.js.map +0 -1
  112. package/dist/rate-limit.d.ts.map +0 -1
  113. package/dist/rate-limit.js.map +0 -1
  114. package/dist/react-server.d.ts.map +0 -1
  115. package/dist/react-server.js.map +0 -1
  116. package/dist/react.d.ts.map +0 -1
  117. package/dist/react.js.map +0 -1
  118. package/dist/revisions.d.ts.map +0 -1
  119. package/dist/revisions.js.map +0 -1
  120. package/dist/rola.d.ts.map +0 -1
  121. package/dist/rola.js.map +0 -1
  122. package/dist/sidebar.d.ts.map +0 -1
  123. package/dist/sidebar.js.map +0 -1
  124. package/dist/taxonomy.d.ts.map +0 -1
  125. package/dist/taxonomy.js.map +0 -1
  126. package/dist/text.d.ts.map +0 -1
  127. package/dist/text.js.map +0 -1
  128. package/dist/tiptap.d.ts.map +0 -1
  129. package/dist/tiptap.js.map +0 -1
  130. package/dist/validation.d.ts.map +0 -1
  131. package/dist/validation.js.map +0 -1
  132. package/dist/versioning.d.ts.map +0 -1
  133. package/dist/versioning.js.map +0 -1
  134. package/dist/well-known.d.ts.map +0 -1
  135. package/dist/well-known.js.map +0 -1
  136. package/dist/x402.d.ts.map +0 -1
  137. package/dist/x402.js.map +0 -1
package/dist/editor.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use client';
2
+ // editor.tsx — the wiki rich-text editor's engine, minus its chrome.
3
+ //
4
+ // `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
5
+ // code block, tabs). This holds what sits around them: which extensions are
6
+ // configured how, what a paste is scrubbed down to, how a pasted URL becomes
7
+ // the right embed, and the state a toolbar reads. Those were byte-identical in
8
+ // both repos — the same twelve-entry extension array, the same paste scrubber,
9
+ // the same embed dispatch including the shortened-map async swap.
10
+ //
11
+ // TWO BUGS ARE FIXED HERE RATHER THAN PROPAGATED, and the reason this file
12
+ // exists at all is that each repo had exactly one of them:
13
+ //
14
+ // 1. `onChangeRef.current = onChange` was written DURING render in one copy.
15
+ // A ref mutated mid-render can tear under concurrent rendering. It is
16
+ // written in an effect here, and only read from events and timeouts, which
17
+ // run later.
18
+ // 2. The other copy had no `onBlur`. The change is debounced 150ms, and
19
+ // clicking Save blurs the editor before the click lands — so the final
20
+ // keystroke of every edit that ended in a click was dropped. Blur flushes
21
+ // the pending debounce.
22
+ //
23
+ // Neither repo was "behind": each had shipped the fix the other lacked, which
24
+ // is the drift that costs the most, because neither file looks like the one to
25
+ // fix.
26
+ //
27
+ // Every @tiptap package here is an OPTIONAL PEER. A consumer that only wants
28
+ // the taxonomy or the MCP transport installs none of them.
29
+ import StarterKit from '@tiptap/starter-kit';
30
+ import TiptapLink from '@tiptap/extension-link';
31
+ import TiptapImage from '@tiptap/extension-image';
32
+ import TiptapTable from '@tiptap/extension-table';
33
+ import TiptapTableRow from '@tiptap/extension-table-row';
34
+ import TiptapTableCell from '@tiptap/extension-table-cell';
35
+ import TiptapTableHeader from '@tiptap/extension-table-header';
36
+ import Placeholder from '@tiptap/extension-placeholder';
37
+ import { useEditor } from '@tiptap/react';
38
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
39
+ import { toMapEmbedUrl } from './maps.js';
40
+ // ---- paste scrubbing --------------------------------------------------------
41
+ /**
42
+ * Reduce pasted HTML to structure plus the three attributes that carry meaning.
43
+ *
44
+ * A paste from a word processor or a web page arrives carrying its whole
45
+ * stylesheet inline. Keeping any of it means the wiki's own typography loses to
46
+ * whatever the author copied from, per paragraph, invisibly — and `style` on a
47
+ * pasted node is also the cheapest way to smuggle a full-bleed overlay into a
48
+ * page body.
49
+ *
50
+ * Browser-only: it parses with `DOMParser`. Called from `transformPastedHTML`,
51
+ * which only ever runs in response to a paste.
52
+ */
53
+ export function cleanPastedHtml(html) {
54
+ const doc = new DOMParser().parseFromString(html, 'text/html');
55
+ doc.querySelectorAll('style, script, meta, link, svg, canvas, noscript').forEach(el => el.remove());
56
+ doc.querySelectorAll('*').forEach(el => {
57
+ el.removeAttribute('style');
58
+ el.removeAttribute('class');
59
+ el.removeAttribute('id');
60
+ Array.from(el.attributes).forEach(attr => {
61
+ if (!['href', 'src', 'alt'].includes(attr.name))
62
+ el.removeAttribute(attr.name);
63
+ });
64
+ });
65
+ return doc.body.innerHTML;
66
+ }
67
+ /**
68
+ * The editor's extension set.
69
+ *
70
+ * `codeBlock: false` on StarterKit is load-bearing: the consumer registers its
71
+ * own via `createCodeBlock`, and leaving StarterKit's in place would give the
72
+ * schema two nodes claiming the same name. Headings stop at h2 — the page title
73
+ * is the only h1 a wiki page has.
74
+ */
75
+ export function wikiEditorExtensions({ placeholder = '', nodes = [], } = {}) {
76
+ return [
77
+ StarterKit.configure({ heading: { levels: [2, 3, 4] }, codeBlock: false }),
78
+ TiptapLink.configure({ openOnClick: false, HTMLAttributes: { class: 'link' } }),
79
+ TiptapImage.configure({
80
+ inline: false,
81
+ allowBase64: true,
82
+ HTMLAttributes: { class: 'rounded-lg max-w-full' },
83
+ }),
84
+ TiptapTable.configure({ resizable: false, HTMLAttributes: { class: 'tiptap-table' } }),
85
+ TiptapTableRow,
86
+ TiptapTableCell.configure({ HTMLAttributes: { class: 'p-2' } }),
87
+ TiptapTableHeader.configure({ HTMLAttributes: { class: 'p-2 font-semibold bg-surface-1' } }),
88
+ ...nodes,
89
+ Placeholder.configure({ placeholder }),
90
+ ];
91
+ }
92
+ // ---- pasted URLs ------------------------------------------------------------
93
+ const YOUTUBE = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]+)/;
94
+ const TWEET = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
95
+ const SHORT_MAP = /maps\.app\.goo\.gl|goo\.gl\/maps/;
96
+ /**
97
+ * Turn a pasted URL into the richest node that fits it, falling back to a bare
98
+ * iframe. Order matters: a YouTube URL is also a valid iframe source, so the
99
+ * specific cases have to be tried before the general one.
100
+ *
101
+ * A shortened map link inserts IMMEDIATELY with `about:blank` and swaps its
102
+ * `src` once the redirect resolves. Pasting must not block on a network hop,
103
+ * and the node has to already exist for the reader to see anything happen. The
104
+ * swap re-finds the node by its `url` attribute rather than caching a position,
105
+ * because every keystroke between the paste and the resolve moves it.
106
+ */
107
+ export function insertEmbed(editor, url, { resolveMapUrl }) {
108
+ const yt = url.match(YOUTUBE);
109
+ if (yt) {
110
+ editor.chain().focus().setYoutubeVideo({ src: url }).run();
111
+ return;
112
+ }
113
+ const tw = url.match(TWEET);
114
+ if (tw) {
115
+ editor.chain().focus().insertContent({ type: 'twitterEmbed', attrs: { tweetId: tw[1], url } }).run();
116
+ return;
117
+ }
118
+ const mapSrc = toMapEmbedUrl(url);
119
+ if (mapSrc) {
120
+ editor.chain().focus().insertContent({ type: 'mapEmbed', attrs: { src: mapSrc, url } }).run();
121
+ return;
122
+ }
123
+ if (SHORT_MAP.test(url)) {
124
+ editor.chain().focus().insertContent({ type: 'mapEmbed', attrs: { src: 'about:blank', url } }).run();
125
+ void resolveMapUrl(url).then(src => {
126
+ if (!src)
127
+ return;
128
+ editor.state.doc.descendants((node, pos) => {
129
+ if (node.type.name === 'mapEmbed' && node.attrs.url === url && node.attrs.src === 'about:blank') {
130
+ editor.chain().setNodeSelection(pos).updateAttributes('mapEmbed', { src }).run();
131
+ return false;
132
+ }
133
+ return true;
134
+ });
135
+ });
136
+ return;
137
+ }
138
+ editor.chain().focus().insertContent({ type: 'iframe', attrs: { src: url } }).run();
139
+ }
140
+ /** The table controls, shown only while the selection is inside a table. */
141
+ export const TABLE_ACTIONS = [
142
+ ['addColumnAfter', '+Col'],
143
+ ['addRowAfter', '+Row'],
144
+ ['deleteColumn', '-Col', true],
145
+ ['deleteRow', '-Row', true],
146
+ ['deleteTable', '-Tbl', true],
147
+ ];
148
+ /**
149
+ * The editor, its upload plumbing and the state a toolbar reads.
150
+ *
151
+ * The toolbar itself stays with the consumer: the two wikis differ on which
152
+ * buttons exist, which icon set draws them, and whether a link is entered in a
153
+ * `window.prompt` or an inline field. Those are real differences. What they had
154
+ * no business differing on is everything below.
155
+ */
156
+ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], proseClass, uploadImage, debounceMs = 150, }) {
157
+ const fileInputRef = useRef(null);
158
+ const [isUploading, setIsUploading] = useState(false);
159
+ const initialValueRef = useRef(value);
160
+ const onChangeRef = useRef(onChange);
161
+ const debounceRef = useRef(undefined);
162
+ // Written AFTER render, not during: a ref mutated mid-render can tear under
163
+ // concurrent rendering. Only read from events and timeouts, which run later.
164
+ useEffect(() => {
165
+ onChangeRef.current = onChange;
166
+ });
167
+ const extensions = useMemo(() => wikiEditorExtensions({ placeholder, nodes }),
168
+ // `nodes` is built with `useMemo` by the consumer; rebuilding the array on
169
+ // every render would tear the editor down and lose the selection.
170
+ [placeholder, nodes]);
171
+ const editor = useEditor({
172
+ extensions,
173
+ editorProps: {
174
+ attributes: { class: `outline-none focus:outline-none ${proseClass} min-h-20` },
175
+ transformPastedHTML: cleanPastedHtml,
176
+ },
177
+ onUpdate: ({ editor }) => {
178
+ clearTimeout(debounceRef.current);
179
+ debounceRef.current = setTimeout(() => onChangeRef.current(editor.getHTML()), debounceMs);
180
+ },
181
+ // Flush the pending debounce on blur, so a quick Save — which blurs the
182
+ // editor before the click fires — never drops the final keystroke.
183
+ onBlur: ({ editor }) => {
184
+ clearTimeout(debounceRef.current);
185
+ onChangeRef.current(editor.getHTML());
186
+ },
187
+ immediatelyRender: false,
188
+ onCreate: ({ editor }) => {
189
+ if (initialValueRef.current) {
190
+ queueMicrotask(() => editor.commands.setContent(initialValueRef.current));
191
+ }
192
+ },
193
+ });
194
+ useEffect(() => () => clearTimeout(debounceRef.current), []);
195
+ // Accept an outside change, but never while the author is typing into it.
196
+ useEffect(() => {
197
+ if (!editor || editor.isFocused)
198
+ return;
199
+ if (editor.getHTML() !== value)
200
+ editor.commands.setContent(value);
201
+ }, [value, editor]);
202
+ const handleFileChange = useCallback(async (e) => {
203
+ const file = e.target.files?.[0];
204
+ if (!file || !editor)
205
+ return;
206
+ setIsUploading(true);
207
+ const url = await uploadImage(file);
208
+ if (url)
209
+ editor.chain().focus().setImage({ src: url }).run();
210
+ setIsUploading(false);
211
+ // Cleared so choosing the same file twice in a row still fires a change.
212
+ if (fileInputRef.current)
213
+ fileInputRef.current.value = '';
214
+ }, [editor, uploadImage]);
215
+ const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
216
+ const isActive = useCallback((a) => a ? Boolean(Array.isArray(a) ? editor?.isActive(a[0], a[1]) : editor?.isActive(a)) : false, [editor]);
217
+ return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload, isActive };
218
+ }
package/dist/feed.d.ts CHANGED
@@ -54,4 +54,3 @@ export interface FeedChannel {
54
54
  */
55
55
  export declare function renderFeed(channel: FeedChannel, items: readonly FeedItem[]): string;
56
56
  export declare const FEED_HEADERS: Record<string, string>;
57
- //# sourceMappingURL=feed.d.ts.map
package/dist/feed.js CHANGED
@@ -92,4 +92,3 @@ export const FEED_HEADERS = {
92
92
  'Content-Type': 'application/rss+xml; charset=utf-8',
93
93
  'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
94
94
  };
95
- //# sourceMappingURL=feed.js.map
@@ -17,4 +17,3 @@ export declare function isStale(page: FreshnessInput, now: number, maxAgeDays?:
17
17
  * being re-typed either side of the extraction.
18
18
  */
19
19
  export declare function freshnessNotice(page: FreshnessInput): string;
20
- //# sourceMappingURL=freshness.d.ts.map
package/dist/freshness.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // `now` is a parameter rather than a `Date.now()` call inside the function.
9
9
  // This runs during SSR, and a render that reads the clock is a render that can
10
10
  // disagree with the one the server just sent.
11
+ import { isoDate } from './html.js';
11
12
  export const DEFAULT_MAX_AGE_DAYS = 180;
12
13
  const DAY_MS = 86_400_000;
13
14
  /** Whole days between `date` and `now`, or null when there is no usable date. */
@@ -35,8 +36,7 @@ export function isStale(page, now, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
35
36
  */
36
37
  export function freshnessNotice(page) {
37
38
  const when = page.lastVerifiedAt
38
- ? `last verified ${new Date(page.lastVerifiedAt).toISOString().slice(0, 10)}`
39
+ ? `last verified ${isoDate(new Date(page.lastVerifiedAt))}`
39
40
  : 'not yet verified against sources';
40
41
  return `This page was ${when} and may be out of date. Please help re-check its facts against current sources and the live ledger.`;
41
42
  }
42
- //# sourceMappingURL=freshness.js.map
@@ -39,4 +39,3 @@ export declare function injectHeadingIds(html: string, options?: HeadingIdOption
39
39
  * consumers use only the injector above.
40
40
  */
41
41
  export declare function headingsFrom(html: string): Heading[];
42
- //# sourceMappingURL=headings.d.ts.map
package/dist/headings.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // otherwise mint the same id twice and every link to the second one lands on
18
18
  // the first. That was already a bug in the copy that lacked it, so this always
19
19
  // dedupes.
20
+ import { getAttr, stripTags } from './html.js';
20
21
  /** The default slug rule: lowercase words joined by hyphens. */
21
22
  export function slugifyHeading(text) {
22
23
  return text
@@ -27,8 +28,6 @@ export function slugifyHeading(text) {
27
28
  .replace(/^-+|-+$/g, '');
28
29
  }
29
30
  const HEADING = /<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>/gi;
30
- const stripTags = (s) => s.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim();
31
- const getAttr = (attrs, name) => attrs.match(new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? null;
32
31
  const defaultAnchor = (id) => `<a class="heading-anchor" href="#${id}" aria-label="Permalink to this section" tabindex="-1"></a>`;
33
32
  /**
34
33
  * Give every heading in `html` an id and a permalink anchor. Headings that
@@ -78,4 +77,3 @@ export function headingsFrom(html) {
78
77
  }
79
78
  return out;
80
79
  }
81
- //# sourceMappingURL=headings.js.map
package/dist/html.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tags out, the two entities that survive a strip decoded, whitespace trimmed.
3
+ *
4
+ * The `&nbsp;`/`&amp;` pass is not decoration: an anchor whose text is only a
5
+ * non-breaking space is an empty anchor, and without decoding it reads as
6
+ * non-empty and keeps its blank label.
7
+ */
8
+ export declare const stripTags: (s: string) => string;
9
+ /** Read one double-quoted attribute out of a tag's attribute string. */
10
+ export declare const getAttr: (attrs: string, name: string) => string | null;
11
+ /** Drop every occurrence of one attribute from a tag's attribute string. */
12
+ export declare const removeAttr: (attrs: string, name: string) => string;
13
+ /** `YYYY-MM-DD`, from either a Date or a string that already starts with one. */
14
+ export declare const isoDate: (d: Date | string) => string;
15
+ /** Join class names, dropping the falsy ones. The package depends on no `cn`. */
16
+ export declare const cx: (...parts: (string | false | undefined)[]) => string;
package/dist/html.js ADDED
@@ -0,0 +1,23 @@
1
+ // html.ts — the small string helpers several modules had each written for
2
+ // themselves. Internal: no subpath, not in the root barrel.
3
+ //
4
+ // Every one of these existed two, three or four times in this package, which is
5
+ // the same fault it was extracted to fix — the module headers in `headings.ts`
6
+ // and `text.ts` both record having inherited a copy from a sibling repo, and
7
+ // then the extraction kept the copy.
8
+ /**
9
+ * Tags out, the two entities that survive a strip decoded, whitespace trimmed.
10
+ *
11
+ * The `&nbsp;`/`&amp;` pass is not decoration: an anchor whose text is only a
12
+ * non-breaking space is an empty anchor, and without decoding it reads as
13
+ * non-empty and keeps its blank label.
14
+ */
15
+ export const stripTags = (s) => s.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim();
16
+ /** Read one double-quoted attribute out of a tag's attribute string. */
17
+ export const getAttr = (attrs, name) => attrs.match(new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? null;
18
+ /** Drop every occurrence of one attribute from a tag's attribute string. */
19
+ export const removeAttr = (attrs, name) => attrs.replace(new RegExp(`\\s${name}\\s*=\\s*"[^"]*"`, 'gi'), '');
20
+ /** `YYYY-MM-DD`, from either a Date or a string that already starts with one. */
21
+ export const isoDate = (d) => (typeof d === 'string' ? d : d.toISOString()).split('T')[0];
22
+ /** Join class names, dropping the falsy ones. The package depends on no `cn`. */
23
+ export const cx = (...parts) => parts.filter(Boolean).join(' ');
package/dist/http.d.ts CHANGED
@@ -81,4 +81,23 @@ export declare function pageLine(opts: {
81
81
  excerpt?: string;
82
82
  updated?: Date | string | null;
83
83
  }): string;
84
- //# sourceMappingURL=http.d.ts.map
84
+ /** The pair every corpus endpoint computes before it decides to render. */
85
+ export interface CorpusValidators {
86
+ etag: string;
87
+ lastModified: string;
88
+ }
89
+ /**
90
+ * A GET handler serving `build()` under corpus validators — or a 304 instead.
91
+ *
92
+ * The build is not called on a 304, which is the whole point: these are the
93
+ * most-recrawled and most expensive URLs a wiki serves, and rendering a corpus
94
+ * to discard it is the cost this exists to avoid.
95
+ *
96
+ * `validators` is a thunk rather than a value because it is a query. The depth
97
+ * or scope it closes over belongs in the seed — three depths sharing one ETag
98
+ * is legal (a tag is scoped to its URI) and still wrong in the case that
99
+ * matters: an edit to one depth's own preamble moves no page row, so the tag
100
+ * would not move and the stale document would be served until something else
101
+ * in the corpus changed.
102
+ */
103
+ export declare function corpusRoute(validators: () => Promise<CorpusValidators> | CorpusValidators, build: () => Promise<string> | string, headers?: (etag: string, lastModified: string) => Record<string, string>): (request: Request) => Promise<Response>;
package/dist/http.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // most-recrawled URLs a wiki serves and the most expensive to render. With a
5
5
  // corpus-derived ETag a recrawl costs a 304 instead of a full corpus build.
6
6
  // Without one, every AI crawler pays full price on every pass, forever.
7
+ import { isoDate } from './html.js';
7
8
  /** A stable ETag from whatever the corpus revision is (count + newest stamp). */
8
9
  export function corpusEtag(parts) {
9
10
  const seed = parts
@@ -151,9 +152,27 @@ export function cleanSnippet(text, max = 160) {
151
152
  /** One markdown bullet: linked title, excerpt, and the date an agent diffs on. */
152
153
  export function pageLine(opts) {
153
154
  const excerpt = opts.excerpt ? `: ${cleanSnippet(opts.excerpt)}` : '';
154
- const stamp = opts.updated
155
- ? (typeof opts.updated === 'string' ? opts.updated : opts.updated.toISOString()).split('T')[0]
156
- : '';
155
+ const stamp = opts.updated ? isoDate(opts.updated) : '';
157
156
  return `- [${opts.title}](${opts.url})${excerpt}${stamp ? ` _(updated ${stamp})_` : ''}`;
158
157
  }
159
- //# sourceMappingURL=http.js.map
158
+ /**
159
+ * A GET handler serving `build()` under corpus validators — or a 304 instead.
160
+ *
161
+ * The build is not called on a 304, which is the whole point: these are the
162
+ * most-recrawled and most expensive URLs a wiki serves, and rendering a corpus
163
+ * to discard it is the cost this exists to avoid.
164
+ *
165
+ * `validators` is a thunk rather than a value because it is a query. The depth
166
+ * or scope it closes over belongs in the seed — three depths sharing one ETag
167
+ * is legal (a tag is scoped to its URI) and still wrong in the case that
168
+ * matters: an edit to one depth's own preamble moves no page row, so the tag
169
+ * would not move and the stale document would be served until something else
170
+ * in the corpus changed.
171
+ */
172
+ export function corpusRoute(validators, build, headers = textHeaders) {
173
+ return async (request) => {
174
+ const { etag, lastModified } = await validators();
175
+ return (notModified(request, etag, lastModified) ??
176
+ new Response(await build(), { headers: headers(etag, lastModified) }));
177
+ };
178
+ }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export * from './mcp.js';
3
3
  export * from './headings.js';
4
4
  export * from './links.js';
5
5
  export * from './markdown.js';
6
- export * from './entities.js';
6
+ export * from './markdown.js';
7
7
  export * from './http.js';
8
8
  export * from './pagination.js';
9
9
  export * from './versioning.js';
@@ -18,4 +18,4 @@ export * from './crawlers.js';
18
18
  export * from './revisions.js';
19
19
  export * from './feed.js';
20
20
  export * from './license.js';
21
- //# sourceMappingURL=index.d.ts.map
21
+ export * from './seeded.js';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export * from './mcp.js';
8
8
  export * from './headings.js';
9
9
  export * from './links.js';
10
10
  export * from './markdown.js';
11
- export * from './entities.js';
11
+ export * from './markdown.js';
12
12
  export * from './http.js';
13
13
  export * from './pagination.js';
14
14
  export * from './versioning.js';
@@ -23,4 +23,4 @@ export * from './crawlers.js';
23
23
  export * from './revisions.js';
24
24
  export * from './feed.js';
25
25
  export * from './license.js';
26
- //# sourceMappingURL=index.js.map
26
+ export * from './seeded.js';
package/dist/license.d.ts CHANGED
@@ -43,4 +43,3 @@ export declare function licenseLines({ license, scope, scopeVerb, excludes, head
43
43
  export declare function licenseBlock(opts: LicenseBlockOptions): string;
44
44
  /** The one-line form, for a frontmatter field or a feed's `<copyright>`. */
45
45
  export declare function licenseNote(license: License): string;
46
- //# sourceMappingURL=license.d.ts.map
package/dist/license.js CHANGED
@@ -47,4 +47,3 @@ export function licenseBlock(opts) {
47
47
  export function licenseNote(license) {
48
48
  return `${license.name} (${license.spdx}): ${license.url}`;
49
49
  }
50
- //# sourceMappingURL=license.js.map
@@ -0,0 +1,92 @@
1
+ export interface Probe {
2
+ url: string;
3
+ status: number;
4
+ ok: boolean;
5
+ error?: string;
6
+ code?: string;
7
+ tls?: string;
8
+ note?: string;
9
+ contentType?: string;
10
+ bytes?: number;
11
+ }
12
+ export interface ProbeOptions {
13
+ /** Ordinary per-attempt budget. */
14
+ timeoutMs?: number;
15
+ /** Second-attempt budget for a host that is merely slow. */
16
+ slowTimeoutMs?: number;
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
+ /**
35
+ * Run `fn` with at most one request in flight against this hostname.
36
+ *
37
+ * ECONNREFUSED looks deterministic and is not. A delayed retry was tried first
38
+ * and made things worse — 269 to 277 broken, the refusals unchanged and 8 fresh
39
+ * timeouts on hosts that had been fine — because the problem is not timing, it
40
+ * is how many sockets one host is asked for at once. The global pool still runs
41
+ * wide across DIFFERENT hosts; any single host is probed one request at a time,
42
+ * exactly as a hand re-probe does it.
43
+ */
44
+ export declare function perHost<T>(url: string, fn: () => Promise<T>): Promise<T>;
45
+ /**
46
+ * Probe one URL, honestly.
47
+ *
48
+ * A timeout is not a death and neither is a dropped connection: slow replays —
49
+ * web.archive.org above all, which a sweep leans on to rescue dying citations —
50
+ * exceed the ordinary budget and land as status 0. So a status-0 result with a
51
+ * transient code earns one more attempt on the longer budget before it is
52
+ * reported. A 404 does not: it is an answer.
53
+ */
54
+ export declare function probeUrl(url: string, opts?: ProbeOptions): Promise<Probe>;
55
+ export declare const YOUTUBE_EMBED: RegExp;
56
+ /**
57
+ * A YouTube `/embed/<id>` URL answers 200 for private, deleted and
58
+ * playback-restricted videos alike, so HEADing it can never spot a dead hero
59
+ * video. oEmbed can:
60
+ *
61
+ * 200 → public and embeddable | 404 → deleted | 401/403 → private or embedding off
62
+ */
63
+ export declare function probeYouTube(videoId: string, opts?: ProbeOptions): Promise<{
64
+ status: number;
65
+ ok: boolean;
66
+ reason?: string;
67
+ error?: string;
68
+ }>;
69
+ /** An external anchor, with a video treated as a video. */
70
+ export declare function probeExternal(url: string, opts?: ProbeOptions): Promise<Probe & {
71
+ videoId?: string;
72
+ }>;
73
+ /**
74
+ * Hosts that answer 200 with a JavaScript loader shell regardless of whether
75
+ * the deck / store / dataset behind the query string still exists. A status
76
+ * check on these is meaningless, so a caller should report them as unverifiable
77
+ * rather than healthy — a green row is worse than an unknown one, because
78
+ * nobody looks at it again.
79
+ */
80
+ export declare function unverifiableReason(url: string, hosts: ReadonlyMap<string, string>): string | null;
81
+ /** Every anchor in a fragment, as `{ href, text }`. */
82
+ export declare function extractLinks(html: string): Array<{
83
+ href: string;
84
+ text: string;
85
+ }>;
86
+ /** Every `<iframe>` and `<img>` source in a fragment. */
87
+ export declare function extractEmbeds(html: string): Array<{
88
+ kind: string;
89
+ url: string;
90
+ }>;
91
+ /** `Promise.all` with a ceiling, preserving input order in the results. */
92
+ export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;