jamdesk 1.1.71 → 1.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/unit/dev-loading-server.test.js +30 -14
- package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -1
- package/dist/__tests__/unit/vendored-sync.test.js +5 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +7 -5
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +53 -6
- package/dist/lib/docs-config.js.map +1 -1
- package/package.json +2 -2
- package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +118 -0
- package/vendored/app/(jd-system)/jd-inactive/layout.tsx +12 -0
- package/vendored/app/(jd-system)/jd-inactive/page.tsx +40 -0
- package/vendored/app/globals.css +5 -0
- package/vendored/app/layout.tsx +36 -0
- package/vendored/components/navigation/Header.tsx +4 -2
- package/vendored/lib/build/error-parser.ts +26 -0
- package/vendored/lib/docs-isr.ts +33 -19
- package/vendored/lib/docs-types.ts +1 -1
- package/vendored/lib/email-notifier.ts +1 -1
- package/vendored/lib/isr-build-executor.ts +1 -1
- package/vendored/lib/layout-helpers.tsx +54 -2
- package/vendored/lib/middleware-helpers.ts +46 -8
- package/vendored/lib/preprocess-mdx.ts +20 -15
- package/vendored/lib/redis.ts +86 -0
- package/vendored/lib/revalidation-trigger.ts +29 -15
- package/vendored/lib/validate-config.ts +68 -7
- package/vendored/schema/docs-schema.json +1 -1
- package/vendored/themes/index.ts +6 -4
- package/vendored/workspace-package-lock.json +115 -130
package/vendored/app/layout.tsx
CHANGED
|
@@ -23,6 +23,15 @@ import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
|
|
|
23
23
|
import { DocsChrome, getLocalFileContent } from '@/lib/layout-helpers';
|
|
24
24
|
|
|
25
25
|
export async function generateMetadata(): Promise<Metadata> {
|
|
26
|
+
// The placeholder shell would otherwise render with the paused
|
|
27
|
+
// project's title/favicon (and pay an R2 round-trip for it) — the
|
|
28
|
+
// nested (jd-system)/jd-inactive layout supplies neutral metadata.
|
|
29
|
+
if (isIsrMode()) {
|
|
30
|
+
const placeholderCheck = await headers();
|
|
31
|
+
if (placeholderCheck.get('x-jd-layout') === 'placeholder') {
|
|
32
|
+
return {robots: {index: false, follow: false}};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
26
35
|
let config: DocsConfig;
|
|
27
36
|
if (isIsrMode()) {
|
|
28
37
|
const headersList = await headers();
|
|
@@ -58,6 +67,33 @@ export default async function RootLayout({
|
|
|
58
67
|
}) {
|
|
59
68
|
const headersList = await headers();
|
|
60
69
|
|
|
70
|
+
// Placeholder short-circuit: proxy sets x-jd-layout=placeholder when
|
|
71
|
+
// rewriting an inactive site to /jd-inactive. Skip docs chrome (would
|
|
72
|
+
// otherwise leak the customer's nav, search, sidebar around our
|
|
73
|
+
// paused-site placeholder) and render a minimal dark shell.
|
|
74
|
+
if (headersList.get('x-jd-layout') === 'placeholder') {
|
|
75
|
+
return (
|
|
76
|
+
<html lang="en" suppressHydrationWarning>
|
|
77
|
+
<head>
|
|
78
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
79
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
80
|
+
<meta name="theme-color" content="#0a0a0d" />
|
|
81
|
+
</head>
|
|
82
|
+
<body
|
|
83
|
+
style={{
|
|
84
|
+
margin: 0,
|
|
85
|
+
minHeight: '100vh',
|
|
86
|
+
backgroundColor: '#0a0a0d',
|
|
87
|
+
colorScheme: 'dark',
|
|
88
|
+
}}
|
|
89
|
+
suppressHydrationWarning
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</body>
|
|
93
|
+
</html>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
61
97
|
// Unlock-mode short-circuit: middleware sets x-jd-unlock-mode when
|
|
62
98
|
// rewriting to /jd/unlock. Skip docs chrome — the unlock page renders
|
|
63
99
|
// its own minimal shell via app/(unlock)/jd/unlock/page.tsx.
|
|
@@ -59,8 +59,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
59
59
|
// Determine effective tabsPosition (same logic as Sidebar)
|
|
60
60
|
const themeConfig = getTheme(config.theme);
|
|
61
61
|
|
|
62
|
-
// Nebula theme uses compact search (icon only) instead of full search bar
|
|
63
|
-
|
|
62
|
+
// Nebula theme uses compact search (icon only) instead of full search bar.
|
|
63
|
+
// Read from `themeConfig.name` (canonical-case from registry) rather than
|
|
64
|
+
// `config.theme` so uppercase docs.json values like "NEBULA" still match.
|
|
65
|
+
const useCompactSearch = themeConfig.name === 'nebula';
|
|
64
66
|
const effectiveTabsPosition: TabsPosition = config.tabsPosition || tabsPositionProp || themeConfig.defaultTabsPosition;
|
|
65
67
|
const showTabsInHeader = effectiveTabsPosition === 'top';
|
|
66
68
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ErrorDetails } from '../../shared/types.js';
|
|
2
2
|
import { DEPRECATED_COMPONENTS } from '../deprecated-components.js';
|
|
3
|
+
import { MIGRATION_DOCS_URL } from '../validate-config.js';
|
|
3
4
|
|
|
4
5
|
/** Format: ERR-XXXXXXXX (first 8 chars of buildId, uppercase) */
|
|
5
6
|
export function generateErrorRef(buildId: string): string {
|
|
@@ -108,6 +109,31 @@ export function parseErrorDetails(
|
|
|
108
109
|
// Extract error source information upfront - used by multiple error types
|
|
109
110
|
const errorSource = extractErrorSource(output, pageToFileMap);
|
|
110
111
|
|
|
112
|
+
// Mintlify migration needed — must come before the generic config_error branch
|
|
113
|
+
// so the migrate-specific suggestion wins. We match on the migration docs URL
|
|
114
|
+
// (which every Mintlify-detection branch in validate-config embeds) rather than
|
|
115
|
+
// a prose phrase, so future copy edits can't silently break the contract.
|
|
116
|
+
if (message.includes(MIGRATION_DOCS_URL)) {
|
|
117
|
+
return {
|
|
118
|
+
type: 'config_error',
|
|
119
|
+
message: 'Mintlify config detected — migration needed',
|
|
120
|
+
details: message,
|
|
121
|
+
suggestion:
|
|
122
|
+
'Your docs.json is still configured for Mintlify. Jamdesk includes a ' +
|
|
123
|
+
'tool that converts Mintlify projects automatically.\n\n' +
|
|
124
|
+
'From your project\'s root directory, run:\n\n' +
|
|
125
|
+
' npm install -g jamdesk\n' +
|
|
126
|
+
' jamdesk migrate\n\n' +
|
|
127
|
+
'The migration will:\n' +
|
|
128
|
+
'• Convert mint.json / docs.json to the Jamdesk format\n' +
|
|
129
|
+
'• Update the theme to "jam"\n' +
|
|
130
|
+
'• Convert Mintlify-only MDX components (e.g. <CardGroup> → <Columns>)\n' +
|
|
131
|
+
'• Rewrite parent-relative snippet imports\n\n' +
|
|
132
|
+
'Then commit the changes and push — the next build will pick them up.\n\n' +
|
|
133
|
+
'Full guide: https://jamdesk.com/docs/setup/migration',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
// Configuration validation errors (from validate phase)
|
|
112
138
|
if (message.includes('Missing docs.json') || message.includes('Invalid docs.json')) {
|
|
113
139
|
return {
|
package/vendored/lib/docs-isr.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
|
+
import { cache } from 'react';
|
|
15
16
|
import {
|
|
16
17
|
fetchDocsConfig,
|
|
17
18
|
fetchMdxContent,
|
|
@@ -72,17 +73,19 @@ function walkMdx(dir: string): string[] {
|
|
|
72
73
|
return out;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
// React cache() — request-scoped memoization. Layered on top of Next.js
|
|
77
|
+
// Data Cache (which fetchDocsConfig already wraps via unstable_cache) so
|
|
78
|
+
// repeated calls within the same render pass don't re-enter the Data Cache
|
|
79
|
+
// either. Production fra1 logs showed 3 R2 fetches per request from
|
|
80
|
+
// layout.generateMetadata + layout render + content-loader.getConfig — when
|
|
81
|
+
// they all hit a Data Cache miss simultaneously (fresh region/cold cache),
|
|
82
|
+
// each pays a cross-continent round-trip (~300ms each). cache() collapses
|
|
83
|
+
// these to one in-flight promise.
|
|
84
|
+
//
|
|
85
|
+
// Caveat: cache() is documented to dedupe across generateMetadata + page
|
|
86
|
+
// render in the same request, but historical Next.js bugs (#50080, #67133)
|
|
87
|
+
// occasionally split contexts. In the worst case this still reduces 3→2.
|
|
88
|
+
async function getDocsConfigUncached(projectSlug: string): Promise<DocsConfig> {
|
|
86
89
|
requireIsrMode();
|
|
87
90
|
const config = await fetchDocsConfig(projectSlug);
|
|
88
91
|
if (config) return config;
|
|
@@ -99,6 +102,14 @@ export async function getDocsConfig(projectSlug: string): Promise<DocsConfig> {
|
|
|
99
102
|
throw new Error(`Project not found in R2: ${projectSlug}`);
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Get docs.json configuration for a project.
|
|
107
|
+
*
|
|
108
|
+
* Request-scoped (React cache) on top of Next.js Data Cache. Same projectSlug
|
|
109
|
+
* within one render → one R2 fetch.
|
|
110
|
+
*/
|
|
111
|
+
export const getDocsConfig = cache(getDocsConfigUncached);
|
|
112
|
+
|
|
102
113
|
/**
|
|
103
114
|
* Get all document paths for a project.
|
|
104
115
|
*
|
|
@@ -114,14 +125,7 @@ export async function getAllDocPaths(projectSlug: string): Promise<string[]> {
|
|
|
114
125
|
return listAllPaths(projectSlug);
|
|
115
126
|
}
|
|
116
127
|
|
|
117
|
-
|
|
118
|
-
* Get raw MDX content for a page.
|
|
119
|
-
*
|
|
120
|
-
* @param projectSlug - The project identifier
|
|
121
|
-
* @param pagePath - Path to the page (e.g., 'api/auth')
|
|
122
|
-
* @returns Raw MDX content string
|
|
123
|
-
*/
|
|
124
|
-
export async function getMdxContent(
|
|
128
|
+
async function getMdxContentUncached(
|
|
125
129
|
projectSlug: string,
|
|
126
130
|
pagePath: string
|
|
127
131
|
): Promise<string> {
|
|
@@ -141,6 +145,16 @@ export async function getMdxContent(
|
|
|
141
145
|
}
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Get raw MDX content for a page.
|
|
150
|
+
*
|
|
151
|
+
* Request-scoped (React cache) so generateMetadata's frontmatter read and
|
|
152
|
+
* the subsequent page render share one R2 fetch instead of paying it twice.
|
|
153
|
+
* Production fra1 logs showed every page making 2× fetchMdxContent calls
|
|
154
|
+
* for the same path (~600ms duplicated work per request).
|
|
155
|
+
*/
|
|
156
|
+
export const getMdxContent = cache(getMdxContentUncached);
|
|
157
|
+
|
|
144
158
|
/**
|
|
145
159
|
* Get snippet content.
|
|
146
160
|
*
|
|
@@ -758,7 +758,7 @@ export interface SpellcheckConfig {
|
|
|
758
758
|
export interface PasswordAuthConfig {
|
|
759
759
|
/** Opt in to password protection. Site is only gated when this is true AND a password has been set in the dashboard. */
|
|
760
760
|
enabled?: boolean;
|
|
761
|
-
/** Optional hint shown on the unlock page (e.g., "Ask
|
|
761
|
+
/** Optional hint shown on the unlock page (e.g., "Ask #docs-access on Slack"). Plain text, no HTML. */
|
|
762
762
|
hint?: string;
|
|
763
763
|
/** Paths or globs that bypass the password check. Supports '*' (one path segment) and '**' (recursive). */
|
|
764
764
|
public?: string[];
|
|
@@ -38,7 +38,7 @@ export async function sendInternalBuildFailureEmail(info: BuildFailureEmailInfo)
|
|
|
38
38
|
const result = await resend.emails.send({
|
|
39
39
|
from: 'Jamdesk <no-reply@mail.jamdesk.com>',
|
|
40
40
|
to: reportEmail,
|
|
41
|
-
subject: `
|
|
41
|
+
subject: `Jamdesk build failed: ${info.projectName || info.projectId}`,
|
|
42
42
|
html,
|
|
43
43
|
text,
|
|
44
44
|
});
|
|
@@ -294,7 +294,7 @@ export const ISR_PHASES = {
|
|
|
294
294
|
optimize_images: { label: 'Optimizing images...', weight: 5 },
|
|
295
295
|
r2_upload: { label: 'Uploading to CDN...', weight: 35 },
|
|
296
296
|
embeddings: { label: 'Indexing AI search + chat...', weight: 5 },
|
|
297
|
-
vercel_purge: { label: 'Refreshing cache...', weight:
|
|
297
|
+
vercel_purge: { label: 'Refreshing cache...', weight: 10 },
|
|
298
298
|
cleanup: { label: 'Cleaning up...', weight: 5 },
|
|
299
299
|
} as const;
|
|
300
300
|
|
|
@@ -26,6 +26,31 @@ import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
|
26
26
|
import { ProjectSlugProvider } from '@/lib/project-slug-context';
|
|
27
27
|
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
28
28
|
|
|
29
|
+
const scrollLockBootstrap = `
|
|
30
|
+
(function() {
|
|
31
|
+
try {
|
|
32
|
+
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
|
|
33
|
+
var unlocked = false;
|
|
34
|
+
function unlock() {
|
|
35
|
+
if (unlocked) return;
|
|
36
|
+
unlocked = true;
|
|
37
|
+
var el = document.getElementById('content-scroll-container');
|
|
38
|
+
if (el) el.scrollTop = 0;
|
|
39
|
+
document.documentElement.removeAttribute('data-scroll-locked');
|
|
40
|
+
}
|
|
41
|
+
function ready() {
|
|
42
|
+
requestAnimationFrame(function() { requestAnimationFrame(unlock); });
|
|
43
|
+
}
|
|
44
|
+
if (document.readyState === 'loading') {
|
|
45
|
+
document.addEventListener('DOMContentLoaded', ready, { once: true });
|
|
46
|
+
} else {
|
|
47
|
+
ready();
|
|
48
|
+
}
|
|
49
|
+
setTimeout(unlock, 2000);
|
|
50
|
+
} catch (e) {}
|
|
51
|
+
})();
|
|
52
|
+
`;
|
|
53
|
+
|
|
29
54
|
// Pre-load fonts at module scope — Next.js requires this for static analysis.
|
|
30
55
|
export const inter = Inter({
|
|
31
56
|
subsets: ['latin'],
|
|
@@ -216,7 +241,10 @@ export async function DocsChrome({
|
|
|
216
241
|
customJs,
|
|
217
242
|
children,
|
|
218
243
|
}: DocsChromeProps): Promise<React.ReactElement> {
|
|
219
|
-
|
|
244
|
+
// Lowercase to match docs-config canonical case — `data-theme="nebula"` CSS
|
|
245
|
+
// selectors are case-sensitive, so `theme: "NEBULA"` from disk would silently
|
|
246
|
+
// skip every theme-scoped rule without this normalization.
|
|
247
|
+
const themeName = (config.theme?.toLowerCase() as ThemeName | undefined) ?? undefined;
|
|
220
248
|
const themeCss = themeName && themeName !== 'jam' ? getThemeCssContent(themeName) : null;
|
|
221
249
|
const fontClassName = getFontClassName(themeName, config.fonts);
|
|
222
250
|
|
|
@@ -259,8 +287,32 @@ export async function DocsChrome({
|
|
|
259
287
|
preload('/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
|
|
260
288
|
|
|
261
289
|
return (
|
|
262
|
-
<html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth">
|
|
290
|
+
<html lang={lang} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
|
|
263
291
|
<head>
|
|
292
|
+
{/*
|
|
293
|
+
SSR scroll lock — prevents Chrome's same-tab cross-origin "preserve
|
|
294
|
+
scroll" heuristic from scrolling #content-scroll-container on first
|
|
295
|
+
paint. Custom inner-scroll layout (body{overflow:hidden} +
|
|
296
|
+
#content-scroll-container{overflow-y:auto}) means body.scrollY=0 but
|
|
297
|
+
the inner container inherits the previous origin's scroll ratio.
|
|
298
|
+
history.scrollRestoration='manual' doesn't suppress this code path.
|
|
299
|
+
Solution: ship overflow-y:hidden on the container via a CSS rule
|
|
300
|
+
gated on html[data-scroll-locked], release it on DOMContentLoaded +
|
|
301
|
+
2 frames so Chrome's restore attempts hit a structurally
|
|
302
|
+
non-scrollable element (scrollTop pinned to 0).
|
|
303
|
+
*/}
|
|
304
|
+
<script
|
|
305
|
+
dangerouslySetInnerHTML={{
|
|
306
|
+
__html: scrollLockBootstrap,
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
{/* JS-off fallback — bootstrap can't unlock, so override the rule. */}
|
|
310
|
+
<noscript
|
|
311
|
+
dangerouslySetInnerHTML={{
|
|
312
|
+
__html:
|
|
313
|
+
'<style>html[data-scroll-locked] #content-scroll-container{overflow-y:auto !important;}</style>',
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
264
316
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
265
317
|
{config.fonts && (
|
|
266
318
|
<>
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
getProjectConfig,
|
|
14
14
|
isSubdomain,
|
|
15
15
|
} from './project-resolver';
|
|
16
|
-
import { redis } from './redis';
|
|
16
|
+
import { redis, getProjectInactive } from './redis';
|
|
17
17
|
import { getForwardedHosts, isJamdeskDomain } from './domain-helpers';
|
|
18
18
|
import { getRedirects, matchRedirect, mergeQueryStrings, isInvalidDestination } from './redirect-matcher';
|
|
19
19
|
import { ASSET_PREFIX } from './docs-types';
|
|
@@ -46,6 +46,20 @@ export interface ProjectResolutionResult {
|
|
|
46
46
|
skip?: boolean;
|
|
47
47
|
/** Domain verification status */
|
|
48
48
|
domainStatus?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Project owner's subscription is inactive — proxy rewrites to the
|
|
51
|
+
* `/jd-inactive` placeholder instead of serving real content. Set by
|
|
52
|
+
* Stripe webhook propagation (canceled/past_due/incomplete_expired)
|
|
53
|
+
* and cleared on successful payment.
|
|
54
|
+
*/
|
|
55
|
+
inactive?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* True when the project resolved via the custom-domain branch
|
|
58
|
+
* (i.e., a user's own domain pointing at our CNAME), vs via the
|
|
59
|
+
* subdomain branch (slug.jamdesk.app). Used by the branded inactive
|
|
60
|
+
* page to show owner-sign-in hints only to real customers.
|
|
61
|
+
*/
|
|
62
|
+
customDomain?: boolean;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
/**
|
|
@@ -127,24 +141,33 @@ export async function handleProjectResolution(
|
|
|
127
141
|
const customForwardedHost = forwardedHosts.find(h => !isJamdeskDomain(h));
|
|
128
142
|
|
|
129
143
|
if (customForwardedHost) {
|
|
130
|
-
// Resolve hostAtDocs from custom domain config
|
|
131
|
-
|
|
144
|
+
// Resolve hostAtDocs from custom domain config (batched with
|
|
145
|
+
// inactive flag — both are Redis reads off the resolved slug).
|
|
146
|
+
const [customResolution, inactive] = await Promise.all([
|
|
147
|
+
resolveCustomDomain(customForwardedHost),
|
|
148
|
+
getProjectInactive(projectSlug),
|
|
149
|
+
]);
|
|
132
150
|
log('info', 'Project resolved from subdomain with forwarded custom domain', {
|
|
133
151
|
hostname,
|
|
134
152
|
projectSlug,
|
|
135
153
|
forwardedHost: customForwardedHost,
|
|
136
154
|
hostAtDocs: customResolution.hostAtDocs,
|
|
155
|
+
inactive,
|
|
137
156
|
});
|
|
138
157
|
return {
|
|
139
158
|
projectSlug,
|
|
140
159
|
hostAtDocs: customResolution.hostAtDocs,
|
|
160
|
+
inactive,
|
|
141
161
|
};
|
|
142
162
|
}
|
|
143
163
|
|
|
144
|
-
// No forwarded custom domain
|
|
145
|
-
const config = await
|
|
146
|
-
|
|
147
|
-
|
|
164
|
+
// No forwarded custom domain — batch config + inactive lookup.
|
|
165
|
+
const [config, inactive] = await Promise.all([
|
|
166
|
+
getProjectConfig(projectSlug),
|
|
167
|
+
getProjectInactive(projectSlug),
|
|
168
|
+
]);
|
|
169
|
+
log('info', 'Project resolved from subdomain', { hostname, projectSlug, inactive });
|
|
170
|
+
return { projectSlug, hostAtDocs: config.hostAtDocs, inactive };
|
|
148
171
|
}
|
|
149
172
|
}
|
|
150
173
|
|
|
@@ -171,11 +194,17 @@ export async function handleProjectResolution(
|
|
|
171
194
|
};
|
|
172
195
|
}
|
|
173
196
|
|
|
174
|
-
|
|
197
|
+
// Piggyback inactive read on the custom-domain resolution path. Single
|
|
198
|
+
// extra Redis GET per request; the proxy reads .inactive to rewrite.
|
|
199
|
+
const inactive = await getProjectInactive(resolution.projectSlug);
|
|
200
|
+
|
|
201
|
+
log('info', 'Project resolved from custom domain', { hostname, projectSlug: resolution.projectSlug, inactive });
|
|
175
202
|
return {
|
|
176
203
|
projectSlug: resolution.projectSlug,
|
|
177
204
|
hostAtDocs: resolution.hostAtDocs,
|
|
178
205
|
domainStatus: resolution.domainStatus,
|
|
206
|
+
inactive,
|
|
207
|
+
customDomain: true,
|
|
179
208
|
};
|
|
180
209
|
}
|
|
181
210
|
|
|
@@ -558,6 +587,15 @@ const TRUSTED_PROXY_HEADERS = [
|
|
|
558
587
|
'x-host-at-docs',
|
|
559
588
|
'x-jd-language',
|
|
560
589
|
'x-jd-unlock-mode',
|
|
590
|
+
// Placeholder branch — set by the inactive rewrite. The layout
|
|
591
|
+
// gate (`x-jd-layout: placeholder`) controls whether the chrome is
|
|
592
|
+
// skipped, and a forged `x-jd-custom-domain: 1` would let an
|
|
593
|
+
// attacker on a subdomain ALSO toggle the "owner can sign in"
|
|
594
|
+
// copy. Strip them all from the inbound request.
|
|
595
|
+
'x-jd-layout',
|
|
596
|
+
'x-jd-custom-domain',
|
|
597
|
+
'x-jd-project-name',
|
|
598
|
+
'x-jd-project-logo',
|
|
561
599
|
] as const;
|
|
562
600
|
|
|
563
601
|
/**
|
|
@@ -39,31 +39,37 @@ const JSX_CONTENT_COMPONENTS = [
|
|
|
39
39
|
'Visibility',
|
|
40
40
|
] as const;
|
|
41
41
|
|
|
42
|
+
// Match a fenced code block (``` or ~~~), used to mask code-block content
|
|
43
|
+
// before scanning for top-level snippet imports — otherwise import lines
|
|
44
|
+
// inside docs-on-snippets code examples are treated as real imports and
|
|
45
|
+
// trigger 5+ failing R2 fetches per page render.
|
|
46
|
+
const FENCED_CODE_BLOCK_RE =
|
|
47
|
+
/^( *)(```+|~~~+)[^\n]*\n([\s\S]*?)\n\1\2\s*$/gm;
|
|
48
|
+
|
|
42
49
|
/**
|
|
43
50
|
* Strip snippet import statements from MDX content.
|
|
44
51
|
*
|
|
45
|
-
*
|
|
46
|
-
* - import { Component } from "/snippets/file.mdx"
|
|
47
|
-
* - import { Component } from '../snippets/file.mdx'
|
|
48
|
-
* - import Component from "/snippets/file.mdx"
|
|
49
|
-
*
|
|
50
|
-
* Note: Relative MDX imports (./file.mdx, ../folder/file.mdx) that DON'T
|
|
51
|
-
* reference /snippets/ are NOT stripped. These need to be handled by MDX
|
|
52
|
-
* compiler or will fail at build time with a helpful error.
|
|
52
|
+
* Imports inside fenced code blocks are preserved (documentation examples).
|
|
53
53
|
*
|
|
54
54
|
* @param content - Raw MDX content
|
|
55
55
|
* @returns MDX content with snippet imports removed
|
|
56
56
|
*/
|
|
57
57
|
export function stripSnippetImports(content: string): string {
|
|
58
|
-
// Only strip imports from /snippets/ directory
|
|
59
58
|
const snippetsPattern = /^import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?["'](?:\.\.\/)*\/?snippets\/[^"']+["'];?\s*$/gm;
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const codeBlocks: string[] = [];
|
|
60
|
+
const masked = content.replace(FENCED_CODE_BLOCK_RE, (match) => {
|
|
61
|
+
const idx = codeBlocks.length;
|
|
62
|
+
codeBlocks.push(match);
|
|
63
|
+
return '\0CB' + idx + '\0';
|
|
64
|
+
});
|
|
65
|
+
const stripped = masked.replace(snippetsPattern, '');
|
|
66
|
+
return stripped.replace(/\0CB(\d+)\0/g, (_, idx) => codeBlocks[Number(idx)]);
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/**
|
|
65
70
|
* Extract snippet import information from MDX content.
|
|
66
|
-
*
|
|
71
|
+
*
|
|
72
|
+
* Imports inside fenced code blocks are ignored (documentation examples).
|
|
67
73
|
*
|
|
68
74
|
* @param content - Raw MDX content
|
|
69
75
|
* @returns Array of import info objects
|
|
@@ -74,12 +80,11 @@ export function extractSnippetImports(content: string): Array<{
|
|
|
74
80
|
path: string;
|
|
75
81
|
}> {
|
|
76
82
|
const results: Array<{ statement: string; imports: string[]; path: string }> = [];
|
|
77
|
-
|
|
78
|
-
// Match import statements from /snippets/
|
|
83
|
+
const scanContent = content.replace(FENCED_CODE_BLOCK_RE, '');
|
|
79
84
|
const importPattern = /^(import\s+(?:(?:\{([^}]*)\}|(\w+)|\*\s+as\s+(\w+))\s+from\s+)?["']((?:\.\.\/)*\/?snippets\/[^"']+)["'];?\s*)$/gm;
|
|
80
85
|
|
|
81
86
|
let match;
|
|
82
|
-
while ((match = importPattern.exec(
|
|
87
|
+
while ((match = importPattern.exec(scanContent)) !== null) {
|
|
83
88
|
const [, statement, namedImports, defaultImport, namespaceImport, importPath] = match;
|
|
84
89
|
|
|
85
90
|
const imports: string[] = [];
|
package/vendored/lib/redis.ts
CHANGED
|
@@ -115,3 +115,89 @@ export async function deleteDomainAuthSecret(
|
|
|
115
115
|
if (!kvUrl || !kvToken) return;
|
|
116
116
|
await upstashCommand(kvUrl, kvToken, 'DEL', `domainAuthSecret:${hostname}`);
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Project inactive flag.
|
|
121
|
+
// Set when the project owner's subscription is canceled, past_due, or
|
|
122
|
+
// incomplete_expired. Read by builder/proxy middleware to serve the
|
|
123
|
+
// inactive placeholder instead of the real site. No TTL — the flag lives
|
|
124
|
+
// until explicitly cleared by a successful payment webhook.
|
|
125
|
+
// Fails open (returns false) both when Redis is not configured AND when
|
|
126
|
+
// the underlying GET throws (network blip, Upstash outage). Availability
|
|
127
|
+
// wins over the inactive-site gate — a flaky Redis shouldn't 500 every
|
|
128
|
+
// request on every docs site.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
const PROJECT_INACTIVE_PREFIX = 'projectInactive:';
|
|
132
|
+
|
|
133
|
+
// Key shape: projectInactive:<slug>. The project ID is NOT the edge
|
|
134
|
+
// key — callers with a projectId must resolve via the dashboard's
|
|
135
|
+
// `writeProjectInactiveBySlug` helper first.
|
|
136
|
+
export async function getProjectInactive(slug: string): Promise<boolean> {
|
|
137
|
+
if (!redis) return false;
|
|
138
|
+
try {
|
|
139
|
+
// Upstash SDK auto-parses the body as JSON, so the stored string
|
|
140
|
+
// "1" comes back as the number 1. Accept either to keep the gate
|
|
141
|
+
// honest regardless of how the value round-trips.
|
|
142
|
+
const value = await redis.get<string | number>(
|
|
143
|
+
`${PROJECT_INACTIVE_PREFIX}${slug}`,
|
|
144
|
+
);
|
|
145
|
+
return value === '1' || value === 1;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.warn('[redis] getProjectInactive failed, failing open:', {
|
|
148
|
+
slug,
|
|
149
|
+
error: (err as Error).message,
|
|
150
|
+
});
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function setProjectInactive(
|
|
156
|
+
slug: string,
|
|
157
|
+
inactive: boolean,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
if (!redis) return;
|
|
160
|
+
const key = `${PROJECT_INACTIVE_PREFIX}${slug}`;
|
|
161
|
+
if (inactive) {
|
|
162
|
+
await redis.set(key, '1');
|
|
163
|
+
} else {
|
|
164
|
+
await redis.del(key);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Project meta (display name + logo) for the inactive placeholder.
|
|
170
|
+
// Mirrored to Redis at the same write site as projectInactive so the edge
|
|
171
|
+
// brand mark renders without a Firestore or R2 round trip. Lifecycle is
|
|
172
|
+
// 1:1 with projectInactive — written when the flag is set, deleted when
|
|
173
|
+
// it's cleared. Optional logoUrl tracks docs.json `logo` (single string
|
|
174
|
+
// only; theme-variant maps degrade to the name-only brand mark).
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const PROJECT_META_PREFIX = 'projectMeta:';
|
|
178
|
+
|
|
179
|
+
export interface ProjectMeta {
|
|
180
|
+
name: string;
|
|
181
|
+
logoUrl?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function getProjectMeta(
|
|
185
|
+
slug: string,
|
|
186
|
+
): Promise<ProjectMeta | null> {
|
|
187
|
+
if (!redis) return null;
|
|
188
|
+
try {
|
|
189
|
+
const value = await redis.get<ProjectMeta>(
|
|
190
|
+
`${PROJECT_META_PREFIX}${slug}`,
|
|
191
|
+
);
|
|
192
|
+
if (!value || typeof value.name !== 'string' || !value.name) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.warn('[redis] getProjectMeta failed, returning null:', {
|
|
198
|
+
slug,
|
|
199
|
+
error: (err as Error).message,
|
|
200
|
+
});
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -19,7 +19,13 @@ export interface RevalidationOptions {
|
|
|
19
19
|
export async function triggerRevalidation(options: RevalidationOptions): Promise<void> {
|
|
20
20
|
const { projectSlug, changedPaths, all } = options;
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Fail loud if unset — the previous default `https://docs.jamdesk.app` is an
|
|
23
|
+
// orphan domain whose cert silently expired (May 2026 incident); falling back
|
|
24
|
+
// to it produced "fetch failed" with no clue to the cause.
|
|
25
|
+
const isrAppUrl = (process.env.ISR_APP_URL || process.env.VERCEL_DEPLOYMENT_URL || '').trim();
|
|
26
|
+
if (!isrAppUrl) {
|
|
27
|
+
throw new Error('Revalidation failed: ISR_APP_URL not configured');
|
|
28
|
+
}
|
|
23
29
|
const secret = (process.env.REVALIDATE_SECRET || '').trim();
|
|
24
30
|
|
|
25
31
|
const body: Record<string, unknown> = {
|
|
@@ -55,22 +61,30 @@ async function revalidateIsrApp(
|
|
|
55
61
|
secret: string,
|
|
56
62
|
body: Record<string, unknown>,
|
|
57
63
|
): Promise<{ ok: boolean; status?: number; error?: string; revalidated?: string[] }> {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// Wrap fetch in try/catch so network-level failures (DNS, TLS, ECONNREFUSED)
|
|
65
|
+
// surface through the same { ok: false } path as HTTP errors. Without this,
|
|
66
|
+
// a raw `TypeError: fetch failed` escapes to the build error handler with no
|
|
67
|
+
// hint that revalidation was the failing step (May 2026 incident).
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`${isrAppUrl}/api/revalidate`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'x-revalidate-secret': secret,
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
});
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
const error = await response.text();
|
|
80
|
+
return { ok: false, status: response.status, error };
|
|
81
|
+
}
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
const result = (await response.json()) as { success: boolean; revalidated?: string[] };
|
|
84
|
+
return { ok: true, revalidated: result.revalidated };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { ok: false, error: `${(err as Error).message} (url=${isrAppUrl})` };
|
|
87
|
+
}
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
/** Purge the proxy's Vercel CDN cache. Non-fatal on failure. */
|