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 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.
@@ -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[]>;
@@ -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.45.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",