wiki-formant 0.45.0 → 0.47.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 +21 -0
- package/bin/indexnow.mjs +52 -0
- package/dist/indexnow.d.ts +22 -0
- package/dist/indexnow.js +63 -0
- package/dist/metadata.d.ts +21 -0
- package/dist/metadata.js +44 -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.
|
|
@@ -417,6 +431,13 @@ The schema.org nodes sit beside it, because they state the same facts to a diffe
|
|
|
417
431
|
<JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
|
|
418
432
|
```
|
|
419
433
|
|
|
434
|
+
`questionPairs(html)` reads a page's FAQ out of its own headings: every `h2`/`h3` ending in `?`, answered by the text beneath it. It never rewrites a statement heading as a question, so a page earns `FAQPage` by being written as one. `faqPageLd(pairs, url)` is that node, or null under two pairs, and takes an authored list just as well.
|
|
435
|
+
|
|
436
|
+
```tsx
|
|
437
|
+
const faq = faqPageLd(questionPairs(html), url);
|
|
438
|
+
{faq && <JsonLd data={faq} />}
|
|
439
|
+
```
|
|
440
|
+
|
|
420
441
|
`sitePageMetadata({ siteName, handle })` binds the three fields Next drops from every page that sets its own card, with `locale` defaulting to `en_US`. `siteGraphLd` is the Organization, WebSite (with its `SearchAction`) and `WebAPI` as one `@graph` for the root layout, every node carrying an `@id`, and `siteRefs(url)` hands out the same ids for a page's `publisher` and `isPartOf` — so no page writes an inline copy of the publisher that can disagree with the layout's.
|
|
421
442
|
|
|
422
443
|
```ts
|
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/dist/metadata.d.ts
CHANGED
|
@@ -121,6 +121,27 @@ export declare function collectionLd(o: CollectionLdOptions): LdNode;
|
|
|
121
121
|
* an authored `&` is not what a crawler reads.
|
|
122
122
|
*/
|
|
123
123
|
export declare function citationsFromReferences(items: readonly ReferenceItem[], max?: number): LdNode[];
|
|
124
|
+
export interface QaPair {
|
|
125
|
+
q: string;
|
|
126
|
+
a: string;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The question/answer pairs in a page's HTML: every `h2`/`h3` that ends in a
|
|
130
|
+
* question mark, with the text up to the next such heading as its answer.
|
|
131
|
+
*
|
|
132
|
+
* It never turns a statement heading into a question. Markup that rewrites
|
|
133
|
+
* "Protocol fees" as "What is Protocol fees?" claims a question the page never
|
|
134
|
+
* asked, so a page earns an FAQ by being written as one. An answer under 40
|
|
135
|
+
* characters is a heading with no section, and is skipped.
|
|
136
|
+
*/
|
|
137
|
+
export declare function questionPairs(html: string): QaPair[];
|
|
138
|
+
/**
|
|
139
|
+
* `FAQPage` over question/answer pairs, or null under two. One question is an
|
|
140
|
+
* article with a question for a heading, not an FAQ. Pairs come from
|
|
141
|
+
* `questionPairs` on stored HTML, or from a list the caller authors directly
|
|
142
|
+
* as plain text, so a home-page FAQ and a wiki article render the same node.
|
|
143
|
+
*/
|
|
144
|
+
export declare function faqPageLd(pairs: readonly QaPair[], url: string): LdNode | null;
|
|
124
145
|
/**
|
|
125
146
|
* Characters a document `<title>` has before a search result truncates it.
|
|
126
147
|
*
|
package/dist/metadata.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// with no image while their og:image named a card, one listed citations, one
|
|
20
20
|
// collection page had no ItemList at all.
|
|
21
21
|
import { decodeEntities } from './markdown.js';
|
|
22
|
+
import { stripHtml } from './text.js';
|
|
22
23
|
export function pageMetadata(o) {
|
|
23
24
|
const { title, description, url, type = 'website', image, imageSize = { width: 1200, height: 630 }, siteName, locale, handle, markdownTwin, article } = o;
|
|
24
25
|
const images = image ? [{ url: image, ...imageSize, alt: o.imageAlt ?? title }] : undefined;
|
|
@@ -107,6 +108,49 @@ export function citationsFromReferences(items, max = 50) {
|
|
|
107
108
|
})
|
|
108
109
|
.slice(0, max);
|
|
109
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The question/answer pairs in a page's HTML: every `h2`/`h3` that ends in a
|
|
113
|
+
* question mark, with the text up to the next such heading as its answer.
|
|
114
|
+
*
|
|
115
|
+
* It never turns a statement heading into a question. Markup that rewrites
|
|
116
|
+
* "Protocol fees" as "What is Protocol fees?" claims a question the page never
|
|
117
|
+
* asked, so a page earns an FAQ by being written as one. An answer under 40
|
|
118
|
+
* characters is a heading with no section, and is skipped.
|
|
119
|
+
*/
|
|
120
|
+
export function questionPairs(html) {
|
|
121
|
+
const pairs = [];
|
|
122
|
+
const sections = html.split(/<h[23][^>]*>/i);
|
|
123
|
+
for (const section of sections.slice(1)) {
|
|
124
|
+
const [heading = '', ...rest] = section.split(/<\/h[23]>/i);
|
|
125
|
+
const q = stripHtml(heading).replace(/\s+/g, ' ');
|
|
126
|
+
if (!q.endsWith('?'))
|
|
127
|
+
continue;
|
|
128
|
+
const a = stripHtml(rest.join('')).replace(/\s+/g, ' ');
|
|
129
|
+
if (a.length >= 40)
|
|
130
|
+
pairs.push({ q, a: a.slice(0, 1000) });
|
|
131
|
+
}
|
|
132
|
+
return pairs;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `FAQPage` over question/answer pairs, or null under two. One question is an
|
|
136
|
+
* article with a question for a heading, not an FAQ. Pairs come from
|
|
137
|
+
* `questionPairs` on stored HTML, or from a list the caller authors directly
|
|
138
|
+
* as plain text, so a home-page FAQ and a wiki article render the same node.
|
|
139
|
+
*/
|
|
140
|
+
export function faqPageLd(pairs, url) {
|
|
141
|
+
if (pairs.length < 2)
|
|
142
|
+
return null;
|
|
143
|
+
return {
|
|
144
|
+
'@context': 'https://schema.org',
|
|
145
|
+
'@type': 'FAQPage',
|
|
146
|
+
url,
|
|
147
|
+
mainEntity: pairs.map(p => ({
|
|
148
|
+
'@type': 'Question',
|
|
149
|
+
name: p.q,
|
|
150
|
+
acceptedAnswer: { '@type': 'Answer', text: p.a },
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
110
154
|
/**
|
|
111
155
|
* Characters a document `<title>` has before a search result truncates it.
|
|
112
156
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.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",
|