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.
- package/README.md +2 -2
- package/dist/__tests__/unit/dusk-embargo.test.js +3 -2
- package/dist/__tests__/unit/dusk-embargo.test.js.map +1 -1
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +7 -1
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/dusk-embargo.d.ts.map +1 -1
- package/dist/lib/dusk-embargo.js +1 -1
- package/dist/lib/dusk-embargo.js.map +1 -1
- package/package.json +3 -3
- package/templates/quickstart.mdx +1 -1
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +31 -1
- package/vendored/app/api/revalidate/route.ts +24 -1
- package/vendored/components/mdx/Tooltip.tsx +27 -1
- package/vendored/components/navigation/Header.tsx +16 -29
- package/vendored/components/navigation/NavbarItems.tsx +49 -0
- package/vendored/components/navigation/Sidebar.tsx +44 -1
- package/vendored/lib/cache-tags.ts +8 -0
- package/vendored/lib/dusk-embargo.ts +1 -1
- package/vendored/lib/email-notifier.ts +6 -8
- package/vendored/lib/indexnow.ts +162 -9
- package/vendored/lib/layout-helpers.tsx +1 -1
- package/vendored/lib/mailfully.ts +86 -0
- package/vendored/lib/markdown-absolute-links.ts +197 -0
- package/vendored/lib/middleware-helpers.ts +24 -0
- package/vendored/lib/navbar-labels.ts +34 -0
- package/vendored/lib/project-deploy-id.ts +1 -1
- package/vendored/lib/revalidation-helpers.ts +26 -8
- package/vendored/lib/revalidation-trigger.ts +122 -34
- package/vendored/lib/screenshot-capture.ts +88 -16
- package/vendored/lib/static-artifacts.ts +25 -3
- package/vendored/lib/static-cdn-purge.ts +72 -0
- package/vendored/lib/static-file-route.ts +25 -1
- package/vendored/themes/dusk/variables.css +63 -10
- package/vendored/workspace-package-lock.json +576 -262
package/vendored/lib/indexnow.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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:
|
|
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,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
|
-
*
|
|
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
|
|
225
|
-
//
|
|
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(
|