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.
- package/dist/__tests__/unit/vendored-sync.test.js +9 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.js +3 -3
- package/dist/lib/deps.js.map +1 -1
- package/package.json +5 -5
- package/vendored/app/layout.tsx +43 -4
- package/vendored/components/CodeBlockCopyButton.tsx +7 -2
- package/vendored/components/layout/LayoutWrapper.tsx +7 -0
- package/vendored/components/mdx/CodeGroup.tsx +161 -13
- package/vendored/components/mdx/MDXComponents.tsx +93 -50
- package/vendored/components/navigation/Header.tsx +7 -0
- package/vendored/components/navigation/LanguageSelector.tsx +35 -0
- package/vendored/components/navigation/ThemePreviewPicker.tsx +197 -0
- package/vendored/components/ui/CodePanel.tsx +88 -51
- package/vendored/components/ui/CodePanelModal.tsx +57 -36
- package/vendored/hooks/useWheelScrollChaining.ts +181 -0
- package/vendored/lib/docs-types.ts +12 -0
- package/vendored/lib/language-cookie.ts +20 -0
- package/vendored/lib/language-matcher.ts +124 -0
- package/vendored/lib/language-utils.ts +103 -0
- package/vendored/lib/languages-artifact.ts +160 -0
- package/vendored/lib/layout-helpers.tsx +16 -3
- package/vendored/lib/middleware-helpers.ts +228 -1
- package/vendored/lib/page-timestamps.ts +36 -0
- package/vendored/lib/rehype-code-meta.ts +74 -9
- package/vendored/lib/render-doc-page.tsx +14 -3
- package/vendored/lib/revalidation-helpers.ts +3 -0
- package/vendored/lib/root-page-slug.ts +9 -4
- package/vendored/lib/shiki-transformers.ts +13 -0
- package/vendored/lib/static-artifacts.ts +70 -2
- package/vendored/lib/theme-preview-context.tsx +39 -0
- package/vendored/lib/theme-preview.ts +150 -0
- package/vendored/schema/docs-schema.json +21 -0
- 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({
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
e
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
e
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|