jamdesk 1.1.7 → 1.1.9

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 (43) hide show
  1. package/README.md +5 -2
  2. package/dist/lib/deps.js +3 -3
  3. package/dist/lib/openapi/types.d.ts +11 -6
  4. package/dist/lib/openapi/types.d.ts.map +1 -1
  5. package/dist/lib/path-security.d.ts +3 -0
  6. package/dist/lib/path-security.d.ts.map +1 -1
  7. package/dist/lib/path-security.js +14 -1
  8. package/dist/lib/path-security.js.map +1 -1
  9. package/dist/lib/tech-words.js +5 -5
  10. package/dist/lib/tech-words.js.map +1 -1
  11. package/package.json +13 -10
  12. package/vendored/app/[[...slug]]/page.tsx +50 -13
  13. package/vendored/app/api/assets/[...path]/route.ts +2 -0
  14. package/vendored/components/layout/LayoutWrapper.tsx +3 -4
  15. package/vendored/components/mdx/ApiEndpoint.tsx +13 -2
  16. package/vendored/components/mdx/MDXComponents.tsx +16 -0
  17. package/vendored/components/mdx/OpenApiEndpoint.tsx +76 -36
  18. package/vendored/components/mdx/Tabs.tsx +1 -1
  19. package/vendored/components/mdx/Video.tsx +82 -0
  20. package/vendored/components/navigation/Header.tsx +3 -3
  21. package/vendored/components/navigation/Sidebar.tsx +3 -3
  22. package/vendored/components/ui/CodePanel.tsx +5 -5
  23. package/vendored/components/ui/CodePanelModal.tsx +3 -3
  24. package/vendored/components/ui/DevOnlyNotice.tsx +78 -0
  25. package/vendored/hooks/useChatPanel.tsx +21 -2
  26. package/vendored/hooks/useMediaQuery.ts +27 -0
  27. package/vendored/lib/build-endpoint-from-mdx.ts +66 -0
  28. package/vendored/lib/isr-build-executor.ts +1 -1
  29. package/vendored/lib/middleware-helpers.ts +26 -1
  30. package/vendored/lib/openapi/code-examples.ts +479 -99
  31. package/vendored/lib/openapi/index.ts +9 -1
  32. package/vendored/lib/openapi/types.ts +29 -5
  33. package/vendored/lib/preprocess-mdx.ts +103 -36
  34. package/vendored/lib/process-mdx-with-exports.ts +22 -14
  35. package/vendored/lib/remark-extract-param-fields.ts +134 -0
  36. package/vendored/lib/shiki-client.ts +12 -0
  37. package/vendored/lib/static-artifacts.ts +2 -1
  38. package/vendored/lib/url-safety.ts +122 -0
  39. package/vendored/next.config.js +8 -0
  40. package/vendored/schema/docs-schema.json +16 -3
  41. package/vendored/scripts/copy-files.cjs +60 -54
  42. package/vendored/scripts/validate-links.cjs +1 -1
  43. package/vendored/shared/path-security.ts +17 -1
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo } from 'react';
3
+ import { useDevOnlyNotice, DEV_FEATURE } from '../ui/DevOnlyNotice';
4
+
5
+ import { useState, useEffect, useMemo } from 'react';
4
6
  import { ApiEndpoint } from './ApiEndpoint';
5
7
  import { ParamField } from './ParamField';
6
8
  import { CodeGroup } from './CodeGroup';
@@ -14,9 +16,10 @@ import type {
14
16
  OpenApiEndpointData,
15
17
  ParsedParameter,
16
18
  ParsedResponse,
17
- CodeExamples,
19
+ CodeExample,
18
20
  JsonSchema,
19
21
  SecurityRequirement,
22
+ AuthMethod,
20
23
  } from '@/lib/openapi/types';
21
24
 
22
25
  // Preload Shiki highlighter on module load
@@ -28,9 +31,25 @@ interface OpenApiEndpointProps {
28
31
  /** Endpoint data from OpenAPI spec */
29
32
  endpoint: OpenApiEndpointData;
30
33
  /** Generated code examples */
31
- codeExamples?: CodeExamples;
34
+ codeExamples?: CodeExample[];
32
35
  /** Additional content to render after endpoint info */
33
36
  children?: React.ReactNode;
37
+ /** Playground display mode */
38
+ playgroundDisplay?: 'interactive' | 'simple' | 'none';
39
+ /** Auth method for playground */
40
+ authMethod?: AuthMethod;
41
+ /** Custom auth header name */
42
+ authHeaderName?: string;
43
+ /** Fallback server URL when endpoint.servers is empty */
44
+ serverUrl?: string;
45
+ /** Whether CORS proxy is enabled */
46
+ proxyEnabled?: boolean;
47
+ /** Languages for code generation */
48
+ languages?: string[];
49
+ /** When true, render only the ApiEndpoint badge (with Try it) + PlaygroundModal.
50
+ * Skips all auto-generated param docs, response panels, and code examples.
51
+ * Used for api: pages where the MDX content provides the documentation. */
52
+ playgroundOnly?: boolean;
34
53
  }
35
54
 
36
55
  /**
@@ -834,35 +853,23 @@ function ResponseFieldItem({
834
853
  /**
835
854
  * Code examples section - renders in right panel using CodePanel
836
855
  */
837
- function CodeExamplesSection({ examples }: { examples: CodeExamples }) {
856
+ function CodeExamplesSection({ examples }: { examples: CodeExample[] }) {
838
857
  const codeItems = useMemo(
839
- () => [
840
- { code: examples.curl, language: 'bash' },
841
- { code: examples.python, language: 'python' },
842
- { code: examples.javascript, language: 'javascript' },
843
- ],
844
- [examples.curl, examples.python, examples.javascript]
858
+ () => examples.map(ex => ({ code: ex.code, language: ex.language })),
859
+ [examples]
845
860
  );
846
861
 
847
862
  const { results: highlightedResults, isLoading } = useShikiHighlightMultiple(codeItems);
848
863
 
849
- // Render full Shiki HTML for theme background support
864
+ // Shiki generates HTML from trusted code strings (not user input)
850
865
  const tabs: CodePanelTab[] = useMemo(
851
- () => [
852
- {
853
- label: 'cURL',
854
- content: <div dangerouslySetInnerHTML={{ __html: highlightedResults[0] || '' }} />,
855
- },
856
- {
857
- label: 'Python',
858
- content: <div dangerouslySetInnerHTML={{ __html: highlightedResults[1] || '' }} />,
859
- },
860
- {
861
- label: 'JavaScript',
862
- content: <div dangerouslySetInnerHTML={{ __html: highlightedResults[2] || '' }} />,
863
- },
864
- ],
865
- [highlightedResults]
866
+ () => examples.map((ex, i) => ({
867
+ label: ex.label,
868
+ content: highlightedResults[i]
869
+ ? <div dangerouslySetInnerHTML={{ __html: highlightedResults[i] }} />
870
+ : null,
871
+ })),
872
+ [examples, highlightedResults]
866
873
  );
867
874
 
868
875
  // Don't render until highlighting is complete to prevent flash of unformatted content
@@ -880,13 +887,20 @@ function CodeExamplesSection({ examples }: { examples: CodeExamples }) {
880
887
  export function OpenApiEndpoint({
881
888
  endpoint,
882
889
  codeExamples,
883
- children
890
+ children,
891
+ playgroundDisplay = 'interactive',
892
+ authMethod,
893
+ authHeaderName,
894
+ serverUrl,
895
+ proxyEnabled,
896
+ languages,
897
+ playgroundOnly,
884
898
  }: OpenApiEndpointProps) {
885
- const {
886
- method,
887
- path,
888
- summary,
889
- description,
899
+ const {
900
+ method,
901
+ path,
902
+ summary,
903
+ description,
890
904
  deprecated,
891
905
  parameters,
892
906
  requestBody,
@@ -895,15 +909,33 @@ export function OpenApiEndpoint({
895
909
  externalDocs,
896
910
  servers,
897
911
  } = endpoint;
898
-
912
+
899
913
  // Group parameters by location
900
914
  const pathParams = parameters.filter(p => p.in === 'path');
901
915
  const queryParams = parameters.filter(p => p.in === 'query');
902
916
  const headerParams = parameters.filter(p => p.in === 'header');
903
917
  const cookieParams = parameters.filter(p => p.in === 'cookie');
904
-
918
+
905
919
  const baseUrl = servers[0]?.url;
906
-
920
+
921
+ // Playground state
922
+ const showPlayground = playgroundDisplay !== 'none';
923
+ const { showNotice: showDevNotice, notice: devNotice } = useDevOnlyNotice();
924
+
925
+ if (playgroundOnly) {
926
+ return (
927
+ <div className="openapi-endpoint">
928
+ <ApiEndpoint
929
+ method={method}
930
+ path={path}
931
+ baseUrl={baseUrl}
932
+ onTryIt={showPlayground ? () => { showDevNotice(DEV_FEATURE.API_PLAYGROUND); } : undefined}
933
+ />
934
+ {devNotice}
935
+ </div>
936
+ );
937
+ }
938
+
907
939
  return (
908
940
  <div className="openapi-endpoint">
909
941
  {/* Deprecation Warning */}
@@ -917,7 +949,12 @@ export function OpenApiEndpoint({
917
949
  )}
918
950
 
919
951
  {/* Endpoint Badge */}
920
- <ApiEndpoint method={method} path={path} baseUrl={baseUrl} />
952
+ <ApiEndpoint
953
+ method={method}
954
+ path={path}
955
+ baseUrl={baseUrl}
956
+ onTryIt={showPlayground ? () => { showDevNotice(DEV_FEATURE.API_PLAYGROUND); } : undefined}
957
+ />
921
958
 
922
959
  {/* External Docs */}
923
960
  {externalDocs && (
@@ -961,9 +998,12 @@ export function OpenApiEndpoint({
961
998
 
962
999
  {/* Responses - field documentation in main content */}
963
1000
  <ResponseSection responses={responses} />
1001
+ {devNotice}
964
1002
 
965
1003
  {/* Additional Content */}
966
1004
  {children}
1005
+
1006
+ {/* Playground Modal (lazy-loaded) */}
967
1007
  </div>
968
1008
  );
969
1009
  }
@@ -115,7 +115,7 @@ export const Tabs = memo(function Tabs({ children, borderBottom }: TabsProps) {
115
115
  <button
116
116
  key={index}
117
117
  onClick={() => handleTabClick(index)}
118
- className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
118
+ className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap cursor-pointer ${
119
119
  isActive
120
120
  ? 'border-[var(--color-accent)] text-[var(--color-accent-text)]'
121
121
  : 'border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border)]'
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef } from 'react';
4
+
5
+ /** Shared with preprocess-mdx.ts — keep in sync or extract to shared module */
6
+ export const VIDEO_EXTENSIONS = ['.mp4', '.webm'];
7
+
8
+ interface VideoProps {
9
+ src: string;
10
+ title?: string;
11
+ /** Default: true */
12
+ controls?: boolean;
13
+ /** Browsers block unmuted autoplay — pair with muted */
14
+ autoPlay?: boolean;
15
+ muted?: boolean;
16
+ loop?: boolean;
17
+ width?: string;
18
+ height?: string;
19
+ }
20
+
21
+ /**
22
+ * HTML5 video player for embedded video files.
23
+ *
24
+ * Markdown ![alt](/videos/demo.mp4) is converted to <Video> JSX by the
25
+ * preprocessor (transformMarkdownVideos). The img handler in MDXComponents
26
+ * has a fallback isVideoUrl check for raw <img src="video.mp4"> in MDX.
27
+ *
28
+ * muted is set via useRef/useEffect — React doesn't reflect it as a DOM
29
+ * attribute (https://github.com/facebook/react/issues/6544).
30
+ */
31
+ export function Video({
32
+ src,
33
+ title,
34
+ controls = true,
35
+ autoPlay,
36
+ muted,
37
+ loop,
38
+ width,
39
+ height,
40
+ }: VideoProps): React.ReactElement | null {
41
+ const videoRef = useRef<HTMLVideoElement>(null);
42
+
43
+ useEffect(() => {
44
+ if (videoRef.current) {
45
+ if (muted) {
46
+ videoRef.current.setAttribute('muted', '');
47
+ } else {
48
+ videoRef.current.removeAttribute('muted');
49
+ }
50
+ }
51
+ }, [muted]);
52
+
53
+ if (!src) {
54
+ console.warn('Video component requires a "src" prop');
55
+ return null;
56
+ }
57
+
58
+ // Append #t=0.1 to force browsers to decode and display a preview frame.
59
+ // Without it, Chrome/Safari often show a black rectangle with preload="metadata".
60
+ const srcWithPreview = src.includes('#') ? src : `${src}#t=0.1`;
61
+
62
+ // No <div> wrapper — markdown syntax can place this inside a <p>, and
63
+ // <p><div> is invalid HTML (causes hydration errors).
64
+ return (
65
+ <video
66
+ ref={videoRef}
67
+ data-testid="video-player"
68
+ src={srcWithPreview}
69
+ controls={controls}
70
+ autoPlay={autoPlay}
71
+ loop={loop}
72
+ width={width}
73
+ height={height}
74
+ preload="metadata"
75
+ playsInline
76
+ aria-label={title}
77
+ className="my-6 rounded-xl w-full border border-theme-border"
78
+ >
79
+ Your browser does not support the video tag.
80
+ </video>
81
+ );
82
+ }
@@ -16,7 +16,7 @@ import { LanguageSelector } from './LanguageSelector';
16
16
  import { resolveNavigation } from '@/lib/navigation-resolver';
17
17
  import { getIconClass } from '@/lib/icon-utils';
18
18
  import { useLinkPrefix } from '@/lib/link-prefix-context';
19
- import { useChatPanel } from '@/hooks/useChatPanel';
19
+ import { useChatOpenHandler } from '@/hooks/useChatPanel';
20
20
 
21
21
  interface HeaderProps {
22
22
  config: DocsConfig;
@@ -36,8 +36,7 @@ const MOBILE_ICON_BUTTON = 'lg:hidden p-2 text-[var(--color-text-muted)] hover:t
36
36
  const COMPACT_ICON_BUTTON = 'hidden md:flex p-2 hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors cursor-pointer';
37
37
 
38
38
  export function Header({ config, layout = 'header-logo', tabsPosition: tabsPositionProp, isSidebarOpen, onToggleSidebar }: HeaderProps) {
39
- const { chatEnabled: chatContextEnabled, setIsChatOpen } = useChatPanel();
40
- const onChatOpen = chatContextEnabled ? () => setIsChatOpen(true) : undefined;
39
+ const { onChatOpen, devNotice } = useChatOpenHandler();
41
40
  const [isSearchOpen, setIsSearchOpen] = useState(false);
42
41
  const [logoError, setLogoError] = useState(false);
43
42
  const [isDark, setIsDark] = useState(false);
@@ -542,6 +541,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
542
541
  onClose={() => setIsSearchOpen(false)}
543
542
  popularPages={config.search?.popularPages}
544
543
  />
544
+ {devNotice}
545
545
  </>
546
546
  );
547
547
  }
@@ -30,7 +30,7 @@ import { ThemeToggle, ThemeToggleCycle } from '@/components/theme/ThemeToggle';
30
30
  import { getIconClass } from '@/lib/icon-utils';
31
31
  import { getTabsFromConfig } from '@/lib/navigation-utils';
32
32
  import { useLinkPrefix } from '@/lib/link-prefix-context';
33
- import { useChatPanel } from '@/hooks/useChatPanel';
33
+ import { useChatOpenHandler } from '@/hooks/useChatPanel';
34
34
 
35
35
  interface SidebarProps {
36
36
  config: DocsConfig;
@@ -150,8 +150,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
150
150
  const linkPrefix = useLinkPrefix();
151
151
 
152
152
  // Chat panel state — used for Pulsar sidebar AI button
153
- const { chatEnabled, setIsChatOpen } = useChatPanel();
154
- const onChatOpen = chatEnabled ? () => setIsChatOpen(true) : undefined;
153
+ const { onChatOpen, devNotice } = useChatOpenHandler();
155
154
 
156
155
  // Optimistic navigation: immediately highlight clicked link while page loads.
157
156
  // Strips hash fragments so anchor links (e.g. /page#section) match pathname.
@@ -750,6 +749,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
750
749
 
751
750
  {/* Search Modal - shown when search is opened from sidebar */}
752
751
  <SearchModal isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} onNavigate={handleNavigate} />
752
+ {devNotice}
753
753
  </>
754
754
  );
755
755
  }
@@ -279,7 +279,7 @@ export function CodePanel({
279
279
  {enableFullscreen && (
280
280
  <button
281
281
  onClick={() => setIsModalOpen(true)}
282
- className="p-1.5 rounded-md transition-colors flex-shrink-0"
282
+ className="p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer"
283
283
  style={{ color: codePanelColors.textMuted }}
284
284
  onMouseEnter={(e) => {
285
285
  e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
@@ -298,7 +298,7 @@ export function CodePanel({
298
298
  {/* Copy Button */}
299
299
  <button
300
300
  onClick={handleCopy}
301
- className={`p-1.5 rounded-md transition-colors flex-shrink-0 ${enableFullscreen ? 'ml-1' : ''}`}
301
+ className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : ''}`}
302
302
  style={{ color: codePanelColors.textMuted }}
303
303
  onMouseEnter={(e) => {
304
304
  e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
@@ -348,7 +348,7 @@ export function CodePanel({
348
348
  <button
349
349
  key={index}
350
350
  onClick={() => handleTabClick(index)}
351
- className="px-1 py-1 font-medium transition-colors whitespace-nowrap flex items-center"
351
+ className="px-1 py-1 font-medium transition-colors whitespace-nowrap flex items-center cursor-pointer"
352
352
  style={{
353
353
  color: isActive ? activeColor : codePanelColors.textMuted,
354
354
  cursor: 'pointer',
@@ -407,7 +407,7 @@ export function CodePanel({
407
407
  {enableFullscreen && (
408
408
  <button
409
409
  onClick={() => setIsModalOpen(true)}
410
- className="p-1.5 rounded-md transition-colors flex-shrink-0 ml-auto"
410
+ className="p-1.5 rounded-md transition-colors flex-shrink-0 ml-auto cursor-pointer"
411
411
  style={{ color: codePanelColors.textMuted }}
412
412
  onMouseEnter={(e) => {
413
413
  e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
@@ -426,7 +426,7 @@ export function CodePanel({
426
426
  {/* Fixed copy button */}
427
427
  <button
428
428
  onClick={handleCopy}
429
- className={`p-1.5 rounded-md transition-colors flex-shrink-0 ${enableFullscreen ? 'ml-1' : 'ml-auto'}`}
429
+ className={`p-1.5 rounded-md transition-colors flex-shrink-0 cursor-pointer ${enableFullscreen ? 'ml-1' : 'ml-auto'}`}
430
430
  style={{ color: codePanelColors.textMuted }}
431
431
  onMouseEnter={(e) => {
432
432
  e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
@@ -198,7 +198,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
198
198
  <button
199
199
  key={index}
200
200
  onClick={() => setActiveTab(index)}
201
- className="px-3 py-1.5 rounded-md text-sm font-medium transition-colors"
201
+ className="px-3 py-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer"
202
202
  style={{
203
203
  backgroundColor: activeTab === index ? codePanelColors.tabActiveBg : 'transparent',
204
204
  color: activeTab === index ? codePanelColors.text : codePanelColors.textMuted,
@@ -226,7 +226,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
226
226
  {/* Copy Button */}
227
227
  <button
228
228
  onClick={handleCopy}
229
- className="p-2 rounded-md transition-colors"
229
+ className="p-2 rounded-md transition-colors cursor-pointer"
230
230
  style={{ color: codePanelColors.textMuted }}
231
231
  onMouseEnter={(e) => {
232
232
  e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
@@ -249,7 +249,7 @@ export function CodePanelModal({ isOpen, onClose, tabs, title, initialTabIndex =
249
249
  {/* Close Button */}
250
250
  <button
251
251
  onClick={handleClose}
252
- className="p-2 rounded-md transition-colors"
252
+ className="p-2 rounded-md transition-colors cursor-pointer"
253
253
  style={{ color: codePanelColors.textMuted }}
254
254
  onMouseEnter={(e) => {
255
255
  e.currentTarget.style.backgroundColor = codePanelColors.tabHoverBg;
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * DevOnlyNotice
5
+ *
6
+ * A toast-like popup shown when users click features that are only available
7
+ * in production (e.g., API playground, AI chat). Appears briefly, then
8
+ * auto-dismisses. Used in local dev / CLI mode where the backend
9
+ * infrastructure (proxy endpoints, Upstash Vector, etc.) isn't available.
10
+ */
11
+
12
+ import { useState, useEffect, useCallback } from 'react';
13
+
14
+ /** Feature names for production-only features. */
15
+ export const DEV_FEATURE = {
16
+ AI_CHAT: 'AI Chat',
17
+ API_PLAYGROUND: 'API Playground',
18
+ } as const;
19
+
20
+ interface DevOnlyNoticeProps {
21
+ feature: string;
22
+ onDismiss: () => void;
23
+ }
24
+
25
+ function DevOnlyNotice({ feature, onDismiss }: DevOnlyNoticeProps) {
26
+ useEffect(() => {
27
+ const timer = setTimeout(onDismiss, 4000);
28
+ return () => clearTimeout(timer);
29
+ }, [onDismiss]);
30
+
31
+ return (
32
+ <div
33
+ role="alert"
34
+ style={{
35
+ position: 'fixed',
36
+ bottom: '1.5rem',
37
+ left: '50%',
38
+ transform: 'translateX(-50%)',
39
+ zIndex: 1000010,
40
+ background: 'var(--color-bg-primary, #fff)',
41
+ border: '1px solid var(--color-border, #e2e8f0)',
42
+ borderRadius: '8px',
43
+ padding: '0.75rem 1.25rem',
44
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
45
+ fontSize: '0.8125rem',
46
+ color: 'var(--color-text-primary, #1e293b)',
47
+ maxWidth: '400px',
48
+ textAlign: 'center',
49
+ animation: 'devNoticeIn 0.2s ease-out',
50
+ }}
51
+ >
52
+ <div style={{ fontWeight: 600, marginBottom: '0.25rem' }}>
53
+ {feature} is available in production
54
+ </div>
55
+ <div style={{ color: 'var(--color-text-muted, #64748b)', fontSize: '0.75rem' }}>
56
+ Deploy your docs to see this feature live.
57
+ </div>
58
+ <style>{`@keyframes devNoticeIn { from { opacity: 0; transform: translateX(-50%) translateY(8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }`}</style>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Hook that returns a trigger function and the notice element.
65
+ * Call `showNotice()` to display the toast for a given feature.
66
+ */
67
+ export function useDevOnlyNotice() {
68
+ const [activeFeature, setActiveFeature] = useState<string | null>(null);
69
+ const dismiss = useCallback(() => setActiveFeature(null), []);
70
+
71
+ const showNotice = setActiveFeature;
72
+
73
+ const notice = activeFeature ? (
74
+ <DevOnlyNotice feature={activeFeature} onDismiss={dismiss} />
75
+ ) : null;
76
+
77
+ return { showNotice, notice };
78
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
4
+ import { useDevOnlyNotice, DEV_FEATURE } from '@/components/ui/DevOnlyNotice';
4
5
 
5
6
  const MIN_WIDTH = 350;
6
7
  const MAX_WIDTH = 500;
@@ -61,8 +62,9 @@ export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starter
61
62
 
62
63
  // Keyboard shortcuts: Cmd+I / Ctrl+I to toggle, Escape to close
63
64
  // Uses capture phase for Escape so it fires before SearchModal's bubble-phase handler
65
+ const isDevMode = process.env.NODE_ENV !== 'production';
64
66
  useEffect(() => {
65
- if (!chatEnabled) return;
67
+ if (!chatEnabled || isDevMode) return;
66
68
 
67
69
  const handler = (e: KeyboardEvent) => {
68
70
  // Cmd+I / Ctrl+I — toggle chat
@@ -81,7 +83,7 @@ export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starter
81
83
  };
82
84
  document.addEventListener('keydown', handler, true); // capture phase
83
85
  return () => document.removeEventListener('keydown', handler, true);
84
- }, [chatEnabled, isChatOpen]);
86
+ }, [chatEnabled, isChatOpen, isDevMode]);
85
87
 
86
88
  return (
87
89
  <ChatPanelContext.Provider value={{ isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEnabled, chatEndpoint, starterQuestions }}>
@@ -99,3 +101,20 @@ export function useChatPanel(): ChatPanelContextValue {
99
101
  }
100
102
 
101
103
  export { MIN_WIDTH, MAX_WIDTH, DEFAULT_WIDTH };
104
+
105
+ /**
106
+ * Returns a chat-open handler that shows DevOnlyNotice in dev mode
107
+ * instead of opening the panel. Used by Header and Sidebar.
108
+ */
109
+ export function useChatOpenHandler() {
110
+ const { chatEnabled, setIsChatOpen } = useChatPanel();
111
+ const { showNotice, notice } = useDevOnlyNotice();
112
+ const isDevMode = process.env.NODE_ENV !== 'production';
113
+
114
+ const onChatOpen = chatEnabled ? () => {
115
+ if (isDevMode) { showNotice(DEV_FEATURE.AI_CHAT); return; }
116
+ setIsChatOpen(true);
117
+ } : undefined;
118
+
119
+ return { onChatOpen, devNotice: notice };
120
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * SSR-safe media query hook using useSyncExternalStore.
7
+ *
8
+ * Returns true when the viewport matches the given CSS media query string,
9
+ * false otherwise. Returns false on the server (getServerSnapshot).
10
+ */
11
+ export function useMediaQuery(query: string): boolean {
12
+ const subscribe = useCallback((callback: () => void) => {
13
+ if (typeof window === 'undefined') return () => {};
14
+ const mql = window.matchMedia(query);
15
+ mql.addEventListener('change', callback);
16
+ return () => mql.removeEventListener('change', callback);
17
+ }, [query]);
18
+
19
+ const getSnapshot = useCallback(() => {
20
+ if (typeof window === 'undefined') return false;
21
+ return window.matchMedia(query).matches;
22
+ }, [query]);
23
+
24
+ const getServerSnapshot = useCallback(() => false, []);
25
+
26
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
27
+ }
@@ -0,0 +1,66 @@
1
+ import type { OpenApiEndpointData, ParsedParameter, ParsedRequestBody } from './openapi/types';
2
+ import type { ExtractedParam } from './remark-extract-param-fields';
3
+
4
+ /**
5
+ * Build an OpenApiEndpointData from extracted ParamField data and api: frontmatter.
6
+ *
7
+ * Bridges `api:` pages (MDX-defined endpoints) into the same data structure
8
+ * that the playground consumes for `openapi:` pages.
9
+ *
10
+ * Known limitation: only top-level <ParamField> components are supported.
11
+ * Nested ParamFields (inside <Expandable> etc.) are extracted as flat fields.
12
+ */
13
+ export function buildEndpointFromMdx(
14
+ method: string,
15
+ path: string,
16
+ params: ExtractedParam[],
17
+ serverUrl?: string,
18
+ ): OpenApiEndpointData {
19
+ const bodyParams = params.filter(p => p.in === 'body');
20
+ const nonBodyParams = params.filter(p => p.in !== 'body');
21
+
22
+ const parameters: ParsedParameter[] = nonBodyParams.map(p => ({
23
+ name: p.name,
24
+ in: p.in as 'path' | 'query' | 'header' | 'cookie',
25
+ required: p.required,
26
+ schema: {
27
+ type: p.type,
28
+ ...(p.default !== undefined ? { default: p.default } : {}),
29
+ },
30
+ }));
31
+
32
+ let requestBody: ParsedRequestBody | undefined;
33
+ if (bodyParams.length > 0) {
34
+ const properties: Record<string, { type?: string }> = {};
35
+ const required: string[] = [];
36
+ for (const p of bodyParams) {
37
+ properties[p.name] = {
38
+ type: p.type,
39
+ ...(p.default !== undefined ? { default: p.default } : {}),
40
+ };
41
+ if (p.required) required.push(p.name);
42
+ }
43
+ requestBody = {
44
+ required: required.length > 0,
45
+ content: {
46
+ 'application/json': {
47
+ schema: {
48
+ type: 'object',
49
+ properties,
50
+ ...(required.length > 0 ? { required } : {}),
51
+ },
52
+ },
53
+ },
54
+ };
55
+ }
56
+
57
+ return {
58
+ method: method as OpenApiEndpointData['method'],
59
+ path,
60
+ parameters,
61
+ requestBody,
62
+ responses: {},
63
+ servers: serverUrl ? [{ url: serverUrl }] : [],
64
+ security: [],
65
+ };
66
+ }
@@ -62,7 +62,7 @@ export async function collectMdxFiles(
62
62
  * @returns Array of asset file paths relative to projectDir
63
63
  */
64
64
  export async function collectAssetFiles(projectDir: string): Promise<string[]> {
65
- const files = await glob('**/*.{png,jpg,jpeg,gif,svg,webp,ico}', {
65
+ const files = await glob('**/*.{png,jpg,jpeg,gif,svg,webp,ico,mp4,webm}', {
66
66
  cwd: projectDir,
67
67
  ignore: ['node_modules/**', '.git/**'],
68
68
  });