jamdesk 1.1.206 → 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 (34) hide show
  1. package/dist/__tests__/unit/vendored-sync.test.js +9 -0
  2. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  3. package/dist/lib/deps.js +3 -3
  4. package/dist/lib/deps.js.map +1 -1
  5. package/package.json +5 -5
  6. package/vendored/app/layout.tsx +43 -4
  7. package/vendored/components/CodeBlockCopyButton.tsx +7 -2
  8. package/vendored/components/layout/LayoutWrapper.tsx +7 -0
  9. package/vendored/components/mdx/CodeGroup.tsx +161 -13
  10. package/vendored/components/mdx/MDXComponents.tsx +93 -50
  11. package/vendored/components/navigation/Header.tsx +7 -0
  12. package/vendored/components/navigation/LanguageSelector.tsx +35 -0
  13. package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
  14. package/vendored/components/ui/CodePanel.tsx +88 -51
  15. package/vendored/components/ui/CodePanelModal.tsx +57 -36
  16. package/vendored/hooks/useWheelScrollChaining.ts +181 -0
  17. package/vendored/lib/docs-types.ts +12 -0
  18. package/vendored/lib/language-cookie.ts +20 -0
  19. package/vendored/lib/language-matcher.ts +124 -0
  20. package/vendored/lib/language-utils.ts +103 -0
  21. package/vendored/lib/languages-artifact.ts +160 -0
  22. package/vendored/lib/layout-helpers.tsx +16 -3
  23. package/vendored/lib/middleware-helpers.ts +228 -1
  24. package/vendored/lib/page-timestamps.ts +36 -0
  25. package/vendored/lib/rehype-code-meta.ts +74 -9
  26. package/vendored/lib/render-doc-page.tsx +14 -3
  27. package/vendored/lib/revalidation-helpers.ts +3 -0
  28. package/vendored/lib/root-page-slug.ts +9 -4
  29. package/vendored/lib/shiki-transformers.ts +13 -0
  30. package/vendored/lib/static-artifacts.ts +70 -2
  31. package/vendored/lib/theme-preview-context.tsx +39 -0
  32. package/vendored/lib/theme-preview.ts +150 -0
  33. package/vendored/schema/docs-schema.json +21 -0
  34. package/vendored/workspace-package-lock.json +207 -153
@@ -11,9 +11,23 @@ interface CodePanelModalProps {
11
11
  tabs: CodePanelTab[];
12
12
  title?: string;
13
13
  initialTabIndex?: number;
14
+ /**
15
+ * Suppress the header copy button, Cmd/Ctrl+C shortcut, and its footer hint
16
+ * for every tab. A tab's own `hideCopy` overrides this while it is active —
17
+ * the modal tracks its own activeTab, so it resolves this itself rather than
18
+ * receiving the panel's already-resolved value.
19
+ */
20
+ hideCopy?: boolean;
14
21
  }
15
22
 
16
- export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex = 0 }: CodePanelModalProps) {
23
+ export function CodePanelModal({
24
+ isOpen,
25
+ onClose,
26
+ tabs,
27
+ title,
28
+ initialTabIndex = 0,
29
+ hideCopy = false,
30
+ }: CodePanelModalProps) {
17
31
  const [activeTab, setActiveTab] = useState(initialTabIndex);
18
32
  const [copied, setCopied] = useState(false);
19
33
  const [isClosing, setIsClosing] = useState(false);
@@ -22,6 +36,9 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
22
36
  const contentRef = useRef<HTMLDivElement>(null);
23
37
 
24
38
  const currentTab = tabs[activeTab];
39
+ // Must agree with CodePanel's own resolution, or expanding a nocopy tab to
40
+ // fullscreen would hand back the copy button the panel just suppressed.
41
+ const copyHidden = currentTab?.hideCopy ?? hideCopy;
25
42
 
26
43
  // Handle close with animation
27
44
  const handleClose = useCallback(() => {
@@ -87,7 +104,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
87
104
  }
88
105
 
89
106
  // Cmd/Ctrl+C to copy (when not selecting text)
90
- if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !window.getSelection()?.toString()) {
107
+ if (!copyHidden && (e.metaKey || e.ctrlKey) && e.key === 'c' && !window.getSelection()?.toString()) {
91
108
  e.preventDefault();
92
109
  handleCopy();
93
110
  return;
@@ -113,7 +130,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
113
130
 
114
131
  document.addEventListener('keydown', handleKeyDown);
115
132
  return () => document.removeEventListener('keydown', handleKeyDown);
116
- }, [isOpen, handleClose, handleCopy]);
133
+ }, [isOpen, handleClose, handleCopy, copyHidden]);
117
134
 
118
135
  if (!isOpen) return null;
119
136
 
@@ -224,27 +241,29 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
224
241
  {/* Right: Actions */}
225
242
  <div className="flex items-center gap-2">
226
243
  {/* Copy Button */}
227
- <button
228
- onClick={handleCopy}
229
- className="p-2 rounded-md transition-colors cursor-pointer"
230
- style={{ color: codePanelColors.textMuted }}
231
- onMouseEnter={(e) => {
232
- e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
233
- e.currentTarget.style.color = codePanelColors.text;
234
- }}
235
- onMouseLeave={(e) => {
236
- e.currentTarget.style.backgroundColor = 'transparent';
237
- e.currentTarget.style.color = codePanelColors.textMuted;
238
- }}
239
- title="Copy code"
240
- aria-label="Copy code to clipboard"
241
- >
242
- {copied ? (
243
- <i className="fa-solid fa-check text-emerald-500" aria-hidden="true" />
244
- ) : (
245
- <i className="fa-regular fa-copy" aria-hidden="true" />
246
- )}
247
- </button>
244
+ {!copyHidden && (
245
+ <button
246
+ onClick={handleCopy}
247
+ className="p-2 rounded-md transition-colors cursor-pointer"
248
+ style={{ color: codePanelColors.textMuted }}
249
+ onMouseEnter={(e) => {
250
+ e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
251
+ e.currentTarget.style.color = codePanelColors.text;
252
+ }}
253
+ onMouseLeave={(e) => {
254
+ e.currentTarget.style.backgroundColor = 'transparent';
255
+ e.currentTarget.style.color = codePanelColors.textMuted;
256
+ }}
257
+ title="Copy code"
258
+ aria-label="Copy code to clipboard"
259
+ >
260
+ {copied ? (
261
+ <i className="fa-solid fa-check text-emerald-500" aria-hidden="true" />
262
+ ) : (
263
+ <i className="fa-regular fa-copy" aria-hidden="true" />
264
+ )}
265
+ </button>
266
+ )}
248
267
 
249
268
  {/* Close Button */}
250
269
  <button
@@ -304,18 +323,20 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
304
323
  </kbd>
305
324
  <span>Close</span>
306
325
  </span>
307
- <span className="flex items-center gap-1.5">
308
- <kbd
309
- className="px-1.5 py-0.5 rounded text-[10px] font-mono"
310
- style={{
311
- backgroundColor: codePanelColors.tabHoverBg,
312
- border: `0.5px solid ${codePanelColors.border}`,
313
- }}
314
- >
315
- {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+C
316
- </kbd>
317
- <span>Copy</span>
318
- </span>
326
+ {!copyHidden && (
327
+ <span className="flex items-center gap-1.5">
328
+ <kbd
329
+ className="px-1.5 py-0.5 rounded text-[10px] font-mono"
330
+ style={{
331
+ backgroundColor: codePanelColors.tabHoverBg,
332
+ border: `0.5px solid ${codePanelColors.border}`,
333
+ }}
334
+ >
335
+ {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}+C
336
+ </kbd>
337
+ <span>Copy</span>
338
+ </span>
339
+ )}
319
340
  </div>
320
341
  {currentTab?.language && (
321
342
  <span className="uppercase tracking-wide">{currentTab.language}</span>
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Restores native scroll chaining for the desktop three-column layout.
7
+ *
8
+ * At >=1024px the layout shell is `lg:h-screen lg:overflow-hidden`
9
+ * (LayoutWrapper), so the document never scrolls — `#content-scroll-container`
10
+ * does. That makes every sibling column a scroll dead end: the browser looks
11
+ * for an ancestor to chain the wheel to, finds only `overflow: hidden`, and
12
+ * drops the event.
13
+ *
14
+ * The worst case is the TOC <aside>, which PageColumns renders unconditionally
15
+ * at xl+ while TableOfContents returns null when the page has no h2/h3. On a
16
+ * heading-less page that leaves ~288px (a quarter of a 1280px viewport) where
17
+ * the wheel does nothing at all. A TOC too short to overflow, a short sidebar
18
+ * nav, and the shell's own horizontal padding fail the same way.
19
+ *
20
+ * This walks up from the event target looking for any scroll container. If
21
+ * there is none, the delta is handed to the content column — which is what the
22
+ * browser would have done had the ancestors not been `overflow: hidden`.
23
+ *
24
+ * Scope is deliberately narrower than true scroll chaining: a column that CAN
25
+ * scroll but has hit its end keeps the wheel and stops, exactly as it does
26
+ * today. Widening that is a separate, visible behaviour change.
27
+ *
28
+ * Listener is passive: nothing else was going to scroll, so there is no
29
+ * default to prevent and no scroll-blocking cost.
30
+ *
31
+ * Verifying this with Playwright: its synthetic wheel reports a deltaY twice
32
+ * what Chrome actually applies (confirmed on a bare overflow-y:auto box —
33
+ * event 200 -> 100px scrolled). The harness can show WHETHER a region scrolls,
34
+ * never HOW FAR. Real wheel events are 1:1 in DOM_DELTA_PIXEL.
35
+ */
36
+
37
+ /** Fallback px-per-line for wheels reporting DOM_DELTA_LINE (Firefox). */
38
+ const LINE_HEIGHT_PX = 16;
39
+
40
+ /**
41
+ * Does `el` own this wheel event — either by declaring it, or by being a
42
+ * scroll container with somewhere left to go in either direction?
43
+ *
44
+ * Deliberately direction-agnostic. A sidebar already scrolled to its bottom
45
+ * still counts as "owns the wheel", so this hook does not take the wheel away
46
+ * from a column that merely ran out of room — that would be full scroll
47
+ * chaining, a behaviour change well beyond restoring the dead zones. Only a
48
+ * region that can never scroll at all hands its wheel to the content column.
49
+ *
50
+ * Only overflowY auto/scroll qualifies: a code block with `overflow-x: auto`
51
+ * and `overflow-y: hidden` must stay transparent to a vertical wheel, or every
52
+ * wide snippet becomes its own dead zone.
53
+ *
54
+ * The ORDER of the reads below is load-bearing, not incidental. overflowY and
55
+ * overscrollBehaviorY are style-only resolved values — reading them can cost a
56
+ * style recalc, never a layout. scrollHeight/clientHeight ARE layout-dependent
57
+ * and flush layout if it is dirty. Because the overflow test gates them, an
58
+ * ancestor that is not a scroll container costs one cheap read, and a typical
59
+ * 6-10 node walk forces zero layouts. Hoisting the scroll-range check above the
60
+ * overflow check would make every wheel event force layout on every ancestor.
61
+ */
62
+ function ownsTheWheel(el: Element): boolean {
63
+ const style = getComputedStyle(el);
64
+
65
+ // `overscroll-behavior-y: contain | none` is an author saying explicitly
66
+ // "scrolling must not chain out of me". Honour it whether or not the element
67
+ // currently has room — an empty chat transcript or a short playground pane
68
+ // still means it, and forwarding past it would scroll the docs underneath.
69
+ // Used by ChatPanel, PlaygroundModal and Prompt.
70
+ const overscrollY = style.overscrollBehaviorY;
71
+ if (overscrollY === 'contain' || overscrollY === 'none') return true;
72
+
73
+ const overflowY = style.overflowY;
74
+ if (overflowY !== 'auto' && overflowY !== 'scroll') return false;
75
+ // Sub-pixel heights (zoom, fractional DPR) can leave a hairline of fake
76
+ // scroll range, so require more than a pixel before calling it scrollable.
77
+ return el.scrollHeight - el.clientHeight > 1;
78
+ }
79
+
80
+ /** Can the content column still move in the direction of `deltaY`? */
81
+ function canConsume(el: Element, deltaY: number): boolean {
82
+ const overflowY = getComputedStyle(el).overflowY;
83
+ if (overflowY !== 'auto' && overflowY !== 'scroll') return false;
84
+ if (el.scrollHeight - el.clientHeight <= 1) return false;
85
+ return deltaY > 0
86
+ ? el.scrollTop + el.clientHeight < el.scrollHeight - 1
87
+ : el.scrollTop > 1;
88
+ }
89
+
90
+ /** Normalise wheel delta to pixels — deltaY is lines in mode 1, pages in mode 2. */
91
+ function deltaToPixels(event: WheelEvent, viewportHeight: number): number {
92
+ if (event.deltaMode === 1) return event.deltaY * LINE_HEIGHT_PX;
93
+ if (event.deltaMode === 2) return event.deltaY * viewportHeight;
94
+ return event.deltaY;
95
+ }
96
+
97
+ /**
98
+ * Is a modal holding a body scroll lock right now?
99
+ *
100
+ * `useBodyScrollLock` sets `document.body.style.overflow = 'hidden'` and
101
+ * nothing else. The `html[data-scroll-locked]` CSS rule in globals.css that
102
+ * DOES neutralise the content column is first-paint only — `scrollLockBootstrap`
103
+ * removes the attribute within ~100ms of DOMContentLoaded, long before any
104
+ * modal opens. So with Cmd+K open the content column is still `overflow-y:
105
+ * auto` and still scrollable, and without this check the wheel over a modal's
106
+ * dimmed backdrop scrolled the docs behind it (verified live).
107
+ */
108
+ function isScrollLocked(): boolean {
109
+ // Inline first: that is literally what useBodyScrollLock writes, and it is
110
+ // the one form jsdom reports faithfully (it does not expand the `overflow`
111
+ // shorthand into computed `overflowY`). The computed check stays as the
112
+ // catch-all for a lock applied via a stylesheet instead. Both were confirmed
113
+ // to read 'hidden' in Chrome with the search modal open.
114
+ if (document.body.style.overflow === 'hidden') return true;
115
+ return getComputedStyle(document.body).overflowY === 'hidden';
116
+ }
117
+
118
+ /**
119
+ * @param enabled - pass false in embed mode. PageColumns emits
120
+ * `#content-scroll-container` unconditionally (only the TOC aside is
121
+ * embed-gated), so the hook is NOT inert there by construction — it only
122
+ * happens to no-op because the embed shell is `min-h-screen`, leaving the
123
+ * column unbounded. Any future bounded height would switch it on inside a
124
+ * customer's embedded widget.
125
+ */
126
+ export function useWheelScrollChaining(enabled = true): void {
127
+ useEffect(() => {
128
+ if (!enabled) return;
129
+
130
+ const handleWheel = (event: WheelEvent) => {
131
+ // ctrl+wheel is pinch-zoom, not a scroll. shift+wheel is the browser's
132
+ // horizontal-scroll modifier — it still arrives as deltaY, and native
133
+ // chaining would look for somewhere HORIZONTAL to go, find nothing in a
134
+ // dead zone, and do nothing. Scrolling vertically instead would invent
135
+ // behaviour the browser never had.
136
+ if (event.ctrlKey || event.shiftKey || event.deltaY === 0) return;
137
+ // A snippet can register its own non-passive wheel handler (a pan/zoom
138
+ // canvas, a custom carousel) and call preventDefault. Nothing in
139
+ // build-service does today, but customer `/snippets` 'use client' files
140
+ // can, and overriding them would scroll the docs out from under the
141
+ // widget. React's onWheel is passive at the root, so this only catches
142
+ // native listeners — which are exactly the ones that can mean it.
143
+ if (event.defaultPrevented) return;
144
+ if (isScrollLocked()) return;
145
+
146
+ const content = document.querySelector<HTMLElement>('#content-scroll-container');
147
+ if (!content) return;
148
+
149
+ // Below lg this is `overflow-y: visible`, so the document scrolls
150
+ // normally and native chaining already works — bail out there.
151
+ if (!canConsume(content, event.deltaY)) return;
152
+
153
+ // Element, not HTMLElement: an SVG target (D2/Mermaid diagrams, inline
154
+ // icons) is an SVGElement, and narrowing to HTMLElement skipped the walk
155
+ // entirely — the delta was then forwarded on top of the browser's own
156
+ // scroll, doubling scroll speed over every diagram.
157
+ const target: Element | null =
158
+ event.target instanceof Element ? event.target : null;
159
+
160
+ // The chat panel is its own surface, not page chrome. Before any message
161
+ // arrives it has nothing to scroll, and forwarding there would scroll the
162
+ // docs behind an open panel — surprising, and not the dead zone we set
163
+ // out to fix. Leave it inert, exactly as it is today.
164
+ if (target?.closest('[data-chat-panel]')) return;
165
+
166
+ let node: Element | null = target;
167
+
168
+ while (node) {
169
+ // Something under the cursor owns the wheel — let the browser do it.
170
+ if (ownsTheWheel(node)) return;
171
+ if (node === document.documentElement) break;
172
+ node = node.parentElement;
173
+ }
174
+
175
+ content.scrollTop += deltaToPixels(event, content.clientHeight);
176
+ };
177
+
178
+ window.addEventListener('wheel', handleWheel, { passive: true });
179
+ return () => window.removeEventListener('wheel', handleWheel);
180
+ }, [enabled]);
181
+ }
@@ -806,6 +806,17 @@ export interface MetadataConfig {
806
806
  timestamp?: boolean;
807
807
  }
808
808
 
809
+ /**
810
+ * Localization behaviour.
811
+ *
812
+ * `autoRedirect` opts a project into server-side Accept-Language routing at
813
+ * locale roots. Default OFF: turning it on changes what a first-time visitor
814
+ * sees, so it is never inferred from the presence of `navigation.languages`.
815
+ */
816
+ export interface LocalizationConfig {
817
+ autoRedirect?: boolean;
818
+ }
819
+
809
820
  /**
810
821
  * Icons library configuration
811
822
  */
@@ -947,6 +958,7 @@ export interface DocsConfig {
947
958
  thumbnails?: ThumbnailsConfig;
948
959
  interaction?: InteractionConfig;
949
960
  metadata?: MetadataConfig;
961
+ localization?: LocalizationConfig;
950
962
  analytics?: AnalyticsConfig;
951
963
  chat?: ChatConfig;
952
964
  spellcheck?: SpellcheckConfig;
@@ -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
+ }