jamdesk 1.1.214 → 1.1.216

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.
Files changed (40) hide show
  1. package/README.md +2 -2
  2. package/dist/__tests__/unit/migrate-resolve-theme.test.js +4 -6
  3. package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
  4. package/dist/commands/migrate/index.d.ts.map +1 -1
  5. package/dist/commands/migrate/index.js +1 -4
  6. package/dist/commands/migrate/index.js.map +1 -1
  7. package/dist/commands/migrate/types.d.ts +1 -1
  8. package/dist/commands/migrate/types.d.ts.map +1 -1
  9. package/dist/commands/migrate/types.js +2 -3
  10. package/dist/commands/migrate/types.js.map +1 -1
  11. package/dist/lib/deps.d.ts.map +1 -1
  12. package/dist/lib/deps.js +6 -0
  13. package/dist/lib/deps.js.map +1 -1
  14. package/package.json +1 -1
  15. package/templates/quickstart.mdx +1 -1
  16. package/vendored/app/api/revalidate/route.ts +24 -1
  17. package/vendored/components/navigation/Header.tsx +16 -29
  18. package/vendored/components/navigation/NavbarItems.tsx +49 -0
  19. package/vendored/components/navigation/Sidebar.tsx +44 -1
  20. package/vendored/lib/cache-tags.ts +8 -0
  21. package/vendored/lib/indexnow.ts +162 -9
  22. package/vendored/lib/navbar-labels.ts +34 -0
  23. package/vendored/lib/project-deploy-id.ts +1 -1
  24. package/vendored/lib/revalidation-helpers.ts +26 -8
  25. package/vendored/lib/revalidation-trigger.ts +122 -34
  26. package/vendored/lib/static-artifacts.ts +15 -0
  27. package/vendored/lib/static-cdn-purge.ts +72 -0
  28. package/vendored/lib/static-file-route.ts +8 -0
  29. package/vendored/lib/theme-preview.ts +3 -5
  30. package/vendored/themes/dusk/variables.css +5 -2
  31. package/vendored/workspace-package-lock.json +405 -114
  32. package/dist/__tests__/unit/dusk-embargo.test.d.ts +0 -2
  33. package/dist/__tests__/unit/dusk-embargo.test.d.ts.map +0 -1
  34. package/dist/__tests__/unit/dusk-embargo.test.js +0 -25
  35. package/dist/__tests__/unit/dusk-embargo.test.js.map +0 -1
  36. package/dist/lib/dusk-embargo.d.ts +0 -15
  37. package/dist/lib/dusk-embargo.d.ts.map +0 -1
  38. package/dist/lib/dusk-embargo.js +0 -15
  39. package/dist/lib/dusk-embargo.js.map +0 -1
  40. package/vendored/lib/dusk-embargo.ts +0 -16
@@ -9,6 +9,12 @@ import { createHash } from 'crypto';
9
9
 
10
10
  const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';
11
11
  const FETCH_TIMEOUT_MS = 5_000;
12
+
13
+ /** Identifies the key self-check to bot protection (no shared outbound UA constant exists). */
14
+ const INDEXNOW_CHECK_USER_AGENT = 'Jamdesk-IndexNow-KeyCheck/1.0 (+https://jamdesk.com)';
15
+ // Separate from the single-URL GET budget: a batch body can carry thousands of
16
+ // URLs, so the POST needs more headroom than a one-URL ping.
17
+ const BATCH_FETCH_TIMEOUT_MS = 15_000;
12
18
  const MAX_BATCH_SIZE = 10_000;
13
19
  const KEY_SALT = 'jamdesk-indexnow-v1';
14
20
 
@@ -48,38 +54,185 @@ export function buildChangedUrls(
48
54
  });
49
55
  }
50
56
 
51
- /** Submit URLs to IndexNow. Single URL uses GET, multiple uses batch POST. */
57
+ /**
58
+ * Submit URLs to IndexNow. Single URL uses GET, multiple uses batch POST.
59
+ *
60
+ * @param keyLocation - Where the key file is served. Omit for root tenants
61
+ * (IndexNow's default `/{key}.txt` lookup works); required for prefixed
62
+ * tenants (see the comment at the batch call below).
63
+ */
52
64
  export async function submitToIndexNow(
53
- urls: string[], host: string, key: string
65
+ urls: string[], host: string, key: string, keyLocation?: string
54
66
  ): Promise<IndexNowResult> {
55
67
  if (urls.length === 0) {
56
68
  return { success: true, status: 200, submitted: 0 };
57
69
  }
58
70
 
59
- const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
60
-
61
71
  if (urls.length === 1) {
62
72
  const params = new URLSearchParams({ url: urls[0], key });
73
+ if (keyLocation) params.set('keyLocation', keyLocation);
63
74
  const res = await fetch(`${INDEXNOW_ENDPOINT}?${params}`, {
64
- signal: timeoutSignal,
75
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
65
76
  });
66
77
  return { success: isSuccessStatus(res.status), status: res.status, submitted: 1 };
67
78
  }
68
79
 
69
80
  const submitted = Math.min(urls.length, MAX_BATCH_SIZE);
70
- // keyLocation omitted — IndexNow requires it at the root level of the host,
71
- // and our key verification route is at /api/indexnow/{key} (a subdirectory).
72
- // When omitted, IndexNow defaults to /{key}.txt which it verifies asynchronously.
81
+ // keyLocation is sent only when the caller passes it. Prefixed tenants
82
+ // (/docs, /help) need it: their Cloudflare Worker proxies only the prefix, so
83
+ // IndexNow's default root lookup (/{key}.txt) never reaches us, and a key
84
+ // served under /docs/ authorizes /docs/* URLs. The old /api/indexnow/{key}
85
+ // location was rejected because it did not sit above the page URLs it claimed.
73
86
  const res = await fetch(INDEXNOW_ENDPOINT, {
74
87
  method: 'POST',
75
88
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
76
- signal: timeoutSignal,
89
+ signal: AbortSignal.timeout(BATCH_FETCH_TIMEOUT_MS),
77
90
  body: JSON.stringify({
78
91
  host,
79
92
  key,
93
+ ...(keyLocation ? { keyLocation } : {}),
80
94
  urlList: urls.slice(0, MAX_BATCH_SIZE),
81
95
  }),
82
96
  });
83
97
 
84
98
  return { success: isSuccessStatus(res.status), status: res.status, submitted };
85
99
  }
100
+
101
+ export interface IndexNowKeyCheck {
102
+ /**
103
+ * `ok`: 200 and the body is the key.
104
+ * `missing`: a definite misconfiguration (404/410, 200 with a different body,
105
+ * or any 3xx), so submitting would only be discarded.
106
+ * `inconclusive`: we could not tell (401/403/429/5xx, timeout, network error).
107
+ */
108
+ status: 'ok' | 'missing' | 'inconclusive';
109
+ /** Absent when no response arrived (timeout, network error). */
110
+ httpStatus?: number;
111
+ /** Value of the `cf-mitigated` response header (e.g. `challenge`), when sent. */
112
+ mitigated?: string;
113
+ }
114
+
115
+ /**
116
+ * Self-check that the key file is served before we submit. IndexNow verifies
117
+ * it asynchronously and silently discards submissions it cannot verify, so a
118
+ * missing key file is otherwise invisible.
119
+ *
120
+ * Tri-state on purpose: only a definite `missing` should suppress the
121
+ * submission. Bot protection (a Cloudflare challenge, a 403/429) or a transient
122
+ * 5xx on a customer zone blocks OUR check without saying anything about
123
+ * IndexNow's own fetcher, and before this check existed we submitted without
124
+ * any verification, so `inconclusive` callers should still submit (and warn).
125
+ *
126
+ * `redirect: 'manual'` because IndexNow's own fetch behaviour on redirects is
127
+ * undocumented: a key that only resolves through a redirect is treated as
128
+ * `missing` rather than optimistically trusted.
129
+ */
130
+ export async function verifyIndexNowKeyFile(keyFileUrl: string, key: string): Promise<IndexNowKeyCheck> {
131
+ try {
132
+ const res = await fetch(keyFileUrl, {
133
+ // Descriptive UA: Node's default is a common target for bot challenges on
134
+ // Vercel, Cloudflare or a customer zone.
135
+ headers: { 'User-Agent': INDEXNOW_CHECK_USER_AGENT },
136
+ redirect: 'manual',
137
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
138
+ });
139
+ const httpStatus = res.status;
140
+ const mitigated = res.headers?.get('cf-mitigated') ?? undefined;
141
+ const withEvidence = (status: IndexNowKeyCheck['status']): IndexNowKeyCheck => ({
142
+ status,
143
+ httpStatus,
144
+ ...(mitigated ? { mitigated } : {}),
145
+ });
146
+
147
+ if (httpStatus === 200) {
148
+ return withEvidence((await res.text()).trim() === key ? 'ok' : 'missing');
149
+ }
150
+ if (httpStatus === 404 || httpStatus === 410 || (httpStatus >= 300 && httpStatus < 400)) {
151
+ return withEvidence('missing');
152
+ }
153
+ return withEvidence('inconclusive');
154
+ } catch {
155
+ // Timeout, DNS/TLS failure or a body-read error: no evidence either way.
156
+ return { status: 'inconclusive' };
157
+ }
158
+ }
159
+
160
+ export interface IndexNowPlanInput {
161
+ slug: string;
162
+ /** Custom domain (bare host); undefined for *.jamdesk.app tenants. */
163
+ domain?: string;
164
+ /** e.g. https://jamdesk.com — no trailing slash, no prefix. */
165
+ baseUrl: string;
166
+ /** '' | '/docs' | '/help' */
167
+ docsPrefix: string;
168
+ /** Site-wide robots noindex. */
169
+ noindex: boolean;
170
+ /** build.ts authPlaneGated. */
171
+ authGated: boolean;
172
+ /** Changed .mdx paths (extension stripped, locale-prefixed like `fr/intro`). */
173
+ mdxChangedPaths: string[];
174
+ mdxDeletedPaths: string[];
175
+ /** null = unavailable, so fail closed. */
176
+ pageIndexability: ReadonlyMap<string, boolean> | null;
177
+ }
178
+
179
+ export interface IndexNowSubmission {
180
+ urls: string[];
181
+ host: string;
182
+ key: string;
183
+ /** Where the key is served (always set; used for the self-check). */
184
+ keyFileUrl: string;
185
+ /** Sent to IndexNow only for prefixed tenants. */
186
+ keyLocation?: string;
187
+ /** URLs dropped past MAX_BATCH_SIZE. */
188
+ truncated: number;
189
+ }
190
+
191
+ /**
192
+ * Decide what (if anything) to tell IndexNow after a build. Pure: never
193
+ * mutates its inputs (changedPaths and mdxChangedPaths can be the same array).
194
+ *
195
+ * Returns null — submit nothing — when the site is noindex or auth-gated, when
196
+ * indexability is unknown (fail closed), or when a prefixed tenant has no
197
+ * custom domain (its *.jamdesk.app URLs are x-jd-noindex, so not canonical).
198
+ *
199
+ * A changed page is kept only if the sitemap would list it. Deleted pages are
200
+ * kept because they are absent from the build by definition; IndexNow is how
201
+ * engines learn to drop them. Pages that just became hidden/noindex are NOT
202
+ * submitted — engines drop them on their next crawl, and we cannot tell "just
203
+ * became hidden" from "was always hidden" without the previous build's state.
204
+ *
205
+ * The same blindness applies to deletions: a deleted page is submitted even if
206
+ * it was noindex or hidden before it was deleted. We have no previous-build
207
+ * visibility to tell, and the cost is benign (engines fetch the URL, get a
208
+ * 404, and drop it or ignore it).
209
+ */
210
+ export function planIndexNowSubmission(input: IndexNowPlanInput): IndexNowSubmission | null {
211
+ const {
212
+ slug, domain, baseUrl, docsPrefix, noindex, authGated,
213
+ mdxChangedPaths, mdxDeletedPaths, pageIndexability,
214
+ } = input;
215
+
216
+ if (noindex || authGated || pageIndexability === null) return null;
217
+ if (docsPrefix !== '' && !domain) return null;
218
+
219
+ const deleted = new Set(mdxDeletedPaths);
220
+ const kept = [...new Set([...mdxChangedPaths, ...mdxDeletedPaths])].filter(
221
+ (p) => pageIndexability.get(p) === true || deleted.has(p),
222
+ );
223
+ if (kept.length === 0) return null;
224
+
225
+ const capped = kept.slice(0, MAX_BATCH_SIZE);
226
+ const key = generateIndexNowKey(slug);
227
+ const keyFileUrl = `${baseUrl}${docsPrefix}/${key}.txt`;
228
+
229
+ return {
230
+ urls: buildChangedUrls(capped, baseUrl, docsPrefix !== '', docsPrefix),
231
+ host: new URL(baseUrl).host,
232
+ key,
233
+ keyFileUrl,
234
+ // Root tenants: IndexNow's default /{key}.txt lookup already works.
235
+ keyLocation: docsPrefix ? keyFileUrl : undefined,
236
+ truncated: kept.length - capped.length,
237
+ };
238
+ }
@@ -0,0 +1,34 @@
1
+ import type { NavbarLink, NavbarPrimary } from './docs-types';
2
+
3
+ /**
4
+ * Navbar labels in the reader's language. Shared by the header (md and up)
5
+ * and the mobile drawer (below md), which render the same navbar entries and
6
+ * must not disagree on what they're called.
7
+ */
8
+
9
+ /**
10
+ * The override for `lang`. Exact key first, then case-insensitive:
11
+ * extractLanguageFromPath returns codes like 'pt-BR' and 'zh-Hans', while
12
+ * authors write `labels` keys by hand ("pt-br").
13
+ */
14
+ function override(labels: Record<string, string> | undefined, lang: string | undefined): string | undefined {
15
+ if (!lang || !labels) return undefined;
16
+ if (labels[lang]) return labels[lang];
17
+ const wanted = lang.toLowerCase();
18
+ const key = Object.keys(labels).find((k) => k.toLowerCase() === wanted);
19
+ return key ? labels[key] : undefined;
20
+ }
21
+
22
+ /** A link's label for `lang`, falling back to `label`. */
23
+ export function navbarLinkLabel(link: NavbarLink, lang: string | undefined): string {
24
+ return override(link.labels, lang) || link.label;
25
+ }
26
+
27
+ /** The primary button's label for `lang`, then `label`, then the default for its type. */
28
+ export function navbarPrimaryLabel(primary: NavbarPrimary, lang: string | undefined): string {
29
+ return (
30
+ override(primary.labels, lang) ||
31
+ primary.label ||
32
+ (primary.type === 'github' ? 'GitHub' : 'Get Started')
33
+ );
34
+ }
@@ -56,7 +56,7 @@ function lruSet(slug: string, value: string | null): void {
56
56
 
57
57
  /**
58
58
  * Clear the in-process LRU. With a slug, evicts just that project's entry —
59
- * whole-project revalidation calls this so the next read re-resolves the
59
+ * project-scoped revalidation calls this so the next read re-resolves the
60
60
  * pointer from Redis instead of serving a ≤30s-stale deployId into freshly
61
61
  * purged Data Cache entries. Without a slug, clears everything (tests,
62
62
  * bare `{all: true}` revalidation).
@@ -15,7 +15,8 @@ import { getStaticRevalidationPaths } from './static-file-route';
15
15
  import { clearRedirectCache } from './redirect-matcher';
16
16
  import { clearLanguageCache } from './language-matcher';
17
17
  import { clearProjectDeployIdCache } from './project-deploy-id';
18
- import { mdxCacheTag, projectCacheTag } from './cache-tags';
18
+ import { mdxCacheTag, projectCacheTag, staticFileCdnTag } from './cache-tags';
19
+ import { purgeStaticFileCdn } from './static-cdn-purge';
19
20
 
20
21
  /**
21
22
  * Validate the revalidation secret using constant-time comparison.
@@ -137,6 +138,16 @@ export async function executeRevalidation(
137
138
  ): Promise<RevalidateResult> {
138
139
  const revalidated: string[] = [];
139
140
 
141
+ // Every project-scoped request, targeted or not: evict this instance's
142
+ // deployId pointer LRU first. Cached fetchers key the Data Cache on the
143
+ // resolved deployId, so a <=30s-stale pointer here would keep reads pinned
144
+ // to the previous deploy's (dead) keys — and would let this instance refill
145
+ // the static-file CDN entry the tag purge below just deleted (that purge runs
146
+ // for paths-only builds too). Unlike the shims gated below, this only drops a
147
+ // Redis pointer cache: it emits no tag and discards no parsed data, so it
148
+ // cannot widen a targeted invalidation.
149
+ if (request.project) clearProjectDeployIdCache(request.project);
150
+
140
151
  // Whole-project clear: only when a project is named and the caller gave us
141
152
  // nothing to scope on. A targeted request (tags or paths supplied) must NOT
142
153
  // run the project-wide shims — clearConfigCache emits a revalidateTag that
@@ -149,11 +160,6 @@ export async function executeRevalidation(
149
160
 
150
161
  if (isWholeProjectClear) {
151
162
  const project = request.project!;
152
- // Evict the deployId pointer LRU first: cached fetchers key the Data
153
- // Cache on the resolved deployId, so a ≤30s-stale pointer on this
154
- // instance would otherwise keep reads pinned to the previous deploy's
155
- // (dead) cache keys until the LRU TTL lapses.
156
- clearProjectDeployIdCache(project);
157
163
  clearConfigCache(project);
158
164
  clearSnippetCache(project);
159
165
  clearOpenApiCache(project);
@@ -205,6 +211,16 @@ export async function executeRevalidation(
205
211
  }
206
212
  }
207
213
 
214
+ // Purge the tenant's static-file responses (sitemap, llms.txt, ...) from the
215
+ // Vercel CDN by tag. Runs for targeted builds too: they re-upload the
216
+ // sitemap/llms files as well, and revalidatePath cannot evict them.
217
+ if (request.project) {
218
+ const outcome = await purgeStaticFileCdn(request.project);
219
+ if (outcome === 'purged') {
220
+ revalidated.push(`cdn-tag:${staticFileCdnTag(request.project)}`);
221
+ }
222
+ }
223
+
208
224
  // Revalidate everything
209
225
  if (request.all) {
210
226
  // Bare {all: true} (no project scope) must also flush the in-memory parsed
@@ -221,8 +237,10 @@ export async function executeRevalidation(
221
237
  await clearLanguageCache();
222
238
  if (revalidatePath) {
223
239
  revalidatePath('/', 'layout');
224
- // Also purge CDN cache for static file routes (sitemap, robots, llms.txt, etc.)
225
- // These are route handlers with s-maxage caching that revalidatePath('/', 'layout') doesn't cover.
240
+ // Also revalidate the static file route paths (sitemap, robots, llms.txt, etc.).
241
+ // NOTE: revalidatePath does not purge Vercel CDN entries of these
242
+ // force-dynamic s-maxage route handlers (prod evidence 2026-09-30); the
243
+ // tag delete above does. This loop still refreshes the ISR layer.
226
244
  // Absent docsPrefix/previousDocsSubpath fall back to the legacy '/docs'-only
227
245
  // set, so older in-flight requests purge exactly what they always did.
228
246
  const staticPaths = getStaticRevalidationPaths(
@@ -5,6 +5,9 @@
5
5
 
6
6
  import { log } from './logger.js';
7
7
  import { redis } from './redis.js';
8
+ // Constant only: @vercel/functions is dynamically imported inside
9
+ // purgeStaticFileCdn, so this adds nothing to module load.
10
+ import { STATIC_CDN_REPURGE_DELAY_MS } from './static-cdn-purge.js';
8
11
 
9
12
  export const DEPLOY_FLIP_TAG = '[deploy-flip]';
10
13
 
@@ -56,11 +59,18 @@ export async function triggerRevalidation(options: RevalidationOptions): Promise
56
59
  if (previousDocsSubpath) body.previousDocsSubpath = previousDocsSubpath;
57
60
 
58
61
  // Proxy/CF failures are non-fatal — caches will expire on their own
59
- const [isrResult] = await Promise.all([
60
- revalidateIsrApp(isrAppUrl, secret, body),
61
- revalidateProxy(secret, projectSlug),
62
- purgeCloudflareCache(projectSlug),
63
- ]);
62
+ let isrResult: Awaited<ReturnType<typeof revalidateIsrApp>>;
63
+ try {
64
+ [isrResult] = await Promise.all([
65
+ revalidateIsrApp(isrAppUrl, secret, body),
66
+ revalidateProxy(secret, projectSlug),
67
+ ]);
68
+ } finally {
69
+ // After ISR settles (success or failure), never alongside it: Cloudflare's
70
+ // purge_everything racing the Vercel CDN purge refilled Cloudflare from the
71
+ // stale Vercel HIT. Never throws.
72
+ await purgeCloudflareCacheTwice(projectSlug);
73
+ }
64
74
 
65
75
  // Only throw if ISR revalidation failed (it's the critical one)
66
76
  if (!isrResult.ok) {
@@ -238,20 +248,54 @@ async function revalidateProxy(secret: string, projectSlug: string): Promise<voi
238
248
  }
239
249
  }
240
250
 
251
+ /**
252
+ * Purge Cloudflare, wait past the stale-deployId window, purge again.
253
+ *
254
+ * Other ISR instances keep a per-instance deployId LRU for up to 30s
255
+ * (`lib/project-deploy-id.ts`); a jamdesk.com/docs request landing on one in
256
+ * that window refills Vercel with stale content, and Cloudflare then caches
257
+ * it for up to 4h (max-age=14400). The 2nd purge lands after every LRU entry
258
+ * has expired (repurge delay + 5s of slack). Awaited, not fire-and-forget:
259
+ * Cloud Run may throttle CPU once the build returns. Never throws.
260
+ */
261
+ async function purgeCloudflareCacheTwice(projectSlug: string): Promise<void> {
262
+ await purgeCloudflareCache(projectSlug, 1);
263
+ // Without credentials pass 1 was a no-op, so don't sleep for nothing.
264
+ if (!isCloudflarePurgeEnabled(projectSlug)) return;
265
+ await sleep(CLOUDFLARE_REPURGE_DELAY_MS);
266
+ await purgeCloudflareCache(projectSlug, 2);
267
+ }
268
+
269
+ const CLOUDFLARE_REPURGE_DELAY_MS = STATIC_CDN_REPURGE_DELAY_MS + 5_000;
270
+
271
+ function sleep(ms: number): Promise<void> {
272
+ return new Promise((resolve) => setTimeout(resolve, ms));
273
+ }
274
+
275
+ function isCloudflarePurgeEnabled(projectSlug: string): boolean {
276
+ return (
277
+ projectSlug === 'jamdesk-docs' &&
278
+ Boolean(process.env.CLOUDFLARE_ZONE_ID) &&
279
+ Boolean(process.env.CLOUDFLARE_API_TOKEN)
280
+ );
281
+ }
282
+
241
283
  /**
242
284
  * Purge Cloudflare CDN cache for jamdesk.com docs assets.
243
285
  * Only runs for the jamdesk-docs project (served via Cloudflare Worker at jamdesk.com/docs).
244
286
  * Customer projects at *.jamdesk.app go through Vercel CDN, not Cloudflare.
245
287
  * Non-fatal on failure — cache will expire based on TTL.
288
+ *
289
+ * @param pass 1 = immediate purge, 2 = delayed re-purge (see purgeCloudflareCacheTwice)
246
290
  */
247
- async function purgeCloudflareCache(projectSlug: string): Promise<void> {
291
+ async function purgeCloudflareCache(projectSlug: string, pass: 1 | 2): Promise<void> {
248
292
  if (projectSlug !== 'jamdesk-docs') return;
249
293
 
250
294
  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
251
295
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;
252
296
 
253
297
  if (!zoneId || !apiToken) {
254
- log('info', 'Skipping Cloudflare cache purge (credentials not configured)', { projectSlug });
298
+ log('info', 'Skipping Cloudflare cache purge (credentials not configured)', { projectSlug, pass });
255
299
  return;
256
300
  }
257
301
 
@@ -271,16 +315,16 @@ async function purgeCloudflareCache(projectSlug: string): Promise<void> {
271
315
  const data = await response.json() as { success: boolean; errors?: Array<{ message: string }> };
272
316
 
273
317
  if (response.ok && data.success) {
274
- log('info', 'Cloudflare cache purged for jamdesk.com', { projectSlug });
318
+ log('info', 'Cloudflare cache purged for jamdesk.com', { projectSlug, pass });
275
319
  } else {
276
320
  const errorMsg = data.errors?.[0]?.message || `HTTP ${response.status}`;
277
321
  log('warn', 'Cloudflare cache purge failed (non-fatal)', {
278
- projectSlug, error: errorMsg,
322
+ projectSlug, pass, error: errorMsg,
279
323
  });
280
324
  }
281
325
  } catch (err) {
282
326
  log('warn', 'Cloudflare cache purge error (non-fatal)', {
283
- projectSlug, error: (err as Error).message,
327
+ projectSlug, pass, error: (err as Error).message,
284
328
  });
285
329
  }
286
330
  }
@@ -297,36 +341,67 @@ export interface Manifest {
297
341
  docsPrefix?: string;
298
342
  }
299
343
 
344
+ /**
345
+ * IndexNow's per-request URL limit. Collection stops here so a pathological
346
+ * sweep (e.g., a 50k-page Prettier run) can't build an unbounded list; past
347
+ * it the tail is simply not submitted, which IndexNow's own cap would force
348
+ * anyway. The old cap was MANY_MDX_THRESHOLD + 1 because the list was
349
+ * discarded once the decision escalated to `all` — IndexNow now reads it
350
+ * exactly in that case, so the list must survive escalation.
351
+ */
352
+ const MAX_TRACKED_MDX_CHANGES = 10_000;
353
+
300
354
  function getMdxChangedPaths(
301
355
  oldManifest: Manifest | null,
302
356
  newManifest: Manifest
303
- ): string[] {
357
+ ): { changed: string[]; deleted: string[] } {
304
358
  if (!oldManifest) {
305
- return Object.keys(newManifest.files).map(path => path.replace('.mdx', ''));
359
+ return {
360
+ changed: Object.keys(newManifest.files).map(path => path.replace('.mdx', '')),
361
+ deleted: [],
362
+ };
306
363
  }
307
364
 
308
- // Past the threshold the decision escalates to project-wide and the array
309
- // is discarded, so cap collection at threshold+1 to avoid building huge
310
- // path lists for bulk edits (e.g., 5k-page Prettier sweeps).
311
- const cap = MANY_MDX_THRESHOLD + 1;
312
365
  const changed: string[] = [];
366
+ const deleted: string[] = [];
367
+ let truncated = false;
313
368
 
314
369
  for (const [path, info] of Object.entries(newManifest.files)) {
315
- if (changed.length >= cap) return changed;
316
370
  const oldInfo = oldManifest.files[path];
317
371
  if (!oldInfo || oldInfo.hash !== info.hash) {
372
+ if (changed.length >= MAX_TRACKED_MDX_CHANGES) {
373
+ truncated = true;
374
+ break;
375
+ }
318
376
  changed.push(path.replace('.mdx', ''));
319
377
  }
320
378
  }
321
379
 
322
- for (const path of Object.keys(oldManifest.files)) {
323
- if (changed.length >= cap) return changed;
324
- if (!(path in newManifest.files)) {
325
- changed.push(path.replace('.mdx', ''));
380
+ if (!truncated) {
381
+ for (const path of Object.keys(oldManifest.files)) {
382
+ if (path in newManifest.files) continue;
383
+ if (changed.length >= MAX_TRACKED_MDX_CHANGES) {
384
+ truncated = true;
385
+ break;
386
+ }
387
+ const stripped = path.replace('.mdx', '');
388
+ changed.push(stripped);
389
+ // Deletions stay distinguishable: a deleted page needs a different
390
+ // IndexNow treatment than an edited one (its URL now 404s).
391
+ deleted.push(stripped);
326
392
  }
327
393
  }
328
394
 
329
- return changed;
395
+ // Silent truncation would look like a complete list downstream; one line per
396
+ // decision makes a capped sweep visible in Cloud Run logs.
397
+ if (truncated) {
398
+ log('warn', 'MDX change list truncated at the tracking cap; remaining changed pages will not be submitted to IndexNow', {
399
+ maxTrackedMdxChanges: MAX_TRACKED_MDX_CHANGES,
400
+ tracked: changed.length,
401
+ });
402
+ }
403
+
404
+ return { changed, deleted };
330
405
  }
331
406
 
332
407
  function hasConfigChanges(oldManifest: Manifest, newManifest: Manifest): boolean {
@@ -372,9 +447,7 @@ function hasAssetChanges(oldManifest: Manifest, newManifest: Manifest): boolean
372
447
  * `previousDocsSubpath`, which is sticky BY DESIGN (Task 3 keeps it forever
373
448
  * so the old prefix's 308 redirect keeps working) and would otherwise make
374
449
  * every build after a rename look like a rename, permanently replacing
375
- * targeted revalidation with a full purge and starving IndexNow of paths
376
- * (build.ts's `indexNowPaths.length > 0` gate never fires again once `all`
377
- * empties changedPaths on every build).
450
+ * targeted revalidation with a full purge on every build.
378
451
  *
379
452
  * Manifest-based comparison self-clears: the build that performs the rename
380
453
  * records the new prefix into ITS OWN manifest, so the very next build's
@@ -392,13 +465,11 @@ function hasAssetChanges(oldManifest: Manifest, newManifest: Manifest): boolean
392
465
  * build after this shipped, then self-clears once the new manifest records
393
466
  * a value to compare against next time.
394
467
  *
395
- * That one legacy fire is not entirely free: it escalates the build to `all`,
396
- * which empties `changedPaths`, and build.ts gates IndexNow on
397
- * `indexNowPaths.length > 0` — so each tenant's FIRST post-deploy build
398
- * silently submits nothing to IndexNow. Harmless (the next build with real
399
- * MDX changes submits normally, and a full purge already refreshed the CDN),
400
- * but it means a wave of full-project purges with no IndexNow pings is the
401
- * EXPECTED signature in the days after this ships, not a regression.
468
+ * That one legacy fire escalates the build to `all`, which empties
469
+ * `changedPaths` — but IndexNow reads `mdxChangedPaths`, which `all` never
470
+ * touches, so the fire-once build still submits whatever MDX actually
471
+ * changed. A full purge with few or no IndexNow pings is therefore just a
472
+ * build that changed no MDX, not a sign IndexNow was starved.
402
473
  */
403
474
  function hasDocsPrefixChange(oldManifest: Manifest, newManifest: Manifest): boolean {
404
475
  if (newManifest.docsPrefix === undefined) return false;
@@ -421,6 +492,15 @@ export interface RevalidationDecision {
421
492
  changedPaths: string[];
422
493
  all: boolean;
423
494
  reasons: RevalidationReason[];
495
+ /** Added, modified and deleted MDX paths (`.mdx` stripped, locale-prefixed
496
+ * e.g. `fr/intro`), regardless of `all`. Unlike `changedPaths` — which is
497
+ * emptied whenever the decision escalates to a full purge — this survives
498
+ * config/many_mdx/docs_prefix/FORCE_ALL escalation, because IndexNow must
499
+ * not be starved by a cache-escalation decision. `[]` on first build. */
500
+ mdxChangedPaths: string[];
501
+ /** Subset of `mdxChangedPaths` present in the old manifest but not the new
502
+ * (deleted pages). `[]` on first build. */
503
+ mdxDeletedPaths: string[];
424
504
  }
425
505
 
426
506
  /**
@@ -436,10 +516,16 @@ export function getRevalidationDecision(
436
516
  newManifest: Manifest
437
517
  ): RevalidationDecision {
438
518
  if (!oldManifest) {
439
- return { changedPaths: [], all: true, reasons: ['first_build'] };
519
+ return {
520
+ changedPaths: [],
521
+ all: true,
522
+ reasons: ['first_build'],
523
+ mdxChangedPaths: [],
524
+ mdxDeletedPaths: [],
525
+ };
440
526
  }
441
527
 
442
- const changedPaths = getMdxChangedPaths(oldManifest, newManifest);
528
+ const { changed: changedPaths, deleted: deletedPaths } = getMdxChangedPaths(oldManifest, newManifest);
443
529
  const reasons: RevalidationReason[] = [];
444
530
  if (changedPaths.length > MANY_MDX_THRESHOLD) {
445
531
  reasons.push('many_mdx');
@@ -468,6 +554,8 @@ export function getRevalidationDecision(
468
554
  changedPaths: all ? [] : changedPaths,
469
555
  all,
470
556
  reasons,
557
+ mdxChangedPaths: changedPaths,
558
+ mdxDeletedPaths: deletedPaths,
471
559
  };
472
560
  }
473
561
 
@@ -93,6 +93,21 @@ function isPageMetadataIncluded(page: PageMetadata, visibility?: VisibilityInput
93
93
  ).inArtifacts;
94
94
  }
95
95
 
96
+ /**
97
+ * Per-page indexability for every page in the build, keyed by page path.
98
+ *
99
+ * Uses the same rule as the sitemap and llms.txt (isPageMetadataIncluded), so
100
+ * IndexNow submissions can never disagree with what we tell crawlers is
101
+ * indexable. A path missing from the map means the page is not in this build
102
+ * at all (deleted, or never existed) — callers must not treat that as "true".
103
+ */
104
+ export function getPageIndexability(
105
+ pages: PageMetadata[],
106
+ visibility?: VisibilityInputs,
107
+ ): Map<string, boolean> {
108
+ return new Map(pages.map((p) => [p.path, isPageMetadataIncluded(p, visibility)]));
109
+ }
110
+
96
111
  /**
97
112
  * Generate sitemap.xml from page metadata.
98
113
  *