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.
Files changed (46) hide show
  1. package/dist/lib/deps.d.ts.map +1 -1
  2. package/dist/lib/deps.js +5 -0
  3. package/dist/lib/deps.js.map +1 -1
  4. package/package.json +2 -1
  5. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +71 -1
  6. package/vendored/app/api/og/route.tsx +140 -101
  7. package/vendored/app/layout.tsx +8 -0
  8. package/vendored/components/AIActionsMenu.tsx +72 -3
  9. package/vendored/components/chat/ChatMessage.tsx +8 -0
  10. package/vendored/components/chat/ChatPanel.tsx +70 -6
  11. package/vendored/components/chat/CopyButton.tsx +57 -0
  12. package/vendored/components/layout/EmbedLinkInterceptor.tsx +37 -0
  13. package/vendored/components/layout/LayoutWrapper.tsx +29 -1
  14. package/vendored/components/layout/PageColumns.tsx +10 -5
  15. package/vendored/components/navigation/Breadcrumb.tsx +6 -1
  16. package/vendored/components/navigation/SocialFooter.tsx +40 -17
  17. package/vendored/components/search/SearchModal.tsx +50 -1
  18. package/vendored/hooks/useChatPanel.tsx +28 -2
  19. package/vendored/lib/api-spec-menu-gate.ts +36 -0
  20. package/vendored/lib/api-specs-bundle.ts +255 -0
  21. package/vendored/lib/api-specs-markdown-hint.ts +40 -0
  22. package/vendored/lib/api-specs-route.ts +45 -0
  23. package/vendored/lib/chat-scroll.ts +17 -0
  24. package/vendored/lib/chat-transcript.ts +15 -0
  25. package/vendored/lib/docs-types.ts +1 -1
  26. package/vendored/lib/git-utils.ts +27 -1
  27. package/vendored/lib/heading-extractor.ts +34 -30
  28. package/vendored/lib/isr-build-executor.ts +269 -7
  29. package/vendored/lib/layout-helpers.tsx +19 -2
  30. package/vendored/lib/middleware-helpers.ts +29 -0
  31. package/vendored/lib/platform-keys.ts +17 -0
  32. package/vendored/lib/preprocess-mdx.ts +19 -0
  33. package/vendored/lib/r2-cleanup.ts +239 -1
  34. package/vendored/lib/r2-manifest.ts +1 -0
  35. package/vendored/lib/render-doc-page.tsx +102 -83
  36. package/vendored/lib/sanitize-url.ts +125 -0
  37. package/vendored/lib/scanner-blocklist.ts +7 -0
  38. package/vendored/lib/seo.ts +29 -19
  39. package/vendored/lib/snippet-loader-isr.ts +1 -13
  40. package/vendored/lib/static-artifacts.ts +39 -9
  41. package/vendored/lib/static-file-route.ts +35 -1
  42. package/vendored/scripts/github-slugger-regex.cjs +13 -0
  43. package/vendored/scripts/validate-links.cjs +45 -19
  44. package/vendored/shared/status-reporter.ts +1 -1
  45. package/vendored/themes/jam/variables.css +8 -0
  46. 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 to bottom when new messages arrive. Uses useLayoutEffect so
113
- // scrollHeight is accurate (DOM is updated) and there's no visible 1-frame lag.
114
- // Scrolls the container directly instead of scrollIntoView to avoid scrolling
115
- // ancestor elements (which pushes the panel off-screen on mobile).
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
- container.scrollTop = container.scrollHeight;
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={clearChat}
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 { useChatPanel } from '@/hooks/useChatPanel';
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
- const { isChatOpen } = useChatPanel();
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 and not in wide mode */}
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
- export function SocialFooter({ config, hidden, projectSlug }: SocialFooterProps) {
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
- {showBranding && (
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
  );