wiki-formant 0.45.0 → 0.46.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 +14 -0
- package/bin/indexnow.mjs +52 -0
- package/dist/indexnow.d.ts +22 -0
- package/dist/indexnow.js +63 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -400,6 +400,20 @@ Every agent group also allows `AGENT_SURFACE_PATHS` — `/api/mcp`, the three `l
|
|
|
400
400
|
|
|
401
401
|
`trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
|
|
402
402
|
|
|
403
|
+
## IndexNow
|
|
404
|
+
|
|
405
|
+
Bing, Yandex, Seznam, Naver and Yep take a POST of changed URLs instead of waiting to recrawl. The `indexnow` bin reads an origin's live sitemap and posts the rows whose `lastModified` falls inside the window, so the editor, the MCP write tools and the sweep scripts that write straight to the database are all covered with no hook in any of them. It runs where the sweeps run, never as a cron on the host: a submission costs the origin one cached sitemap fetch and no function time.
|
|
406
|
+
|
|
407
|
+
The key is public by design, since an engine checks it by fetching `/<key>.txt`. The file in the app's `public/` is the only copy, and the bin finds it there, so run it from the app's root. It checks that the live origin serves the key before posting anything.
|
|
408
|
+
|
|
409
|
+
```sh
|
|
410
|
+
npx indexnow https://radix.wiki # rows modified in the last 24 hours
|
|
411
|
+
npx indexnow https://radix.wiki --hours 6
|
|
412
|
+
npx indexnow https://radix.wiki --all # every row: a new origin or a new key
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Rows with no `lastModified` go only in a full submission. A 403 or 422 from the engine exits 1.
|
|
416
|
+
|
|
403
417
|
## Page metadata
|
|
404
418
|
|
|
405
419
|
`wiki-formant/metadata`'s `pageMetadata` builds a page's canonical, markdown-twin alternate, Open Graph and Twitter card from one input. Next replaces those objects per route segment rather than merging them, and fills a missing twitter card from `openGraph` only when no `twitter` object was inherited — so under a layout that sets its own card, a page that sets one and forgets the other shows the layout's. Two wikis wrote a helper around that, covering different halves.
|
package/bin/indexnow.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/indexnow.mjs – post an origin's recently changed sitemap rows to IndexNow
|
|
3
|
+
// (`wiki-formant/indexnow`). Run from the app's root: the key is the one file in
|
|
4
|
+
// `public/` whose name is its own content plus `.txt`. The live copy is checked
|
|
5
|
+
// before anything is posted, since an engine refuses every URL until it can
|
|
6
|
+
// fetch the key.
|
|
7
|
+
//
|
|
8
|
+
// npx indexnow https://radix.wiki # rows modified in the last 24 hours
|
|
9
|
+
// npx indexnow https://radix.wiki --hours 6
|
|
10
|
+
// npx indexnow https://radix.wiki --all # every row: a new origin or a new key
|
|
11
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { sitemapEntries, changedUrls, submitIndexNow } from '../dist/indexnow.js';
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const origin = args.find(a => /^https?:\/\//.test(a))?.replace(/\/$/, '');
|
|
16
|
+
const hours = Number(args.includes('--hours') ? args[args.indexOf('--hours') + 1] : 24);
|
|
17
|
+
|
|
18
|
+
if (!origin || !(hours > 0)) {
|
|
19
|
+
console.error('usage: indexnow <origin> [--hours n | --all]');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let files = [];
|
|
24
|
+
try {
|
|
25
|
+
files = readdirSync('public');
|
|
26
|
+
} catch {
|
|
27
|
+
// reported below
|
|
28
|
+
}
|
|
29
|
+
const key = files
|
|
30
|
+
.map(f => /^([A-Za-z0-9-]{8,128})\.txt$/.exec(f)?.[1])
|
|
31
|
+
.find(k => k && readFileSync(`public/${k}.txt`, 'utf8').trim() === k);
|
|
32
|
+
if (!key) {
|
|
33
|
+
console.error('no IndexNow key in ./public: a <key>.txt holding the key');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const live = await fetch(`${origin}/${key}.txt`).then(r => (r.ok ? r.text() : ''));
|
|
38
|
+
if (live.trim() !== key) {
|
|
39
|
+
console.error(`${origin}/${key}.txt does not serve the key yet; deploy first`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sitemap = await fetch(`${origin}/sitemap.xml`);
|
|
44
|
+
if (!sitemap.ok) {
|
|
45
|
+
console.error(`${origin}/sitemap.xml answered ${sitemap.status}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const entries = sitemapEntries(await sitemap.text());
|
|
49
|
+
const urls = args.includes('--all') ? entries.map(e => e.url) : changedUrls(entries, Date.now() - hours * 3_600_000);
|
|
50
|
+
const statuses = await submitIndexNow(key, urls);
|
|
51
|
+
console.log(JSON.stringify({ origin, submitted: urls.length, statuses }));
|
|
52
|
+
if (!statuses.every(s => s === 200 || s === 202)) process.exit(1);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
|
|
2
|
+
/** The protocol's cap on the URLs in one POST. */
|
|
3
|
+
export declare const INDEXNOW_BATCH = 10000;
|
|
4
|
+
export interface IndexNowEntry {
|
|
5
|
+
url: string;
|
|
6
|
+
lastModified?: string;
|
|
7
|
+
}
|
|
8
|
+
/** The `<loc>` and `<lastmod>` of every `<url>` in a sitemap. */
|
|
9
|
+
export declare function sitemapEntries(xml: string): IndexNowEntry[];
|
|
10
|
+
/**
|
|
11
|
+
* The URLs modified at or after `since`. An entry with no `lastModified` is
|
|
12
|
+
* left out: nothing says it changed, and the protocol asks for changed pages
|
|
13
|
+
* only. A full submission (`--all`) is how those reach an engine.
|
|
14
|
+
*/
|
|
15
|
+
export declare function changedUrls(entries: IndexNowEntry[], since: number): string[];
|
|
16
|
+
/**
|
|
17
|
+
* POST `urls` in batches of `INDEXNOW_BATCH` and return each batch's status.
|
|
18
|
+
* 200 is accepted and 202 accepted pending the key check; 403 means the key
|
|
19
|
+
* file is missing or disagrees, 422 that a URL is off the host. Every URL must
|
|
20
|
+
* share the first one's host, which a sitemap built from one origin does.
|
|
21
|
+
*/
|
|
22
|
+
export declare function submitIndexNow(key: string, urls: string[], fetchImpl?: typeof fetch): Promise<number[]>;
|
package/dist/indexnow.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// indexnow.ts — telling the IndexNow engines (Bing, Yandex, Seznam, Naver, Yep)
|
|
2
|
+
// which pages changed, rather than waiting for them to recrawl.
|
|
3
|
+
//
|
|
4
|
+
// The `indexnow` bin reads an origin's live sitemap, keeps the rows whose
|
|
5
|
+
// `lastModified` falls inside the window, and posts them. It runs on the machine
|
|
6
|
+
// that runs the sweeps, not as a cron on the host: a submission costs the origin
|
|
7
|
+
// one cached sitemap fetch and no function time. The sitemap is the one place
|
|
8
|
+
// every writer shows up — the editor, the MCP write tools, and the sweep scripts
|
|
9
|
+
// that write to the database without passing through the app — so reading it
|
|
10
|
+
// covers all of them with no hook in any.
|
|
11
|
+
//
|
|
12
|
+
// The key is not a secret. An engine verifies a submission by fetching
|
|
13
|
+
// `/<key>.txt` from the origin, so the key is public by design, and the file in
|
|
14
|
+
// each app's `public/` is the only copy.
|
|
15
|
+
export const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';
|
|
16
|
+
/** The protocol's cap on the URLs in one POST. */
|
|
17
|
+
export const INDEXNOW_BATCH = 10_000;
|
|
18
|
+
const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
19
|
+
const unescapeXml = (s) => s.trim().replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENTITIES[e]);
|
|
20
|
+
/** The `<loc>` and `<lastmod>` of every `<url>` in a sitemap. */
|
|
21
|
+
export function sitemapEntries(xml) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
for (const [, body = ''] of xml.matchAll(/<url>([\s\S]*?)<\/url>/g)) {
|
|
24
|
+
const loc = /<loc>([\s\S]*?)<\/loc>/.exec(body)?.[1];
|
|
25
|
+
if (!loc)
|
|
26
|
+
continue;
|
|
27
|
+
const lastmod = /<lastmod>([\s\S]*?)<\/lastmod>/.exec(body)?.[1];
|
|
28
|
+
entries.push(lastmod ? { url: unescapeXml(loc), lastModified: lastmod.trim() } : { url: unescapeXml(loc) });
|
|
29
|
+
}
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The URLs modified at or after `since`. An entry with no `lastModified` is
|
|
34
|
+
* left out: nothing says it changed, and the protocol asks for changed pages
|
|
35
|
+
* only. A full submission (`--all`) is how those reach an engine.
|
|
36
|
+
*/
|
|
37
|
+
export function changedUrls(entries, since) {
|
|
38
|
+
return entries
|
|
39
|
+
.filter(e => e.lastModified !== undefined && new Date(e.lastModified).getTime() >= since)
|
|
40
|
+
.map(e => e.url);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* POST `urls` in batches of `INDEXNOW_BATCH` and return each batch's status.
|
|
44
|
+
* 200 is accepted and 202 accepted pending the key check; 403 means the key
|
|
45
|
+
* file is missing or disagrees, 422 that a URL is off the host. Every URL must
|
|
46
|
+
* share the first one's host, which a sitemap built from one origin does.
|
|
47
|
+
*/
|
|
48
|
+
export async function submitIndexNow(key, urls, fetchImpl = fetch) {
|
|
49
|
+
const first = urls[0];
|
|
50
|
+
if (!first)
|
|
51
|
+
return [];
|
|
52
|
+
const host = new URL(first).host;
|
|
53
|
+
const statuses = [];
|
|
54
|
+
for (let i = 0; i < urls.length; i += INDEXNOW_BATCH) {
|
|
55
|
+
const res = await fetchImpl(INDEXNOW_ENDPOINT, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
58
|
+
body: JSON.stringify({ host, key, urlList: urls.slice(i, i + INDEXNOW_BATCH) }),
|
|
59
|
+
});
|
|
60
|
+
statuses.push(res.status);
|
|
61
|
+
}
|
|
62
|
+
return statuses;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -92,6 +92,11 @@
|
|
|
92
92
|
"import": "./dist/combobox.js",
|
|
93
93
|
"default": "./dist/combobox.js"
|
|
94
94
|
},
|
|
95
|
+
"./indexnow": {
|
|
96
|
+
"types": "./dist/indexnow.d.ts",
|
|
97
|
+
"import": "./dist/indexnow.js",
|
|
98
|
+
"default": "./dist/indexnow.js"
|
|
99
|
+
},
|
|
95
100
|
"./crawlers": {
|
|
96
101
|
"types": "./dist/crawlers.d.ts",
|
|
97
102
|
"import": "./dist/crawlers.js",
|
|
@@ -240,7 +245,8 @@
|
|
|
240
245
|
"check-pages": "bin/check-pages.mjs",
|
|
241
246
|
"analytics-digest": "bin/analytics-digest.mjs",
|
|
242
247
|
"passkey-invite": "bin/passkey-invite.mjs",
|
|
243
|
-
"fts-ddl": "bin/fts-ddl.mjs"
|
|
248
|
+
"fts-ddl": "bin/fts-ddl.mjs",
|
|
249
|
+
"indexnow": "bin/indexnow.mjs"
|
|
244
250
|
},
|
|
245
251
|
"files": [
|
|
246
252
|
"dist",
|