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
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// Shared chrome rendered by the docs catch-all layout. Inputs (config,
|
|
2
|
+
// language, project slug) are resolved upstream from middleware headers
|
|
3
|
+
// or — in non-ISR dev/tests — from URL params; this module owns the
|
|
4
|
+
// rendering once those have resolved.
|
|
5
|
+
import { Inter, JetBrains_Mono } from 'next/font/google';
|
|
6
|
+
import { preload, preinit } from 'react-dom';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { ThemeProvider } from '@/components/theme/ThemeProvider';
|
|
10
|
+
import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
|
|
11
|
+
import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
|
|
12
|
+
import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
|
|
13
|
+
import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
|
|
14
|
+
import { JdReadySentinel } from '@/components/JdReadySentinel';
|
|
15
|
+
import { HtmlLangSync } from '@/components/HtmlLangSync';
|
|
16
|
+
import { FA_CSS_HREF } from '@/lib/font-awesome';
|
|
17
|
+
import { getTheme, type ThemeName } from '@/themes';
|
|
18
|
+
import {
|
|
19
|
+
generateFontImports,
|
|
20
|
+
generateFontVariables,
|
|
21
|
+
isPreloadedFont,
|
|
22
|
+
getPrimaryFontFamily,
|
|
23
|
+
} from '@/lib/fonts';
|
|
24
|
+
import type { DocsConfig, FontConfig, LanguageCode } from '@/lib/docs-types';
|
|
25
|
+
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
26
|
+
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
|
+
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
|
+
|
|
29
|
+
// Pre-load fonts at module scope — Next.js requires this for static analysis.
|
|
30
|
+
export const inter = Inter({
|
|
31
|
+
subsets: ['latin'],
|
|
32
|
+
display: 'swap',
|
|
33
|
+
variable: '--font-primary',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const jetbrainsMono = JetBrains_Mono({
|
|
37
|
+
subsets: ['latin'],
|
|
38
|
+
display: 'swap',
|
|
39
|
+
variable: '--font-mono',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function getLocalFileContent(filename: string): string | null {
|
|
43
|
+
try {
|
|
44
|
+
const filePath = path.join(process.cwd(), 'public', filename);
|
|
45
|
+
if (fs.existsSync(filePath)) {
|
|
46
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore errors
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getThemeCssContent(themeName: ThemeName | undefined): string | null {
|
|
55
|
+
try {
|
|
56
|
+
const theme = getTheme(themeName);
|
|
57
|
+
const themeCssPath = path.join(process.cwd(), 'themes', theme.cssPath);
|
|
58
|
+
if (fs.existsSync(themeCssPath)) {
|
|
59
|
+
return fs.readFileSync(themeCssPath, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Ignore errors - will use default styles from globals.css
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function generatePrimaryColorVariables(
|
|
68
|
+
primaryColor?: string,
|
|
69
|
+
lightColor?: string,
|
|
70
|
+
darkColor?: string,
|
|
71
|
+
): string | null {
|
|
72
|
+
if (!primaryColor) return null;
|
|
73
|
+
|
|
74
|
+
// Validate that the color is a valid hex, rgb, or hsl value to prevent XSS
|
|
75
|
+
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;
|
|
76
|
+
|
|
77
|
+
if (!colorRegex.test(primaryColor.trim())) {
|
|
78
|
+
console.warn(`Invalid color value: ${primaryColor}. Color must be a valid hex, rgb, or hsl value.`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const validLight = lightColor && colorRegex.test(lightColor.trim()) ? lightColor.trim() : null;
|
|
83
|
+
const validDark = darkColor && colorRegex.test(darkColor.trim()) ? darkColor.trim() : null;
|
|
84
|
+
|
|
85
|
+
// Light mode uses primary color, dark mode uses light color (or primary if light not provided)
|
|
86
|
+
const darkModeColor = validLight || primaryColor.trim();
|
|
87
|
+
|
|
88
|
+
return `:root {
|
|
89
|
+
--color-primary: ${primaryColor.trim()};
|
|
90
|
+
--color-primary-dark: ${validDark || primaryColor.trim()};
|
|
91
|
+
--color-accent: ${primaryColor.trim()};
|
|
92
|
+
--color-accent-hover: ${validDark || primaryColor.trim()};
|
|
93
|
+
--color-accent-text: color-mix(in srgb, ${primaryColor.trim()} 85%, black);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.dark {
|
|
97
|
+
--color-primary: ${darkModeColor};
|
|
98
|
+
--color-primary-dark: ${primaryColor.trim()};
|
|
99
|
+
--color-accent: ${darkModeColor};
|
|
100
|
+
--color-accent-hover: ${validLight || darkModeColor};
|
|
101
|
+
--color-accent-text: color-mix(in srgb, ${darkModeColor} 85%, white);
|
|
102
|
+
}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getFontClassName(
|
|
106
|
+
themeName: ThemeName | undefined,
|
|
107
|
+
customFonts?: FontConfig,
|
|
108
|
+
): string {
|
|
109
|
+
const primaryFont = getPrimaryFontFamily(customFonts);
|
|
110
|
+
|
|
111
|
+
if (primaryFont) {
|
|
112
|
+
const classes: string[] = [];
|
|
113
|
+
|
|
114
|
+
if (isPreloadedFont(primaryFont)) {
|
|
115
|
+
if (primaryFont === 'Inter') {
|
|
116
|
+
classes.push(inter.variable, inter.className);
|
|
117
|
+
} else if (primaryFont === 'JetBrains Mono') {
|
|
118
|
+
classes.push(jetbrainsMono.variable, 'font-mono');
|
|
119
|
+
} else {
|
|
120
|
+
classes.push(inter.variable, inter.className);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
classes.push('font-sans');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
classes.push(jetbrainsMono.variable);
|
|
127
|
+
return classes.join(' ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const theme = getTheme(themeName);
|
|
131
|
+
if (theme.name === 'nebula') {
|
|
132
|
+
return `${jetbrainsMono.variable} font-mono`;
|
|
133
|
+
}
|
|
134
|
+
return `${inter.variable} ${jetbrainsMono.variable} ${inter.className}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function ConditionalGTM({ gtmId }: { gtmId: string }) {
|
|
138
|
+
try {
|
|
139
|
+
const { GoogleTagManager } = await import('@next/third-parties/google');
|
|
140
|
+
return <GoogleTagManager gtmId={gtmId} />;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('Failed to load GoogleTagManager:', error);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function ConditionalGA({ gaId }: { gaId: string }) {
|
|
148
|
+
try {
|
|
149
|
+
const { GoogleAnalytics } = await import('@next/third-parties/google');
|
|
150
|
+
return <GoogleAnalytics gaId={gaId} />;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('Failed to load GoogleAnalytics:', error);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function PlausibleScript({
|
|
158
|
+
domain,
|
|
159
|
+
server,
|
|
160
|
+
scriptUrl,
|
|
161
|
+
}: {
|
|
162
|
+
domain?: string;
|
|
163
|
+
server?: string;
|
|
164
|
+
scriptUrl?: string;
|
|
165
|
+
}): React.ReactElement {
|
|
166
|
+
// Paid proxy script mode (pa-XXXXX.js) — Plausible's CDN handles routing internally
|
|
167
|
+
if (scriptUrl) {
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<script async src={scriptUrl} />
|
|
171
|
+
<script dangerouslySetInnerHTML={{
|
|
172
|
+
__html: 'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()',
|
|
173
|
+
}} />
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const baseServer = server || 'https://plausible.io';
|
|
179
|
+
const plausibleServer = baseServer.replace(/\/+$/, '');
|
|
180
|
+
const scriptProps: Record<string, unknown> = {
|
|
181
|
+
defer: true,
|
|
182
|
+
'data-domain': domain,
|
|
183
|
+
src: `${plausibleServer}/js/script.js`,
|
|
184
|
+
};
|
|
185
|
+
if (server) {
|
|
186
|
+
scriptProps['data-api'] = `${plausibleServer}/api/event`;
|
|
187
|
+
}
|
|
188
|
+
return <script {...scriptProps} />;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface DocsChromeProps {
|
|
192
|
+
config: DocsConfig;
|
|
193
|
+
resolvedProjectSlug: string;
|
|
194
|
+
lang: LanguageCode;
|
|
195
|
+
dir: 'rtl' | undefined;
|
|
196
|
+
projectDefaultLang: LanguageCode;
|
|
197
|
+
customCss: string | null;
|
|
198
|
+
customJs: string | null;
|
|
199
|
+
children: React.ReactNode;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Full docs chrome — html shell, head tags, providers, scripts. Rendered
|
|
204
|
+
* by the docs catch-all layout (`app/[[...slug]]/layout.tsx`) once it has
|
|
205
|
+
* resolved its inputs (config, language, project slug, custom CSS/JS).
|
|
206
|
+
*/
|
|
207
|
+
export async function DocsChrome({
|
|
208
|
+
config,
|
|
209
|
+
resolvedProjectSlug,
|
|
210
|
+
lang,
|
|
211
|
+
dir,
|
|
212
|
+
projectDefaultLang,
|
|
213
|
+
customCss,
|
|
214
|
+
customJs,
|
|
215
|
+
children,
|
|
216
|
+
}: DocsChromeProps): Promise<React.ReactElement> {
|
|
217
|
+
const themeName = config.theme as ThemeName | undefined;
|
|
218
|
+
const themeCss = themeName && themeName !== 'jam' ? getThemeCssContent(themeName) : null;
|
|
219
|
+
const fontClassName = getFontClassName(themeName, config.fonts);
|
|
220
|
+
|
|
221
|
+
const fontImports = generateFontImports(config.fonts);
|
|
222
|
+
const fontVariables = generateFontVariables(config.fonts);
|
|
223
|
+
|
|
224
|
+
const primaryColorVariables = generatePrimaryColorVariables(
|
|
225
|
+
config.colors?.primary,
|
|
226
|
+
config.colors?.light,
|
|
227
|
+
config.colors?.dark,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const appearanceDefault = config.appearance?.default || 'system';
|
|
231
|
+
const appearanceStrict = config.appearance?.strict || false;
|
|
232
|
+
|
|
233
|
+
const linkPrefix = config.hostAtDocs ? '/docs' : '';
|
|
234
|
+
|
|
235
|
+
const analyticsScript = config.analytics?.enabled !== false
|
|
236
|
+
? getAnalyticsScript(resolvedProjectSlug)
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
// Font Awesome CSS uses preinit (not preload) so React 19 emits the
|
|
240
|
+
// stylesheet link in <head> at SSR time. The previous approach used a
|
|
241
|
+
// beforeInteractive Script that injected <link rel=stylesheet> at runtime —
|
|
242
|
+
// that delayed @font-face parsing past the preloaded fonts' "few seconds"
|
|
243
|
+
// grace window, producing the chrome console warning "preloaded but not
|
|
244
|
+
// used within a few seconds." preinit closes the gap: the preloaded fonts
|
|
245
|
+
// are matched to their @font-face declarations as soon as the stylesheet
|
|
246
|
+
// parses, which now starts at HTML-parse time alongside the preloads.
|
|
247
|
+
//
|
|
248
|
+
// The font preloads stay on react-dom preload() so React 19 dedupes them
|
|
249
|
+
// (JSX <link rel=preload> tags used to ship twice — once from the JSX tree,
|
|
250
|
+
// once from React's preload manager). The <noscript> fallback below uses
|
|
251
|
+
// dangerouslySetInnerHTML for the same reason: a JSX <link rel=stylesheet>
|
|
252
|
+
// inside noscript was being hoisted into <head> as a real stylesheet.
|
|
253
|
+
preinit(FA_CSS_HREF, { as: 'style' });
|
|
254
|
+
preload('/_jd/fonts/fontawesome/webfonts/fa-light-300.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
|
|
255
|
+
preload('/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth">
|
|
259
|
+
<head>
|
|
260
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
261
|
+
{config.fonts && (
|
|
262
|
+
<>
|
|
263
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
264
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
{/* Font Awesome stylesheet is emitted by the preinit() call above —
|
|
268
|
+
React 19 hoists it to <head> at SSR time. The <noscript> fallback
|
|
269
|
+
below uses dangerouslySetInnerHTML so React doesn't hoist *that*
|
|
270
|
+
<link rel="stylesheet"> into <head> as a real stylesheet. */}
|
|
271
|
+
<noscript dangerouslySetInnerHTML={{ __html: `<link rel="stylesheet" href="${FA_CSS_HREF}">` }} />
|
|
272
|
+
{config.styling?.latex && (
|
|
273
|
+
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
|
274
|
+
)}
|
|
275
|
+
{config.styling?.latex && (
|
|
276
|
+
<link
|
|
277
|
+
rel="stylesheet"
|
|
278
|
+
href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css"
|
|
279
|
+
crossOrigin="anonymous"
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
{(config.integrations?.ga4 || config.integrations?.gtm) && (
|
|
283
|
+
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
|
284
|
+
)}
|
|
285
|
+
{config.integrations?.posthog && (
|
|
286
|
+
<link rel="dns-prefetch" href={config.integrations.posthog.apiHost || "https://app.posthog.com"} />
|
|
287
|
+
)}
|
|
288
|
+
{process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
|
|
289
|
+
<link rel="dns-prefetch" href={(() => {
|
|
290
|
+
try {
|
|
291
|
+
return config.integrations!.plausible!.scriptUrl
|
|
292
|
+
? new URL(config.integrations!.plausible!.scriptUrl).origin
|
|
293
|
+
: config.integrations!.plausible!.server || "https://plausible.io";
|
|
294
|
+
} catch {
|
|
295
|
+
return config.integrations!.plausible!.server || "https://plausible.io";
|
|
296
|
+
}
|
|
297
|
+
})()} />
|
|
298
|
+
)}
|
|
299
|
+
{config.integrations?.crisp && (
|
|
300
|
+
<link rel="dns-prefetch" href="https://client.crisp.chat" />
|
|
301
|
+
)}
|
|
302
|
+
{config.integrations?.intercom && (
|
|
303
|
+
<link rel="dns-prefetch" href="https://widget.intercom.io" />
|
|
304
|
+
)}
|
|
305
|
+
{config.integrations?.hotjar && (
|
|
306
|
+
<link rel="dns-prefetch" href="https://static.hotjar.com" />
|
|
307
|
+
)}
|
|
308
|
+
{config.integrations?.segment && (
|
|
309
|
+
<link rel="dns-prefetch" href="https://cdn.segment.com" />
|
|
310
|
+
)}
|
|
311
|
+
{/*
|
|
312
|
+
For project subdomains: handle hydration errors gracefully and hide
|
|
313
|
+
Next.js dev overlays on customer subdomains.
|
|
314
|
+
*/}
|
|
315
|
+
<script
|
|
316
|
+
dangerouslySetInnerHTML={{
|
|
317
|
+
__html: `
|
|
318
|
+
(function() {
|
|
319
|
+
var h = location.hostname;
|
|
320
|
+
var isSubdomain = h.split('.').length > 2 && h.indexOf('localhost') === -1 && h.indexOf('vercel.app') === -1;
|
|
321
|
+
if (!isSubdomain) return;
|
|
322
|
+
|
|
323
|
+
window.__IS_PROJECT_SITE__ = true;
|
|
324
|
+
|
|
325
|
+
window.addEventListener('error', function(e) {
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
e.stopPropagation();
|
|
328
|
+
console.log('[Project] Suppressed error:', e.message);
|
|
329
|
+
return true;
|
|
330
|
+
}, true);
|
|
331
|
+
|
|
332
|
+
window.addEventListener('unhandledrejection', function(e) {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
console.log('[Project] Suppressed rejection:', e.reason);
|
|
335
|
+
return true;
|
|
336
|
+
}, true);
|
|
337
|
+
|
|
338
|
+
var origError = console.error;
|
|
339
|
+
console.error = function() {
|
|
340
|
+
var msg = arguments[0];
|
|
341
|
+
if (typeof msg === 'string' && (
|
|
342
|
+
msg.indexOf('Hydration') !== -1 ||
|
|
343
|
+
msg.indexOf('hydrat') !== -1 ||
|
|
344
|
+
msg.indexOf('did not match') !== -1 ||
|
|
345
|
+
msg.indexOf('server-rendered') !== -1
|
|
346
|
+
)) {
|
|
347
|
+
console.log('[Project] Suppressed hydration warning');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
origError.apply(console, arguments);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
var style = document.createElement('style');
|
|
354
|
+
style.textContent = 'nextjs-portal, [data-next-badge], [data-next-badge-root], next-badge-root, [data-nextjs-dialog-root] { display: none !important; visibility: hidden !important; }';
|
|
355
|
+
document.head.appendChild(style);
|
|
356
|
+
|
|
357
|
+
var removeDevIndicators = function() {
|
|
358
|
+
document.querySelectorAll('next-badge-root, [data-next-badge-root], nextjs-portal').forEach(function(el) {
|
|
359
|
+
el.remove();
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
removeDevIndicators();
|
|
363
|
+
var observeBody = function() {
|
|
364
|
+
if (document.body) {
|
|
365
|
+
new MutationObserver(removeDevIndicators).observe(document.body, { childList: true, subtree: true });
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
if (document.body) { observeBody(); } else { document.addEventListener('DOMContentLoaded', observeBody, { once: true }); }
|
|
369
|
+
})();
|
|
370
|
+
`,
|
|
371
|
+
}}
|
|
372
|
+
/>
|
|
373
|
+
{/*
|
|
374
|
+
precedence + stable href lets React 19 hoist these into <head> as
|
|
375
|
+
managed resources and dedupe across renders, so theme/font styles
|
|
376
|
+
aren't recreated on each soft navigation.
|
|
377
|
+
*/}
|
|
378
|
+
{fontImports && (
|
|
379
|
+
<style
|
|
380
|
+
precedence="jd-layout"
|
|
381
|
+
href={`jd-fonts-${resolvedProjectSlug}`}
|
|
382
|
+
>
|
|
383
|
+
{fontImports}
|
|
384
|
+
</style>
|
|
385
|
+
)}
|
|
386
|
+
{fontVariables && (
|
|
387
|
+
<style
|
|
388
|
+
precedence="jd-layout"
|
|
389
|
+
href={`jd-font-vars-${resolvedProjectSlug}`}
|
|
390
|
+
>
|
|
391
|
+
{fontVariables}
|
|
392
|
+
</style>
|
|
393
|
+
)}
|
|
394
|
+
{themeCss && (
|
|
395
|
+
<style precedence="jd-layout" href={`jd-theme-${themeName ?? 'jam'}`}>
|
|
396
|
+
{themeCss}
|
|
397
|
+
</style>
|
|
398
|
+
)}
|
|
399
|
+
{primaryColorVariables && (
|
|
400
|
+
<style
|
|
401
|
+
precedence="jd-layout"
|
|
402
|
+
href={`jd-colors-${resolvedProjectSlug}`}
|
|
403
|
+
>
|
|
404
|
+
{primaryColorVariables}
|
|
405
|
+
</style>
|
|
406
|
+
)}
|
|
407
|
+
{customCss && (
|
|
408
|
+
<style
|
|
409
|
+
precedence="jd-layout"
|
|
410
|
+
href={`jd-custom-${resolvedProjectSlug}`}
|
|
411
|
+
>
|
|
412
|
+
{customCss}
|
|
413
|
+
</style>
|
|
414
|
+
)}
|
|
415
|
+
{process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
|
|
416
|
+
<PlausibleScript
|
|
417
|
+
domain={config.integrations.plausible.domain}
|
|
418
|
+
server={config.integrations.plausible.server}
|
|
419
|
+
scriptUrl={config.integrations.plausible.scriptUrl}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
422
|
+
</head>
|
|
423
|
+
<body className={fontClassName} data-theme={themeName || 'jam'}>
|
|
424
|
+
{config.integrations?.gtm?.tagId && (
|
|
425
|
+
<ConditionalGTM gtmId={config.integrations.gtm.tagId} />
|
|
426
|
+
)}
|
|
427
|
+
<ThemeProvider
|
|
428
|
+
defaultTheme={appearanceDefault}
|
|
429
|
+
forcedTheme={appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined}
|
|
430
|
+
>
|
|
431
|
+
<LinkPrefixProvider prefix={linkPrefix}>
|
|
432
|
+
<ProjectSlugProvider slug={resolvedProjectSlug || ''}>
|
|
433
|
+
<LayoutWrapper config={config}>
|
|
434
|
+
{children}
|
|
435
|
+
</LayoutWrapper>
|
|
436
|
+
</ProjectSlugProvider>
|
|
437
|
+
</LinkPrefixProvider>
|
|
438
|
+
<CodeBlockCopyButton />
|
|
439
|
+
<HeaderLinkCopy />
|
|
440
|
+
<FontAwesomeLoader />
|
|
441
|
+
<HtmlLangSync defaultLanguage={projectDefaultLang} />
|
|
442
|
+
</ThemeProvider>
|
|
443
|
+
{config.integrations?.crisp?.websiteId &&
|
|
444
|
+
/^[a-f0-9-]{36}$/.test(config.integrations.crisp.websiteId) && (
|
|
445
|
+
<script
|
|
446
|
+
dangerouslySetInnerHTML={{
|
|
447
|
+
__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)})();`,
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
)}
|
|
451
|
+
{customJs && (
|
|
452
|
+
<script dangerouslySetInnerHTML={{ __html: customJs }} />
|
|
453
|
+
)}
|
|
454
|
+
{analyticsScript && (
|
|
455
|
+
<script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
|
|
456
|
+
)}
|
|
457
|
+
{config.integrations?.ga4?.measurementId && (
|
|
458
|
+
<ConditionalGA gaId={config.integrations.ga4.measurementId} />
|
|
459
|
+
)}
|
|
460
|
+
<JdReadySentinel />
|
|
461
|
+
</body>
|
|
462
|
+
</html>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
@@ -27,89 +27,11 @@ import type { NextRequest } from 'next/server';
|
|
|
27
27
|
*/
|
|
28
28
|
export const VALID_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* Mangle a request pathname so it routes into the (docs)/[project] sub-tree.
|
|
32
|
-
* The proxy applies this rewrite internally; user-visible URLs are unchanged.
|
|
33
|
-
*
|
|
34
|
-
* Slug must be URL-safe — VALID_SLUG_RE catches that upstream, so we
|
|
35
|
-
* fail loud here if something slipped through.
|
|
36
|
-
*/
|
|
37
|
-
export function injectProjectSlugIntoPath(
|
|
38
|
-
pathname: string,
|
|
39
|
-
projectSlug: string,
|
|
40
|
-
_hostAtDocs: boolean,
|
|
41
|
-
): string {
|
|
42
|
-
if (!pathname || !pathname.startsWith('/')) {
|
|
43
|
-
throw new Error(`injectProjectSlugIntoPath: invalid pathname '${pathname}'`);
|
|
44
|
-
}
|
|
45
|
-
if (!VALID_SLUG_RE.test(projectSlug)) {
|
|
46
|
-
throw new Error(`injectProjectSlugIntoPath: invalid slug '${projectSlug}'`);
|
|
47
|
-
}
|
|
48
|
-
// hostAtDocs note: the user URL has /docs prefix; we still inject [project]
|
|
49
|
-
// BEFORE everything else so the route is /[project]/docs/<rest>. The
|
|
50
|
-
// existing (docs)/[project]/[[...slug]] catch-all handles either shape.
|
|
51
|
-
if (pathname === '/') return `/${projectSlug}/`;
|
|
52
|
-
return `/${projectSlug}${pathname}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Names of static endpoint route handlers that live at the bare app/<name>/route.ts
|
|
57
|
-
* (and their app/docs/<name>/route.ts twins for hostAtDocs sites). They render
|
|
58
|
-
* project-aware output, but do so by reading x-project-slug headers — they are
|
|
59
|
-
* NOT inside the (docs)/[project] sub-tree, so rewriting their URL to inject a
|
|
60
|
-
* /[project]/ prefix would route to a path that doesn't exist and 404.
|
|
61
|
-
*
|
|
62
|
-
* KEEP IN SYNC with the actual files on disk: every app/<name>/route.ts must
|
|
63
|
-
* appear here. The Step 5 verification grep guards against drift.
|
|
64
|
-
*/
|
|
65
|
-
export const STATIC_ENDPOINT_NAMES = [
|
|
66
|
-
'sitemap.xml',
|
|
67
|
-
'llms.txt',
|
|
68
|
-
'llms-full.txt',
|
|
69
|
-
'robots.txt',
|
|
70
|
-
'feed.xml',
|
|
71
|
-
'docs.json',
|
|
72
|
-
'search-data.json',
|
|
73
|
-
] as const;
|
|
74
|
-
|
|
75
|
-
/** Precomputed path set so the hot-path check avoids per-call template allocs. */
|
|
76
|
-
const STATIC_ENDPOINT_PATHS = new Set<string>(
|
|
77
|
-
STATIC_ENDPOINT_NAMES.flatMap((n) => [`/${n}`, `/docs/${n}`]),
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
/** Roots that bypass the (docs) rewrite — both `/X` exact and `/X/...` prefix. */
|
|
81
|
-
const SYSTEM_PATH_ROOTS = [
|
|
82
|
-
'/_next', // Next.js asset traffic — most-common bypass for docs sites
|
|
83
|
-
'/api',
|
|
84
|
-
'/_jd',
|
|
85
|
-
'/.well-known',
|
|
86
|
-
'/jd/unlock',
|
|
87
|
-
] as const;
|
|
88
|
-
|
|
89
30
|
/** True if pathname is exactly `root` or lives under `root/`. */
|
|
90
31
|
export function isPathOrUnder(pathname: string, root: string): boolean {
|
|
91
32
|
return pathname === root || pathname.startsWith(root + '/');
|
|
92
33
|
}
|
|
93
34
|
|
|
94
|
-
/**
|
|
95
|
-
* Return true when a pathname should be internally rewritten into the
|
|
96
|
-
* (docs)/[project] route group. Stays outside the group for:
|
|
97
|
-
* - SYSTEM_PATH_ROOTS (api routes, Next assets, _jd, .well-known, unlock)
|
|
98
|
-
* - STATIC_ENDPOINT_NAMES files (sitemap.xml, llms.txt, robots.txt, feed.xml,
|
|
99
|
-
* docs.json, search-data.json — and their
|
|
100
|
-
* /docs/<name> twins for hostAtDocs sites)
|
|
101
|
-
*
|
|
102
|
-
* MDX pages under /docs/<slug> (hostAtDocs MDX content) ARE rewritten — only
|
|
103
|
-
* the named static endpoints above are excluded.
|
|
104
|
-
*/
|
|
105
|
-
export function shouldRewriteIntoDocsGroup(pathname: string): boolean {
|
|
106
|
-
if (STATIC_ENDPOINT_PATHS.has(pathname)) return false;
|
|
107
|
-
for (const root of SYSTEM_PATH_ROOTS) {
|
|
108
|
-
if (isPathOrUnder(pathname, root)) return false;
|
|
109
|
-
}
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
35
|
/**
|
|
114
36
|
* Result of project resolution.
|
|
115
37
|
*/
|
|
@@ -159,6 +159,22 @@ export function getBaseUrl(headers: Headers, projectSlug: string, hostAtDocs = f
|
|
|
159
159
|
return `https://${projectSlug}.jamdesk.app`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Header-free base URL fallback. Always returns the canonical subdomain
|
|
164
|
+
* URL (`https://<slug>.jamdesk.app[/docs]`).
|
|
165
|
+
*
|
|
166
|
+
* Used when the caller does not have request headers — direct URL params
|
|
167
|
+
* branch in non-ISR dev/tests, and metadata callers that want a stable
|
|
168
|
+
* canonical without forcing the segment dynamic. The header-aware variant
|
|
169
|
+
* `getBaseUrl` should be preferred whenever headers are available so
|
|
170
|
+
* custom-domain customers see their own host in canonical/OG tags.
|
|
171
|
+
*/
|
|
172
|
+
export function getBaseUrlFromConfig(projectSlug: string, hostAtDocs = false): string {
|
|
173
|
+
return hostAtDocs
|
|
174
|
+
? `https://${projectSlug}.jamdesk.app/docs`
|
|
175
|
+
: `https://${projectSlug}.jamdesk.app`;
|
|
176
|
+
}
|
|
177
|
+
|
|
162
178
|
/**
|
|
163
179
|
* Read the active locale from request headers.
|
|
164
180
|
*
|
|
@@ -98,11 +98,18 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
* Get project configuration for a subdomain.
|
|
101
|
+
* Get project configuration for a subdomain (silent-fallback variant).
|
|
102
102
|
* Used for hostAtDocs setting on *.jamdesk.app domains.
|
|
103
103
|
*
|
|
104
104
|
* Default is hostAtDocs=false for subdomains (serve at root).
|
|
105
105
|
* Projects that need /docs path must explicitly set hostAtDocs=true.
|
|
106
|
+
*
|
|
107
|
+
* Swallows Redis errors and returns `{hostAtDocs: false}` so a transient
|
|
108
|
+
* Redis blip doesn't take down the proxy hot path. Page-side callers
|
|
109
|
+
* that wrap the result in `unstable_cache` should use
|
|
110
|
+
* `getProjectConfigStrict` instead — silent-fallback on a cold render
|
|
111
|
+
* would memoize a wrong `false` for the cache TTL, the exact failure
|
|
112
|
+
* mode that produced the jamdesk.com/docs/* 404 incident.
|
|
106
113
|
*/
|
|
107
114
|
export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDocs: boolean }> {
|
|
108
115
|
if (!redis) {
|
|
@@ -119,6 +126,26 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
|
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Strict variant of `getProjectConfig` — throws on Redis errors instead of
|
|
131
|
+
* silently returning `{hostAtDocs: false}`. Use this from page-side
|
|
132
|
+
* `unstable_cache` wrappers so a transient Redis failure during a cold
|
|
133
|
+
* render produces a 500 (not cached) rather than memoizing a wrong
|
|
134
|
+
* `false` for the cache TTL.
|
|
135
|
+
*
|
|
136
|
+
* A missing `projectCfg:<slug>` key is NOT an error — that's the legitimate
|
|
137
|
+
* "project hasn't opted into hostAtDocs" case and is safe to cache.
|
|
138
|
+
*/
|
|
139
|
+
export async function getProjectConfigStrict(projectSlug: string): Promise<{ hostAtDocs: boolean }> {
|
|
140
|
+
if (!redis) {
|
|
141
|
+
return { hostAtDocs: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
|
|
145
|
+
const cfg = parseRedisConfig(cfgRaw);
|
|
146
|
+
return { hostAtDocs: cfg?.hostAtDocs === true };
|
|
147
|
+
}
|
|
148
|
+
|
|
122
149
|
/**
|
|
123
150
|
* Full project resolution with custom domain fallback.
|
|
124
151
|
*
|
|
@@ -19,6 +19,14 @@ export function clearConfigCache(_projectSlug?: string): void {}
|
|
|
19
19
|
|
|
20
20
|
export function isR2NotFound(_err: unknown): boolean { return false; }
|
|
21
21
|
|
|
22
|
+
export function withDataCache<T>(
|
|
23
|
+
fn: () => Promise<T>,
|
|
24
|
+
_keyParts: string[],
|
|
25
|
+
_tags: string[],
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
return fn();
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
let fetchDocsConfigWarned = false;
|
|
23
31
|
export async function fetchDocsConfig(_projectSlug: string): Promise<DocsConfig | null> {
|
|
24
32
|
if (!fetchDocsConfigWarned) {
|