jamdesk 1.1.49 → 1.1.50
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.
- package/dist/__tests__/unit/deps-sync.test.js +9 -5
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/(unlock)/layout.tsx +43 -0
- package/vendored/app/[[...slug]]/layout.tsx +119 -0
- package/vendored/app/[[...slug]]/page.tsx +56 -751
- package/vendored/app/layout.tsx +11 -606
- package/vendored/app/not-found.tsx +27 -43
- package/vendored/components/AIActionsMenu.tsx +1 -1
- package/vendored/components/FontAwesomeLoader.tsx +7 -11
- package/vendored/components/HtmlLangSync.tsx +1 -1
- package/vendored/components/errors/NotFoundContent.tsx +5 -1
- package/vendored/components/layout/LayoutWrapper.tsx +5 -21
- package/vendored/components/layout/PageColumns.tsx +24 -1
- package/vendored/components/navigation/Header.tsx +1 -1
- package/vendored/components/navigation/LanguageSelector.tsx +2 -2
- package/vendored/components/navigation/Sidebar.tsx +38 -9
- package/vendored/components/navigation/TabsNav.tsx +1 -1
- package/vendored/components/search/SearchModal.tsx +1 -1
- package/vendored/lib/layout-helpers.tsx +464 -0
- package/vendored/lib/middleware-helpers.ts +0 -78
- package/vendored/lib/page-isr-helpers.ts +16 -0
- package/vendored/lib/project-resolver.ts +28 -1
- package/vendored/lib/r2-content.ts +8 -0
- package/vendored/lib/render-doc-page.tsx +595 -0
- package/vendored/lib/seo.ts +21 -0
- package/vendored/workspace-package-lock.json +7 -7
package/vendored/app/layout.tsx
CHANGED
|
@@ -1,611 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import { JdReadySentinel } from '@/components/JdReadySentinel';
|
|
12
|
-
import { HtmlLangSync } from '@/components/HtmlLangSync';
|
|
13
|
-
import { FA_CSS_HREF } from '@/lib/font-awesome';
|
|
14
|
-
import { getDocsConfig } from '@/lib/docs';
|
|
15
|
-
import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
|
|
16
|
-
import {
|
|
17
|
-
isIsrMode,
|
|
18
|
-
getProjectFromRequest,
|
|
19
|
-
getHostAtDocs,
|
|
20
|
-
getLanguageFromRequest,
|
|
21
|
-
} from '@/lib/page-isr-helpers';
|
|
22
|
-
import { isRTLLanguage, resolveLanguageWithFallback } from '@/lib/language-utils';
|
|
23
|
-
import { getTheme, type ThemeName } from '@/themes';
|
|
24
|
-
import { generateFontImports, generateFontVariables, isPreloadedFont, getPrimaryFontFamily } from '@/lib/fonts';
|
|
25
|
-
import fs from 'fs';
|
|
26
|
-
import path from 'path';
|
|
27
|
-
import type { DocsConfig, Favicon, FontConfig } from '@/lib/docs-types';
|
|
28
|
-
import { transformConfigImagePath } from '@/lib/docs-types';
|
|
29
|
-
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
30
|
-
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
31
|
-
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
32
|
-
import { buildSiteTitle } from '@/lib/seo';
|
|
33
|
-
import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
34
|
-
|
|
35
|
-
// Pre-load fonts - Next.js will tree-shake unused ones
|
|
36
|
-
const inter = Inter({
|
|
37
|
-
subsets: ['latin'],
|
|
38
|
-
display: 'swap',
|
|
39
|
-
variable: '--font-primary',
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const jetbrainsMono = JetBrains_Mono({
|
|
43
|
-
subsets: ['latin'],
|
|
44
|
-
display: 'swap',
|
|
45
|
-
variable: '--font-mono',
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Get favicon path from config, routing it through the /_jd/ asset pipeline.
|
|
50
|
-
* Returns undefined when no favicon is configured — no default Jamdesk
|
|
51
|
-
* favicon is injected, so customers without a favicon get no icon.
|
|
52
|
-
*/
|
|
53
|
-
function getFaviconPath(favicon: Favicon | undefined, assetVersion?: string): string | undefined {
|
|
54
|
-
if (!favicon) return undefined;
|
|
55
|
-
const raw = typeof favicon === 'string' ? favicon : favicon.light;
|
|
56
|
-
return transformConfigImagePath(raw, assetVersion) || undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const FALLBACK_METADATA: Metadata = {
|
|
60
|
-
title: {
|
|
61
|
-
template: '%s — Documentation',
|
|
62
|
-
default: 'Documentation',
|
|
63
|
-
},
|
|
64
|
-
description: 'Documentation',
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export async function generateMetadata(): Promise<Metadata> {
|
|
68
|
-
// Get config - from R2 in ISR mode, from filesystem in static mode
|
|
69
|
-
let config: DocsConfig;
|
|
70
|
-
|
|
71
|
-
if (isIsrMode()) {
|
|
72
|
-
const headersList = await headers();
|
|
73
|
-
const projectSlug = getProjectFromRequest(headersList);
|
|
74
|
-
if (projectSlug) {
|
|
75
|
-
try {
|
|
76
|
-
config = await getIsrDocsConfig(projectSlug);
|
|
77
|
-
} catch {
|
|
78
|
-
return FALLBACK_METADATA;
|
|
79
|
-
}
|
|
80
|
-
} else {
|
|
81
|
-
return FALLBACK_METADATA;
|
|
82
|
-
}
|
|
83
|
-
} else {
|
|
84
|
-
config = getDocsConfig();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const faviconPath = getFaviconPath(config.favicon, config.assetVersion);
|
|
88
|
-
return {
|
|
89
|
-
title: {
|
|
90
|
-
template: `%s — ${config.name}`,
|
|
91
|
-
default: buildSiteTitle(config.name),
|
|
92
|
-
},
|
|
93
|
-
description: config.description || `Documentation for ${config.name}`,
|
|
94
|
-
...(faviconPath && { icons: { icon: faviconPath } }),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Read a file from public/ directory for inline injection
|
|
99
|
-
function getLocalFileContent(filename: string): string | null {
|
|
100
|
-
try {
|
|
101
|
-
const filePath = path.join(process.cwd(), 'public', filename);
|
|
102
|
-
if (fs.existsSync(filePath)) {
|
|
103
|
-
return fs.readFileSync(filePath, 'utf8');
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Ignore errors
|
|
107
|
-
}
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Read theme CSS at build time
|
|
112
|
-
function getThemeCssContent(themeName: ThemeName | undefined): string | null {
|
|
113
|
-
try {
|
|
114
|
-
const theme = getTheme(themeName);
|
|
115
|
-
const themeCssPath = path.join(process.cwd(), 'themes', theme.cssPath);
|
|
116
|
-
if (fs.existsSync(themeCssPath)) {
|
|
117
|
-
return fs.readFileSync(themeCssPath, 'utf8');
|
|
118
|
-
}
|
|
119
|
-
} catch {
|
|
120
|
-
// Ignore errors - will use default styles from globals.css
|
|
121
|
-
}
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Generate primary color CSS variables from docs.json colors
|
|
126
|
-
function generatePrimaryColorVariables(
|
|
127
|
-
primaryColor?: string,
|
|
128
|
-
lightColor?: string,
|
|
129
|
-
darkColor?: string
|
|
130
|
-
): string | null {
|
|
131
|
-
if (!primaryColor) return null;
|
|
132
|
-
|
|
133
|
-
// Validate that the color is a valid hex, rgb, or hsl value to prevent XSS
|
|
134
|
-
const colorRegex = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|hsl\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*\))$/i;
|
|
135
|
-
|
|
136
|
-
if (!colorRegex.test(primaryColor.trim())) {
|
|
137
|
-
console.warn(`Invalid color value: ${primaryColor}. Color must be a valid hex, rgb, or hsl value.`);
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const validLight = lightColor && colorRegex.test(lightColor.trim()) ? lightColor.trim() : null;
|
|
142
|
-
const validDark = darkColor && colorRegex.test(darkColor.trim()) ? darkColor.trim() : null;
|
|
143
|
-
|
|
144
|
-
// Light mode uses primary color, dark mode uses light color (or primary if light not provided)
|
|
145
|
-
const darkModeColor = validLight || primaryColor.trim();
|
|
146
|
-
|
|
147
|
-
return `:root {
|
|
148
|
-
--color-primary: ${primaryColor.trim()};
|
|
149
|
-
--color-primary-dark: ${validDark || primaryColor.trim()};
|
|
150
|
-
--color-accent: ${primaryColor.trim()};
|
|
151
|
-
--color-accent-hover: ${validDark || primaryColor.trim()};
|
|
152
|
-
--color-accent-text: color-mix(in srgb, ${primaryColor.trim()} 85%, black);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
.dark {
|
|
156
|
-
--color-primary: ${darkModeColor};
|
|
157
|
-
--color-primary-dark: ${primaryColor.trim()};
|
|
158
|
-
--color-accent: ${darkModeColor};
|
|
159
|
-
--color-accent-hover: ${validLight || darkModeColor};
|
|
160
|
-
--color-accent-text: color-mix(in srgb, ${darkModeColor} 85%, white);
|
|
161
|
-
}`;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Generate font CSS based on theme and custom fonts
|
|
165
|
-
// Supports both new font config format and legacy format
|
|
166
|
-
function getFontClassName(
|
|
167
|
-
themeName: ThemeName | undefined,
|
|
168
|
-
customFonts?: FontConfig
|
|
169
|
-
): string {
|
|
170
|
-
// Extract primary font family from any format
|
|
171
|
-
const primaryFont = getPrimaryFontFamily(customFonts);
|
|
172
|
-
|
|
173
|
-
// If custom fonts are specified, use CSS variables (set via generateFontVariables)
|
|
174
|
-
// For pre-loaded fonts, we still set CSS variables but can use Next.js optimized classes
|
|
175
|
-
if (primaryFont) {
|
|
176
|
-
const classes: string[] = [];
|
|
177
|
-
|
|
178
|
-
if (isPreloadedFont(primaryFont)) {
|
|
179
|
-
// Use Next.js optimized font for pre-loaded fonts
|
|
180
|
-
if (primaryFont === 'Inter') {
|
|
181
|
-
classes.push(inter.variable, inter.className);
|
|
182
|
-
} else if (primaryFont === 'JetBrains Mono') {
|
|
183
|
-
classes.push(jetbrainsMono.variable, 'font-mono');
|
|
184
|
-
} else {
|
|
185
|
-
// Other pre-loaded fonts - fall back to Inter
|
|
186
|
-
classes.push(inter.variable, inter.className);
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
// Custom font - use Tailwind font-sans which will use --font-primary CSS variable
|
|
190
|
-
classes.push('font-sans');
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Always include mono font variable
|
|
194
|
-
classes.push(jetbrainsMono.variable);
|
|
195
|
-
|
|
196
|
-
return classes.join(' ');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// No custom fonts - use theme defaults
|
|
200
|
-
const theme = getTheme(themeName);
|
|
201
|
-
|
|
202
|
-
if (theme.name === 'nebula') {
|
|
203
|
-
// Nebula uses JetBrains Mono for both primary and code
|
|
204
|
-
return `${jetbrainsMono.variable} font-mono`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Default (jam) uses Inter + JetBrains Mono
|
|
208
|
-
return `${inter.variable} ${jetbrainsMono.variable} ${inter.className}`;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Lazy-load Google Tag Manager (returns null on import failure)
|
|
212
|
-
async function ConditionalGTM({ gtmId }: { gtmId: string }) {
|
|
213
|
-
try {
|
|
214
|
-
const { GoogleTagManager } = await import('@next/third-parties/google');
|
|
215
|
-
return <GoogleTagManager gtmId={gtmId} />;
|
|
216
|
-
} catch (error) {
|
|
217
|
-
console.error('Failed to load GoogleTagManager:', error);
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Lazy-load Google Analytics (returns null on import failure)
|
|
223
|
-
async function ConditionalGA({ gaId }: { gaId: string }) {
|
|
224
|
-
try {
|
|
225
|
-
const { GoogleAnalytics } = await import('@next/third-parties/google');
|
|
226
|
-
return <GoogleAnalytics gaId={gaId} />;
|
|
227
|
-
} catch (error) {
|
|
228
|
-
console.error('Failed to load GoogleAnalytics:', error);
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Render Plausible Analytics — supports standard (data-domain) and paid proxy (scriptUrl) modes
|
|
234
|
-
function PlausibleScript({
|
|
235
|
-
domain,
|
|
236
|
-
server,
|
|
237
|
-
scriptUrl,
|
|
238
|
-
}: {
|
|
239
|
-
domain?: string;
|
|
240
|
-
server?: string;
|
|
241
|
-
scriptUrl?: string;
|
|
242
|
-
}): React.ReactElement {
|
|
243
|
-
// Paid proxy script mode (pa-XXXXX.js) — Plausible's CDN handles routing internally,
|
|
244
|
-
// no endpoint or data-domain needed. scriptUrl takes precedence over domain/server.
|
|
245
|
-
if (scriptUrl) {
|
|
246
|
-
return (
|
|
247
|
-
<>
|
|
248
|
-
<script async src={scriptUrl} />
|
|
249
|
-
<script dangerouslySetInnerHTML={{
|
|
250
|
-
__html: 'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()',
|
|
251
|
-
}} />
|
|
252
|
-
</>
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Standard mode — data-domain with optional self-hosted server
|
|
257
|
-
const baseServer = server || 'https://plausible.io';
|
|
258
|
-
const plausibleServer = baseServer.replace(/\/+$/, '');
|
|
259
|
-
const scriptProps: Record<string, unknown> = {
|
|
260
|
-
defer: true,
|
|
261
|
-
'data-domain': domain,
|
|
262
|
-
src: `${plausibleServer}/js/script.js`,
|
|
263
|
-
};
|
|
264
|
-
if (server) {
|
|
265
|
-
scriptProps['data-api'] = `${plausibleServer}/api/event`;
|
|
266
|
-
}
|
|
267
|
-
return <script {...scriptProps} />;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export default async function RootLayout({
|
|
1
|
+
// Bare root layout — must NOT read any dynamic API. Sub-tree layouts
|
|
2
|
+
// (app/(unlock)/layout.tsx and the catch-all app/[[...slug]]/layout.tsx)
|
|
3
|
+
// own all per-project chrome and request-aware concerns.
|
|
4
|
+
//
|
|
5
|
+
// Why bare: any `headers()`, `cookies()`, or `searchParams` read here
|
|
6
|
+
// would force the entire app dynamic and defeat Vercel edge caching for
|
|
7
|
+
// docs pages. See docs/plans/archive/2026-04-20-docs-nav-perf.md for the
|
|
8
|
+
// postmortem that proved both prior approaches fail until this layout is
|
|
9
|
+
// bare.
|
|
10
|
+
export default function RootLayout({
|
|
271
11
|
children,
|
|
272
12
|
}: {
|
|
273
13
|
children: React.ReactNode;
|
|
274
14
|
}) {
|
|
275
|
-
|
|
276
|
-
// rewriting to /jd/unlock so we skip docs chrome, analytics, and R2 config
|
|
277
|
-
// fetch. The unlock page owns its own visuals.
|
|
278
|
-
const headersList = await headers();
|
|
279
|
-
if (headersList.get('x-jd-unlock-mode') === '1') {
|
|
280
|
-
// Unlock mode short-circuits before we fetch project config, so we
|
|
281
|
-
// can't consult `config.navigation.languages` here. The middleware
|
|
282
|
-
// already extracts language from the original `from` path
|
|
283
|
-
// (proxy.ts), so the header is our only signal — fall back to "en".
|
|
284
|
-
const unlockLang = resolveLanguageWithFallback(
|
|
285
|
-
getLanguageFromRequest(headersList),
|
|
286
|
-
undefined,
|
|
287
|
-
);
|
|
288
|
-
const unlockDir = isRTLLanguage(unlockLang) ? 'rtl' : undefined;
|
|
289
|
-
return (
|
|
290
|
-
<html lang={unlockLang} dir={unlockDir}>
|
|
291
|
-
<head>
|
|
292
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
293
|
-
<meta name="robots" content="noindex, nofollow" />
|
|
294
|
-
</head>
|
|
295
|
-
<body
|
|
296
|
-
style={{
|
|
297
|
-
margin: 0,
|
|
298
|
-
minHeight: '100vh',
|
|
299
|
-
fontFamily:
|
|
300
|
-
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
|
301
|
-
backgroundColor: '#f7f7f8',
|
|
302
|
-
}}
|
|
303
|
-
>
|
|
304
|
-
{children}
|
|
305
|
-
</body>
|
|
306
|
-
</html>
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Get config - from R2 in ISR mode, from filesystem in static mode
|
|
311
|
-
let config: DocsConfig;
|
|
312
|
-
let resolvedProjectSlug: string | null = null;
|
|
313
|
-
if (isIsrMode()) {
|
|
314
|
-
const projectSlug = getProjectFromRequest(headersList);
|
|
315
|
-
resolvedProjectSlug = projectSlug;
|
|
316
|
-
const hostAtDocs = getHostAtDocs(headersList);
|
|
317
|
-
if (projectSlug) {
|
|
318
|
-
try {
|
|
319
|
-
config = await getIsrDocsConfig(projectSlug);
|
|
320
|
-
// Inject hostAtDocs for navigation link prefixing
|
|
321
|
-
config = { ...config, hostAtDocs };
|
|
322
|
-
} catch {
|
|
323
|
-
// Project not found in R2 - use minimal fallback config
|
|
324
|
-
// This allows the 404 page to render properly
|
|
325
|
-
config = {
|
|
326
|
-
name: 'Documentation',
|
|
327
|
-
theme: 'jam',
|
|
328
|
-
colors: { primary: '#0ea5e9' },
|
|
329
|
-
navigation: { groups: [] },
|
|
330
|
-
hostAtDocs,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
} else {
|
|
334
|
-
// Fallback to static config if no project slug (shouldn't happen in ISR mode)
|
|
335
|
-
config = getDocsConfig();
|
|
336
|
-
}
|
|
337
|
-
} else {
|
|
338
|
-
config = getDocsConfig();
|
|
339
|
-
// In static export mode, inject hostAtDocs from build-time env var
|
|
340
|
-
// (set by build.ts when running Next.js build)
|
|
341
|
-
if (process.env.HOST_AT_DOCS === 'true') {
|
|
342
|
-
config = { ...config, hostAtDocs: true };
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Custom CSS/JS: In ISR mode, _has* flags are set by build.ts on the R2 config.
|
|
347
|
-
// In local dev, the project source docs.json has no _has* flags, so read files directly.
|
|
348
|
-
const isIsr = isIsrMode();
|
|
349
|
-
|
|
350
|
-
let customCss: string | null = null;
|
|
351
|
-
let customJs: string | null = null;
|
|
352
|
-
if (isIsr && resolvedProjectSlug) {
|
|
353
|
-
if (config._hasCustomCss) {
|
|
354
|
-
customCss = await fetchCustomCss(resolvedProjectSlug);
|
|
355
|
-
}
|
|
356
|
-
if (config._hasCustomJs) {
|
|
357
|
-
customJs = await fetchCustomJs(resolvedProjectSlug);
|
|
358
|
-
}
|
|
359
|
-
} else {
|
|
360
|
-
customCss = getLocalFileContent('custom.css');
|
|
361
|
-
customJs = getLocalFileContent('custom.js');
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const themeName = config.theme as ThemeName | undefined;
|
|
365
|
-
const themeCss = themeName && themeName !== 'jam' ? getThemeCssContent(themeName) : null;
|
|
366
|
-
const fontClassName = getFontClassName(themeName, config.fonts);
|
|
367
|
-
|
|
368
|
-
// Generate font imports and CSS variables for custom fonts
|
|
369
|
-
const fontImports = generateFontImports(config.fonts);
|
|
370
|
-
const fontVariables = generateFontVariables(config.fonts);
|
|
371
|
-
|
|
372
|
-
// Generate primary color CSS variables from docs.json
|
|
373
|
-
const primaryColorVariables = generatePrimaryColorVariables(
|
|
374
|
-
config.colors?.primary,
|
|
375
|
-
config.colors?.light,
|
|
376
|
-
config.colors?.dark
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
// Appearance configuration
|
|
380
|
-
const appearanceDefault = config.appearance?.default || 'system';
|
|
381
|
-
const appearanceStrict = config.appearance?.strict || false;
|
|
382
|
-
|
|
383
|
-
// Link prefix for hostAtDocs: when true, MDX component links need /docs prefix
|
|
384
|
-
const linkPrefix = config.hostAtDocs ? '/docs' : '';
|
|
385
|
-
|
|
386
|
-
// Jamdesk Analytics - enabled by default, opt-out via analytics.enabled: false
|
|
387
|
-
const analyticsScript = config.analytics?.enabled !== false
|
|
388
|
-
? getAnalyticsScript(resolvedProjectSlug)
|
|
389
|
-
: null;
|
|
390
|
-
|
|
391
|
-
// Project default — what `lang` resolves to when no path locale is present.
|
|
392
|
-
// HtmlLangSync uses it on soft-nav back to a non-localized path (e.g. `/`).
|
|
393
|
-
const projectDefaultLang = resolveLanguageWithFallback(null, config.navigation?.languages);
|
|
394
|
-
const lang = getLanguageFromRequest(headersList) ?? projectDefaultLang;
|
|
395
|
-
const dir = isRTLLanguage(lang) ? 'rtl' : undefined;
|
|
396
|
-
|
|
397
|
-
return (
|
|
398
|
-
<html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth">
|
|
399
|
-
<head>
|
|
400
|
-
{/* Add viewport meta for mobile */}
|
|
401
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
402
|
-
{/* Preconnect to Google Fonts - only when custom fonts are configured */}
|
|
403
|
-
{config.fonts && (
|
|
404
|
-
<>
|
|
405
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
406
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
407
|
-
</>
|
|
408
|
-
)}
|
|
409
|
-
{/* Font Awesome CSS for navigation icons - self-hosted web fonts */}
|
|
410
|
-
{/* Loaded async (preload + script injection) to avoid render-blocking 40KB CSS */}
|
|
411
|
-
<link rel="preload" href={FA_CSS_HREF} as="style" />
|
|
412
|
-
{/* Preload FA font files to avoid waterfall: CSS → font discovery → download */}
|
|
413
|
-
<link rel="preload" href="/_jd/fonts/fontawesome/webfonts/fa-light-300.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
|
|
414
|
-
<link rel="preload" href="/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
|
|
415
|
-
<Script id="fa-css-async-loader" strategy="beforeInteractive">
|
|
416
|
-
{`var l=document.createElement('link');l.rel='stylesheet';l.href='${FA_CSS_HREF}';document.head.appendChild(l);window.__FA_CSS_LOADED__=true;`}
|
|
417
|
-
</Script>
|
|
418
|
-
<noscript>
|
|
419
|
-
<link rel="stylesheet" href={FA_CSS_HREF} />
|
|
420
|
-
</noscript>
|
|
421
|
-
{/* DNS prefetch for KaTeX CDN - only when latex is enabled */}
|
|
422
|
-
{config.styling?.latex && (
|
|
423
|
-
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
|
424
|
-
)}
|
|
425
|
-
{/* KaTeX CSS for LaTeX math rendering - loaded conditionally when enabled */}
|
|
426
|
-
{config.styling?.latex && (
|
|
427
|
-
<link
|
|
428
|
-
rel="stylesheet"
|
|
429
|
-
href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css"
|
|
430
|
-
crossOrigin="anonymous"
|
|
431
|
-
/>
|
|
432
|
-
)}
|
|
433
|
-
{/* DNS prefetch for analytics services - resolves DNS before scripts load */}
|
|
434
|
-
{(config.integrations?.ga4 || config.integrations?.gtm) && (
|
|
435
|
-
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
|
436
|
-
)}
|
|
437
|
-
{config.integrations?.posthog && (
|
|
438
|
-
<link rel="dns-prefetch" href={config.integrations.posthog.apiHost || "https://app.posthog.com"} />
|
|
439
|
-
)}
|
|
440
|
-
{process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
|
|
441
|
-
<link rel="dns-prefetch" href={(() => {
|
|
442
|
-
try {
|
|
443
|
-
return config.integrations!.plausible!.scriptUrl
|
|
444
|
-
? new URL(config.integrations!.plausible!.scriptUrl).origin
|
|
445
|
-
: config.integrations!.plausible!.server || "https://plausible.io";
|
|
446
|
-
} catch {
|
|
447
|
-
return config.integrations!.plausible!.server || "https://plausible.io";
|
|
448
|
-
}
|
|
449
|
-
})()} />
|
|
450
|
-
)}
|
|
451
|
-
{config.integrations?.crisp && (
|
|
452
|
-
<link rel="dns-prefetch" href="https://client.crisp.chat" />
|
|
453
|
-
)}
|
|
454
|
-
{config.integrations?.intercom && (
|
|
455
|
-
<link rel="dns-prefetch" href="https://widget.intercom.io" />
|
|
456
|
-
)}
|
|
457
|
-
{config.integrations?.hotjar && (
|
|
458
|
-
<link rel="dns-prefetch" href="https://static.hotjar.com" />
|
|
459
|
-
)}
|
|
460
|
-
{config.integrations?.segment && (
|
|
461
|
-
<link rel="dns-prefetch" href="https://cdn.segment.com" />
|
|
462
|
-
)}
|
|
463
|
-
{/*
|
|
464
|
-
For project subdomains: Handle hydration errors gracefully.
|
|
465
|
-
Catch errors, suppress them, and let the static HTML work without React.
|
|
466
|
-
*/}
|
|
467
|
-
<script
|
|
468
|
-
dangerouslySetInnerHTML={{
|
|
469
|
-
__html: `
|
|
470
|
-
(function() {
|
|
471
|
-
var h = location.hostname;
|
|
472
|
-
var isSubdomain = h.split('.').length > 2 && h.indexOf('localhost') === -1 && h.indexOf('vercel.app') === -1;
|
|
473
|
-
if (!isSubdomain) return;
|
|
474
|
-
|
|
475
|
-
// Mark as project site
|
|
476
|
-
window.__IS_PROJECT_SITE__ = true;
|
|
477
|
-
|
|
478
|
-
// Catch and suppress all errors (hydration errors included)
|
|
479
|
-
window.addEventListener('error', function(e) {
|
|
480
|
-
e.preventDefault();
|
|
481
|
-
e.stopPropagation();
|
|
482
|
-
console.log('[Project] Suppressed error:', e.message);
|
|
483
|
-
return true;
|
|
484
|
-
}, true);
|
|
485
|
-
|
|
486
|
-
// Catch unhandled promise rejections (React errors)
|
|
487
|
-
window.addEventListener('unhandledrejection', function(e) {
|
|
488
|
-
e.preventDefault();
|
|
489
|
-
console.log('[Project] Suppressed rejection:', e.reason);
|
|
490
|
-
return true;
|
|
491
|
-
}, true);
|
|
492
|
-
|
|
493
|
-
// Override console.error to suppress React hydration warnings
|
|
494
|
-
var origError = console.error;
|
|
495
|
-
console.error = function() {
|
|
496
|
-
var msg = arguments[0];
|
|
497
|
-
if (typeof msg === 'string' && (
|
|
498
|
-
msg.indexOf('Hydration') !== -1 ||
|
|
499
|
-
msg.indexOf('hydrat') !== -1 ||
|
|
500
|
-
msg.indexOf('did not match') !== -1 ||
|
|
501
|
-
msg.indexOf('server-rendered') !== -1
|
|
502
|
-
)) {
|
|
503
|
-
console.log('[Project] Suppressed hydration warning');
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
origError.apply(console, arguments);
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
// Let Next.js handle navigation normally - no interception needed
|
|
510
|
-
// The error suppression above handles any hydration issues without breaking SPA navigation
|
|
511
|
-
|
|
512
|
-
// Hide Next.js dev overlays and indicators (CSS + JS removal for Shadow DOM)
|
|
513
|
-
var style = document.createElement('style');
|
|
514
|
-
style.textContent = 'nextjs-portal, [data-next-badge], [data-next-badge-root], next-badge-root, [data-nextjs-dialog-root] { display: none !important; visibility: hidden !important; }';
|
|
515
|
-
document.head.appendChild(style);
|
|
516
|
-
|
|
517
|
-
// Remove dev indicator elements that use Shadow DOM (CSS can't reach them)
|
|
518
|
-
var removeDevIndicators = function() {
|
|
519
|
-
document.querySelectorAll('next-badge-root, [data-next-badge-root], nextjs-portal').forEach(function(el) {
|
|
520
|
-
el.remove();
|
|
521
|
-
});
|
|
522
|
-
};
|
|
523
|
-
// Run immediately and observe for new elements once body exists
|
|
524
|
-
removeDevIndicators();
|
|
525
|
-
var observeBody = function() {
|
|
526
|
-
if (document.body) {
|
|
527
|
-
new MutationObserver(removeDevIndicators).observe(document.body, { childList: true, subtree: true });
|
|
528
|
-
}
|
|
529
|
-
};
|
|
530
|
-
if (document.body) { observeBody(); } else { document.addEventListener('DOMContentLoaded', observeBody, { once: true }); }
|
|
531
|
-
})();
|
|
532
|
-
`,
|
|
533
|
-
}}
|
|
534
|
-
/>
|
|
535
|
-
{/* Custom font imports (Google Fonts) */}
|
|
536
|
-
{fontImports && (
|
|
537
|
-
<style dangerouslySetInnerHTML={{ __html: fontImports }} />
|
|
538
|
-
)}
|
|
539
|
-
{/* Custom font CSS variables */}
|
|
540
|
-
{fontVariables && (
|
|
541
|
-
<style dangerouslySetInnerHTML={{ __html: fontVariables }} />
|
|
542
|
-
)}
|
|
543
|
-
{/* Theme-specific CSS override (if not using default jam theme) */}
|
|
544
|
-
{themeCss && (
|
|
545
|
-
<style dangerouslySetInnerHTML={{ __html: themeCss }} />
|
|
546
|
-
)}
|
|
547
|
-
{/* Primary color CSS variables from docs.json - after theme CSS so brand colors take precedence */}
|
|
548
|
-
{primaryColorVariables && (
|
|
549
|
-
<style dangerouslySetInnerHTML={{ __html: primaryColorVariables }} />
|
|
550
|
-
)}
|
|
551
|
-
{/* Custom CSS - injected in head to prevent flash of unstyled content */}
|
|
552
|
-
{customCss && (
|
|
553
|
-
<style dangerouslySetInnerHTML={{ __html: customCss }} />
|
|
554
|
-
)}
|
|
555
|
-
{/* Plausible Analytics — production only; dev injection would spam "Ignoring Event: localhost" */}
|
|
556
|
-
{process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
|
|
557
|
-
<PlausibleScript
|
|
558
|
-
domain={config.integrations.plausible.domain}
|
|
559
|
-
server={config.integrations.plausible.server}
|
|
560
|
-
scriptUrl={config.integrations.plausible.scriptUrl}
|
|
561
|
-
/>
|
|
562
|
-
)}
|
|
563
|
-
</head>
|
|
564
|
-
<body className={fontClassName} data-theme={themeName || 'jam'}>
|
|
565
|
-
{/* Google Tag Manager */}
|
|
566
|
-
{config.integrations?.gtm?.tagId && (
|
|
567
|
-
<ConditionalGTM gtmId={config.integrations.gtm.tagId} />
|
|
568
|
-
)}
|
|
569
|
-
<ThemeProvider
|
|
570
|
-
defaultTheme={appearanceDefault}
|
|
571
|
-
forcedTheme={appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined}
|
|
572
|
-
>
|
|
573
|
-
<LinkPrefixProvider prefix={linkPrefix}>
|
|
574
|
-
<ProjectSlugProvider slug={resolvedProjectSlug || ''}>
|
|
575
|
-
<LayoutWrapper config={config}>
|
|
576
|
-
{children}
|
|
577
|
-
</LayoutWrapper>
|
|
578
|
-
</ProjectSlugProvider>
|
|
579
|
-
</LinkPrefixProvider>
|
|
580
|
-
{/* Client components for copy buttons after hydration */}
|
|
581
|
-
<CodeBlockCopyButton />
|
|
582
|
-
<HeaderLinkCopy />
|
|
583
|
-
<FontAwesomeLoader />
|
|
584
|
-
<HtmlLangSync defaultLanguage={projectDefaultLang} />
|
|
585
|
-
</ThemeProvider>
|
|
586
|
-
{/* Crisp Chat */}
|
|
587
|
-
{config.integrations?.crisp?.websiteId &&
|
|
588
|
-
/^[a-f0-9-]{36}$/.test(config.integrations.crisp.websiteId) && (
|
|
589
|
-
<script
|
|
590
|
-
dangerouslySetInnerHTML={{
|
|
591
|
-
__html: `window.$crisp=[];window.CRISP_WEBSITE_ID="${config.integrations.crisp.websiteId}";(function(){var d=document;var s=d.createElement("script");s.src="https://client.crisp.chat/l.js";s.async=1;d.getElementsByTagName("head")[0].appendChild(s)})();`,
|
|
592
|
-
}}
|
|
593
|
-
/>
|
|
594
|
-
)}
|
|
595
|
-
{/* Custom JavaScript - inlined from R2 (ISR) or public/ (dev) */}
|
|
596
|
-
{customJs && (
|
|
597
|
-
<script dangerouslySetInnerHTML={{ __html: customJs }} />
|
|
598
|
-
)}
|
|
599
|
-
{/* Jamdesk Analytics — deferred to body for faster FCP */}
|
|
600
|
-
{analyticsScript && (
|
|
601
|
-
<script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
|
|
602
|
-
)}
|
|
603
|
-
{/* Google Analytics 4 */}
|
|
604
|
-
{config.integrations?.ga4?.measurementId && (
|
|
605
|
-
<ConditionalGA gaId={config.integrations.ga4.measurementId} />
|
|
606
|
-
)}
|
|
607
|
-
<JdReadySentinel />
|
|
608
|
-
</body>
|
|
609
|
-
</html>
|
|
610
|
-
);
|
|
15
|
+
return children as React.ReactElement;
|
|
611
16
|
}
|