jamdesk 1.1.71 → 1.1.73

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.
Files changed (32) hide show
  1. package/dist/__tests__/unit/dev-loading-server.test.js +30 -14
  2. package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -1
  3. package/dist/__tests__/unit/vendored-sync.test.js +12 -0
  4. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  5. package/dist/lib/deps.d.ts.map +1 -1
  6. package/dist/lib/deps.js +7 -5
  7. package/dist/lib/deps.js.map +1 -1
  8. package/dist/lib/docs-config.d.ts +1 -0
  9. package/dist/lib/docs-config.d.ts.map +1 -1
  10. package/dist/lib/docs-config.js +53 -6
  11. package/dist/lib/docs-config.js.map +1 -1
  12. package/package.json +2 -2
  13. package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +118 -0
  14. package/vendored/app/(jd-system)/jd-inactive/layout.tsx +12 -0
  15. package/vendored/app/(jd-system)/jd-inactive/page.tsx +40 -0
  16. package/vendored/app/globals.css +5 -0
  17. package/vendored/app/layout.tsx +36 -0
  18. package/vendored/components/navigation/Header.tsx +4 -2
  19. package/vendored/lib/build/error-parser.ts +26 -0
  20. package/vendored/lib/docs-isr.ts +33 -19
  21. package/vendored/lib/docs-types.ts +1 -1
  22. package/vendored/lib/email-notifier.ts +1 -1
  23. package/vendored/lib/isr-build-executor.ts +1 -1
  24. package/vendored/lib/layout-helpers.tsx +54 -2
  25. package/vendored/lib/middleware-helpers.ts +46 -8
  26. package/vendored/lib/preprocess-mdx.ts +20 -15
  27. package/vendored/lib/redis.ts +86 -0
  28. package/vendored/lib/revalidation-trigger.ts +29 -15
  29. package/vendored/lib/validate-config.ts +68 -7
  30. package/vendored/schema/docs-schema.json +1 -1
  31. package/vendored/themes/index.ts +6 -4
  32. package/vendored/workspace-package-lock.json +115 -130
@@ -23,6 +23,15 @@ import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
23
23
  import { DocsChrome, getLocalFileContent } from '@/lib/layout-helpers';
24
24
 
25
25
  export async function generateMetadata(): Promise<Metadata> {
26
+ // The placeholder shell would otherwise render with the paused
27
+ // project's title/favicon (and pay an R2 round-trip for it) — the
28
+ // nested (jd-system)/jd-inactive layout supplies neutral metadata.
29
+ if (isIsrMode()) {
30
+ const placeholderCheck = await headers();
31
+ if (placeholderCheck.get('x-jd-layout') === 'placeholder') {
32
+ return {robots: {index: false, follow: false}};
33
+ }
34
+ }
26
35
  let config: DocsConfig;
27
36
  if (isIsrMode()) {
28
37
  const headersList = await headers();
@@ -58,6 +67,33 @@ export default async function RootLayout({
58
67
  }) {
59
68
  const headersList = await headers();
60
69
 
70
+ // Placeholder short-circuit: proxy sets x-jd-layout=placeholder when
71
+ // rewriting an inactive site to /jd-inactive. Skip docs chrome (would
72
+ // otherwise leak the customer's nav, search, sidebar around our
73
+ // paused-site placeholder) and render a minimal dark shell.
74
+ if (headersList.get('x-jd-layout') === 'placeholder') {
75
+ return (
76
+ <html lang="en" suppressHydrationWarning>
77
+ <head>
78
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
79
+ <meta name="robots" content="noindex, nofollow" />
80
+ <meta name="theme-color" content="#0a0a0d" />
81
+ </head>
82
+ <body
83
+ style={{
84
+ margin: 0,
85
+ minHeight: '100vh',
86
+ backgroundColor: '#0a0a0d',
87
+ colorScheme: 'dark',
88
+ }}
89
+ suppressHydrationWarning
90
+ >
91
+ {children}
92
+ </body>
93
+ </html>
94
+ );
95
+ }
96
+
61
97
  // Unlock-mode short-circuit: middleware sets x-jd-unlock-mode when
62
98
  // rewriting to /jd/unlock. Skip docs chrome — the unlock page renders
63
99
  // its own minimal shell via app/(unlock)/jd/unlock/page.tsx.
@@ -59,8 +59,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
59
59
  // Determine effective tabsPosition (same logic as Sidebar)
60
60
  const themeConfig = getTheme(config.theme);
61
61
 
62
- // Nebula theme uses compact search (icon only) instead of full search bar
63
- const useCompactSearch = config.theme === 'nebula';
62
+ // Nebula theme uses compact search (icon only) instead of full search bar.
63
+ // Read from `themeConfig.name` (canonical-case from registry) rather than
64
+ // `config.theme` so uppercase docs.json values like "NEBULA" still match.
65
+ const useCompactSearch = themeConfig.name === 'nebula';
64
66
  const effectiveTabsPosition: TabsPosition = config.tabsPosition || tabsPositionProp || themeConfig.defaultTabsPosition;
65
67
  const showTabsInHeader = effectiveTabsPosition === 'top';
66
68
 
@@ -1,5 +1,6 @@
1
1
  import type { ErrorDetails } from '../../shared/types.js';
2
2
  import { DEPRECATED_COMPONENTS } from '../deprecated-components.js';
3
+ import { MIGRATION_DOCS_URL } from '../validate-config.js';
3
4
 
4
5
  /** Format: ERR-XXXXXXXX (first 8 chars of buildId, uppercase) */
5
6
  export function generateErrorRef(buildId: string): string {
@@ -108,6 +109,31 @@ export function parseErrorDetails(
108
109
  // Extract error source information upfront - used by multiple error types
109
110
  const errorSource = extractErrorSource(output, pageToFileMap);
110
111
 
112
+ // Mintlify migration needed — must come before the generic config_error branch
113
+ // so the migrate-specific suggestion wins. We match on the migration docs URL
114
+ // (which every Mintlify-detection branch in validate-config embeds) rather than
115
+ // a prose phrase, so future copy edits can't silently break the contract.
116
+ if (message.includes(MIGRATION_DOCS_URL)) {
117
+ return {
118
+ type: 'config_error',
119
+ message: 'Mintlify config detected — migration needed',
120
+ details: message,
121
+ suggestion:
122
+ 'Your docs.json is still configured for Mintlify. Jamdesk includes a ' +
123
+ 'tool that converts Mintlify projects automatically.\n\n' +
124
+ 'From your project\'s root directory, run:\n\n' +
125
+ ' npm install -g jamdesk\n' +
126
+ ' jamdesk migrate\n\n' +
127
+ 'The migration will:\n' +
128
+ '• Convert mint.json / docs.json to the Jamdesk format\n' +
129
+ '• Update the theme to "jam"\n' +
130
+ '• Convert Mintlify-only MDX components (e.g. <CardGroup> → <Columns>)\n' +
131
+ '• Rewrite parent-relative snippet imports\n\n' +
132
+ 'Then commit the changes and push — the next build will pick them up.\n\n' +
133
+ 'Full guide: https://jamdesk.com/docs/setup/migration',
134
+ };
135
+ }
136
+
111
137
  // Configuration validation errors (from validate phase)
112
138
  if (message.includes('Missing docs.json') || message.includes('Invalid docs.json')) {
113
139
  return {
@@ -12,6 +12,7 @@
12
12
 
13
13
  import fs from 'fs';
14
14
  import path from 'path';
15
+ import { cache } from 'react';
15
16
  import {
16
17
  fetchDocsConfig,
17
18
  fetchMdxContent,
@@ -72,17 +73,19 @@ function walkMdx(dir: string): string[] {
72
73
  return out;
73
74
  }
74
75
 
75
- /**
76
- * Get docs.json configuration for a project.
77
- *
78
- * In ISR mode, fetches from R2 with in-memory caching (1 minute TTL).
79
- * Throws an error if the project doesn't exist.
80
- *
81
- * @param projectSlug - The project identifier (e.g., 'acme')
82
- * @returns Parsed docs.json configuration
83
- * @throws Error if project doesn't exist in R2
84
- */
85
- export async function getDocsConfig(projectSlug: string): Promise<DocsConfig> {
76
+ // React cache() — request-scoped memoization. Layered on top of Next.js
77
+ // Data Cache (which fetchDocsConfig already wraps via unstable_cache) so
78
+ // repeated calls within the same render pass don't re-enter the Data Cache
79
+ // either. Production fra1 logs showed 3 R2 fetches per request from
80
+ // layout.generateMetadata + layout render + content-loader.getConfig when
81
+ // they all hit a Data Cache miss simultaneously (fresh region/cold cache),
82
+ // each pays a cross-continent round-trip (~300ms each). cache() collapses
83
+ // these to one in-flight promise.
84
+ //
85
+ // Caveat: cache() is documented to dedupe across generateMetadata + page
86
+ // render in the same request, but historical Next.js bugs (#50080, #67133)
87
+ // occasionally split contexts. In the worst case this still reduces 3→2.
88
+ async function getDocsConfigUncached(projectSlug: string): Promise<DocsConfig> {
86
89
  requireIsrMode();
87
90
  const config = await fetchDocsConfig(projectSlug);
88
91
  if (config) return config;
@@ -99,6 +102,14 @@ export async function getDocsConfig(projectSlug: string): Promise<DocsConfig> {
99
102
  throw new Error(`Project not found in R2: ${projectSlug}`);
100
103
  }
101
104
 
105
+ /**
106
+ * Get docs.json configuration for a project.
107
+ *
108
+ * Request-scoped (React cache) on top of Next.js Data Cache. Same projectSlug
109
+ * within one render → one R2 fetch.
110
+ */
111
+ export const getDocsConfig = cache(getDocsConfigUncached);
112
+
102
113
  /**
103
114
  * Get all document paths for a project.
104
115
  *
@@ -114,14 +125,7 @@ export async function getAllDocPaths(projectSlug: string): Promise<string[]> {
114
125
  return listAllPaths(projectSlug);
115
126
  }
116
127
 
117
- /**
118
- * Get raw MDX content for a page.
119
- *
120
- * @param projectSlug - The project identifier
121
- * @param pagePath - Path to the page (e.g., 'api/auth')
122
- * @returns Raw MDX content string
123
- */
124
- export async function getMdxContent(
128
+ async function getMdxContentUncached(
125
129
  projectSlug: string,
126
130
  pagePath: string
127
131
  ): Promise<string> {
@@ -141,6 +145,16 @@ export async function getMdxContent(
141
145
  }
142
146
  }
143
147
 
148
+ /**
149
+ * Get raw MDX content for a page.
150
+ *
151
+ * Request-scoped (React cache) so generateMetadata's frontmatter read and
152
+ * the subsequent page render share one R2 fetch instead of paying it twice.
153
+ * Production fra1 logs showed every page making 2× fetchMdxContent calls
154
+ * for the same path (~600ms duplicated work per request).
155
+ */
156
+ export const getMdxContent = cache(getMdxContentUncached);
157
+
144
158
  /**
145
159
  * Get snippet content.
146
160
  *
@@ -758,7 +758,7 @@ export interface SpellcheckConfig {
758
758
  export interface PasswordAuthConfig {
759
759
  /** Opt in to password protection. Site is only gated when this is true AND a password has been set in the dashboard. */
760
760
  enabled?: boolean;
761
- /** Optional hint shown on the unlock page (e.g., "Ask your account manager"). Plain text, no HTML. */
761
+ /** Optional hint shown on the unlock page (e.g., "Ask #docs-access on Slack"). Plain text, no HTML. */
762
762
  hint?: string;
763
763
  /** Paths or globs that bypass the password check. Supports '*' (one path segment) and '**' (recursive). */
764
764
  public?: string[];
@@ -38,7 +38,7 @@ export async function sendInternalBuildFailureEmail(info: BuildFailureEmailInfo)
38
38
  const result = await resend.emails.send({
39
39
  from: 'Jamdesk <no-reply@mail.jamdesk.com>',
40
40
  to: reportEmail,
41
- subject: `[Jamdesk] Build Failed: ${info.projectName || info.projectId}`,
41
+ subject: `Jamdesk build failed: ${info.projectName || info.projectId}`,
42
42
  html,
43
43
  text,
44
44
  });
@@ -294,7 +294,7 @@ export const ISR_PHASES = {
294
294
  optimize_images: { label: 'Optimizing images...', weight: 5 },
295
295
  r2_upload: { label: 'Uploading to CDN...', weight: 35 },
296
296
  embeddings: { label: 'Indexing AI search + chat...', weight: 5 },
297
- vercel_purge: { label: 'Refreshing cache...', weight: 20 },
297
+ vercel_purge: { label: 'Refreshing cache...', weight: 10 },
298
298
  cleanup: { label: 'Cleaning up...', weight: 5 },
299
299
  } as const;
300
300
 
@@ -26,6 +26,31 @@ import { LinkPrefixProvider } from '@/lib/link-prefix-context';
26
26
  import { ProjectSlugProvider } from '@/lib/project-slug-context';
27
27
  import { getAnalyticsScript } from '@/lib/analytics-script';
28
28
 
29
+ const scrollLockBootstrap = `
30
+ (function() {
31
+ try {
32
+ if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
33
+ var unlocked = false;
34
+ function unlock() {
35
+ if (unlocked) return;
36
+ unlocked = true;
37
+ var el = document.getElementById('content-scroll-container');
38
+ if (el) el.scrollTop = 0;
39
+ document.documentElement.removeAttribute('data-scroll-locked');
40
+ }
41
+ function ready() {
42
+ requestAnimationFrame(function() { requestAnimationFrame(unlock); });
43
+ }
44
+ if (document.readyState === 'loading') {
45
+ document.addEventListener('DOMContentLoaded', ready, { once: true });
46
+ } else {
47
+ ready();
48
+ }
49
+ setTimeout(unlock, 2000);
50
+ } catch (e) {}
51
+ })();
52
+ `;
53
+
29
54
  // Pre-load fonts at module scope — Next.js requires this for static analysis.
30
55
  export const inter = Inter({
31
56
  subsets: ['latin'],
@@ -216,7 +241,10 @@ export async function DocsChrome({
216
241
  customJs,
217
242
  children,
218
243
  }: DocsChromeProps): Promise<React.ReactElement> {
219
- const themeName = config.theme as ThemeName | undefined;
244
+ // Lowercase to match docs-config canonical case `data-theme="nebula"` CSS
245
+ // selectors are case-sensitive, so `theme: "NEBULA"` from disk would silently
246
+ // skip every theme-scoped rule without this normalization.
247
+ const themeName = (config.theme?.toLowerCase() as ThemeName | undefined) ?? undefined;
220
248
  const themeCss = themeName && themeName !== 'jam' ? getThemeCssContent(themeName) : null;
221
249
  const fontClassName = getFontClassName(themeName, config.fonts);
222
250
 
@@ -259,8 +287,32 @@ export async function DocsChrome({
259
287
  preload('/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
260
288
 
261
289
  return (
262
- <html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth">
290
+ <html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
263
291
  <head>
292
+ {/*
293
+ SSR scroll lock — prevents Chrome's same-tab cross-origin "preserve
294
+ scroll" heuristic from scrolling #content-scroll-container on first
295
+ paint. Custom inner-scroll layout (body{overflow:hidden} +
296
+ #content-scroll-container{overflow-y:auto}) means body.scrollY=0 but
297
+ the inner container inherits the previous origin's scroll ratio.
298
+ history.scrollRestoration='manual' doesn't suppress this code path.
299
+ Solution: ship overflow-y:hidden on the container via a CSS rule
300
+ gated on html[data-scroll-locked], release it on DOMContentLoaded +
301
+ 2 frames so Chrome's restore attempts hit a structurally
302
+ non-scrollable element (scrollTop pinned to 0).
303
+ */}
304
+ <script
305
+ dangerouslySetInnerHTML={{
306
+ __html: scrollLockBootstrap,
307
+ }}
308
+ />
309
+ {/* JS-off fallback — bootstrap can't unlock, so override the rule. */}
310
+ <noscript
311
+ dangerouslySetInnerHTML={{
312
+ __html:
313
+ '<style>html[data-scroll-locked] #content-scroll-container{overflow-y:auto !important;}</style>',
314
+ }}
315
+ />
264
316
  <meta name="viewport" content="width=device-width, initial-scale=1" />
265
317
  {config.fonts && (
266
318
  <>
@@ -13,7 +13,7 @@ import {
13
13
  getProjectConfig,
14
14
  isSubdomain,
15
15
  } from './project-resolver';
16
- import { redis } from './redis';
16
+ import { redis, getProjectInactive } from './redis';
17
17
  import { getForwardedHosts, isJamdeskDomain } from './domain-helpers';
18
18
  import { getRedirects, matchRedirect, mergeQueryStrings, isInvalidDestination } from './redirect-matcher';
19
19
  import { ASSET_PREFIX } from './docs-types';
@@ -46,6 +46,20 @@ export interface ProjectResolutionResult {
46
46
  skip?: boolean;
47
47
  /** Domain verification status */
48
48
  domainStatus?: string;
49
+ /**
50
+ * Project owner's subscription is inactive — proxy rewrites to the
51
+ * `/jd-inactive` placeholder instead of serving real content. Set by
52
+ * Stripe webhook propagation (canceled/past_due/incomplete_expired)
53
+ * and cleared on successful payment.
54
+ */
55
+ inactive?: boolean;
56
+ /**
57
+ * True when the project resolved via the custom-domain branch
58
+ * (i.e., a user's own domain pointing at our CNAME), vs via the
59
+ * subdomain branch (slug.jamdesk.app). Used by the branded inactive
60
+ * page to show owner-sign-in hints only to real customers.
61
+ */
62
+ customDomain?: boolean;
49
63
  }
50
64
 
51
65
  /**
@@ -127,24 +141,33 @@ export async function handleProjectResolution(
127
141
  const customForwardedHost = forwardedHosts.find(h => !isJamdeskDomain(h));
128
142
 
129
143
  if (customForwardedHost) {
130
- // Resolve hostAtDocs from custom domain config
131
- const customResolution = await resolveCustomDomain(customForwardedHost);
144
+ // Resolve hostAtDocs from custom domain config (batched with
145
+ // inactive flag both are Redis reads off the resolved slug).
146
+ const [customResolution, inactive] = await Promise.all([
147
+ resolveCustomDomain(customForwardedHost),
148
+ getProjectInactive(projectSlug),
149
+ ]);
132
150
  log('info', 'Project resolved from subdomain with forwarded custom domain', {
133
151
  hostname,
134
152
  projectSlug,
135
153
  forwardedHost: customForwardedHost,
136
154
  hostAtDocs: customResolution.hostAtDocs,
155
+ inactive,
137
156
  });
138
157
  return {
139
158
  projectSlug,
140
159
  hostAtDocs: customResolution.hostAtDocs,
160
+ inactive,
141
161
  };
142
162
  }
143
163
 
144
- // No forwarded custom domain - use project config
145
- const config = await getProjectConfig(projectSlug);
146
- log('info', 'Project resolved from subdomain', { hostname, projectSlug });
147
- return { projectSlug, hostAtDocs: config.hostAtDocs };
164
+ // No forwarded custom domain batch config + inactive lookup.
165
+ const [config, inactive] = await Promise.all([
166
+ getProjectConfig(projectSlug),
167
+ getProjectInactive(projectSlug),
168
+ ]);
169
+ log('info', 'Project resolved from subdomain', { hostname, projectSlug, inactive });
170
+ return { projectSlug, hostAtDocs: config.hostAtDocs, inactive };
148
171
  }
149
172
  }
150
173
 
@@ -171,11 +194,17 @@ export async function handleProjectResolution(
171
194
  };
172
195
  }
173
196
 
174
- log('info', 'Project resolved from custom domain', { hostname, projectSlug: resolution.projectSlug });
197
+ // Piggyback inactive read on the custom-domain resolution path. Single
198
+ // extra Redis GET per request; the proxy reads .inactive to rewrite.
199
+ const inactive = await getProjectInactive(resolution.projectSlug);
200
+
201
+ log('info', 'Project resolved from custom domain', { hostname, projectSlug: resolution.projectSlug, inactive });
175
202
  return {
176
203
  projectSlug: resolution.projectSlug,
177
204
  hostAtDocs: resolution.hostAtDocs,
178
205
  domainStatus: resolution.domainStatus,
206
+ inactive,
207
+ customDomain: true,
179
208
  };
180
209
  }
181
210
 
@@ -558,6 +587,15 @@ const TRUSTED_PROXY_HEADERS = [
558
587
  'x-host-at-docs',
559
588
  'x-jd-language',
560
589
  'x-jd-unlock-mode',
590
+ // Placeholder branch — set by the inactive rewrite. The layout
591
+ // gate (`x-jd-layout: placeholder`) controls whether the chrome is
592
+ // skipped, and a forged `x-jd-custom-domain: 1` would let an
593
+ // attacker on a subdomain ALSO toggle the "owner can sign in"
594
+ // copy. Strip them all from the inbound request.
595
+ 'x-jd-layout',
596
+ 'x-jd-custom-domain',
597
+ 'x-jd-project-name',
598
+ 'x-jd-project-logo',
561
599
  ] as const;
562
600
 
563
601
  /**
@@ -39,31 +39,37 @@ const JSX_CONTENT_COMPONENTS = [
39
39
  'Visibility',
40
40
  ] as const;
41
41
 
42
+ // Match a fenced code block (``` or ~~~), used to mask code-block content
43
+ // before scanning for top-level snippet imports — otherwise import lines
44
+ // inside docs-on-snippets code examples are treated as real imports and
45
+ // trigger 5+ failing R2 fetches per page render.
46
+ const FENCED_CODE_BLOCK_RE =
47
+ /^( *)(```+|~~~+)[^\n]*\n([\s\S]*?)\n\1\2\s*$/gm;
48
+
42
49
  /**
43
50
  * Strip snippet import statements from MDX content.
44
51
  *
45
- * Removes import statements that reference the /snippets/ directory:
46
- * - import { Component } from "/snippets/file.mdx"
47
- * - import { Component } from '../snippets/file.mdx'
48
- * - import Component from "/snippets/file.mdx"
49
- *
50
- * Note: Relative MDX imports (./file.mdx, ../folder/file.mdx) that DON'T
51
- * reference /snippets/ are NOT stripped. These need to be handled by MDX
52
- * compiler or will fail at build time with a helpful error.
52
+ * Imports inside fenced code blocks are preserved (documentation examples).
53
53
  *
54
54
  * @param content - Raw MDX content
55
55
  * @returns MDX content with snippet imports removed
56
56
  */
57
57
  export function stripSnippetImports(content: string): string {
58
- // Only strip imports from /snippets/ directory
59
58
  const snippetsPattern = /^import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?["'](?:\.\.\/)*\/?snippets\/[^"']+["'];?\s*$/gm;
60
-
61
- return content.replace(snippetsPattern, '');
59
+ const codeBlocks: string[] = [];
60
+ const masked = content.replace(FENCED_CODE_BLOCK_RE, (match) => {
61
+ const idx = codeBlocks.length;
62
+ codeBlocks.push(match);
63
+ return '\0CB' + idx + '\0';
64
+ });
65
+ const stripped = masked.replace(snippetsPattern, '');
66
+ return stripped.replace(/\0CB(\d+)\0/g, (_, idx) => codeBlocks[Number(idx)]);
62
67
  }
63
68
 
64
69
  /**
65
70
  * Extract snippet import information from MDX content.
66
- * Only matches imports from /snippets/ directory.
71
+ *
72
+ * Imports inside fenced code blocks are ignored (documentation examples).
67
73
  *
68
74
  * @param content - Raw MDX content
69
75
  * @returns Array of import info objects
@@ -74,12 +80,11 @@ export function extractSnippetImports(content: string): Array<{
74
80
  path: string;
75
81
  }> {
76
82
  const results: Array<{ statement: string; imports: string[]; path: string }> = [];
77
-
78
- // Match import statements from /snippets/
83
+ const scanContent = content.replace(FENCED_CODE_BLOCK_RE, '');
79
84
  const importPattern = /^(import\s+(?:(?:\{([^}]*)\}|(\w+)|\*\s+as\s+(\w+))\s+from\s+)?["']((?:\.\.\/)*\/?snippets\/[^"']+)["'];?\s*)$/gm;
80
85
 
81
86
  let match;
82
- while ((match = importPattern.exec(content)) !== null) {
87
+ while ((match = importPattern.exec(scanContent)) !== null) {
83
88
  const [, statement, namedImports, defaultImport, namespaceImport, importPath] = match;
84
89
 
85
90
  const imports: string[] = [];
@@ -115,3 +115,89 @@ export async function deleteDomainAuthSecret(
115
115
  if (!kvUrl || !kvToken) return;
116
116
  await upstashCommand(kvUrl, kvToken, 'DEL', `domainAuthSecret:${hostname}`);
117
117
  }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Project inactive flag.
121
+ // Set when the project owner's subscription is canceled, past_due, or
122
+ // incomplete_expired. Read by builder/proxy middleware to serve the
123
+ // inactive placeholder instead of the real site. No TTL — the flag lives
124
+ // until explicitly cleared by a successful payment webhook.
125
+ // Fails open (returns false) both when Redis is not configured AND when
126
+ // the underlying GET throws (network blip, Upstash outage). Availability
127
+ // wins over the inactive-site gate — a flaky Redis shouldn't 500 every
128
+ // request on every docs site.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const PROJECT_INACTIVE_PREFIX = 'projectInactive:';
132
+
133
+ // Key shape: projectInactive:<slug>. The project ID is NOT the edge
134
+ // key — callers with a projectId must resolve via the dashboard's
135
+ // `writeProjectInactiveBySlug` helper first.
136
+ export async function getProjectInactive(slug: string): Promise<boolean> {
137
+ if (!redis) return false;
138
+ try {
139
+ // Upstash SDK auto-parses the body as JSON, so the stored string
140
+ // "1" comes back as the number 1. Accept either to keep the gate
141
+ // honest regardless of how the value round-trips.
142
+ const value = await redis.get<string | number>(
143
+ `${PROJECT_INACTIVE_PREFIX}${slug}`,
144
+ );
145
+ return value === '1' || value === 1;
146
+ } catch (err) {
147
+ console.warn('[redis] getProjectInactive failed, failing open:', {
148
+ slug,
149
+ error: (err as Error).message,
150
+ });
151
+ return false;
152
+ }
153
+ }
154
+
155
+ export async function setProjectInactive(
156
+ slug: string,
157
+ inactive: boolean,
158
+ ): Promise<void> {
159
+ if (!redis) return;
160
+ const key = `${PROJECT_INACTIVE_PREFIX}${slug}`;
161
+ if (inactive) {
162
+ await redis.set(key, '1');
163
+ } else {
164
+ await redis.del(key);
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Project meta (display name + logo) for the inactive placeholder.
170
+ // Mirrored to Redis at the same write site as projectInactive so the edge
171
+ // brand mark renders without a Firestore or R2 round trip. Lifecycle is
172
+ // 1:1 with projectInactive — written when the flag is set, deleted when
173
+ // it's cleared. Optional logoUrl tracks docs.json `logo` (single string
174
+ // only; theme-variant maps degrade to the name-only brand mark).
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const PROJECT_META_PREFIX = 'projectMeta:';
178
+
179
+ export interface ProjectMeta {
180
+ name: string;
181
+ logoUrl?: string;
182
+ }
183
+
184
+ export async function getProjectMeta(
185
+ slug: string,
186
+ ): Promise<ProjectMeta | null> {
187
+ if (!redis) return null;
188
+ try {
189
+ const value = await redis.get<ProjectMeta>(
190
+ `${PROJECT_META_PREFIX}${slug}`,
191
+ );
192
+ if (!value || typeof value.name !== 'string' || !value.name) {
193
+ return null;
194
+ }
195
+ return value;
196
+ } catch (err) {
197
+ console.warn('[redis] getProjectMeta failed, returning null:', {
198
+ slug,
199
+ error: (err as Error).message,
200
+ });
201
+ return null;
202
+ }
203
+ }
@@ -19,7 +19,13 @@ export interface RevalidationOptions {
19
19
  export async function triggerRevalidation(options: RevalidationOptions): Promise<void> {
20
20
  const { projectSlug, changedPaths, all } = options;
21
21
 
22
- const isrAppUrl = process.env.ISR_APP_URL || process.env.VERCEL_DEPLOYMENT_URL || 'https://docs.jamdesk.app';
22
+ // Fail loud if unset the previous default `https://docs.jamdesk.app` is an
23
+ // orphan domain whose cert silently expired (May 2026 incident); falling back
24
+ // to it produced "fetch failed" with no clue to the cause.
25
+ const isrAppUrl = (process.env.ISR_APP_URL || process.env.VERCEL_DEPLOYMENT_URL || '').trim();
26
+ if (!isrAppUrl) {
27
+ throw new Error('Revalidation failed: ISR_APP_URL not configured');
28
+ }
23
29
  const secret = (process.env.REVALIDATE_SECRET || '').trim();
24
30
 
25
31
  const body: Record<string, unknown> = {
@@ -55,22 +61,30 @@ async function revalidateIsrApp(
55
61
  secret: string,
56
62
  body: Record<string, unknown>,
57
63
  ): Promise<{ ok: boolean; status?: number; error?: string; revalidated?: string[] }> {
58
- const response = await fetch(`${isrAppUrl}/api/revalidate`, {
59
- method: 'POST',
60
- headers: {
61
- 'Content-Type': 'application/json',
62
- 'x-revalidate-secret': secret,
63
- },
64
- body: JSON.stringify(body),
65
- });
64
+ // Wrap fetch in try/catch so network-level failures (DNS, TLS, ECONNREFUSED)
65
+ // surface through the same { ok: false } path as HTTP errors. Without this,
66
+ // a raw `TypeError: fetch failed` escapes to the build error handler with no
67
+ // hint that revalidation was the failing step (May 2026 incident).
68
+ try {
69
+ const response = await fetch(`${isrAppUrl}/api/revalidate`, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ 'x-revalidate-secret': secret,
74
+ },
75
+ body: JSON.stringify(body),
76
+ });
66
77
 
67
- if (!response.ok) {
68
- const error = await response.text();
69
- return { ok: false, status: response.status, error };
70
- }
78
+ if (!response.ok) {
79
+ const error = await response.text();
80
+ return { ok: false, status: response.status, error };
81
+ }
71
82
 
72
- const result = (await response.json()) as { success: boolean; revalidated?: string[] };
73
- return { ok: true, revalidated: result.revalidated };
83
+ const result = (await response.json()) as { success: boolean; revalidated?: string[] };
84
+ return { ok: true, revalidated: result.revalidated };
85
+ } catch (err) {
86
+ return { ok: false, error: `${(err as Error).message} (url=${isrAppUrl})` };
87
+ }
74
88
  }
75
89
 
76
90
  /** Purge the proxy's Vercel CDN cache. Non-fatal on failure. */