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.
- package/README.md +12 -25
- package/bin/check-classes.mjs +2 -3
- package/dist/analytics.d.ts +0 -1
- package/dist/analytics.js +0 -1
- package/dist/block-views.d.ts +97 -0
- package/dist/block-views.js +119 -0
- package/dist/blocks.d.ts +0 -1
- package/dist/blocks.js +1 -2
- package/dist/combobox.d.ts +0 -1
- package/dist/combobox.js +0 -1
- package/dist/conformance.d.ts +0 -3
- package/dist/conformance.js +40 -38
- package/dist/crawlers.d.ts +0 -1
- package/dist/crawlers.js +0 -1
- package/dist/dom.d.ts +0 -1
- package/dist/dom.js +0 -1
- package/dist/editor.d.ts +101 -0
- package/dist/editor.js +218 -0
- package/dist/feed.d.ts +0 -1
- package/dist/feed.js +0 -1
- package/dist/freshness.d.ts +0 -1
- package/dist/freshness.js +2 -2
- package/dist/headings.d.ts +0 -1
- package/dist/headings.js +1 -3
- package/dist/html.d.ts +16 -0
- package/dist/html.js +23 -0
- package/dist/http.d.ts +20 -1
- package/dist/http.js +23 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/license.d.ts +0 -1
- package/dist/license.js +0 -1
- package/dist/link-check.d.ts +92 -0
- package/dist/link-check.js +249 -0
- package/dist/links.d.ts +0 -1
- package/dist/links.js +1 -6
- package/dist/maps.d.ts +0 -1
- package/dist/maps.js +2 -2
- package/dist/markdown.d.ts +1 -1
- package/dist/markdown.js +34 -3
- package/dist/mcp.d.ts +17 -7
- package/dist/mcp.js +8 -8
- package/dist/pagination.d.ts +19 -1
- package/dist/pagination.js +21 -1
- package/dist/rate-limit.d.ts +18 -1
- package/dist/rate-limit.js +22 -1
- package/dist/react-server.d.ts +91 -9
- package/dist/react-server.js +68 -16
- package/dist/react.d.ts +37 -48
- package/dist/react.js +44 -23
- package/dist/revisions.d.ts +0 -1
- package/dist/revisions.js +0 -1
- package/dist/rola.d.ts +0 -1
- package/dist/rola.js +0 -1
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +32 -0
- package/dist/sidebar.d.ts +0 -1
- package/dist/sidebar.js +0 -1
- package/dist/taxonomy.d.ts +0 -1
- package/dist/taxonomy.js +0 -1
- package/dist/text.d.ts +14 -1
- package/dist/text.js +15 -2
- package/dist/tiptap.d.ts +1 -1
- package/dist/tiptap.js +1 -2
- package/dist/validation.d.ts +2 -3
- package/dist/validation.js +4 -5
- package/dist/versioning.d.ts +0 -1
- package/dist/versioning.js +0 -1
- package/dist/well-known.d.ts +19 -2
- package/dist/well-known.js +28 -2
- package/dist/x402.d.ts +2 -11
- package/dist/x402.js +4 -16
- package/package.json +52 -27
- package/dist/analytics.d.ts.map +0 -1
- package/dist/analytics.js.map +0 -1
- package/dist/blocks.d.ts.map +0 -1
- package/dist/blocks.js.map +0 -1
- package/dist/combobox.d.ts.map +0 -1
- package/dist/combobox.js.map +0 -1
- package/dist/conformance.d.ts.map +0 -1
- package/dist/conformance.js.map +0 -1
- package/dist/crawlers.d.ts.map +0 -1
- package/dist/crawlers.js.map +0 -1
- package/dist/dom.d.ts.map +0 -1
- package/dist/dom.js.map +0 -1
- package/dist/entities.d.ts +0 -3
- package/dist/entities.d.ts.map +0 -1
- package/dist/entities.js +0 -30
- package/dist/entities.js.map +0 -1
- package/dist/feed.d.ts.map +0 -1
- package/dist/feed.js.map +0 -1
- package/dist/freshness.d.ts.map +0 -1
- package/dist/freshness.js.map +0 -1
- package/dist/headings.d.ts.map +0 -1
- package/dist/headings.js.map +0 -1
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/license.d.ts.map +0 -1
- package/dist/license.js.map +0 -1
- package/dist/links.d.ts.map +0 -1
- package/dist/links.js.map +0 -1
- package/dist/maps.d.ts.map +0 -1
- package/dist/maps.js.map +0 -1
- package/dist/markdown.d.ts.map +0 -1
- package/dist/markdown.js.map +0 -1
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js.map +0 -1
- package/dist/pagination.d.ts.map +0 -1
- package/dist/pagination.js.map +0 -1
- package/dist/rate-limit.d.ts.map +0 -1
- package/dist/rate-limit.js.map +0 -1
- package/dist/react-server.d.ts.map +0 -1
- package/dist/react-server.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/revisions.d.ts.map +0 -1
- package/dist/revisions.js.map +0 -1
- package/dist/rola.d.ts.map +0 -1
- package/dist/rola.js.map +0 -1
- package/dist/sidebar.d.ts.map +0 -1
- package/dist/sidebar.js.map +0 -1
- package/dist/taxonomy.d.ts.map +0 -1
- package/dist/taxonomy.js.map +0 -1
- package/dist/text.d.ts.map +0 -1
- package/dist/text.js.map +0 -1
- package/dist/tiptap.d.ts.map +0 -1
- package/dist/tiptap.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/versioning.d.ts.map +0 -1
- package/dist/versioning.js.map +0 -1
- package/dist/well-known.d.ts.map +0 -1
- package/dist/well-known.js.map +0 -1
- package/dist/x402.d.ts.map +0 -1
- 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
package/dist/feed.js
CHANGED
package/dist/freshness.d.ts
CHANGED
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)
|
|
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
|
package/dist/headings.d.ts
CHANGED
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(/ /g, ' ').replace(/&/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 ` `/`&` 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 ` `/`&` 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(/ /g, ' ').replace(/&/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
|
-
|
|
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
|
-
|
|
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 './
|
|
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
|
-
|
|
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 './
|
|
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
|
-
|
|
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
|
@@ -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[]>;
|