jamdesk 1.1.162 → 1.1.164

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.
@@ -65,6 +65,31 @@ export function validateFontName(fontName: string | undefined): boolean {
65
65
  return fontName.trim().length > 0 && fontName.length < 100;
66
66
  }
67
67
 
68
+ /**
69
+ * Merge a configured body weight with the theme's heading-weight ladder.
70
+ *
71
+ * The theme renders headings at 500/600 (--prose-hN-weight). If we requested
72
+ * ONLY the client's chosen body weight, headings would have no bold face and
73
+ * collapse down to the body weight — flattening the type hierarchy (Inter avoids
74
+ * this because next/font loads it as a full variable font). So we always keep the
75
+ * heading weights loaded alongside the chosen body weight. Deduped + sorted; the
76
+ * body weight defaults to 300 (the theme's --prose-body-weight) when unset.
77
+ */
78
+ function withHeadingWeights(bodyWeight: number | undefined, headingLadder: number[]): number[] {
79
+ return Array.from(new Set([bodyWeight ?? 300, ...headingLadder])).sort((a, b) => a - b);
80
+ }
81
+
82
+ /**
83
+ * The client-configured body-text weight, across config formats. Legacy configs
84
+ * have no weight concept. Returns undefined when unset, so callers can fall back
85
+ * to the theme default rather than forcing a weight.
86
+ */
87
+ function getConfiguredBodyWeight(fonts: FontConfig): number | undefined {
88
+ if (isSingleFontConfig(fonts)) return fonts.weight;
89
+ if (isHeadingBodyFontConfig(fonts)) return fonts.body?.weight;
90
+ return undefined;
91
+ }
92
+
68
93
  /**
69
94
  * Type guard for legacy font config
70
95
  */
@@ -152,7 +177,9 @@ export function generateFontImports(fonts: FontConfig | undefined): string | nul
152
177
  if (fonts.family && validateFontName(fonts.family)) {
153
178
  // If source is provided, it's a custom font file - don't use Google Fonts
154
179
  if (!fonts.source && !isPreloadedFont(fonts.family)) {
155
- const weights = fonts.weight ? [fonts.weight] : [300, 400, 500, 600, 700];
180
+ // Load the chosen body weight AND the theme's heading ladder, so headings
181
+ // keep a bold face instead of collapsing to the body weight.
182
+ const weights = withHeadingWeights(fonts.weight, [400, 500, 600, 700]);
156
183
  imports.push(generateGoogleFontsUrl(fonts.family, weights));
157
184
  }
158
185
  }
@@ -167,7 +194,9 @@ export function generateFontImports(fonts: FontConfig | undefined): string | nul
167
194
 
168
195
  if (fonts.body?.family && validateFontName(fonts.body.family)) {
169
196
  if (!fonts.body.source && !isPreloadedFont(fonts.body.family)) {
170
- const weights = fonts.body.weight ? [fonts.body.weight] : [300, 400, 500, 600];
197
+ // Keep the heading weights (incl. h1's 600) loaded — the body font is
198
+ // reused for headings when no separate heading font is configured.
199
+ const weights = withHeadingWeights(fonts.body.weight, [400, 500, 600]);
171
200
  const bodyUrl = generateGoogleFontsUrl(fonts.body.family, weights);
172
201
  if (!imports.includes(bodyUrl)) {
173
202
  imports.push(bodyUrl);
@@ -229,5 +258,17 @@ export function generateFontVariables(fonts: FontConfig | undefined): string {
229
258
 
230
259
  if (variables.length === 0) return '';
231
260
 
232
- return `:root {\n ${variables.join('\n ')}\n}`;
261
+ const blocks = [`:root {\n ${variables.join('\n ')}\n}`];
262
+
263
+ // Pin body text to the client's configured weight. The theme sets
264
+ // --prose-body-weight: 300 on :root (tuned for Inter); a custom font may not
265
+ // ship a 300, and the client may want a lighter/heavier body. Emit at
266
+ // :root:root specificity (0,0,2,0) so it beats the theme's :root default
267
+ // regardless of stylesheet order — the theme CSS is injected AFTER this block.
268
+ const bodyWeight = getConfiguredBodyWeight(fonts);
269
+ if (typeof bodyWeight === 'number' && Number.isFinite(bodyWeight)) {
270
+ blocks.push(`:root:root {\n --prose-body-weight: ${bodyWeight};\n}`);
271
+ }
272
+
273
+ return blocks.join('\n');
233
274
  }
@@ -5,7 +5,7 @@
5
5
  * with caching for ISR mode rendering.
6
6
  */
7
7
 
8
- import yaml from 'js-yaml';
8
+ import {load as yamlLoad} from 'js-yaml';
9
9
  import SwaggerParser from '@apidevtools/swagger-parser';
10
10
  import { fetchOpenApiFile } from './r2-content';
11
11
 
@@ -67,7 +67,7 @@ export async function fetchOpenApiSpecFromR2(
67
67
 
68
68
  // Parse YAML or JSON into a plain object
69
69
  const isYaml = /\.ya?ml$/i.test(specPath);
70
- const raw = isYaml ? yaml.load(content) : JSON.parse(content);
70
+ const raw = isYaml ? yamlLoad(content) : JSON.parse(content);
71
71
 
72
72
  // Dereference all $ref pointers (matching static mode's SwaggerParser.validate behavior).
73
73
  // SwaggerParser.dereference accepts a loose Document type that does not line up with
@@ -78,7 +78,15 @@ async function fetchPublicR2Body(
78
78
  const start = performance.now();
79
79
  const region = process.env.VERCEL_REGION || 'unknown';
80
80
  try {
81
- const res = await fetch(url, { signal: controller.signal });
81
+ // WAF bypass for the r2.jamdesk.com default-deny rule: the public
82
+ // custom domain rejects requests missing this shared secret so the
83
+ // bucket can't be scanned, while platform reads keep their CDN cache
84
+ // hits (WAF custom rules evaluate before cache). Env unset → send no
85
+ // header; the resulting 403 routes the read to the S3 fallback.
86
+ const headers: Record<string, string> = {};
87
+ const wafSecret = process.env.R2_PUBLIC_FETCH_SECRET;
88
+ if (wafSecret) headers['x-jd-r2-key'] = wafSecret;
89
+ const res = await fetch(url, { signal: controller.signal, headers });
82
90
  const ms = Math.round(performance.now() - start);
83
91
  logger.info('[r2-timing]', {
84
92
  operation: `publicFetch(${key})`,
@@ -0,0 +1,97 @@
1
+ import type {Request, Response} from 'express';
2
+ import {getFirestore} from 'firebase-admin/firestore';
3
+ import {secretsEqual} from './crypto-helpers.js';
4
+ import {captureAndStoreScreenshot} from './screenshot-capture.js';
5
+ import {getR2S3Client, getR2Config} from './r2.js';
6
+
7
+ const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
8
+
9
+ /**
10
+ * Re-run screenshot capture for a project's latest completed build, WITHOUT a
11
+ * full rebuild. Called by the dashboard `regenerateScreenshot` callable
12
+ * (secret-guarded, mirrors /gc-deploys and /delete-project-assets).
13
+ *
14
+ * Reads the capture params (firstPagePath/hostAtDocs) off the build doc — they
15
+ * were persisted by captureAndStoreScreenshot at build time. This avoids
16
+ * reading docs.json (fetchDocsConfig uses next/cache, which is unavailable in
17
+ * this plain Express Cloud Run process).
18
+ *
19
+ * IMPORTANT: this handler AWAITS the capture (does NOT return 202 and background
20
+ * it). The build service is deployed WITHOUT --no-cpu-throttling, so once the
21
+ * HTTP response is sent Cloud Run may throttle CPU to ~zero and kill any
22
+ * in-flight background work. Holding the request open keeps CPU allocated until
23
+ * the capture finishes. The caller (regenerateScreenshot callable) therefore
24
+ * uses a single long-timeout request with NO retry — see Task 5.
25
+ */
26
+ export async function handleRegenerateScreenshot(req: Request, res: Response): Promise<void> {
27
+ const buildSecret = req.headers['x-build-service-secret'] as string | undefined;
28
+ const expectedSecret = process.env.BUILD_SERVICE_SECRET;
29
+ if (!expectedSecret) {
30
+ console.log(JSON.stringify({severity: 'ERROR', message: 'BUILD_SERVICE_SECRET not configured - rejecting /regenerate-screenshot'}));
31
+ res.status(500).json({error: 'Server misconfiguration'});
32
+ return;
33
+ }
34
+ if (!buildSecret || !secretsEqual(buildSecret, expectedSecret)) {
35
+ console.log(JSON.stringify({severity: 'ERROR', message: 'Unauthorized /regenerate-screenshot request', hasSecret: !!buildSecret}));
36
+ res.status(401).json({error: 'Unauthorized'});
37
+ return;
38
+ }
39
+
40
+ const {projectId, slug, buildId} = (req.body ?? {}) as {
41
+ projectId?: string; slug?: string; buildId?: string;
42
+ };
43
+ if (!projectId || typeof projectId !== 'string') {
44
+ res.status(400).json({error: 'invalid_projectId'});
45
+ return;
46
+ }
47
+ if (!slug || typeof slug !== 'string' || !SLUG_RE.test(slug)) {
48
+ res.status(400).json({error: 'invalid_slug'});
49
+ return;
50
+ }
51
+ if (!buildId || typeof buildId !== 'string') {
52
+ res.status(400).json({error: 'invalid_buildId'});
53
+ return;
54
+ }
55
+
56
+ const buildRef = getFirestore()
57
+ .collection('projects').doc(projectId)
58
+ .collection('builds').doc(buildId);
59
+
60
+ const snap = await buildRef.get();
61
+ if (!snap.exists) {
62
+ res.status(404).json({error: 'build_not_found'});
63
+ return;
64
+ }
65
+ const params = (snap.data()?.screenshotCapture ?? {}) as {
66
+ firstPagePath?: string; hostAtDocs?: boolean;
67
+ };
68
+ const firstPagePath = params.firstPagePath ?? 'introduction';
69
+ const hostAtDocs = params.hostAtDocs ?? true;
70
+
71
+ // Flip to 'capturing' so the dashboard shows the spinner (not Retry) while we
72
+ // work. Stamp the start time so the dashboard can fall back to Retry if this
73
+ // capture is stranded here (e.g. the instance dies before the terminal write).
74
+ await buildRef
75
+ .update({screenshotStatus: 'capturing', screenshotCapturingAt: Date.now()})
76
+ .catch(() => {});
77
+
78
+ try {
79
+ await captureAndStoreScreenshot({
80
+ slug,
81
+ buildId,
82
+ projectId,
83
+ siteUrl: `https://${slug}.jamdesk.app`,
84
+ hostAtDocs,
85
+ firstPagePath,
86
+ r2Client: getR2S3Client(),
87
+ r2Bucket: getR2Config().bucketName,
88
+ });
89
+ // captureAndStoreScreenshot has already written screenshotStatus ready|failed.
90
+ // (A soft capture failure returns void here and is written 'failed' by it.)
91
+ res.status(200).json({ok: true});
92
+ } catch (err) {
93
+ console.log(JSON.stringify({severity: 'ERROR', message: 'regenerate-screenshot capture threw', projectId, buildId, error: err instanceof Error ? err.message : String(err)}));
94
+ await buildRef.update({screenshotStatus: 'failed'}).catch(() => {});
95
+ res.status(502).json({error: 'capture_failed'});
96
+ }
97
+ }
@@ -10,21 +10,22 @@ import type { Root, Element } from 'hast';
10
10
  export function rehypeClassToClassName() {
11
11
  return (tree: Root) => {
12
12
  visit(tree, 'element', (node: Element) => {
13
- if (node.properties) {
14
- // Check for both 'class' and 'className' as MDX might pass either
15
- if ('class' in node.properties) {
16
- const classValue = node.properties.class;
17
- // Merge with existing className if present
18
- if (node.properties.className) {
19
- const existing = Array.isArray(node.properties.className)
20
- ? node.properties.className.join(' ')
21
- : node.properties.className;
22
- node.properties.className = `${existing} ${classValue}`;
23
- } else {
24
- node.properties.className = classValue;
25
- }
26
- delete node.properties.class;
27
- }
13
+ if (node.properties && 'class' in node.properties) {
14
+ // hast stores className as a space-separated token array, not a string.
15
+ // Split any incoming `class`/`className` value into tokens and merge, so
16
+ // the result satisfies the hast `Properties` type (the JSX runtime joins
17
+ // the array back into a string, so rendered output is unchanged).
18
+ const toTokens = (value: unknown): string[] =>
19
+ Array.isArray(value)
20
+ ? value.map(String)
21
+ : value == null
22
+ ? []
23
+ : String(value).split(/\s+/).filter(Boolean);
24
+ node.properties.className = [
25
+ ...toTokens(node.properties.className),
26
+ ...toTokens(node.properties.class),
27
+ ];
28
+ delete node.properties.class;
28
29
  }
29
30
  });
30
31
  };
@@ -273,6 +273,29 @@ export function classifyScannerProbe(pathname: string): ScannerCategory | null {
273
273
  return null;
274
274
  }
275
275
 
276
+ // Categories that are benign stale re-crawls of dead URLs, not hostile
277
+ // probes. Two consumers key off this set:
278
+ // - the middlewares answer them 410 Gone instead of 404 — 410 is the
279
+ // permanent-removal signal, so well-behaved crawlers evict the URL
280
+ // from their index instead of replaying it forever;
281
+ // - the scanner-block digest keeps them out of the per-IP WAF-candidate
282
+ // table (they would show up every window as noise) and reports them
283
+ // only as a category-count line.
284
+ // Never WAF-promote a category in this set.
285
+ export const BENIGN_SCANNER_CATEGORIES: ReadonlySet<ScannerCategory> = new Set(
286
+ ['legacy-content'],
287
+ );
288
+
289
+ // Response for a blocked probe. Hostile probes get the plain 404 so the
290
+ // block is indistinguishable from a nonexistent path.
291
+ export function scannerBlockResponse(
292
+ category: ScannerCategory,
293
+ ): { status: 404 | 410; body: string } {
294
+ return BENIGN_SCANNER_CATEGORIES.has(category)
295
+ ? { status: 410, body: 'Gone' }
296
+ : { status: 404, body: 'Not Found' };
297
+ }
298
+
276
299
  const LOG_PREFIX = '[middleware] scanner-block';
277
300
 
278
301
  export type ClientIpSource = 'x-real-ip' | 'x-forwarded-for' | 'none';
@@ -31,9 +31,64 @@ interface CaptureResult {
31
31
  }
32
32
 
33
33
  // Headroom for the retry path: two 15s page.goto budgets + 3s settle wait
34
- // already total 33s before the screenshot is even taken.
34
+ // already total 33s before the screenshot is even taken. Launch retries
35
+ // (closed-target only) fail fast (<~2s) so they stay inside this 45s race.
35
36
  const SCREENSHOT_TIMEOUT_MS = 45000;
36
37
 
38
+ // Playwright launch flakes intermittently in the container — the Chromium
39
+ // process is reaped during the CDP handshake, surfacing as one of a few
40
+ // transient phrasings. Retry ONLY these. Do NOT match the generic
41
+ // "browserType.launch:" prefix — it also fronts permanent failures like
42
+ // "Executable doesn't exist", which must fail fast, not burn the 45s budget.
43
+ // Observed in prod logs: "Target page, context or browser has been closed".
44
+ // The crashed/disconnected/Protocol-error variants are the same reaped-process
45
+ // failure under load and are equally safe to retry.
46
+ const RETRIABLE_LAUNCH_RE =
47
+ /browser has been closed|browser has disconnected|Target (page, context or browser has been closed|closed|crashed)|Target crashed|Protocol error/i;
48
+
49
+ function defaultChromiumLaunch(): Promise<Browser> {
50
+ return chromium.launch({
51
+ // Undefined → Playwright uses its own bundled, version-matched Chromium
52
+ // (see Dockerfile). The env override stays as an escape hatch for envs
53
+ // that must point at a system binary. Do NOT hardcode /usr/bin/chromium:
54
+ // the unpinned apt chromium drifted out of sync with Playwright and
55
+ // SIGTRAPped at launch (2026-07-08 capture outage).
56
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
57
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
58
+ // 8s (not 10s) so 3 hung launches (~24s) + backoff still leave headroom
59
+ // for a page.goto inside the 45s outer race. Closed-target flakes reject
60
+ // near-instantly in practice, so this only bounds the pathological case.
61
+ timeout: 8000,
62
+ });
63
+ }
64
+
65
+ export async function launchChromiumWithRetry(
66
+ launch: () => Promise<Browser> = defaultChromiumLaunch,
67
+ attempts = 3,
68
+ sleep: (ms: number) => Promise<void> = (ms) =>
69
+ new Promise((resolve) => setTimeout(resolve, ms)),
70
+ ): Promise<Browser> {
71
+ let lastError: unknown;
72
+ for (let attempt = 1; attempt <= attempts; attempt++) {
73
+ try {
74
+ return await launch();
75
+ } catch (error) {
76
+ lastError = error;
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ if (!RETRIABLE_LAUNCH_RE.test(message) || attempt === attempts) {
79
+ throw error;
80
+ }
81
+ const delayMs = 500 * 2 ** (attempt - 1); // 500ms, 1000ms
82
+ console.log(
83
+ `Chromium launch attempt ${attempt} failed (${message}); ` +
84
+ `retrying in ${delayMs}ms`,
85
+ );
86
+ await sleep(delayMs);
87
+ }
88
+ }
89
+ throw lastError;
90
+ }
91
+
37
92
  /**
38
93
  * Clean up old screenshots for a project, keeping only the current build's screenshot.
39
94
  * This prevents R2 storage from accumulating old screenshots.
@@ -121,12 +176,18 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
121
176
  });
122
177
 
123
178
  const capturePromise = async (): Promise<CaptureResult> => {
124
- // Launch browser with system Chromium
125
- browser = await chromium.launch({
126
- executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || '/usr/bin/chromium',
127
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
128
- timeout: 10000, // 10s browser launch timeout
129
- });
179
+ // Launch browser with system Chromium (retry transient launch flakes)
180
+ browser = await launchChromiumWithRetry();
181
+
182
+ // The 45s outer race can fire while a retry is mid-backoff; if it already
183
+ // timed out, close the just-launched browser now so the retry path can't
184
+ // strand a Chromium process (the finally-block cleanup ran with browser
185
+ // still null). Widened by retry latency, so guard it explicitly.
186
+ if (timedOut) {
187
+ await browser.close().catch(() => {});
188
+ browser = null;
189
+ return { screenshotUrl: null, error: 'Screenshot capture timed out' };
190
+ }
130
191
 
131
192
  const page = await browser.newPage();
132
193
  await page.setViewportSize({ width: 1280, height: 720 });
@@ -222,22 +283,45 @@ interface CaptureAndStoreOptions extends CaptureOptions {
222
283
  * This is the main entry point called from build.ts after build completion.
223
284
  * Fire-and-forget - does NOT block build completion.
224
285
  */
225
- export async function captureAndStoreScreenshot(options: CaptureAndStoreOptions): Promise<void> {
226
- const { projectId, ...captureOptions } = options;
227
- const result = await captureHomepageScreenshot(captureOptions);
286
+ export async function captureAndStoreScreenshot(
287
+ options: CaptureAndStoreOptions,
288
+ capture: (o: CaptureOptions) => Promise<CaptureResult> = captureHomepageScreenshot,
289
+ ): Promise<void> {
290
+ const {projectId, ...captureOptions} = options;
291
+ const buildRef = getFirestore()
292
+ .collection('projects')
293
+ .doc(projectId)
294
+ .collection('builds')
295
+ .doc(options.buildId);
296
+
297
+ // Persist the exact params so a later /regenerate-screenshot can reuse them
298
+ // without re-reading docs.json (which is unavailable in the Cloud Run server).
299
+ const screenshotCapture = {
300
+ firstPagePath: captureOptions.firstPagePath,
301
+ hostAtDocs: captureOptions.hostAtDocs,
302
+ };
303
+
304
+ const result = await capture(captureOptions);
228
305
 
229
306
  if (!result.screenshotUrl) {
230
- if (result.error) console.log(`Screenshot skipped: ${result.error}`);
307
+ if (result.error) console.log(`Screenshot failed: ${result.error}`);
308
+ // Record the definitive failure so the dashboard can offer Retry instead of
309
+ // spinning forever. Non-fatal: never throw back into the build.
310
+ await buildRef
311
+ .update({screenshotStatus: 'failed', screenshotCapture})
312
+ .catch((err) => console.warn('Failed to record screenshotStatus:failed:', err));
231
313
  return;
232
314
  }
233
315
 
234
- const db = getFirestore();
235
- await db
236
- .collection('projects')
237
- .doc(projectId)
238
- .collection('builds')
239
- .doc(options.buildId)
240
- .update({ screenshotUrl: result.screenshotUrl });
316
+ await buildRef.update({
317
+ screenshotUrl: result.screenshotUrl,
318
+ screenshotStatus: 'ready',
319
+ screenshotCapture,
320
+ // Real capture time. The dashboard folds this into its cache-bust query
321
+ // param so a same-URL regenerate (same buildId → same key) still busts the
322
+ // browser cache and re-renders; it also gives onSnapshot a changed field.
323
+ screenshotUpdatedAt: Date.now(),
324
+ });
241
325
 
242
326
  console.log(`Screenshot saved: ${result.screenshotUrl}`);
243
327
  }