jamdesk 1.1.213 → 1.1.215

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 (35) hide show
  1. package/README.md +2 -2
  2. package/dist/__tests__/unit/dusk-embargo.test.js +3 -2
  3. package/dist/__tests__/unit/dusk-embargo.test.js.map +1 -1
  4. package/dist/lib/deps.d.ts.map +1 -1
  5. package/dist/lib/deps.js +7 -1
  6. package/dist/lib/deps.js.map +1 -1
  7. package/dist/lib/dusk-embargo.d.ts.map +1 -1
  8. package/dist/lib/dusk-embargo.js +1 -1
  9. package/dist/lib/dusk-embargo.js.map +1 -1
  10. package/package.json +3 -3
  11. package/templates/quickstart.mdx +1 -1
  12. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +31 -1
  13. package/vendored/app/api/revalidate/route.ts +24 -1
  14. package/vendored/components/mdx/Tooltip.tsx +27 -1
  15. package/vendored/components/navigation/Header.tsx +16 -29
  16. package/vendored/components/navigation/NavbarItems.tsx +49 -0
  17. package/vendored/components/navigation/Sidebar.tsx +44 -1
  18. package/vendored/lib/cache-tags.ts +8 -0
  19. package/vendored/lib/dusk-embargo.ts +1 -1
  20. package/vendored/lib/email-notifier.ts +6 -8
  21. package/vendored/lib/indexnow.ts +162 -9
  22. package/vendored/lib/layout-helpers.tsx +1 -1
  23. package/vendored/lib/mailfully.ts +86 -0
  24. package/vendored/lib/markdown-absolute-links.ts +197 -0
  25. package/vendored/lib/middleware-helpers.ts +24 -0
  26. package/vendored/lib/navbar-labels.ts +34 -0
  27. package/vendored/lib/project-deploy-id.ts +1 -1
  28. package/vendored/lib/revalidation-helpers.ts +26 -8
  29. package/vendored/lib/revalidation-trigger.ts +122 -34
  30. package/vendored/lib/screenshot-capture.ts +88 -16
  31. package/vendored/lib/static-artifacts.ts +25 -3
  32. package/vendored/lib/static-cdn-purge.ts +72 -0
  33. package/vendored/lib/static-file-route.ts +25 -1
  34. package/vendored/themes/dusk/variables.css +63 -10
  35. package/vendored/workspace-package-lock.json +576 -262
@@ -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
+ }
@@ -114,7 +114,7 @@ export const figtree = Figtree({
114
114
  export const ibmPlexSerif = IBM_Plex_Serif({
115
115
  subsets: ['latin'],
116
116
  display: 'swap',
117
- weight: ['400', '600'],
117
+ weight: ['400', '500', '600'],
118
118
  variable: '--font-serif',
119
119
  preload: false,
120
120
  });
@@ -0,0 +1,86 @@
1
+ // DO NOT EDIT — this file is auto-synced from shared/. Edit the source in shared/ and run ./scripts/sync-shared.sh
2
+
3
+ /**
4
+ * Minimal Mailfully client — the one call Jamdesk makes (send an email).
5
+ * Not the `mailfully` npm SDK on purpose: it requires Node 24 and is
6
+ * ESM-only, and our Firebase Functions run Node 22. Never throws: every
7
+ * failure (transport, non-2xx, bad JSON) comes back on the error arm.
8
+ */
9
+
10
+ export type MailfullySendInput = {
11
+ from: string;
12
+ to: string | string[];
13
+ cc?: string | string[];
14
+ bcc?: string | string[];
15
+ replyTo?: string | string[];
16
+ subject: string;
17
+ html?: string;
18
+ text?: string;
19
+ headers?: Record<string, string>;
20
+ tags?: { name: string; value: string }[];
21
+ type?: "transactional" | "marketing";
22
+ };
23
+
24
+ export type MailfullyResult =
25
+ | { data: { id: string; status: "queued" | "canceled" }; error: null }
26
+ | {
27
+ data: null;
28
+ error: {
29
+ statusCode: number | null;
30
+ type: string | null;
31
+ message: string;
32
+ retryAfter: number | null;
33
+ };
34
+ };
35
+
36
+ const DEFAULT_BASE_URL = "https://api.mailfully.com";
37
+
38
+ function fail(
39
+ statusCode: number | null,
40
+ type: string | null,
41
+ message: string,
42
+ retryAfter: number | null,
43
+ ): MailfullyResult {
44
+ return { data: null, error: { statusCode, type, message, retryAfter } };
45
+ }
46
+
47
+ export async function sendMailfullyEmail(
48
+ apiKey: string,
49
+ input: MailfullySendInput,
50
+ opts: { idempotencyKey?: string; baseUrl?: string } = {},
51
+ ): Promise<MailfullyResult> {
52
+ const { replyTo, ...rest } = input;
53
+ const body = { ...rest, ...(replyTo ? { reply_to: replyTo } : {}) };
54
+ let res: Response;
55
+ try {
56
+ res = await fetch(`${opts.baseUrl ?? DEFAULT_BASE_URL}/v1/emails`, {
57
+ method: "POST",
58
+ headers: {
59
+ "Authorization": `Bearer ${apiKey}`,
60
+ "Content-Type": "application/json",
61
+ ...(opts.idempotencyKey ?
62
+ { "Idempotency-Key": opts.idempotencyKey } : {}),
63
+ },
64
+ body: JSON.stringify(body),
65
+ // A hung request would otherwise hold a newsletter run or a function
66
+ // until its own timeout; a timeout comes back as a transport error.
67
+ signal: AbortSignal.timeout(15_000),
68
+ });
69
+ } catch (err) {
70
+ return fail(null, null, (err as Error).message, null);
71
+ }
72
+ const retryAfter = Number(res.headers.get("retry-after")) || null;
73
+ let json: unknown;
74
+ try {
75
+ json = await res.json();
76
+ } catch {
77
+ return fail(res.status, null, "Malformed JSON response", retryAfter);
78
+ }
79
+ if (!res.ok) {
80
+ const e = (json as { error?: { type?: string; message?: string } }).error;
81
+ return fail(res.status, e?.type ?? null,
82
+ e?.message ?? `HTTP ${res.status}`, retryAfter);
83
+ }
84
+ const { id, status } = json as { id: string; status: "queued" | "canceled" };
85
+ return { data: { id, status }, error: null };
86
+ }
@@ -0,0 +1,197 @@
1
+ import { unified, type Processor } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { visit } from 'unist-util-visit';
5
+ import type { Root, Link, Definition, Html } from 'mdast';
6
+ import { prefixRelativeHref } from './link-prefix-helpers';
7
+
8
+ /**
9
+ * Rewrite root-relative page links in a served `.md` export to absolute URLs
10
+ * on the tenant's public host.
11
+ *
12
+ * Two reasons, both about agents reading the markdown away from the page:
13
+ *
14
+ * - The export is the author's MDX, so links are written as the author typed
15
+ * them (`/setup/access-control`). The HTML renderer adds the tenant's docs
16
+ * prefix to those hrefs (prefixRelativeHref), the export never did. On a
17
+ * subpath-mounted site such as jamdesk.com/docs, `/setup/access-control`
18
+ * resolves to the marketing site, not the docs page.
19
+ * - Agent pipelines drop the fetch URL (RAG chunks, summaries, pastes), so even
20
+ * a correct root-relative link stops resolving. afdocs 0.22's
21
+ * `markdown-link-portability` check warns on every page that has one.
22
+ *
23
+ * Only `[text](/path)` links and `[id]: /path` definitions are rewritten, with
24
+ * the same prefix rule the HTML renderer applies to `<a href>`. Images are left
25
+ * alone: custom-domain images are served through `/_jd/images`, not the docs
26
+ * prefix, so the anchor rule would point them at a 404. Code spans and fenced
27
+ * code are never touched because they are not link nodes in the parse tree.
28
+ *
29
+ * `baseUrl` is the public docs root (e.g. `https://jamdesk.com/docs`, from
30
+ * getBaseUrl). `linkPrefix` is the prefix the HTML renderer puts on anchors
31
+ * for this request. The two can differ: a subdomain served at the root whose
32
+ * custom domain serves at `/help`. So a link is first resolved the renderer's
33
+ * way, then the renderer's prefix is swapped for the base URL.
34
+ */
35
+ export function absolutizeMarkdownLinks(
36
+ markdown: string,
37
+ baseUrl: string,
38
+ linkPrefix: string,
39
+ ): string {
40
+ // Cheap exit: most pages have no root-relative link at all, and the parse is
41
+ // the only non-trivial cost on this route.
42
+ if (!/\]\(\s*<?\/(?!\/)|\]:\s*<?\/(?!\/)/.test(markdown)) return markdown;
43
+
44
+ // Frontmatter is YAML, not markdown: blank it (same length, newlines kept)
45
+ // so offsets still line up with `markdown` but nothing in it is parsed.
46
+ const fm = markdown.match(FRONTMATTER_RE);
47
+ const source = blankTagLines(
48
+ fm ? blank(fm[0]) + markdown.slice(fm[0].length) : markdown,
49
+ );
50
+
51
+ const edits: Edit[] = [];
52
+ try {
53
+ collectEdits(source, 0, baseUrl.replace(/\/+$/, ''), linkPrefix, edits, 0);
54
+ } catch {
55
+ return markdown;
56
+ }
57
+ if (edits.length === 0) return markdown;
58
+
59
+ let out = markdown;
60
+ for (const e of edits.sort((a, b) => b.start - a.start)) {
61
+ out = out.slice(0, e.start) + e.url + out.slice(e.end);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ interface Edit { start: number; end: number; url: string }
67
+
68
+ /**
69
+ * The source is MDX, and MDX has no indented code blocks: prose indented
70
+ * inside `<Step>` or `<Tab>` is still prose, and the HTML page renders its
71
+ * links. Plain markdown would read it as code and skip the link, so turn the
72
+ * construct off the way remark-mdx does. Fenced code is unaffected.
73
+ */
74
+ function mdxIndentation(this: Processor) {
75
+ const data = this.data();
76
+ const list = (data.micromarkExtensions ??= []) as unknown[];
77
+ list.push({ disable: { null: ['codeIndented'] } });
78
+ }
79
+
80
+ /** Built once: the export route runs this on every uncached `.md` request. */
81
+ const parser = unified().use(remarkParse).use(remarkGfm).use(mdxIndentation);
82
+
83
+ /** Same anchored frontmatter match as agent-directive.tsx. */
84
+ const FRONTMATTER_RE = /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(\r?\n|$)/;
85
+
86
+ /** Replace every character except line breaks with a space. */
87
+ function blank(text: string): string {
88
+ return text.replace(/[^\r\n]/g, ' ');
89
+ }
90
+
91
+ /** One opening or closing tag. `[^>]*` stops at the first `>`, which is fine: only offsets matter. */
92
+ const TAG = /<\/?[A-Za-z][^>]*>/g;
93
+
94
+ /** A line made only of tags: `<Tab title="Py">`, `</Steps>`, `<iframe ...></iframe>`. */
95
+ const TAG_ONLY_LINE = /^[ \t]*(?:<\/?[A-Za-z][^>]*>[ \t]*)+$/;
96
+
97
+ /**
98
+ * Blank every tag-only line (same length) before parsing.
99
+ *
100
+ * A component tag directly above a fence (`<CodeGroup>` then a code fence) parses
101
+ * as a CommonMark HTML block that ends at the first blank line in the code.
102
+ * The rest of the code is then read as prose and its links rewritten, and
103
+ * the closing fence opens a new fence that swallows the prose after it. MDX
104
+ * never reads the tag as HTML, so with the tag lines gone the plain parser
105
+ * sees the same fences and paragraphs MDX does. Only offsets matter here, and
106
+ * blanking keeps them.
107
+ */
108
+ function blankTagLines(text: string): string {
109
+ return text
110
+ .split(/(\r?\n)/)
111
+ .map((part) => (TAG_ONLY_LINE.test(part) ? blank(part) : part))
112
+ .join('');
113
+ }
114
+
115
+ /**
116
+ * Walk one parse of `text` (which starts at `base` in the original string)
117
+ * and record a rewrite for every root-relative link or definition.
118
+ *
119
+ * Tag-only lines are blanked before the first pass, but a line that mixes a
120
+ * tag with text (`<li>See [a](/x).</li>`) still starts a raw HTML node that
121
+ * hides the links in it. MDX renders those as links, so such a node is parsed
122
+ * again with every tag in it blanked. One level of nesting per pass, capped
123
+ * so pathological input cannot recurse.
124
+ */
125
+ function collectEdits(
126
+ text: string,
127
+ base: number,
128
+ baseUrl: string,
129
+ linkPrefix: string,
130
+ edits: Edit[],
131
+ depth: number,
132
+ ): void {
133
+ const tree = parser.parse(text) as Root;
134
+
135
+ visit(tree, (node) => {
136
+ if (node.type === 'html') {
137
+ const start = node.position?.start.offset;
138
+ if (start === undefined || depth >= 4) return;
139
+ const end = node.position?.end.offset;
140
+ const value = (node as Html).value;
141
+ if (end === undefined || value.trimStart().startsWith('<!--')) return;
142
+ // Inside a container (blockquote, list item) mdast strips the `> ` or
143
+ // indent from continuation lines, so `value` is no longer the source
144
+ // span and its offsets would land in the wrong place. Skip those.
145
+ if (text.slice(start, end) !== value) return;
146
+ const inner = value.replace(TAG, blank);
147
+ if (inner !== value) {
148
+ collectEdits(inner, base + start, baseUrl, linkPrefix, edits, depth + 1);
149
+ }
150
+ return;
151
+ }
152
+ if (node.type !== 'link' && node.type !== 'definition') return;
153
+ const n = node as Link | Definition;
154
+ const url = n.url;
155
+ if (!url.startsWith('/') || url.startsWith('//')) return;
156
+ const start = n.position?.start.offset;
157
+ const end = n.position?.end.offset;
158
+ if (start === undefined || end === undefined) return;
159
+
160
+ // The destination sits after the label: `](` for a link, `]:` for a
161
+ // definition. Search from the end of the label so link text that happens
162
+ // to contain the same path is never the match.
163
+ const labelEnd = n.type === 'link' && n.children.length > 0
164
+ ? n.children[n.children.length - 1].position?.end.offset ?? start
165
+ : start;
166
+ const opener = n.type === 'link' ? '](' : ']:';
167
+ const openAt = text.indexOf(opener, labelEnd);
168
+ if (openAt === -1 || openAt >= end) return;
169
+ let destAt = openAt + opener.length;
170
+ while (destAt < end && /\s/.test(text[destAt])) destAt++;
171
+ if (text[destAt] === '<') destAt++;
172
+
173
+ // The parser hands back the decoded URL. Skip anything whose source text
174
+ // differs (entities, escapes) rather than guess at the raw span.
175
+ if (text.slice(destAt, destAt + url.length) !== url) return;
176
+
177
+ edits.push({
178
+ start: base + destAt,
179
+ end: base + destAt + url.length,
180
+ url: toAbsolute(url, baseUrl, linkPrefix),
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * `/setup` → prefixRelativeHref gives the path the HTML page links to
187
+ * (`/docs/setup`); dropping that renderer prefix leaves the page path under
188
+ * the docs root, which is then joined to the public base URL.
189
+ */
190
+ function toAbsolute(url: string, baseUrl: string, linkPrefix: string): string {
191
+ const rendered = prefixRelativeHref(url, linkPrefix);
192
+ const underPrefix = linkPrefix &&
193
+ (rendered === linkPrefix || rendered.startsWith(`${linkPrefix}/`) ||
194
+ rendered.startsWith(`${linkPrefix}?`) || rendered.startsWith(`${linkPrefix}#`));
195
+ const pagePath = underPrefix ? rendered.slice(linkPrefix.length) : rendered;
196
+ return `${baseUrl}${pagePath}`;
197
+ }
@@ -347,6 +347,9 @@ export async function validateForwardedHost(
347
347
  mappedProject: mappedProject || 'not-registered',
348
348
  status: status || 'missing',
349
349
  });
350
+ // dashboard/functions/src/reports/domain-reachability.ts matches
351
+ // this text (REFUSED_HOST_TEXT) to auto-detach dead retained
352
+ // domains. Change both together.
350
353
  return {
351
354
  allowed: false,
352
355
  error: `Domain "${forwardedHost}" is not authorized to serve this content.`,
@@ -1190,6 +1193,27 @@ export function isPresentationAsset(pathname: string): boolean {
1190
1193
  return PRESENTATION_EXTENSIONS.some((ext) => p.endsWith(ext));
1191
1194
  }
1192
1195
 
1196
+ // Exact shape written by lib/screenshot-capture.ts: a Firestore auto-ID build
1197
+ // id, then a per-capture 128-bit random hex file name.
1198
+ const SITE_SCREENSHOT_RE = /^\/_screenshots\/[A-Za-z0-9]{20}\/[0-9a-f]{32}\.jpg$/;
1199
+
1200
+ /**
1201
+ * The dashboard site-preview screenshot, exempt from the password/JWT gate.
1202
+ *
1203
+ * The dashboard's <img> is a cross-site subresource that never carries the
1204
+ * visitor's jd_auth cookie, so a gated site's preview always 401'd. The random
1205
+ * file name is the capability: the full URL is stored only on the members-only
1206
+ * build doc. The build ID is NOT secret (lastBuildId sits on the projects doc,
1207
+ * readable by any signed-in user), which is why the legacy
1208
+ * `/_screenshots/{buildId}/homepage.jpg` shape stays gated.
1209
+ *
1210
+ * The image is the REAL gated homepage (the capture mints an unlock cookie), so
1211
+ * never widen this match or write the file name anywhere world-readable.
1212
+ */
1213
+ export function isSiteScreenshotPath(pathname: string): boolean {
1214
+ return SITE_SCREENSHOT_RE.test(pathname);
1215
+ }
1216
+
1193
1217
  /**
1194
1218
  * Should this request be 404'd because the project serves from its custom
1195
1219
  * domain only? Returns the response to send, or null to continue.
@@ -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(