jamdesk 1.1.70 → 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__/integration/validate.integration.test.js +21 -0
- package/dist/__tests__/integration/validate.integration.test.js.map +1 -1
- 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/extract-hooks.test.js +102 -1
- package/dist/__tests__/unit/extract-hooks.test.js.map +1 -1
- package/dist/__tests__/unit/mdx-validator.test.js +22 -0
- package/dist/__tests__/unit/mdx-validator.test.js.map +1 -1
- package/dist/__tests__/unit/migrate-mdx.test.js +121 -1
- package/dist/__tests__/unit/migrate-mdx.test.js.map +1 -1
- package/dist/__tests__/unit/relative-mdx-imports.test.d.ts +2 -0
- package/dist/__tests__/unit/relative-mdx-imports.test.d.ts.map +1 -0
- package/dist/__tests__/unit/relative-mdx-imports.test.js +452 -0
- package/dist/__tests__/unit/relative-mdx-imports.test.js.map +1 -0
- package/dist/__tests__/unit/relocate-snippets.test.d.ts +2 -0
- package/dist/__tests__/unit/relocate-snippets.test.d.ts.map +1 -0
- package/dist/__tests__/unit/relocate-snippets.test.js +542 -0
- package/dist/__tests__/unit/relocate-snippets.test.js.map +1 -0
- package/dist/__tests__/unit/run-build-script.test.js +23 -1
- package/dist/__tests__/unit/run-build-script.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/__tests__/unit/warn-relative-imports.test.d.ts +2 -0
- package/dist/__tests__/unit/warn-relative-imports.test.d.ts.map +1 -0
- package/dist/__tests__/unit/warn-relative-imports.test.js +44 -0
- package/dist/__tests__/unit/warn-relative-imports.test.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +16 -1
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/migrate/convert-mdx.d.ts +5 -1
- package/dist/commands/migrate/convert-mdx.d.ts.map +1 -1
- package/dist/commands/migrate/convert-mdx.js +19 -6
- package/dist/commands/migrate/convert-mdx.js.map +1 -1
- package/dist/commands/migrate/extract-hooks.d.ts +26 -0
- package/dist/commands/migrate/extract-hooks.d.ts.map +1 -1
- package/dist/commands/migrate/extract-hooks.js +71 -12
- package/dist/commands/migrate/extract-hooks.js.map +1 -1
- package/dist/commands/migrate/fix-mdx-syntax.d.ts +38 -0
- package/dist/commands/migrate/fix-mdx-syntax.d.ts.map +1 -0
- package/dist/commands/migrate/fix-mdx-syntax.js +80 -0
- package/dist/commands/migrate/fix-mdx-syntax.js.map +1 -0
- package/dist/commands/migrate/index.d.ts.map +1 -1
- package/dist/commands/migrate/index.js +165 -3
- package/dist/commands/migrate/index.js.map +1 -1
- package/dist/commands/migrate/relocate-snippets.d.ts +30 -0
- package/dist/commands/migrate/relocate-snippets.d.ts.map +1 -0
- package/dist/commands/migrate/relocate-snippets.js +357 -0
- package/dist/commands/migrate/relocate-snippets.js.map +1 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +31 -1
- package/dist/commands/validate.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/dist/lib/mdx-syntax-fixes.d.ts +14 -0
- package/dist/lib/mdx-syntax-fixes.d.ts.map +1 -0
- package/dist/lib/mdx-syntax-fixes.js +38 -0
- package/dist/lib/mdx-syntax-fixes.js.map +1 -0
- package/dist/lib/mdx-validator.d.ts +11 -2
- package/dist/lib/mdx-validator.d.ts.map +1 -1
- package/dist/lib/mdx-validator.js +41 -4
- package/dist/lib/mdx-validator.js.map +1 -1
- package/dist/lib/relative-mdx-imports.d.ts +44 -0
- package/dist/lib/relative-mdx-imports.d.ts.map +1 -0
- package/dist/lib/relative-mdx-imports.js +145 -0
- package/dist/lib/relative-mdx-imports.js.map +1 -0
- package/dist/lib/run-build-script.d.ts.map +1 -1
- package/dist/lib/run-build-script.js +7 -0
- package/dist/lib/run-build-script.js.map +1 -1
- package/dist/lib/warn-relative-imports.d.ts +2 -0
- package/dist/lib/warn-relative-imports.d.ts.map +1 -0
- package/dist/lib/warn-relative-imports.js +29 -0
- package/dist/lib/warn-relative-imports.js.map +1 -0
- 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/(unlock)/jd/unlock/page.tsx +32 -0
- package/vendored/app/[[...slug]]/page.tsx +63 -15
- package/vendored/app/globals.css +5 -0
- package/vendored/app/layout.tsx +36 -0
- package/vendored/components/navigation/Header.tsx +4 -2
- package/vendored/components/navigation/SocialFooter.tsx +4 -18
- package/vendored/lib/branding-url.ts +9 -0
- 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 +62 -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/scripts/compile-snippets.cjs +50 -7
- package/vendored/themes/index.ts +6 -4
- package/vendored/workspace-package-lock.json +118 -133
|
@@ -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[] = [];
|
|
@@ -518,6 +523,42 @@ export function normalizeJsxIndentation(content: string): string {
|
|
|
518
523
|
return result.join('\n');
|
|
519
524
|
}
|
|
520
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Un-indent a closing JSX tag absorbed into the previous markdown list item.
|
|
528
|
+
*
|
|
529
|
+
* Distinct from `unindentJsxAfterListItems` below: that one rewrites indented
|
|
530
|
+
* *opening* tags inside list context. Here the opening tag is at root level
|
|
531
|
+
* and only the closing tag is indented after the list — markdown reads it as
|
|
532
|
+
* listItem continuation, so MDX compiles see the parent component as unclosed
|
|
533
|
+
* ("Expected the closing tag `</Card>` either after the end of `listItem`...").
|
|
534
|
+
*
|
|
535
|
+
* CLI mirror: `builder/cli/src/lib/mdx-syntax-fixes.ts` — keep regex in sync.
|
|
536
|
+
*/
|
|
537
|
+
export function unindentClosingTagAfterList(content: string): string {
|
|
538
|
+
// Hot-path prefilter — preprocessMdx runs on every page render, and most
|
|
539
|
+
// MDX has no indented closing tags at all. Skip the split+walk in that case.
|
|
540
|
+
if (!/^\s+<\/[A-Z]/m.test(content)) return content;
|
|
541
|
+
|
|
542
|
+
const closingTagPattern = /^(\s+)(<\/[A-Z][\w-]*>)\s*$/;
|
|
543
|
+
const listItemPattern = /^\s*(?:[-*+]\s|\d+\.\s)/;
|
|
544
|
+
const lines = content.split('\n');
|
|
545
|
+
const out: string[] = [];
|
|
546
|
+
let lastNonBlank = '';
|
|
547
|
+
|
|
548
|
+
for (const line of lines) {
|
|
549
|
+
const closing = line.match(closingTagPattern);
|
|
550
|
+
if (closing && listItemPattern.test(lastNonBlank)) {
|
|
551
|
+
out.push(closing[2]);
|
|
552
|
+
lastNonBlank = closing[2];
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
out.push(line);
|
|
556
|
+
if (line.trim() !== '') lastNonBlank = line;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return out.join('\n');
|
|
560
|
+
}
|
|
561
|
+
|
|
521
562
|
/**
|
|
522
563
|
* Un-indent JSX blocks that follow markdown list items.
|
|
523
564
|
*
|
|
@@ -997,6 +1038,12 @@ export function preprocessMdx(content: string, options?: { assetVersion?: string
|
|
|
997
1038
|
// Convert noStyle to data-no-style for React compatibility
|
|
998
1039
|
processed = convertNoStyleToDataAttr(processed);
|
|
999
1040
|
|
|
1041
|
+
// Un-indent absorbed closing tags FIRST — handles the case where the
|
|
1042
|
+
// opening tag is at root level but the closing tag got indented after a
|
|
1043
|
+
// list. unindentJsxAfterListItems below only catches cases where the
|
|
1044
|
+
// opening tag itself is indented inside list context.
|
|
1045
|
+
processed = unindentClosingTagAfterList(processed);
|
|
1046
|
+
|
|
1000
1047
|
// Un-indent JSX blocks that follow list items to prevent
|
|
1001
1048
|
// "Expected closing tag before end of listItem" errors
|
|
1002
1049
|
// This must run BEFORE ensureBlankLinesInJsx since it changes block structure
|
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. */
|
|
@@ -64,6 +64,44 @@ export interface ValidationResult {
|
|
|
64
64
|
|
|
65
65
|
type Schema = Record<string, unknown>;
|
|
66
66
|
|
|
67
|
+
// Migration docs URL — embedded verbatim in every Mintlify-migration error message.
|
|
68
|
+
// error-parser.ts pattern-matches on this string to attach the step-by-step
|
|
69
|
+
// migrate suggestion, so it MUST stay in the user-facing message exactly as below.
|
|
70
|
+
// (Sentinel doubles as the customer-facing link target.)
|
|
71
|
+
export const MIGRATION_DOCS_URL = 'https://jamdesk.com/docs/setup/migration';
|
|
72
|
+
|
|
73
|
+
/** Mintlify themes (Mint pre-2.0 + Themes 2.0). Used as a fallback signal when
|
|
74
|
+
* `$schema` has been stripped during a partial migration attempt. */
|
|
75
|
+
const MINTLIFY_THEMES = [
|
|
76
|
+
'mint', 'maple', 'palm', 'willow', 'quill', 'aspen', 'linden', 'prism',
|
|
77
|
+
'almond', 'sequoia', 'luma',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Detect Mintlify by $schema URL — the durable signal Mintlify itself controls. */
|
|
81
|
+
function isMintlifyBySchema(config: unknown): boolean {
|
|
82
|
+
if (!config || typeof config !== 'object') return false;
|
|
83
|
+
const schema = (config as Record<string, unknown>).$schema;
|
|
84
|
+
return typeof schema === 'string' && schema.includes('mintlify.com');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Detect Mintlify by theme name (fallback when $schema is missing). */
|
|
88
|
+
function isMintlifyByThemeName(theme: unknown): boolean {
|
|
89
|
+
return typeof theme === 'string' && MINTLIFY_THEMES.includes(theme.toLowerCase());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build the "needs migration" error result. `reason` is interpolated as the
|
|
93
|
+
* leading sentence so each detection branch can explain *which* signal fired. */
|
|
94
|
+
function mintlifyMigrationError(reason: string): ValidationResult {
|
|
95
|
+
return {
|
|
96
|
+
valid: false,
|
|
97
|
+
error:
|
|
98
|
+
`Invalid docs.json: ${reason} This project hasn't been migrated to Jamdesk yet ` +
|
|
99
|
+
'— run `jamdesk migrate` from your project directory to convert your config ' +
|
|
100
|
+
`(and Mintlify-only MDX components) automatically. See ${MIGRATION_DOCS_URL} for details.`,
|
|
101
|
+
errorType: 'config_error',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
// Cache the schema
|
|
68
106
|
let cachedSchema: Schema | null = null;
|
|
69
107
|
|
|
@@ -267,6 +305,12 @@ export function formatValidationErrors(errors: ErrorObject[] | null | undefined)
|
|
|
267
305
|
export async function validateConfig(configPath: string, docsPath?: string): Promise<ValidationResult> {
|
|
268
306
|
// Check file exists
|
|
269
307
|
if (!fs.existsSync(configPath)) {
|
|
308
|
+
// If `mint.json` sits where docs.json should be, the project is mid-migration
|
|
309
|
+
// (or hasn't started). Give the migrate hint instead of the generic "missing".
|
|
310
|
+
const mintPath = path.join(path.dirname(configPath), 'mint.json');
|
|
311
|
+
if (fs.existsSync(mintPath)) {
|
|
312
|
+
return mintlifyMigrationError('Found `mint.json` but no `docs.json`.');
|
|
313
|
+
}
|
|
270
314
|
const pathHint = docsPath
|
|
271
315
|
? `Missing docs.json at '${docsPath}/docs.json'. Check that your monorepo path is correct.`
|
|
272
316
|
: 'Missing docs.json configuration file. Your repository must have a docs.json file in the root directory.';
|
|
@@ -288,7 +332,15 @@ export async function validateConfig(configPath: string, docsPath?: string): Pro
|
|
|
288
332
|
error: `Failed to parse docs.json: ${error.message}. Ensure your JSON is valid (tip: use JSON5 format to allow comments).`,
|
|
289
333
|
};
|
|
290
334
|
}
|
|
291
|
-
|
|
335
|
+
|
|
336
|
+
// Mintlify-shaped configs trip every structural rule below (their
|
|
337
|
+
// `navigation` is an array, anchors are required keys, etc.). Detect via
|
|
338
|
+
// $schema BEFORE structural validation so customers see "run jamdesk migrate"
|
|
339
|
+
// instead of "Array of groups is not supported".
|
|
340
|
+
if (isMintlifyBySchema(config)) {
|
|
341
|
+
return mintlifyMigrationError('Mintlify `$schema` detected.');
|
|
342
|
+
}
|
|
343
|
+
|
|
292
344
|
// Basic validation (always runs, even without schema)
|
|
293
345
|
if (!config.name) {
|
|
294
346
|
return {
|
|
@@ -327,13 +379,22 @@ export async function validateConfig(configPath: string, docsPath?: string): Pro
|
|
|
327
379
|
};
|
|
328
380
|
}
|
|
329
381
|
|
|
330
|
-
// Validate theme
|
|
382
|
+
// Validate theme (case-insensitive — "MINT" still falls through to the Mintlify branch below).
|
|
331
383
|
const validThemes = ['jam', 'nebula', 'pulsar'];
|
|
332
|
-
if (config.theme
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
384
|
+
if (typeof config.theme === 'string') {
|
|
385
|
+
const themeLower = config.theme.toLowerCase();
|
|
386
|
+
if (!validThemes.includes(themeLower)) {
|
|
387
|
+
if (isMintlifyByThemeName(config.theme)) {
|
|
388
|
+
return mintlifyMigrationError(`Theme "${config.theme}" looks like a Mintlify theme.`);
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
valid: false,
|
|
392
|
+
error: `Invalid docs.json: Theme "${config.theme}" is not supported. Valid themes are: ${validThemes.join(', ')}.`,
|
|
393
|
+
errorType: 'config_error',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// Normalize so downstream Ajv schema (lowercase enum) accepts it.
|
|
397
|
+
config.theme = themeLower as 'jam' | 'nebula' | 'pulsar';
|
|
337
398
|
}
|
|
338
399
|
|
|
339
400
|
if (legacyAnchors) {
|
|
@@ -1577,7 +1577,7 @@
|
|
|
1577
1577
|
},
|
|
1578
1578
|
"hint": {
|
|
1579
1579
|
"type": "string",
|
|
1580
|
-
"description": "Optional hint shown on the unlock page (e.g., \"Ask
|
|
1580
|
+
"description": "Optional hint shown on the unlock page (e.g., \"Ask #docs-access on Slack\"). Plain text, no HTML.",
|
|
1581
1581
|
"maxLength": 200
|
|
1582
1582
|
},
|
|
1583
1583
|
"public": {
|
|
@@ -321,6 +321,22 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
321
321
|
|
|
322
322
|
const snippetFiles = findSnippetFiles(snippetsDir);
|
|
323
323
|
|
|
324
|
+
// Deterministic order: within a (dir, baseName) collision, process .mdx
|
|
325
|
+
// first so the .tsx/.jsx code file overwrites the wrapped-MDX output on
|
|
326
|
+
// disk. Without this, fs.readdir order varies by platform and the
|
|
327
|
+
// surviving file content is non-deterministic. Outside of collisions,
|
|
328
|
+
// the order doesn't matter — sort makes it stable.
|
|
329
|
+
const EXT_PRIORITY = { '.mdx': 0, '.md': 1, '.ts': 2, '.js': 3, '.tsx': 4, '.jsx': 5 };
|
|
330
|
+
snippetFiles.sort((a, b) => {
|
|
331
|
+
const aDir = path.join(a.dir, '');
|
|
332
|
+
const bDir = path.join(b.dir, '');
|
|
333
|
+
if (aDir !== bDir) return aDir < bDir ? -1 : 1;
|
|
334
|
+
const aBase = a.file.replace(/\.[^.]+$/, '');
|
|
335
|
+
const bBase = b.file.replace(/\.[^.]+$/, '');
|
|
336
|
+
if (aBase !== bBase) return aBase < bBase ? -1 : 1;
|
|
337
|
+
return (EXT_PRIORITY[path.extname(a.file)] ?? 99) - (EXT_PRIORITY[path.extname(b.file)] ?? 99);
|
|
338
|
+
});
|
|
339
|
+
|
|
324
340
|
if (snippetFiles.length === 0) {
|
|
325
341
|
console.log(' No snippet files found');
|
|
326
342
|
|
|
@@ -376,7 +392,8 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
376
392
|
name: exportName,
|
|
377
393
|
dir: dir,
|
|
378
394
|
fileName,
|
|
379
|
-
needsClient
|
|
395
|
+
needsClient,
|
|
396
|
+
sourceFile: dir ? path.join(dir, file) : file,
|
|
380
397
|
});
|
|
381
398
|
|
|
382
399
|
const status = written ? '(updated)' : '(unchanged)';
|
|
@@ -399,7 +416,8 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
399
416
|
name: baseName,
|
|
400
417
|
dir: dir,
|
|
401
418
|
fileName,
|
|
402
|
-
needsClient
|
|
419
|
+
needsClient,
|
|
420
|
+
sourceFile: dir ? path.join(dir, file) : file,
|
|
403
421
|
});
|
|
404
422
|
|
|
405
423
|
const status = written ? '(updated)' : '(unchanged)';
|
|
@@ -414,8 +432,33 @@ export const SnippetComponents: Record<string, React.ComponentType<any>> = {};
|
|
|
414
432
|
// For locale-specific snippets (e.g., es/Counter), we export under prefixed name (EsCounter).
|
|
415
433
|
// The buildSnippetAliasMap function handles mapping import aliases to the correct component.
|
|
416
434
|
|
|
435
|
+
// Dedupe by (dir, name): two source files with the same base name (e.g.
|
|
436
|
+
// `vercel-json-generator.mdx` + `vercel-json-generator.tsx`) compile to the
|
|
437
|
+
// same generated fileName. The on-disk file is correctly overwritten by
|
|
438
|
+
// the last writer (sorted above so code files win), but componentInfo
|
|
439
|
+
// would otherwise carry both entries and produce duplicate import/export
|
|
440
|
+
// lines in ProjectSnippets.tsx. Keep the last (= the code file).
|
|
441
|
+
const seenComponentKeys = new Map();
|
|
442
|
+
for (const c of componentInfo) {
|
|
443
|
+
const key = `${c.dir}|${c.name}`;
|
|
444
|
+
if (seenComponentKeys.has(key)) {
|
|
445
|
+
const prior = seenComponentKeys.get(key);
|
|
446
|
+
// stderr (not stdout) so `runBuildScript` surfaces this in non-verbose
|
|
447
|
+
// mode — without it, the `jamdesk dev` user sees the collision is
|
|
448
|
+
// silently resolved and the next collision (a different name) crashes
|
|
449
|
+
// Turbopack with no breadcrumb.
|
|
450
|
+
console.warn(
|
|
451
|
+
` ⚠ Snippet name collision: "${c.name}" defined in both ` +
|
|
452
|
+
`${prior.sourceFile} and ${c.sourceFile} — using ${c.sourceFile}. ` +
|
|
453
|
+
`Remove or rename one of these files to clear this warning.`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
seenComponentKeys.set(key, c);
|
|
457
|
+
}
|
|
458
|
+
const dedupedComponentInfo = [...seenComponentKeys.values()];
|
|
459
|
+
|
|
417
460
|
// Build unique import names for each component (prefixed with dir if needed)
|
|
418
|
-
const importInfo =
|
|
461
|
+
const importInfo = dedupedComponentInfo.map(c => {
|
|
419
462
|
const prefix = c.dir ? toPascalCase(c.dir.replace(/\//g, '-')) : '';
|
|
420
463
|
const uniqueName = prefix + c.name.charAt(0).toUpperCase() + c.name.slice(1);
|
|
421
464
|
// Strip the .tsx extension from fileName to get the import path
|
|
@@ -468,17 +511,17 @@ ${importInfo.map(c => ` ${c.uniqueName},`).join('\n')}
|
|
|
468
511
|
}
|
|
469
512
|
}
|
|
470
513
|
|
|
471
|
-
const clientCount =
|
|
472
|
-
const serverCount =
|
|
514
|
+
const clientCount = dedupedComponentInfo.filter(c => c.needsClient).length;
|
|
515
|
+
const serverCount = dedupedComponentInfo.length - clientCount;
|
|
473
516
|
|
|
474
517
|
console.log(`\n✅ Generated snippets:`);
|
|
475
518
|
console.log(` Index: ${outputPath} ${indexWritten ? '(updated)' : '(unchanged)'}`);
|
|
476
|
-
console.log(` Components: ${generatedDir}/ (${
|
|
519
|
+
console.log(` Components: ${generatedDir}/ (${dedupedComponentInfo.length} files)`);
|
|
477
520
|
if (removedCount > 0) {
|
|
478
521
|
console.log(` Removed: ${removedCount} stale file(s)`);
|
|
479
522
|
}
|
|
480
523
|
console.log(` Client components: ${clientCount}, Server components: ${serverCount}`);
|
|
481
|
-
console.log(` Exports: ${
|
|
524
|
+
console.log(` Exports: ${dedupedComponentInfo.map(c => c.name).join(', ')}\n`);
|
|
482
525
|
}
|
|
483
526
|
|
|
484
527
|
// Run the compilation
|