|
| 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