jamdesk 1.1.213 → 1.1.214

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.
@@ -14,6 +14,9 @@
14
14
  import { chromium, Browser } from 'playwright';
15
15
  import { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } from '@aws-sdk/client-s3';
16
16
  import { getFirestore } from 'firebase-admin/firestore';
17
+ import { randomBytes } from 'node:crypto';
18
+ import { resolveAuth, type ResolvedAuth } from './auth-resolver';
19
+ import { signAuthCookie, signAuthCookieV2 } from '../shared/auth-cookie';
17
20
 
18
21
  interface CaptureOptions {
19
22
  slug: string;
@@ -97,20 +100,64 @@ export async function launchChromiumWithRetry(
97
100
  throw lastError;
98
101
  }
99
102
 
103
+ // Long enough to cover the 45s capture race, short enough that a leaked
104
+ // cookie is worthless almost immediately (visitor unlocks last 30 days).
105
+ const CAPTURE_COOKIE_TTL_MS = 5 * 60_000;
106
+
107
+ /**
108
+ * Mint the jd_auth cookie a successful unlock would set, so a password- or
109
+ * JWT-gated site is captured as an unlocked visitor sees it instead of as the
110
+ * unlock form. Uses the gate's own resolveAuth + signing, so the cookie is
111
+ * exactly what the proxy verifies. Cloud Run already holds EDGE_AUTH_SECRET
112
+ * and R2 read access to every tenant's content, so this grants the capture
113
+ * nothing new. JWT mode gets no groups (least privilege): a group-restricted
114
+ * homepage captures as the gate's 404.
115
+ *
116
+ * Returns null when the site is ungated, or when the cookie can't be minted —
117
+ * the capture then proceeds unauthenticated, as it did before this existed.
118
+ * Never rejects: the capture starts it before launching Chromium and may
119
+ * abandon it on timeout.
120
+ */
121
+ export async function mintCaptureAuthCookie(
122
+ slug: string,
123
+ host: string,
124
+ ): Promise<{ name: string; value: string } | null> {
125
+ let auth: ResolvedAuth | null;
126
+ try {
127
+ auth = await resolveAuth(slug);
128
+ } catch (error) {
129
+ console.warn(`Screenshot auth lookup failed for ${slug}; capturing without unlock cookie:`, error);
130
+ return null;
131
+ }
132
+ if (!auth) return null;
133
+ const secret = process.env.EDGE_AUTH_SECRET;
134
+ if (!secret) {
135
+ console.warn(`Screenshot: ${slug} is gated but EDGE_AUTH_SECRET is unset; capture will hit the password wall`);
136
+ return null;
137
+ }
138
+
139
+ const payload = { slug, host, version: auth.version, expiresAt: Date.now() + CAPTURE_COOKIE_TTL_MS };
140
+ const value = auth.authType === 'jwt'
141
+ ? await signAuthCookieV2(payload, {}, secret)
142
+ : await signAuthCookie(payload, secret);
143
+ return { name: `jd_auth_${slug}`, value };
144
+ }
145
+
100
146
  /**
101
- * Clean up old screenshots for a project, keeping only the current build's screenshot.
102
- * This prevents R2 storage from accumulating old screenshots.
147
+ * Clean up old screenshots for a project, keeping only the one just uploaded.
148
+ * This prevents R2 storage from accumulating old screenshots. Exact key, not
149
+ * the build's prefix: a regenerate reuses the buildId under a new file name,
150
+ * and the superseded image must not stay reachable.
103
151
  */
104
152
  async function cleanupOldScreenshots(
105
153
  r2Client: S3Client,
106
154
  r2Bucket: string,
107
155
  slug: string,
108
- currentBuildId: string
156
+ currentKey: string
109
157
  ): Promise<void> {
110
158
  try {
111
159
  // R2 key pattern matches assets API: {slug}/assets/_screenshots/{buildId}/
112
160
  const prefix = `${slug}/assets/_screenshots/`;
113
- const currentPrefix = `${slug}/assets/_screenshots/${currentBuildId}/`;
114
161
 
115
162
  // List all screenshot objects for this project
116
163
  const listResponse = await r2Client.send(
@@ -124,9 +171,8 @@ async function cleanupOldScreenshots(
124
171
  return;
125
172
  }
126
173
 
127
- // Filter out objects from the current build
128
174
  const objectsToDelete = listResponse.Contents.filter(
129
- (obj) => obj.Key && !obj.Key.startsWith(currentPrefix)
175
+ (obj) => obj.Key && obj.Key !== currentKey
130
176
  ).map((obj) => ({ Key: obj.Key! }));
131
177
 
132
178
  if (objectsToDelete.length === 0) {
@@ -191,6 +237,11 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
191
237
  });
192
238
 
193
239
  const capturePromise = async (): Promise<CaptureResult> => {
240
+ // Gated site: without this the capture screenshots the unlock form.
241
+ // Started before launch so its Redis reads overlap Chromium startup
242
+ // instead of spending the 45s budget sequentially.
243
+ const authCookiePromise = mintCaptureAuthCookie(slug, new URL(siteUrl).host);
244
+
194
245
  // Launch browser with system Chromium (retry transient launch flakes)
195
246
  browser = await launchChromiumWithRetry();
196
247
 
@@ -207,6 +258,13 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
207
258
  const page = await browser.newPage();
208
259
  await page.setViewportSize({ width: 1280, height: 720 });
209
260
 
261
+ const authCookie = await authCookiePromise;
262
+ if (authCookie) {
263
+ await page.context().addCookies([
264
+ { ...authCookie, url: siteUrl, httpOnly: true, secure: true, sameSite: 'Lax' },
265
+ ]);
266
+ }
267
+
210
268
  // Cold render after a project-wide revalidation can transiently 404 —
211
269
  // retry once on 4xx/5xx, then capture whatever the page shows so the
212
270
  // dashboard preview reflects the real state of the site.
@@ -218,14 +276,22 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
218
276
  // (jamdesk-docs) NEVER goes network-idle. 'networkidle' timed out at 15s
219
277
  // and every capture/Retry failed. 'load' fires before the prefetch storm.
220
278
  const gotoOpts = { waitUntil: 'load', timeout: 15000 } as const;
221
- console.log(`Capturing screenshot from: ${homepageUrl}`);
222
- const firstResponse = await page.goto(homepageUrl, gotoOpts);
223
- const firstStatus = firstResponse?.status();
279
+ console.log(`Capturing screenshot from: ${homepageUrl}${authCookie ? ' (unlock cookie attached)' : ''}`);
280
+ let response = await page.goto(homepageUrl, gotoOpts);
281
+ const firstStatus = response?.status();
224
282
 
225
283
  if (firstStatus !== undefined && firstStatus >= 400) {
226
284
  console.log(`Screenshot got ${firstStatus} on first attempt, retrying once after 3s`);
227
285
  await page.waitForTimeout(3000);
228
- await page.goto(homepageUrl, gotoOpts);
286
+ response = await page.goto(homepageUrl, gotoOpts);
287
+ }
288
+
289
+ // Still behind the password/JWT wall (cookie not minted or rejected):
290
+ // the page is the unlock form, which must never be stored as the site
291
+ // preview. Fail so the dashboard offers Retry instead.
292
+ const finalStatus = response?.status();
293
+ if (finalStatus === 401 || finalStatus === 403) {
294
+ return { screenshotUrl: null, error: `Screenshot blocked by site auth (HTTP ${finalStatus})` };
229
295
  }
230
296
 
231
297
  // Short best-effort settle for late images/fonts — the page above already
@@ -256,10 +322,17 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
256
322
  }
257
323
 
258
324
  // Upload to R2 with timeout via AbortController
259
- // R2 key follows ISR assets pattern: {slug}/assets/_screenshots/{buildId}/homepage.jpg
325
+ // R2 key follows ISR assets pattern: {slug}/assets/_screenshots/{buildId}/{name}.jpg
260
326
  // ISR middleware routes /_screenshots/*.jpeg → /api/assets/_screenshots/*.jpeg
261
327
  // Assets API builds R2 key: {project}/assets/{assetPath}
262
- const screenshotKey = `${slug}/assets/_screenshots/${buildId}/homepage.jpg`;
328
+ //
329
+ // The random name is what keeps a password-protected site's preview
330
+ // private: the proxy serves this path without the unlock cookie, and the
331
+ // buildId alone is NOT secret (lastBuildId sits on the projects doc, which
332
+ // any signed-in user can read). The full URL is stored only on the
333
+ // members-only build doc. See isSiteScreenshotPath.
334
+ const screenshotPath = `_screenshots/${buildId}/${randomBytes(16).toString('hex')}.jpg`;
335
+ const screenshotKey = `${slug}/assets/${screenshotPath}`;
263
336
  const abortController = new AbortController();
264
337
  const uploadTimeout = setTimeout(() => abortController.abort(), 5000);
265
338
 
@@ -279,11 +352,10 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
279
352
  }
280
353
 
281
354
  // Clean up old screenshots after successful upload
282
- await cleanupOldScreenshots(r2Client, r2Bucket, slug, buildId);
355
+ await cleanupOldScreenshots(r2Client, r2Bucket, slug, screenshotKey);
283
356
 
284
- // Return the project subdomain URL (with buildId for cache busting)
285
- // URL format: https://{slug}.jamdesk.app/_screenshots/{buildId}/homepage.jpg
286
- const screenshotUrl = `https://${slug}.jamdesk.app/_screenshots/${buildId}/homepage.jpg`;
357
+ // Return the project subdomain URL (a new name per capture busts caches)
358
+ const screenshotUrl = `https://${slug}.jamdesk.app/${screenshotPath}`;
287
359
 
288
360
  return { screenshotUrl };
289
361
  };
@@ -364,8 +364,9 @@ function bucketPagesByLocale<P extends { path: string }>(
364
364
  }
365
365
 
366
366
  /**
367
- * Render the root llms.txt (default locale) plus one file per declared,
368
- * non-hidden, non-default language (see bucketPagesByLocale for membership).
367
+ * Render the root llms.txt (default locale) plus one file per active language
368
+ * (see bucketPagesByLocale for membership). The default language's file is a
369
+ * copy of the root, written only when another language is active too.
369
370
  * Cross-links between locale files render under the trailing "## Optional"
370
371
  * section.
371
372
  */
@@ -400,7 +401,13 @@ export function generateLlmsTxtFiles(
400
401
 
401
402
  const byLocale: Record<string, string> = {};
402
403
  for (const code of activeCodes) {
403
- if (code === defaultCode) continue;
404
+ if (code === defaultCode) {
405
+ // The default locale's index IS the root file. Also writing it under
406
+ // /{default}/ keeps its URL in step with /{default}/llms-full.txt, which
407
+ // generateLlmsFullTxtFiles writes under the same two-active-locales rule.
408
+ if (activeCodes.length > 1) byLocale[code] = root;
409
+ continue;
410
+ }
404
411
  byLocale[code] = generateLlmsTxt({
405
412
  ...rest, baseUrl, hostAtDocs, docsPrefix, sections,
406
413
  pages: pagesFor(code),
@@ -11,6 +11,7 @@ import path from 'path';
11
11
 
12
12
  import { NextRequest, NextResponse } from 'next/server';
13
13
 
14
+ import { getLanguageRouting } from '@/lib/language-matcher';
14
15
  import { isKnownLanguageCode } from '@/lib/language-utils';
15
16
  import { log } from '@/lib/logger';
16
17
  import { isIsrMode } from '@/lib/page-isr-helpers';
@@ -173,6 +174,13 @@ export function createCorsStaticFileHandler(
173
174
  * Like llms.txt, locale files are intentionally NOT suppressed on
174
175
  * `x-jd-noindex` — AI agents fetch them directly and don't honor robots
175
176
  * semantics.
177
+ *
178
+ * Builds since 2026-09-23 write `/{default}/llms.txt` as a copy of the root.
179
+ * For sites not rebuilt since, a miss on the default language falls back to
180
+ * the root file, so `/en/llms.txt` answers next to `/en/llms-full.txt` without
181
+ * waiting for a rebuild. The default comes from `_languages.json`, which is
182
+ * null when fewer than two languages are routable, when a site predates the
183
+ * file, or on a read error — all of those keep the plain 404.
176
184
  */
177
185
  export function createLocaleLlmsTxtHandler(file: 'llms.txt' | 'llms-full.txt' = 'llms.txt'): (
178
186
  request: NextRequest,
@@ -205,7 +213,15 @@ export function createLocaleLlmsTxtHandler(file: 'llms.txt' | 'llms-full.txt' =
205
213
  }
206
214
 
207
215
  try {
208
- const content = await fetchStaticFile(projectSlug, filename);
216
+ let content = await fetchStaticFile(projectSlug, filename);
217
+ if (content === null && file === 'llms.txt') {
218
+ // Fallback for sites built before the default-locale copy existed (see
219
+ // the doc comment for when routing is null).
220
+ const routing = await getLanguageRouting(projectSlug);
221
+ if (routing?.defaultLanguage.toLowerCase() === locale.toLowerCase()) {
222
+ content = await fetchStaticFile(projectSlug, 'llms.txt');
223
+ }
224
+ }
209
225
  if (content === null) {
210
226
  return new NextResponse('Not found', { status: 404 });
211
227
  }
@@ -40,9 +40,10 @@
40
40
 
41
41
  --color-border: #D4D7DD;
42
42
  --color-border-hover: #C6CBD3;
43
- --border-width: 1px;
43
+ --border-width: 0.5px; /* hairline dividers, jam's weight */
44
44
 
45
45
  --color-text-primary: #1A1D22; /* 15.1:1 */
46
+ --color-heading: #2B3038; /* 11.8:1 — a step softer than text-primary */
46
47
  --color-text-secondary: #3C424C; /* 9.0:1 */
47
48
  --color-text-tertiary: #515A67; /* 6.2:1 */
48
49
  --color-text-muted: #616B7B; /* 4.8:1 */
@@ -104,7 +105,7 @@
104
105
 
105
106
  /* Weights only — sizes come from jam's ladder. Body is 400, not jam's 300:
106
107
  a thin weight undoes the contrast the grays above are tuned for. */
107
- --prose-h1-weight: 600;
108
+ --prose-h1-weight: 500;
108
109
  --prose-h2-weight: 600;
109
110
  --prose-h3-weight: 600;
110
111
  --prose-h4-weight: 600;
@@ -123,6 +124,7 @@
123
124
  --color-border-hover: #3E444E;
124
125
 
125
126
  --color-text-primary: #E4E7EA; /* 15.0:1 */
127
+ --color-heading: var(--color-text-primary);
126
128
  --color-text-secondary: #B8BFC7; /* 10.0:1 */
127
129
  --color-text-tertiary: #909AA7; /* 6.5:1 */
128
130
  --color-text-muted: #768394; /* 4.8:1 */
@@ -192,7 +194,24 @@ body[data-theme="dusk"] h1 {
192
194
  line-height: var(--prose-h1-line-height);
193
195
  font-weight: var(--prose-h1-weight);
194
196
  letter-spacing: -0.01em;
195
- color: var(--color-text-primary);
197
+ color: var(--color-heading);
198
+ }
199
+
200
+ /* A bigger H1 than jam's 30/27/24 ladder. Set on body so it beats jam's
201
+ :root media queries regardless of stylesheet order. */
202
+ body[data-theme="dusk"] {
203
+ --prose-h1-size: 34px;
204
+ }
205
+ @media (min-width: 640px) and (max-width: 1023px) {
206
+ body[data-theme="dusk"] { --prose-h1-size: 30px; }
207
+ }
208
+ @media (max-width: 639px) {
209
+ body[data-theme="dusk"] { --prose-h1-size: 26px; }
210
+ }
211
+
212
+ /* More air around the page title (breadcrumb above, lead text below). */
213
+ body[data-theme="dusk"] header > div > h1 {
214
+ margin-block: 12px;
196
215
  }
197
216
 
198
217
  body[data-theme="dusk"] .prose h2 {
@@ -201,7 +220,7 @@ body[data-theme="dusk"] .prose h2 {
201
220
  line-height: var(--prose-h2-line-height);
202
221
  font-weight: var(--prose-h2-weight);
203
222
  letter-spacing: var(--prose-h2-tracking);
204
- color: var(--color-text-primary);
223
+ color: var(--color-heading);
205
224
  }
206
225
 
207
226
  body[data-theme="dusk"] .prose h3 {
@@ -210,7 +229,7 @@ body[data-theme="dusk"] .prose h3 {
210
229
  line-height: var(--prose-h3-line-height);
211
230
  font-weight: var(--prose-h3-weight);
212
231
  letter-spacing: var(--prose-h3-tracking);
213
- color: var(--color-text-primary);
232
+ color: var(--color-heading);
214
233
  }
215
234
 
216
235
  body[data-theme="dusk"] .prose h4 {
@@ -219,7 +238,7 @@ body[data-theme="dusk"] .prose h4 {
219
238
  line-height: var(--prose-h4-line-height);
220
239
  font-weight: var(--prose-h4-weight);
221
240
  letter-spacing: var(--prose-h4-tracking);
222
- color: var(--color-text-primary);
241
+ color: var(--color-heading);
223
242
  }
224
243
 
225
244
  /* Inline code — jam's padding, Dusk's surface. */
@@ -250,14 +269,14 @@ body[data-theme="dusk"] hr {
250
269
 
251
270
  /* ===== HEADER ===== */
252
271
  /* The header-logo <header> has no border of its own. Same construction as
253
- nebula's divider (see its comment for the inset math), at Dusk's 1px. */
272
+ nebula's divider (see its comment for the inset math), at --border-width. */
254
273
  body[data-theme="dusk"] header[data-has-tabs]::after {
255
274
  content: '';
256
275
  position: absolute;
257
276
  left: -1rem;
258
277
  right: -1rem;
259
278
  bottom: 0;
260
- height: 1px;
279
+ height: var(--border-width);
261
280
  background-color: var(--color-border);
262
281
  pointer-events: none;
263
282
  }
@@ -269,10 +288,41 @@ body[data-theme="dusk"] header[data-has-tabs]::after {
269
288
  }
270
289
  }
271
290
 
291
+ /* Tailwind's `border*` utilities are a hard 1px. Route them through
292
+ --border-width so every divider (sidebar edge, cards, panels) is the same
293
+ hairline — jam's rule, re-scoped. */
294
+ body[data-theme="dusk"] .border {
295
+ border-width: var(--border-width) !important;
296
+ }
297
+ body[data-theme="dusk"] .border-t {
298
+ border-top-width: var(--border-width) !important;
299
+ }
300
+ body[data-theme="dusk"] .border-b {
301
+ border-bottom-width: var(--border-width) !important;
302
+ }
303
+ body[data-theme="dusk"] .border-l {
304
+ border-left-width: var(--border-width) !important;
305
+ }
306
+ body[data-theme="dusk"] .border-r {
307
+ border-right-width: var(--border-width) !important;
308
+ }
309
+
272
310
  /* ===== SIDEBAR (jam's spacing, re-scoped) ===== */
273
311
  /* The sidebar keeps base's border-r — Dusk separates with borders, so unlike
274
312
  jam and halo it does not remove it. */
275
313
 
314
+ /* The shared desktop <aside> is `lg:self-start`, so it is only as tall as its
315
+ nav and the border-r stopped short of the column bottom on short pages.
316
+ Stretch it to the full layout row so the edge always runs the whole height;
317
+ `lg:max-h-full` still caps it and .sidebar-scroll (flex: 1, below) keeps
318
+ scrolling inside it. Matched via .sidebar-scroll so the TOC <aside> is
319
+ untouched; desktop only, since below lg this element is the drawer. */
320
+ @media (min-width: 1024px) {
321
+ body[data-theme="dusk"] aside:has(.sidebar-scroll) {
322
+ align-self: stretch;
323
+ }
324
+ }
325
+
276
326
  body[data-theme="dusk"] [data-nav-anchor] {
277
327
  padding: 0 0 16px 0;
278
328
  position: relative;