jamdesk 1.1.49 → 1.1.51

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.
@@ -1,611 +1,16 @@
1
- import type { Metadata } from 'next';
2
- import { Inter, JetBrains_Mono } from 'next/font/google';
3
- import { headers } from 'next/headers';
4
- import Script from 'next/script';
5
- import './globals.css';
6
- import { ThemeProvider } from '@/components/theme/ThemeProvider';
7
- import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
8
- import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
9
- import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
10
- import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
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
- // Unlock-mode short-circuit: middleware sets x-jd-unlock-mode when
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
  }