jamdesk 1.1.156 → 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
+ }
@@ -84,6 +84,7 @@ import {
84
84
  type AuthMethod,
85
85
  } from '@/lib/openapi';
86
86
  import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
87
+ import { findOperation, deriveOperationDescription, collectLocalSpecPaths } from '@/lib/openapi/operation-description';
87
88
  import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
88
89
  import { findFirstNavPage } from '@/lib/find-first-nav-page';
89
90
  import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
@@ -210,6 +211,47 @@ function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
210
211
  return normalizedSlug;
211
212
  }
212
213
 
214
+ /**
215
+ * Resolve a page `<meta>`/og description from its OpenAPI operation when the
216
+ * page has no frontmatter `description`. Pure: the spec loader is injected so
217
+ * this is unit-testable independent of the render path (and so `buildDocMetadata`
218
+ * can hand it the SAME cached loader the renderer uses — no extra spec fetch).
219
+ *
220
+ * `loadSpec` MUST be a cached loader (see `buildDocMetadata` wiring): docs
221
+ * routes are `force-dynamic`, so this runs on every page view; an uncached
222
+ * fetch+parse here would add an R2 round-trip per render. Remote (`http(s)://`)
223
+ * spec entries are dropped — they are fetched by the renderer, never resolvable
224
+ * as a meta-time read. Returns null on any unresolvable/unparseable input so the
225
+ * caller degrades to `generateAutoDescription`.
226
+ */
227
+ export async function resolveOpenApiMetaDescription(
228
+ openapiRef: string,
229
+ specPaths: string[],
230
+ loadSpec: (specPath: string) => Promise<unknown | null>,
231
+ ): Promise<string | null> {
232
+ const local = specPaths.filter((p) => !/^https?:\/\//i.test(p));
233
+ if (local.length === 0) return null;
234
+ let parsed;
235
+ try {
236
+ parsed = parseOpenApiFrontmatter(openapiRef, local);
237
+ } catch {
238
+ return null;
239
+ }
240
+ // Full-format ref carries its own spec path; short-format must try each
241
+ // configured spec until the operation resolves.
242
+ const candidates = parsed.isShortFormat ? local : [parsed.specPath];
243
+ for (const sp of candidates) {
244
+ if (!sp) continue;
245
+ const spec = await loadSpec(sp);
246
+ if (!spec) continue;
247
+ const op = findOperation(spec, parsed.method, parsed.path);
248
+ if (!op) continue;
249
+ const d = deriveOperationDescription(op);
250
+ if (d) return d;
251
+ }
252
+ return null;
253
+ }
254
+
213
255
  export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
214
256
  const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
215
257
 
@@ -252,7 +294,44 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
252
294
  const data = parsed.data as FrontmatterData;
253
295
 
254
296
  if (!data.description) {
255
- data.description = generateAutoDescription(parsed.content);
297
+ if (typeof data.openapi === 'string' && data.openapi && config.api?.openapi) {
298
+ // Source the description from the OpenAPI operation before falling back to
299
+ // first-paragraph extraction (which is empty for prose-less API pages).
300
+ // The spec loader REUSES the render path's cached loaders — the ISR module
301
+ // cache (`r2:${slug}:${specPath}`, 10-min TTL) that `renderDocPage`
302
+ // populates when it renders the page's <ApiEndpoint>, or the static
303
+ // `getCachedSpec`. So the spec is the same warm parse the render already
304
+ // did: the marginal cost of this lookup is a cache hit, never an uncached
305
+ // fetch+parse. That matters because docs routes are force-dynamic, so this
306
+ // metadata runs on EVERY page view (every human + crawler). Mirrors the
307
+ // `useIsr`/`projectDir` derivation in `renderDocPage`'s OpenAPI branch.
308
+ const specPaths = collectLocalSpecPaths(config.api.openapi);
309
+ const useIsr = isIsrMode() && !!projectSlug;
310
+ const projectDir = useIsr ? null : getContentDir();
311
+ const loadSpecForMeta = async (sp: string): Promise<unknown | null> => {
312
+ try {
313
+ if (useIsr && projectSlug) {
314
+ const { resolveOpenApiSpec } = await import('@/lib/openapi-isr');
315
+ return await resolveOpenApiSpec(projectSlug, sp);
316
+ }
317
+ if (projectDir) {
318
+ const { api } = await getCachedSpec(sp, projectDir);
319
+ return api;
320
+ }
321
+ return null;
322
+ } catch {
323
+ // Any load/parse error degrades to generateAutoDescription below.
324
+ return null;
325
+ }
326
+ };
327
+ const sourced = await resolveOpenApiMetaDescription(
328
+ data.openapi, specPaths, loadSpecForMeta,
329
+ );
330
+ if (sourced) data.description = sourced;
331
+ }
332
+ if (!data.description) {
333
+ data.description = generateAutoDescription(parsed.content);
334
+ }
256
335
  }
257
336
 
258
337
  const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
@@ -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,
@@ -436,14 +436,20 @@ export interface RawPageInfo {
436
436
  * @param pages - Array of raw page info
437
437
  * @returns Array of page metadata
438
438
  */
439
- export function extractPageMetadata(pages: RawPageInfo[]): PageMetadata[] {
439
+ export function extractPageMetadata(
440
+ pages: RawPageInfo[],
441
+ sourced?: Map<string, {description: string}>,
442
+ ): PageMetadata[] {
440
443
  return pages.map(page => {
441
444
  const pathWithoutExt = page.path.replace(/\.mdx?$/, '');
442
445
 
443
446
  return {
444
447
  path: pathWithoutExt,
445
448
  title: page.frontmatter.title || pathWithoutExt,
446
- description: page.frontmatter.description,
449
+ // Frontmatter wins; else fall back to a description sourced from the
450
+ // page's OpenAPI operation (keyed by the extension-ful page.path).
451
+ description:
452
+ page.frontmatter.description ?? sourced?.get(page.path)?.description,
447
453
  noindex: page.frontmatter.noindex || page.frontmatter.seo?.noindex,
448
454
  hidden: page.frontmatter.hidden,
449
455
  lastModified: page.frontmatter.lastModified,
@@ -1,14 +1,20 @@
1
1
  import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
2
+ import { MIN_FRONTMATTER_DESCRIPTION_CHARS } from './openapi/operation-description.js';
2
3
 
3
4
  const MIN_DESCRIPTION_CHARS = 110; // matches the Ahrefs "too short" floor used elsewhere
5
+ // Task 1 invariant: MIN_DESCRIPTION_CHARS must equal MIN_FRONTMATTER_DESCRIPTION_CHARS.
6
+ // Asserted by test. Do NOT change either value without updating both.
4
7
 
5
8
  interface PageInfo {
6
9
  path: string;
7
- frontmatter: { title?: unknown; description?: unknown; hidden?: unknown };
10
+ frontmatter: { title?: unknown; description?: unknown; hidden?: unknown; openapi?: unknown };
8
11
  content: string;
9
12
  }
10
13
 
11
- export function validatePageFrontmatter(pages: PageInfo[]): BuildWarning[] {
14
+ export function validatePageFrontmatter(
15
+ pages: PageInfo[],
16
+ sourced?: Map<string, { description: string; length: number }>,
17
+ ): BuildWarning[] {
12
18
  const warnings: BuildWarning[] = [];
13
19
  for (const page of pages) {
14
20
  const fm = page.frontmatter ?? {};
@@ -21,7 +27,37 @@ export function validatePageFrontmatter(pages: PageInfo[]): BuildWarning[] {
21
27
  warnings.push({ type: 'page_missing_title', file: page.path, message: 'Page has no frontmatter title; navigation and llms.txt fall back to the file path.' });
22
28
  }
23
29
  if (!description) {
24
- warnings.push({ type: 'page_missing_description', file: page.path, message: 'Page has no frontmatter description; add one for SEO and llms.txt.' });
30
+ const src = sourced?.get(page.path);
31
+ if (src) {
32
+ if (src.length >= MIN_FRONTMATTER_DESCRIPTION_CHARS) {
33
+ // Cat 1: OpenAPI-sourced description is long enough for Fix-with-AI to write
34
+ // into frontmatter — keep the warning but re-message it toward that action.
35
+ warnings.push({
36
+ type: 'page_missing_description',
37
+ file: page.path,
38
+ message: "No frontmatter description — your page's description is auto-sourced " +
39
+ "from your OpenAPI spec. Use Fix with AI to write it into the page for portability.",
40
+ });
41
+ } else {
42
+ // Cat 2: sourced text IS live in <meta>/llms, but too short to commit via
43
+ // Fix-with-AI. Do NOT suppress — the author still needs a longer description.
44
+ // A sourced-short description must warn no less than an authored-short one
45
+ // (else `summary: "TODO"` silently becomes the live meta + OG card with zero
46
+ // signal to fix it).
47
+ warnings.push({
48
+ type: 'page_missing_description',
49
+ file: page.path,
50
+ message:
51
+ `Description auto-sourced from your OpenAPI spec is only ` +
52
+ `${src.length} chars — add a longer frontmatter description ` +
53
+ `(aim for ${MIN_FRONTMATTER_DESCRIPTION_CHARS}–160; too short ` +
54
+ `for Fix with AI to write into the page).`,
55
+ });
56
+ }
57
+ } else {
58
+ // Cat 3: genuinely missing, nothing to source from the spec.
59
+ warnings.push({ type: 'page_missing_description', file: page.path, message: 'Page has no frontmatter description; add one for SEO and llms.txt.' });
60
+ }
25
61
  } else if (description.length < MIN_DESCRIPTION_CHARS) {
26
62
  warnings.push({ type: 'page_short_description', file: page.path, message: `Description is ${description.length} chars; aim for ${MIN_DESCRIPTION_CHARS}–160.` });
27
63
  }
@@ -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);