openvibers/openvibe.blog Commits
Blog tells search engines when a page changes (plan T11, IndexNow): with INDEXNOW_KEY set the key file is served at /<key>.txt and an indexable page appearing, changing or going away pings the engines (its path and the sitemap); drafts, private and noindex pages never; unset, nothing is mounted or sent. A home-page performance budget runs in CI where there was none. Tests green (verified).
Changes
@@@ -81,6 +81,10 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200# Browser origins that may call /api/v1 with a Bearer Network JWT#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media++# IndexNow (openvibe-shared/indexnow): with a key, search engines are told the moment a public page++# appears, changes or leaves the index (the key file is served at /<key>.txt). Unset: off.++#INDEXNOW_KEY=++# release.json (openvibe-shared/release): the deployed commit when git is not available#RELEASE_COMMIT=#MIN_CLIENT_RELEASE=@@@ -196,6 +196,15 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Everynever appear in feeds, sitemaps or Search. Feeds and sitemaps are never built for a particularviewer.++### IndexNow (openvibe-shared/indexnow)++++- With `INDEXNOW_KEY` set, the key file is served at `/<key>.txt` as `text/plain`.++- Publishing, updating (a new revision or a slug change), unpublishing and deleting ping++ `api.indexnow.org` with the post's canonical URL and `/sitemap.xml`; the module batches and++ debounces (one POST per 30s window). A failed ping never takes a publish down.++- Drafts, scheduled, unlisted, members-only, private and noindex posts never ping. Unset key: off —++ no key file, no requests.++### Caching (no leaks through shared caches)- All HTML and JSON varies on `Cookie` and `Authorization`.@@@ -293,6 +302,7 @@ Each grant is `[client, capability, audience]`:| Capability-guarded service tokens, X-OV-Subject membership, AI drafts that need a person's review, and problem+json with request ids. | `test/api-ai.test.js` || The seed post, readiness, release, robots, llms.txt and the sitemap index. | `test/seed-ops.test.js` || The contract proposals are valid against the released schemas and match the code. | `test/contracts.test.js` |++| IndexNow: off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and a publish or unpublish pings the post path and the sitemap; a draft never pings. | `test/indexnow.test.js` |## Launch rule@@@ -31,6 +31,7 @@ const { createCommunity } = require('./clients/community');const { createMedia } = require('./clients/media');const { createVip } = require('./clients/vip');const { createServiceOutbox } = require('openvibe-sdk/events');++const { createIndexNow } = require('openvibe-shared/indexnow');const { createPublicRoutes } = require('./http/public');const { createEditorRoutes } = require('./http/editor');const { createApi } = require('./http/api');@@@ -62,8 +63,13 @@ async function createApp(opts = {}) {clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,now: store.now, fetch: fetchImpl, log,});++ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing++ // mounted, nothing sent; tests and drills never set it.++ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({++ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log,++ });const blogs = createBlogs({ store, config });-- const publication = createPublication({ store, config, outbox });++ const publication = createPublication({ store, config, outbox, indexnow });const posts = createPosts({ store, blogs, publication, access, outbox, log });const people = createPeople({ store, config, fetchImpl });const community = createCommunity({ store, config, fetchImpl });@@@ -88,7 +94,7 @@ async function createApp(opts = {}) {const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog };++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, indexnow };const app = express();app.disable('x-powered-by');@@@ -159,6 +165,9 @@ async function createApp(opts = {}) {app.use('/auth', sso.router(express));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'blog', service: 'blog', host: 'openvibe.blog', name: 'OpenVibe.Blog', profile: 'ugc' })); }++ // GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).++ if (indexnow.enabled) app.use(indexnow.keyFile);++// ── Static assets (content-hashed ?v= → immutable) ──────// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).app.use('/shared', require('openvibe-shared/serve').handler());@@@ -63,6 +63,11 @@ function load(env = process.env) {},// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),++++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment++ // it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →++ // off: no key file, nothing sent. Tests and drills never set it.++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'blog',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',@@@ -25,7 +25,7 @@ const OWNER = 'blog';/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };--function createPublication({ store, config, outbox }) {++function createPublication({ store, config, outbox, indexnow }) {const { db } = store;const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');const membersOf = db.prepare('SELECT subject FROM blog_memberships WHERE blog_id = ? ORDER BY subject');@@@ -149,6 +149,13 @@ function createPublication({ store, config, outbox }) {actor: actorRef(actor), document: doc, decision, now: store.now(),extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },}), { traceparent });++ // IndexNow: tell the engines a public, indexable page appeared, changed or went away;++ // never for drafts, private or noindex pages. The sitemap is pinged alongside the page.++ if (indexnow && indexnow.enabled) {++ const sitemap = abs('/sitemap.xml');++ if (after && after.state === 'published' && decision && decision.indexable) indexnow.pingSoon([after.url, sitemap]);++ else if (before && before.state === 'published') indexnow.pingSoon([before.url, sitemap]);++ }}await syncIndex(blog, post, { traceparent });return { action, event, post, blog };@@@ -45,7 +45,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow });// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing++ * sent). With a key, the key file is served at /<key>.txt as text/plain and publishing an indexable++ * post pings the engines with the post's path and the sitemap. Drafts never ping.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const KEY = 'k'.repeat(32);++const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');++++(async () => {++ const off = await boot();++ await check('without a key IndexNow is off: no key route and nothing sent', async () => {++ assert.strictEqual(off.ctx.indexnow.enabled, false);++ const res = await off.get(`/${KEY}.txt`);++ assert.strictEqual(res.status, 404, res.text);++ });++ await off.close();++++ const on = await boot({ env: { INDEXNOW_KEY: KEY } });++ await check('with a key the key file answers text/plain with the key', async () => {++ assert.strictEqual(on.ctx.indexnow.enabled, true);++ const res = await on.get(`/${KEY}.txt`);++ assert.strictEqual(res.status, 200, res.text);++ assert.match(res.headers.get('content-type'), /text\/plain/);++ assert.strictEqual(res.text, KEY);++ });++ await on.close();++++ // A spy in place of the module's HTTP send: records every pingSoon batch.++ const pings = [];++ const spy = {++ enabled: true,++ keyFile: (_req, _res, next) => next(),++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },++ ping: async () => ({ sent: 0, status: 0 }),++ flush: async () => ({ sent: 0, status: 0 }),++ };++ const t = await boot({ indexnow: spy });++ const alice = t.network.addUser('alice', { display_name: 'Alice Writer' });++ await t.get('/api/v1/blogs', { as: alice, json: {} });++ let post;++++ await check('a draft never pings', async () => {++ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'First light', body: `Hello **world**.\n\n${LONG}` } });++ assert.strictEqual(r.status, 201, r.text);++ post = r.json().post;++ assert.deepStrictEqual(pings, []);++ });++++ await check('a publish pings the page path and the sitemap', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(pings.includes('https://openvibe.blog/@alice/first-light'), JSON.stringify(pings));++ assert.ok(pings.includes('https://openvibe.blog/sitemap.xml'), JSON.stringify(pings));++ });++++ await check('an unpublish pings the page and the sitemap again', async () => {++ pings.length = 0;++ const r = await t.get(`/api/v1/posts/${post.id}/unpublish`, { as: alice, json: {} });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(pings.includes('https://openvibe.blog/@alice/first-light'), JSON.stringify(pings));++ assert.ok(pings.includes('https://openvibe.blog/sitemap.xml'), JSON.stringify(pings));++ });++ await t.close();++++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -81,6 +81,10 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
@@ -81,6 +81,10 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
# Browser origins that may call /api/v1 with a Bearer Network JWT
# Browser origins that may call /api/v1 with a Bearer Network JWT
#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media
#API_CORS_ORIGINS=https://openvibe.network,https://openvibe.live,https://openvibe.community,https://openvibe.media
+# IndexNow (openvibe-shared/indexnow): with a key, search engines are told the moment a public page
+# appears, changes or leaves the index (the key file is served at /<key>.txt). Unset: off.
+#INDEXNOW_KEY=
+
# release.json (openvibe-shared/release): the deployed commit when git is not available
# release.json (openvibe-shared/release): the deployed commit when git is not available
#RELEASE_COMMIT=
#RELEASE_COMMIT=
#MIN_CLIENT_RELEASE=
#MIN_CLIENT_RELEASE=
@@ -196,6 +196,15 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every
@@ -196,6 +196,15 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every
never appear in feeds, sitemaps or Search. Feeds and sitemaps are never built for a particular
never appear in feeds, sitemaps or Search. Feeds and sitemaps are never built for a particular
viewer.
viewer.
+### IndexNow (openvibe-shared/indexnow)
+
+- With `INDEXNOW_KEY` set, the key file is served at `/<key>.txt` as `text/plain`.
+- Publishing, updating (a new revision or a slug change), unpublishing and deleting ping
+ `api.indexnow.org` with the post's canonical URL and `/sitemap.xml`; the module batches and
+ debounces (one POST per 30s window). A failed ping never takes a publish down.
+- Drafts, scheduled, unlisted, members-only, private and noindex posts never ping. Unset key: off —
+ no key file, no requests.
+
### Caching (no leaks through shared caches)
### Caching (no leaks through shared caches)
- All HTML and JSON varies on `Cookie` and `Authorization`.
- All HTML and JSON varies on `Cookie` and `Authorization`.
@@ -293,6 +302,7 @@ Each grant is `[client, capability, audience]`:
@@ -293,6 +302,7 @@ Each grant is `[client, capability, audience]`:
| Capability-guarded service tokens, X-OV-Subject membership, AI drafts that need a person's review, and problem+json with request ids. | `test/api-ai.test.js` |
| Capability-guarded service tokens, X-OV-Subject membership, AI drafts that need a person's review, and problem+json with request ids. | `test/api-ai.test.js` |
| The seed post, readiness, release, robots, llms.txt and the sitemap index. | `test/seed-ops.test.js` |
| The seed post, readiness, release, robots, llms.txt and the sitemap index. | `test/seed-ops.test.js` |
| The contract proposals are valid against the released schemas and match the code. | `test/contracts.test.js` |
| The contract proposals are valid against the released schemas and match the code. | `test/contracts.test.js` |
+| IndexNow: off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and a publish or unpublish pings the post path and the sitemap; a draft never pings. | `test/indexnow.test.js` |
## Launch rule
## Launch rule
@@ -31,6 +31,7 @@ const { createCommunity } = require('./clients/community');
@@ -31,6 +31,7 @@ const { createCommunity } = require('./clients/community');
const { createMedia } = require('./clients/media');
const { createMedia } = require('./clients/media');
const { createVip } = require('./clients/vip');
const { createVip } = require('./clients/vip');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
+const { createIndexNow } = require('openvibe-shared/indexnow');
const { createPublicRoutes } = require('./http/public');
const { createPublicRoutes } = require('./http/public');
const { createEditorRoutes } = require('./http/editor');
const { createEditorRoutes } = require('./http/editor');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -62,8 +63,13 @@ async function createApp(opts = {}) {
@@ -62,8 +63,13 @@ async function createApp(opts = {}) {
clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,
clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,
now: store.now, fetch: fetchImpl, log,
now: store.now, fetch: fetchImpl, log,
});
});
+ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing
+ // mounted, nothing sent; tests and drills never set it.
+ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
+ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log,
+ });
const blogs = createBlogs({ store, config });
const blogs = createBlogs({ store, config });
- const publication = createPublication({ store, config, outbox });
+ const publication = createPublication({ store, config, outbox, indexnow });
const posts = createPosts({ store, blogs, publication, access, outbox, log });
const posts = createPosts({ store, blogs, publication, access, outbox, log });
const people = createPeople({ store, config, fetchImpl });
const people = createPeople({ store, config, fetchImpl });
const community = createCommunity({ store, config, fetchImpl });
const community = createCommunity({ store, config, fetchImpl });
@@ -88,7 +94,7 @@ async function createApp(opts = {}) {
@@ -88,7 +94,7 @@ async function createApp(opts = {}) {
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog };
+ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, indexnow };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -159,6 +165,9 @@ async function createApp(opts = {}) {
@@ -159,6 +165,9 @@ async function createApp(opts = {}) {
app.use('/auth', sso.router(express));
app.use('/auth', sso.router(express));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'blog', service: 'blog', host: 'openvibe.blog', name: 'OpenVibe.Blog', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'blog', service: 'blog', host: 'openvibe.blog', name: 'OpenVibe.Blog', profile: 'ugc' })); }
+ // GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).
+ if (indexnow.enabled) app.use(indexnow.keyFile);
+
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -63,6 +63,11 @@ function load(env = process.env) {
@@ -63,6 +63,11 @@ function load(env = process.env) {
},
},
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
+
+ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment
+ // it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →
+ // off: no key file, nothing sent. Tests and drills never set it.
+ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'blog',
clientId: env.OV_OAUTH_CLIENT_ID || 'blog',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
@@ -25,7 +25,7 @@ const OWNER = 'blog';
@@ -25,7 +25,7 @@ const OWNER = 'blog';
/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */
/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */
const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };
const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };
-function createPublication({ store, config, outbox }) {
+function createPublication({ store, config, outbox, indexnow }) {
const { db } = store;
const { db } = store;
const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');
const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');
const membersOf = db.prepare('SELECT subject FROM blog_memberships WHERE blog_id = ? ORDER BY subject');
const membersOf = db.prepare('SELECT subject FROM blog_memberships WHERE blog_id = ? ORDER BY subject');
@@ -149,6 +149,13 @@ function createPublication({ store, config, outbox }) {
@@ -149,6 +149,13 @@ function createPublication({ store, config, outbox }) {
actor: actorRef(actor), document: doc, decision, now: store.now(),
actor: actorRef(actor), document: doc, decision, now: store.now(),
extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },
extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },
}), { traceparent });
}), { traceparent });
+ // IndexNow: tell the engines a public, indexable page appeared, changed or went away;
+ // never for drafts, private or noindex pages. The sitemap is pinged alongside the page.
+ if (indexnow && indexnow.enabled) {
+ const sitemap = abs('/sitemap.xml');
+ if (after && after.state === 'published' && decision && decision.indexable) indexnow.pingSoon([after.url, sitemap]);
+ else if (before && before.state === 'published') indexnow.pingSoon([before.url, sitemap]);
+ }
}
}
await syncIndex(blog, post, { traceparent });
await syncIndex(blog, post, { traceparent });
return { action, event, post, blog };
return { action, event, post, blog };
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow });
// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.
// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.
await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);
await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing
+ * sent). With a key, the key file is served at /<key>.txt as text/plain and publishing an indexable
+ * post pings the engines with the post's path and the sitemap. Drafts never ping.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const KEY = 'k'.repeat(32);
+const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
+
+(async () => {
+ const off = await boot();
+ await check('without a key IndexNow is off: no key route and nothing sent', async () => {
+ assert.strictEqual(off.ctx.indexnow.enabled, false);
+ const res = await off.get(`/${KEY}.txt`);
+ assert.strictEqual(res.status, 404, res.text);
+ });
+ await off.close();
+
+ const on = await boot({ env: { INDEXNOW_KEY: KEY } });
+ await check('with a key the key file answers text/plain with the key', async () => {
+ assert.strictEqual(on.ctx.indexnow.enabled, true);
+ const res = await on.get(`/${KEY}.txt`);
+ assert.strictEqual(res.status, 200, res.text);
+ assert.match(res.headers.get('content-type'), /text\/plain/);
+ assert.strictEqual(res.text, KEY);
+ });
+ await on.close();
+
+ // A spy in place of the module's HTTP send: records every pingSoon batch.
+ const pings = [];
+ const spy = {
+ enabled: true,
+ keyFile: (_req, _res, next) => next(),
+ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
+ ping: async () => ({ sent: 0, status: 0 }),
+ flush: async () => ({ sent: 0, status: 0 }),
+ };
+ const t = await boot({ indexnow: spy });
+ const alice = t.network.addUser('alice', { display_name: 'Alice Writer' });
+ await t.get('/api/v1/blogs', { as: alice, json: {} });
+ let post;
+
+ await check('a draft never pings', async () => {
+ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'First light', body: `Hello **world**.\n\n${LONG}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ post = r.json().post;
+ assert.deepStrictEqual(pings, []);
+ });
+
+ await check('a publish pings the page path and the sitemap', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(pings.includes('https://openvibe.blog/@alice/first-light'), JSON.stringify(pings));
+ assert.ok(pings.includes('https://openvibe.blog/sitemap.xml'), JSON.stringify(pings));
+ });
+
+ await check('an unpublish pings the page and the sitemap again', async () => {
+ pings.length = 0;
+ const r = await t.get(`/api/v1/posts/${post.id}/unpublish`, { as: alice, json: {} });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(pings.includes('https://openvibe.blog/@alice/first-light'), JSON.stringify(pings));
+ assert.ok(pings.includes('https://openvibe.blog/sitemap.xml'), JSON.stringify(pings));
+ });
+ await t.close();
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });