Skip to content

Commit 136b988

Browse files
authored
feat(vite): add IndexNow build plugin (#13)
Add a production-only Vite plugin that submits sitemap manifest URLs to IndexNow after builds, with a local verification-key guard for non-deploy environments.
1 parent 0c7568d commit 136b988

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

src/lib/vite/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99
export { demoManifestPlugin, type DemoManifestOptions } from './demo-manifest.js'
1010
export { docMirrorsPlugin, type DocMirrorsOptions } from './doc-mirrors.js'
11+
export { indexNowPlugin, type IndexNowOptions } from './indexnow.js'
1112
export { llmsFullPlugin, type LlmsFullOptions } from './llms-full.js'
1213
export { llmsPlugin, type LlmsOptions } from './llms.js'
1314
export { sitemapManifestPlugin, type SitemapManifestOptions } from './sitemap-manifest.js'

src/lib/vite/indexnow.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* Vite plugin: docs-kit IndexNow submitter.
3+
*
4+
* Reads the sitemap manifest emitted by `sitemapManifestPlugin()` and submits
5+
* the public URLs to IndexNow after a production build. This replaces per-repo
6+
* `deploy:indexnow` scripts while keeping the ping coupled to real production
7+
* builds only; `pnpm dev` and non-production build modes do not call IndexNow.
8+
*
9+
* Wiring (consumer's `vite.config.ts`):
10+
*
11+
* ```ts
12+
* import { indexNowPlugin, sitemapManifestPlugin } from '@humanspeak/docs-kit/vite'
13+
*
14+
* export default defineConfig({
15+
* plugins: [
16+
* sitemapManifestPlugin(),
17+
* indexNowPlugin({
18+
* siteUrl: 'https://example.com',
19+
* key: '00000000-0000-0000-0000-000000000000'
20+
* })
21+
* ]
22+
* })
23+
* ```
24+
*/
25+
import { existsSync } from 'node:fs'
26+
import { readFile } from 'node:fs/promises'
27+
import { resolve as resolvePath } from 'node:path'
28+
import type { Plugin } from 'vite'
29+
30+
type SitemapManifest = Record<string, string>
31+
32+
const submissionPromisesKey = Symbol.for('docs-kit:indexnow:submission-promises')
33+
34+
interface IndexNowGlobal {
35+
[submissionPromisesKey]?: Map<string, Promise<void>>
36+
}
37+
38+
export interface IndexNowOptions {
39+
/** Public site origin, e.g. `https://virtualchat.svelte.page`. Required. */
40+
siteUrl: string
41+
/** IndexNow key. Required. The matching `<key>.txt` file must be publicly reachable. */
42+
key: string
43+
/** Host sent to IndexNow. Defaults to the hostname parsed from `siteUrl`. */
44+
host?: string
45+
/** Public key file URL. Defaults to `${siteUrl}/${key}.txt`. */
46+
keyLocation?: string
47+
/**
48+
* Local verification file path, relative to the Vite root.
49+
* Default `static/<key>.txt`. Pass `false` to disable the local existence check.
50+
*/
51+
keyFilePath?: string | false
52+
/** Manifest path relative to the Vite root. Default `src/lib/sitemap-manifest.json`. */
53+
manifestPath?: string
54+
/** Route prefixes to exclude from submission. Default `['/social-cards']`. */
55+
excludePrefixes?: string[]
56+
/** Extra route paths or absolute URLs to submit in addition to the manifest. */
57+
extraUrls?: string[]
58+
/** IndexNow endpoint. Default `https://api.indexnow.org/indexnow`. */
59+
endpoint?: string
60+
/** Set false to log IndexNow failures without failing the build. Default true. */
61+
failOnError?: boolean
62+
/**
63+
* Build mode that is allowed to submit. Default `production`.
64+
* Pass `false` to allow any `vite build` mode.
65+
*/
66+
productionMode?: string | false
67+
/** Filesystem root. When omitted, the plugin adopts Vite's resolved root. */
68+
root?: string
69+
}
70+
71+
function assertOptions(options: IndexNowOptions) {
72+
if (!options.siteUrl) throw new Error('[docs-kit:indexnow] `siteUrl` option is required')
73+
if (!options.key) throw new Error('[docs-kit:indexnow] `key` option is required')
74+
}
75+
76+
function normalizeSiteUrl(siteUrl: string): URL {
77+
const url = new URL(siteUrl)
78+
url.pathname = url.pathname.replace(/\/+$/, '')
79+
url.search = ''
80+
url.hash = ''
81+
return url
82+
}
83+
84+
function normalizeRoute(routeOrUrl: string, siteUrl: URL): string {
85+
if (/^https?:\/\//i.test(routeOrUrl)) return routeOrUrl
86+
87+
const route = routeOrUrl.startsWith('/') ? routeOrUrl : `/${routeOrUrl}`
88+
return new URL(route, siteUrl).toString()
89+
}
90+
91+
function uniqueSorted(values: string[]): string[] {
92+
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
93+
}
94+
95+
function resolveKeyFilePath(root: string, options: IndexNowOptions): string | false {
96+
const keyFilePath = options.keyFilePath
97+
if (keyFilePath === false) return false
98+
return resolvePath(root, keyFilePath ?? `static/${options.key}.txt`)
99+
}
100+
101+
async function readManifest(path: string): Promise<SitemapManifest> {
102+
const raw = await readFile(path, 'utf8')
103+
const parsed = JSON.parse(raw) as unknown
104+
105+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
106+
throw new Error(`[docs-kit:indexnow] Expected sitemap manifest at ${path} to be an object`)
107+
}
108+
109+
return parsed as SitemapManifest
110+
}
111+
112+
/** Factory for the Vite plugin. */
113+
export function indexNowPlugin(userOptions: IndexNowOptions): Plugin {
114+
assertOptions(userOptions)
115+
116+
const siteUrl = normalizeSiteUrl(userOptions.siteUrl)
117+
const host = userOptions.host ?? siteUrl.hostname
118+
const endpoint = userOptions.endpoint ?? 'https://api.indexnow.org/indexnow'
119+
const excludePrefixes = userOptions.excludePrefixes ?? ['/social-cards']
120+
const failOnError = userOptions.failOnError ?? true
121+
const productionMode = userOptions.productionMode ?? 'production'
122+
let root = userOptions.root ?? process.cwd()
123+
let manifestPath = resolvePath(
124+
root,
125+
userOptions.manifestPath ?? 'src/lib/sitemap-manifest.json'
126+
)
127+
let keyFilePath = resolveKeyFilePath(root, userOptions)
128+
let shouldSubmit = false
129+
130+
async function submitOnce() {
131+
const globalState = globalThis as typeof globalThis & IndexNowGlobal
132+
const submissionPromises =
133+
globalState[submissionPromisesKey] ?? new Map<string, Promise<void>>()
134+
globalState[submissionPromisesKey] = submissionPromises
135+
136+
const submissionKey = JSON.stringify({
137+
root,
138+
siteUrl: siteUrl.toString(),
139+
host,
140+
key: userOptions.key,
141+
keyLocation: userOptions.keyLocation,
142+
keyFilePath,
143+
manifestPath,
144+
excludePrefixes,
145+
extraUrls: userOptions.extraUrls,
146+
endpoint
147+
})
148+
149+
let submissionPromise = submissionPromises.get(submissionKey)
150+
if (!submissionPromise) {
151+
submissionPromise = submitIndexNow()
152+
submissionPromises.set(submissionKey, submissionPromise)
153+
}
154+
155+
await submissionPromise
156+
}
157+
158+
async function submitIndexNow() {
159+
if (keyFilePath !== false && !existsSync(keyFilePath)) {
160+
console.log(
161+
`[docs-kit:indexnow] Skipping submission because key file is missing: ${keyFilePath}`
162+
)
163+
return
164+
}
165+
166+
const manifest = await readManifest(manifestPath)
167+
const manifestUrls = Object.keys(manifest)
168+
.filter((route) => !excludePrefixes.some((prefix) => route.startsWith(prefix)))
169+
.map((route) => normalizeRoute(route, siteUrl))
170+
const extraUrls = (userOptions.extraUrls ?? []).map((url) => normalizeRoute(url, siteUrl))
171+
const urlList = uniqueSorted([...manifestUrls, ...extraUrls])
172+
173+
if (urlList.length === 0) {
174+
console.log('[docs-kit:indexnow] No URLs to submit')
175+
return
176+
}
177+
178+
console.log(`[docs-kit:indexnow] Submitting ${urlList.length} URLs to IndexNow`)
179+
180+
const response = await fetch(endpoint, {
181+
method: 'POST',
182+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
183+
body: JSON.stringify({
184+
host,
185+
key: userOptions.key,
186+
keyLocation:
187+
userOptions.keyLocation ??
188+
new URL(`/${userOptions.key}.txt`, siteUrl).toString(),
189+
urlList
190+
})
191+
})
192+
193+
if (response.ok || response.status === 202) {
194+
console.log(
195+
`[docs-kit:indexnow] Accepted (${response.status}): ${urlList.length} URLs submitted`
196+
)
197+
return
198+
}
199+
200+
const message = `[docs-kit:indexnow] Rejected (${response.status}): ${await response.text()}`
201+
if (failOnError) throw new Error(message)
202+
console.warn(message)
203+
}
204+
205+
return {
206+
name: 'docs-kit:indexnow',
207+
apply: 'build',
208+
configResolved(config) {
209+
if (userOptions.root === undefined) root = config.root
210+
manifestPath = resolvePath(
211+
root,
212+
userOptions.manifestPath ?? 'src/lib/sitemap-manifest.json'
213+
)
214+
keyFilePath = resolveKeyFilePath(root, userOptions)
215+
shouldSubmit = productionMode === false || config.mode === productionMode
216+
},
217+
async closeBundle() {
218+
if (!shouldSubmit) return
219+
await submitOnce()
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)