jamdesk 1.1.157 → 1.1.158

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Redis NX upload lock with heartbeat-and-steal semantics — TS-native.
3
+ *
4
+ * Mirrors `scripts/lib/upload-lock.cjs` semantics but uses the @upstash/redis
5
+ * SDK via `lib/redis.ts` so the production ISR build path (build.ts +
6
+ * scripts/upload-content-to-r2.ts) can reuse the same wire-level behaviour
7
+ * without shelling out to the CJS path.
8
+ *
9
+ * Defense-in-depth against concurrent ISR uploads for the same project. Two
10
+ * builds racing the manifest read/write would stamp the wrong deployId into
11
+ * Redis and point readers at a half-uploaded R2 prefix. The Firebase Functions
12
+ * queue is supposed to serialize these, but it lives in a separate process;
13
+ * this lock is the fallback that catches mistakes and orchestration drift.
14
+ *
15
+ * Lock key: r2UploadLock:<projectName>
16
+ * Lock value: <deployId>:<startedAtMs>:<lastHeartbeatMs>
17
+ * TTL: 600s (absolute ceiling so SIGKILL self-cleans)
18
+ * Heartbeat: 30s (refreshes value + EX TTL)
19
+ * Stale threshold: 90s (a holder whose heartbeat is older may be stolen)
20
+ *
21
+ * Best-effort: when Redis isn't configured (no @upstash/redis client) or a
22
+ * call throws, `acquireUploadLock` returns `{ acquired: false }` plus either
23
+ * `skipped: true` (no client) or `errored: true` (Redis threw) so the caller
24
+ * can degrade to "no lock" rather than block uploads on Upstash availability.
25
+ */
26
+
27
+ import { redis } from './redis.js';
28
+ import { logger } from '../shared/logger.js';
29
+
30
+ export const STALE_AFTER_MS = 90_000;
31
+ export const HEARTBEAT_INTERVAL_MS = 30_000;
32
+ export const TTL_SECONDS = 600;
33
+
34
+ export function lockKeyFor(projectName: string): string {
35
+ return `r2UploadLock:${projectName}`;
36
+ }
37
+
38
+ export function buildLockValue(
39
+ deployId: string,
40
+ startedAtMs: number,
41
+ heartbeatMs: number,
42
+ ): string {
43
+ return `${deployId}:${startedAtMs}:${heartbeatMs}`;
44
+ }
45
+
46
+ export interface AcquireResult {
47
+ acquired: boolean;
48
+ /** Redis call threw (network blip, outage). Caller should warn and proceed. */
49
+ errored?: boolean;
50
+ /** No Redis client configured. Caller should treat as "skip locking". */
51
+ skipped?: boolean;
52
+ error?: string;
53
+ }
54
+
55
+ interface AcquireArgs {
56
+ projectName: string;
57
+ deployId: string;
58
+ startedAtMs?: number;
59
+ now?: () => number;
60
+ }
61
+
62
+ interface ReleaseArgs {
63
+ projectName: string;
64
+ deployId: string;
65
+ }
66
+
67
+ interface HeartbeatArgs {
68
+ projectName: string;
69
+ deployId: string;
70
+ startedAtMs: number;
71
+ /** Called on lock theft (GET returns null OR a value not prefixed with our deployId). */
72
+ onTheft?: (current: unknown) => void;
73
+ now?: () => number;
74
+ }
75
+
76
+ /**
77
+ * Coerce `redis.get`'s parsed return to a string for prefix comparisons.
78
+ * The Upstash SDK auto-JSON-parses bodies, so a stored value like
79
+ * "deploy-abc:1:2" round-trips as the JS string "deploy-abc:1:2", but a
80
+ * value like "1234:5678:9012" *might* round-trip as a number depending on
81
+ * how the writer encoded it. String-coercing keeps the prefix check honest.
82
+ */
83
+ function asString(value: unknown): string | null {
84
+ if (value === null || value === undefined) return null;
85
+ return String(value);
86
+ }
87
+
88
+ /**
89
+ * Try to acquire the lock for ${projectName}.
90
+ *
91
+ * Behaviour by case:
92
+ * 1. Lock free → SET NX returns 'OK', acquired.
93
+ * 2. Lock held, fresh holder → returns false.
94
+ * 3. Lock vanished between SET NX & GET → retry SET NX once.
95
+ * 4. Lock held, holder is stale (>90s) → SET (no NX) steals it; warns.
96
+ * 5. Lock value malformed (non-numeric ts) → treat as fresh, returns false.
97
+ *
98
+ * Returns acquired:false errored:true on Redis throw so the caller can
99
+ * proceed without the lock — best-effort defense-in-depth, not a hard
100
+ * dependency. An Upstash outage must NOT block all uploads.
101
+ *
102
+ * Returns acquired:false skipped:true when Redis isn't configured (e.g. local
103
+ * dev without KV credentials) — same degradation path as the CJS lockEnabled
104
+ * check.
105
+ */
106
+ export async function acquireUploadLock(
107
+ args: AcquireArgs,
108
+ ): Promise<AcquireResult> {
109
+ if (!redis) {
110
+ return { acquired: false, skipped: true };
111
+ }
112
+
113
+ const now = args.now ?? Date.now;
114
+ const startedAtMs = args.startedAtMs ?? now();
115
+ const key = lockKeyFor(args.projectName);
116
+
117
+ try {
118
+ const initial = await redis.set(
119
+ key,
120
+ buildLockValue(args.deployId, startedAtMs, startedAtMs),
121
+ { nx: true, ex: TTL_SECONDS },
122
+ );
123
+ if (initial === 'OK') return { acquired: true };
124
+
125
+ const currentRaw = await redis.get(key);
126
+ const current = asString(currentRaw);
127
+ if (current === null) {
128
+ // Lock was released between the failed SET NX and the GET. Retry once
129
+ // — concurrent racer might have just finished a tiny upload.
130
+ const retry = await redis.set(
131
+ key,
132
+ buildLockValue(args.deployId, startedAtMs, now()),
133
+ { nx: true, ex: TTL_SECONDS },
134
+ );
135
+ return { acquired: retry === 'OK' };
136
+ }
137
+
138
+ const parts = current.split(':');
139
+ const lastHeartbeat = Number(parts[2]);
140
+ if (
141
+ Number.isFinite(lastHeartbeat) &&
142
+ now() - lastHeartbeat > STALE_AFTER_MS
143
+ ) {
144
+ // Steal the stale lock. SET without NX overwrites; preserves TTL.
145
+ await redis.set(
146
+ key,
147
+ buildLockValue(args.deployId, startedAtMs, now()),
148
+ { ex: TTL_SECONDS },
149
+ );
150
+ const ageSec = Math.round((now() - lastHeartbeat) / 1000);
151
+ logger.warn(
152
+ `Stole stale upload lock for ${args.projectName} (last heartbeat ${ageSec}s ago)`,
153
+ );
154
+ return { acquired: true };
155
+ }
156
+
157
+ // Lock held by a fresh, live holder — or value malformed (Number.isFinite
158
+ // false). In both cases we abort: malformed values are not stealable
159
+ // because we can't tell if the writer is alive.
160
+ return { acquired: false };
161
+ } catch (err) {
162
+ return {
163
+ acquired: false,
164
+ errored: true,
165
+ error: err instanceof Error ? err.message : String(err),
166
+ };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Release the lock if and only if we still hold it (compare-and-delete).
172
+ * Best-effort: never throws. Caller is responsible for stopping the
173
+ * heartbeat timer BEFORE calling this so the timer doesn't race the
174
+ * release and re-extend the TTL after DEL.
175
+ */
176
+ export async function releaseUploadLock(args: ReleaseArgs): Promise<void> {
177
+ if (!redis) return;
178
+ try {
179
+ const key = lockKeyFor(args.projectName);
180
+ const currentRaw = await redis.get(key);
181
+ const current = asString(currentRaw);
182
+ if (current && current.startsWith(`${args.deployId}:`)) {
183
+ await redis.del(key);
184
+ }
185
+ } catch {
186
+ // best-effort
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Handle returned by `startHeartbeat`. `stop()` MUST be called before
192
+ * `releaseUploadLock` — clearing the interval alone is insufficient because
193
+ * a tick that started before clearInterval can still be suspended on
194
+ * `await redis.get` and would re-extend TTL after the lock has been DEL'd
195
+ * (issue M1 from the Phase 4 QA pass — non-corrupting but ~90s self-recovery
196
+ * via stale-steal). `stop()` flips an `aborted` flag the tick re-checks at
197
+ * every async boundary AND clears the timer so no new ticks fire.
198
+ */
199
+ export interface HeartbeatHandle {
200
+ stop: () => void;
201
+ }
202
+
203
+ /**
204
+ * Start the heartbeat interval. Returns a handle whose `stop()` aborts
205
+ * any in-flight tick AND clears the timer.
206
+ *
207
+ * Each tick:
208
+ * 1. GETs the lock value.
209
+ * 2. If the value is missing or no longer prefixed with our deployId,
210
+ * the lock has been stolen (or expired + re-acquired) — call onTheft
211
+ * with the observed value so the caller can throw / abort cleanly.
212
+ * 3. Otherwise, SET (no NX) refreshes the heartbeat ms and bumps TTL.
213
+ *
214
+ * Default `onTheft` logs an error — callers are expected to provide a
215
+ * theft handler that throws or sets a flag the upload phase reads, so
216
+ * theft cleanly aborts the build instead of relying on `process.exit(2)`
217
+ * (which would skip the build's failure-reporting path).
218
+ *
219
+ * Transient Redis errors during a tick are swallowed: a single missed
220
+ * heartbeat shouldn't kill the build, and the 600s TTL is the backstop.
221
+ */
222
+ export function startHeartbeat(args: HeartbeatArgs): HeartbeatHandle {
223
+ const { projectName, deployId, startedAtMs } = args;
224
+ const now = args.now ?? Date.now;
225
+ const onTheft =
226
+ args.onTheft ??
227
+ ((current: unknown) => {
228
+ logger.error(
229
+ `[r2-upload] lock stolen by another build (was: ${current})`,
230
+ );
231
+ });
232
+ const key = lockKeyFor(projectName);
233
+
234
+ let aborted = false;
235
+
236
+ const tick = async (): Promise<void> => {
237
+ if (aborted || !redis) return;
238
+ try {
239
+ const currentRaw = await redis.get(key);
240
+ // After every async boundary, re-check the abort flag. The release
241
+ // path flips it; a tick that resumed mid-flight must not call
242
+ // onTheft (would surface a stale theft signal) or extend TTL
243
+ // (would defeat the DEL).
244
+ if (aborted) return;
245
+ const current = asString(currentRaw);
246
+ if (!current || !current.startsWith(`${deployId}:`)) {
247
+ onTheft(current);
248
+ return;
249
+ }
250
+ const heartbeatMs = now();
251
+ if (aborted) return;
252
+ await redis.set(
253
+ key,
254
+ buildLockValue(deployId, startedAtMs, heartbeatMs),
255
+ { ex: TTL_SECONDS },
256
+ );
257
+ } catch {
258
+ // Transient Redis error — swallow. TTL backstop covers worst case.
259
+ }
260
+ };
261
+
262
+ const timer = setInterval(() => {
263
+ tick().catch(() => {
264
+ // Already swallowed inside tick(); this catch is for the ESLint
265
+ // floating-promise rule on setInterval callbacks.
266
+ });
267
+ }, HEARTBEAT_INTERVAL_MS);
268
+ // Don't keep the event loop alive for the heartbeat alone.
269
+ if (typeof timer.unref === 'function') timer.unref();
270
+
271
+ return {
272
+ stop: () => {
273
+ aborted = true;
274
+ clearInterval(timer);
275
+ },
276
+ };
277
+ }
@@ -13,6 +13,7 @@ import { clearProjectCache as clearMcpProjectCache, clearCache as clearMcpCache
13
13
  import { clearNavigationCache } from './navigation-resolver';
14
14
  import { STATIC_REVALIDATION_PATHS } from './static-file-route';
15
15
  import { clearRedirectCache } from './redirect-matcher';
16
+ import { clearProjectDeployIdCache } from './project-deploy-id';
16
17
  import { mdxCacheTag, projectCacheTag } from './cache-tags';
17
18
 
18
19
  /**
@@ -132,6 +133,11 @@ export async function executeRevalidation(
132
133
 
133
134
  if (isWholeProjectClear) {
134
135
  const project = request.project!;
136
+ // Evict the deployId pointer LRU first: cached fetchers key the Data
137
+ // Cache on the resolved deployId, so a ≤30s-stale pointer on this
138
+ // instance would otherwise keep reads pinned to the previous deploy's
139
+ // (dead) cache keys until the LRU TTL lapses.
140
+ clearProjectDeployIdCache(project);
135
141
  clearConfigCache(project);
136
142
  clearSnippetCache(project);
137
143
  clearOpenApiCache(project);
@@ -193,6 +199,7 @@ export async function executeRevalidation(
193
199
  clearOpenApiCache();
194
200
  clearMcpCache();
195
201
  clearNavigationCache();
202
+ clearProjectDeployIdCache();
196
203
  await clearRedirectCache();
197
204
  if (revalidatePath) {
198
205
  revalidatePath('/', 'layout');
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { log } from './logger.js';
7
+ import { redis } from './redis.js';
8
+
9
+ export const DEPLOY_FLIP_TAG = '[deploy-flip]';
7
10
 
8
11
  export interface RevalidationOptions {
9
12
  projectSlug: string;
@@ -56,6 +59,115 @@ export async function triggerRevalidation(options: RevalidationOptions): Promise
56
59
  });
57
60
  }
58
61
 
62
+ /**
63
+ * Permanent revalidation failure after all retry attempts.
64
+ *
65
+ * Signals to the build orchestrator that Vercel Data Cache may now be serving
66
+ * stale `DocsConfig` (which references the OLD `projectDeployId` for nested
67
+ * asset URLs). The build is marked `succeeded_with_warnings` rather than
68
+ * silently degrading — there's no automatic recovery because the cache key
69
+ * is `[config, slug]`, not deployId-keyed.
70
+ */
71
+ export class RevalidationFailedError extends Error {
72
+ constructor(public readonly slug: string, public readonly cause: Error) {
73
+ super(`Revalidation failed permanently for ${slug}: ${cause.message}`);
74
+ this.name = 'RevalidationFailedError';
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Wraps `triggerRevalidation` with bounded retry + escalation.
80
+ *
81
+ * On every successful flip of `projectDeployId` in Redis the build pipeline
82
+ * MUST flush Vercel Data Cache via `revalidateTag(projectCacheTag(slug))`,
83
+ * or readers keep serving the OLD config (asset URLs refer to the previous
84
+ * deployId, breaking the new build for cached pages).
85
+ *
86
+ * This wrapper:
87
+ * 1. Calls `triggerRevalidation`. On rejection, sleeps `1s * attemptIndex`
88
+ * and retries up to `attempts` total.
89
+ * 2. On permanent failure, best-effort SETs `revalidationFailure:<slug>`
90
+ * in Redis with a 24h TTL so dashboards/on-call can surface it.
91
+ * 3. Throws `RevalidationFailedError` so the build orchestrator can mark
92
+ * the build `succeeded_with_warnings` instead of swallowing the error.
93
+ */
94
+ export async function triggerRevalidationWithRetry(
95
+ options: RevalidationOptions,
96
+ attempts = 2,
97
+ ): Promise<void> {
98
+ log('info', `${DEPLOY_FLIP_TAG} revalidation triggered`, { projectSlug: options.projectSlug });
99
+
100
+ let lastError: Error | undefined;
101
+ for (let attempt = 1; attempt <= attempts; attempt++) {
102
+ try {
103
+ await triggerRevalidation(options);
104
+ // Success path: clear any prior `revalidationFailure:<slug>` flag so
105
+ // dashboards/on-call don't see stale alerts after the next clean build.
106
+ // Best-effort — the flag has a 24h TTL so it self-clears regardless.
107
+ await clearRevalidationFailureFlag(options.projectSlug);
108
+ return;
109
+ } catch (err) {
110
+ lastError = err instanceof Error ? err : new Error(String(err));
111
+ if (attempt < attempts) {
112
+ log('warn', `${DEPLOY_FLIP_TAG} revalidation failed, retrying`, {
113
+ projectSlug: options.projectSlug,
114
+ attempt,
115
+ error: lastError.message,
116
+ });
117
+ // Linear backoff: 1s before attempt 2, 2s before attempt 3, etc.
118
+ // Bounded retry — short sleep is fine; the orchestrator already
119
+ // gates this phase with a phase-level timer.
120
+ await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
121
+ }
122
+ }
123
+ }
124
+
125
+ // All attempts exhausted — escalate.
126
+ const cause = lastError ?? new Error('Revalidation failed (unknown cause)');
127
+ log('error', `${DEPLOY_FLIP_TAG} revalidation failed permanently — stale Data Cache risk`, {
128
+ projectSlug: options.projectSlug,
129
+ error: cause.message,
130
+ });
131
+
132
+ // Best-effort flag write: if Redis is unconfigured or throws, swallow —
133
+ // the throw below is the load-bearing escalation path.
134
+ try {
135
+ if (redis) {
136
+ const payload = JSON.stringify({
137
+ at: new Date().toISOString(),
138
+ error: cause.message,
139
+ });
140
+ await redis.set(`revalidationFailure:${options.projectSlug}`, payload, {
141
+ ex: 86400, // 24h TTL — dashboards/on-call can surface within a day.
142
+ });
143
+ }
144
+ } catch (flagErr) {
145
+ log('warn', `${DEPLOY_FLIP_TAG} failed to write revalidationFailure flag (non-fatal)`, {
146
+ projectSlug: options.projectSlug,
147
+ error: flagErr instanceof Error ? flagErr.message : String(flagErr),
148
+ });
149
+ }
150
+
151
+ throw new RevalidationFailedError(options.projectSlug, cause);
152
+ }
153
+
154
+ /**
155
+ * DEL the 24h `revalidationFailure:<slug>` alert flag. Called on the
156
+ * success path so a recovery build clears the alert from a prior failure.
157
+ * Best-effort: never throws. DEL on a non-existent key is a no-op.
158
+ */
159
+ async function clearRevalidationFailureFlag(slug: string): Promise<void> {
160
+ if (!redis) return;
161
+ try {
162
+ await redis.del(`revalidationFailure:${slug}`);
163
+ } catch (err) {
164
+ log('warn', `${DEPLOY_FLIP_TAG} failed to clear revalidationFailure flag (non-fatal)`, {
165
+ projectSlug: slug,
166
+ error: err instanceof Error ? err.message : String(err),
167
+ });
168
+ }
169
+ }
170
+
59
171
  async function revalidateIsrApp(
60
172
  isrAppUrl: string,
61
173
  secret: string,
@@ -29,6 +29,7 @@ export const BUILD_WARNING_TYPES = [
29
29
  'missing_image',
30
30
  'missing_page',
31
31
  'missing_openapi_ref',
32
+ 'revalidation_failed',
32
33
  'inline_code_on_api_page',
33
34
  'invalid_openapi_spec',
34
35
  'missing_snippet',
@@ -221,6 +221,28 @@
221
221
  margin-bottom: 0.8rem;
222
222
  }
223
223
 
224
+ /* Card body typography — must apply at FIRST PAINT.
225
+ Keep in sync with the same rules in Card.tsx's styled-jsx (they exist there
226
+ so the styles travel with the vendored component; they exist here so they
227
+ apply at first paint — Next.js dev injects styled-jsx only AFTER first paint,
228
+ so card paragraphs would briefly inherit the looser .prose line-height/margins
229
+ and the card would visibly shrink on load). These bare selectors sit after
230
+ `.prose p` above, so they win by source order (margins) or specificity
231
+ (:last-child) and also cover cards rendered outside .prose. */
232
+ .card-content > p {
233
+ line-height: 1.375;
234
+ margin: 0 0 0.75rem 0;
235
+ }
236
+ .card-content > p:last-child {
237
+ margin-bottom: 0;
238
+ }
239
+ .card-content > *:first-child {
240
+ margin-top: 0;
241
+ }
242
+ .card-content > *:last-child {
243
+ margin-bottom: 0;
244
+ }
245
+
224
246
  /* Links */
225
247
  .prose a {
226
248
  color: var(--color-text-primary);
@@ -372,7 +372,7 @@ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-d
372
372
 
373
373
  /* Prose body */
374
374
  body[data-theme="jam"] .prose {
375
- font-family: var(--font-sans, 'Inter'), sans-serif;
375
+ font-family: var(--font-sans, var(--font-primary, 'Inter')), sans-serif;
376
376
  font-size: var(--prose-body-size);
377
377
  line-height: var(--prose-body-line-height);
378
378
  font-weight: var(--prose-body-weight);
@@ -382,7 +382,7 @@ body[data-theme="jam"] .prose {
382
382
  /* Prose headings */
383
383
  body[data-theme="jam"] .prose h1,
384
384
  body[data-theme="jam"] h1 {
385
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
385
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
386
386
  font-size: var(--prose-h1-size);
387
387
  line-height: var(--prose-h1-line-height);
388
388
  font-weight: var(--prose-h1-weight);
@@ -391,7 +391,7 @@ body[data-theme="jam"] h1 {
391
391
  }
392
392
 
393
393
  body[data-theme="jam"] .prose h2 {
394
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
394
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
395
395
  font-size: var(--prose-h2-size);
396
396
  line-height: var(--prose-h2-line-height);
397
397
  font-weight: var(--prose-h2-weight);
@@ -400,7 +400,7 @@ body[data-theme="jam"] .prose h2 {
400
400
  }
401
401
 
402
402
  body[data-theme="jam"] .prose h3 {
403
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
403
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
404
404
  font-size: var(--prose-h3-size);
405
405
  line-height: var(--prose-h3-line-height);
406
406
  font-weight: var(--prose-h3-weight);
@@ -409,7 +409,7 @@ body[data-theme="jam"] .prose h3 {
409
409
  }
410
410
 
411
411
  body[data-theme="jam"] .prose h4 {
412
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
412
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
413
413
  font-size: var(--prose-h4-size);
414
414
  line-height: var(--prose-h4-line-height);
415
415
  font-weight: var(--prose-h4-weight);
@@ -175,7 +175,7 @@
175
175
 
176
176
  /* Prose body */
177
177
  .prose {
178
- font-family: var(--font-sans, 'Inter'), sans-serif;
178
+ font-family: var(--font-sans, var(--font-primary, 'Inter')), sans-serif;
179
179
  font-size: var(--prose-body-size);
180
180
  line-height: var(--prose-body-line-height);
181
181
  font-weight: var(--prose-body-weight);
@@ -185,7 +185,7 @@
185
185
  /* Prose headings */
186
186
  .prose h1,
187
187
  h1 {
188
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
188
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
189
189
  font-size: var(--prose-h1-size);
190
190
  line-height: var(--prose-h1-line-height);
191
191
  font-weight: var(--prose-h1-weight);
@@ -194,7 +194,7 @@ h1 {
194
194
  }
195
195
 
196
196
  .prose h2 {
197
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
197
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
198
198
  font-size: var(--prose-h2-size);
199
199
  line-height: var(--prose-h2-line-height);
200
200
  font-weight: var(--prose-h2-weight);
@@ -203,7 +203,7 @@ h1 {
203
203
  }
204
204
 
205
205
  .prose h3 {
206
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
206
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
207
207
  font-size: var(--prose-h3-size);
208
208
  line-height: var(--prose-h3-line-height);
209
209
  font-weight: var(--prose-h3-weight);
@@ -212,7 +212,7 @@ h1 {
212
212
  }
213
213
 
214
214
  .prose h4 {
215
- font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
215
+ font-family: var(--font-heading, var(--font-sans, var(--font-primary, 'Inter'))), sans-serif;
216
216
  font-size: var(--prose-h4-size);
217
217
  line-height: var(--prose-h4-line-height);
218
218
  font-weight: var(--prose-h4-weight);