@tagperfect/emdash-plugin-indexnow 0.0.0-stage → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tag Perfect OÜ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,133 @@
1
- # Temporary Holding Version
1
+ # IndexNow for EmDash
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Notifies search engines through [IndexNow](https://www.indexnow.org) when content on an [EmDash](https://emdashcms.com) site is published, changed, unpublished or deleted, so they recrawl the page right away instead of waiting for the next crawl.
4
+
5
+ - Sends the entry's public URL on **publish**, on **publishing a change** to a live entry, on **unpublish** and on **delete** (IndexNow accepts removed URLs: the engine recrawls them and drops them).
6
+ - Never sends drafts or entries marked **noindex** in the SEO panel.
7
+ - Builds absolute URLs from the collection's URL pattern and locale prefix, or from your own **URL rules** for localized sections.
8
+ - Batches with `POST` + `urlList` (up to 10,000 URLs per request), removes duplicates and foreign-host URLs.
9
+ - Logs every request with a readable explanation of the response (200, 202, 400, 403, 422, 429, 5xx) and retries 429, 5xx and network errors every 30 minutes.
10
+ - Admin: a Settings page, a Submissions journal (newest 100) with **Resubmit all published URLs**, and a dashboard widget.
11
+ - Standard plugin format: runs trusted (`plugins: []`) or sandboxed (`sandboxed: []`), and installs from the EmDash plugin registry.
12
+
13
+ ## Engines
14
+
15
+ IndexNow is shared: a URL sent to one participating engine reaches all of them. That covers Bing (and the products built on its index), Yandex, Seznam, Naver, Yep and other participants.
16
+
17
+ **Google does not support IndexNow.** Keep your sitemap for Google.
18
+
19
+ ## Install
20
+
21
+ Requires EmDash 1.3.1 or later.
22
+
23
+ ### From npm
24
+
25
+ ```sh
26
+ pnpm add @tagperfect/emdash-plugin-indexnow
27
+ ```
28
+
29
+ ```ts title="astro.config.mjs"
30
+ import indexnow from "@tagperfect/emdash-plugin-indexnow";
31
+
32
+ emdash({
33
+ // Trusted, in the site's process:
34
+ plugins: [indexnow],
35
+ // …or isolated, when the Worker Loader binding is available (Cloudflare):
36
+ // sandboxed: [indexnow],
37
+ // sandboxRunner: sandbox(), // from "@emdash-cms/cloudflare"
38
+ });
39
+ ```
40
+
41
+ ### From the EmDash registry
42
+
43
+ In the admin, open **Plugins → Marketplace**, find **IndexNow** and install it. The install dialog lists the capabilities and the public route below for approval.
44
+
45
+ ## Setup
46
+
47
+ 1. Open **IndexNow** in the admin sidebar.
48
+ 2. Click **Generate key** (or paste an existing key: 8–128 characters, letters, digits and dashes).
49
+ 3. Serve the key file. The page shows the exact URL, by default `https://your-site/{key}.txt`; it must return the key as plain text. Any of these works:
50
+ - a static file `public/{key}.txt` in your site;
51
+ - a site route that returns the key;
52
+ - a URL rewrite (for example a Cloudflare Transform Rule) from `/{key}.txt` to the plugin's key route `/_emdash/api/plugins/indexnow/key`.
53
+ 4. Turn on **Submit URLs to IndexNow** and save.
54
+
55
+ The key file must sit at the site root: per the protocol, a key file covers only URLs in its own folder and below, so a key served only under `/_emdash/api/plugins/…` would not cover your pages. Use **Key file → Custom URL** only if the file lives elsewhere at the root of the same host.
56
+
57
+ ## Settings
58
+
59
+ | Setting | Default | Purpose |
60
+ | --- | --- | --- |
61
+ | Submit URLs to IndexNow | off | Master switch. Nothing is sent while it is off. |
62
+ | Key | empty | The IndexNow key. **Generate key** creates a 32-character key. |
63
+ | Endpoint | `api.indexnow.org` | Where requests go. Any participant shares the URLs with the others; Bing, Yandex, Seznam, Naver and Yep endpoints can be chosen directly. |
64
+ | Collections | empty (all) | Comma-separated collection slugs to track, e.g. `posts, pages`. **Resubmit all** needs a list (or URL rules). |
65
+ | Site URL | EmDash's site URL | The origin of submitted URLs, e.g. `https://example.com`. Set it when the admin runs on another host. Only URLs on this host are sent. |
66
+ | Key file | Site root | `https://site/{key}.txt`, or a custom URL on the same host. |
67
+ | URL rules | empty | Optional rules for URLs that the collection URL pattern cannot express (see below). |
68
+
69
+ ### URL rules
70
+
71
+ Without rules, the plugin uses EmDash's public URL of the entry: the collection URL pattern with the locale prefix. When sections are localized (`/services/…` in one language, `/ru/uslugi/…` in another), or an entry's change should ping another page, add rules, one per line:
72
+
73
+ ```text
74
+ # <collection> <locale|*> <pattern|-> [conditions…]
75
+ services * - card_link # a section of another page: no URL of its own
76
+ services et /teenused/{slug}/
77
+ services ru /ru/uslugi/{slug}/
78
+ doctors * - staff_type!=doctor
79
+ pages et / page_key=home
80
+ pages * /{locale}/{slug}/
81
+ price_items et /hinnad/ # a price change pings the price list
82
+ ```
83
+
84
+ - Placeholders: `{slug}`, `{id}`, `{locale}`. Slugs are URL-encoded.
85
+ - `-` means the entry has no page: nothing is sent.
86
+ - Conditions test entry fields (and `slug`, `locale`, `id`): `field=value`, `field!=value`, `field` (non-empty), `!field` (empty).
87
+ - The first matching rule wins. Entries without a matching rule fall back to the collection URL pattern.
88
+
89
+ ## Admin
90
+
91
+ - **IndexNow** (settings): the form above, the key file URL and the plugin key route.
92
+ - **IndexNow submissions**: totals for the last 7 days, the newest 100 requests (time, URL, status, event, message) and **Resubmit all published URLs**: every live, indexable URL of the tracked collections in one batch, for the first setup or a domain move.
93
+ - **Dashboard widget**: the last 5 requests and the 7-day totals; a warning with a link to the settings when the key is rejected (403) or URLs do not match the host (422); "Off" or "Not configured" with a **Set up** link otherwise.
94
+
95
+ ## Capabilities
96
+
97
+ | Capability | Why |
98
+ | --- | --- |
99
+ | `content:read` | Read the changed entry: its status (drafts are skipped), SEO `noIndex` flag, slug, locale and fields used by URL rules, and its public URL. **Resubmit all** lists published entries of the tracked collections. |
100
+ | `network:request` with `allowedHosts`: `api.indexnow.org`, `www.bing.com`, `yandex.com`, `search.seznam.cz`, `searchadvisor.naver.com`, `indexnow.yep.com` | `POST` the URL list to the IndexNow endpoint chosen in Settings. No other host can be reached. |
101
+
102
+ No other host APIs are used. Settings, the journal (declared storage `submissions`) and internal state (KV) are plugin-scoped and need no capability.
103
+
104
+ **Public route:** `GET /_emdash/api/plugins/indexnow/key` returns the key as `text/plain` (404 while no key is set). The key is public by design: search engines read it to verify the host.
105
+
106
+ ## Limits
107
+
108
+ - Google does not use IndexNow.
109
+ - EmDash saves edits to a live entry as a draft; the URL is sent when that change is published, not on every save.
110
+ - After unpublish or delete, the plugin sends the URL it last submitted for that entry. For entries published before the plugin was installed, it uses a URL rule if one matches; run **Resubmit all** once after setup so every live entry has a known URL.
111
+ - Publishing the same entry again within 60 seconds does not resend its URL.
112
+ - **Resubmit all** reads up to 5,000 entries per run.
113
+ - Only URLs on the Site URL host are sent; IndexNow rejects a request that mixes hosts.
114
+ - **Cloudflare Workers:** `api.indexnow.org` and `www.bing.com` may answer `429 Too many requests` to every request from a Worker (Bing rate-limits Cloudflare's shared egress addresses). Choose `yandex.com` or `searchadvisor.naver.com` as the endpoint: the URLs still reach Bing and the other participants.
115
+
116
+ ## Develop
117
+
118
+ ```sh
119
+ pnpm install
120
+ pnpm run validate
121
+ pnpm run typecheck
122
+ pnpm test # builds the plugin and runs it through EmDash's sandbox host
123
+ pnpm run build
124
+ pnpm run bundle # registry tarball in dist/
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT, see [LICENSE](./LICENSE).
130
+
131
+ ---
132
+
133
+ Made by [Tag Perfect](https://tagperfect.eu).
@@ -0,0 +1,5 @@
1
+ // Auto-generated by emdash-plugin build. Do not edit.
2
+ import type { PluginDescriptor } from "emdash";
3
+
4
+ declare const descriptor: PluginDescriptor;
5
+ export default descriptor;
package/dist/index.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Auto-generated by emdash-plugin build. Do not edit.
2
+ // Source: emdash-plugin.jsonc + package.json
3
+ //
4
+ // Default-exports a sandboxed plugin descriptor. Pass it directly into
5
+ // emdash's `plugins:` or `sandboxed:` array — no factory call needed.
6
+
7
+ /** @type {import("emdash").PluginDescriptor} */
8
+ const descriptor = Object.freeze({
9
+ "id": "indexnow",
10
+ "version": "1.1.0",
11
+ "format": "standard",
12
+ "entrypoint": "@tagperfect/emdash-plugin-indexnow/sandbox",
13
+ "capabilities": [
14
+ "content:read",
15
+ "network:request"
16
+ ],
17
+ "allowedHosts": [
18
+ "api.indexnow.org",
19
+ "www.bing.com",
20
+ "yandex.com",
21
+ "search.seznam.cz",
22
+ "searchadvisor.naver.com",
23
+ "indexnow.yep.com"
24
+ ],
25
+ "storage": {
26
+ "submissions": {
27
+ "indexes": [
28
+ "at"
29
+ ]
30
+ }
31
+ },
32
+ "hooks": [
33
+ "plugin:activate",
34
+ "plugin:deactivate",
35
+ {
36
+ "name": "content:afterPublish",
37
+ "errorPolicy": "continue"
38
+ },
39
+ {
40
+ "name": "content:afterSave",
41
+ "errorPolicy": "continue"
42
+ },
43
+ {
44
+ "name": "content:afterRestore",
45
+ "errorPolicy": "continue"
46
+ },
47
+ {
48
+ "name": "content:afterUnpublish",
49
+ "errorPolicy": "continue"
50
+ },
51
+ {
52
+ "name": "content:beforeDelete",
53
+ "errorPolicy": "continue"
54
+ },
55
+ {
56
+ "name": "content:afterDelete",
57
+ "errorPolicy": "continue"
58
+ },
59
+ "cron"
60
+ ],
61
+ "routes": [
62
+ {
63
+ "name": "admin",
64
+ "permission": "plugins:manage"
65
+ },
66
+ {
67
+ "name": "key",
68
+ "methods": [
69
+ "GET",
70
+ "HEAD"
71
+ ],
72
+ "response": "raw",
73
+ "public": true,
74
+ "cacheControl": "public, max-age=300"
75
+ }
76
+ ],
77
+ "adminPages": [
78
+ {
79
+ "path": "/settings",
80
+ "label": "IndexNow",
81
+ "icon": "settings"
82
+ },
83
+ {
84
+ "path": "/submissions",
85
+ "label": "IndexNow submissions",
86
+ "icon": "list"
87
+ }
88
+ ],
89
+ "adminWidgets": [
90
+ {
91
+ "id": "status",
92
+ "title": "IndexNow",
93
+ "size": "third"
94
+ }
95
+ ]
96
+ });
97
+
98
+ export default descriptor;
@@ -0,0 +1,106 @@
1
+ {
2
+ "id": "indexnow",
3
+ "version": "1.1.0",
4
+ "declaredAccess": {
5
+ "content": {
6
+ "read": {}
7
+ },
8
+ "network": {
9
+ "request": {
10
+ "allowedHosts": [
11
+ "api.indexnow.org",
12
+ "www.bing.com",
13
+ "yandex.com",
14
+ "search.seznam.cz",
15
+ "searchadvisor.naver.com",
16
+ "indexnow.yep.com"
17
+ ]
18
+ }
19
+ }
20
+ },
21
+ "capabilities": [
22
+ "content:read",
23
+ "network:request"
24
+ ],
25
+ "allowedHosts": [
26
+ "api.indexnow.org",
27
+ "www.bing.com",
28
+ "yandex.com",
29
+ "search.seznam.cz",
30
+ "searchadvisor.naver.com",
31
+ "indexnow.yep.com"
32
+ ],
33
+ "storage": {
34
+ "submissions": {
35
+ "indexes": [
36
+ "at"
37
+ ]
38
+ }
39
+ },
40
+ "hooks": [
41
+ "plugin:activate",
42
+ "plugin:deactivate",
43
+ {
44
+ "name": "content:afterPublish",
45
+ "errorPolicy": "continue"
46
+ },
47
+ {
48
+ "name": "content:afterSave",
49
+ "errorPolicy": "continue"
50
+ },
51
+ {
52
+ "name": "content:afterRestore",
53
+ "errorPolicy": "continue"
54
+ },
55
+ {
56
+ "name": "content:afterUnpublish",
57
+ "errorPolicy": "continue"
58
+ },
59
+ {
60
+ "name": "content:beforeDelete",
61
+ "errorPolicy": "continue"
62
+ },
63
+ {
64
+ "name": "content:afterDelete",
65
+ "errorPolicy": "continue"
66
+ },
67
+ "cron"
68
+ ],
69
+ "routes": [
70
+ {
71
+ "name": "admin",
72
+ "permission": "plugins:manage"
73
+ },
74
+ {
75
+ "name": "key",
76
+ "methods": [
77
+ "GET",
78
+ "HEAD"
79
+ ],
80
+ "response": "raw",
81
+ "public": true,
82
+ "cacheControl": "public, max-age=300"
83
+ }
84
+ ],
85
+ "admin": {
86
+ "pages": [
87
+ {
88
+ "path": "/settings",
89
+ "label": "IndexNow",
90
+ "icon": "settings"
91
+ },
92
+ {
93
+ "path": "/submissions",
94
+ "label": "IndexNow submissions",
95
+ "icon": "list"
96
+ }
97
+ ],
98
+ "widgets": [
99
+ {
100
+ "id": "status",
101
+ "title": "IndexNow",
102
+ "size": "third"
103
+ }
104
+ ]
105
+ }
106
+ }
@@ -0,0 +1,6 @@
1
+ import { SandboxedPlugin } from "emdash/plugin";
2
+
3
+ //#region src/plugin.d.ts
4
+ declare const plugin: SandboxedPlugin;
5
+ //#endregion
6
+ export { plugin as default };
@@ -0,0 +1 @@
1
+ function e(e={}){let t=[];return new Headers(e.headers).forEach((e,n)=>t.push([n,e])),{__emdashPluginResponse:!0,status:e.status??200,headers:t,body:e.body??null}}const t=[{host:`api.indexnow.org`,label:`api.indexnow.org (shared by all engines)`},{host:`www.bing.com`,label:`Bing`},{host:`yandex.com`,label:`Yandex`},{host:`search.seznam.cz`,label:`Seznam`},{host:`searchadvisor.naver.com`,label:`Naver`},{host:`indexnow.yep.com`,label:`Yep`}],n=`api.indexnow.org`,r=/^[A-Za-z0-9-]{8,128}$/;function i(e){return t.some(t=>t.host===e)}function a(e){return`https://${e}/indexnow`}function o(){return crypto.randomUUID().replaceAll(`-`,``)}function s(e,t,n,r){return{host:e,key:t,keyLocation:n,urlList:r}}function c(e,t=1e4){let n=[];for(let r=0;r<e.length;r+=t)n.push(e.slice(r,r+t));return n}const l={200:{ok:!0,retry:!1,setupProblem:!1,message:`Submitted.`},202:{ok:!0,retry:!1,setupProblem:!1,message:`Accepted; key validation pending.`},400:{ok:!1,retry:!1,setupProblem:!1,message:`Bad request: the request format is invalid.`},403:{ok:!1,retry:!1,setupProblem:!0,message:`Key not valid: the key file was not found at keyLocation or does not contain the key.`},422:{ok:!1,retry:!1,setupProblem:!0,message:`Unprocessable: the URLs do not belong to the host, or the key does not match the protocol format.`},429:{ok:!1,retry:!0,setupProblem:!1,message:`Too many requests: rate limited, will retry later.`}};function u(e,t=``){let n=l[e],r;r=n||(e===0?{ok:!1,retry:!0,setupProblem:!1,message:`Network error: no response, will retry later.`}:e>=500?{ok:!1,retry:!0,setupProblem:!1,message:`Search engine error ${e}, will retry later.`}:e>=200&&e<300?{ok:!0,retry:!1,setupProblem:!1,message:`Accepted (${e}).`}:{ok:!1,retry:!1,setupProblem:!1,message:`Unexpected response ${e}.`});let i=r.ok?``:t.replace(/\s+/g,` `).trim().slice(0,200);return{status:e,...r,message:i?`${r.message} ${i}`:r.message}}const d=864e5,f=e=>e.storage.submissions,p=e=>`stats:${e.slice(0,10)}`;async function ee(e,t){let n=`${t.at}-${crypto.randomUUID().slice(0,8)}`;await f(e).put(n,{...t,urls:t.urls.slice(0,20),count:t.urls.length});let r=p(t.at),i=await e.kv.get(r)??{ok:0,failed:0};await e.kv.set(r,t.ok?{...i,ok:i.ok+1}:{...i,failed:i.failed+1}),await te(e)}async function te(e){let t=f(e),n=await t.count()-100;if(n>0){let e=await t.query({orderBy:{at:`asc`},limit:Math.min(n,100)});await t.deleteMany(e.items.map(e=>e.id))}let r=p(new Date(Date.now()-8*d).toISOString()),i=(await e.kv.list(`stats:`)).filter(e=>e.key<r);await Promise.all(i.map(t=>e.kv.delete(t.key)))}async function m(e,t=100){return(await f(e).query({orderBy:{at:`desc`},limit:Math.min(t,100)})).items.map(e=>e.data)}async function h(e,t=new Date){let n=Array.from({length:7},(e,n)=>p(new Date(t.getTime()-n*d).toISOString()));return(await Promise.all(n.map(t=>e.kv.get(t)))).reduce((e,t)=>({ok:e.ok+(t?.ok??0),failed:e.failed+(t?.failed??0)}),{ok:0,failed:0})}const g={get:e=>e.kv.get(`state:setupProblem`),set:(e,t)=>e.kv.set(`state:setupProblem`,t),clear:e=>e.kv.delete(`state:setupProblem`)},_=/^[A-Za-z_]\w*$/,ne=/\{(slug|id|locale)\}/g;function v(e){let t=/^([A-Za-z_]\w*)(!=|=)(.*)$/.exec(e);if(t)return{field:t[1],op:t[2]===`=`?`eq`:`neq`,value:t[3]};let n=/^(!?)([A-Za-z_]\w*)$/.exec(e);return n?{field:n[2],op:n[1]?`unset`:`set`}:null}function y(e){let t=[],n=[];return e.split(/\r?\n/).forEach((e,r)=>{let i=r+1,a=e.trim();if(!a||a.startsWith(`#`))return;let[o,s,c,...l]=a.split(/\s+/);if(!o||!s||!c){n.push(`Line ${i}: expected "<collection> <locale|*> <pattern|->".`);return}if(!_.test(o)){n.push(`Line ${i}: "${o}" is not a collection slug.`);return}if(c!==`-`&&!c.startsWith(`/`)){n.push(`Line ${i}: the pattern must start with "/" or be "-".`);return}let u=[];for(let e of l){let t=v(e);if(!t){n.push(`Line ${i}: "${e}" is not a condition (field=value, field!=value, field, !field).`);return}u.push(t)}t.push({collection:o,locale:s,pattern:c===`-`?null:c,conditions:u,line:i})}),{rules:t,errors:n}}const re=e=>typeof e==`string`?e:typeof e==`number`||typeof e==`boolean`?String(e):``;function ie(e,t){return t===`slug`?e.slug??``:t===`locale`?e.locale??``:t===`id`?e.id:re(e.data[t])}function b(e,t){let n=ie(t,e.field);switch(e.op){case`eq`:return n===e.value;case`neq`:return n!==e.value;case`set`:return n!==``&&n!==`false`;case`unset`:return n===``||n===`false`}}function x(e,t){return e.find(e=>e.collection===t.collection&&(e.locale===`*`||e.locale===(t.locale??``))&&e.conditions.every(e=>b(e,t)))}function ae(e,t){let n=!1,r=e.replace(ne,(e,r)=>{let i=r===`slug`?t.slug:r===`id`?t.id:t.locale;return i||(n=!0),encodeURIComponent(i??``)});return n?null:r}function S(e){try{let t=new URL(e);return t.protocol===`https:`||t.protocol===`http:`?t.origin:null}catch{return null}}function C(e,t){return`${e.replace(/\/+$/,``)}${t.startsWith(`/`)?``:`/`}${t}`}function oe(e,t){let n=new URL(e);return C(t,`${n.pathname}${n.search}`)}function w(e){return e.status===`published`?e.seo?.noIndex===!0?`noindex`:`ok`:`draft`}function T(e){return[...new Set(e.split(/[\s,]+/).filter(e=>_.test(e)))]}function E(e,t){return e.length===0||e.includes(t)}function se(e,t){let n=new Set,r=[];for(let i of e){let e;try{e=new URL(i.trim())}catch{r.push(i);continue}if(e.protocol!==`https:`&&e.protocol!==`http:`||e.host!==t){r.push(i);continue}e.hash=``,n.add(e.href)}return{urls:[...n],dropped:r}}const D=e=>typeof e==`string`?e.trim():``;async function O(e){let[t,r,a,o,s,c,l,u]=await Promise.all([`enabled`,`key`,`endpoint`,`collections`,`siteUrl`,`keyLocation`,`keyLocationUrl`,`urlRules`].map(t=>e.settings.get(t))),d=D(a),f=D(o);return{enabled:t===!0,key:D(r),endpoint:i(d)?d:n,collectionsText:f,collections:T(f),siteUrl:D(s),keyLocation:c===`custom`?`custom`:`root`,keyLocationUrl:D(l),urlRules:typeof u==`string`?u:``}}function k(e){let t=D(e.key),a=D(e.siteUrl).replace(/\/+$/,``),o=e.keyLocation===`custom`?`custom`:`root`,s=D(e.keyLocationUrl),c=D(e.endpoint)||n,l=typeof e.urlRules==`string`?e.urlRules:``;if(t&&!r.test(t))return{ok:!1,error:`The key must be 8–128 characters: letters, digits and dashes.`};if(a&&!S(a))return{ok:!1,error:`Site URL must be an absolute http(s) URL, e.g. https://example.com.`};if(o===`custom`&&!S(s))return{ok:!1,error:`Key file URL must be an absolute http(s) URL.`};if(!i(c))return{ok:!1,error:`Unknown endpoint.`};let{errors:u}=y(l);return u.length?{ok:!1,error:`URL rules: ${u[0]}`}:e.enabled===!0&&!t?{ok:!1,error:`Set or generate a key before enabling.`}:{ok:!0,values:{enabled:e.enabled===!0,key:t,endpoint:c,collections:T(D(e.collections)).join(`, `),siteUrl:a,keyLocation:o,keyLocationUrl:s,urlRules:l}}}function A(e,t){return S(e.siteUrl||t.site.url||``)}function j(e,t){return e.keyLocation===`custom`&&e.keyLocationUrl?e.keyLocationUrl:C(t,`/${e.key}.txt`)}function M(e,t){return e.enabled?e.key?t?null:`The site URL is unknown: set Site URL.`:`No key is set.`:`IndexNow is turned off.`}const N=(e,t)=>`url:${e}:${t}`,P=(e,t)=>`gone:${e}:${t}`,F=`state:retry`;function I(e,t){let n=e=>typeof e==`string`?e:null;return{collection:e,id:String(t.id??``),slug:n(t.slug),locale:n(t.locale),status:n(t.status)??``,data:t.data&&typeof t.data==`object`?t.data:{},seo:t.seo??null}}async function L(e,t,n,r){let i=x(y(t.urlRules).rules,r);if(i){let e=i.pattern&&ae(i.pattern,r);return e?C(n,e):null}if(r.status!==`published`)return null;let a=await e.content?.getPublicUrl?.(r.collection,r.id);return a?oe(a,n):null}async function R(e,t,n,r){let i=A(t,e),o=M(t,i);if(o||!i)return e.log.info(`IndexNow: not sent (${o})`,{urls:n}),null;let l=new URL(i).host,d=se(n,l);if(d.dropped.length&&e.log.warn(`IndexNow: skipped URLs outside ${l}`,{urls:d.dropped}),!d.urls.length)return null;if(!e.http)throw Error(`IndexNow: the network:request capability is not granted.`);let f=null;for(let n of c(d.urls)){let o;try{let r=await e.http.fetch(a(t.endpoint),{method:`POST`,headers:{"Content-Type":`application/json; charset=utf-8`},body:JSON.stringify(s(l,t.key,j(t,i),n))});o=u(r.status,r.ok?``:await r.text().catch(()=>``))}catch(e){o=u(0,e instanceof Error?e.message:String(e))}let c=new Date().toISOString();await ee(e,{at:c,event:r,urls:n,status:o.status,ok:o.ok,message:o.message,endpoint:t.endpoint});let d={status:o.status,count:n.length,urls:n.slice(0,5),endpoint:t.endpoint};o.ok?(e.log.info(`IndexNow ${o.status}: ${o.message}`,d),await g.clear(e)):(e.log.warn(`IndexNow ${o.status}: ${o.message}`,d),o.setupProblem&&await g.set(e,{at:c,status:o.status,message:o.message}),o.retry&&r!==`retry`&&await z(e,n)),f=o}return f}async function z(e,t){let n=await e.kv.get(F)??[];await e.kv.set(F,[...new Set([...n,...t])].slice(-1e4))}async function ce(e){let t=await e.kv.get(F)??[];t.length&&(await e.kv.delete(F),(await R(e,await O(e),t,`retry`))?.retry&&await z(e,t))}async function B(e,t,n,r){let i=await O(e);if(!i.enabled||!E(i.collections,t)||!n)return;let a=await e.content?.get(t,n);if(!a)return;let o=I(t,a);if(r===`update`&&a.draftRevisionId&&a.draftRevisionId!==a.liveRevisionId)return;let s=w(o);if(s!==`ok`){e.log.info(`IndexNow: ${t}/${n} skipped (${s})`),s===`noindex`&&await e.kv.delete(N(t,n));return}let c=A(i,e),l=c&&await L(e,i,c,o);if(!l){e.log.info(`IndexNow: ${t}/${n} has no public URL`);return}let u=await e.kv.get(N(t,n));if(await e.kv.set(N(t,n),l),await U(e,l)){e.log.info(`IndexNow: ${l} skipped (sent less than ${V/1e3} s ago)`);return}await R(e,i,[l],r===`publish`&&u?`update`:r)}const V=6e4,H=`state:recent`;async function U(e,t,n=Date.now()){let r=await e.kv.get(H)??{},i=Object.fromEntries(Object.entries(r).filter(([,e])=>n-e<V));return i[t]===void 0?(await e.kv.set(H,{...i,[t]:n}),!1):!0}async function W(e,t,n){let r=await O(e);if(!r.enabled||!E(r.collections,t)||!n)return;let i=await e.kv.get(N(t,n));if(!i){let a=await e.content?.get(t,n),o=A(r,e);a&&o&&(i=await L(e,r,o,{...I(t,a),status:`published`}))}i&&(await e.kv.delete(N(t,n)),await R(e,r,[i],`unpublish`))}async function le(e,t,n){let r=await O(e);if(!r.enabled||!E(r.collections,t))return;let i=await e.kv.get(N(t,n));if(!i){let a=await e.content?.get(t,n),o=A(r,e);a&&o&&w(I(t,a))===`ok`&&(i=await L(e,r,o,I(t,a)))}i&&await e.kv.set(P(t,n),i)}async function ue(e,t,n){let r=await e.kv.get(P(t,n));r&&(await Promise.all([e.kv.delete(P(t,n)),e.kv.delete(N(t,n))]),await R(e,await O(e),[r],`delete`))}async function de(e){let t=await O(e),n=A(t,e),r=M(t,n);if(r||!n)return{outcome:null,urls:0,error:r??`Site URL unknown.`};let i=t.collections.length?t.collections:[...new Set(y(t.urlRules).rules.map(e=>e.collection))];if(!i.length)return{outcome:null,urls:0,error:`List the collections to submit in Settings → Collections.`};let a=[],o=0;for(let r of i){let i;do{let s=await e.content.list(r,{where:{status:`published`},limit:100,cursor:i});for(let i of s.items){let o=I(r,i);if(w(o)!==`ok`)continue;let s=await L(e,t,n,o);s&&(a.push(s),await e.kv.set(N(r,o.id),s))}o+=s.items.length,i=s.hasMore?s.cursor:void 0}while(i&&o<5e3)}let s=[...new Set(a)];return s.length?{outcome:await R(e,t,s,`resubmit`),urls:s.length}:{outcome:null,urls:0,error:`No published URLs found.`}}const G=`/submissions`,K={kind:`plugin-page`,path:`/settings`};function q(e){return e.status===0?`no response`:String(e.status)}function J(e){let t=e.urls[0]??``;return e.count>1?`${t} (+${e.count-1})`:t}async function Y(e,n){let r=n??await O(e),i=A(r,e),a=M(r,i),o=[{type:`header`,text:`IndexNow`}];return a&&o.push({type:`banner`,variant:`alert`,title:`Not submitting`,description:a}),r.key||o.push({type:`section`,text:`No key yet. Generate one, then serve it at the key file URL shown below.`,accessory:{type:`button`,action_id:`generate_key`,label:`Generate key`,style:`primary`}}),o.push({type:`form`,block_id:`settings`,fields:[{type:`toggle`,action_id:`enabled`,label:`Submit URLs to IndexNow`,initial_value:r.enabled},{type:`text_input`,action_id:`key`,label:`Key`,placeholder:`8–128 characters: a–z, A–Z, 0–9, -`,initial_value:r.key},{type:`select`,action_id:`endpoint`,label:`Endpoint`,options:t.map(e=>({label:e.label,value:e.host})),initial_value:r.endpoint},{type:`text_input`,action_id:`collections`,label:`Collections`,placeholder:`posts, pages (empty = all)`,initial_value:r.collectionsText},{type:`text_input`,action_id:`siteUrl`,label:`Site URL`,placeholder:e.site.url||`https://example.com`,initial_value:r.siteUrl},{type:`select`,action_id:`keyLocation`,label:`Key file`,options:[{label:`Site root: /{key}.txt (recommended)`,value:`root`},{label:`Custom URL`,value:`custom`}],initial_value:r.keyLocation},{type:`text_input`,action_id:`keyLocationUrl`,label:`Key file URL`,placeholder:`https://example.com/indexnow-key.txt`,initial_value:r.keyLocationUrl,condition:{field:`keyLocation`,eq:`custom`}},{type:`text_input`,action_id:`urlRules`,label:`URL rules (advanced)`,multiline:!0,initial_value:r.urlRules}],submit:{label:`Save`,action_id:`save_settings`}}),o.push({type:`context`,text:"One rule per line: `<collection> <locale|*> <pattern|-> [conditions]`. Placeholders {slug}, {id}, {locale}; `-` = no page. Conditions on entry fields: field=value, field!=value, field (set), !field (empty). First match wins; entries without a match use the collection's URL pattern."}),r.key&&i&&(o.push({type:`fields`,fields:[{label:`Key file must return the key at`,value:j(r,i)},{label:`Plugin key route`,value:`${i}/_emdash/api/plugins/indexnow/key`}]}),o.push({type:`context`,text:`A key file covers only URLs in its own folder and below, so serve it at the site root (a static file, a site route, or a URL rewrite to the plugin key route).`})),o.push({type:`actions`,elements:[{type:`link`,label:`Submissions`,target:{kind:`plugin-page`,path:G}}]}),{blocks:o}}async function X(e){let[t,n]=await Promise.all([m(e),h(e)]);return{blocks:[{type:`header`,text:`IndexNow submissions`},{type:`stats`,items:[{label:`Successful, 7 days`,value:n.ok},{label:`Failed, 7 days`,value:n.failed}]},{type:`actions`,elements:[{type:`button`,action_id:`resubmit_all`,label:`Resubmit all published URLs`,style:`primary`,confirm:{title:`Resubmit all published URLs?`,text:`Every live, indexable URL of the tracked collections is sent again. Use it after setup or a site move, not routinely.`,confirm:`Resubmit`,deny:`Cancel`}},{type:`link`,label:`Settings`,target:K}]},{type:`table`,page_action_id:`log_page`,columns:[{key:`at`,label:`Time`,format:`relative_time`},{key:`url`,label:`URL`},{key:`status`,label:`Status`,format:`badge`},{key:`event`,label:`Event`},{key:`message`,label:`Message`}],rows:t.map(e=>({at:e.at,url:J(e),status:q(e),event:e.event,message:e.message})),empty_text:`Nothing submitted yet.`}]}}async function fe(e){let t=await O(e);if(M(t,A(t,e)))return{blocks:[{type:`section`,text:t.enabled?`Not configured`:`Off`,accessory:{type:`link`,label:`Set up`,target:K,appearance:`primary`}}]};let[n,r,i]=await Promise.all([m(e,5),h(e),g.get(e)]),a=[];return i&&(a.push({type:`banner`,variant:`error`,title:i.status===403?`Key rejected (403)`:`Setup problem (${i.status})`,description:i.message}),a.push({type:`actions`,elements:[{type:`link`,label:`Check settings`,target:K,appearance:`primary`}]})),a.push({type:`stats`,items:[{label:`OK, 7 days`,value:r.ok},{label:`Failed, 7 days`,value:r.failed,...r.failed?{trend:`down`}:{}}]}),a.push({type:`table`,page_action_id:`widget_page`,columns:[{key:`url`,label:`URL`},{key:`status`,label:`Status`,format:`badge`},{key:`at`,label:`Time`,format:`relative_time`}],rows:n.map(e=>({url:J(e),status:q(e),at:e.at})),empty_text:`Nothing submitted yet.`}),{blocks:a}}async function Z(e,t){return t===`widget:status`?fe(e):t===G?X(e):Y(e)}function pe(e){if(!e||typeof e!=`object`)return!1;let t=e.type;return t===`page_load`||t===`block_action`||t===`form_submit`}async function me(e,t){if(!pe(e))return{blocks:[]};if(e.type===`page_load`)return Z(t,e.page);if(e.type===`form_submit`&&e.action_id===`save_settings`){let n=k(e.values);return n.ok?(await Promise.all(Object.entries(n.values).map(([e,n])=>t.settings.set(e,n))),await g.clear(t),{...await Y(t),toast:{type:`success`,message:`Settings saved`}}):{...await Y(t),toast:{type:`error`,message:n.error}}}if(e.type===`block_action`&&e.action_id===`generate_key`)return(await O(t)).key||await t.settings.set(`key`,o()),{...await Y(t),toast:{type:`success`,message:`Key generated. Serve the key file, then enable submissions.`}};if(e.type===`block_action`&&e.action_id===`resubmit_all`){let e=await de(t),n=e.error?{type:`error`,message:e.error}:e.outcome?.ok?{type:`success`,message:`${e.urls} URLs submitted (${e.outcome.status}).`}:{type:`error`,message:e.outcome?.message??`Nothing was sent.`};return{...await X(t),toast:n}}return Z(t,e.page)}const Q=`retry`,$=e=>typeof e.id==`string`?e.id:``,he={hooks:{"plugin:activate":async(e,t)=>{await t.cron?.schedule(Q,{schedule:`*/30 * * * *`})},"plugin:deactivate":async(e,t)=>{await t.cron?.cancel(Q)},"content:afterPublish":{errorPolicy:`continue`,handler:async(e,t)=>B(t,e.collection,$(e.content),`publish`)},"content:afterSave":{errorPolicy:`continue`,handler:async(e,t)=>B(t,e.collection,$(e.content),`update`)},"content:afterRestore":{errorPolicy:`continue`,handler:async(e,t)=>B(t,e.collection,$(e.content),`restore`)},"content:afterUnpublish":{errorPolicy:`continue`,handler:async(e,t)=>W(t,e.collection,$(e.content))},"content:beforeDelete":{errorPolicy:`continue`,handler:async(e,t)=>{await le(t,e.collection,e.id)}},"content:afterDelete":{errorPolicy:`continue`,handler:async(e,t)=>ue(t,e.collection,e.id)},cron:async(e,t)=>{e.name===Q&&await ce(t)}},routes:{admin:{permission:`plugins:manage`,handler:async(e,t)=>me(e.input,t)},key:{public:!0,methods:[`GET`,`HEAD`],response:`raw`,cacheControl:`public, max-age=300`,handler:async(t,n)=>{let{key:r}=await O(n);return e(r?{status:200,headers:{"Content-Type":`text/plain; charset=utf-8`},body:{kind:`text`,value:r}}:{status:404,headers:{"Content-Type":`text/plain; charset=utf-8`},body:{kind:`text`,value:`Not found`}})}}}};export{he as default};
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
3
+
4
+ "slug": "indexnow",
5
+ "name": "IndexNow",
6
+ "description": "Notifies Bing, Yandex, Seznam, Naver and other IndexNow engines when content is published, changed or removed.",
7
+ "keywords": ["indexnow", "seo", "bing", "yandex", "search"],
8
+ // Atmosphere account (DID or handle) that owns registry releases. Replace before the first registry publish.
9
+ "publisher": "did:plc:REPLACE-WITH-TAGPERFECT-DID",
10
+
11
+ "license": "MIT",
12
+ "author": { "name": "Tag Perfect OÜ", "url": "https://tagperfect.eu", "email": "npm@tagperfect.eu" },
13
+ "security": { "url": "https://github.com/tagperfect/emdash-plugin-indexnow/security" },
14
+ "repo": "https://github.com/tagperfect/emdash-plugin-indexnow",
15
+
16
+ // Trust contract. content:read: the entry's status, slug, locale, SEO noindex flag and public URL.
17
+ // network:request: POST to the IndexNow endpoints below and nowhere else.
18
+ "capabilities": ["content:read", "network:request"],
19
+ "allowedHosts": [
20
+ "api.indexnow.org",
21
+ "www.bing.com",
22
+ "yandex.com",
23
+ "search.seznam.cz",
24
+ "searchadvisor.naver.com",
25
+ "indexnow.yep.com"
26
+ ],
27
+ // Submission journal (newest 100), ordered by time.
28
+ "storage": {
29
+ "submissions": { "indexes": ["at"] }
30
+ },
31
+
32
+ "admin": {
33
+ "pages": [
34
+ { "path": "/settings", "label": "IndexNow", "icon": "settings" },
35
+ { "path": "/submissions", "label": "IndexNow submissions", "icon": "list" }
36
+ ],
37
+ "widgets": [{ "id": "status", "title": "IndexNow", "size": "third" }]
38
+ },
39
+
40
+ "release": {
41
+ "artifacts": { "icon": { "file": "./assets/icon.png" } }
42
+ }
43
+ }
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round">
2
+ <circle cx="112" cy="112" r="80"/>
3
+ <line x1="168.6" y1="168.6" x2="224" y2="224"/>
4
+ <polyline points="124 64 92 116 132 116 100 160"/>
5
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
- "name": "@tagperfect/emdash-plugin-indexnow",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@tagperfect/emdash-plugin-indexnow",
3
+ "version": "1.1.0",
4
+ "description": "Notifies Bing, Yandex, Seznam, Naver and other IndexNow engines when EmDash content is published, changed, unpublished or deleted.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Tag Perfect OÜ <npm@tagperfect.eu>",
8
+ "homepage": "https://tagperfect.eu",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/tagperfect/emdash-plugin-indexnow.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/tagperfect/emdash-plugin-indexnow/issues"
15
+ },
16
+ "keywords": [
17
+ "emdash",
18
+ "emdash-plugin",
19
+ "indexnow",
20
+ "seo",
21
+ "bing",
22
+ "yandex"
23
+ ],
24
+ "packageManager": "pnpm@10.34.7",
25
+ "main": "dist/index.mjs",
26
+ "exports": {
27
+ ".": {
28
+ "import": "./dist/index.mjs",
29
+ "types": "./dist/index.d.mts"
30
+ },
31
+ "./sandbox": "./dist/plugin.mjs"
32
+ },
33
+ "files": [
34
+ "dist/index.mjs",
35
+ "dist/index.d.mts",
36
+ "dist/plugin.mjs",
37
+ "dist/plugin.d.mts",
38
+ "dist/manifest.json",
39
+ "emdash-plugin.jsonc",
40
+ "icon.svg",
41
+ "LICENSE",
42
+ "README.md"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "provenance": true
47
+ },
48
+ "scripts": {
49
+ "validate": "emdash-plugin validate",
50
+ "build": "emdash-plugin build",
51
+ "dev": "emdash-plugin dev",
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "emdash-plugin validate && vitest run",
54
+ "bundle": "emdash-plugin bundle",
55
+ "login": "emdash-plugin login",
56
+ "publish:registry": "emdash-plugin publish",
57
+ "release:setup": "emdash-plugin release setup",
58
+ "prepublishOnly": "pnpm run build"
59
+ },
60
+ "peerDependencies": {
61
+ "emdash": ">=1.3.1 <2"
62
+ },
63
+ "devDependencies": {
64
+ "@emdash-cms/blocks": "1.3.1",
65
+ "@emdash-cms/plugin-cli": "0.13.4",
66
+ "@emdash-cms/plugin-test": "0.2.10",
67
+ "emdash": "1.3.1",
68
+ "typescript": "^5.9.0",
69
+ "vitest": "^4.1.0"
70
+ }
71
+ }