Skip to content

Commit 74a38e3

Browse files
jdevalkclaude
andcommitted
feat: broaden IndexNow triggers to saves and permanent deletes
Adopt two hooks added in emdash 0.21.0: - content:afterSave -> handleIndexNowPublished. Pings IndexNow whenever published content is saved, closing the gap where edits to an already- published page never notified search engines (afterPublish only fires on the draft->published transition). afterPublish and afterSave both route through this handler; a per-URL 60s debounce collapses the duplicate they fire at the publish moment and absorbs autosave bursts. - content:afterDelete -> handleIndexNowDelete. Pings the dead URL on a permanent delete so engines recrawl and see the 404/410. The delete event carries only { id, collection } (no slug), so published saves cache an id->url mapping in KV that the delete handler resolves and clears. Trashing stays on the afterUnpublish path. Extracted submitUrlToIndexNow from handleIndexNowTransition (now only afterUnpublish). Added 9 tests; README + CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5d33fff commit 74a38e3

5 files changed

Lines changed: 294 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [0.12.0] - 2026-06-18
99

10+
### Added
11+
12+
- **IndexNow now pings on edits to already-published pages.** The new `content:afterSave` hook fires on every save; the handler pings IndexNow whenever *published* content is saved. Previously only the draft→published transition (`content:afterPublish`) pinged, so editing a live page and re-saving never notified search engines. Both hooks route through the same handler; a per-URL debounce (60s, `PING_DEBOUNCE_MS`) collapses the duplicate the two fire at the publish moment and guards against autosave bursts.
13+
- **IndexNow now pings on permanent deletes.** The new `content:afterDelete` hook submits a permanently-deleted page's last-known URL to IndexNow so engines recrawl and see the 404/410. Because the delete event carries only `{ id, collection }` (no slug), published saves record an `id → url` mapping in plugin KV (`indexnow:urlmap:<collection>:<id>`) that the delete handler resolves and then clears. Trashing is treated as an unpublish and continues to flow through `content:afterUnpublish`.
14+
1015
### Changed
1116

12-
- **`peerDependencies.emdash` bumped to `^0.21.0`.** Tracks the current EmDash release. The plugin API surface this plugin depends on — the `page:metadata`, `content:afterPublish`, and `content:afterUnpublish` hooks, plus the `read:content`, `page:inject`, and `network:fetch` capabilities — is unchanged from `0.6.0`, so no source changes were required. Verified against `emdash@0.21.0`: typecheck clean, all tests passing.
17+
- **`peerDependencies.emdash` bumped to `^0.21.0`.** Tracks the current EmDash release. The plugin API surface this plugin depends on — the `page:metadata`, `content:afterPublish`, and `content:afterUnpublish` hooks, plus the `read:content`, `page:inject`, and `network:fetch` capabilities — is unchanged from `0.6.0`, so the compatibility bump itself required no source changes. Verified against `emdash@0.21.0`: typecheck clean, all tests passing.
1318
- Internal plugin `version` strings in `src/index.ts` bumped to `0.12.0` to match the package version.
1419

1520
## [0.11.0] - 2026-04-21

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ An SEO plugin for [EmDash CMS](https://github.com/emdash-cms/emdash) that genera
3434
- **Schema map** *(experimental)* — exposes a list of every published URL backed by schema markup at the plugin's `schema/map` route, ready to be wired to a `/schemamap.xml` Astro endpoint for agent/crawler discovery
3535
- **Fuzzy Redirects** — admin tool that mines the core 404 log, ranks live URLs by path similarity (Levenshtein + token overlap + last-segment match), and lets you one-click create a 301 redirect for the best target. Catches moved slugs, typos in inbound links, and punctuation drift without having to write regex rules
3636
- **NLWeb `<link>` tag** — when the **NLWeb endpoint URL** setting is set, every rendered page carries `<link rel="nlweb" href="…">` advertising the site's conversational endpoint for agent discovery. Requires EmDash with [emdash-cms/emdash#523](https://github.com/emdash-cms/emdash/pull/523) merged; older versions drop the contribution silently
37-
- **IndexNow** — on publish/unpublish transitions, submits the affected URL to [IndexNow](https://www.indexnow.org) so Bing, Yandex, Seznam, Naver, and Yep recrawl immediately. Opt-in via a single toggle in the settings UI; the key is generated and persisted automatically on first use
37+
- **IndexNow** — on publish, on edits to an already-published page, on unpublish, and on permanent delete, submits the affected URL to [IndexNow](https://www.indexnow.org) so Bing, Yandex, Seznam, Naver, and Yep recrawl immediately. Opt-in via a single toggle in the settings UI; the key is generated and persisted automatically on first use
3838
- **Admin settings UI** — auto-generated from `settingsSchema` for configuring Person/Organization identity, social profiles, title separator, and default description
3939

4040
## Installation
@@ -129,9 +129,21 @@ URLs become `/fr-ca/…` and `/fr-fr/…`, and the emitted `hreflang` attributes
129129
## IndexNow
130130

131131
When enabled via the **IndexNow submission** setting, the plugin submits
132-
the canonical URL of any content item that transitions to or from
133-
published. A 32-character hex key is minted on first use and persisted in
134-
plugin KV.
132+
the canonical URL of a content item whenever it changes in a way search
133+
engines should re-crawl:
134+
135+
- **Published, or edited while published** (`content:afterPublish` +
136+
`content:afterSave`) — so both first publish and later edits to a live
137+
page are picked up. A per-URL 60-second debounce collapses the duplicate
138+
these two fire at the publish moment and absorbs autosave bursts.
139+
- **Unpublished** (`content:afterUnpublish`) — pings the now-dead URL so
140+
engines see the 404/410.
141+
- **Permanently deleted** (`content:afterDelete`, only when the delete is
142+
permanent rather than a trash) — the delete event carries no slug, so
143+
published saves cache an `id → url` mapping in plugin KV that the delete
144+
handler resolves, submits, and then clears.
145+
146+
A 32-character hex key is minted on first use and persisted in plugin KV.
135147

136148
The front-end Astro site must serve the key-verification file at
137149
`/<key>.txt`. Fetch the key from the plugin's `indexnow/key` route and

src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { metadataHandler } from "./metadata.js";
44
import {
55
getKeyFileBody,
66
getOrCreateIndexNowKey,
7+
handleIndexNowDelete,
8+
handleIndexNowPublished,
79
handleIndexNowTransition,
810
} from "./indexnow.js";
911
import { generateLlmsTxt } from "./llms.js";
@@ -37,13 +39,21 @@ export function createPlugin() {
3739
priority: 10,
3840
},
3941
"content:afterPublish": {
40-
handler: handleIndexNowTransition,
42+
handler: handleIndexNowPublished,
43+
priority: 50,
44+
},
45+
"content:afterSave": {
46+
handler: handleIndexNowPublished,
4147
priority: 50,
4248
},
4349
"content:afterUnpublish": {
4450
handler: handleIndexNowTransition,
4551
priority: 50,
4652
},
53+
"content:afterDelete": {
54+
handler: handleIndexNowDelete,
55+
priority: 50,
56+
},
4757
},
4858

4959
routes: {

src/indexnow.ts

Lines changed: 151 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,36 @@ import { buildPageUrl } from "./urls.js";
1010
const KEY_KV = "indexnow:key";
1111
const ENABLED_KV = "settings:indexnowEnabled";
1212

13+
/**
14+
* Maps a content record's `id` to the canonical URL it was last published
15+
* at: `indexnow:urlmap:<collection>:<id>` → absolute URL. We keep this
16+
* because `content:afterDelete` carries only `{ id, collection }` — no
17+
* slug — so the URL of a permanently-deleted item can't be rebuilt from
18+
* the delete event alone. Written whenever published content is saved,
19+
* read (and cleared) on permanent delete.
20+
*/
21+
const URLMAP_PREFIX = "indexnow:urlmap:";
22+
23+
/**
24+
* Records the epoch-ms of the last IndexNow submission per URL:
25+
* `indexnow:lastping:<url>` → number. Used to debounce rapid autosaves of
26+
* the same page so each editing burst pings at most once per window.
27+
*/
28+
const LASTPING_PREFIX = "indexnow:lastping:";
29+
30+
/**
31+
* Minimum gap between IndexNow submissions for the same URL. Editors
32+
* autosave frequently; without this guard every keystroke-save of a live
33+
* page would re-ping. A genuine edit in a later session (past the window)
34+
* still pings. Tunable trade-off: longer = fewer pings but can swallow a
35+
* real edit made shortly after publishing.
36+
*/
37+
const PING_DEBOUNCE_MS = 60_000;
38+
39+
function urlMapKey(collection: string, id: string): string {
40+
return `${URLMAP_PREFIX}${collection}:${id}`;
41+
}
42+
1343
/**
1444
* Read or lazily generate the IndexNow key. The key is persisted in plugin
1545
* KV so subsequent submissions (and the key-file route exposed on the
@@ -83,10 +113,47 @@ async function urlForContent(
83113
}
84114

85115
/**
86-
* Handler for `content:afterPublish` and `content:afterUnpublish`.
87-
* Submits the transitioned URL to IndexNow so participating engines
88-
* recrawl and pick up the new state (including 410/404 for unpublished
89-
* content). Fire-and-forget: never throws, logs errors on ctx.log.
116+
* Submit a single URL to IndexNow for the current site. Resolves the host
117+
* from `ctx.site.url`, mints/reuses the key, and logs each engine result.
118+
* Returns silently when the site URL is missing or unparseable.
119+
*/
120+
async function submitUrlToIndexNow(ctx: PluginContext, url: string): Promise<void> {
121+
const siteUrl = ctx.site.url;
122+
if (!siteUrl) return;
123+
124+
let host: string;
125+
try {
126+
host = new URL(siteUrl).hostname;
127+
} catch {
128+
return;
129+
}
130+
131+
const key = await getOrCreateIndexNowKey(ctx);
132+
133+
const results = await submitToIndexNow({ host, key, urls: [url] });
134+
135+
for (const r of results) {
136+
if (r.ok) {
137+
ctx.log.info("IndexNow: submitted", { url, status: r.status });
138+
} else {
139+
ctx.log.warn("IndexNow: submission failed", {
140+
url,
141+
status: r.status,
142+
message: r.message,
143+
});
144+
}
145+
}
146+
}
147+
148+
/** Extract a string `id` from a content record, or null. */
149+
function contentId(content: Record<string, unknown>): string | null {
150+
return typeof content.id === "string" && content.id ? content.id : null;
151+
}
152+
153+
/**
154+
* Handler for `content:afterUnpublish`. Submits the now-dead URL to
155+
* IndexNow so participating engines recrawl and pick up the 404/410.
156+
* Fire-and-forget: never throws, logs errors on ctx.log.
90157
*/
91158
export async function handleIndexNowTransition(
92159
event: { content: Record<string, unknown>; collection: string },
@@ -101,37 +168,91 @@ export async function handleIndexNowTransition(
101168
const url = await urlForContent(event.content, event.collection, siteUrl);
102169
if (!url) return;
103170

104-
let host: string;
105-
try {
106-
host = new URL(siteUrl).hostname;
107-
} catch {
108-
return;
109-
}
110-
111-
const key = await getOrCreateIndexNowKey(ctx);
112-
113-
const results = await submitToIndexNow({
114-
host,
115-
key,
116-
urls: [url],
117-
});
118-
119-
for (const r of results) {
120-
if (r.ok) {
121-
ctx.log.info("IndexNow: submitted", { url, status: r.status });
122-
} else {
123-
ctx.log.warn("IndexNow: submission failed", {
124-
url,
125-
status: r.status,
126-
message: r.message,
127-
});
128-
}
129-
}
171+
await submitUrlToIndexNow(ctx, url);
130172
} catch (error) {
131173
ctx.log.warn("IndexNow: transition handler error", { error });
132174
}
133175
}
134176

177+
/**
178+
* Handler for `content:afterPublish` and `content:afterSave`. Pings
179+
* IndexNow whenever *published* content is saved — this covers the
180+
* publish transition (via `afterPublish`) and, crucially, edits to an
181+
* already-published page (via `afterSave`), which `afterPublish` alone
182+
* never sees. Non-published saves (drafts, autosaves of unpublished
183+
* content) are ignored.
184+
*
185+
* Wiring both hooks here is deliberate: `afterPublish` guarantees the
186+
* publish ping even if `afterSave` semantics differ, and the per-URL
187+
* debounce (see `PING_DEBOUNCE_MS`) collapses the duplicate the two hooks
188+
* fire at the publish moment into a single submission.
189+
*
190+
* Also records the `id → url` mapping so a later permanent delete can
191+
* resolve the URL. Fire-and-forget: never throws.
192+
*/
193+
export async function handleIndexNowPublished(
194+
event: { content: Record<string, unknown>; collection: string },
195+
ctx: PluginContext,
196+
): Promise<void> {
197+
try {
198+
if (!(await isIndexNowEnabled(ctx))) return;
199+
200+
const status =
201+
typeof event.content.status === "string" ? event.content.status : null;
202+
if (status !== "published") return;
203+
204+
const siteUrl = ctx.site.url;
205+
if (!siteUrl) return;
206+
207+
const url = await urlForContent(event.content, event.collection, siteUrl);
208+
if (!url) return;
209+
210+
// Remember where this id lives so afterDelete can ping the dead URL.
211+
const id = contentId(event.content);
212+
if (id) await ctx.kv.set(urlMapKey(event.collection, id), url);
213+
214+
// Debounce rapid autosaves of the same URL.
215+
const lastKey = `${LASTPING_PREFIX}${url}`;
216+
const now = Date.now();
217+
const last = await ctx.kv.get<number>(lastKey);
218+
if (typeof last === "number" && now - last < PING_DEBOUNCE_MS) return;
219+
await ctx.kv.set(lastKey, now);
220+
221+
await submitUrlToIndexNow(ctx, url);
222+
} catch (error) {
223+
ctx.log.warn("IndexNow: published handler error", { error });
224+
}
225+
}
226+
227+
/**
228+
* Handler for `content:afterDelete`. On a *permanent* delete of content
229+
* that was previously published, submits its last-known URL to IndexNow
230+
* so engines recrawl and see the 404/410, then clears the cached mapping.
231+
* Trashing (`permanent === false`) is an unpublish and is handled by
232+
* `handleIndexNowTransition`; we skip it here. No-op when the id has no
233+
* cached URL (never published, or already cleaned up).
234+
*/
235+
export async function handleIndexNowDelete(
236+
event: { id: string; collection: string; permanent: boolean },
237+
ctx: PluginContext,
238+
): Promise<void> {
239+
try {
240+
if (!event.permanent) return;
241+
if (!(await isIndexNowEnabled(ctx))) return;
242+
243+
const mapKey = urlMapKey(event.collection, event.id);
244+
const url = await ctx.kv.get<string>(mapKey);
245+
if (typeof url !== "string" || !url) return;
246+
247+
await submitUrlToIndexNow(ctx, url);
248+
249+
await ctx.kv.delete(mapKey);
250+
await ctx.kv.delete(`${LASTPING_PREFIX}${url}`);
251+
} catch (error) {
252+
ctx.log.warn("IndexNow: delete handler error", { error });
253+
}
254+
}
255+
135256
/** Returns the plain-text body to serve at `/<key>.txt`. */
136257
export async function getKeyFileBody(ctx: PluginContext): Promise<string> {
137258
const key = await getOrCreateIndexNowKey(ctx);

0 commit comments

Comments
 (0)