jamdesk 1.1.126 → 1.1.128
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/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +5 -0
- package/dist/lib/deps.js.map +1 -1
- package/package.json +2 -1
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +71 -1
- package/vendored/app/api/og/route.tsx +140 -101
- package/vendored/app/layout.tsx +8 -0
- package/vendored/components/AIActionsMenu.tsx +72 -3
- package/vendored/components/chat/ChatMessage.tsx +8 -0
- package/vendored/components/chat/ChatPanel.tsx +70 -6
- package/vendored/components/chat/CopyButton.tsx +57 -0
- package/vendored/components/layout/EmbedLinkInterceptor.tsx +37 -0
- package/vendored/components/layout/LayoutWrapper.tsx +29 -1
- package/vendored/components/layout/PageColumns.tsx +10 -5
- package/vendored/components/navigation/Breadcrumb.tsx +6 -1
- package/vendored/components/navigation/SocialFooter.tsx +40 -17
- package/vendored/components/search/SearchModal.tsx +50 -1
- package/vendored/hooks/useChatPanel.tsx +28 -2
- package/vendored/lib/api-spec-menu-gate.ts +36 -0
- package/vendored/lib/api-specs-bundle.ts +255 -0
- package/vendored/lib/api-specs-markdown-hint.ts +40 -0
- package/vendored/lib/api-specs-route.ts +45 -0
- package/vendored/lib/chat-scroll.ts +17 -0
- package/vendored/lib/chat-transcript.ts +15 -0
- package/vendored/lib/docs-types.ts +1 -1
- package/vendored/lib/git-utils.ts +27 -1
- package/vendored/lib/heading-extractor.ts +34 -30
- package/vendored/lib/isr-build-executor.ts +269 -7
- package/vendored/lib/layout-helpers.tsx +19 -2
- package/vendored/lib/middleware-helpers.ts +29 -0
- package/vendored/lib/platform-keys.ts +17 -0
- package/vendored/lib/preprocess-mdx.ts +19 -0
- package/vendored/lib/r2-cleanup.ts +239 -1
- package/vendored/lib/r2-manifest.ts +1 -0
- package/vendored/lib/render-doc-page.tsx +102 -83
- package/vendored/lib/sanitize-url.ts +125 -0
- package/vendored/lib/scanner-blocklist.ts +7 -0
- package/vendored/lib/seo.ts +29 -19
- package/vendored/lib/snippet-loader-isr.ts +1 -13
- package/vendored/lib/static-artifacts.ts +39 -9
- package/vendored/lib/static-file-route.ts +35 -1
- package/vendored/scripts/github-slugger-regex.cjs +13 -0
- package/vendored/scripts/validate-links.cjs +45 -19
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/themes/jam/variables.css +8 -0
- package/vendored/workspace-package-lock.json +144 -126
|
@@ -79,6 +79,16 @@ function FileTextIcon({ className }: { className?: string }) {
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function DownloadIcon({ className }: { className?: string }) {
|
|
83
|
+
return (
|
|
84
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
85
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
86
|
+
<polyline points="7 10 12 15 17 10" />
|
|
87
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
88
|
+
</svg>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
function PlugIcon({ className }: { className?: string }) {
|
|
83
93
|
return (
|
|
84
94
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
|
@@ -150,7 +160,7 @@ function VSCodeIcon({ className }: { className?: string }) {
|
|
|
150
160
|
// BUILTIN OPTION METADATA
|
|
151
161
|
// =============================================================================
|
|
152
162
|
|
|
153
|
-
const BUILTIN_OPTIONS: Record<string, BuiltinOptionMeta> = {
|
|
163
|
+
export const BUILTIN_OPTIONS: Record<string, BuiltinOptionMeta> = {
|
|
154
164
|
copy: {
|
|
155
165
|
title: 'Copy page',
|
|
156
166
|
description: 'Copy page as Markdown for LLMs',
|
|
@@ -205,6 +215,12 @@ const BUILTIN_OPTIONS: Record<string, BuiltinOptionMeta> = {
|
|
|
205
215
|
icon: <VSCodeIcon className="w-4 h-4" />,
|
|
206
216
|
isExternal: true,
|
|
207
217
|
},
|
|
218
|
+
'download-api-spec': {
|
|
219
|
+
title: 'Download API spec',
|
|
220
|
+
description: 'Download all OpenAPI specs as a zip',
|
|
221
|
+
icon: <DownloadIcon className="w-4 h-4" />,
|
|
222
|
+
isExternal: false,
|
|
223
|
+
},
|
|
208
224
|
};
|
|
209
225
|
|
|
210
226
|
// =============================================================================
|
|
@@ -244,6 +260,20 @@ function buildMcpServerName(projectName: string): string {
|
|
|
244
260
|
return `${slug}-docs`;
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
/** Absolute URL of the site's api-specs.zip, honoring the hostAtDocs link prefix. */
|
|
264
|
+
export function buildApiSpecZipUrl(origin: string, linkPrefix: string): string {
|
|
265
|
+
return `${origin}${linkPrefix}/api-specs.zip`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* True only for a real zip response. Guards the download from saving a 404 body
|
|
270
|
+
* or the password-unlock HTML page (both return 200 text/html) as "api-specs.zip".
|
|
271
|
+
*/
|
|
272
|
+
export function isZipContentType(contentType: string): boolean {
|
|
273
|
+
const ct = contentType.toLowerCase();
|
|
274
|
+
return ct.includes('zip') || ct.includes('octet-stream');
|
|
275
|
+
}
|
|
276
|
+
|
|
247
277
|
function buildMcpConfig(projectName: string, mcpUrl: string) {
|
|
248
278
|
return {
|
|
249
279
|
mcpServers: {
|
|
@@ -293,7 +323,9 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
293
323
|
const [isOpen, setIsOpen] = useState(false);
|
|
294
324
|
const [copyFeedback, setCopyFeedback] = useState<FeedbackState>('idle');
|
|
295
325
|
const [mcpFeedback, setMcpFeedback] = useState<FeedbackState>('idle');
|
|
326
|
+
const [downloadFeedback, setDownloadFeedback] = useState<FeedbackState>('idle');
|
|
296
327
|
const copyLoadingRef = useRef(false);
|
|
328
|
+
const downloadLoadingRef = useRef(false);
|
|
297
329
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
298
330
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
299
331
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
@@ -306,6 +338,7 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
306
338
|
const mdRelativePath = `${pathname}.md`;
|
|
307
339
|
const mdAbsoluteUrl = `${origin}${pathname}.md`;
|
|
308
340
|
const mcpUrl = `${origin}${linkPrefix}/_mcp`;
|
|
341
|
+
const apiSpecZipUrl = buildApiSpecZipUrl(origin, linkPrefix);
|
|
309
342
|
|
|
310
343
|
// Close on navigation
|
|
311
344
|
useEffect(() => {
|
|
@@ -336,6 +369,7 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
336
369
|
// Reset feedback states after 2s
|
|
337
370
|
useFeedbackTimer(copyFeedback, setCopyFeedback);
|
|
338
371
|
useFeedbackTimer(mcpFeedback, setMcpFeedback);
|
|
372
|
+
useFeedbackTimer(downloadFeedback, setDownloadFeedback);
|
|
339
373
|
|
|
340
374
|
const handleCopy = useCallback(async () => {
|
|
341
375
|
if (copyLoadingRef.current) return;
|
|
@@ -384,6 +418,35 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
384
418
|
}
|
|
385
419
|
}, [projectName, mcpUrl]);
|
|
386
420
|
|
|
421
|
+
const handleDownloadSpec = useCallback(async () => {
|
|
422
|
+
if (downloadLoadingRef.current) return;
|
|
423
|
+
downloadLoadingRef.current = true;
|
|
424
|
+
setDownloadFeedback('loading');
|
|
425
|
+
try {
|
|
426
|
+
const res = await fetch(apiSpecZipUrl);
|
|
427
|
+
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
428
|
+
if (!isZipContentType(res.headers.get('content-type') || '')) {
|
|
429
|
+
throw new Error(`Unexpected content-type: ${res.headers.get('content-type')}`);
|
|
430
|
+
}
|
|
431
|
+
const blob = await res.blob();
|
|
432
|
+
const url = URL.createObjectURL(blob);
|
|
433
|
+
const a = document.createElement('a');
|
|
434
|
+
a.href = url;
|
|
435
|
+
a.download = 'api-specs.zip';
|
|
436
|
+
document.body.appendChild(a);
|
|
437
|
+
a.click();
|
|
438
|
+
a.remove();
|
|
439
|
+
// Defer revoke: Safari fires the download asynchronously after click(),
|
|
440
|
+
// and revoking the object URL synchronously can cancel the download.
|
|
441
|
+
setTimeout(() => URL.revokeObjectURL(url), 100);
|
|
442
|
+
setDownloadFeedback('success');
|
|
443
|
+
} catch {
|
|
444
|
+
setDownloadFeedback('error');
|
|
445
|
+
} finally {
|
|
446
|
+
downloadLoadingRef.current = false;
|
|
447
|
+
}
|
|
448
|
+
}, [apiSpecZipUrl]);
|
|
449
|
+
|
|
387
450
|
const handleAction = useCallback((option: ContextualOption) => {
|
|
388
451
|
if (typeof option !== 'string') {
|
|
389
452
|
// Custom option — open href
|
|
@@ -399,6 +462,9 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
399
462
|
case 'view':
|
|
400
463
|
window.open(mdRelativePath, '_blank', 'noopener,noreferrer');
|
|
401
464
|
break;
|
|
465
|
+
case 'download-api-spec':
|
|
466
|
+
handleDownloadSpec();
|
|
467
|
+
break;
|
|
402
468
|
case 'chatgpt':
|
|
403
469
|
case 'claude':
|
|
404
470
|
case 'gemini':
|
|
@@ -434,13 +500,13 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
434
500
|
}
|
|
435
501
|
|
|
436
502
|
// Close dropdown — copy actions close after brief delay so user sees feedback
|
|
437
|
-
if (option === 'copy' || option === 'mcp') {
|
|
503
|
+
if (option === 'copy' || option === 'mcp' || option === 'download-api-spec') {
|
|
438
504
|
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
439
505
|
closeTimerRef.current = setTimeout(() => setIsOpen(false), COPY_FEEDBACK_DELAY_MS);
|
|
440
506
|
} else {
|
|
441
507
|
setIsOpen(false);
|
|
442
508
|
}
|
|
443
|
-
}, [handleCopy, handleMcpCopy, mdRelativePath, mdAbsoluteUrl, projectName, mcpUrl]);
|
|
509
|
+
}, [handleCopy, handleMcpCopy, handleDownloadSpec, mdRelativePath, mdAbsoluteUrl, projectName, mcpUrl]);
|
|
444
510
|
|
|
445
511
|
// Keyboard navigation within dropdown
|
|
446
512
|
const handleMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
@@ -620,6 +686,9 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
|
|
|
620
686
|
: option === 'mcp'
|
|
621
687
|
? (mcpFeedback === 'success' ? <CheckIcon className="w-4 h-4 text-emerald-500" /> :
|
|
622
688
|
mcpFeedback === 'error' ? <XIcon className="w-4 h-4 text-red-500" /> : meta.icon)
|
|
689
|
+
: option === 'download-api-spec'
|
|
690
|
+
? (downloadFeedback === 'success' ? <CheckIcon className="w-4 h-4 text-emerald-500" /> :
|
|
691
|
+
downloadFeedback === 'error' ? <XIcon className="w-4 h-4 text-red-500" /> : meta.icon)
|
|
623
692
|
: meta.icon;
|
|
624
693
|
|
|
625
694
|
return (
|
|
@@ -4,6 +4,7 @@ import { memo } from 'react';
|
|
|
4
4
|
import ReactMarkdown from 'react-markdown';
|
|
5
5
|
import remarkGfm from 'remark-gfm';
|
|
6
6
|
import { ChatCodeBlock } from './ChatCodeBlock';
|
|
7
|
+
import { CopyButton } from './CopyButton';
|
|
7
8
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
8
9
|
import type { ChatMessage as ChatMessageType } from '@/hooks/useChat';
|
|
9
10
|
|
|
@@ -126,6 +127,13 @@ export const ChatMessage = memo(function ChatMessage({ message, onSelectOption }
|
|
|
126
127
|
)}
|
|
127
128
|
</div>
|
|
128
129
|
|
|
130
|
+
{/* Copy answer — only on a finalized, non-empty assistant message */}
|
|
131
|
+
{!message.isStreaming && message.content && (
|
|
132
|
+
<div className="mt-1 -ml-2">
|
|
133
|
+
<CopyButton getText={() => message.content} ariaLabel="Copy answer" />
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
129
137
|
{/* Clarification options — tappable buttons for disambiguation */}
|
|
130
138
|
{hasClarificationOptions && (
|
|
131
139
|
<div className="mt-3">
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
|
|
4
4
|
import { useChat } from '@/hooks/useChat';
|
|
5
5
|
import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
|
|
6
|
+
import { useChatPanelOptional } from '@/hooks/useChatPanel';
|
|
6
7
|
import { ChatMessage } from './ChatMessage';
|
|
7
8
|
import { ChatInput } from './ChatInput';
|
|
8
9
|
import { ChatEmptyState } from './ChatEmptyState';
|
|
10
|
+
import { CopyButton } from './CopyButton';
|
|
11
|
+
import { formatTranscript } from '@/lib/chat-transcript';
|
|
12
|
+
import { isNearBottom } from '@/lib/chat-scroll';
|
|
9
13
|
import { crispAvailable, hideCrispLauncher, openCrispChat, showCrispLauncher } from '../../lib/crisp-bridge';
|
|
10
14
|
|
|
11
15
|
const SOMETHING_ELSE_PATTERNS = ['something else', 'none of the above', 'none of these'];
|
|
@@ -43,6 +47,33 @@ interface ChatPanelProps {
|
|
|
43
47
|
export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mode = 'overlay' }: ChatPanelProps) {
|
|
44
48
|
const { messages, sendMessage, isLoading, abort, retry, clearChat, error, markClarificationSelected } = useChat(chatEndpoint);
|
|
45
49
|
|
|
50
|
+
// Search (and any external trigger) hands a question to chat via the
|
|
51
|
+
// ChatPanelContext. We append it as the next message — no clearChat — so an
|
|
52
|
+
// in-progress conversation is preserved. useChatPanelOptional (not the
|
|
53
|
+
// throwing useChatPanel) keeps ChatPanel renderable in unit tests / outside
|
|
54
|
+
// a provider.
|
|
55
|
+
const chatPanel = useChatPanelOptional();
|
|
56
|
+
const pendingQuestion = chatPanel?.pendingQuestion ?? null;
|
|
57
|
+
const consumePendingQuestion = chatPanel?.consumePendingQuestion;
|
|
58
|
+
const consumingRef = useRef(false);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!pendingQuestion) {
|
|
62
|
+
consumingRef.current = false;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// R1: sendMessage() is a no-op while a stream is in flight (useChat guards
|
|
66
|
+
// on isLoadingRef). Do NOT consume the question then — it would vanish.
|
|
67
|
+
// Hold it; isLoading is a dependency, so when the stream finishes the
|
|
68
|
+
// effect re-runs and delivers. consumingRef prevents an effect double-fire
|
|
69
|
+
// from sending twice within one delivery; it resets when pendingQuestion
|
|
70
|
+
// clears.
|
|
71
|
+
if (isLoading || consumingRef.current) return;
|
|
72
|
+
consumingRef.current = true;
|
|
73
|
+
sendMessage(pendingQuestion);
|
|
74
|
+
consumePendingQuestion?.();
|
|
75
|
+
}, [pendingQuestion, isLoading, sendMessage, consumePendingQuestion]);
|
|
76
|
+
|
|
46
77
|
const [hasCrisp, setHasCrisp] = useState(false);
|
|
47
78
|
useEffect(() => { setHasCrisp(crispAvailable()); }, []);
|
|
48
79
|
|
|
@@ -51,6 +82,15 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
51
82
|
openCrispChat();
|
|
52
83
|
}, [onClose]);
|
|
53
84
|
|
|
85
|
+
// "New chat" must also drop any not-yet-delivered handoff question. Otherwise
|
|
86
|
+
// a question queued mid-stream (held by the consume effect while isLoading)
|
|
87
|
+
// would fire into the freshly cleared conversation once clearChat()'s abort()
|
|
88
|
+
// flips isLoading false and the effect re-runs.
|
|
89
|
+
const handleNewChat = useCallback(() => {
|
|
90
|
+
clearChat();
|
|
91
|
+
consumePendingQuestion?.();
|
|
92
|
+
}, [clearChat, consumePendingQuestion]);
|
|
93
|
+
|
|
54
94
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
55
95
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
56
96
|
const overlayPanelRef = useRef<HTMLDivElement>(null);
|
|
@@ -109,14 +149,29 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
109
149
|
};
|
|
110
150
|
}, [isOpen, isInline]);
|
|
111
151
|
|
|
112
|
-
// Auto-scroll
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
152
|
+
// Auto-scroll only when the user is already at the bottom. If they've
|
|
153
|
+
// scrolled up to re-read mid-stream, leave their position alone. We track
|
|
154
|
+
// "pinned" in a ref (not state) so updating it on every scroll event doesn't
|
|
155
|
+
// re-render. Direct container scroll (no scrollIntoView — that scrolls every
|
|
156
|
+
// ancestor; documented anti-pattern in CLAUDE.md).
|
|
157
|
+
const isPinnedToBottomRef = useRef(true);
|
|
158
|
+
|
|
159
|
+
const handleMessagesScroll = useCallback(() => {
|
|
160
|
+
const container = messagesContainerRef.current;
|
|
161
|
+
if (!container) return;
|
|
162
|
+
isPinnedToBottomRef.current = isNearBottom(
|
|
163
|
+
container.scrollTop,
|
|
164
|
+
container.scrollHeight,
|
|
165
|
+
container.clientHeight,
|
|
166
|
+
);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
116
169
|
useLayoutEffect(() => {
|
|
117
170
|
const container = messagesContainerRef.current;
|
|
118
171
|
if (!container) return;
|
|
119
|
-
|
|
172
|
+
if (isPinnedToBottomRef.current) {
|
|
173
|
+
container.scrollTop = container.scrollHeight;
|
|
174
|
+
}
|
|
120
175
|
}, [messages]);
|
|
121
176
|
|
|
122
177
|
// Escape key handling is in useChatPanel (capture phase, with search-modal awareness)
|
|
@@ -152,9 +207,17 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
152
207
|
</span>
|
|
153
208
|
</div>
|
|
154
209
|
<div className="flex items-center gap-1">
|
|
210
|
+
{/* R8: only when no message is mid-stream, so the transcript captures
|
|
211
|
+
the post-rewrite (absolute-URL) link form, not slug placeholders. */}
|
|
212
|
+
{hasMessages && !messages.some((m) => m.isStreaming) && (
|
|
213
|
+
<CopyButton
|
|
214
|
+
getText={() => formatTranscript(messages)}
|
|
215
|
+
ariaLabel="Copy transcript"
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
155
218
|
{hasMessages && (
|
|
156
219
|
<button
|
|
157
|
-
onClick={
|
|
220
|
+
onClick={handleNewChat}
|
|
158
221
|
className="px-2 py-1 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors rounded-md hover:bg-[var(--color-bg-secondary)] cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
|
|
159
222
|
>
|
|
160
223
|
New chat
|
|
@@ -175,6 +238,7 @@ export function ChatPanel({ isOpen, onClose, starterQuestions, chatEndpoint, mod
|
|
|
175
238
|
ref={messagesContainerRef}
|
|
176
239
|
className="flex-1 overflow-y-auto min-h-0"
|
|
177
240
|
style={{ overscrollBehavior: 'contain' }}
|
|
241
|
+
onScroll={handleMessagesScroll}
|
|
178
242
|
aria-live="polite"
|
|
179
243
|
aria-label="Chat messages"
|
|
180
244
|
>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// builder/build-service/components/chat/CopyButton.tsx
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
interface CopyButtonProps {
|
|
7
|
+
/** Lazily produce the text to copy (so we don't recompute on every render). */
|
|
8
|
+
getText: () => string;
|
|
9
|
+
/** Accessible name + tooltip (e.g. "Copy answer", "Copy transcript"). */
|
|
10
|
+
ariaLabel: string;
|
|
11
|
+
/** Optional visible label rendered next to the icon (used by the transcript button). */
|
|
12
|
+
idleLabel?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clipboard button with a transient "copied" checkmark. Mirrors the copy
|
|
18
|
+
* pattern in ChatCodeBlock.tsx but reusable: powers both the per-answer copy
|
|
19
|
+
* and the "Copy transcript" header action. Fails silently when the Clipboard
|
|
20
|
+
* API is unavailable (non-HTTPS) or rejects (permission denied).
|
|
21
|
+
*/
|
|
22
|
+
export const CopyButton = memo(function CopyButton({ getText, ariaLabel, idleLabel, className }: CopyButtonProps) {
|
|
23
|
+
const [copied, setCopied] = useState(false);
|
|
24
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => () => {
|
|
27
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const handleCopy = useCallback(() => {
|
|
31
|
+
const text = getText();
|
|
32
|
+
if (!text || !navigator.clipboard?.writeText) return;
|
|
33
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
34
|
+
setCopied(true);
|
|
35
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
36
|
+
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
|
37
|
+
}).catch(() => {
|
|
38
|
+
// Clipboard write rejected (permissions) — leave UI unchanged.
|
|
39
|
+
});
|
|
40
|
+
}, [getText]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={handleCopy}
|
|
46
|
+
aria-label={ariaLabel}
|
|
47
|
+
title={copied ? 'Copied!' : ariaLabel}
|
|
48
|
+
className={
|
|
49
|
+
className ??
|
|
50
|
+
'inline-flex items-center gap-1.5 px-2 py-1 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors rounded-md hover:bg-[var(--color-bg-secondary)] cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]'
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
<i className={`fa-solid ${copied ? 'fa-check text-green-500' : 'fa-copy'}`} aria-hidden="true" />
|
|
54
|
+
{idleLabel && <span>{copied ? 'Copied' : idleLabel}</span>}
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In embed mode the page lives inside a cross-origin iframe (the widget modal).
|
|
7
|
+
* Same-page hash anchors must scroll in-frame; any link that would navigate to
|
|
8
|
+
* a different page is broken OUT of the iframe into a new tab so users aren't
|
|
9
|
+
* trapped in the small modal. Uses event delegation so it covers MDX-rendered
|
|
10
|
+
* links without touching every anchor component. `display:contents` keeps the
|
|
11
|
+
* wrapper out of layout (prose CSS undisturbed) while clicks still bubble up.
|
|
12
|
+
*/
|
|
13
|
+
export function EmbedLinkInterceptor({ children }: { children: ReactNode }) {
|
|
14
|
+
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
15
|
+
// Defer entirely to the browser for modified / non-primary clicks: the user
|
|
16
|
+
// has already asked for new-tab (cmd/ctrl), new-window (shift) or background
|
|
17
|
+
// (middle) behavior — hijacking it would double-open or break their intent.
|
|
18
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
19
|
+
const anchor = (e.target as HTMLElement).closest('a');
|
|
20
|
+
if (!anchor) return;
|
|
21
|
+
const href = anchor.getAttribute('href');
|
|
22
|
+
if (!href) return;
|
|
23
|
+
// Same-page hash anchors (and pure "#") scroll inside the frame — leave them.
|
|
24
|
+
if (href.startsWith('#')) return;
|
|
25
|
+
// Non-navigable schemes (mail/phone/js) must use their native handler,
|
|
26
|
+
// not a blank new tab.
|
|
27
|
+
if (/^(mailto:|tel:|javascript:)/i.test(href)) return;
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
window.open(href, '_blank', 'noopener');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div onClick={onClick} style={{ display: 'contents' }}>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -15,6 +15,8 @@ import { getTheme, type ThemeName } from '@/themes';
|
|
|
15
15
|
interface LayoutWrapperProps {
|
|
16
16
|
config: DocsConfig;
|
|
17
17
|
children: React.ReactNode;
|
|
18
|
+
/** When true, render content only — no Header, Sidebar, TabsNav, chat, or footer chrome. */
|
|
19
|
+
embed?: boolean;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -35,7 +37,7 @@ function useMediaQuery(query: string): boolean {
|
|
|
35
37
|
return matches;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
|
|
40
|
+
export function LayoutWrapper({ config, children, embed }: LayoutWrapperProps) {
|
|
39
41
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
40
42
|
// Chat button is shown in both dev and production so the layout matches.
|
|
41
43
|
// In dev mode, clicking it shows a "production only" notice instead of opening the panel.
|
|
@@ -47,6 +49,32 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
|
|
|
47
49
|
// Handle hash navigation for three-column independent scroll layout
|
|
48
50
|
useHashNavigation();
|
|
49
51
|
|
|
52
|
+
// Embed mode: render content only — no Header, Sidebar, TabsNav, chat,
|
|
53
|
+
// or footer chrome. Keeps theme + typography (provided by DocsChrome).
|
|
54
|
+
// Must appear AFTER all hook calls (Rules of Hooks).
|
|
55
|
+
if (embed) {
|
|
56
|
+
// Background sits on the full-height wrapper (not on <main> as in the
|
|
57
|
+
// chrome'd layouts) intentionally: embed has no sidebar column, so the
|
|
58
|
+
// wrapper IS the page surface.
|
|
59
|
+
return (
|
|
60
|
+
<div data-embed="true" className="min-h-screen bg-[var(--color-bg-primary)] transition-colors">
|
|
61
|
+
{/* Horizontal inset for embed content lives on the wrapper, not the
|
|
62
|
+
<article>: on mobile (<=1023px) the jam theme pins `#main-content
|
|
63
|
+
article` left/right padding to 0.5rem with !important, which an
|
|
64
|
+
article-level style can't beat — so the breathing room has to come
|
|
65
|
+
from a parent the rule doesn't target. The gradient (on <body>) and
|
|
66
|
+
the document scrollbar still reach the modal edge; only the content
|
|
67
|
+
insets. NOTE: that jam pin is mobile-only, so at >=1024px this wrapper
|
|
68
|
+
padding stacks on the article's own px-* — acceptable because the
|
|
69
|
+
default 560px modal renders at the mobile breakpoint; only unusually
|
|
70
|
+
wide modals see the extra inset. */}
|
|
71
|
+
<main id="main-content" className="flex flex-col overflow-x-hidden px-5 sm:px-6">
|
|
72
|
+
{children}
|
|
73
|
+
</main>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
// The body.scrolled toggle used to live here, but #content-scroll-container
|
|
51
79
|
// is rendered by PageColumns (in page.tsx) and remounts on every navigation.
|
|
52
80
|
// The listener attached once at LayoutWrapper mount referenced a detached
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, type ReactNode } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { useChatPanelOptional } from '@/hooks/useChatPanel';
|
|
5
5
|
|
|
6
6
|
interface PageColumnsProps {
|
|
7
7
|
children: ReactNode;
|
|
8
8
|
toc: ReactNode;
|
|
9
9
|
isWideMode?: boolean;
|
|
10
|
+
embed?: boolean;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -14,8 +15,12 @@ interface PageColumnsProps {
|
|
|
14
15
|
* On xl+ screens, the TOC is hidden when chat is open (chat renders at layout level).
|
|
15
16
|
* On <xl screens, only content is rendered (TOC hidden, chat is a mobile overlay).
|
|
16
17
|
*/
|
|
17
|
-
export function PageColumns({ children, toc, isWideMode }: PageColumnsProps) {
|
|
18
|
-
|
|
18
|
+
export function PageColumns({ children, toc, isWideMode, embed }: PageColumnsProps) {
|
|
19
|
+
// Embed mode renders this column OUTSIDE the ChatPanelProvider (the embed
|
|
20
|
+
// LayoutWrapper branch drops the provider along with the rest of the chrome),
|
|
21
|
+
// so the throwing useChatPanel() would 500 the whole embed render. Use the
|
|
22
|
+
// optional accessor: no provider → chat closed, and `!embed` hides the TOC anyway.
|
|
23
|
+
const isChatOpen = useChatPanelOptional()?.isChatOpen ?? false;
|
|
19
24
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
20
25
|
|
|
21
26
|
// Toggle body.scrolled when the user scrolls past 500px in the content
|
|
@@ -50,8 +55,8 @@ export function PageColumns({ children, toc, isWideMode }: PageColumnsProps) {
|
|
|
50
55
|
{children}
|
|
51
56
|
</div>
|
|
52
57
|
|
|
53
|
-
{/* TOC — shown when chat is closed
|
|
54
|
-
{!isChatOpen && !isWideMode && (
|
|
58
|
+
{/* TOC — shown when chat is closed, not in wide mode, and not embedded */}
|
|
59
|
+
{!isChatOpen && !isWideMode && !embed && (
|
|
55
60
|
<aside className="hidden xl:block w-72 flex-shrink-0 xl:h-full xl:overflow-y-auto toc-scroll xl:ml-0.5 pr-2">
|
|
56
61
|
<div className="py-6 sm:py-10 pr-4">
|
|
57
62
|
{toc}
|
|
@@ -8,6 +8,9 @@ import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
|
8
8
|
interface BreadcrumbProps {
|
|
9
9
|
slug: string[];
|
|
10
10
|
config?: DocsConfig;
|
|
11
|
+
/** Embed render (widget modal): drop the breadcrumb — its links navigate the
|
|
12
|
+
* full docs and read as out of place inside an embedded changelog. */
|
|
13
|
+
hidden?: boolean;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
interface BreadcrumbItem {
|
|
@@ -245,8 +248,10 @@ function findPageNavTitle(config: DocsConfig, targetSlug: string): string | null
|
|
|
245
248
|
return null;
|
|
246
249
|
}
|
|
247
250
|
|
|
248
|
-
export function Breadcrumb({ slug, config }: BreadcrumbProps) {
|
|
251
|
+
export function Breadcrumb({ slug, config, hidden }: BreadcrumbProps) {
|
|
252
|
+
// Call hooks before the early return so hook order stays stable (Rules of Hooks).
|
|
249
253
|
const linkPrefix = useLinkPrefix();
|
|
254
|
+
if (hidden) return null;
|
|
250
255
|
const homeLink = config ? `${linkPrefix}/${getFirstPage(config)}` : `${linkPrefix}/introduction`;
|
|
251
256
|
const targetSlug = slug.join('/');
|
|
252
257
|
|
|
@@ -22,6 +22,9 @@ interface SocialFooterProps {
|
|
|
22
22
|
config: DocsConfig;
|
|
23
23
|
hidden?: boolean;
|
|
24
24
|
projectSlug?: string;
|
|
25
|
+
/** Embed render (widget modal): keep ONLY the "Powered by Jamdesk"
|
|
26
|
+
* attribution — drop the link columns + social icons. */
|
|
27
|
+
embed?: boolean;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -137,7 +140,42 @@ function SocialIcons({ socials }: { socials: Partial<Record<SocialPlatform, stri
|
|
|
137
140
|
);
|
|
138
141
|
}
|
|
139
142
|
|
|
140
|
-
|
|
143
|
+
/** "Powered by Jamdesk" attribution link, shared by the full footer and the
|
|
144
|
+
* embed footer. Renders nothing when branding is disabled at build time. */
|
|
145
|
+
function BrandingLink({ projectSlug }: { projectSlug?: string }) {
|
|
146
|
+
if (!showBranding) return null;
|
|
147
|
+
return (
|
|
148
|
+
<a
|
|
149
|
+
href={getBrandingUrl(projectSlug)}
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
className="group flex items-baseline gap-1 text-sm text-[var(--color-text-muted)]/60 hover:text-[var(--color-text-muted)] transition-colors whitespace-nowrap"
|
|
153
|
+
>
|
|
154
|
+
Powered by
|
|
155
|
+
<span
|
|
156
|
+
role="img"
|
|
157
|
+
aria-label="Jamdesk"
|
|
158
|
+
className="inline-block translate-y-[2px] text-[var(--color-text-muted)]/85 group-hover:text-[var(--color-text-primary)]"
|
|
159
|
+
style={WORDMARK_STYLE}
|
|
160
|
+
/>
|
|
161
|
+
</a>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function SocialFooter({ config, hidden, projectSlug, embed }: SocialFooterProps) {
|
|
166
|
+
// Embed render (widget modal): keep ONLY the "Powered by Jamdesk" attribution.
|
|
167
|
+
// The link columns + social icons read as out of place inside an embedded
|
|
168
|
+
// changelog; the attribution stays even when the normal footer is hidden,
|
|
169
|
+
// since it's the embed widget's branding.
|
|
170
|
+
if (embed) {
|
|
171
|
+
if (!showBranding) return null;
|
|
172
|
+
return (
|
|
173
|
+
<footer className="mt-10 pt-6 border-t border-[var(--color-border)] flex justify-center">
|
|
174
|
+
<BrandingLink projectSlug={projectSlug} />
|
|
175
|
+
</footer>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
141
179
|
if (hidden) return null;
|
|
142
180
|
|
|
143
181
|
const { socials, links } = config.footer || {};
|
|
@@ -156,22 +194,7 @@ export function SocialFooter({ config, hidden, projectSlug }: SocialFooterProps)
|
|
|
156
194
|
{hasLinks && <LinkColumns columns={links} />}
|
|
157
195
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
158
196
|
{hasSocials && <SocialIcons socials={socials} />}
|
|
159
|
-
{
|
|
160
|
-
<a
|
|
161
|
-
href={getBrandingUrl(projectSlug)}
|
|
162
|
-
target="_blank"
|
|
163
|
-
rel="noopener noreferrer"
|
|
164
|
-
className="group flex items-baseline gap-1 text-sm text-[var(--color-text-muted)]/60 hover:text-[var(--color-text-muted)] transition-colors whitespace-nowrap"
|
|
165
|
-
>
|
|
166
|
-
Powered by
|
|
167
|
-
<span
|
|
168
|
-
role="img"
|
|
169
|
-
aria-label="Jamdesk"
|
|
170
|
-
className="inline-block translate-y-[2px] text-[var(--color-text-muted)]/85 group-hover:text-[var(--color-text-primary)]"
|
|
171
|
-
style={WORDMARK_STYLE}
|
|
172
|
-
/>
|
|
173
|
-
</a>
|
|
174
|
-
)}
|
|
197
|
+
<BrandingLink projectSlug={projectSlug} />
|
|
175
198
|
</div>
|
|
176
199
|
</footer>
|
|
177
200
|
);
|