jamdesk 1.1.70 → 1.1.72

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 (103) hide show
  1. package/dist/__tests__/integration/validate.integration.test.js +21 -0
  2. package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
  3. package/dist/__tests__/unit/dev-loading-server.test.js +30 -14
  4. package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -1
  5. package/dist/__tests__/unit/extract-hooks.test.js +102 -1
  6. package/dist/__tests__/unit/extract-hooks.test.js.map +1 -1
  7. package/dist/__tests__/unit/mdx-validator.test.js +22 -0
  8. package/dist/__tests__/unit/mdx-validator.test.js.map +1 -1
  9. package/dist/__tests__/unit/migrate-mdx.test.js +121 -1
  10. package/dist/__tests__/unit/migrate-mdx.test.js.map +1 -1
  11. package/dist/__tests__/unit/relative-mdx-imports.test.d.ts +2 -0
  12. package/dist/__tests__/unit/relative-mdx-imports.test.d.ts.map +1 -0
  13. package/dist/__tests__/unit/relative-mdx-imports.test.js +452 -0
  14. package/dist/__tests__/unit/relative-mdx-imports.test.js.map +1 -0
  15. package/dist/__tests__/unit/relocate-snippets.test.d.ts +2 -0
  16. package/dist/__tests__/unit/relocate-snippets.test.d.ts.map +1 -0
  17. package/dist/__tests__/unit/relocate-snippets.test.js +542 -0
  18. package/dist/__tests__/unit/relocate-snippets.test.js.map +1 -0
  19. package/dist/__tests__/unit/run-build-script.test.js +23 -1
  20. package/dist/__tests__/unit/run-build-script.test.js.map +1 -1
  21. package/dist/__tests__/unit/vendored-sync.test.js +5 -0
  22. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  23. package/dist/__tests__/unit/warn-relative-imports.test.d.ts +2 -0
  24. package/dist/__tests__/unit/warn-relative-imports.test.d.ts.map +1 -0
  25. package/dist/__tests__/unit/warn-relative-imports.test.js +44 -0
  26. package/dist/__tests__/unit/warn-relative-imports.test.js.map +1 -0
  27. package/dist/commands/dev.d.ts.map +1 -1
  28. package/dist/commands/dev.js +16 -1
  29. package/dist/commands/dev.js.map +1 -1
  30. package/dist/commands/migrate/convert-mdx.d.ts +5 -1
  31. package/dist/commands/migrate/convert-mdx.d.ts.map +1 -1
  32. package/dist/commands/migrate/convert-mdx.js +19 -6
  33. package/dist/commands/migrate/convert-mdx.js.map +1 -1
  34. package/dist/commands/migrate/extract-hooks.d.ts +26 -0
  35. package/dist/commands/migrate/extract-hooks.d.ts.map +1 -1
  36. package/dist/commands/migrate/extract-hooks.js +71 -12
  37. package/dist/commands/migrate/extract-hooks.js.map +1 -1
  38. package/dist/commands/migrate/fix-mdx-syntax.d.ts +38 -0
  39. package/dist/commands/migrate/fix-mdx-syntax.d.ts.map +1 -0
  40. package/dist/commands/migrate/fix-mdx-syntax.js +80 -0
  41. package/dist/commands/migrate/fix-mdx-syntax.js.map +1 -0
  42. package/dist/commands/migrate/index.d.ts.map +1 -1
  43. package/dist/commands/migrate/index.js +165 -3
  44. package/dist/commands/migrate/index.js.map +1 -1
  45. package/dist/commands/migrate/relocate-snippets.d.ts +30 -0
  46. package/dist/commands/migrate/relocate-snippets.d.ts.map +1 -0
  47. package/dist/commands/migrate/relocate-snippets.js +357 -0
  48. package/dist/commands/migrate/relocate-snippets.js.map +1 -0
  49. package/dist/commands/validate.d.ts.map +1 -1
  50. package/dist/commands/validate.js +31 -1
  51. package/dist/commands/validate.js.map +1 -1
  52. package/dist/lib/deps.d.ts.map +1 -1
  53. package/dist/lib/deps.js +7 -5
  54. package/dist/lib/deps.js.map +1 -1
  55. package/dist/lib/docs-config.d.ts +1 -0
  56. package/dist/lib/docs-config.d.ts.map +1 -1
  57. package/dist/lib/docs-config.js +53 -6
  58. package/dist/lib/docs-config.js.map +1 -1
  59. package/dist/lib/mdx-syntax-fixes.d.ts +14 -0
  60. package/dist/lib/mdx-syntax-fixes.d.ts.map +1 -0
  61. package/dist/lib/mdx-syntax-fixes.js +38 -0
  62. package/dist/lib/mdx-syntax-fixes.js.map +1 -0
  63. package/dist/lib/mdx-validator.d.ts +11 -2
  64. package/dist/lib/mdx-validator.d.ts.map +1 -1
  65. package/dist/lib/mdx-validator.js +41 -4
  66. package/dist/lib/mdx-validator.js.map +1 -1
  67. package/dist/lib/relative-mdx-imports.d.ts +44 -0
  68. package/dist/lib/relative-mdx-imports.d.ts.map +1 -0
  69. package/dist/lib/relative-mdx-imports.js +145 -0
  70. package/dist/lib/relative-mdx-imports.js.map +1 -0
  71. package/dist/lib/run-build-script.d.ts.map +1 -1
  72. package/dist/lib/run-build-script.js +7 -0
  73. package/dist/lib/run-build-script.js.map +1 -1
  74. package/dist/lib/warn-relative-imports.d.ts +2 -0
  75. package/dist/lib/warn-relative-imports.d.ts.map +1 -0
  76. package/dist/lib/warn-relative-imports.js +29 -0
  77. package/dist/lib/warn-relative-imports.js.map +1 -0
  78. package/package.json +2 -2
  79. package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +118 -0
  80. package/vendored/app/(jd-system)/jd-inactive/layout.tsx +12 -0
  81. package/vendored/app/(jd-system)/jd-inactive/page.tsx +40 -0
  82. package/vendored/app/(unlock)/jd/unlock/page.tsx +32 -0
  83. package/vendored/app/[[...slug]]/page.tsx +63 -15
  84. package/vendored/app/globals.css +5 -0
  85. package/vendored/app/layout.tsx +36 -0
  86. package/vendored/components/navigation/Header.tsx +4 -2
  87. package/vendored/components/navigation/SocialFooter.tsx +4 -18
  88. package/vendored/lib/branding-url.ts +9 -0
  89. package/vendored/lib/build/error-parser.ts +26 -0
  90. package/vendored/lib/docs-isr.ts +33 -19
  91. package/vendored/lib/docs-types.ts +1 -1
  92. package/vendored/lib/email-notifier.ts +1 -1
  93. package/vendored/lib/isr-build-executor.ts +1 -1
  94. package/vendored/lib/layout-helpers.tsx +54 -2
  95. package/vendored/lib/middleware-helpers.ts +46 -8
  96. package/vendored/lib/preprocess-mdx.ts +62 -15
  97. package/vendored/lib/redis.ts +86 -0
  98. package/vendored/lib/revalidation-trigger.ts +29 -15
  99. package/vendored/lib/validate-config.ts +68 -7
  100. package/vendored/schema/docs-schema.json +1 -1
  101. package/vendored/scripts/compile-snippets.cjs +50 -7
  102. package/vendored/themes/index.ts +6 -4
  103. package/vendored/workspace-package-lock.json +118 -133
@@ -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[] = [];
@@ -518,6 +523,42 @@ export function normalizeJsxIndentation(content: string): string {
518
523
  return result.join('\n');
519
524
  }
520
525
 
526
+ /**
527
+ * Un-indent a closing JSX tag absorbed into the previous markdown list item.
528
+ *
529
+ * Distinct from `unindentJsxAfterListItems` below: that one rewrites indented
530
+ * *opening* tags inside list context. Here the opening tag is at root level
531
+ * and only the closing tag is indented after the list — markdown reads it as
532
+ * listItem continuation, so MDX compiles see the parent component as unclosed
533
+ * ("Expected the closing tag `</Card>` either after the end of `listItem`...").
534
+ *
535
+ * CLI mirror: `builder/cli/src/lib/mdx-syntax-fixes.ts` — keep regex in sync.
536
+ */
537
+ export function unindentClosingTagAfterList(content: string): string {
538
+ // Hot-path prefilter — preprocessMdx runs on every page render, and most
539
+ // MDX has no indented closing tags at all. Skip the split+walk in that case.
540
+ if (!/^\s+<\/[A-Z]/m.test(content)) return content;
541
+
542
+ const closingTagPattern = /^(\s+)(<\/[A-Z][\w-]*>)\s*$/;
543
+ const listItemPattern = /^\s*(?:[-*+]\s|\d+\.\s)/;
544
+ const lines = content.split('\n');
545
+ const out: string[] = [];
546
+ let lastNonBlank = '';
547
+
548
+ for (const line of lines) {
549
+ const closing = line.match(closingTagPattern);
550
+ if (closing && listItemPattern.test(lastNonBlank)) {
551
+ out.push(closing[2]);
552
+ lastNonBlank = closing[2];
553
+ continue;
554
+ }
555
+ out.push(line);
556
+ if (line.trim() !== '') lastNonBlank = line;
557
+ }
558
+
559
+ return out.join('\n');
560
+ }
561
+
521
562
  /**
522
563
  * Un-indent JSX blocks that follow markdown list items.
523
564
  *
@@ -997,6 +1038,12 @@ export function preprocessMdx(content: string, options?: { assetVersion?: string
997
1038
  // Convert noStyle to data-no-style for React compatibility
998
1039
  processed = convertNoStyleToDataAttr(processed);
999
1040
 
1041
+ // Un-indent absorbed closing tags FIRST — handles the case where the
1042
+ // opening tag is at root level but the closing tag got indented after a
1043
+ // list. unindentJsxAfterListItems below only catches cases where the
1044
+ // opening tag itself is indented inside list context.
1045
+ processed = unindentClosingTagAfterList(processed);
1046
+
1000
1047
  // Un-indent JSX blocks that follow list items to prevent
1001
1048
  // "Expected closing tag before end of listItem" errors
1002
1049
  // This must run BEFORE ensureBlankLinesInJsx since it changes block structure
@@ -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. */
@@ -64,6 +64,44 @@ export interface ValidationResult {
64
64
 
65
65
  type Schema = Record<string, unknown>;
66
66
 
67
+ // Migration docs URL — embedded verbatim in every Mintlify-migration error message.
68
+ // error-parser.ts pattern-matches on this string to attach the step-by-step
69
+ // migrate suggestion, so it MUST stay in the user-facing message exactly as below.
70
+ // (Sentinel doubles as the customer-facing link target.)
71
+ export const MIGRATION_DOCS_URL = 'https://jamdesk.com/docs/setup/migration';
72
+
73
+ /** Mintlify themes (Mint pre-2.0 + Themes 2.0). Used as a fallback signal when
74
+ * `$schema` has been stripped during a partial migration attempt. */
75
+ const MINTLIFY_THEMES = [
76
+ 'mint', 'maple', 'palm', 'willow', 'quill', 'aspen', 'linden', 'prism',
77
+ 'almond', 'sequoia', 'luma',
78
+ ];
79
+
80
+ /** Detect Mintlify by $schema URL — the durable signal Mintlify itself controls. */
81
+ function isMintlifyBySchema(config: unknown): boolean {
82
+ if (!config || typeof config !== 'object') return false;
83
+ const schema = (config as Record<string, unknown>).$schema;
84
+ return typeof schema === 'string' && schema.includes('mintlify.com');
85
+ }
86
+
87
+ /** Detect Mintlify by theme name (fallback when $schema is missing). */
88
+ function isMintlifyByThemeName(theme: unknown): boolean {
89
+ return typeof theme === 'string' && MINTLIFY_THEMES.includes(theme.toLowerCase());
90
+ }
91
+
92
+ /** Build the "needs migration" error result. `reason` is interpolated as the
93
+ * leading sentence so each detection branch can explain *which* signal fired. */
94
+ function mintlifyMigrationError(reason: string): ValidationResult {
95
+ return {
96
+ valid: false,
97
+ error:
98
+ `Invalid docs.json: ${reason} This project hasn't been migrated to Jamdesk yet ` +
99
+ '— run `jamdesk migrate` from your project directory to convert your config ' +
100
+ `(and Mintlify-only MDX components) automatically. See ${MIGRATION_DOCS_URL} for details.`,
101
+ errorType: 'config_error',
102
+ };
103
+ }
104
+
67
105
  // Cache the schema
68
106
  let cachedSchema: Schema | null = null;
69
107
 
@@ -267,6 +305,12 @@ export function formatValidationErrors(errors: ErrorObject[] | null | undefined)
267
305
  export async function validateConfig(configPath: string, docsPath?: string): Promise<ValidationResult> {
268
306
  // Check file exists
269
307
  if (!fs.existsSync(configPath)) {
308
+ // If `mint.json` sits where docs.json should be, the project is mid-migration
309
+ // (or hasn't started). Give the migrate hint instead of the generic "missing".
310
+ const mintPath = path.join(path.dirname(configPath), 'mint.json');
311
+ if (fs.existsSync(mintPath)) {
312
+ return mintlifyMigrationError('Found `mint.json` but no `docs.json`.');
313
+ }
270
314
  const pathHint = docsPath
271
315
  ? `Missing docs.json at '${docsPath}/docs.json'. Check that your monorepo path is correct.`
272
316
  : 'Missing docs.json configuration file. Your repository must have a docs.json file in the root directory.';
@@ -288,7 +332,15 @@ export async function validateConfig(configPath: string, docsPath?: string): Pro
288
332
  error: `Failed to parse docs.json: ${error.message}. Ensure your JSON is valid (tip: use JSON5 format to allow comments).`,
289
333
  };
290
334
  }
291
-
335
+
336
+ // Mintlify-shaped configs trip every structural rule below (their
337
+ // `navigation` is an array, anchors are required keys, etc.). Detect via
338
+ // $schema BEFORE structural validation so customers see "run jamdesk migrate"
339
+ // instead of "Array of groups is not supported".
340
+ if (isMintlifyBySchema(config)) {
341
+ return mintlifyMigrationError('Mintlify `$schema` detected.');
342
+ }
343
+
292
344
  // Basic validation (always runs, even without schema)
293
345
  if (!config.name) {
294
346
  return {
@@ -327,13 +379,22 @@ export async function validateConfig(configPath: string, docsPath?: string): Pro
327
379
  };
328
380
  }
329
381
 
330
- // Validate theme
382
+ // Validate theme (case-insensitive — "MINT" still falls through to the Mintlify branch below).
331
383
  const validThemes = ['jam', 'nebula', 'pulsar'];
332
- if (config.theme && !validThemes.includes(config.theme)) {
333
- return {
334
- valid: false,
335
- error: `Invalid docs.json: Theme "${config.theme}" is not supported. Valid themes are: ${validThemes.join(', ')}.`,
336
- };
384
+ if (typeof config.theme === 'string') {
385
+ const themeLower = config.theme.toLowerCase();
386
+ if (!validThemes.includes(themeLower)) {
387
+ if (isMintlifyByThemeName(config.theme)) {
388
+ return mintlifyMigrationError(`Theme "${config.theme}" looks like a Mintlify theme.`);
389
+ }
390
+ return {
391
+ valid: false,
392
+ error: `Invalid docs.json: Theme "${config.theme}" is not supported. Valid themes are: ${validThemes.join(', ')}.`,
393
+ errorType: 'config_error',
394
+ };
395
+ }
396
+ // Normalize so downstream Ajv schema (lowercase enum) accepts it.
397
+ config.theme = themeLower as 'jam' | 'nebula' | 'pulsar';
337
398
  }
338
399
 
339
400
  if (legacyAnchors) {
@@ -1577,7 +1577,7 @@
1577
1577
  },
1578
1578
  "hint": {
1579
1579
  "type": "string",
1580
- "description": "Optional hint shown on the unlock page (e.g., \"Ask your account manager\"). Plain text, no HTML.",
1580
+ "description": "Optional hint shown on the unlock page (e.g., \"Ask #docs-access on Slack\"). Plain text, no HTML.",
1581
1581
  "maxLength": 200
1582
1582
  },
1583
1583
  "public": {
@@ -321,6 +321,22 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
321
321
 
322
322
  const snippetFiles = findSnippetFiles(snippetsDir);
323
323
 
324
+ // Deterministic order: within a (dir, baseName) collision, process .mdx
325
+ // first so the .tsx/.jsx code file overwrites the wrapped-MDX output on
326
+ // disk. Without this, fs.readdir order varies by platform and the
327
+ // surviving file content is non-deterministic. Outside of collisions,
328
+ // the order doesn't matter — sort makes it stable.
329
+ const EXT_PRIORITY = { '.mdx': 0, '.md': 1, '.ts': 2, '.js': 3, '.tsx': 4, '.jsx': 5 };
330
+ snippetFiles.sort((a, b) => {
331
+ const aDir = path.join(a.dir, '');
332
+ const bDir = path.join(b.dir, '');
333
+ if (aDir !== bDir) return aDir < bDir ? -1 : 1;
334
+ const aBase = a.file.replace(/\.[^.]+$/, '');
335
+ const bBase = b.file.replace(/\.[^.]+$/, '');
336
+ if (aBase !== bBase) return aBase < bBase ? -1 : 1;
337
+ return (EXT_PRIORITY[path.extname(a.file)] ?? 99) - (EXT_PRIORITY[path.extname(b.file)] ?? 99);
338
+ });
339
+
324
340
  if (snippetFiles.length === 0) {
325
341
  console.log(' No snippet files found');
326
342
 
@@ -376,7 +392,8 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
376
392
  name: exportName,
377
393
  dir: dir,
378
394
  fileName,
379
- needsClient
395
+ needsClient,
396
+ sourceFile: dir ? path.join(dir, file) : file,
380
397
  });
381
398
 
382
399
  const status = written ? '(updated)' : '(unchanged)';
@@ -399,7 +416,8 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
399
416
  name: baseName,
400
417
  dir: dir,
401
418
  fileName,
402
- needsClient
419
+ needsClient,
420
+ sourceFile: dir ? path.join(dir, file) : file,
403
421
  });
404
422
 
405
423
  const status = written ? '(updated)' : '(unchanged)';
@@ -414,8 +432,33 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
414
432
  // For locale-specific snippets (e.g., es/Counter), we export under prefixed name (EsCounter).
415
433
  // The buildSnippetAliasMap function handles mapping import aliases to the correct component.
416
434
 
435
+ // Dedupe by (dir, name): two source files with the same base name (e.g.
436
+ // `vercel-json-generator.mdx` + `vercel-json-generator.tsx`) compile to the
437
+ // same generated fileName. The on-disk file is correctly overwritten by
438
+ // the last writer (sorted above so code files win), but componentInfo
439
+ // would otherwise carry both entries and produce duplicate import/export
440
+ // lines in ProjectSnippets.tsx. Keep the last (= the code file).
441
+ const seenComponentKeys = new Map();
442
+ for (const c of componentInfo) {
443
+ const key = `${c.dir}|${c.name}`;
444
+ if (seenComponentKeys.has(key)) {
445
+ const prior = seenComponentKeys.get(key);
446
+ // stderr (not stdout) so `runBuildScript` surfaces this in non-verbose
447
+ // mode — without it, the `jamdesk dev` user sees the collision is
448
+ // silently resolved and the next collision (a different name) crashes
449
+ // Turbopack with no breadcrumb.
450
+ console.warn(
451
+ ` ⚠ Snippet name collision: "${c.name}" defined in both ` +
452
+ `${prior.sourceFile} and ${c.sourceFile} — using ${c.sourceFile}. ` +
453
+ `Remove or rename one of these files to clear this warning.`,
454
+ );
455
+ }
456
+ seenComponentKeys.set(key, c);
457
+ }
458
+ const dedupedComponentInfo = [...seenComponentKeys.values()];
459
+
417
460
  // Build unique import names for each component (prefixed with dir if needed)
418
- const importInfo = componentInfo.map(c => {
461
+ const importInfo = dedupedComponentInfo.map(c => {
419
462
  const prefix = c.dir ? toPascalCase(c.dir.replace(/\//g, '-')) : '';
420
463
  const uniqueName = prefix + c.name.charAt(0).toUpperCase() + c.name.slice(1);
421
464
  // Strip the .tsx extension from fileName to get the import path
@@ -468,17 +511,17 @@ ${importInfo.map(c => ` ${c.uniqueName},`).join('\n')}
468
511
  }
469
512
  }
470
513
 
471
- const clientCount = componentInfo.filter(c => c.needsClient).length;
472
- const serverCount = componentInfo.length - clientCount;
514
+ const clientCount = dedupedComponentInfo.filter(c => c.needsClient).length;
515
+ const serverCount = dedupedComponentInfo.length - clientCount;
473
516
 
474
517
  console.log(`\n✅ Generated snippets:`);
475
518
  console.log(` Index: ${outputPath} ${indexWritten ? '(updated)' : '(unchanged)'}`);
476
- console.log(` Components: ${generatedDir}/ (${componentInfo.length} files)`);
519
+ console.log(` Components: ${generatedDir}/ (${dedupedComponentInfo.length} files)`);
477
520
  if (removedCount > 0) {
478
521
  console.log(` Removed: ${removedCount} stale file(s)`);
479
522
  }
480
523
  console.log(` Client components: ${clientCount}, Server components: ${serverCount}`);
481
- console.log(` Exports: ${componentInfo.map(c => c.name).join(', ')}\n`);
524
+ console.log(` Exports: ${dedupedComponentInfo.map(c => c.name).join(', ')}\n`);
482
525
  }
483
526
 
484
527
  // Run the compilation