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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.70",
3
+ "version": "1.1.72",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -115,7 +115,7 @@
115
115
  "nspell": "^2.1.5",
116
116
  "open": "^11.0.0",
117
117
  "ora": "^9.4.0",
118
- "tar": "^7.5.9"
118
+ "tar": "^7.5.14"
119
119
  },
120
120
  "devDependencies": {
121
121
  "@mdx-js/mdx": "^3.1.1",
@@ -0,0 +1,118 @@
1
+ import React from "react";
2
+
3
+ export interface BrandedInactiveProps {
4
+ projectName?: string;
5
+ logoUrl?: string;
6
+ hasCustomDomain?: boolean;
7
+ }
8
+
9
+ export function BrandedInactive({
10
+ projectName, logoUrl, hasCustomDomain = false,
11
+ }: BrandedInactiveProps): React.ReactElement {
12
+ const showBrandMark = Boolean(logoUrl || projectName);
13
+ return (
14
+ <div style={styles.shell}>
15
+ <main style={styles.main}>
16
+ <div style={styles.card}>
17
+ {showBrandMark ? (
18
+ <div style={styles.brandMark}>
19
+ {logoUrl ? (
20
+ <img
21
+ src={logoUrl}
22
+ alt={projectName ?? ""}
23
+ width={120}
24
+ height={32}
25
+ style={styles.logo}
26
+ />
27
+ ) : null}
28
+ {projectName ? (
29
+ <span style={styles.projectName}>{projectName}</span>
30
+ ) : null}
31
+ </div>
32
+ ) : null}
33
+ <h1 style={styles.heading}>This site is currently paused</h1>
34
+ <p style={styles.body}>
35
+ The owner needs to reactivate their subscription to bring
36
+ this documentation back online.
37
+ </p>
38
+ {hasCustomDomain ? (
39
+ <p style={styles.bodySubtle}>
40
+ If you&rsquo;re the owner, sign in to restore access.
41
+ </p>
42
+ ) : null}
43
+ </div>
44
+ </main>
45
+ <footer style={styles.footer}>
46
+ <a href="https://jamdesk.com" style={styles.poweredBy}>
47
+ Powered by{" "}
48
+ <strong style={styles.brand} translate="no">Jamdesk</strong>
49
+ </a>
50
+ </footer>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ const styles: Record<string, React.CSSProperties> = {
56
+ shell: {
57
+ minHeight: "100vh",
58
+ display: "grid",
59
+ gridTemplateRows: "1fr auto",
60
+ background:
61
+ "radial-gradient(ellipse 80% 50% at 50% 30%, " +
62
+ "rgba(255,54,33,0.07), transparent 70%), #0a0a0d",
63
+ color: "#e8e8ee",
64
+ fontFamily:
65
+ "'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
66
+ padding:
67
+ "env(safe-area-inset-top) env(safe-area-inset-right) " +
68
+ "env(safe-area-inset-bottom) env(safe-area-inset-left)",
69
+ },
70
+ main: {
71
+ display: "flex", flexDirection: "column",
72
+ alignItems: "center", justifyContent: "center",
73
+ padding: "2rem", textAlign: "center",
74
+ },
75
+ card: {maxWidth: "32rem", width: "100%"},
76
+ brandMark: {
77
+ display: "inline-flex", alignItems: "center",
78
+ gap: "0.75rem", marginBottom: "1.25rem",
79
+ },
80
+ logo: {
81
+ height: "auto", maxHeight: "2rem", maxWidth: "8rem",
82
+ display: "block", opacity: 0.95,
83
+ },
84
+ projectName: {
85
+ fontSize: "clamp(1.75rem, 4.5vw, 2.25rem)",
86
+ fontWeight: 600, color: "#e8e8ee",
87
+ lineHeight: 1.2,
88
+ letterSpacing: "-0.01em",
89
+ textWrap: "balance",
90
+ },
91
+ heading: {
92
+ fontSize: "1rem",
93
+ fontWeight: 400, lineHeight: 1.6,
94
+ margin: "0 0 0.5rem",
95
+ color: "#e8e8ee",
96
+ textWrap: "balance",
97
+ },
98
+ body: {
99
+ fontSize: "1rem", color: "#b4b4bf",
100
+ lineHeight: 1.6, margin: "0 0 0.75rem",
101
+ textWrap: "pretty",
102
+ },
103
+ bodySubtle: {
104
+ fontSize: "0.875rem", color: "#8a8a96",
105
+ margin: 0, textWrap: "pretty",
106
+ },
107
+ footer: {
108
+ display: "flex", justifyContent: "center",
109
+ padding: "1.5rem",
110
+ },
111
+ poweredBy: {
112
+ color: "#6a6a76",
113
+ fontSize: "0.8125rem",
114
+ textDecoration: "none",
115
+ letterSpacing: "0.02em",
116
+ },
117
+ brand: {color: "#ff3621", fontWeight: 600},
118
+ };
@@ -0,0 +1,12 @@
1
+ import type {Metadata} from "next";
2
+
3
+ // Root layout's `x-jd-layout: placeholder` branch owns the shell.
4
+ export const metadata: Metadata = {
5
+ title: "Docs Site Unavailable",
6
+ };
7
+
8
+ export default function InactiveLayout(
9
+ {children}: {children: React.ReactNode},
10
+ ) {
11
+ return children;
12
+ }
@@ -0,0 +1,40 @@
1
+ import {headers} from "next/headers";
2
+ import {notFound} from "next/navigation";
3
+ import {isUrlSafe} from "@/lib/url-safety";
4
+ import {BrandedInactive} from "./BrandedInactive";
5
+
6
+ // Read project data from middleware-set headers per-request. We can't
7
+ // use force-static because the proxy sets different headers for each
8
+ // paused project (name, logo, custom-domain flag). The response is
9
+ // still trivially cacheable at the edge with a short TTL.
10
+ export const dynamic = "force-dynamic";
11
+
12
+ export default async function InactivePage() {
13
+ const h = await headers();
14
+ // Direct hits to /jd-inactive (without a proxy rewrite) must 404 rather
15
+ // than render the generic placeholder. Otherwise anyone could probe
16
+ // <slug>.jamdesk.app/jd-inactive and learn whether the slug is hosted
17
+ // here, plus see the "site paused" copy on slugs that are actually
18
+ // healthy. The proxy always sets x-jd-layout=placeholder when it
19
+ // rewrites for the inactive flag — its absence means we got here from
20
+ // the user typing the URL.
21
+ if (h.get("x-jd-layout") !== "placeholder") {
22
+ notFound();
23
+ }
24
+ const projectName = h.get("x-jd-project-name") ?? undefined;
25
+ // Logo URL is customer-supplied (flows from Redis through middleware
26
+ // headers). isUrlSafe blocks data:/javascript:, plus SSRF vectors
27
+ // (private IPs, encoded IPv4 bypasses, IPv6 loopback) — same threat
28
+ // model as the API-playground proxy uses. Mirror in proxy/lib/
29
+ // inactive-html.ts via sanitizeLogoUrl.
30
+ const rawLogo = h.get("x-jd-project-logo");
31
+ const logoUrl = rawLogo && isUrlSafe(rawLogo) ? rawLogo : undefined;
32
+ const hasCustomDomain = h.get("x-jd-custom-domain") === "1";
33
+ return (
34
+ <BrandedInactive
35
+ projectName={projectName}
36
+ logoUrl={logoUrl}
37
+ hasCustomDomain={hasCustomDomain}
38
+ />
39
+ );
40
+ }
@@ -2,6 +2,7 @@ import { headers } from 'next/headers';
2
2
  import { getDocsConfig } from '@/lib/docs-isr';
3
3
  import { normalizeLogo } from '@/lib/docs-types';
4
4
  import { sanitizeFrom } from '@/lib/sanitize-from';
5
+ import { getBrandingUrl } from '@/lib/branding-url';
5
6
  import UnlockForm from './UnlockForm';
6
7
 
7
8
  interface UnlockPageProps {
@@ -29,14 +30,19 @@ export default async function UnlockPage({ searchParams }: UnlockPageProps) {
29
30
  const from = sanitizeFrom(rawFrom ?? null);
30
31
  const showError = params.error === '1';
31
32
 
33
+ const showBranding = process.env.NEXT_PUBLIC_SHOW_BRANDING !== 'false';
34
+ const brandingUrl = getBrandingUrl(slug);
35
+
32
36
  return (
33
37
  <main
34
38
  style={{
35
39
  minHeight: '100vh',
36
40
  display: 'flex',
41
+ flexDirection: 'column',
37
42
  alignItems: 'center',
38
43
  justifyContent: 'center',
39
44
  padding: '24px 16px',
45
+ gap: 24,
40
46
  }}
41
47
  >
42
48
  <section
@@ -152,6 +158,32 @@ export default async function UnlockPage({ searchParams }: UnlockPageProps) {
152
158
  </p>
153
159
  )}
154
160
  </section>
161
+
162
+ {showBranding && (
163
+ <a
164
+ href={brandingUrl}
165
+ target="_blank"
166
+ rel="noopener noreferrer"
167
+ style={{
168
+ display: 'inline-flex',
169
+ alignItems: 'center',
170
+ gap: 6,
171
+ fontSize: 13,
172
+ color: '#9ca3af',
173
+ textDecoration: 'none',
174
+ }}
175
+ >
176
+ Powered by
177
+ {/* eslint-disable-next-line @next/next/no-img-element */}
178
+ <img
179
+ src="/_jd/branding/jamdesk-wordmark.svg"
180
+ alt="Jamdesk"
181
+ width={58}
182
+ height={15}
183
+ style={{ display: 'inline-block', verticalAlign: 'middle' }}
184
+ />
185
+ </a>
186
+ )}
155
187
  </main>
156
188
  );
157
189
  }
@@ -25,54 +25,102 @@ interface PageProps {
25
25
  }>;
26
26
  }
27
27
 
28
- function getAllDocPaths(): string[] {
28
+ // Mirror of the CLI's detector regex in cli/src/lib/relative-mdx-imports.ts.
29
+ // Duplicated intentionally — build-service uses bundler module resolution
30
+ // and shouldn't reach into cli/src/. Keep the regex itself in sync with
31
+ // that file. Two intentional simplifications vs the CLI version:
32
+ // - no `g` flag (boolean test, not iteration)
33
+ // - fence stripping replaces matches with empty string instead of
34
+ // blank lines of equal count — line numbers don't matter here, only
35
+ // a yes/no skip decision (the CLI preserves them for warning text).
36
+ const PARENT_RELATIVE_MDX_IMPORT_RE =
37
+ /^[ \t]*import\s+(?:type\s+)?[\w$*{}, \n\r]+\s+from\s+["']\.{1,2}\/[^"']+\.mdx["']\s*;?/m;
38
+
39
+ const FENCED_CODE_BLOCK_RE =
40
+ /^( *)(```+|~~~+)[^\n]*\n([\s\S]*?)\n\1\2\s*$/gm;
41
+
42
+ export function pageHasRelativeMdxImport(filePath: string): boolean {
43
+ try {
44
+ // Read the full file — MDX imports can appear at any top-level position
45
+ // (after a long prose intro or table of contents), and a slice was
46
+ // missing real imports past byte ~8192 in 70-94 KB customer pages.
47
+ const content = fs.readFileSync(filePath, 'utf-8');
48
+ // Strip fenced code blocks so documentation examples (e.g.
49
+ // ```mdx\nimport X from "../snippets/foo.mdx";\n```) don't false-trigger.
50
+ return PARENT_RELATIVE_MDX_IMPORT_RE.test(content.replace(FENCED_CODE_BLOCK_RE, ''));
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ interface CollectedPaths {
57
+ supported: string[];
58
+ skipped: string[];
59
+ }
60
+
61
+ function getAllDocPaths(): CollectedPaths {
29
62
  const contentDir = getContentDir();
30
- const paths: string[] = [];
63
+ const supported: string[] = [];
64
+ const skipped: string[] = [];
31
65
 
32
66
  function traverseDir(dir: string, basePath: string = '') {
33
67
  if (!fs.existsSync(dir)) return;
34
68
  const files = fs.readdirSync(dir);
35
69
  for (const file of files) {
36
- // Skip dotfiles (.claude, .git, etc.)
37
70
  if (file.startsWith('.')) continue;
38
71
  const filePath = path.join(dir, file);
39
72
  let stat;
40
73
  try {
41
74
  stat = fs.statSync(filePath);
42
75
  } catch {
43
- // Broken symlink or inaccessible — skip
44
76
  continue;
45
77
  }
46
78
  if (stat.isDirectory()) {
47
79
  traverseDir(filePath, path.join(basePath, file));
48
80
  } else if (file.endsWith('.mdx')) {
49
81
  const slug = path.join(basePath, file.replace(/\.mdx$/, ''));
50
- paths.push(slug);
82
+ if (pageHasRelativeMdxImport(filePath)) {
83
+ skipped.push(slug);
84
+ } else {
85
+ supported.push(slug);
86
+ }
51
87
  }
52
88
  }
53
89
  }
54
90
 
55
91
  traverseDir(contentDir);
56
- return paths;
92
+ return { supported, skipped };
57
93
  }
58
94
 
95
+ // In `jamdesk dev`, Next.js calls generateStaticParams during initial
96
+ // compile AND for each lazily-compiled route (every page click). Without
97
+ // a guard the same multi-line warning floods the dev console after every
98
+ // navigation. Cache the prior skipped set and re-log only when it changes
99
+ // — that way a user who fixes one import still sees the list shrink, but
100
+ // repeated calls with no change stay quiet.
101
+ let lastSkippedKey: string | null = null;
102
+
59
103
  export async function generateStaticParams() {
60
104
  // ISR: pages generated on-demand, no build-time pre-render.
61
105
  if (isIsrMode()) return [];
62
106
 
63
- const paths = getAllDocPaths();
64
- // next-mdx-remote can't compile relative MDX imports — skip those tests.
65
- const unsupportedPatterns = ['deep-relative-test', 'relative-snippets-test'];
107
+ const { supported, skipped } = getAllDocPaths();
66
108
 
67
- const supportedPaths = paths.filter(p => {
68
- const skip = unsupportedPatterns.some(pattern => p.includes(pattern));
69
- if (skip) console.log(`[Build] Skipping page with unsupported relative imports: ${p}`);
70
- return !skip;
71
- });
109
+ const skippedKey = skipped.length === 0 ? '' : skipped.slice().sort().join('\n');
110
+ if (skippedKey !== lastSkippedKey) {
111
+ lastSkippedKey = skippedKey;
112
+ if (skipped.length > 0) {
113
+ console.log(`[Build] Skipped ${skipped.length} page(s) with parent-relative MDX imports:`);
114
+ for (const slug of skipped) {
115
+ console.log(` - ${slug}`);
116
+ }
117
+ console.log(' Run "jamdesk migrate" to auto-fix moveable imports, or "jamdesk validate" for per-import detail.');
118
+ }
119
+ }
72
120
 
73
121
  return [
74
122
  { slug: [] },
75
- ...supportedPaths.map((p) => ({ slug: p.split('/') })),
123
+ ...supported.map((p) => ({ slug: p.split('/') })),
76
124
  ];
77
125
  }
78
126
 
@@ -34,4 +34,9 @@ img.inline-block { display: inline-block !important; }
34
34
  .dark img.dark\:inline, .dark .dark\:inline { display: inline !important; }
35
35
  .dark img.dark\:inline-block, .dark .dark\:inline-block { display: inline-block !important; }
36
36
 
37
+ /* SSR scroll lock — see lib/layout-helpers.tsx scrollLockBootstrap. */
38
+ html[data-scroll-locked] #content-scroll-container {
39
+ overflow-y: hidden !important;
40
+ }
41
+
37
42
  /* Shiki handles syntax highlighting via CSS variables - no theme import needed */
@@ -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
 
@@ -3,19 +3,9 @@
3
3
  import Image from 'next/image';
4
4
  import type { DocsConfig, SocialPlatform, FooterLinkColumn } from '@/lib/docs-types';
5
5
  import { getIconClass } from '@/lib/icon-utils';
6
+ import { getBrandingUrl } from '@/lib/branding-url';
6
7
 
7
- // Branding configuration (read at build time from environment variables)
8
- // Uses NEXT_PUBLIC_ prefix so these are inlined during build for client components
9
- const showBranding = process.env.NEXT_PUBLIC_SHOW_BRANDING !== 'false'; // Default true
10
-
11
- /**
12
- * Generate branding URL with UTM parameters for attribution tracking.
13
- * projectSlug is passed as a prop (works in ISR multi-tenant mode).
14
- * Falls back to NEXT_PUBLIC_PROJECT_SLUG env var (works in CLI dev mode).
15
- */
16
- function getBrandingUrl(projectSlug: string): string {
17
- return `https://www.jamdesk.com?utm_campaign=poweredBy&utm_medium=referral&utm_source=${projectSlug}`;
18
- }
8
+ const showBranding = process.env.NEXT_PUBLIC_SHOW_BRANDING !== 'false';
19
9
 
20
10
  interface SocialFooterProps {
21
11
  config: DocsConfig;
@@ -150,23 +140,19 @@ export function SocialFooter({ config, hidden, projectSlug }: SocialFooterProps)
150
140
  return null;
151
141
  }
152
142
 
153
- // Resolve slug: prop (ISR) → env var (CLI dev) → fallback
154
- const slug = projectSlug || process.env.NEXT_PUBLIC_PROJECT_SLUG || 'docs';
155
-
156
143
  return (
157
144
  <footer className="mt-12 sm:mt-16 pt-6 sm:pt-8 border-t border-[var(--color-border)]">
158
145
  {hasLinks && <LinkColumns columns={links} />}
159
- {/* Desktop: row layout, Mobile: column layout */}
160
146
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
161
147
  {hasSocials && <SocialIcons socials={socials} />}
162
148
  {showBranding && (
163
149
  <a
164
- href={getBrandingUrl(slug)}
150
+ href={getBrandingUrl(projectSlug)}
165
151
  target="_blank"
166
152
  rel="noopener noreferrer"
167
153
  className="group flex items-center gap-1 text-sm text-[#AEAEAE] hover:text-[#6A6D70] transition-colors whitespace-nowrap"
168
154
  >
169
- <span>Powered by</span>
155
+ Powered by
170
156
  <span className="relative inline-block w-[58px] h-[15px] top-[1px]">
171
157
  <Image
172
158
  src="/_jd/branding/jamdesk-wordmark.svg"
@@ -0,0 +1,9 @@
1
+ // `NEXT_PUBLIC_PROJECT_SLUG` is read inline so Next.js inlines the value at build
2
+ // time for client components (SocialFooter). Hoisting the read would defeat the
3
+ // inline. Server components (the unlock page) just see it as runtime env.
4
+ export function getBrandingUrl(projectSlug?: string | null): string {
5
+ const slug = encodeURIComponent(
6
+ projectSlug || process.env.NEXT_PUBLIC_PROJECT_SLUG || 'docs'
7
+ );
8
+ return `https://www.jamdesk.com?utm_campaign=poweredBy&utm_medium=referral&utm_source=${slug}`;
9
+ }
@@ -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