jamdesk 1.1.205 → 1.1.207

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.
Files changed (47) hide show
  1. package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts +20 -0
  2. package/dist/__tests__/unit/turbopack-loader-config-drift.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/turbopack-loader-config-drift.test.js +79 -0
  4. package/dist/__tests__/unit/turbopack-loader-config-drift.test.js.map +1 -0
  5. package/dist/__tests__/unit/vendored-sync.test.js +9 -0
  6. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  7. package/dist/lib/deps.js +3 -3
  8. package/dist/lib/deps.js.map +1 -1
  9. package/package.json +5 -5
  10. package/vendored/app/[[...slug]]/page.tsx +1 -102
  11. package/vendored/app/api/jd/auth/logout/route.ts +36 -4
  12. package/vendored/app/api/jd/unlock/route.ts +1 -4
  13. package/vendored/app/layout.tsx +43 -4
  14. package/vendored/components/CodeBlockCopyButton.tsx +7 -2
  15. package/vendored/components/layout/LayoutWrapper.tsx +7 -0
  16. package/vendored/components/mdx/CodeGroup.tsx +161 -13
  17. package/vendored/components/mdx/MDXComponents.tsx +93 -50
  18. package/vendored/components/navigation/Header.tsx +7 -0
  19. package/vendored/components/navigation/LanguageSelector.tsx +35 -0
  20. package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
  21. package/vendored/components/ui/CodePanel.tsx +88 -51
  22. package/vendored/components/ui/CodePanelModal.tsx +57 -36
  23. package/vendored/hooks/useWheelScrollChaining.ts +181 -0
  24. package/vendored/lib/auth-plane-write-warning.ts +84 -0
  25. package/vendored/lib/docs-types.ts +12 -0
  26. package/vendored/lib/jwt-key-build-warning.ts +38 -0
  27. package/vendored/lib/language-cookie.ts +20 -0
  28. package/vendored/lib/language-matcher.ts +124 -0
  29. package/vendored/lib/language-utils.ts +103 -0
  30. package/vendored/lib/languages-artifact.ts +160 -0
  31. package/vendored/lib/layout-helpers.tsx +16 -3
  32. package/vendored/lib/mdx-import-scan.ts +110 -0
  33. package/vendored/lib/middleware-helpers.ts +228 -1
  34. package/vendored/lib/page-timestamps.ts +36 -0
  35. package/vendored/lib/rehype-code-meta.ts +74 -9
  36. package/vendored/lib/render-doc-page.tsx +14 -3
  37. package/vendored/lib/revalidation-helpers.ts +3 -0
  38. package/vendored/lib/root-page-slug.ts +9 -4
  39. package/vendored/lib/shiki-transformers.ts +13 -0
  40. package/vendored/lib/static-artifacts.ts +70 -2
  41. package/vendored/lib/theme-preview-context.tsx +39 -0
  42. package/vendored/lib/theme-preview.ts +150 -0
  43. package/vendored/lib/unlock-audit.ts +10 -0
  44. package/vendored/next.config.js +32 -0
  45. package/vendored/schema/docs-schema.json +21 -0
  46. package/vendored/scripts/turbopack-js-to-ts-loader.cjs +51 -0
  47. package/vendored/workspace-package-lock.json +231 -177
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The language-preference cookie, in a module with NO imports.
3
+ *
4
+ * It lives alone because both sides need it and neither may reach the other:
5
+ * `middleware-helpers.ts` pulls in `next/server`, `@upstash/redis` (via
6
+ * ./redis and ./cached-redis) and the project resolver, while
7
+ * `language-utils.ts` is client code — the `'use client'` LanguageSelector
8
+ * imports it. Having language-utils import the constant from
9
+ * middleware-helpers would both close an import cycle (middleware-helpers
10
+ * already imports extractLanguageFromPath from language-utils, line 22) and
11
+ * drag the edge/Redis modules into the browser bundle. Neither `tsc --noEmit`
12
+ * nor vitest would catch that — only `next build` would, and this repo has
13
+ * already taken a production outage from exactly that class of bug.
14
+ */
15
+
16
+ /** Cookie carrying the visitor's chosen documentation language. */
17
+ export const LANGUAGE_COOKIE = 'jd-lang';
18
+
19
+ /** One year — this is a stated preference, not a session detail. */
20
+ export const LANGUAGE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Language routing module.
3
+ *
4
+ * Fetches a project's language-routing descriptor from `_languages.json` in R2,
5
+ * cached in Upstash Redis with a 5-minute TTL. Mirrors redirect-matcher.ts —
6
+ * same cache shape, same negative caching, same fail-open contract.
7
+ *
8
+ * Fail-open is not optional here: this runs inside proxy.ts, which has no error
9
+ * boundary. Any throw becomes a 500 on a page that would otherwise have served
10
+ * fine, so every path in this file returns null instead of raising.
11
+ */
12
+
13
+ import { redis } from './redis';
14
+ import type { LanguagesFile } from './languages-artifact';
15
+ import { getFileBufferFromR2 } from './r2';
16
+
17
+ // Cache TTL in seconds (5 minutes) — same as redirects.
18
+ const CACHE_TTL = 300;
19
+
20
+ // Redis cache key prefix.
21
+ const CACHE_PREFIX = 'languages:';
22
+
23
+ /** Sentinel written on a miss so a project without the file stops hitting R2. */
24
+ const EMPTY: LanguagesFile = {
25
+ version: 1,
26
+ autoRedirect: false,
27
+ defaultLanguage: '',
28
+ languages: [],
29
+ };
30
+
31
+ /** Structural gate — a half-written or hand-edited file must not reach callers. */
32
+ function isValid(value: unknown): value is LanguagesFile {
33
+ if (!value || typeof value !== 'object') return false;
34
+ const file = value as Partial<LanguagesFile>;
35
+ return (
36
+ typeof file.autoRedirect === 'boolean' &&
37
+ typeof file.defaultLanguage === 'string' &&
38
+ Array.isArray(file.languages) &&
39
+ file.languages.every((code) => typeof code === 'string')
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Clear cached language routing for a project (or all projects).
45
+ * Called during revalidation to pick up an updated `_languages.json` from R2.
46
+ */
47
+ export async function clearLanguageCache(projectName?: string): Promise<void> {
48
+ if (!redis) return;
49
+ try {
50
+ if (projectName) {
51
+ await redis.del(`${CACHE_PREFIX}${projectName}`);
52
+ return;
53
+ }
54
+ const keys: string[] = [];
55
+ let cursor = '0';
56
+ do {
57
+ const result: [string, string[]] = await redis.scan(cursor, {
58
+ match: `${CACHE_PREFIX}*`,
59
+ count: 100,
60
+ });
61
+ cursor = result[0];
62
+ keys.push(...result[1]);
63
+ } while (cursor !== '0');
64
+ if (keys.length > 0) {
65
+ const pipeline = redis.pipeline();
66
+ for (const key of keys) pipeline.del(key);
67
+ await pipeline.exec();
68
+ }
69
+ } catch {
70
+ // Ignore cache clear errors — a stale entry expires in CACHE_TTL anyway.
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch language routing for a project from R2 with Redis caching.
76
+ * Returns null when the project has no descriptor, or on any failure.
77
+ */
78
+ export async function getLanguageRouting(projectName: string): Promise<LanguagesFile | null> {
79
+ const cacheKey = `${CACHE_PREFIX}${projectName}`;
80
+
81
+ if (redis) {
82
+ try {
83
+ const cached = await redis.get<LanguagesFile>(cacheKey);
84
+ if (isValid(cached)) {
85
+ return cached.languages.length > 0 ? cached : null;
86
+ }
87
+ } catch (error) {
88
+ console.error('[Languages] Redis cache read failed:', error);
89
+ // Continue to R2.
90
+ }
91
+ }
92
+
93
+ try {
94
+ const buffer = await getFileBufferFromR2(projectName, '_languages.json');
95
+ if (!buffer) {
96
+ // Negative-cache the miss so a single-language project does not pay an R2
97
+ // round trip on every request for the next five minutes.
98
+ if (redis) {
99
+ try {
100
+ await redis.set(cacheKey, EMPTY, { ex: CACHE_TTL });
101
+ } catch {
102
+ // Ignore cache write errors.
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ const parsed: unknown = JSON.parse(buffer.toString('utf-8'));
109
+ if (!isValid(parsed)) return null;
110
+
111
+ if (redis) {
112
+ try {
113
+ await redis.set(cacheKey, parsed, { ex: CACHE_TTL });
114
+ } catch {
115
+ // Ignore cache write errors.
116
+ }
117
+ }
118
+
119
+ return parsed.languages.length > 0 ? parsed : null;
120
+ } catch (error) {
121
+ console.error('[Languages] Failed to load _languages.json:', error);
122
+ return null;
123
+ }
124
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { LanguageCode, LanguageConfig } from './docs-types';
8
8
  import LANGUAGE_CODES_JSON from './language-codes.json';
9
+ import { LANGUAGE_COOKIE, LANGUAGE_COOKIE_MAX_AGE } from './language-cookie';
9
10
 
10
11
  /**
11
12
  * Canonical language code list, shared with the CJS build script via
@@ -472,6 +473,13 @@ function storageKey(projectSlug: string): string {
472
473
  return `${LANGUAGE_STORAGE_KEY_PREFIX}${projectSlug}`;
473
474
  }
474
475
 
476
+ // Mirrors proxy.ts's `secure: process.env.NODE_ENV === 'production'` exactly
477
+ // — gated rather than hardcoded so a write from this client code doesn't
478
+ // silently downgrade the attribute the server's redirect already set.
479
+ // `NODE_ENV` is inlined at build time by Next.js, so this is a build-time
480
+ // constant even though it reads like a runtime check.
481
+ const COOKIE_SECURE_ATTR = process.env.NODE_ENV === 'production' ? '; secure' : '';
482
+
475
483
  /**
476
484
  * Save language preference to localStorage, scoped to project slug.
477
485
  * Handles private browsing mode gracefully.
@@ -484,6 +492,16 @@ export function saveLanguagePreference(code: LanguageCode, projectSlug: string):
484
492
  // localStorage not available (private browsing, etc.)
485
493
  // Fail silently
486
494
  }
495
+ // Mirror the choice into the cookie the edge reads. Without this, a visitor
496
+ // who overrides the server's Accept-Language guess is redirected back to the
497
+ // language they just rejected on their next visit to a locale root.
498
+ try {
499
+ document.cookie =
500
+ `${LANGUAGE_COOKIE}=${encodeURIComponent(code)}; path=/; max-age=${LANGUAGE_COOKIE_MAX_AGE}; samesite=lax${COOKIE_SECURE_ATTR}`;
501
+ } catch {
502
+ // Cookies blocked (private mode, strict settings) — localStorage still
503
+ // carries the preference for this browser, so degrade quietly.
504
+ }
487
505
  }
488
506
 
489
507
  /**
@@ -521,6 +539,11 @@ export function clearLanguagePreference(projectSlug: string): void {
521
539
  } catch {
522
540
  // localStorage not available
523
541
  }
542
+ try {
543
+ document.cookie = `${LANGUAGE_COOKIE}=; path=/; max-age=0; samesite=lax${COOKIE_SECURE_ATTR}`;
544
+ } catch {
545
+ // Ignore — see saveLanguagePreference.
546
+ }
524
547
  }
525
548
 
526
549
  /**
@@ -545,3 +568,83 @@ export function resolveLanguageWithFallback(
545
568
  }
546
569
  return 'en';
547
570
  }
571
+
572
+ /**
573
+ * Cap on Accept-Language segments we will parse. A header is attacker-
574
+ * controlled; parsing is O(segments x available) and middleware runs on every
575
+ * request, so we stop reading after a sane number of entries rather than let a
576
+ * crafted header burn edge CPU. Real browsers send fewer than 10.
577
+ */
578
+ const MAX_ACCEPT_LANGUAGE_SEGMENTS = 20;
579
+
580
+ /**
581
+ * Pick the best available language for an Accept-Language header.
582
+ *
583
+ * Returns `null` — meaning "do nothing" — when the header is absent, unusable,
584
+ * matches nothing available, or resolves to the site's default language. A null
585
+ * return is always safe: the caller simply serves the page it was going to
586
+ * serve. This function never throws; middleware has no error boundary, so a
587
+ * throw here would 500 the page.
588
+ *
589
+ * Matching is case-insensitive and two-pass: an exact tag match (`zh-Hans`)
590
+ * beats a base-language match (`zh`), so a site offering both gets the precise
591
+ * one.
592
+ */
593
+ export function negotiateLanguage(
594
+ header: string | null,
595
+ available: string[],
596
+ defaultLang: string,
597
+ ): string | null {
598
+ if (!header || available.length === 0) return null;
599
+
600
+ // Map lowercased code -> original casing, so we return the code exactly as
601
+ // the tenant configured it (callers build URLs from this).
602
+ const availableByLower = new Map<string, string>();
603
+ for (const code of available) {
604
+ availableByLower.set(code.toLowerCase(), code);
605
+ }
606
+
607
+ const parsed: Array<{ tag: string; q: number }> = [];
608
+ const segments = header.split(',', MAX_ACCEPT_LANGUAGE_SEGMENTS);
609
+
610
+ for (const segment of segments) {
611
+ const [rawTag, ...params] = segment.trim().split(';');
612
+ const tag = rawTag.trim().toLowerCase();
613
+ // '*' means "anything" — it expresses no preference, so we decline to guess.
614
+ if (!tag || tag === '*') continue;
615
+ if (!BCP47_LANGUAGE_RE.test(tag)) continue;
616
+
617
+ let q = 1;
618
+ for (const param of params) {
619
+ const match = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(param);
620
+ if (!match) continue;
621
+ const value = Number.parseFloat(match[1]);
622
+ // A malformed q (NaN) is ignored, leaving the default q=1 — RFC 9110
623
+ // treats an unparseable parameter as absent rather than fatal.
624
+ if (Number.isFinite(value)) q = value;
625
+ }
626
+ if (q <= 0) continue;
627
+ parsed.push({ tag, q });
628
+ }
629
+
630
+ if (parsed.length === 0) return null;
631
+
632
+ // Stable sort by descending q: equal-q tags keep header order, which is the
633
+ // client's own stated preference order.
634
+ parsed.sort((a, b) => b.q - a.q);
635
+
636
+ const defaultLower = defaultLang.toLowerCase();
637
+
638
+ for (const { tag } of parsed) {
639
+ // Pass 1: exact tag ('zh-hans' -> 'zh-Hans').
640
+ const exact = availableByLower.get(tag);
641
+ if (exact) return exact.toLowerCase() === defaultLower ? null : exact;
642
+
643
+ // Pass 2: base language ('fr-ca' -> 'fr').
644
+ const base = tag.split('-')[0];
645
+ const baseMatch = availableByLower.get(base);
646
+ if (baseMatch) return baseMatch.toLowerCase() === defaultLower ? null : baseMatch;
647
+ }
648
+
649
+ return null;
650
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The `_languages.json` descriptor shape, plus the pure builder for it — kept
3
+ * free of `./redis` and `./r2`.
4
+ *
5
+ * Two very different callers need it. `lib/language-matcher.ts` reads the file
6
+ * at the edge and therefore imports `./redis` (Upstash) and `./r2`. `build.ts`
7
+ * runs on Cloud Run and only ever *builds* the descriptor — a pure transform
8
+ * that must not pull an edge Redis client into the build image. Keeping the
9
+ * type and the builder here is what keeps those two apart: this module imports
10
+ * the `DocsConfig` type (erased at compile time) plus two import-light pure
11
+ * helpers — `./locale-helpers` and `./root-page-slug`, whose own chain
12
+ * (find-first-nav-page, language-utils, page-isr-helpers, shared/docs-subpath)
13
+ * is plain data and string work with no React, Next or network in it. The edge
14
+ * side imports THIS module `import type`-only, so none of that reaches the
15
+ * middleware bundle.
16
+ *
17
+ * `languages` and `defaultLanguage` are lowercased at build time by
18
+ * normalizeLanguageList(), so comparisons at the edge need no normalization.
19
+ */
20
+ import { normalizeLanguageList } from './locale-helpers';
21
+ import { findFirstPage } from './root-page-slug';
22
+ import type { DocsConfig } from './docs-types';
23
+
24
+ export interface LanguagesFile {
25
+ version: number;
26
+ generatedAt?: string;
27
+ /** Mirrors docs.json `localization.autoRedirect`. */
28
+ autoRedirect: boolean;
29
+ defaultLanguage: string;
30
+ languages: string[];
31
+ }
32
+
33
+ /**
34
+ * The descriptor that means "this project does not language-route", written
35
+ * whenever fewer than two languages are routable.
36
+ *
37
+ * Byte-identical in shape to language-matcher's EMPTY sentinel, deliberately:
38
+ * both `getLanguageRouting` return sites gate on `languages.length > 0` and
39
+ * hand the caller `null`, so this file reads at the edge exactly like no file
40
+ * at all.
41
+ *
42
+ * `autoRedirect` is hardcoded false rather than mirrored from docs.json. It is
43
+ * not a report of the tenant's setting here — it is this descriptor's own
44
+ * meaning, and `{autoRedirect: true, languages: []}` would be a file that
45
+ * contradicts itself for anyone reading it out of R2 during an incident.
46
+ */
47
+ function noRoutingDescriptor(): LanguagesFile {
48
+ return {
49
+ version: 1,
50
+ generatedAt: new Date().toISOString(),
51
+ autoRedirect: false,
52
+ defaultLanguage: '',
53
+ languages: [],
54
+ };
55
+ }
56
+
57
+ /** docs.json nav paths and on-disk page paths, reduced to one comparable form. */
58
+ function normalizePagePath(value: string): string {
59
+ return value
60
+ .replace(/\\/g, '/')
61
+ .replace(/^\/+/, '')
62
+ .replace(/\.mdx?$/i, '')
63
+ .toLowerCase();
64
+ }
65
+
66
+ /**
67
+ * Build the `_languages.json` descriptor for a project. ALWAYS returns one.
68
+ *
69
+ * Never null, and that is the whole contract. R2ContentUploader exposes no
70
+ * delete (lib/r2.ts:279-294), so "the file is absent" is not a state a build
71
+ * can reach for a project that has ever written one — every state has to be
72
+ * expressed by WRITING the truth over the top. An earlier version returned
73
+ * null both for the opted-out switch (correctly reasoned about in a comment)
74
+ * and for "fewer than two languages" (not), which meant a tenant who pulled a
75
+ * bad translation the documented way — `hidden: true` on the second language —
76
+ * skipped the write entirely and left `{autoRedirect: true, languages:
77
+ * ['en','fr']}` in R2 forever, 307-ing French visitors into the language just
78
+ * hidden, or into an `/fr` the build no longer produces. Silently: the success
79
+ * log sat inside the same `if`, so a skipped write looked exactly like a
80
+ * project that never had the feature. Single-language projects now write a
81
+ * no-routing descriptor too — ~93 small objects, one per build, which is the
82
+ * price of the absent state not existing.
83
+ *
84
+ * Two filters decide what may be a redirect DESTINATION:
85
+ *
86
+ * 1. Eligibility, from docs.json. `hidden: true` is the documented way to
87
+ * stage a translation that is not ready, and auto-redirecting a first-time
88
+ * visitor into it is the maximum possible surfacing of a language its
89
+ * author deliberately hid. `href` points at an external site with no local
90
+ * `/<code>` tree here, so redirecting there is a 307 into a 404.
91
+ *
92
+ * 2. Routability, from what this build actually PRODUCED. Config alone never
93
+ * proves a destination exists: `{"language": "de"}` added before anything
94
+ * is translated used to cost a broken entry in the language switcher — one
95
+ * click — and would now 307 every German-preferring visitor off the
96
+ * homepage into a 404. A non-default language is routable only when the
97
+ * page `/<code>` really renders is (a) inside that language's own tree and
98
+ * (b) among this build's pages.
99
+ *
100
+ * Both clauses are load-bearing. `findFirstPage` falls through to the WHOLE
101
+ * nav for a language block with no pages of its own, so (b) alone would
102
+ * happily route `/de` at an English page — English served under a German
103
+ * URL with `jd-lang=de` pinned, which is worse than not redirecting. And
104
+ * (a) alone would route a language whose tree exists but whose front door
105
+ * was never translated. The boundary this draws is deliberate: the test is
106
+ * the visitor's ENTRY POINT, not page count. One translated page is enough
107
+ * if it is the one `/<code>` lands on; two hundred are not enough if it is
108
+ * missing. Resolution goes through findFirstPage — the same module the HTML
109
+ * renderer and the markdown export both use — so this check cannot drift
110
+ * from what the URL actually serves; it is called with the lowercased code
111
+ * because that is the segment decideLanguageRedirect emits.
112
+ *
113
+ * The default language is exempt from (2). It is never a redirect destination
114
+ * — decideLanguageRedirect returns null as soon as the negotiated language is
115
+ * the default — but it has to stay in `languages` for negotiation to see it
116
+ * and for `defaultLanguage` to remain a member of its own list.
117
+ *
118
+ * @param builtPagePaths page paths this build produced, e.g. pageInfos[].path.
119
+ * Required, not defaulted: an empty list disables routing for every
120
+ * non-default language, and that must be a caller's decision rather than
121
+ * something a forgotten argument does quietly.
122
+ */
123
+ export function buildLanguagesArtifact(
124
+ docsConfig: DocsConfig,
125
+ builtPagePaths: readonly string[],
126
+ ): LanguagesFile {
127
+ const nav = docsConfig.navigation as
128
+ | { languages?: Array<{ language: string; default?: boolean; hidden?: boolean; href?: string }> }
129
+ | undefined;
130
+
131
+ // Applied HERE and not inside normalizeLanguageList(). That helper has
132
+ // another caller — deriveChunkLocale, via embedding-chunker.ts and
133
+ // json-ld.ts — which answers "what locale does this file belong to" for
134
+ // translation chunking and localized JSON-LD. A hidden or href-only entry is
135
+ // still a real locale for that question; it is only ineligible as an
136
+ // autoRedirect destination. Filtering inside normalizeLanguageList would
137
+ // have silently changed which pages get translated or how chunks are
138
+ // locale-tagged — a much bigger blast radius than this feature.
139
+ const eligible = (nav?.languages ?? []).filter((l) => l.hidden !== true && !l.href);
140
+ const entries = normalizeLanguageList(eligible);
141
+
142
+ const built = new Set(builtPagePaths.map(normalizePagePath));
143
+ const routable = entries.filter((entry) => {
144
+ if (entry.isDefault) return true;
145
+ const landing = normalizePagePath(findFirstPage(docsConfig, entry.code));
146
+ return landing.startsWith(`${entry.code}/`) && built.has(landing);
147
+ });
148
+
149
+ if (routable.length < 2) return noRoutingDescriptor();
150
+
151
+ const defaultEntry = routable.find((e) => e.isDefault) ?? routable[0];
152
+
153
+ return {
154
+ version: 1,
155
+ generatedAt: new Date().toISOString(),
156
+ autoRedirect: docsConfig.localization?.autoRedirect === true,
157
+ defaultLanguage: defaultEntry.code,
158
+ languages: routable.map((e) => e.code),
159
+ };
160
+ }
@@ -24,6 +24,7 @@ import {
24
24
  import type { BackgroundConfig, DocsConfig, FontConfig, LanguageCode } from '@/lib/docs-types';
25
25
  import { LinkPrefixProvider } from '@/lib/link-prefix-context';
26
26
  import { ProjectSlugProvider } from '@/lib/project-slug-context';
27
+ import { ThemePreviewProvider, type ThemePreviewValue } from '@/lib/theme-preview-context';
27
28
  import { getAnalyticsScript } from '@/lib/analytics-script';
28
29
  import { isConsentGatingEnabled, buildGatedScripts, getCmpScriptSrcs } from '@/lib/consent-gating';
29
30
  import { ConsentGate } from '@/components/ConsentGate';
@@ -410,6 +411,7 @@ interface DocsChromeProps {
410
411
  children: React.ReactNode;
411
412
  embed?: boolean;
412
413
  embedTheme?: 'light' | 'dark' | 'auto';
414
+ themePreview?: ThemePreviewValue;
413
415
  }
414
416
 
415
417
  /**
@@ -430,6 +432,7 @@ export async function DocsChrome({
430
432
  children,
431
433
  embed,
432
434
  embedTheme,
435
+ themePreview,
433
436
  }: DocsChromeProps): Promise<React.ReactElement> {
434
437
  // Lowercase to match docs-config canonical case — `data-theme="nebula"` CSS
435
438
  // selectors are case-sensitive, so `theme: "NEBULA"` from disk would silently
@@ -781,9 +784,19 @@ export async function DocsChrome({
781
784
  >
782
785
  <LinkPrefixProvider prefix={linkPrefix}>
783
786
  <ProjectSlugProvider slug={resolvedProjectSlug || ''}>
784
- <LayoutWrapper config={config} embed={embed}>
785
- {children}
786
- </LayoutWrapper>
787
+ <ThemePreviewProvider
788
+ value={
789
+ themePreview ?? {
790
+ enabled: false,
791
+ activeTheme: themeName ?? 'jam',
792
+ defaultTheme: themeName ?? 'jam',
793
+ }
794
+ }
795
+ >
796
+ <LayoutWrapper config={config} embed={embed}>
797
+ {children}
798
+ </LayoutWrapper>
799
+ </ThemePreviewProvider>
787
800
  </ProjectSlugProvider>
788
801
  </LinkPrefixProvider>
789
802
  <CodeBlockCopyButton />
@@ -0,0 +1,110 @@
1
+ // Extracted from app/[[...slug]]/page.tsx: Next.js's route/page typegen
2
+ // requires that a page.tsx export ONLY the known page-convention symbols
3
+ // (default, generateStaticParams, generateMetadata, dynamic, ...) — any
4
+ // other export fails `tsc`'s check of .next/types/app/**/page.ts with
5
+ // TS2344 ("does not satisfy the constraint '{ [x: string]: never }'").
6
+ // These helpers are used internally by generateStaticParams but also need
7
+ // to be unit-testable, so they live here instead and are imported (not
8
+ // re-exported) by page.tsx.
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getContentDir } from '@/lib/docs';
12
+ import { readFileSync as readMdxFile } from '@/lib/fs-readfile';
13
+
14
+ // Mirror of the CLI's detector regex in cli/src/lib/relative-mdx-imports.ts.
15
+ // Duplicated intentionally — build-service uses bundler module resolution
16
+ // and shouldn't reach into cli/src/. Keep the regex itself in sync with
17
+ // that file. Two intentional simplifications vs the CLI version:
18
+ // - no `g` flag (boolean test, not iteration)
19
+ // - fence stripping replaces matches with empty string instead of
20
+ // blank lines of equal count — line numbers don't matter here, only
21
+ // a yes/no skip decision (the CLI preserves them for warning text).
22
+ const PARENT_RELATIVE_MDX_IMPORT_RE =
23
+ /^[ \t]*import\s+(?:type\s+)?[\w$*{}, \n\r]+\s+from\s+["']\.{1,2}\/[^"']+\.mdx["']\s*;?/m;
24
+
25
+ const FENCED_CODE_BLOCK_RE =
26
+ /^( *)(```+|~~~+)[^\n]*\n([\s\S]*?)\n\1\2\s*$/gm;
27
+
28
+ // Cache for pageHasRelativeMdxImport, keyed by absolute file path,
29
+ // invalidated when the file's mtime changes. `generateStaticParams` runs
30
+ // on every nav in `jamdesk dev`, and the regex-test reads the FULL file
31
+ // (per fix 7205c17c). For dodo (~200 MDX × 70-94 KB) that's 14-19 MB of
32
+ // disk I/O per nav. Cache hit reduces it to a directory walk + statSync.
33
+ //
34
+ // In production ISR mode generateStaticParams returns [] before any of
35
+ // this runs (see isIsrMode early-return), so the cache is dev-only in
36
+ // practice. The cache is unbounded — entries for deleted files persist
37
+ // until process exit. For a dev-only ~200-file working set this is
38
+ // trivial (a few KB). If this pattern is reused in a long-running ISR
39
+ // context in the future, add LRU eviction or rebuild-on-walk.
40
+ const mdxImportCache = new Map<string, { mtimeMs: number; hasImport: boolean }>();
41
+
42
+ export function pageHasRelativeMdxImport(filePath: string, mtimeMs?: number): boolean {
43
+ const cached = mdxImportCache.get(filePath);
44
+ if (cached && mtimeMs !== undefined && cached.mtimeMs === mtimeMs) {
45
+ return cached.hasImport;
46
+ }
47
+
48
+ let hasImport: boolean;
49
+ try {
50
+ // Read the full file — MDX imports can appear at any top-level position
51
+ // (after a long prose intro or table of contents), and a slice was
52
+ // missing real imports past byte ~8192 in 70-94 KB customer pages.
53
+ const content = readMdxFile(filePath);
54
+ // Strip fenced code blocks so documentation examples (e.g.
55
+ // ```mdx\nimport X from "../snippets/foo.mdx";\n```) don't false-trigger.
56
+ hasImport = PARENT_RELATIVE_MDX_IMPORT_RE.test(content.replace(FENCED_CODE_BLOCK_RE, ''));
57
+ } catch {
58
+ hasImport = false;
59
+ }
60
+
61
+ if (mtimeMs !== undefined) {
62
+ mdxImportCache.set(filePath, { mtimeMs, hasImport });
63
+ }
64
+ return hasImport;
65
+ }
66
+
67
+ /** Test-only: clear the per-file MDX-import cache between test cases. */
68
+ export function _resetMdxImportCacheForTest(): void {
69
+ if (process.env.NODE_ENV === 'production') return;
70
+ mdxImportCache.clear();
71
+ }
72
+
73
+ interface CollectedPaths {
74
+ supported: string[];
75
+ skipped: string[];
76
+ }
77
+
78
+ export function getAllDocPaths(): CollectedPaths {
79
+ const contentDir = getContentDir();
80
+ const supported: string[] = [];
81
+ const skipped: string[] = [];
82
+
83
+ function traverseDir(dir: string, basePath: string = '') {
84
+ if (!fs.existsSync(dir)) return;
85
+ const files = fs.readdirSync(dir);
86
+ for (const file of files) {
87
+ if (file.startsWith('.')) continue;
88
+ const filePath = path.join(dir, file);
89
+ let stat;
90
+ try {
91
+ stat = fs.statSync(filePath);
92
+ } catch {
93
+ continue;
94
+ }
95
+ if (stat.isDirectory()) {
96
+ traverseDir(filePath, path.join(basePath, file));
97
+ } else if (file.endsWith('.mdx')) {
98
+ const slug = path.join(basePath, file.replace(/\.mdx$/, ''));
99
+ if (pageHasRelativeMdxImport(filePath, stat.mtimeMs)) {
100
+ skipped.push(slug);
101
+ } else {
102
+ supported.push(slug);
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ traverseDir(contentDir);
109
+ return { supported, skipped };
110
+ }