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.
@@ -0,0 +1,470 @@
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
+ Project-subdomain hydration-error + dev-overlay suppression.
313
+ Gated to production so the inline <script> doesn't trigger React
314
+ 19's "scripts inside React components are never executed when
315
+ rendering on the client" warning in `jamdesk dev`. The IIFE
316
+ itself already self-gates to non-localhost subdomains, so dev
317
+ was a no-op — gating the JSX is pure dev-console cleanup.
318
+ */}
319
+ {process.env.NODE_ENV === 'production' && (
320
+ <script
321
+ dangerouslySetInnerHTML={{
322
+ __html: `
323
+ (function() {
324
+ var h = location.hostname;
325
+ var isSubdomain = h.split('.').length > 2 && h.indexOf('localhost') === -1 && h.indexOf('vercel.app') === -1;
326
+ if (!isSubdomain) return;
327
+
328
+ window.__IS_PROJECT_SITE__ = true;
329
+
330
+ window.addEventListener('error', function(e) {
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ console.log('[Project] Suppressed error:', e.message);
334
+ return true;
335
+ }, true);
336
+
337
+ window.addEventListener('unhandledrejection', function(e) {
338
+ e.preventDefault();
339
+ console.log('[Project] Suppressed rejection:', e.reason);
340
+ return true;
341
+ }, true);
342
+
343
+ var origError = console.error;
344
+ console.error = function() {
345
+ var msg = arguments[0];
346
+ if (typeof msg === 'string' && (
347
+ msg.indexOf('Hydration') !== -1 ||
348
+ msg.indexOf('hydrat') !== -1 ||
349
+ msg.indexOf('did not match') !== -1 ||
350
+ msg.indexOf('server-rendered') !== -1
351
+ )) {
352
+ console.log('[Project] Suppressed hydration warning');
353
+ return;
354
+ }
355
+ origError.apply(console, arguments);
356
+ };
357
+
358
+ var style = document.createElement('style');
359
+ style.textContent = 'nextjs-portal, [data-next-badge], [data-next-badge-root], next-badge-root, [data-nextjs-dialog-root] { display: none !important; visibility: hidden !important; }';
360
+ document.head.appendChild(style);
361
+
362
+ var removeDevIndicators = function() {
363
+ document.querySelectorAll('next-badge-root, [data-next-badge-root], nextjs-portal').forEach(function(el) {
364
+ el.remove();
365
+ });
366
+ };
367
+ removeDevIndicators();
368
+ var observeBody = function() {
369
+ if (document.body) {
370
+ new MutationObserver(removeDevIndicators).observe(document.body, { childList: true, subtree: true });
371
+ }
372
+ };
373
+ if (document.body) { observeBody(); } else { document.addEventListener('DOMContentLoaded', observeBody, { once: true }); }
374
+ })();
375
+ `,
376
+ }}
377
+ />
378
+ )}
379
+ {/*
380
+ precedence + stable href lets React 19 hoist these into <head> as
381
+ managed resources and dedupe across renders, so theme/font styles
382
+ aren't recreated on each soft navigation.
383
+ */}
384
+ {fontImports && (
385
+ <style
386
+ precedence="jd-layout"
387
+ href={`jd-fonts-${resolvedProjectSlug}`}
388
+ >
389
+ {fontImports}
390
+ </style>
391
+ )}
392
+ {fontVariables && (
393
+ <style
394
+ precedence="jd-layout"
395
+ href={`jd-font-vars-${resolvedProjectSlug}`}
396
+ >
397
+ {fontVariables}
398
+ </style>
399
+ )}
400
+ {themeCss && (
401
+ <style precedence="jd-layout" href={`jd-theme-${themeName ?? 'jam'}`}>
402
+ {themeCss}
403
+ </style>
404
+ )}
405
+ {primaryColorVariables && (
406
+ <style
407
+ precedence="jd-layout"
408
+ href={`jd-colors-${resolvedProjectSlug}`}
409
+ >
410
+ {primaryColorVariables}
411
+ </style>
412
+ )}
413
+ {customCss && (
414
+ <style
415
+ precedence="jd-layout"
416
+ href={`jd-custom-${resolvedProjectSlug}`}
417
+ >
418
+ {customCss}
419
+ </style>
420
+ )}
421
+ {process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
422
+ <PlausibleScript
423
+ domain={config.integrations.plausible.domain}
424
+ server={config.integrations.plausible.server}
425
+ scriptUrl={config.integrations.plausible.scriptUrl}
426
+ />
427
+ )}
428
+ </head>
429
+ <body className={fontClassName} data-theme={themeName || 'jam'}>
430
+ {config.integrations?.gtm?.tagId && (
431
+ <ConditionalGTM gtmId={config.integrations.gtm.tagId} />
432
+ )}
433
+ <ThemeProvider
434
+ defaultTheme={appearanceDefault}
435
+ forcedTheme={appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined}
436
+ >
437
+ <LinkPrefixProvider prefix={linkPrefix}>
438
+ <ProjectSlugProvider slug={resolvedProjectSlug || ''}>
439
+ <LayoutWrapper config={config}>
440
+ {children}
441
+ </LayoutWrapper>
442
+ </ProjectSlugProvider>
443
+ </LinkPrefixProvider>
444
+ <CodeBlockCopyButton />
445
+ <HeaderLinkCopy />
446
+ <FontAwesomeLoader />
447
+ <HtmlLangSync defaultLanguage={projectDefaultLang} />
448
+ </ThemeProvider>
449
+ {config.integrations?.crisp?.websiteId &&
450
+ /^[a-f0-9-]{36}$/.test(config.integrations.crisp.websiteId) && (
451
+ <script
452
+ dangerouslySetInnerHTML={{
453
+ __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)})();`,
454
+ }}
455
+ />
456
+ )}
457
+ {customJs && (
458
+ <script dangerouslySetInnerHTML={{ __html: customJs }} />
459
+ )}
460
+ {analyticsScript && (
461
+ <script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
462
+ )}
463
+ {config.integrations?.ga4?.measurementId && (
464
+ <ConditionalGA gaId={config.integrations.ga4.measurementId} />
465
+ )}
466
+ <JdReadySentinel />
467
+ </body>
468
+ </html>
469
+ );
470
+ }
@@ -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) {